Compare commits

..
3 Commits
19 changed files with 202 additions and 26 deletions
+1 -1
View File
@@ -29,4 +29,4 @@ CMD ["python", "-m", "uvicorn", "pigway_frame_flow.main:app", "--host", "0.0.0.0
ARG BUILD_COMMIT ARG BUILD_COMMIT
ARG BUILD_TIME ARG BUILD_TIME
ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$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.14" org.opencontainers.image.revision=$BUILD_COMMIT
+5
View File
@@ -0,0 +1,5 @@
# v0.1.12
修复临时展示到期或手动恢复后,“上一张图片”仍指向当前图片,导致已确认删除仍被恢复保护拦截的问题。只有设备已确认同步到受管理的当前图片、且没有基础恢复目标、临时展示或待确认写入时,才清除这条自身引用;不同图片的有效恢复引用继续受保护。既有残留引用在读取设备状态或删除校验时自动修正,不需要强制删除图片或重置数据库。
有效恢复引用仍拒绝删除,并通过结构化原因显示中英文提示。保留当前图片删除确认,不主动删除照片或切换画框。
+3
View File
@@ -0,0 +1,3 @@
# v0.1.13
照片底部同时显示所有已识别且已命名的人物,人物之间使用 ` & `;日期、人物、主题、地点及文件名之间继续使用 ` · `。例如 `2026-10-18 · Alice & Bob · Family · one.jpg`。人物仍来自 Photos 实际相册归属,未命名人物不显示;历史生成图片不自动重绘。
+5
View File
@@ -0,0 +1,5 @@
# v0.1.14
底部文字仅显示日期、已命名人物(使用 & 连接)、文件夹主题及地点,不显示原始文件名。原始文件名仍用于图库和诊断记录。
上传传输区分明确的 DNS/连接建立失败与已建立连接中断:前者尚未发送 HTTP 写入,最多重试三次,失败后结束该次操作,不进入阻塞后续写入的 UNKNOWN;读超时或连接中断等不确定结果继续保留核对保护,不盲目重放上传。既有未知操作通过只读设备/图库核验后使用原有 acknowledge 流程关闭,保留原始错误和审计记录。
+3 -2
View File
@@ -126,7 +126,7 @@ def create_app(settings=None, start_worker=True):
app = FastAPI( app = FastAPI(
title="Pigway Frame Flow API", title="Pigway Frame Flow API",
docs_url=None, docs_url=None,
version="0.1.11", version="0.1.14",
lifespan=lifespan, lifespan=lifespan,
responses=error_responses, responses=error_responses,
) )
@@ -305,6 +305,7 @@ def create_app(settings=None, start_worker=True):
) )
def device(device_id: str, refresh: bool = False): def device(device_id: str, refresh: bool = False):
d = registry.get(device_id).device(refresh) 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,)) state = db.one("SELECT * FROM device_state WHERE device_id=?", (device_id,))
possible = bool( possible = bool(
state state
@@ -1031,7 +1032,7 @@ def create_app(settings=None, start_worker=True):
supports_upload=True, supports_upload=True,
), ),
], ],
version="0.1.11", version="0.1.14",
mock=settings.mock, mock=settings.mock,
write_enabled=settings.write_enabled, write_enabled=settings.write_enabled,
api_key_configured=bool(settings.api_key) api_key_configured=bool(settings.api_key)
+32 -3
View File
@@ -205,6 +205,27 @@ class DisplayService:
) )
return self.db.one("SELECT * FROM device_state WHERE device_id=?", (id,)) 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): def content_asset(self, content, device_id=None, operation_id=None):
if isinstance(content, AutoCollageContent): if isinstance(content, AutoCollageContent):
from pigway_frame_flow.extensions.auto_collage import prepare from pigway_frame_flow.extensions.auto_collage import prepare
@@ -275,8 +296,6 @@ class DisplayService:
data, name, caption = self.photo_sources.read( data, name, caption = self.photo_sources.read(
content.source_id, item["id"], target_size=size content.source_id, item["id"], target_size=size
) )
if source.folder_caption:
caption = {**(caption or {}), "filename": name}
if ( if (
source.minimum_sharpness source.minimum_sharpness
or source.minimum_brightness or source.minimum_brightness
@@ -376,6 +395,7 @@ class DisplayService:
def current_content(self, device_id): def current_content(self, device_id):
device = self.registry.get(device_id).device() device = self.registry.get(device_id).device()
self.clear_self_restore_reference(device_id, device)
image_id = device.current_content.id image_id = device.current_content.id
row = self.db.one( row = self.db.one(
"SELECT * FROM image_sources WHERE device_id=? AND image_id=?", "SELECT * FROM image_sources WHERE device_id=? AND image_id=?",
@@ -513,6 +533,8 @@ class DisplayService:
previous = device.current_content.id previous = device.current_content.id
state = self.state(device_id) state = self.state(device_id)
if action == "delete": if action == "delete":
self.clear_self_restore_reference(device_id, device)
state = self.state(device_id)
image_id = payload["image_id"] image_id = payload["image_id"]
if payload.get("cloud_cleanup"): if payload.get("cloud_cleanup"):
if ( if (
@@ -551,7 +573,12 @@ class DisplayService:
) )
} }
if image_id in protected and not payload.get("purge"): 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"]: if image_id == previous and not payload["confirm_current"]:
raise FlowError(E.CONFLICT, "Current image needs confirmation", 409) raise FlowError(E.CONFLICT, "Current image needs confirmation", 409)
self._start_write(id) self._start_write(id)
@@ -743,6 +770,7 @@ class DisplayService:
and device.current_content.synced and device.current_content.synced
): ):
self.update(op["id"], "COMPLETED") self.update(op["id"], "COMPLETED")
self.clear_self_restore_reference(op["device_id"], device)
self.cloud_library.confirm(op["id"]) self.cloud_library.confirm(op["id"])
self.db.history(op, "COMPLETED") self.db.history(op, "COMPLETED")
self.db.event("operation.completed", {"operation_id": op["id"]}) self.db.event("operation.completed", {"operation_id": op["id"]})
@@ -768,6 +796,7 @@ class DisplayService:
and device.current_content.synced and device.current_content.synced
): ):
self.update(op["id"], "COMPLETED") self.update(op["id"], "COMPLETED")
self.clear_self_restore_reference(op["device_id"], device)
self.cloud_library.confirm(op["id"]) self.cloud_library.confirm(op["id"])
self.db.history(op, "COMPLETED") self.db.history(op, "COMPLETED")
self.db.event("operation.completed", {"operation_id": op["id"]}) self.db.event("operation.completed", {"operation_id": op["id"]})
+10 -1
View File
@@ -10,6 +10,7 @@ from collections import deque
from urllib.parse import quote from urllib.parse import quote
import requests import requests
from urllib3.exceptions import MaxRetryError, NewConnectionError
from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError from pigway_frame_flow.errors import FlowError
@@ -124,7 +125,15 @@ class SafeTransport:
), ),
"request_error", "request_error",
) )
not_sent = isinstance(exc, requests.ConnectTimeout) # A socket/DNS establishment failure precedes the HTTP write. A
# dropped established connection still has an uncertain outcome.
not_sent = isinstance(exc, requests.ConnectTimeout) or any(
isinstance(cause, MaxRetryError)
and isinstance(cause.reason, NewConnectionError)
for cause in exc.args
)
if not_sent and category == "connection_error":
category = "connect_error"
uncertain = write and not not_sent uncertain = write and not not_sent
raise FlowError( raise FlowError(
E.WRITE_RESULT_UNKNOWN if uncertain else E.CLOUD_UNAVAILABLE, E.WRITE_RESULT_UNKNOWN if uncertain else E.CLOUD_UNAVAILABLE,
+2 -2
View File
@@ -366,7 +366,7 @@ class SettingsView(BaseModel):
class Health(BaseModel): class Health(BaseModel):
status: str status: str
version: str = "0.1.11" version: str = "0.1.14"
class Readiness(BaseModel): class Readiness(BaseModel):
@@ -396,7 +396,7 @@ class RestoreRequest(StrictModel):
class Version(BaseModel): class Version(BaseModel):
product: str = "Pigway Frame Flow" product: str = "Pigway Frame Flow"
version: str = "0.1.11" version: str = "0.1.14"
api_version: str = "v1" api_version: str = "v1"
commit: str = "development" commit: str = "development"
build_time: str = "unknown" build_time: str = "unknown"
+5 -3
View File
@@ -621,9 +621,8 @@ class PhotoSources:
) )
if source.folder_caption: if source.folder_caption:
caption = dict(item.get("caption") or {}) caption = dict(item.get("caption") or {})
caption["filename"] = item["name"]
try: try:
caption["people"] = " · ".join( caption["people"] = " & ".join(
self.synology.item_people(item["id"], space=source.people_space) self.synology.item_people(item["id"], space=source.people_space)
) )
except FlowError: except FlowError:
@@ -631,7 +630,10 @@ class PhotoSources:
logging.getLogger(__name__).warning( logging.getLogger(__name__).warning(
"Photos person labels unavailable; keeping photo caption" "Photos person labels unavailable; keeping photo caption"
) )
item = {**item, "caption": caption} item = {
**item,
"caption": {k: v for k, v in caption.items() if v} or None,
}
elif source.kind in ("google-drive", "alipan", "baidu"): elif source.kind in ("google-drive", "alipan", "baidu"):
from pigway_frame_flow.providers.cloud_drives import CloudDrives from pigway_frame_flow.providers.cloud_drives import CloudDrives
+1 -1
View File
@@ -41,7 +41,7 @@ def render_captioned_photo(
draw = ImageDraw.Draw(canvas) draw = ImageDraw.Draw(canvas)
values = [ values = [
" ".join(str(caption.get(key, "")).split()) " ".join(str(caption.get(key, "")).split())
for key in ("date", "people", "title", "place", "filename") for key in ("date", "people", "title", "place")
] ]
text = " · ".join(value for value in values if value) text = " · ".join(value for value in values if value)
font_size = layout.font_size font_size = layout.font_size
+3 -2
View File
@@ -10,6 +10,7 @@ async function loadConnection(){
} }
const icons={dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'}; const icons={dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'};
const t=k=>window.MESSAGES[state.lang][k]||k; 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmt=v=>v?new Date(v).toLocaleString(state.lang==='zh'?'zh-CN':'en-GB',{timeZone:'Asia/Shanghai'}):'—'; 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')}`; 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);} 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}); 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(); 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; if(response.status===204)return null;
return options.binary?await response.blob():await response.json(); 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. // 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)}`;} 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 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){ function showModal(html){
const dialog=$('#modal'),content=$('#modal-content');content.innerHTML=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'); 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');
+5 -2
View File
@@ -20,8 +20,8 @@ Object.assign(MESSAGES.en,{officialContentTitle:'KoKonna official widgets and su
Object.assign(MESSAGES.zh,{photoSources:'照片数据源',sourceHint:'通过 Synology Photos API 管理照片来源、人物和筛选规则。浏览与预览不会改变画框。',addSource:'添加数据源',browse:'浏览照片',randomPreview:'随机选图预览',sourceJob:'定时随机照片',noSources:'暂无照片,请添加数据源或检查目录。',sourceKind:'数据源类型',sourceLocation:'目录 / WebDAV URL',sourceLocationHint:'Synology Photos:所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。其他云盘使用对应目录地址或 ID。',sourceCredential:'WebDAV 凭据环境变量名',sourceCredentialHint:'仅填写变量名,禁止填写密码。管理员通过环境变量或 Secret 配置 JSON username/password。',sourceRecursive:'包含子目录',sourceImages:'可用照片',sourceBrowseHint:'按需读取目录,点击文件名才生成预览;不会下载全部原图。',previousPage:'上一页',nextPage:'下一页',sourcePreviewHint:'这是已选中照片的预览,显示时使用同一张照片。',randomPhoto:'随机照片',sourceJobHint:'每次执行重新随机选图。默认暂停;启用后会按时改变画框显示。'}); Object.assign(MESSAGES.zh,{photoSources:'照片数据源',sourceHint:'通过 Synology Photos API 管理照片来源、人物和筛选规则。浏览与预览不会改变画框。',addSource:'添加数据源',browse:'浏览照片',randomPreview:'随机选图预览',sourceJob:'定时随机照片',noSources:'暂无照片,请添加数据源或检查目录。',sourceKind:'数据源类型',sourceLocation:'目录 / WebDAV URL',sourceLocationHint:'Synology Photos:所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。其他云盘使用对应目录地址或 ID。',sourceCredential:'WebDAV 凭据环境变量名',sourceCredentialHint:'仅填写变量名,禁止填写密码。管理员通过环境变量或 Secret 配置 JSON username/password。',sourceRecursive:'包含子目录',sourceImages:'可用照片',sourceBrowseHint:'按需读取目录,点击文件名才生成预览;不会下载全部原图。',previousPage:'上一页',nextPage:'下一页',sourcePreviewHint:'这是已选中照片的预览,显示时使用同一张照片。',randomPhoto:'随机照片',sourceJobHint:'每次执行重新随机选图。默认暂停;启用后会按时改变画框显示。'});
Object.assign(MESSAGES.en,{photoSources:'Photo sources',sourceHint:'Manage photo sources, people and filters through the Synology Photos API. Browsing and previewing do not change the frame.',addSource:'Add source',browse:'Browse photos',randomPreview:'Preview random photo',sourceJob:'Schedule random photos',noSources:'No photos yet. Add a source or check the folder.',sourceKind:'Source type',sourceLocation:'Folder / WebDAV URL',sourceLocationHint:'Synology Photos: folder path within the selected space; . means the whole space. Downloads XL previews on demand, not originals. Other drives use their folder URL or ID.',sourceCredential:'WebDAV credential environment name',sourceCredentialHint:'Enter a variable name, never a password. Admin configures JSON username/password via environment or Secret.',sourceRecursive:'Include subfolders',sourceImages:'Available photos',sourceBrowseHint:'Catalog loaded on demand. Click a filename to preview; originals are not downloaded in bulk.',previousPage:'Previous page',nextPage:'Next page',sourcePreviewHint:'Display uses this exact previewed photo.',randomPhoto:'Random photo',sourceJobHint:'Choose a fresh random photo each run. Paused by default; enabling changes the frame on schedule.'}); Object.assign(MESSAGES.en,{photoSources:'Photo sources',sourceHint:'Manage photo sources, people and filters through the Synology Photos API. Browsing and previewing do not change the frame.',addSource:'Add source',browse:'Browse photos',randomPreview:'Preview random photo',sourceJob:'Schedule random photos',noSources:'No photos yet. Add a source or check the folder.',sourceKind:'Source type',sourceLocation:'Folder / WebDAV URL',sourceLocationHint:'Synology Photos: folder path within the selected space; . means the whole space. Downloads XL previews on demand, not originals. Other drives use their folder URL or ID.',sourceCredential:'WebDAV credential environment name',sourceCredentialHint:'Enter a variable name, never a password. Admin configures JSON username/password via environment or Secret.',sourceRecursive:'Include subfolders',sourceImages:'Available photos',sourceBrowseHint:'Catalog loaded on demand. Click a filename to preview; originals are not downloaded in bulk.',previousPage:'Previous page',nextPage:'Next page',sourcePreviewHint:'Display uses this exact previewed photo.',randomPhoto:'Random photo',sourceJobHint:'Choose a fresh random photo each run. Paused by default; enabling changes the frame on schedule.'});
Object.assign(MESSAGES.zh,{organizedPhotos:'仅日期开头文件夹中的照片',folderCaption:'照片下方显示文件夹文字',folderCaptionHint:'底部单行显示日期、已命名人物、主题、@ 后的地点和文件名。子目录沿用最近的日期文件夹;没有日期文件夹时保留文件名,未命名人物不显示。'}); Object.assign(MESSAGES.zh,{organizedPhotos:'仅日期开头文件夹中的照片',folderCaption:'照片下方显示文件夹文字',folderCaptionHint:'底部单行显示日期、已命名人物、主题和 @ 后的地点。子目录沿用最近的日期文件夹;未命名人物不显示,不显示原始文件名。'});
Object.assign(MESSAGES.en,{organizedPhotos:'Only photos in date-prefixed folders',folderCaption:'Show folder caption below the photo',folderCaptionHint:'One footer line shows date, named people, title, place after @ and filename. Subfolders inherit their nearest dated folder; otherwise the filename is kept. Unnamed people are omitted.'}); Object.assign(MESSAGES.en,{organizedPhotos:'Only photos in date-prefixed folders',folderCaption:'Show folder caption below the photo',folderCaptionHint:'One footer line shows date, named people, title and place after @. Subfolders inherit their nearest dated folder; unnamed people and original filenames are omitted.'});
Object.assign(MESSAGES.zh,{});Object.assign(MESSAGES.en,{}); Object.assign(MESSAGES.zh,{});Object.assign(MESSAGES.en,{});
@@ -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(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(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.'});
+1 -1
View File
@@ -1,3 +1,3 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>Pigway Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head> <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>Pigway Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.11</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=photo-caption-v0111"></script><script src="/ui/features.js?v=photo-caption-v0111"></script><script src="/ui/extensions.js?v=photo-caption-v0111"></script><script src="/ui/app.js?v=photo-caption-v0111"></script></body></html> <body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.14</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=upload-caption-v0114"></script><script src="/ui/features.js?v=upload-caption-v0114"></script><script src="/ui/extensions.js?v=upload-caption-v0114"></script><script src="/ui/app.js?v=upload-caption-v0114"></script></body></html>
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "pigway-frame-flow" name = "pigway-frame-flow"
version = "0.1.11" version = "0.1.14"
requires-python = ">=3.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"] 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] [project.optional-dependencies]
+1 -1
View File
@@ -151,7 +151,7 @@ def release(lock):
assert dst.execute("pragma quick_check").fetchone()[0] == "ok" assert dst.execute("pragma quick_check").fetchone()[0] == "ok"
base = "pigway-frame-flow:release-base-" + commit[:12] base = "pigway-frame-flow:release-base-" + commit[:12]
run("docker", "image", "tag", before["Image"], base) run("docker", "image", "tag", before["Image"], base)
image = "pigway-frame-flow:0.1.11-" + commit[:12] image = "pigway-frame-flow:0.1.14-" + commit[:12]
try: try:
run( run(
"docker", "docker",
+17
View File
@@ -165,6 +165,23 @@ def test_transport_failure_records_safe_category_without_replay(exception, categ
assert s.request.call_count == (3 if category == "connect_timeout" else 1) assert s.request.call_count == (3 if category == "connect_timeout" else 1)
def test_connection_establishment_failure_is_unsent_and_bounded(transport_clock):
from urllib3.exceptions import MaxRetryError, NewConnectionError
s = session()
s.request.side_effect = requests.ConnectionError(
MaxRetryError(None, "/private", NewConnectionError(None, "private"))
)
c = KokonnaClient("secret", True, s)
with pytest.raises(FlowError) as error:
c.display(1)
assert error.value.code == "CLOUD_UNAVAILABLE"
assert error.value.details["write_outcome"] == "not_sent"
assert error.value.details["transport_error"] == "connect_error"
assert s.request.call_count == 3
assert "private" not in str(error.value.details)
@pytest.mark.parametrize("write", [False, True]) @pytest.mark.parametrize("write", [False, True])
def test_connect_timeout_retries_then_succeeds(write, transport_clock): def test_connect_timeout_retries_then_succeeds(write, transport_clock):
s = session({"success": True}) s = session({"success": True})
+99
View File
@@ -145,6 +145,105 @@ def test_delete_restore_reference_protected(service):
service.process_one() service.process_one()
assert service.operation(delete)["status"] == "FAILED" assert service.operation(delete)["status"] == "FAILED"
assert service.operation(delete)["error"]["code"] == "CONFLICT" 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): def test_offline(service):
+5 -3
View File
@@ -7,7 +7,9 @@ from pigway_frame_flow.renderers.photo_caption import render_captioned_photo
from pigway_frame_flow.renderers.photo_layout import PhotoLayout, WatermarkLayout from pigway_frame_flow.renderers.photo_layout import PhotoLayout, WatermarkLayout
def test_watermark_includes_filename_and_recognized_people(service, monkeypatch): def test_watermark_omits_raw_filename_and_includes_recognized_people(
service, monkeypatch
):
drawn = [] drawn = []
original = ImageDraw.ImageDraw.text original = ImageDraw.ImageDraw.text
@@ -24,12 +26,12 @@ def test_watermark_includes_filename_and_recognized_people(service, monkeypatch)
"date": "2026-10-17", "date": "2026-10-17",
"title": "Family", "title": "Family",
"filename": "one.jpg", "filename": "one.jpg",
"people": "Alice · Bob", "people": "Alice & Bob",
}, },
service.renderer.font, service.renderer.font,
size=(1600, 1200), size=(1600, 1200),
) )
assert drawn == ["2026-10-17 · Alice · Bob · Family · one.jpg"] assert drawn == ["2026-10-17 · Alice & Bob · Family"]
@pytest.mark.parametrize("size", [(1600, 1200), (1200, 1600)]) @pytest.mark.parametrize("size", [(1600, 1200), (1200, 1600)])
+3 -3
View File
@@ -106,7 +106,7 @@ def test_caption_people_use_actual_membership_and_cache(settings, monkeypatch):
@pytest.mark.parametrize("labels_available", [True, False]) @pytest.mark.parametrize("labels_available", [True, False])
def test_api_caption_keeps_filename_and_folder_with_optional_people( def test_api_caption_keeps_folder_and_optional_people_without_filename(
service, settings, monkeypatch, labels_available service, settings, monkeypatch, labels_available
): ):
session(settings) session(settings)
@@ -133,8 +133,8 @@ def test_api_caption_keeps_filename_and_folder_with_optional_people(
_, _, caption = provider.read("captions", "1") _, _, caption = provider.read("captions", "1")
assert caption["date"] == "2026-10-17" assert caption["date"] == "2026-10-17"
assert caption["title"] == "Family" assert caption["title"] == "Family"
assert caption["filename"] == "one.jpg" assert "filename" not in caption
assert caption.get("people", "") == ("Alice · Bob" if labels_available else "") assert caption.get("people", "") == ("Alice & Bob" if labels_available else "")
def test_source_without_mount_pagination_scope_and_session( def test_source_without_mount_pagination_scope_and_session(