diff --git a/docs/PHOTO_SOURCES.md b/docs/PHOTO_SOURCES.md
index 2d2e4ab..1d0e685 100644
--- a/docs/PHOTO_SOURCES.md
+++ b/docs/PHOTO_SOURCES.md
@@ -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.
Operation `result.photo_attempts` records source-relative item IDs, names, status
and stable error codes, including failures. This does not retry upload or switch.
+
+### Starred cloud photos and seven-day retention
+
+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.
diff --git a/docs/openapi.json b/docs/openapi.json
index f109ff2..8036573 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -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": {
"get": {
"tags": [
@@ -10094,6 +10261,22 @@
},
"GalleryImage": {
"properties": {
+ "starred": {
+ "type": "boolean",
+ "title": "Starred",
+ "default": false
+ },
+ "expires_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Expires At"
+ },
"width": {
"anyOf": [
{
@@ -10328,6 +10511,20 @@
],
"title": "ImageContent"
},
+ "ImageStar": {
+ "properties": {
+ "starred": {
+ "type": "boolean",
+ "title": "Starred"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "starred"
+ ],
+ "title": "ImageStar"
+ },
"ImageURLConfig": {
"properties": {
"allowed_hosts": {
@@ -10345,6 +10542,11 @@
},
"Job": {
"properties": {
+ "cleanup_unstarred": {
+ "type": "boolean",
+ "title": "Cleanup Unstarred",
+ "default": false
+ },
"enabled": {
"type": "boolean",
"title": "Enabled",
@@ -10481,6 +10683,11 @@
},
"JobInput": {
"properties": {
+ "cleanup_unstarred": {
+ "type": "boolean",
+ "title": "Cleanup Unstarred",
+ "default": false
+ },
"enabled": {
"type": "boolean",
"title": "Enabled",
@@ -10812,6 +11019,11 @@
},
"Options": {
"properties": {
+ "cleanup_unstarred": {
+ "type": "boolean",
+ "title": "Cleanup Unstarred",
+ "default": false
+ },
"duration": {
"type": "integer",
"maximum": 604800.0,
diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py
index b24cba3..b9d020a 100644
--- a/pigway_frame_flow/api/app.py
+++ b/pigway_frame_flow/api/app.py
@@ -37,6 +37,7 @@ from pigway_frame_flow.models import (
Health,
History,
ImageContent,
+ ImageStar,
ImageURLConfig,
Job,
JobInput,
@@ -310,7 +311,19 @@ def create_app(settings=None, start_worker=True):
dependencies=[Depends(require("images:read"))],
)
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(
"/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,
restore_previous=job.restore_previous,
priority=job.priority,
+ cleanup_unstarred=job.cleanup_unstarred,
),
)
service.validate(payload)
diff --git a/pigway_frame_flow/display/retention.py b/pigway_frame_flow/display/retention.py
new file mode 100644
index 0000000..0217861
--- /dev/null
+++ b/pigway_frame_flow/display/retention.py
@@ -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),
+ )
diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py
index bb74ff5..bed9f0e 100644
--- a/pigway_frame_flow/display/service.py
+++ b/pigway_frame_flow/display/service.py
@@ -5,6 +5,7 @@ import time
from datetime import UTC, datetime, timedelta
from uuid import uuid4
+from pigway_frame_flow.display.retention import Retention
from pigway_frame_flow.drivers.base import DriverRegistry
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
@@ -36,6 +37,7 @@ class DisplayService:
renderer,
)
self.lock = threading.RLock()
+ self.retention = Retention(self)
self.expiration_checked: dict[str, float] = {}
# A killed write cannot be replayed safely; pre-write work can resume.
db.execute(
@@ -400,6 +402,16 @@ class DisplayService:
state = self.state(device_id)
if action == "delete":
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"]} | {
x["image_id"]
for x in self.db.all(
@@ -420,6 +432,10 @@ class DisplayService:
"DELETE FROM image_sources WHERE device_id=? AND 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()
self.update(id, "COMPLETED", {"image_id": image_id})
self.db.history(op, "COMPLETED")
@@ -478,6 +494,12 @@ class DisplayService:
if asset:
self.update(id, "UPLOADING")
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:
driver.display_by_name(content.image_name)
else:
diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py
index dc8b08b..cd02965 100644
--- a/pigway_frame_flow/models.py
+++ b/pigway_frame_flow/models.py
@@ -80,6 +80,7 @@ Content = Annotated[
class Options(StrictModel):
+ cleanup_unstarred: bool = False
duration: int = Field(default=0, ge=0, le=604800)
restore_previous: bool = False
priority: int = Field(default=50, ge=0, le=100)
@@ -102,6 +103,15 @@ class DisplayRequest(StrictModel):
options: Options = Field(default_factory=Options)
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):
operation_id: str
@@ -203,6 +213,8 @@ class Device(BaseModel):
class GalleryImage(BaseModel):
+ starred: bool = False
+ expires_at: str | None = None
width: int | None = None
height: int | None = None
size_bytes: int | None = None
@@ -226,6 +238,7 @@ class RestoreContent(StrictModel):
class JobInput(StrictModel):
+ cleanup_unstarred: bool = False
enabled: bool = True
device_id: str = "living-room"
name: str = Field(min_length=1, max_length=120)
@@ -237,6 +250,10 @@ class JobInput(StrictModel):
@model_validator(mode="after")
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(
duration=self.display_duration,
restore_previous=self.restore_previous,
@@ -418,3 +435,7 @@ class WidgetState(StrictModel):
current: Widget | None = None
current_verified: bool = False
writable: Literal[False] = False
+
+
+class ImageStar(StrictModel):
+ starred: bool
diff --git a/pigway_frame_flow/scheduler/engine.py b/pigway_frame_flow/scheduler/engine.py
index d008126..dbd3516 100644
--- a/pigway_frame_flow/scheduler/engine.py
+++ b/pigway_frame_flow/scheduler/engine.py
@@ -169,6 +169,7 @@ class Scheduler:
duration=job.display_duration,
restore_previous=job.restore_previous,
priority=job.priority,
+ cleanup_unstarred=job.cleanup_unstarred,
),
).model_dump(mode="json")
)
@@ -253,6 +254,7 @@ class Scheduler:
try:
self.tick()
self.service.expire()
+ self.service.retention.sweep()
processed = self.service.process_one()
self.service.sync()
except Exception:
diff --git a/pigway_frame_flow/storage/database.py b/pigway_frame_flow/storage/database.py
index 9d2fb25..be400df 100644
--- a/pigway_frame_flow/storage/database.py
+++ b/pigway_frame_flow/storage/database.py
@@ -23,6 +23,7 @@ class Database:
PRAGMA journal_mode=WAL;
PRAGMA busy_timeout=5000;
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 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));
diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js
index 6d2abf4..4caa188 100644
--- a/pigway_frame_flow/web/app.js
+++ b/pigway_frame_flow/web/app.js
@@ -70,8 +70,8 @@ const views={
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`
${t('history')}
${buttons('history-refresh','refresh')}${ops.some(x=>x.status==='UNKNOWN')?`${t('unknown')}
`:''}${ops.length?`
${['created','action','status','result','client','requestId'].map(x=>`| ${t(x)} | `).join('')}
${ops.map((o,i)=>`| ${fmt(o.created_at)} | ${esc(o.action)} ${esc(o.device_id)} | ${t(o.status)}${o.status==='UNKNOWN'?``:''} | ${esc(o.result?.image_id||o.result?.asset_id||o.error?.code||'—')} | ${esc(o.client_id)} | ${esc(o.request_id)} ${esc(o.operation_id)} |
`).join('')}
`:`
${t('noHistory')}
`}
`;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(`${t('reconcile')}
${t('current')}: ${esc(r.current_image_id)}
${t('result')}: ${esc(r.expected_image_id)}
${t('unknown')}
${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}
`);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()+`${t('settings')}
${t('tokenHint')}
${buttons('logout','logout')}${t('auth')}
${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}
${t('refreshInterval')}
${t('apiDocs')} →GET /openapi.json · /api/v1
`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();}
};
-function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,...changes};}
-async function jobEditor(job){const j=job||{name:'',enabled:true,device_id:state.device,schedule:{type:'daily',time:'07:00'},source:{type:'template',template:'daily-dashboard',data:{}},display_duration:5400,restore_previous:true,priority:50};showModal(`${t(job?'edit':'newJob')}
`);const source=$('#job-source');const choose=document.createElement('div');choose.className='field';choose.innerHTML=``;source.parentElement.before(choose);$('#job-template').onchange=()=>{const id=$('#job-template').value;if(id)source.value=JSON.stringify({type:'template',template:id,data:{}},null,2);};function update(){const type=$('#job-schedule').value;$('#schedule-label').textContent=t(type==='cron'?'cron':['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';}$('#job-schedule').onchange=()=>{update();$('#schedule-value').value='';};update();$('#job-form').onsubmit=busy(async e=>{e.preventDefault();const type=$('#job-schedule').value,value=$('#schedule-value').value;const schedule={type};if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse($('#job-source').value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,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);}
+function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...changes};}
+async function jobEditor(job){const j=job||{name:'',enabled:true,device_id:state.device,schedule:{type:'daily',time:'07:00'},source:{type:'template',template:'daily-dashboard',data:{}},display_duration:5400,restore_previous:true,priority:50};showModal(`${t(job?'edit':'newJob')}
`);const source=$('#job-source');const choose=document.createElement('div');choose.className='field';choose.innerHTML=``;source.parentElement.before(choose);$('#job-template').onchange=()=>{const id=$('#job-template').value;if(id)source.value=JSON.stringify({type:'template',template:id,data:{}},null,2);};function update(){const type=$('#job-schedule').value;$('#schedule-label').textContent=t(type==='cron'?'cron':['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';}$('#job-schedule').onchange=()=>{update();$('#schedule-value').value='';};update();$('#job-form').onsubmit=busy(async e=>{e.preventDefault();const type=$('#job-schedule').value,value=$('#schedule-value').value;const schedule={type};if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse($('#job-source').value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:$('#job-retention').checked,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?'/'+job.id:''),{method:job?'PUT':'POST',body:payload});$('#modal').close();await navigate();});await jobSourceEditor(j);}
async function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`${t('playlists')}
`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});await playlistImagePicker(p);}
$('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#device').onchange=()=>{state.device=$('#device').value;navigate();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme);
applyTheme();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>$('#footer').innerHTML=`Pigway Frame Flow · ${t('footer')}v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}`).catch(()=>{});
@@ -83,7 +83,7 @@ async function jobEditor(job){
const original=job||base;
const j={...base,...original,schedule:normaliseSchedule(original.schedule||base.schedule)};
const scheduleValue=j.schedule.time||j.schedule.cron||(j.schedule.at?j.schedule.at.slice(0,16):'07:00');
- showModal(`${t(job?'edit':'newJob')}
`);
+ showModal(`${t(job?'edit':'newJob')}
`);
const source=$('#job-source');
const choose=document.createElement('div');
choose.className='field';
@@ -93,7 +93,7 @@ async function jobEditor(job){
function updateScheduleField(){const type=$('#job-schedule').value;$('#schedule-label').textContent=type==='cron'?scheduleTypeLabel(type):t(['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';$('#schedule-hint').textContent=scheduleHint(type);}
$('#job-schedule').onchange=()=>{updateScheduleField();$('#schedule-value').value='';};
updateScheduleField();
- $('#job-form').onsubmit=busy(async event=>{event.preventDefault();const type=$('#job-schedule').value;const value=$('#schedule-value').value;const schedule={type};if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse(source.value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,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);
}
diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js
index 821c3bc..0615980 100644
--- a/pigway_frame_flow/web/features.js
+++ b/pigway_frame_flow/web/features.js
@@ -33,7 +33,7 @@ async function enhanceGallery(){
const grid=$('.gallery');if(!grid)return;
const controls=document.createElement('div');controls.className='form-grid';controls.innerHTML=``;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));};
- 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(){
const weather=await api('/api/v1/providers/weather');const box=document.createElement('section');box.className='card';box.innerHTML=`${t('weatherSettings')}
`;$('#main').append(box);$('#weather-provider').value=weather.config.provider;
@@ -144,5 +144,5 @@ async function previewSource(id,item_id=null){
showModal(`${t('preview')}
![${t('preview')}]()
${t('sourcePreviewHint')}
${buttons('modal-cancel','cancel')}${buttons('source-display','display',true)}
`);await blobImage($('#source-preview'),op.result.url);$('#source-display').disabled=!liveWriteAllowed();bind('source-display',()=>displayDialog({type:'image',asset_id:op.result.asset_id}));
}
function sourceJobForm(id){
- showModal(`${t('sourceJob')}
`);$('#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(`${t('sourceJob')}
`);$('#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');});
}
diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js
index ce678fe..c1ceea3 100644
--- a/pigway_frame_flow/web/i18n.js
+++ b/pigway_frame_flow/web/i18n.js
@@ -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.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:'});
diff --git a/scripts/ui-gallery-stars.cjs b/scripts/ui-gallery-stars.cjs
new file mode 100644
index 0000000..d2d9fd3
--- /dev/null
+++ b/scripts/ui-gallery-stars.cjs
@@ -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)});
diff --git a/scripts/ui-photo-retention.cjs b/scripts/ui-photo-retention.cjs
new file mode 100644
index 0000000..574ec0f
--- /dev/null
+++ b/scripts/ui-photo-retention.cjs
@@ -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)});
diff --git a/tests/contract.json b/tests/contract.json
index 5bdce9c..85e0490 100644
--- a/tests/contract.json
+++ b/tests/contract.json
@@ -962,5 +962,21 @@
"502",
"503"
]
+ },
+ "/api/v1/devices/{device_id}/images/{image_id}/star": {
+ "put": [
+ "200",
+ "400",
+ "401",
+ "403",
+ "404",
+ "409",
+ "413",
+ "422",
+ "429",
+ "500",
+ "502",
+ "503"
+ ]
}
}
diff --git a/tests/schema-contract.json b/tests/schema-contract.json
index fc3d749..4082c5d 100644
--- a/tests/schema-contract.json
+++ b/tests/schema-contract.json
@@ -255,6 +255,22 @@
},
"GalleryImage": {
"properties": {
+ "starred": {
+ "type": "boolean",
+ "title": "Starred",
+ "default": false
+ },
+ "expires_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Expires At"
+ },
"width": {
"anyOf": [
{
@@ -535,6 +551,11 @@
},
"Options": {
"properties": {
+ "cleanup_unstarred": {
+ "type": "boolean",
+ "title": "Cleanup Unstarred",
+ "default": false
+ },
"duration": {
"type": "integer",
"maximum": 604800.0,
diff --git a/tests/test_retention.py b/tests/test_retention.py
new file mode 100644
index 0000000..0ba04b3
--- /dev/null
+++ b/tests/test_retention.py
@@ -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,
+ )