Compare commits

..
17 Commits
Author SHA1 Message Date
渡口浪人 1494904b12 fix: close library upload flow and harden image storage
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 8m9s
2026-10-24 00:38:00 +08:00
渡口浪人 0859871363 docs: audit commercial readiness and security gaps
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 7m4s
2026-10-23 23:47:52 +08:00
渡口浪人 ed3b61bc58 fix: resolve gallery labels from current plans and preserve render snapshots
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 7m11s
2026-10-23 23:10:50 +08:00
渡口浪人 30bc5a0bad fix: recognize displayed terminal versions and clean obsolete gallery remnants
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 11m49s
2026-10-23 22:57:53 +08:00
渡口浪人 64bd8dd736 refactor: retire legacy gallery pages and confirm terminal unbinding
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 7m20s
2026-10-23 22:33:51 +08:00
渡口浪人 cca5838803 fix: use single-select terminal tags in library
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 8m25s
2026-10-23 22:20:47 +08:00
渡口浪人 129cfc8024 fix: place terminal library pagination below image cards
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 7m32s
2026-10-23 22:16:08 +08:00
渡口浪人 83c1c07fbd fix: right align plan actions and name deletion confirmation
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 8m23s
2026-10-23 22:12:47 +08:00
渡口浪人 d680ba77f9 fix: group source library tabs and images in a dedicated panel
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 7m55s
2026-10-23 21:04:01 +08:00
渡口浪人 6ebc5e7afb feat: add paged NAS photo browser with metadata filters and previews
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 7m29s
2026-10-23 19:59:02 +08:00
渡口浪人 8ce4aafb93 feat: split source and terminal library navigation
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 6m45s
2026-10-23 19:21:44 +08:00
渡口浪人 deb3b89bf8 fix: align library plan actions and move pagination below cards
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 6m39s
2026-10-23 19:13:33 +08:00
渡口浪人 9188d1743e fix: compact source library controls into one row
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 6m40s
2026-10-23 18:55:41 +08:00
渡口浪人 6277c9d563 feat: unify source libraries, generation plans and terminal bindings
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 6m24s
2026-10-23 17:45:30 +08:00
渡口浪人 081d8b6ee0 feat: add a separate watch thumbnail gallery and logical device
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 6m12s
2026-10-23 15:00:29 +08:00
渡口浪人 7178235022 feat: manage saved thumbnail plans and select plans for tasks
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 5m58s
2026-10-23 14:47:27 +08:00
渡口浪人 d94031eb50 feat: generate source-only watch thumbnails after photo uploads
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 7m6s
2026-10-23 13:27:17 +08:00
71 changed files with 11024 additions and 432 deletions
+5 -1
View File
@@ -8,6 +8,10 @@ Development currently supports Python 3.14.x. See [contributing](CONTRIBUTING.md
PIGWay Frame Flow is a lightweight, self-hosted platform for programmable smart-frame automation. It runs independently on a NAS or server, with its own Web UI, image library, rendering, scheduler and REST API. KoKonna is a supported device integration; external applications do not need its credentials or SDK. PIGWay Frame Flow is a lightweight, self-hosted platform for programmable smart-frame automation. It runs independently on a NAS or server, with its own Web UI, image library, rendering, scheduler and REST API. KoKonna is a supported device integration; external applications do not need its credentials or SDK.
### Source and terminal libraries
Each photo source owns a library and generation plans. A terminal binds to one source and one plan within it; terminals may share a plan and its generated version. Source tasks select one picture and produce independent frame/watch versions. See the [library model and workflow](docs/SOURCE_LIBRARIES.md).
### Installation ### Installation
Deploy with Docker Compose. The current default configuration and verified platform are AMD64: Deploy with Docker Compose. The current default configuration and verified platform are AMD64:
@@ -46,7 +50,7 @@ Synology Photos API sources work without `/photos` or NFS mounts, with shared/cu
### Features and boundaries ### Features and boundaries
- Chinese and English Web UI, light/dark themes, account login, device dashboard and image previews. - Chinese and English Web UI, light/dark themes, account login, device dashboard and image previews.
- Local staging and KoKonna cloud gallery, uploads, image switching, stars, source/task filters and batch actions. - Source libraries with source-scoped generation plans, terminal libraries with one source/plan binding per terminal, and paged NAS browsing with image previews and metadata filters. Legacy gallery pages are retired; existing task compatibility remains.
- Aspect-ratio-preserving contain/cover/smart conversion, color/grayscale profiles and orientation selection for photo sources. - Aspect-ratio-preserving contain/cover/smart conversion, color/grayscale profiles and orientation selection for photo sources.
- Seven templates: Daily Dashboard, Weather, Calendar, Quote, Photo + Text, Daily Poetry and System Alert. - Seven templates: Daily Dashboard, Weather, Calendar, Quote, Photo + Text, Daily Poetry and System Alert.
- Layout Studio for collages/posters with 1–12 images; optional OpenAI-compatible layout planning and source-driven automatic collages. - Layout Studio for collages/posters with 1–12 images; optional OpenAI-compatible layout planning and source-driven automatic collages.
+6 -2
View File
@@ -8,6 +8,10 @@
轻量、自托管的智能画框自动化平台,可独立运行在 NAS 或服务器上,提供自己的 Web 界面、图库、图片渲染、调度器和 REST API。KoKonna 是支持的设备集成之一;外部应用无需持有它的凭据或引入其 SDK。 轻量、自托管的智能画框自动化平台,可独立运行在 NAS 或服务器上,提供自己的 Web 界面、图库、图片渲染、调度器和 REST API。KoKonna 是支持的设备集成之一;外部应用无需持有它的凭据或引入其 SDK。
### 图库与终端
图库分为来源图库和终端图库。每个来源有自己的生成方案;每个终端单选一个来源及其中一个方案,同一方案可以被多个终端共用。任务一次选图,分别生成相框与手表版本。参见[使用与架构说明](docs/SOURCE_LIBRARIES.md)。
### 安装 ### 安装
使用 Docker Compose 部署。当前默认配置与已验证平台为 AMD64: 使用 Docker Compose 部署。当前默认配置与已验证平台为 AMD64:
@@ -48,7 +52,7 @@ Synology Photos 可使用独立的 API 来源,无需 `/photos` 或 NFS 挂载
### 功能与边界 ### 功能与边界
- 中英文 Web 界面、深浅色主题、账号登录、设备面板与图片预览。 - 中英文 Web 界面、深浅色主题、账号登录、设备面板与图片预览。
- NAS 本地图库和 KoKonna 云端图库;星标、来源/任务筛选及批量操作位于本地图库,上传到云端并显示需单独操作。 - 来源图库管理源画面和来源内生成方案;终端图库按绑定管理成品。NAS 选图支持分页、预览及元数据筛选,旧图库页面已退役,已有任务兼容逻辑保留。
- 保持比例的 contain/cover/smart 图片转换,彩色/灰度模式与照片数据源的横竖方向选择。 - 保持比例的 contain/cover/smart 图片转换,彩色/灰度模式与照片数据源的横竖方向选择。
- 七种模板:家庭早报、天气、日历、一句话、照片与文字、每日诗词、系统告警。 - 七种模板:家庭早报、天气、日历、一句话、照片与文字、每日诗词、系统告警。
- 拼图工作室支持 1–12 张图片的拼图与海报;可选 OpenAI 兼容接口排版,以及从数据源自动选图拼接。 - 拼图工作室支持 1–12 张图片的拼图与海报;可选 OpenAI 兼容接口排版,以及从数据源自动选图拼接。
@@ -106,4 +110,4 @@ KoKonna 是支持的设备集成。本项目与 KoKonna 无隶属关系,也不
历史记录支持服务端翻页、图片名称/请求 ID/任务 ID/来源搜索,以及状态、操作、设备和日期(上海时区)筛选;可选择当前页记录进行单条或批量删除,操作前需确认。删除仅从历史列表移除,内部执行凭据继续供图库来源、幂等及恢复使用,不会删除图片或取消任务;运行中的任务不能删除。 历史记录支持服务端翻页、图片名称/请求 ID/任务 ID/来源搜索,以及状态、操作、设备和日期(上海时区)筛选;可选择当前页记录进行单条或批量删除,操作前需确认。删除仅从历史列表移除,内部执行凭据继续供图库来源、幂等及恢复使用,不会删除图片或取消任务;运行中的任务不能删除。
源图库选用标记:本地图库支持单张和批量“不再选用/恢复选用”,照片源(Synology Photos、WebDAV、网盘等)的浏览列表支持独立星标及选用标记。后续自动选图和自动拼图跳过被标记的图片,星标保留与不再选用互斥,需先取消原标记再切换;图片仍保留,可手动预览、使用或恢复。标记按图库和图片 ID 独立保存,不取消已提交任务,也不修改网盘原文件。 来源图库的源画面支持“星标保留”和“不再选用/恢复选用”,二者互斥;后续自动选图和自动拼图跳过被标记的照片。图片仍保留,可手动预览、生成或恢复。标记按来源和图片 ID 独立保存,不取消已提交任务,也不修改网盘原文件。
+10
View File
@@ -1,3 +1,13 @@
# 当前开发修订
- 更新图片处理、证书信任和遥测依赖;不可信图片只交给允许的格式解析器。
- 手动上传接入来源图库,保存源画面后可按方案生成并关联终端。
- 补齐图片写权限;客户端权限选项使用服务端统一目录。
- 图片文件和数据库变更加入事务、失败清理及重启恢复;缓存转成品也保留一致性。
- 统一上传及图库文案,移除图库重复设备选择器;NAS 空批次支持明确继续读取提示。
- 设置页显示磁盘容量和待清理文件;登录计算不再占用共享数据库锁。
- 保持单用户、内网 HTTP 访问,无证书要求。
# pigway-frame-flow v0.1.29 # pigway-frame-flow v0.1.29
完善星标与“不再选用”禁用状态的原因提示。 完善星标与“不再选用”禁用状态的原因提示。
+42 -32
View File
@@ -8,8 +8,8 @@ c8c545718e9b9880555c950a078cac160c82e0cd6e593afcf20bc13727539ec5 .github/workfl
b12a3f0ff3f15db8d79b5163d0750a3dd10eb56aadc41ada20c5f8a10b46c118 Dockerfile b12a3f0ff3f15db8d79b5163d0750a3dd10eb56aadc41ada20c5f8a10b46c118 Dockerfile
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE 6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md 250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md
b7a41a2c00df1a0dbe3869d059d65c3bcaee8c50e51217c358527c4f1d3d6c98 README.md 31d523d1ed7465d8e7bf44011ca940e9ff0dd21af0e1bde5814c28dbfff53dd8 README.md
94184f76401192678ec420c2ccd23142d90e07a8ccc882a4a0d4701a0c278afc README.zh-CN.md 1b809c686f4c1275bb9e04c1505020a3b5a9cb3a8e9298eb902ffe168bb17739 README.zh-CN.md
4a5de9afa11143f35fb98cb4b97bde4ac1f8b67646810522483d30aecdda16d2 RELEASE_NOTES.md 4a5de9afa11143f35fb98cb4b97bde4ac1f8b67646810522483d30aecdda16d2 RELEASE_NOTES.md
3dd364e47fdbd2ce9ce2df83342c523adc21583f093a5439d501b3424878ce2f SECURITY.md 3dd364e47fdbd2ce9ce2df83342c523adc21583f093a5439d501b3424878ce2f SECURITY.md
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep
@@ -24,39 +24,46 @@ f78d6c3d40e6c5246601bb99c2b46eef91e883a8f83841d85c925394708af0c0 docker-compose
93b6e60570d9ca2ce14a454641a32885ca84d16a82db05db22cd62e488191dff docs/BUILDING.md 93b6e60570d9ca2ce14a454641a32885ca84d16a82db05db22cd62e488191dff docs/BUILDING.md
d9f07164c75c757e85f4ace2b55deaf82b8b904216c5222be6c0e3ab7b28cb36 docs/EXTENSIONS.md d9f07164c75c757e85f4ace2b55deaf82b8b904216c5222be6c0e3ab7b28cb36 docs/EXTENSIONS.md
1d8c5f79eff3115b1cd13d8f63fff1332b80d1fe1bec6b0f2713b5f80b0cbf67 docs/KOKONNA_RECOVERY.md 1d8c5f79eff3115b1cd13d8f63fff1332b80d1fe1bec6b0f2713b5f80b0cbf67 docs/KOKONNA_RECOVERY.md
0fe1896a53cef4cfd2e380a0b979eceda493733082a9b56c3d7e89e7f62a65b3 docs/NAS.md 09cf79be06991e3b482518a3059a6f26504cb68857bc98a8a8bf8628724a5a93 docs/NAS.md
3280bddcbe2d2721bb7efeb0eb2ec03d9d5ef867c69f3e70066b69f1ad5926e2 docs/PHOTO_SOURCES.md 3280bddcbe2d2721bb7efeb0eb2ec03d9d5ef867c69f3e70066b69f1ad5926e2 docs/PHOTO_SOURCES.md
edf44436fd044561539c48480c481e66c1a38164f51ba5226cde04c756f7bacb docs/PHOTO_SOURCE_CONNECTIONS.md edf44436fd044561539c48480c481e66c1a38164f51ba5226cde04c756f7bacb docs/PHOTO_SOURCE_CONNECTIONS.md
b48f3ab6f28404bb34c188067a832384f4ba290e56912461ef3565528aa157d8 docs/PUBLICATION.md b48f3ab6f28404bb34c188067a832384f4ba290e56912461ef3565528aa157d8 docs/PUBLICATION.md
5fe4641e75e0746929f71256c6ff11116c777cca8d4288ccc2d7aee91bd7a6ac docs/README.md 10842c477d34e6fde7af0a98c739ef513ef48480d52e2fe6d6874eed33461d15 docs/README.md
5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md 5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md
bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md
5ba13866ad96cb931c9dcb5fcc141c60fc44103a969fe912636a44afefb6854f docs/SOURCE_LIBRARIES.md
fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md
f8ef7749c58d4396781bbc21371048f6561e791efbd489570224dc51173bffff docs/brand/README.md f8ef7749c58d4396781bbc21371048f6561e791efbd489570224dc51173bffff docs/brand/README.md
f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3 docs/brand/pigway-avatar.png f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3 docs/brand/pigway-avatar.png
da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7 docs/brand/pigway-logo.png da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7 docs/brand/pigway-logo.png
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 docs/brand/pigway-logo.svg 0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 docs/brand/pigway-logo.svg
ac0481196e593ff9cca2e5133ff41fb3614268e6a10728a0deca3ad3bfb80b34 docs/integrations/TICKTAR.md ac0481196e593ff9cca2e5133ff41fb3614268e6a10728a0deca3ad3bfb80b34 docs/integrations/TICKTAR.md
349d7acb49ad5b5f1df169c6ffe99063f11a55ca8764302ac383663161f1c6fb docs/openapi.json f1549ec1c51602a949261319c0483abb34d1ae6d272bdaf7e057ada7ebaa78a2 docs/openapi.json
54bf7061e0626900ce35e19e81efc5c32c8fd6a0438bdad68f494e1ce81616d4 docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md 54bf7061e0626900ce35e19e81efc5c32c8fd6a0438bdad68f494e1ce81616d4 docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md
a07cbe71571c6e2d5a1b94897bd63321c74675c05d15438c4964eef39db0fd76 docs/research/KOKONNA_CONTENT_SUBSCRIPTIONS.md a07cbe71571c6e2d5a1b94897bd63321c74675c05d15438c4964eef39db0fd76 docs/research/KOKONNA_CONTENT_SUBSCRIPTIONS.md
ec53d587b855be84283c50f8fb45be62a4c1caba78b28d436a8260fd171943a2 docs/research/KOKONNA_LOCAL_CONTROL.md ec53d587b855be84283c50f8fb45be62a4c1caba78b28d436a8260fd171943a2 docs/research/KOKONNA_LOCAL_CONTROL.md
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json 2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__init__.py
0d9582321b07b570adeab0e6f4c7fddfd45b9ae8703c1ff75edb53ec5cde2e09 pigway_frame_flow/api/app.py ea7b720fa34ebe4039398eb34ac2d9457aafa46d14e273e6a01b257b2acdbb0e pigway_frame_flow/api/app.py
1efe9b1fc774a97e5be72d81199b1b65b25c258956fdf166aebff732cc820d7c pigway_frame_flow/api/history.py 1efe9b1fc774a97e5be72d81199b1b65b25c258956fdf166aebff732cc820d7c pigway_frame_flow/api/history.py
a6f7336da1e4b90ec41a6f7cb2467e4b1bfa78551bf71b14b64e82d8ea08498f pigway_frame_flow/api/libraries.py
3dda509a34daca61836913976dba35e59c2135c48bf04bc7617e923cc5cffdc1 pigway_frame_flow/api/thumbnails.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py
0b8402603c2f397f4f42491873b27bf363959194b05a0c67c7b1d49596288b95 pigway_frame_flow/core/accounts.py 0b8402603c2f397f4f42491873b27bf363959194b05a0c67c7b1d49596288b95 pigway_frame_flow/core/accounts.py
818e8fb29d22c13159a6cb22bcbc6b9e211a8a1301588126eb192ac4ed71441b pigway_frame_flow/core/auth.py 818e8fb29d22c13159a6cb22bcbc6b9e211a8a1301588126eb192ac4ed71441b pigway_frame_flow/core/auth.py
2f03a04c1f0f32d95bfd3d27d37ca98f9b79daaa20453999024b16fc8f34cd5d pigway_frame_flow/core/photo_preferences.py 2f03a04c1f0f32d95bfd3d27d37ca98f9b79daaa20453999024b16fc8f34cd5d pigway_frame_flow/core/photo_preferences.py
8f2220fa90b0dc2e45df2023dc5ac695efe55361644ef266829553c99151949b pigway_frame_flow/core/registry.py 8f2220fa90b0dc2e45df2023dc5ac695efe55361644ef266829553c99151949b pigway_frame_flow/core/registry.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/display/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/display/__init__.py
81effa34d6c452579cfa65781909f7c5a9e8ddad136e4c47ee0704939851879a pigway_frame_flow/display/cloud_library.py 881698e97f4427ff2db025f08ee82d9d106ce36744ce8f17cae94f2e8a703e18 pigway_frame_flow/display/cloud_library.py
94ac69244669b7ccc816ebac3e62f0bf44a0a076e1d7b8bd7bab332dad779c79 pigway_frame_flow/display/libraries.py
5e7e272e671a6d01d9b677416c77967ca488b4e2aa559f4211cf0de4ba1e88bc pigway_frame_flow/display/recovery.py 5e7e272e671a6d01d9b677416c77967ca488b4e2aa559f4211cf0de4ba1e88bc pigway_frame_flow/display/recovery.py
e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py
d974caaa2772b013cc6f080f997d37cd0d2212b29fa9524002f3fb87dd937364 pigway_frame_flow/display/service.py 52f1e4a1adb3dca8d47647e14e55479fe18664bfac63f220da76ff90e0a358b1 pigway_frame_flow/display/service.py
de6b53b2911837af5e8e4ee3065ae513e4523436ddabde4a11503e432c3c1351 pigway_frame_flow/display/thumbnail_gallery.py
18fc72b7f726c85d8b11bed78d88403c7867b7d71d2580d47afad6fa0242edc2 pigway_frame_flow/display/thumbnail_plans.py
0975b24488be19f24e768c4c76d9f3c0348417c1351c4e410897da00f7be6656 pigway_frame_flow/display/upload_reconciliation.py 0975b24488be19f24e768c4c76d9f3c0348417c1351c4e410897da00f7be6656 pigway_frame_flow/display/upload_reconciliation.py
a521e5d107f456e007050de65cd25e7ed3f269c27360e2bda6f768faa9b28841 pigway_frame_flow/display/wrist_thumbnail.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/drivers/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/drivers/__init__.py
9a8386ef368a6df7fd8cb23bcc442c28e784729a5ce4a78911ad86245d5c5794 pigway_frame_flow/drivers/base.py 9a8386ef368a6df7fd8cb23bcc442c28e784729a5ce4a78911ad86245d5c5794 pigway_frame_flow/drivers/base.py
54cff8782e21e2ee4b8540259a4868380c58a0aa656c9d23796a049ab96a1b4f pigway_frame_flow/drivers/generic_http.py 54cff8782e21e2ee4b8540259a4868380c58a0aa656c9d23796a049ab96a1b4f pigway_frame_flow/drivers/generic_http.py
@@ -77,7 +84,7 @@ d463465ca3aea449e67386bc84a5f25b58535f74d89d7858c5b9bca0d93189d3 pigway_frame_f
6d70e410d3559f370e4489aa92b54eea91d11407e8039c9adf31612de0456df1 pigway_frame_flow/extensions/providers.py 6d70e410d3559f370e4489aa92b54eea91d11407e8039c9adf31612de0456df1 pigway_frame_flow/extensions/providers.py
ab3081bd1afd2a4cd501355f10771cd92f3432439d9bc240d0790c2e76c2a508 pigway_frame_flow/extensions/quality.py ab3081bd1afd2a4cd501355f10771cd92f3432439d9bc240d0790c2e76c2a508 pigway_frame_flow/extensions/quality.py
8e7d7da457d3b82811dc98cf6c3a93fcd7cbe8a4addb5682f48b1acd4983de82 pigway_frame_flow/extensions/secrets.py 8e7d7da457d3b82811dc98cf6c3a93fcd7cbe8a4addb5682f48b1acd4983de82 pigway_frame_flow/extensions/secrets.py
49f2d6f7a35675fedaf03ca648f046bde6e49aebb21d6e7afb39f1b8a523fbb2 pigway_frame_flow/extensions/storage.py 83fea5407cbb83ca426cda420cc995e9346955e747e0cda2f7e6546fbd50dc06 pigway_frame_flow/extensions/storage.py
1f2db3a58e7ebe5a4531f4550356fc9bcb331a2701f0bc25edf0ea8b13a57d97 pigway_frame_flow/extensions/webhooks.py 1f2db3a58e7ebe5a4531f4550356fc9bcb331a2701f0bc25edf0ea8b13a57d97 pigway_frame_flow/extensions/webhooks.py
b0855f8d22665fb208dfe47856f374b76a3861c15efb312d4ada81e84fee64e5 pigway_frame_flow/integrations/__init__.py b0855f8d22665fb208dfe47856f374b76a3861c15efb312d4ada81e84fee64e5 pigway_frame_flow/integrations/__init__.py
d3025e294466d72c9332a7c577c3262816c6a12d12c7c2f0c241a7e423034eed pigway_frame_flow/integrations/kokonna_official/README.md d3025e294466d72c9332a7c577c3262816c6a12d12c7c2f0c241a7e423034eed pigway_frame_flow/integrations/kokonna_official/README.md
@@ -88,7 +95,7 @@ a518965aa4db9abeb60865c315229c3053c8e9edd26d80f24dc0af0528bad9e6 pigway_frame_f
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 pigway_frame_flow/integrations/kokonna_official/widget_catalog.json 2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 pigway_frame_flow/integrations/kokonna_official/widget_catalog.json
aea594fb4e4c9a6e2094d4d7973eb627ac02fb632811b2d1a558a0ee1cd12e77 pigway_frame_flow/integrations/kokonna_official/widgets.py aea594fb4e4c9a6e2094d4d7973eb627ac02fb632811b2d1a558a0ee1cd12e77 pigway_frame_flow/integrations/kokonna_official/widgets.py
1fe53f4bfb407b8db774fe730131ecf1213e16d752a3506c4c56b46365ea14e8 pigway_frame_flow/main.py 1fe53f4bfb407b8db774fe730131ecf1213e16d752a3506c4c56b46365ea14e8 pigway_frame_flow/main.py
c9afccb6336bcb312e738a3c46a402bb693f961dd1cfffb4fff7011f9c3c1395 pigway_frame_flow/models.py 17cbaba7fe46377bf62c003dadde9e6dbbe8d230a1c2675d0541770cc413e96d pigway_frame_flow/models.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/providers/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/providers/__init__.py
b7a6cb8eb40d8dedfbce18777f8fbd433a174f14bcdadf498bd834132aa568fd pigway_frame_flow/providers/cloud_drives.py b7a6cb8eb40d8dedfbce18777f8fbd433a174f14bcdadf498bd834132aa568fd pigway_frame_flow/providers/cloud_drives.py
58b070f0c814d639a4a912d079479d751d459816d59f2199d0be03e4d573ec3b pigway_frame_flow/providers/external.py 58b070f0c814d639a4a912d079479d751d459816d59f2199d0be03e4d573ec3b pigway_frame_flow/providers/external.py
@@ -96,19 +103,20 @@ e90b6becc3e19baa83fab763cb156eb8882a96594c04040305dcb315f68e85d0 pigway_frame_f
69e7419e67e8416bf73f37e257a2586d2a1aa4d2706da8f7b42c5cb8ca7f1eb3 pigway_frame_flow/providers/oauth.py 69e7419e67e8416bf73f37e257a2586d2a1aa4d2706da8f7b42c5cb8ca7f1eb3 pigway_frame_flow/providers/oauth.py
abff8ee4b4eb1575451bcd5addf54451056af665d7aaa90173b5ed69a75a4d8e pigway_frame_flow/providers/photo_names.py abff8ee4b4eb1575451bcd5addf54451056af665d7aaa90173b5ed69a75a4d8e pigway_frame_flow/providers/photo_names.py
7d6fd8ff3ae99798b26d7a82644353e2c9ce5439e18c64393dc261c395ac6f4c pigway_frame_flow/providers/photo_orientation.py 7d6fd8ff3ae99798b26d7a82644353e2c9ce5439e18c64393dc261c395ac6f4c pigway_frame_flow/providers/photo_orientation.py
885d1945dacd611143eb6992da4621ce32ae304d80514f150d55318bdcb10e0f pigway_frame_flow/providers/photo_sources.py 21353750457ecd5802c944b24a9976f05cbaa1920052785d26fda7e468ea3aae pigway_frame_flow/providers/photo_sources.py
114adfb3fcd1ab050c1dd2f1fff9d91fdde192b8eced907dee3bc8086037e13d pigway_frame_flow/providers/photos_cache.py 114adfb3fcd1ab050c1dd2f1fff9d91fdde192b8eced907dee3bc8086037e13d pigway_frame_flow/providers/photos_cache.py
119427bc435ec8d4c91114713f37d07f22067eaeec391261d1441a1436baf5e0 pigway_frame_flow/providers/poetry.py 119427bc435ec8d4c91114713f37d07f22067eaeec391261d1441a1436baf5e0 pigway_frame_flow/providers/poetry.py
c44bf2b4a3776bebe3689b251ae3e8c352a43088829ae8c3f6fd379b64dea05e pigway_frame_flow/providers/synology_photos.py b1266d22ea914aa4d52e0b80cc1e2948c72219ea766140eb5aa22f6d42531053 pigway_frame_flow/providers/source_browser.py
935340780577fbc7a54f893e492f4284f1e8fbffdd397663b71c0137e93c174b pigway_frame_flow/providers/synology_photos.py
7dfc5eab241122c4758d8b0c027369049231451dc8e0e1b49f7da56532a8e87d pigway_frame_flow/providers/weather.py 7dfc5eab241122c4758d8b0c027369049231451dc8e0e1b49f7da56532a8e87d pigway_frame_flow/providers/weather.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/renderers/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/renderers/__init__.py
ce1e3d9dd7d8dc8d76d6206ab9cd05974bd2e81e1bbf28711f4b461332bf9236 pigway_frame_flow/renderers/frame_geometry.py ce1e3d9dd7d8dc8d76d6206ab9cd05974bd2e81e1bbf28711f4b461332bf9236 pigway_frame_flow/renderers/frame_geometry.py
e80029d9edc781e54e523570940110c948a0b0e564dcacf64f2fe1425f4509ea pigway_frame_flow/renderers/images.py 95da34371ed6a46e63fb7f01dbaad3b9a73b520cdcaec1231048b81b01a0184e pigway_frame_flow/renderers/images.py
b70197edc25101b7ee73434b253ae947cddc8a9d3e55545369ac7189e117b2a2 pigway_frame_flow/renderers/photo_caption.py 0683436692d083c86b580aff71a9803a94e9fa07d45f384795020423b486494a pigway_frame_flow/renderers/photo_caption.py
241f7d80997a6b6c7d421723d5375d4890f64eea8c70680abefb0fa5d15b9502 pigway_frame_flow/renderers/photo_layout.py 241f7d80997a6b6c7d421723d5375d4890f64eea8c70680abefb0fa5d15b9502 pigway_frame_flow/renderers/photo_layout.py
d36258296d04b6575d78dd9831f8d3f66cf8b1e9182e2e90a589d10fa0067a04 pigway_frame_flow/renderers/templates.py d36258296d04b6575d78dd9831f8d3f66cf8b1e9182e2e90a589d10fa0067a04 pigway_frame_flow/renderers/templates.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/scheduler/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/scheduler/__init__.py
ee80352e10eb871293bd8700837fab8d5f2e0da777d22e32811e935cf7572edc pigway_frame_flow/scheduler/engine.py d904579f2918c0bc97f05d2a84964ae5f0b3b9dfe6b5201c0866b4c4c5a1d304 pigway_frame_flow/scheduler/engine.py
d54facc006da1345a150780c414830efaa2e0ab75fa0c9673cf5d39e912472b1 pigway_frame_flow/scheduler/upload_retries.py d54facc006da1345a150780c414830efaa2e0ab75fa0c9673cf5d39e912472b1 pigway_frame_flow/scheduler/upload_retries.py
77464a08816507a2423f08327c7767a9c019f55ea8f232856fa647e72e1e0246 pigway_frame_flow/settings.py 77464a08816507a2423f08327c7767a9c019f55ea8f232856fa647e72e1e0246 pigway_frame_flow/settings.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/storage/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/storage/__init__.py
@@ -122,19 +130,22 @@ f9e777980ea5bb3ea8fb2357f23530802811ea2674d25100af9b728cbc9f83a9 pigway_frame_f
8f37296f52a88c11a000a5a0767310354550da34e1d4c0c7260393ec00cd0d9b pigway_frame_flow/templates/quote.json 8f37296f52a88c11a000a5a0767310354550da34e1d4c0c7260393ec00cd0d9b pigway_frame_flow/templates/quote.json
82c17f4e7eaac95483592add181f549186dea9d7c0375fa9cad3554e3ccd1794 pigway_frame_flow/templates/system-alert.json 82c17f4e7eaac95483592add181f549186dea9d7c0375fa9cad3554e3ccd1794 pigway_frame_flow/templates/system-alert.json
d6f705b9d183b57d50759e283db2161f8493c4e3969459e74da635252014c925 pigway_frame_flow/templates/weather.json d6f705b9d183b57d50759e283db2161f8493c4e3969459e74da635252014c925 pigway_frame_flow/templates/weather.json
a300dfc1083bc45058416759a795fcee0d5efa85148a88f7c2e22aed464c1cc8 pigway_frame_flow/web/app.js 81a26552958e5c1132b69054b2ef4e8139c3ab6c6dc81ac179811cd5495a7cf2 pigway_frame_flow/web/app.js
f332fac63f611572597812a06ae0cc266a59e5677efda0d6e9b6925acf9f9309 pigway_frame_flow/web/apple-touch-icon.png f332fac63f611572597812a06ae0cc266a59e5677efda0d6e9b6925acf9f9309 pigway_frame_flow/web/apple-touch-icon.png
d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js
94558dbf524a1068e83283eb814c1b1501512d4b7e75e092b52e9aabfea720e7 pigway_frame_flow/web/favicon.ico 94558dbf524a1068e83283eb814c1b1501512d4b7e75e092b52e9aabfea720e7 pigway_frame_flow/web/favicon.ico
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/favicon.svg 0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/favicon.svg
028a9f2000f0e9787e88d7815545d02d24c9247bff473b0fac54194d58103359 pigway_frame_flow/web/features.js 9eec4fe71cf1750a55bf9d8ebc9a24434c3951e8659dd40267b5184b20dcb37e pigway_frame_flow/web/features.js
a07ea444ab4ac75ea11e1188a431b48195353b1e08f7e80f3b931e8097ea6221 pigway_frame_flow/web/i18n.js a07ea444ab4ac75ea11e1188a431b48195353b1e08f7e80f3b931e8097ea6221 pigway_frame_flow/web/i18n.js
da655fc633ec69e5dfdfbb5c67ae8a878a2df35200a1231035f59edb640c1cf1 pigway_frame_flow/web/index.html 6385ec8b0383fa9bdbb23c4c685c8ef1afd7926fd74c8032811e647808548255 pigway_frame_flow/web/index.html
3d3078b5eb74f3c48369deb1cc654e064dea97689cddff65fd2a44f362164e00 pigway_frame_flow/web/libraries.js
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/logo-symbol.svg 0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/logo-symbol.svg
2ad647cb775eb3a8f665d0f2cf4520557b849bba5ed51acee75bd17cdd1a7c37 pigway_frame_flow/web/style.css 9dcf0419d0292efce8ead7309dc70b4afd27dd24fe82cf9e673682342650f4da pigway_frame_flow/web/source-browser.js
d1e42232c50e3ed23912504997d776e70127f089daba944f514f68c90364c332 pigway_frame_flow/web/style.css
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE
050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js 050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js
1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css 1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css
0ea2cc7da61c89c7505f4a312aad692d52084df32fc279f5742c96c31d84d09e pigway_frame_flow/web/thumbnails.js
5f392f94636d261ae2982815763b794ae9595a1b9226946b792105634ec3f559 pyproject.toml 5f392f94636d261ae2982815763b794ae9595a1b9226946b792105634ec3f559 pyproject.toml
d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock
008af5f4adf616c86e805febc016faad60be0008abf5dddad6e955f1d03606cf scripts/build-verify.sh 008af5f4adf616c86e805febc016faad60be0008abf5dddad6e955f1d03606cf scripts/build-verify.sh
@@ -144,6 +155,7 @@ b0ec44fba0adfceca3655628021683030469be77c9e303c3f5cf830e8fd0cee3 scripts/ci-com
6ebe82488d7eb2f10f69cf8bf4ed4e1ee272a216413fc5be1329f05889ed49a7 scripts/ci-install.py 6ebe82488d7eb2f10f69cf8bf4ed4e1ee272a216413fc5be1329f05889ed49a7 scripts/ci-install.py
eb94843315f0671ca14fac66fc0c6e7380866f28dc822cce14312fa548bb5c82 scripts/create-account.py eb94843315f0671ca14fac66fc0c6e7380866f28dc822cce14312fa548bb5c82 scripts/create-account.py
cdabf30144ea089ae8e64228f6762951f53d4fe8c191673fb5676331d249473d scripts/migrate-photos-api.py cdabf30144ea089ae8e64228f6762951f53d4fe8c191673fb5676331d249473d scripts/migrate-photos-api.py
a39fd668383a948a9fbe1ecb00aebb23f375af52ea4dd3bc7049106925cfebb5 scripts/migrate-source-libraries.py
80813607835c19d321085fac4cabb17d865d98ed62a09b7f45195ac48bec94a8 scripts/package-nas.py 80813607835c19d321085fac4cabb17d865d98ed62a09b7f45195ac48bec94a8 scripts/package-nas.py
b0b877cfbb2bdf830e2e41097c2057538bb9d0b61a3c39952fe505d910cd5c29 scripts/persistence-check.py b0b877cfbb2bdf830e2e41097c2057538bb9d0b61a3c39952fe505d910cd5c29 scripts/persistence-check.py
b7247c5590fe17ae461311f2fbdbeda0daa8a34c0804cef22a314c7ff8f4c389 scripts/production-deploy.py b7247c5590fe17ae461311f2fbdbeda0daa8a34c0804cef22a314c7ff8f4c389 scripts/production-deploy.py
@@ -153,37 +165,31 @@ b7247c5590fe17ae461311f2fbdbeda0daa8a34c0804cef22a314c7ff8f4c389 scripts/produc
1236d3b2d27559cbe73a27dcf56248ef3c60404c6fb21453caa7225570d4ac45 scripts/ui-audit-regressions.cjs 1236d3b2d27559cbe73a27dcf56248ef3c60404c6fb21453caa7225570d4ac45 scripts/ui-audit-regressions.cjs
57a79a534a71442f5e3a70ed9ff96f426007d9f6dd0bb9b1f5d79eac773d2254 scripts/ui-auto-collage.cjs 57a79a534a71442f5e3a70ed9ff96f426007d9f6dd0bb9b1f5d79eac773d2254 scripts/ui-auto-collage.cjs
5b61836a89db45cbd22274b9e0b035283d29d4b4926c05e203bd5da85a3bb270 scripts/ui-copy-audit.cjs 5b61836a89db45cbd22274b9e0b035283d29d4b4926c05e203bd5da85a3bb270 scripts/ui-copy-audit.cjs
6b602f617ba845fb64335ececc90ca3fac46073f13555707652f2f080be73b2e scripts/ui-dashboard-preview.cjs 3374deeef1e7678b02c7aa628a1e8940a7275489a01b8142e5fadf83feca9626 scripts/ui-dashboard-preview.cjs
67b87d9155b74e2591ab459cc785aa4f2ff6e30c0ad6acef297593261f34ed00 scripts/ui-gallery-bootstrap.cjs
f981da8a8b4a043b4d39686feadd0e107e008c4e9e6804c45b92607777d2dbbc scripts/ui-gallery-current.cjs
01991b94630c9b4af9f52d304b3b7e7b121c59e32d5765f778b53d238cbc6c06 scripts/ui-gallery-destinations.cjs
bc47ba7a196d84a155d9df5f0e2e23e892256979faa435e01b0a63816562fa01 scripts/ui-gallery-stars.cjs
8260a11478e23f63bc56c119205a89df04efe7c8a9bfb25b80db24aa56fd302b scripts/ui-history-layout.cjs 8260a11478e23f63bc56c119205a89df04efe7c8a9bfb25b80db24aa56fd302b scripts/ui-history-layout.cjs
86cf401ffb8b78691b6dca0851925af3b3107acfffd6943d78027ca4e9a54df7 scripts/ui-history-localization.cjs 86cf401ffb8b78691b6dca0851925af3b3107acfffd6943d78027ca4e9a54df7 scripts/ui-history-localization.cjs
1f87c8b0c6d403c96bf91ca62b6679d1ccdfa3d8cca3f13a006d73f669fe7b09 scripts/ui-image-loading.cjs 9c91dd27fb13305e5d6b38fc2ab365cc50d0bdf3022afeccebd089d40d50b731 scripts/ui-image-loading.cjs
742197504264a547bcf5f121ed7fae447d8d31f51672d41278b4b984e94b5129 scripts/ui-integrations.cjs 742197504264a547bcf5f121ed7fae447d8d31f51672d41278b4b984e94b5129 scripts/ui-integrations.cjs
66a12251ea36f372072923ee0b8c7bfebac222fe1dbb702b22242a3859512a9a scripts/ui-interval-schedule.cjs 66a12251ea36f372072923ee0b8c7bfebac222fe1dbb702b22242a3859512a9a scripts/ui-interval-schedule.cjs
70837acf16586f2c97e4e0ba30c1de00484b12113caeedda54ddb226ccaad510 scripts/ui-layout-audit.cjs 70837acf16586f2c97e4e0ba30c1de00484b12113caeedda54ddb226ccaad510 scripts/ui-layout-audit.cjs
39969a70bc473ff3f7496490a2096a0de4aaf98e389953b2be4a9bd1fef3f006 scripts/ui-layout.cjs 39969a70bc473ff3f7496490a2096a0de4aaf98e389953b2be4a9bd1fef3f006 scripts/ui-layout.cjs
7c9fc679880d39ef3af262dd43d58835740fdeee45112b848303ee88c15448de scripts/ui-libraries.cjs
b8b6c94cce58efbcb97dd33fc9ce3f3525be1349679f0cf543a6c61af4bd7e6f scripts/ui-login-layout.cjs b8b6c94cce58efbcb97dd33fc9ce3f3525be1349679f0cf543a6c61af4bd7e6f scripts/ui-login-layout.cjs
8574025d0b22f653c58846bdbb81cc10b4a7a7ca515d8e6b462083f6ca6aa39a scripts/ui-official-connection.cjs 8574025d0b22f653c58846bdbb81cc10b4a7a7ca515d8e6b462083f6ca6aa39a scripts/ui-official-connection.cjs
98a03f5fb7aeead34a50ad6c17ee4cd5f2cc61eeb6ca7460652835dc31255447 scripts/ui-photo-people.cjs 98a03f5fb7aeead34a50ad6c17ee4cd5f2cc61eeb6ca7460652835dc31255447 scripts/ui-photo-people.cjs
19ec17efbcef3dd162d3698e0b30d55f4182ae44d2058eb94552d2ef44562e04 scripts/ui-photo-retention.cjs
f721682b12291d232bf727b1c999a7231fb1f4fc5030303baea04ad3b3bfdf2a scripts/ui-photo-sources.cjs
a4ba9a6d5fbfff7aa05d77ce7eab74a6468c1e929a90305a4afa8abbc6c891a0 scripts/ui-photos-api-form.cjs a4ba9a6d5fbfff7aa05d77ce7eab74a6468c1e929a90305a4afa8abbc6c891a0 scripts/ui-photos-api-form.cjs
7047f9f49886abe3c2f08b6b2bfaea35bf9682d4e3aaee773e90c5f3885375fc scripts/ui-photos-api.cjs 7047f9f49886abe3c2f08b6b2bfaea35bf9682d4e3aaee773e90c5f3885375fc scripts/ui-photos-api.cjs
d390c1c9a76ddc167a17bfd7afb0bd1a7169809c8a0b30a26a5be4d18c3032de scripts/ui-progressive-loading.cjs 6c40d6bd4ae1bd3ecbcf9cfa3cad65893350f178399e4464cf138a4cac3bdf4b scripts/ui-smoke.cjs
8eee6d087ae3d65208c423d40623052a9bfbf3db4907299b832e81fbe835fb71 scripts/ui-smoke.cjs
4d1c13a81d28f681aa1401953636d702cecfaed81274b347876c39e09931bb53 scripts/ui-studio-image-loading.cjs 4d1c13a81d28f681aa1401953636d702cecfaed81274b347876c39e09931bb53 scripts/ui-studio-image-loading.cjs
8a1a3ea2b4deb25ac656a0089e085e9837330ce9a386672f302326ac0cb71306 scripts/ui-widgets.cjs 8a1a3ea2b4deb25ac656a0089e085e9837330ce9a386672f302326ac0cb71306 scripts/ui-widgets.cjs
e474740bdb7dbe0617f7c6b170ada918e51f5eed8edbb9c2c47c4590a5dc2359 scripts/verify-photos-api-container.py e474740bdb7dbe0617f7c6b170ada918e51f5eed8edbb9c2c47c4590a5dc2359 scripts/verify-photos-api-container.py
b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify-photos-api.py b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify-photos-api.py
d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py
964a29221c6f7a4185067cb686e1ea003b99ce4609d6fb4ee4bacb70c9fe5ce8 tests/contract.json 771c825ff00147638627451ce90d4b7f8aab0c6844f8187afa7ab618f8dc235e tests/contract.json
86aba7819e70b6ea9372eecc17ab5dfee3aa433e38417ebe81785d3450b4ad0d tests/schema-contract.json 9b3632a93c68c057b2fb276aa4a1b2b514f74934f0183480930d951d91704cae tests/schema-contract.json
23ffc7413f8388648c9d52d3c8f56e2966b73ca1bdc5e209e6c15469c790edba tests/test_accounts.py 23ffc7413f8388648c9d52d3c8f56e2966b73ca1bdc5e209e6c15469c790edba tests/test_accounts.py
e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py
a6edb781f4d081b2db45ffe2b02eed52a10f9b87a70e1e201b5d2f7c6813b93b tests/test_api.py d5bf4f0463a9e369557adf2f440ad401815cb8ffea84af49d106e916eee16501 tests/test_api.py
24fca799b0f5e33c800c6310764b18f6b39fc210be769684171388cf14b95746 tests/test_audit_regressions.py 24fca799b0f5e33c800c6310764b18f6b39fc210be769684171388cf14b95746 tests/test_audit_regressions.py
7001a2209473e6f6bf1050d33212985f63f4844defeb5df8e75d9f2a96c0d83c tests/test_auto_collage.py 7001a2209473e6f6bf1050d33212985f63f4844defeb5df8e75d9f2a96c0d83c tests/test_auto_collage.py
f267ebcdb3cf1fcb01ef9fd9ef7c5c8c4daca11d02543f84bfbe56359f35b78a tests/test_ci_audit.py f267ebcdb3cf1fcb01ef9fd9ef7c5c8c4daca11d02543f84bfbe56359f35b78a tests/test_ci_audit.py
@@ -225,12 +231,16 @@ e0a426eed6f1b6f59207b4de7d8286b6852550bfa28ad30e20f7e911ad9865ee tests/test_ret
ab2cd30163453fe571b55c30cb26dc047a104a536ec01d2c64aa2bc91996ba11 tests/test_scheduler.py ab2cd30163453fe571b55c30cb26dc047a104a536ec01d2c64aa2bc91996ba11 tests/test_scheduler.py
0cccc08adf35a06dbf704eb5c35966bc1c3de0ce39d3014efd7ca751253ce79f tests/test_server_release.py 0cccc08adf35a06dbf704eb5c35966bc1c3de0ce39d3014efd7ca751253ce79f tests/test_server_release.py
a7ae1b4a64bfcbcf2b7ff795b30ba91db24ee2ad7544073dca2bf43d021cd266 tests/test_smoke_dependencies.py a7ae1b4a64bfcbcf2b7ff795b30ba91db24ee2ad7544073dca2bf43d021cd266 tests/test_smoke_dependencies.py
a821fbe5096b2fa5b781423e66673b8d5659b60fc37c5c10470afa2a818659c8 tests/test_source_browser.py
d019a765860f82ee0249d34174c73dc56ca0073765d98e2891938178e80fbc3f tests/test_source_libraries.py
2624fb6ca01f32d93499d7d771144dcaa2850e5f77b2ac3b7b566891b5e9d283 tests/test_synology_photos.py 2624fb6ca01f32d93499d7d771144dcaa2850e5f77b2ac3b7b566891b5e9d283 tests/test_synology_photos.py
ca98469f6a6b8b49c36a00657ea1a81775c3496e1fbdd5e38eb4d5298b5936b4 tests/test_synology_photos_api.py ca98469f6a6b8b49c36a00657ea1a81775c3496e1fbdd5e38eb4d5298b5936b4 tests/test_synology_photos_api.py
a83390e9f580bc27a2928eb6f94d7377307ec46ab45ef71bfea1e5378cb7adb9 tests/test_thumbnail_plans.py
33f05bc6279cf3573493335ae5bb1c91130754b927b52aa5f50e47f2d951d2ae tests/test_upload_reconciliation.py 33f05bc6279cf3573493335ae5bb1c91130754b927b52aa5f50e47f2d951d2ae tests/test_upload_reconciliation.py
bdc3142f9c2291a91a5091b4b98b1addf9569cfb141810024ce6f85309704b9b tests/test_upload_retries.py bdc3142f9c2291a91a5091b4b98b1addf9569cfb141810024ce6f85309704b9b tests/test_upload_retries.py
8ec55ce5706d791a155f13445fe7e0828278e8ee1fa1b527591fdf24cc5b2e86 tests/test_weather.py 8ec55ce5706d791a155f13445fe7e0828278e8ee1fa1b527591fdf24cc5b2e86 tests/test_weather.py
e95eaa1f8a7416c5035ae6deae640b8d5defb2db36c0b8dac6d65bcceedc6750 tests/test_widgets.py e95eaa1f8a7416c5035ae6deae640b8d5defb2db36c0b8dac6d65bcceedc6750 tests/test_widgets.py
2b3ab560a0211e45a7bc7e26104d3d9416c4615fbeccaf3bb5eec4747aaea4cf tests/test_wrist_thumbnail.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 vendor/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 vendor/__init__.py
4cae8606769fdb590c427bc1716a871017bf22d91aabf8a52513bf126cfb847f vendor/guooscar_kokonna.py 4cae8606769fdb590c427bc1716a871017bf22d91aabf8a52513bf126cfb847f vendor/guooscar_kokonna.py
63c7a2f1a6adf98e7fb83a83ae2d6a2dddcecd7bce4d7de25dd6f26bd496019f vendor/guooscar_protocol.py 63c7a2f1a6adf98e7fb83a83ae2d6a2dddcecd7bce4d7de25dd6f26bd496019f vendor/guooscar_protocol.py
+181
View File
@@ -0,0 +1,181 @@
# 商业化审计(2026-10-23)
审计基线:`ed3b61b`,版本 0.1.29。目标定位为单用户、自托管、多终端图片服务;不把多租户、微服务或复杂权限系统作为当前必需项。本次审计不修改业务、不执行设备写入、不删除生产数据。
## 结论
当前“照片来源 → 来源图库 → 多个生成方案 → 终端绑定单个来源及方案”的模型合理,适合继续发展;来源画面、生成版本、上传回执、设备实际显示状态分开管理也是正确的。无需重写技术栈。
当前适合受控内网使用和继续迭代,尚不能据此宣布达到面向外部客户的商业交付标准。主要差距集中在依赖安全、传输安全、上传流程闭环、存储失败一致性和产品表达统一。未发现经过验证的未授权接管或数据外泄事件;也不据此声称不存在其他漏洞。
优先级:P1 为对外交付前处理;P2 为下一轮产品完善;P3 为演进建议。以下“确认”指代码或本地复现已证实,“风险”表示存在可解释路径但没有执行攻击或故障压测。
## 2026-10-24 范围确认与本轮整改
用户确认产品为单用户、内网 IP 访问,接受 HTTP,不使用证书。A02 在此部署范围内是已接受风险,不作为交付阻塞项;未来暴露到不可信网络时重新评估。下文保留原始审计依据,不能将其当作当前全部未修复事项。
本轮对应已确认的改进:依赖升级并重新扫描、图片插件白名单、上传接入来源图库、文件/数据库事务及失败恢复、统一 `images:write` 权限目录、上传/图库术语和设备选择器去重、NAS 空批次继续读取提示、设置页磁盘容量提示、登录 scrypt 不占用数据库锁。补充故障注入及中英文、桌面/移动端流程验证。
本轮不引入 HTTPS、多用户、微服务或新数据库;原审计中的其他商业运营、外部服务条款和扩展能力建议继续作为条件性建议,不声称已完成外部认证。
## 一、对外交付前的问题
### A01 · P1 · 锁定依赖存在新公开安全告警(确认,利用条件需分别判断)
`requirements.lock:4,11,13` 固定 certifi 2026.7.22、opentelemetry-api 1.45.0、Pillow 12.3.0。本次 `pip-audit -r requirements.lock` 返回非零:3 个包、37 条告警,其中 Pillow 35 条。不能把这个数字等同于 37 个可利用的应用漏洞。
已核对公告及代码路径:
- Pillow 的 PNG EXIF 解析复杂度问题关联当前允许的 PNG 输入,以及 `renderers/images.py:30` 后的 `ImageOps.exif_transpose()`、`display/libraries.py:178` 后的 EXIF 读取。文件和像素上限不能充分限制元数据解析耗时。[公告](https://github.com/advisories/GHSA-g4qv-2jxx-769h)
- XBM 解析告警发生于 `Image.open()` 内;当前在打开之后检查格式,不等于阻止识别阶段调用其他插件。建议所有不可信图片入口明确传入允许的 `formats`,再做格式、像素和字节检查。[公告](https://github.com/advisories/GHSA-xgh8-jp6q-p5x8)
- certifi 告警涉及被撤销的中间证书对应信任链,影响使用该 CA 包且攻击者能够干预网络的 TLS 请求;NAS 指纹固定路径不能代表所有外部 HTTPS 路径均受同等保护。[公告](https://github.com/advisories/GHSA-6q48-6c7w-9gw2)
- OpenTelemetry 告警涉及特定 tracestate 解析路径,本次没有证明项目启用了对应的入站解析,不将其写成已证实远程攻击面。[公告](https://github.com/advisories/GHSA-xcq7-p3pf-p88h)
建议:先升级有修复版本且兼容的依赖、重建镜像,再逐条确认剩余公告可达性。扫描建议版本包括 certifi 2026.10.19、OpenTelemetry API 1.45.1、Pillow 13.0.0;不是未经兼容验证的直接升级指令。部分公告没有给出修复版本,需要限定输入路径或记录有依据的暂缓项。验收要求是锁文件与镜像一致、关键渲染回归通过、告警逐条有处置结论。
### A02 · P1 · 当前管理入口使用 HTTP(确认)
实际访问入口使用 HTTP,线上可读取 `/ready`。`core/accounts.py` 根据请求 scheme 决定 Cookie 的 Secure 标志,HTTP 登录不会得到 Secure Cookie。密码、会话和家庭照片在该网络段缺少传输机密性;这是部署安全边界,不能只靠密码哈希解决。
建议商业交付通过 HTTPS 入口服务;明确可信反向代理、转发协议和 Origin 配置,验证登录、CSRF、Cookie Secure、HTTP 跳转后再按域名条件启用 HSTS。不要直接改转发头信任而扩大来源伪造风险。验收包含整个会话的 HTTPS,而不只是登录页。
### A03 · P1 · 手动上传没有接入新图库闭环(确认)
`web/app.js:126` 的“仅保存到本地图库”仍执行设备 upload 操作;成功提示“已保存到服务器,可用于模板与任务”。该资产不等于 `source_images` 中的来源图片,当前来源/终端图库也不是通用 assets 浏览器。移除旧图库后,新用户无法按按钮承诺的位置找回这类图片。
建议采用明确的“手动上传来源”,保存后走现有来源入库及方案流程;或先将入口准确命名为“保存为模板素材”,提供可访问的素材管理入口。不要再引入一个没有生命周期定义的第三套图库。验收:上传、保存、刷新、离开重进、按方案生成、终端使用均能找回同一图片。
### A04 · P1 · 文件与多表数据变更缺乏完整失败一致性(代码确认,故障损失量未测试)
`renderers/images.py:86` 先写文件再插入 assets;`display/libraries.py:166` 入库和 `:310` 生成分别提交资产、来源或版本及溯源记录。`remove_asset():645` 先 unlink,再逐表删除。`storage/database.py:79` 单次 execute 提交,不会自动把整个业务操作合成事务。进程退出、磁盘满、SQL 失败可能留下孤儿文件/记录,删除中断也可能留下指向缺失文件的记录。
建议复用现有 transaction,覆盖一个业务动作的数据库变更;文件采用临时文件及失败补偿,删除流程记录可重试状态。数据库事务不能回滚文件,因此两者都要处理。无需为此引入分布式事务。验收应注入一次写文件失败、一次 SQL 失败及一次删除中断,并验证可恢复或可清理,不误删仍被任务/终端引用的资产。
### A05 · P1(商业使用时)· 外部服务商业授权边界未完成(确认存在约束)
项目 MIT 许可和第三方 NOTICE 已有基础;但 `NOTICE.md` 明确 Open-Meteo 公共 API 按家庭非商业部署使用,商业部署需重新审查服务条款。开源项目许可证不能替代外部天气 API、厂商账号/API 及品牌使用授权。
建议在出售或收费托管前确认所启用服务的商业套餐、额度、品牌说明和数据使用边界;不需要启用的可选服务默认关闭。此项不是法律合规认证,也不意味着现有家庭使用已经违规。
## 二、安全与权限
### A06 · P2 · 图片写入权限无法单独授予(本地复现)
`core/auth.py:14` 的 ALL_SCOPES 缺少 `images:write`,而 `api/app.py:363,672,693` 要求该权限。`extensions/api.py:234` 拒绝未知权限。
隔离 Mock 实测:创建 `images:read` 客户端返回 201;同结构创建 `images:write` 返回 400 / `Unknown permission scope`。管理员可使用接口,但最小权限集成被迫使用更宽权限。这是权限目录与接口契约不一致,不是已证实的越权。
建议统一权限注册表,并测试所有 require(scope) 均可被授权,前端选项与 OpenAPI 使用同一来源。
### A07 · P2 · 登录计算占用共享数据库锁(风险)
`core/accounts.py:110` 附近登录函数在 `with db.lock` 中执行 scrypt,包括不存在的用户名。数据库线程共用 RLock,慢密码计算会阻塞其他需要该锁的操作。已有 IP 和账号失败限流有效降低重复尝试,但不消除并发或分散用户名的资源消耗。
建议将昂贵校验移到锁外,短事务更新尝试次数;必要时限制并发认证数量。验收使用本地少量并发请求,验证正常读取和调度不被明显拖慢。本次未向生产发送压力请求。
### A08 · P2 · 商业账号能力还需补齐(设计差距)
已有安全哈希、登录限流、CSRF、退出和改密撤销会话。会话目前为固定绝对过期(12 小时或记住 30 天),没有空闲过期和会话列表/逐个撤销界面。单用户是明确需求,应保留;可增加“其他设备退出”和可信恢复流程,无需引入用户组织体系。
密码至少 8 位符合当前用户要求。若面向单因素商业登录,应重新确定最低长度、弱密码拦截和可选第二因素,而不是未经确认改现有规则。NIST 当前指导中单因素最低 15 位、多因素中的密码最低 8 位,可作为产品策略参考,不据此宣称项目违法。[NIST](https://pages.nist.gov/800-63-4/sp800-63b.html)
### A09 · P2 · CSP 仍允许 inline script 和外部 CDN(确认)
`api/app.py:227` 允许 script-src unsafe-inline 及 jsDelivr,虽然 Swagger 已本地化。这削弱注入后的防护边界;本次未证明存在 XSS。建议盘点实际 inline 需求后移除多余域名,并逐步使用独立脚本或 nonce;不要未经验证一次性收紧导致界面失效。
## 三、数据模型与架构
### A10 · P2 · 当前名称与生成快照应持续明确区分(已有改善,需约束)
`display/libraries.py:236` 版本列表已集中关联当前方案名称,同时保留生成时 plan 快照、原存储名称及参数一致性标记。方案改名不应篡改历史参数,也不应导致重新生成;参数改变则应产生新版本。这一方向正确。
后续必须保持:关联使用稳定 ID;列表显示当前名称;历史参数明确标注生成时;下载文件名与物理文件名可以不同。建议把这套展示对象复用于图库、详情、任务和下载,避免各页自行读取 payload.name。代码抽查与现有回归不等于对所有历史数据库状态的完整证明。
### A11 · P2 · 数据关系主要依赖应用维护(确认)
SQLite 表存在跨表 ID 引用,但未形成统一外键/迁移版本体系,结构初始化和演进分布在模块中。单实例 SQLite 对当前规模合理;问题是关系变化后,删除/迁移/清理更依赖人工枚举引用。
建议新增结构版本和小型可重复迁移机制,逐步明确来源、方案、版本、资产、任务、回执的删除约束;先检查已有数据再决定增加外键,不强行一次重构所有表。保留用于幂等和当前状态判断的回执,不能因“历史”标签就删除。
### A12 · P2 · 容量与运行边界未形成交付承诺(风险)
进程内锁、单 Uvicorn worker、SQLite 和本地文件适合单实例;不能直接扩为多个副本共享同一目录。缓存有界不代表长期来源资产及多方案版本有界。当前未建立客户可见的存储水位、版本清理规则和满盘处理闭环。
建议先公布单实例支持边界,加入磁盘余量提示和可预览的按引用清理,不需要立即更换数据库或增加队列。商业验收补充典型 NAS 库、并发预览及长任务下的响应时间、峰值内存和满盘行为。
## 四、导航、UI 和关键流程
### A13 · P2 · 一级导航职责需要分组(设计审计)
桌面当前 12 个平级入口将核心操作、创作工具和系统管理混在一起。“来源图库”和“照片数据源”有真实职责差异,但新用户需要先理解;“模板”和“拼版与海报”也缺少关系解释。
建议保留现有页面,按“图库 / 创作 / 运行 / 管理”分组;来源图库负责照片及输出,照片数据源负责 NAS/网盘连接和筛选规则,连接中心负责外部服务凭据。首次流程以“连接来源 → 选入照片 → 方案 → 绑定终端 → 查看结果”串联。避免只改菜单名、不改页面职责。
### A14 · P2 · 全局设备选择器与图库局部终端选择重复(确认,认知风险)
全局 shell 仍选择设备,终端图库又以标签选终端;来源图库不依赖某个终端却同样显示全局设备。手表无传输能力时,“设备/相框/终端”容易混用。
建议只在依赖目标设备的页面显示全局目标,或统一由终端图库标签控制,并明确设备与手表输出档案的能力区别。所有按钮都依据能力显示状态和原因;“已生成、已上传、已在终端显示”继续分开,不能将操作成功等同于实物屏幕已显示。
### A15 · P2 · 文案和本地图库旧概念仍残留(确认)
`web/app.js` 上传页和 `web/i18n.js` 的 staging、uploadNotice 等仍表达“本地图库”,但当前用户模型已转为来源图库/终端图库。多个词典覆盖和页面内 tr() 分散维护,增加中英文错位的机会。
建议统一术语:照片数据源、来源图片、生成方案、成品版本、终端;为保存、生成、上传、显示分别定义动作词。将重复文案收敛到现有词典,不必更换前端框架。验收覆盖中英文按钮、禁用原因、错误提示、空状态、确认框和帮助文本。
### A16 · P2 · NAS 分页筛选不是完整全库索引搜索(确认)
`providers/source_browser.py:196` 每次最多读取 4 批,每批默认 36 条,再对多项条件本地筛选;人物等可利用提供方能力。这个设计保护响应时间,但筛选后当前页为空并不等于整个 NAS 没有符合结果;也没有准确的匹配总数。
建议明确“本批未找到,可继续扫描”,显示已扫描量和继续状态;需要全库快速定位时优先使用提供方原生搜索能力,仅在需求明确且测量后考虑持久索引。不能为了准确总数回到每次扫描整库。验收至少包括跨多批才命中的筛选、空批后继续、筛选变化重置游标和重复/漏项检查。
### A17 · P2 · 移动端与无障碍覆盖还不够完整(部分验证)
本次在 1440×1000、390×844 打开来源图库、终端图库、上传、设置页面,未发现 document 横向溢出;这些页面 main 内 input/select 的关联标签抽查无缺失。截图确认基础配色、圆角和按钮体系统一,移动端会将导航变为底部条。
但上述仅为隔离 Mock 的部分页面,其中来源图库为空状态,不是全部真实卡片/数据/错误状态验证。移动导航入口较多,发现性与滚动提示还需用户验证;未完成屏幕阅读器、全键盘任务链、对比度量测、长方案名和全部弹窗矩阵。不能据此声称 WCAG 合规。建议保留当前视觉系统,补任务级测试和可见焦点,不做纯审美重绘。
## 五、交付、运维、隐私和维护
### A18 · P2 · 运维基础有,但缺少客户服务级证据(部分确认)
已有 /health、/ready;/ready 检查 SQLite、设备配置、调度器和认证,不能误称只有进程存活检查。当前线上四项均正常。还需客户可理解的磁盘/供应商不可用提示、脱敏诊断导出、关键任务失败通知和明确支持边界。
代码和版本可恢复不等于数据库、凭据、设备绑定和方案数据可恢复。当前遵循项目“不保留备份”的明确运维选择,本次不创建或修改备份策略;商业交付前需明确该选择对应的数据丢失承诺与恢复责任。
### A19 · P2 · 隐私和数据生命周期需要面向用户表达(交付差距)
照片、人物名称、日期位置以及 NAS/厂商账号均属于需要清晰说明的数据。应说明哪些数据留本地、哪些请求发给哪个服务、预览与源文件的区别、删除是否涉及 NAS 原文件/设备云端、保留期限及账号撤销方式。已有 SSRF、凭据脱敏和证书指纹措施有价值,但不能替代用户可理解的说明。本次未作法律合规认证或外部供应商全面审计。
### A20 · P3 · 前端全局状态与大型模块增加改动风险(确认结构,非重写理由)
`web/app.js`、`web/libraries.js`、词典和追加 CSS 依赖共享 state/全局函数,部分页面模板为很长单行;后端 app/service 也承载较多职责。现有按领域拆分 providers/renderers/display/extensions 已有良好基础。
建议随实际功能修改逐步抽取共享展示对象、API 包装、确认框和状态枚举,合并重复 CSS/词条;不要为了“商业化”一次迁移框架或拆微服务。保留真正使用的旧任务兼容路径,依据路由、调用和测试证据删代码,不能仅凭无导航入口判定整个内部模块无用。
## 已有基础与不应误判之处
- scrypt 使用随机盐、常量时间比较;当前 N=2^14,r=8,p=5 属于 OWASP 列出的可接受配置之一,不应只看 N 就判定哈希不合格。[OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
- Cookie HttpOnly/SameSite Strict、浏览器 CSRF 校验、登录来源检查、账号/IP 限流、令牌哈希和改密撤销会话已有实现;仍应通过 HTTPS 完成传输保护。[会话建议](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
- 图片 URL 有 HTTPS、允许域名、私网地址及 DNS 连接约束;NAS 凭据传输有证书指纹校验;已有字节/像素限制。依赖解析漏洞说明这些措施仍需分层补强,而非这些措施全部无效。
- 真实设备写保护、幂等和操作结果未知状态、引用删除保护、生成快照以及当前显示状态核对值得保留。
- 单用户、单实例和 SQLite 与当前产品定位匹配;不需要为了审计引入租户、计费、角色矩阵或分布式架构。
- MIT、NOTICE、字体和 Swagger 等第三方声明已存在,不能写成“项目没有许可证”。
## 建议整改顺序与验收
1. 安全基线:A01 依赖与图片插件入口、A02 HTTPS;完善基于可达性的告警处置。
2. 核心业务:A03 上传找回与生成闭环、A04 原子性/补偿、A06 权限目录一致性。
3. 产品统一:A10 当前引用/快照契约、A13–A16 导航、目标设备、术语、分页语义;统一覆盖页面与任务。
4. 交付完善:A07–A09 认证资源和会话、A11–A12 迁移及容量、A17–A19 可访问性和运维隐私;确认启用外部服务商业条款。
最低商业交付验收:新用户能完成连接来源至终端使用全过程;改名/改参数/解绑/删除无引用错乱;图片失败和满盘不产生不可解释状态;HTTPS 会话与最小权限 API 可用;供应商失败、未知写结果和恢复责任有明确界面与说明。
## 本次验证及限制
- 代码审读覆盖认证/授权、请求中间件、存储、图库/方案、图片处理、NAS 分页、主要 UI、部署及许可证声明。
- 隔离 Mock 复现权限目录缺陷;Playwright 检查 4 页面×2 尺寸并查看实际截图,未修改生产配置。
- 执行全量 pytest 与依赖扫描,结果见文末验证记录。没有运行恶意图片、生产压测、外网渗透、硬件显示验收或商业法律审查。
- 本报告为基于当前代码与抽查的工程审计,不是穷尽证明、安全认证或所有页面及业务组合的完整覆盖。
验证记录:pytest **665 passed(124.93s)**;依赖扫描 **37 条告警/3 个锁定包,未通过**;Mock 权限测试返回 **201/400**;8 个页面尺寸组合未发现横向溢出;线上 `/ready` 返回 ready,4 项检查为 true;`git diff --check` 通过。测试通过不抵消安全扫描失败。仅提交本审计文档,无业务修复、依赖升级、部署或正式发布。
+2
View File
@@ -1,5 +1,7 @@
# Synology NAS deployment / 群晖部署 # Synology NAS deployment / 群晖部署
当前生产服务已迁至 Ubuntu Server(参见 `scripts/server-release.py`)。以下内容仅适用于自行选择群晖容器部署的安装,不代表当前生产环境。
Target: DS923+, DSM 7.4.1 (verified on target; originally specified 7.3.1), AMD64, Container Manager. Target: DS923+, DSM 7.4.1 (verified on target; originally specified 7.3.1), AMD64, Container Manager.
Directory: /volume2/docker/pigway-frame-flow. Default published port: 6005. Directory: /volume2/docker/pigway-frame-flow. Default published port: 6005.
The container still listens on 18787; Compose maps host 6005 to container 18787. The container still listens on 18787; Compose maps host 6005 to container 18787.
+1
View File
@@ -2,6 +2,7 @@
安装、连接及日常使用从[中文首页](../README.zh-CN.md)或[英文首页](../README.md)开始。本目录收录仍适用的功能、配置与开发说明;版本变化见[版本记录](../RELEASE_NOTES.md)。 安装、连接及日常使用从[中文首页](../README.zh-CN.md)或[英文首页](../README.md)开始。本目录收录仍适用的功能、配置与开发说明;版本变化见[版本记录](../RELEASE_NOTES.md)。
- [来源图库、生成方案与终端](SOURCE_LIBRARIES.md)
- [自动拼图](AUTOMATIC_COLLAGE.md) - [自动拼图](AUTOMATIC_COLLAGE.md)
- [构建、缓存与 CI](BUILDING.md) - [构建、缓存与 CI](BUILDING.md)
- [可选连接与扩展](EXTENSIONS.md) - [可选连接与扩展](EXTENSIONS.md)
+60
View File
@@ -0,0 +1,60 @@
# 来源图库、生成方案与终端
平台图库分为两类:来源图库与终端图库。照片数据源的每条配置对应一个来源图库,可以建立多个。每个来源图库拥有自己的生成方案,方案不能跨来源移动。每个终端最多绑定一个来源图库及其中一个方案;同一来源、同一方案允许多个终端共用。未完成绑定的终端不参与来源统一输出。
## 使用顺序
1. 在“照片数据源”配置来源、人物、分辨率、质量、去重等选图条件。
2. 在侧栏“来源图库”选择来源,选入照片,保存无文字叠加的源画面。
3. 新增该来源内的生成方案,可使用相框横版、竖版、手表预设,或自定义尺寸、适配方式、裁切位置、质量、色彩与说明叠加。
4. 来源图库标签为“源画面”和该来源的生成方案;未生成的版本明确显示未生成,生成后可预览、下载、查看参数快照、删除。归档方案仍可查看已有版本。
5. 在“终端图库”对每个终端单选来源和方案。切换来源后必须重新选择方案,服务端检查归属和屏幕尺寸。绑定同一方案的终端共用生成文件。
6. 需要自动化时,为照片任务勾选“按来源图库统一生成并推送”。新建照片任务默认使用该模式;旧任务不在启动时自动改写,确认绑定后可在编辑任务时切换。唯一明确的单来源、单照片任务也可使用 `scripts/migrate-source-libraries.py` 预检,再加 `--apply` 迁移;脚本保留启用状态、调度配置和下次执行时间,不推送设备。若当前设备有可靠的照片来源回执,会选入该画面并仅在本地生成两个版本。生成模式不再额外选择旧版小图方案。
## 从来源选入照片
“从来源选入照片”打开独立选图页面,使用照片卡片、按需预览和前后翻页。Synology Photos 每批显示最多 36 张照片,直接按偏移量读取 NAS 元数据,不再先遍历整个目录。一次请求最多检查四批;筛选较少见的照片时,当前批可能不足 36 张或为空,仍可继续下一页检索。页码表示当前扫描位置,不提供未经全库扫描的虚假总数;NAS 内容变动时可重置筛选重新浏览。
可按文件名、文件夹关键字、文件大小、像素宽高、日期范围及识别人物组合筛选。保留来源本身的目录、递归、人物、方向、最小分辨率等限制。日期使用系统时区:入库日期取 NAS `indexed_time`,照片日期取 `time`,不将入库时间冒充原文件创建时间。人物数据使用 NAS 现有识别结果,不执行新的人脸识别。
预览懒加载 NAS 中图(`m`),选入时才下载用于本地生成的 XL 画面。已浏览的选择记录缓存最多 2048 条、有效期 30 分钟,并绑定来源配置和 NAS 会话;来源范围或登录账号改变后不能复用旧预览。选入会使用已校验的页面记录,避免再次全库读取和遍历所有人物相册。其他云盘继续使用现有目录接口,高级筛选与快速分页仅支持 Synology Photos。
## 一次选图,多种版本
任务先选定一次源画面并保存到本地,再按来源的终端绑定生成版本。默认等比裁切铺满,不拉伸、不补黑边。主动选择完整画面时必要处补白;说明叠加使用来源提供的日期、标题、地点等元数据。源画面本身始终不加文字。Synology Photos 当前取得的是可用源预览,不称为原图;本地存储按画面原尺寸归一化为 JPEG 并处理 EXIF 朝向。
每份成品关联源画面、方案 ID 和生成时的完整参数快照。来源标签、终端绑定、成品卡片及下载名称均通过方案 ID 读取当前名称;生成参数窗口单独标示生成时的名称和原始文件名。仅改名不影响像素,不重复生成,也不改写历史快照和操作回执。方案渲染参数变更后,旧成品显示旧参数提示;重新生成才产生新版本。相同源画面和相同渲染参数复用已有版本(名称及来源名称不计入渲染版本);修改方案后重新生成产生新版本,不覆盖历史图片。任务持久保存选图和方案快照,进程中断后继续处理该画面,不重新随机选图或重复提交同一终端输出。
相框通过现有上传队列和设备确认流程传输;“已生成”“待推送”“等待设备确认”“已确认显示”分别记录。来源任务完成代表生成和提交完成,不代表所有终端已显示;具体结果看终端图库的独立操作。终端当前画面已同步确认且关联到该成品时,推送按钮显示“已在终端显示”并禁用;仅有历史推送成功记录不足以认定当前仍在显示,离线或同步状态未知时也不作该判断。手表当前只生成 410×502 成品,传输尚未接入。某个方案生成失败不撤销其他输出,源画面保留,可以单独重新生成或重新推送失败目标。
在来源图库手动点击“随机选入并按绑定生成”只生成,不自动推送。终端图库的相框推送需要明确点击并确认,受全局设备写入开关约束。更换终端绑定后,尚未执行的旧版本推送会在设备写入前重新校验,不符合当前绑定时停止。
## 保留与删除
来源文件放在 `data/source-images/`,版本放在 `data/renditions/`,不进入旧的临时缓存自动清理范围。来源图库的星标与不再选用沿用照片来源偏好,二者互斥;不再选用只影响后续自动选图。
删除本地源画面会同时删除其本地生成版本,执行前检查任务、模板和待处理操作的引用;不会删除上游照片或设备已接收的副本。删除方案采用归档,历史版本保留快照;被终端绑定的方案必须先解除或更换绑定。包含绑定、活动方案或本地源画面的来源配置禁止直接删除。
旧本地成品、旧小图及现有云端图片数据保留,但旧图库页面和入口已退役。来源图库与终端图库只展示按新模型关联的图片,不凭文件名或尺寸将旧成品猜测为源画面。照片数据源配置、任务执行及已有小图任务的兼容接口继续保留;新任务使用来源内生成方案和终端绑定。
## API 与存储
- `/api/v1/source-libraries`:来源图库;`/{id}/images`、`/import`、`/run` 用于列表、选入与统一生成。
- `/api/v1/source-libraries/{id}/browse`:按批浏览 NAS 元数据;`/browse/{item_id}/preview` 读取已浏览照片的小预览。
- `/api/v1/generation-plans`:按 `source_id` 查询与创建方案;`/{id}` 修改或归档。
- `/api/v1/source-images/{id}`:源画面删除;`/generate`、`/preview`、`/star`、`/exclusion` 用于版本生成、预览和来源偏好。
- `/api/v1/library-terminals`:终端;`/{id}/binding` 管理单一绑定,`/{id}/images` 查询成品,`/{id}/images/{asset_id}/send` 显式推送。
- `/api/v1/library-versions/{asset_id}`:删除本地成品版本。
- `PUT /api/v1/jobs/{job_id}/library-mode`:使用 `jobs:write` 切换模式,保留下次执行时间和启用状态。
新增 `library_plans`、`source_images`、`library_versions`、`terminal_bindings`、`terminal_deliveries` 表,复用现有 SQLite、资源鉴权、Pillow 渲染及任务队列。`terminal_bindings.terminal_id` 是主键,保证终端仅有一个绑定;服务端同步校验来源、方案、版本归属以及屏幕尺寸。读取需要 `images:read`,管理需要 `admin`,本地删除需要 `images:delete`,终端推送需要 `display:write`。旧 API 保留兼容。
## 手动上传与本地存储
“上传图片”可将无叠加的源画面保存到所选来源图库;默认“手动上传”来源在首次成功上传时创建,不需要 NAS 连接。保存后通过“查看来源图库”找回图片,再建立方案和终端绑定。手动来源的随机选图及任务沿用同一套来源流程,不创建另一套图库。上传到已有来源图库只保存本地副本,不写回 NAS 或网盘。源画面按输入像素尺寸及 EXIF 朝向归一化,预览的相框裁切、色彩不会改变保存的源画面;实际输出由生成方案决定。相同内容在同一来源重复保存会复用图片。
本地上传使用 `POST /api/v1/source-libraries/{id}/upload`,链接导入使用 `/upload-url`,二者要求 `images:write`,保留图片大小、格式及 URL 安全限制。`manual-uploads` 是内建手动来源的保留 ID;设置页管理本地存储,照片数据源页管理远程连接。
资产写入及相关来源/版本记录共享数据库事务。文件先落临时文件并同步后再提交资产;未完成创建和已提交删除通过持久化文件记录在启动/维护时恢复清理。缓存转为持久成品时也保留可恢复的源/目标状态。删除不会在数据库事务成功前移除图片文件;清理失败会留下可重试记录,设置页显示待清理数量。清理记录不是照片备份,不会保留已删除图片供恢复。
设置页显示数据盘总容量和可用容量;可用低于 512 MiB 或总容量的 5% 时提示空间不足。容量提示不自动开启清理策略,也不自动删除来源图片或成品。
+5661 -5
View File
File diff suppressed because it is too large Load Diff
+61 -23
View File
@@ -141,6 +141,12 @@ def create_app(settings=None, start_worker=True):
install_extensions(app, service) install_extensions(app, service)
install_accounts(app, db) install_accounts(app, db)
install_history(app, service) install_history(app, service)
from pigway_frame_flow.api.thumbnails import install_thumbnails
install_thumbnails(app, service)
from pigway_frame_flow.api.libraries import install_libraries
install_libraries(app, service)
app.state.service = service app.state.service = service
app.state.worker_enabled = start_worker app.state.worker_enabled = start_worker
app.state.scheduler = scheduler app.state.scheduler = scheduler
@@ -419,7 +425,9 @@ def create_app(settings=None, start_worker=True):
from PIL import Image from PIL import Image
try: try:
with Image.open(BytesIO(data)) as source: with Image.open(
BytesIO(data), formats=("JPEG", "PNG", "WEBP")
) as source:
if source.width * source.height > 25_000_000: if source.width * source.height > 25_000_000:
raise ValueError() raise ValueError()
if source.format not in {"JPEG", "PNG", "WEBP"}: if source.format not in {"JPEG", "PNG", "WEBP"}:
@@ -432,13 +440,13 @@ def create_app(settings=None, start_worker=True):
thumb = cached.with_name(cached.stem + "-thumb.jpg") thumb = cached.with_name(cached.stem + "-thumb.jpg")
from PIL import Image from PIL import Image
with Image.open(cached) as img: with Image.open(cached, formats=("JPEG", "PNG", "WEBP")) as img:
# The 430px cards need up to 860px on 2x displays. Replace the # The 430px cards need up to 860px on 2x displays. Replace the
# existing cache in place so old 300px previews are upgraded. # existing cache in place so old 300px previews are upgraded.
expected_edge = min(860, max(img.size)) expected_edge = min(860, max(img.size))
regenerate = not thumb.exists() regenerate = not thumb.exists()
if not regenerate: if not regenerate:
with Image.open(thumb) as existing: with Image.open(thumb, formats=("JPEG", "PNG", "WEBP")) as existing:
regenerate = max(existing.size) != expected_edge regenerate = max(existing.size) != expected_edge
if regenerate: if regenerate:
img.thumbnail((860, 860), Image.Resampling.LANCZOS) img.thumbnail((860, 860), Image.Resampling.LANCZOS)
@@ -446,7 +454,7 @@ def create_app(settings=None, start_worker=True):
return FileResponse(thumb, media_type="image/jpeg") return FileResponse(thumb, media_type="image/jpeg")
from PIL import Image from PIL import Image
with Image.open(cached) as original: with Image.open(cached, formats=("JPEG", "PNG", "WEBP")) as original:
media_type = Image.MIME.get( media_type = Image.MIME.get(
original.format or "", "application/octet-stream" original.format or "", "application/octet-stream"
) )
@@ -569,15 +577,15 @@ def create_app(settings=None, start_worker=True):
if len(data) > settings.max_bytes: if len(data) > settings.max_bytes:
raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB") raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB")
name = Path(file.filename or "image").name[:120] name = Path(file.filename or "image").name[:120]
asset = assets.put( with assets.creating(
await run_in_threadpool(convert, data, fit, profile), await run_in_threadpool(convert, data, fit, profile),
settings.sanitize(name), settings.sanitize(name),
kind="cache", kind="cache",
) ) as asset:
db.execute( db.execute(
"INSERT INTO asset_metadata(asset_id,origin) VALUES(?,?)", "INSERT INTO asset_metadata(asset_id,origin) VALUES(?,?)",
(asset["asset_id"], "manual"), (asset["asset_id"], "manual"),
) )
return assets.info(asset["asset_id"]) return assets.info(asset["asset_id"])
@app.get( @app.get(
@@ -598,7 +606,8 @@ def create_app(settings=None, start_worker=True):
return [ return [
assets.info(r["id"]) assets.info(r["id"])
for r in db.all( for r in db.all(
"SELECT assets.id FROM assets LEFT JOIN asset_metadata ON asset_metadata.asset_id=assets.id WHERE kind!='cache' AND " "SELECT assets.id FROM assets LEFT JOIN asset_metadata ON asset_metadata.asset_id=assets.id WHERE kind NOT IN ('cache','source-images','renditions') "
"AND NOT EXISTS (SELECT 1 FROM asset_sources s WHERE s.asset_id=assets.id AND json_extract(s.payload,'$.type')='thumbnail') AND "
"(?='all' OR (?='uploaded' AND kind='images') OR (?='generated' AND kind='generated')) " "(?='all' OR (?='uploaded' AND kind='images') OR (?='generated' AND kind='generated')) "
"AND (?='all' OR COALESCE(origin,'unknown')=?) AND (? IS NULL OR job_id=?) " "AND (?='all' OR COALESCE(origin,'unknown')=?) AND (? IS NULL OR job_id=?) "
"ORDER BY created_at DESC LIMIT ? OFFSET ?", "ORDER BY created_at DESC LIMIT ? OFFSET ?",
@@ -613,7 +622,14 @@ def create_app(settings=None, start_worker=True):
dependencies=[Depends(require("images:delete"))], dependencies=[Depends(require("images:delete"))],
) )
def delete_asset(asset_id: str): def delete_asset(asset_id: str):
path = assets.path(asset_id) assets.path(asset_id)
if db.one(
"SELECT id FROM assets WHERE id=? AND kind IN ('source-images','renditions')",
(asset_id,),
):
raise FlowError(
E.CONFLICT, "Delete library images through their source library", 409
)
reference = "%" + asset_id + "%" reference = "%" + asset_id + "%"
if ( if (
db.one( db.one(
@@ -631,14 +647,17 @@ def create_app(settings=None, start_worker=True):
"Asset is referenced by a task, template or pending operation", "Asset is referenced by a task, template or pending operation",
409, 409,
) )
path.unlink(missing_ok=True) service.libraries.remove_asset(asset_id)
db.execute( for row in db.all(
"DELETE FROM photo_preferences WHERE source_id='@assets' AND item_id=?", "SELECT key,payload FROM app_config WHERE key LIKE 'wrist-thumbnail:%'"
(asset_id,), ):
) import json
db.execute("DELETE FROM asset_metadata WHERE asset_id=?", (asset_id,))
db.execute("DELETE FROM asset_sources WHERE asset_id=?", (asset_id,)) if json.loads(row["payload"]).get("asset_id") == asset_id:
db.execute("DELETE FROM assets WHERE id=?", (asset_id,)) db.execute(
"DELETE FROM app_config WHERE key=? AND payload=?",
(row["key"], row["payload"]),
)
return Response(status_code=204) return Response(status_code=204)
@app.put( @app.put(
@@ -785,7 +804,13 @@ def create_app(settings=None, start_worker=True):
dependencies=[Depends(require("admin"))], dependencies=[Depends(require("admin"))],
) )
def delete_photo_source(source_id: str): def delete_photo_source(source_id: str):
service.photo_sources.delete(source_id) with service.lock:
service.libraries.check_source_delete(source_id)
service.photo_sources.delete(source_id)
db.execute(
"DELETE FROM library_plans WHERE source_id=? AND archived=1",
(source_id,),
)
return Response(status_code=204) return Response(status_code=204)
@app.get( @app.get(
@@ -986,7 +1011,8 @@ def create_app(settings=None, start_worker=True):
dependencies=[Depends(require("jobs:write"))], dependencies=[Depends(require("jobs:write"))],
) )
def create_job(payload: JobInput): def create_job(payload: JobInput):
return scheduler.save_job(payload) with service.lock:
return scheduler.save_job(payload)
@app.put( @app.put(
"/api/v1/jobs/{job_id}", "/api/v1/jobs/{job_id}",
@@ -997,7 +1023,8 @@ def create_app(settings=None, start_worker=True):
def update_job(job_id: str, payload: JobInput): def update_job(job_id: str, payload: JobInput):
if not db.one("SELECT id FROM jobs WHERE id=?", (job_id,)): if not db.one("SELECT id FROM jobs WHERE id=?", (job_id,)):
raise FlowError(E.NOT_FOUND, "Job not found", 404) raise FlowError(E.NOT_FOUND, "Job not found", 404)
return scheduler.save_job(payload, job_id) with service.lock:
return scheduler.save_job(payload, job_id)
@app.delete( @app.delete(
"/api/v1/jobs/{job_id}", "/api/v1/jobs/{job_id}",
@@ -1090,6 +1117,14 @@ def create_app(settings=None, start_worker=True):
or bool(settings.api_key) or bool(settings.api_key)
or any(registry.keys), or any(registry.keys),
), ),
GalleryDestination(
id="watch",
adapter="watch-thumbnails",
name="Watch",
storage="local",
connected=True,
supports_upload=True,
),
GalleryDestination( GalleryDestination(
id="local", id="local",
adapter="local-assets", adapter="local-assets",
@@ -1366,6 +1401,9 @@ def create_app(settings=None, start_worker=True):
def static(filename: str): def static(filename: str):
if filename not in [ if filename not in [
"app.js", "app.js",
"thumbnails.js",
"libraries.js",
"source-browser.js",
"features.js", "features.js",
"extensions.js", "extensions.js",
"style.css", "style.css",
+380
View File
@@ -0,0 +1,380 @@
import hashlib
from fastapi import Depends, File, Query, Request, UploadFile
from fastapi.responses import Response
from pydantic import Field
from pigway_frame_flow.core.auth import require
from pigway_frame_flow.core.photo_preferences import set_flags
from pigway_frame_flow.display.libraries import GenerationPlan
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import ImageExclusion, ImageStar, StrictModel
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
from pigway_frame_flow.providers.source_browser import BrowseFilters
class BindingInput(StrictModel):
source_id: str = Field(min_length=1, max_length=120)
plan_id: str = Field(pattern=r"^[a-f0-9]{32}$")
class UploadURLInput(StrictModel):
url: str = Field(min_length=1, max_length=2048)
class ImportInput(StrictModel):
item_id: str = Field(min_length=1, max_length=2048)
class RunInput(StrictModel):
item_id: str | None = Field(default=None, min_length=1, max_length=2048)
deliver: bool = False
class LibraryModeInput(StrictModel):
enabled: bool
class GenerateInput(StrictModel):
plan_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$")
def install_libraries(app, service):
libraries, db = service.libraries, service.db
@app.put(
"/api/v1/jobs/{job_id}/library-mode",
dependencies=[Depends(require("jobs:write"))],
)
def set_mode(job_id: str, payload: LibraryModeInput):
return app.state.scheduler.set_library_mode(job_id, payload.enabled)
@app.get("/api/v1/source-libraries", dependencies=[Depends(require("images:read"))])
def sources():
return [
{
**source,
"image_count": db.one(
"SELECT COUNT(*) AS n FROM source_images WHERE source_id=?",
(source["id"],),
)["n"],
}
for source in service.photo_sources.list()
]
def save_upload(source_id, data, name):
if len(data) > service.settings.max_bytes:
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
with service.lock:
if source_id == "manual-uploads" and not db.one(
"SELECT key FROM app_config WHERE key='photo_source:manual-uploads'"
):
from pigway_frame_flow.renderers.images import convert
convert(data, size=(1, 1))
service.photo_sources.save(
source_id,
PhotoSourceInput(name="手动上传", kind="uploads", location="."),
)
if not service.photo_sources.get(source_id).enabled:
raise FlowError(E.CONFLICT, "Photo source is disabled", 409)
return libraries.ingest(
source_id, "upload:" + hashlib.sha256(data).hexdigest(), data, name, {}
)
@app.post(
"/api/v1/source-libraries/{source_id}/upload",
dependencies=[Depends(require("images:write"))],
)
def upload(source_id: str, file: UploadFile = File(...)):
return save_upload(
source_id,
file.file.read(service.settings.max_bytes + 1),
file.filename or "upload.jpg",
)
@app.post(
"/api/v1/source-libraries/{source_id}/upload-url",
dependencies=[Depends(require("images:write"))],
)
def upload_url(source_id: str, payload: UploadURLInput):
return save_upload(
source_id, service.assets.fetch_url(payload.url), "url-upload.jpg"
)
@app.get(
"/api/v1/source-libraries/{source_id}/images",
dependencies=[Depends(require("images:read"))],
)
def pictures(
source_id: str,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
):
service.photo_sources.get(source_id)
rows = db.all(
"SELECT id FROM source_images WHERE source_id=? ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?",
(source_id, limit, offset),
)
return {
"items": [libraries.picture(row["id"]) for row in rows],
"offset": offset,
"limit": limit,
"total": db.one(
"SELECT COUNT(*) AS n FROM source_images WHERE source_id=?",
(source_id,),
)["n"],
}
@app.get(
"/api/v1/source-libraries/{source_id}/browse",
dependencies=[Depends(require("images:read"))],
)
def browse(
source_id: str,
filters: BrowseFilters = Query(),
):
return service.photo_sources.browser.page(
source_id, filters.offset, filters.limit, filters
)
@app.get(
"/api/v1/source-libraries/{source_id}/browse/{item_id}/preview",
dependencies=[Depends(require("images:read"))],
)
def browse_preview(source_id: str, item_id: str):
data = service.photo_sources.browser.preview(source_id, item_id)
return Response(
data,
media_type="image/jpeg",
headers={"Cache-Control": "private, no-store"},
)
@app.post(
"/api/v1/source-libraries/{source_id}/import",
dependencies=[Depends(require("admin"))],
)
def import_picture(source_id: str, payload: ImportInput):
return libraries.import_picture(source_id, payload.item_id)
@app.post(
"/api/v1/source-libraries/{source_id}/run",
dependencies=[Depends(require("admin"))],
)
def run(source_id: str, payload: RunInput, request: Request):
with service.lock:
return libraries.pipeline(
source_id,
payload.item_id,
deliver=payload.deliver,
caller=request.state.client_id,
)
@app.get("/api/v1/generation-plans", dependencies=[Depends(require("images:read"))])
def plans(source_id: str):
return libraries.plans(source_id)
@app.post("/api/v1/generation-plans", dependencies=[Depends(require("admin"))])
def create_plan(payload: GenerationPlan):
return libraries.save_plan(payload)
@app.put(
"/api/v1/generation-plans/{plan_id}", dependencies=[Depends(require("admin"))]
)
def update_plan(plan_id: str, payload: GenerationPlan):
return libraries.save_plan(payload, plan_id)
@app.delete(
"/api/v1/generation-plans/{plan_id}",
status_code=204,
dependencies=[Depends(require("admin"))],
)
def delete_plan(plan_id: str):
libraries.archive(plan_id)
@app.post(
"/api/v1/source-images/{picture_id}/generate",
dependencies=[Depends(require("admin"))],
)
def generate(picture_id: str, payload: GenerateInput):
with service.lock:
picture = libraries.picture(picture_id)
ids = (
[payload.plan_id]
if payload.plan_id
else [
p["id"]
for p in libraries.plans(picture["source_id"])
if not p["archived"]
]
)
return [libraries.generate(picture_id, plan_id) for plan_id in ids]
@app.post(
"/api/v1/source-images/{picture_id}/preview",
dependencies=[Depends(require("images:read"))],
)
def preview(picture_id: str, payload: GenerationPlan):
picture = libraries.picture(picture_id)
if picture["source_id"] != payload.source_id:
raise FlowError(
E.CONFLICT, "Picture and plan must share a source library", 409
)
data = libraries.render(
service.assets.path(picture_id).read_bytes(), picture["caption"], payload
)
return Response(
data, media_type="image/jpeg", headers={"Cache-Control": "no-store"}
)
@app.put(
"/api/v1/source-images/{picture_id}/star",
dependencies=[Depends(require("images:write"))],
)
def star(picture_id: str, payload: ImageStar):
picture = libraries.picture(picture_id)
return set_flags(
db, picture["source_id"], picture["item_id"], starred=payload.starred
)
@app.put(
"/api/v1/source-images/{picture_id}/exclusion",
dependencies=[Depends(require("images:write"))],
)
def exclude(picture_id: str, payload: ImageExclusion):
picture = libraries.picture(picture_id)
return set_flags(
db, picture["source_id"], picture["item_id"], excluded=payload.excluded
)
@app.delete(
"/api/v1/source-images/{picture_id}",
status_code=204,
dependencies=[Depends(require("images:delete"))],
)
def delete_picture(picture_id: str):
libraries.delete_picture(picture_id)
@app.delete(
"/api/v1/library-versions/{asset_id}",
status_code=204,
dependencies=[Depends(require("images:delete"))],
)
def delete_version(asset_id: str):
with service.lock:
if not db.one(
"SELECT asset_id FROM library_versions WHERE asset_id=?", (asset_id,)
):
raise FlowError(E.NOT_FOUND, "Generated image not found", 404)
libraries.check_asset_references(asset_id)
libraries.remove_asset(asset_id)
@app.get(
"/api/v1/library-terminals", dependencies=[Depends(require("images:read"))]
)
def terminals():
return libraries.terminals()
@app.put(
"/api/v1/library-terminals/{terminal_id}/binding",
dependencies=[Depends(require("admin"))],
)
def bind(terminal_id: str, payload: BindingInput):
return libraries.bind(terminal_id, payload.source_id, payload.plan_id)
@app.delete(
"/api/v1/library-terminals/{terminal_id}/binding",
status_code=204,
dependencies=[Depends(require("admin"))],
)
def unbind(terminal_id: str):
with service.lock:
db.execute(
"DELETE FROM terminal_bindings WHERE terminal_id=?", (terminal_id,)
)
@app.get(
"/api/v1/library-terminals/{terminal_id}/images",
dependencies=[Depends(require("images:read"))],
)
def terminal_images(
terminal_id: str,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
):
terminal = next(
(t for t in libraries.terminals() if t["id"] == terminal_id), None
)
if terminal is None:
raise FlowError(E.NOT_FOUND, "Terminal not found", 404)
binding = libraries.binding(terminal_id)
if not binding:
return {"items": [], "total": 0, "offset": offset, "limit": limit}
where = "FROM library_versions v JOIN source_images s ON s.id=v.source_image_id WHERE v.plan_id=? AND s.source_id=?"
args = (binding["plan_id"], binding["source_id"])
rows = db.all(
"SELECT v.asset_id,v.source_image_id "
+ where
+ " ORDER BY v.created_at DESC,v.asset_id LIMIT ? OFFSET ?",
(*args, limit, offset),
)
current_asset = None
if rows and terminal["transport_supported"]:
try:
device = service.registry.get(terminal_id).device()
if device.status == "online":
current = service.current_content(terminal_id, device=device)
if current["synced"] and current["source_known"]:
current_asset = current["asset_id"]
except FlowError:
# Offline devices must not hide locally generated versions.
pass
items = []
versions_by_picture = {}
for row in rows:
picture_id = row["source_image_id"]
if picture_id not in versions_by_picture:
versions_by_picture[picture_id] = {
v["asset_id"]: v for v in libraries.versions(picture_id)
}
version = versions_by_picture[picture_id][row["asset_id"]]
delivery = db.one(
"SELECT operation_id FROM terminal_deliveries WHERE terminal_id=? AND asset_id=? ORDER BY created_at DESC,operation_id DESC LIMIT 1",
(terminal_id, row["asset_id"]),
)
operation = (
db.one(
"SELECT id AS operation_id,status,created_at,updated_at FROM operations WHERE id=?",
(delivery["operation_id"],),
)
if delivery and delivery["operation_id"]
else None
)
items.append(
{
**version,
"operation": operation,
"current": row["asset_id"] == current_asset,
"delivery_status": operation["status"]
if operation
else "GENERATED",
}
)
return {
"items": items,
"total": db.one("SELECT COUNT(*) AS n " + where, args)["n"],
"offset": offset,
"limit": limit,
}
@app.post(
"/api/v1/library-terminals/{terminal_id}/images/{asset_id}/send",
dependencies=[Depends(require("display:write"))],
)
def send(terminal_id: str, asset_id: str, request: Request):
return {
"operation_id": libraries.send(
terminal_id, asset_id, client_id=request.state.client_id
)
}
+174
View File
@@ -0,0 +1,174 @@
import json
from uuid import uuid4
from fastapi import Depends, Query
from fastapi.responses import Response
from pydantic import Field
from pigway_frame_flow.core.auth import require
from pigway_frame_flow.display.thumbnail_plans import (
ThumbnailPlan,
get_plan,
render,
save_plan,
)
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import StrictModel
class ThumbnailRequest(StrictModel):
source_id: str = Field(min_length=1, max_length=120)
item_id: str = Field(min_length=1, max_length=2048)
plan: ThumbnailPlan
def install_thumbnails(app, service):
from pigway_frame_flow.display.thumbnail_gallery import record, recover
recover(service)
@app.get(
"/api/v1/thumbnail-devices", dependencies=[Depends(require("images:read"))]
)
def devices():
return [
{
"id": "watch",
"name": "Watch",
"width": 410,
"height": 502,
"transport_connected": False,
"status": "generation_only",
}
]
@app.get("/api/v1/thumbnails", dependencies=[Depends(require("images:read"))])
def gallery(offset: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200)):
rows = service.db.all(
"SELECT assets.id,asset_sources.payload FROM assets JOIN asset_sources ON asset_sources.asset_id=assets.id "
"WHERE json_extract(asset_sources.payload,'$.type')='thumbnail' "
"ORDER BY assets.created_at DESC,assets.id DESC LIMIT ? OFFSET ?",
(limit, offset),
)
total = service.db.one(
"SELECT COUNT(*) AS count FROM assets JOIN asset_sources ON asset_sources.asset_id=assets.id "
"WHERE json_extract(asset_sources.payload,'$.type')='thumbnail'"
)["count"]
return {
"items": [
{
**service.assets.info(row["id"]),
"thumbnail": json.loads(row["payload"]),
}
for row in rows
],
"total": total,
"offset": offset,
"limit": limit,
}
@app.get("/api/v1/thumbnail-plans", dependencies=[Depends(require("images:read"))])
def plans():
return [
dict(
id=r["key"].removeprefix("thumbnail-plan:"), **json.loads(r["payload"])
)
for r in service.db.all(
"SELECT key,payload FROM app_config WHERE key LIKE 'thumbnail-plan:%' ORDER BY key"
)
]
@app.post("/api/v1/thumbnail-plans", dependencies=[Depends(require("admin"))])
def create(plan: ThumbnailPlan):
plan_id = uuid4().hex
save_plan(service, plan_id, plan)
return dict(id=plan_id, **plan.model_dump())
@app.get(
"/api/v1/thumbnail-plans/{plan_id}",
dependencies=[Depends(require("images:read"))],
)
def detail(plan_id: str):
return dict(id=plan_id, **get_plan(service, plan_id).model_dump())
@app.put(
"/api/v1/thumbnail-plans/{plan_id}", dependencies=[Depends(require("admin"))]
)
def update(plan_id: str, plan: ThumbnailPlan):
with service.lock:
get_plan(service, plan_id)
save_plan(service, plan_id, plan)
return dict(id=plan_id, **plan.model_dump())
@app.delete(
"/api/v1/thumbnail-plans/{plan_id}",
dependencies=[Depends(require("admin"))],
status_code=204,
)
def delete(plan_id: str):
with service.lock:
get_plan(service, plan_id)
for row in service.db.all("SELECT payload FROM jobs"):
job = json.loads(row["payload"])
if job.get("thumbnail_plan_id") == plan_id:
raise FlowError(
E.CONFLICT,
"Thumbnail plan is used by a task",
409,
{"reason": "thumbnailPlanInUse"},
)
for row in service.db.all(
"SELECT payload FROM operations WHERE status IN ('QUEUED','RENDERING','UPLOADING','UNKNOWN')"
):
if (
json.loads(row["payload"])
.get("options", {})
.get("thumbnail_plan_id")
== plan_id
):
raise FlowError(
E.CONFLICT,
"Thumbnail plan is used by a pending operation",
409,
{"reason": "thumbnailPlanInUse"},
)
service.db.execute(
"DELETE FROM app_config WHERE key=?", ("thumbnail-plan:" + plan_id,)
)
def image(request):
data, _, _ = service.photo_sources.read(
request.source_id,
request.item_id,
target_size=(request.plan.width, request.plan.height),
)
return render(data, request.plan)
@app.post(
"/api/v1/thumbnails/preview", dependencies=[Depends(require("images:read"))]
)
def preview(request: ThumbnailRequest):
return Response(
image(request),
media_type="image/jpeg",
headers={"Cache-Control": "no-store"},
)
@app.post("/api/v1/thumbnails/generate", dependencies=[Depends(require("admin"))])
def generate(request: ThumbnailRequest):
asset = service.assets.put(
image(request), request.plan.name + ".jpg", "generated"
)
record(
service,
asset["asset_id"],
{
"width": request.plan.width,
"height": request.plan.height,
"plan": request.plan.model_dump(),
"generated_at": asset["created_at"],
"source": {"source_id": request.source_id, "item_id": request.item_id},
},
)
return asset
+45 -25
View File
@@ -4,6 +4,7 @@ import hashlib
import hmac import hmac
import json import json
import secrets import secrets
import threading
import time import time
from pathlib import Path from pathlib import Path
@@ -105,9 +106,12 @@ def session_user(request: Request):
def install_accounts(app, db): def install_accounts(app, db):
# Serialize authentication work without holding the database lock during scrypt.
login_lock = threading.Lock()
@app.post("/api/v1/auth/login", response_model=AccountView, tags=["Accounts"]) @app.post("/api/v1/auth/login", response_model=AccountView, tags=["Accounts"])
def login(payload: Login, request: Request, response: Response): def login(payload: Login, request: Request, response: Response):
with db.lock: with login_lock:
# Reject login CSRF before checking credentials. Never trust forwarded hosts. # Reject login CSRF before checking credentials. Never trust forwarded hosts.
origin = request.headers.get("origin") origin = request.headers.get("origin")
if request.headers.get("sec-fetch-site") == "cross-site" or ( if request.headers.get("sec-fetch-site") == "cross-site" or (
@@ -154,31 +158,47 @@ def install_accounts(app, db):
(key, failures, time.time() + 300 if failures >= 5 else 0), (key, failures, time.time() + 300 if failures >= 5 else 0),
) )
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401) raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
for key in identities: upgraded = (
db.execute("DELETE FROM login_attempts WHERE identity=?", (key,)) password_hash(payload.password)
if row["password_hash"].startswith("scrypt$"): if row["password_hash"].startswith("scrypt$")
db.execute( else None
"UPDATE users SET password_hash=? WHERE username=?",
(password_hash(payload.password), row["username"]),
)
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
lifetime = 30 * 86400 if payload.remember else 12 * 3600
db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),))
old = request.cookies.get(COOKIE)
if old:
db.execute(
"DELETE FROM sessions WHERE token_hash=?",
(hashlib.sha256(old.encode()).hexdigest(),),
)
db.execute(
"INSERT INTO sessions VALUES(?,?,?,?)",
(
hashlib.sha256(raw.encode()).hexdigest(),
row["username"],
csrf,
time.time() + lifetime,
),
) )
with db.lock:
# A concurrent password change must not create a session from old credentials.
current_row = db.one(
"SELECT * FROM users WHERE username=? AND enabled=1",
(payload.username,),
)
if (
not current_row
or current_row["password_hash"] != row["password_hash"]
):
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
for key in identities:
db.execute("DELETE FROM login_attempts WHERE identity=?", (key,))
if row["password_hash"].startswith("scrypt$"):
db.execute(
"UPDATE users SET password_hash=? WHERE username=?",
(upgraded, row["username"]),
)
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
lifetime = 30 * 86400 if payload.remember else 12 * 3600
db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),))
old = request.cookies.get(COOKIE)
if old:
db.execute(
"DELETE FROM sessions WHERE token_hash=?",
(hashlib.sha256(old.encode()).hexdigest(),),
)
db.execute(
"INSERT INTO sessions VALUES(?,?,?,?)",
(
hashlib.sha256(raw.encode()).hexdigest(),
row["username"],
csrf,
time.time() + lifetime,
),
)
response.set_cookie( response.set_cookie(
COOKIE, COOKIE,
raw, raw,
+1
View File
@@ -14,6 +14,7 @@ bearer = HTTPBearer(auto_error=False)
ALL_SCOPES = [ ALL_SCOPES = [
"devices:read", "devices:read",
"images:read", "images:read",
"images:write",
"images:delete", "images:delete",
"display:write", "display:write",
"templates:read", "templates:read",
@@ -20,6 +20,11 @@ class CloudLibrary:
job = self.db.one( job = self.db.one(
"SELECT job_id FROM job_runs WHERE operation_id=?", (operation_id,) "SELECT job_id FROM job_runs WHERE operation_id=?", (operation_id,)
) )
if not job and operation["client_id"] == "library-terminal":
job = self.db.one(
"SELECT job_runs.job_id FROM terminal_deliveries JOIN job_runs ON job_runs.operation_id=terminal_deliveries.parent_operation_id WHERE terminal_deliveries.operation_id=?",
(operation_id,),
)
if not job and operation["client_id"] == "scheduler-retry": if not job and operation["client_id"] == "scheduler-retry":
job = self.db.one( job = self.db.one(
"SELECT job_id FROM asset_metadata WHERE asset_id=? AND job_id IS NOT NULL", "SELECT job_id FROM asset_metadata WHERE asset_id=? AND job_id IS NOT NULL",
+714
View File
@@ -0,0 +1,714 @@
"""Source pictures, source-scoped render plans, and single terminal bindings."""
import hashlib
import json
from io import BytesIO
from pathlib import Path
from uuid import uuid4
from PIL import Image
from pydantic import Field, model_validator
from pigway_frame_flow.core.photo_preferences import flags
from pigway_frame_flow.display.thumbnail_plans import ThumbnailPlan
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import PhotoSourceContent
from pigway_frame_flow.renderers.images import MAX_IMAGE_PIXELS, convert
from pigway_frame_flow.renderers.photo_caption import render_captioned_photo
from pigway_frame_flow.storage.database import encode, now_iso
class GenerationPlan(ThumbnailPlan):
source_id: str = Field(min_length=1, max_length=120)
caption: bool = False
profile: str = Field(default="color", pattern="^(color|grayscale)$")
@model_validator(mode="after")
def caption_size(self):
if self.caption and min(self.width, self.height) < 128:
raise ValueError(
"Caption output requires both dimensions to be at least 128 pixels"
)
return self
class Libraries:
def __init__(self, service):
self.service, self.db = service, service.db
for kind in ("source-images", "renditions"):
(service.settings.data_dir / kind).mkdir(exist_ok=True)
for sql in [
"CREATE TABLE IF NOT EXISTS library_plans(id TEXT PRIMARY KEY,source_id TEXT NOT NULL,payload TEXT NOT NULL,archived INTEGER NOT NULL DEFAULT 0)",
"CREATE TABLE IF NOT EXISTS source_images(id TEXT PRIMARY KEY,source_id TEXT NOT NULL,item_id TEXT NOT NULL,digest TEXT NOT NULL,caption TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(source_id,item_id,digest))",
"CREATE TABLE IF NOT EXISTS library_versions(asset_id TEXT PRIMARY KEY,source_image_id TEXT NOT NULL,plan_id TEXT NOT NULL,revision TEXT NOT NULL,plan TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(source_image_id,plan_id,revision))",
"CREATE TABLE IF NOT EXISTS terminal_bindings(terminal_id TEXT PRIMARY KEY,source_id TEXT NOT NULL,plan_id TEXT NOT NULL)",
"CREATE TABLE IF NOT EXISTS terminal_deliveries(terminal_id TEXT NOT NULL,asset_id TEXT NOT NULL,operation_id TEXT PRIMARY KEY,parent_operation_id TEXT,created_at TEXT NOT NULL)",
]:
self.db.execute(sql)
service.operation_handlers["library-generate"] = self.execute
def terminals(self):
result = []
for terminal_id, driver in self.service.registry.drivers.items():
cached = getattr(driver, "cached", None)
result.append(
{
"id": terminal_id,
"name": getattr(cached, "name", terminal_id),
"kind": "frame",
"transport_supported": True,
"binding": self.binding(terminal_id),
}
)
result.append(
{
"id": "watch",
"name": "Watch",
"kind": "watch",
"width": 410,
"height": 502,
"transport_supported": False,
"binding": self.binding("watch"),
}
)
return result
def binding(self, terminal_id):
return self.db.one(
"SELECT * FROM terminal_bindings WHERE terminal_id=?", (terminal_id,)
)
def plan(self, plan_id, active=True):
row = self.db.one("SELECT * FROM library_plans WHERE id=?", (plan_id,))
if not row or (active and row["archived"]):
raise FlowError(E.NOT_FOUND, "Generation plan not found", 404)
return GenerationPlan.model_validate_json(row["payload"])
def plans(self, source_id):
self.service.photo_sources.get(source_id)
return [
{**json.loads(r["payload"]), "id": r["id"], "archived": bool(r["archived"])}
for r in self.db.all(
"SELECT * FROM library_plans WHERE source_id=? ORDER BY archived,id",
(source_id,),
)
]
def save_plan(self, plan, plan_id=None):
with self.service.lock:
self.service.photo_sources.get(plan.source_id)
if plan_id:
previous = self.plan(plan_id)
if previous.source_id != plan.source_id:
raise FlowError(
E.CONFLICT,
"A generation plan cannot move between source libraries",
409,
)
for row in self.db.all(
"SELECT terminal_id FROM terminal_bindings WHERE plan_id=?",
(plan_id,),
):
self.validate_size(row["terminal_id"], plan)
plan_id = plan_id or uuid4().hex
self.db.execute(
"INSERT INTO library_plans VALUES(?,?,?,0) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload",
(plan_id, plan.source_id, encode(plan.model_dump())),
)
return {"id": plan_id, **plan.model_dump(), "archived": False}
def archive(self, plan_id):
with self.service.lock:
self.plan(plan_id)
if self.db.one(
"SELECT terminal_id FROM terminal_bindings WHERE plan_id=?", (plan_id,)
):
raise FlowError(
E.CONFLICT,
"Unbind terminals before deleting this generation plan",
409,
{"reason": "libraryPlanBound"},
)
self.db.execute(
"UPDATE library_plans SET archived=1 WHERE id=?", (plan_id,)
)
def validate_size(self, terminal_id, plan):
if terminal_id == "watch":
allowed = {(410, 502)}
else:
display = self.service.registry.get(terminal_id).device().display
allowed = {(display.width, display.height), (display.height, display.width)}
if (plan.width, plan.height) not in allowed:
raise FlowError(
E.INVALID_REQUEST,
"Generation dimensions must match the terminal screen",
422,
{"reason": "librarySizeMismatch"},
)
def bind(self, terminal_id, source_id, plan_id):
with self.service.lock:
self.service.photo_sources.get(source_id)
plan = self.plan(plan_id)
if plan.source_id != source_id:
raise FlowError(
E.CONFLICT, "Generation plan belongs to another source library", 409
)
self.validate_size(terminal_id, plan)
self.db.execute(
"INSERT INTO terminal_bindings VALUES(?,?,?) ON CONFLICT(terminal_id) DO UPDATE SET source_id=excluded.source_id,plan_id=excluded.plan_id",
(terminal_id, source_id, plan_id),
)
return self.binding(terminal_id)
def ingest(self, source_id, item_id, data, name, caption):
if self.service.photo_sources.get(source_id).kind == "uploads":
existing = self.db.one(
"SELECT id FROM source_images WHERE source_id=? AND item_id=?",
(source_id, item_id),
)
if existing:
return self.picture(existing["id"])
digest = hashlib.sha256(
data + b"\0" + encode(caption or {}).encode()
).hexdigest()
row = self.db.one(
"SELECT id FROM source_images WHERE source_id=? AND item_id=? AND digest=?",
(source_id, item_id, digest),
)
if row:
return self.picture(row["id"])
# Normalize EXIF and JPEG storage without resizing or adding decorations.
try:
with Image.open(BytesIO(data), formats=("JPEG", "PNG", "WEBP")) as image:
if (
image.format not in {"JPEG", "PNG", "WEBP"}
or image.width * image.height > MAX_IMAGE_PIXELS
):
raise ValueError("Unsupported source image")
size = image.size
if image.getexif().get(274, 1) in (5, 6, 7, 8):
size = (image.height, image.width)
normalized = convert(data, "cover", size=size, quality=95)
except (OSError, ValueError, Image.DecompressionBombError):
raise FlowError(E.INVALID_REQUEST, "Invalid source image") from None
with self.service.assets.creating(normalized, name, "source-images") as asset:
self.db.execute(
"INSERT INTO source_images VALUES(?,?,?,?,?,?)",
(
asset["asset_id"],
source_id,
item_id,
digest,
encode(caption or {}),
now_iso(),
),
)
self.db.execute(
"INSERT INTO asset_sources VALUES(?,?)",
(
asset["asset_id"],
encode(
{
"type": "library_source",
"source_id": source_id,
"item_id": item_id,
"source_rendition": "upload"
if item_id.startswith("upload:")
else "preview"
if self.service.photo_sources.get(source_id).kind
== "synology-photos"
else "source",
}
),
),
)
return self.picture(asset["asset_id"])
def import_picture(self, source_id, item_id):
with self.service.lock:
data, name, caption = self.service.photo_sources.read(source_id, item_id)
return self.ingest(source_id, item_id, data, name, caption)
def picture(self, picture_id):
row = self.db.one("SELECT * FROM source_images WHERE id=?", (picture_id,))
if not row:
raise FlowError(E.NOT_FOUND, "Source picture not found", 404)
return {
**self.service.assets.info(picture_id),
**row,
"caption": json.loads(row["caption"]),
**flags(self.db, row["source_id"], row["item_id"]),
"versions": self.versions(picture_id),
}
@staticmethod
def render_parameters(plan):
return plan.model_dump(exclude={"name", "source_id"})
def versions(self, picture_id):
result = []
rows = self.db.all(
"SELECT v.*,p.payload AS current_plan,p.archived,a.name AS source_name "
"FROM library_versions v JOIN library_plans p ON p.id=v.plan_id "
"JOIN source_images s ON s.id=v.source_image_id JOIN assets a ON a.id=s.id "
"WHERE v.source_image_id=? ORDER BY v.created_at DESC,v.asset_id DESC",
(picture_id,),
)
for row in rows:
current = GenerationPlan.model_validate_json(row["current_plan"])
snapshot = GenerationPlan.model_validate_json(row["plan"])
asset = self.service.assets.info(row["asset_id"])
display_name = (
Path(Path(row["source_name"]).stem + "-" + current.name).name[:110]
+ ".jpg"
)
result.append(
{
**asset,
"name": display_name,
"stored_name": asset["name"],
"source_image_id": row["source_image_id"],
"plan_id": row["plan_id"],
"plan_name": current.name,
"plan_archived": bool(row["archived"]),
"matches_current_plan": self.render_parameters(current)
== self.render_parameters(snapshot),
"revision": row["revision"],
"plan": json.loads(row["plan"]),
"terminal_uses": self.terminal_uses(
row["plan_id"], row["asset_id"]
),
}
)
return result
def terminal_uses(self, plan_id, asset_id):
return self.db.all(
"SELECT b.terminal_id,d.operation_id,COALESCE(o.status,'GENERATED') AS status "
"FROM terminal_bindings b LEFT JOIN terminal_deliveries d ON d.operation_id=(SELECT operation_id FROM terminal_deliveries latest "
"WHERE latest.terminal_id=b.terminal_id AND latest.asset_id=? ORDER BY latest.created_at DESC,latest.operation_id DESC LIMIT 1) "
"LEFT JOIN operations o ON o.id=d.operation_id WHERE b.plan_id=? ORDER BY b.terminal_id",
(asset_id, plan_id),
)
def render(self, data, caption, plan):
if plan.caption and caption:
return render_captioned_photo(
data,
caption,
self.service.renderer.font,
plan.fit,
plan.profile,
size=(plan.width, plan.height),
centering=(plan.center_x, plan.center_y),
quality=plan.quality,
respect_fit=True,
)
return convert(
data,
plan.fit,
plan.profile,
size=(plan.width, plan.height),
centering=(plan.center_x, plan.center_y),
quality=plan.quality,
)
def generate(self, picture_id, plan_id, snapshot=None):
with self.service.lock:
picture = self.picture(picture_id)
plan = (
GenerationPlan.model_validate(snapshot)
if snapshot
else self.plan(plan_id)
)
if picture["source_id"] != plan.source_id:
raise FlowError(
E.CONFLICT,
"Picture and generation plan must share a source library",
409,
)
parameters = self.render_parameters(plan)
revision = hashlib.sha256(encode(parameters).encode()).hexdigest()
row = self.db.one(
"SELECT asset_id FROM library_versions WHERE source_image_id=? AND plan_id=? AND revision=?",
(picture_id, plan_id, revision),
)
existing = self.versions(picture_id)
if row:
return next(v for v in existing if v["asset_id"] == row["asset_id"])
# Older revisions hashed the display name too. Reuse matching pixels'
# settings without rewriting historical snapshots or receipt references.
for version in existing:
if (
version["plan_id"] == plan_id
and self.render_parameters(
GenerationPlan.model_validate(version["plan"])
)
== parameters
):
return version
data = self.service.assets.path(picture_id).read_bytes()
with self.service.assets.creating(
self.render(data, picture["caption"], plan),
picture["name"].removesuffix(".jpg") + "-" + plan.name,
"renditions",
) as asset:
self.db.execute(
"INSERT INTO library_versions VALUES(?,?,?,?,?,?)",
(
asset["asset_id"],
picture_id,
plan_id,
revision,
encode(plan.model_dump()),
now_iso(),
),
)
self.db.execute(
"INSERT INTO asset_sources VALUES(?,?)",
(
asset["asset_id"],
encode(
{
"type": "library_version",
"source_image_id": picture_id,
"source_id": plan.source_id,
"plan_id": plan_id,
"revision": revision,
}
),
),
)
return next(
v
for v in self.versions(picture_id)
if v["asset_id"] == asset["asset_id"]
)
def validate_asset_binding(self, terminal_id, asset_id):
if self.db.one("SELECT id FROM source_images WHERE id=?", (asset_id,)):
raise FlowError(
E.CONFLICT,
"Generate a terminal version before sending source pixels",
409,
{"reason": "libraryGenerateFirst"},
)
version = self.db.one(
"SELECT v.plan_id,s.source_id FROM library_versions v JOIN source_images s ON s.id=v.source_image_id WHERE v.asset_id=?",
(asset_id,),
)
if version:
binding = self.binding(terminal_id)
if (
not binding
or binding["source_id"] != version["source_id"]
or binding["plan_id"] != version["plan_id"]
):
raise FlowError(
E.CONFLICT, "Image no longer matches the terminal binding", 409
)
def send(
self,
terminal_id,
asset_id,
options=None,
client_id="admin",
parent_operation_id=None,
):
with self.service.lock:
binding = self.binding(terminal_id)
version = self.db.one(
"SELECT v.*,s.source_id FROM library_versions v JOIN source_images s ON s.id=v.source_image_id WHERE v.asset_id=?",
(asset_id,),
)
if (
not binding
or not version
or binding["source_id"] != version["source_id"]
or binding["plan_id"] != version["plan_id"]
):
raise FlowError(
E.CONFLICT,
"Image does not match the terminal source and plan binding",
409,
)
if terminal_id == "watch":
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "Watch transfer is not connected", 422
)
request = {
"device_id": terminal_id,
"content": {"type": "image", "asset_id": asset_id},
"options": {
**(options or {}),
"cleanup_unstarred": False,
"generate_thumbnail": False,
"thumbnail_plan_id": None,
},
}
from pigway_frame_flow.models import DisplayRequest
request_model = DisplayRequest.model_validate(request)
request = request_model.model_dump(mode="json")
previous = self.db.one(
"SELECT operation_id FROM terminal_deliveries WHERE terminal_id=? AND asset_id=? "
+ ("AND parent_operation_id=? " if parent_operation_id else "")
+ "ORDER BY created_at DESC,operation_id DESC LIMIT 1",
(terminal_id, asset_id, parent_operation_id)
if parent_operation_id
else (terminal_id, asset_id),
)
if previous:
op = self.db.one(
"SELECT id,status,payload FROM operations WHERE id=?",
(previous["operation_id"],),
)
if op and (
parent_operation_id
or (
op["status"]
in ("QUEUED", "RENDERING", "UPLOADING", "SYNCING", "UNKNOWN")
and json.loads(op["payload"]) == request
)
):
return op["id"]
self.service.validate(request_model)
# Reuse the queue's durable idempotency receipt if a restart occurs
# between submitting a child operation and linking its delivery row.
key = (
"library-output-"
+ hashlib.sha256(
encode([parent_operation_id, terminal_id, asset_id]).encode()
).hexdigest()
if parent_operation_id
else None
)
operation = self.service.submit(
"display",
request,
terminal_id,
client_id,
"library-" + uuid4().hex,
key=key,
)
self.db.execute(
"INSERT INTO terminal_deliveries VALUES(?,?,?,?,?)",
(terminal_id, asset_id, operation, parent_operation_id, now_iso()),
)
return operation
def pipeline(
self,
source_id,
item_id=None,
options=None,
deliver=False,
operation_id=None,
caller="admin",
):
root_operation = (
(self.db.one("SELECT * FROM operations WHERE id=?", (operation_id,)) or {})
if operation_id
else {}
)
previous = (
json.loads(root_operation["result"] or "{}") if root_operation else {}
)
bindings = previous.get("binding_snapshot")
if bindings is None:
bindings = self.db.all(
"SELECT * FROM terminal_bindings WHERE source_id=? ORDER BY terminal_id",
(source_id,),
)
bindings = [
{**b, "plan_snapshot": self.plan(b["plan_id"]).model_dump()}
for b in bindings
]
if not bindings:
raise FlowError(
E.CONFLICT,
"Bind at least one terminal to this source library first",
409,
{"reason": "libraryBindFirst"},
)
if operation_id:
self.service.update(operation_id, "RENDERING")
if previous.get("source_image_id"):
selected = self.picture(previous["source_image_id"])
if selected["source_id"] != source_id:
raise FlowError(
E.CONFLICT, "Saved selection belongs to another source library", 409
)
else:
selected = self.service.content_asset(
PhotoSourceContent(
type="photo_source", source_id=source_id, item_id=item_id
),
operation_id=operation_id,
source_only=True,
source_size=max(
(
(b["plan_snapshot"]["width"], b["plan_snapshot"]["height"])
for b in bindings
),
key=lambda size: size[0] * size[1],
),
)
result = {
"source_image_id": selected["asset_id"],
"binding_snapshot": bindings,
"outputs": [],
"errors": [],
}
if operation_id:
self.service.assets.record_origin(selected["asset_id"], root_operation)
self.service.update(operation_id, "RENDERING", result)
versions = {}
for binding in bindings:
terminal_id, plan_id = binding["terminal_id"], binding["plan_id"]
try:
if plan_id not in versions:
versions[plan_id] = self.generate(
selected["asset_id"], plan_id, binding["plan_snapshot"]
)
version = versions[plan_id]
if operation_id:
self.service.assets.record_origin(
version["asset_id"], root_operation
)
output = {
"terminal_id": terminal_id,
"asset_id": version["asset_id"],
"plan_id": plan_id,
"status": "GENERATED",
}
if deliver and terminal_id != "watch":
output["operation_id"] = self.send(
terminal_id,
version["asset_id"],
options,
"library-terminal" if operation_id else caller,
parent_operation_id=operation_id,
)
output["status"] = "QUEUED"
result["outputs"].append(output)
if operation_id:
self.service.update(operation_id, "RENDERING", result)
except (FlowError, OSError) as exc:
result["errors"].append(
{
"terminal_id": terminal_id,
"plan_id": plan_id,
"message": str(exc),
}
)
if operation_id:
self.service.update(
operation_id,
"COMPLETED" if not result["errors"] else "FAILED",
result,
error={
"code": E.INVALID_REQUEST,
"message": "Some terminal outputs failed",
"request_id": root_operation["request_id"],
"details": {"reason": "libraryOutputPartial"},
}
if result["errors"]
else None,
)
return result
def execute(self, operation, payload):
result = self.pipeline(
payload["content"]["source_id"],
payload["content"].get("item_id"),
payload.get("options"),
deliver=True,
operation_id=operation["id"],
)
self.db.history(operation, "COMPLETED" if not result["errors"] else "FAILED")
def check_asset_references(self, asset_id):
reference = "%" + asset_id + "%"
if (
self.db.one(
"SELECT id FROM operations WHERE (payload LIKE ? OR result LIKE ?) AND status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')",
(reference, reference),
)
or self.db.one("SELECT id FROM jobs WHERE payload LIKE ?", (reference,))
or self.db.one(
"SELECT template_id FROM template_config WHERE payload LIKE ?",
(reference,),
)
):
raise FlowError(
E.CONFLICT,
"Image is used by a task, template or pending operation",
409,
)
def remove_assets(self, asset_ids):
with self.db.transaction():
for asset_id in asset_ids:
row = self.db.one("SELECT kind FROM assets WHERE id=?", (asset_id,))
if not row:
continue
self.db.execute(
"INSERT OR REPLACE INTO asset_file_journal VALUES(?,?)",
(asset_id, row["kind"]),
)
for table, key in [
("library_versions", "asset_id"),
("terminal_deliveries", "asset_id"),
("source_images", "id"),
("asset_sources", "asset_id"),
("asset_metadata", "asset_id"),
("assets", "id"),
]:
self.db.execute(f"DELETE FROM {table} WHERE {key}=?", (asset_id,))
self.db.execute(
"DELETE FROM photo_preferences WHERE source_id='@assets' AND item_id=?",
(asset_id,),
)
self.service.assets.recover_files()
def remove_asset(self, asset_id):
self.remove_assets([asset_id])
def delete_picture(self, picture_id):
with self.service.lock:
self.picture(picture_id)
ids = [
r["asset_id"]
for r in self.db.all(
"SELECT asset_id FROM library_versions WHERE source_image_id=?",
(picture_id,),
)
] + [picture_id]
for asset_id in ids:
self.check_asset_references(asset_id)
self.remove_assets(ids)
def check_source_delete(self, source_id):
if (
self.db.one(
"SELECT terminal_id FROM terminal_bindings WHERE source_id=?",
(source_id,),
)
or self.db.one(
"SELECT id FROM library_plans WHERE source_id=? AND archived=0",
(source_id,),
)
or self.db.one(
"SELECT id FROM source_images WHERE source_id=?", (source_id,)
)
):
raise FlowError(
E.CONFLICT,
"Remove terminal bindings, generation plans and local source pictures first",
409,
)
+138 -42
View File
@@ -54,6 +54,9 @@ class DisplayService:
self.cloud_library = CloudLibrary(self) self.cloud_library = CloudLibrary(self)
self.recovery = RecoveryMonitor(self) self.recovery = RecoveryMonitor(self)
self.upload_reconciliation = UploadReconciliation(self) self.upload_reconciliation = UploadReconciliation(self)
from pigway_frame_flow.display.libraries import Libraries
self.libraries = Libraries(self)
self.expiration_checked: dict[str, float] = {} self.expiration_checked: dict[str, float] = {}
self.unknown_checked: dict[str, float] = {} self.unknown_checked: dict[str, float] = {}
# A killed write cannot be replayed safely; pre-write work can resume. # A killed write cannot be replayed safely; pre-write work can resume.
@@ -67,6 +70,18 @@ class DisplayService:
def validate(self, request, writing=True): def validate(self, request, writing=True):
self.registry.get(request.device_id) self.registry.get(request.device_id)
if (
writing
and isinstance(request.content, ImageContent)
and request.content.asset_id
):
self.libraries.validate_asset_binding(
request.device_id, request.content.asset_id
)
if request.options.thumbnail_plan_id:
from pigway_frame_flow.display.thumbnail_plans import get_plan
get_plan(self, request.options.thumbnail_plan_id)
if isinstance(request.content, FutureContent): if isinstance(request.content, FutureContent):
raise FlowError( raise FlowError(
E.UNSUPPORTED_CAPABILITY, "Content type is unsupported", 422 E.UNSUPPORTED_CAPABILITY, "Content type is unsupported", 422
@@ -116,7 +131,14 @@ class DisplayService:
) )
return existing["operation_id"] return existing["operation_id"]
if ( if (
action not in ("display", "render", "render-ai", "local-upload") action
not in (
"display",
"render",
"render-ai",
"local-upload",
"library-generate",
)
and tx.execute( and tx.execute(
"SELECT id FROM operations WHERE device_id=? AND status='UNKNOWN' AND write_started=1 AND action!='render-ai' LIMIT 1", "SELECT id FROM operations WHERE device_id=? AND status='UNKNOWN' AND write_started=1 AND action!='render-ai' LIMIT 1",
(device_id,), (device_id,),
@@ -205,7 +227,12 @@ class DisplayService:
result.pop("preview_url", None) result.pop("preview_url", None)
row = self.db.one("SELECT result FROM operations WHERE id=?", (id,)) row = self.db.one("SELECT result FROM operations WHERE id=?", (id,))
previous = json.loads(row["result"]) if row and row["result"] else {} previous = json.loads(row["result"]) if row and row["result"] else {}
for key in ("photo_attempts", "upload_intent", "sync_observations"): for key in (
"photo_attempts",
"upload_intent",
"sync_observations",
"wrist_thumbnail",
):
if key in previous: if key in previous:
result = {**result, key: previous[key]} result = {**result, key: previous[key]}
self.db.execute( self.db.execute(
@@ -265,7 +292,15 @@ class DisplayService:
) )
def content_asset( def content_asset(
self, content, device_id=None, operation_id=None, *, staging=False self,
content,
device_id=None,
operation_id=None,
*,
staging=False,
source_image=None,
source_only=False,
source_size=None,
): ):
if isinstance(content, AutoCollageContent): if isinstance(content, AutoCollageContent):
from pigway_frame_flow.extensions.auto_collage import prepare from pigway_frame_flow.extensions.auto_collage import prepare
@@ -292,26 +327,31 @@ class DisplayService:
if isinstance(content, TemplateContent): if isinstance(content, TemplateContent):
values = self.renderer.resolve(content.template, content.data) values = self.renderer.resolve(content.template, content.data)
data = self.renderer.render(content.template, values, content.profile) data = self.renderer.render(content.template, values, content.profile)
asset = self.assets.put( with self.assets.creating(
data, content.template + ".jpg", "cache" if staging else "generated" data, content.template + ".jpg", "cache" if staging else "generated"
) ) as asset:
source = { source = {
"type": "template", "type": "template",
"template": content.template, "template": content.template,
"data": self.settings.sanitize(values), "data": self.settings.sanitize(values),
"profile": content.profile, "profile": content.profile,
} }
self.db.execute( self.db.execute(
"INSERT OR REPLACE INTO asset_sources VALUES(?,?)", "INSERT OR REPLACE INTO asset_sources VALUES(?,?)",
(asset["asset_id"], encode(source)), (asset["asset_id"], encode(source)),
) )
return asset return asset
if isinstance(content, PhotoSourceContent): if isinstance(content, PhotoSourceContent):
source = self.photo_sources.get(content.source_id) source = self.photo_sources.get(content.source_id)
display = ( if source_only:
self.registry.get(device_id or self.settings.device_id).device().display size = source_size or (1200, 1600)
) else:
size = frame_canvas(display, source.orientation) display = (
self.registry.get(device_id or self.settings.device_id)
.device()
.display
)
size = frame_canvas(display, source.orientation)
import random import random
items = self.photo_sources.catalog(content.source_id, target_size=size) items = self.photo_sources.catalog(content.source_id, target_size=size)
@@ -391,6 +431,17 @@ class DisplayService:
), ),
), ),
) )
if source_only:
selected = self.libraries.ingest(
content.source_id, item["id"], data, name, caption
)
attempt["status"] = "PREPARED"
if operation_id:
self.db.execute(
"UPDATE operations SET result=? WHERE id=?",
(encode({"photo_attempts": attempts}), operation_id),
)
return selected
rendered = ( rendered = (
render_captioned_photo( render_captioned_photo(
data, data,
@@ -419,29 +470,31 @@ class DisplayService:
): ):
raise raise
continue continue
if source_image is not None:
source_image["data"] = data
attempt["status"] = "PREPARED" attempt["status"] = "PREPARED"
if operation_id: if operation_id:
self.db.execute( self.db.execute(
"UPDATE operations SET result=? WHERE id=?", "UPDATE operations SET result=? WHERE id=?",
(encode({"photo_attempts": attempts}), operation_id), (encode({"photo_attempts": attempts}), operation_id),
) )
asset = self.assets.put( with self.assets.creating(
rendered, name, "cache" if staging else "images" rendered, name, "cache" if staging else "images"
) ) as asset:
self.db.execute( self.db.execute(
"INSERT OR REPLACE INTO asset_sources VALUES(?,?)", "INSERT OR REPLACE INTO asset_sources VALUES(?,?)",
( (
asset["asset_id"], asset["asset_id"],
encode( encode(
{ {
"type": "photo_source", "type": "photo_source",
"source_id": content.source_id, "source_id": content.source_id,
"item_id": item["id"], "item_id": item["id"],
"source_path": item.get("source_path"), "source_path": item.get("source_path"),
} }
),
), ),
), )
)
return self.assets.info(asset["asset_id"]) return self.assets.info(asset["asset_id"])
raise FlowError( raise FlowError(
E.INVALID_REQUEST, "No decodable photo found within 5 candidates" E.INVALID_REQUEST, "No decodable photo found within 5 candidates"
@@ -460,8 +513,9 @@ class DisplayService:
"cache" if staging else "images", "cache" if staging else "images",
) )
def current_content(self, device_id): def current_content(self, device_id, *, device=None):
device = self.registry.get(device_id).device() if device is None:
device = self.registry.get(device_id).device()
self.clear_self_restore_reference(device_id, device) self.clear_self_restore_reference(device_id, device)
image_id = device.current_content.id image_id = device.current_content.id
row = self.db.one( row = self.db.one(
@@ -548,13 +602,20 @@ class DisplayService:
"render", "render",
"render-ai", "render-ai",
"local-upload", "local-upload",
"library-generate",
) and self.blocked(op["device_id"]): ) and self.blocked(op["device_id"]):
continue continue
payload = json.loads(op["payload"]) payload = json.loads(op["payload"])
if ( if (
op["action"] in ("display", "delete", "restore") op["action"] in ("display", "delete", "restore")
and op["client_id"] and op["client_id"]
in ("scheduler", "scheduler-retry", "cloud-library", "retention") in (
"scheduler",
"scheduler-retry",
"library-terminal",
"cloud-library",
"retention",
)
and self.db.one( and self.db.one(
"SELECT id FROM operations WHERE device_id=? AND action='display' " "SELECT id FROM operations WHERE device_id=? AND action='display' "
"AND status='SYNCING' AND write_started=1 AND updated_at>? LIMIT 1", "AND status='SYNCING' AND write_started=1 AND updated_at>? LIMIT 1",
@@ -616,6 +677,9 @@ class DisplayService:
def _execute(self, op, payload): def _execute(self, op, payload):
id, device_id, action = op["id"], op["device_id"], op["action"] id, device_id, action = op["id"], op["device_id"], op["action"]
# A source run can generate watch versions without any frame driver.
if action == "library-generate":
return self.operation_handlers[action](op, payload)
driver = self.registry.get(device_id) driver = self.registry.get(device_id)
if action in self.operation_handlers: if action in self.operation_handlers:
return self.operation_handlers[action](op, payload) return self.operation_handlers[action](op, payload)
@@ -625,15 +689,26 @@ class DisplayService:
from pigway_frame_flow.models import Content from pigway_frame_flow.models import Content
source_image: dict[str, bytes] = {}
asset = self.content_asset( asset = self.content_asset(
TypeAdapter(Content).validate_python(payload), device_id, id TypeAdapter(Content).validate_python(payload),
device_id,
id,
source_image=source_image,
) )
self.assets.record_origin(asset["asset_id"], op) self.assets.record_origin(asset["asset_id"], op)
if action == "local-upload": if action == "local-upload":
asset = self.assets.promote(asset["asset_id"]) asset = self.assets.promote(asset["asset_id"])
self.update( result = dict(asset_id=asset["asset_id"], url=asset["url"])
id, "COMPLETED", dict(asset_id=asset["asset_id"], url=asset["url"]) if action == "local-upload":
) from pigway_frame_flow.display.wrist_thumbnail import publish
thumbnail = publish(
self, device_id, asset["asset_id"], source_image.get("data")
)
if thumbnail:
result["wrist_thumbnail"] = thumbnail
self.update(id, "COMPLETED", result)
self.db.history(op, "COMPLETED") self.db.history(op, "COMPLETED")
return return
device = driver.device(refresh=True) device = driver.device(refresh=True)
@@ -754,9 +829,16 @@ class DisplayService:
): ):
self.reconcile(pending["id"], acknowledge=True) self.reconcile(pending["id"], acknowledge=True)
request = DisplayRequest.model_validate(payload) request = DisplayRequest.model_validate(payload)
if request.options.thumbnail_plan_id:
from pigway_frame_flow.display.thumbnail_plans import get_plan
get_plan(self, request.options.thumbnail_plan_id)
content = request.content content = request.content
if isinstance(content, ImageContent) and content.asset_id:
self.libraries.validate_asset_binding(device_id, content.asset_id)
self.update(id, "RENDERING") self.update(id, "RENDERING")
asset = None asset = None
source_image = {}
if isinstance(content, ImageContent) and ( if isinstance(content, ImageContent) and (
content.image_id or content.image_name content.image_id or content.image_name
): ):
@@ -769,7 +851,9 @@ class DisplayService:
) )
image_id = matches[0].image_id image_id = matches[0].image_id
else: else:
asset = self.content_asset(content, device_id, id, staging=True) asset = self.content_asset(
content, device_id, id, staging=True, source_image=source_image
)
self.assets.record_origin(asset["asset_id"], op) self.assets.record_origin(asset["asset_id"], op)
image_id = None image_id = None
self.db.execute( self.db.execute(
@@ -803,6 +887,18 @@ class DisplayService:
else: else:
driver.display(image_id) driver.display(image_id)
result = {"image_id": image_id} result = {"image_id": image_id}
if asset and request.options.generate_thumbnail:
from pigway_frame_flow.display.wrist_thumbnail import publish
thumbnail = publish(
self,
device_id,
asset["asset_id"],
source_image.get("data"),
plan_id=request.options.thumbnail_plan_id,
)
if thumbnail:
result["wrist_thumbnail"] = thumbnail
if asset: if asset:
result["rendered_asset_id"] = asset["asset_id"] result["rendered_asset_id"] = asset["asset_id"]
source = self.db.one( source = self.db.one(
@@ -0,0 +1,37 @@
"""Separate watch gallery metadata; image files retain existing asset access rules."""
import json
from pigway_frame_flow.storage.database import encode
TARGET_ID = "watch"
def record(service, asset_id, metadata):
service.db.execute(
"INSERT OR REPLACE INTO asset_sources VALUES(?,?)",
(asset_id, encode({**metadata, "type": "thumbnail", "target_id": TARGET_ID})),
)
def recover(service):
# Earlier automatic thumbnails have authoritative operation receipts. Never
# guess that an unrelated image is a thumbnail from its name or dimensions.
rows = service.db.all("SELECT result FROM operations WHERE result IS NOT NULL")
receipts = [json.loads(row["result"]).get("wrist_thumbnail") for row in rows]
receipts += [
json.loads(row["payload"])
for row in service.db.all(
"SELECT payload FROM app_config WHERE key LIKE 'wrist-thumbnail:%'"
)
]
for receipt in receipts:
if not isinstance(receipt, dict) or not receipt.get("asset_id"):
continue
asset_id = receipt["asset_id"]
if service.db.one(
"SELECT id FROM assets WHERE id=?", (asset_id,)
) and not service.db.one(
"SELECT asset_id FROM asset_sources WHERE asset_id=?", (asset_id,)
):
record(service, asset_id, receipt)
@@ -0,0 +1,45 @@
"""Saved thumbnail rendering parameters; source pixels are never decorated."""
from pydantic import Field
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import StrictModel
from pigway_frame_flow.renderers.images import convert
from pigway_frame_flow.storage.database import encode
class ThumbnailPlan(StrictModel):
name: str = Field(min_length=1, max_length=120)
width: int = Field(default=410, ge=16, le=2048)
height: int = Field(default=502, ge=16, le=2048)
fit: str = Field(default="cover", pattern="^(cover|contain)$")
center_x: float = Field(default=0.5, ge=0, le=1, allow_inf_nan=False)
center_y: float = Field(default=0.5, ge=0, le=1, allow_inf_nan=False)
quality: int = Field(default=85, ge=40, le=95)
def get_plan(service, plan_id):
row = service.db.one(
"SELECT payload FROM app_config WHERE key=?", ("thumbnail-plan:" + plan_id,)
)
if not row:
raise FlowError(E.NOT_FOUND, "Thumbnail plan not found", 404)
return ThumbnailPlan.model_validate_json(row["payload"])
def render(data, plan):
return convert(
data,
plan.fit,
size=(plan.width, plan.height),
centering=(plan.center_x, plan.center_y),
quality=plan.quality,
)
def save_plan(service, plan_id, plan):
service.db.execute(
"INSERT OR REPLACE INTO app_config VALUES(?,?)",
("thumbnail-plan:" + plan_id, encode(plan.model_dump())),
)
@@ -0,0 +1,68 @@
"""Source-only rendition for the current 410 × 502 watch display."""
import hashlib
import json
import logging
from datetime import datetime
from zoneinfo import ZoneInfo
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.renderers.images import convert
from pigway_frame_flow.storage.database import encode
logger = logging.getLogger(__name__)
SIZE = (410, 502)
def publish(service, device_id, asset_id, data=None, plan_id=None):
# Templates and old gallery assets have no verified undecorated source.
row = service.db.one(
"SELECT payload FROM asset_sources WHERE asset_id=?", (asset_id,)
)
source = json.loads(row["payload"]) if row else {}
if source.get("type") != "photo_source":
return None
try:
from pigway_frame_flow.display.thumbnail_plans import get_plan, render
plan = get_plan(service, plan_id) if plan_id else None
size = (plan.width, plan.height) if plan else SIZE
if data is None:
data, _, _ = service.photo_sources.read(
source["source_id"], source["item_id"], target_size=size
)
rendered = (
render(data, plan) if plan else convert(data, "cover", "color", size=SIZE)
)
asset = service.assets.put(rendered, "watch.jpg", "cache")
now = datetime.now(ZoneInfo(service.settings.timezone))
result = {
"asset_id": asset["asset_id"],
"url": asset["url"],
"width": size[0],
"height": size[1],
"date": now.date().isoformat(),
"timezone": service.settings.timezone,
"generated_at": now.isoformat(),
"sha256": hashlib.sha256(rendered).hexdigest(),
"bytes": len(rendered),
"source": source,
"fit": plan.fit if plan else "cover",
"plan_id": plan_id,
"plan": plan.model_dump() if plan else None,
"source_rendition": "preview"
if service.photo_sources.get(source["source_id"]).kind == "synology-photos"
else "source",
}
from pigway_frame_flow.display.thumbnail_gallery import record
record(service, asset["asset_id"], {**result, "source_frame_id": device_id})
service.db.execute(
"INSERT OR REPLACE INTO app_config VALUES(?,?)",
("wrist-thumbnail:" + device_id, encode(result)),
)
return result
except (FlowError, OSError, ValueError):
# An auxiliary rendition must never cause a successful upload to replay.
logger.warning("Watch thumbnail generation failed for asset %s", asset_id)
return None
+3 -1
View File
@@ -155,7 +155,9 @@ def install_ai_images(app, service):
image_data = base64.b64decode(result[0]["b64_json"], validate=True) image_data = base64.b64decode(result[0]["b64_json"], validate=True)
else: else:
image_data = service.assets.fetch_url(result[0]["url"]) image_data = service.assets.fetch_url(result[0]["url"])
with Image.open(BytesIO(image_data)) as image: with Image.open(
BytesIO(image_data), formats=("JPEG", "PNG", "WEBP")
) as image:
if image.width * image.height > MAX_IMAGE_PIXELS: if image.width * image.height > MAX_IMAGE_PIXELS:
raise ValueError() raise ValueError()
rgba = ImageOps.exif_transpose(image).convert("RGBA") rgba = ImageOps.exif_transpose(image).convert("RGBA")
+12 -6
View File
@@ -76,7 +76,9 @@ def install_extensions(app, service):
if len(data) > settings.max_bytes: if len(data) > settings.max_bytes:
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit") raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
try: try:
with Image.open(BytesIO(data)) as image: with Image.open(
BytesIO(data), formats=("JPEG", "PNG", "WEBP")
) as image:
if ( if (
image.format not in {"JPEG", "PNG", "WEBP"} image.format not in {"JPEG", "PNG", "WEBP"}
or image.width * image.height > MAX_IMAGE_PIXELS or image.width * image.height > MAX_IMAGE_PIXELS
@@ -92,11 +94,11 @@ def install_extensions(app, service):
raise FlowError(E.INVALID_REQUEST, "Invalid image") from None raise FlowError(E.INVALID_REQUEST, "Invalid image") from None
result = [] result = []
for data, name in prepared: for data, name in prepared:
asset = service.assets.put(data, name) with service.assets.creating(data, name) as asset:
db.execute( db.execute(
"INSERT INTO asset_metadata(asset_id,origin) VALUES(?,'manual')", "INSERT INTO asset_metadata(asset_id,origin) VALUES(?,'manual')",
(asset["asset_id"],), (asset["asset_id"],),
) )
result.append(asset) result.append(asset)
return result return result
@@ -220,6 +222,10 @@ def install_extensions(app, service):
for row in db.all("SELECT * FROM clients") for row in db.all("SELECT * FROM clients")
] ]
@app.get("/api/v1/client-scopes", dependencies=[Depends(require("admin"))])
def client_scopes():
return ALL_SCOPES
@app.post( @app.post(
"/api/v1/clients", "/api/v1/clients",
status_code=201, status_code=201,
+3 -1
View File
@@ -37,7 +37,9 @@ def prepare(service, content, device_id=None):
if source.avoid_duplicates and quality["fingerprint"] in hashes: if source.avoid_duplicates and quality["fingerprint"] in hashes:
continue continue
hashes.update([quality["sha256"], quality["fingerprint"]]) hashes.update([quality["sha256"], quality["fingerprint"]])
with Image.open(BytesIO(data)) as image: with Image.open(
BytesIO(data), formats=("JPEG", "PNG", "WEBP")
) as image:
photo = ImageOps.exif_transpose(image).convert("RGB") photo = ImageOps.exif_transpose(image).convert("RGB")
photo.thumbnail((2400, 2400)) photo.thumbnail((2400, 2400))
output = BytesIO() output = BytesIO()
+7 -2
View File
@@ -126,7 +126,9 @@ class LayoutStudio:
plan = local_plan(request) plan = local_plan(request)
metadata = [] metadata = []
for asset_id in request.asset_ids: for asset_id in request.asset_ids:
with Image.open(self.renderer.assets.path(asset_id)) as image: with Image.open(
self.renderer.assets.path(asset_id), formats=("JPEG", "PNG", "WEBP")
) as image:
metadata.append( metadata.append(
{"asset_id": asset_id, "width": image.width, "height": image.height} {"asset_id": asset_id, "width": image.width, "height": image.height}
) )
@@ -203,7 +205,10 @@ class LayoutStudio:
plan = LayoutPlan.model_validate(data["layout"]) plan = LayoutPlan.model_validate(data["layout"])
canvas = Image.new("RGB", (plan.width, plan.height), "white") canvas = Image.new("RGB", (plan.width, plan.height), "white")
for region in plan.regions: for region in plan.regions:
with Image.open(self.renderer.assets.path(region.asset_id)) as source: with Image.open(
self.renderer.assets.path(region.asset_id),
formats=("JPEG", "PNG", "WEBP"),
) as source:
image = ImageOps.exif_transpose(source).convert("RGB") image = ImageOps.exif_transpose(source).convert("RGB")
image = ImageOps.fit( image = ImageOps.fit(
image, image,
+1 -1
View File
@@ -7,7 +7,7 @@ from PIL import Image, ImageFilter, ImageOps, ImageStat
def photo_quality(data): def photo_quality(data):
with Image.open(BytesIO(data)) as source: with Image.open(BytesIO(data), formats=("JPEG", "PNG", "WEBP")) as source:
image = ImageOps.exif_transpose(source).convert("L") image = ImageOps.exif_transpose(source).convert("L")
image.thumbnail((256, 256)) image.thumbnail((256, 256))
brightness = ImageStat.Stat(image).mean[0] brightness = ImageStat.Stat(image).mean[0]
+17 -10
View File
@@ -1,6 +1,7 @@
"""Opt-in local retention, protecting stars, current content and durable references.""" """Opt-in local retention, protecting stars, current content and durable references."""
import re import re
import shutil
import time import time
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
@@ -44,6 +45,7 @@ class LocalRetention:
"SELECT payload FROM template_config", "SELECT payload FROM template_config",
"SELECT payload FROM operations WHERE status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')", "SELECT payload FROM operations WHERE status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')",
"SELECT payload FROM app_config WHERE key LIKE 'upload-retry:%'", "SELECT payload FROM app_config WHERE key LIKE 'upload-retry:%'",
"SELECT payload FROM app_config WHERE key LIKE 'wrist-thumbnail:%'",
]: ]:
payloads += [row["payload"] for row in db.all(query)] payloads += [row["payload"] for row in db.all(query)]
for payload in payloads: for payload in payloads:
@@ -79,7 +81,19 @@ class LocalRetention:
def stats(self): def stats(self):
rows = self.inspect() rows = self.inspect()
protected = self.protected() protected = self.protected()
disk = shutil.disk_usage(self.service.settings.data_dir)
low_space = disk.free < max(512 * 1024 * 1024, int(disk.total * 0.05))
return { return {
"disk": {
"total_bytes": disk.total,
"used_bytes": disk.used,
"free_bytes": disk.free,
"low_space": low_space,
},
"pending_file_cleanup": len(
self.service.db.all("SELECT id FROM asset_file_journal")
)
+ len(self.service.db.all("SELECT id FROM asset_file_moves")),
"policy": self.config().model_dump(), "policy": self.config().model_dump(),
"total_bytes": sum(x[2] for x in rows), "total_bytes": sum(x[2] for x in rows),
"asset_count": len(rows), "asset_count": len(rows),
@@ -91,6 +105,7 @@ class LocalRetention:
return {} return {}
if not dry_run: if not dry_run:
self.last = time.monotonic() self.last = time.monotonic()
self.service.assets.recover_files()
config = self.config() config = self.config()
if not config.enabled: if not config.enabled:
return {"deleted": 0, "bytes_freed": 0, "disabled": True} return {"deleted": 0, "bytes_freed": 0, "disabled": True}
@@ -100,7 +115,7 @@ class LocalRetention:
total = sum(x[2] for x in rows) total = sum(x[2] for x in rows)
removed = [] removed = []
freed = 0 freed = 0
for row, path, size in rows: for row, _path, size in rows:
# Manual uploaded photos are never removed by automatic retention. # Manual uploaded photos are never removed by automatic retention.
if row["id"] in protected or row["kind"] not in ("generated", "cache"): if row["id"] in protected or row["kind"] not in ("generated", "cache"):
continue continue
@@ -118,15 +133,7 @@ class LocalRetention:
total -= size total -= size
freed += size freed += size
if not dry_run: if not dry_run:
path.unlink(missing_ok=True) self.service.libraries.remove_asset(row["id"])
for table, key in [
("asset_metadata", "asset_id"),
("asset_sources", "asset_id"),
("assets", "id"),
]:
self.service.db.execute(
f"DELETE FROM {table} WHERE {key}=?", (row["id"],)
)
result = {"deleted": len(removed), "bytes_freed": freed, "dry_run": dry_run} result = {"deleted": len(removed), "bytes_freed": freed, "dry_run": dry_run}
if not dry_run: if not dry_run:
self.service.db.execute( self.service.db.execute(
+26
View File
@@ -107,6 +107,8 @@ Content = Annotated[
class Options(StrictModel): class Options(StrictModel):
generate_thumbnail: bool = True
thumbnail_plan_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$")
cleanup_unstarred: bool = False cleanup_unstarred: bool = False
duration: int = Field(default=0, ge=0, le=604800) duration: int = Field(default=0, ge=0, le=604800)
restore_previous: bool = False restore_previous: bool = False
@@ -168,6 +170,10 @@ class ErrorResponse(BaseModel):
class OperationResult(BaseModel): class OperationResult(BaseModel):
source_image_id: str | None = None
outputs: list[dict[str, Any]] = Field(default_factory=list)
errors: list[dict[str, Any]] = Field(default_factory=list)
wrist_thumbnail: dict[str, Any] | None = None
image_name: str | None = None image_name: str | None = None
preview_url: str | None = None preview_url: str | None = None
cover_id: int | None = None cover_id: int | None = None
@@ -281,6 +287,9 @@ class RestoreContent(StrictModel):
class JobInput(StrictModel): class JobInput(StrictModel):
library_pipeline: bool = False
generate_thumbnail: bool = False
thumbnail_plan_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$")
cleanup_unstarred: bool = False cleanup_unstarred: bool = False
enabled: bool = True enabled: bool = True
device_id: str = "living-room" device_id: str = "living-room"
@@ -293,6 +302,23 @@ class JobInput(StrictModel):
@model_validator(mode="after") @model_validator(mode="after")
def validate_options(self): def validate_options(self):
if self.library_pipeline and (
not isinstance(self.source, PhotoSourceContent)
or self.generate_thumbnail
or self.cleanup_unstarred
):
raise ValueError(
"Source-library tasks require a photo source and use terminal-bound generation plans"
)
if self.generate_thumbnail and (
not self.thumbnail_plan_id
or not isinstance(self.source, PhotoSourceContent)
):
raise ValueError(
"Thumbnail generation requires a photo source and one saved plan"
)
if not self.generate_thumbnail and self.thumbnail_plan_id:
raise ValueError("Enable thumbnail generation before selecting a plan")
if self.cleanup_unstarred and not ( if self.cleanup_unstarred and not (
isinstance(self.source, PhotoSourceContent) and self.source.item_id is None isinstance(self.source, PhotoSourceContent) and self.source.item_id is None
): ):
@@ -23,7 +23,7 @@ def dimensions(root: Path, path: Path):
with os.fdopen(fd, "rb") as stream: with os.fdopen(fd, "rb") as stream:
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
return None return None
with Image.open(stream) as image: with Image.open(stream, formats=("JPEG", "PNG", "WEBP")) as image:
width, height = image.size width, height = image.size
if image.getexif().get(274, 1) in (5, 6, 7, 8): if image.getexif().get(274, 1) in (5, 6, 7, 8):
width, height = height, width width, height = height, width
+60 -5
View File
@@ -30,7 +30,13 @@ from pigway_frame_flow.storage.database import encode
class PhotoSourceInput(StrictModel): class PhotoSourceInput(StrictModel):
name: str = Field(min_length=1, max_length=120) name: str = Field(min_length=1, max_length=120)
kind: Literal[ kind: Literal[
"local", "synology-photos", "webdav", "google-drive", "alipan", "baidu" "local",
"synology-photos",
"webdav",
"google-drive",
"alipan",
"baidu",
"uploads",
] ]
location: str = Field(min_length=1, max_length=2048) location: str = Field(min_length=1, max_length=2048)
nas_root_path: str | None = Field(default=None, max_length=2048, pattern=r"^/") nas_root_path: str | None = Field(default=None, max_length=2048, pattern=r"^/")
@@ -88,6 +94,9 @@ class PhotoSources:
self.db, self.settings, self.assets = db, settings, assets self.db, self.settings, self.assets = db, settings, assets
self.cache = {} self.cache = {}
self.api_items = {} self.api_items = {}
from pigway_frame_flow.providers.source_browser import SourceBrowser
self.browser = SourceBrowser(self)
self.synology = SynologyPhotos(settings) self.synology = SynologyPhotos(settings)
# Register provider secrets before request IDs or errors can be reflected. # Register provider secrets before request IDs or errors can be reflected.
for row in self.list(): for row in self.list():
@@ -129,6 +138,12 @@ class PhotoSources:
def save(self, source_id, source): def save(self, source_id, source):
self.require_api_source(source) self.require_api_source(source)
if source.kind == "uploads" and (
source_id != "manual-uploads" or source.location != "."
):
raise FlowError(E.INVALID_REQUEST, "Invalid manual upload source")
if source_id == "manual-uploads" and source.kind != "uploads":
raise FlowError(E.CONFLICT, "Reserved manual upload source")
if source.minimum_brightness > source.maximum_brightness: if source.minimum_brightness > source.maximum_brightness:
raise FlowError(E.INVALID_REQUEST, "Brightness range is reversed") raise FlowError(E.INVALID_REQUEST, "Brightness range is reversed")
if source.kind not in ("local", "synology-photos") and ( if source.kind not in ("local", "synology-photos") and (
@@ -307,6 +322,20 @@ class PhotoSources:
self.require_api_source(source) self.require_api_source(source)
if not source.enabled: if not source.enabled:
raise FlowError(E.CONFLICT, "Photo source is disabled", 409) raise FlowError(E.CONFLICT, "Photo source is disabled", 409)
if source.kind == "uploads":
return [
{
"id": row["item_id"],
"name": row["name"],
"caption": json.loads(row["caption"]),
"starred": bool(row["starred"]),
"excluded": bool(row["excluded"]),
}
for row in self.db.all(
"SELECT s.item_id,s.caption,a.name,COALESCE(p.starred,0) AS starred,COALESCE(p.excluded,0) AS excluded FROM source_images s JOIN assets a ON a.id=s.id LEFT JOIN photo_preferences p ON p.source_id=s.source_id AND p.item_id=s.item_id WHERE s.source_id=? ORDER BY s.created_at DESC,s.id DESC",
(source_id,),
)
]
session_identity = ( session_identity = (
self.synology.sid() if source.kind == "synology-photos" else None self.synology.sid() if source.kind == "synology-photos" else None
) )
@@ -584,7 +613,16 @@ class PhotoSources:
def read(self, source_id, item_id=None, *, target_size=(1200, 1600)): def read(self, source_id, item_id=None, *, target_size=(1200, 1600)):
source = self.get(source_id) source = self.get(source_id)
items = self.catalog(source_id, target_size=target_size) browsed = (
self.browser.cached(source_id, item_id)
if item_id and source.kind == "synology-photos"
else None
)
items = (
[browsed[1]]
if browsed
else self.catalog(source_id, target_size=target_size)
)
if not item_id: if not item_id:
items = eligible(self.db, source_id, items) items = eligible(self.db, source_id, items)
if not items: if not items:
@@ -598,6 +636,18 @@ class PhotoSources:
) )
if item is None: if item is None:
raise FlowError(E.NOT_FOUND, "Photo is not in this source", 404) raise FlowError(E.NOT_FOUND, "Photo is not in this source", 404)
if source.kind == "uploads":
row = self.db.one(
"SELECT id,caption FROM source_images WHERE source_id=? AND item_id=?",
(source_id, item["id"]),
)
if not row:
raise FlowError(E.NOT_FOUND, "Uploaded source picture not found", 404)
return (
self.assets.path(row["id"]).read_bytes(),
item["name"],
json.loads(row["caption"]),
)
if source.kind == "local": if source.kind == "local":
root = self.settings.photo_root.resolve() root = self.settings.photo_root.resolve()
path = root / item["id"] path = root / item["id"]
@@ -641,13 +691,18 @@ class PhotoSources:
raise FlowError(E.NOT_FOUND, "Photo is unavailable", 404) from None raise FlowError(E.NOT_FOUND, "Photo is unavailable", 404) from None
elif source.kind == "synology-photos": elif source.kind == "synology-photos":
data = self.synology.download( data = self.synology.download(
self.api_items[source_id][item["id"]], space=source.people_space browsed[2] if browsed else self.api_items[source_id][item["id"]],
space=source.people_space,
) )
if source.folder_caption: if source.folder_caption:
caption = dict(item.get("caption") or {}) caption = dict(item.get("caption") or {})
try: try:
caption["people"] = " & ".join( caption["people"] = " & ".join(
self.synology.item_people(item["id"], space=source.people_space) [p["name"] for p in item.get("people", []) if p["name"]]
if browsed
else self.synology.item_people(
item["id"], space=source.people_space
)
) )
except FlowError: except FlowError:
# Optional labels must not prevent displaying a valid photo. # Optional labels must not prevent displaying a valid photo.
@@ -671,7 +726,7 @@ class PhotoSources:
from PIL import Image from PIL import Image
with Image.open(BytesIO(data)) as image: with Image.open(BytesIO(data), formats=("JPEG", "PNG", "WEBP")) as image:
width, height = image.size width, height = image.size
if image.getexif().get(274, 1) in (5, 6, 7, 8): if image.getexif().get(274, 1) in (5, 6, 7, 8):
width, height = height, width width, height = height, width
+2 -2
View File
@@ -61,7 +61,7 @@ class PhotosCache:
os.fsync(handle.fileno()) os.fsync(handle.fileno())
if not 0 < part.stat().st_size <= self.max_bytes: if not 0 < part.stat().st_size <= self.max_bytes:
raise ValueError() raise ValueError()
with Image.open(part) as image: with Image.open(part, formats=("JPEG", "PNG", "WEBP")) as image:
if image.format not in ("JPEG", "PNG", "WEBP"): if image.format not in ("JPEG", "PNG", "WEBP"):
raise ValueError() raise ValueError()
if image.width * image.height > MAX_IMAGE_PIXELS: if image.width * image.height > MAX_IMAGE_PIXELS:
@@ -69,7 +69,7 @@ class PhotosCache:
image.verify() image.verify()
# Decode before committing; truncated/compressed-bomb payloads # Decode before committing; truncated/compressed-bomb payloads
# must never become a reusable cache hit. # must never become a reusable cache hit.
with Image.open(part) as image: with Image.open(part, formats=("JPEG", "PNG", "WEBP")) as image:
image.load() image.load()
part.replace(path) part.replace(path)
self.downloads += 1 self.downloads += 1
@@ -0,0 +1,252 @@
"""Bounded provider browsing: page metadata first, download only selected photos."""
import json
import time
from collections import OrderedDict
from datetime import date, datetime, timedelta
from datetime import time as day_time
from typing import Literal
from urllib.parse import quote
from zoneinfo import ZoneInfo
from pydantic import Field, model_validator
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import StrictModel
from pigway_frame_flow.providers.photo_names import path_caption
from pigway_frame_flow.providers.photo_orientation import orientation
class BrowseFilters(StrictModel):
offset: int = Field(default=0, ge=0, le=10000000)
limit: int = Field(default=36, ge=1, le=60)
filename: str = Field(default="", max_length=200)
folder: str = Field(default="", max_length=500)
min_bytes: int = Field(default=0, ge=0, le=10**12)
max_bytes: int | None = Field(default=None, ge=0, le=10**12)
min_width: int = Field(default=0, ge=0, le=100000)
max_width: int | None = Field(default=None, ge=0, le=100000)
min_height: int = Field(default=0, ge=0, le=100000)
max_height: int | None = Field(default=None, ge=0, le=100000)
date_from: date | None = None
date_to: date | None = None
date_field: Literal["time", "indexed_time"] = "indexed_time"
person_id: int | None = Field(default=None, ge=1)
@model_validator(mode="after")
def ranges(self):
for low, high in (
(self.min_bytes, self.max_bytes),
(self.min_width, self.max_width),
(self.min_height, self.max_height),
):
if low is not None and high is not None and low > high:
raise ValueError("Filter range is reversed")
if self.date_from and self.date_to and self.date_from > self.date_to:
raise ValueError("Filter date range is reversed")
return self
class SourceBrowser:
def __init__(self, sources):
self.sources = sources
self.rows: OrderedDict[tuple, tuple] = OrderedDict()
def identity(self, source_id):
source = self.sources.get(source_id)
self.sources.require_api_source(source)
if not source.enabled:
raise FlowError(E.CONFLICT, "Photo source is disabled", 409)
session = (
self.sources.synology.sid() if source.kind == "synology-photos" else None
)
return source, (source_id, source.model_dump_json(), session)
def item(self, source, row):
folder = row.get("additional", {}).get("folder")
if isinstance(folder, dict):
folder = folder.get("name")
if row.get("type") != "photo" or not isinstance(folder, str):
return None
name = row.get("filename")
if not isinstance(name, str):
return None
folder = folder.strip("/")
location = source.location.strip("/") if source.location != "." else ""
if location and not (folder == location or folder.startswith(location + "/")):
return None
if not source.recursive and folder != location:
return None
path = folder + "/" + name
caption = path_caption(path)
if source.organized_only and caption is None:
return None
size = row.get("additional", {}).get("resolution", {})
width, height = size.get("width", 0), size.get("height", 0)
if not self.sources.resolution_matches(source, width, height):
return None
wanted = "portrait" if source.orientation == "auto" else source.orientation
if wanted and (not width or not height or orientation(width, height) != wanted):
return None
people = row.get("additional", {}).get("person", [])
ids = {str(p["id"]) for p in people if isinstance(p, dict) and "id" in p}
if source.people_filter:
selected = {str(i) for i in source.person_ids}
if not (
selected <= ids if source.person_match == "all" else selected & ids
):
return None
return {
"id": str(row["id"]),
"name": name,
"folder": folder,
"source_path": (source.nas_root_path or "").rstrip("/")
+ "/"
+ path.lstrip("/"),
"caption": caption if source.folder_caption else None,
"provider_preview": True,
"width": width,
"height": height,
"orientation": orientation(width, height),
"filesize": row.get("filesize"),
"time": row.get("time"),
"indexed_time": row.get("indexed_time"),
"people": [
{"id": p["id"], "name": p.get("name") or ""}
for p in people
if isinstance(p, dict) and "id" in p
],
}
def matches(self, item, filters):
for key in ("filename", "folder"):
value = item["name" if key == "filename" else key]
if getattr(filters, key).casefold() not in value.casefold():
return False
for key, low, high in (
("filesize", filters.min_bytes, filters.max_bytes),
("width", filters.min_width, filters.max_width),
("height", filters.min_height, filters.max_height),
):
value = item.get(key)
if (low or high is not None) and (
value is None or value < low or (high is not None and value > high)
):
return False
if filters.person_id and filters.person_id not in {
p["id"] for p in item["people"]
}:
return False
zone = ZoneInfo(self.sources.settings.timezone)
value = item.get(filters.date_field)
if filters.date_from and (
not value
or value < datetime.combine(filters.date_from, day_time(), zone).timestamp()
):
return False
if (
filters.date_to
and filters.date_to < date.max
and (
not value
or value
>= datetime.combine(
filters.date_to + timedelta(days=1), day_time(), zone
).timestamp()
)
):
return False
return True
def remember(self, identity, item, row):
key = (*identity, item["id"])
self.rows[key] = (time.monotonic(), item, row)
self.rows.move_to_end(key)
while len(self.rows) > 2048:
self.rows.popitem(last=False)
def cached(self, source_id, item_id):
if not self.rows:
return None
source, identity = self.identity(source_id)
cached = self.rows.get((*identity, item_id))
if cached and time.monotonic() - cached[0] < 1800:
return source, cached[1], cached[2]
return None
def page(self, source_id, offset, limit, filters):
source, identity = self.identity(source_id)
if source.kind != "synology-photos":
# Other providers retain their established catalog implementation.
items = self.sources.catalog(source_id)
if filters.model_dump(exclude_defaults=True, exclude={"offset", "limit"}):
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "These filters require Synology Photos"
)
return {
"items": items[offset : offset + limit],
"next_offset": offset + limit if offset + limit < len(items) else None,
"scanned": 0,
"paged_provider": False,
}
items = []
scanned = 0
prefix = "SYNO.FotoTeam" if source.people_space == "shared" else "SYNO.Foto"
for _ in range(4):
data = self.sources.synology.request(
prefix + ".Browse.Item",
"list",
7,
offset=offset,
limit=limit,
_sid=identity[2],
additional=json.dumps(
["folder", "thumbnail", "resolution", "orientation", "person"]
),
**({"person_id": filters.person_id} if filters.person_id else {}),
)
rows = data.get("list", [])
for index, row in enumerate(rows):
offset += 1
scanned += 1
item = self.item(source, row)
if item and self.matches(item, filters):
self.remember(identity, item, row)
item = {
**item,
"preview_url": f"/api/v1/source-libraries/{quote(source_id, safe='')}/browse/{quote(item['id'], safe='')}/preview",
}
items.append(item)
if len(items) == limit:
more = (
offset if index + 1 < len(rows) or len(rows) == limit else None
)
return {
"items": items,
"next_offset": more,
"scanned": scanned,
"paged_provider": True,
}
if len(rows) < limit:
return {
"items": items,
"next_offset": None,
"scanned": scanned,
"paged_provider": True,
}
return {
"items": items,
"next_offset": offset,
"scanned": scanned,
"paged_provider": True,
}
def preview(self, source_id, item_id):
entry = self.cached(source_id, item_id)
if not entry:
raise FlowError(
E.NOT_FOUND, "Photo preview expired; refresh the browser page", 404
)
source, _, row = entry
return self.sources.synology.download(row, space=source.people_space, size="m")
@@ -337,12 +337,14 @@ class SynologyPhotos:
self.cache[key] = (time.monotonic(), names) self.cache[key] = (time.monotonic(), names)
return list(self.cache[key][1].get(str(item_id), [])) return list(self.cache[key][1].get(str(item_id), []))
def download(self, row, *, space="shared", rendition="thumbnail"): def download(self, row, *, space="shared", rendition="thumbnail", size="xl"):
from pigway_frame_flow.providers.photos_cache import PhotosCache from pigway_frame_flow.providers.photos_cache import PhotosCache
sid = self.sid() sid = self.sid()
url, pin = self.endpoint() url, pin = self.endpoint()
prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto" prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto"
if size not in ("m", "xl"):
raise FlowError(E.INVALID_REQUEST, "Invalid Photos preview size")
if rendition == "thumbnail": if rendition == "thumbnail":
thumb = row.get("additional", {}).get("thumbnail", {}) thumb = row.get("additional", {}).get("thumbnail", {})
if not thumb.get("cache_key") or not thumb.get("unit_id"): if not thumb.get("cache_key") or not thumb.get("unit_id"):
@@ -353,13 +355,13 @@ class SynologyPhotos:
params = dict( params = dict(
id=thumb["unit_id"], id=thumb["unit_id"],
cache_key=thumb["cache_key"], cache_key=thumb["cache_key"],
size="xl", size=size,
type="unit", type="unit",
) )
else: else:
raise FlowError( raise FlowError(
E.UNSUPPORTED_CAPABILITY, E.UNSUPPORTED_CAPABILITY,
"Only verified XL thumbnails are supported; original download is unavailable", "Only verified Photos previews are supported; original download is unavailable",
) )
if not hasattr(self, "image_cache"): if not hasattr(self, "image_cache"):
self.image_cache = PhotosCache(self.settings) self.image_cache = PhotosCache(self.settings)
@@ -372,6 +374,7 @@ class SynologyPhotos:
space, space,
row["id"], row["id"],
rendition, rendition,
size,
row.get("indexed_time"), row.get("indexed_time"),
row.get("filesize"), row.get("filesize"),
row.get("additional", {}).get("thumbnail", {}).get("cache_key"), row.get("additional", {}).get("thumbnail", {}).get("cache_key"),
+137 -25
View File
@@ -1,7 +1,11 @@
import base64 import base64
import ipaddress import ipaddress
import json import json
import logging
import os
import socket import socket
import threading
from contextlib import contextmanager
from io import BytesIO from io import BytesIO
from urllib.parse import urlsplit from urllib.parse import urlsplit
from uuid import uuid4 from uuid import uuid4
@@ -17,9 +21,17 @@ MAX_IMAGE_PIXELS = 25_000_000
Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS
def convert(data, fit="contain", profile="color", *, size=(1200, 1600)): def convert(
data,
fit="contain",
profile="color",
*,
size=(1200, 1600),
centering=(0.5, 0.5),
quality=93,
):
try: try:
with Image.open(BytesIO(data)) as source: with Image.open(BytesIO(data), formats=("JPEG", "PNG", "WEBP")) as source:
if source.format not in {"JPEG", "PNG", "WEBP"}: if source.format not in {"JPEG", "PNG", "WEBP"}:
raise ValueError() raise ValueError()
if source.width * source.height > MAX_IMAGE_PIXELS: if source.width * source.height > MAX_IMAGE_PIXELS:
@@ -33,7 +45,7 @@ def convert(data, fit="contain", profile="color", *, size=(1200, 1600)):
img, size, method=Image.Resampling.LANCZOS, color="white" img, size, method=Image.Resampling.LANCZOS, color="white"
) )
else: else:
center = (0.5, 0.5) center = centering
if fit == "smart": if fit == "smart":
# Lightweight edge saliency: slide an aspect-correct window # Lightweight edge saliency: slide an aspect-correct window
# over a thumbnail; retain the region with most detail. # over a thumbnail; retain the region with most detail.
@@ -65,7 +77,7 @@ def convert(data, fit="contain", profile="color", *, size=(1200, 1600)):
if profile == "grayscale": if profile == "grayscale":
result = ImageOps.grayscale(result).convert("RGB") result = ImageOps.grayscale(result).convert("RGB")
out = BytesIO() out = BytesIO()
result.save(out, "JPEG", quality=93, optimize=True) result.save(out, "JPEG", quality=quality, optimize=True)
return out.getvalue() return out.getvalue()
except (OSError, ValueError, Image.DecompressionBombError): except (OSError, ValueError, Image.DecompressionBombError):
raise FlowError(E.INVALID_REQUEST, "Unsupported or invalid image") from None raise FlowError(E.INVALID_REQUEST, "Unsupported or invalid image") from None
@@ -74,17 +86,99 @@ def convert(data, fit="contain", profile="color", *, size=(1200, 1600)):
class Assets: class Assets:
def __init__(self, db, settings): def __init__(self, db, settings):
self.db, self.settings = db, settings self.db, self.settings = db, settings
self.file_lock = threading.RLock()
db.execute(
"CREATE TABLE IF NOT EXISTS asset_file_journal(id TEXT PRIMARY KEY,kind TEXT NOT NULL)"
)
db.execute(
"CREATE TABLE IF NOT EXISTS asset_file_moves(id TEXT PRIMARY KEY,source_kind TEXT NOT NULL,target_kind TEXT NOT NULL)"
)
self.recover_files()
def put(self, data, name="image.jpg", kind="images"): @staticmethod
def sync_directory(directory):
fd = os.open(directory, os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
def recover_files(self):
with self.file_lock:
self._recover_files()
def _recover_files(self):
for row in self.db.all("SELECT * FROM asset_file_journal"):
path = self.settings.data_dir / row["kind"] / (row["id"] + ".jpg")
try:
if not self.db.one("SELECT id FROM assets WHERE id=?", (row["id"],)):
path.unlink(missing_ok=True)
path.with_suffix(".part").unlink(missing_ok=True)
if path.parent.exists():
self.sync_directory(path.parent)
self.db.execute(
"DELETE FROM asset_file_journal WHERE id=?", (row["id"],)
)
except OSError:
logging.getLogger(__name__).warning("Asset file cleanup pending")
for row in self.db.all("SELECT * FROM asset_file_moves"):
asset = self.db.one("SELECT kind FROM assets WHERE id=?", (row["id"],))
obsolete = (
row["source_kind"]
if asset and asset["kind"] == row["target_kind"]
else row["target_kind"]
)
path = self.settings.data_dir / obsolete / (row["id"] + ".jpg")
try:
path.unlink(missing_ok=True)
self.sync_directory(path.parent)
self.db.execute("DELETE FROM asset_file_moves WHERE id=?", (row["id"],))
except OSError:
logging.getLogger(__name__).warning("Asset promotion cleanup pending")
@contextmanager
def creating(self, data, name="image.jpg", kind="images"):
with self.file_lock:
with self._creating(data, name, kind) as asset:
yield asset
@contextmanager
def _creating(self, data, name="image.jpg", kind="images"):
from pathlib import Path from pathlib import Path
if kind not in {"images", "generated", "cache", "source-images", "renditions"}:
raise ValueError("Invalid asset kind")
name = Path(name).stem[:110] + ".jpg" name = Path(name).stem[:110] + ".jpg"
asset_id = uuid4().hex asset_id = uuid4().hex
(self.settings.data_dir / kind / (asset_id + ".jpg")).write_bytes(data) path = self.settings.data_dir / kind / (asset_id + ".jpg")
self.db.execute( path.parent.mkdir(exist_ok=True)
"INSERT INTO assets VALUES(?,?,?,?)", (asset_id, name, kind, now_iso()) self.db.execute("INSERT INTO asset_file_journal VALUES(?,?)", (asset_id, kind))
) temporary = path.with_suffix(".part")
return self.info(asset_id) try:
with temporary.open("xb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
temporary.replace(path)
self.sync_directory(path.parent)
with self.db.transaction():
self.db.execute(
"INSERT INTO assets VALUES(?,?,?,?)",
(asset_id, name, kind, now_iso()),
)
yield self.info(asset_id)
self.db.execute(
"DELETE FROM asset_file_journal WHERE id=?", (asset_id,)
)
except BaseException:
# The journal remains durable if filesystem cleanup also fails.
self.recover_files()
raise
def put(self, data, name="image.jpg", kind="images"):
with self.creating(data, name, kind) as asset:
return asset
def record_origin(self, asset_id, operation): def record_origin(self, asset_id, operation):
job = self.db.one( job = self.db.one(
@@ -115,20 +209,36 @@ class Assets:
) )
def promote(self, asset_id): def promote(self, asset_id):
row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,)) with self.file_lock:
if row and row["kind"] == "cache": row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
source = self.db.one( if row and row["kind"] == "cache":
"SELECT payload FROM asset_sources WHERE asset_id=?", (asset_id,) source = self.db.one(
) "SELECT payload FROM asset_sources WHERE asset_id=?", (asset_id,)
kind = ( )
"generated" kind = (
if source and json.loads(source["payload"]).get("type") == "template" "generated"
else "images" if source
) and json.loads(source["payload"]).get("type") == "template"
target = self.settings.data_dir / kind / (asset_id + ".jpg") else "images"
self.path(asset_id).replace(target) )
self.db.execute("UPDATE assets SET kind=? WHERE id=?", (kind, asset_id)) target = self.settings.data_dir / kind / (asset_id + ".jpg")
return self.info(asset_id) self.recover_files()
self.db.execute(
"INSERT INTO asset_file_moves VALUES(?,?,?)",
(asset_id, "cache", kind),
)
try:
# Keep the source valid until the database points to the durable target.
os.link(self.path(asset_id), target)
self.sync_directory(target.parent)
self.db.execute(
"UPDATE assets SET kind=? WHERE id=?", (kind, asset_id)
)
except BaseException:
self.recover_files()
raise
self.recover_files()
return self.info(asset_id)
def info(self, asset_id): def info(self, asset_id):
row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,)) row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
@@ -148,7 +258,9 @@ class Assets:
"job_id": meta["job_id"] if meta else None, "job_id": meta["job_id"] if meta else None,
"job_name": meta["job_name"] if meta else None, "job_name": meta["job_name"] if meta else None,
"asset_id": row["id"], "asset_id": row["id"],
"source": "generated" if row["kind"] == "generated" else "uploaded", "source": "generated"
if row["kind"] in ("generated", "renditions")
else "uploaded",
"name": row["name"], "name": row["name"],
"url": f"/api/v1/assets/{row['id']}", "url": f"/api/v1/assets/{row['id']}",
"created_at": row["created_at"], "created_at": row["created_at"],
+8 -3
View File
@@ -17,6 +17,9 @@ def render_captioned_photo(
*, *,
size=(1200, 1600), size=(1200, 1600),
layout_provider=None, layout_provider=None,
centering=(0.5, 0.5),
quality=93,
respect_fit=False,
): ):
width, height = size width, height = size
layout = (layout_provider or WatermarkLayout()).layout(size, caption) layout = (layout_provider or WatermarkLayout()).layout(size, caption)
@@ -31,12 +34,14 @@ def render_captioned_photo(
# keeping aspect ratio and cropping rather than stretching or letterboxing. # keeping aspect ratio and cropping rather than stretching or letterboxing.
photo_data = convert( photo_data = convert(
data, data,
"smart" if fit == "smart" else "cover", fit if respect_fit else ("smart" if fit == "smart" else "cover"),
profile, profile,
size=(width, photo_height), size=(width, photo_height),
centering=centering,
quality=quality,
) )
canvas = Image.new("RGB", size, "white") canvas = Image.new("RGB", size, "white")
with Image.open(BytesIO(photo_data)) as photo: with Image.open(BytesIO(photo_data), formats=("JPEG", "PNG", "WEBP")) as photo:
canvas.paste(photo, (0, 0)) canvas.paste(photo, (0, 0))
draw = ImageDraw.Draw(canvas) draw = ImageDraw.Draw(canvas)
values = [ values = [
@@ -64,5 +69,5 @@ def render_captioned_photo(
anchor="rm", anchor="rm",
) )
out = BytesIO() out = BytesIO()
canvas.save(out, "JPEG", quality=93, optimize=True) canvas.save(out, "JPEG", quality=quality, optimize=True)
return out.getvalue() return out.getvalue()
+3 -1
View File
@@ -177,7 +177,9 @@ class TemplateRenderer:
elif block["kind"] == "photo": elif block["kind"] == "photo":
asset_id = values.get("photo_id") asset_id = values.get("photo_id")
if asset_id: if asset_id:
with Image.open(self.assets.path(asset_id)) as photo: with Image.open(
self.assets.path(asset_id), formats=("JPEG", "PNG", "WEBP")
) as photo:
img.paste(ImageOps.fit(photo.convert("RGB"), (w, h)), (x, y)) img.paste(ImageOps.fit(photo.convert("RGB"), (w, h)), (x, y))
else: else:
draw.rectangle((x, y, x + w, y + h), outline="black", width=5) draw.rectangle((x, y, x + w, y + h), outline="black", width=5)
+63 -4
View File
@@ -15,6 +15,7 @@ from pigway_frame_flow.models import (
ImageContent, ImageContent,
JobInput, JobInput,
Options, Options,
PhotoSourceContent,
PlaylistInput, PlaylistInput,
RestoreContent, RestoreContent,
) )
@@ -86,13 +87,64 @@ class Scheduler:
row["last_result"] = operation["status"] row["last_result"] = operation["status"]
return rows return rows
def save_job(self, job, id=None): def validate_job(self, job):
self.service.registry.get(job.device_id) if not job.library_pipeline:
if job.source.type != "restore": self.service.registry.get(job.device_id)
if job.library_pipeline and not self.db.one(
"SELECT terminal_id FROM terminal_bindings WHERE source_id=?",
(job.source.source_id,),
):
raise FlowError(
E.CONFLICT,
"Bind terminals to the source library before saving this task",
409,
{"reason": "libraryBindFirst"},
)
if job.generate_thumbnail:
from pigway_frame_flow.display.thumbnail_plans import get_plan
get_plan(self.service, job.thumbnail_plan_id)
if job.library_pipeline:
self.service.photo_sources.get(job.source.source_id)
elif job.source.type != "restore":
self.service.validate( self.service.validate(
DisplayRequest(device_id=job.device_id, content=job.source), DisplayRequest(device_id=job.device_id, content=job.source),
writing=False, writing=False,
) )
def set_library_mode(self, job_id, enabled):
with self.service.lock:
row = self.db.one("SELECT payload FROM jobs WHERE id=?", (job_id,))
if not row:
raise FlowError(E.NOT_FOUND, "Job not found", 404)
values = json.loads(row["payload"])
if enabled and values.get("source", {}).get("type") != "photo_source":
raise FlowError(
E.INVALID_REQUEST,
"Only photo-source tasks support source-library mode",
422,
)
values["library_pipeline"] = enabled
if enabled:
values.update(
generate_thumbnail=False,
thumbnail_plan_id=None,
cleanup_unstarred=False,
)
job = JobInput.model_validate(values)
self.validate_job(job)
self.db.execute(
"UPDATE jobs SET payload=?,updated_at=? WHERE id=?",
(
encode(self.service.settings.sanitize(job.model_dump(mode="json"))),
now_iso(),
job_id,
),
)
return next(j for j in self.jobs() if j["id"] == job_id)
def save_job(self, job, id=None):
self.validate_job(job)
upcoming = next_run(job.schedule) upcoming = next_run(job.schedule)
if job.enabled and upcoming is None: if job.enabled and upcoming is None:
raise FlowError(E.INVALID_REQUEST, "One-time task must be in the future") raise FlowError(E.INVALID_REQUEST, "One-time task must be in the future")
@@ -183,9 +235,13 @@ class Scheduler:
restore_previous=job.restore_previous, restore_previous=job.restore_previous,
priority=job.priority, priority=job.priority,
cleanup_unstarred=job.cleanup_unstarred, cleanup_unstarred=job.cleanup_unstarred,
generate_thumbnail=job.generate_thumbnail,
thumbnail_plan_id=job.thumbnail_plan_id,
), ),
).model_dump(mode="json") ).model_dump(mode="json")
) )
if job.library_pipeline:
action = "library-generate"
if action == "display": if action == "display":
payload = self.upload_retries.scheduled_payload( payload = self.upload_retries.scheduled_payload(
row["id"], payload row["id"], payload
@@ -193,7 +249,10 @@ class Scheduler:
operation_id = self.service.submit( operation_id = self.service.submit(
action, action,
payload, payload,
job.device_id, ("source:" + job.source.source_id)
if job.library_pipeline
and isinstance(job.source, PhotoSourceContent)
else job.device_id,
"scheduler", "scheduler",
"schedule-" + uuid4().hex, "schedule-" + uuid4().hex,
"job-" + row["id"] + "-" + slot, "job-" + row["id"] + "-" + slot,
@@ -64,14 +64,7 @@ class UploadRetries:
) )
# Only disposable generated staging files are owned by this recovery. # Only disposable generated staging files are owned by this recovery.
if row and row["kind"] == "cache" and not (meta and meta["starred"]): if row and row["kind"] == "cache" and not (meta and meta["starred"]):
self.service.assets.path(asset_id).unlink(missing_ok=True) self.service.libraries.remove_asset(asset_id)
with self.db.transaction() as tx:
for table, field in (
("assets", "id"),
("asset_sources", "asset_id"),
("asset_metadata", "asset_id"),
):
tx.execute(f"DELETE FROM {table} WHERE {field}=?", (asset_id,))
def tick(self, current): def tick(self, current):
for job in self.db.all("SELECT * FROM jobs WHERE enabled=1"): for job in self.db.all("SELECT * FROM jobs WHERE enabled=1"):
+22 -6
View File
@@ -17,6 +17,7 @@ def encode(value):
class Database: class Database:
def __init__(self, path): def __init__(self, path):
self.lock = threading.RLock() self.lock = threading.RLock()
self._transaction_depth = 0
self.conn = sqlite3.connect(path, check_same_thread=False) self.conn = sqlite3.connect(path, check_same_thread=False)
self.conn.row_factory = sqlite3.Row self.conn.row_factory = sqlite3.Row
self.conn.executescript(""" self.conn.executescript("""
@@ -68,17 +69,32 @@ class Database:
@contextmanager @contextmanager
def transaction(self): def transaction(self):
with self.lock: with self.lock:
self.conn.execute("BEGIN IMMEDIATE") nested = self._transaction_depth > 0
savepoint = "tx_" + str(self._transaction_depth)
self.conn.execute("SAVEPOINT " + savepoint if nested else "BEGIN IMMEDIATE")
self._transaction_depth += 1
try: try:
yield self.conn yield self.conn
self.conn.commit() if nested:
except Exception: self.conn.execute("RELEASE SAVEPOINT " + savepoint)
self.conn.rollback() else:
self.conn.commit()
except BaseException:
if nested:
self.conn.execute("ROLLBACK TO SAVEPOINT " + savepoint)
self.conn.execute("RELEASE SAVEPOINT " + savepoint)
else:
self.conn.rollback()
raise raise
finally:
self._transaction_depth -= 1
def execute(self, query, args=()): def execute(self, query, args=()):
with self.lock, self.conn: with self.lock:
return self.conn.execute(query, args) if self._transaction_depth:
return self.conn.execute(query, args)
with self.conn:
return self.conn.execute(query, args)
def one(self, query, args=()): def one(self, query, args=()):
with self.lock: with self.lock:
+27 -39
View File
@@ -1,14 +1,14 @@
'use strict'; 'use strict';
const $=s=>document.querySelector(s); const $=s=>document.querySelector(s);
const state={lang:localStorage.getItem('ff-language')||'zh',theme:localStorage.getItem('ff-theme')||'system',page:location.hash.slice(1)||'dashboard',token:'',csrf:'',device:'living-room',settings:null,devices:[],templates:[],gallery:[],assets:[],blobs:[],tab:'cloud',preview:null,templatePreview:null,selectedTemplate:null,epoch:0}; const state={lang:localStorage.getItem('ff-language')||'zh',theme:localStorage.getItem('ff-theme')||'system',page:location.hash.slice(1)||'dashboard',token:'',csrf:'',device:'living-room',settings:null,devices:[],templates:[],assets:[],blobs:[],tab:'cloud',preview:null,templatePreview:null,selectedTemplate:null,epoch:0};
function forgetLogin(){state.galleryQuery='';state.galleryOrder='newest';state.galleryJobsRequest=null;state.galleryCache?.clear();localStorage.removeItem('ff-login');state.token='';state.csrf='';state.settings=null;state.devices=[];state.templates=[];} function forgetLogin(){localStorage.removeItem('ff-login');state.token='';state.csrf='';state.settings=null;state.devices=[];state.templates=[];}
async function loadConnection(){ async function loadConnection(){
const [settings,templates]=await Promise.all([api('/api/v1/settings'),api('/api/v1/templates')]); const [settings,templates]=await Promise.all([api('/api/v1/settings'),api('/api/v1/templates')]);
const devices=settings.device_catalog||await api('/api/v1/devices').catch(error=>{toast(error.message);return [];}); const devices=settings.device_catalog||await api('/api/v1/devices').catch(error=>{toast(error.message);return [];});
state.settings=settings;state.devices=devices;state.templates=templates;state.device=devices[0]?.id||settings.device_id||state.device; state.settings=settings;state.devices=devices;state.templates=templates;state.device=devices[0]?.id||settings.device_id||state.device;
if(settings.device_catalog&&state.page!=='dashboard')void api('/api/v1/devices').then(details=>{if(state.settings!==settings||!state.token)return;state.devices=details;shell();}).catch(error=>{if(state.settings===settings&&state.token)toast(error.message);}); if(settings.device_catalog&&state.page!=='dashboard')void api('/api/v1/devices').then(details=>{if(state.settings!==settings||!state.token)return;state.devices=details;shell();}).catch(error=>{if(state.settings===settings&&state.token)toast(error.message);});
} }
const icons={dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'}; const icons={dashboard:'◫',gallery:'▧','terminal-gallery':'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'};
const t=k=>window.MESSAGES[state.lang][k]||k; const t=k=>window.MESSAGES[state.lang][k]||k;
function operationErrorMessage(error){const reason=error?.details?.reason;if(reason&&t(reason)!==reason)return t(reason);return error?.code&&t(error.code)!==error.code?t(error.code):(error?.message||t('error'));} function operationErrorMessage(error){const reason=error?.details?.reason;if(reason&&t(reason)!==reason)return t(reason);return error?.code&&t(error.code)!==error.code?t(error.code):(error?.message||t('error'));}
const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
@@ -36,28 +36,20 @@ function applyTheme(){const dark=state.theme==='dark'||(state.theme==='system'&&
function revealCurrentNavigation(){const active=$('#nav .active'),sidebar=$('.sidebar');if(active&&matchMedia('(max-width:720px)').matches)sidebar.scrollLeft=active.offsetLeft-(sidebar.clientWidth-active.offsetWidth)/2;} function revealCurrentNavigation(){const active=$('#nav .active'),sidebar=$('.sidebar');if(active&&matchMedia('(max-width:720px)').matches)sidebar.scrollLeft=active.offsetLeft-(sidebar.clientWidth-active.offsetWidth)/2;}
window.addEventListener('resize',revealCurrentNavigation); window.addEventListener('resize',revealCurrentNavigation);
function deviceNameForId(id){return deviceDisplayName(state.devices.find(d=>d.id===id)||{id,name:id});} function deviceNameForId(id){return deviceDisplayName(state.devices.find(d=>d.id===id)||{id,name:id});}
function operationActionLabel(action){const labels={'render':['生成预览','Generate preview'],'render-ai':['AI 生成图片','Generate AI image'],'official-widget':['更新官方订阅','Update official widget'],'delete':['删除图片','Delete image'],'display':['显示图片','Display image'],'restore':['恢复图片','Restore image'],'local-upload':['保存到图库','Save to gallery'],'reconcile':['核对状态','Reconcile']};return labels[action]?.[state.lang==='zh'?0:1]||t('operation');} function operationActionLabel(action){const labels={'render':['生成预览','Generate preview'],'render-ai':['AI 生成图片','Generate AI image'],'official-widget':['更新官方订阅','Update official widget'],'delete':['删除图片','Delete image'],'display':['显示图片','Display image'],'restore':['恢复图片','Restore image'],'library-generate':['来源图库统一输出','Source-library outputs'],'local-upload':['准备本地成品','Prepare local output'],'reconcile':['核对状态','Reconcile']};return labels[action]?.[state.lang==='zh'?0:1]||t('operation');}
function operationClientLabel(client){return client==='admin'?tr('管理员','Administrator'):client==='scheduler'?tr('自动调度','Scheduler'):client;} function operationClientLabel(client){return client==='admin'?tr('管理员','Administrator'):client==='scheduler'?tr('自动调度','Scheduler'):client==='library-terminal'?tr('来源图库输出','Source-library output'):client;}
function operationResultHTML(o){ function operationResultHTML(o){
const r=o.result||{},parts=[]; const r=o.result||{},parts=[];
if(r.image_name)parts.push(`<div>${esc(r.image_name)}</div>`); if(r.image_name)parts.push(`<div>${esc(r.image_name)}</div>`);
if(r.image_id!=null)parts.push(`<small>${t('cloudImageId')}: ${esc(r.image_id)}</small>`); if(r.image_id!=null)parts.push(`<small>${t('cloudImageId')}: ${esc(r.image_id)}</small>`);
else if(r.asset_id||r.rendered_asset_id)parts.push(`<small>${t('localAssetId')}: ${esc(r.asset_id||r.rendered_asset_id)}</small>`); else if(r.asset_id||r.rendered_asset_id)parts.push(`<small>${t('localAssetId')}: ${esc(r.asset_id||r.rendered_asset_id)}</small>`);
if(r.source_image_id)parts.push(`<div>${tr('已选定源画面','Source picture selected')}</div><small>${tr('输出:','Outputs: ')}${(r.outputs||[]).map(x=>esc(x.terminal_id==='watch'?tr('手表','Watch'):deviceNameForId(x.terminal_id))).join('、')||'—'}</small>`);
if(o.error)parts.push(`<div class="history-status bad">${esc(operationErrorMessage(o.error))}</div>`); if(o.error)parts.push(`<div class="history-status bad">${esc(operationErrorMessage(o.error))}</div>`);
return parts.join('')||'—'; return parts.join('')||'—';
} }
function operationErrorLabel(error){if(!error?.code)return '';const label=t(error.code);return label===error.code?t('error'):label;}
function deviceDisplayName(device){const name=device.name||device.id;return device.id==='living-room'&&['living-room','Living room'].includes(name)?t('livingRoom'):name;} function deviceDisplayName(device){const name=device.name||device.id;return device.id==='living-room'&&['living-room','Living room'].includes(name)?t('livingRoom'):name;}
function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).map(([id,icon])=>`<button data-page="${id}" class="${id===state.page?'active':''}" ${id===state.page?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('');revealCurrentNavigation();} function renderFooter(){const v=state.versionInfo;if(v)$('#footer').innerHTML=`<span>PIGWay Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`;}
// A destination appears only when its connection and read/write adapter are available. function shell(){renderFooter();document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).filter(([id])=>id!=='source-browser').map(([id,icon])=>`<button data-page="${id}" class="${(id===state.page||id==='gallery'&&state.page==='source-browser')?'active':''}" ${(id===state.page||id==='gallery'&&state.page==='source-browser')?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#device').hidden=['gallery','terminal-gallery','source-browser','photo-sources','settings','connections','history'].includes(state.page);const watchSelected=state.page==='terminal-gallery'&&state.tab==='watch';$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${!watchSelected&&d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('')+`<option value="@watch" ${watchSelected?'selected':''}>${tr('手表 · 410×502','Watch · 410×502')}</option>`;revealCurrentNavigation();}
// Future cloud-drive destinations must implement their adapter before registration here.
const galleryDestinationAdapters=new Set(['kokonna','local-assets']);
function galleryDestinations(){
const configured=state.settings?.gallery_destinations;
const destinations=Array.isArray(configured)?configured:[{id:'cloud',adapter:'kokonna',name:'KoKonna',storage:'cloud',connected:true,supports_upload:true},{id:'local',adapter:'local-assets',name:state.settings?.storage_name||'NAS',storage:'local',connected:true,supports_upload:true}];
return destinations.filter(d=>d.connected&&d.supports_upload&&galleryDestinationAdapters.has(d.adapter));
}
function galleryDestinationName(destination){return `${destination.name} ${t(destination.storage==='local'?'local':'cloudGallery')}`;}
function imageLoading(img){ function imageLoading(img){
if(img.parentElement?.classList.contains('image-loading'))return img.parentElement; if(img.parentElement?.classList.contains('image-loading'))return img.parentElement;
const box=document.createElement('div');box.className='image-loading'; const box=document.createElement('div');box.className='image-loading';
@@ -113,7 +105,7 @@ async function openImageViewer(path,name){showModal(`<h2>${esc(name)}</h2><img i
async function confirmDialog(message,fn){showModal(`<h2>${t('confirm')}</h2><p>${esc(message)}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('modal-confirm','confirm',true)}</div>`);bind('modal-confirm',async()=>{await fn();$('#modal').close();});} async function confirmDialog(message,fn){showModal(`<h2>${t('confirm')}</h2><p>${esc(message)}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('modal-confirm','confirm',true)}</div>`);bind('modal-confirm',async()=>{await fn();$('#modal').close();});}
function detail(label,value){return `<div><small>${t(label)}</small><strong>${esc(value)}</strong></div>`;} function detail(label,value){return `<div><small>${t(label)}</small><strong>${esc(value)}</strong></div>`;}
function liveWriteAllowed(){return state.settings?.mock||state.settings?.write_enabled;} function liveWriteAllowed(){return state.settings?.mock||state.settings?.write_enabled;}
function guardDeviceButtons(){if(!liveWriteAllowed())document.querySelectorAll('[data-show],'+(state.tab==='cloud'?'.image-card .danger,':'')+'#show-name,#display-template,#display-upload,#restore').forEach(b=>{b.disabled=true;b.title=t('readOnlyWarning');});} function guardDeviceButtons(){if(!liveWriteAllowed())document.querySelectorAll('#display-template,#display-upload,#restore').forEach(b=>{b.disabled=true;b.title=t('readOnlyWarning');});}
async function templateEditor(selected){ async function templateEditor(selected){
const raw=$('#template-data');const assets=await api('/api/v1/assets?source=uploaded&limit=200');let values=JSON.parse(raw.value); const raw=$('#template-data');const assets=await api('/api/v1/assets?source=uploaded&limit=200');let values=JSON.parse(raw.value);
const panel=document.createElement('div');panel.id='template-fields';raw.before(panel); const panel=document.createElement('div');panel.id='template-fields';raw.before(panel);
@@ -132,32 +124,26 @@ const views={
connections:()=>connectionsPage(), connections:()=>connectionsPage(),
'photo-sources':async()=>{ $('#main').innerHTML='';await photoSourcesPanel(); }, 'photo-sources':async()=>{ $('#main').innerHTML='';await photoSourcesPanel(); },
async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;state.devices=state.devices.map(x=>x.id===d.id?{...x,name:d.name}:x);shell();const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`<div class="notice warning">${t('lowBattery')}</div>`:''}<section class="card hero"><div class="hero-info"><span class="badge ${d.status==='offline'?'bad':''}">● ${t(d.status).toUpperCase()}</span><h2>${esc(d.name)}</h2><p>${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}</p><div class="actions">${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}</div><p class="hint" style="margin-top:28px">${t('refreshInterval')}</p></div><div class="frame-demo"></div></section><div class="stats">${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['currentImageId',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`<div class="card stat"><div class="label">${t(l)}</div><div class="value">${esc(v)}</div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('device')}</h3><div class="detail-grid">${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('modelSource',t(d.model_verified?'modelVerified':'modelConfigured'))}</div></section><section class="card"><h3>${t('cloudRotation')}</h3><div class="detail-grid">${detail('playbackMode',s.mode?t(s.mode):'—')}${detail('switchInterval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}</div><p class="hint">${t('rotationNotice')}</p><a href="#playlists" id="go-playlists">${t('playlists')} →</a></section></div>`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};}, async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;state.devices=state.devices.map(x=>x.id===d.id?{...x,name:d.name}:x);shell();const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`<div class="notice warning">${t('lowBattery')}</div>`:''}<section class="card hero"><div class="hero-info"><span class="badge ${d.status==='offline'?'bad':''}">● ${t(d.status).toUpperCase()}</span><h2>${esc(d.name)}</h2><p>${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}</p><div class="actions">${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}</div><p class="hint" style="margin-top:28px">${t('refreshInterval')}</p></div><div class="frame-demo"></div></section><div class="stats">${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['currentImageId',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`<div class="card stat"><div class="label">${t(l)}</div><div class="value">${esc(v)}</div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('device')}</h3><div class="detail-grid">${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('modelSource',t(d.model_verified?'modelVerified':'modelConfigured'))}</div></section><section class="card"><h3>${t('cloudRotation')}</h3><div class="detail-grid">${detail('playbackMode',s.mode?t(s.mode):'—')}${detail('switchInterval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}</div><p class="hint">${t('rotationNotice')}</p><a href="#playlists" id="go-playlists">${t('playlists')} →</a></section></div>`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};},
async gallery(epoch){ async upload(epoch){
const destinations=galleryDestinations();if(!destinations.some(d=>d.id===state.tab))state.tab=destinations[0]?.id||'local'; state.preview=null;
const key=JSON.stringify([state.device,state.tab,state.assetOffset||0,state.assetOrigin||'all',state.assetJob||'']); const sources=await api('/api/v1/source-libraries');if(epoch!==state.epoch)return;
const cache=state.galleryCache||(state.galleryCache=new Map());const cached=cache.get(key); const options=[...sources];if(!options.some(x=>x.id==='manual-uploads'))options.push({id:'manual-uploads',name:tr('手动上传','Manual uploads')});
const render=(items,pending=false)=>{if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`<div class="section-heading"><div class="tabs gallery-tabs">${destinations.map(d=>`<button id="${esc(d.id)}-tab" data-gallery-destination="${esc(d.id)}" class="${state.tab===d.id?'active':''}">${esc(galleryDestinationName(d))}</button>`).join('')}</div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}<span id="gallery-list-status" class="hint" role="status" ${pending?'':'hidden'}>${pending?t('galleryListLoading'):''}</span></div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${`<div class="gallery" aria-busy="${pending}">${items.map((x,i)=>`<article class="card image-card"><button class="image-preview" data-original="${i}" aria-label="${t('original')} · ${esc(x.name)}"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"></button><h3 class="${state.tab==='local'?'source-path':''}" title="${esc(x.source_path||x.name)}">${esc(state.tab==='local'?(x.source_path||x.name):x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:t('local')} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`}${!pending&&!items.length?`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`:''}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});enhanceGallery();guardDeviceButtons();hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];await openImageViewer(x.url,x.name);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(state.tab==='local'?t('localDeleteConfirm'):x.current?t('deleteCurrent'):t('cloudDeleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});}; const selected=options.some(x=>x.id===libraryUI.source)?libraryUI.source:'manual-uploads';
render(cached||[],!cached); $('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="field"><label for="upload-source">${tr('保存到来源图库','Save in source library')}</label><select id="upload-source">${librarySelectOptions(options,selected,'')}</select><p class="hint">${tr('保存无叠加的源画面,不写回 NAS 或网盘。生成尺寸、裁切与文字由图库中的生成方案决定。','Save source pixels without overlays, without writing back to NAS or cloud drives. Library generation plans control output size, crop and captions.')}</p></div><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['cover','contain','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button><button id="view-upload-library" hidden>${tr('查看来源图库','View source library')}</button></div><p class="hint">${tr('预览仅用于直接显示到相框;保存到图库会使用源画面。','Preview settings apply to direct frame display; saving in the library uses source pixels.')}</p></section></div>`;
try{ bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);const preview=await api('/api/v1/preview',{method:'POST',body:form});if(!$('#file')||$('#file').files[0]!==file)return;state.preview=preview;state.preview.uploadFile=file;$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});
const items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets?limit=200&offset='+(state.assetOffset||0)+'&origin='+encodeURIComponent(state.assetOrigin||'all')+(state.assetJob?'&job_id='+encodeURIComponent(state.assetJob):'')); for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=!$('#file').files[0];$('#display-upload').disabled=true;$('#view-upload-library').hidden=true;};
if(epoch!==state.epoch)return;cache.set(key,items); bind('save-local',async()=>{const sourceId=$('#upload-source').value,file=state.preview?.uploadFile||$('#file').files[0];let picture;if(state.preview?.sourceUrl){picture=await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/upload-url',{method:'POST',body:{url:state.preview.sourceUrl}});}else{if(!file)throw new Error(t('selectFile'));const body=new FormData();body.append('file',file);picture=await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/upload',{method:'POST',body});}libraryUI.source=picture.source_id;libraryUI.plan=null;libraryUI.offset=0;$('#view-upload-library').hidden=false;toast(tr('已保存源画面,可在来源图库按方案生成','Source pixels saved. Generate plan versions in the source library.'));});
if(!cached||JSON.stringify(cached)!==JSON.stringify(items))render(items); bind('view-upload-library',()=>navigate('gallery'));bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));
}catch(error){
if(epoch!==state.epoch)return;if(!state.token)throw error;
const status=$('#gallery-list-status');status.hidden=false;status.setAttribute('role','alert');status.textContent=error.message;
$('.gallery')?.setAttribute('aria-busy','false');
}
}, },
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}}, async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));}, async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
async history(epoch){const data=await api('/api/v1/operation-history?'+historyQuery());if(epoch!==state.epoch)return;state.historyPageData=data;state.historyPage=data.page;const ops=data.items;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr data-operation-id="${esc(o.operation_id)}"><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${operationResultHTML(o)}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));}, async history(epoch){const data=await api('/api/v1/operation-history?'+historyQuery());if(epoch!==state.epoch)return;state.historyPageData=data;state.historyPage=data.page;const ops=data.items;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr data-operation-id="${esc(o.operation_id)}"><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(o.action==='library-generate'?tr('按来源绑定终端','Source-bound terminals'):deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${operationResultHTML(o)}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns settings-columns"><section class="card"><h3>${t('settings')}</h3><div class="form-grid settings-preferences"><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();} async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns settings-columns"><section class="card"><h3>${t('settings')}</h3><div class="form-grid settings-preferences"><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();}
}; };
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...changes};} function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,library_pipeline:j.library_pipeline||false,cleanup_unstarred:j.cleanup_unstarred||false,generate_thumbnail:j.generate_thumbnail||false,thumbnail_plan_id:j.thumbnail_plan_id||null,...changes};}
async function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`<h2>${t('playlists')}</h2><form id="playlist-form"><div class="field"><label for="playlist-name">${t('name')}</label><input id="playlist-name" required maxlength="120" value="${esc(p.name)}"></div><div class="field"><label for="playlist-images">${t('imageIds')}</label><input id="playlist-images" required value="${esc(p.image_ids.join(','))}" placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval">${t('interval')}</label><input id="playlist-interval" type="number" min="5" max="10080" value="${p.interval_minutes}"></div><div class="field"><label for="playlist-mode">${t('playbackMode')}</label><select id="playlist-mode"><option value="queue" ${p.mode==='queue'?'selected':''}>${t('queue')}</option><option value="random" ${p.mode==='random'?'selected':''}>${t('random')}</option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});await playlistImagePicker(p);} async function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`<h2>${t('playlists')}</h2><form id="playlist-form"><div class="field"><label for="playlist-name">${t('name')}</label><input id="playlist-name" required maxlength="120" value="${esc(p.name)}"></div><div class="field"><label for="playlist-images">${t('imageIds')}</label><input id="playlist-images" required value="${esc(p.image_ids.join(','))}" placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval">${t('interval')}</label><input id="playlist-interval" type="number" min="5" max="10080" value="${p.interval_minutes}"></div><div class="field"><label for="playlist-mode">${t('playbackMode')}</label><select id="playlist-mode"><option value="queue" ${p.mode==='queue'?'selected':''}>${t('queue')}</option><option value="random" ${p.mode==='random'?'selected':''}>${t('random')}</option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});await playlistImagePicker(p);}
$('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#device').onchange=()=>{state.device=$('#device').value;navigate();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme); $('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme);
applyTheme();shell();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>$('#footer').innerHTML=`<span>PIGWay Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`).catch(()=>{}); applyTheme();shell();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>{state.versionInfo=v;renderFooter();}).catch(()=>{});
setInterval(()=>{if(state.token&&state.page==='dashboard'&&!$('#modal').open)navigate();},60000); setInterval(()=>{if(state.token&&state.page==='dashboard'&&!$('#modal').open)navigate();},60000);
// Keep Cron available for exceptional schedules, but make ordinary schedules form-based. // Keep Cron available for exceptional schedules, but make ordinary schedules form-based.
@@ -176,14 +162,16 @@ async function jobEditor(job){
function updateScheduleField(){const type=$('#job-schedule').value;const interval=['hours','minutes'].includes(type);$('#schedule-value').min=interval?'1':'';$('#schedule-value').max=type==='hours'?'168':type==='minutes'?'10080':'';$('#schedule-value').step=interval?'1':'';$('#schedule-label').textContent=interval?tr('间隔数值','Interval'):type==='cron'?scheduleTypeLabel(type):t(['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=interval?'number':type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';$('#schedule-hint').textContent=scheduleHint(type);} function updateScheduleField(){const type=$('#job-schedule').value;const interval=['hours','minutes'].includes(type);$('#schedule-value').min=interval?'1':'';$('#schedule-value').max=type==='hours'?'168':type==='minutes'?'10080':'';$('#schedule-value').step=interval?'1':'';$('#schedule-label').textContent=interval?tr('间隔数值','Interval'):type==='cron'?scheduleTypeLabel(type):t(['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=interval?'number':type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';$('#schedule-hint').textContent=scheduleHint(type);}
$('#job-schedule').onchange=()=>{updateScheduleField();$('#schedule-value').value='';}; $('#job-schedule').onchange=()=>{updateScheduleField();$('#schedule-value').value='';};
updateScheduleField(); updateScheduleField();
$('#job-form').onsubmit=busy(async event=>{event.preventDefault();const type=$('#job-schedule').value;const value=$('#schedule-value').value;const schedule=['hours','minutes'].includes(type)?{type:'interval',interval_minutes:Number(value)*(type==='hours'?60:1)}:{type};if(['hours','minutes'].includes(type)){}else if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse(source.value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:false,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?.id?'/'+job.id:''),{method:job?.id?'PUT':'POST',body:payload});$('#modal').close();await navigate();}); $('#job-form').onsubmit=busy(async event=>{event.preventDefault();const type=$('#job-schedule').value;const value=$('#schedule-value').value;const schedule=['hours','minutes'].includes(type)?{type:'interval',interval_minutes:Number(value)*(type==='hours'?60:1)}:{type};if(['hours','minutes'].includes(type)){}else if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse(source.value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:false,library_pipeline:$('#job-library-pipeline').checked,generate_thumbnail:!$('#job-library-pipeline').checked&&$('#job-thumbnail').checked,thumbnail_plan_id:!$('#job-library-pipeline').checked&&$('#job-thumbnail').checked?$('#job-thumbnail-plan').value:null,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?.id?'/'+job.id:''),{method:job?.id?'PUT':'POST',body:payload});$('#modal').close();await navigate();});
await jobSourceEditor(j); await jobSourceEditor(j);
await thumbnailJobFields(j);
await libraryJobFields(j,!job?.id);
} }
views.automation=async epoch=>{ views.automation=async epoch=>{
const jobs=await api('/api/v1/jobs'); const jobs=await api('/api/v1/jobs');
if(epoch!==state.epoch)return; if(epoch!==state.epoch)return;
$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">${state.lang==='zh'?'上海时间':'Shanghai time'} · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table class="automation-table"><thead><tr>${['name','targetFrame','schedule','status','nextRun','lastRun','result','action'].map(key=>`<th>${key==='targetFrame'?tr('目标相框','Target frame'):t(key)}</th>`).join('')}</tr></thead><tbody>${jobs.map((job,index)=>`<tr><td><strong>${esc(job.name)}</strong></td><td>${esc(deviceDisplayName(state.devices.find(d=>d.id===job.device_id)||{id:job.device_id,name:job.device_id}))}</td><td>${esc(scheduleSummary(job.schedule))}</td><td><span class="automation-status">${t(job.enabled?'enabled':'disabled')}</span></td><td>${fmt(job.next_run)}</td><td>${fmt(job.last_run)}</td><td><code>${esc(job.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${index}">${t('edit')}</button><button data-job-toggle="${index}">${t(job.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${index}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`; $('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">${state.lang==='zh'?'上海时间':'Shanghai time'} · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table class="automation-table"><thead><tr>${['name','targetFrame','schedule','status','nextRun','lastRun','result','action'].map(key=>`<th>${key==='targetFrame'?tr('输出目标','Output target'):t(key)}</th>`).join('')}</tr></thead><tbody>${jobs.map((job,index)=>`<tr><td><strong>${esc(job.name)}</strong></td><td>${esc(job.library_pipeline?tr('按来源绑定终端','Source-bound terminals'):deviceDisplayName(state.devices.find(d=>d.id===job.device_id)||{id:job.device_id,name:job.device_id}))}</td><td>${esc(scheduleSummary(job.schedule))}</td><td><span class="automation-status">${t(job.enabled?'enabled':'disabled')}</span></td><td>${fmt(job.next_run)}</td><td>${fmt(job.last_run)}</td><td><code>${esc(job.library_pipeline&&job.last_result==='COMPLETED'?tr('生成与提交完成','Generated and submitted'):job.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${index}">${t('edit')}</button><button data-job-toggle="${index}">${t(job.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${index}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;
bind('new-job',()=>jobEditor()); bind('new-job',()=>jobEditor());
document.querySelectorAll('[data-job-edit]').forEach(button=>button.onclick=()=>jobEditor(jobs[button.dataset.jobEdit])); document.querySelectorAll('[data-job-edit]').forEach(button=>button.onclick=()=>jobEditor(jobs[button.dataset.jobEdit]));
document.querySelectorAll('[data-job-toggle]').forEach(button=>button.onclick=busy(async()=>{const job=jobs[button.dataset.jobToggle];await api('/api/v1/jobs/'+job.id,{method:'PUT',body:jobPayload(job,{enabled:!job.enabled})});await navigate();})); document.querySelectorAll('[data-job-toggle]').forEach(button=>button.onclick=busy(async()=>{const job=jobs[button.dataset.jobToggle];await api('/api/v1/jobs/'+job.id,{method:'PUT',body:jobPayload(job,{enabled:!job.enabled})});await navigate();}));
+3 -3
View File
@@ -26,16 +26,16 @@ async function layoutStudioPage(){
} }
async function connectionsPage(){ async function connectionsPage(){
$('#main').innerHTML=`<div class="notice">${tr('可选连接不会影响 NAS 照片和现有设备。账号密钥保存在受限配置文件中,外部客户端密钥仅显示一次。','Optional connections do not affect NAS photos or existing devices. Provider keys use restricted config files; client tokens are shown once.')}</div>`; $('#main').innerHTML=`<div class="notice">${tr('可选连接不会影响 NAS 照片和现有设备。账号密钥保存在受限配置文件中,外部客户端密钥仅显示一次。','Optional connections do not affect NAS photos or existing devices. Provider keys use restricted config files; client tokens are shown once.')}</div>`;
await aiSettingsPanel();await aiImagePanel();await storageSettingsPanel();await clientsPanel();await webhooksPanel();await externalProvidersPanel();await mqttPanel(); await aiSettingsPanel();await aiImagePanel();await clientsPanel();await webhooksPanel();await externalProvidersPanel();await mqttPanel();
} }
async function aiSettingsPanel(){ async function aiSettingsPanel(){
const config=await api('/api/v1/settings/ai-layout');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('AI 排版 · OpenAI 兼容','AI layout · OpenAI compatible')}</h3><p class="hint">${tr('只发送文字、尺寸和排版要求,不发送照片。填写完整 chat/completions 地址;使用服务商的模型名称。','Only text, dimensions and instructions are sent; never photos. Enter the full chat/completions URL and provider model name.')}</p><form id="ai-settings-form"><div class="field"><label for="ai-endpoint">Endpoint</label><input id="ai-endpoint" type="url" value="${esc(config.endpoint)}" placeholder="https://…/v1/chat/completions"></div><div class="field"><label for="ai-model">Model</label><input id="ai-model" value="${esc(config.model)}"></div><div class="field"><label for="ai-key">${tr('API Key(留空保留已有密钥)','API key (blank keeps existing)')}</label><input id="ai-key" type="password" autocomplete="off"><p class="hint">${t('apiKeyConfigured')}: ${t(config.key_configured?'yes':'no')}</p></div><button type="submit">${t('save')}</button></form>`;$('#main').append(card);$('#ai-settings-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-layout',{method:'PUT',body:{endpoint:$('#ai-endpoint').value,model:$('#ai-model').value}});const key=$('#ai-key').value;$('#ai-key').value='';if(key)await api('/api/v1/settings/ai-layout/key',{method:'PUT',body:{key}});toast(t('done'));}); const config=await api('/api/v1/settings/ai-layout');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('AI 排版 · OpenAI 兼容','AI layout · OpenAI compatible')}</h3><p class="hint">${tr('只发送文字、尺寸和排版要求,不发送照片。填写完整 chat/completions 地址;使用服务商的模型名称。','Only text, dimensions and instructions are sent; never photos. Enter the full chat/completions URL and provider model name.')}</p><form id="ai-settings-form"><div class="field"><label for="ai-endpoint">Endpoint</label><input id="ai-endpoint" type="url" value="${esc(config.endpoint)}" placeholder="https://…/v1/chat/completions"></div><div class="field"><label for="ai-model">Model</label><input id="ai-model" value="${esc(config.model)}"></div><div class="field"><label for="ai-key">${tr('API Key(留空保留已有密钥)','API key (blank keeps existing)')}</label><input id="ai-key" type="password" autocomplete="off"><p class="hint">${t('apiKeyConfigured')}: ${t(config.key_configured?'yes':'no')}</p></div><button type="submit">${t('save')}</button></form>`;$('#main').append(card);$('#ai-settings-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-layout',{method:'PUT',body:{endpoint:$('#ai-endpoint').value,model:$('#ai-model').value}});const key=$('#ai-key').value;$('#ai-key').value='';if(key)await api('/api/v1/settings/ai-layout/key',{method:'PUT',body:{key}});toast(t('done'));});
} }
async function storageSettingsPanel(){ async function storageSettingsPanel(){
const data=await api('/api/v1/storage');const p=data.policy;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('本地图库保留策略','Local storage retention')}</h3><p>${data.asset_count} ${t('count')} · ${(data.total_bytes/1024/1024).toFixed(1)} MiB · ${tr('受保护','Protected')}: ${data.protected_count}</p><p class="hint">${tr('仅清理生成图和预览缓存;手动上传、星标、当前/恢复图片以及任务/模板引用会保留。不会删除 NAS 原照片或云端图片。先查看清理预估,再开启自动清理。','Only generated images and preview cache are cleaned. Manual uploads, stars, current/restore content and task/template references are retained. NAS originals and cloud images are untouched. Preview cleanup before enabling it.')}</p><form id="storage-policy"><label class="check-row"><input id="storage-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="form-grid"><div class="field"><label for="storage-days">${tr('生成图保留天数','Generated image retention days')}</label><input id="storage-days" type="number" min="1" value="${p.generated_days}"></div><div class="field"><label for="storage-cache-days">${tr('缓存保留天数','Cache retention days')}</label><input id="storage-cache-days" type="number" min="1" value="${p.cache_days}"></div><div class="field"><label for="storage-size">${tr('容量目标 MiB','Target size MiB')}</label><input id="storage-size" type="number" min="1" value="${p.max_bytes/1024/1024}"></div></div><div class="actions"><button type="submit">${t('save')}</button><button id="storage-preview" type="button">${tr('预估清理','Preview cleanup')}</button></div><p id="storage-estimate"></p></form>`;$('#main').append(card);$('#storage-policy').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/storage/policy',{method:'PUT',body:{enabled:$('#storage-enabled').checked,generated_days:Number($('#storage-days').value),cache_days:Number($('#storage-cache-days').value),max_bytes:Number($('#storage-size').value)*1024*1024}});toast(t('done'));});bind('storage-preview',async()=>{const result=await api('/api/v1/storage/cleanup?dry_run=true',{method:'POST'});$('#storage-estimate').textContent=result.disabled?tr('策略未启用;不会清理','Policy disabled; nothing is removed'):tr('可清理 ','Eligible: ')+result.deleted+' · '+(result.bytes_freed/1024/1024).toFixed(1)+' MiB';}); const data=await api('/api/v1/storage');const p=data.policy;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('本地存储与清理','Local storage and cleanup')}</h3>${data.disk?`<p class="${data.disk.low_space?'notice warning':'hint'}">${tr('磁盘可用','Disk available')}: ${(data.disk.free_bytes/1024**3).toFixed(2)} GiB / ${(data.disk.total_bytes/1024**3).toFixed(2)} GiB${data.disk.low_space?tr(';空间不足,请清理无用文件或扩容,来源图片和成品不会自动删除。','; low space. Remove unused files or expand storage. Source pictures and versions are not automatically deleted.'):''}</p>`:''}${data.pending_file_cleanup?`<p class="notice warning">${tr('待重试文件清理','Pending file cleanup')}: ${data.pending_file_cleanup}</p>`:''}<p>${data.asset_count} ${t('count')} · ${(data.total_bytes/1024/1024).toFixed(1)} MiB · ${tr('受保护','Protected')}: ${data.protected_count}</p><p class="hint">${tr('仅清理生成图和预览缓存;手动上传、星标、当前/恢复图片以及任务/模板引用会保留。不会删除 NAS 原照片或云端图片。先查看清理预估,再开启自动清理。','Only generated images and preview cache are cleaned. Manual uploads, stars, current/restore content and task/template references are retained. NAS originals and cloud images are untouched. Preview cleanup before enabling it.')}</p><form id="storage-policy"><label class="check-row"><input id="storage-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="form-grid"><div class="field"><label for="storage-days">${tr('生成图保留天数','Generated image retention days')}</label><input id="storage-days" type="number" min="1" value="${p.generated_days}"></div><div class="field"><label for="storage-cache-days">${tr('缓存保留天数','Cache retention days')}</label><input id="storage-cache-days" type="number" min="1" value="${p.cache_days}"></div><div class="field"><label for="storage-size">${tr('容量目标 MiB','Target size MiB')}</label><input id="storage-size" type="number" min="1" value="${p.max_bytes/1024/1024}"></div></div><div class="actions"><button type="submit">${t('save')}</button><button id="storage-preview" type="button">${tr('预估清理','Preview cleanup')}</button></div><p id="storage-estimate"></p></form>`;$('#main').append(card);$('#storage-policy').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/storage/policy',{method:'PUT',body:{enabled:$('#storage-enabled').checked,generated_days:Number($('#storage-days').value),cache_days:Number($('#storage-cache-days').value),max_bytes:Number($('#storage-size').value)*1024*1024}});toast(t('done'));});bind('storage-preview',async()=>{const result=await api('/api/v1/storage/cleanup?dry_run=true',{method:'POST'});$('#storage-estimate').textContent=result.disabled?tr('策略未启用;不会清理','Policy disabled; nothing is removed'):tr('可清理 ','Eligible: ')+result.deleted+' · '+(result.bytes_freed/1024/1024).toFixed(1)+' MiB';});
} }
async function clientsPanel(){ async function clientsPanel(){
const clients=await api('/api/v1/clients');const scopes=['devices:read','images:read','display:write','templates:read','render:write','jobs:read','jobs:write','history:read'];const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('外部客户端权限','External client permissions')}</h3><p class="hint">${tr('网页继续使用账号登录。这里只为其他程序创建独立凭据,可随时撤销。','The web UI still uses account login. These credentials are for other programs and can be revoked.')}</p>${clients.map(c=>`<p>${esc(c.name)} · ${esc(c.client_id)} · ${t(c.enabled?'enabled':'disabled')} ${c.client_id!=='admin'&&c.enabled?`<button data-revoke="${esc(c.client_id)}">${tr('撤销','Revoke')}</button>`:''}</p>`).join('')}<form id="client-form"><div class="field"><label for="client-id">Client ID</label><input id="client-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label for="client-name">${t('name')}</label><input id="client-name" required></div>${scopes.map(s=>`<label class="check-row"><input type="checkbox" name="client-scope" value="${s}" ${['devices:read','display:write','templates:read','render:write'].includes(s)?'checked':''}>${s}</label>`).join('')}<button type="submit">${tr('创建客户端','Create client')}</button><div id="client-secret" class="notice" hidden></div></form>`;$('#main').append(card);document.querySelectorAll('[data-revoke]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/clients/'+b.dataset.revoke,{method:'DELETE'});await navigate();}));$('#client-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/clients',{method:'POST',body:{client_id:$('#client-id').value,name:$('#client-name').value,permissions:[...document.querySelectorAll('[name="client-scope"]:checked')].map(x=>x.value)}});$('#client-secret').hidden=false;$('#client-secret').textContent=tr('仅显示一次,请保存:','Shown once; save it: ')+r.token;}); const [clients,scopes]=await Promise.all([api('/api/v1/clients'),api('/api/v1/client-scopes')]);const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('外部客户端权限','External client permissions')}</h3><p class="hint">${tr('网页继续使用账号登录。这里只为其他程序创建独立凭据,可随时撤销。','The web UI still uses account login. These credentials are for other programs and can be revoked.')}</p>${clients.map(c=>`<p>${esc(c.name)} · ${esc(c.client_id)} · ${t(c.enabled?'enabled':'disabled')} ${c.client_id!=='admin'&&c.enabled?`<button data-revoke="${esc(c.client_id)}">${tr('撤销','Revoke')}</button>`:''}</p>`).join('')}<form id="client-form"><div class="field"><label for="client-id">Client ID</label><input id="client-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label for="client-name">${t('name')}</label><input id="client-name" required></div>${scopes.map(s=>`<label class="check-row"><input type="checkbox" name="client-scope" value="${s}" ${['devices:read','display:write','templates:read','render:write'].includes(s)?'checked':''}>${s}</label>`).join('')}<button type="submit">${tr('创建客户端','Create client')}</button><div id="client-secret" class="notice" hidden></div></form>`;$('#main').append(card);document.querySelectorAll('[data-revoke]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/clients/'+b.dataset.revoke,{method:'DELETE'});await navigate();}));$('#client-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/clients',{method:'POST',body:{client_id:$('#client-id').value,name:$('#client-name').value,permissions:[...document.querySelectorAll('[name="client-scope"]:checked')].map(x=>x.value)}});$('#client-secret').hidden=false;$('#client-secret').textContent=tr('仅显示一次,请保存:','Shown once; save it: ')+r.token;});
} }
async function webhooksPanel(){ async function webhooksPanel(){
const hooks=await api('/api/v1/webhooks');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>Webhook</h3><p class="hint">${tr('HTTPS 地址须先加入设置中的允许主机。事件带 HMAC 签名,最多重试三次;接收端应按事件 ID 去重。','Add the HTTPS host to the Settings allowlist first. Events are HMAC signed, with up to three attempts; deduplicate by event ID.')}</p>${hooks.map(h=>`<p>${esc(h.callback_url)} <button data-hook-delete="${h.id}">${t('delete')}</button></p>`).join('')}<form id="hook-form"><div class="field"><label for="hook-url">Callback URL</label><input id="hook-url" type="url" required></div><div class="field"><label for="hook-events">${tr('事件(逗号分隔)','Events (comma separated)')}</label><input id="hook-events" value="operation.completed,operation.failed"></div><button type="submit">${t('save')}</button><p id="hook-secret"></p></form>`;$('#main').append(card);document.querySelectorAll('[data-hook-delete]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/webhooks/'+b.dataset.hookDelete,{method:'DELETE'});await navigate();}));$('#hook-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/webhooks',{method:'POST',body:{callback_url:$('#hook-url').value,event_types:$('#hook-events').value.split(',').map(x=>x.trim()).filter(Boolean)}});$('#hook-secret').textContent=tr('签名密钥仅显示一次:','Signing key shown once: ')+r.signing_secret;}); const hooks=await api('/api/v1/webhooks');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>Webhook</h3><p class="hint">${tr('HTTPS 地址须先加入设置中的允许主机。事件带 HMAC 签名,最多重试三次;接收端应按事件 ID 去重。','Add the HTTPS host to the Settings allowlist first. Events are HMAC signed, with up to three attempts; deduplicate by event ID.')}</p>${hooks.map(h=>`<p>${esc(h.callback_url)} <button data-hook-delete="${h.id}">${t('delete')}</button></p>`).join('')}<form id="hook-form"><div class="field"><label for="hook-url">Callback URL</label><input id="hook-url" type="url" required></div><div class="field"><label for="hook-events">${tr('事件(逗号分隔)','Events (comma separated)')}</label><input id="hook-events" value="operation.completed,operation.failed"></div><button type="submit">${t('save')}</button><p id="hook-secret"></p></form>`;$('#main').append(card);document.querySelectorAll('[data-hook-delete]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/webhooks/'+b.dataset.hookDelete,{method:'DELETE'});await navigate();}));$('#hook-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/webhooks',{method:'POST',body:{callback_url:$('#hook-url').value,event_types:$('#hook-events').value.split(',').map(x=>x.trim()).filter(Boolean)}});$('#hook-secret').textContent=tr('签名密钥仅显示一次:','Signing key shown once: ')+r.signing_secret;});
+7 -28
View File
@@ -31,20 +31,6 @@ function displayDialog(content){
showModal(`<h2>${t('displayOptions')}</h2><form id="display-options"><p class="hint">${t('temporaryOptionsHint')}</p><label class="check-row"><input id="override-restore" type="checkbox">${t('restoreAfter')}</label><div class="form-grid"><div class="field"><label for="override-duration">${t('duration')}</label><input id="override-duration" type="number" min="1" max="604800" value="1800"></div><div class="field"><label for="override-priority">${t('priority')}</label><input id="override-priority" type="number" min="0" max="100" value="50"></div></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button id="confirm-display" type="submit" class="primary">${t('display')}</button></div></form>`); showModal(`<h2>${t('displayOptions')}</h2><form id="display-options"><p class="hint">${t('temporaryOptionsHint')}</p><label class="check-row"><input id="override-restore" type="checkbox">${t('restoreAfter')}</label><div class="form-grid"><div class="field"><label for="override-duration">${t('duration')}</label><input id="override-duration" type="number" min="1" max="604800" value="1800"></div><div class="field"><label for="override-priority">${t('priority')}</label><input id="override-priority" type="number" min="0" max="100" value="50"></div></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button id="confirm-display" type="submit" class="primary">${t('display')}</button></div></form>`);
$('#display-options').onsubmit=busy(async e=>{e.preventDefault();const restore=$('#override-restore').checked;const options={duration:restore?Number($('#override-duration').value):0,restore_previous:restore,priority:Number($('#override-priority').value)};await accepted('/api/v1/display',{device_id:state.device,content,options});$('#modal').close();await navigate('history');}); $('#display-options').onsubmit=busy(async e=>{e.preventDefault();const restore=$('#override-restore').checked;const options={duration:restore?Number($('#override-duration').value):0,restore_previous:restore,priority:Number($('#override-priority').value)};await accepted('/api/v1/display',{device_id:state.device,content,options});$('#modal').close();await navigate('history');});
} }
function enhanceGallery(){
const grid=$('.gallery');if(!grid)return;
const controls=document.createElement('div');controls.className='form-grid gallery-filters';controls.innerHTML=`<div class="field"><label for="gallery-search">${t('searchImages')}</label><input id="gallery-search" type="search"></div><div class="field"><label for="gallery-sort">${t('sortOrder')}</label><select id="gallery-sort"><option value="newest">${t('sortNewest')}</option><option value="oldest">${t('sortOldest')}</option><option value="name">${t('sortName')}</option></select></div>`;grid.before(controls);
if(state.tab==='local'){controls.classList.add('gallery-filters-local');const bar=document.createElement('div');bar.className='actions';bar.innerHTML=`<select id="asset-origin" aria-label="${t('originFilter')}">${['all','manual','task','external','template','unknown'].map(x=>`<option value="${x}">${t('origin_'+x)}</option>`).join('')}</select><select id="asset-job" aria-label="${t('taskOrigin')}"><option value="">${t('allTasks')}</option></select><button id="asset-prev">${t('previousPage')}</button><button id="asset-next">${t('nextPage')}</button><button id="asset-all">${t('selectAll')}</button><button id="asset-invert">${t('invertSelection')}</button><button id="asset-batch-star">${t('star')}</button><button id="asset-batch-unstar">${t('unstar')}</button><button id="asset-batch-exclude">${t('excludePhoto')}</button><button id="asset-batch-include">${t('includePhoto')}</button><button id="asset-batch-delete" class="danger">${t('deleteSelected')}</button>`;grid.before(bar);for(const [id,key] of [['asset-origin','originFilter'],['asset-job','taskOrigin']]){const field=document.createElement('div');field.className='field';const label=document.createElement('label');label.htmlFor=id;label.textContent=t(key);field.append(label,bar.querySelector('#'+id));controls.append(field);}const help=document.createElement('p');help.className='hint';help.id='gallery-selection-help';help.textContent=t('gallerySelectionHelp')+' '+t('starHelp')+' '+t('excludePhotoHelp');grid.before(help);bar.setAttribute('aria-describedby',help.id);$('#asset-origin').value=state.assetOrigin||'all';if(state.galleryJobsEpoch!==state.epoch){state.galleryJobsEpoch=state.epoch;state.galleryJobsRequest=api('/api/v1/jobs');}void state.galleryJobsRequest.then(jobs=>{if(!bar.isConnected)return;const picker=controls.querySelector('#asset-job');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;picker.append(option);}picker.value=state.assetJob||'';}).catch(error=>{if(bar.isConnected)toast(error.message);});$('#asset-origin').onchange=()=>{state.assetOffset=0;state.assetOrigin=$('#asset-origin').value;return navigate();};$('#asset-job').onchange=()=>{state.assetOffset=0;state.assetJob=$('#asset-job').value;state.assetOrigin=state.assetJob?'task':state.assetOrigin;return navigate();};$('#asset-prev').disabled=!(state.assetOffset||0);$('#asset-next').disabled=state.gallery.length<200;bind('asset-prev',()=>{state.assetOffset=Math.max(0,(state.assetOffset||0)-200);return navigate();});bind('asset-next',()=>{state.assetOffset=(state.assetOffset||0)+200;return navigate();});const visibleSelection=()=>[...grid.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);$('#asset-all').onclick=()=>{const boxes=visibleSelection();const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};$('#asset-invert').onclick=()=>visibleSelection().forEach(x=>x.checked=!x.checked);for(const action of ['star','unstar','exclude','include','delete']){bind('asset-batch-'+action,async()=>{const ids=[...document.querySelectorAll('.asset-select:checked')].map(x=>x.dataset.asset);if(!ids.length){toast(t('selectImagesFirst'));return;}if(action==='delete'&&!confirm(t('deleteSelectedConfirm')))return;const results=[];for(let i=0;i<ids.length;i+=100){const r=await api('/api/v1/assets/batch',{method:'POST',body:{asset_ids:ids.slice(i,i+100),action}});results.push(...r.results);}const failed=results.filter(x=>x.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}}
const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{state.galleryQuery=$('#gallery-search').value;const query=state.galleryQuery.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;state.galleryOrder=order;const indexes=cards.map((card,index)=>({card,item:state.gallery[index]}));indexes.sort((a,b)=>order==='name'?a.item.name.localeCompare(b.item.name):order==='oldest'?String(a.item.created_at).localeCompare(String(b.item.created_at)):String(b.item.created_at).localeCompare(String(a.item.created_at)));indexes.forEach(x=>grid.append(x.card));};
cards.forEach((card,i)=>{const item=state.gallery[i];if(state.tab==='local'){const star=document.createElement('button');star.textContent=t(item.starred?'unstar':'star');star.setAttribute('aria-pressed',String(item.starred));star.disabled=Boolean(item.excluded);star.title=item.excluded?t('clearExclusionFirst'):'';card.querySelector('.actions').append(star);const exclude=document.createElement('button');exclude.textContent=t(item.excluded?'includePhoto':'excludePhoto');exclude.setAttribute('aria-pressed',String(Boolean(item.excluded)));exclude.disabled=Boolean(item.starred);exclude.title=item.starred?t('clearStarFirst'):'';card.querySelector('.actions').append(exclude);exclude.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/exclusion',{method:'PUT',body:{excluded:!item.excluded}});await navigate();});if(item.starred){const kept=document.createElement('small');kept.className='photo-mark';kept.textContent=t('photoStarred');card.querySelector('h3').after(kept);}if(item.excluded){const badge=document.createElement('small');badge.className='excluded-photo';badge.textContent=t('photoExcluded');card.querySelector('h3').after(badge);}star.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/star',{method:'PUT',body:{starred:!item.starred}});await navigate();});const label=document.createElement('small');label.textContent=item.origin==='task'?t('taskOrigin')+' · '+(item.job_name||item.job_id):t('origin_'+item.origin);card.querySelector('h3').after(label);const select=document.createElement('input');select.type='checkbox';select.className='asset-select';select.dataset.asset=item.asset_id;select.setAttribute('aria-label',t('selectImage'));card.prepend(select);}if(item.width&&item.height){const dimensions=document.createElement('small');dimensions.textContent=item.width+' × '+item.height;card.querySelector('h3').after(dimensions);}const download=document.createElement('button');download.textContent=t(state.tab==='local'?'downloadShort':'download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local')card.querySelector('.actions').append(card.querySelector('[data-delete]'));});
if(state.tab==='local'){
const updateMarks=()=>{const selected=[...grid.querySelectorAll('.asset-select:checked')].map(box=>state.gallery.find(item=>item.asset_id===box.dataset.asset));const starred=selected.some(item=>item?.starred),excluded=selected.some(item=>item?.excluded);$('#asset-batch-star').disabled=!selected.length||excluded;$('#asset-batch-star').title=!selected.length?t('selectImagesFirst'):excluded?t('clearExclusionFirst'):'';$('#asset-batch-exclude').disabled=!selected.length||starred;$('#asset-batch-exclude').title=!selected.length?t('selectImagesFirst'):starred?t('clearStarFirst'):'';disabledMarkHints(grid.parentElement);};
grid.querySelectorAll('.asset-select').forEach(box=>box.onchange=updateMarks);
for(const id of ['asset-all','asset-invert']){const previous=$('#'+id).onclick;$('#'+id).onclick=event=>{previous(event);updateMarks();};}updateMarks();
}
disabledMarkHints(grid);
$('#gallery-search').value=state.galleryQuery||'';$('#gallery-sort').value=state.galleryOrder||'newest';$('#gallery-search').oninput();$('#gallery-sort').onchange();
}
async function weatherSettings(){ async function weatherSettings(){
const weather=await api('/api/v1/providers/weather');const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('weatherSettings')}</h3><form id="weather-form"><div class="field"><label for="weather-provider">${t('weatherSettings')}</label><select id="weather-provider"><option value="mock">${t('weatherMock')}</option><option value="open-meteo">Open-Meteo</option></select></div><div class="field"><label for="weather-location">${t('location')}</label><input id="weather-location" required maxlength="120" value="${esc(weather.config.location)}"></div><div class="form-grid weather-coordinates">${['latitude','longitude'].map(k=>`<div class="field"><label for="weather-${k}">${t(k)}</label><input id="weather-${k}" required type="number" step="any" min="${k==='latitude'?-90:-180}" max="${k==='latitude'?90:180}" value="${weather.config[k]}"></div>`).join('')}</div><button type="submit" class="primary">${t('save')}</button></form><div id="weather-result"></div>`;$('#main').append(box);$('#weather-provider').value=weather.config.provider; const weather=await api('/api/v1/providers/weather');const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('weatherSettings')}</h3><form id="weather-form"><div class="field"><label for="weather-provider">${t('weatherSettings')}</label><select id="weather-provider"><option value="mock">${t('weatherMock')}</option><option value="open-meteo">Open-Meteo</option></select></div><div class="field"><label for="weather-location">${t('location')}</label><input id="weather-location" required maxlength="120" value="${esc(weather.config.location)}"></div><div class="form-grid weather-coordinates">${['latitude','longitude'].map(k=>`<div class="field"><label for="weather-${k}">${t(k)}</label><input id="weather-${k}" required type="number" step="any" min="${k==='latitude'?-90:-180}" max="${k==='latitude'?90:180}" value="${weather.config[k]}"></div>`).join('')}</div><button type="submit" class="primary">${t('save')}</button></form><div id="weather-result"></div>`;$('#main').append(box);$('#weather-provider').value=weather.config.provider;
function result(w){$('#weather-result').innerHTML=`<p>${esc(w.current.temperature)} · ${esc(w.current.summary)}</p><p class="hint">${w.current.mock?t('weatherMock'):t('weatherAttribution')}${w.current.stale?' · '+t('stale'):''}</p><h4>${t('forecast')}</h4>${w.forecast.map(x=>`<p>${esc(x.date)} · ${esc(x.summary)} · ${x.minimum_c}–${x.maximum_c}°C · ${x.rain_probability}%</p>`).join('')}`;} function result(w){$('#weather-result').innerHTML=`<p>${esc(w.current.temperature)} · ${esc(w.current.summary)}</p><p class="hint">${w.current.mock?t('weatherMock'):t('weatherAttribution')}${w.current.stale?' · '+t('stale'):''}</p><h4>${t('forecast')}</h4>${w.forecast.map(x=>`<p>${esc(x.date)} · ${esc(x.summary)} · ${x.minimum_c}–${x.maximum_c}°C · ${x.rain_probability}%</p>`).join('')}`;}
@@ -74,8 +60,8 @@ async function playlistImagePicker(playlist){
const raw=$('#playlist-images');const box=document.createElement('div');raw.parentElement.after(box);box.innerHTML=`<p>${t('imageChoice')}</p><div id="playlist-image-choices">${t('loading')}</div>`;const images=await api('/api/v1/devices/'+state.device+'/images');if(!box.isConnected)return;const chosen=new Set(playlist.image_ids); const raw=$('#playlist-images');const box=document.createElement('div');raw.parentElement.after(box);box.innerHTML=`<p>${t('imageChoice')}</p><div id="playlist-image-choices">${t('loading')}</div>`;const images=await api('/api/v1/devices/'+state.device+'/images');if(!box.isConnected)return;const chosen=new Set(playlist.image_ids);
$('#playlist-image-choices').innerHTML=images.map(image=>`<label class="check-row"><input type="checkbox" data-playlist-image="${image.image_id}" ${chosen.has(image.image_id)?'checked':''}>${esc(image.name)} · #${image.image_id}${image.current?' · '+t('current'):''}</label>`).join('');box.querySelectorAll('[data-playlist-image]').forEach(el=>el.onchange=()=>{const id=Number(el.dataset.playlistImage);if(el.checked)chosen.add(id);else chosen.delete(id);raw.value=[...chosen].join(',');});raw.oninput=()=>{chosen.clear();raw.value.split(',').map(Number).filter(x=>x>0).forEach(x=>chosen.add(x));box.querySelectorAll('[data-playlist-image]').forEach(el=>el.checked=chosen.has(Number(el.dataset.playlistImage)));}; $('#playlist-image-choices').innerHTML=images.map(image=>`<label class="check-row"><input type="checkbox" data-playlist-image="${image.image_id}" ${chosen.has(image.image_id)?'checked':''}>${esc(image.name)} · #${image.image_id}${image.current?' · '+t('current'):''}</label>`).join('');box.querySelectorAll('[data-playlist-image]').forEach(el=>el.onchange=()=>{const id=Number(el.dataset.playlistImage);if(el.checked)chosen.add(id);else chosen.delete(id);raw.value=[...chosen].join(',');});raw.oninput=()=>{chosen.clear();raw.value.split(',').map(Number).filter(x=>x>0).forEach(x=>chosen.add(x));box.querySelectorAll('[data-playlist-image]').forEach(el=>el.checked=chosen.has(Number(el.dataset.playlistImage)));};
} }
async function enhanceSettings(){await weatherSettings();const config=await api('/api/v1/settings/image-url');const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('urlSettings')}</h3><form id="image-host-form"><div class="field"><label for="image-hosts">${t('allowedHosts')}</label><textarea id="image-hosts" rows="3">${esc(config.allowed_hosts.join('\n'))}</textarea><p class="hint">${t('urlHint')}</p></div><button type="submit">${t('save')}</button></form>`;$('#main').append(box);$('#image-host-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/image-url',{method:'PUT',body:{allowed_hosts:$('#image-hosts').value.split('\n').map(x=>x.trim()).filter(Boolean)}});toast(t('done'));});} async function enhanceSettings(){await storageSettingsPanel();await weatherSettings();const config=await api('/api/v1/settings/image-url');const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('urlSettings')}</h3><form id="image-host-form"><div class="field"><label for="image-hosts">${t('allowedHosts')}</label><textarea id="image-hosts" rows="3">${esc(config.allowed_hosts.join('\n'))}</textarea><p class="hint">${t('urlHint')}</p></div><button type="submit">${t('save')}</button></form>`;$('#main').append(box);$('#image-host-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/image-url',{method:'PUT',body:{allowed_hosts:$('#image-hosts').value.split('\n').map(x=>x.trim()).filter(Boolean)}});toast(t('done'));});}
function enhanceUpload(){const box=document.createElement('div');box.innerHTML=`<h3>${t('uploadURL')}</h3><div class="field"><label for="upload-url">${t('imageURL')}</label><input id="upload-url" type="url" placeholder="https://…"><p class="hint">${t('urlHint')}</p></div>${buttons('preview-url','uploadURL')}`;$('#make-preview').parentElement.append(box);bind('preview-url',async()=>{if(!$('#upload-url').checkValidity())return;const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image_url',url:$('#upload-url').value,fit:$('#fit').value,profile:$('#profile').value});const op=await waitOperation(id);state.preview={asset_id:op.result.asset_id,url:op.result.url};$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});} function enhanceUpload(){const box=document.createElement('div');box.innerHTML=`<h3>${t('uploadURL')}</h3><div class="field"><label for="upload-url">${t('imageURL')}</label><input id="upload-url" type="url" placeholder="https://…"><p class="hint">${t('urlHint')}</p></div>${buttons('preview-url','uploadURL')}`;$('#make-preview').parentElement.append(box);$('#upload-url').oninput=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('preview-url',async()=>{if(!$('#upload-url').checkValidity())return;const sourceUrl=$('#upload-url').value;const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image_url',url:sourceUrl,fit:$('#fit').value,profile:$('#profile').value});const op=await waitOperation(id);if(!box.isConnected||$('#upload-url').value!==sourceUrl)return;state.preview={asset_id:op.result.asset_id,url:op.result.url,sourceUrl};$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});}
async function poetryControls(){ async function poetryControls(){
if(state.selectedTemplate!=='daily-poetry')return; if(state.selectedTemplate!=='daily-poetry')return;
const provider=await api('/api/v1/providers/poetry');if(!$('#template-fields'))return; const provider=await api('/api/v1/providers/poetry');if(!$('#template-fields'))return;
@@ -146,14 +132,14 @@ function photosLoginDialog(){
async function photoSourcesPanel(){ async function photoSourcesPanel(){
const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section); const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section);
const sources=await api('/api/v1/photo-sources');if(!section.isConnected)return; const sources=(await api('/api/v1/photo-sources')).filter(s=>s.kind!=='uploads');if(!section.isConnected)return;
section.innerHTML=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><section id="photos-connection" class="photos-connection" aria-label="Synology Photos"></section><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint source-identity">${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}</p><p class="hint">${s.kind==='synology-photos'?t('sourcePhotosAPI'):t('sourceOwnCredentials')}</p><p class="hint">${esc(s.kind==='synology-photos'?'Synology Photos':s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}</p><div class="actions"><button data-source-browse="${esc(s.id)}">${t('browse')}</button><button data-source-random="${esc(s.id)}" ${s.enabled?'':'disabled'}>${t('randomPreview')}</button><button data-source-job="${esc(s.id)}">${t('sourceJob')}</button><button data-source-edit="${esc(s.id)}">${t('edit')}</button><button class="danger" data-source-delete="${esc(s.id)}">${t('remove')}</button></div></article>`).join(''):`<p class="hint">${t('noSources')}</p>`}</div><div id="source-browser"></div>`; section.innerHTML=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><section id="photos-connection" class="photos-connection" aria-label="Synology Photos"></section><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint source-identity">${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}</p><p class="hint">${s.kind==='synology-photos'?t('sourcePhotosAPI'):t('sourceOwnCredentials')}</p><p class="hint">${esc(s.kind==='synology-photos'?'Synology Photos':s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}</p><div class="actions"><button data-source-browse="${esc(s.id)}">${t('browse')}</button><button data-source-random="${esc(s.id)}" ${s.enabled?'':'disabled'}>${t('randomPreview')}</button><button data-source-job="${esc(s.id)}">${t('sourceJob')}</button><button data-source-edit="${esc(s.id)}">${t('edit')}</button><button class="danger" data-source-delete="${esc(s.id)}">${t('remove')}</button></div></article>`).join(''):`<p class="hint">${t('noSources')}</p>`}</div>`;
await photosConnectionPanel(); await photosConnectionPanel();
bind('add-photo-source',()=>photoSourceForm()); bind('add-photo-source',()=>photoSourceForm());
section.querySelectorAll('[data-source-edit]').forEach(b=>b.onclick=()=>photoSourceForm(sources.find(s=>s.id===b.dataset.sourceEdit))); section.querySelectorAll('[data-source-edit]').forEach(b=>b.onclick=()=>photoSourceForm(sources.find(s=>s.id===b.dataset.sourceEdit)));
section.querySelectorAll('[data-source-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/photo-sources/'+b.dataset.sourceDelete,{method:'DELETE'});await navigate('photo-sources');})); section.querySelectorAll('[data-source-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/photo-sources/'+b.dataset.sourceDelete,{method:'DELETE'});await navigate('photo-sources');}));
section.querySelectorAll('[data-source-random]').forEach(b=>b.onclick=busy(()=>previewSource(b.dataset.sourceRandom))); section.querySelectorAll('[data-source-random]').forEach(b=>b.onclick=busy(()=>previewSource(b.dataset.sourceRandom)));
section.querySelectorAll('[data-source-browse]').forEach(b=>b.onclick=busy(()=>browseSource(b.dataset.sourceBrowse))); section.querySelectorAll('[data-source-browse]').forEach(b=>b.onclick=()=>openSourceBrowser(b.dataset.sourceBrowse));
section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob)); section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob));
} }
async function photoSourceForm(source=null){ async function photoSourceForm(source=null){
@@ -161,13 +147,6 @@ async function photoSourceForm(source=null){
$('#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='';}; $('#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');}); $('#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;
box.innerHTML=`<h4>${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}</h4><p class="hint">${t('sourceBrowseHint')} ${t('excludePhotoHelp')}</p><div class="source-items">${result.items.map((x,i)=>`<article class="source-photo"><button data-source-item="${i}">${esc(x.name)}${x.width?`<small>${x.width} × ${x.height}</small>`:''}${x.caption?`<small>${esc([x.caption.date,x.caption.people,x.caption.title,x.caption.place,x.caption.filename].filter(Boolean).join(' · '))}</small>`:''}</button><div class="actions"><button data-source-star="${i}" aria-pressed="${Boolean(x.starred)}" ${x.excluded?'disabled':''} title="${x.excluded?t('clearExclusionFirst'):''}">${t(x.starred?'unstar':'star')}</button><button data-source-exclude="${i}" aria-pressed="${Boolean(x.excluded)}" ${x.starred?'disabled':''} title="${x.starred?t('clearStarFirst'):''}">${t(x.excluded?'includePhoto':'excludePhoto')}</button></div>${x.starred?`<small class="photo-mark">${t('photoStarred')}</small>`:''}${x.excluded?`<small class="excluded-photo">${t('photoExcluded')}</small>`:''}</article>`).join('')||t('noSourcePhotos')}</div><div class="actions"><button id="source-prev" ${offset?'':'disabled'}>${t('previousPage')}</button><button id="source-next" ${offset+result.items.length<result.total?'':'disabled'}>${t('nextPage')}</button>${buttons('source-refresh','refresh')}</div>`;
for(const key of ['star','exclude'])box.querySelectorAll('[data-source-'+key+']').forEach(button=>button.onclick=busy(async()=>{const item=result.items[Number(button.dataset[key==='star'?'sourceStar':'sourceExclude'])],field=key==='star'?'starred':'excluded';await api('/api/v1/photo-sources/'+id+'/preferences?device_id='+encodeURIComponent(state.device),{method:'PUT',body:{item_id:item.id,[field]:!item[field]}});await browseSource(id,offset);}));
disabledMarkHints(box);
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){ async function previewSource(id,item_id=null){
const acceptedResult=await api('/api/v1/devices/'+state.device+'/upload',{method:'POST',body:{type:'photo_source',source_id:id,item_id,fit:'contain',profile:'color'},headers:{'Idempotency-Key':clientUUID()}});const op=await waitOperation(acceptedResult.operation_id); const acceptedResult=await api('/api/v1/devices/'+state.device+'/upload',{method:'POST',body:{type:'photo_source',source_id:id,item_id,fit:'contain',profile:'color'},headers:{'Idempotency-Key':clientUUID()}});const op=await waitOperation(acceptedResult.operation_id);
showModal(`<h2>${t('preview')}</h2><img id="source-preview" class="preview" alt="${t('preview')}"><p class="hint">${t('sourcePreviewHint')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('source-display','display',true)}</div>`);await blobImage($('#source-preview'),op.result.url);$('#source-display').disabled=!liveWriteAllowed();bind('source-display',()=>displayDialog({type:'image',asset_id:op.result.asset_id})); showModal(`<h2>${t('preview')}</h2><img id="source-preview" class="preview" alt="${t('preview')}"><p class="hint">${t('sourcePreviewHint')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('source-display','display',true)}</div>`);await blobImage($('#source-preview'),op.result.url);$('#source-display').disabled=!liveWriteAllowed();bind('source-display',()=>displayDialog({type:'image',asset_id:op.result.asset_id}));
@@ -182,7 +161,7 @@ function historyControls(){
const filters=state.historyFilters||{}; const filters=state.historyFilters||{};
const form=document.createElement('form');form.id='history-filters';form.className='card history-filters'; const form=document.createElement('form');form.id='history-filters';form.className='card history-filters';
const select=(id,label,options)=>`<div class="field"><label for="${id}">${t(label)}</label><select id="${id}">${options}</select></div>`; const select=(id,label,options)=>`<div class="field"><label for="${id}">${t(label)}</label><select id="${id}">${options}</select></div>`;
form.innerHTML=`<div class="field"><label for="history-search">${t('searchHistory')}</label><input id="history-search" type="search" maxlength="200" value="${esc(filters.q||'')}"></div>`+select('history-status','status',`<option value="">${t('allStatuses')}</option>`+['QUEUED','RENDERING','UPLOADING','SYNCING','COMPLETED','FAILED','UNKNOWN'].map(x=>`<option value="${x}">${t(x)}</option>`).join(''))+select('history-action','action',`<option value="">${t('allActions')}</option>`+['display','render','render-ai','local-upload','delete','restore','official-widget','reconcile'].map(x=>`<option value="${x}">${esc(operationActionLabel(x))}</option>`).join(''))+select('history-device','device',`<option value="">${t('allDevices')}</option>`+state.devices.map(d=>`<option value="${esc(d.id)}">${esc(deviceNameForId(d.id))}</option>`).join(''))+`<div class="field"><label for="history-start">${t('startDate')}</label><input id="history-start" type="date" value="${esc(filters.start||'')}"></div><div class="field"><label for="history-end">${t('endDate')}</label><input id="history-end" type="date" value="${esc(filters.end||'')}"></div><div class="actions"><button type="submit" class="primary">${t('filter')}</button><button id="history-reset" type="button">${t('resetFilters')}</button></div>`; form.innerHTML=`<div class="field"><label for="history-search">${t('searchHistory')}</label><input id="history-search" type="search" maxlength="200" value="${esc(filters.q||'')}"></div>`+select('history-status','status',`<option value="">${t('allStatuses')}</option>`+['QUEUED','RENDERING','UPLOADING','SYNCING','COMPLETED','FAILED','UNKNOWN'].map(x=>`<option value="${x}">${t(x)}</option>`).join(''))+select('history-action','action',`<option value="">${t('allActions')}</option>`+['library-generate','display','render','render-ai','local-upload','delete','restore','official-widget','reconcile'].map(x=>`<option value="${x}">${esc(operationActionLabel(x))}</option>`).join(''))+select('history-device','device',`<option value="">${t('allDevices')}</option>`+state.devices.map(d=>`<option value="${esc(d.id)}">${esc(deviceNameForId(d.id))}</option>`).join(''))+`<div class="field"><label for="history-start">${t('startDate')}</label><input id="history-start" type="date" value="${esc(filters.start||'')}"></div><div class="field"><label for="history-end">${t('endDate')}</label><input id="history-end" type="date" value="${esc(filters.end||'')}"></div><div class="actions"><button type="submit" class="primary">${t('filter')}</button><button id="history-reset" type="button">${t('resetFilters')}</button></div>`;
const wrap=$('#main .table-wrap');wrap.before(form); const wrap=$('#main .table-wrap');wrap.before(form);
for(const key of ['status','action','device'])$('#history-'+key).value=filters[key==='device'?'device_id':key]||''; for(const key of ['status','action','device'])$('#history-'+key).value=filters[key==='device'?'device_id':key]||'';
form.onsubmit=busy(async e=>{e.preventDefault();const start=$('#history-start'),end=$('#history-end');end.setCustomValidity(start.value&&end.value&&start.value>end.value?t('dateRangeInvalid'):'');if(!form.reportValidity())return;state.historyFilters={};for(const [id,key] of [['search','q'],['status','status'],['action','action'],['device','device_id'],['start','start'],['end','end']]){const value=$('#history-'+id).value;if(value)state.historyFilters[key]=value;}state.historyPage=1;await navigate();}); form.onsubmit=busy(async e=>{e.preventDefault();const start=$('#history-start'),end=$('#history-end');end.setCustomValidity(start.value&&end.value&&start.value>end.value?t('dateRangeInvalid'):'');if(!form.reportValidity())return;state.historyFilters={};for(const [id,key] of [['search','q'],['status','status'],['action','action'],['device','device_id'],['start','start'],['end','end']]){const value=$('#history-'+id).value;if(value)state.historyFilters[key]=value;}state.historyPage=1;await navigate();});
@@ -201,7 +180,7 @@ async function deleteHistory(ids){await confirmDialog(t('deleteHistoryConfirm').
function hideDisabledMarkTooltip(){const tooltip=document.getElementById('disabled-mark-tooltip');if(tooltip)tooltip.hidden=true;} function hideDisabledMarkTooltip(){const tooltip=document.getElementById('disabled-mark-tooltip');if(tooltip)tooltip.hidden=true;}
function disabledMarkHints(root){ function disabledMarkHints(root){
hideDisabledMarkTooltip(); hideDisabledMarkTooltip();
for(const button of root.querySelectorAll('.image-card .actions button[aria-pressed],#asset-batch-star,#asset-batch-exclude,[data-source-star],[data-source-exclude]')){ for(const button of root.querySelectorAll('[data-library-star],[data-library-exclude]')){
let wrapper=button.parentElement.classList.contains('disabled-mark-hint')?button.parentElement:null; let wrapper=button.parentElement.classList.contains('disabled-mark-hint')?button.parentElement:null;
const reason=button.disabled?(button.title||wrapper?.dataset.reason||''):''; const reason=button.disabled?(button.title||wrapper?.dataset.reason||''):'';
if(!reason){if(wrapper)wrapper.replaceWith(button);continue;} if(!reason){if(wrapper)wrapper.replaceWith(button);continue;}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,3 +1,3 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="icon" type="image/svg+xml" href="/ui/favicon.svg"><link rel="icon" href="/ui/favicon.ico" sizes="16x16 32x32 48x48"><link rel="apple-touch-icon" sizes="180x180" href="/ui/apple-touch-icon.png"><link rel="stylesheet" href="/ui/style.css"></head> <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="icon" type="image/svg+xml" href="/ui/favicon.svg"><link rel="icon" href="/ui/favicon.ico" sizes="16x16 32x32 48x48"><link rel="apple-touch-icon" sizes="180x180" href="/ui/apple-touch-icon.png"><link rel="stylesheet" href="/ui/style.css"></head>
<body><aside class="sidebar"><a class="brand" href="/" aria-label="PIGWay Frame Flow"><img class="pigway-logo" src="/ui/logo-symbol.svg" alt="" width="56" height="56"><span>PIGWAY<small>FRAME FLOW</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.29</small></div></aside><div class="workspace"><header><div class="workspace-heading"><img class="mobile-brand-logo pigway-logo" src="/ui/logo-symbol.svg" alt="PIGWay" width="56" height="56"><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.29"></script><script src="/ui/features.js?v=0.1.29"></script><script src="/ui/extensions.js?v=0.1.29"></script><script src="/ui/app.js?v=0.1.29"></script></body></html> <body><aside class="sidebar"><a class="brand" href="/" aria-label="PIGWay Frame Flow"><img class="pigway-logo" src="/ui/logo-symbol.svg" alt="" width="56" height="56"><span>PIGWAY<small>FRAME FLOW</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.29</small></div></aside><div class="workspace"><header><div class="workspace-heading"><img class="mobile-brand-logo pigway-logo" src="/ui/logo-symbol.svg" alt="PIGWay" width="56" height="56"><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.29"></script><script src="/ui/features.js?v=0.1.29"></script><script src="/ui/extensions.js?v=0.1.29"></script><script src="/ui/app.js?v=0.1.29"></script><script src="/ui/thumbnails.js?v=0.1.29"></script><script src="/ui/libraries.js?v=0.1.29"></script><script src="/ui/source-browser.js?v=0.1.29"></script></body></html>
+111
View File
@@ -0,0 +1,111 @@
'use strict';
// Source pixels, generated versions and terminal bindings share one gallery model.
Object.assign(MESSAGES.zh,{libraryOutputPartial:'部分终端输出失败,源画面与成功版本已保留,可单独重试失败目标。',libraryGenerateFirst:'请先按终端生成方案生成成品,源画面不能直接推送。',librarySourceMismatch:'方案不属于所选来源图库,请重新选择。',librarySizeMismatch:'方案尺寸与终端屏幕不匹配;相框使用设备屏幕尺寸,当前手表为 410×502。',libraryPlanBound:'此方案正在被终端使用,请先解除绑定或更换方案。',libraryBindFirst:'请先在终端图库中绑定此来源及其中的生成方案。',libraryBindingChanged:'终端绑定已改变,此版本不再匹配,已停止推送。'});Object.assign(MESSAGES.en,{libraryOutputPartial:'Some outputs failed. Source pixels and successful versions are kept; retry failed targets individually.',libraryGenerateFirst:'Generate a terminal version before sending source pixels.',librarySourceMismatch:'The plan belongs to another source library.',librarySizeMismatch:'Plan dimensions must match the terminal screen. The current watch uses 410×502.',libraryPlanBound:'Unbind terminals or select another plan first.',libraryBindFirst:'Bind a terminal to this source and one of its plans first.',libraryBindingChanged:'The terminal binding changed; this version was not sent.'});
Object.assign(MESSAGES.zh,{gallery:'来源图库','terminal-gallery':'终端图库'});
Object.assign(MESSAGES.en,{gallery:'Source library','terminal-gallery':'Terminal library'});
const libraryUI={kind:'source',source:null,plan:null,terminal:null,offset:0};
function libraryStatus(status){return ({GENERATED:tr('已生成','Generated'),QUEUED:tr('待推送','Queued'),RENDERING:tr('准备推送','Preparing'),UPLOADING:tr('上传中','Uploading'),SYNCING:tr('等待设备确认','Awaiting confirmation'),COMPLETED:tr('已确认显示','Display confirmed'),FAILED:tr('推送失败','Failed'),UNKNOWN:tr('结果待核对','Outcome unknown')})[status]||status;}
function terminalLabel(terminal){return terminal.id==='watch'?tr('手表','Watch'):deviceDisplayName(terminal.name?terminal:state.devices.find(d=>d.id===terminal.id)||terminal);}
function librarySelectOptions(items,selected,label){return `<option value="">${label}</option>`+items.map(p=>`<option value="${esc(p.id)}" ${p.id===selected?'selected':''}>${esc(p.kind==='uploads'?tr('手动上传','Manual uploads'):p.name)}</option>`).join('');}
views.gallery=epoch=>libraryPage(epoch,'source');
views['terminal-gallery']=epoch=>libraryPage(epoch,'terminal');
async function libraryPage(epoch,kind){
if(libraryUI.kind!==kind)libraryUI.offset=0;
libraryUI.kind=kind;
const [sources,terminals]=await Promise.all([api('/api/v1/source-libraries'),api('/api/v1/library-terminals')]);
if(epoch!==state.epoch)return;
state.devices=state.devices.map(device=>{const terminal=terminals.find(t=>t.id===device.id&&t.name&&t.name!==t.id);return terminal?{...device,name:terminal.name}:device;});shell();
$('#main').innerHTML=`<div class="section-heading"><span></span><button id="library-refresh">${t('refresh')}</button></div><div id="library-content"></div>`;
bind('library-refresh',()=>navigate());
if(kind==='source')await sourceLibraryPage(epoch,sources,terminals);else await terminalLibraryPage(epoch,sources,terminals);
}
async function sourceLibraryPage(epoch,sources,terminals){
const panel=$('#library-content');
if(!sources.length){panel.innerHTML=`<section class="card empty"><p>${tr('连接照片数据源或手动上传照片,再在来源图库建立生成方案。','Connect a photo source or upload photos, then create generation plans in its library.')}</p><button id="library-add-source">${tr('添加照片来源','Add photo source')}</button></section>`;panel.querySelector('section').insertAdjacentHTML('beforeend',`<button id="library-first-upload">${tr('上传照片到来源图库','Upload photos to source library')}</button>`);bind('library-first-upload',()=>navigate('upload'));bind('library-add-source',()=>navigate('photo-sources'));return;}
if(!sources.some(s=>s.id===libraryUI.source)){libraryUI.source=sources[0].id;libraryUI.plan=null;libraryUI.offset=0;}
const sourceId=libraryUI.source,manual=sources.find(s=>s.id===sourceId)?.kind==='uploads';
const [plans,page]=await Promise.all([api('/api/v1/generation-plans?source_id='+encodeURIComponent(sourceId)),api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/images?offset='+libraryUI.offset+'&limit=30')]);
if(epoch!==state.epoch)return;
if(libraryUI.plan&&!plans.some(p=>p.id===libraryUI.plan))libraryUI.plan=null;
const plan=plans.find(p=>p.id===libraryUI.plan),uses=terminals.filter(t=>t.binding?.source_id===sourceId&&(!plan||t.binding.plan_id===plan.id));
panel.innerHTML=`<section class="card library-source-controls"><div class="library-source-toolbar"><div class="field"><label for="library-source">${tr('来源图库','Source library')}</label><select id="library-source">${librarySelectOptions(sources,sourceId,tr('选择来源','Choose source'))}</select></div><div class="actions"><button id="library-import">${manual?tr('上传照片','Upload photos'):tr('从来源选入照片','Import from source')}</button><button id="library-run">${tr('随机选入并按绑定生成','Pick once and generate bound plans')}</button>${manual?'':`<button id="library-source-settings">${tr('来源设置','Source settings')}</button>`}</div></div><p class="hint">${tr('源画面先保存到本地;同一张照片按不同方案生成版本,多个终端可共用一份版本。Photos 画面可能是源预览。','Source pixels are stored locally first. Plans create versions of the same photo; terminals may share one version. Photos may supply source previews.')}</p></section><section class="card library-output-panel"><div class="library-plan-toolbar"><div class="tabs gallery-tabs library-plan-tabs" role="tablist" aria-label="${tr('源画面与生成方案','Source pixels and generation plans')}"><button id="library-plan-tab-source" role="tab" aria-controls="library-plan-content" aria-selected="${!plan}" tabindex="${!plan?0:-1}" data-library-plan="" class="${!plan?'active':''}">${tr('源画面','Source pixels')}</button>${plans.map(p=>`<button id="library-plan-tab-${esc(p.id)}" role="tab" aria-controls="library-plan-content" aria-selected="${p.id===plan?.id}" tabindex="${p.id===plan?.id?0:-1}" data-library-plan="${esc(p.id)}" class="${p.id===plan?.id?'active':''}">${esc(p.name)}${p.archived?tr('(已归档)',' (archived)'):''}</button>`).join('')}</div><div class="actions"><button id="library-new-plan">${tr('新增生成方案','New generation plan')}</button>${plan&&!plan.archived?`<button id="library-edit-plan">${t('edit')}</button><button id="library-delete-plan" class="danger">${tr('删除方案','Delete plan')}</button>`:''}</div></div><div id="library-plan-content" role="tabpanel" aria-labelledby="library-plan-tab-${plan?esc(plan.id):'source'}"><p class="hint">${tr('使用终端:','Used by: ')}${uses.map(terminalLabel).map(esc).join('、')||tr('尚未绑定','No terminals bound')}</p><div class="gallery source-library-gallery">${page.items.map((picture,i)=>{
const version=plan?picture.versions.find(v=>v.plan_id===plan.id):picture;
return `<article class="card image-card">${version?`<button class="image-preview" data-library-preview="${i}"><img class="thumb" alt="${esc(picture.name)}" data-src="${esc(version.url)}" style="object-fit:contain;${plan?`aspect-ratio:${version.plan.width}/${version.plan.height}`:''}"></button>`:`<div class="empty">${tr('此方案尚未生成','Not generated for this plan')}</div>`}<h3>${esc(picture.name)}</h3><small>${plan?version?`${esc(version.plan_name)} · ${version.plan.width} × ${version.plan.height}`:tr('未生成','Not generated'):tr('无叠加的源画面','Source pixels without overlays')}</small><small>${esc(fmt(version?.created_at||picture.created_at))}</small>${!plan&&picture.starred?`<small class="photo-mark">${tr('已星标保留','Starred for retention')}</small>`:''}${!plan&&picture.excluded?`<small class="photo-mark">${tr('已标记不再选用','Excluded from automatic selection')}</small>`:''}${plan&&version?.matches_current_plan===false?`<small class="hint">${tr('方案参数已更新,此图片为旧参数版本','Plan settings changed; this image uses earlier settings')}</small>`:''}${version?.terminal_uses?.length?`<small>${version.terminal_uses.map(u=>esc(terminalLabel(terminals.find(t=>t.id===u.terminal_id)||{id:u.terminal_id}))+' · '+esc(libraryStatus(u.status))).join(' / ')}</small>`:''}<div class="actions">${version?`<button data-library-preview="${i}">${tr('预览','Preview')}</button><button data-library-download="${i}">${tr('下载图片','Download image')}</button>${plan?`<button data-library-settings="${i}">${tr('生成参数','Generation settings')}</button>`:''}`:''}${!plan?.archived?`<button data-library-generate="${i}">${plan?tr('按此方案生成','Generate this plan'):tr('生成各方案版本','Generate plan versions')}</button>`:''}${!plan?`<button data-library-star="${i}" ${picture.excluded?'disabled':''} title="${picture.excluded?esc(t('clearExclusionFirst')):''}">${t(picture.starred?'unstar':'star')}</button><button data-library-exclude="${i}" ${picture.starred?'disabled':''} title="${picture.starred?esc(t('clearStarFirst')):''}">${t(picture.excluded?'includePhoto':'excludePhoto')}</button>`:''}<button class="danger" data-library-delete="${i}" ${plan&&!version?'disabled':''}>${t('delete')}</button></div></article>`;
}).join('')}</div>${page.items.length?'':`<section class="card empty">${manual?tr('尚未上传照片。上传后可按方案生成。','No uploads yet. Upload a photo, then generate its versions.'):tr('尚未选入源画面。先从来源选入照片,再按方案生成。','No source pixels imported yet. Import a photo, then generate its versions.')}</section>`}<div class="actions library-pagination"><button id="library-prev" ${libraryUI.offset===0?'disabled':''}>${tr('上一页','Previous')}</button><button id="library-next" ${libraryUI.offset+30>=page.total?'disabled':''}>${tr('下一页','Next')}</button><span>${tr('共','Total')} ${page.total} ${tr('张来源照片','source pictures')}</span></div></div></section>`;
$('#library-source').onchange=()=>{libraryUI.source=$('#library-source').value;libraryUI.plan=null;libraryUI.offset=0;return navigate();};
document.querySelectorAll('[data-library-plan]').forEach(b=>{
b.onclick=()=>{libraryUI.plan=b.dataset.libraryPlan||null;return navigate();};
b.onkeydown=async e=>{const tabs=[...document.querySelectorAll('[data-library-plan]')],index=tabs.indexOf(b);let next;if(e.key==='ArrowRight')next=(index+1)%tabs.length;else if(e.key==='ArrowLeft')next=(index+tabs.length-1)%tabs.length;else if(e.key==='Home')next=0;else if(e.key==='End')next=tabs.length-1;else return;e.preventDefault();libraryUI.plan=tabs[next].dataset.libraryPlan||null;await navigate();document.querySelector('[data-library-plan][aria-selected="true"]')?.focus();};
});
bind('library-import',()=>manual?navigate('upload'):libraryImportDialog(sourceId));bind('library-source-settings',()=>navigate('photo-sources'));
bind('library-new-plan',()=>generationPlanEditor(sourceId,null,page.items));bind('library-edit-plan',()=>generationPlanEditor(sourceId,plan,page.items));
bind('library-delete-plan',()=>confirmDialog(tr(`确认删除生成方案“${plan.name}”?已有生成版本保留参数快照;正在被终端使用的方案不能删除。`,`Delete generation plan “${plan.name}”? Existing versions keep their snapshots. Bound plans cannot be deleted.`),async()=>{await api('/api/v1/generation-plans/'+plan.id,{method:'DELETE'});await navigate();}));
bind('library-run',async()=>{const result=await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/run',{method:'POST',body:{deliver:false}});toast(result.errors.length?result.errors.map(e=>e.message).join('; '):tr('已从同一来源画面生成各终端版本,尚未推送','Generated terminal versions from one source picture; not sent'));libraryUI.offset=0;await navigate();});
bind('library-prev',()=>{libraryUI.offset=Math.max(0,libraryUI.offset-30);return navigate();});bind('library-next',()=>{libraryUI.offset+=30;return navigate();});
const selected=i=>{const p=page.items[i];return plan?p.versions.find(v=>v.plan_id===plan.id):p;};
document.querySelectorAll('[data-library-preview]').forEach(b=>b.onclick=busy(()=>{const v=selected(b.dataset.libraryPreview);return openImageViewer(v.url,v.name);}));
document.querySelectorAll('[data-library-download]').forEach(b=>b.onclick=busy(()=>{const v=selected(b.dataset.libraryDownload);return downloadImage(v.url,v.name);}));
document.querySelectorAll('[data-library-settings]').forEach(b=>b.onclick=()=>generationVersionDetails(selected(b.dataset.librarySettings)));
document.querySelectorAll('[data-library-generate]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/source-images/'+page.items[b.dataset.libraryGenerate].id+'/generate',{method:'POST',body:{plan_id:plan?.id||null}});await navigate();}));
document.querySelectorAll('[data-library-star]').forEach(b=>b.onclick=busy(async()=>{const p=page.items[b.dataset.libraryStar];await api('/api/v1/source-images/'+p.id+'/star',{method:'PUT',body:{starred:!p.starred}});await navigate();}));
document.querySelectorAll('[data-library-exclude]').forEach(b=>b.onclick=busy(async()=>{const p=page.items[b.dataset.libraryExclude];await api('/api/v1/source-images/'+p.id+'/exclusion',{method:'PUT',body:{excluded:!p.excluded}});await navigate();}));
document.querySelectorAll('[data-library-delete]').forEach(b=>b.onclick=()=>{const p=page.items[b.dataset.libraryDelete],v=selected(b.dataset.libraryDelete);if(plan&&!v)return;return confirmDialog(plan?tr('删除这个本地生成版本?不会删除源画面或设备上已接收的图片。','Delete this local version? Source pixels and device copies are kept.'):tr('删除本地源画面及其所有生成版本?不会删除照片数据源中的照片或设备副本。','Delete this local source picture and all generated versions? Provider photos and device copies are kept.'),async()=>{await api(plan?'/api/v1/library-versions/'+v.asset_id:'/api/v1/source-images/'+p.id,{method:'DELETE'});await navigate();});});
disabledMarkHints(panel);hydrateImages();
}
async function generationPlanEditor(sourceId,plan,pictures){
const p=plan||{name:'',width:410,height:502,fit:'cover',quality:85,center_x:0.5,center_y:0.5,caption:false,profile:'color'};
showModal(`<h2>${tr('来源图库生成方案','Source-library generation plan')}</h2><form id="generation-form">${!plan?`<div class="actions"><button type="button" data-generation-preset="frame-portrait">${tr('相框竖版','Portrait frame')}</button><button type="button" data-generation-preset="frame-landscape">${tr('相框横版','Landscape frame')}</button><button type="button" data-generation-preset="watch">${tr('手表','Watch')}</button></div>`:''}<div class="field"><label for="generation-name">${t('name')}</label><input id="generation-name" required maxlength="120" value="${esc(p.name)}"></div><div class="form-grid">${[['width',tr('宽度','Width'),16,2048,1],['height',tr('高度','Height'),16,2048,1],['quality',tr('JPEG 质量','JPEG quality'),40,95,1],['center_x',tr('水平裁切位置(0–1)','Crop X (0–1)'),0,1,0.01],['center_y',tr('垂直裁切位置(0–1)','Crop Y (0–1)'),0,1,0.01]].map(([key,label,min,max,step])=>`<div class="field"><label for="generation-${key}">${label}</label><input id="generation-${key}" type="number" required min="${min}" max="${max}" step="${step}" value="${p[key]}"></div>`).join('')}<div class="field"><label for="generation-fit">${tr('适配方式','Fit')}</label><select id="generation-fit"><option value="cover">${tr('等比裁切铺满','Proportional fill')}</option><option value="contain">${tr('完整画面,必要时补白','Full picture, white padding')}</option></select></div><div class="field"><label for="generation-profile">${t('profile')}</label><select id="generation-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div></div><label class="check-row"><input id="generation-caption" type="checkbox" ${p.caption?'checked':''}>${tr('叠加来源提供的日期与说明','Add date and caption supplied by the source')}</label><p class="hint">${tr('默认不拉伸、不补黑边。相框常用 1200×1600 或 1600×1200,当前手表为 410×502。绑定终端时校验尺寸。修改方案只影响后续生成,旧版本保留参数快照。','No stretching or black padding by default. Frames use 1200×1600 or 1600×1200; the current watch uses 410×502. Binding checks screen dimensions. Existing versions keep parameter snapshots.')}</p><div class="field"><label for="generation-picture">${tr('用本地源画面预览','Preview with a local source picture')}</label><select id="generation-picture">${librarySelectOptions(pictures.map(x=>({id:x.id,name:x.name})),pictures[0]?.id,tr('选择照片','Choose picture'))}</select></div><div id="generation-preview-box"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="button" id="generation-preview">${tr('预览','Preview')}</button><button type="submit" class="primary">${t('save')}</button></div></form>`);
$('#generation-fit').value=p.fit;$('#generation-profile').value=p.profile;
const fill=values=>{for(const key of ['name','width','height','quality','center_x','center_y','fit'])$('#generation-'+key).value=values[key];$('#generation-caption').checked=values.caption||false;$('#generation-profile').value=values.profile||'color';};
document.querySelectorAll('[data-generation-preset]').forEach(b=>b.onclick=()=>{const type=b.dataset.generationPreset;fill({...p,name:type==='watch'?tr('手表','Watch'):type==='frame-portrait'?tr('相框竖版','Portrait frame'):tr('相框横版','Landscape frame'),width:type==='watch'?410:type==='frame-portrait'?1200:1600,height:type==='watch'?502:type==='frame-portrait'?1600:1200,caption:type!=='watch'});});
const read=()=>({source_id:sourceId,name:$('#generation-name').value,width:Number($('#generation-width').value),height:Number($('#generation-height').value),quality:Number($('#generation-quality').value),center_x:Number($('#generation-center_x').value),center_y:Number($('#generation-center_y').value),fit:$('#generation-fit').value,profile:$('#generation-profile').value,caption:$('#generation-caption').checked});
bind('generation-preview',async()=>{if(!$('#generation-form').reportValidity())return;const id=$('#generation-picture').value;if(!id)throw new Error(tr('请先选入源画面','Import a source picture first'));const blob=await api('/api/v1/source-images/'+id+'/preview',{method:'POST',body:read(),binary:true});if(!$('#generation-preview-box'))return;const url=URL.createObjectURL(blob);state.blobs.push(url);$('#generation-preview-box').innerHTML=`<img alt="${tr('生成预览','Generation preview')}" style="max-width:100%;max-height:360px" src="${url}">`;});
$('#generation-form').onsubmit=busy(async e=>{e.preventDefault();const saved=await api('/api/v1/generation-plans'+(plan?'/'+plan.id:''),{method:plan?'PUT':'POST',body:read()});libraryUI.plan=saved.id;$('#modal').close();await navigate();});
}
function libraryImportDialog(sourceId){return openSourceBrowser(sourceId);}
async function terminalLibraryPage(epoch,sources,terminals){
if(!terminals.some(t=>t.id===libraryUI.terminal))libraryUI.terminal=state.tab==='watch'?'watch':state.device;
const terminal=terminals.find(t=>t.id===libraryUI.terminal)||terminals[0];libraryUI.terminal=terminal.id;state.tab=terminal.id==='watch'?'watch':'cloud';if(terminal.id!=='watch')state.device=terminal.id;shell();
const [page,plans]=await Promise.all([api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/images?limit=30&offset='+libraryUI.offset),terminal.binding?api('/api/v1/generation-plans?source_id='+encodeURIComponent(terminal.binding.source_id)):Promise.resolve([])]);
if(epoch!==state.epoch)return;
const source=sources.find(s=>s.id===terminal.binding?.source_id),plan=plans.find(p=>p.id===terminal.binding?.plan_id);
$('#library-content').innerHTML=`<section class="card"><div class="tabs gallery-tabs terminal-choice" role="group" aria-label="${tr('终端','Terminal')}">${terminals.map(t=>`<button type="button" data-library-terminal="${esc(t.id)}" class="${t.id===terminal.id?'active':''}" aria-pressed="${t.id===terminal.id}">${esc(terminalLabel(t))}</button>`).join('')}</div><h2>${esc(terminalLabel(terminal))}</h2><p>${tr('来源图库:','Source library: ')}${esc(source?.name||tr('未绑定','Not bound'))} · ${tr('生成方案:','Generation plan: ')}${esc(plan?.name||tr('未选择','Not selected'))}</p><p class="hint">${terminal.transport_supported?tr('生成、上传和设备确认分别记录;已生成不等于已显示。','Generation, upload and device confirmation are tracked separately. Generated does not mean displayed.'):tr('当前手表屏幕 410×502,传输尚未接入;这里管理生成成品。','Current watch screen: 410×502. Transfer is not connected; this library manages generated versions.')}</p><div class="actions"><button id="library-binding" class="primary">${tr('设置来源与方案(各单选)','Bind one source and one plan')}</button>${terminal.binding?`<button id="library-unbind">${tr('解除绑定','Unbind')}</button><button id="library-to-source">${tr('查看来源与生成版本','View source and versions')}</button>`:''}</div></section><div class="gallery terminal-library-gallery">${page.items.map((v,i)=>`<article class="card image-card"><button class="image-preview" data-terminal-preview="${i}"><img class="thumb" alt="${esc(v.name)}" data-src="${esc(v.url)}" style="object-fit:contain;aspect-ratio:${v.plan.width}/${v.plan.height}"></button><h3>${esc(v.name)}</h3><small>${v.plan.width} × ${v.plan.height} · ${esc(v.plan_name)}</small><small>${esc(libraryStatus(v.delivery_status))} · ${esc(fmt(v.created_at))}</small>${v.matches_current_plan===false?`<small class="hint">${tr('方案参数已更新,此图片为旧参数版本','Plan settings changed; this image uses earlier settings')}</small>`:''}<div class="actions"><button data-terminal-preview="${i}">${tr('预览','Preview')}</button><button data-terminal-download="${i}">${tr('下载图片','Download image')}</button><button data-terminal-settings="${i}">${tr('生成参数','Generation settings')}</button>${v.operation?`<button data-terminal-operation="${i}">${tr('推送记录','Delivery history')}</button>`:''}<button data-terminal-send="${i}" ${v.current||!terminal.transport_supported||!liveWriteAllowed()||['QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN'].includes(v.delivery_status)?'disabled':''} title="${terminal.transport_supported?(liveWriteAllowed()?'':esc(t('readOnlyWarning'))):tr('手表传输尚未接入','Watch transfer is not connected')}">${v.current?tr('已在终端显示','Already displayed on terminal'):tr('推送到终端','Send to terminal')}</button></div></article>`).join('')}</div>${page.items.length?'':`<section class="card empty">${tr('尚无对应成品。绑定来源与方案后,在来源图库选入照片并生成。','No matching versions. Bind a source and plan, then import and generate in the source library.')}</section>`}<div class="actions library-pagination"><button id="terminal-prev" ${libraryUI.offset===0?'disabled':''}>${tr('上一页','Previous')}</button><button id="terminal-next" ${libraryUI.offset+30>=page.total?'disabled':''}>${tr('下一页','Next')}</button><span>${tr('共','Total')} ${page.total} ${tr('个成品版本','generated versions')}</span></div>`;
document.querySelectorAll('[data-library-terminal]').forEach(b=>b.onclick=()=>{if(b.dataset.libraryTerminal===libraryUI.terminal)return;libraryUI.terminal=b.dataset.libraryTerminal;libraryUI.offset=0;state.tab=libraryUI.terminal==='watch'?'watch':'cloud';if(libraryUI.terminal!=='watch')state.device=libraryUI.terminal;return navigate();});
bind('library-binding',()=>terminalBindingEditor(terminal,sources));bind('library-unbind',()=>confirmDialog(tr(`确认解除“${terminalLabel(terminal)}”与来源图库及生成方案的绑定?已有图片保留,后续来源任务不再向此终端输出。`,`Unbind “${terminalLabel(terminal)}” from its source library and generation plan? Images are kept; future source tasks will not output to it.`),async()=>{await api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/binding',{method:'DELETE'});await navigate();}));
bind('library-to-source',()=>{libraryUI.source=terminal.binding.source_id;libraryUI.plan=terminal.binding.plan_id;libraryUI.offset=0;state.tab='local';return navigate('gallery');});
bind('terminal-prev',()=>{libraryUI.offset=Math.max(0,libraryUI.offset-30);return navigate();});bind('terminal-next',()=>{libraryUI.offset+=30;return navigate();});
document.querySelectorAll('[data-terminal-preview]').forEach(b=>b.onclick=busy(()=>{const v=page.items[b.dataset.terminalPreview];return openImageViewer(v.url,v.name);}));
document.querySelectorAll('[data-terminal-download]').forEach(b=>b.onclick=busy(()=>{const v=page.items[b.dataset.terminalDownload];return downloadImage(v.url,v.name);}));
document.querySelectorAll('[data-terminal-settings]').forEach(b=>b.onclick=()=>generationVersionDetails(page.items[b.dataset.terminalSettings]));
document.querySelectorAll('[data-terminal-operation]').forEach(b=>b.onclick=()=>{state.historyFilters={q:page.items[b.dataset.terminalOperation].operation.operation_id};state.historyPage=1;return navigate('history');});
document.querySelectorAll('[data-terminal-send]').forEach(b=>b.onclick=()=>{const v=page.items[b.dataset.terminalSend];if(v.current)return;return confirmDialog(tr('将此版本推送到终端并显示?','Send and display this version on the terminal?'),async()=>{await api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/images/'+v.asset_id+'/send',{method:'POST'});toast(t('accepted'));await navigate();});});hydrateImages();
}
async function terminalBindingEditor(terminal,sources){
showModal(`<h2>${esc(terminalLabel(terminal))} · ${tr('来源与生成方案','Source and generation plan')}</h2><form id="terminal-binding-form"><div class="field"><label for="binding-source">${tr('来源图库(单选)','Source library (one)')}</label><select id="binding-source" required>${librarySelectOptions(sources,terminal.binding?.source_id,tr('请选择来源','Choose source'))}</select></div><div class="field"><label for="binding-plan">${tr('该来源内的生成方案(单选)','Generation plan in this source (one)')}</label><select id="binding-plan" required></select></div><p class="hint">${tr('切换来源必须重新选择方案。方案可被多个终端共用,但尺寸必须匹配各终端屏幕。请先在来源图库中创建方案。','Changing source clears the plan selection. Terminals may share a plan when screen dimensions match. Create plans in the source library first.')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}<button class="primary" type="submit">${t('save')}</button></div></form>`);
let request=0;
async function load(selected){const source=$('#binding-source').value,ticket=++request;$('#binding-plan').innerHTML='<option value="">—</option>';if(!source)return;const plans=await api('/api/v1/generation-plans?source_id='+encodeURIComponent(source));if(ticket!==request||!$('#binding-plan'))return;$('#binding-plan').innerHTML=librarySelectOptions(plans.filter(p=>!p.archived),selected,tr('请选择生成方案','Choose generation plan'));}
$('#binding-source').onchange=busy(()=>load(null));$('#terminal-binding-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/binding',{method:'PUT',body:{source_id:$('#binding-source').value,plan_id:$('#binding-plan').value}});$('#modal').close();await navigate();});await load(terminal.binding?.plan_id);
}
$('#device').onchange=()=>{const id=$('#device').value;if(id==='@watch'||state.page==='terminal-gallery'){libraryUI.kind='terminal';libraryUI.terminal=id==='@watch'?'watch':id;libraryUI.offset=0;state.tab=id==='@watch'?'watch':'cloud';if(id!=='@watch')state.device=id;return navigate('terminal-gallery');}state.device=id;return navigate();};
async function libraryJobFields(job,isNew){
const box=document.createElement('section');box.innerHTML=`<label class="check-row"><input id="job-library-pipeline" type="checkbox" ${job.library_pipeline||(isNew&&job.source?.type==='photo_source')?'checked':''}>${tr('按来源图库统一生成并推送','Generate and send through source-library bindings')}</label><p class="hint">${tr('选一次照片并保存源画面,按来源内的终端绑定分别生成;同一方案共用成品。相框继续走上传和显示确认,手表仅生成。此模式不再单独选择“小图方案”。旧任务默认保留原模式,勾选后切换。','Select one photo, save source pixels and generate each bound plan. Terminals sharing a plan share its version. Frames upload and confirm display; watches generate only. Legacy tasks keep their old mode until enabled.')}</p>`;
$('#job-form .modal-actions').before(box);
const kind=$('#job-content-type'),pipeline=$('#job-library-pipeline');
const update=()=>{const supported=kind.value==='photo_source';box.hidden=!supported;$('#legacy-thumbnail-job-fields').hidden=!supported||pipeline.checked||(!job.generate_thumbnail&&!job.thumbnail_plan_id);pipeline.disabled=!supported;if(!supported)pipeline.checked=false;if(pipeline.checked){$('#job-thumbnail').checked=false;$('#job-thumbnail').disabled=true;$('#job-thumbnail-plan').required=false;$('#job-thumbnail-plan').disabled=true;}else{ $('#job-thumbnail').disabled=!supported;}};
kind.addEventListener('change',()=>{if(isNew&&kind.value==='photo_source')pipeline.checked=true;update();});pipeline.onchange=update;update();
}
if(state.settings&&state.page==='gallery')void navigate('gallery');else shell();
function generationVersionDetails(version){
const p=version.plan;
showModal(`<h2>${esc(version.plan_name)}</h2><div class="detail-grid">${detail(tr('生成时方案名称','Plan name at generation'),p.name)}${detail(tr('原始生成文件名','Stored filename'),version.stored_name)}${detail(tr('输出尺寸','Output size'),p.width+' × '+p.height)}${detail(tr('JPEG 质量','JPEG quality'),p.quality)}${detail(tr('适配方式','Fit'),p.fit==='cover'?tr('等比铺满','Proportional fill'):tr('完整画面','Full picture'))}${detail(tr('裁切位置','Crop position'),p.center_x+' / '+p.center_y)}${detail(tr('叠加说明','Caption'),p.caption?tr('开启','Enabled'):tr('关闭','Disabled'))}${detail(tr('生成时间','Generated at'),fmt(version.created_at))}</div><p class="hint">${tr('这是生成此版本时的参数快照。修改方案不会改变已有图片。','This snapshot records the settings used for this version. Editing a plan does not change existing images.')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);
}
+36
View File
@@ -0,0 +1,36 @@
'use strict';
Object.assign(MESSAGES.zh,{'source-browser':'从来源选入照片'});
Object.assign(MESSAGES.en,{'source-browser':'Import from source'});
icons['source-browser']='▧';
const sourceBrowser={source:null,filters:{},offset:0,previous:[]};
function openSourceBrowser(sourceId){sourceBrowser.source=sourceId;sourceBrowser.offset=0;sourceBrowser.previous=[];return navigate('source-browser');}
views['source-browser']=async function(epoch){
const sources=await api('/api/v1/source-libraries');if(epoch!==state.epoch)return;
if(!sources.length)return navigate('gallery');
if(!sources.some(s=>s.id===sourceBrowser.source)){sourceBrowser.source=sources[0].id;sourceBrowser.filters={};sourceBrowser.offset=0;sourceBrowser.previous=[];}
const source=sources.find(s=>s.id===sourceBrowser.source),nas=source.kind==='synology-photos',f=sourceBrowser.filters;
const input=(name,label,type='text',extra='')=>`<div class="field"><label for="browse-${name}">${label}</label><input id="browse-${name}" name="${name}" type="${type}" value="${esc(f[name]||'')}" ${extra}></div>`;
$('#main').innerHTML=`<section class="card"><div class="library-source-toolbar"><div class="field"><label for="browse-source">${tr('照片来源','Photo source')}</label><select id="browse-source">${librarySelectOptions(sources,source.id,'')}</select></div><button id="browse-back">${tr('返回来源图库','Back to source library')}</button></div><p class="hint">${tr('按批读取照片信息,预览仅在进入可视区域时加载;选入后保存在本地,不会推送设备。','Reads metadata in bounded batches and loads previews on demand. Import stores locally without sending to devices.')}</p><form id="source-browser-filters"><fieldset ${nas?'':'disabled'}><div class="source-browser-filters">${input('filename',tr('文件名关键字','Filename keyword'),'search','maxlength="200"')}${input('folder',tr('文件夹关键字','Folder keyword'),'search','maxlength="500"')}${input('min_mb',tr('最小文件大小(MB)','Min file size (MB)'),'number','min="0" step="0.1" max="1000000"')}${input('max_mb',tr('最大文件大小(MB)','Max file size (MB)'),'number','min="0" step="0.1" max="1000000"')}${input('min_width',tr('最小宽度(px)','Min width (px)'),'number','min="0" max="100000"')}${input('max_width',tr('最大宽度(px)','Max width (px)'),'number','min="0" max="100000"')}${input('min_height',tr('最小高度(px)','Min height (px)'),'number','min="0" max="100000"')}${input('max_height',tr('最大高度(px)','Max height (px)'),'number','min="0" max="100000"')}<div class="field"><label for="browse-date_field">${tr('日期类型','Date type')}</label><select name="date_field" id="browse-date_field"><option value="indexed_time" ${f.date_field!=='time'?'selected':''}>${tr('NAS 创建/入库日期','NAS indexed date')}</option><option value="time" ${f.date_field==='time'?'selected':''}>${tr('照片拍摄日期','Photo date')}</option></select></div>${input('date_from',tr('开始日期','From date'),'date')}${input('date_to',tr('结束日期(含当天)','Through date (inclusive)'),'date')}<div class="field"><label for="browse-person_id">${tr('人物识别','Recognized person')}</label><select id="browse-person_id" name="person_id"><option value="">${tr('不限人物','Any person')}</option>${f.person_id?`<option value="${esc(f.person_id)}" selected>${esc(f.person_name||f.person_id)}</option>`:''}</select><small id="browse-people-status" class="hint"></small></div></div><div class="actions"><button type="submit" class="primary">${tr('搜索 / 筛选','Search / Filter')}</button><button type="reset">${tr('重置','Reset')}</button></div></fieldset></form><p class="hint">${nas?tr('筛选在当前来源设置范围内进行;日期使用系统时区,入库日期不等同于原文件的创建时间。筛选较少见照片时,可继续下一页检索,页面不会等待全库扫描。','Filters stay within this source. Dates use the system timezone; indexed dates are not original file creation dates. Continue through pages for rare matches without waiting for a full scan.'):tr('此来源使用现有目录接口;高级筛选和快速分页目前支持 Synology Photos。','This provider uses its existing catalog. Advanced filters and fast paging currently support Synology Photos.')}</p></section><p id="browse-status" role="status">${tr('正在读取本批照片…','Reading this batch…')}</p><div id="browse-gallery" class="gallery source-browser-gallery" aria-busy="true"></div><div class="actions library-pagination"><button id="browse-prev" ${sourceBrowser.previous.length?'':'disabled'}>${tr('上一页','Previous')}</button><button id="browse-next" disabled>${tr('下一页','Next')}</button></div>`;
bind('browse-back',()=>{libraryUI.source=source.id;libraryUI.offset=0;return navigate('gallery');});
$('#browse-source').onchange=()=>{sourceBrowser.filters={};return openSourceBrowser($('#browse-source').value);};
$('#source-browser-filters').onsubmit=busy(async e=>{e.preventDefault();const values=Object.fromEntries(new FormData(e.currentTarget));const people=$('#browse-person_id');sourceBrowser.filters={...values,person_name:people.selectedOptions[0]?.textContent||''};sourceBrowser.offset=0;sourceBrowser.previous=[];await navigate();});
$('#source-browser-filters').onreset=e=>{e.preventDefault();sourceBrowser.filters={};sourceBrowser.offset=0;sourceBrowser.previous=[];navigate();};
const params=new URLSearchParams({offset:sourceBrowser.offset,limit:36});for(const [key,value] of Object.entries(f)){if(value!==''&&key!=='person_name'&&!key.endsWith('_mb'))params.set(key,value);}for(const [key,target] of [['min_mb','min_bytes'],['max_mb','max_bytes']])if(f[key]!==undefined&&f[key]!=='')params.set(target,Math.round(Number(f[key])*1024*1024));
if(nas)loadBrowserPeople(source,epoch);
let page;try{page=await api('/api/v1/source-libraries/'+encodeURIComponent(source.id)+'/browse?'+params);}catch(error){if(epoch!==state.epoch)return;$('#browse-status').textContent=error.message;$('#browse-gallery').setAttribute('aria-busy','false');return;}
if(epoch!==state.epoch)return;
$('#browse-status').textContent=tr(`本批找到 ${page.items.length} 张照片${page.paged_provider?`,检查 ${page.scanned} 条记录`:''}。${!page.items.length&&page.next_offset!==null?'可继续下一页检索。':''}`,`${page.items.length} matches in this batch${page.paged_provider?`, ${page.scanned} records checked`:''}.${!page.items.length&&page.next_offset!==null?' Continue to the next batch.':''}`);
$('#browse-gallery').innerHTML=page.items.map((item,i)=>`<article class="card image-card"><button class="image-preview" data-browse-preview="${i}" ${item.preview_url?'':'disabled'}>${item.preview_url?`<img class="thumb" alt="${esc(item.name)}" data-src="${esc(item.preview_url)}">`:`<span class="empty">${tr('选入后预览','Preview after import')}</span>`}</button><h3 title="${esc(item.name)}">${esc(item.name)}</h3><small class="browse-folder">${esc(item.folder||item.source_path||'')}</small><small>${item.width&&item.height?`${item.width} × ${item.height} px · `:''}${item.filesize?`${(item.filesize/1024/1024).toFixed(2)} MB`:''}</small><small>${item.indexed_time?tr('入库:','Indexed: ')+esc(fmt(new Date(item.indexed_time*1000).toISOString())):''}</small><small>${item.time?tr('拍摄:','Photo: ')+esc(fmt(new Date(item.time*1000).toISOString())):''}</small><small>${esc((item.people||[]).map(p=>p.name).filter(Boolean).join('、'))}</small><div class="actions"><button data-browse-preview="${i}" ${item.preview_url?'':'disabled'}>${tr('预览','Preview')}</button><button class="primary" data-browse-import="${i}">${tr('选入本地','Import locally')}</button></div></article>`).join('');$('#browse-gallery').setAttribute('aria-busy','false');
$('#browse-next').disabled=page.next_offset===null;
$('#browse-next').textContent=tr('继续读取','Read next batch');
if(!page.items.length)$('#browse-gallery').innerHTML=`<section class="card empty"><p>${page.next_offset!==null?tr('本批未找到匹配照片,不代表整个来源没有结果。请继续读取下一批。','No matches in this batch. This does not mean the entire source has no results. Read the next batch.'):tr('已读至来源末尾,当前筛选范围内没有更多结果。','End of source reached. No further results for these filters.')}</p></section>`;
bind('browse-prev',()=>{sourceBrowser.offset=sourceBrowser.previous.pop()||0;return navigate();});
bind('browse-next',()=>{if(page.next_offset===null)return;sourceBrowser.previous.push(sourceBrowser.offset);sourceBrowser.offset=page.next_offset;return navigate();});
document.querySelectorAll('[data-browse-preview]').forEach(b=>b.onclick=busy(()=>{const item=page.items[b.dataset.browsePreview];return openImageViewer(item.preview_url,item.name);}));
document.querySelectorAll('[data-browse-import]').forEach(b=>b.onclick=busy(async()=>{const item=page.items[b.dataset.browseImport];await api('/api/v1/source-libraries/'+encodeURIComponent(source.id)+'/import',{method:'POST',body:{item_id:item.id}});if(epoch!==state.epoch)return;b.textContent=tr('已选入','Imported');b.disabled=true;libraryUI.offset=0;toast(tr('已保存到来源图库','Saved in source library'));}));
hydrateImages();
};
async function loadBrowserPeople(source,epoch){
try{const people=await api('/api/v1/integrations/synology-photos/people?space='+encodeURIComponent(source.people_space||'shared'));if(epoch!==state.epoch)return;$('#browse-person_id').innerHTML='<option value="">'+tr('不限人物','Any person')+'</option>'+people.map(p=>`<option value="${p.id}" ${String(p.id)===String(sourceBrowser.filters.person_id)?'selected':''}>${esc(p.named?p.name:tr('未命名人物 ','Unnamed person ')+p.id)} (${p.count})</option>`).join('');}catch(error){if(epoch===state.epoch)$('#browse-people-status').textContent=tr('人物列表暂不可用,可继续浏览照片','People unavailable; photo browsing remains available');}
}
+30 -10
View File
@@ -172,12 +172,6 @@ body.login-mode header,body.login-mode footer{flex-shrink:0}
.image-card .actions{gap:6px} .image-card .actions{gap:6px}
.image-card .actions button{white-space:nowrap} .image-card .actions button{white-space:nowrap}
.gallery-filters{align-items:end}
.gallery-filters-local{grid-template-columns:repeat(4,minmax(0,1fr))}
.gallery-filters .field{min-width:0}
@media(max-width:1050px){.gallery-filters{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:720px){.gallery-filters{grid-template-columns:minmax(0,1fr)}}
.gallery-filters input,.gallery-filters select{height:46px}
.automation-status{color:var(--accent)} .automation-status{color:var(--accent)}
.automation-table td{vertical-align:middle} .automation-table td{vertical-align:middle}
@@ -248,18 +242,44 @@ form#history-filters{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));
@media(max-width:1050px){form#history-filters{grid-template-columns:repeat(2,minmax(0,1fr))}} @media(max-width:1050px){form#history-filters{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:720px){form#history-filters{grid-template-columns:minmax(0,1fr)}} @media(max-width:720px){form#history-filters{grid-template-columns:minmax(0,1fr)}}
.source-photo{min-width:0;display:flex;flex-direction:column;gap:12px;padding:14px;border:1px solid var(--border);border-radius:12px}
.source-photo>[data-source-item]{width:100%;text-align:left;overflow-wrap:anywhere}
.source-photo .actions{margin-top:auto}
.excluded-photo{display:block;color:var(--muted);font-size:12px} .excluded-photo{display:block;color:var(--muted);font-size:12px}
.image-card>small{display:block} .image-card>small{display:block}
.photo-mark{display:block;color:var(--accent);font-size:12px} .photo-mark{display:block;color:var(--accent);font-size:12px}
.image-card{display:flex;flex-direction:column} .image-card{display:flex;flex-direction:column}
.image-card>.asset-select{align-self:flex-start}
.image-card>.actions{margin-top:auto;padding-top:12px;justify-content:flex-start} .image-card>.actions{margin-top:auto;padding-top:12px;justify-content:flex-start}
.disabled-mark-hint{display:inline-flex;max-width:100%;cursor:help} .disabled-mark-hint{display:inline-flex;max-width:100%;cursor:help}
.disabled-mark-hint>button:disabled{pointer-events:none} .disabled-mark-hint>button:disabled{pointer-events:none}
#disabled-mark-tooltip{position:fixed;z-index:250;width:max-content;max-width:min(300px,calc(100vw - 24px));padding:10px 12px;border-radius:8px;background:var(--ink);color:var(--panel);box-shadow:var(--shadow);font-size:12px;line-height:1.6;white-space:normal;pointer-events:none} #disabled-mark-tooltip{position:fixed;z-index:250;width:max-content;max-width:min(300px,calc(100vw - 24px));padding:10px 12px;border-radius:8px;background:var(--ink);color:var(--panel);box-shadow:var(--shadow);font-size:12px;line-height:1.6;white-space:normal;pointer-events:none}
.library-source-toolbar{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.library-source-toolbar>.field{display:flex;flex-direction:row;align-items:center;gap:12px;margin:0;min-width:0}
.library-source-toolbar label{margin:0;white-space:nowrap}
.library-source-toolbar select{width:220px;max-width:100%;min-width:0}
@media(max-width:720px){.library-source-toolbar>.field{max-width:100%}.library-source-toolbar select{width:180px}}
.library-plan-toolbar{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.library-plan-toolbar>.tabs{margin-bottom:0}
.source-library-gallery,.terminal-library-gallery{grid-template-columns:repeat(auto-fill,min(100%,500px));margin-top:20px}
.library-pagination{margin-top:20px;margin-bottom:20px}
#source-browser-filters{margin-top:20px}
#source-browser-filters fieldset{border:0;padding:0;margin:0;min-width:0}
.source-browser-filters{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:18px}
.source-browser-filters>.field{margin:0;min-width:0}
.source-browser-gallery{grid-template-columns:repeat(auto-fill,minmax(min(100%,260px),1fr));margin-top:20px}
.browse-folder{overflow-wrap:anywhere}
@media(max-width:1050px){.source-browser-filters{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:480px){.source-browser-filters{grid-template-columns:minmax(0,1fr)}}
.library-source-controls>.hint:last-child{margin-bottom:0}
.library-output-panel{margin-top:20px}
.library-output-panel>.library-plan-toolbar{border-bottom:1px solid var(--border);padding-bottom:12px;margin-bottom:14px}
.library-output-panel .library-plan-tabs{background:transparent;padding:0;border-radius:0;gap:6px}
.library-output-panel .library-plan-tabs button{border-radius:8px 8px 0 0;border-bottom:2px solid transparent}
.library-output-panel .library-plan-tabs button.active{border-bottom-color:var(--accent)}
.library-output-panel .library-pagination{margin-bottom:0}
.library-plan-toolbar>.actions{margin-left:auto;justify-content:flex-end}
+9
View File
@@ -0,0 +1,9 @@
'use strict';
// Compatibility controls for existing thumbnail tasks; legacy gallery pages are retired.
async function thumbnailJobFields(job){
const plans=await api('/api/v1/thumbnail-plans');if(!$('#job-form'))return;
const box=document.createElement('section');box.id='legacy-thumbnail-job-fields';box.hidden=!job.generate_thumbnail&&!job.thumbnail_plan_id;box.innerHTML=`<label class="check-row"><input type="checkbox" id="job-thumbnail" ${job.generate_thumbnail?'checked':''}>${tr('生成小图','Generate thumbnail')}</label><div class="field"><label for="job-thumbnail-plan">${tr('小图生成方案(单选)','Thumbnail plan (choose one)')}</label><select id="job-thumbnail-plan"><option value="">${tr('请选择已保存方案','Choose a saved plan')}</option>${plans.map(p=>`<option value="${esc(p.id)}" ${job.thumbnail_plan_id===p.id?'selected':''}>${esc(p.name)} · ${p.width}×${p.height}</option>`).join('')}</select></div><p class="hint">${tr('仅支持照片数据源任务;上传成功后使用源画面生成。新任务请使用来源图库生成方案与终端绑定。','For photo-source tasks only; generates from source pixels after upload succeeds. New tasks should use source-library plans and terminal bindings.')}</p>`;
$('#job-form .modal-actions').before(box);
const update=()=>{const supported=$('#job-content-type')?.value==='photo_source';$('#job-thumbnail').disabled=!supported;if(!supported)$('#job-thumbnail').checked=false;$('#job-thumbnail-plan').disabled=!$('#job-thumbnail').checked;$('#job-thumbnail-plan').required=$('#job-thumbnail').checked;};
$('#job-thumbnail').onchange=update;$('#job-content-type')?.addEventListener('change',update);update();
}
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.29"
readme = "README.md" readme = "README.md"
license = {file = "LICENSE"} license = {file = "LICENSE"}
requires-python = ">=3.14,<3.15" requires-python = ">=3.14,<3.15"
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"] dependencies = ["paho-mqtt==2.1.0", "python-dateutil==2.9.0.post0", "fastapi==0.142.2", "uvicorn==0.54.0", "pillow==13.0.0", "requests==2.34.2", "croniter==6.2.4", "python-multipart==0.0.32", "pyyaml==6.0.3"]
[project.optional-dependencies] [project.optional-dependencies]
test = ["pytest==9.1.1", "httpx2==2.13.1", "mypy==2.3.1", "pip-audit==2.10.1", "types-requests==2.33.0.20260906", "types-PyYAML==6.0.12.20260906", "types-croniter==6.2.4.20260711", "ruff==0.16.9"] test = ["pytest==9.1.1", "httpx2==2.13.1", "mypy==2.3.1", "pip-audit==2.10.1", "types-requests==2.33.0.20260906", "types-PyYAML==6.0.12.20260906", "types-croniter==6.2.4.20260711", "ruff==0.16.9"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
+3 -3
View File
@@ -1,16 +1,16 @@
annotated-doc==0.0.5 annotated-doc==0.0.5
annotated-types==0.8.0 annotated-types==0.8.0
anyio==4.15.1 anyio==4.15.1
certifi==2026.7.22 certifi==2026.10.23
charset-normalizer==3.5.2 charset-normalizer==3.5.2
click==8.5.0 click==8.5.0
croniter==6.2.4 croniter==6.2.4
fastapi==0.142.2 fastapi==0.142.2
h11==0.16.0 h11==0.16.0
idna==3.20 idna==3.20
opentelemetry-api==1.45.0 opentelemetry-api==1.45.1
paho-mqtt==2.1.0 paho-mqtt==2.1.0
pillow==12.3.0 pillow==13.0.0
pydantic-core==2.46.5 pydantic-core==2.46.5
pydantic==2.13.5 pydantic==2.13.5
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
+199
View File
@@ -0,0 +1,199 @@
"""Opt-in migration of an unambiguous single photo-source task; no device writes."""
import argparse
import json
import os
import urllib.error
import urllib.request
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from pigway_frame_flow.renderers.frame_geometry import frame_canvas
from pigway_frame_flow.settings import Settings
def migrate(call, apply=False):
jobs = call("GET", "/api/v1/jobs")
candidates = [j for j in jobs if j["source"]["type"] == "photo_source"]
sources = call("GET", "/api/v1/source-libraries")
if len(candidates) != 1 or len(sources) != 1:
raise RuntimeError(
"Migration requires exactly one photo task and one source; configure ambiguous bindings in the UI"
)
job, source = candidates[0], sources[0]
if job.get("library_pipeline"):
return {"already_configured": True}
if job["source"]["source_id"] != source["id"] or job["source"].get("item_id"):
raise RuntimeError(
"Migration requires a random photo task from the sole source"
)
if job.get("next_run") and datetime.fromisoformat(job["next_run"]) <= datetime.now(
UTC
) + timedelta(minutes=2):
raise RuntimeError(
"Task is due soon; retry after it completes without changing its schedule"
)
for status in ["QUEUED", "RENDERING", "UPLOADING", "SYNCING", "UNKNOWN"]:
if call("GET", "/api/v1/operation-history?status=" + status + "&page_size=1")[
"total"
]:
raise RuntimeError(
"Pending operations exist; resolve them before migrating"
)
terminals = call("GET", "/api/v1/library-terminals")
terminal_ids = {t["id"] for t in terminals}
if job["device_id"] not in terminal_ids or "watch" not in terminal_ids:
raise RuntimeError("The task frame and watch must both exist")
device = call("GET", "/api/v1/devices/" + job["device_id"])
width, height = frame_canvas(
SimpleNamespace(**device["display"]), source.get("orientation")
)
frame = {
"source_id": source["id"],
"name": "现有相框方案",
"width": width,
"height": height,
"fit": "cover"
if source["folder_caption"]
else job["source"].get("fit", "contain"),
"caption": source["folder_caption"],
"profile": job["source"].get("profile", "color"),
"quality": 93,
"center_x": 0.5,
"center_y": 0.5,
}
if frame["fit"] == "smart" or job["source"].get("fit") == "smart":
raise RuntimeError("Smart crop requires an explicit generation-plan choice")
watch = {
"source_id": source["id"],
"name": "手表默认方案",
"width": 410,
"height": 502,
"fit": "cover",
"caption": False,
"profile": "color",
"quality": 85,
"center_x": 0.5,
"center_y": 0.5,
}
if job.get("thumbnail_plan_id"):
previous = call("GET", "/api/v1/thumbnail-plans/" + job["thumbnail_plan_id"])
if (previous["width"], previous["height"]) != (410, 502):
raise RuntimeError(
"The previous thumbnail plan does not match the watch screen"
)
watch.update(
{
k: previous[k]
for k in ["width", "height", "fit", "quality", "center_x", "center_y"]
}
)
plans = call("GET", "/api/v1/generation-plans?source_id=" + source["id"])
expected_names = {frame["name"], watch["name"]}
if any(not p["archived"] and p["name"] not in expected_names for p in plans):
raise RuntimeError(
"Custom generation plans already exist; configure bindings explicitly"
)
known = {}
for target in [frame, watch]:
match = [p for p in plans if not p["archived"] and p["name"] == target["name"]]
if match and any(match[0].get(k) != v for k, v in target.items()):
raise RuntimeError(
"Existing migration plan parameters differ; configure bindings explicitly"
)
if match:
known[target["name"]] = match[0]
for terminal in terminals:
if terminal["id"] in [job["device_id"], "watch"] and terminal["binding"]:
expected = known.get(
frame["name"] if terminal["id"] == job["device_id"] else watch["name"]
)
if (
not expected
or terminal["binding"]["source_id"] != source["id"]
or terminal["binding"]["plan_id"] != expected["id"]
):
raise RuntimeError("A terminal already has another binding")
if not apply:
return {
"ready": True,
"frame_size": [width, height],
"watch_size": [410, 502],
"next_run": job["next_run"],
}
for target in [frame, watch]:
if target["name"] not in known:
known[target["name"]] = call("POST", "/api/v1/generation-plans", target)
for terminal_id, target in [(job["device_id"], frame), ("watch", watch)]:
call(
"PUT",
"/api/v1/library-terminals/" + terminal_id + "/binding",
{"source_id": source["id"], "plan_id": known[target["name"]]["id"]},
)
migrated = call(
"PUT", "/api/v1/jobs/" + job["id"] + "/library-mode", {"enabled": True}
)
if (
migrated["next_run"] != job["next_run"]
or migrated["enabled"] != job["enabled"]
or migrated["schedule"] != job["schedule"]
):
raise RuntimeError("Task scheduling changed concurrently; review the task")
seeded = False
try:
current = call(
"GET", "/api/v1/devices/" + job["device_id"] + "/current-content"
)
origin = current.get("source") or {}
if (
current.get("source_known")
and origin.get("type") == "photo_source"
and origin.get("source_id") == source["id"]
and origin.get("item_id")
):
picture = call(
"POST",
"/api/v1/source-libraries/" + source["id"] + "/import",
{"item_id": origin["item_id"]},
)
call("POST", "/api/v1/source-images/" + picture["id"] + "/generate", {})
seeded = True
except urllib.error.HTTPError:
# Current pixels may no longer be available upstream. The binding and
# schedule remain valid; never guess a substitute or write to a device.
pass
return {
"migrated": True,
"terminals_bound": 2,
"current_source_generated_locally": seeded,
"next_run": migrated["next_run"],
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
settings = Settings()
origin = os.environ.get(
"FRAME_FLOW_MIGRATION_URL", "http://127.0.0.1:18787"
).rstrip("/")
def call(method, path, payload=None):
request = urllib.request.Request(
origin + path,
method=method,
headers={
"Authorization": "Bearer " + settings.api_token,
"Content-Type": "application/json",
},
data=json.dumps(payload).encode() if payload is not None else None,
)
with urllib.request.urlopen(request, timeout=120) as response:
return json.load(response) if response.status != 204 else None
print(json.dumps(migrate(call, args.apply), ensure_ascii=False))
if __name__ == "__main__":
main()
+40 -4
View File
@@ -1,5 +1,41 @@
/* Copy must describe actual storage targets and actions; run against isolated Mock only. */ /* Current gallery terminology and upload flow; isolated Mock only. */
const assert=require('assert'),fs=require('fs'),vm=require('vm'),{chromium}=require('playwright'); const assert=require('assert'),fs=require('fs'),vm=require('vm'),{chromium}=require('playwright');
const context={};context.window=context;vm.createContext(context);vm.runInContext(fs.readFileSync('pigway_frame_flow/web/i18n.js','utf8'),context); const translations={};translations.window=translations;vm.createContext(translations);vm.runInContext(fs.readFileSync('pigway_frame_flow/web/i18n.js','utf8'),translations);
for(const file of ['app.js','features.js','extensions.js'])for(const match of fs.readFileSync('pigway_frame_flow/web/'+file,'utf8').matchAll(/\bt\('([^']+)'\)/g))for(const lang of ['zh','en'])assert(context.MESSAGES[lang][match[1]],`${file}: missing ${lang}.${match[1]}`); for(const file of ['app.js','features.js','extensions.js','libraries.js','source-browser.js'])for(const match of fs.readFileSync('pigway_frame_flow/web/'+file,'utf8').matchAll(/\bt\('([^']+)'\)/g))for(const lang of ['zh','en'])assert(translations.MESSAGES[lang][match[1]],`${file}: missing ${lang}.${match[1]}`);
(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});try{const p=await b.newPage();let deletes=0;await p.route('**/api/v1/assets?*',r=>r.fulfill({json:[{asset_id:'fixture-local',name:'Local fixture',origin:'manual',starred:false,current:true,url:'/fixture.jpg'}]}));p.on('request',r=>{if(r.method()==='DELETE')deletes++;});await p.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await p.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await p.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await p.locator('#login-form button').click();await p.locator('.hero').waitFor();assert(await p.evaluate(()=>state.settings.mock));for(const lang of ['zh','en']){await p.evaluate(async lang=>{state.lang=lang;state.tab='local';await navigate('gallery')},lang);await p.locator('[data-delete="0"]').click();assert(await p.locator('#modal-content').textContent().then(x=>x.includes(lang==='zh'?'不会切换画框':'frame display is unchanged')));await p.locator('#modal-cancel').click();assert.equal(await p.locator('label[for="gallery-sort"]').textContent(),lang==='zh'?'排序方式':'Sort order');assert(await p.locator('#gallery-selection-help').textContent().then(x=>x.includes(lang==='zh'?'手动删除仍有效':'manual deletion still applies')));await p.evaluate(()=>navigate('playlists'));await p.locator('#new-playlist').click();assert.equal(await p.locator('label[for="playlist-mode"]').textContent(),lang==='zh'?'播放顺序':'Playback order');await p.locator('#modal-cancel').click();await p.evaluate(()=>navigate('dashboard'));await p.waitForFunction(()=>document.querySelector('#main').textContent.includes(t('weather')));assert(await p.locator('#main').textContent().then(x=>x.includes(lang==='zh'?'型号信息来源':'Model information source')));}assert.equal(deletes,0);console.log('Copy audit PASS: all literal translations present; bilingual sort/playback/model labels, local deletion and star scope verified; no deletions');}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)}); (async()=>{
const base=process.env.FRAME_FLOW_URL;assert(base);
const token=process.env.FRAME_FLOW_TOKEN||fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);
const headers={Authorization:'Bearer '+token};const request=async(path,method='GET',body)=>{const response=await fetch(base+path,{method,headers:{...headers,...(body?{'Content-Type':'application/json'}:{})},body:body?JSON.stringify(body):undefined});assert(response.ok,`${path}: ${response.status}`);return response.status===204?null:response.json();};
assert((await request('/api/v1/settings')).mock===true);
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
try{
const page=await browser.newPage({extraHTTPHeaders:headers}),errors=[];
page.on('pageerror',e=>errors.push(e.message));await page.route('**/api/v1/auth/me',r=>r.fulfill({json:{username:'fixture',csrf_token:''}}));
await page.goto(base+'/#upload');await page.locator('#upload-source').waitFor();
const png=Buffer.from(process.env.FRAME_FLOW_FIXTURE_BASE64,'base64');assert(png.length);
await page.locator('#upload-source').selectOption('manual-uploads');
await page.locator('#file').setInputFiles({name:'Audit fixture.png',mimeType:'image/png',buffer:png});
assert(await page.locator('#save-local').isEnabled());await page.locator('#save-local').click();await page.locator('#view-upload-library').waitFor();
await page.locator('#view-upload-library').click();await page.locator('[data-library-preview="0"]').first().waitFor();
assert.equal(await page.locator('#library-source').inputValue(),'manual-uploads');assert(await page.locator('#library-import').textContent().then(x=>x.includes('上传')));assert(await page.locator('#device').isHidden());
const pictures=await request('/api/v1/source-libraries/manual-uploads/images');assert.equal(pictures.total,1);
const plan=await request('/api/v1/generation-plans','POST',{source_id:'manual-uploads',name:'UI fixture watch',width:410,height:502});
await request('/api/v1/source-images/'+pictures.items[0].id+'/generate','POST',{plan_id:plan.id});
await request('/api/v1/library-terminals/watch/binding','PUT',{source_id:'manual-uploads',plan_id:plan.id});
for(const lang of ['zh','en'])for(const width of [1440,390]){
await page.setViewportSize({width,height:1000});await page.evaluate(async lang=>{state.lang=lang;await navigate('gallery');},lang);await page.locator('#library-source').waitFor();
const tabs=page.locator('[data-library-plan]');assert(await tabs.count()>=2);await page.locator('[data-library-plan="'+plan.id+'"]').click();await page.locator('[data-library-settings="0"]').waitFor();
assert(!(await page.locator('#main').innerText()).includes('本地图库'));assert(await page.locator('#device').isHidden());assert((await page.locator('#footer').innerText()).includes(lang==='zh'?'独立智能':'Independent smart'));
await page.evaluate(()=>navigate('terminal-gallery'));await page.locator('[data-library-terminal="watch"]').click();await page.locator('[data-terminal-send="0"]').waitFor();assert(await page.locator('[data-terminal-send="0"]').isDisabled());
await page.evaluate(()=>navigate('upload'));await page.locator('#upload-source').waitFor();assert.equal(await page.locator('#save-local').innerText(),lang==='zh'?'保存源画面到图库':'Save source pixels to library');
await page.evaluate(()=>navigate('settings'));await page.locator('#storage-policy').waitFor();assert(await page.locator('#main').innerText().then(x=>x.includes(lang==='zh'?'磁盘可用':'Disk available')));
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),`${lang} ${width}: overflow`);
}
await page.evaluate(()=>navigate('connections'));await page.locator('#client-form').waitFor();assert.equal(await page.locator('input[name="client-scope"][value="images:write"]').count(),1);
const sources=await request('/api/v1/source-libraries'),nas=sources.find(s=>s.kind==='synology-photos');assert(nas);
await page.route('**/api/v1/source-libraries/'+nas.id+'/browse?*',r=>r.fulfill({json:{items:[],next_offset:new URL(r.request().url()).searchParams.get('offset')==='0'?40:null,scanned:40,paged_provider:true}}));
await page.evaluate(id=>openSourceBrowser(id),nas.id);await page.locator('#browse-next').waitFor({state:'visible'});await page.waitForFunction(()=>!document.querySelector('#browse-next').disabled);assert((await page.locator('#browse-gallery').innerText()).includes('does not mean'));await page.locator('#browse-next').click();await page.waitForFunction(()=>document.querySelector('#browse-gallery').textContent.includes('End of source'));assert(await page.locator('#browse-next').isDisabled());
if(process.env.FRAME_FLOW_UI_SCREENSHOT_DIR){fs.mkdirSync(process.env.FRAME_FLOW_UI_SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.FRAME_FLOW_UI_SCREENSHOT_DIR+'/browser-empty-mobile.png',fullPage:true});await page.evaluate(()=>navigate('gallery'));await page.locator('#library-source').waitFor();await page.screenshot({path:process.env.FRAME_FLOW_UI_SCREENSHOT_DIR+'/source-upload-mobile.png',fullPage:true});}
assert.deepEqual(errors,[]);console.log('PASS: upload → source library → plan generation → terminal, bilingual desktop/mobile copy, disk capacity, client scopes and sparse-search continuation');
}finally{await browser.close();}
})().catch(e=>{console.error(e.message);process.exit(1)});
+2 -2
View File
@@ -1,6 +1,6 @@
/* Isolated Mock only: dashboard and gallery share the same read-only image viewer. */ /* Isolated Mock only: dashboard and gallery share the same read-only image viewer. */
const assert=require('assert'),{chromium}=require('playwright'); const assert=require('assert'),{chromium}=require('playwright');
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});let checks=0; (async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});let checks=0;
try{for(const width of [1440,768,390]){const p=await browser.newPage({viewport:{width,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.route('**/api/v1/devices/*/current-content',r=>r.fulfill({json:{image_id:42,preview_url:'/fixture-preview.svg?thumbnail=true',synced:true,source_known:false,overrides:[]}}));await p.route('**/fixture-preview.svg*',r=>{return r.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="WIDTH" height="900"><rect width="1200" height="900" fill="#6c9c83"/><circle cx="600" cy="450" r="220" fill="#e9d9af"/></svg>'.replace('WIDTH',new URL(r.request().url()).searchParams.has('thumbnail')?'300':'1200')});});await p.route('**/api/v1/devices/*/images',r=>r.fulfill({json:[{image_id:42,name:'Fixture gallery',current:true,url:'/fixture-preview.svg'}]})); try{for(const width of [1440,768,390]){const p=await browser.newPage({viewport:{width,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.route('**/api/v1/devices/*/current-content',r=>r.fulfill({json:{image_id:42,preview_url:'/fixture-preview.svg?thumbnail=true',synced:true,source_known:false,overrides:[]}}));await p.route('**/fixture-preview.svg*',r=>{return r.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="WIDTH" height="900"><rect width="1200" height="900" fill="#6c9c83"/><circle cx="600" cy="450" r="220" fill="#e9d9af"/></svg>'.replace('WIDTH',new URL(r.request().url()).searchParams.has('thumbnail')?'300':'1200')});});await p.route('**/api/v1/source-libraries',r=>r.fulfill({json:[{id:'fixture',name:'Fixture',kind:'synology-photos'}]}));await p.route('**/api/v1/generation-plans?*',r=>r.fulfill({json:[]}));await p.route('**/api/v1/source-libraries/fixture/images?*',r=>r.fulfill({json:{total:1,items:[{id:'fixture-image',name:'Fixture gallery',url:'/fixture-preview.svg',created_at:'2026-10-23',versions:[]}]}}));
await p.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await p.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await p.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await p.locator('#login-form button').click();await p.locator('#current-frame-preview').waitFor();assert(await p.evaluate(()=>state.settings.mock)); await p.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await p.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await p.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await p.locator('#login-form button').click();await p.locator('#current-frame-preview').waitFor();assert(await p.evaluate(()=>state.settings.mock));
for(const lang of ['zh','en'])for(const theme of ['dark','light']){await p.evaluate(async({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;await navigate('dashboard')},{lang,theme});await p.waitForFunction(()=>document.querySelector('#current-frame-image')?.naturalWidth===1200);assert(await p.locator('#current-frame-preview').evaluate(e=>e.getBoundingClientRect().height)>=(width<=720?320:400));assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await p.locator('#current-frame-preview').focus();const fullImage=p.waitForRequest(r=>new URL(r.url()).pathname==='/fixture-preview.svg'&&!new URL(r.url()).searchParams.has('thumbnail'));await p.keyboard.press('Enter');await fullImage;await p.locator('dialog.image-viewer[open]').waitFor();await p.waitForFunction(()=>document.querySelector('#original-image')?.naturalWidth===1200);assert.equal(await p.locator('.image-viewer-name').textContent(),(lang==='zh'?'当前显示内容':'Currently displayed content')+' #42');assert.equal(await p.locator('.image-viewer-actions button').count(),3);const sizing=p.locator('.image-viewer-actions button[aria-pressed]');await sizing.nth(1).click();assert(await p.locator('.image-viewer-viewport').evaluate(e=>e.classList.contains('actual-size')));await sizing.nth(0).click();assert(!await p.locator('.image-viewer-viewport').evaluate(e=>e.classList.contains('actual-size')));await p.keyboard.press('Escape');assert(!await p.locator('#modal').evaluate(e=>e.open));await p.waitForFunction(()=>!document.body.classList.contains('image-viewer-open'));if(process.env.FRAME_FLOW_SCREENSHOT_DIR&&lang==='zh'&&theme==='dark'){require('fs').mkdirSync(process.env.FRAME_FLOW_SCREENSHOT_DIR,{recursive:true});await p.screenshot({path:process.env.FRAME_FLOW_SCREENSHOT_DIR+'/dashboard-'+width+'.png',fullPage:true});}await p.evaluate(()=>navigate('gallery'));await p.locator('[data-original="0"]').click();await p.locator('dialog.image-viewer[open]').waitFor();assert.equal(await p.locator('.image-viewer-actions button').count(),3);await p.locator('#modal-cancel').click();checks++;}assert.deepEqual(errors,[]);await p.close();}console.log('Dashboard preview: '+checks+' profiles PASS; larger preview, keyboard opening, shared gallery viewer, fit/actual size, Escape, no overflow');}finally{await browser.close()}})().catch(e=>{console.error(e);process.exit(1)}); for(const lang of ['zh','en'])for(const theme of ['dark','light']){await p.evaluate(async({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;await navigate('dashboard')},{lang,theme});await p.waitForFunction(()=>document.querySelector('#current-frame-image')?.naturalWidth===1200);assert(await p.locator('#current-frame-preview').evaluate(e=>e.getBoundingClientRect().height)>=(width<=720?320:400));assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await p.locator('#current-frame-preview').focus();const fullImage=p.waitForRequest(r=>new URL(r.url()).pathname==='/fixture-preview.svg'&&!new URL(r.url()).searchParams.has('thumbnail'));await p.keyboard.press('Enter');await fullImage;await p.locator('dialog.image-viewer[open]').waitFor();await p.waitForFunction(()=>document.querySelector('#original-image')?.naturalWidth===1200);assert.equal(await p.locator('.image-viewer-name').textContent(),(lang==='zh'?'当前显示内容':'Currently displayed content')+' #42');assert.equal(await p.locator('.image-viewer-actions button').count(),3);const sizing=p.locator('.image-viewer-actions button[aria-pressed]');await sizing.nth(1).click();assert(await p.locator('.image-viewer-viewport').evaluate(e=>e.classList.contains('actual-size')));await sizing.nth(0).click();assert(!await p.locator('.image-viewer-viewport').evaluate(e=>e.classList.contains('actual-size')));await p.keyboard.press('Escape');assert(!await p.locator('#modal').evaluate(e=>e.open));await p.waitForFunction(()=>!document.body.classList.contains('image-viewer-open'));if(process.env.FRAME_FLOW_SCREENSHOT_DIR&&lang==='zh'&&theme==='dark'){require('fs').mkdirSync(process.env.FRAME_FLOW_SCREENSHOT_DIR,{recursive:true});await p.screenshot({path:process.env.FRAME_FLOW_SCREENSHOT_DIR+'/dashboard-'+width+'.png',fullPage:true});}await p.evaluate(()=>navigate('gallery'));await p.locator('[data-library-preview="0"]').first().click();await p.locator('dialog.image-viewer[open]').waitFor();assert.equal(await p.locator('.image-viewer-actions button').count(),3);await p.locator('#modal-cancel').click();checks++;}assert.deepEqual(errors,[]);await p.close();}console.log('Dashboard preview: '+checks+' profiles PASS; larger preview, keyboard opening, shared gallery viewer, fit/actual size, Escape, no overflow');}finally{await browser.close()}})().catch(e=>{console.error(e);process.exit(1)});
-10
View File
@@ -1,10 +0,0 @@
/* Mock-only: slow device status and cloud metadata must not block real gallery controls. */
const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright');
(async()=>{const base=process.env.FRAME_FLOW_URL;assert(base);const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);assert((await(await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}})).json()).mock===true);const b=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
try{const p=await b.newPage({extraHTTPHeaders:{Authorization:'Bearer '+token}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.route('**/api/v1/auth/me',r=>r.fulfill({json:{username:'fixture',csrf_token:''}}));let releaseStatus,releaseList;const pending=[];let checks=0;
await p.route('**/api/v1/devices',async r=>{pending.push('device');await new Promise(resolve=>releaseStatus=resolve);await r.fulfill({json:[{id:'living-room',name:'Fixture frame'}]});});
await p.route('**/api/v1/devices/*/images',async r=>{pending.push('list');await new Promise(resolve=>releaseList=resolve);await r.fulfill({json:[]});});
await p.goto(base+'/#gallery');await p.locator('#gallery-search').waitFor({timeout:5000});for(let i=0;i<100&&pending.length<2;i++)await p.waitForTimeout(20);assert.deepEqual(pending.sort(),['device','list']);assert(await p.locator('#gallery-upload').isEnabled());assert(await p.locator('#local-tab').isEnabled());assert.equal(await p.locator('.image-card,.placeholder-line,.image-pending,.page-loading').count(),0);checks++;if(process.env.FRAME_FLOW_SCREENSHOT_DIR){fs.mkdirSync(process.env.FRAME_FLOW_SCREENSHOT_DIR,{recursive:true});await p.screenshot({path:process.env.FRAME_FLOW_SCREENSHOT_DIR+'/gallery-list-pending.png'});}
await p.locator('#gallery-search').fill('kept query');await p.locator('#gallery-sort').selectOption('name');releaseStatus();releaseList();await p.waitForFunction(()=>document.querySelector('.gallery')?.getAttribute('aria-busy')==='false');assert.equal(await p.locator('#gallery-search').inputValue(),'kept query');assert.equal(await p.locator('#gallery-sort').inputValue(),'name');assert.equal(await p.locator('.card.empty').count(),1);checks++;
assert.deepEqual(errors,[]);console.log('Cold gallery bootstrap:',checks,'checks PASS; device/cloud requests blocked independently, real controls ready, input preserved');
}finally{await b.close();}})().catch(e=>{console.error(e.message);process.exit(1)});
-14
View File
@@ -1,14 +0,0 @@
/* Mock current-image metadata; never performs a device write. */
const assert=require('assert'),{chromium}=require('playwright');
(async()=>{
const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}),page=await browser.newPage();let checks=0,writes=0;
await page.route('**/api/v1/devices/*/images',r=>r.fulfill({json:[{image_id:1,name:'Current fixture',current:true,url:'/mock-image.jpg',created_at:null},{image_id:2,name:'Other fixture',current:false,url:'/mock-image.jpg',created_at:null}]}));
page.on('request',r=>{if(r.method()==='POST'&&r.url().endsWith('/display'))writes++;});
try{
await page.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');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));
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-show="0"]').waitFor();assert(await page.locator('[data-show="0"]').isDisabled());assert(await page.locator('[data-show="0"]').textContent()===(lang==='zh'?'当前图片':'Current image'));assert(await page.locator('[data-show="1"]').isEnabled());await page.locator('[data-show="0"]').evaluate(b=>b.onclick());assert(!await page.locator('#modal').evaluate(d=>d.open));assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++;
}
assert(writes===0);console.log('Current-image gallery button: '+checks+' profiles passed; disabled label, other image enabled, no display request');
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exit(1)});
-18
View File
@@ -1,18 +0,0 @@
/* Mock-only: real writable destinations, concrete names and responsive labels. */
const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright');
(async()=>{
const base=process.env.FRAME_FLOW_URL;assert(base);const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);
assert((await(await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}})).json()).mock===true);
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
try{
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto(base);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();
await page.evaluate(()=>{state.settings.storage_name='FixtureNAS';state.settings.gallery_destinations=[{id:'cloud',adapter:'kokonna',name:'KoKonna',storage:'cloud',connected:true,supports_upload:true},{id:'local',adapter:'local-assets',name:'FixtureNAS',storage:'local',connected:true,supports_upload:true},{id:'read-only-drive',adapter:'kokonna',name:'Read-only fixture',storage:'cloud',connected:true,supports_upload:false},{id:'offline-drive',adapter:'kokonna',name:'Offline fixture',storage:'cloud',connected:false,supports_upload:true},{id:'unimplemented-drive',adapter:'unimplemented',name:'Future fixture',storage:'cloud',connected:true,supports_upload:true}];});let checks=0;
for(const width of [320,390,768,1440])for(const lang of ['zh','en'])for(const theme of ['light','dark']){
await page.setViewportSize({width,height:900});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;return navigate('gallery');},{lang,theme});
assert.equal(await page.locator('#cloud-tab').innerText(),lang==='zh'?'KoKonna 云端图库':'KoKonna cloud gallery');assert.equal(await page.locator('#local-tab').innerText(),lang==='zh'?'FixtureNAS 本地图库':'FixtureNAS Local gallery');assert.equal(await page.locator('[data-gallery-destination]').count(),2);assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++;
}
await page.setViewportSize({width:320,height:740});await page.evaluate(()=>{state.settings.gallery_destinations.find(d=>d.id==='local').name='家庭照片存储服务器名称很长的NAS';return navigate('gallery');});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++;
await page.locator('#local-tab').click();assert.equal(await page.evaluate(()=>state.tab),'local');await page.locator('#cloud-tab').click();assert.equal(await page.evaluate(()=>state.tab),'cloud');checks++;
assert.deepEqual(errors,[]);console.log('Gallery destinations:',checks,'checks PASS; concrete labels, capability/connection/adapter gates, responsive layout and tab switching');
}finally{await browser.close();}
})().catch(e=>{console.error(e.message);process.exit(1)});
-18
View File
@@ -1,18 +0,0 @@
/* Mock current-image metadata; never performs a device write. */
const assert=require('assert'),{chromium}=require('playwright');
(async()=>{
const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}),page=await browser.newPage();let checks=0,writes=0,starred=false;
await page.route('**/api/v1/assets?*',r=>r.fulfill({json:[{asset_id:'local-1',name:'Current fixture',current:true,origin:'manual',starred,url:'/mock-image.jpg',created_at:null},{asset_id:'local-2',name:'Other fixture',current:false,origin:'task',job_id:'fixture-task',job_name:'Fixture task',url:'/mock-image.jpg',created_at:null}]}));
await page.route('**/api/v1/assets/local-1/star',r=>{starred=r.request().postDataJSON().starred;return r.fulfill({json:{starred,expires_at:null}});});
page.on('request',r=>{if(r.method()==='POST'&&r.url().endsWith('/display'))writes++;});
try{
await page.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');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));
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('#local-tab').click();await page.locator('[data-show="0"]').waitFor();const boxes=page.locator('.asset-select');const selected=()=>boxes.evaluateAll(xs=>xs.map(x=>x.checked));
assert.equal(await page.locator('#asset-invert').textContent(),lang==='zh'?'反选':'Invert selection');await boxes.nth(0).check();await page.locator('#asset-invert').click();assert.deepEqual(await selected(),[false,true]);await page.locator('#asset-invert').click();assert.deepEqual(await selected(),[true,false]);
await page.locator('#gallery-search').fill('Current fixture');await page.locator('#asset-invert').click();assert.deepEqual(await selected(),[false,false]);await page.locator('#asset-invert').click();assert.deepEqual(await selected(),[true,false]);await page.locator('#gallery-search').fill('no matching fixture');await page.locator('#asset-invert').click();assert.deepEqual(await selected(),[true,false]);await page.locator('#gallery-search').fill('');await page.locator('#asset-all').click();assert.deepEqual(await selected(),[true,true]);await page.locator('#asset-invert').click();assert.deepEqual(await selected(),[false,false]);
const star=page.locator('.image-card').first().locator('button[aria-pressed]');assert(await star.count()===1);const original=await star.getAttribute('aria-pressed');await star.click();await page.waitForFunction(expected=>document.querySelector('.image-card button[aria-pressed]')?.getAttribute('aria-pressed')===expected,original==='true'?'false':'true');assert(await page.locator('[data-show="0"]').isDisabled());assert(await page.locator('[data-show="0"]').textContent()===(lang==='zh'?'当前图片':'Current image'));assert(await page.locator('[data-show="1"]').isEnabled());await page.locator('[data-show="0"]').evaluate(b=>b.onclick());assert(!await page.locator('#modal').evaluate(d=>d.open));assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++;
}
assert(writes===0);console.log('Gallery inversion and current-image star: '+checks+' profiles passed; disabled label, other image enabled, no display request');
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exit(1)});
+4 -2
View File
@@ -9,7 +9,9 @@ const {chromium}=require('playwright');
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}); const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
try{ try{
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message)); const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.route('**/api/v1/devices/*/images',r=>r.fulfill({json:[{image_id:1,name:'Loading fixture',url:'/ui-audit-image.svg',current:false}]})); await page.route('**/api/v1/source-libraries',r=>r.fulfill({json:[{id:'fixture',name:'Fixture',kind:'synology-photos'}]}));
await page.route('**/api/v1/generation-plans?*',r=>r.fulfill({json:[]}));
await page.route('**/api/v1/source-libraries/fixture/images?*',r=>r.fulfill({json:{total:1,items:[{id:'fixture-image',name:'Loading fixture',url:'/ui-audit-image.svg',created_at:'2026-10-23',versions:[]}]}}));
let release;let requested; let release;let requested;
await page.route('**/ui-audit-image.svg*',async r=>{requested();await new Promise(resolve=>release=resolve);await r.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"><rect width="300" height="400" fill="#167253"/></svg>'});}); await page.route('**/ui-audit-image.svg*',async r=>{requested();await new Promise(resolve=>release=resolve);await r.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"><rect width="300" height="400" fill="#167253"/></svg>'});});
await page.goto(base);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(); await page.goto(base);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();
@@ -26,7 +28,7 @@ const {chromium}=require('playwright');
const request=new Promise(resolve=>requested=resolve);await page.evaluate(()=>navigate('gallery'));await request;await centered(); const request=new Promise(resolve=>requested=resolve);await page.evaluate(()=>navigate('gallery'));await request;await centered();
const before=await page.locator('.image-loading').boundingBox();release();await page.waitForFunction(()=>document.querySelector('.thumb')?.naturalWidth===300&&!document.querySelector('.image-loading')); const before=await page.locator('.image-loading').boundingBox();release();await page.waitForFunction(()=>document.querySelector('.thumb')?.naturalWidth===300&&!document.querySelector('.image-loading'));
const after=await page.locator('.thumb').boundingBox();assert(Math.abs(before.height-after.height)<1,'Thumbnail placeholder changes height');checks++; const after=await page.locator('.thumb').boundingBox();assert(Math.abs(before.height-after.height)<1,'Thumbnail placeholder changes height');checks++;
const originalRequest=new Promise(resolve=>requested=resolve);await page.locator('[data-original]').click();await originalRequest;await centered();release();await page.waitForFunction(()=>document.querySelector('#original-image')?.naturalWidth===300&&!document.querySelector('.image-loading'));await page.locator('#modal-cancel').click();checks++; const originalRequest=new Promise(resolve=>requested=resolve);await page.locator('[data-library-preview]').first().click();await originalRequest;await centered();release();await page.waitForFunction(()=>document.querySelector('#original-image')?.naturalWidth===300&&!document.querySelector('.image-loading'));await page.locator('#modal-cancel').click();checks++;
await page.evaluate(async()=>{const img=document.querySelector('.thumb');await blobImage(img,'/ui-audit-missing-image');}); await page.evaluate(async()=>{const img=document.querySelector('.thumb');await blobImage(img,'/ui-audit-missing-image');});
assert.equal(await page.locator('.image-load-error').count(),1);assert.equal(await page.locator('.image-load-error .spinner').count(),0);assert(await page.locator('.image-load-error').innerText());checks++; assert.equal(await page.locator('.image-load-error').count(),1);assert.equal(await page.locator('.image-load-error .spinner').count(),0);assert(await page.locator('.image-load-error').innerText());checks++;
} }
+41
View File
@@ -0,0 +1,41 @@
/* Mock-only regression for the current source/terminal galleries and unbinding. */
const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright');
(async()=>{
const base=process.env.FRAME_FLOW_URL;assert(base);
const token=process.env.FRAME_FLOW_TOKEN||fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);
assert((await(await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}})).json()).mock===true);
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
try{
const page=await browser.newPage({extraHTTPHeaders:{Authorization:'Bearer '+token}}),errors=[];
page.on('pageerror',e=>errors.push(e.message));
await page.route('**/api/v1/auth/me',r=>r.fulfill({json:{username:'fixture',csrf_token:''}}));
const sources=await(await fetch(base+'/api/v1/source-libraries',{headers:{Authorization:'Bearer '+token}})).json();assert(sources.length);
const sourceId=sources[0].id,planId='a'.repeat(32);let bound=true,deletions=0,current=true;
let plan={id:planId,name:'Fixture frame',source_id:sourceId,width:1600,height:1200,quality:85,fit:'cover',center_x:0.5,center_y:0.5,caption:true,profile:'color'};
const version=()=>({asset_id:'fixture-version',source_image_id:'fixture-source',plan_id:planId,name:'Fixture-'+plan.name+'.jpg',stored_name:'Fixture-original.jpg',plan_name:plan.name,matches_current_plan:true,url:'/ui/logo-symbol.svg',created_at:'2026-10-23',plan:{...plan,name:'Original snapshot'},terminal_uses:[],delivery_status:'COMPLETED'});
await page.route('**/api/v1/library-terminals',r=>r.fulfill({json:[{id:'living-room',name:'Authoritative frame',kind:'frame',transport_supported:true,binding:bound?{source_id:sourceId,plan_id:planId}:null},{id:'watch',kind:'watch',transport_supported:false}]}));
await page.route('**/api/v1/generation-plans?*',r=>r.fulfill({json:[plan]}));
await page.route('**/api/v1/generation-plans/'+planId,r=>{assert.equal(r.request().method(),'PUT');plan={...plan,...r.request().postDataJSON()};return r.fulfill({json:plan});});
await page.route('**/api/v1/source-libraries/'+sourceId+'/images?*',r=>r.fulfill({json:{total:1,items:[{id:'fixture-source',name:'Fixture.jpg',url:'/ui/logo-symbol.svg',created_at:'2026-10-23',versions:[version()]}]}}));
await page.route('**/api/v1/library-terminals/*/images?*',r=>r.fulfill({json:{total:1,items:[{...version(),current:current&&!r.request().url().includes('/watch/')}]}}));
await page.route('**/api/v1/library-terminals/living-room/binding',r=>{assert.equal(r.request().method(),'DELETE');deletions++;bound=false;return r.fulfill({json:{}});});
await page.route('**/api/v1/source-libraries/*/browse?*',r=>r.fulfill({json:{items:[],next_offset:null,scanned:0,paged_provider:true}}));
await page.goto(base+'/#gallery');await page.locator('#library-new-plan').waitFor();
assert.equal(await page.locator('#library-legacy,#library-old-terminal,[data-page="legacy-gallery"],[data-page="legacy-plans"]').count(),0);
await page.locator('#library-new-plan').click();await page.locator('#generation-form').waitFor();assert.equal(await page.locator('#generation-legacy').count(),0);await page.locator('[data-generation-preset="watch"]').click();assert.equal(await page.locator('#generation-width').inputValue(),'410');await page.locator('#modal-cancel').click();
await page.locator('[data-library-plan="'+planId+'"]').click();await page.locator('#library-edit-plan').click();await page.locator('#generation-name').fill('Renamed frame');await page.locator('#generation-form button[type=submit]').click();await page.locator('[data-library-plan="'+planId+'"]').filter({hasText:'Renamed frame'}).waitFor();assert((await page.locator('.source-library-gallery').innerText()).includes('Renamed frame'));
await page.locator('[data-page="terminal-gallery"]').click();await page.locator('#library-unbind').waitFor();assert.equal(await page.locator('[data-library-terminal="living-room"]').innerText(),'Authoritative frame');assert.equal(await page.locator('#device option:checked').innerText(),'Authoritative frame');assert.equal(await page.locator('.terminal-library-gallery h3').innerText(),'Fixture-Renamed frame.jpg');assert((await page.locator('.terminal-library-gallery').innerText()).includes('Renamed frame'));await page.locator('[data-terminal-settings="0"]').click();assert.equal(await page.locator('#modal-content h2').innerText(),'Renamed frame');assert((await page.locator('#modal-content').innerText()).includes('Original snapshot'));await page.locator('#modal-cancel').click();
const send=page.locator('[data-terminal-send="0"]');assert(await send.isDisabled());assert.equal(await send.innerText(),'已在终端显示');
current=false;await page.locator('#library-refresh').click();await page.waitForFunction(()=>document.querySelector('[data-terminal-send="0"]')?.disabled===false);assert.equal(await send.innerText(),'推送到终端');
await page.locator('#library-unbind').click();await page.locator('dialog[open]').waitFor();assert.equal(deletions,0);assert((await page.locator('#modal-content').innerText()).includes('来源图库'));await page.locator('#modal-cancel').click();assert.equal(deletions,0);
await page.locator('#library-unbind').click();await page.locator('#modal-confirm').click();await page.waitForFunction(()=>!document.querySelector('#library-unbind'));assert.equal(deletions,1);
for(const width of [1440,390]){await page.setViewportSize({width,height:900});await page.locator('[data-library-terminal="watch"]').click();await page.locator('[data-library-terminal="watch"][aria-pressed=true]').waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.locator('[data-library-terminal="living-room"]').click();}
await page.locator('[data-page="photo-sources"]').click();await page.locator('[data-source-browse]').first().click();await page.locator('#source-browser-filters').waitFor();assert(page.url().endsWith('#source-browser'));
await page.locator('[data-page="automation"]').click();await page.locator('#new-job').click();await page.locator('#job-library-pipeline').waitFor({state:'attached'});await page.locator('#job-content-type').selectOption('photo_source');await page.locator('#job-library-pipeline').waitFor();assert(await page.locator('#legacy-thumbnail-job-fields').isHidden());await page.locator('#modal-cancel').click();
const oldPlanId='b'.repeat(32);
await page.route('**/api/v1/thumbnail-plans',r=>r.fulfill({json:[{id:oldPlanId,name:'Existing task plan',width:410,height:502}]}));
await page.evaluate(async ({sourceId,oldPlanId})=>jobEditor({id:'fixture-job',name:'Existing task',device_id:'living-room',enabled:false,schedule:{type:'daily',time:'08:00'},source:{type:'photo_source',source_id:sourceId,fit:'contain',profile:'color'},display_duration:0,restore_previous:false,priority:50,generate_thumbnail:true,thumbnail_plan_id:oldPlanId}),{sourceId,oldPlanId});
assert(await page.locator('#legacy-thumbnail-job-fields').isVisible());assert(await page.locator('#job-thumbnail').isChecked());assert.equal(await page.locator('#job-thumbnail-plan').inputValue(),oldPlanId);await page.locator('#modal-cancel').click();
assert.deepEqual(errors,[]);console.log('Current galleries, retired entries, generation presets, unbind confirmation, source browser, task editor and responsive layout passed');
}finally{await browser.close();}
})();
-3
View File
@@ -1,3 +0,0 @@
/* 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(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');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="photo-sources"]').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-orientation').selectOption('landscape');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="photo-sources"]').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-orientation').inputValue()==='landscape');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);assert(await page.locator('#source-preview').evaluate(img=>img.naturalWidth===1600&&img.naturalHeight===1200));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-form').textContent().then(x=>x.includes('最新成功图')||x.includes('latest confirmed')));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)});
-3
View File
@@ -1,3 +0,0 @@
/* 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(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');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="photo-sources"]').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-orientation').selectOption('landscape');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="photo-sources"]').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-orientation').inputValue()==='landscape');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);assert(await page.locator('#source-preview').evaluate(img=>img.naturalWidth===1600&&img.naturalHeight===1200));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)});
-54
View File
@@ -1,54 +0,0 @@
/* Mock-only: known image metadata renders real slots; no synthetic skeletons. */
const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright');
(async()=>{
const base=process.env.FRAME_FLOW_URL;assert(base);
const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);
assert((await(await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}})).json()).mock===true);
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
try{
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
let releaseAccount;await page.route('**/api/v1/auth/me',async r=>{await new Promise(resolve=>releaseAccount=resolve);await r.continue();});
await page.goto(base);await page.locator('#nav button').first().waitFor();assert.equal(await page.locator('.image-pending,.placeholder-line,.page-placeholder').count(),0);assert.equal(await page.locator('.page-loading').count(),0);releaseAccount();
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();
let checks=1;
for(const width of [1440,390]){
await page.setViewportSize({width,height:844});await page.evaluate(()=>state.galleryCache?.clear());let releaseMetadata,metadataRequested,releaseImage,imageRequested;
await page.route('**/api/v1/devices/*/images',async r=>{metadataRequested();await new Promise(resolve=>releaseMetadata=resolve);await r.fulfill({json:[{image_id:1,name:'Progressive fixture',url:'/progressive-fixture.svg',current:false}]});});
await page.route('**/progressive-fixture.svg*',async r=>{assert.equal(await page.locator('#gallery-search').count(),1,'Search must precede image requests');assert(await page.locator('#gallery-search').evaluate(e=>typeof e.oninput==='function'));imageRequested();await new Promise(resolve=>releaseImage=resolve);await r.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"/>'});});
const metadata=new Promise(resolve=>metadataRequested=resolve);const image=new Promise(resolve=>imageRequested=resolve);
await page.evaluate(()=>{window.navigation=navigate('gallery');});await metadata;
assert.equal(await page.locator('.image-pending,.placeholder-line,.page-placeholder').count(),0);assert.equal(await page.locator('.image-card').count(),0);assert.equal(await page.locator('.page-loading').count(),0);checks++;
assert(await page.locator('#cloud-tab').isVisible());assert(await page.locator('#local-tab').isEnabled());assert(await page.locator('#gallery-upload').isEnabled());assert(await page.locator('#gallery-search').isVisible());assert(await page.locator('#gallery-sort').isEnabled());assert(await page.locator('#gallery-list-status').isVisible());assert.equal(await page.locator('.card.empty').count(),0);await page.locator('#gallery-search').fill('Progressive');await page.locator('#gallery-sort').selectOption('name');checks++;
releaseMetadata();await image;await page.evaluate(()=>window.navigation);
assert.equal(await page.locator('#gallery-search').inputValue(),'Progressive');assert.equal(await page.locator('#gallery-sort').inputValue(),'name');assert(await page.locator('#gallery-upload').isEnabled());assert.equal(await page.locator('.image-loading .spinner').count(),1);assert.equal(await page.locator('.image-pending').count(),0);checks++;
assert(await page.locator('.image-loading').evaluate(box=>{const a=box.getBoundingClientRect(),b=box.querySelector('.spinner').getBoundingClientRect();return Math.abs(a.x+a.width/2-b.x-b.width/2)<1&&Math.abs(a.y+a.height/2-b.y-b.height/2)<1;}));checks++;
releaseImage();await page.waitForFunction(()=>document.querySelector('.thumb')?.naturalWidth===300&&!document.querySelector('.image-loading'));checks++;
await page.evaluate(()=>navigate('upload'));
const cachedMetadata=new Promise(resolve=>metadataRequested=resolve);const cachedImage=new Promise(resolve=>imageRequested=resolve);
await page.evaluate(()=>{window.navigation=navigate('gallery');});await cachedMetadata;await cachedImage;
assert.equal(await page.locator('#gallery-search').count(),1,'Search must exist while cached metadata refresh is pending');assert(await page.locator('#gallery-search').evaluate(e=>typeof e.oninput==='function'),'Search must be interactive');assert.equal(await page.locator('.image-card').count(),1);assert.equal(await page.locator('.image-loading .spinner').count(),1);assert.equal(await page.locator('.image-pending,.placeholder-line,.page-placeholder').count(),0);checks++;
releaseMetadata();await page.evaluate(()=>window.navigation);releaseImage();await page.waitForFunction(()=>document.querySelector('.thumb')?.naturalWidth===300&&!document.querySelector('.image-loading'));
await page.unroute('**/api/v1/devices/*/images');await page.unroute('**/progressive-fixture.svg*');
let releaseDevice,deviceRequested;await page.route('**/api/v1/devices/living-room',async r=>{deviceRequested();await new Promise(resolve=>releaseDevice=resolve);await r.continue();});
const device=new Promise(resolve=>deviceRequested=resolve);await page.evaluate(()=>{window.navigation=navigate('dashboard');});await device;
assert.equal(await page.locator('.image-pending,.placeholder-line,.page-placeholder').count(),0);assert.equal(await page.locator('.page-loading').count(),0);checks++;
releaseDevice();await page.evaluate(()=>window.navigation);await page.unroute('**/api/v1/devices/living-room');
}
await page.evaluate(()=>{state.galleryCache?.clear();state.tab='cloud';});
let releaseSlow,slowRequested;const slow=new Promise(resolve=>slowRequested=resolve);
await page.route('**/api/v1/devices/*/images',async r=>{slowRequested();await new Promise(resolve=>releaseSlow=resolve);await r.fulfill({json:[]});});
await page.evaluate(()=>{window.slowNavigation=navigate('gallery');});await slow;await page.locator('#local-tab').click();await page.waitForFunction(()=>state.tab==='local'&&document.querySelector('#local-tab')?.classList.contains('active'));releaseSlow();await page.evaluate(()=>window.slowNavigation);assert.equal(await page.evaluate(()=>state.tab),'local');assert(await page.locator('#gallery-search').isVisible());checks++;
await page.unroute('**/api/v1/devices/*/images');
await page.evaluate(()=>{state.galleryCache?.clear();state.tab='cloud';});
await page.route('**/api/v1/devices/*/images',r=>r.fulfill({status:503,json:{error:{code:'CLOUD_UNAVAILABLE',message:'fixture unavailable'}}}));
await page.evaluate(()=>navigate('gallery'));assert(await page.locator('#gallery-search').isVisible());assert(await page.locator('#local-tab').isEnabled());assert.equal(await page.locator('#gallery-list-status').getAttribute('role'),'alert');checks++;
await page.unroute('**/api/v1/devices/*/images');
let releaseJobs,jobsRequested;await page.route('**/api/v1/jobs',async r=>{jobsRequested();await new Promise(resolve=>releaseJobs=resolve);await r.fulfill({json:[]});});
await page.route('**/api/v1/assets?*',r=>r.fulfill({json:[{asset_id:'local-fixture',name:'Local fixture',url:'/local-fixture.svg',origin:'manual',starred:false}]}));
await page.route('**/local-fixture.svg',r=>r.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"/>'}));
const jobs=new Promise(resolve=>jobsRequested=resolve);await page.evaluate(()=>{state.tab='local';return navigate('gallery');});await jobs;
assert.equal(await page.locator('.asset-select').count(),1);assert(await page.locator('#gallery-search').evaluate(e=>typeof e.oninput==='function'));await page.locator('#gallery-search').fill('no-match');assert(await page.locator('.image-card').evaluate(e=>e.hidden));releaseJobs();checks++;
assert.deepEqual(errors,[]);console.log('Known image positions without skeletons:',checks,'checks PASS; delayed bootstrap, metadata and images; no page errors');
}finally{await browser.close();}
})().catch(e=>{console.error(e.message);process.exit(1)});
+5 -5
View File
@@ -10,13 +10,13 @@ const fs=require('fs');const path=require('path');const assert=require('assert')
await page.locator('#lang').click();await page.getByRole('heading',{name:'Dashboard',exact:true}).waitFor();checks++; await page.locator('#lang').click();await page.getByRole('heading',{name:'Dashboard',exact:true}).waitFor();checks++;
await page.locator('#theme').click();assert(await page.locator('html').getAttribute('data-theme')==='dark');checks++; await page.locator('#theme').click();assert(await page.locator('html').getAttribute('data-theme')==='dark');checks++;
await page.screenshot({path:path.join(shots,'dashboard-dark-en.png'),fullPage:true}); await page.screenshot({path:path.join(shots,'dashboard-dark-en.png'),fullPage:true});
await page.locator('[data-page="gallery"]').click();await page.locator('.image-card').first().waitFor();await page.locator('.image-card .thumb').first().evaluate(img=>new Promise((resolve,reject)=>{if(img.complete&&img.naturalWidth)return resolve();img.onload=resolve;img.onerror=reject;}));checks++; await page.locator('[data-page="gallery"]').click();await page.locator('#library-content').waitFor();assert.equal(await page.locator('#library-legacy').count(),0);checks++;
await page.locator('#gallery-search').fill('no-such-picture-fixture');assert(await page.locator('.image-card:visible').count()===0);checks++;await page.locator('#gallery-search').fill('');await page.locator('#gallery-sort').selectOption('name');assert(await page.locator('.image-card:visible').count()>0);checks++; await page.locator('[data-page="terminal-gallery"]').click();await page.locator('[data-library-terminal="watch"]').click();await page.locator('[data-library-terminal="watch"][aria-pressed=true]').waitFor();checks++;
await page.locator('#show-name').click();await page.locator('#image-name').fill('Original Art.jpg');await page.locator('#modal-cancel').click();checks++; await page.locator('[data-library-terminal="living-room"]').click();await page.locator('#library-binding').click();await page.locator('#terminal-binding-form').waitFor();await page.locator('#modal-cancel').click();checks++;
await page.locator('[data-page="upload"]').click();await page.locator('#file').setInputFiles({name:uploadName+'.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a1OIAAAAASUVORK5CYII=','base64')});checks++; await page.locator('[data-page="upload"]').click();await page.locator('#file').setInputFiles({name:uploadName+'.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a1OIAAAAASUVORK5CYII=','base64')});checks++;
await page.locator('#make-preview').click();await page.waitForFunction(()=>!document.querySelector('#save-local').disabled);checks++; await page.locator('#make-preview').click();await page.waitForFunction(()=>!document.querySelector('#save-local').disabled);checks++;
await page.locator('#save-local').click();const uploaded=page.locator('.image-card').filter({hasText:uploadName+'.jpg'});await uploaded.waitFor();checks++; await page.locator('#save-local').click();await page.waitForFunction(name=>document.querySelector('#toast')?.textContent.includes('Saved on server'),uploadName);checks++;
await uploaded.locator('[data-delete]').click();await page.locator('#modal-confirm').click();await uploaded.waitFor({state:'hidden'});checks++; const uploadedId=await page.evaluate(async name=>(await api('/api/v1/assets?source=uploaded&limit=200')).find(x=>x.name===name+'.jpg')?.asset_id,uploadName);assert(uploadedId);await page.evaluate(id=>api('/api/v1/assets/'+id,{method:'DELETE'}),uploadedId);checks++;
await page.locator('[data-page="templates"]').click();await page.locator('.template-card').first().waitFor();assert(await page.locator('.template-card').count()===9);checks++; await page.locator('[data-page="templates"]').click();await page.locator('.template-card').first().waitFor();assert(await page.locator('.template-card').count()===9);checks++;
await page.locator('#render-template').click();await page.locator('#template-image').waitFor({timeout:30000});assert(await page.locator('#display-template').isEnabled());checks++; await page.locator('#render-template').click();await page.locator('#template-image').waitFor({timeout:30000});assert(await page.locator('#display-template').isEnabled());checks++;
await page.locator('#field-title').fill('Edited title');assert(await page.locator('#display-template').isDisabled());checks++;await page.locator('#save-template').click();await page.reload();await page.locator('#field-title').waitFor();assert(await page.locator('#field-title').inputValue()==='Edited title');checks++;await page.locator('#reset-template').click();await page.locator('#modal-confirm').click();await page.waitForFunction(()=>document.querySelector('#field-title')?.value!=='Edited title'&&document.querySelector('#field-title'));assert(await page.locator('#field-title').inputValue()!=='Edited title');checks++;await page.locator('#field-title').fill('Provenance UI title');await page.locator('#render-template').click();await page.locator('#template-image').waitFor();await page.locator('#display-template').click();await page.locator('#confirm-display').click();await page.locator('[data-page="history"]').click();await page.locator('tbody tr').first().waitFor();checks++;await page.locator('tbody tr button').first().click();await page.locator('#modal-cancel').click();checks++;await page.waitForFunction(async()=>{const response=await fetch('/api/v1/devices/living-room/current-content');const content=await response.json();return content.source?.data?.title==='Provenance UI title';});await page.locator('[data-page="dashboard"]').click();await page.locator('#edit-current-template').waitFor({timeout:30000});await page.locator('#edit-current-template').click();await page.locator('#field-title').waitFor();assert(await page.locator('#field-title').inputValue()==='Provenance UI title');checks++; await page.locator('#field-title').fill('Edited title');assert(await page.locator('#display-template').isDisabled());checks++;await page.locator('#save-template').click();await page.reload();await page.locator('#field-title').waitFor();assert(await page.locator('#field-title').inputValue()==='Edited title');checks++;await page.locator('#reset-template').click();await page.locator('#modal-confirm').click();await page.waitForFunction(()=>document.querySelector('#field-title')?.value!=='Edited title'&&document.querySelector('#field-title'));assert(await page.locator('#field-title').inputValue()!=='Edited title');checks++;await page.locator('#field-title').fill('Provenance UI title');await page.locator('#render-template').click();await page.locator('#template-image').waitFor();await page.locator('#display-template').click();await page.locator('#confirm-display').click();await page.locator('[data-page="history"]').click();await page.locator('tbody tr').first().waitFor();checks++;await page.locator('tbody tr button').first().click();await page.locator('#modal-cancel').click();checks++;await page.waitForFunction(async()=>{const response=await fetch('/api/v1/devices/living-room/current-content');const content=await response.json();return content.source?.data?.title==='Provenance UI title';});await page.locator('[data-page="dashboard"]').click();await page.locator('#edit-current-template').waitFor({timeout:30000});await page.locator('#edit-current-template').click();await page.locator('#field-title').waitFor();assert(await page.locator('#field-title').inputValue()==='Provenance UI title');checks++;
+532
View File
@@ -1510,5 +1510,537 @@
"502", "502",
"503" "503"
] ]
},
"/api/v1/thumbnail-plans": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/thumbnail-plans/{plan_id}": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"delete": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/thumbnails/preview": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/thumbnails/generate": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/thumbnail-devices": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/thumbnails": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-libraries": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-libraries/{source_id}/images": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-libraries/{source_id}/import": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-libraries/{source_id}/run": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/generation-plans": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/generation-plans/{plan_id}": {
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"delete": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-images/{picture_id}/generate": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-images/{picture_id}/preview": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-images/{picture_id}/star": {
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-images/{picture_id}/exclusion": {
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-images/{picture_id}": {
"delete": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/library-versions/{asset_id}": {
"delete": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/library-terminals": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/library-terminals/{terminal_id}/binding": {
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"delete": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/library-terminals/{terminal_id}/images": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/library-terminals/{terminal_id}/images/{asset_id}/send": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/jobs/{job_id}/library-mode": {
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-libraries/{source_id}/browse": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-libraries/{source_id}/browse/{item_id}/preview": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/client-scopes": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-libraries/{source_id}/upload": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-libraries/{source_id}/upload-url": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
} }
} }
+60 -2
View File
@@ -581,6 +581,23 @@
"minimum": 0.0, "minimum": 0.0,
"title": "Priority", "title": "Priority",
"default": 50 "default": 50
},
"generate_thumbnail": {
"type": "boolean",
"title": "Generate Thumbnail",
"default": true
},
"thumbnail_plan_id": {
"anyOf": [
{
"type": "string",
"pattern": "^[a-f0-9]{32}$"
},
{
"type": "null"
}
],
"title": "Thumbnail Plan Id"
} }
}, },
"additionalProperties": false, "additionalProperties": false,
@@ -781,6 +798,45 @@
"type": "boolean", "type": "boolean",
"title": "Deferred", "title": "Deferred",
"default": false "default": false
},
"source_image_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Image Id"
},
"outputs": {
"items": {
"additionalProperties": true,
"type": "object"
},
"type": "array",
"title": "Outputs"
},
"errors": {
"items": {
"additionalProperties": true,
"type": "object"
},
"type": "array",
"title": "Errors"
},
"wrist_thumbnail": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"title": "Wrist Thumbnail"
} }
}, },
"type": "object", "type": "object",
@@ -895,7 +951,8 @@
"webdav", "webdav",
"google-drive", "google-drive",
"alipan", "alipan",
"baidu" "baidu",
"uploads"
], ],
"title": "Kind" "title": "Kind"
}, },
@@ -1075,7 +1132,8 @@
"webdav", "webdav",
"google-drive", "google-drive",
"alipan", "alipan",
"baidu" "baidu",
"uploads"
], ],
"title": "Kind" "title": "Kind"
}, },
+55
View File
@@ -242,3 +242,58 @@ def test_only_one_browser_account(app):
"INSERT INTO users VALUES(?,?,1)", "INSERT INTO users VALUES(?,?,1)",
("other", password_hash("another-password")), ("other", password_hash("another-password")),
) )
def test_password_hash_does_not_hold_database_lock(client, app, monkeypatch):
import threading
from concurrent.futures import ThreadPoolExecutor
from pigway_frame_flow.core import accounts
account(app)
started, release = threading.Event(), threading.Event()
verify = accounts.verify_password
def delayed(*args):
started.set()
assert release.wait(5)
return verify(*args)
monkeypatch.setattr(accounts, "verify_password", delayed)
with ThreadPoolExecutor(max_workers=2) as executor:
request = executor.submit(
client.post,
"/api/v1/auth/login",
json={"username": "test-user", "password": "fixture-password"},
)
try:
assert started.wait(5)
query = executor.submit(app.state.service.db.one, "SELECT 1 AS ok")
assert query.result(timeout=2)["ok"] == 1
finally:
release.set()
assert request.result(timeout=5).status_code == 200
def test_password_changed_during_login_does_not_create_session(
client, app, monkeypatch
):
from pigway_frame_flow.core import accounts
account(app)
verify = accounts.verify_password
def changed(*args):
result = verify(*args)
app.state.service.db.execute(
"UPDATE users SET password_hash=?", (password_hash("replacement-password"),)
)
return result
monkeypatch.setattr(accounts, "verify_password", changed)
response = client.post(
"/api/v1/auth/login",
json={"username": "test-user", "password": "fixture-password"},
)
assert response.status_code == 401
assert app.state.service.db.all("SELECT * FROM sessions") == []
+2 -1
View File
@@ -453,7 +453,8 @@ def test_settings_exposes_storage_display_name(client, service):
result = client.get("/api/v1/settings").json() result = client.get("/api/v1/settings").json()
assert result["storage_name"] == "Family NAS" assert result["storage_name"] == "Family NAS"
destinations = {d["id"]: d for d in result["gallery_destinations"]} destinations = {d["id"]: d for d in result["gallery_destinations"]}
assert set(destinations) == {"cloud", "local"} assert set(destinations) == {"cloud", "local", "watch"}
assert destinations["watch"]["adapter"] == "watch-thumbnails"
assert destinations["cloud"]["name"] == "KoKonna" assert destinations["cloud"]["name"] == "KoKonna"
assert destinations["local"]["name"] == "Family NAS" assert destinations["local"]["name"] == "Family NAS"
assert all(d["connected"] and d["supports_upload"] for d in destinations.values()) assert all(d["connected"] and d["supports_upload"] for d in destinations.values())
+129
View File
@@ -0,0 +1,129 @@
import shutil
import pytest
def test_image_write_permission_can_be_granted_without_admin(client):
from io import BytesIO
from PIL import Image
data = BytesIO()
Image.new("RGB", (30, 20), "green").save(data, "PNG")
picture = client.post(
"/api/v1/source-libraries/manual-uploads/upload",
files={"file": ("fixture.png", data.getvalue())},
).json()
scopes = client.get("/api/v1/client-scopes").json()
assert "images:write" in scopes
response = client.post(
"/api/v1/clients",
json={
"client_id": "image-writer",
"name": "Writer",
"permissions": ["images:write"],
},
)
assert response.status_code == 201
client.headers["Authorization"] = "Bearer " + response.json()["token"]
# Authorization succeeds and resource lookup fails; administrative routes stay forbidden.
assert (
client.put("/api/v1/assets/missing/star", json={"starred": True}).status_code
== 404
)
assert (
client.put(
"/api/v1/source-images/" + picture["id"] + "/star", json={"starred": True}
).status_code
== 200
)
assert (
client.put(
"/api/v1/source-images/" + picture["id"] + "/exclusion",
json={"excluded": True},
).status_code
== 409
)
assert client.get("/api/v1/clients").status_code == 403
def test_nested_transaction_rollback_includes_execute(service):
with pytest.raises(RuntimeError):
with service.db.transaction():
service.db.execute("INSERT INTO app_config VALUES('outer','{}')")
with service.db.transaction():
service.db.execute("INSERT INTO app_config VALUES('inner','{}')")
raise RuntimeError("injected failure")
assert (
service.db.one("SELECT key FROM app_config WHERE key IN ('outer','inner')")
is None
)
def test_low_disk_space_is_reported_without_enabling_cleanup(client, monkeypatch):
monkeypatch.setattr(
shutil,
"disk_usage",
lambda _path: shutil._ntuple_diskusage(100 * 1024**3, 99 * 1024**3, 1024**3),
)
data = client.get("/api/v1/storage").json()
assert data["disk"]["low_space"] is True
assert data["disk"]["free_bytes"] == 1024**3
assert data["policy"]["enabled"] is False
def test_unsupported_image_plugin_is_never_invoked(monkeypatch):
from PIL import Image
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.renderers.images import convert
Image.init()
invoked = []
def vulnerable_plugin(*_args):
invoked.append(True)
raise RuntimeError("Unsupported plugin must not parse input")
monkeypatch.setitem(Image.OPEN, "XBM", (vulnerable_plugin, lambda _prefix: True))
with pytest.raises(FlowError):
convert(b"#define image_width 1\n#define image_height 1\n")
assert not invoked
def test_promotion_sql_failure_keeps_original_cache(service, monkeypatch):
asset = service.assets.put(b"fixture", "cached.jpg", "cache")
original = service.db.execute
def fail(query, args=()):
if query.startswith("UPDATE assets SET kind"):
raise RuntimeError("injected promotion SQL failure")
return original(query, args)
monkeypatch.setattr(service.db, "execute", fail)
with pytest.raises(RuntimeError):
service.assets.promote(asset["asset_id"])
assert service.assets.path(asset["asset_id"]).parent.name == "cache"
assert service.assets.path(asset["asset_id"]).read_bytes() == b"fixture"
assert not list((service.settings.data_dir / "images").iterdir())
assert not service.db.all("SELECT * FROM asset_file_moves")
def test_promotion_restart_finishes_committed_move(service):
from pigway_frame_flow.renderers.images import Assets
asset = service.assets.put(b"fixture", "cached.jpg", "cache")
source = service.assets.path(asset["asset_id"])
target = service.settings.data_dir / "images" / source.name
target.write_bytes(source.read_bytes())
service.db.execute(
"INSERT INTO asset_file_moves VALUES(?,?,?)",
(asset["asset_id"], "cache", "images"),
)
service.db.execute(
"UPDATE assets SET kind='images' WHERE id=?", (asset["asset_id"],)
)
Assets(service.db, service.settings)
assert not source.exists()
assert service.assets.path(asset["asset_id"]).read_bytes() == b"fixture"
assert not service.db.all("SELECT * FROM asset_file_moves")
+189
View File
@@ -0,0 +1,189 @@
import copy
from datetime import UTC, datetime
from io import BytesIO
import pytest
from PIL import Image
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
@pytest.fixture
def browser(client, service, monkeypatch):
photos = service.photo_sources
photos.save(
"family",
PhotoSourceInput(
name="Family",
kind="synology-photos",
location="albums",
people_filter=True,
person_ids=[7],
),
)
monkeypatch.setattr(photos.synology, "sid", lambda: "test-session")
monkeypatch.setattr(
photos, "catalog", lambda *a, **kw: pytest.fail("Must not scan full catalog")
)
rows = [
{
"id": i,
"type": "photo",
"filename": f"Photo-{i}.jpg",
"filesize": 2000000,
"time": int(datetime(2026, 10, 20, 2, tzinfo=UTC).timestamp()),
"indexed_time": int(datetime(2026, 10, 21, 2, tzinfo=UTC).timestamp()),
"additional": {
"folder": "albums/travel",
"resolution": {"width": 1600, "height": 1200},
"person": [{"id": 7, "name": "Person"}],
"thumbnail": {"cache_key": "x", "unit_id": i},
},
}
for i in range(1, 201)
]
calls = []
def request(api, method, version, **params):
calls.append(params)
assert params["limit"] <= 60
selected = rows
if params.get("person_id"):
selected = [
r
for r in rows
if any(
p["id"] == params["person_id"] for p in r["additional"]["person"]
)
]
return {
"list": copy.deepcopy(
selected[params["offset"] : params["offset"] + params["limit"]]
)
}
monkeypatch.setattr(photos.synology, "request", request)
return photos, rows, calls
def test_bounded_paging_preview_and_import(client, service, browser, monkeypatch):
photos, _, calls = browser
first = client.get("/api/v1/source-libraries/family/browse?limit=12").json()
assert len(first["items"]) == 12 and first["next_offset"] == 12 and len(calls) == 1
second = client.get(
"/api/v1/source-libraries/family/browse?limit=12&offset=12"
).json()
assert not {x["id"] for x in first["items"]} & {x["id"] for x in second["items"]}
image = BytesIO()
Image.new("RGB", (1600, 1200)).save(image, format="JPEG")
downloads = []
def download(row, **kwargs):
downloads.append(kwargs)
return image.getvalue()
monkeypatch.setattr(photos.synology, "download", download)
monkeypatch.setattr(
photos.synology,
"item_people",
lambda *a, **kw: pytest.fail("No full person album lookup"),
)
assert client.get(first["items"][0]["preview_url"]).status_code == 200
assert downloads[-1]["size"] == "m"
# Optional captions must use metadata already returned on this page.
source = photos.get("family").model_copy(update={"folder_caption": True})
photos.save("family", source)
client.get("/api/v1/source-libraries/family/browse?limit=12")
r = client.post(
"/api/v1/source-libraries/family/import",
json={"item_id": first["items"][0]["id"]},
)
assert r.status_code == 200, r.text
assert "size" not in downloads[-1] # Default XL for saved source pixels.
assert r.json()["caption"]["people"] == "Person"
def test_sparse_search_is_bounded_and_can_continue(client, browser):
_, _, calls = browser
r = client.get(
"/api/v1/source-libraries/family/browse?filename=not-found&limit=10"
).json()
assert r["items"] == [] and r["scanned"] == 40 and r["next_offset"] == 40
assert len(calls) == 4
def test_metadata_filters_and_source_scope(client, browser):
photos, rows, _ = browser
rows[0]["additional"]["folder"] = "albums-other/secret"
rows[1]["additional"]["person"] = [{"id": 8, "name": "Other"}]
rows[2]["filesize"] = 100
rows[3]["additional"]["resolution"]["width"] = 200
rows[4]["indexed_time"] = 1
params = {
"limit": 8,
"filename": "PHOTO-",
"folder": "TRAVEL",
"min_bytes": 1000000,
"max_bytes": 3000000,
"min_width": 1000,
"max_width": 2000,
"min_height": 1000,
"max_height": 2000,
"date_from": "2026-10-21",
"date_to": "2026-10-21",
"person_id": 7,
}
r = client.get("/api/v1/source-libraries/family/browse", params=params)
assert r.status_code == 200, r.text
assert {i["id"] for i in r.json()["items"]}.isdisjoint({"1", "2", "3", "4", "5"})
assert r.json()["items"]
assert photos.browser.cached("family", "1") is None
assert (
client.get("/api/v1/source-libraries/family/browse/1/preview").status_code
== 404
)
# Metadata already cached must not survive a source scope change.
item_id = r.json()["items"][0]["id"]
photos.save("family", photos.get("family").model_copy(update={"location": "other"}))
assert photos.browser.cached("family", item_id) is None
@pytest.mark.parametrize(
"query",
[
"limit=1000",
"offset=-1",
"min_width=-1",
"min_bytes=20&max_bytes=10",
"date_from=2026-10-22&date_to=2026-10-20",
"person_id=-1",
"date_field=unknown",
"unexpected=1",
],
)
def test_invalid_filters_rejected(client, browser, query):
assert (
client.get("/api/v1/source-libraries/family/browse?" + query).status_code == 422
)
def test_session_changes_invalidate_preview(client, browser, monkeypatch):
photos, _, _ = browser
item = client.get("/api/v1/source-libraries/family/browse?limit=1").json()["items"][
0
]
monkeypatch.setattr(photos.synology, "sid", lambda: "new-session")
assert client.get(item["preview_url"]).status_code == 404
def test_sparse_search_finds_match_after_an_empty_batch(client, browser):
_, rows, _ = browser
rows[45]["filename"] = "later-match.jpg"
first = client.get(
"/api/v1/source-libraries/family/browse?filename=later-match&limit=10"
).json()
assert first["items"] == [] and first["next_offset"] == 40
second = client.get(
"/api/v1/source-libraries/family/browse?filename=later-match&limit=10&offset=40"
).json()
assert [item["name"] for item in second["items"]] == ["later-match.jpg"]
+955
View File
@@ -0,0 +1,955 @@
import hashlib
from datetime import UTC, datetime
from io import BytesIO
import pytest
from PIL import Image
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
@pytest.fixture
def library_source(service, settings, legacy_photo_engine):
root = settings.data_dir.parent / "photos"
root.mkdir()
settings.photo_root = root
Image.new("RGB", (900, 600), (25, 100, 220)).save(root / "photo.jpg")
for source_id in ["family", "travel"]:
service.photo_sources.save(
source_id, PhotoSourceInput(name=source_id, kind="local", location=".")
)
return "family"
def create_plan(
client, source="family", name="Watch", width=410, height=502, **changes
):
response = client.post(
"/api/v1/generation-plans",
json={
"source_id": source,
"name": name,
"width": width,
"height": height,
**changes,
},
)
assert response.status_code == 200, response.text
return response.json()
def bind(client, terminal, source, plan):
response = client.put(
"/api/v1/library-terminals/" + terminal + "/binding",
json={"source_id": source, "plan_id": plan["id"]},
)
assert response.status_code == 200, response.text
def import_picture(client):
response = client.post(
"/api/v1/source-libraries/family/import", json={"item_id": "photo.jpg"}
)
assert response.status_code == 200, response.text
return response.json()
def test_single_binding_and_cross_library_guards(client, service, library_source):
first = create_plan(client)
other = create_plan(client, "travel")
bind(client, "watch", "family", first)
assert (
client.put(
"/api/v1/library-terminals/watch/binding",
json={"source_id": "travel", "plan_id": first["id"]},
).status_code
== 409
)
bind(client, "watch", "travel", other)
assert service.db.one("SELECT COUNT(*) AS n FROM terminal_bindings")["n"] == 1
assert client.delete("/api/v1/generation-plans/" + other["id"]).status_code == 409
assert client.delete("/api/v1/photo-sources/travel").status_code == 409
assert (
client.put(
"/api/v1/library-terminals/living-room/binding",
json={"source_id": "family", "plan_id": first["id"]},
).status_code
== 422
)
assert (
client.put(
"/api/v1/generation-plans/" + other["id"],
json={
k: v
for k, v in {**other, "width": 400}.items()
if k not in ("id", "archived")
},
).status_code
== 422
)
assert (
client.put(
"/api/v1/generation-plans/" + first["id"],
json={"name": "Move", "source_id": "travel"},
).status_code
== 409
)
def test_original_and_versions_share_picture_with_immutable_snapshots(
client, service, library_source
):
picture = import_picture(client)
assert import_picture(client)["id"] == picture["id"]
first = create_plan(client)
create_plan(client, name="Frame", width=1200, height=1600)
versions = client.post(
"/api/v1/source-images/" + picture["id"] + "/generate", json={}
).json()
assert len(versions) == 2
assert all(v["source"] == "generated" for v in versions)
assert all(picture["name"].removesuffix(".jpg") in v["name"] for v in versions)
assert len({v["source_image_id"] for v in versions}) == 1
repeated = client.post(
"/api/v1/source-images/" + picture["id"] + "/generate", json={}
).json()
assert {v["asset_id"] for v in versions} == {v["asset_id"] for v in repeated}
payload = {k: v for k, v in first.items() if k not in ("id", "archived")}
assert (
client.put(
"/api/v1/generation-plans/" + first["id"], json={**payload, "quality": 70}
).status_code
== 200
)
updated = client.post(
"/api/v1/source-images/" + picture["id"] + "/generate",
json={"plan_id": first["id"]},
).json()[0]
assert updated["asset_id"] not in {v["asset_id"] for v in versions}
assert updated["plan"]["quality"] == 70
assert (
next(v for v in versions if v["plan_id"] == first["id"])["plan"]["quality"]
== 85
)
other = create_plan(client, "travel")
assert (
client.post(
"/api/v1/source-images/" + picture["id"] + "/generate",
json={"plan_id": other["id"]},
).status_code
== 409
)
assert client.get("/api/v1/assets").json() == []
assert client.delete("/api/v1/assets/" + picture["id"]).status_code == 409
assert (
client.post(
"/api/v1/devices/living-room/display",
json={"content": {"type": "image", "asset_id": picture["id"]}},
).status_code
== 409
)
assert (
client.put(
"/api/v1/source-images/" + picture["id"] + "/star", json={"starred": True}
).status_code
== 200
)
assert (
client.put(
"/api/v1/source-images/" + picture["id"] + "/exclusion",
json={"excluded": True},
).status_code
== 409
)
assert client.delete("/api/v1/source-images/" + picture["id"]).status_code == 204
assert service.db.one("SELECT COUNT(*) AS n FROM library_versions")["n"] == 0
assert (service.settings.photo_root / "photo.jpg").exists()
def test_pipeline_selects_once_for_frame_and_watch_and_shared_plan(
client, service, library_source, monkeypatch
):
frame = create_plan(client, name="Frame", width=1200, height=1600, caption=True)
watch = create_plan(client)
service.registry.drivers["second-frame"] = service.registry.get("living-room")
for terminal in ["living-room", "second-frame"]:
bind(client, terminal, "family", frame)
bind(client, "watch", "family", watch)
def unavailable_frame(*args, **kwargs):
raise AssertionError("Source generation must not read a frame device")
original_device = service.registry.get("living-room").device
monkeypatch.setattr(
service.registry.get("living-room"), "device", unavailable_frame
)
read = service.photo_sources.read
calls = []
def source_read(*args, **kwargs):
calls.append(1)
data, name, _ = read(*args, **kwargs)
return data, name, {"title": "Caption", "date": "2026-10-23"}
monkeypatch.setattr(service.photo_sources, "read", source_read)
result = client.post("/api/v1/source-libraries/family/run", json={}).json()
assert result["errors"] == []
assert len(calls) == 1
assert len(result["outputs"]) == 3
assert len({v["asset_id"] for v in result["outputs"]}) == 2
monkeypatch.setattr(service.registry.get("living-room"), "device", original_device)
for terminal in ["living-room", "watch"]:
page = client.get("/api/v1/library-terminals/" + terminal + "/images").json()
assert page["total"] == 1
version = page["items"][0]
with Image.open(BytesIO(client.get(version["url"]).content)) as image:
if terminal == "watch":
assert image.size == (410, 502)
assert image.getpixel((200, 490))[2] > 200
else:
assert image.getpixel((10, 1590))[0] > 240
assert client.post(
"/api/v1/library-terminals/watch/images/"
+ result["outputs"][-1]["asset_id"]
+ "/send"
).status_code in (409, 422)
assert service.db.one("SELECT COUNT(*) AS n FROM operations")["n"] == 0
def test_scheduled_pipeline_links_independent_delivery_and_rechecks_binding(
client, app, service, library_source, monkeypatch
):
frame = create_plan(client, name="Frame", width=1200, height=1600)
watch = create_plan(client)
bind(client, "living-room", "family", frame)
bind(client, "watch", "family", watch)
saved = client.post(
"/api/v1/jobs",
json={
"name": "Shared source",
"schedule": {"type": "daily", "time": "07:00"},
"source": {"type": "photo_source", "source_id": "family"},
"library_pipeline": True,
},
).json()
now = datetime.now(UTC)
service.db.execute(
"UPDATE jobs SET next_run=? WHERE id=?", (now.isoformat(), saved["id"])
)
app.state.scheduler.tick(now)
root = service.db.one("SELECT id FROM operations WHERE action='library-generate'")
assert root and service.process_one()
op = client.get("/api/v1/operations/" + root["id"]).json()
assert op["status"] == "COMPLETED"
assert len(op["result"]["outputs"]) == 2
child = service.db.one(
"SELECT id FROM operations WHERE client_id='library-terminal'"
)
assert child
assert (
service.db.one("SELECT parent_operation_id FROM terminal_deliveries")[
"parent_operation_id"
]
== root["id"]
)
client.delete("/api/v1/library-terminals/living-room/binding")
writes = []
monkeypatch.setattr(
service.registry.get("living-room"), "upload", lambda *args: writes.append(args)
)
assert service.process_one()
assert service.operation(child["id"])["status"] == "FAILED"
assert writes == []
assert client.get("/api/v1/library-terminals/watch/images").json()["total"] == 1
def test_preview_is_unsaved_and_references_prevent_deletion(
client, service, library_source
):
picture = import_picture(client)
plan = create_plan(client, name="Frame", width=1200, height=1600)
payload = {k: v for k, v in plan.items() if k not in ("id", "archived")}
assert (
client.post(
"/api/v1/source-images/" + picture["id"] + "/preview", json=payload
).status_code
== 200
)
assert service.db.one("SELECT COUNT(*) AS n FROM library_versions")["n"] == 0
bind(client, "living-room", "family", plan)
version = client.post(
"/api/v1/source-images/" + picture["id"] + "/generate",
json={"plan_id": plan["id"]},
).json()[0]
assert (
client.post(
"/api/v1/library-terminals/living-room/images/"
+ version["asset_id"]
+ "/send"
).status_code
== 200
)
assert client.delete("/api/v1/source-images/" + picture["id"]).status_code == 409
def test_resumed_source_run_reuses_saved_picture_plans_and_delivery(
client, service, library_source, monkeypatch
):
frame = create_plan(client, name="Frame", width=1200, height=1600)
bind(client, "living-room", "family", frame)
root = service.submit(
"library-generate",
{"content": {"type": "photo_source", "source_id": "family"}, "options": {}},
"living-room",
"scheduler",
"resume-test",
)
assert service.process_one()
previous = service.operation(root)["result"]
child = previous["outputs"][0]["operation_id"]
service.db.execute("UPDATE operations SET status='COMPLETED' WHERE id=?", (child,))
update = {k: v for k, v in frame.items() if k not in ("id", "archived")}
assert (
client.put(
"/api/v1/generation-plans/" + frame["id"], json={**update, "quality": 60}
).status_code
== 200
)
service.db.execute("UPDATE operations SET status='QUEUED' WHERE id=?", (root,))
def unexpected_read(*args, **kwargs):
raise AssertionError("Resumed run must not select a new photo")
monkeypatch.setattr(service.photo_sources, "read", unexpected_read)
service.settings.mock = False
service.settings.write_enabled = False
assert service.process_one()
current = service.operation(root)["result"]
assert current["source_image_id"] == previous["source_image_id"]
assert current["outputs"][0]["operation_id"] == child
assert service.db.one("SELECT COUNT(*) AS n FROM library_versions")["n"] == 1
assert (
service.db.one("SELECT COUNT(*) AS n FROM operations WHERE action='display'")[
"n"
]
== 1
)
def test_one_plan_failure_preserves_other_output_and_source(
client, service, library_source, monkeypatch
):
from pigway_frame_flow.errors import ErrorCode, FlowError
frame = create_plan(client, name="Frame", width=1200, height=1600)
watch = create_plan(client)
bind(client, "living-room", "family", frame)
bind(client, "watch", "family", watch)
generate = service.libraries.generate
def fail_frame(picture_id, plan_id, *args, **kwargs):
if plan_id == frame["id"]:
raise FlowError(ErrorCode.INVALID_REQUEST, "Frame render failed")
return generate(picture_id, plan_id, *args, **kwargs)
monkeypatch.setattr(service.libraries, "generate", fail_frame)
root = service.submit(
"library-generate",
{"content": {"type": "photo_source", "source_id": "family"}, "options": {}},
"living-room",
"scheduler",
"partial-test",
)
assert service.process_one()
op = client.get("/api/v1/operations/" + root).json()
assert op["status"] == "FAILED"
assert len(op["result"]["errors"]) == 1 and len(op["result"]["outputs"]) == 1
assert op["result"]["outputs"][0]["terminal_id"] == "watch"
picture_id = op["result"]["source_image_id"]
monkeypatch.setattr(service.libraries, "generate", generate)
assert (
client.post(
"/api/v1/source-images/" + picture_id + "/generate",
json={"plan_id": frame["id"]},
).status_code
== 200
)
assert service.db.one("SELECT COUNT(*) AS n FROM source_images")["n"] == 1
def test_successful_scheduled_frame_delivery_retains_job_provenance(
client, app, service, library_source
):
frame = create_plan(client, name="Frame", width=1200, height=1600)
bind(client, "living-room", "family", frame)
saved = client.post(
"/api/v1/jobs",
json={
"name": "Library job",
"schedule": {"type": "daily", "time": "07:00"},
"source": {"type": "photo_source", "source_id": "family"},
"library_pipeline": True,
},
).json()
now = datetime.now(UTC)
service.db.execute(
"UPDATE jobs SET next_run=? WHERE id=?", (now.isoformat(), saved["id"])
)
app.state.scheduler.tick(now)
assert service.process_one() and service.process_one()
assert (
service.db.one("SELECT job_id FROM managed_cloud_uploads")["job_id"]
== saved["id"]
)
service.sync()
page = client.get("/api/v1/library-terminals/living-room/images").json()
assert page["items"][0]["delivery_status"] == "COMPLETED"
assert page["items"][0]["current"] is True
from pigway_frame_flow.storage.database import encode, now_iso
token = "library-reader-test"
service.db.execute(
"INSERT INTO clients VALUES(?,?,?,?,?,?,?)",
(
"reader",
hashlib.sha256(token.encode()).hexdigest(),
"Reader",
encode(["images:read", "devices:read"]),
now_iso(),
None,
1,
),
)
public = client.get(
"/api/v1/library-terminals/living-room/images",
headers={"Authorization": "Bearer " + token},
)
assert public.status_code == 200
summary = public.json()["items"][0]["operation"]
assert (
"request_id" not in summary
and "client_id" not in summary
and "error" not in summary
)
assert (
client.get(
"/api/v1/operations/" + summary["operation_id"],
headers={"Authorization": "Bearer " + token},
).status_code
== 403
)
def test_caption_plan_respects_full_picture_fit(client, service, library_source):
picture = import_picture(client)
service.db.execute(
"UPDATE source_images SET caption=? WHERE id=?",
('{"title":"Caption"}', picture["id"]),
)
payload = {
"source_id": "family",
"name": "Caption",
"width": 300,
"height": 400,
"caption": True,
"fit": "contain",
}
response = client.post(
"/api/v1/source-images/" + picture["id"] + "/preview", json=payload
)
assert response.status_code == 200
with Image.open(BytesIO(response.content)) as image:
assert image.getpixel((150, 0))[0] > 240
response = client.post(
"/api/v1/source-images/" + picture["id"] + "/preview",
json={**payload, "fit": "cover"},
)
with Image.open(BytesIO(response.content)) as image:
assert image.getpixel((150, 0))[0] < 60
def test_shared_version_keeps_separate_delivery_receipts_for_each_job(
client, app, service, library_source
):
frame = create_plan(client, name="Frame", width=1200, height=1600)
bind(client, "living-room", "family", frame)
job_ids = []
for name in ["Morning", "Evening"]:
job = client.post(
"/api/v1/jobs",
json={
"name": name,
"schedule": {"type": "daily", "time": "07:00"},
"source": {"type": "photo_source", "source_id": "family"},
"library_pipeline": True,
},
).json()
job_ids.append(job["id"])
now = datetime.now(UTC)
service.db.execute("UPDATE jobs SET next_run=?", (now.isoformat(),))
app.state.scheduler.tick(now)
assert service.process_one() and service.process_one()
assert service.db.one("SELECT COUNT(*) AS n FROM library_versions")["n"] == 1
assert service.db.one("SELECT COUNT(*) AS n FROM terminal_deliveries")["n"] == 2
assert service.process_one()
service.sync()
assert service.process_one()
service.sync()
assert {
r["job_id"] for r in service.db.all("SELECT job_id FROM managed_cloud_uploads")
} == set(job_ids)
page = client.get("/api/v1/library-terminals/living-room/images").json()
assert page["total"] == 1 and page["items"][0]["delivery_status"] == "COMPLETED"
def test_watch_only_source_task_does_not_require_a_frame_driver(
client, app, service, library_source
):
plan = create_plan(client)
bind(client, "watch", "family", plan)
service.registry.drivers.clear()
response = client.post(
"/api/v1/jobs",
json={
"name": "Watch only",
"device_id": "watch",
"schedule": {"type": "daily", "time": "07:00"},
"source": {"type": "photo_source", "source_id": "family"},
"library_pipeline": True,
},
)
assert response.status_code == 201
now = datetime.now(UTC)
service.db.execute("UPDATE jobs SET next_run=?", (now.isoformat(),))
app.state.scheduler.tick(now)
assert service.process_one()
operation = service.db.one(
"SELECT id,device_id,status FROM operations WHERE action='library-generate'"
)
assert (
operation["device_id"] == "source:family" and operation["status"] == "COMPLETED"
)
assert (
service.operation(operation["id"])["result"]["outputs"][0]["terminal_id"]
== "watch"
)
def test_interrupted_delivery_link_reuses_queue_idempotency(
client, service, library_source
):
plan = create_plan(client, name="Frame", width=1200, height=1600)
bind(client, "living-room", "family", plan)
root = service.submit(
"library-generate",
{"content": {"type": "photo_source", "source_id": "family"}, "options": {}},
"source:family",
"scheduler",
"receipt-gap",
)
assert service.process_one()
old = service.operation(root)["result"]
child = old["outputs"][0]["operation_id"]
service.db.execute("DELETE FROM terminal_deliveries WHERE operation_id=?", (child,))
service.db.execute("UPDATE operations SET status='QUEUED' WHERE id=?", (root,))
assert service.process_one()
assert service.operation(root)["result"]["outputs"][0]["operation_id"] == child
assert (
service.db.one("SELECT COUNT(*) AS n FROM operations WHERE action='display'")[
"n"
]
== 1
)
assert (
service.db.one("SELECT operation_id FROM terminal_deliveries")["operation_id"]
== child
)
def test_mode_switch_preserves_existing_schedule(client, service, library_source):
plan = create_plan(client)
bind(client, "watch", "family", plan)
original = client.post(
"/api/v1/jobs",
json={
"name": "Preserve timing",
"schedule": {"type": "interval", "interval_minutes": 120},
"source": {"type": "photo_source", "source_id": "family"},
},
).json()
switched = client.put(
"/api/v1/jobs/" + original["id"] + "/library-mode", json={"enabled": True}
)
assert switched.status_code == 200
current = switched.json()
assert current["library_pipeline"] is True
assert (
current["next_run"] == original["next_run"]
and current["schedule"] == original["schedule"]
and current["enabled"] == original["enabled"]
)
assert (
client.put(
"/api/v1/jobs/" + original["id"] + "/library-mode", json={"enabled": False}
).status_code
== 200
)
def test_single_source_migration_preserves_timing_and_generates_without_device_writes(
client, service, library_source, monkeypatch
):
import runpy
from pathlib import Path
migrate = runpy.run_path(
str(Path(__file__).parents[1] / "scripts/migrate-source-libraries.py")
)["migrate"]
job = client.post(
"/api/v1/jobs",
json={
"name": "Existing source task",
"schedule": {"type": "interval", "interval_minutes": 120},
"source": {"type": "photo_source", "source_id": "family"},
},
).json()
def call(method, path, payload=None):
response = client.request(method, path, json=payload)
response.raise_for_status()
return None if response.status_code == 204 else response.json()
with pytest.raises(RuntimeError):
migrate(call, apply=True)
assert client.delete("/api/v1/photo-sources/travel").status_code == 204
source_op = service.submit(
"display",
{
"content": {"type": "photo_source", "source_id": "family"},
"options": {"priority": 0},
},
"living-room",
"scheduler",
"current-photo",
)
assert service.process_one()
service.sync()
assert service.operation(source_op)["status"] == "COMPLETED"
writes = []
monkeypatch.setattr(
service.registry.get("living-room"), "upload", lambda *args: writes.append(args)
)
result = migrate(call, apply=True)
assert result["migrated"] and result["current_source_generated_locally"]
assert writes == []
migrated = client.get("/api/v1/jobs").json()[0]
assert (
migrated["library_pipeline"] is True and migrated["next_run"] == job["next_run"]
)
assert service.db.one("SELECT COUNT(*) AS n FROM terminal_bindings")["n"] == 2
assert service.db.one("SELECT COUNT(*) AS n FROM source_images")["n"] == 1
assert service.db.one("SELECT COUNT(*) AS n FROM library_versions")["n"] == 2
assert migrate(call, apply=True)["already_configured"]
def test_terminal_current_picture_uses_synced_live_asset(
client, service, library_source, monkeypatch
):
from pigway_frame_flow.errors import ErrorCode, FlowError
plan = create_plan(client, name="Frame", width=1200, height=1600)
bind(client, "living-room", "family", plan)
picture = import_picture(client)
version = client.post(
"/api/v1/source-images/" + picture["id"] + "/generate", json={}
).json()[0]
current = {"synced": True, "source_known": True, "asset_id": version["asset_id"]}
monkeypatch.setattr(service, "current_content", lambda _, **kwargs: current)
endpoint = "/api/v1/library-terminals/living-room/images"
assert client.get(endpoint).json()["items"][0]["current"] is True
current["asset_id"] = "another-picture"
assert client.get(endpoint).json()["items"][0]["current"] is False
current.update(asset_id=version["asset_id"], synced=False)
assert client.get(endpoint).json()["items"][0]["current"] is False
current.update(synced=True, source_known=False)
assert client.get(endpoint).json()["items"][0]["current"] is False
def offline(_, **kwargs):
raise FlowError(ErrorCode.CLOUD_UNAVAILABLE, "Offline", 502)
monkeypatch.setattr(service, "current_content", offline)
page = client.get(endpoint)
assert page.status_code == 200 and page.json()["items"][0]["current"] is False
driver = service.registry.get("living-room")
offline_device = driver.device().model_copy(update={"status": "offline"})
monkeypatch.setattr(driver, "device", lambda: offline_device)
monkeypatch.setattr(
service,
"current_content",
lambda _, **kwargs: pytest.fail(
"Offline state must not be treated as a display confirmation"
),
)
assert client.get(endpoint).json()["items"][0]["current"] is False
def test_plan_rename_uses_current_labels_without_rerendering_legacy_versions(
client, service, library_source
):
import json
from pigway_frame_flow.storage.database import encode
plan = create_plan(client, name="Original", width=1200, height=1600)
bind(client, "living-room", "family", plan)
picture = import_picture(client)
endpoint = "/api/v1/source-images/" + picture["id"] + "/generate"
original = client.post(endpoint, json={"plan_id": plan["id"]}).json()[0]
snapshot = original["plan"]
original_bytes = service.assets.path(original["asset_id"]).read_bytes()
# Existing deployed versions used the entire plan, including its old name.
legacy_revision = hashlib.sha256(encode(snapshot).encode()).hexdigest()
service.db.execute(
"UPDATE library_versions SET revision=? WHERE asset_id=?",
(legacy_revision, original["asset_id"]),
)
provenance = json.loads(
service.db.one(
"SELECT payload FROM asset_sources WHERE asset_id=?",
(original["asset_id"],),
)["payload"]
)
provenance["revision"] = legacy_revision
service.db.execute(
"UPDATE asset_sources SET payload=? WHERE asset_id=?",
(encode(provenance), original["asset_id"]),
)
payload = {k: v for k, v in plan.items() if k not in ("id", "archived")}
renamed = {**payload, "name": "Frame v2.1"}
assert (
client.put("/api/v1/generation-plans/" + plan["id"], json=renamed).status_code
== 200
)
repeated = client.post(endpoint, json={"plan_id": plan["id"]}).json()[0]
assert repeated["asset_id"] == original["asset_id"]
assert repeated["revision"] == legacy_revision
assert repeated["plan"] == snapshot
assert repeated["plan_name"] == "Frame v2.1"
assert repeated["name"] == "photo-Frame v2.1.jpg"
assert repeated["stored_name"] == original["stored_name"]
assert repeated["matches_current_plan"] is True
assert service.assets.path(original["asset_id"]).read_bytes() == original_bytes
assert service.db.one("SELECT COUNT(*) AS n FROM library_versions")["n"] == 1
source_version = client.get("/api/v1/source-libraries/family/images").json()[
"items"
][0]["versions"][0]
terminal_version = client.get(
"/api/v1/library-terminals/living-room/images"
).json()["items"][0]
assert source_version["plan_name"] == terminal_version["plan_name"] == "Frame v2.1"
assert source_version["name"] == terminal_version["name"]
assert (
client.put(
"/api/v1/generation-plans/" + plan["id"], json={**renamed, "quality": 70}
).status_code
== 200
)
old = client.get("/api/v1/library-terminals/living-room/images").json()["items"][0]
assert old["matches_current_plan"] is False and old["plan"]["quality"] == 85
updated = client.post(endpoint, json={"plan_id": plan["id"]}).json()[0]
assert (
updated["asset_id"] != original["asset_id"]
and updated["matches_current_plan"] is True
)
assert updated["plan"]["quality"] == 70
client.delete("/api/v1/library-terminals/living-room/binding")
assert client.delete("/api/v1/generation-plans/" + plan["id"]).status_code == 204
versions = client.get("/api/v1/source-libraries/family/images").json()["items"][0][
"versions"
]
assert len(versions) == 2 and all(v["plan_archived"] for v in versions)
def test_source_deletion_removes_unreferenced_archived_plans(
client, service, library_source
):
plan = create_plan(client)
assert client.delete("/api/v1/generation-plans/" + plan["id"]).status_code == 204
assert client.delete("/api/v1/photo-sources/family").status_code == 204
assert (
service.db.one("SELECT id FROM library_plans WHERE source_id='family'") is None
)
assert client.get("/api/v1/generation-plans?source_id=family").status_code == 404
def test_manual_upload_survives_reload_and_generates_bound_versions(client, service):
data = BytesIO()
Image.new("RGB", (700, 500), "blue").save(data, "PNG")
path = "/api/v1/source-libraries/manual-uploads/upload"
result = client.post(
path, files={"file": ("original.png", data.getvalue(), "image/png")}
)
assert result.status_code == 200, result.text
picture = result.json()
with Image.open(service.assets.path(picture["id"])) as image:
assert image.size == (700, 500)
assert (
client.post(path, files={"file": ("same.png", data.getvalue())}).json()["id"]
== picture["id"]
)
listed = client.get("/api/v1/source-libraries/manual-uploads/images").json()
assert listed["total"] == 1 and listed["items"][0]["id"] == picture["id"]
plan = create_plan(client, "manual-uploads")
bind(client, "watch", "manual-uploads", plan)
generated = client.post(
"/api/v1/source-images/" + picture["id"] + "/generate",
json={"plan_id": plan["id"]},
)
assert generated.status_code == 200, generated.text
assert generated.json()[0]["plan"]["width"] == 410
run = client.post(
"/api/v1/source-libraries/manual-uploads/run", json={"deliver": False}
)
assert run.status_code == 200, run.text
assert (
client.get("/api/v1/source-libraries/manual-uploads/images").json()["total"]
== 1
)
assert client.get("/api/v1/library-terminals/watch/images").json()["total"] == 1
def test_invalid_upload_does_not_create_source(client, service):
response = client.post(
"/api/v1/source-libraries/manual-uploads/upload",
files={"file": ("bad.jpg", b"not an image")},
)
assert response.status_code == 400
assert service.photo_sources.list() == []
def test_ingest_sql_failure_rolls_back_asset_and_file(
client, service, library_source, monkeypatch
):
original = service.db.execute
def fail(query, args=()):
if "INSERT INTO source_images" in query:
raise RuntimeError("injected SQL failure")
return original(query, args)
monkeypatch.setattr(service.db, "execute", fail)
with pytest.raises(RuntimeError):
service.libraries.import_picture("family", "photo.jpg")
assert service.db.all("SELECT * FROM assets") == []
assert service.db.all("SELECT * FROM source_images") == []
assert list((service.settings.data_dir / "source-images").iterdir()) == []
assert service.db.all("SELECT * FROM asset_file_journal") == []
def test_generation_sql_failure_keeps_source_without_orphan(
client, service, library_source, monkeypatch
):
picture = import_picture(client)
plan = create_plan(client)
original = service.db.execute
def fail(query, args=()):
if "INSERT INTO library_versions" in query:
raise RuntimeError("injected SQL failure")
return original(query, args)
monkeypatch.setattr(service.db, "execute", fail)
with pytest.raises(RuntimeError):
service.libraries.generate(picture["id"], plan["id"])
assert len(service.db.all("SELECT * FROM assets")) == 1
assert list((service.settings.data_dir / "renditions").iterdir()) == []
assert service.assets.path(picture["id"]).exists()
def test_delete_sql_failure_preserves_files_and_relations(
client, service, library_source, monkeypatch
):
picture = import_picture(client)
plan = create_plan(client)
version = service.libraries.generate(picture["id"], plan["id"])
original = service.db.execute
def fail(query, args=()):
if query == "DELETE FROM source_images WHERE id=?":
raise RuntimeError("injected SQL failure")
return original(query, args)
monkeypatch.setattr(service.db, "execute", fail)
with pytest.raises(RuntimeError):
service.libraries.delete_picture(picture["id"])
assert service.assets.path(version["asset_id"]).exists()
assert (
service.libraries.picture(picture["id"])["versions"][0]["asset_id"]
== version["asset_id"]
)
assert service.db.all("SELECT * FROM asset_file_journal") == []
def test_delete_file_failure_retries_on_restart(
client, service, library_source, monkeypatch
):
from pathlib import Path
from pigway_frame_flow.renderers.images import Assets
picture = import_picture(client)
path = service.assets.path(picture["id"])
unlink = Path.unlink
def fail(file, *args, **kwargs):
if file == path:
raise OSError("injected unlink failure")
return unlink(file, *args, **kwargs)
with monkeypatch.context() as patch:
patch.setattr(Path, "unlink", fail)
service.libraries.delete_picture(picture["id"])
assert service.db.all("SELECT * FROM source_images") == []
assert (
service.db.one("SELECT id FROM asset_file_journal")["id"] == picture["id"]
)
Assets(service.db, service.settings)
assert not path.exists()
assert service.db.all("SELECT * FROM asset_file_journal") == []
def test_asset_disk_write_failure_leaves_no_partial_file(service, monkeypatch):
import os
fsync = os.fsync
calls = 0
def fail(fd):
nonlocal calls
calls += 1
if calls == 1:
raise OSError("injected disk write failure")
return fsync(fd)
monkeypatch.setattr(os, "fsync", fail)
with pytest.raises(OSError):
service.assets.put(b"data", "failed.jpg")
assert service.db.all("SELECT * FROM assets") == []
assert service.db.all("SELECT * FROM asset_file_journal") == []
assert list((service.settings.data_dir / "images").iterdir()) == []
def test_asset_startup_removes_interrupted_creation(service):
from pigway_frame_flow.renderers.images import Assets
asset_id = "c" * 32
root = service.settings.data_dir / "images"
(root / (asset_id + ".jpg")).write_bytes(b"incomplete")
(root / (asset_id + ".part")).write_bytes(b"partial")
service.db.execute(
"INSERT INTO asset_file_journal VALUES(?,?)", (asset_id, "images")
)
Assets(service.db, service.settings)
assert not list(root.iterdir())
assert service.db.all("SELECT * FROM asset_file_journal") == []
+212
View File
@@ -0,0 +1,212 @@
import json
from datetime import UTC, datetime
from io import BytesIO
import pytest
from PIL import Image
from pigway_frame_flow.models import JobInput
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
@pytest.fixture
def source(service, settings, legacy_photo_engine):
root = settings.data_dir.parent / "photos"
root.mkdir()
settings.photo_root = root
image = Image.new("RGB", (900, 300), "red")
image.paste("blue", (450, 0, 900, 300))
image.save(root / "photo.jpg")
service.photo_sources.save(
"family", PhotoSourceInput(name="Family", kind="local", location=".")
)
return {"source_id": "family", "item_id": "photo.jpg"}
def test_plan_crud_and_parameter_validation(client):
assert client.get("/ui/thumbnails.js").status_code == 200
plan = client.post("/api/v1/thumbnail-plans", json={"name": "Watch"}).json()
path = "/api/v1/thumbnail-plans/" + plan["id"]
assert client.get(path).json()["width"] == 410
assert len(client.get("/api/v1/thumbnail-plans").json()) == 1
assert (
client.put(
path, json={"name": "Square", "width": 240, "height": 240}
).status_code
== 200
)
for changes in [
{"width": 0},
{"height": 10000},
{"center_x": 2},
{"quality": 100},
{"fit": "stretch"},
{"extra": True},
]:
assert client.put(path, json={"name": "Invalid", **changes}).status_code == 422
assert client.delete(path).status_code == 204
assert client.get(path).status_code == 404
def test_preview_matches_generated_pixels_and_crop(client, source):
request = {
**source,
"plan": {"name": "Right", "width": 120, "height": 180, "center_x": 1},
}
preview = client.post("/api/v1/thumbnails/preview", json=request)
assert preview.status_code == 200
assert preview.headers["cache-control"] == "no-store"
asset = client.post("/api/v1/thumbnails/generate", json=request).json()
assert client.get(asset["url"]).content == preview.content
with Image.open(BytesIO(preview.content)) as image:
assert image.size == (120, 180)
assert image.getpixel((60, 90))[2] > 240
def test_task_requires_one_existing_plan_and_blocks_delete(client, app, source):
plan = client.post("/api/v1/thumbnail-plans", json={"name": "Watch"}).json()
job = {
"name": "Photos",
"schedule": {"type": "daily", "time": "07:00"},
"source": {"type": "photo_source", "source_id": source["source_id"]},
"generate_thumbnail": True,
"thumbnail_plan_id": plan["id"],
}
saved = client.post("/api/v1/jobs", json=job)
assert saved.status_code == 201
assert saved.json()["thumbnail_plan_id"] == plan["id"]
service = app.state.service
now = datetime.now(UTC)
service.db.execute(
"UPDATE jobs SET next_run=? WHERE id=?", (now.isoformat(), saved.json()["id"])
)
app.state.scheduler.tick(now)
pending = service.db.one("SELECT id,payload FROM operations WHERE action='display'")
assert json.loads(pending["payload"])["options"]["thumbnail_plan_id"] == plan["id"]
assert service.process_one()
assert (
service.operation(pending["id"])["result"]["wrist_thumbnail"]["plan_id"]
== plan["id"]
)
assert client.delete("/api/v1/thumbnail-plans/" + plan["id"]).status_code == 409
assert (
client.post("/api/v1/jobs", json={**job, "thumbnail_plan_id": None}).status_code
== 422
)
assert (
client.post(
"/api/v1/jobs", json={**job, "thumbnail_plan_id": "0" * 32}
).status_code
== 404
)
assert (
client.post(
"/api/v1/jobs",
json={**job, "source": {"type": "template", "template": "quote"}},
).status_code
== 422
)
assert (
JobInput.model_validate(
{
k: v
for k, v in job.items()
if k not in ("generate_thumbnail", "thumbnail_plan_id")
}
).generate_thumbnail
is False
)
@pytest.mark.parametrize("enabled", [True, False])
def test_upload_uses_selected_plan_or_skips(service, client, source, enabled):
plan = client.post(
"/api/v1/thumbnail-plans", json={"name": "Square", "width": 128, "height": 128}
).json()
operation = service.submit(
"display",
{
"content": {"type": "photo_source", "source_id": "family"},
"options": {
"priority": 0,
"generate_thumbnail": enabled,
"thumbnail_plan_id": plan["id"] if enabled else None,
},
},
"living-room",
"test",
"request",
)
service.process_one()
result = service.operation(operation)["result"]
if enabled:
assert result["wrist_thumbnail"]["width"] == 128
assert result["wrist_thumbnail"]["plan_id"] == plan["id"]
else:
assert "wrist_thumbnail" not in result
def test_watch_gallery_separates_thumbnails_and_deletes_latest(client, service, source):
original = service.assets.put(b"fixture", "normal.jpg", "images")
generated = client.post(
"/api/v1/thumbnails/generate", json={**source, "plan": {"name": "Watch"}}
).json()
operation = service.submit(
"display",
{
"content": {"type": "photo_source", "source_id": "family"},
"options": {"priority": 0},
},
"living-room",
"test",
"request",
)
service.process_one()
auto = service.operation(operation)["result"]["wrist_thumbnail"]
page = client.get("/api/v1/thumbnails?limit=1").json()
assert page["total"] == 2 and len(page["items"]) == 1
items = client.get("/api/v1/thumbnails").json()["items"]
assert {x["asset_id"] for x in items} == {generated["asset_id"], auto["asset_id"]}
assert all(x["thumbnail"]["target_id"] == "watch" for x in items)
assert {x["asset_id"] for x in client.get("/api/v1/assets").json()} == {
original["asset_id"]
}
assert (
client.get("/api/v1/thumbnail-devices").json()[0]["transport_connected"]
is False
)
assert client.delete("/api/v1/assets/" + auto["asset_id"]).status_code == 204
assert client.get("/api/v1/thumbnails").json()["total"] == 1
assert (
service.db.one(
"SELECT payload FROM app_config WHERE key='wrist-thumbnail:living-room'"
)
is None
)
assert client.get(original["url"]).status_code == 200
def test_recover_legacy_receipt_does_not_guess_normal_images(service):
from pigway_frame_flow.display.thumbnail_gallery import recover
from pigway_frame_flow.storage.database import encode
thumbnail = service.assets.put(b"fixture", "watch.jpg", "cache")
normal = service.assets.put(b"fixture", "watch.jpg", "images")
service.db.execute(
"INSERT INTO app_config VALUES(?,?)",
(
"wrist-thumbnail:living-room",
encode({**thumbnail, "width": 410, "height": 502}),
),
)
recover(service)
recover(service)
assert service.db.one(
"SELECT payload FROM asset_sources WHERE asset_id=?", (thumbnail["asset_id"],)
)
assert (
service.db.one(
"SELECT payload FROM asset_sources WHERE asset_id=?", (normal["asset_id"],)
)
is None
)
+106
View File
@@ -0,0 +1,106 @@
import json
from io import BytesIO
import pytest
from PIL import Image
from pigway_frame_flow.errors import ErrorCode, FlowError
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
@pytest.fixture
def photo(service, settings, legacy_photo_engine):
root = settings.data_dir.parent / "photos"
root.mkdir()
settings.photo_root = root
Image.new("RGB", (900, 300), (30, 150, 220)).save(root / "photo.jpg")
service.photo_sources.save(
"family", PhotoSourceInput(name="Family", kind="local", location=".")
)
def upload(service):
operation = service.submit(
"display",
{
"content": {"type": "photo_source", "source_id": "family"},
"options": {"priority": 0},
},
"living-room",
"test",
"request",
)
assert service.process_one()
return service.operation(operation)
def test_uploaded_source_generates_full_bleed_watch_image(service, photo, monkeypatch):
# Deliberately decorate the frame rendition; the watch must use source pixels.
def captioned(*args, **kwargs):
output = BytesIO()
Image.new("RGB", kwargs["size"], "black").save(output, "JPEG")
return output.getvalue()
read = service.photo_sources.read
monkeypatch.setattr(
service.photo_sources,
"read",
lambda *a, **kw: (*read(*a, **kw)[:2], {"title": "Caption"}),
)
monkeypatch.setattr(
"pigway_frame_flow.display.service.render_captioned_photo", captioned
)
op = upload(service)
thumbnail = op["result"]["wrist_thumbnail"]
with Image.open(service.assets.path(thumbnail["asset_id"])) as image:
assert image.size == (410, 502)
assert image.mode == "RGB"
assert not image.info.get("progressive")
for point in [(0, 0), (409, 501), (205, 251)]:
assert image.getpixel(point)[2] > 200
row = service.db.one(
"SELECT payload FROM app_config WHERE key='wrist-thumbnail:living-room'"
)
assert json.loads(row["payload"]) == thumbnail
def test_failed_upload_does_not_publish_thumbnail(service, photo, monkeypatch):
def fail(*args):
raise FlowError(ErrorCode.UPLOAD_FAILED, "Rejected", 502)
monkeypatch.setattr(service.registry.get("living-room"), "upload", fail)
op = upload(service)
assert op["status"] == "FAILED"
assert (
service.db.one(
"SELECT payload FROM app_config WHERE key LIKE 'wrist-thumbnail:%'"
)
is None
)
def test_thumbnail_failure_does_not_change_upload_outcome(service, photo, monkeypatch):
def fail(*args, **kwargs):
raise OSError("Image encoder unavailable")
monkeypatch.setattr("pigway_frame_flow.display.wrist_thumbnail.convert", fail)
op = upload(service)
assert op["status"] == "SYNCING"
assert "wrist_thumbnail" not in op["result"]
def test_local_upload_publishes_and_retention_protects_latest(service, photo):
from pigway_frame_flow.extensions.storage import LocalRetention
operation = service.submit(
"local-upload",
{"type": "photo_source", "source_id": "family"},
"living-room",
"test",
"local-request",
)
assert service.process_one()
op = service.operation(operation)
assert op["status"] == "COMPLETED"
asset_id = op["result"]["wrist_thumbnail"]["asset_id"]
assert asset_id in LocalRetention(service).protected()