Add starred cloud photos and opt-in seven-day random upload retention
This commit is contained in:
@@ -161,3 +161,25 @@ Random rendering tries at most five distinct candidates before any device write,
|
|||||||
skipping only invalid or unavailable images. Explicit selections never fall back.
|
skipping only invalid or unavailable images. Explicit selections never fall back.
|
||||||
Operation `result.photo_attempts` records source-relative item IDs, names, status
|
Operation `result.photo_attempts` records source-relative item IDs, names, status
|
||||||
and stable error codes, including failures. This does not retry upload or switch.
|
and stable error codes, including failures. This does not retry upload or switch.
|
||||||
|
|
||||||
|
### Starred cloud photos and seven-day retention
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -1400,6 +1400,173 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/devices/{device_id}/images/{image_id}/star": {
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"Images"
|
||||||
|
],
|
||||||
|
"summary": "Star Image",
|
||||||
|
"operationId": "star_image_api_v1_devices__device_id__images__image_id__star_put",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"HTTPBearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AccountSession": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "device_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Device Id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Image Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ImageStar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": {
|
"/api/v1/devices/{device_id}/images/{image_id}/file": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -10094,6 +10261,22 @@
|
|||||||
},
|
},
|
||||||
"GalleryImage": {
|
"GalleryImage": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"starred": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Starred",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Expires At"
|
||||||
|
},
|
||||||
"width": {
|
"width": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -10328,6 +10511,20 @@
|
|||||||
],
|
],
|
||||||
"title": "ImageContent"
|
"title": "ImageContent"
|
||||||
},
|
},
|
||||||
|
"ImageStar": {
|
||||||
|
"properties": {
|
||||||
|
"starred": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Starred"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"starred"
|
||||||
|
],
|
||||||
|
"title": "ImageStar"
|
||||||
|
},
|
||||||
"ImageURLConfig": {
|
"ImageURLConfig": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"allowed_hosts": {
|
"allowed_hosts": {
|
||||||
@@ -10345,6 +10542,11 @@
|
|||||||
},
|
},
|
||||||
"Job": {
|
"Job": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"cleanup_unstarred": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Cleanup Unstarred",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"title": "Enabled",
|
"title": "Enabled",
|
||||||
@@ -10481,6 +10683,11 @@
|
|||||||
},
|
},
|
||||||
"JobInput": {
|
"JobInput": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"cleanup_unstarred": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Cleanup Unstarred",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"title": "Enabled",
|
"title": "Enabled",
|
||||||
@@ -10812,6 +11019,11 @@
|
|||||||
},
|
},
|
||||||
"Options": {
|
"Options": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"cleanup_unstarred": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Cleanup Unstarred",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
"duration": {
|
"duration": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"maximum": 604800.0,
|
"maximum": 604800.0,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from pigway_frame_flow.models import (
|
|||||||
Health,
|
Health,
|
||||||
History,
|
History,
|
||||||
ImageContent,
|
ImageContent,
|
||||||
|
ImageStar,
|
||||||
ImageURLConfig,
|
ImageURLConfig,
|
||||||
Job,
|
Job,
|
||||||
JobInput,
|
JobInput,
|
||||||
@@ -310,7 +311,19 @@ def create_app(settings=None, start_worker=True):
|
|||||||
dependencies=[Depends(require("images:read"))],
|
dependencies=[Depends(require("images:read"))],
|
||||||
)
|
)
|
||||||
def images(device_id: str):
|
def images(device_id: str):
|
||||||
return registry.get(device_id).images()
|
return [
|
||||||
|
image.model_copy(update=service.retention.info(device_id, image.image_id))
|
||||||
|
for image in registry.get(device_id).images()
|
||||||
|
]
|
||||||
|
|
||||||
|
@app.put(
|
||||||
|
"/api/v1/devices/{device_id}/images/{image_id}/star",
|
||||||
|
tags=["Images"],
|
||||||
|
dependencies=[Depends(require("images:write"))],
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
@app.get(
|
@app.get(
|
||||||
"/api/v1/devices/{device_id}/images/{image_id}/file",
|
"/api/v1/devices/{device_id}/images/{image_id}/file",
|
||||||
@@ -1014,6 +1027,7 @@ def create_app(settings=None, start_worker=True):
|
|||||||
duration=job.display_duration,
|
duration=job.display_duration,
|
||||||
restore_previous=job.restore_previous,
|
restore_previous=job.restore_previous,
|
||||||
priority=job.priority,
|
priority=job.priority,
|
||||||
|
cleanup_unstarred=job.cleanup_unstarred,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
service.validate(payload)
|
service.validate(payload)
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""Opt-in retention for known uploads; never sweep the vendor's entire gallery."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from pigway_frame_flow.errors import ErrorCode as E
|
||||||
|
from pigway_frame_flow.errors import FlowError
|
||||||
|
from pigway_frame_flow.storage.database import now_iso
|
||||||
|
|
||||||
|
|
||||||
|
class Retention:
|
||||||
|
def __init__(self, service):
|
||||||
|
self.service = service
|
||||||
|
self.db = service.db
|
||||||
|
self.checked = 0.0
|
||||||
|
|
||||||
|
def info(self, device_id, image_id):
|
||||||
|
row = self.db.one(
|
||||||
|
"SELECT * FROM image_retention WHERE device_id=? AND image_id=?",
|
||||||
|
(device_id, image_id),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"starred": bool(row and row["starred"]),
|
||||||
|
"expires_at": row["expires_at"] if row else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def star(self, device_id, image_id, starred):
|
||||||
|
with self.service.lock:
|
||||||
|
row = self.db.one(
|
||||||
|
"SELECT cleanup_operation FROM image_retention WHERE device_id=? AND image_id=?",
|
||||||
|
(device_id, image_id),
|
||||||
|
)
|
||||||
|
if row and row["cleanup_operation"]:
|
||||||
|
op = self.db.one(
|
||||||
|
"SELECT write_started FROM operations WHERE id=?",
|
||||||
|
(row["cleanup_operation"],),
|
||||||
|
)
|
||||||
|
if op and op["write_started"]:
|
||||||
|
raise FlowError(
|
||||||
|
E.CONFLICT, "Deletion has begun; reconcile before starring", 409
|
||||||
|
)
|
||||||
|
if image_id not in {
|
||||||
|
x.image_id for x in self.service.registry.get(device_id).images()
|
||||||
|
}:
|
||||||
|
raise FlowError(E.NOT_FOUND, "Gallery image not found", 404)
|
||||||
|
self.db.execute(
|
||||||
|
"INSERT INTO image_retention(device_id,image_id,starred) VALUES(?,?,?) ON CONFLICT(device_id,image_id) DO UPDATE SET starred=excluded.starred",
|
||||||
|
(device_id, image_id, int(starred)),
|
||||||
|
)
|
||||||
|
return self.info(device_id, image_id)
|
||||||
|
|
||||||
|
def register(self, device_id, image_id, operation_id):
|
||||||
|
expires = (datetime.now(UTC) + timedelta(days=7)).isoformat()
|
||||||
|
self.db.execute(
|
||||||
|
"INSERT INTO image_retention(device_id,image_id,starred,expires_at,upload_operation) VALUES(?,?,0,?,?) ON CONFLICT(device_id,image_id) DO NOTHING",
|
||||||
|
(device_id, image_id, expires, operation_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def protected(self, device_id, image_id):
|
||||||
|
state = self.service.state(device_id)
|
||||||
|
ids = {state["previous_image_id"], state["base_image_id"]}
|
||||||
|
ids.update(
|
||||||
|
x["image_id"]
|
||||||
|
for x in self.db.all(
|
||||||
|
"SELECT image_id FROM overrides WHERE device_id=?", (device_id,)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for row in self.db.all("SELECT payload FROM playlists"):
|
||||||
|
p = json.loads(row["payload"])
|
||||||
|
if p.get("device_id") == device_id:
|
||||||
|
ids.update(p.get("image_ids", []))
|
||||||
|
for row in self.db.all("SELECT payload FROM jobs"):
|
||||||
|
job = json.loads(row["payload"])
|
||||||
|
if job.get("device_id") == device_id:
|
||||||
|
ids.add(job.get("source", {}).get("image_id"))
|
||||||
|
return image_id in ids
|
||||||
|
|
||||||
|
def eligible(self, device_id, image_id):
|
||||||
|
row = self.db.one(
|
||||||
|
"SELECT * FROM image_retention WHERE device_id=? AND image_id=?",
|
||||||
|
(device_id, image_id),
|
||||||
|
)
|
||||||
|
return bool(
|
||||||
|
row
|
||||||
|
and row["upload_operation"]
|
||||||
|
and not row["starred"]
|
||||||
|
and row["expires_at"] <= now_iso()
|
||||||
|
and not self.protected(device_id, image_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
def sweep(self):
|
||||||
|
if time.monotonic() - self.checked < 3600:
|
||||||
|
return
|
||||||
|
self.checked = time.monotonic()
|
||||||
|
with self.service.lock:
|
||||||
|
if (
|
||||||
|
not self.service.settings.mock
|
||||||
|
and not self.service.settings.write_enabled
|
||||||
|
):
|
||||||
|
return
|
||||||
|
rows = self.db.all(
|
||||||
|
"SELECT * FROM image_retention WHERE starred=0 AND expires_at<=? AND cleanup_operation IS NULL",
|
||||||
|
(now_iso(),),
|
||||||
|
)
|
||||||
|
# Bound work per cycle; device reads occur only when there is due work.
|
||||||
|
for row in rows[:20]:
|
||||||
|
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
|
||||||
|
driver = self.service.registry.get(device_id)
|
||||||
|
device = driver.device(refresh=True)
|
||||||
|
if device.status != "online" or device.current_content.id == image_id:
|
||||||
|
continue
|
||||||
|
id = self.service.submit(
|
||||||
|
"delete",
|
||||||
|
{"image_id": image_id, "confirm_current": False, "retention": True},
|
||||||
|
device_id,
|
||||||
|
"retention",
|
||||||
|
"retention-" + row["upload_operation"],
|
||||||
|
"retention-"
|
||||||
|
+ row["upload_operation"]
|
||||||
|
+ "-"
|
||||||
|
+ datetime.now(UTC).strftime("%Y%m%d%H"),
|
||||||
|
)
|
||||||
|
self.db.execute(
|
||||||
|
"UPDATE image_retention 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 datetime import UTC, datetime, timedelta
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from pigway_frame_flow.display.retention import Retention
|
||||||
from pigway_frame_flow.drivers.base import DriverRegistry
|
from pigway_frame_flow.drivers.base import DriverRegistry
|
||||||
from pigway_frame_flow.errors import ErrorCode as E
|
from pigway_frame_flow.errors import ErrorCode as E
|
||||||
from pigway_frame_flow.errors import FlowError
|
from pigway_frame_flow.errors import FlowError
|
||||||
@@ -36,6 +37,7 @@ class DisplayService:
|
|||||||
renderer,
|
renderer,
|
||||||
)
|
)
|
||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
|
self.retention = Retention(self)
|
||||||
self.expiration_checked: dict[str, float] = {}
|
self.expiration_checked: dict[str, float] = {}
|
||||||
# A killed write cannot be replayed safely; pre-write work can resume.
|
# A killed write cannot be replayed safely; pre-write work can resume.
|
||||||
db.execute(
|
db.execute(
|
||||||
@@ -400,6 +402,16 @@ class DisplayService:
|
|||||||
state = self.state(device_id)
|
state = self.state(device_id)
|
||||||
if action == "delete":
|
if action == "delete":
|
||||||
image_id = payload["image_id"]
|
image_id = payload["image_id"]
|
||||||
|
if payload.get("retention") and (
|
||||||
|
not self.retention.eligible(device_id, image_id) or image_id == previous
|
||||||
|
):
|
||||||
|
self.db.execute(
|
||||||
|
"UPDATE image_retention SET cleanup_operation=NULL WHERE device_id=? AND image_id=?",
|
||||||
|
(device_id, image_id),
|
||||||
|
)
|
||||||
|
self.update(id, "COMPLETED", {"image_id": image_id, "deferred": True})
|
||||||
|
self.db.history(op, "RETENTION_PROTECTED")
|
||||||
|
return
|
||||||
protected = {state["previous_image_id"], state["base_image_id"]} | {
|
protected = {state["previous_image_id"], state["base_image_id"]} | {
|
||||||
x["image_id"]
|
x["image_id"]
|
||||||
for x in self.db.all(
|
for x in self.db.all(
|
||||||
@@ -420,6 +432,10 @@ class DisplayService:
|
|||||||
"DELETE FROM image_sources WHERE device_id=? AND image_id=?",
|
"DELETE FROM image_sources WHERE device_id=? AND image_id=?",
|
||||||
(device_id, image_id),
|
(device_id, image_id),
|
||||||
)
|
)
|
||||||
|
self.db.execute(
|
||||||
|
"DELETE FROM image_retention WHERE device_id=? AND image_id=?",
|
||||||
|
(device_id, image_id),
|
||||||
|
)
|
||||||
driver.invalidate()
|
driver.invalidate()
|
||||||
self.update(id, "COMPLETED", {"image_id": image_id})
|
self.update(id, "COMPLETED", {"image_id": image_id})
|
||||||
self.db.history(op, "COMPLETED")
|
self.db.history(op, "COMPLETED")
|
||||||
@@ -478,6 +494,12 @@ class DisplayService:
|
|||||||
if asset:
|
if asset:
|
||||||
self.update(id, "UPLOADING")
|
self.update(id, "UPLOADING")
|
||||||
image_id = driver.upload(self.assets.path(asset["asset_id"]), asset["name"])
|
image_id = driver.upload(self.assets.path(asset["asset_id"]), asset["name"])
|
||||||
|
if (
|
||||||
|
request.options.cleanup_unstarred
|
||||||
|
and isinstance(content, PhotoSourceContent)
|
||||||
|
and content.item_id is None
|
||||||
|
):
|
||||||
|
self.retention.register(device_id, image_id, id)
|
||||||
elif isinstance(content, ImageContent) and content.image_name:
|
elif isinstance(content, ImageContent) and content.image_name:
|
||||||
driver.display_by_name(content.image_name)
|
driver.display_by_name(content.image_name)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ Content = Annotated[
|
|||||||
|
|
||||||
|
|
||||||
class Options(StrictModel):
|
class Options(StrictModel):
|
||||||
|
cleanup_unstarred: bool = False
|
||||||
duration: int = Field(default=0, ge=0, le=604800)
|
duration: int = Field(default=0, ge=0, le=604800)
|
||||||
restore_previous: bool = False
|
restore_previous: bool = False
|
||||||
priority: int = Field(default=50, ge=0, le=100)
|
priority: int = Field(default=50, ge=0, le=100)
|
||||||
@@ -102,6 +103,15 @@ class DisplayRequest(StrictModel):
|
|||||||
options: Options = Field(default_factory=Options)
|
options: Options = Field(default_factory=Options)
|
||||||
source: Source = Field(default_factory=Source)
|
source: Source = Field(default_factory=Source)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def retention_source(self):
|
||||||
|
if self.options.cleanup_unstarred and not (
|
||||||
|
isinstance(self.content, PhotoSourceContent)
|
||||||
|
and self.content.item_id is None
|
||||||
|
):
|
||||||
|
raise ValueError("Retention requires random photo-source content")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class Accepted(BaseModel):
|
class Accepted(BaseModel):
|
||||||
operation_id: str
|
operation_id: str
|
||||||
@@ -203,6 +213,8 @@ class Device(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class GalleryImage(BaseModel):
|
class GalleryImage(BaseModel):
|
||||||
|
starred: bool = False
|
||||||
|
expires_at: str | None = None
|
||||||
width: int | None = None
|
width: int | None = None
|
||||||
height: int | None = None
|
height: int | None = None
|
||||||
size_bytes: int | None = None
|
size_bytes: int | None = None
|
||||||
@@ -226,6 +238,7 @@ class RestoreContent(StrictModel):
|
|||||||
|
|
||||||
|
|
||||||
class JobInput(StrictModel):
|
class JobInput(StrictModel):
|
||||||
|
cleanup_unstarred: bool = False
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
device_id: str = "living-room"
|
device_id: str = "living-room"
|
||||||
name: str = Field(min_length=1, max_length=120)
|
name: str = Field(min_length=1, max_length=120)
|
||||||
@@ -237,6 +250,10 @@ class JobInput(StrictModel):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_options(self):
|
def validate_options(self):
|
||||||
|
if self.cleanup_unstarred and not (
|
||||||
|
isinstance(self.source, PhotoSourceContent) and self.source.item_id is None
|
||||||
|
):
|
||||||
|
raise ValueError("Retention requires a random photo-source task")
|
||||||
Options(
|
Options(
|
||||||
duration=self.display_duration,
|
duration=self.display_duration,
|
||||||
restore_previous=self.restore_previous,
|
restore_previous=self.restore_previous,
|
||||||
@@ -418,3 +435,7 @@ class WidgetState(StrictModel):
|
|||||||
current: Widget | None = None
|
current: Widget | None = None
|
||||||
current_verified: bool = False
|
current_verified: bool = False
|
||||||
writable: Literal[False] = False
|
writable: Literal[False] = False
|
||||||
|
|
||||||
|
|
||||||
|
class ImageStar(StrictModel):
|
||||||
|
starred: bool
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ class Scheduler:
|
|||||||
duration=job.display_duration,
|
duration=job.display_duration,
|
||||||
restore_previous=job.restore_previous,
|
restore_previous=job.restore_previous,
|
||||||
priority=job.priority,
|
priority=job.priority,
|
||||||
|
cleanup_unstarred=job.cleanup_unstarred,
|
||||||
),
|
),
|
||||||
).model_dump(mode="json")
|
).model_dump(mode="json")
|
||||||
)
|
)
|
||||||
@@ -253,6 +254,7 @@ class Scheduler:
|
|||||||
try:
|
try:
|
||||||
self.tick()
|
self.tick()
|
||||||
self.service.expire()
|
self.service.expire()
|
||||||
|
self.service.retention.sweep()
|
||||||
processed = self.service.process_one()
|
processed = self.service.process_one()
|
||||||
self.service.sync()
|
self.service.sync()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class Database:
|
|||||||
PRAGMA journal_mode=WAL;
|
PRAGMA journal_mode=WAL;
|
||||||
PRAGMA busy_timeout=5000;
|
PRAGMA busy_timeout=5000;
|
||||||
PRAGMA synchronous=FULL;
|
PRAGMA synchronous=FULL;
|
||||||
|
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 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);
|
CREATE TABLE IF NOT EXISTS asset_sources (asset_id TEXT PRIMARY KEY, payload TEXT NOT NULL);
|
||||||
CREATE TABLE IF NOT EXISTS image_sources (device_id TEXT, image_id INTEGER, asset_id TEXT, operation_id TEXT, payload TEXT NOT NULL, PRIMARY KEY(device_id,image_id));
|
CREATE TABLE IF NOT EXISTS image_sources (device_id TEXT, image_id INTEGER, asset_id TEXT, operation_id TEXT, payload TEXT NOT NULL, PRIMARY KEY(device_id,image_id));
|
||||||
|
|||||||
@@ -70,8 +70,8 @@ const views={
|
|||||||
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(o.action)}<br><small>${esc(o.device_id)}</small></td><td><span class="badge ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||o.error?.code||'—')}</td><td>${esc(o.client_id)}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(o.action)}<br><small>${esc(o.device_id)}</small></td><td><span class="badge ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||o.error?.code||'—')}</td><td>${esc(o.client_id)}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
||||||
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();}
|
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,...changes};}
|
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,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:$('#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 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);}
|
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);
|
$('#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(()=>{});
|
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 original=job||base;
|
||||||
const j={...base,...original,schedule:normaliseSchedule(original.schedule||base.schedule)};
|
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');
|
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><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><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>`);
|
||||||
const source=$('#job-source');
|
const source=$('#job-source');
|
||||||
const choose=document.createElement('div');
|
const choose=document.createElement('div');
|
||||||
choose.className='field';
|
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);}
|
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='';};
|
$('#job-schedule').onchange=()=>{updateScheduleField();$('#schedule-value').value='';};
|
||||||
updateScheduleField();
|
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,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:$('#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);
|
await jobSourceEditor(j);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ async function enhanceGallery(){
|
|||||||
const grid=$('.gallery');if(!grid)return;
|
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);
|
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);
|
||||||
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));};
|
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(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==='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'));});}});
|
||||||
}
|
}
|
||||||
async function weatherSettings(){
|
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;
|
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 +144,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}));
|
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){
|
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><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,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><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');});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,3 +33,6 @@ Object.assign(MESSAGES.en,{'photo-sources':'Photo sources'});
|
|||||||
|
|
||||||
Object.assign(MESSAGES.zh,{photoOrientation:'照片方向',orientationAuto:'自动识别 · 匹配画布方向',orientationLandscape:'横向照片',orientationPortrait:'竖向照片',orientationLegacy:'不筛方向(原有设置)',orientationHint:'自动按设备尺寸和旋转方向选择照片并生成画布(90°/270° 会交换宽高)。横向/竖向同时控制选图和出图方向;按 EXIF 校正尺寸筛选,正方形不匹配。不拉伸照片。'});
|
Object.assign(MESSAGES.zh,{photoOrientation:'照片方向',orientationAuto:'自动识别 · 匹配画布方向',orientationLandscape:'横向照片',orientationPortrait:'竖向照片',orientationLegacy:'不筛方向(原有设置)',orientationHint:'自动按设备尺寸和旋转方向选择照片并生成画布(90°/270° 会交换宽高)。横向/竖向同时控制选图和出图方向;按 EXIF 校正尺寸筛选,正方形不匹配。不拉伸照片。'});
|
||||||
Object.assign(MESSAGES.en,{photoOrientation:'Photo orientation',orientationAuto:'Auto · match canvas orientation',orientationLandscape:'Landscape photos',orientationPortrait:'Portrait photos',orientationLegacy:'Any orientation (existing setting)',orientationHint:'Auto follows device dimensions and rotation, swapping width/height at 90°/270°. Landscape/portrait control both photo selection and output canvas. EXIF-aware; square photos do not match. Photos are not stretched.'});
|
Object.assign(MESSAGES.en,{photoOrientation:'Photo orientation',orientationAuto:'Auto · match canvas orientation',orientationLandscape:'Landscape photos',orientationPortrait:'Portrait photos',orientationLegacy:'Any orientation (existing setting)',orientationHint:'Auto follows device dimensions and rotation, swapping width/height at 90°/270°. Landscape/portrait control both photo selection and output canvas. EXIF-aware; square photos do not match. Photos are not stretched.'});
|
||||||
|
|
||||||
|
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:'});
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* Mock current-image metadata; never performs a device write. */
|
||||||
|
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}});});
|
||||||
|
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++;
|
||||||
|
}
|
||||||
|
assert(writes===0);console.log('Current-image gallery button: '+checks+' profiles passed; disabled label, other image enabled, no display request');
|
||||||
|
}finally{await browser.close();}
|
||||||
|
})().catch(e=>{console.error(e);process.exit(1)});
|
||||||
@@ -0,0 +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)});
|
||||||
@@ -962,5 +962,21 @@
|
|||||||
"502",
|
"502",
|
||||||
"503"
|
"503"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"/api/v1/devices/{device_id}/images/{image_id}/star": {
|
||||||
|
"put": [
|
||||||
|
"200",
|
||||||
|
"400",
|
||||||
|
"401",
|
||||||
|
"403",
|
||||||
|
"404",
|
||||||
|
"409",
|
||||||
|
"413",
|
||||||
|
"422",
|
||||||
|
"429",
|
||||||
|
"500",
|
||||||
|
"502",
|
||||||
|
"503"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,6 +255,22 @@
|
|||||||
},
|
},
|
||||||
"GalleryImage": {
|
"GalleryImage": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"starred": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Starred",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Expires At"
|
||||||
|
},
|
||||||
"width": {
|
"width": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -535,6 +551,11 @@
|
|||||||
},
|
},
|
||||||
"Options": {
|
"Options": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"cleanup_unstarred": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Cleanup Unstarred",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
"duration": {
|
"duration": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"maximum": 604800.0,
|
"maximum": 604800.0,
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from pigway_frame_flow.errors import ErrorCode as E
|
||||||
|
from pigway_frame_flow.errors import FlowError
|
||||||
|
from pigway_frame_flow.models import JobInput, Schedule
|
||||||
|
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
||||||
|
from pigway_frame_flow.storage.database import encode
|
||||||
|
|
||||||
|
|
||||||
|
def upload_random(client, 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=".")
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/display",
|
||||||
|
json={
|
||||||
|
"device_id": "living-room",
|
||||||
|
"content": {"type": "photo_source", "source_id": "family"},
|
||||||
|
"options": {"cleanup_unstarred": True},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 202
|
||||||
|
service.process_one()
|
||||||
|
op = client.get("/api/v1/operations/" + r.json()["operation_id"]).json()
|
||||||
|
return op["result"]["image_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def due(service, image_id):
|
||||||
|
service.db.execute(
|
||||||
|
"UPDATE image_retention SET expires_at=? WHERE image_id=?",
|
||||||
|
((datetime.now(UTC) - timedelta(days=1)).isoformat(), image_id),
|
||||||
|
)
|
||||||
|
# The test driver switches to a different image, not a real device.
|
||||||
|
driver = service.registry.get("living-room")
|
||||||
|
driver.display(1)
|
||||||
|
service.db.execute(
|
||||||
|
"UPDATE device_state SET previous_image_id=NULL,base_image_id=NULL WHERE device_id=?",
|
||||||
|
("living-room",),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_new_random_upload_with_seven_day_expiry(client, service, settings):
|
||||||
|
image_id = upload_random(client, service, settings)
|
||||||
|
row = service.db.one("SELECT * FROM image_retention WHERE image_id=?", (image_id,))
|
||||||
|
delta = datetime.fromisoformat(row["expires_at"]) - datetime.now(UTC)
|
||||||
|
assert 6.99 < delta.total_seconds() / 86400 <= 7
|
||||||
|
assert row["upload_operation"] and not row["starred"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_star_api_and_gallery_persist(client, service, settings):
|
||||||
|
image_id = upload_random(client, service, settings)
|
||||||
|
r = client.put(
|
||||||
|
f"/api/v1/devices/living-room/images/{image_id}/star", json={"starred": True}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 and r.json()["starred"]
|
||||||
|
items = client.get("/api/v1/devices/living-room/images").json()
|
||||||
|
assert next(x for x in items if x["image_id"] == image_id)["starred"]
|
||||||
|
due(service, image_id)
|
||||||
|
service.retention.sweep()
|
||||||
|
assert (
|
||||||
|
service.db.one(
|
||||||
|
"SELECT cleanup_operation FROM image_retention WHERE image_id=?",
|
||||||
|
(image_id,),
|
||||||
|
)["cleanup_operation"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_due_cleanup_deletes_only_owned_upload(client, service, settings):
|
||||||
|
image_id = upload_random(client, service, settings)
|
||||||
|
due(service, image_id)
|
||||||
|
service.retention.sweep()
|
||||||
|
row = service.db.one("SELECT * FROM image_retention WHERE image_id=?", (image_id,))
|
||||||
|
assert row["cleanup_operation"]
|
||||||
|
service.process_one()
|
||||||
|
assert (
|
||||||
|
service.db.one("SELECT * FROM image_retention WHERE image_id=?", (image_id,))
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
assert 1 in {x.image_id for x in service.registry.get("living-room").images()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_and_restore_and_playlist_protected(client, service, settings):
|
||||||
|
image_id = upload_random(client, service, settings)
|
||||||
|
service.db.execute(
|
||||||
|
"UPDATE image_retention SET expires_at=? WHERE image_id=?",
|
||||||
|
("2000-01-01T00:00:00+00:00", image_id),
|
||||||
|
)
|
||||||
|
service.retention.sweep()
|
||||||
|
assert not service.db.one(
|
||||||
|
"SELECT cleanup_operation FROM image_retention WHERE image_id=?", (image_id,)
|
||||||
|
)["cleanup_operation"]
|
||||||
|
due(service, image_id)
|
||||||
|
service.db.execute("UPDATE device_state SET previous_image_id=?", (image_id,))
|
||||||
|
assert not service.retention.eligible("living-room", image_id)
|
||||||
|
service.db.execute("UPDATE device_state SET previous_image_id=NULL")
|
||||||
|
service.db.execute(
|
||||||
|
"INSERT INTO playlists VALUES(?,?,0,NULL)",
|
||||||
|
("p", encode({"device_id": "living-room", "image_ids": [image_id]})),
|
||||||
|
)
|
||||||
|
assert not service.retention.eligible("living-room", image_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_star_between_queue_and_execution_cancels_delete(client, service, settings):
|
||||||
|
image_id = upload_random(client, service, settings)
|
||||||
|
due(service, image_id)
|
||||||
|
service.retention.sweep()
|
||||||
|
assert (
|
||||||
|
client.put(
|
||||||
|
f"/api/v1/devices/living-room/images/{image_id}/star",
|
||||||
|
json={"starred": True},
|
||||||
|
).status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
service.process_one()
|
||||||
|
assert image_id in {
|
||||||
|
x.image_id for x in service.registry.get("living-room").images()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_delete_is_not_retried(client, service, settings, monkeypatch):
|
||||||
|
image_id = upload_random(client, service, settings)
|
||||||
|
due(service, image_id)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def unknown(id):
|
||||||
|
calls.append(id)
|
||||||
|
raise FlowError(E.WRITE_RESULT_UNKNOWN, "Unknown delete result")
|
||||||
|
|
||||||
|
monkeypatch.setattr(service.registry.get("living-room"), "delete", unknown)
|
||||||
|
service.retention.sweep()
|
||||||
|
service.process_one()
|
||||||
|
service.retention.checked = 0
|
||||||
|
service.retention.sweep()
|
||||||
|
assert calls == [image_id]
|
||||||
|
assert service.blocked("living-room")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_option_requires_random_photo_job():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
JobInput(
|
||||||
|
name="x",
|
||||||
|
schedule=Schedule(type="daily", time="07:00"),
|
||||||
|
source={"type": "template", "template": "quote"},
|
||||||
|
cleanup_unstarred=True,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user