diff --git a/docs/PHOTO_SOURCES.md b/docs/PHOTO_SOURCES.md index 1d0e685..171e242 100644 --- a/docs/PHOTO_SOURCES.md +++ b/docs/PHOTO_SOURCES.md @@ -162,24 +162,43 @@ skipping only invalid or unavailable images. Explicit selections never fall back Operation `result.photo_attempts` records source-relative item IDs, names, status and stable error codes, including failures. This does not retry upload or switch. -### Starred cloud photos and seven-day retention +### Local favorites, origins and minimal cloud storage -Automation `cleanup_unstarred` is opt-in and defaults to false. It is valid only -for random photo-source jobs. New uploads retain their confirmed cloud image ID -and an expiry seven days after upload. Existing images are never backfilled. -The cloud gallery's star button stores a Frame Flow star in SQLite; it does not -modify Synology Photos favorites or synchronize with KoKonna app favorites. -Starred images stay; unstarred overdue images are eligible for deletion from the -KoKonna cloud gallery. NAS originals and local Frame Flow assets remain. +Stars are now stored on local assets in SQLite. The local library filters by +`origin` (`manual`, `task`, `external`, `template`, `unknown`) and `job_id`. +Task ID and name are recorded when an asset is generated; deleted tasks do not +rewrite that historical label. Older assets without reliable provenance remain +unknown. Multi-select supports star, unstar and deletion with per-item results. +Referenced assets are rejected rather than breaking pending operations/jobs. -The scheduler checks hourly, queues at most 20 deletions per cycle, and rechecks -stars and protection when executing. Current, previous/restore, override, -playlist and explicit job image references are protected. Offline devices and -write-disabled services skip cleanup. A failed or unknown deletion is retained -for investigation and is not retried automatically. Unstarring an expired image -makes it eligible at the next check. Removing a job does not cancel retention -already assigned to its uploads; star these images to keep them. +Every new task upload is tracked by cloud ID, local asset ID and task ID. Each +task keeps its latest confirmed cloud image. Uploaded local favorites are kept +as cloud copies (one confirmed copy per local asset). New images must reach +COMPLETED with device `synced=true` before an older image can be removed. A +failed/unknown replacement leaves the previous image intact. Current, active +restore/override and playlist/explicit job references remain protected. Cleanup +checks at most once per minute and uses the existing queue, history, write guard +and UNKNOWN barrier. Deletion is never automatically retried after a failure or +unknown response. Superseded task-owned previous-image references are released +only once a replacement is confirmed; unrelated restore references are kept. -API: `PUT /api/v1/devices/{device_id}/images/{image_id}/star` with -`{"starred": true}`; scope `images:write`. Gallery responses add `starred` and -`expires_at`. All cleanup uses the existing operation queue, history and driver. +Starring a local image does not immediately upload it: KoKonna upload changes +the display. The star protects its cloud copy after a normal display upload. +Local originals remain available even if cloud cleanup removes the cloud copy. +NAS source originals are never deleted by these operations. Existing unmanaged +cloud images are not automatically swept; clearing them is an explicit action. +The previous `cleanup_unstarred` seven-day field remains accepted for API +compatibility but is superseded by the minimal-cloud policy in the scheduler. + +Endpoints: +- `PUT /api/v1/assets/{asset_id}/star` with `{"starred": true}`. +- `POST /api/v1/assets/batch` with `asset_ids` (up to 100) and action + `star`, `unstar` or `delete`; per-item completion/failure results. +- `GET /api/v1/assets?origin=task&job_id=...&offset=0&limit=200`. +- Administrator `POST /api/v1/devices/{device_id}/images/clear` with + `{"confirm_current": true}` explicitly clears all listed cloud images, + including current and restore references. It returns queued operation IDs. + This is destructive, does not restore artwork, and stops at unknown writes. + +The legacy cloud star route forwards to the linked local asset when available; +cloud star controls are removed from the UI. App/Synology favorites are separate. diff --git a/docs/openapi.json b/docs/openapi.json index 8036573..0d976dd 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1567,6 +1567,164 @@ } } }, + "/api/v1/devices/{device_id}/images/clear": { + "post": { + "tags": [ + "Images" + ], + "summary": "Clear Cloud", + "operationId": "clear_cloud_api_v1_devices__device_id__images_clear_post", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Device Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRequest" + } + } + } + }, + "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/devices/{device_id}/images/{image_id}/file": { "get": { "tags": [ @@ -2803,6 +2961,51 @@ "default": "all", "title": "Source" } + }, + { + "name": "origin", + "in": "query", + "required": false, + "schema": { + "enum": [ + "all", + "manual", + "task", + "external", + "template", + "unknown" + ], + "type": "string", + "default": "all", + "title": "Origin" + } + }, + { + "name": "job_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Job Id" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0, + "title": "Offset" + } } ], "responses": { @@ -3228,6 +3431,313 @@ } } }, + "/api/v1/assets/{asset_id}/star": { + "put": { + "tags": [ + "Images" + ], + "summary": "Star Asset", + "operationId": "star_asset_api_v1_assets__asset_id__star_put", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Asset Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageStar" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Asset" + } + } + } + }, + "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/assets/batch": { + "post": { + "tags": [ + "Images" + ], + "summary": "Batch Assets", + "operationId": "batch_assets_api_v1_assets_batch_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetBatch" + } + } + }, + "required": true + }, + "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/integrations/synology-photos": { "get": { "tags": [ @@ -9679,6 +10189,45 @@ }, "Asset": { "properties": { + "starred": { + "type": "boolean", + "title": "Starred", + "default": false + }, + "origin": { + "type": "string", + "enum": [ + "manual", + "task", + "external", + "template", + "unknown" + ], + "title": "Origin", + "default": "unknown" + }, + "job_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Job Id" + }, + "job_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Job Name" + }, "source": { "type": "string", "enum": [ @@ -9714,6 +10263,35 @@ ], "title": "Asset" }, + "AssetBatch": { + "properties": { + "asset_ids": { + "items": { + "type": "string" + }, + "type": "array", + "maxItems": 100, + "minItems": 1, + "title": "Asset Ids" + }, + "action": { + "type": "string", + "enum": [ + "delete", + "star", + "unstar" + ], + "title": "Action" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "asset_ids", + "action" + ], + "title": "AssetBatch" + }, "Body_preview_api_v1_preview_post": { "properties": { "file": { @@ -9973,6 +10551,18 @@ ], "title": "CurrentDisplay" }, + "DeleteRequest": { + "properties": { + "confirm_current": { + "type": "boolean", + "title": "Confirm Current", + "default": false + } + }, + "additionalProperties": false, + "type": "object", + "title": "DeleteRequest" + }, "Device": { "properties": { "id": { diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index b9d020a..da9e0ea 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -28,7 +28,9 @@ from pigway_frame_flow.integrations.kokonna_official.connection import ( from pigway_frame_flow.models import ( Accepted, Asset, + AssetBatch, CurrentDisplay, + DeleteRequest, Device, DisplayRequest, ErrorResponse, @@ -311,10 +313,24 @@ def create_app(settings=None, start_worker=True): dependencies=[Depends(require("images:read"))], ) def images(device_id: str): - return [ - image.model_copy(update=service.retention.info(device_id, image.image_id)) - for image in registry.get(device_id).images() - ] + result = [] + for image in registry.get(device_id).images(): + source = db.one( + "SELECT asset_id FROM image_sources WHERE device_id=? AND image_id=?", + (device_id, image.image_id), + ) + meta = ( + db.one( + "SELECT starred FROM asset_metadata WHERE asset_id=?", + (source["asset_id"],), + ) + if source + else None + ) + result.append( + image.model_copy(update={"starred": bool(meta and meta["starred"])}) + ) + return result @app.put( "/api/v1/devices/{device_id}/images/{image_id}/star", @@ -323,7 +339,41 @@ def create_app(settings=None, start_worker=True): ) def star_image(device_id: str, image_id: int, payload: ImageStar): registry.get(device_id) - return service.retention.star(device_id, image_id, payload.starred) + row = db.one( + "SELECT asset_id FROM image_sources WHERE device_id=? AND image_id=?", + (device_id, image_id), + ) + if not row or not row["asset_id"]: + raise FlowError( + E.UNSUPPORTED_CAPABILITY, "Star images in the local library", 409 + ) + return star_asset(row["asset_id"], payload) + + @app.post( + "/api/v1/devices/{device_id}/images/clear", + tags=["Images"], + dependencies=[Depends(require("admin"))], + ) + def clear_cloud(device_id: str, payload: DeleteRequest, request: Request): + if not payload.confirm_current: + raise FlowError( + E.INVALID_REQUEST, + "Explicit current-image deletion confirmation required", + ) + write_guard(device_id) + ids = [image.image_id for image in registry.get(device_id).images()] + return { + "operations": [ + submit( + request, + "delete", + {"image_id": id, "confirm_current": True, "purge": True}, + device_id, + request.state.request_id + "-" + str(id), + ) + for id in ids + ] + } @app.get( "/api/v1/devices/{device_id}/images/{image_id}/file", @@ -496,7 +546,11 @@ def create_app(settings=None, start_worker=True): settings.sanitize(name), kind="cache", ) - return asset + db.execute( + "INSERT INTO asset_metadata(asset_id,origin) VALUES(?,?)", + (asset["asset_id"], "manual"), + ) + return assets.info(asset["asset_id"]) @app.get( "/api/v1/assets", @@ -507,14 +561,20 @@ def create_app(settings=None, start_worker=True): def list_assets( limit: int = Query(default=100, ge=1, le=200), source: Literal["all", "uploaded", "generated"] = "all", + origin: Literal[ + "all", "manual", "task", "external", "template", "unknown" + ] = "all", + job_id: str | None = None, + offset: int = Query(default=0, ge=0), ): return [ assets.info(r["id"]) for r in db.all( - "SELECT id FROM assets WHERE kind!='cache' AND " + "SELECT assets.id FROM assets LEFT JOIN asset_metadata ON asset_metadata.asset_id=assets.id WHERE kind!='cache' AND " "(?='all' OR (?='uploaded' AND kind='images') OR (?='generated' AND kind='generated')) " - "ORDER BY created_at DESC LIMIT ?", - (source, source, source, limit), + "AND (?='all' OR COALESCE(origin,'unknown')=?) AND (? IS NULL OR job_id=?) " + "ORDER BY created_at DESC LIMIT ? OFFSET ?", + (source, source, source, origin, origin, job_id, job_id, limit, offset), ) ] @@ -544,9 +604,51 @@ def create_app(settings=None, start_worker=True): 409, ) path.unlink(missing_ok=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,)) return Response(status_code=204) + @app.put( + "/api/v1/assets/{asset_id}/star", + response_model=Asset, + tags=["Images"], + dependencies=[Depends(require("images:write"))], + ) + def star_asset(asset_id: str, payload: ImageStar): + with service.lock: + assets.info(asset_id) + db.execute( + "INSERT INTO asset_metadata(asset_id,starred) VALUES(?,?) ON CONFLICT(asset_id) DO UPDATE SET starred=excluded.starred", + (asset_id, int(payload.starred)), + ) + db.execute( + "UPDATE image_retention SET starred=? WHERE (device_id,image_id) IN (SELECT device_id,image_id FROM image_sources WHERE asset_id=?)", + (int(payload.starred), asset_id), + ) + return assets.info(asset_id) + + @app.post( + "/api/v1/assets/batch", + tags=["Images"], + dependencies=[Depends(require("images:delete"))], + ) + def batch_assets(payload: AssetBatch): + results = [] + with service.lock: + for id in dict.fromkeys(payload.asset_ids): + try: + if payload.action == "delete": + delete_asset(id) + else: + star_asset(id, ImageStar(starred=payload.action == "star")) + results.append({"asset_id": id, "status": "completed"}) + except FlowError as exc: + results.append( + {"asset_id": id, "status": "failed", "code": exc.code} + ) + return {"results": results} + @app.get( "/api/v1/assets/{asset_id}", tags=["Images"], @@ -1031,13 +1133,18 @@ def create_app(settings=None, start_worker=True): ), ) service.validate(payload) - return submit( + accepted = submit( request, "display", payload.model_dump(mode="json"), job.device_id, idempotency_key, ) + db.execute( + "INSERT OR IGNORE INTO job_runs VALUES(?,?,?)", + (job_id, "manual-" + accepted.operation_id, accepted.operation_id), + ) + return accepted @app.post( "/api/v1/jobs/{job_id}/preview", diff --git a/pigway_frame_flow/display/cloud_library.py b/pigway_frame_flow/display/cloud_library.py new file mode 100644 index 0000000..0cda390 --- /dev/null +++ b/pigway_frame_flow/display/cloud_library.py @@ -0,0 +1,132 @@ +"""Keep confirmed task heads and locally starred uploads; never retry deletion.""" + +import json +import time +from datetime import UTC, datetime + + +class CloudLibrary: + def __init__(self, service): + self.service = service + self.db = service.db + self.checked = 0.0 + + def register(self, device_id, image_id, asset_id, operation_id): + meta = self.service.assets.info(asset_id) + self.db.execute( + "INSERT INTO managed_cloud_uploads VALUES(?,?,?,?,?,?,NULL,NULL)", + ( + device_id, + image_id, + asset_id, + meta.get("job_id"), + operation_id, + datetime.now(UTC).isoformat(), + ), + ) + + def confirm(self, operation_id): + self.db.execute( + "UPDATE managed_cloud_uploads SET confirmed_at=? WHERE operation_id=?", + (datetime.now(UTC).isoformat(), operation_id), + ) + self.checked = 0.0 + + def retained(self, row): + meta = self.db.one( + "SELECT starred FROM asset_metadata WHERE asset_id=?", (row["asset_id"],) + ) + if meta and meta["starred"]: + head = self.db.one( + "SELECT image_id FROM managed_cloud_uploads WHERE device_id=? AND asset_id=? AND confirmed_at IS NOT NULL ORDER BY uploaded_at DESC,image_id DESC LIMIT 1", + (row["device_id"], row["asset_id"]), + ) + if not head or head["image_id"] == row["image_id"]: + return True + if row["job_id"]: + head = self.db.one( + "SELECT image_id FROM managed_cloud_uploads WHERE device_id=? AND job_id=? AND confirmed_at IS NOT NULL ORDER BY uploaded_at DESC,image_id DESC LIMIT 1", + (row["device_id"], row["job_id"]), + ) + if head and head["image_id"] == row["image_id"]: + return True + return False + + def eligible(self, device_id, image_id): + row = self.db.one( + "SELECT * FROM managed_cloud_uploads WHERE device_id=? AND image_id=?", + (device_id, image_id), + ) + if not row or self.retained(row): + return False + # A confirmed replacement is mandatory; failed/unknown uploads cannot + # evict the last known usable cloud image. + newer = self.db.one( + "SELECT image_id FROM managed_cloud_uploads WHERE device_id=? AND confirmed_at IS NOT NULL AND uploaded_at>? LIMIT 1", + (device_id, row["uploaded_at"]), + ) + if not newer: + return False + state = self.service.state(device_id) + if state["base_image_id"] == image_id: + return False + if self.db.one( + "SELECT id FROM overrides WHERE device_id=? AND image_id=?", + (device_id, image_id), + ): + return False + for r in self.db.all( + "SELECT payload FROM playlists UNION ALL SELECT payload FROM jobs" + ): + data = json.loads(r["payload"]) + if data.get("device_id") == device_id and ( + image_id in data.get("image_ids", []) + or data.get("source", {}).get("image_id") == image_id + ): + return False + return True + + def sweep(self): + if time.monotonic() - self.checked < 60: + return + self.checked = time.monotonic() + with self.service.lock: + if ( + not self.service.settings.mock + and not self.service.settings.write_enabled + ): + return + for row in self.db.all( + "SELECT * FROM managed_cloud_uploads WHERE cleanup_operation IS NULL ORDER BY uploaded_at" + ): + device_id, image_id = row["device_id"], row["image_id"] + if self.service.blocked(device_id) or not self.eligible( + device_id, image_id + ): + continue + device = self.service.registry.get(device_id).device(refresh=True) + if ( + device.status != "online" + or not device.current_content.synced + or device.current_content.id == image_id + ): + continue + id = self.service.submit( + "delete", + { + "image_id": image_id, + "confirm_current": False, + "cloud_cleanup": True, + }, + device_id, + "cloud-library", + "cloud-cleanup-" + row["operation_id"], + "cloud-cleanup-" + + row["operation_id"] + + "-" + + datetime.now(UTC).strftime("%Y%m%d%H%M"), + ) + self.db.execute( + "UPDATE managed_cloud_uploads SET cleanup_operation=? WHERE device_id=? AND image_id=?", + (id, device_id, image_id), + ) diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py index bed9f0e..f033fdb 100644 --- a/pigway_frame_flow/display/service.py +++ b/pigway_frame_flow/display/service.py @@ -5,6 +5,7 @@ import time from datetime import UTC, datetime, timedelta from uuid import uuid4 +from pigway_frame_flow.display.cloud_library import CloudLibrary from pigway_frame_flow.display.retention import Retention from pigway_frame_flow.drivers.base import DriverRegistry from pigway_frame_flow.errors import ErrorCode as E @@ -38,6 +39,7 @@ class DisplayService: ) self.lock = threading.RLock() self.retention = Retention(self) + self.cloud_library = CloudLibrary(self) self.expiration_checked: dict[str, float] = {} # A killed write cannot be replayed safely; pre-write work can resume. db.execute( @@ -383,6 +385,7 @@ class DisplayService: asset = self.content_asset( TypeAdapter(Content).validate_python(payload), device_id, id ) + self.assets.record_origin(asset["asset_id"], op) if action == "local-upload": asset = self.assets.promote(asset["asset_id"]) self.update( @@ -402,6 +405,26 @@ class DisplayService: state = self.state(device_id) if action == "delete": image_id = payload["image_id"] + if payload.get("cloud_cleanup"): + if ( + not self.cloud_library.eligible(device_id, image_id) + or previous == image_id + ): + self.db.execute( + "UPDATE managed_cloud_uploads SET cleanup_operation=NULL WHERE device_id=? AND image_id=?", + (device_id, image_id), + ) + self.update( + id, "COMPLETED", {"image_id": image_id, "deferred": True} + ) + return + # The confirmed replacement supersedes this task-owned restore + # reference; unrelated artwork and active overrides stay protected. + self.db.execute( + "UPDATE device_state SET previous_image_id=NULL WHERE device_id=? AND previous_image_id=?", + (device_id, image_id), + ) + state = self.state(device_id) if payload.get("retention") and ( not self.retention.eligible(device_id, image_id) or image_id == previous ): @@ -418,12 +441,26 @@ class DisplayService: "SELECT image_id FROM overrides WHERE device_id=?", (device_id,) ) } - if image_id in protected: + if image_id in protected and not payload.get("purge"): raise FlowError(E.CONFLICT, "Image is needed for restoration", 409) if image_id == previous and not payload["confirm_current"]: raise FlowError(E.CONFLICT, "Current image needs confirmation", 409) self._start_write(id) driver.delete(image_id) + self.db.execute( + "DELETE FROM managed_cloud_uploads WHERE device_id=? AND image_id=?", + (device_id, image_id), + ) + if payload.get("purge"): + self.db.execute( + "DELETE FROM overrides WHERE device_id=? AND image_id=?", + (device_id, image_id), + ) + for field in ("previous_image_id", "base_image_id", "managed_image_id"): + self.db.execute( + f"UPDATE device_state SET {field}=NULL WHERE device_id=? AND {field}=?", + (device_id, image_id), + ) for suffix in [".original", "-thumb.jpg"]: ( self.settings.data_dir / "cache" / f"{device_id}-{image_id}{suffix}" @@ -476,6 +513,7 @@ class DisplayService: image_id = matches[0].image_id else: asset = self.content_asset(content, device_id, id) + self.assets.record_origin(asset["asset_id"], op) image_id = None self.db.execute( "UPDATE device_state SET previous_image_id=? WHERE device_id=?", @@ -494,6 +532,7 @@ class DisplayService: if asset: self.update(id, "UPLOADING") image_id = driver.upload(self.assets.path(asset["asset_id"]), asset["name"]) + self.cloud_library.register(device_id, image_id, asset["asset_id"], id) if ( request.options.cleanup_unstarred and isinstance(content, PhotoSourceContent) @@ -561,6 +600,7 @@ class DisplayService: and device.current_content.synced ): self.update(op["id"], "COMPLETED") + self.cloud_library.confirm(op["id"]) self.db.history(op, "COMPLETED") self.db.event("operation.completed", {"operation_id": op["id"]}) elif ( diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py index cd02965..23fc44a 100644 --- a/pigway_frame_flow/models.py +++ b/pigway_frame_flow/models.py @@ -317,6 +317,10 @@ class Readiness(BaseModel): class Asset(BaseModel): + starred: bool = False + origin: Literal["manual", "task", "external", "template", "unknown"] = "unknown" + job_id: str | None = None + job_name: str | None = None source: Literal["uploaded", "generated"] = "uploaded" asset_id: str name: str @@ -439,3 +443,8 @@ class WidgetState(StrictModel): class ImageStar(StrictModel): starred: bool + + +class AssetBatch(StrictModel): + asset_ids: list[str] = Field(min_length=1, max_length=100) + action: Literal["delete", "star", "unstar"] diff --git a/pigway_frame_flow/renderers/images.py b/pigway_frame_flow/renderers/images.py index 4fdb9b9..917d2c0 100644 --- a/pigway_frame_flow/renderers/images.py +++ b/pigway_frame_flow/renderers/images.py @@ -84,6 +84,34 @@ class Assets: ) return self.info(asset_id) + def record_origin(self, asset_id, operation): + job = self.db.one( + "SELECT jobs.id,jobs.payload FROM job_runs JOIN jobs ON jobs.id=job_runs.job_id WHERE operation_id=?", + (operation["id"],), + ) + import json + + payload = json.loads(operation["payload"]) + content = payload.get("content", payload) + origin = ( + "task" + if job + else "template" + if content.get("type") == "template" + else "manual" + if operation["client_id"].startswith("user:") + else "external" + ) + self.db.execute( + "INSERT OR IGNORE INTO asset_metadata(asset_id,origin,job_id,job_name) VALUES(?,?,?,?)", + ( + asset_id, + origin, + job["id"] if job else None, + json.loads(job["payload"])["name"] if job else None, + ), + ) + def promote(self, asset_id): row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,)) if row and row["kind"] == "cache": @@ -96,7 +124,12 @@ class Assets: row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,)) if not row: raise FlowError(E.NOT_FOUND, "Asset not found", 404) + meta = self.db.one("SELECT * FROM asset_metadata WHERE asset_id=?", (asset_id,)) return { + "starred": bool(meta and meta["starred"]), + "origin": meta["origin"] if meta else "unknown", + "job_id": meta["job_id"] if meta else None, + "job_name": meta["job_name"] if meta else None, "asset_id": row["id"], "source": "generated" if row["kind"] == "generated" else "uploaded", "name": row["name"], diff --git a/pigway_frame_flow/scheduler/engine.py b/pigway_frame_flow/scheduler/engine.py index dbd3516..650f834 100644 --- a/pigway_frame_flow/scheduler/engine.py +++ b/pigway_frame_flow/scheduler/engine.py @@ -254,7 +254,7 @@ class Scheduler: try: self.tick() self.service.expire() - self.service.retention.sweep() + self.service.cloud_library.sweep() processed = self.service.process_one() self.service.sync() except Exception: diff --git a/pigway_frame_flow/storage/database.py b/pigway_frame_flow/storage/database.py index be400df..2cc61de 100644 --- a/pigway_frame_flow/storage/database.py +++ b/pigway_frame_flow/storage/database.py @@ -23,6 +23,8 @@ class Database: PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; PRAGMA synchronous=FULL; + CREATE TABLE IF NOT EXISTS managed_cloud_uploads (device_id TEXT, image_id INTEGER, asset_id TEXT, job_id TEXT, operation_id TEXT, uploaded_at TEXT, confirmed_at TEXT, cleanup_operation TEXT, PRIMARY KEY(device_id,image_id)); + CREATE TABLE IF NOT EXISTS asset_metadata (asset_id TEXT PRIMARY KEY, starred INTEGER NOT NULL DEFAULT 0, origin TEXT NOT NULL DEFAULT 'unknown', job_id TEXT, job_name TEXT); CREATE TABLE IF NOT EXISTS image_retention (device_id TEXT NOT NULL, image_id INTEGER NOT NULL, starred INTEGER NOT NULL DEFAULT 0, expires_at TEXT, upload_operation TEXT, cleanup_operation TEXT, PRIMARY KEY(device_id,image_id)); CREATE TABLE IF NOT EXISTS photo_dimensions (path TEXT PRIMARY KEY, mtime INTEGER NOT NULL, bytes INTEGER NOT NULL, width INTEGER NOT NULL, height INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS asset_sources (asset_id TEXT PRIMARY KEY, payload TEXT NOT NULL); diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js index 4caa188..f7468fe 100644 --- a/pigway_frame_flow/web/app.js +++ b/pigway_frame_flow/web/app.js @@ -62,7 +62,7 @@ function accountSettings(){const section=$('#logout').parentElement;const form=d const views={ 'photo-sources':async()=>{ $('#main').innerHTML='';await photoSourcesPanel(); }, async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;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')}

${t('testLabel')}
${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['current',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('model',t(d.model_verified?'modelVerified':'modelConfigured'))}

${t('cloudRotation')}

${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',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){let items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets');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)}
${state.tab==='local'?`
${t('uploadNotice')}
`:''}${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});});});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];showModal(`

${esc(x.name)}

${esc(x.name)}`);await blobImage($('#original-image'),x.url);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(x.current?t('deleteCurrent'):t('deleteConfirm'),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();});});}, + async gallery(epoch){let items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets?limit=200&offset='+(state.assetOffset||0)+'&origin='+encodeURIComponent(state.assetOrigin||'all')+(state.assetJob?'&job_id='+encodeURIComponent(state.assetJob):''));if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}
${state.tab==='local'?`
${t('uploadNotice')}
`:''}${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});});});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];showModal(`

${esc(x.name)}

${esc(x.name)}`);await blobImage($('#original-image'),x.url);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(x.current?t('deleteCurrent'):t('deleteConfirm'),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();});});}, async upload(){state.preview=null;$('#main').innerHTML=notice()+`
${t('uploadNotice')}

${t('upload')}

${t('selectFile')}

${t('uploadHint')}

${buttons('make-preview','preview',true)}

${t('previewHint')}

${t('preview')}

${t('previewHint')}
`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='Preview';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));}, async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`
${t('templateHint')}
${state.templates.map((x,i)=>`
${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}

${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}

1200 × 1600 · ${esc(x.id)}
`).join('')}

${t('templateData')}

${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}

${t('preview')}

${t('previewHint')}
`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate();};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='Template preview';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}}, async automation(epoch){const jobs=await api('/api/v1/jobs');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`

Asia/Shanghai · ${t('automation')}

${buttons('new-job','newJob',true)}
${jobs.length?`
${['name','schedule','status','nextRun','lastRun','result','action'].map(x=>``).join('')}${jobs.map((j,i)=>``).join('')}
${t(x)}
${esc(j.name)}
${esc(j.device_id)}
${t(j.schedule.type)}
${esc(j.schedule.time||j.schedule.cron||fmt(j.schedule.at))}
${t(j.enabled?'enabled':'disabled')}${fmt(j.next_run)}${fmt(j.last_run)}${esc(j.last_result)}
`:`
${t('noTasks')}
`}
`;bind('new-job',()=>jobEditor());document.querySelectorAll('[data-job-edit]').forEach(b=>b.onclick=()=>jobEditor(jobs[b.dataset.jobEdit]));document.querySelectorAll('[data-job-toggle]').forEach(b=>b.onclick=busy(async()=>{const j=jobs[b.dataset.jobToggle];await api('/api/v1/jobs/'+j.id,{method:'PUT',body:jobPayload(j,{enabled:!j.enabled})});await navigate();}));document.querySelectorAll('[data-job-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/jobs/'+jobs[b.dataset.jobDelete].id,{method:'DELETE'});await navigate();}));}, @@ -71,7 +71,7 @@ const views={ async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`

${t('settings')}

${t('tokenHint')}

${buttons('logout','logout')}

${t('auth')}

${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}

${t('refreshInterval')}

${t('apiDocs')} →

GET /openapi.json · /api/v1

`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();} }; function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...changes};} -async function jobEditor(job){const j=job||{name:'',enabled:true,device_id:state.device,schedule:{type:'daily',time:'07:00'},source:{type:'template',template:'daily-dashboard',data:{}},display_duration:5400,restore_previous:true,priority:50};showModal(`

${t(job?'edit':'newJob')}

`);const source=$('#job-source');const choose=document.createElement('div');choose.className='field';choose.innerHTML=``;source.parentElement.before(choose);$('#job-template').onchange=()=>{const id=$('#job-template').value;if(id)source.value=JSON.stringify({type:'template',template:id,data:{}},null,2);};function update(){const type=$('#job-schedule').value;$('#schedule-label').textContent=t(type==='cron'?'cron':['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';}$('#job-schedule').onchange=()=>{update();$('#schedule-value').value='';};update();$('#job-form').onsubmit=busy(async e=>{e.preventDefault();const type=$('#job-schedule').value,value=$('#schedule-value').value;const schedule={type};if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse($('#job-source').value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:$('#job-retention').checked,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?'/'+job.id:''),{method:job?'PUT':'POST',body:payload});$('#modal').close();await navigate();});await jobSourceEditor(j);} +async function jobEditor(job){const j=job||{name:'',enabled:true,device_id:state.device,schedule:{type:'daily',time:'07:00'},source:{type:'template',template:'daily-dashboard',data:{}},display_duration:5400,restore_previous:true,priority:50};showModal(`

${t(job?'edit':'newJob')}

`);const source=$('#job-source');const choose=document.createElement('div');choose.className='field';choose.innerHTML=``;source.parentElement.before(choose);$('#job-template').onchange=()=>{const id=$('#job-template').value;if(id)source.value=JSON.stringify({type:'template',template:id,data:{}},null,2);};function update(){const type=$('#job-schedule').value;$('#schedule-label').textContent=t(type==='cron'?'cron':['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';}$('#job-schedule').onchange=()=>{update();$('#schedule-value').value='';};update();$('#job-form').onsubmit=busy(async e=>{e.preventDefault();const type=$('#job-schedule').value,value=$('#schedule-value').value;const schedule={type};if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse($('#job-source').value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:false,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?'/'+job.id:''),{method:job?'PUT':'POST',body:payload});$('#modal').close();await navigate();});await jobSourceEditor(j);} 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); applyTheme();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(()=>{}); @@ -83,7 +83,7 @@ async function jobEditor(job){ const original=job||base; const j={...base,...original,schedule:normaliseSchedule(original.schedule||base.schedule)}; const scheduleValue=j.schedule.time||j.schedule.cron||(j.schedule.at?j.schedule.at.slice(0,16):'07:00'); - showModal(`

${t(job?'edit':'newJob')}

${t('retentionHint')}

`); + showModal(`

${t(job?'edit':'newJob')}

${t('cloudKeepLatest')}

`); const source=$('#job-source'); const choose=document.createElement('div'); choose.className='field'; @@ -93,7 +93,7 @@ async function jobEditor(job){ function updateScheduleField(){const type=$('#job-schedule').value;$('#schedule-label').textContent=type==='cron'?scheduleTypeLabel(type):t(['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';$('#schedule-hint').textContent=scheduleHint(type);} $('#job-schedule').onchange=()=>{updateScheduleField();$('#schedule-value').value='';}; updateScheduleField(); - $('#job-form').onsubmit=busy(async event=>{event.preventDefault();const type=$('#job-schedule').value;const value=$('#schedule-value').value;const schedule={type};if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse(source.value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:$('#job-retention').checked,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?'/'+job.id:''),{method:job?'PUT':'POST',body:payload});$('#modal').close();await navigate();}); + $('#job-form').onsubmit=busy(async event=>{event.preventDefault();const type=$('#job-schedule').value;const value=$('#schedule-value').value;const schedule={type};if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse(source.value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:false,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?'/'+job.id:''),{method:job?'PUT':'POST',body:payload});$('#modal').close();await navigate();}); await jobSourceEditor(j); } diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js index 0615980..2147013 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -32,8 +32,9 @@ function displayDialog(content){ async function enhanceGallery(){ const grid=$('.gallery');if(!grid)return; const controls=document.createElement('div');controls.className='form-grid';controls.innerHTML=`
`;grid.before(controls); + if(state.tab==='local'){const bar=document.createElement('div');bar.className='actions';bar.innerHTML=``;grid.before(bar);$('#asset-origin').value=state.assetOrigin||'all';const jobs=await api('/api/v1/jobs');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;$('#asset-job').append(option);}$('#asset-job').value=state.assetJob||'';$('#asset-origin').onchange=()=>{state.assetOffset=0;state.assetOrigin=$('#asset-origin').value;return navigate();};$('#asset-job').onchange=()=>{state.assetOffset=0;state.assetJob=$('#asset-job').value;state.assetOrigin=state.assetJob?'task':state.assetOrigin;return navigate();};$('#asset-prev').disabled=!(state.assetOffset||0);$('#asset-next').disabled=state.gallery.length<200;bind('asset-prev',()=>{state.assetOffset=Math.max(0,(state.assetOffset||0)-200);return navigate();});bind('asset-next',()=>{state.assetOffset=(state.assetOffset||0)+200;return navigate();});$('#asset-all').onclick=()=>{const boxes=[...document.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};for(const action of ['star','unstar','delete']){bind('asset-batch-'+action,async()=>{const ids=[...document.querySelectorAll('.asset-select:checked')].map(x=>x.dataset.asset);if(!ids.length){toast(t('selectImagesFirst'));return;}if(action==='delete'&&!confirm(t('deleteSelectedConfirm')))return;const results=[];for(let i=0;ix.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}} const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{const query=$('#gallery-search').value.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;const indexes=cards.map((card,index)=>({card,item:state.gallery[index]}));indexes.sort((a,b)=>order==='name'?a.item.name.localeCompare(b.item.name):order==='oldest'?String(a.item.created_at).localeCompare(String(b.item.created_at)):String(b.item.created_at).localeCompare(String(a.item.created_at)));indexes.forEach(x=>grid.append(x.card));}; - cards.forEach((card,i)=>{const item=state.gallery[i];if(state.tab==='cloud'){const star=document.createElement('button');star.textContent=t(item.starred?'unstar':'star');star.setAttribute('aria-pressed',String(item.starred));card.querySelector('.actions').append(star);star.onclick=busy(async()=>{await api('/api/v1/devices/'+state.device+'/images/'+item.image_id+'/star',{method:'PUT',body:{starred:!item.starred}});await navigate();});if(item.expires_at){const hint=document.createElement('small');hint.textContent=item.starred?t('starredKeep'):t('expiresAt')+' '+fmt(item.expires_at);card.querySelector('.actions').before(hint);}}if(item.width&&item.height){const dimensions=document.createElement('small');dimensions.textContent=item.width+' × '+item.height;card.querySelector('h3').after(dimensions);}const download=document.createElement('button');download.textContent=t('download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local'){const edit=document.createElement('button');edit.textContent=t('continueEdit');card.querySelector('.actions').append(edit);edit.onclick=busy(async()=>{const meta=await api('/api/v1/assets/'+item.asset_id+'/source');if(meta.source?.type==='template')await editTemplateSource(meta.source);else toast(t('sourceUnknown'));});}}); + cards.forEach((card,i)=>{const item=state.gallery[i];if(state.tab==='local'){const star=document.createElement('button');star.textContent=t(item.starred?'unstar':'star');star.setAttribute('aria-pressed',String(item.starred));card.querySelector('.actions').append(star);star.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/star',{method:'PUT',body:{starred:!item.starred}});await navigate();});const label=document.createElement('small');label.textContent=item.origin==='task'?t('taskOrigin')+' · '+(item.job_name||item.job_id):t('origin_'+item.origin);card.querySelector('h3').after(label);const select=document.createElement('input');select.type='checkbox';select.className='asset-select';select.dataset.asset=item.asset_id;select.setAttribute('aria-label',t('selectImage'));card.prepend(select);}if(item.width&&item.height){const dimensions=document.createElement('small');dimensions.textContent=item.width+' × '+item.height;card.querySelector('h3').after(dimensions);}const download=document.createElement('button');download.textContent=t('download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local'){const edit=document.createElement('button');edit.textContent=t('continueEdit');card.querySelector('.actions').append(edit);edit.onclick=busy(async()=>{const meta=await api('/api/v1/assets/'+item.asset_id+'/source');if(meta.source?.type==='template')await editTemplateSource(meta.source);else toast(t('sourceUnknown'));});}}); } async function weatherSettings(){ const weather=await api('/api/v1/providers/weather');const box=document.createElement('section');box.className='card';box.innerHTML=`

${t('weatherSettings')}

${['latitude','longitude'].map(k=>`
`).join('')}
`;$('#main').append(box);$('#weather-provider').value=weather.config.provider; @@ -144,5 +145,5 @@ async function previewSource(id,item_id=null){ showModal(`

${t('preview')}

${t('preview')}

${t('sourcePreviewHint')}

`);await blobImage($('#source-preview'),op.result.url);$('#source-display').disabled=!liveWriteAllowed();bind('source-display',()=>displayDialog({type:'image',asset_id:op.result.asset_id})); } function sourceJobForm(id){ - showModal(`

${t('sourceJob')}

${t('sourceJobHint')}

${t('retentionHint')}

`);$('#source-job-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/jobs',{method:'POST',body:{name:$('#source-job-name').value,device_id:state.device,enabled:$('#source-job-enabled').checked,cleanup_unstarred:$('#source-job-retention').checked,schedule:{type:'cron',cron:$('#source-job-cron').value},source:{type:'photo_source',source_id:id,fit:'contain',profile:'color'}}});$('#modal').close();await navigate('automation');}); + showModal(`

${t('sourceJob')}

${t('sourceJobHint')}

${t('cloudKeepLatest')}

`);$('#source-job-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/jobs',{method:'POST',body:{name:$('#source-job-name').value,device_id:state.device,enabled:$('#source-job-enabled').checked,cleanup_unstarred:false,schedule:{type:'cron',cron:$('#source-job-cron').value},source:{type:'photo_source',source_id:id,fit:'contain',profile:'color'}}});$('#modal').close();await navigate('automation');}); } diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js index c1ceea3..0f2f6ed 100644 --- a/pigway_frame_flow/web/i18n.js +++ b/pigway_frame_flow/web/i18n.js @@ -36,3 +36,7 @@ Object.assign(MESSAGES.en,{photoOrientation:'Photo orientation',orientationAuto: Object.assign(window.MESSAGES.zh,{cleanupUnstarred:'未星标随机照片上传 7 天后清理',retentionHint:'仅适用于随机照片任务的新上传。星标保留;当前图片、恢复图片和播放列表引用不会自动删除。不影响 NAS 原图。',star:'☆ 星标保留',unstar:'★ 取消星标',starredKeep:'已星标 · 保留',expiresAt:'未星标清理时间:'}); Object.assign(window.MESSAGES.en,{cleanupUnstarred:'Clean unstarred random uploads after 7 days',retentionHint:'New random photo uploads only. Starred, current, restore and playlist images are protected. NAS originals remain untouched.',star:'☆ Keep',unstar:'★ Unstar',starredKeep:'Starred · kept',expiresAt:'Cleanup due:'}); +Object.assign(window.MESSAGES.zh,{cloudKeepLatest:'随机任务云端仅保留最新成功图;本地星标上传后保留云端副本。新图失败不会淘汰旧图。',taskOrigin:'任务生成',originFilter:'来源筛选',origin_all:'全部来源',origin_manual:'手动上传',origin_task:'任务生成',origin_external:'外部推送',origin_template:'模板生成',origin_unknown:'来源未知',allTasks:'全部任务',selectImage:'选择图片',selectAll:'全选 / 取消全选',deleteSelected:'删除所选',selectImagesFirst:'请先选择图片',deleteSelectedConfirm:'删除所选本地图片?不会删除 NAS 原始照片。',batchFailed:'未成功操作的图片数:'}); +Object.assign(window.MESSAGES.en,{cloudKeepLatest:'Each random task keeps its latest confirmed cloud image. Uploaded local favorites stay. Failed replacements never evict the last good image.',taskOrigin:'Task generated',originFilter:'Filter by origin',origin_all:'All origins',origin_manual:'Manual upload',origin_task:'Task generated',origin_external:'External push',origin_template:'Template generated',origin_unknown:'Unknown origin',allTasks:'All tasks',selectImage:'Select image',selectAll:'Select / deselect all',deleteSelected:'Delete selected',selectImagesFirst:'Select images first',deleteSelectedConfirm:'Delete selected local images? NAS originals remain untouched.',batchFailed:'Failed items:'}); + +Object.assign(window.MESSAGES.zh,{previousPage:'上一页',nextPage:'下一页'});Object.assign(window.MESSAGES.en,{previousPage:'Previous page',nextPage:'Next page'}); diff --git a/scripts/ui-gallery-stars.cjs b/scripts/ui-gallery-stars.cjs index d2d9fd3..0917311 100644 --- a/scripts/ui-gallery-stars.cjs +++ b/scripts/ui-gallery-stars.cjs @@ -2,13 +2,13 @@ const assert=require('assert'),{chromium}=require('playwright'); (async()=>{ const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}),page=await browser.newPage();let checks=0,writes=0,starred=false; - await page.route('**/api/v1/devices/*/images',r=>r.fulfill({json:[{image_id:1,name:'Current fixture',current:true,starred,url:'/mock-image.jpg',created_at:null},{image_id:2,name:'Other fixture',current:false,url:'/mock-image.jpg',created_at:null}]})); - await page.route('**/api/v1/devices/*/images/1/star',r=>{starred=r.request().postDataJSON().starred;return r.fulfill({json:{starred,expires_at:null}});}); + await page.route('**/api/v1/assets?*',r=>r.fulfill({json:[{asset_id:'local-1',name:'Current fixture',current:true,origin:'manual',starred,url:'/mock-image.jpg',created_at:null},{asset_id:'local-2',name:'Other fixture',current:false,origin:'task',job_id:'fixture-task',job_name:'Fixture task',url:'/mock-image.jpg',created_at:null}]})); + await page.route('**/api/v1/assets/local-1/star',r=>{starred=r.request().postDataJSON().starred;return r.fulfill({json:{starred,expires_at:null}});}); page.on('request',r=>{if(r.method()==='POST'&&r.url().endsWith('/display'))writes++;}); try{ await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock)); for(const lang of ['zh','en'])for(const width of [1440,390])for(const theme of ['dark','light']){ - await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="gallery"]').click();await page.locator('[data-show="0"]').waitFor();const star=page.locator('.image-card').first().locator('button[aria-pressed]');assert(await star.count()===1);const original=await star.getAttribute('aria-pressed');await star.click();await page.waitForFunction(expected=>document.querySelector('.image-card button[aria-pressed]')?.getAttribute('aria-pressed')===expected,original==='true'?'false':'true');assert(await page.locator('[data-show="0"]').isDisabled());assert(await page.locator('[data-show="0"]').textContent()===(lang==='zh'?'当前图片':'Current image'));assert(await page.locator('[data-show="1"]').isEnabled());await page.locator('[data-show="0"]').evaluate(b=>b.onclick());assert(!await page.locator('#modal').evaluate(d=>d.open));assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++; + await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="gallery"]').click();await page.locator('#local-tab').click();await page.locator('[data-show="0"]').waitFor();const star=page.locator('.image-card').first().locator('button[aria-pressed]');assert(await star.count()===1);const original=await star.getAttribute('aria-pressed');await star.click();await page.waitForFunction(expected=>document.querySelector('.image-card button[aria-pressed]')?.getAttribute('aria-pressed')===expected,original==='true'?'false':'true');assert(await page.locator('[data-show="0"]').isDisabled());assert(await page.locator('[data-show="0"]').textContent()===(lang==='zh'?'当前图片':'Current image'));assert(await page.locator('[data-show="1"]').isEnabled());await page.locator('[data-show="0"]').evaluate(b=>b.onclick());assert(!await page.locator('#modal').evaluate(d=>d.open));assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++; } assert(writes===0);console.log('Current-image gallery button: '+checks+' profiles passed; disabled label, other image enabled, no display request'); }finally{await browser.close();} diff --git a/scripts/ui-photo-retention.cjs b/scripts/ui-photo-retention.cjs index 574ec0f..69c1e0a 100644 --- a/scripts/ui-photo-retention.cjs +++ b/scripts/ui-photo-retention.cjs @@ -1,3 +1,3 @@ /* 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('http://127.0.0.1:18787');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-retention').check();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-retention').isChecked());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)}); +(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('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));const id='ui-photo-'+Date.now();let jobId;let checks=0;try{await page.locator('[data-page="photo-sources"]').click();await page.locator('#add-photo-source').click();await page.locator('#source-id').fill(id);await page.locator('#source-name').fill('UI photo fixture');await page.locator('#source-location').fill('.');await page.locator('#source-orientation').selectOption('landscape');await page.locator('#source-organized').check();await page.locator('#source-caption').check();await page.locator('#source-synology-previews').check();await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-browse="${id}"]`).waitFor();for(const lang of ['zh','en'])for(const width of [1440,390])for(const theme of ['dark','light']){await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="photo-sources"]').click();await page.locator(`[data-source-browse="${id}"]`).click();await page.locator('[data-source-item]').first().waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-id').inputValue()===id);assert(await page.locator('#source-orientation').inputValue()==='landscape');assert(await page.locator('#source-organized').isChecked());assert(await page.locator('#source-caption').isChecked());assert(await page.locator('#source-synology-previews').isChecked());await page.locator('#modal-cancel').click();checks++;}await page.locator(`[data-source-random="${id}"]`).click();await page.locator('#source-preview').waitFor();await page.waitForFunction(()=>document.querySelector('#source-preview')?.naturalWidth>0);assert(await page.locator('#source-preview').evaluate(img=>img.naturalWidth===1600&&img.naturalHeight===1200));const before=await page.evaluate(()=>api('/api/v1/devices/'+state.device));await page.locator('#modal-cancel').click();await page.locator(`[data-source-job="${id}"]`).click();assert(!await page.locator('#source-job-enabled').isChecked());await page.locator('#source-job-name').fill(id);await page.locator('#source-job-form button[type="submit"]').click();await page.locator('#new-job').waitFor();jobId=await page.evaluate(async id=>(await api('/api/v1/jobs')).find(j=>j.name===id).id,id);await page.evaluate(async jobId=>{const job=(await api('/api/v1/jobs')).find(j=>j.id===jobId);await jobEditor(job);},jobId);await page.locator('#job-photo-source').waitFor();assert(await page.locator('#job-form').textContent().then(x=>x.includes('最新成功图')||x.includes('latest confirmed')));assert(await page.locator('#job-content-type').inputValue()==='photo_source');assert(await page.locator('#job-photo-source').inputValue()===id);await page.locator('#modal-cancel').click();const after=await page.evaluate(()=>api('/api/v1/devices/'+state.device));assert(before.current_content.id===after.current_content.id);checks+=3;console.log('Photo sources browser: '+checks+' passed (8 language/size/theme profiles, preview and scheduler editing)');}finally{await page.evaluate(async({id,jobId})=>{if(jobId)await api('/api/v1/jobs/'+jobId,{method:'DELETE'});await api('/api/v1/photo-sources/'+id,{method:'DELETE'});},{id,jobId});await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/contract.json b/tests/contract.json index 85e0490..3979031 100644 --- a/tests/contract.json +++ b/tests/contract.json @@ -159,6 +159,38 @@ "503" ] }, + "/api/v1/devices/{device_id}/images/{image_id}/star": { + "put": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, + "/api/v1/devices/{device_id}/images/clear": { + "post": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, "/api/v1/devices/{device_id}/images/{image_id}/file": { "get": [ "200", @@ -317,6 +349,38 @@ "503" ] }, + "/api/v1/assets/{asset_id}/star": { + "put": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, + "/api/v1/assets/batch": { + "post": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, "/api/v1/integrations/synology-photos": { "get": [ "200", @@ -962,21 +1026,5 @@ "502", "503" ] - }, - "/api/v1/devices/{device_id}/images/{image_id}/star": { - "put": [ - "200", - "400", - "401", - "403", - "404", - "409", - "413", - "422", - "429", - "500", - "502", - "503" - ] } } diff --git a/tests/test_local_cloud_library.py b/tests/test_local_cloud_library.py new file mode 100644 index 0000000..06409d0 --- /dev/null +++ b/tests/test_local_cloud_library.py @@ -0,0 +1,146 @@ +from io import BytesIO + +from PIL import Image + +from pigway_frame_flow.providers.photo_sources import PhotoSourceInput + + +def setup_photos(service, settings): + root = settings.data_dir.parent / "photos" + root.mkdir() + settings.photo_root = root + Image.new("RGB", (300, 200)).save(root / "a.jpg") + service.photo_sources.save( + "family", PhotoSourceInput(name="Family", kind="local", location=".") + ) + + +def make_job(client, name): + r = client.post( + "/api/v1/jobs", + json={ + "name": name, + "enabled": False, + "schedule": {"type": "daily", "time": "07:00"}, + "source": {"type": "photo_source", "source_id": "family"}, + }, + ) + assert r.status_code == 201 + return r.json()["id"] + + +def run(client, service, job, confirm=True): + r = client.post(f"/api/v1/jobs/{job}/run") + assert r.status_code == 202, r.text + id = r.json()["operation_id"] + service.process_one() + if confirm: + service.sync() + op = client.get("/api/v1/operations/" + id).json() + return op["result"]["image_id"], op["result"]["rendered_asset_id"] + + +def test_task_origin_filter_and_star_bulk(client, service, settings): + setup_photos(service, settings) + job = make_job(client, "Family random") + image, asset = run(client, service, job) + rows = client.get("/api/v1/assets", params={"origin": "task", "job_id": job}).json() + assert len(rows) == 1 and rows[0]["job_name"] == "Family random" + r = client.post( + "/api/v1/assets/batch", json={"action": "star", "asset_ids": [asset, asset]} + ) + assert len(r.json()["results"]) == 1 + assert client.get("/api/v1/assets").json()[0]["starred"] + r = client.post( + "/api/v1/assets/batch", + json={"action": "delete", "asset_ids": [asset, "missing"]}, + ) + assert [x["status"] for x in r.json()["results"]] == ["completed", "failed"] + + +def test_each_task_keeps_one_after_confirmation(client, service, settings): + setup_photos(service, settings) + job = make_job(client, "A") + first, _ = run(client, service, job) + second, _ = run(client, service, job) + service.cloud_library.sweep() + assert service.process_one() + assert first not in { + x.image_id for x in service.registry.get("living-room").images() + } + assert second in {x.image_id for x in service.registry.get("living-room").images()} + other = make_job(client, "B") + third, _ = run(client, service, other) + service.cloud_library.checked = 0 + service.cloud_library.sweep() + assert second in {x.image_id for x in service.registry.get("living-room").images()} + assert third in {x.image_id for x in service.registry.get("living-room").images()} + + +def test_unconfirmed_replacement_never_evicts_previous(client, service, settings): + setup_photos(service, settings) + job = make_job(client, "A") + first, _ = run(client, service, job) + second, _ = run(client, service, job, confirm=False) + service.cloud_library.sweep() + assert not service.process_one() + assert first in {x.image_id for x in service.registry.get("living-room").images()} + assert not service.db.one( + "SELECT cleanup_operation FROM managed_cloud_uploads WHERE image_id=?", (first,) + )["cleanup_operation"] + + +def test_starred_old_upload_survives_then_unstar_cleans(client, service, settings): + setup_photos(service, settings) + job = make_job(client, "A") + first, asset = run(client, service, job) + assert ( + client.put(f"/api/v1/assets/{asset}/star", json={"starred": True}).status_code + == 200 + ) + run(client, service, job) + service.cloud_library.sweep() + assert not service.process_one() + assert ( + client.put(f"/api/v1/assets/{asset}/star", json={"starred": False}).status_code + == 200 + ) + service.cloud_library.checked = 0 + service.cloud_library.sweep() + assert service.process_one() + assert first not in { + x.image_id for x in service.registry.get("living-room").images() + } + assert service.assets.path(asset).exists() + + +def test_manual_upload_origin(client, service): + buf = BytesIO() + Image.new("RGB", (30, 20)).save(buf, "JPEG") + r = client.post( + "/api/v1/preview", files={"file": ("manual.jpg", buf.getvalue(), "image/jpeg")} + ) + id = r.json()["asset_id"] + r = client.post( + "/api/v1/devices/living-room/upload", json={"type": "image", "asset_id": id} + ) + service.process_one() + rows = client.get("/api/v1/assets", params={"origin": "manual"}).json() + assert len(rows) == 1 and rows[0]["asset_id"] == id + + +def test_clear_cloud_requires_confirmation_and_clears_current(client, service): + before = len(service.registry.get("living-room").images()) + assert ( + client.post( + "/api/v1/devices/living-room/images/clear", json={"confirm_current": False} + ).status_code + == 400 + ) + r = client.post( + "/api/v1/devices/living-room/images/clear", json={"confirm_current": True} + ) + assert r.status_code == 200 and len(r.json()["operations"]) == before + for _ in range(before): + assert service.process_one() + assert not service.registry.get("living-room").images()