Include oversized Synology photos through existing read-only XL previews
This commit is contained in:
@@ -6,5 +6,6 @@
|
||||
"enabled": true,
|
||||
"catalog_limit": 100000,
|
||||
"organized_only": true,
|
||||
"folder_caption": true
|
||||
"folder_caption": true,
|
||||
"synology_previews": true
|
||||
}
|
||||
|
||||
@@ -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/<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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
):
|
||||
|
||||
@@ -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(`<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>`);
|
||||
$('#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(`<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;$('#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;
|
||||
|
||||
@@ -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'});
|
||||
|
||||
@@ -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)});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") == []
|
||||
|
||||
Reference in New Issue
Block a user