From 30bc5a0bad7a994bba31a01e4631db031d46920f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Fri, 23 Oct 2026 22:57:53 +0800 Subject: [PATCH] fix: recognize displayed terminal versions and clean obsolete gallery remnants --- README.md | 2 +- README.zh-CN.md | 4 +- SHA256SUMS | 34 +- docs/NAS.md | 2 + docs/SOURCE_LIBRARIES.md | 2 +- docs/WATCH-THUMBNAIL.md | 23 - docs/openapi.json | 500 ++++++++++++++++++ pigway_frame_flow/api/libraries.py | 17 +- pigway_frame_flow/display/service.py | 5 +- .../providers/synology_photos.py | 2 +- pigway_frame_flow/web/app.js | 1 - pigway_frame_flow/web/features.js | 2 +- pigway_frame_flow/web/libraries.js | 6 +- scripts/ui-dashboard-preview.cjs | 4 +- scripts/ui-image-loading.cjs | 6 +- scripts/ui-libraries.cjs | 6 +- scripts/ui-photo-sources.cjs | 3 - scripts/ui-smoke.cjs | 10 +- tests/test_source_libraries.py | 45 ++ 19 files changed, 606 insertions(+), 68 deletions(-) delete mode 100644 docs/WATCH-THUMBNAIL.md delete mode 100644 scripts/ui-photo-sources.cjs diff --git a/README.md b/README.md index 387ab91..d09f50a 100644 --- a/README.md +++ b/README.md @@ -50,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. diff --git a/README.zh-CN.md b/README.zh-CN.md index e26ce9f..7c30bc6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -52,7 +52,7 @@ Synology Photos 可使用独立的 API 来源,无需 `/photos` 或 NFS 挂载 ### 功能与边界 - 中英文 Web 界面、深浅色主题、账号登录、设备面板与图片预览。 -- NAS 本地图库和 KoKonna 云端图库;星标、来源/任务筛选及批量操作位于本地图库,上传到云端并显示需单独操作。 +- 来源图库管理源画面和来源内生成方案;终端图库按绑定管理成品。NAS 选图支持分页、预览及元数据筛选,旧图库页面已退役,已有任务兼容逻辑保留。 - 保持比例的 contain/cover/smart 图片转换,彩色/灰度模式与照片数据源的横竖方向选择。 - 七种模板:家庭早报、天气、日历、一句话、照片与文字、每日诗词、系统告警。 - 拼图工作室支持 1–12 张图片的拼图与海报;可选 OpenAI 兼容接口排版,以及从数据源自动选图拼接。 @@ -110,4 +110,4 @@ KoKonna 是支持的设备集成。本项目与 KoKonna 无隶属关系,也不 历史记录支持服务端翻页、图片名称/请求 ID/任务 ID/来源搜索,以及状态、操作、设备和日期(上海时区)筛选;可选择当前页记录进行单条或批量删除,操作前需确认。删除仅从历史列表移除,内部执行凭据继续供图库来源、幂等及恢复使用,不会删除图片或取消任务;运行中的任务不能删除。 -源图库选用标记:本地图库支持单张和批量“不再选用/恢复选用”,照片源(Synology Photos、WebDAV、网盘等)的浏览列表支持独立星标及选用标记。后续自动选图和自动拼图跳过被标记的图片,星标保留与不再选用互斥,需先取消原标记再切换;图片仍保留,可手动预览、使用或恢复。标记按图库和图片 ID 独立保存,不取消已提交任务,也不修改网盘原文件。 +来源图库的源画面支持“星标保留”和“不再选用/恢复选用”,二者互斥;后续自动选图和自动拼图跳过被标记的照片。图片仍保留,可手动预览、生成或恢复。标记按来源和图片 ID 独立保存,不取消已提交任务,也不修改网盘原文件。 diff --git a/SHA256SUMS b/SHA256SUMS index 6927862..efe3cf0 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -8,8 +8,8 @@ c8c545718e9b9880555c950a078cac160c82e0cd6e593afcf20bc13727539ec5 .github/workfl b12a3f0ff3f15db8d79b5163d0750a3dd10eb56aadc41ada20c5f8a10b46c118 Dockerfile 6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE 250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md -5bc4b12649768e753cf743da61b6608603c3080c4492def1b637fb60b78ef5eb README.md -06b316caa3e06c5cc0fbe97d9c0ef9d84ac32efcd3a52370bf887b72ec6d72a6 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,22 +24,21 @@ 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 10842c477d34e6fde7af0a98c739ef513ef48480d52e2fe6d6874eed33461d15 docs/README.md 5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md -35c15a33db5ee1eab2507f1fe1e2dd7934584a321f155c66cb1593d04a342fb4 docs/SOURCE_LIBRARIES.md +604e86417063600682392c03e0993eae9c48519c25c1f94a208b4b373f8b2965 docs/SOURCE_LIBRARIES.md fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md -1ae7e53355dc27e2995c85f2a9a55ad87e53085cccb3235970236666b6485799 docs/WATCH-THUMBNAIL.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 -debddbc1ad67c51fc73891ac1855fb971ae929c582966ac2dd03d84c0229400c 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 @@ -48,7 +47,7 @@ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_f e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__init__.py 37f907c13f5747a37252d9e3e8a882b106fbd9158e8727d7dc05cb827f63173c pigway_frame_flow/api/app.py 1efe9b1fc774a97e5be72d81199b1b65b25c258956fdf166aebff732cc820d7c pigway_frame_flow/api/history.py -5debca31ee59487c4168ab910e516fc42a3f3f008c3ade617feeb0f6d56cc6c8 pigway_frame_flow/api/libraries.py +cb8b2bfe0f9044b82766e0817ad6e7ac67b7b480dbd4edbd4906f36a4bb60c60 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 @@ -60,7 +59,7 @@ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_f 81d969357cd267e4047edc344db24afb88f67c1ffa176010cef384eee8d25457 pigway_frame_flow/display/libraries.py 5e7e272e671a6d01d9b677416c77967ca488b4e2aa559f4211cf0de4ba1e88bc pigway_frame_flow/display/recovery.py e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py -9fb5a61dbc73011babad2e146ed8a6215ea0a0cbee12839b7e3b66ee1dd95750 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 @@ -108,7 +107,7 @@ abff8ee4b4eb1575451bcd5addf54451056af665d7aaa90173b5ed69a75a4d8e pigway_frame_f 114adfb3fcd1ab050c1dd2f1fff9d91fdde192b8eced907dee3bc8086037e13d pigway_frame_flow/providers/photos_cache.py 119427bc435ec8d4c91114713f37d07f22067eaeec391261d1441a1436baf5e0 pigway_frame_flow/providers/poetry.py b1266d22ea914aa4d52e0b80cc1e2948c72219ea766140eb5aa22f6d42531053 pigway_frame_flow/providers/source_browser.py -fd24397a0a0a8f3268586bc505c13816aca53ae08791f794fdb81cdbc1e6eaab pigway_frame_flow/providers/synology_photos.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 @@ -131,15 +130,15 @@ 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 -c4003d43b43fbba8d4084ec40357b468a034f228fda7fae9435b1b7555ad7854 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 -b511a840ce201cf9ced4ef094e1ad564b54dcfd40a8be275c69a0869571b0150 pigway_frame_flow/web/features.js +9eec4fe71cf1750a55bf9d8ebc9a24434c3951e8659dd40267b5184b20dcb37e pigway_frame_flow/web/features.js a07ea444ab4ac75ea11e1188a431b48195353b1e08f7e80f3b931e8097ea6221 pigway_frame_flow/web/i18n.js 6385ec8b0383fa9bdbb23c4c685c8ef1afd7926fd74c8032811e647808548255 pigway_frame_flow/web/index.html -b8de7d813b0ff738b182f25167e520468aad2533ad741e5d25e93ee5d681b3e4 pigway_frame_flow/web/libraries.js +943e71c859a8711e8e718fe79bda1849cd43280ff1c898587f8412ffe9792bbb pigway_frame_flow/web/libraries.js 0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/logo-symbol.svg 9dcf0419d0292efce8ead7309dc70b4afd27dd24fe82cf9e673682342650f4da pigway_frame_flow/web/source-browser.js d1e42232c50e3ed23912504997d776e70127f089daba944f514f68c90364c332 pigway_frame_flow/web/style.css @@ -166,22 +165,21 @@ 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 +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 -820f305bed5e04ee621c76a4a80057d878152788bf0f6e382bcdd02d5d61fd3d scripts/ui-libraries.cjs +21c432c0b9337844e555e4e54235a6b128d872374072c6db5e57c99a61ab9ab8 scripts/ui-libraries.cjs b8b6c94cce58efbcb97dd33fc9ce3f3525be1349679f0cf543a6c61af4bd7e6f scripts/ui-login-layout.cjs 8574025d0b22f653c58846bdbb81cc10b4a7a7ca515d8e6b462083f6ca6aa39a scripts/ui-official-connection.cjs 98a03f5fb7aeead34a50ad6c17ee4cd5f2cc61eeb6ca7460652835dc31255447 scripts/ui-photo-people.cjs -f721682b12291d232bf727b1c999a7231fb1f4fc5030303baea04ad3b3bfdf2a scripts/ui-photo-sources.cjs a4ba9a6d5fbfff7aa05d77ce7eab74a6468c1e929a90305a4afa8abbc6c891a0 scripts/ui-photos-api-form.cjs 7047f9f49886abe3c2f08b6b2bfaea35bf9682d4e3aaee773e90c5f3885375fc scripts/ui-photos-api.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 @@ -234,7 +232,7 @@ ab2cd30163453fe571b55c30cb26dc047a104a536ec01d2c64aa2bc91996ba11 tests/test_sch 0cccc08adf35a06dbf704eb5c35966bc1c3de0ce39d3014efd7ca751253ce79f tests/test_server_release.py a7ae1b4a64bfcbcf2b7ff795b30ba91db24ee2ad7544073dca2bf43d021cd266 tests/test_smoke_dependencies.py a821fbe5096b2fa5b781423e66673b8d5659b60fc37c5c10470afa2a818659c8 tests/test_source_browser.py -686b925646d52dcf6d9574b9b451af499a402ee2793a99f1154d4984f8458183 tests/test_source_libraries.py +e1e9b81ba3fd3b08c7d07fcf09dc2393493434dbd766a747d345ec3c16d56b5b tests/test_source_libraries.py 2624fb6ca01f32d93499d7d771144dcaa2850e5f77b2ac3b7b566891b5e9d283 tests/test_synology_photos.py ca98469f6a6b8b49c36a00657ea1a81775c3496e1fbdd5e38eb4d5298b5936b4 tests/test_synology_photos_api.py a83390e9f580bc27a2928eb6f94d7377307ec46ab45ef71bfea1e5378cb7adb9 tests/test_thumbnail_plans.py diff --git a/docs/NAS.md b/docs/NAS.md index 37f7afc..9767b9d 100644 --- a/docs/NAS.md +++ b/docs/NAS.md @@ -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. diff --git a/docs/SOURCE_LIBRARIES.md b/docs/SOURCE_LIBRARIES.md index ec2054d..f4a6893 100644 --- a/docs/SOURCE_LIBRARIES.md +++ b/docs/SOURCE_LIBRARIES.md @@ -25,7 +25,7 @@ 每份成品关联源画面、方案 ID 和生成时的完整参数快照。相同源画面和相同参数复用已有版本;修改方案后重新生成产生新版本,不覆盖历史图片。任务持久保存选图和方案快照,进程中断后继续处理该画面,不重新随机选图或重复提交同一终端输出。 -相框通过现有上传队列和设备确认流程传输;“已生成”“待推送”“等待设备确认”“已确认显示”分别记录。来源任务完成代表生成和提交完成,不代表所有终端已显示;具体结果看终端图库的独立操作。手表当前只生成 410×502 成品,传输尚未接入。某个方案生成失败不撤销其他输出,源画面保留,可以单独重新生成或重新推送失败目标。 +相框通过现有上传队列和设备确认流程传输;“已生成”“待推送”“等待设备确认”“已确认显示”分别记录。来源任务完成代表生成和提交完成,不代表所有终端已显示;具体结果看终端图库的独立操作。终端当前画面已同步确认且关联到该成品时,推送按钮显示“已在终端显示”并禁用;仅有历史推送成功记录不足以认定当前仍在显示,离线或同步状态未知时也不作该判断。手表当前只生成 410×502 成品,传输尚未接入。某个方案生成失败不撤销其他输出,源画面保留,可以单独重新生成或重新推送失败目标。 在来源图库手动点击“随机选入并按绑定生成”只生成,不自动推送。终端图库的相框推送需要明确点击并确认,受全局设备写入开关约束。更换终端绑定后,尚未执行的旧版本推送会在设备写入前重新校验,不符合当前绑定时停止。 diff --git a/docs/WATCH-THUMBNAIL.md b/docs/WATCH-THUMBNAIL.md deleted file mode 100644 index cb3fcb7..0000000 --- a/docs/WATCH-THUMBNAIL.md +++ /dev/null @@ -1,23 +0,0 @@ -# 小图方案(兼容入口) - -主界面已采用[来源图库、生成方案与终端](SOURCE_LIBRARIES.md)。本文描述保留的旧版小图 API 与历史入口;新方案应在对应来源图库中建立,终端单选来源及其方案。 - -平台侧边栏的“小图方案”提供方案新增、查看、修改与删除。每个方案可保存名称、输出宽高(16–2048 像素)、JPEG 质量(40–95)、适配方式及水平/垂直裁切位置(0–1)。默认 410×502、质量 85、居中等比裁切铺满,不拉伸、不补黑边。也可主动选择完整画面,此时必要处补白;裁切位置仅用于铺满模式。 - -选择照片数据源及源图片后,可以使用当前参数预览或“生成并保存到图库”。预览不保存文件,正式生成沿用同一渲染器,结果在本地图库按既有方式查看、下载与删除。修改参数后须重新预览;保存方案与保存生成图片是独立操作。 - -自动化任务提供“生成小图”勾选,默认关闭。勾选后必须单选一个已保存方案,仅支持照片数据源任务。画框上传成功后,按所选方案生成小图;计划执行时使用该方案最新保存的参数。正在被任务或待处理操作引用的方案不能删除,请先取消任务勾选或更换方案。已有任务默认保持不生成小图;手动照片上传沿用原有默认小图行为。 - -小图始终使用添加画框说明前的源画面,不叠加日期、文字或边框,不修改源文件。Synology Photos 当前只能提供源预览,不称为原图。没有可验证源画面的模板和旧图库成品不生成。 - -成功操作结果的 `wrist_thumbnail` 包含鉴权资源 URL、方案 ID 及参数、尺寸、字节数、SHA-256、生成时间、服务器配置时区及当地日期。`app_config` 的 `wrist-thumbnail:` 保存该设备最后一次成功生成的记录;跨日不将旧图冒充当天图片。资源沿用 `/api/v1/assets/{asset_id}` 的鉴权规则。 - -方案接口为 `/api/v1/thumbnail-plans` 和 `/{plan_id}`;预览及生成分别使用 `/api/v1/thumbnails/preview` 与 `/generate`。读取和预览需要 `images:read`,写入需要 `admin`。参数由服务端校验,任务提交也校验方案存在性与来源类型。 - -上传失败不更新小图。辅助小图生成失败保留已有记录并记录日志,不重放已成功的画框上传。最新小图受现有本地清理策略保护,较早小图沿用 cache 清理策略。本阶段未接入 BLE、手表端下载或实机显示验证。 - -## 手表小图图库 - -图库新增“手表 · 小图图库”标签,顶部设备选择器也提供“手表 · 410×502”,选择后进入该图库。手表是独立逻辑设备,传输状态明确为尚未接入,不会把小图发送到现有画框。图库支持分页、预览、下载、删除,并展示尺寸、生成方案和时间。“小图方案”页生成后可直接跳转查看。 - -手动生成与任务生成的小图都按专用元数据归入该图库,从普通本地图库中分离;图像资源继续使用原有鉴权和删除接口。删除最新小图时同时清除对应的最新记录,不影响源照片。历史自动小图按操作回执或最新记录恢复分类,不会根据文件名、尺寸猜测普通图片。旧版手动生成的小图缺少可靠分类信息,仍在原本地图库。 diff --git a/docs/openapi.json b/docs/openapi.json index a1cac47..f5a5b83 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -19133,6 +19133,506 @@ } } } + }, + "/api/v1/source-libraries/{source_id}/browse": { + "get": { + "summary": "Browse", + "operationId": "browse_api_v1_source_libraries__source_id__browse_get", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Source Id" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 10000000, + "minimum": 0, + "default": 0, + "title": "Offset" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 60, + "minimum": 1, + "default": 36, + "title": "Limit" + } + }, + { + "name": "filename", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 200, + "default": "", + "title": "Filename" + } + }, + { + "name": "folder", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 500, + "default": "", + "title": "Folder" + } + }, + { + "name": "min_bytes", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 1000000000000, + "minimum": 0, + "default": 0, + "title": "Min Bytes" + } + }, + { + "name": "max_bytes", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "maximum": 1000000000000, + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "Max Bytes" + } + }, + { + "name": "min_width", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100000, + "minimum": 0, + "default": 0, + "title": "Min Width" + } + }, + { + "name": "max_width", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "maximum": 100000, + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "Max Width" + } + }, + { + "name": "min_height", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100000, + "minimum": 0, + "default": 0, + "title": "Min Height" + } + }, + { + "name": "max_height", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "maximum": 100000, + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "Max Height" + } + }, + { + "name": "date_from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Date From" + } + }, + { + "name": "date_to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Date To" + } + }, + { + "name": "date_field", + "in": "query", + "required": false, + "schema": { + "enum": [ + "time", + "indexed_time" + ], + "type": "string", + "default": "indexed_time", + "title": "Date Field" + } + }, + { + "name": "person_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ], + "title": "Person Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/source-libraries/{source_id}/browse/{item_id}/preview": { + "get": { + "summary": "Browse Preview", + "operationId": "browse_preview_api_v1_source_libraries__source_id__browse__item_id__preview_get", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Source Id" + } + }, + { + "name": "item_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Item Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } } }, "components": { diff --git a/pigway_frame_flow/api/libraries.py b/pigway_frame_flow/api/libraries.py index 932673b..2ba93e1 100644 --- a/pigway_frame_flow/api/libraries.py +++ b/pigway_frame_flow/api/libraries.py @@ -256,7 +256,10 @@ def install_libraries(app, service): offset: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), ): - if not any(t["id"] == terminal_id for t in libraries.terminals()): + 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: @@ -269,6 +272,17 @@ def install_libraries(app, service): + " 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 = [] for row in rows: version = next( @@ -292,6 +306,7 @@ def install_libraries(app, service): { **version, "operation": operation, + "current": row["asset_id"] == current_asset, "delivery_status": operation["status"] if operation else "GENERATED", diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py index 269b6ad..22ec9cc 100644 --- a/pigway_frame_flow/display/service.py +++ b/pigway_frame_flow/display/service.py @@ -513,8 +513,9 @@ class DisplayService: "cache" if staging else "images", ) - def current_content(self, device_id): - device = self.registry.get(device_id).device() + 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 row = self.db.one( diff --git a/pigway_frame_flow/providers/synology_photos.py b/pigway_frame_flow/providers/synology_photos.py index 3fb0ee7..95d68a1 100644 --- a/pigway_frame_flow/providers/synology_photos.py +++ b/pigway_frame_flow/providers/synology_photos.py @@ -361,7 +361,7 @@ class SynologyPhotos: 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) diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js index bbc2c16..b87fe74 100644 --- a/pigway_frame_flow/web/app.js +++ b/pigway_frame_flow/web/app.js @@ -47,7 +47,6 @@ function operationResultHTML(o){ if(o.error)parts.push(`
${esc(operationErrorMessage(o.error))}
`); 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).filter(([id])=>id!=='source-browser').map(([id,icon])=>``).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(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=>``).join('')+``;revealCurrentNavigation();} function imageLoading(img){ diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js index a2181be..c4b9403 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -180,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;} diff --git a/pigway_frame_flow/web/libraries.js b/pigway_frame_flow/web/libraries.js index ac3a03a..5cf7a1d 100644 --- a/pigway_frame_flow/web/libraries.js +++ b/pigway_frame_flow/web/libraries.js @@ -51,7 +51,7 @@ async function sourceLibraryPage(epoch,sources,terminals){ 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();});}); - hydrateImages(); + disabledMarkHints(panel);hydrateImages(); } async function generationPlanEditor(sourceId,plan,pictures){ @@ -73,7 +73,7 @@ async function terminalLibraryPage(epoch,sources,terminals){ 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=`

${esc(terminalLabel(terminal))}

${tr('来源图库:','Source library: ')}${esc(source?.name||tr('未绑定','Not bound'))} · ${tr('生成方案:','Generation plan: ')}${esc(plan?.name||tr('未选择','Not selected'))}

${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.')}

${terminal.binding?``:''}
${page.items.length?'':`
${tr('尚无对应成品。绑定来源与方案后,在来源图库选入照片并生成。','No matching versions. Bind a source and plan, then import and generate in the source library.')}
`}
${tr('共','Total')} ${page.total} ${tr('个成品版本','generated versions')}
`; + $('#library-content').innerHTML=`

${esc(terminalLabel(terminal))}

${tr('来源图库:','Source library: ')}${esc(source?.name||tr('未绑定','Not bound'))} · ${tr('生成方案:','Generation plan: ')}${esc(plan?.name||tr('未选择','Not selected'))}

${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.')}

${terminal.binding?``:''}
${page.items.length?'':`
${tr('尚无对应成品。绑定来源与方案后,在来源图库选入照片并生成。','No matching versions. Bind a source and plan, then import and generate in the source library.')}
`}
${tr('共','Total')} ${page.total} ${tr('个成品版本','generated versions')}
`; 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');}); @@ -82,7 +82,7 @@ async function terminalLibraryPage(epoch,sources,terminals){ 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];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(); + 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){ diff --git a/scripts/ui-dashboard-preview.cjs b/scripts/ui-dashboard-preview.cjs index 39f89af..4261120 100644 --- a/scripts/ui-dashboard-preview.cjs +++ b/scripts/ui-dashboard-preview.cjs @@ -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:''.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:''.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)}); diff --git a/scripts/ui-image-loading.cjs b/scripts/ui-image-loading.cjs index c54e011..f7b9900 100644 --- a/scripts/ui-image-loading.cjs +++ b/scripts/ui-image-loading.cjs @@ -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:''});}); 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++; } diff --git a/scripts/ui-libraries.cjs b/scripts/ui-libraries.cjs index 2bf8bb7..e484a6d 100644 --- a/scripts/ui-libraries.cjs +++ b/scripts/ui-libraries.cjs @@ -10,16 +10,18 @@ const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright') 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; + const sourceId=sources[0].id,planId='a'.repeat(32);let bound=true,deletions=0,current=true; await page.route('**/api/v1/library-terminals',r=>r.fulfill({json:[{id:'living-room',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:[{id:planId,name:'Fixture frame',source_id:sourceId,width:1600,height:1200}]})); - await page.route('**/api/v1/library-terminals/*/images?*',r=>r.fulfill({json:{total:0,items:[]}})); + await page.route('**/api/v1/library-terminals/*/images?*',r=>r.fulfill({json:{total:1,items:[{asset_id:'fixture-version',name:'Fixture.jpg',url:'/ui/logo-symbol.svg',created_at:'2026-10-23',plan:{width:1600,height:1200,name:'Fixture plan'},delivery_status:'COMPLETED',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-page="terminal-gallery"]').click();await page.locator('#library-unbind').waitFor(); + 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();} diff --git a/scripts/ui-photo-sources.cjs b/scripts/ui-photo-sources.cjs deleted file mode 100644 index ef237bf..0000000 --- a/scripts/ui-photo-sources.cjs +++ /dev/null @@ -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)}); diff --git a/scripts/ui-smoke.cjs b/scripts/ui-smoke.cjs index 277189c..6a1bce0 100644 --- a/scripts/ui-smoke.cjs +++ b/scripts/ui-smoke.cjs @@ -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++; diff --git a/tests/test_source_libraries.py b/tests/test_source_libraries.py index 1b1f36a..6b02f57 100644 --- a/tests/test_source_libraries.py +++ b/tests/test_source_libraries.py @@ -179,6 +179,7 @@ def test_pipeline_selects_once_for_frame_and_watch_and_shared_plan( 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 ) @@ -196,6 +197,7 @@ def test_pipeline_selects_once_for_frame_and_watch_and_shared_plan( 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 @@ -402,6 +404,7 @@ def test_successful_scheduled_frame_delivery_retains_job_provenance( 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" @@ -647,3 +650,45 @@ def test_single_source_migration_preserves_timing_and_generates_without_device_w 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