Move favorites to local library and keep confirmed task heads in cloud
This commit is contained in:
+37
-18
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+117
-10
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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?`<div class="notice warning">${t('lowBattery')}</div>`:''}<section class="card hero"><div class="hero-info"><span class="badge ${d.status==='offline'?'bad':''}">● ${t(d.status).toUpperCase()}</span><h2>${esc(d.name)}</h2><p>${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}</p><div class="actions">${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}</div><p class="hint" style="margin-top:28px">${t('refreshInterval')}</p></div><div class="frame-demo"><div class="mini-art"><div class="sun"></div></div><span class="frame-label">${t('testLabel')}</span></div></section><div class="stats">${[['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])=>`<div class="card stat"><div class="label">${t(l)}</div><div class="value">${esc(v)}</div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('device')}</h3><div class="detail-grid">${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('model',t(d.model_verified?'modelVerified':'modelConfigured'))}</div></section><section class="card"><h3>${t('cloudRotation')}</h3><div class="detail-grid">${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}</div><p class="hint">${t('rotationNotice')}</p><a href="#playlists" id="go-playlists">${t('playlists')} →</a></section></div>`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};},
|
||||
async gallery(epoch){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()+`<div class="section-heading"><div class="tabs"><button id="cloud-tab" class="${state.tab==='cloud'?'active':''}">${t('cloud')}</button><button id="local-tab" class="${state.tab==='local'?'active':''}">${t('local')}</button></div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}</div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${items.length?`<div class="gallery">${items.map((x,i)=>`<article class="card image-card"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"><h3 title="${esc(x.name)}">${esc(x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:'LOCAL'} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button data-original="${i}">${t('original')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`:`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});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(`<h2>${esc(x.name)}</h2><img id="original-image" class="preview" alt="${esc(x.name)}"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);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()+`<div class="section-heading"><div class="tabs"><button id="cloud-tab" class="${state.tab==='cloud'?'active':''}">${t('cloud')}</button><button id="local-tab" class="${state.tab==='local'?'active':''}">${t('local')}</button></div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}</div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${items.length?`<div class="gallery">${items.map((x,i)=>`<article class="card image-card"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"><h3 title="${esc(x.name)}">${esc(x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:'LOCAL'} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button data-original="${i}">${t('original')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`:`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});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(`<h2>${esc(x.name)}</h2><img id="original-image" class="preview" alt="${esc(x.name)}"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);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()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
|
||||
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}</div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('previewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate();};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
|
||||
async automation(epoch){const jobs=await api('/api/v1/jobs');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">Asia/Shanghai · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table><thead><tr>${['name','schedule','status','nextRun','lastRun','result','action'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${jobs.map((j,i)=>`<tr><td><strong>${esc(j.name)}</strong><br><small>${esc(j.device_id)}</small></td><td>${t(j.schedule.type)}<br>${esc(j.schedule.time||j.schedule.cron||fmt(j.schedule.at))}</td><td><span class="badge">${t(j.enabled?'enabled':'disabled')}</span></td><td>${fmt(j.next_run)}</td><td>${fmt(j.last_run)}</td><td><code>${esc(j.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${i}">${t('edit')}</button><button data-job-toggle="${i}">${t(j.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${i}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-job',()=>jobEditor());document.querySelectorAll('[data-job-edit]').forEach(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()+`<div class="columns"><section class="card"><h3>${t('settings')}</h3><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();}
|
||||
};
|
||||
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...changes};}
|
||||
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(`<h2>${t(job?'edit':'newJob')}</h2><form id="job-form"><div class="field"><label for="job-name">${t('name')}</label><input id="job-name" required maxlength="120" value="${esc(j.name)}"></div><div class="form-grid"><div class="field"><label for="job-schedule">${t('schedule')}</label><select id="job-schedule">${['daily','weekdays','weekends','cron','date','temporary'].map(x=>`<option ${j.schedule.type===x?'selected':''} value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label id="schedule-label" for="schedule-value">${t('time')}</label><input id="schedule-value" required value="${esc(j.schedule.time||j.schedule.cron||(j.schedule.at?j.schedule.at.slice(0,16):'07:00'))}"></div></div><div class="field"><label for="job-source">${t('source')} (JSON)</label><textarea id="job-source">${esc(JSON.stringify(j.source,null,2))}</textarea></div><div class="form-grid"><div class="field"><label for="job-duration">${t('duration')}</label><input id="job-duration" type="number" min="0" max="604800" value="${j.display_duration}"></div><div class="field"><label for="job-priority">${t('priority')}</label><input id="job-priority" type="number" min="0" max="100" value="${j.priority}"></div></div><label class="check-row"><input type="checkbox" id="job-enabled" ${j.enabled?'checked':''}>${t('enabled')}</label><label class="check-row"><input type="checkbox" id="job-restore" ${j.restore_previous?'checked':''}>${t('restoreAfter')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);const source=$('#job-source');const choose=document.createElement('div');choose.className='field';choose.innerHTML=`<label for="job-template">${t('template')}</label><select id="job-template"><option value="">${t('advanced')}</option>${state.templates.map(x=>`<option value="${esc(x.id)}" ${j.source.template===x.id?'selected':''}>${esc(x.name)}</option>`).join('')}</select>`;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(`<h2>${t(job?'edit':'newJob')}</h2><form id="job-form"><div class="field"><label for="job-name">${t('name')}</label><input id="job-name" required maxlength="120" value="${esc(j.name)}"></div><div class="form-grid"><div class="field"><label for="job-schedule">${t('schedule')}</label><select id="job-schedule">${['daily','weekdays','weekends','cron','date','temporary'].map(x=>`<option ${j.schedule.type===x?'selected':''} value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label id="schedule-label" for="schedule-value">${t('time')}</label><input id="schedule-value" required value="${esc(j.schedule.time||j.schedule.cron||(j.schedule.at?j.schedule.at.slice(0,16):'07:00'))}"></div></div><div class="field"><label for="job-source">${t('source')} (JSON)</label><textarea id="job-source">${esc(JSON.stringify(j.source,null,2))}</textarea></div><div class="form-grid"><div class="field"><label for="job-duration">${t('duration')}</label><input id="job-duration" type="number" min="0" max="604800" value="${j.display_duration}"></div><div class="field"><label for="job-priority">${t('priority')}</label><input id="job-priority" type="number" min="0" max="100" value="${j.priority}"></div></div><label class="check-row"><input type="checkbox" id="job-enabled" ${j.enabled?'checked':''}>${t('enabled')}</label><label class="check-row"><input type="checkbox" id="job-restore" ${j.restore_previous?'checked':''}>${t('restoreAfter')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);const source=$('#job-source');const choose=document.createElement('div');choose.className='field';choose.innerHTML=`<label for="job-template">${t('template')}</label><select id="job-template"><option value="">${t('advanced')}</option>${state.templates.map(x=>`<option value="${esc(x.id)}" ${j.source.template===x.id?'selected':''}>${esc(x.name)}</option>`).join('')}</select>`;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(`<h2>${t('playlists')}</h2><form id="playlist-form"><div class="field"><label for="playlist-name">${t('name')}</label><input id="playlist-name" required maxlength="120" value="${esc(p.name)}"></div><div class="field"><label for="playlist-images">${t('imageIds')}</label><input id="playlist-images" required value="${esc(p.image_ids.join(','))}" placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval">${t('interval')}</label><input id="playlist-interval" type="number" min="5" max="10080" value="${p.interval_minutes}"></div><div class="field"><label for="playlist-mode">${t('mode')}</label><select id="playlist-mode"><option value="queue" ${p.mode==='queue'?'selected':''}>${t('queue')}</option><option value="random" ${p.mode==='random'?'selected':''}>${t('random')}</option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});await playlistImagePicker(p);}
|
||||
$('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#device').onchange=()=>{state.device=$('#device').value;navigate();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme);
|
||||
applyTheme();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>$('#footer').innerHTML=`<span>Pigway Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`).catch(()=>{});
|
||||
@@ -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(`<h2>${t(job?'edit':'newJob')}</h2><form id="job-form"><div class="field"><label for="job-name">${t('name')}</label><input id="job-name" required maxlength="120" value="${esc(j.name)}"></div><div class="form-grid"><div class="field"><label for="job-schedule">${t('schedule')}</label><select id="job-schedule">${['daily','weekdays','weekends','date','temporary','cron'].map(type=>`<option value="${type}" ${j.schedule.type===type?'selected':''}>${scheduleTypeLabel(type)}</option>`).join('')}</select></div><div class="field"><label id="schedule-label" for="schedule-value">${t('time')}</label><input id="schedule-value" required value="${esc(scheduleValue)}"></div></div><p class="hint" id="schedule-hint"></p><div class="field"><label for="job-source">${t('source')} (JSON)</label><textarea id="job-source">${esc(JSON.stringify(j.source,null,2))}</textarea></div><div class="form-grid"><div class="field"><label for="job-duration">${t('duration')}</label><input id="job-duration" type="number" min="0" max="604800" value="${j.display_duration}"></div><div class="field"><label for="job-priority">${t('priority')}</label><input id="job-priority" type="number" min="0" max="100" value="${j.priority}"></div></div><label class="check-row"><input type="checkbox" id="job-enabled" ${j.enabled?'checked':''}>${t('enabled')}</label><label class="check-row"><input type="checkbox" id="job-restore" ${j.restore_previous?'checked':''}>${t('restoreAfter')}</label><label class="check-row"><input type="checkbox" id="job-retention" ${j.cleanup_unstarred?'checked':''}>${t('cleanupUnstarred')}</label><p class="hint">${t('retentionHint')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
|
||||
showModal(`<h2>${t(job?'edit':'newJob')}</h2><form id="job-form"><div class="field"><label for="job-name">${t('name')}</label><input id="job-name" required maxlength="120" value="${esc(j.name)}"></div><div class="form-grid"><div class="field"><label for="job-schedule">${t('schedule')}</label><select id="job-schedule">${['daily','weekdays','weekends','date','temporary','cron'].map(type=>`<option value="${type}" ${j.schedule.type===type?'selected':''}>${scheduleTypeLabel(type)}</option>`).join('')}</select></div><div class="field"><label id="schedule-label" for="schedule-value">${t('time')}</label><input id="schedule-value" required value="${esc(scheduleValue)}"></div></div><p class="hint" id="schedule-hint"></p><div class="field"><label for="job-source">${t('source')} (JSON)</label><textarea id="job-source">${esc(JSON.stringify(j.source,null,2))}</textarea></div><div class="form-grid"><div class="field"><label for="job-duration">${t('duration')}</label><input id="job-duration" type="number" min="0" max="604800" value="${j.display_duration}"></div><div class="field"><label for="job-priority">${t('priority')}</label><input id="job-priority" type="number" min="0" max="100" value="${j.priority}"></div></div><label class="check-row"><input type="checkbox" id="job-enabled" ${j.enabled?'checked':''}>${t('enabled')}</label><label class="check-row"><input type="checkbox" id="job-restore" ${j.restore_previous?'checked':''}>${t('restoreAfter')}</label><p class="hint">${t('cloudKeepLatest')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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=`<div class="field"><label for="gallery-search">${t('searchImages')}</label><input id="gallery-search" type="search"></div><div class="field"><label for="gallery-sort">${t('mode')}</label><select id="gallery-sort"><option value="newest">${t('sortNewest')}</option><option value="oldest">${t('sortOldest')}</option><option value="name">${t('sortName')}</option></select></div>`;grid.before(controls);
|
||||
if(state.tab==='local'){const bar=document.createElement('div');bar.className='actions';bar.innerHTML=`<select id="asset-origin" aria-label="${t('originFilter')}">${['all','manual','task','external','template','unknown'].map(x=>`<option value="${x}">${t('origin_'+x)}</option>`).join('')}</select><select id="asset-job" aria-label="${t('taskOrigin')}"><option value="">${t('allTasks')}</option></select><button id="asset-prev">${t('previousPage')}</button><button id="asset-next">${t('nextPage')}</button><button id="asset-all">${t('selectAll')}</button><button id="asset-batch-star">${t('star')}</button><button id="asset-batch-unstar">${t('unstar')}</button><button id="asset-batch-delete" class="danger">${t('deleteSelected')}</button>`;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;i<ids.length;i+=100){const r=await api('/api/v1/assets/batch',{method:'POST',body:{asset_ids:ids.slice(i,i+100),action}});results.push(...r.results);}const failed=results.filter(x=>x.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}}
|
||||
const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{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=`<h3>${t('weatherSettings')}</h3><form id="weather-form"><div class="field"><label for="weather-provider">Provider</label><select id="weather-provider"><option value="mock">${t('weatherMock')}</option><option value="open-meteo">Open-Meteo</option></select></div><div class="field"><label for="weather-location">${t('location')}</label><input id="weather-location" required maxlength="120" value="${esc(weather.config.location)}"></div><div class="form-grid">${['latitude','longitude'].map(k=>`<div class="field"><label for="weather-${k}">${t(k)}</label><input id="weather-${k}" required type="number" step="any" min="${k==='latitude'?-90:-180}" max="${k==='latitude'?90:180}" value="${weather.config[k]}"></div>`).join('')}</div><button type="submit" class="primary">${t('save')}</button></form><div id="weather-result"></div>`;$('#main').append(box);$('#weather-provider').value=weather.config.provider;
|
||||
@@ -144,5 +145,5 @@ async function previewSource(id,item_id=null){
|
||||
showModal(`<h2>${t('preview')}</h2><img id="source-preview" class="preview" alt="${t('preview')}"><p class="hint">${t('sourcePreviewHint')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('source-display','display',true)}</div>`);await blobImage($('#source-preview'),op.result.url);$('#source-display').disabled=!liveWriteAllowed();bind('source-display',()=>displayDialog({type:'image',asset_id:op.result.asset_id}));
|
||||
}
|
||||
function sourceJobForm(id){
|
||||
showModal(`<h2>${t('sourceJob')}</h2><form id="source-job-form"><div class="field"><label for="source-job-name">${t('name')}</label><input id="source-job-name" required maxlength="120" value="${t('randomPhoto')}"></div><div class="field"><label for="source-job-cron">Cron · Asia/Shanghai</label><input id="source-job-cron" required value="0 8 * * *"><p class="hint">${t('sourceJobHint')}</p></div><div class="field"><label class="check-row"><input id="source-job-enabled" type="checkbox">${t('enabled')}</label></div><label class="check-row"><input id="source-job-retention" type="checkbox">${t('cleanupUnstarred')}</label><p class="hint">${t('retentionHint')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}<button class="primary" type="submit">${t('save')}</button></div></form>`);$('#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(`<h2>${t('sourceJob')}</h2><form id="source-job-form"><div class="field"><label for="source-job-name">${t('name')}</label><input id="source-job-name" required maxlength="120" value="${t('randomPhoto')}"></div><div class="field"><label for="source-job-cron">Cron · Asia/Shanghai</label><input id="source-job-cron" required value="0 8 * * *"><p class="hint">${t('sourceJobHint')}</p></div><div class="field"><label class="check-row"><input id="source-job-enabled" type="checkbox">${t('enabled')}</label></div><p class="hint">${t('cloudKeepLatest')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}<button class="primary" type="submit">${t('save')}</button></div></form>`);$('#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');});
|
||||
}
|
||||
|
||||
@@ -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'});
|
||||
|
||||
@@ -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();}
|
||||
|
||||
@@ -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)});
|
||||
|
||||
+64
-16
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user