Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7600f8329 | ||
|
|
40671cae5d | ||
|
|
a86a9e2080 |
@@ -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.9" org.opencontainers.image.revision=$BUILD_COMMIT
|
||||
LABEL org.opencontainers.image.title="Pigway Frame Flow" org.opencontainers.image.version="0.1.13" org.opencontainers.image.revision=$BUILD_COMMIT
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# v0.1.11
|
||||
|
||||
照片底部单行按“日期 · 已命名人物 · 主题 · 地点 · 文件名”排列,空项省略,人物之间也使用 ` · `。主题、日期和地点沿用最近的日期文件夹解析;没有结构化日期目录时仍保留文件名。人物来自 Synology Photos 已命名人物相册的实际图片归属,按账号会话和空间缓存 15 分钟,不显示未命名人物编号。人物查询失败时保留图片及其他描述。
|
||||
|
||||
新建照片源默认开启底部文字,既有来源保留明确设置。横向来源继续同时约束选图和生成画布,不拉伸图片。更新来源表单说明与浏览描述顺序。
|
||||
|
||||
使用既有 Ubuntu Server 发布流程,备份配置、数据库与 Compose,从提交源码构建并核验部署哈希;失败恢复旧 Compose。发布过程不主动切换真实画框,不重绘历史图库图片。
|
||||
@@ -0,0 +1,5 @@
|
||||
# v0.1.12
|
||||
|
||||
修复临时展示到期或手动恢复后,“上一张图片”仍指向当前图片,导致已确认删除仍被恢复保护拦截的问题。只有设备已确认同步到受管理的当前图片、且没有基础恢复目标、临时展示或待确认写入时,才清除这条自身引用;不同图片的有效恢复引用继续受保护。既有残留引用在读取设备状态或删除校验时自动修正,不需要强制删除图片或重置数据库。
|
||||
|
||||
有效恢复引用仍拒绝删除,并通过结构化原因显示中英文提示。保留当前图片删除确认,不主动删除照片或切换画框。
|
||||
@@ -0,0 +1,3 @@
|
||||
# v0.1.13
|
||||
|
||||
照片底部同时显示所有已识别且已命名的人物,人物之间使用 ` & `;日期、人物、主题、地点及文件名之间继续使用 ` · `。例如 `2026-10-18 · Alice & Bob · Family · one.jpg`。人物仍来自 Photos 实际相册归属,未命名人物不显示;历史生成图片不自动重绘。
|
||||
@@ -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.9",
|
||||
version="0.1.13",
|
||||
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.9",
|
||||
version="0.1.13",
|
||||
mock=settings.mock,
|
||||
write_enabled=settings.write_enabled,
|
||||
api_key_configured=bool(settings.api_key)
|
||||
|
||||
@@ -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
|
||||
@@ -275,6 +296,8 @@ class DisplayService:
|
||||
data, name, caption = self.photo_sources.read(
|
||||
content.source_id, item["id"], target_size=size
|
||||
)
|
||||
if source.folder_caption:
|
||||
caption = {**(caption or {}), "filename": name}
|
||||
if (
|
||||
source.minimum_sharpness
|
||||
or source.minimum_brightness
|
||||
@@ -374,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=?",
|
||||
@@ -511,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 (
|
||||
@@ -549,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)
|
||||
@@ -741,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"]})
|
||||
@@ -766,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"]})
|
||||
|
||||
@@ -366,7 +366,7 @@ class SettingsView(BaseModel):
|
||||
|
||||
class Health(BaseModel):
|
||||
status: str
|
||||
version: str = "0.1.9"
|
||||
version: str = "0.1.13"
|
||||
|
||||
|
||||
class Readiness(BaseModel):
|
||||
@@ -396,7 +396,7 @@ class RestoreRequest(StrictModel):
|
||||
|
||||
class Version(BaseModel):
|
||||
product: str = "Pigway Frame Flow"
|
||||
version: str = "0.1.9"
|
||||
version: str = "0.1.13"
|
||||
api_version: str = "v1"
|
||||
commit: str = "development"
|
||||
build_time: str = "unknown"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import random
|
||||
@@ -618,6 +619,19 @@ class PhotoSources:
|
||||
data = self.synology.download(
|
||||
self.api_items[source_id][item["id"]], space=source.people_space
|
||||
)
|
||||
if source.folder_caption:
|
||||
caption = dict(item.get("caption") or {})
|
||||
caption["filename"] = item["name"]
|
||||
try:
|
||||
caption["people"] = " & ".join(
|
||||
self.synology.item_people(item["id"], space=source.people_space)
|
||||
)
|
||||
except FlowError:
|
||||
# Optional labels must not prevent displaying a valid photo.
|
||||
logging.getLogger(__name__).warning(
|
||||
"Photos person labels unavailable; keeping photo caption"
|
||||
)
|
||||
item = {**item, "caption": caption}
|
||||
elif source.kind in ("google-drive", "alipan", "baidu"):
|
||||
from pigway_frame_flow.providers.cloud_drives import CloudDrives
|
||||
|
||||
|
||||
@@ -315,6 +315,26 @@ class SynologyPhotos:
|
||||
merged = {key: row for group in groups for key, row in group.items()}
|
||||
return [merged[key] for key in sorted(selected, key=int)][:limit]
|
||||
|
||||
def item_people(self, item_id, *, space="shared"):
|
||||
"""Reuse verified person albums; never label an unnamed face by its ID."""
|
||||
prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto"
|
||||
with self.lock:
|
||||
key = ("caption-people", self.sid(), space)
|
||||
cached = self.cache.get(key)
|
||||
if not cached or time.monotonic() - cached[0] >= 900:
|
||||
names: dict[str, list[str]] = {}
|
||||
for person in self.people(space):
|
||||
if not person["named"]:
|
||||
continue
|
||||
for row in self.pages(
|
||||
prefix + ".Browse.Item", 7, person_id=person["id"]
|
||||
):
|
||||
item_names = names.setdefault(str(row["id"]), [])
|
||||
if person["name"] not in item_names:
|
||||
item_names.append(person["name"])
|
||||
self.cache[key] = (time.monotonic(), names)
|
||||
return list(self.cache[key][1].get(str(item_id), []))
|
||||
|
||||
def download(self, row, *, space="shared", rendition="thumbnail"):
|
||||
from pigway_frame_flow.providers.photos_cache import PhotosCache
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ def render_captioned_photo(
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
values = [
|
||||
" ".join(str(caption.get(key, "")).split())
|
||||
for key in ("title", "date", "place")
|
||||
for key in ("date", "people", "title", "place", "filename")
|
||||
]
|
||||
text = " · ".join(value for value in values if value)
|
||||
font_size = layout.font_size
|
||||
@@ -51,8 +51,8 @@ def render_captioned_photo(
|
||||
font_size -= 2
|
||||
font = font_loader(font_size)
|
||||
# Shorten the title before touching the date/location. Never wrap the band.
|
||||
while draw.textlength(text, font=font) > available and len(values[0]) > 2:
|
||||
values[0] = values[0].rstrip("…")[:-1] + "…"
|
||||
while draw.textlength(text, font=font) > available and len(values[2]) > 2:
|
||||
values[2] = values[2].rstrip("…")[:-1] + "…"
|
||||
text = " · ".join(value for value in values if value)
|
||||
while draw.textlength(text, font=font) > available and len(text) > 2:
|
||||
text = text.rstrip("…")[:-1] + "…"
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -150,13 +150,13 @@ async function photoSourcesPanel(){
|
||||
section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob));
|
||||
}
|
||||
async function photoSourceForm(source=null){
|
||||
showModal(`<h2>${t(source?'edit':'addSource')}</h2><p class="notice">${t('sourceIndependentHelp')}</p><form id="photo-source-form"><input id="source-id" type="hidden" value="${esc(source?.id||'photos-'+clientUUID())}"><div class="field"><label for="source-name">${t('name')}</label><input id="source-name" required maxlength="120" value="${esc(source?.name||'')}"></div><div class="field" ${!source||source.kind==='synology-photos'?'hidden':''}><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="synology-photos">${t('synologyAPISource')}</option><option value="webdav">WebDAV</option><option value="google-drive">Google Drive</option><option value="alipan">阿里云盘 / AliPan</option><option value="baidu">百度网盘 / Baidu</option></select></div><div class="field"><label for="source-location">${t('sourceLocation')}</label><input id="source-location" required maxlength="2048" value="${esc(source?.location||'.')}"><p class="hint">${t('sourceLocationHint')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${source?.folder_caption?'checked':''}>${t('folderCaption')}</label><label hidden><input id="source-synology-previews" type="checkbox" disabled></label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
|
||||
showModal(`<h2>${t(source?'edit':'addSource')}</h2><p class="notice">${t('sourceIndependentHelp')}</p><form id="photo-source-form"><input id="source-id" type="hidden" value="${esc(source?.id||'photos-'+clientUUID())}"><div class="field"><label for="source-name">${t('name')}</label><input id="source-name" required maxlength="120" value="${esc(source?.name||'')}"></div><div class="field" ${!source||source.kind==='synology-photos'?'hidden':''}><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="synology-photos">${t('synologyAPISource')}</option><option value="webdav">WebDAV</option><option value="google-drive">Google Drive</option><option value="alipan">阿里云盘 / AliPan</option><option value="baidu">百度网盘 / Baidu</option></select></div><div class="field"><label for="source-location">${t('sourceLocation')}</label><input id="source-location" required maxlength="2048" value="${esc(source?.location||'.')}"><p class="hint">${t('sourceLocationHint')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${!source||source.folder_caption?'checked':''}>${t('folderCaption')}</label><label hidden><input id="source-synology-previews" type="checkbox" disabled></label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
|
||||
$('#source-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'synology-photos';const update=()=>{const photosAPI=$('#source-kind').value==='synology-photos';$('#source-location').labels[0].textContent=photosAPI?tr('照片目录','Photo folder'):t('sourceLocation');$('#source-location').nextElementSibling.textContent=photosAPI?tr('填写所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。','Folder path within the selected space; . means the whole space. Downloads XL previews on demand, not originals.'):t('sourceLocationHint');const dav=!['local','synology-photos'].includes($('#source-kind').value);$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-credential').closest('.field').hidden=$('#source-kind').value!=='webdav';const oauth=$('#source-oauth');if(oauth)oauth.closest('.field').hidden=!['google-drive','alipan','baidu'].includes($('#source-kind').value);const quality=$('#source-local-quality');if(quality)quality.hidden=dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=$('#source-kind').value!=='local';$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};addPhotoQualityFields(source);$('#source-location').closest('.field').before($('#source-people-space').closest('.field'));update();$('#source-kind').onchange=update;$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';};
|
||||
$('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=!['local','synology-photos'].includes($('#source-kind').value);await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,recursive:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:false,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');});
|
||||
}
|
||||
async function browseSource(id,offset=0,refresh=false){
|
||||
const result=await api('/api/v1/photo-sources/'+id+'/images?device_id='+encodeURIComponent(state.device)+'&offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return;
|
||||
box.innerHTML=`<h4>${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}</h4><p class="hint">${t('sourceBrowseHint')}</p><div class="source-items">${result.items.map((x,i)=>`<button data-source-item="${i}">${esc(x.name)}${x.width?`<small>${x.width} × ${x.height}</small>`:''}${x.caption?`<small>${esc([x.caption.title,x.caption.date,x.caption.place].filter(Boolean).join(' · '))}</small>`:''}</button>`).join('')||t('noSourcePhotos')}</div><div class="actions"><button id="source-prev" ${offset?'':'disabled'}>${t('previousPage')}</button><button id="source-next" ${offset+result.items.length<result.total?'':'disabled'}>${t('nextPage')}</button>${buttons('source-refresh','refresh')}</div>`;
|
||||
box.innerHTML=`<h4>${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}</h4><p class="hint">${t('sourceBrowseHint')}</p><div class="source-items">${result.items.map((x,i)=>`<button data-source-item="${i}">${esc(x.name)}${x.width?`<small>${x.width} × ${x.height}</small>`:''}${x.caption?`<small>${esc([x.caption.date,x.caption.people,x.caption.title,x.caption.place,x.caption.filename].filter(Boolean).join(' · '))}</small>`:''}</button>`).join('')||t('noSourcePhotos')}</div><div class="actions"><button id="source-prev" ${offset?'':'disabled'}>${t('previousPage')}</button><button id="source-next" ${offset+result.items.length<result.total?'':'disabled'}>${t('nextPage')}</button>${buttons('source-refresh','refresh')}</div>`;
|
||||
box.querySelectorAll('[data-source-item]').forEach(b=>b.onclick=busy(()=>previewSource(id,result.items[Number(b.dataset.sourceItem)].id)));bind('source-prev',()=>browseSource(id,Math.max(0,offset-50)));bind('source-next',()=>browseSource(id,offset+50));bind('source-refresh',()=>browseSource(id,0,true));
|
||||
}
|
||||
async function previewSource(id,item_id=null){
|
||||
|
||||
@@ -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.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.en,{organizedPhotos:'Only photos in date-prefixed folders',folderCaption:'Show folder caption below the photo',folderCaptionHint:'Line 1: title without date/place. Line 2: date. Line 3: place after @, omitted when absent. Subfolders inherit their nearest dated folder.'});
|
||||
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.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(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,3 +1,3 @@
|
||||
<!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>
|
||||
<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.9</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=audit-ui-20261017-history"></script><script src="/ui/features.js?v=audit-ui-20261017-history"></script><script src="/ui/extensions.js?v=audit-ui-20261017-history"></script><script src="/ui/app.js?v=audit-ui-20261017-history"></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.13</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=people-caption-v0113"></script><script src="/ui/features.js?v=people-caption-v0113"></script><script src="/ui/extensions.js?v=people-caption-v0113"></script><script src="/ui/app.js?v=people-caption-v0113"></script></body></html>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pigway-frame-flow"
|
||||
version = "0.1.9"
|
||||
version = "0.1.13"
|
||||
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]
|
||||
|
||||
@@ -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.9-" + commit[:12]
|
||||
image = "pigway-frame-flow:0.1.13-" + commit[:12]
|
||||
try:
|
||||
run(
|
||||
"docker",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from pigway_frame_flow.renderers.photo_caption import render_captioned_photo
|
||||
from pigway_frame_flow.renderers.photo_layout import PhotoLayout, WatermarkLayout
|
||||
|
||||
|
||||
def test_watermark_includes_filename_and_recognized_people(service, monkeypatch):
|
||||
drawn = []
|
||||
original = ImageDraw.ImageDraw.text
|
||||
|
||||
def text(draw, xy, value, *args, **kwargs):
|
||||
drawn.append(value)
|
||||
return original(draw, xy, value, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(ImageDraw.ImageDraw, "text", text)
|
||||
stream = BytesIO()
|
||||
Image.new("RGB", (160, 120), "blue").save(stream, "JPEG")
|
||||
render_captioned_photo(
|
||||
stream.getvalue(),
|
||||
{
|
||||
"date": "2026-10-17",
|
||||
"title": "Family",
|
||||
"filename": "one.jpg",
|
||||
"people": "Alice & Bob",
|
||||
},
|
||||
service.renderer.font,
|
||||
size=(1600, 1200),
|
||||
)
|
||||
assert drawn == ["2026-10-17 · Alice & Bob · Family · one.jpg"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", [(1600, 1200), (1200, 1600)])
|
||||
@pytest.mark.parametrize("fit", ["contain", "cover", "smart"])
|
||||
def test_watermark_has_no_letterboxing(service, size, fit):
|
||||
|
||||
@@ -78,6 +78,65 @@ def test_people_pagination_does_not_stop_at_500(settings, monkeypatch):
|
||||
assert calls == [0, 500, 1000]
|
||||
|
||||
|
||||
def test_caption_people_use_actual_membership_and_cache(settings, monkeypatch):
|
||||
session(settings)
|
||||
provider = SynologyPhotos(settings)
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"people",
|
||||
lambda space: [
|
||||
{"id": 1, "name": "Alice", "named": True},
|
||||
{"id": 2, "name": "Bob", "named": True},
|
||||
{"id": 3, "name": "3", "named": False},
|
||||
],
|
||||
)
|
||||
calls = []
|
||||
|
||||
def pages(api, version, **params):
|
||||
calls.append(api)
|
||||
return [{"id": params["person_id"]}, {"id": 10}]
|
||||
|
||||
monkeypatch.setattr(provider, "pages", pages)
|
||||
assert provider.item_people(1) == ["Alice"]
|
||||
assert provider.item_people(10) == ["Alice", "Bob"]
|
||||
assert provider.item_people(99) == []
|
||||
assert calls == ["SYNO.FotoTeam.Browse.Item"] * 2
|
||||
assert provider.item_people(10, space="personal") == ["Alice", "Bob"]
|
||||
assert calls[-2:] == ["SYNO.Foto.Browse.Item"] * 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("labels_available", [True, False])
|
||||
def test_api_caption_keeps_filename_and_folder_with_optional_people(
|
||||
service, settings, monkeypatch, labels_available
|
||||
):
|
||||
session(settings)
|
||||
provider = service.photo_sources
|
||||
monkeypatch.setattr(
|
||||
provider.synology,
|
||||
"items",
|
||||
lambda *a, **k: [row(1, "/20261017 Family@Park", "one.jpg")],
|
||||
)
|
||||
monkeypatch.setattr(provider.synology, "download", lambda *a, **k: jpeg())
|
||||
|
||||
def names(*a, **k):
|
||||
if not labels_available:
|
||||
raise FlowError("CLOUD_UNAVAILABLE", "unavailable", 502)
|
||||
return ["Alice", "Bob"]
|
||||
|
||||
monkeypatch.setattr(provider.synology, "item_people", names)
|
||||
provider.save(
|
||||
"captions",
|
||||
PhotoSourceInput(
|
||||
name="Family", kind="synology-photos", location=".", folder_caption=True
|
||||
),
|
||||
)
|
||||
_, _, caption = provider.read("captions", "1")
|
||||
assert caption["date"] == "2026-10-17"
|
||||
assert caption["title"] == "Family"
|
||||
assert caption["filename"] == "one.jpg"
|
||||
assert caption.get("people", "") == ("Alice & Bob" if labels_available else "")
|
||||
|
||||
|
||||
def test_source_without_mount_pagination_scope_and_session(
|
||||
client, service, settings, monkeypatch
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user