Include oversized Synology photos through existing read-only XL previews

This commit is contained in:
Pigway
2026-10-05 08:18:56 +08:00
parent 763d340e4d
commit 9e14adcb1f
9 changed files with 111 additions and 6 deletions
+2 -1
View File
@@ -6,5 +6,6 @@
"enabled": true, "enabled": true,
"catalog_limit": 100000, "catalog_limit": 100000,
"organized_only": true, "organized_only": true,
"folder_caption": true "folder_caption": true,
"synology_previews": true
} }
+8
View File
@@ -119,3 +119,11 @@ Output is 1200×1600: a 1200×1280 photo area plus a white caption area. Origina
files remain unchanged. Contain/cover/smart and color/grayscale profiles still apply. files remain unchanged. Contain/cover/smart and color/grayscale profiles still apply.
A prepared source definition is in `deploy/organized-photos-source.json`; use it A prepared source definition is in `deploy/organized-photos-source.json`; use it
with `PUT /api/v1/photo-sources/organized-photos` after mounting the library. with `PUT /api/v1/photo-sources/organized-photos` after mounting the library.
For Synology Photos shared libraries, `synology_previews: true` allows images over
20 MiB to use their existing `@eaDir/<filename>/SYNOPHOTO_THUMB_XL.jpg` preview.
This is a read-only fallback: original files and Photos indexes remain unchanged,
no thumbnails are generated on the NAS, and the ordinary size limit still applies
to the selected preview. The catalog marks these entries `provider_preview: true`.
If the preview is missing, the oversized original remains excluded. The folder
caption still comes from the original curated directory.
+15
View File
@@ -10326,6 +10326,11 @@
} }
], ],
"title": "Caption" "title": "Caption"
},
"provider_preview": {
"type": "boolean",
"title": "Provider Preview",
"default": false
} }
}, },
"additionalProperties": false, "additionalProperties": false,
@@ -10416,6 +10421,11 @@
"title": "Catalog Limit", "title": "Catalog Limit",
"default": 10000 "default": 10000
}, },
"synology_previews": {
"type": "boolean",
"title": "Synology Previews",
"default": false
},
"organized_only": { "organized_only": {
"type": "boolean", "type": "boolean",
"title": "Organized Only", "title": "Organized Only",
@@ -10487,6 +10497,11 @@
"title": "Catalog Limit", "title": "Catalog Limit",
"default": 10000 "default": 10000
}, },
"synology_previews": {
"type": "boolean",
"title": "Synology Previews",
"default": false
},
"organized_only": { "organized_only": {
"type": "boolean", "type": "boolean",
"title": "Organized Only", "title": "Organized Only",
+31 -1
View File
@@ -29,6 +29,7 @@ class PhotoSourceInput(StrictModel):
recursive: bool = True recursive: bool = True
enabled: bool = True enabled: bool = True
catalog_limit: int = Field(default=10000, ge=1, le=100000) catalog_limit: int = Field(default=10000, ge=1, le=100000)
synology_previews: bool = False
organized_only: bool = False organized_only: bool = False
folder_caption: bool = False folder_caption: bool = False
credential_env: str | None = Field( credential_env: str | None = Field(
@@ -44,6 +45,7 @@ class PhotoItem(StrictModel):
id: str id: str
name: str name: str
caption: dict[str, str] | None = None caption: dict[str, str] | None = None
provider_preview: bool = False
class PhotoCatalog(StrictModel): class PhotoCatalog(StrictModel):
@@ -201,6 +203,22 @@ class PhotoSources:
self.settings.additional_secrets.append(encoded) self.settings.additional_secrets.append(encoded)
return {"Authorization": "Basic " + encoded} return {"Authorization": "Basic " + encoded}
def thumbnail(self, path):
candidate = path.parent / "@eaDir" / path.name / "SYNOPHOTO_THUMB_XL.jpg"
try:
if candidate.is_symlink() or not candidate.resolve().is_relative_to(
path.parent.resolve()
):
return None
if (
candidate.is_file()
and candidate.stat().st_size <= self.settings.max_bytes
):
return candidate
except OSError:
pass
return None
def catalog(self, source_id, refresh=False): def catalog(self, source_id, refresh=False):
source = self.get(source_id) source = self.get(source_id)
if not source.enabled: if not source.enabled:
@@ -240,13 +258,20 @@ class PhotoSources:
): ):
continue continue
try: try:
if path.stat().st_size > self.settings.max_bytes: large = path.stat().st_size > self.settings.max_bytes
preview = (
large
and source.synology_previews
and self.thumbnail(path) is not None
)
if large and not preview:
continue continue
relative = path.relative_to(root).as_posix() relative = path.relative_to(root).as_posix()
items.append( items.append(
{ {
"id": relative, "id": relative,
"name": name, "name": name,
"provider_preview": preview,
"caption": caption if source.folder_caption else None, "caption": caption if source.folder_caption else None,
} }
) )
@@ -328,6 +353,11 @@ class PhotoSources:
if source.kind == "local": if source.kind == "local":
root = self.settings.photo_root.resolve() root = self.settings.photo_root.resolve()
path = root / item["id"] path = root / item["id"]
if item.get("provider_preview"):
preview = self.thumbnail(path)
if preview is None:
raise FlowError(E.NOT_FOUND, "Synology preview is unavailable", 404)
path = preview
if path.is_symlink() or not path.resolve().is_relative_to( if path.is_symlink() or not path.resolve().is_relative_to(
self.local_directory(source.location) self.local_directory(source.location)
): ):
+3 -3
View File
@@ -117,9 +117,9 @@ async function photoSourcesPanel(){
section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob)); section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob));
} }
function photoSourceForm(source=null){ function photoSourceForm(source=null){
showModal(`<h2>${t(source?'edit':'addSource')}</h2><form id="photo-source-form"><div class="field"><label for="source-id">ID</label><input id="source-id" pattern="[a-z0-9-]{1,60}" required value="${esc(source?.id||'')}" ${source?'readonly':''}></div><div class="field"><label for="source-name">${t('name')}</label><input id="source-name" required maxlength="120" value="${esc(source?.name||'')}"></div><div class="field"><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="local">${t('nasDirectory')}</option><option value="webdav">WebDAV</option></select></div><div class="field"><label for="source-location">${t('sourceLocation')}</label><input id="source-location" required maxlength="2048" value="${esc(source?.location||'.')}"><p class="hint">${t('sourceLocationHint')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${source?.folder_caption?'checked':''}>${t('folderCaption')}</label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`); showModal(`<h2>${t(source?'edit':'addSource')}</h2><form id="photo-source-form"><div class="field"><label for="source-id">ID</label><input id="source-id" pattern="[a-z0-9-]{1,60}" required value="${esc(source?.id||'')}" ${source?'readonly':''}></div><div class="field"><label for="source-name">${t('name')}</label><input id="source-name" required maxlength="120" value="${esc(source?.name||'')}"></div><div class="field"><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="local">${t('nasDirectory')}</option><option value="webdav">WebDAV</option></select></div><div class="field"><label for="source-location">${t('sourceLocation')}</label><input id="source-location" required maxlength="2048" value="${esc(source?.location||'.')}"><p class="hint">${t('sourceLocationHint')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${source?.folder_caption?'checked':''}>${t('folderCaption')}</label><label class="check-row"><input id="source-synology-previews" type="checkbox" ${source?.synology_previews?'checked':''}>${t('synologyPreviews')}</label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
$('#source-kind').value=source?.kind||'local';const update=()=>{const dav=$('#source-kind').value==='webdav';$('#source-credential').disabled=!dav;$('#source-recursive').disabled=dav;};update();$('#source-kind').onchange=update; $('#source-kind').value=source?.kind||'local';const update=()=>{const dav=$('#source-kind').value==='webdav';$('#source-credential').disabled=!dav;$('#source-recursive').disabled=dav;$('#source-synology-previews').disabled=dav;};update();$('#source-kind').onchange=update;
$('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const dav=$('#source-kind').value==='webdav';await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,recursive:!dav&&$('#source-recursive').checked,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:$('#source-organized').checked,folder_caption:$('#source-caption').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});$('#modal').close();await navigate('gallery');}); $('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const dav=$('#source-kind').value==='webdav';await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,recursive:!dav&&$('#source-recursive').checked,synology_previews:!dav&&$('#source-synology-previews').checked,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:$('#source-organized').checked,folder_caption:$('#source-caption').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});$('#modal').close();await navigate('gallery');});
} }
async function browseSource(id,offset=0,refresh=false){ async function browseSource(id,offset=0,refresh=false){
const result=await api('/api/v1/photo-sources/'+id+'/images?offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return; const result=await api('/api/v1/photo-sources/'+id+'/images?offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return;
+2
View File
@@ -22,3 +22,5 @@ Object.assign(MESSAGES.en,{photoSources:'Photo sources',sourceHint:'Choose photo
Object.assign(MESSAGES.zh,{organizedPhotos:'仅日期开头文件夹中的照片',folderCaption:'照片下方显示文件夹文字',folderCaptionHint:'第一行:去掉日期和地点后的主题;第二行:日期;第三行:@ 后的地点(没有地点则省略)。子目录沿用上级日期文件夹。'}); Object.assign(MESSAGES.zh,{organizedPhotos:'仅日期开头文件夹中的照片',folderCaption:'照片下方显示文件夹文字',folderCaptionHint:'第一行:去掉日期和地点后的主题;第二行:日期;第三行:@ 后的地点(没有地点则省略)。子目录沿用上级日期文件夹。'});
Object.assign(MESSAGES.en,{organizedPhotos:'Only photos in date-prefixed folders',folderCaption:'Show folder caption below the photo',folderCaptionHint:'Line 1: title without date/place. Line 2: date. Line 3: place after @, omitted when absent. Subfolders inherit their nearest dated folder.'}); Object.assign(MESSAGES.en,{organizedPhotos:'Only photos in date-prefixed folders',folderCaption:'Show folder caption below the photo',folderCaptionHint:'Line 1: title without date/place. Line 2: date. Line 3: place after @, omitted when absent. Subfolders inherit their nearest dated folder.'});
Object.assign(MESSAGES.zh,{synologyPreviews:'超大图片使用 Synology Photos 的现有预览图'});Object.assign(MESSAGES.en,{synologyPreviews:'Use existing Synology Photos previews for oversized images'});
+1 -1
View File
@@ -1,3 +1,3 @@
/* Local mock server only; no vendor/cloud writes. PHOTO_ROOT must contain a fixture image. */ /* Local mock server only; no vendor/cloud writes. PHOTO_ROOT must contain a fixture image. */
const assert=require('assert'),{chromium}=require('playwright'); const assert=require('assert'),{chromium}=require('playwright');
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));const id='ui-photo-'+Date.now();let jobId;let checks=0;try{await page.locator('[data-page="gallery"]').click();await page.locator('#add-photo-source').click();await page.locator('#source-id').fill(id);await page.locator('#source-name').fill('UI photo fixture');await page.locator('#source-location').fill('.');await page.locator('#source-organized').check();await page.locator('#source-caption').check();await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-browse="${id}"]`).waitFor();for(const lang of ['zh','en'])for(const width of [1440,390])for(const theme of ['dark','light']){await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="gallery"]').click();await page.locator(`[data-source-browse="${id}"]`).click();await page.locator('[data-source-item]').first().waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-id').inputValue()===id);assert(await page.locator('#source-organized').isChecked());assert(await page.locator('#source-caption').isChecked());await page.locator('#modal-cancel').click();checks++;}await page.locator(`[data-source-random="${id}"]`).click();await page.locator('#source-preview').waitFor();await page.waitForFunction(()=>document.querySelector('#source-preview')?.naturalWidth>0);const before=await page.evaluate(()=>api('/api/v1/devices/'+state.device));await page.locator('#modal-cancel').click();await page.locator(`[data-source-job="${id}"]`).click();assert(!await page.locator('#source-job-enabled').isChecked());await page.locator('#source-job-name').fill(id);await page.locator('#source-job-form button[type="submit"]').click();await page.locator('#new-job').waitFor();jobId=await page.evaluate(async id=>(await api('/api/v1/jobs')).find(j=>j.name===id).id,id);await page.evaluate(async jobId=>{const job=(await api('/api/v1/jobs')).find(j=>j.id===jobId);await jobEditor(job);},jobId);await page.locator('#job-photo-source').waitFor();assert(await page.locator('#job-content-type').inputValue()==='photo_source');assert(await page.locator('#job-photo-source').inputValue()===id);await page.locator('#modal-cancel').click();const after=await page.evaluate(()=>api('/api/v1/devices/'+state.device));assert(before.current_content.id===after.current_content.id);checks+=3;console.log('Photo sources browser: '+checks+' passed (8 language/size/theme profiles, preview and scheduler editing)');}finally{await page.evaluate(async({id,jobId})=>{if(jobId)await api('/api/v1/jobs/'+jobId,{method:'DELETE'});await api('/api/v1/photo-sources/'+id,{method:'DELETE'});},{id,jobId});await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); (async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));const id='ui-photo-'+Date.now();let jobId;let checks=0;try{await page.locator('[data-page="gallery"]').click();await page.locator('#add-photo-source').click();await page.locator('#source-id').fill(id);await page.locator('#source-name').fill('UI photo fixture');await page.locator('#source-location').fill('.');await page.locator('#source-organized').check();await page.locator('#source-caption').check();await page.locator('#source-synology-previews').check();await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-browse="${id}"]`).waitFor();for(const lang of ['zh','en'])for(const width of [1440,390])for(const theme of ['dark','light']){await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="gallery"]').click();await page.locator(`[data-source-browse="${id}"]`).click();await page.locator('[data-source-item]').first().waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-id').inputValue()===id);assert(await page.locator('#source-organized').isChecked());assert(await page.locator('#source-caption').isChecked());assert(await page.locator('#source-synology-previews').isChecked());await page.locator('#modal-cancel').click();checks++;}await page.locator(`[data-source-random="${id}"]`).click();await page.locator('#source-preview').waitFor();await page.waitForFunction(()=>document.querySelector('#source-preview')?.naturalWidth>0);const before=await page.evaluate(()=>api('/api/v1/devices/'+state.device));await page.locator('#modal-cancel').click();await page.locator(`[data-source-job="${id}"]`).click();assert(!await page.locator('#source-job-enabled').isChecked());await page.locator('#source-job-name').fill(id);await page.locator('#source-job-form button[type="submit"]').click();await page.locator('#new-job').waitFor();jobId=await page.evaluate(async id=>(await api('/api/v1/jobs')).find(j=>j.name===id).id,id);await page.evaluate(async jobId=>{const job=(await api('/api/v1/jobs')).find(j=>j.id===jobId);await jobEditor(job);},jobId);await page.locator('#job-photo-source').waitFor();assert(await page.locator('#job-content-type').inputValue()==='photo_source');assert(await page.locator('#job-photo-source').inputValue()===id);await page.locator('#modal-cancel').click();const after=await page.evaluate(()=>api('/api/v1/devices/'+state.device));assert(before.current_content.id===after.current_content.id);checks+=3;console.log('Photo sources browser: '+checks+' passed (8 language/size/theme profiles, preview and scheduler editing)');}finally{await page.evaluate(async({id,jobId})=>{if(jobId)await api('/api/v1/jobs/'+jobId,{method:'DELETE'});await api('/api/v1/photo-sources/'+id,{method:'DELETE'});},{id,jobId});await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
+15
View File
@@ -837,6 +837,11 @@
"title": "Catalog Limit", "title": "Catalog Limit",
"default": 10000 "default": 10000
}, },
"synology_previews": {
"type": "boolean",
"title": "Synology Previews",
"default": false
},
"organized_only": { "organized_only": {
"type": "boolean", "type": "boolean",
"title": "Organized Only", "title": "Organized Only",
@@ -908,6 +913,11 @@
"title": "Catalog Limit", "title": "Catalog Limit",
"default": 10000 "default": 10000
}, },
"synology_previews": {
"type": "boolean",
"title": "Synology Previews",
"default": false
},
"organized_only": { "organized_only": {
"type": "boolean", "type": "boolean",
"title": "Organized Only", "title": "Organized Only",
@@ -968,6 +978,11 @@
} }
], ],
"title": "Caption" "title": "Caption"
},
"provider_preview": {
"type": "boolean",
"title": "Provider Preview",
"default": false
} }
}, },
"additionalProperties": false, "additionalProperties": false,
+34
View File
@@ -125,3 +125,37 @@ def test_curated_catalog_includes_photos_beyond_old_ten_thousand_limit(
), ),
) )
assert len(service.photo_sources.catalog("all")) == 10001 assert len(service.photo_sources.catalog("all")) == 10001
def test_oversized_synology_photo_uses_read_only_cached_preview(service, settings):
root = settings.data_dir.parent / "synology"
settings.photo_root = root
settings.max_bytes = 1000
original = root / "20261005 出游@金山" / "large.jpg"
original.parent.mkdir(parents=True)
original.write_bytes(b"x" * 1001)
preview = original.parent / "@eaDir" / original.name / "SYNOPHOTO_THUMB_XL.jpg"
preview.parent.mkdir(parents=True)
Image.new("RGB", (20, 20), "blue").save(preview)
source = PhotoSourceInput(
name="Synology",
kind="local",
location=".",
organized_only=True,
folder_caption=True,
synology_previews=True,
)
service.photo_sources.save("synology", source)
items = service.photo_sources.catalog("synology")
assert len(items) == 1 and items[0]["provider_preview"]
data, name, caption = service.photo_sources.read("synology")
assert (
data == preview.read_bytes()
and name == "large.jpg"
and caption["place"] == "金山"
)
assert original.read_bytes() == b"x" * 1001
service.photo_sources.save(
"synology", source.model_copy(update={"synology_previews": False})
)
assert service.photo_sources.catalog("synology") == []