diff --git a/docs/openapi.json b/docs/openapi.json index 0524db4..456f826 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -14203,6 +14203,17 @@ }, "Asset": { "properties": { + "source_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Path" + }, "starred": { "type": "boolean", "title": "Starred", @@ -16227,6 +16238,17 @@ ], "title": "Caption" }, + "source_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Path" + }, "provider_preview": { "type": "boolean", "title": "Provider Preview", @@ -16346,6 +16368,19 @@ "minLength": 1, "title": "Location" }, + "nas_root_path": { + "anyOf": [ + { + "type": "string", + "maxLength": 2048, + "pattern": "^/" + }, + { + "type": "null" + } + ], + "title": "Nas Root Path" + }, "recursive": { "type": "boolean", "title": "Recursive", @@ -16513,6 +16548,19 @@ "minLength": 1, "title": "Location" }, + "nas_root_path": { + "anyOf": [ + { + "type": "string", + "maxLength": 2048, + "pattern": "^/" + }, + { + "type": "null" + } + ], + "title": "Nas Root Path" + }, "recursive": { "type": "boolean", "title": "Recursive", diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py index da6e5d6..04884c6 100644 --- a/pigway_frame_flow/display/service.py +++ b/pigway_frame_flow/display/service.py @@ -392,7 +392,24 @@ class DisplayService: "UPDATE operations SET result=? WHERE id=?", (encode({"photo_attempts": attempts}), operation_id), ) - return self.assets.put(rendered, name, "cache" if staging else "images") + asset = self.assets.put( + rendered, name, "cache" if staging else "images" + ) + self.db.execute( + "INSERT OR REPLACE INTO asset_sources VALUES(?,?)", + ( + asset["asset_id"], + encode( + { + "type": "photo_source", + "source_id": content.source_id, + "item_id": item["id"], + "source_path": item.get("source_path"), + } + ), + ), + ) + return self.assets.info(asset["asset_id"]) raise FlowError( E.INVALID_REQUEST, "No decodable photo found within 5 candidates" ) diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py index 5b9cada..b35ece4 100644 --- a/pigway_frame_flow/models.py +++ b/pigway_frame_flow/models.py @@ -375,6 +375,7 @@ class Readiness(BaseModel): class Asset(BaseModel): + source_path: str | None = None starred: bool = False origin: Literal["manual", "task", "external", "template", "unknown"] = "unknown" job_id: str | None = None diff --git a/pigway_frame_flow/providers/photo_sources.py b/pigway_frame_flow/providers/photo_sources.py index 176a68f..fefcf3f 100644 --- a/pigway_frame_flow/providers/photo_sources.py +++ b/pigway_frame_flow/providers/photo_sources.py @@ -32,6 +32,7 @@ class PhotoSourceInput(StrictModel): "local", "synology-photos", "webdav", "google-drive", "alipan", "baidu" ] location: str = Field(min_length=1, max_length=2048) + nas_root_path: str | None = Field(default=None, max_length=2048, pattern=r"^/") recursive: bool = True enabled: bool = True catalog_limit: int = Field(default=10000, ge=1, le=100000) @@ -62,6 +63,7 @@ class PhotoItem(StrictModel): id: str name: str caption: dict[str, str] | None = None + source_path: str | None = None provider_preview: bool = False orientation: Literal["landscape", "portrait", "square"] | None = None width: int | None = None @@ -492,6 +494,13 @@ class PhotoSources: "id": str(row["id"]), "name": row["filename"], "provider_preview": True, + "source_path": ( + source.nas_root_path.rstrip("/") + + "/" + + relative.lstrip("/") + if source.nas_root_path + else "/" + relative.lstrip("/") + ), "caption": caption if source.folder_caption else None, "width": width, "height": height, diff --git a/pigway_frame_flow/renderers/images.py b/pigway_frame_flow/renderers/images.py index 0771856..cc2673c 100644 --- a/pigway_frame_flow/renderers/images.py +++ b/pigway_frame_flow/renderers/images.py @@ -134,7 +134,13 @@ class Assets: if not row: raise FlowError(E.NOT_FOUND, "Asset not found", 404) meta = self.db.one("SELECT * FROM asset_metadata WHERE asset_id=?", (asset_id,)) + provenance = self.db.one( + "SELECT payload FROM asset_sources WHERE asset_id=?", (asset_id,) + ) return { + "source_path": json.loads(provenance["payload"]).get("source_path") + if provenance + else None, "starred": bool(meta and meta["starred"]), "origin": meta["origin"] if meta else "unknown", "job_id": meta["job_id"] if meta else None, diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js index 20df0cd..e4a1704 100644 --- a/pigway_frame_flow/web/app.js +++ b/pigway_frame_flow/web/app.js @@ -128,7 +128,7 @@ const views={ const destinations=galleryDestinations();if(!destinations.some(d=>d.id===state.tab))state.tab=destinations[0]?.id||'local'; const key=JSON.stringify([state.device,state.tab,state.assetOffset||0,state.assetOrigin||'all',state.assetJob||'']); const cache=state.galleryCache||(state.galleryCache=new Map());const cached=cache.get(key); - const render=(items,pending=false)=>{if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`
${t('sourceIndependentHelp')}
`); - $('#source-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'synology-photos';const update=()=>{const photosAPI=$('#source-kind').value==='synology-photos';$('#source-location').labels[0].textContent=photosAPI?tr('照片目录','Photo folder'):t('sourceLocation');$('#source-location').nextElementSibling.textContent=photosAPI?tr('填写所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。','Folder path within the selected space; . means the whole space. Downloads XL previews on demand, not originals.'):t('sourceLocationHint');const dav=!['local','synology-photos'].includes($('#source-kind').value);$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-credential').closest('.field').hidden=$('#source-kind').value!=='webdav';const oauth=$('#source-oauth');if(oauth)oauth.closest('.field').hidden=!['google-drive','alipan','baidu'].includes($('#source-kind').value);const quality=$('#source-local-quality');if(quality)quality.hidden=dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=$('#source-kind').value!=='local';$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};addPhotoQualityFields(source);$('#source-location').closest('.field').before($('#source-people-space').closest('.field'));update();$('#source-kind').onchange=update;$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';}; - $('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=!['local','synology-photos'].includes($('#source-kind').value);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:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:false,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_short_edge:Number($('#source-min-short').value),minimum_long_edge:Number($('#source-min-long').value),minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');}); + showModal(`${t('sourceIndependentHelp')}
`); + $('#source-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'synology-photos';const update=()=>{const photosAPI=$('#source-kind').value==='synology-photos';$('#source-nas-root-field').hidden=!photosAPI;$('#source-location').labels[0].textContent=photosAPI?tr('照片目录','Photo folder'):t('sourceLocation');$('#source-location').nextElementSibling.textContent=photosAPI?tr('填写所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。','Folder path within the selected space; . means the whole space. Downloads XL previews on demand, not originals.'):t('sourceLocationHint');const dav=!['local','synology-photos'].includes($('#source-kind').value);$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-credential').closest('.field').hidden=$('#source-kind').value!=='webdav';const oauth=$('#source-oauth');if(oauth)oauth.closest('.field').hidden=!['google-drive','alipan','baidu'].includes($('#source-kind').value);const quality=$('#source-local-quality');if(quality)quality.hidden=dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=$('#source-kind').value!=='local';$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};addPhotoQualityFields(source);$('#source-location').closest('.field').before($('#source-people-space').closest('.field'));update();$('#source-kind').onchange=update;$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';}; + $('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=!['local','synology-photos'].includes($('#source-kind').value);await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,nas_root_path:$('#source-nas-root').value||null,recursive:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:false,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_short_edge:Number($('#source-min-short').value),minimum_long_edge:Number($('#source-min-long').value),minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');}); } async function browseSource(id,offset=0,refresh=false){ const result=await api('/api/v1/photo-sources/'+id+'/images?device_id='+encodeURIComponent(state.device)+'&offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return; diff --git a/pigway_frame_flow/web/style.css b/pigway_frame_flow/web/style.css index a7ebcf2..81f6762 100644 --- a/pigway_frame_flow/web/style.css +++ b/pigway_frame_flow/web/style.css @@ -225,3 +225,5 @@ form#mqtt-config{grid-template-columns:minmax(160px,1fr) 110px minmax(180px,1.5f .automation-table td:is(:nth-child(2),:nth-child(5),:nth-child(6)){white-space:nowrap} .automation-table .row-actions{flex-wrap:nowrap} .history-table td:is(:nth-child(3),:nth-child(4),:nth-child(5)),.history-table .history-actions{vertical-align:middle} + +.image-card h3.source-path{white-space:normal;overflow-wrap:anywhere} diff --git a/tests/schema-contract.json b/tests/schema-contract.json index 7020848..92e216b 100644 --- a/tests/schema-contract.json +++ b/tests/schema-contract.json @@ -883,6 +883,19 @@ "minLength": 1, "title": "Location" }, + "nas_root_path": { + "anyOf": [ + { + "type": "string", + "maxLength": 2048, + "pattern": "^/" + }, + { + "type": "null" + } + ], + "title": "Nas Root Path" + }, "recursive": { "type": "boolean", "title": "Recursive", @@ -1050,6 +1063,19 @@ "minLength": 1, "title": "Location" }, + "nas_root_path": { + "anyOf": [ + { + "type": "string", + "maxLength": 2048, + "pattern": "^/" + }, + { + "type": "null" + } + ], + "title": "Nas Root Path" + }, "recursive": { "type": "boolean", "title": "Recursive", @@ -1220,6 +1246,17 @@ ], "title": "Caption" }, + "source_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Path" + }, "provider_preview": { "type": "boolean", "title": "Provider Preview", diff --git a/tests/test_photo_paths.py b/tests/test_photo_paths.py new file mode 100644 index 0000000..956a901 --- /dev/null +++ b/tests/test_photo_paths.py @@ -0,0 +1,59 @@ +import io + +from PIL import Image + +from pigway_frame_flow.models import PhotoSourceContent +from pigway_frame_flow.providers.photo_sources import PhotoSourceInput + + +def test_photo_path_survives_render_and_asset_listing(client, service, monkeypatch): + photos = service.photo_sources + photos.save( + "family", + PhotoSourceInput( + name="Family", + kind="synology-photos", + location=".", + nas_root_path="/volume1/photo", + ), + ) + monkeypatch.setattr(photos.synology, "sid", lambda: "fixture") + monkeypatch.setattr( + photos.synology, + "items", + lambda *a, **kw: [ + { + "id": 42, + "filename": "original.PNG", + "additional": { + "folder": "/家庭/20190130", + "resolution": {"width": 1600, "height": 1200}, + }, + } + ], + ) + out = io.BytesIO() + Image.new("RGB", (1600, 1200)).save(out, "PNG") + monkeypatch.setattr(photos.synology, "download", lambda *a, **kw: out.getvalue()) + assert ( + photos.catalog("family")[0]["source_path"] + == "/volume1/photo/家庭/20190130/original.PNG" + ) + asset = service.content_asset( + PhotoSourceContent(type="photo_source", source_id="family", item_id="42") + ) + assert asset["source_path"] == "/volume1/photo/家庭/20190130/original.PNG" + assert client.get("/api/v1/assets").json()[0]["source_path"] == asset["source_path"] + assert ( + client.get("/api/v1/assets/" + asset["asset_id"] + "/source").json()["source"][ + "item_id" + ] + == "42" + ) + + +def test_missing_photo_path_is_not_invented(service): + out = io.BytesIO() + Image.new("RGB", (20, 20)).save(out, "JPEG") + asset = service.assets.put(out.getvalue(), "manual.jpg") + assert asset["source_path"] is None