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('sourceIndependentHelp')}
`); + 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-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('sourceBrowseHint')}
${t('sourceBrowseHint')}