Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1494904b12 | ||
|
|
0859871363 | ||
|
|
ed3b61bc58 | ||
|
|
30bc5a0bad | ||
|
|
64bd8dd736 | ||
|
|
cca5838803 | ||
|
|
129cfc8024 | ||
|
|
83c1c07fbd | ||
|
|
d680ba77f9 | ||
|
|
6ebc5e7afb | ||
|
|
8ce4aafb93 | ||
|
|
deb3b89bf8 | ||
|
|
9188d1743e | ||
|
|
6277c9d563 | ||
|
|
081d8b6ee0 | ||
|
|
7178235022 | ||
|
|
d94031eb50 |
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
+6
-2
@@ -8,6 +8,10 @@
|
||||
|
||||
轻量、自托管的智能画框自动化平台,可独立运行在 NAS 或服务器上,提供自己的 Web 界面、图库、图片渲染、调度器和 REST API。KoKonna 是支持的设备集成之一;外部应用无需持有它的凭据或引入其 SDK。
|
||||
|
||||
### 图库与终端
|
||||
|
||||
图库分为来源图库和终端图库。每个来源有自己的生成方案;每个终端单选一个来源及其中一个方案,同一方案可以被多个终端共用。任务一次选图,分别生成相框与手表版本。参见[使用与架构说明](docs/SOURCE_LIBRARIES.md)。
|
||||
|
||||
### 安装
|
||||
|
||||
使用 Docker Compose 部署。当前默认配置与已验证平台为 AMD64:
|
||||
@@ -48,7 +52,7 @@ Synology Photos 可使用独立的 API 来源,无需 `/photos` 或 NFS 挂载
|
||||
### 功能与边界
|
||||
|
||||
- 中英文 Web 界面、深浅色主题、账号登录、设备面板与图片预览。
|
||||
- NAS 本地图库和 KoKonna 云端图库;星标、来源/任务筛选及批量操作位于本地图库,上传到云端并显示需单独操作。
|
||||
- 来源图库管理源画面和来源内生成方案;终端图库按绑定管理成品。NAS 选图支持分页、预览及元数据筛选,旧图库页面已退役,已有任务兼容逻辑保留。
|
||||
- 保持比例的 contain/cover/smart 图片转换,彩色/灰度模式与照片数据源的横竖方向选择。
|
||||
- 七种模板:家庭早报、天气、日历、一句话、照片与文字、每日诗词、系统告警。
|
||||
- 拼图工作室支持 1–12 张图片的拼图与海报;可选 OpenAI 兼容接口排版,以及从数据源自动选图拼接。
|
||||
@@ -106,4 +110,4 @@ KoKonna 是支持的设备集成。本项目与 KoKonna 无隶属关系,也不
|
||||
|
||||
历史记录支持服务端翻页、图片名称/请求 ID/任务 ID/来源搜索,以及状态、操作、设备和日期(上海时区)筛选;可选择当前页记录进行单条或批量删除,操作前需确认。删除仅从历史列表移除,内部执行凭据继续供图库来源、幂等及恢复使用,不会删除图片或取消任务;运行中的任务不能删除。
|
||||
|
||||
源图库选用标记:本地图库支持单张和批量“不再选用/恢复选用”,照片源(Synology Photos、WebDAV、网盘等)的浏览列表支持独立星标及选用标记。后续自动选图和自动拼图跳过被标记的图片,星标保留与不再选用互斥,需先取消原标记再切换;图片仍保留,可手动预览、使用或恢复。标记按图库和图片 ID 独立保存,不取消已提交任务,也不修改网盘原文件。
|
||||
来源图库的源画面支持“星标保留”和“不再选用/恢复选用”,二者互斥;后续自动选图和自动拼图跳过被标记的照片。图片仍保留,可手动预览、生成或恢复。标记按来源和图片 ID 独立保存,不取消已提交任务,也不修改网盘原文件。
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
# 当前开发修订
|
||||
|
||||
- 更新图片处理、证书信任和遥测依赖;不可信图片只交给允许的格式解析器。
|
||||
- 手动上传接入来源图库,保存源画面后可按方案生成并关联终端。
|
||||
- 补齐图片写权限;客户端权限选项使用服务端统一目录。
|
||||
- 图片文件和数据库变更加入事务、失败清理及重启恢复;缓存转成品也保留一致性。
|
||||
- 统一上传及图库文案,移除图库重复设备选择器;NAS 空批次支持明确继续读取提示。
|
||||
- 设置页显示磁盘容量和待清理文件;登录计算不再占用共享数据库锁。
|
||||
- 保持单用户、内网 HTTP 访问,无证书要求。
|
||||
|
||||
# pigway-frame-flow v0.1.29
|
||||
|
||||
完善星标与“不再选用”禁用状态的原因提示。
|
||||
|
||||
+42
-32
@@ -8,8 +8,8 @@ c8c545718e9b9880555c950a078cac160c82e0cd6e593afcf20bc13727539ec5 .github/workfl
|
||||
b12a3f0ff3f15db8d79b5163d0750a3dd10eb56aadc41ada20c5f8a10b46c118 Dockerfile
|
||||
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
|
||||
250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md
|
||||
b7a41a2c00df1a0dbe3869d059d65c3bcaee8c50e51217c358527c4f1d3d6c98 README.md
|
||||
94184f76401192678ec420c2ccd23142d90e07a8ccc882a4a0d4701a0c278afc README.zh-CN.md
|
||||
31d523d1ed7465d8e7bf44011ca940e9ff0dd21af0e1bde5814c28dbfff53dd8 README.md
|
||||
1b809c686f4c1275bb9e04c1505020a3b5a9cb3a8e9298eb902ffe168bb17739 README.zh-CN.md
|
||||
4a5de9afa11143f35fb98cb4b97bde4ac1f8b67646810522483d30aecdda16d2 RELEASE_NOTES.md
|
||||
3dd364e47fdbd2ce9ce2df83342c523adc21583f093a5439d501b3424878ce2f SECURITY.md
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep
|
||||
@@ -24,39 +24,46 @@ f78d6c3d40e6c5246601bb99c2b46eef91e883a8f83841d85c925394708af0c0 docker-compose
|
||||
93b6e60570d9ca2ce14a454641a32885ca84d16a82db05db22cd62e488191dff docs/BUILDING.md
|
||||
d9f07164c75c757e85f4ace2b55deaf82b8b904216c5222be6c0e3ab7b28cb36 docs/EXTENSIONS.md
|
||||
1d8c5f79eff3115b1cd13d8f63fff1332b80d1fe1bec6b0f2713b5f80b0cbf67 docs/KOKONNA_RECOVERY.md
|
||||
0fe1896a53cef4cfd2e380a0b979eceda493733082a9b56c3d7e89e7f62a65b3 docs/NAS.md
|
||||
09cf79be06991e3b482518a3059a6f26504cb68857bc98a8a8bf8628724a5a93 docs/NAS.md
|
||||
3280bddcbe2d2721bb7efeb0eb2ec03d9d5ef867c69f3e70066b69f1ad5926e2 docs/PHOTO_SOURCES.md
|
||||
edf44436fd044561539c48480c481e66c1a38164f51ba5226cde04c756f7bacb docs/PHOTO_SOURCE_CONNECTIONS.md
|
||||
b48f3ab6f28404bb34c188067a832384f4ba290e56912461ef3565528aa157d8 docs/PUBLICATION.md
|
||||
5fe4641e75e0746929f71256c6ff11116c777cca8d4288ccc2d7aee91bd7a6ac docs/README.md
|
||||
10842c477d34e6fde7af0a98c739ef513ef48480d52e2fe6d6874eed33461d15 docs/README.md
|
||||
5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md
|
||||
bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md
|
||||
5ba13866ad96cb931c9dcb5fcc141c60fc44103a969fe912636a44afefb6854f docs/SOURCE_LIBRARIES.md
|
||||
fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md
|
||||
f8ef7749c58d4396781bbc21371048f6561e791efbd489570224dc51173bffff docs/brand/README.md
|
||||
f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3 docs/brand/pigway-avatar.png
|
||||
da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7 docs/brand/pigway-logo.png
|
||||
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 docs/brand/pigway-logo.svg
|
||||
ac0481196e593ff9cca2e5133ff41fb3614268e6a10728a0deca3ad3bfb80b34 docs/integrations/TICKTAR.md
|
||||
349d7acb49ad5b5f1df169c6ffe99063f11a55ca8764302ac383663161f1c6fb docs/openapi.json
|
||||
f1549ec1c51602a949261319c0483abb34d1ae6d272bdaf7e057ada7ebaa78a2 docs/openapi.json
|
||||
54bf7061e0626900ce35e19e81efc5c32c8fd6a0438bdad68f494e1ce81616d4 docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md
|
||||
a07cbe71571c6e2d5a1b94897bd63321c74675c05d15438c4964eef39db0fd76 docs/research/KOKONNA_CONTENT_SUBSCRIPTIONS.md
|
||||
ec53d587b855be84283c50f8fb45be62a4c1caba78b28d436a8260fd171943a2 docs/research/KOKONNA_LOCAL_CONTROL.md
|
||||
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__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
|
||||
a6f7336da1e4b90ec41a6f7cb2467e4b1bfa78551bf71b14b64e82d8ea08498f pigway_frame_flow/api/libraries.py
|
||||
3dda509a34daca61836913976dba35e59c2135c48bf04bc7617e923cc5cffdc1 pigway_frame_flow/api/thumbnails.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py
|
||||
0b8402603c2f397f4f42491873b27bf363959194b05a0c67c7b1d49596288b95 pigway_frame_flow/core/accounts.py
|
||||
818e8fb29d22c13159a6cb22bcbc6b9e211a8a1301588126eb192ac4ed71441b pigway_frame_flow/core/auth.py
|
||||
2f03a04c1f0f32d95bfd3d27d37ca98f9b79daaa20453999024b16fc8f34cd5d pigway_frame_flow/core/photo_preferences.py
|
||||
8f2220fa90b0dc2e45df2023dc5ac695efe55361644ef266829553c99151949b pigway_frame_flow/core/registry.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
|
||||
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
|
||||
a521e5d107f456e007050de65cd25e7ed3f269c27360e2bda6f768faa9b28841 pigway_frame_flow/display/wrist_thumbnail.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/drivers/__init__.py
|
||||
9a8386ef368a6df7fd8cb23bcc442c28e784729a5ce4a78911ad86245d5c5794 pigway_frame_flow/drivers/base.py
|
||||
54cff8782e21e2ee4b8540259a4868380c58a0aa656c9d23796a049ab96a1b4f pigway_frame_flow/drivers/generic_http.py
|
||||
@@ -77,7 +84,7 @@ d463465ca3aea449e67386bc84a5f25b58535f74d89d7858c5b9bca0d93189d3 pigway_frame_f
|
||||
6d70e410d3559f370e4489aa92b54eea91d11407e8039c9adf31612de0456df1 pigway_frame_flow/extensions/providers.py
|
||||
ab3081bd1afd2a4cd501355f10771cd92f3432439d9bc240d0790c2e76c2a508 pigway_frame_flow/extensions/quality.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
|
||||
b0855f8d22665fb208dfe47856f374b76a3861c15efb312d4ada81e84fee64e5 pigway_frame_flow/integrations/__init__.py
|
||||
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
|
||||
aea594fb4e4c9a6e2094d4d7973eb627ac02fb632811b2d1a558a0ee1cd12e77 pigway_frame_flow/integrations/kokonna_official/widgets.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
|
||||
b7a6cb8eb40d8dedfbce18777f8fbd433a174f14bcdadf498bd834132aa568fd pigway_frame_flow/providers/cloud_drives.py
|
||||
58b070f0c814d639a4a912d079479d751d459816d59f2199d0be03e4d573ec3b pigway_frame_flow/providers/external.py
|
||||
@@ -96,19 +103,20 @@ e90b6becc3e19baa83fab763cb156eb8882a96594c04040305dcb315f68e85d0 pigway_frame_f
|
||||
69e7419e67e8416bf73f37e257a2586d2a1aa4d2706da8f7b42c5cb8ca7f1eb3 pigway_frame_flow/providers/oauth.py
|
||||
abff8ee4b4eb1575451bcd5addf54451056af665d7aaa90173b5ed69a75a4d8e pigway_frame_flow/providers/photo_names.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
|
||||
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
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/renderers/__init__.py
|
||||
ce1e3d9dd7d8dc8d76d6206ab9cd05974bd2e81e1bbf28711f4b461332bf9236 pigway_frame_flow/renderers/frame_geometry.py
|
||||
e80029d9edc781e54e523570940110c948a0b0e564dcacf64f2fe1425f4509ea pigway_frame_flow/renderers/images.py
|
||||
b70197edc25101b7ee73434b253ae947cddc8a9d3e55545369ac7189e117b2a2 pigway_frame_flow/renderers/photo_caption.py
|
||||
95da34371ed6a46e63fb7f01dbaad3b9a73b520cdcaec1231048b81b01a0184e pigway_frame_flow/renderers/images.py
|
||||
0683436692d083c86b580aff71a9803a94e9fa07d45f384795020423b486494a pigway_frame_flow/renderers/photo_caption.py
|
||||
241f7d80997a6b6c7d421723d5375d4890f64eea8c70680abefb0fa5d15b9502 pigway_frame_flow/renderers/photo_layout.py
|
||||
d36258296d04b6575d78dd9831f8d3f66cf8b1e9182e2e90a589d10fa0067a04 pigway_frame_flow/renderers/templates.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
|
||||
77464a08816507a2423f08327c7767a9c019f55ea8f232856fa647e72e1e0246 pigway_frame_flow/settings.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/storage/__init__.py
|
||||
@@ -122,19 +130,22 @@ f9e777980ea5bb3ea8fb2357f23530802811ea2674d25100af9b728cbc9f83a9 pigway_frame_f
|
||||
8f37296f52a88c11a000a5a0767310354550da34e1d4c0c7260393ec00cd0d9b pigway_frame_flow/templates/quote.json
|
||||
82c17f4e7eaac95483592add181f549186dea9d7c0375fa9cad3554e3ccd1794 pigway_frame_flow/templates/system-alert.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
|
||||
d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js
|
||||
94558dbf524a1068e83283eb814c1b1501512d4b7e75e092b52e9aabfea720e7 pigway_frame_flow/web/favicon.ico
|
||||
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
|
||||
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
|
||||
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
|
||||
050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js
|
||||
1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css
|
||||
0ea2cc7da61c89c7505f4a312aad692d52084df32fc279f5742c96c31d84d09e pigway_frame_flow/web/thumbnails.js
|
||||
5f392f94636d261ae2982815763b794ae9595a1b9226946b792105634ec3f559 pyproject.toml
|
||||
d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock
|
||||
008af5f4adf616c86e805febc016faad60be0008abf5dddad6e955f1d03606cf scripts/build-verify.sh
|
||||
@@ -144,6 +155,7 @@ b0ec44fba0adfceca3655628021683030469be77c9e303c3f5cf830e8fd0cee3 scripts/ci-com
|
||||
6ebe82488d7eb2f10f69cf8bf4ed4e1ee272a216413fc5be1329f05889ed49a7 scripts/ci-install.py
|
||||
eb94843315f0671ca14fac66fc0c6e7380866f28dc822cce14312fa548bb5c82 scripts/create-account.py
|
||||
cdabf30144ea089ae8e64228f6762951f53d4fe8c191673fb5676331d249473d scripts/migrate-photos-api.py
|
||||
a39fd668383a948a9fbe1ecb00aebb23f375af52ea4dd3bc7049106925cfebb5 scripts/migrate-source-libraries.py
|
||||
80813607835c19d321085fac4cabb17d865d98ed62a09b7f45195ac48bec94a8 scripts/package-nas.py
|
||||
b0b877cfbb2bdf830e2e41097c2057538bb9d0b61a3c39952fe505d910cd5c29 scripts/persistence-check.py
|
||||
b7247c5590fe17ae461311f2fbdbeda0daa8a34c0804cef22a314c7ff8f4c389 scripts/production-deploy.py
|
||||
@@ -153,37 +165,31 @@ b7247c5590fe17ae461311f2fbdbeda0daa8a34c0804cef22a314c7ff8f4c389 scripts/produc
|
||||
1236d3b2d27559cbe73a27dcf56248ef3c60404c6fb21453caa7225570d4ac45 scripts/ui-audit-regressions.cjs
|
||||
57a79a534a71442f5e3a70ed9ff96f426007d9f6dd0bb9b1f5d79eac773d2254 scripts/ui-auto-collage.cjs
|
||||
5b61836a89db45cbd22274b9e0b035283d29d4b4926c05e203bd5da85a3bb270 scripts/ui-copy-audit.cjs
|
||||
6b602f617ba845fb64335ececc90ca3fac46073f13555707652f2f080be73b2e 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
|
||||
3374deeef1e7678b02c7aa628a1e8940a7275489a01b8142e5fadf83feca9626 scripts/ui-dashboard-preview.cjs
|
||||
8260a11478e23f63bc56c119205a89df04efe7c8a9bfb25b80db24aa56fd302b scripts/ui-history-layout.cjs
|
||||
86cf401ffb8b78691b6dca0851925af3b3107acfffd6943d78027ca4e9a54df7 scripts/ui-history-localization.cjs
|
||||
1f87c8b0c6d403c96bf91ca62b6679d1ccdfa3d8cca3f13a006d73f669fe7b09 scripts/ui-image-loading.cjs
|
||||
9c91dd27fb13305e5d6b38fc2ab365cc50d0bdf3022afeccebd089d40d50b731 scripts/ui-image-loading.cjs
|
||||
742197504264a547bcf5f121ed7fae447d8d31f51672d41278b4b984e94b5129 scripts/ui-integrations.cjs
|
||||
66a12251ea36f372072923ee0b8c7bfebac222fe1dbb702b22242a3859512a9a scripts/ui-interval-schedule.cjs
|
||||
70837acf16586f2c97e4e0ba30c1de00484b12113caeedda54ddb226ccaad510 scripts/ui-layout-audit.cjs
|
||||
39969a70bc473ff3f7496490a2096a0de4aaf98e389953b2be4a9bd1fef3f006 scripts/ui-layout.cjs
|
||||
7c9fc679880d39ef3af262dd43d58835740fdeee45112b848303ee88c15448de scripts/ui-libraries.cjs
|
||||
b8b6c94cce58efbcb97dd33fc9ce3f3525be1349679f0cf543a6c61af4bd7e6f scripts/ui-login-layout.cjs
|
||||
8574025d0b22f653c58846bdbb81cc10b4a7a7ca515d8e6b462083f6ca6aa39a scripts/ui-official-connection.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
|
||||
7047f9f49886abe3c2f08b6b2bfaea35bf9682d4e3aaee773e90c5f3885375fc scripts/ui-photos-api.cjs
|
||||
d390c1c9a76ddc167a17bfd7afb0bd1a7169809c8a0b30a26a5be4d18c3032de scripts/ui-progressive-loading.cjs
|
||||
8eee6d087ae3d65208c423d40623052a9bfbf3db4907299b832e81fbe835fb71 scripts/ui-smoke.cjs
|
||||
6c40d6bd4ae1bd3ecbcf9cfa3cad65893350f178399e4464cf138a4cac3bdf4b scripts/ui-smoke.cjs
|
||||
4d1c13a81d28f681aa1401953636d702cecfaed81274b347876c39e09931bb53 scripts/ui-studio-image-loading.cjs
|
||||
8a1a3ea2b4deb25ac656a0089e085e9837330ce9a386672f302326ac0cb71306 scripts/ui-widgets.cjs
|
||||
e474740bdb7dbe0617f7c6b170ada918e51f5eed8edbb9c2c47c4590a5dc2359 scripts/verify-photos-api-container.py
|
||||
b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify-photos-api.py
|
||||
d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py
|
||||
964a29221c6f7a4185067cb686e1ea003b99ce4609d6fb4ee4bacb70c9fe5ce8 tests/contract.json
|
||||
86aba7819e70b6ea9372eecc17ab5dfee3aa433e38417ebe81785d3450b4ad0d tests/schema-contract.json
|
||||
771c825ff00147638627451ce90d4b7f8aab0c6844f8187afa7ab618f8dc235e tests/contract.json
|
||||
9b3632a93c68c057b2fb276aa4a1b2b514f74934f0183480930d951d91704cae tests/schema-contract.json
|
||||
23ffc7413f8388648c9d52d3c8f56e2966b73ca1bdc5e209e6c15469c790edba tests/test_accounts.py
|
||||
e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py
|
||||
a6edb781f4d081b2db45ffe2b02eed52a10f9b87a70e1e201b5d2f7c6813b93b tests/test_api.py
|
||||
d5bf4f0463a9e369557adf2f440ad401815cb8ffea84af49d106e916eee16501 tests/test_api.py
|
||||
24fca799b0f5e33c800c6310764b18f6b39fc210be769684171388cf14b95746 tests/test_audit_regressions.py
|
||||
7001a2209473e6f6bf1050d33212985f63f4844defeb5df8e75d9f2a96c0d83c tests/test_auto_collage.py
|
||||
f267ebcdb3cf1fcb01ef9fd9ef7c5c8c4daca11d02543f84bfbe56359f35b78a tests/test_ci_audit.py
|
||||
@@ -225,12 +231,16 @@ e0a426eed6f1b6f59207b4de7d8286b6852550bfa28ad30e20f7e911ad9865ee tests/test_ret
|
||||
ab2cd30163453fe571b55c30cb26dc047a104a536ec01d2c64aa2bc91996ba11 tests/test_scheduler.py
|
||||
0cccc08adf35a06dbf704eb5c35966bc1c3de0ce39d3014efd7ca751253ce79f tests/test_server_release.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
|
||||
ca98469f6a6b8b49c36a00657ea1a81775c3496e1fbdd5e38eb4d5298b5936b4 tests/test_synology_photos_api.py
|
||||
a83390e9f580bc27a2928eb6f94d7377307ec46ab45ef71bfea1e5378cb7adb9 tests/test_thumbnail_plans.py
|
||||
33f05bc6279cf3573493335ae5bb1c91130754b927b52aa5f50e47f2d951d2ae tests/test_upload_reconciliation.py
|
||||
bdc3142f9c2291a91a5091b4b98b1addf9569cfb141810024ce6f85309704b9b tests/test_upload_retries.py
|
||||
8ec55ce5706d791a155f13445fe7e0828278e8ee1fa1b527591fdf24cc5b2e86 tests/test_weather.py
|
||||
e95eaa1f8a7416c5035ae6deae640b8d5defb2db36c0b8dac6d65bcceedc6750 tests/test_widgets.py
|
||||
2b3ab560a0211e45a7bc7e26104d3d9416c4615fbeccaf3bb5eec4747aaea4cf tests/test_wrist_thumbnail.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 vendor/__init__.py
|
||||
4cae8606769fdb590c427bc1716a871017bf22d91aabf8a52513bf126cfb847f vendor/guooscar_kokonna.py
|
||||
63c7a2f1a6adf98e7fb83a83ae2d6a2dddcecd7bce4d7de25dd6f26bd496019f vendor/guooscar_protocol.py
|
||||
|
||||
@@ -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` 通过。测试通过不抵消安全扫描失败。仅提交本审计文档,无业务修复、依赖升级、部署或正式发布。
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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.
|
||||
Directory: /volume2/docker/pigway-frame-flow. Default published port: 6005.
|
||||
The container still listens on 18787; Compose maps host 6005 to container 18787.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
安装、连接及日常使用从[中文首页](../README.zh-CN.md)或[英文首页](../README.md)开始。本目录收录仍适用的功能、配置与开发说明;版本变化见[版本记录](../RELEASE_NOTES.md)。
|
||||
|
||||
- [来源图库、生成方案与终端](SOURCE_LIBRARIES.md)
|
||||
- [自动拼图](AUTOMATIC_COLLAGE.md)
|
||||
- [构建、缓存与 CI](BUILDING.md)
|
||||
- [可选连接与扩展](EXTENSIONS.md)
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -141,6 +141,12 @@ def create_app(settings=None, start_worker=True):
|
||||
install_extensions(app, service)
|
||||
install_accounts(app, db)
|
||||
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.worker_enabled = start_worker
|
||||
app.state.scheduler = scheduler
|
||||
@@ -419,7 +425,9 @@ def create_app(settings=None, start_worker=True):
|
||||
from PIL import Image
|
||||
|
||||
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:
|
||||
raise ValueError()
|
||||
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")
|
||||
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
|
||||
# existing cache in place so old 300px previews are upgraded.
|
||||
expected_edge = min(860, max(img.size))
|
||||
regenerate = not thumb.exists()
|
||||
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
|
||||
if regenerate:
|
||||
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")
|
||||
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(
|
||||
original.format or "", "application/octet-stream"
|
||||
)
|
||||
@@ -569,11 +577,11 @@ def create_app(settings=None, start_worker=True):
|
||||
if len(data) > settings.max_bytes:
|
||||
raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB")
|
||||
name = Path(file.filename or "image").name[:120]
|
||||
asset = assets.put(
|
||||
with assets.creating(
|
||||
await run_in_threadpool(convert, data, fit, profile),
|
||||
settings.sanitize(name),
|
||||
kind="cache",
|
||||
)
|
||||
) as asset:
|
||||
db.execute(
|
||||
"INSERT INTO asset_metadata(asset_id,origin) VALUES(?,?)",
|
||||
(asset["asset_id"], "manual"),
|
||||
@@ -598,7 +606,8 @@ def create_app(settings=None, start_worker=True):
|
||||
return [
|
||||
assets.info(r["id"])
|
||||
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')) "
|
||||
"AND (?='all' OR COALESCE(origin,'unknown')=?) AND (? IS NULL OR job_id=?) "
|
||||
"ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
@@ -613,7 +622,14 @@ def create_app(settings=None, start_worker=True):
|
||||
dependencies=[Depends(require("images:delete"))],
|
||||
)
|
||||
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 + "%"
|
||||
if (
|
||||
db.one(
|
||||
@@ -631,14 +647,17 @@ def create_app(settings=None, start_worker=True):
|
||||
"Asset is referenced by a task, template or pending operation",
|
||||
409,
|
||||
)
|
||||
path.unlink(missing_ok=True)
|
||||
service.libraries.remove_asset(asset_id)
|
||||
for row in db.all(
|
||||
"SELECT key,payload FROM app_config WHERE key LIKE 'wrist-thumbnail:%'"
|
||||
):
|
||||
import json
|
||||
|
||||
if json.loads(row["payload"]).get("asset_id") == asset_id:
|
||||
db.execute(
|
||||
"DELETE FROM photo_preferences WHERE source_id='@assets' AND item_id=?",
|
||||
(asset_id,),
|
||||
"DELETE FROM app_config WHERE key=? AND payload=?",
|
||||
(row["key"], row["payload"]),
|
||||
)
|
||||
db.execute("DELETE FROM asset_metadata WHERE asset_id=?", (asset_id,))
|
||||
db.execute("DELETE FROM asset_sources WHERE asset_id=?", (asset_id,))
|
||||
db.execute("DELETE FROM assets WHERE id=?", (asset_id,))
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.put(
|
||||
@@ -785,7 +804,13 @@ def create_app(settings=None, start_worker=True):
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def delete_photo_source(source_id: str):
|
||||
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)
|
||||
|
||||
@app.get(
|
||||
@@ -986,6 +1011,7 @@ def create_app(settings=None, start_worker=True):
|
||||
dependencies=[Depends(require("jobs:write"))],
|
||||
)
|
||||
def create_job(payload: JobInput):
|
||||
with service.lock:
|
||||
return scheduler.save_job(payload)
|
||||
|
||||
@app.put(
|
||||
@@ -997,6 +1023,7 @@ def create_app(settings=None, start_worker=True):
|
||||
def update_job(job_id: str, payload: JobInput):
|
||||
if not db.one("SELECT id FROM jobs WHERE id=?", (job_id,)):
|
||||
raise FlowError(E.NOT_FOUND, "Job not found", 404)
|
||||
with service.lock:
|
||||
return scheduler.save_job(payload, job_id)
|
||||
|
||||
@app.delete(
|
||||
@@ -1090,6 +1117,14 @@ def create_app(settings=None, start_worker=True):
|
||||
or bool(settings.api_key)
|
||||
or any(registry.keys),
|
||||
),
|
||||
GalleryDestination(
|
||||
id="watch",
|
||||
adapter="watch-thumbnails",
|
||||
name="Watch",
|
||||
storage="local",
|
||||
connected=True,
|
||||
supports_upload=True,
|
||||
),
|
||||
GalleryDestination(
|
||||
id="local",
|
||||
adapter="local-assets",
|
||||
@@ -1366,6 +1401,9 @@ def create_app(settings=None, start_worker=True):
|
||||
def static(filename: str):
|
||||
if filename not in [
|
||||
"app.js",
|
||||
"thumbnails.js",
|
||||
"libraries.js",
|
||||
"source-browser.js",
|
||||
"features.js",
|
||||
"extensions.js",
|
||||
"style.css",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -4,6 +4,7 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
@@ -105,9 +106,12 @@ def session_user(request: Request):
|
||||
|
||||
|
||||
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"])
|
||||
def login(payload: Login, request: Request, response: Response):
|
||||
with db.lock:
|
||||
with login_lock:
|
||||
# Reject login CSRF before checking credentials. Never trust forwarded hosts.
|
||||
origin = request.headers.get("origin")
|
||||
if request.headers.get("sec-fetch-site") == "cross-site" or (
|
||||
@@ -154,12 +158,28 @@ def install_accounts(app, db):
|
||||
(key, failures, time.time() + 300 if failures >= 5 else 0),
|
||||
)
|
||||
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
|
||||
upgraded = (
|
||||
password_hash(payload.password)
|
||||
if row["password_hash"].startswith("scrypt$")
|
||||
else None
|
||||
)
|
||||
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=?",
|
||||
(password_hash(payload.password), row["username"]),
|
||||
(upgraded, row["username"]),
|
||||
)
|
||||
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
|
||||
lifetime = 30 * 86400 if payload.remember else 12 * 3600
|
||||
|
||||
@@ -14,6 +14,7 @@ bearer = HTTPBearer(auto_error=False)
|
||||
ALL_SCOPES = [
|
||||
"devices:read",
|
||||
"images:read",
|
||||
"images:write",
|
||||
"images:delete",
|
||||
"display:write",
|
||||
"templates:read",
|
||||
|
||||
@@ -20,6 +20,11 @@ class CloudLibrary:
|
||||
job = self.db.one(
|
||||
"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":
|
||||
job = self.db.one(
|
||||
"SELECT job_id FROM asset_metadata WHERE asset_id=? AND job_id IS NOT NULL",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -54,6 +54,9 @@ class DisplayService:
|
||||
self.cloud_library = CloudLibrary(self)
|
||||
self.recovery = RecoveryMonitor(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.unknown_checked: dict[str, float] = {}
|
||||
# A killed write cannot be replayed safely; pre-write work can resume.
|
||||
@@ -67,6 +70,18 @@ class DisplayService:
|
||||
|
||||
def validate(self, request, writing=True):
|
||||
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):
|
||||
raise FlowError(
|
||||
E.UNSUPPORTED_CAPABILITY, "Content type is unsupported", 422
|
||||
@@ -116,7 +131,14 @@ class DisplayService:
|
||||
)
|
||||
return existing["operation_id"]
|
||||
if (
|
||||
action not in ("display", "render", "render-ai", "local-upload")
|
||||
action
|
||||
not in (
|
||||
"display",
|
||||
"render",
|
||||
"render-ai",
|
||||
"local-upload",
|
||||
"library-generate",
|
||||
)
|
||||
and tx.execute(
|
||||
"SELECT id FROM operations WHERE device_id=? AND status='UNKNOWN' AND write_started=1 AND action!='render-ai' LIMIT 1",
|
||||
(device_id,),
|
||||
@@ -205,7 +227,12 @@ class DisplayService:
|
||||
result.pop("preview_url", None)
|
||||
row = self.db.one("SELECT result FROM operations WHERE id=?", (id,))
|
||||
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:
|
||||
result = {**result, key: previous[key]}
|
||||
self.db.execute(
|
||||
@@ -265,7 +292,15 @@ class DisplayService:
|
||||
)
|
||||
|
||||
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):
|
||||
from pigway_frame_flow.extensions.auto_collage import prepare
|
||||
@@ -292,9 +327,9 @@ class DisplayService:
|
||||
if isinstance(content, TemplateContent):
|
||||
values = self.renderer.resolve(content.template, content.data)
|
||||
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"
|
||||
)
|
||||
) as asset:
|
||||
source = {
|
||||
"type": "template",
|
||||
"template": content.template,
|
||||
@@ -308,8 +343,13 @@ class DisplayService:
|
||||
return asset
|
||||
if isinstance(content, PhotoSourceContent):
|
||||
source = self.photo_sources.get(content.source_id)
|
||||
if source_only:
|
||||
size = source_size or (1200, 1600)
|
||||
else:
|
||||
display = (
|
||||
self.registry.get(device_id or self.settings.device_id).device().display
|
||||
self.registry.get(device_id or self.settings.device_id)
|
||||
.device()
|
||||
.display
|
||||
)
|
||||
size = frame_canvas(display, source.orientation)
|
||||
import random
|
||||
@@ -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 = (
|
||||
render_captioned_photo(
|
||||
data,
|
||||
@@ -419,15 +470,17 @@ class DisplayService:
|
||||
):
|
||||
raise
|
||||
continue
|
||||
if source_image is not None:
|
||||
source_image["data"] = data
|
||||
attempt["status"] = "PREPARED"
|
||||
if operation_id:
|
||||
self.db.execute(
|
||||
"UPDATE operations SET result=? WHERE id=?",
|
||||
(encode({"photo_attempts": attempts}), operation_id),
|
||||
)
|
||||
asset = self.assets.put(
|
||||
with self.assets.creating(
|
||||
rendered, name, "cache" if staging else "images"
|
||||
)
|
||||
) as asset:
|
||||
self.db.execute(
|
||||
"INSERT OR REPLACE INTO asset_sources VALUES(?,?)",
|
||||
(
|
||||
@@ -460,7 +513,8 @@ class DisplayService:
|
||||
"cache" if staging else "images",
|
||||
)
|
||||
|
||||
def current_content(self, device_id):
|
||||
def current_content(self, device_id, *, device=None):
|
||||
if device is None:
|
||||
device = self.registry.get(device_id).device()
|
||||
self.clear_self_restore_reference(device_id, device)
|
||||
image_id = device.current_content.id
|
||||
@@ -548,13 +602,20 @@ class DisplayService:
|
||||
"render",
|
||||
"render-ai",
|
||||
"local-upload",
|
||||
"library-generate",
|
||||
) and self.blocked(op["device_id"]):
|
||||
continue
|
||||
payload = json.loads(op["payload"])
|
||||
if (
|
||||
op["action"] in ("display", "delete", "restore")
|
||||
and op["client_id"]
|
||||
in ("scheduler", "scheduler-retry", "cloud-library", "retention")
|
||||
in (
|
||||
"scheduler",
|
||||
"scheduler-retry",
|
||||
"library-terminal",
|
||||
"cloud-library",
|
||||
"retention",
|
||||
)
|
||||
and self.db.one(
|
||||
"SELECT id FROM operations WHERE device_id=? AND action='display' "
|
||||
"AND status='SYNCING' AND write_started=1 AND updated_at>? LIMIT 1",
|
||||
@@ -616,6 +677,9 @@ class DisplayService:
|
||||
|
||||
def _execute(self, op, payload):
|
||||
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)
|
||||
if action in self.operation_handlers:
|
||||
return self.operation_handlers[action](op, payload)
|
||||
@@ -625,15 +689,26 @@ class DisplayService:
|
||||
|
||||
from pigway_frame_flow.models import Content
|
||||
|
||||
source_image: dict[str, bytes] = {}
|
||||
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)
|
||||
if action == "local-upload":
|
||||
asset = self.assets.promote(asset["asset_id"])
|
||||
self.update(
|
||||
id, "COMPLETED", dict(asset_id=asset["asset_id"], url=asset["url"])
|
||||
result = 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")
|
||||
return
|
||||
device = driver.device(refresh=True)
|
||||
@@ -754,9 +829,16 @@ class DisplayService:
|
||||
):
|
||||
self.reconcile(pending["id"], acknowledge=True)
|
||||
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
|
||||
if isinstance(content, ImageContent) and content.asset_id:
|
||||
self.libraries.validate_asset_binding(device_id, content.asset_id)
|
||||
self.update(id, "RENDERING")
|
||||
asset = None
|
||||
source_image = {}
|
||||
if isinstance(content, ImageContent) and (
|
||||
content.image_id or content.image_name
|
||||
):
|
||||
@@ -769,7 +851,9 @@ class DisplayService:
|
||||
)
|
||||
image_id = matches[0].image_id
|
||||
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)
|
||||
image_id = None
|
||||
self.db.execute(
|
||||
@@ -803,6 +887,18 @@ class DisplayService:
|
||||
else:
|
||||
driver.display(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:
|
||||
result["rendered_asset_id"] = asset["asset_id"]
|
||||
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
|
||||
@@ -155,7 +155,9 @@ def install_ai_images(app, service):
|
||||
image_data = base64.b64decode(result[0]["b64_json"], validate=True)
|
||||
else:
|
||||
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:
|
||||
raise ValueError()
|
||||
rgba = ImageOps.exif_transpose(image).convert("RGBA")
|
||||
|
||||
@@ -76,7 +76,9 @@ def install_extensions(app, service):
|
||||
if len(data) > settings.max_bytes:
|
||||
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
|
||||
try:
|
||||
with Image.open(BytesIO(data)) as image:
|
||||
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
|
||||
@@ -92,7 +94,7 @@ def install_extensions(app, service):
|
||||
raise FlowError(E.INVALID_REQUEST, "Invalid image") from None
|
||||
result = []
|
||||
for data, name in prepared:
|
||||
asset = service.assets.put(data, name)
|
||||
with service.assets.creating(data, name) as asset:
|
||||
db.execute(
|
||||
"INSERT INTO asset_metadata(asset_id,origin) VALUES(?,'manual')",
|
||||
(asset["asset_id"],),
|
||||
@@ -220,6 +222,10 @@ def install_extensions(app, service):
|
||||
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(
|
||||
"/api/v1/clients",
|
||||
status_code=201,
|
||||
|
||||
@@ -37,7 +37,9 @@ def prepare(service, content, device_id=None):
|
||||
if source.avoid_duplicates and quality["fingerprint"] in hashes:
|
||||
continue
|
||||
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.thumbnail((2400, 2400))
|
||||
output = BytesIO()
|
||||
|
||||
@@ -126,7 +126,9 @@ class LayoutStudio:
|
||||
plan = local_plan(request)
|
||||
metadata = []
|
||||
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(
|
||||
{"asset_id": asset_id, "width": image.width, "height": image.height}
|
||||
)
|
||||
@@ -203,7 +205,10 @@ class LayoutStudio:
|
||||
plan = LayoutPlan.model_validate(data["layout"])
|
||||
canvas = Image.new("RGB", (plan.width, plan.height), "white")
|
||||
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.fit(
|
||||
image,
|
||||
|
||||
@@ -7,7 +7,7 @@ from PIL import Image, ImageFilter, ImageOps, ImageStat
|
||||
|
||||
|
||||
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.thumbnail((256, 256))
|
||||
brightness = ImageStat.Stat(image).mean[0]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Opt-in local retention, protecting stars, current content and durable references."""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
@@ -44,6 +45,7 @@ class LocalRetention:
|
||||
"SELECT payload FROM template_config",
|
||||
"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 'wrist-thumbnail:%'",
|
||||
]:
|
||||
payloads += [row["payload"] for row in db.all(query)]
|
||||
for payload in payloads:
|
||||
@@ -79,7 +81,19 @@ class LocalRetention:
|
||||
def stats(self):
|
||||
rows = self.inspect()
|
||||
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 {
|
||||
"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(),
|
||||
"total_bytes": sum(x[2] for x in rows),
|
||||
"asset_count": len(rows),
|
||||
@@ -91,6 +105,7 @@ class LocalRetention:
|
||||
return {}
|
||||
if not dry_run:
|
||||
self.last = time.monotonic()
|
||||
self.service.assets.recover_files()
|
||||
config = self.config()
|
||||
if not config.enabled:
|
||||
return {"deleted": 0, "bytes_freed": 0, "disabled": True}
|
||||
@@ -100,7 +115,7 @@ class LocalRetention:
|
||||
total = sum(x[2] for x in rows)
|
||||
removed = []
|
||||
freed = 0
|
||||
for row, path, size in rows:
|
||||
for row, _path, size in rows:
|
||||
# Manual uploaded photos are never removed by automatic retention.
|
||||
if row["id"] in protected or row["kind"] not in ("generated", "cache"):
|
||||
continue
|
||||
@@ -118,15 +133,7 @@ class LocalRetention:
|
||||
total -= size
|
||||
freed += size
|
||||
if not dry_run:
|
||||
path.unlink(missing_ok=True)
|
||||
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"],)
|
||||
)
|
||||
self.service.libraries.remove_asset(row["id"])
|
||||
result = {"deleted": len(removed), "bytes_freed": freed, "dry_run": dry_run}
|
||||
if not dry_run:
|
||||
self.service.db.execute(
|
||||
|
||||
@@ -107,6 +107,8 @@ Content = Annotated[
|
||||
|
||||
|
||||
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
|
||||
duration: int = Field(default=0, ge=0, le=604800)
|
||||
restore_previous: bool = False
|
||||
@@ -168,6 +170,10 @@ class ErrorResponse(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
|
||||
preview_url: str | None = None
|
||||
cover_id: int | None = None
|
||||
@@ -281,6 +287,9 @@ class RestoreContent(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
|
||||
enabled: bool = True
|
||||
device_id: str = "living-room"
|
||||
@@ -293,6 +302,23 @@ class JobInput(StrictModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
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 (
|
||||
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:
|
||||
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
||||
return None
|
||||
with Image.open(stream) as image:
|
||||
with Image.open(stream, formats=("JPEG", "PNG", "WEBP")) as image:
|
||||
width, height = image.size
|
||||
if image.getexif().get(274, 1) in (5, 6, 7, 8):
|
||||
width, height = height, width
|
||||
|
||||
@@ -30,7 +30,13 @@ from pigway_frame_flow.storage.database import encode
|
||||
class PhotoSourceInput(StrictModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
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)
|
||||
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.cache = {}
|
||||
self.api_items = {}
|
||||
from pigway_frame_flow.providers.source_browser import SourceBrowser
|
||||
|
||||
self.browser = SourceBrowser(self)
|
||||
self.synology = SynologyPhotos(settings)
|
||||
# Register provider secrets before request IDs or errors can be reflected.
|
||||
for row in self.list():
|
||||
@@ -129,6 +138,12 @@ class PhotoSources:
|
||||
|
||||
def save(self, source_id, 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:
|
||||
raise FlowError(E.INVALID_REQUEST, "Brightness range is reversed")
|
||||
if source.kind not in ("local", "synology-photos") and (
|
||||
@@ -307,6 +322,20 @@ class PhotoSources:
|
||||
self.require_api_source(source)
|
||||
if not source.enabled:
|
||||
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 = (
|
||||
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)):
|
||||
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:
|
||||
items = eligible(self.db, source_id, items)
|
||||
if not items:
|
||||
@@ -598,6 +636,18 @@ class PhotoSources:
|
||||
)
|
||||
if item is None:
|
||||
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":
|
||||
root = self.settings.photo_root.resolve()
|
||||
path = root / item["id"]
|
||||
@@ -641,13 +691,18 @@ class PhotoSources:
|
||||
raise FlowError(E.NOT_FOUND, "Photo is unavailable", 404) from None
|
||||
elif source.kind == "synology-photos":
|
||||
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:
|
||||
caption = dict(item.get("caption") or {})
|
||||
try:
|
||||
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:
|
||||
# Optional labels must not prevent displaying a valid photo.
|
||||
@@ -671,7 +726,7 @@ class PhotoSources:
|
||||
|
||||
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
|
||||
if image.getexif().get(274, 1) in (5, 6, 7, 8):
|
||||
width, height = height, width
|
||||
|
||||
@@ -61,7 +61,7 @@ class PhotosCache:
|
||||
os.fsync(handle.fileno())
|
||||
if not 0 < part.stat().st_size <= self.max_bytes:
|
||||
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"):
|
||||
raise ValueError()
|
||||
if image.width * image.height > MAX_IMAGE_PIXELS:
|
||||
@@ -69,7 +69,7 @@ class PhotosCache:
|
||||
image.verify()
|
||||
# Decode before committing; truncated/compressed-bomb payloads
|
||||
# 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()
|
||||
part.replace(path)
|
||||
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)
|
||||
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
|
||||
|
||||
sid = self.sid()
|
||||
url, pin = self.endpoint()
|
||||
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":
|
||||
thumb = row.get("additional", {}).get("thumbnail", {})
|
||||
if not thumb.get("cache_key") or not thumb.get("unit_id"):
|
||||
@@ -353,13 +355,13 @@ class SynologyPhotos:
|
||||
params = dict(
|
||||
id=thumb["unit_id"],
|
||||
cache_key=thumb["cache_key"],
|
||||
size="xl",
|
||||
size=size,
|
||||
type="unit",
|
||||
)
|
||||
else:
|
||||
raise FlowError(
|
||||
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"):
|
||||
self.image_cache = PhotosCache(self.settings)
|
||||
@@ -372,6 +374,7 @@ class SynologyPhotos:
|
||||
space,
|
||||
row["id"],
|
||||
rendition,
|
||||
size,
|
||||
row.get("indexed_time"),
|
||||
row.get("filesize"),
|
||||
row.get("additional", {}).get("thumbnail", {}).get("cache_key"),
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import base64
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from io import BytesIO
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import uuid4
|
||||
@@ -17,9 +21,17 @@ MAX_IMAGE_PIXELS = 25_000_000
|
||||
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:
|
||||
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"}:
|
||||
raise ValueError()
|
||||
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"
|
||||
)
|
||||
else:
|
||||
center = (0.5, 0.5)
|
||||
center = centering
|
||||
if fit == "smart":
|
||||
# Lightweight edge saliency: slide an aspect-correct window
|
||||
# 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":
|
||||
result = ImageOps.grayscale(result).convert("RGB")
|
||||
out = BytesIO()
|
||||
result.save(out, "JPEG", quality=93, optimize=True)
|
||||
result.save(out, "JPEG", quality=quality, optimize=True)
|
||||
return out.getvalue()
|
||||
except (OSError, ValueError, Image.DecompressionBombError):
|
||||
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:
|
||||
def __init__(self, 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
|
||||
|
||||
if kind not in {"images", "generated", "cache", "source-images", "renditions"}:
|
||||
raise ValueError("Invalid asset kind")
|
||||
name = Path(name).stem[:110] + ".jpg"
|
||||
asset_id = uuid4().hex
|
||||
(self.settings.data_dir / kind / (asset_id + ".jpg")).write_bytes(data)
|
||||
path = self.settings.data_dir / kind / (asset_id + ".jpg")
|
||||
path.parent.mkdir(exist_ok=True)
|
||||
self.db.execute("INSERT INTO asset_file_journal VALUES(?,?)", (asset_id, kind))
|
||||
temporary = path.with_suffix(".part")
|
||||
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())
|
||||
"INSERT INTO assets VALUES(?,?,?,?)",
|
||||
(asset_id, name, kind, now_iso()),
|
||||
)
|
||||
return self.info(asset_id)
|
||||
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):
|
||||
job = self.db.one(
|
||||
@@ -115,6 +209,7 @@ class Assets:
|
||||
)
|
||||
|
||||
def promote(self, asset_id):
|
||||
with self.file_lock:
|
||||
row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
|
||||
if row and row["kind"] == "cache":
|
||||
source = self.db.one(
|
||||
@@ -122,12 +217,27 @@ class Assets:
|
||||
)
|
||||
kind = (
|
||||
"generated"
|
||||
if source and json.loads(source["payload"]).get("type") == "template"
|
||||
if source
|
||||
and json.loads(source["payload"]).get("type") == "template"
|
||||
else "images"
|
||||
)
|
||||
target = self.settings.data_dir / kind / (asset_id + ".jpg")
|
||||
self.path(asset_id).replace(target)
|
||||
self.db.execute("UPDATE assets SET kind=? WHERE id=?", (kind, 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):
|
||||
@@ -148,7 +258,9 @@ class Assets:
|
||||
"job_id": meta["job_id"] if meta else None,
|
||||
"job_name": meta["job_name"] if meta else None,
|
||||
"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"],
|
||||
"url": f"/api/v1/assets/{row['id']}",
|
||||
"created_at": row["created_at"],
|
||||
|
||||
@@ -17,6 +17,9 @@ def render_captioned_photo(
|
||||
*,
|
||||
size=(1200, 1600),
|
||||
layout_provider=None,
|
||||
centering=(0.5, 0.5),
|
||||
quality=93,
|
||||
respect_fit=False,
|
||||
):
|
||||
width, height = size
|
||||
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.
|
||||
photo_data = convert(
|
||||
data,
|
||||
"smart" if fit == "smart" else "cover",
|
||||
fit if respect_fit else ("smart" if fit == "smart" else "cover"),
|
||||
profile,
|
||||
size=(width, photo_height),
|
||||
centering=centering,
|
||||
quality=quality,
|
||||
)
|
||||
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))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
values = [
|
||||
@@ -64,5 +69,5 @@ def render_captioned_photo(
|
||||
anchor="rm",
|
||||
)
|
||||
out = BytesIO()
|
||||
canvas.save(out, "JPEG", quality=93, optimize=True)
|
||||
canvas.save(out, "JPEG", quality=quality, optimize=True)
|
||||
return out.getvalue()
|
||||
|
||||
@@ -177,7 +177,9 @@ class TemplateRenderer:
|
||||
elif block["kind"] == "photo":
|
||||
asset_id = values.get("photo_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))
|
||||
else:
|
||||
draw.rectangle((x, y, x + w, y + h), outline="black", width=5)
|
||||
|
||||
@@ -15,6 +15,7 @@ from pigway_frame_flow.models import (
|
||||
ImageContent,
|
||||
JobInput,
|
||||
Options,
|
||||
PhotoSourceContent,
|
||||
PlaylistInput,
|
||||
RestoreContent,
|
||||
)
|
||||
@@ -86,13 +87,64 @@ class Scheduler:
|
||||
row["last_result"] = operation["status"]
|
||||
return rows
|
||||
|
||||
def save_job(self, job, id=None):
|
||||
def validate_job(self, job):
|
||||
if not job.library_pipeline:
|
||||
self.service.registry.get(job.device_id)
|
||||
if job.source.type != "restore":
|
||||
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(
|
||||
DisplayRequest(device_id=job.device_id, content=job.source),
|
||||
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)
|
||||
if job.enabled and upcoming is None:
|
||||
raise FlowError(E.INVALID_REQUEST, "One-time task must be in the future")
|
||||
@@ -183,9 +235,13 @@ class Scheduler:
|
||||
restore_previous=job.restore_previous,
|
||||
priority=job.priority,
|
||||
cleanup_unstarred=job.cleanup_unstarred,
|
||||
generate_thumbnail=job.generate_thumbnail,
|
||||
thumbnail_plan_id=job.thumbnail_plan_id,
|
||||
),
|
||||
).model_dump(mode="json")
|
||||
)
|
||||
if job.library_pipeline:
|
||||
action = "library-generate"
|
||||
if action == "display":
|
||||
payload = self.upload_retries.scheduled_payload(
|
||||
row["id"], payload
|
||||
@@ -193,7 +249,10 @@ class Scheduler:
|
||||
operation_id = self.service.submit(
|
||||
action,
|
||||
payload,
|
||||
job.device_id,
|
||||
("source:" + job.source.source_id)
|
||||
if job.library_pipeline
|
||||
and isinstance(job.source, PhotoSourceContent)
|
||||
else job.device_id,
|
||||
"scheduler",
|
||||
"schedule-" + uuid4().hex,
|
||||
"job-" + row["id"] + "-" + slot,
|
||||
|
||||
@@ -64,14 +64,7 @@ class UploadRetries:
|
||||
)
|
||||
# Only disposable generated staging files are owned by this recovery.
|
||||
if row and row["kind"] == "cache" and not (meta and meta["starred"]):
|
||||
self.service.assets.path(asset_id).unlink(missing_ok=True)
|
||||
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,))
|
||||
self.service.libraries.remove_asset(asset_id)
|
||||
|
||||
def tick(self, current):
|
||||
for job in self.db.all("SELECT * FROM jobs WHERE enabled=1"):
|
||||
|
||||
@@ -17,6 +17,7 @@ def encode(value):
|
||||
class Database:
|
||||
def __init__(self, path):
|
||||
self.lock = threading.RLock()
|
||||
self._transaction_depth = 0
|
||||
self.conn = sqlite3.connect(path, check_same_thread=False)
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
self.conn.executescript("""
|
||||
@@ -68,16 +69,31 @@ class Database:
|
||||
@contextmanager
|
||||
def transaction(self):
|
||||
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:
|
||||
yield self.conn
|
||||
if nested:
|
||||
self.conn.execute("RELEASE SAVEPOINT " + savepoint)
|
||||
else:
|
||||
self.conn.commit()
|
||||
except Exception:
|
||||
except BaseException:
|
||||
if nested:
|
||||
self.conn.execute("ROLLBACK TO SAVEPOINT " + savepoint)
|
||||
self.conn.execute("RELEASE SAVEPOINT " + savepoint)
|
||||
else:
|
||||
self.conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._transaction_depth -= 1
|
||||
|
||||
def execute(self, query, args=()):
|
||||
with self.lock, self.conn:
|
||||
with self.lock:
|
||||
if self._transaction_depth:
|
||||
return self.conn.execute(query, args)
|
||||
with self.conn:
|
||||
return self.conn.execute(query, args)
|
||||
|
||||
def one(self, query, args=()):
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
'use strict';
|
||||
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};
|
||||
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=[];}
|
||||
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(){localStorage.removeItem('ff-login');state.token='';state.csrf='';state.settings=null;state.devices=[];state.templates=[];}
|
||||
async function loadConnection(){
|
||||
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 [];});
|
||||
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);});
|
||||
}
|
||||
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;
|
||||
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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;}
|
||||
window.addEventListener('resize',revealCurrentNavigation);
|
||||
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 operationClientLabel(client){return client==='admin'?tr('管理员','Administrator'):client==='scheduler'?tr('自动调度','Scheduler'):client;}
|
||||
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==='library-terminal'?tr('来源图库输出','Source-library output'):client;}
|
||||
function operationResultHTML(o){
|
||||
const r=o.result||{},parts=[];
|
||||
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>`);
|
||||
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>`);
|
||||
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 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();}
|
||||
// A destination appears only when its connection and read/write adapter are available.
|
||||
// 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 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>`;}
|
||||
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();}
|
||||
function imageLoading(img){
|
||||
if(img.parentElement?.classList.contains('image-loading'))return img.parentElement;
|
||||
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();});}
|
||||
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 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){
|
||||
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);
|
||||
@@ -132,32 +124,26 @@ const views={
|
||||
connections:()=>connectionsPage(),
|
||||
'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 gallery(epoch){
|
||||
const destinations=galleryDestinations();if(!destinations.some(d=>d.id===state.tab))state.tab=destinations[0]?.id||'local';
|
||||
const key=JSON.stringify([state.device,state.tab,state.assetOffset||0,state.assetOrigin||'all',state.assetJob||'']);
|
||||
const cache=state.galleryCache||(state.galleryCache=new Map());const cached=cache.get(key);
|
||||
const render=(items,pending=false)=>{if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`<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();});});};
|
||||
render(cached||[],!cached);
|
||||
try{
|
||||
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):''));
|
||||
if(epoch!==state.epoch)return;cache.set(key,items);
|
||||
if(!cached||JSON.stringify(cached)!==JSON.stringify(items))render(items);
|
||||
}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(epoch){
|
||||
state.preview=null;
|
||||
const sources=await api('/api/v1/source-libraries');if(epoch!==state.epoch)return;
|
||||
const options=[...sources];if(!options.some(x=>x.id==='manual-uploads'))options.push({id:'manual-uploads',name:tr('手动上传','Manual uploads')});
|
||||
const selected=options.some(x=>x.id===libraryUI.source)?libraryUI.source:'manual-uploads';
|
||||
$('#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>`;
|
||||
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();});
|
||||
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;};
|
||||
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.'));});
|
||||
bind('view-upload-library',()=>navigate('gallery'));bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));
|
||||
},
|
||||
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 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();}
|
||||
};
|
||||
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);}
|
||||
$('#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);
|
||||
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(()=>{});
|
||||
$('#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=>{state.versionInfo=v;renderFooter();}).catch(()=>{});
|
||||
setInterval(()=>{if(state.token&&state.page==='dashboard'&&!$('#modal').open)navigate();},60000);
|
||||
|
||||
// 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);}
|
||||
$('#job-schedule').onchange=()=>{updateScheduleField();$('#schedule-value').value='';};
|
||||
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 thumbnailJobFields(j);
|
||||
await libraryJobFields(j,!job?.id);
|
||||
}
|
||||
|
||||
views.automation=async epoch=>{
|
||||
const jobs=await api('/api/v1/jobs');
|
||||
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());
|
||||
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();}));
|
||||
|
||||
@@ -26,16 +26,16 @@ async function layoutStudioPage(){
|
||||
}
|
||||
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>`;
|
||||
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(){
|
||||
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(){
|
||||
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(){
|
||||
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(){
|
||||
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;});
|
||||
|
||||
@@ -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>`);
|
||||
$('#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(){
|
||||
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('')}`;}
|
||||
@@ -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);
|
||||
$('#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'));});}
|
||||
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();});}
|
||||
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);$('#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(){
|
||||
if(state.selectedTemplate!=='daily-poetry')return;
|
||||
const provider=await api('/api/v1/providers/poetry');if(!$('#template-fields'))return;
|
||||
@@ -146,14 +132,14 @@ function photosLoginDialog(){
|
||||
|
||||
async function photoSourcesPanel(){
|
||||
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;
|
||||
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>`;
|
||||
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>`;
|
||||
await photosConnectionPanel();
|
||||
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-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-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));
|
||||
}
|
||||
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='';};
|
||||
$('#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){
|
||||
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}));
|
||||
@@ -182,7 +161,7 @@ function historyControls(){
|
||||
const filters=state.historyFilters||{};
|
||||
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>`;
|
||||
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);
|
||||
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();});
|
||||
@@ -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 disabledMarkHints(root){
|
||||
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;
|
||||
const reason=button.disabled?(button.title||wrapper?.dataset.reason||''):'';
|
||||
if(!reason){if(wrapper)wrapper.replaceWith(button);continue;}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
||||
<!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>
|
||||
<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>
|
||||
|
||||
@@ -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>`);
|
||||
}
|
||||
@@ -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');}
|
||||
}
|
||||
@@ -172,12 +172,6 @@ body.login-mode header,body.login-mode footer{flex-shrink:0}
|
||||
.image-card .actions{gap:6px}
|
||||
.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-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: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}
|
||||
|
||||
.image-card>small{display:block}
|
||||
.photo-mark{display:block;color:var(--accent);font-size:12px}
|
||||
|
||||
.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}
|
||||
|
||||
.disabled-mark-hint{display:inline-flex;max-width:100%;cursor:help}
|
||||
.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}
|
||||
|
||||
.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}
|
||||
|
||||
@@ -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
@@ -4,7 +4,7 @@ version = "0.1.29"
|
||||
readme = "README.md"
|
||||
license = {file = "LICENSE"}
|
||||
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]
|
||||
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]
|
||||
|
||||
+3
-3
@@ -1,16 +1,16 @@
|
||||
annotated-doc==0.0.5
|
||||
annotated-types==0.8.0
|
||||
anyio==4.15.1
|
||||
certifi==2026.7.22
|
||||
certifi==2026.10.23
|
||||
charset-normalizer==3.5.2
|
||||
click==8.5.0
|
||||
croniter==6.2.4
|
||||
fastapi==0.142.2
|
||||
h11==0.16.0
|
||||
idna==3.20
|
||||
opentelemetry-api==1.45.0
|
||||
opentelemetry-api==1.45.1
|
||||
paho-mqtt==2.1.0
|
||||
pillow==12.3.0
|
||||
pillow==13.0.0
|
||||
pydantic-core==2.46.5
|
||||
pydantic==2.13.5
|
||||
python-dateutil==2.9.0.post0
|
||||
|
||||
@@ -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()
|
||||
@@ -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 context={};context.window=context;vm.createContext(context);vm.runInContext(fs.readFileSync('pigway_frame_flow/web/i18n.js','utf8'),context);
|
||||
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]}`);
|
||||
(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)});
|
||||
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','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 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)});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* Isolated Mock only: dashboard and gallery share the same read-only image viewer. */
|
||||
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;
|
||||
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));
|
||||
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)});
|
||||
|
||||
@@ -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)});
|
||||
@@ -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)});
|
||||
@@ -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)});
|
||||
@@ -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)});
|
||||
@@ -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'});
|
||||
try{
|
||||
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;
|
||||
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();
|
||||
@@ -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 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 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');});
|
||||
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++;
|
||||
}
|
||||
|
||||
@@ -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();}
|
||||
})();
|
||||
@@ -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)});
|
||||
@@ -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)});
|
||||
@@ -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)});
|
||||
@@ -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('#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.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('#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('#show-name').click();await page.locator('#image-name').fill('Original Art.jpg');await page.locator('#modal-cancel').click();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('[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('[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('#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 uploaded.locator('[data-delete]').click();await page.locator('#modal-confirm').click();await uploaded.waitFor({state:'hidden'});checks++;
|
||||
await page.locator('#save-local').click();await page.waitForFunction(name=>document.querySelector('#toast')?.textContent.includes('Saved on server'),uploadName);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('#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++;
|
||||
|
||||
@@ -1510,5 +1510,537 @@
|
||||
"502",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,6 +581,23 @@
|
||||
"minimum": 0.0,
|
||||
"title": "Priority",
|
||||
"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,
|
||||
@@ -781,6 +798,45 @@
|
||||
"type": "boolean",
|
||||
"title": "Deferred",
|
||||
"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",
|
||||
@@ -895,7 +951,8 @@
|
||||
"webdav",
|
||||
"google-drive",
|
||||
"alipan",
|
||||
"baidu"
|
||||
"baidu",
|
||||
"uploads"
|
||||
],
|
||||
"title": "Kind"
|
||||
},
|
||||
@@ -1075,7 +1132,8 @@
|
||||
"webdav",
|
||||
"google-drive",
|
||||
"alipan",
|
||||
"baidu"
|
||||
"baidu",
|
||||
"uploads"
|
||||
],
|
||||
"title": "Kind"
|
||||
},
|
||||
|
||||
@@ -242,3 +242,58 @@ def test_only_one_browser_account(app):
|
||||
"INSERT INTO users VALUES(?,?,1)",
|
||||
("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
@@ -453,7 +453,8 @@ def test_settings_exposes_storage_display_name(client, service):
|
||||
result = client.get("/api/v1/settings").json()
|
||||
assert result["storage_name"] == "Family NAS"
|
||||
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["local"]["name"] == "Family NAS"
|
||||
assert all(d["connected"] and d["supports_upload"] for d in destinations.values())
|
||||
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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") == []
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user