diff --git a/SHA256SUMS b/SHA256SUMS index 312c6a6..76b52fc 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -32,22 +32,22 @@ b48f3ab6f28404bb34c188067a832384f4ba290e56912461ef3565528aa157d8 docs/PUBLICATI 5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md -00602929b1a4a2ab5f0e1b5746e2bede71e5568e2c423e11bf44ec9397c8679f docs/WATCH-THUMBNAIL.md +36695835826ce75d416353938eae1c46224f3a34aa57b80b2694443604c0c1a0 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 -d81f11dda188117b5dbf84983ca54309f6d09cd81d9a342c446bb46b28241e8e docs/openapi.json +f0ffb49c9832dad780bb9e7917690645076e02f2f89b7aa348bab3a5d792b284 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 -5df5fa4e4baeaac6a68e5007cf1097131d93ec9659f15e65c239df099eddc8df pigway_frame_flow/api/app.py +2b68a29bc4927700c49de0693d5647134671494a3d54af76df6c0e8f06a9e377 pigway_frame_flow/api/app.py 1efe9b1fc774a97e5be72d81199b1b65b25c258956fdf166aebff732cc820d7c pigway_frame_flow/api/history.py -512fbf679913d29ad8bfbed496faa9a29d0e2385501a81c0ee4a54659b8f270f pigway_frame_flow/api/thumbnails.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 @@ -58,9 +58,10 @@ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_f 5e7e272e671a6d01d9b677416c77967ca488b4e2aa559f4211cf0de4ba1e88bc pigway_frame_flow/display/recovery.py e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py a00adacca6b5bbc18c4d15dec6ff3c7bba018f8f8575c69205526228032c39b1 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 -9de31405afb22dd5e26ec0cfe023a33c4d5c65b8326c26a8df3378764056023a pigway_frame_flow/display/wrist_thumbnail.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 @@ -126,7 +127,7 @@ 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 -62e898e118823965f1ab90e8386a604512a028b1b9e8bf41a6e82062ac1e99e0 pigway_frame_flow/web/app.js +4c97eed75273e9b7579c023dc45177c794fbc4dfcce4fd8308c0849fe00730f6 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 @@ -139,7 +140,7 @@ f756a8a103cf06abdbd7c2ed6e50da49b0de166b1d7bb98a9b798bc892d07287 pigway_frame_f cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE 050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js 1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css -af2d71e493dab0ecc2c0ebf93abde752344a1e54f63dc7b3908bee534b507d5a pigway_frame_flow/web/thumbnails.js +8bfe4c25024860cda7b83d385be9197a1b78ac4fd29ff27954f1f52d1097e346 pigway_frame_flow/web/thumbnails.js 5f392f94636d261ae2982815763b794ae9595a1b9226946b792105634ec3f559 pyproject.toml d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock 008af5f4adf616c86e805febc016faad60be0008abf5dddad6e955f1d03606cf scripts/build-verify.sh @@ -184,11 +185,11 @@ d390c1c9a76ddc167a17bfd7afb0bd1a7169809c8a0b30a26a5be4d18c3032de scripts/ui-pro e474740bdb7dbe0617f7c6b170ada918e51f5eed8edbb9c2c47c4590a5dc2359 scripts/verify-photos-api-container.py b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify-photos-api.py d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py -5108885a53feb7119c9b1933432744ef794b0146febef9a33f021e7655198aaa tests/contract.json +c583ae686b2988a31067d9a3af348843a93f04b644e8b01ef6203fd752595383 tests/contract.json cf57d406e1743df4697d5f8341ce3470a963880f7a7d17a5e375eafff625fd65 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 @@ -232,7 +233,7 @@ ab2cd30163453fe571b55c30cb26dc047a104a536ec01d2c64aa2bc91996ba11 tests/test_sch a7ae1b4a64bfcbcf2b7ff795b30ba91db24ee2ad7544073dca2bf43d021cd266 tests/test_smoke_dependencies.py 2624fb6ca01f32d93499d7d771144dcaa2850e5f77b2ac3b7b566891b5e9d283 tests/test_synology_photos.py ca98469f6a6b8b49c36a00657ea1a81775c3496e1fbdd5e38eb4d5298b5936b4 tests/test_synology_photos_api.py -e623f7c5796586e4ad47ebd38a49f8d03c08d63a46c539fcb3bd16b2182ac0b4 tests/test_thumbnail_plans.py +a83390e9f580bc27a2928eb6f94d7377307ec46ab45ef71bfea1e5378cb7adb9 tests/test_thumbnail_plans.py 33f05bc6279cf3573493335ae5bb1c91130754b927b52aa5f50e47f2d951d2ae tests/test_upload_reconciliation.py bdc3142f9c2291a91a5091b4b98b1addf9569cfb141810024ce6f85309704b9b tests/test_upload_retries.py 8ec55ce5706d791a155f13445fe7e0828278e8ee1fa1b527591fdf24cc5b2e86 tests/test_weather.py diff --git a/docs/WATCH-THUMBNAIL.md b/docs/WATCH-THUMBNAIL.md index 4a058a9..8d688bb 100644 --- a/docs/WATCH-THUMBNAIL.md +++ b/docs/WATCH-THUMBNAIL.md @@ -13,3 +13,9 @@ 方案接口为 `/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 343cd1b..0ee9375 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -15844,6 +15844,299 @@ } ] } + }, + "/api/v1/thumbnail-devices": { + "get": { + "summary": "Devices", + "operationId": "devices_api_v1_thumbnail_devices_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "413": { + "description": "Content Too Large", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "502": { + "description": "Bad Gateway", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ] + } + }, + "/api/v1/thumbnails": { + "get": { + "summary": "Gallery", + "operationId": "gallery_api_v1_thumbnails_get", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0, + "title": "Offset" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "default": 50, + "title": "Limit" + } + } + ], + "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/app.py b/pigway_frame_flow/api/app.py index 23d5c51..70405e2 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -601,7 +601,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!='cache' " + "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 ?", @@ -642,6 +643,16 @@ def create_app(settings=None, start_worker=True): 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,)) + 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 app_config WHERE key=? AND payload=?", + (row["key"], row["payload"]), + ) return Response(status_code=204) @app.put( @@ -1095,6 +1106,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", diff --git a/pigway_frame_flow/api/thumbnails.py b/pigway_frame_flow/api/thumbnails.py index 14ab82e..7b610ac 100644 --- a/pigway_frame_flow/api/thumbnails.py +++ b/pigway_frame_flow/api/thumbnails.py @@ -1,7 +1,7 @@ import json from uuid import uuid4 -from fastapi import Depends +from fastapi import Depends, Query from fastapi.responses import Response from pydantic import Field @@ -24,6 +24,50 @@ class ThumbnailRequest(StrictModel): 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 [ @@ -113,6 +157,18 @@ def install_thumbnails(app, service): @app.post("/api/v1/thumbnails/generate", dependencies=[Depends(require("admin"))]) def generate(request: ThumbnailRequest): - return service.assets.put( + 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 diff --git a/pigway_frame_flow/display/thumbnail_gallery.py b/pigway_frame_flow/display/thumbnail_gallery.py new file mode 100644 index 0000000..2049e16 --- /dev/null +++ b/pigway_frame_flow/display/thumbnail_gallery.py @@ -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) diff --git a/pigway_frame_flow/display/wrist_thumbnail.py b/pigway_frame_flow/display/wrist_thumbnail.py index b3faa96..706bf2b 100644 --- a/pigway_frame_flow/display/wrist_thumbnail.py +++ b/pigway_frame_flow/display/wrist_thumbnail.py @@ -54,6 +54,9 @@ def publish(service, device_id, asset_id, data=None, plan_id=None): 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)), diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js index 9598950..ad0db6e 100644 --- a/pigway_frame_flow/web/app.js +++ b/pigway_frame_flow/web/app.js @@ -48,16 +48,16 @@ function operationResultHTML(o){ } 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])=>``).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=>``).join('');revealCurrentNavigation();} +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])=>``).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);const watchSelected=state.page==='gallery'&&state.tab==='watch';$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>``).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']); +const galleryDestinationAdapters=new Set(['kokonna','local-assets','watch-thumbnails']); 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 galleryDestinationName(destination){if(destination.id==='watch')return tr('手表 · 小图图库','Watch · Thumbnail gallery');return `${destination.name} ${t(destination.storage==='local'?'local':'cloudGallery')}`;} function imageLoading(img){ if(img.parentElement?.classList.contains('image-loading'))return img.parentElement; const box=document.createElement('div');box.className='image-loading'; @@ -134,9 +134,10 @@ const views={ 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?`
${t('lowBattery')}
`:''}
● ${t(d.status).toUpperCase()}

${esc(d.name)}

${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}

${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}

${t('refreshInterval')}

${[['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])=>`
${t(l)}
${esc(v)}
`).join('')}

${t('device')}

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

${t('cloudRotation')}

${detail('playbackMode',s.mode?t(s.mode):'—')}${detail('switchInterval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}

${t('rotationNotice')}

${t('playlists')} →
`;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'; + if(state.tab==='watch')return watchGalleryPage(epoch,destinations); 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()+`
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}${pending?t('galleryListLoading'):''}
${state.tab==='local'?`
${t('uploadNotice')}
`:''}${``}${!pending&&!items.length?`
${t('noImages')}${t('emptyHint')}
`:''}`;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(`

${t('displayByName')}

`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});enhanceGallery();guardDeviceButtons();hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];await openImageViewer(x.url,x.name);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(state.tab==='local'?t('localDeleteConfirm'):x.current?t('deleteCurrent'):t('cloudDeleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});}; + const render=(items,pending=false)=>{if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}${pending?t('galleryListLoading'):''}
${state.tab==='local'?`
${t('uploadNotice')}
`:''}${``}${!pending&&!items.length?`
${t('noImages')}${t('emptyHint')}
`:''}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('watch-tab',()=>{state.tab='watch';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`

${t('displayByName')}

`);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):'')); @@ -156,7 +157,7 @@ const views={ }; 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,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(`

${t('playlists')}

`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});await playlistImagePicker(p);} -$('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#device').onchange=()=>{state.device=$('#device').value;navigate();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme); +$('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#device').onchange=()=>{if($('#device').value==='@watch'){state.tab='watch';state.watchOffset=0;return navigate('gallery');}state.device=$('#device').value;if(state.page==='gallery'&&state.tab==='watch')state.tab='cloud';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=`PIGWay Frame Flow · ${t('footer')}v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}`).catch(()=>{}); setInterval(()=>{if(state.token&&state.page==='dashboard'&&!$('#modal').open)navigate();},60000); diff --git a/pigway_frame_flow/web/thumbnails.js b/pigway_frame_flow/web/thumbnails.js index 86c2db5..6905ab3 100644 --- a/pigway_frame_flow/web/thumbnails.js +++ b/pigway_frame_flow/web/thumbnails.js @@ -5,11 +5,12 @@ Object.assign(MESSAGES.en, {thumbnails:'Thumbnail plans',thumbnailPlanInUse:'Thi views.thumbnails=async function(epoch){ const [plans,sources]=await Promise.all([api('/api/v1/thumbnail-plans'),api('/api/v1/photo-sources')]); if(epoch!==state.epoch)return; - $('#main').innerHTML=`
${[['width',tr('宽度(像素)','Width (px)'),410,16,2048,1],['height',tr('高度(像素)','Height (px)'),502,16,2048,1],['center_x',tr('水平裁切位置(0 左 / 1 右)','Crop X (0 left / 1 right)'),0.5,0,1,0.01],['center_y',tr('垂直裁切位置(0 上 / 1 下)','Crop Y (0 top / 1 bottom)'),0.5,0,1,0.01],['quality',tr('JPEG 质量','JPEG quality'),85,40,95,1]].map(([key,label,value,min,max,step])=>`
`).join('')}

${tr('不拉伸、不叠加文字;裁切位置仅在铺满时生效。Photos 使用可取得的源预览。','No stretching or text. Crop positions apply to fill mode. Photos uses available source previews.')}

${tr('预览与生成','Preview and generate')}

`; + $('#main').innerHTML=`
${[['width',tr('宽度(像素)','Width (px)'),410,16,2048,1],['height',tr('高度(像素)','Height (px)'),502,16,2048,1],['center_x',tr('水平裁切位置(0 左 / 1 右)','Crop X (0 left / 1 right)'),0.5,0,1,0.01],['center_y',tr('垂直裁切位置(0 上 / 1 下)','Crop Y (0 top / 1 bottom)'),0.5,0,1,0.01],['quality',tr('JPEG 质量','JPEG quality'),85,40,95,1]].map(([key,label,value,min,max,step])=>`
`).join('')}

${tr('不拉伸、不叠加文字;裁切位置仅在铺满时生效。Photos 使用可取得的源预览。','No stretching or text. Crop positions apply to fill mode. Photos uses available source previews.')}

${tr('预览与生成','Preview and generate')}

`; const defaults={name:'',width:410,height:502,center_x:0.5,center_y:0.5,quality:85,fit:'cover'}; const fill=p=>{for(const key of Object.keys(defaults))$('#thumb-'+key).value=p[key];}; const read=()=>{const p={};for(const key of Object.keys(defaults))p[key]=['name','fit'].includes(key)?$('#thumb-'+key).value:Number($('#thumb-'+key).value);return p;}; fill(defaults);$('#thumb-delete').disabled=true; + bind('thumb-gallery',()=>{state.tab='watch';state.watchOffset=0;return navigate('gallery');}); $('#thumb-form').oninput=()=>{$('#thumb-image').hidden=true;$('#thumb-result').textContent=tr('参数已修改,请重新预览','Parameters changed; preview again');}; $('#thumb-plan').onchange=()=>{fill(plans.find(p=>p.id===$('#thumb-plan').value)||defaults);$('#thumb-delete').disabled=!$('#thumb-plan').value;$('#thumb-image').hidden=true;}; $('#thumb-form').onsubmit=busy(async e=>{e.preventDefault();const id=$('#thumb-plan').value;await api('/api/v1/thumbnail-plans'+(id?'/'+id:''),{method:id?'PUT':'POST',body:read()});toast(t('save'));await navigate();}); @@ -20,7 +21,7 @@ views.thumbnails=async function(epoch){ bind('thumb-prev',async()=>{offset=Math.max(0,offset-50);await images();});bind('thumb-next',async()=>{offset+=50;await images();}); function payload(){if(!$('#thumb-form').reportValidity())throw new Error(tr('请补全方案参数','Complete the plan parameters'));if(!$('#thumb-item').value)throw new Error(tr('请选择源图片','Choose a source image'));return {source_id:$('#thumb-source').value,item_id:$('#thumb-item').value,plan:read()};} bind('thumb-preview',async()=>{const blob=await api('/api/v1/thumbnails/preview',{method:'POST',body:payload(),binary:true});if(epoch!==state.epoch)return;const url=URL.createObjectURL(blob);state.blobs.push(url);$('#thumb-image').src=url;$('#thumb-image').hidden=false;$('#thumb-result').textContent=tr('预览未保存','Preview is not saved');}); - bind('thumb-generate',async()=>{const asset=await api('/api/v1/thumbnails/generate',{method:'POST',body:payload()});if(epoch!==state.epoch)return;$('#thumb-image').hidden=false;await blobImage($('#thumb-image'),asset.url);$('#thumb-result').textContent=tr('已保存到本地图库:','Saved to local gallery: ')+asset.name;}); + bind('thumb-generate',async()=>{const asset=await api('/api/v1/thumbnails/generate',{method:'POST',body:payload()});if(epoch!==state.epoch)return;$('#thumb-image').hidden=false;await blobImage($('#thumb-image'),asset.url);$('#thumb-result').textContent=tr('已保存到手表小图图库:','Saved to watch thumbnail gallery: ')+asset.name;}); }; async function thumbnailJobFields(job){ @@ -30,3 +31,18 @@ async function thumbnailJobFields(job){ 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(); } + +async function watchGalleryPage(epoch,destinations){ + const offset=state.watchOffset||0; + const [page,devices]=await Promise.all([api('/api/v1/thumbnails?limit=50&offset='+offset),api('/api/v1/thumbnail-devices')]); + if(epoch!==state.epoch)return; + const watch=devices.find(d=>d.id==='watch'); + $('#main').innerHTML=`

${tr('手表','Watch')} · ${watch.width} × ${watch.height}

${tr('独立小图设备 · 图片已保存在服务器;设备传输尚未接入。','Separate thumbnail device · Images are saved on the server; device transfer is not connected.')}

${tr('共','Total')} ${page.total} ${tr('张小图','thumbnails')}
${page.items.length?'':`
${tr('还没有小图','No thumbnails yet')}

${tr('在“小图方案”中生成,或为照片任务勾选生成小图。旧版手动生成的小图仍在原本地图库。','Generate one in Thumbnail plans or enable thumbnails on a photo task. Older manually generated images remain in the original local gallery.')}

`}`; + document.querySelectorAll('[data-watch-tab]').forEach(b=>b.onclick=()=>{state.tab=b.dataset.watchTab;return navigate('gallery');}); + bind('watch-refresh',()=>navigate());bind('watch-plans',()=>navigate('thumbnails')); + bind('watch-prev',()=>{state.watchOffset=Math.max(0,offset-50);return navigate();});bind('watch-next',()=>{state.watchOffset=offset+50;return navigate();}); + document.querySelectorAll('[data-watch-preview]').forEach(b=>b.onclick=busy(()=>{const x=page.items[b.dataset.watchPreview];return openImageViewer(x.url,x.name);})); + document.querySelectorAll('[data-watch-download]').forEach(b=>b.onclick=busy(()=>{const x=page.items[b.dataset.watchDownload];return downloadImage(x.url,x.name);})); + document.querySelectorAll('[data-watch-delete]').forEach(b=>b.onclick=()=>{const x=page.items[b.dataset.watchDelete];return confirmDialog(tr('删除这张手表小图?不会删除源照片。','Delete this watch thumbnail? The source photo is kept.'),async()=>{await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});state.watchOffset=Math.max(0,offset-(page.items.length===1?50:0));await navigate();});}); + hydrateImages(); +} diff --git a/tests/contract.json b/tests/contract.json index e1d0d10..b9e2df9 100644 --- a/tests/contract.json +++ b/tests/contract.json @@ -1616,5 +1616,37 @@ "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" + ] } } diff --git a/tests/test_api.py b/tests/test_api.py index aba912b..1e7bbe6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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()) diff --git a/tests/test_thumbnail_plans.py b/tests/test_thumbnail_plans.py index c76d358..62dc40b 100644 --- a/tests/test_thumbnail_plans.py +++ b/tests/test_thumbnail_plans.py @@ -144,3 +144,69 @@ def test_upload_uses_selected_plan_or_skips(service, client, source, enabled): 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 + )