From 41c68971514a56bbf4c87a9888626c042fbab629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sun, 18 Oct 2026 08:19:03 +0800 Subject: [PATCH] fix: classify unsent upload connections and omit raw photo filenames [skip ci] --- deploy/Dockerfile.runtime-update | 2 +- docs/releases/v0.1.14.md | 5 +++++ pigway_frame_flow/api/app.py | 4 ++-- pigway_frame_flow/display/service.py | 2 -- pigway_frame_flow/drivers/kokonna/client.py | 11 ++++++++++- pigway_frame_flow/models.py | 4 ++-- pigway_frame_flow/providers/photo_sources.py | 6 ++++-- pigway_frame_flow/renderers/photo_caption.py | 2 +- pigway_frame_flow/web/i18n.js | 4 ++-- pigway_frame_flow/web/index.html | 2 +- pyproject.toml | 2 +- scripts/server-release.py | 2 +- tests/test_client.py | 17 +++++++++++++++++ tests/test_photo_watermark.py | 6 ++++-- tests/test_synology_photos_api.py | 4 ++-- 15 files changed, 53 insertions(+), 20 deletions(-) create mode 100644 docs/releases/v0.1.14.md diff --git a/deploy/Dockerfile.runtime-update b/deploy/Dockerfile.runtime-update index 905fba3..7b46d9a 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.13" org.opencontainers.image.revision=$BUILD_COMMIT +LABEL org.opencontainers.image.title="Pigway Frame Flow" org.opencontainers.image.version="0.1.14" org.opencontainers.image.revision=$BUILD_COMMIT diff --git a/docs/releases/v0.1.14.md b/docs/releases/v0.1.14.md new file mode 100644 index 0000000..1cbc385 --- /dev/null +++ b/docs/releases/v0.1.14.md @@ -0,0 +1,5 @@ +# v0.1.14 + +底部文字仅显示日期、已命名人物(使用 & 连接)、文件夹主题及地点,不显示原始文件名。原始文件名仍用于图库和诊断记录。 + +上传传输区分明确的 DNS/连接建立失败与已建立连接中断:前者尚未发送 HTTP 写入,最多重试三次,失败后结束该次操作,不进入阻塞后续写入的 UNKNOWN;读超时或连接中断等不确定结果继续保留核对保护,不盲目重放上传。既有未知操作通过只读设备/图库核验后使用原有 acknowledge 流程关闭,保留原始错误和审计记录。 diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index 391d67e..1f181fd 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.13", + version="0.1.14", lifespan=lifespan, responses=error_responses, ) @@ -1032,7 +1032,7 @@ def create_app(settings=None, start_worker=True): supports_upload=True, ), ], - version="0.1.13", + version="0.1.14", 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 601f6d8..727f1a4 100644 --- a/pigway_frame_flow/display/service.py +++ b/pigway_frame_flow/display/service.py @@ -296,8 +296,6 @@ 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/drivers/kokonna/client.py b/pigway_frame_flow/drivers/kokonna/client.py index 36f4d06..465b358 100644 --- a/pigway_frame_flow/drivers/kokonna/client.py +++ b/pigway_frame_flow/drivers/kokonna/client.py @@ -10,6 +10,7 @@ from collections import deque from urllib.parse import quote import requests +from urllib3.exceptions import MaxRetryError, NewConnectionError from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import FlowError @@ -124,7 +125,15 @@ class SafeTransport: ), "request_error", ) - not_sent = isinstance(exc, requests.ConnectTimeout) + # A socket/DNS establishment failure precedes the HTTP write. A + # dropped established connection still has an uncertain outcome. + not_sent = isinstance(exc, requests.ConnectTimeout) or any( + isinstance(cause, MaxRetryError) + and isinstance(cause.reason, NewConnectionError) + for cause in exc.args + ) + if not_sent and category == "connection_error": + category = "connect_error" uncertain = write and not not_sent raise FlowError( E.WRITE_RESULT_UNKNOWN if uncertain else E.CLOUD_UNAVAILABLE, diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py index 0888f75..855fee7 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.13" + version: str = "0.1.14" class Readiness(BaseModel): @@ -396,7 +396,7 @@ class RestoreRequest(StrictModel): class Version(BaseModel): product: str = "Pigway Frame Flow" - version: str = "0.1.13" + version: str = "0.1.14" 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 3d568ba..5fa5c32 100644 --- a/pigway_frame_flow/providers/photo_sources.py +++ b/pigway_frame_flow/providers/photo_sources.py @@ -621,7 +621,6 @@ class PhotoSources: ) 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) @@ -631,7 +630,10 @@ class PhotoSources: logging.getLogger(__name__).warning( "Photos person labels unavailable; keeping photo caption" ) - item = {**item, "caption": caption} + item = { + **item, + "caption": {k: v for k, v in caption.items() if v} or None, + } elif source.kind in ("google-drive", "alipan", "baidu"): from pigway_frame_flow.providers.cloud_drives import CloudDrives diff --git a/pigway_frame_flow/renderers/photo_caption.py b/pigway_frame_flow/renderers/photo_caption.py index 32958c4..6922f77 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 ("date", "people", "title", "place", "filename") + for key in ("date", "people", "title", "place") ] text = " · ".join(value for value in values if value) font_size = layout.font_size diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js index b76ce35..7ab9d4b 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:'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,{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 and place after @. Subfolders inherit their nearest dated folder; unnamed people and original filenames 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 f0d4c39..f9192b5 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 7592bcd..977ac85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pigway-frame-flow" -version = "0.1.13" +version = "0.1.14" 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 1db596a..dbc0b7e 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.13-" + commit[:12] + image = "pigway-frame-flow:0.1.14-" + commit[:12] try: run( "docker", diff --git a/tests/test_client.py b/tests/test_client.py index 50144bf..ba0312c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -165,6 +165,23 @@ def test_transport_failure_records_safe_category_without_replay(exception, categ assert s.request.call_count == (3 if category == "connect_timeout" else 1) +def test_connection_establishment_failure_is_unsent_and_bounded(transport_clock): + from urllib3.exceptions import MaxRetryError, NewConnectionError + + s = session() + s.request.side_effect = requests.ConnectionError( + MaxRetryError(None, "/private", NewConnectionError(None, "private")) + ) + c = KokonnaClient("secret", True, s) + with pytest.raises(FlowError) as error: + c.display(1) + assert error.value.code == "CLOUD_UNAVAILABLE" + assert error.value.details["write_outcome"] == "not_sent" + assert error.value.details["transport_error"] == "connect_error" + assert s.request.call_count == 3 + assert "private" not in str(error.value.details) + + @pytest.mark.parametrize("write", [False, True]) def test_connect_timeout_retries_then_succeeds(write, transport_clock): s = session({"success": True}) diff --git a/tests/test_photo_watermark.py b/tests/test_photo_watermark.py index 6da9a4d..d1ab2ea 100644 --- a/tests/test_photo_watermark.py +++ b/tests/test_photo_watermark.py @@ -7,7 +7,9 @@ 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): +def test_watermark_omits_raw_filename_and_includes_recognized_people( + service, monkeypatch +): drawn = [] original = ImageDraw.ImageDraw.text @@ -29,7 +31,7 @@ def test_watermark_includes_filename_and_recognized_people(service, monkeypatch) service.renderer.font, size=(1600, 1200), ) - assert drawn == ["2026-10-17 · Alice & Bob · Family · one.jpg"] + assert drawn == ["2026-10-17 · Alice & Bob · Family"] @pytest.mark.parametrize("size", [(1600, 1200), (1200, 1600)]) diff --git a/tests/test_synology_photos_api.py b/tests/test_synology_photos_api.py index 2f97208..a6e69e6 100644 --- a/tests/test_synology_photos_api.py +++ b/tests/test_synology_photos_api.py @@ -106,7 +106,7 @@ def test_caption_people_use_actual_membership_and_cache(settings, monkeypatch): @pytest.mark.parametrize("labels_available", [True, False]) -def test_api_caption_keeps_filename_and_folder_with_optional_people( +def test_api_caption_keeps_folder_and_optional_people_without_filename( service, settings, monkeypatch, labels_available ): session(settings) @@ -133,7 +133,7 @@ def test_api_caption_keeps_filename_and_folder_with_optional_people( _, _, caption = provider.read("captions", "1") assert caption["date"] == "2026-10-17" assert caption["title"] == "Family" - assert caption["filename"] == "one.jpg" + assert "filename" not in caption assert caption.get("people", "") == ("Alice & Bob" if labels_available else "")