Add starred cloud photos and opt-in seven-day random upload retention

This commit is contained in:
Pigway
2026-10-06 09:10:36 +08:00
parent 0a5b859ca0
commit c005a9444a
16 changed files with 643 additions and 7 deletions
+22
View File
@@ -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.
+212
View File
@@ -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,
+15 -1
View File
@@ -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)
+131
View File
@@ -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),
)
+22
View File
@@ -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:
+21
View File
@@ -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
+2
View File
@@ -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:
+1
View File
@@ -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));
+4 -4
View File
@@ -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);
} }
+2 -2
View File
@@ -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');});
} }
+3
View File
@@ -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:'});
+15
View File
@@ -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)});
+3
View File
@@ -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)});
+16
View File
@@ -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"
]
} }
} }
+21
View File
@@ -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,
+153
View File
@@ -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,
)