From a86a9e2080ce7cc8789415ab4901066108bc6f47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sat, 17 Oct 2026 23:28:04 +0800 Subject: [PATCH] fix: restore photo captions and place recognized people after date [skip ci] --- deploy/Dockerfile.runtime-update | 2 +- docs/releases/v0.1.11.md | 7 +++ pigway_frame_flow/api/app.py | 4 +- pigway_frame_flow/display/service.py | 2 + pigway_frame_flow/models.py | 4 +- pigway_frame_flow/providers/photo_sources.py | 14 +++++ .../providers/synology_photos.py | 20 +++++++ pigway_frame_flow/renderers/photo_caption.py | 6 +- pigway_frame_flow/web/features.js | 4 +- pigway_frame_flow/web/i18n.js | 4 +- pigway_frame_flow/web/index.html | 2 +- pyproject.toml | 2 +- scripts/server-release.py | 2 +- tests/test_photo_watermark.py | 27 ++++++++- tests/test_synology_photos_api.py | 59 +++++++++++++++++++ 15 files changed, 143 insertions(+), 16 deletions(-) create mode 100644 docs/releases/v0.1.11.md diff --git a/deploy/Dockerfile.runtime-update b/deploy/Dockerfile.runtime-update index 8290ba7..d8d5520 100644 --- a/deploy/Dockerfile.runtime-update +++ b/deploy/Dockerfile.runtime-update @@ -29,4 +29,4 @@ CMD ["python", "-m", "uvicorn", "pigway_frame_flow.main:app", "--host", "0.0.0.0 ARG BUILD_COMMIT ARG BUILD_TIME ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME -LABEL org.opencontainers.image.title="Pigway Frame Flow" org.opencontainers.image.version="0.1.9" org.opencontainers.image.revision=$BUILD_COMMIT +LABEL org.opencontainers.image.title="Pigway Frame Flow" org.opencontainers.image.version="0.1.11" org.opencontainers.image.revision=$BUILD_COMMIT diff --git a/docs/releases/v0.1.11.md b/docs/releases/v0.1.11.md new file mode 100644 index 0000000..117261a --- /dev/null +++ b/docs/releases/v0.1.11.md @@ -0,0 +1,7 @@ +# v0.1.11 + +照片底部单行按“日期 · 已命名人物 · 主题 · 地点 · 文件名”排列,空项省略,人物之间也使用 ` · `。主题、日期和地点沿用最近的日期文件夹解析;没有结构化日期目录时仍保留文件名。人物来自 Synology Photos 已命名人物相册的实际图片归属,按账号会话和空间缓存 15 分钟,不显示未命名人物编号。人物查询失败时保留图片及其他描述。 + +新建照片源默认开启底部文字,既有来源保留明确设置。横向来源继续同时约束选图和生成画布,不拉伸图片。更新来源表单说明与浏览描述顺序。 + +使用既有 Ubuntu Server 发布流程,备份配置、数据库与 Compose,从提交源码构建并核验部署哈希;失败恢复旧 Compose。发布过程不主动切换真实画框,不重绘历史图库图片。 diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index 83f0bed..00701f6 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -126,7 +126,7 @@ def create_app(settings=None, start_worker=True): app = FastAPI( title="Pigway Frame Flow API", docs_url=None, - version="0.1.9", + version="0.1.11", lifespan=lifespan, responses=error_responses, ) @@ -1031,7 +1031,7 @@ def create_app(settings=None, start_worker=True): supports_upload=True, ), ], - version="0.1.9", + version="0.1.11", mock=settings.mock, write_enabled=settings.write_enabled, api_key_configured=bool(settings.api_key) diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py index 85d9344..3a15815 100644 --- a/pigway_frame_flow/display/service.py +++ b/pigway_frame_flow/display/service.py @@ -275,6 +275,8 @@ class DisplayService: data, name, caption = self.photo_sources.read( content.source_id, item["id"], target_size=size ) + if source.folder_caption: + caption = {**(caption or {}), "filename": name} if ( source.minimum_sharpness or source.minimum_brightness diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py index 74ed9b3..cb306cc 100644 --- a/pigway_frame_flow/models.py +++ b/pigway_frame_flow/models.py @@ -366,7 +366,7 @@ class SettingsView(BaseModel): class Health(BaseModel): status: str - version: str = "0.1.9" + version: str = "0.1.11" class Readiness(BaseModel): @@ -396,7 +396,7 @@ class RestoreRequest(StrictModel): class Version(BaseModel): product: str = "Pigway Frame Flow" - version: str = "0.1.9" + version: str = "0.1.11" api_version: str = "v1" commit: str = "development" build_time: str = "unknown" diff --git a/pigway_frame_flow/providers/photo_sources.py b/pigway_frame_flow/providers/photo_sources.py index 9b6f259..bceeb83 100644 --- a/pigway_frame_flow/providers/photo_sources.py +++ b/pigway_frame_flow/providers/photo_sources.py @@ -2,6 +2,7 @@ import base64 import json +import logging import os import posixpath import random @@ -618,6 +619,19 @@ class PhotoSources: data = self.synology.download( self.api_items[source_id][item["id"]], space=source.people_space ) + if source.folder_caption: + caption = dict(item.get("caption") or {}) + caption["filename"] = item["name"] + try: + caption["people"] = " · ".join( + self.synology.item_people(item["id"], space=source.people_space) + ) + except FlowError: + # Optional labels must not prevent displaying a valid photo. + logging.getLogger(__name__).warning( + "Photos person labels unavailable; keeping photo caption" + ) + item = {**item, "caption": caption} elif source.kind in ("google-drive", "alipan", "baidu"): from pigway_frame_flow.providers.cloud_drives import CloudDrives diff --git a/pigway_frame_flow/providers/synology_photos.py b/pigway_frame_flow/providers/synology_photos.py index c687be0..07f615f 100644 --- a/pigway_frame_flow/providers/synology_photos.py +++ b/pigway_frame_flow/providers/synology_photos.py @@ -315,6 +315,26 @@ class SynologyPhotos: merged = {key: row for group in groups for key, row in group.items()} return [merged[key] for key in sorted(selected, key=int)][:limit] + def item_people(self, item_id, *, space="shared"): + """Reuse verified person albums; never label an unnamed face by its ID.""" + prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto" + with self.lock: + key = ("caption-people", self.sid(), space) + cached = self.cache.get(key) + if not cached or time.monotonic() - cached[0] >= 900: + names: dict[str, list[str]] = {} + for person in self.people(space): + if not person["named"]: + continue + for row in self.pages( + prefix + ".Browse.Item", 7, person_id=person["id"] + ): + item_names = names.setdefault(str(row["id"]), []) + if person["name"] not in item_names: + item_names.append(person["name"]) + self.cache[key] = (time.monotonic(), names) + return list(self.cache[key][1].get(str(item_id), [])) + def download(self, row, *, space="shared", rendition="thumbnail"): from pigway_frame_flow.providers.photos_cache import PhotosCache diff --git a/pigway_frame_flow/renderers/photo_caption.py b/pigway_frame_flow/renderers/photo_caption.py index 4fb02a4..32958c4 100644 --- a/pigway_frame_flow/renderers/photo_caption.py +++ b/pigway_frame_flow/renderers/photo_caption.py @@ -41,7 +41,7 @@ def render_captioned_photo( draw = ImageDraw.Draw(canvas) values = [ " ".join(str(caption.get(key, "")).split()) - for key in ("title", "date", "place") + for key in ("date", "people", "title", "place", "filename") ] text = " · ".join(value for value in values if value) font_size = layout.font_size @@ -51,8 +51,8 @@ def render_captioned_photo( font_size -= 2 font = font_loader(font_size) # Shorten the title before touching the date/location. Never wrap the band. - while draw.textlength(text, font=font) > available and len(values[0]) > 2: - values[0] = values[0].rstrip("…")[:-1] + "…" + while draw.textlength(text, font=font) > available and len(values[2]) > 2: + values[2] = values[2].rstrip("…")[:-1] + "…" text = " · ".join(value for value in values if value) while draw.textlength(text, font=font) > available and len(text) > 2: text = text.rstrip("…")[:-1] + "…" diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js index 0f4b291..42ea5af 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -150,13 +150,13 @@ async function photoSourcesPanel(){ section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob)); } async function photoSourceForm(source=null){ - showModal(`

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

${t('sourceIndependentHelp')}

${t('sourceLocationHint')}

${t('sourceCredentialHint')}

${t('orientationHint')}

${t('folderCaptionHint')}

${t('peopleHint')}

${t('peopleSelected')}${(source?.person_ids||[]).length}

`); + showModal(`

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

${t('sourceIndependentHelp')}

${t('sourceLocationHint')}

${t('sourceCredentialHint')}

${t('orientationHint')}

${t('folderCaptionHint')}

${t('peopleHint')}

${t('peopleSelected')}${(source?.person_ids||[]).length}

`); $('#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_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; - box.innerHTML=`

${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}

${t('sourceBrowseHint')}

${result.items.map((x,i)=>``).join('')||t('noSourcePhotos')}
${buttons('source-refresh','refresh')}
`; + box.innerHTML=`

${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}

${t('sourceBrowseHint')}

${result.items.map((x,i)=>``).join('')||t('noSourcePhotos')}
${buttons('source-refresh','refresh')}
`; box.querySelectorAll('[data-source-item]').forEach(b=>b.onclick=busy(()=>previewSource(id,result.items[Number(b.dataset.sourceItem)].id)));bind('source-prev',()=>browseSource(id,Math.max(0,offset-50)));bind('source-next',()=>browseSource(id,offset+50));bind('source-refresh',()=>browseSource(id,0,true)); } async function previewSource(id,item_id=null){ diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js index 1204642..d345b6a 100644 --- a/pigway_frame_flow/web/i18n.js +++ b/pigway_frame_flow/web/i18n.js @@ -20,8 +20,8 @@ Object.assign(MESSAGES.en,{officialContentTitle:'KoKonna official widgets and su Object.assign(MESSAGES.zh,{photoSources:'照片数据源',sourceHint:'通过 Synology Photos API 管理照片来源、人物和筛选规则。浏览与预览不会改变画框。',addSource:'添加数据源',browse:'浏览照片',randomPreview:'随机选图预览',sourceJob:'定时随机照片',noSources:'暂无照片,请添加数据源或检查目录。',sourceKind:'数据源类型',sourceLocation:'目录 / WebDAV URL',sourceLocationHint:'Synology Photos:所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。其他云盘使用对应目录地址或 ID。',sourceCredential:'WebDAV 凭据环境变量名',sourceCredentialHint:'仅填写变量名,禁止填写密码。管理员通过环境变量或 Secret 配置 JSON username/password。',sourceRecursive:'包含子目录',sourceImages:'可用照片',sourceBrowseHint:'按需读取目录,点击文件名才生成预览;不会下载全部原图。',previousPage:'上一页',nextPage:'下一页',sourcePreviewHint:'这是已选中照片的预览,显示时使用同一张照片。',randomPhoto:'随机照片',sourceJobHint:'每次执行重新随机选图。默认暂停;启用后会按时改变画框显示。'}); Object.assign(MESSAGES.en,{photoSources:'Photo sources',sourceHint:'Manage photo sources, people and filters through the Synology Photos API. Browsing and previewing do not change the frame.',addSource:'Add source',browse:'Browse photos',randomPreview:'Preview random photo',sourceJob:'Schedule random photos',noSources:'No photos yet. Add a source or check the folder.',sourceKind:'Source type',sourceLocation:'Folder / WebDAV URL',sourceLocationHint:'Synology Photos: folder path within the selected space; . means the whole space. Downloads XL previews on demand, not originals. Other drives use their folder URL or ID.',sourceCredential:'WebDAV credential environment name',sourceCredentialHint:'Enter a variable name, never a password. Admin configures JSON username/password via environment or Secret.',sourceRecursive:'Include subfolders',sourceImages:'Available photos',sourceBrowseHint:'Catalog loaded on demand. Click a filename to preview; originals are not downloaded in bulk.',previousPage:'Previous page',nextPage:'Next page',sourcePreviewHint:'Display uses this exact previewed photo.',randomPhoto:'Random photo',sourceJobHint:'Choose a fresh random photo each run. Paused by default; enabling changes the frame on schedule.'}); -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,{organizedPhotos:'仅日期开头文件夹中的照片',folderCaption:'照片下方显示文件夹文字',folderCaptionHint:'底部单行显示日期、已命名人物、主题、@ 后的地点和文件名。子目录沿用最近的日期文件夹;没有日期文件夹时保留文件名,未命名人物不显示。'}); +Object.assign(MESSAGES.en,{organizedPhotos:'Only photos in date-prefixed folders',folderCaption:'Show folder caption below the photo',folderCaptionHint:'One footer line shows date, named people, title, place after @ and filename. Subfolders inherit their nearest dated folder; otherwise the filename is kept. Unnamed people are omitted.'}); Object.assign(MESSAGES.zh,{});Object.assign(MESSAGES.en,{}); diff --git a/pigway_frame_flow/web/index.html b/pigway_frame_flow/web/index.html index b23b8d2..b236131 100644 --- a/pigway_frame_flow/web/index.html +++ b/pigway_frame_flow/web/index.html @@ -1,3 +1,3 @@ Pigway Frame Flow -
PIGWAY FRAME FLOW

+
PIGWAY FRAME FLOW

diff --git a/pyproject.toml b/pyproject.toml index 7e743ed..95c1734 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pigway-frame-flow" -version = "0.1.9" +version = "0.1.11" requires-python = ">=3.12" dependencies = ["paho-mqtt==2.1.0", "python-dateutil==2.9.0.post0", "fastapi==0.142.2", "uvicorn==0.54.0", "pillow==12.3.0", "requests==2.34.2", "croniter==6.2.4", "python-multipart==0.0.32", "pyyaml==6.0.3"] [project.optional-dependencies] diff --git a/scripts/server-release.py b/scripts/server-release.py index b5310e8..16467ec 100644 --- a/scripts/server-release.py +++ b/scripts/server-release.py @@ -151,7 +151,7 @@ def release(lock): assert dst.execute("pragma quick_check").fetchone()[0] == "ok" base = "pigway-frame-flow:release-base-" + commit[:12] run("docker", "image", "tag", before["Image"], base) - image = "pigway-frame-flow:0.1.9-" + commit[:12] + image = "pigway-frame-flow:0.1.11-" + commit[:12] try: run( "docker", diff --git a/tests/test_photo_watermark.py b/tests/test_photo_watermark.py index 33511f1..fe60582 100644 --- a/tests/test_photo_watermark.py +++ b/tests/test_photo_watermark.py @@ -1,12 +1,37 @@ from io import BytesIO import pytest -from PIL import Image +from PIL import Image, ImageDraw from pigway_frame_flow.renderers.photo_caption import render_captioned_photo from pigway_frame_flow.renderers.photo_layout import PhotoLayout, WatermarkLayout +def test_watermark_includes_filename_and_recognized_people(service, monkeypatch): + drawn = [] + original = ImageDraw.ImageDraw.text + + def text(draw, xy, value, *args, **kwargs): + drawn.append(value) + return original(draw, xy, value, *args, **kwargs) + + monkeypatch.setattr(ImageDraw.ImageDraw, "text", text) + stream = BytesIO() + Image.new("RGB", (160, 120), "blue").save(stream, "JPEG") + render_captioned_photo( + stream.getvalue(), + { + "date": "2026-10-17", + "title": "Family", + "filename": "one.jpg", + "people": "Alice · Bob", + }, + service.renderer.font, + size=(1600, 1200), + ) + assert drawn == ["2026-10-17 · Alice · Bob · Family · one.jpg"] + + @pytest.mark.parametrize("size", [(1600, 1200), (1200, 1600)]) @pytest.mark.parametrize("fit", ["contain", "cover", "smart"]) def test_watermark_has_no_letterboxing(service, size, fit): diff --git a/tests/test_synology_photos_api.py b/tests/test_synology_photos_api.py index 74e3b3d..efb0b2a 100644 --- a/tests/test_synology_photos_api.py +++ b/tests/test_synology_photos_api.py @@ -78,6 +78,65 @@ def test_people_pagination_does_not_stop_at_500(settings, monkeypatch): assert calls == [0, 500, 1000] +def test_caption_people_use_actual_membership_and_cache(settings, monkeypatch): + session(settings) + provider = SynologyPhotos(settings) + monkeypatch.setattr( + provider, + "people", + lambda space: [ + {"id": 1, "name": "Alice", "named": True}, + {"id": 2, "name": "Bob", "named": True}, + {"id": 3, "name": "3", "named": False}, + ], + ) + calls = [] + + def pages(api, version, **params): + calls.append(api) + return [{"id": params["person_id"]}, {"id": 10}] + + monkeypatch.setattr(provider, "pages", pages) + assert provider.item_people(1) == ["Alice"] + assert provider.item_people(10) == ["Alice", "Bob"] + assert provider.item_people(99) == [] + assert calls == ["SYNO.FotoTeam.Browse.Item"] * 2 + assert provider.item_people(10, space="personal") == ["Alice", "Bob"] + assert calls[-2:] == ["SYNO.Foto.Browse.Item"] * 2 + + +@pytest.mark.parametrize("labels_available", [True, False]) +def test_api_caption_keeps_filename_and_folder_with_optional_people( + service, settings, monkeypatch, labels_available +): + session(settings) + provider = service.photo_sources + monkeypatch.setattr( + provider.synology, + "items", + lambda *a, **k: [row(1, "/20261017 Family@Park", "one.jpg")], + ) + monkeypatch.setattr(provider.synology, "download", lambda *a, **k: jpeg()) + + def names(*a, **k): + if not labels_available: + raise FlowError("CLOUD_UNAVAILABLE", "unavailable", 502) + return ["Alice", "Bob"] + + monkeypatch.setattr(provider.synology, "item_people", names) + provider.save( + "captions", + PhotoSourceInput( + name="Family", kind="synology-photos", location=".", folder_caption=True + ), + ) + _, _, caption = provider.read("captions", "1") + assert caption["date"] == "2026-10-17" + assert caption["title"] == "Family" + assert caption["filename"] == "one.jpg" + assert caption.get("people", "") == ("Alice · Bob" if labels_available else "") + + def test_source_without_mount_pagination_scope_and_session( client, service, settings, monkeypatch ):