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')}
${[['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])=>`
`).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()+`${t('cloud')} ${t('local')}
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}
${state.tab==='local'?`${t('uploadNotice')}
`:''}${items.length?`${items.map((x,i)=>`
${esc(x.name)} ${state.tab==='cloud'?'#'+x.image_id:'LOCAL'} · ${esc(fmt(x.created_at))} ${t(x.current?'current':'display')} ${t('original')} ${t('delete')}
`).join('')}
`:`${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')} ${t('name')}
${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}
`);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)} ${buttons('modal-cancel','cancel')}
`);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()+`${t('cloud')} ${t('local')}
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}
${state.tab==='local'?`${t('uploadNotice')}
`:''}${items.length?`${items.map((x,i)=>`
${esc(x.name)} ${state.tab==='cloud'?'#'+x.image_id:'LOCAL'} · ${esc(fmt(x.created_at))} ${t(x.current?'current':'display')} ${t('original')} ${t('delete')}
`).join('')}
`:`${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')} ${t('name')}
${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}
`);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)} ${buttons('modal-cancel','cancel')}
`);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')} ${buttons('make-preview','preview',true)}${t('previewHint')}
${t('preview')} ${t('previewHint')}
${t('staging')} ${t('display')}
`;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=' ';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')} ${t('profile')} ${t('color')} ${t('grayscale')}
${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}
${t('preview')} ${t('previewHint')}
${t('display')} `;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=' ';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=>`${t(x)} `).join('')} ${jobs.map((j,i)=>`${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('edit')} ${t(j.enabled?'disabled':'enabled')} ${t('remove')}
`).join('')}
`:`
${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('theme')} ${['system','light','dark'].map(x=>`${t(x==='system'?'systemTheme':x)} `).join('')}
${t('language')} 简体中文 English
${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=`${t('template')} ${t('advanced')} ${state.templates.map(x=>`${esc(x.name)} `).join('')} `;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')} ${t('name')}
${t('source')} (JSON) ${esc(JSON.stringify(j.source,null,2))}
${t('enabled')} ${t('restoreAfter')}${buttons('modal-cancel','cancel')}${t('save')}
`);const source=$('#job-source');const choose=document.createElement('div');choose.className='field';choose.innerHTML=`${t('template')} ${t('advanced')} ${state.templates.map(x=>`${esc(x.name)} `).join('')} `;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')} ${t('name')}
${t('imageIds')}
${t('enabled')}${buttons('modal-cancel','cancel')}${t('save')}
`);$('#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('name')}
${t('source')} (JSON) ${esc(JSON.stringify(j.source,null,2))}
${t('enabled')} ${t('restoreAfter')} ${t('cleanupUnstarred')}${t('retentionHint')}
${buttons('modal-cancel','cancel')}${t('save')}
`);
+ showModal(`${t(job?'edit':'newJob')} ${t('name')}
${t('source')} (JSON) ${esc(JSON.stringify(j.source,null,2))}
${t('enabled')} ${t('restoreAfter')}${t('cloudKeepLatest')}
${buttons('modal-cancel','cancel')}${t('save')}
`);
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=`${t('searchImages')}
${t('mode')} ${t('sortNewest')} ${t('sortOldest')} ${t('sortName')}
`;grid.before(controls);
+ if(state.tab==='local'){const bar=document.createElement('div');bar.className='actions';bar.innerHTML=`${['all','manual','task','external','template','unknown'].map(x=>`${t('origin_'+x)} `).join('')} ${t('allTasks')} ${t('previousPage')} ${t('nextPage')} ${t('selectAll')} ${t('star')} ${t('unstar')} ${t('deleteSelected')} `;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')} Provider ${t('weatherMock')} Open-Meteo
${t('location')}
${t('save')}
`;$('#main').append(box);$('#weather-provider').value=weather.config.provider;
@@ -144,5 +145,5 @@ async function previewSource(id,item_id=null){
showModal(`${t('preview')} ${t('sourcePreviewHint')}
${buttons('modal-cancel','cancel')}${buttons('source-display','display',true)}
`);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('name')}
${t('enabled')}
${t('cleanupUnstarred')}${t('retentionHint')}
${buttons('modal-cancel','cancel')}${t('save')}
`);$('#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('name')}
${t('enabled')}
${t('cloudKeepLatest')}
${buttons('modal-cancel','cancel')}${t('save')}
`);$('#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()