diff --git a/deploy/organized-photos-source.json b/deploy/organized-photos-source.json index e1ee95e..504fc92 100644 --- a/deploy/organized-photos-source.json +++ b/deploy/organized-photos-source.json @@ -6,5 +6,6 @@ "enabled": true, "catalog_limit": 100000, "organized_only": true, - "folder_caption": true + "folder_caption": true, + "synology_previews": true } diff --git a/docs/PHOTO_SOURCES.md b/docs/PHOTO_SOURCES.md index f253e59..b13323f 100644 --- a/docs/PHOTO_SOURCES.md +++ b/docs/PHOTO_SOURCES.md @@ -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. 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. + +For Synology Photos shared libraries, `synology_previews: true` allows images over +20 MiB to use their existing `@eaDir//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. diff --git a/docs/openapi.json b/docs/openapi.json index 63ba710..86b16ec 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -10326,6 +10326,11 @@ } ], "title": "Caption" + }, + "provider_preview": { + "type": "boolean", + "title": "Provider Preview", + "default": false } }, "additionalProperties": false, @@ -10416,6 +10421,11 @@ "title": "Catalog Limit", "default": 10000 }, + "synology_previews": { + "type": "boolean", + "title": "Synology Previews", + "default": false + }, "organized_only": { "type": "boolean", "title": "Organized Only", @@ -10487,6 +10497,11 @@ "title": "Catalog Limit", "default": 10000 }, + "synology_previews": { + "type": "boolean", + "title": "Synology Previews", + "default": false + }, "organized_only": { "type": "boolean", "title": "Organized Only", diff --git a/pigway_frame_flow/providers/photo_sources.py b/pigway_frame_flow/providers/photo_sources.py index f6fa7dd..497b18a 100644 --- a/pigway_frame_flow/providers/photo_sources.py +++ b/pigway_frame_flow/providers/photo_sources.py @@ -29,6 +29,7 @@ class PhotoSourceInput(StrictModel): recursive: bool = True enabled: bool = True catalog_limit: int = Field(default=10000, ge=1, le=100000) + synology_previews: bool = False organized_only: bool = False folder_caption: bool = False credential_env: str | None = Field( @@ -44,6 +45,7 @@ class PhotoItem(StrictModel): id: str name: str caption: dict[str, str] | None = None + provider_preview: bool = False class PhotoCatalog(StrictModel): @@ -201,6 +203,22 @@ class PhotoSources: self.settings.additional_secrets.append(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): source = self.get(source_id) if not source.enabled: @@ -240,13 +258,20 @@ class PhotoSources: ): continue 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 relative = path.relative_to(root).as_posix() items.append( { "id": relative, "name": name, + "provider_preview": preview, "caption": caption if source.folder_caption else None, } ) @@ -328,6 +353,11 @@ class PhotoSources: if source.kind == "local": root = self.settings.photo_root.resolve() 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( self.local_directory(source.location) ): diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js index 01ed999..f5d64a2 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -117,9 +117,9 @@ async function photoSourcesPanel(){ section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob)); } function photoSourceForm(source=null){ - showModal(`

${t(source?'edit':'addSource')}

${t('sourceLocationHint')}

${t('sourceCredentialHint')}

${t('folderCaptionHint')}

`); - $('#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; - $('#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');}); + showModal(`

${t(source?'edit':'addSource')}

${t('sourceLocationHint')}

${t('sourceCredentialHint')}

${t('folderCaptionHint')}

`); + $('#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,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){ const result=await api('/api/v1/photo-sources/'+id+'/images?offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return; diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js index 19be271..e7d3e7e 100644 --- a/pigway_frame_flow/web/i18n.js +++ b/pigway_frame_flow/web/i18n.js @@ -22,3 +22,5 @@ Object.assign(MESSAGES.en,{photoSources:'Photo sources',sourceHint:'Choose photo 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.zh,{synologyPreviews:'超大图片使用 Synology Photos 的现有预览图'});Object.assign(MESSAGES.en,{synologyPreviews:'Use existing Synology Photos previews for oversized images'}); diff --git a/scripts/ui-photo-sources.cjs b/scripts/ui-photo-sources.cjs index cba9296..9b59678 100644 --- a/scripts/ui-photo-sources.cjs +++ b/scripts/ui-photo-sources.cjs @@ -1,3 +1,3 @@ /* Local mock server only; no vendor/cloud writes. PHOTO_ROOT must contain a fixture image. */ 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)}); diff --git a/tests/schema-contract.json b/tests/schema-contract.json index b9f7269..e11fc7b 100644 --- a/tests/schema-contract.json +++ b/tests/schema-contract.json @@ -837,6 +837,11 @@ "title": "Catalog Limit", "default": 10000 }, + "synology_previews": { + "type": "boolean", + "title": "Synology Previews", + "default": false + }, "organized_only": { "type": "boolean", "title": "Organized Only", @@ -908,6 +913,11 @@ "title": "Catalog Limit", "default": 10000 }, + "synology_previews": { + "type": "boolean", + "title": "Synology Previews", + "default": false + }, "organized_only": { "type": "boolean", "title": "Organized Only", @@ -968,6 +978,11 @@ } ], "title": "Caption" + }, + "provider_preview": { + "type": "boolean", + "title": "Provider Preview", + "default": false } }, "additionalProperties": false, diff --git a/tests/test_photo_captions.py b/tests/test_photo_captions.py index 9910216..e552136 100644 --- a/tests/test_photo_captions.py +++ b/tests/test_photo_captions.py @@ -125,3 +125,37 @@ def test_curated_catalog_includes_photos_beyond_old_ten_thousand_limit( ), ) 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") == []