diff --git a/deploy/Dockerfile.runtime-update b/deploy/Dockerfile.runtime-update index d8d5520..2a23228 100644 --- a/deploy/Dockerfile.runtime-update +++ b/deploy/Dockerfile.runtime-update @@ -29,4 +29,4 @@ CMD ["python", "-m", "uvicorn", "pigway_frame_flow.main:app", "--host", "0.0.0.0 ARG BUILD_COMMIT ARG BUILD_TIME ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME -LABEL org.opencontainers.image.title="Pigway Frame Flow" org.opencontainers.image.version="0.1.11" org.opencontainers.image.revision=$BUILD_COMMIT +LABEL org.opencontainers.image.title="Pigway Frame Flow" org.opencontainers.image.version="0.1.12" org.opencontainers.image.revision=$BUILD_COMMIT diff --git a/docs/releases/v0.1.12.md b/docs/releases/v0.1.12.md new file mode 100644 index 0000000..96aec59 --- /dev/null +++ b/docs/releases/v0.1.12.md @@ -0,0 +1,5 @@ +# v0.1.12 + +修复临时展示到期或手动恢复后,“上一张图片”仍指向当前图片,导致已确认删除仍被恢复保护拦截的问题。只有设备已确认同步到受管理的当前图片、且没有基础恢复目标、临时展示或待确认写入时,才清除这条自身引用;不同图片的有效恢复引用继续受保护。既有残留引用在读取设备状态或删除校验时自动修正,不需要强制删除图片或重置数据库。 + +有效恢复引用仍拒绝删除,并通过结构化原因显示中英文提示。保留当前图片删除确认,不主动删除照片或切换画框。 diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index 00701f6..c2660a7 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -126,7 +126,7 @@ def create_app(settings=None, start_worker=True): app = FastAPI( title="Pigway Frame Flow API", docs_url=None, - version="0.1.11", + version="0.1.12", lifespan=lifespan, responses=error_responses, ) @@ -305,6 +305,7 @@ def create_app(settings=None, start_worker=True): ) def device(device_id: str, refresh: bool = False): d = registry.get(device_id).device(refresh) + service.clear_self_restore_reference(device_id, d) state = db.one("SELECT * FROM device_state WHERE device_id=?", (device_id,)) possible = bool( state @@ -1031,7 +1032,7 @@ def create_app(settings=None, start_worker=True): supports_upload=True, ), ], - version="0.1.11", + version="0.1.12", mock=settings.mock, write_enabled=settings.write_enabled, api_key_configured=bool(settings.api_key) diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py index 3a15815..601f6d8 100644 --- a/pigway_frame_flow/display/service.py +++ b/pigway_frame_flow/display/service.py @@ -205,6 +205,27 @@ class DisplayService: ) return self.db.one("SELECT * FROM device_state WHERE device_id=?", (id,)) + def clear_self_restore_reference(self, device_id, device): + # A confirmed return to the managed image consumes its restore target. + # Keep references while an override or an uncertain write still needs them. + image_id = device.current_content.id + if ( + device.status == "offline" + or not device.current_content.synced + or image_id is None + ): + return + with self.lock: + self.db.execute( + """UPDATE device_state SET previous_image_id=NULL + WHERE device_id=? AND previous_image_id=? AND managed_image_id=? + AND base_image_id IS NULL + AND NOT EXISTS (SELECT 1 FROM overrides WHERE device_id=?) + AND NOT EXISTS (SELECT 1 FROM operations WHERE device_id=? + AND write_started=1 AND status IN ('UPLOADING','SYNCING','UNKNOWN'))""", + (device_id, image_id, image_id, device_id, device_id), + ) + def content_asset(self, content, device_id=None, operation_id=None): if isinstance(content, AutoCollageContent): from pigway_frame_flow.extensions.auto_collage import prepare @@ -376,6 +397,7 @@ class DisplayService: def current_content(self, device_id): device = self.registry.get(device_id).device() + self.clear_self_restore_reference(device_id, device) image_id = device.current_content.id row = self.db.one( "SELECT * FROM image_sources WHERE device_id=? AND image_id=?", @@ -513,6 +535,8 @@ class DisplayService: previous = device.current_content.id state = self.state(device_id) if action == "delete": + self.clear_self_restore_reference(device_id, device) + state = self.state(device_id) image_id = payload["image_id"] if payload.get("cloud_cleanup"): if ( @@ -551,7 +575,12 @@ class DisplayService: ) } if image_id in protected and not payload.get("purge"): - raise FlowError(E.CONFLICT, "Image is needed for restoration", 409) + raise FlowError( + E.CONFLICT, + "Image is needed for restoration", + 409, + {"reason": "RESTORE_IMAGE_PROTECTED"}, + ) if image_id == previous and not payload["confirm_current"]: raise FlowError(E.CONFLICT, "Current image needs confirmation", 409) self._start_write(id) @@ -743,6 +772,7 @@ class DisplayService: and device.current_content.synced ): self.update(op["id"], "COMPLETED") + self.clear_self_restore_reference(op["device_id"], device) self.cloud_library.confirm(op["id"]) self.db.history(op, "COMPLETED") self.db.event("operation.completed", {"operation_id": op["id"]}) @@ -768,6 +798,7 @@ class DisplayService: and device.current_content.synced ): self.update(op["id"], "COMPLETED") + self.clear_self_restore_reference(op["device_id"], device) self.cloud_library.confirm(op["id"]) self.db.history(op, "COMPLETED") self.db.event("operation.completed", {"operation_id": op["id"]}) diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py index cb306cc..54198d8 100644 --- a/pigway_frame_flow/models.py +++ b/pigway_frame_flow/models.py @@ -366,7 +366,7 @@ class SettingsView(BaseModel): class Health(BaseModel): status: str - version: str = "0.1.11" + version: str = "0.1.12" class Readiness(BaseModel): @@ -396,7 +396,7 @@ class RestoreRequest(StrictModel): class Version(BaseModel): product: str = "Pigway Frame Flow" - version: str = "0.1.11" + version: str = "0.1.12" api_version: str = "v1" commit: str = "development" build_time: str = "unknown" diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js index 3bd8d25..c37d62a 100644 --- a/pigway_frame_flow/web/app.js +++ b/pigway_frame_flow/web/app.js @@ -10,6 +10,7 @@ async function loadConnection(){ } const icons={dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'}; const t=k=>window.MESSAGES[state.lang][k]||k; +function operationErrorMessage(error){const reason=error?.details?.reason;if(reason&&t(reason)!==reason)return t(reason);return error?.code&&t(error.code)!==error.code?t(error.code):(error?.message||t('error'));} const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const fmt=v=>v?new Date(v).toLocaleString(state.lang==='zh'?'zh-CN':'en-GB',{timeZone:'Asia/Shanghai'}):'—'; const padTime=(hour,minute)=>`${String(hour).padStart(2,'0')}:${String(minute).padStart(2,'0')}`; @@ -25,7 +26,7 @@ async function api(path,options={}){ if(options.body&&!(options.body instanceof FormData)){headers['Content-Type']='application/json';options.body=JSON.stringify(options.body);} const response=await fetch(path,{...options,headers}); if(!response.ok){let error;try{error=(await response.json()).error;}catch{}if(response.status===401&&error?.code!=='OFFICIAL_AUTH_REQUIRED')forgetLogin(); - const e=new Error((error?.code||response.status)+': '+(error?.code&&t(error.code)!==error.code?t(error.code):(error?.message||t('error'))));e.code=error?.code;throw e;} + const e=new Error((error?.code||response.status)+': '+operationErrorMessage(error));e.code=error?.code;throw e;} if(response.status===204)return null; return options.binary?await response.blob():await response.json(); } @@ -82,7 +83,7 @@ function login(){document.body.classList.add('login-mode');$('#page-title').text // LAN HTTP is not a secure context; getRandomValues remains available there. function clientUUID(){if(typeof crypto.randomUUID==='function')return crypto.randomUUID();const bytes=crypto.getRandomValues(new Uint8Array(16));bytes[6]=(bytes[6]&15)|64;bytes[8]=(bytes[8]&63)|128;const hex=Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;} async function accepted(path,payload){const key='web-'+clientUUID();const result=await api(path,{method:'POST',body:payload,headers:{'Idempotency-Key':key}});toast(t('accepted'));return result.operation_id;} -async function waitOperation(id){for(let n=0;n<120;n++){const operation=await api('/api/v1/operations/'+id);if(operation.status==='COMPLETED')return operation;if(['FAILED','UNKNOWN'].includes(operation.status))throw new Error(operation.status==='UNKNOWN'?t('unknown'):(operation.error?.message||t('error')));await new Promise(resolve=>setTimeout(resolve,2000));}throw new Error(t('syncing'));} +async function waitOperation(id){for(let n=0;n<120;n++){const operation=await api('/api/v1/operations/'+id);if(operation.status==='COMPLETED')return operation;if(['FAILED','UNKNOWN'].includes(operation.status))throw new Error(operation.status==='UNKNOWN'?t('unknown'):operationErrorMessage(operation.error));await new Promise(resolve=>setTimeout(resolve,2000));}throw new Error(t('syncing'));} function showModal(html){ const dialog=$('#modal'),content=$('#modal-content');content.innerHTML=html; const image=content.querySelector('img');dialog.classList.toggle('image-viewer',!!image);document.body.classList.toggle('image-viewer-open',!!image);dialog.onclose=()=>document.body.classList.remove('image-viewer-open'); diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js index d345b6a..b76ce35 100644 --- a/pigway_frame_flow/web/i18n.js +++ b/pigway_frame_flow/web/i18n.js @@ -146,3 +146,6 @@ Object.assign(MESSAGES.zh,{photosReconnectHelp:'尚未连接或会话已失效 Object.assign(MESSAGES.en,{photosReconnectHelp:'Not connected or session expired. Photos API sources and people-filtered tasks need a new sign-in before reading photos.',photosSharedDisconnectWarning:'Disconnect the shared session used by all Photos API sources and people filters? Their tasks cannot read photos until reconnected. Folder settings and separate drive connections are retained.',photosConfigure:'Configure SYNOLOGY_PHOTOS_URL and SYNOLOGY_PHOTOS_CERT_SHA256. Photos and people are read through the API.'}); Object.assign(window.MESSAGES.zh,{signIn:'登录',livingRoom:'客厅'});Object.assign(window.MESSAGES.en,{signIn:'Sign in',livingRoom:'Living room'}); + +Object.assign(MESSAGES.zh,{RESTORE_IMAGE_PROTECTED:'这张图片仍用于恢复显示,暂时不能删除。请先结束临时展示,或显示另一张图片以更新恢复目标。'}); +Object.assign(MESSAGES.en,{RESTORE_IMAGE_PROTECTED:'This image is still needed to restore the display. End the temporary display or show another image to update the restore target before deleting it.'}); diff --git a/pigway_frame_flow/web/index.html b/pigway_frame_flow/web/index.html index b236131..84f6d05 100644 --- a/pigway_frame_flow/web/index.html +++ b/pigway_frame_flow/web/index.html @@ -1,3 +1,3 @@ Pigway Frame Flow -
PIGWAY FRAME FLOW

+
PIGWAY FRAME FLOW

diff --git a/pyproject.toml b/pyproject.toml index 95c1734..332aa05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pigway-frame-flow" -version = "0.1.11" +version = "0.1.12" requires-python = ">=3.12" dependencies = ["paho-mqtt==2.1.0", "python-dateutil==2.9.0.post0", "fastapi==0.142.2", "uvicorn==0.54.0", "pillow==12.3.0", "requests==2.34.2", "croniter==6.2.4", "python-multipart==0.0.32", "pyyaml==6.0.3"] [project.optional-dependencies] diff --git a/scripts/server-release.py b/scripts/server-release.py index 16467ec..7144e6e 100644 --- a/scripts/server-release.py +++ b/scripts/server-release.py @@ -151,7 +151,7 @@ def release(lock): assert dst.execute("pragma quick_check").fetchone()[0] == "ok" base = "pigway-frame-flow:release-base-" + commit[:12] run("docker", "image", "tag", before["Image"], base) - image = "pigway-frame-flow:0.1.11-" + commit[:12] + image = "pigway-frame-flow:0.1.12-" + commit[:12] try: run( "docker", diff --git a/tests/test_display.py b/tests/test_display.py index 965c689..62a135e 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -145,6 +145,105 @@ def test_delete_restore_reference_protected(service): service.process_one() assert service.operation(delete)["status"] == "FAILED" assert service.operation(delete)["error"]["code"] == "CONFLICT" + assert ( + service.operation(delete)["error"]["details"]["reason"] + == "RESTORE_IMAGE_PROTECTED" + ) + + +@pytest.mark.parametrize("automatic", [True, False]) +def test_completed_restore_consumes_self_reference(client, service, automatic): + display = submit(service, 60, True) + service.process_one() + service.sync() + if automatic: + expired(service, display) + service.expire() + else: + service.submit("restore", {}, "living-room", "admin", "restore-test") + service.process_one() + service.sync() + assert service.state("living-room")["previous_image_id"] is None + assert not client.get("/api/v1/devices/living-room").json()["can_restore"] + delete = service.submit( + "delete", + {"image_id": 1, "confirm_current": True}, + "living-room", + "admin", + "delete-restored", + ) + service.process_one() + assert service.operation(delete)["status"] == "COMPLETED" + + +@pytest.mark.parametrize("via_delete", [False, True]) +def test_existing_self_reference_is_repaired(client, service, via_delete): + service.state("living-room") + service.db.execute( + "UPDATE device_state SET previous_image_id=1,managed_image_id=1 WHERE device_id=?", + ("living-room",), + ) + if via_delete: + operation = service.submit( + "delete", + {"image_id": 1, "confirm_current": True}, + "living-room", + "admin", + "delete-stale-reference", + ) + service.process_one() + assert service.operation(operation)["status"] == "COMPLETED" + else: + assert not client.get("/api/v1/devices/living-room?refresh=true").json()[ + "can_restore" + ] + assert service.state("living-room")["previous_image_id"] is None + + +@pytest.mark.parametrize( + "protection", + ["override", "base", "unsynced", "unknown", "syncing", "other_previous"], +) +def test_self_reference_cleanup_preserves_real_restore_requirements( + service, protection +): + service.state("living-room") + service.db.execute( + "UPDATE device_state SET previous_image_id=1,managed_image_id=1 WHERE device_id=?", + ("living-room",), + ) + device = service.registry.get("living-room").device() + if protection == "override": + service.db.execute( + "INSERT INTO overrides VALUES(?,?,?,?,?,?)", + ("active", "living-room", 1, 50, "2099-01-01", "2026-01-01"), + ) + elif protection == "base": + service.db.execute( + "UPDATE device_state SET base_image_id=1 WHERE device_id=?", + ("living-room",), + ) + elif protection == "unsynced": + device = device.model_copy( + update={ + "current_content": device.current_content.model_copy( + update={"synced": False} + ) + } + ) + elif protection in ("unknown", "syncing"): + operation = submit(service) + service._start_write(operation) + service.update(operation, protection.upper()) + else: + service.db.execute( + "UPDATE device_state SET previous_image_id=2 WHERE device_id=?", + ("living-room",), + ) + service.clear_self_restore_reference("living-room", device) + assert service.state("living-room")["previous_image_id"] == ( + 2 if protection == "other_previous" else 1 + ) def test_offline(service):