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=>``).join('')}${ops.map((o,i)=>``).join('')}
${t(x)}
${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)}
`:`
${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')}

`);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')}

${t('retentionHint')}

`); 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')}

${['latitude','longitude'].map(k=>`
`).join('')}
`;$('#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')}

`);await blobImage($('#source-preview'),op.result.url);$('#source-display').disabled=!liveWriteAllowed();bind('source-display',()=>displayDialog({type:'image',asset_id:op.result.asset_id})); } function sourceJobForm(id){ - showModal(`

${t('sourceJob')}

${t('sourceJobHint')}

`);$('#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')}

${t('sourceJobHint')}

${t('retentionHint')}

`);$('#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, + )