Compare commits

..
33 Commits
Author SHA1 Message Date
渡口浪人 8aaff83b8d release: v0.1.17 photo clarity provenance and layout refinements [skip ci] 2026-10-18 15:51:43 +08:00
渡口浪人 7393325b6e Revert "fix: fit image viewer dialog width to its image"
This reverts commit db0b408680.
2026-10-18 15:46:53 +08:00
渡口浪人 db0b408680 fix: fit image viewer dialog width to its image 2026-10-18 15:34:30 +08:00
渡口浪人 2af0aea2f6 feat: show original NAS photo paths in local gallery cards 2026-10-18 15:30:38 +08:00
渡口浪人 a05260712e fix: resize gallery thumbnails for current high density cards 2026-10-18 15:17:55 +08:00
渡口浪人 c0291050be fix: use original images in cloud gallery cards 2026-10-18 15:06:01 +08:00
渡口浪人 3cff5045b2 feat: add minimum photo resolution filters for sources 2026-10-18 14:59:35 +08:00
渡口浪人 9f1c15a4c4 fix: use full resolution image for dashboard preview 2026-10-18 14:48:21 +08:00
渡口浪人 443a60ec54 fix: remove space from PIGWay sidebar brand 2026-10-18 14:41:21 +08:00
渡口浪人 9b31c1f425 fix: simplify sidebar branding with PIG Way title 2026-10-18 14:38:56 +08:00
渡口浪人 743c6afe0c fix: align settings and authentication card heights 2026-10-18 14:29:45 +08:00
渡口浪人 7bf23e5687 fix: vertically center history statuses results and actions 2026-10-18 14:28:23 +08:00
渡口浪人 8cd005fd89 fix: show target frame in a dedicated automation column 2026-10-18 14:26:59 +08:00
渡口浪人 ec14ca2f1d release: v0.1.16 with compact layouts and corrected settings columns [skip ci] 2026-10-18 14:15:46 +08:00
渡口浪人 f0a42caf45 fix: collapse empty connection result placeholders 2026-10-18 14:11:19 +08:00
渡口浪人 7579ee13d1 fix: stack settings information below account controls 2026-10-18 14:09:12 +08:00
渡口浪人 c153822479 fix: compact connection and collage controls across responsive pages 2026-10-18 14:05:02 +08:00
渡口浪人 7cb940cf36 fix: compact related settings controls into shared rows 2026-10-18 13:57:16 +08:00
渡口浪人 54a8d37f5e fix: rebalance history columns and simplify status text 2026-10-18 13:54:03 +08:00
渡口浪人 b101bb2f2d fix: simplify automation status and center row actions 2026-10-18 13:52:30 +08:00
渡口浪人 f1ce7e2cb3 fix: align gallery search and filters in one desktop row 2026-10-18 13:49:30 +08:00
渡口浪人 10534fe8b0 fix: widen shared workspace for three gallery columns 2026-10-18 13:45:24 +08:00
渡口浪人 32e8d4066e fix: size gallery cards to fit a single action row 2026-10-18 13:40:57 +08:00
渡口浪人 83837a4511 fix: widen gallery cards and compact action rows 2026-10-18 13:36:37 +08:00
渡口浪人 2b5f36019e docs: align copyright brand capitalization 2026-10-18 13:35:31 +08:00
渡口浪人 f7b6c78a10 fix: standardize brand capitalization as PIGWay 2026-10-18 13:35:10 +08:00
渡口浪人 ba91dde2e7 fix: preview gallery images on click with square thumbnails 2026-10-18 13:31:07 +08:00
渡口浪人 7c929cb955 feat: recover failed scheduled uploads with bounded persistent retries [skip ci] 2026-10-18 08:42:05 +08:00
渡口浪人 41c6897151 fix: classify unsent upload connections and omit raw photo filenames [skip ci] 2026-10-18 08:19:03 +08:00
渡口浪人 d7600f8329 fix: join recognized photo people with ampersands [skip ci] 2026-10-18 00:12:28 +08:00
渡口浪人 40671cae5d fix: release consumed restore references and explain deletion protection [skip ci] 2026-10-17 23:44:40 +08:00
渡口浪人 a86a9e2080 fix: restore photo captions and place recognized people after date [skip ci] 2026-10-17 23:28:04 +08:00
渡口浪人 bda5229183 build: exclude packaging tools from production images [skip ci] 2026-10-17 13:41:29 +08:00
53 changed files with 1424 additions and 91 deletions
+3
View File
@@ -14,3 +14,6 @@ dist
.mypy_cache
**/__pycache__
**/*.pyc
# macOS metadata at any depth
**/.DS_Store
+14 -1
View File
@@ -1,5 +1,5 @@
ARG PYTHON_BASE=python:3.14.5-slim-bookworm@sha256:ec58d916f9e24a6035cab2bdf07f6206c4cc092a16613c60597534711332d9d6
FROM ${PYTHON_BASE}
FROM ${PYTHON_BASE} AS prepared
ARG DEBIAN_MIRROR=deb.debian.org
ARG DEBIAN_SECURITY_MIRROR=deb.debian.org
ARG TARGETARCH
@@ -37,6 +37,19 @@ RUN --mount=type=cache,id=frameflow-pip-py314-${TARGETARCH},target=/root/.cache/
else python /build-tools/install.py --locked; fi
COPY pigway_frame_flow ./pigway_frame_flow
COPY vendor ./vendor
# Publish only the cleaned filesystem, without retaining build-stage layers.
RUN rm -rf /usr/local/lib/python*/site-packages/pip \
/usr/local/lib/python*/site-packages/pip-*.dist-info \
/usr/local/lib/python*/ensurepip \
&& rm -f /usr/local/bin/pip /usr/local/bin/pip[0-9]* \
&& find / -name .DS_Store -type f -delete
FROM scratch
COPY --from=prepared / /
ENV PATH=/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 TZ=Asia/Shanghai \
DATA_DIR=/data CONFIG_DIR=/config
WORKDIR /app
ARG BUILD_COMMIT=development
ARG BUILD_TIME=unknown
ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Pigway
Copyright (c) 2026 PIGWay
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+1 -1
View File
@@ -1,6 +1,6 @@
# Notices
Pigway Frame Flow is an independent project. KoKonna is a supported device
PIGWay Frame Flow is an independent project. KoKonna is a supported device
integration and is not affiliated with this project unless otherwise stated.
KoKonna names and marks belong to their respective owners.
+2 -2
View File
@@ -1,8 +1,8 @@
# Pigway Frame Flow
# PIGWay Frame Flow
[简体中文](README.zh-CN.md) | English
Pigway Frame Flow is a lightweight, self-hosted platform for programmable smart-frame automation. It runs independently on a NAS or server, with its own Web UI, image library, rendering, scheduler and REST API. KoKonna is a supported device integration; external applications do not need its credentials or SDK.
PIGWay Frame Flow is a lightweight, self-hosted platform for programmable smart-frame automation. It runs independently on a NAS or server, with its own Web UI, image library, rendering, scheduler and REST API. KoKonna is a supported device integration; external applications do not need its credentials or SDK.
### Installation
+3 -1
View File
@@ -1,4 +1,4 @@
# Pigway Frame Flow
# PIGWay Frame Flow
简体中文 | [English](README.md)
@@ -69,6 +69,8 @@ Web UI / Scheduler / REST API → DisplayService → FrameDeviceDriver → KoKon
Ticktar 集成为可选项,Frame Flow 不依赖 Ticktar 启动,也不访问其数据库。参见[外部集成契约](docs/integrations/TICKTAR.md)。
Console 监控接入提供独立只读 ZIP 插件,支持服务就绪、画框在线/电量及任务最近结果,见 [Console 插件安装说明](../pigway-console-adapter-frame-flow/README.md)。
### 存储与安全
Docker 持久化 `/data/frameflow.db`、`/data` 下的图片/生成文件/缓存,以及 `/config` 下的私有配置和会话文件。容器以 UID/GID 10333 运行,根文件系统只读,使用独立桥接网络、`unless-stopped` 重启策略和有大小限制的标准输出日志;不需要 privileged、host network 或 Docker socket。
+22 -2
View File
@@ -1,12 +1,32 @@
# Reuse a reviewed image only when the locked Python dependencies are identical.
# For a fresh installation use the repository's main Dockerfile instead.
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
FROM ${BASE_IMAGE} AS prepared
# Cleanup runs only in the disposable preparation stage.
USER 0
COPY requirements.lock /app/requirements.expected.lock
RUN python -c "from pathlib import Path; assert Path('/app/requirements.lock').read_bytes() == Path('/app/requirements.expected.lock').read_bytes(), 'Dependency lock changed; use the full Dockerfile'"
COPY pigway_frame_flow /app/pigway_frame_flow
COPY vendor /app/vendor
# Publish only the cleaned filesystem, without retaining build-stage layers.
RUN rm -rf /usr/local/lib/python*/site-packages/pip \
/usr/local/lib/python*/site-packages/pip-*.dist-info \
/usr/local/lib/python*/ensurepip \
&& rm -f /usr/local/bin/pip /usr/local/bin/pip[0-9]* \
&& find / -name .DS_Store -type f -delete
FROM scratch
COPY --from=prepared / /
ENV PATH=/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 TZ=Asia/Shanghai \
DATA_DIR=/data CONFIG_DIR=/config
WORKDIR /app
USER 10333:10333
EXPOSE 18787
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18787/health',timeout=3)"
CMD ["python", "-m", "uvicorn", "pigway_frame_flow.main:app", "--host", "0.0.0.0", "--port", "18787", "--workers", "1", "--no-access-log"]
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.17" org.opencontainers.image.revision=$BUILD_COMMIT
+1 -1
View File
@@ -9,7 +9,7 @@
duplicates, name/date/dimensions anomalies. No download-all action.
3. Stop on discrepancy. Missing model fields must be reported as unavailable.
4. Once explicitly authorized, locally render **one** 1200×1600 test image containing
PIGWAY FRAME FLOW TEST and Shanghai timestamp, no private content.
PIGWay Frame Flow TEST and Shanghai timestamp, no private content.
5. Record an operation ID and previous image; one upload only, no write retries.
6. Poll device read-only no more often than 60 seconds, respecting the 20/min budget.
Confirm imageId and synced. If sent but response lost, mark UNKNOWN, block writes,
+78 -2
View File
@@ -1,7 +1,7 @@
{
"openapi": "3.1.0",
"info": {
"title": "Pigway Frame Flow API",
"title": "PIGWay Frame Flow API",
"version": "0.1.9"
},
"paths": {
@@ -14203,6 +14203,17 @@
},
"Asset": {
"properties": {
"source_path": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Path"
},
"starred": {
"type": "boolean",
"title": "Starred",
@@ -16227,6 +16238,17 @@
],
"title": "Caption"
},
"source_path": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Path"
},
"provider_preview": {
"type": "boolean",
"title": "Provider Preview",
@@ -16346,6 +16368,19 @@
"minLength": 1,
"title": "Location"
},
"nas_root_path": {
"anyOf": [
{
"type": "string",
"maxLength": 2048,
"pattern": "^/"
},
{
"type": "null"
}
],
"title": "Nas Root Path"
},
"recursive": {
"type": "boolean",
"title": "Recursive",
@@ -16394,6 +16429,20 @@
],
"title": "Orientation"
},
"minimum_short_edge": {
"type": "integer",
"maximum": 50000.0,
"minimum": 0.0,
"title": "Minimum Short Edge",
"default": 0
},
"minimum_long_edge": {
"type": "integer",
"maximum": 50000.0,
"minimum": 0.0,
"title": "Minimum Long Edge",
"default": 0
},
"minimum_sharpness": {
"type": "number",
"maximum": 10000.0,
@@ -16499,6 +16548,19 @@
"minLength": 1,
"title": "Location"
},
"nas_root_path": {
"anyOf": [
{
"type": "string",
"maxLength": 2048,
"pattern": "^/"
},
{
"type": "null"
}
],
"title": "Nas Root Path"
},
"recursive": {
"type": "boolean",
"title": "Recursive",
@@ -16547,6 +16609,20 @@
],
"title": "Orientation"
},
"minimum_short_edge": {
"type": "integer",
"maximum": 50000.0,
"minimum": 0.0,
"title": "Minimum Short Edge",
"default": 0
},
"minimum_long_edge": {
"type": "integer",
"maximum": 50000.0,
"minimum": 0.0,
"title": "Minimum Long Edge",
"default": 0
},
"minimum_sharpness": {
"type": "number",
"maximum": 10000.0,
@@ -17409,7 +17485,7 @@
"product": {
"type": "string",
"title": "Product",
"default": "Pigway Frame Flow"
"default": "PIGWay Frame Flow"
},
"version": {
"type": "string",
+7
View File
@@ -0,0 +1,7 @@
# v0.1.11
照片底部单行按“日期 · 已命名人物 · 主题 · 地点 · 文件名”排列,空项省略,人物之间也使用 ` · `。主题、日期和地点沿用最近的日期文件夹解析;没有结构化日期目录时仍保留文件名。人物来自 Synology Photos 已命名人物相册的实际图片归属,按账号会话和空间缓存 15 分钟,不显示未命名人物编号。人物查询失败时保留图片及其他描述。
新建照片源默认开启底部文字,既有来源保留明确设置。横向来源继续同时约束选图和生成画布,不拉伸图片。更新来源表单说明与浏览描述顺序。
使用既有 Ubuntu Server 发布流程,备份配置、数据库与 Compose,从提交源码构建并核验部署哈希;失败恢复旧 Compose。发布过程不主动切换真实画框,不重绘历史图库图片。
+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 流程关闭,保留原始错误和审计记录。
+9
View File
@@ -0,0 +1,9 @@
# v0.1.15
自动照片、拼版、模板及数据模板任务在上传失败后,持久保存同一张暂存图片,并在下一次计划之前安排最多 5 次额外重试。间隔按 1、2、4、8、16 权重递增,随剩余窗口缩放,计划时间集中在窗口前半段,为上传与设备同步留出时间。重试不移动原定计划,也不复用旧操作 ID;每次独立记录操作及事件。成功后取消剩余重试,重启后沿用已有计数与截止时间;停用任务不再追加重试,删除任务取消其重试计划。
若额外重试均失败,下一次计划仍先上传同一张图;若这一次仍失败,只删除该任务未入库、未星标的生成缓存,再按照片源生成并上传新图。NAS 原照片、用户上传文件和成功图库图片不由此流程删除。新图仍失败时,进入它自己的下一轮恢复窗口。
上传结果未知不再阻塞后续独立上传。每次重试前先只读核对设备及云图库,精确确认已成功的上传不再重传;需继续尝试时关闭旧尝试并保留错误和核对审计。删除、恢复及自动清理仍保留各自的保护条件。重试窗口到期后不再启动额外上传,待同步尝试经核对结束后交给原计划执行。
任务生成图片先保存在缓存,确认任务成功后才进入本地图库;失败不会产生看似成功的本地图库记录。额外重试仍归属原任务的云端保留规则,任务列表显示最近尝试结果。
+12
View File
@@ -0,0 +1,12 @@
# v0.1.16
统一 PIGWay 品牌写法,优化图库、自动化、历史记录、设置、连接中心和拼图页面布局。
- 图库直接点击图片查看,移除重复的查看按钮;缩略图底框为正方形,卡片宽度适配单行操作按钮。
- 全站共用更宽的内容区域,宽屏图库可一行显示三张卡片;搜索、排序、来源和任务筛选合并排列。
- 自动化与历史记录状态改为仅保留颜色的文字,优化列宽与操作对齐。
- 设置与鉴权信息保持左右并排,鉴权卡片内部信息改为单列;相关设置控件紧凑排列。
- 连接中心和自动拼图短字段合并排列,按钮按内容宽度显示,空结果提示不再占据额外空间。
- 窄屏自动换行,保留图片预览、长文本和表格所需空间。
本轮界面检查覆盖 11 个主页面、本地图库及弹窗,中英文、明暗主题和四种屏幕宽度共 289 项布局检查通过;隔离环境拼图流程 8 项检查通过。生产部署后的界面复查使用隔离数据,没有执行真实画框写入。
+11
View File
@@ -0,0 +1,11 @@
# v0.1.17
- 总览预览改用原图,避免放大低分辨率缩略图。
- 云端图库缩略图升级为最大 860×860,等比缩放、JPEG 质量 88;原位替换旧缓存,仍只保留一档。点击放大继续读取原图。
- 照片源新增最小短边、最小长边参数,同时检查源照片和实际取得的预览尺寸,默认 0 不限制。
- 本地图库显示 NAS 原照片完整路径并支持换行,保留原文件名大小写和后缀;历史四张图片已按照片 ID 核实并补齐来源。
- 自动化新增“目标相框”列;历史记录的状态、结果、调用方及操作按钮垂直居中。
- 左上角统一显示 PIGWay Frame Flow,移除图标和重复品牌前缀。
- 设置与鉴权保持左右并排、卡片等高,鉴权内部单列。图片预览弹窗保留原有布局。
不修改 NAS 原照片,不主动切换真实画框。清理仅针对项目临时构建物及过期镜像,保留持久数据和近期回滚资源。
+1 -1
View File
@@ -1,4 +1,4 @@
# Pigway Frame Flow v0.1.5
# PIGWay Frame Flow v0.1.5
## 简体中文
+1 -1
View File
@@ -1,4 +1,4 @@
# Pigway Frame Flow v0.1.6
# PIGWay Frame Flow v0.1.6
## 简体中文
+1 -1
View File
@@ -1,4 +1,4 @@
# Pigway Frame Flow v0.1.7
# PIGWay Frame Flow v0.1.7
## 简体中文
+1 -1
View File
@@ -1,4 +1,4 @@
# Pigway Frame Flow v0.1.8
# PIGWay Frame Flow v0.1.8
增加 NAS 本地图库反选、放大总览图片,并统一图片查看器与界面表达。
+18 -9
View File
@@ -124,9 +124,9 @@ def create_app(settings=None, start_worker=True):
for code in [400, 401, 403, 404, 409, 413, 422, 429, 500, 502, 503]
}
app = FastAPI(
title="Pigway Frame Flow API",
title="PIGWay Frame Flow API",
docs_url=None,
version="0.1.9",
version="0.1.17",
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
@@ -420,12 +421,19 @@ def create_app(settings=None, start_worker=True):
raise FlowError(E.INVALID_REQUEST, "Invalid cloud image") from None
if thumbnail:
thumb = cached.with_name(cached.stem + "-thumb.jpg")
if not thumb.exists():
from PIL import Image
from PIL import Image
with Image.open(cached) as img:
img.thumbnail((300, 400))
img.save(thumb, "JPEG")
with Image.open(cached) as img:
# The 430px cards need up to 860px on 2x displays. Replace the
# existing cache in place so old 300px previews are upgraded.
expected_edge = min(860, max(img.size))
regenerate = not thumb.exists()
if not regenerate:
with Image.open(thumb) as existing:
regenerate = max(existing.size) != expected_edge
if regenerate:
img.thumbnail((860, 860), Image.Resampling.LANCZOS)
img.convert("RGB").save(thumb, "JPEG", quality=88, optimize=True)
return FileResponse(thumb, media_type="image/jpeg")
from PIL import Image
@@ -943,6 +951,7 @@ def create_app(settings=None, start_worker=True):
)
def delete_job(job_id: str):
db.execute("DELETE FROM jobs WHERE id=?", (job_id,))
db.execute("DELETE FROM app_config WHERE key=?", ("upload-retry:" + job_id,))
return Response(status_code=204)
@app.get(
@@ -1031,7 +1040,7 @@ def create_app(settings=None, start_worker=True):
supports_upload=True,
),
],
version="0.1.9",
version="0.1.17",
mock=settings.mock,
write_enabled=settings.write_enabled,
api_key_configured=bool(settings.api_key)
@@ -1260,7 +1269,7 @@ def create_app(settings=None, start_worker=True):
def api_docs():
response = get_swagger_ui_html(
openapi_url="/openapi.json",
title="Pigway Frame Flow · API Docs",
title="PIGWay Frame Flow · API Docs",
swagger_js_url="/ui/swagger-ui-bundle.js",
swagger_css_url="/ui/swagger-ui.css",
swagger_favicon_url='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>',
+14 -3
View File
@@ -13,18 +13,29 @@ class CloudLibrary:
def register(self, device_id, image_id, asset_id, operation_id):
operation = self.db.one(
"SELECT payload FROM operations WHERE id=?", (operation_id,)
"SELECT payload,client_id FROM operations WHERE id=?", (operation_id,)
)
payload = json.loads(operation["payload"])
content = payload.get("content", {})
job = self.db.one(
"SELECT job_id FROM job_runs WHERE operation_id=?", (operation_id,)
)
if not job and operation["client_id"] == "scheduler-retry":
job = self.db.one(
"SELECT job_id FROM asset_metadata WHERE asset_id=? AND job_id IS NOT NULL",
(asset_id,),
)
job_config = (
self.db.one("SELECT payload FROM jobs WHERE id=?", (job["job_id"],))
if job
else None
)
source = json.loads(job_config["payload"])["source"] if job_config else content
job_id = (
job["job_id"]
if job
and content.get("type") in ("photo_source", "auto_collage")
and content.get("item_id") is None
and source.get("type") in ("photo_source", "auto_collage")
and source.get("item_id") is None
else None
)
self.db.execute(
+108 -9
View File
@@ -115,7 +115,7 @@ class DisplayService:
)
return existing["operation_id"]
if (
action not in ("render", "render-ai", "local-upload")
action not in ("display", "render", "render-ai", "local-upload")
and tx.execute(
"SELECT id FROM operations WHERE device_id=? AND status='UNKNOWN' AND write_started=1 AND action!='render-ai' LIMIT 1",
(device_id,),
@@ -187,6 +187,13 @@ class DisplayService:
id,
),
)
if status == "COMPLETED":
row = self.db.one("SELECT result FROM operations WHERE id=?", (id,))
asset_id = json.loads(row["result"] or "{}").get("rendered_asset_id")
if asset_id and self.db.one(
"SELECT id FROM assets WHERE id=?", (asset_id,)
):
self.assets.promote(asset_id)
if status in ("COMPLETED", "FAILED"):
self.recovery.resolve(id)
@@ -205,12 +212,38 @@ class DisplayService:
)
return self.db.one("SELECT * FROM device_state WHERE device_id=?", (id,))
def content_asset(self, content, device_id=None, operation_id=None):
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, *, staging=False
):
if isinstance(content, AutoCollageContent):
from pigway_frame_flow.extensions.auto_collage import prepare
return self.content_asset(
prepare(self, content, device_id), device_id, operation_id
prepare(self, content, device_id),
device_id,
operation_id,
staging=staging,
)
if isinstance(content, ProviderContent):
values = {**content.data, **self.providers.get(content.provider_id)}
@@ -223,11 +256,14 @@ class DisplayService:
),
device_id,
operation_id,
staging=staging,
)
if isinstance(content, TemplateContent):
values = self.renderer.resolve(content.template, content.data)
data = self.renderer.render(content.template, values, content.profile)
asset = self.assets.put(data, content.template + ".jpg", "generated")
asset = self.assets.put(
data, content.template + ".jpg", "cache" if staging else "generated"
)
source = {
"type": "template",
"template": content.template,
@@ -356,7 +392,24 @@ class DisplayService:
"UPDATE operations SET result=? WHERE id=?",
(encode({"photo_attempts": attempts}), operation_id),
)
return self.assets.put(rendered, name)
asset = self.assets.put(
rendered, name, "cache" if staging else "images"
)
self.db.execute(
"INSERT OR REPLACE INTO asset_sources VALUES(?,?)",
(
asset["asset_id"],
encode(
{
"type": "photo_source",
"source_id": content.source_id,
"item_id": item["id"],
"source_path": item.get("source_path"),
}
),
),
)
return self.assets.info(asset["asset_id"])
raise FlowError(
E.INVALID_REQUEST, "No decodable photo found within 5 candidates"
)
@@ -369,11 +422,14 @@ class DisplayService:
else:
raise FlowError(E.INVALID_REQUEST, "Content requires renderable image data")
return self.assets.put(
convert(data, content.fit, content.profile), content.name
convert(data, content.fit, content.profile),
content.name,
"cache" if staging else "images",
)
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=?",
@@ -424,10 +480,38 @@ class DisplayService:
yield from rows
cursor_time, cursor_id = rows[-1]["created_at"], rows[-1]["id"]
def upload_retry_expired(self, op):
if op["client_id"] != "scheduler-retry" or not op["request_id"].startswith(
"upload-retry-"
):
return False
root = op["request_id"].split("-")[-2]
for row in self.db.all(
"SELECT payload FROM app_config WHERE key LIKE 'upload-retry:%'"
):
plan = json.loads(row["payload"])
if plan["root"] == root:
return now_iso() >= plan["deadline"]
return True
def process_one(self):
with self.lock:
for op in self.queued_operations():
if self.upload_retry_expired(op):
self.update(
op["id"],
"FAILED",
error={
"code": E.CONFLICT,
"message": "Upload retry window closed",
"request_id": op["request_id"],
"details": {"reason": "RETRY_WINDOW_CLOSED"},
},
)
self.db.history(op, "RETRY_WINDOW_CLOSED")
continue
if op["action"] not in (
"display",
"render",
"render-ai",
"local-upload",
@@ -511,6 +595,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 +635,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)
@@ -603,6 +694,13 @@ class DisplayService:
self.update(id, "SYNCING", {"image_id": target})
self.db.history(op, "ACCEPTED")
return
# A new upload is independent of a previous failed write. Read back its
# outcome before closing the old attempt; never replay that operation.
for pending in self.db.all(
"SELECT id FROM operations WHERE device_id=? AND status='UNKNOWN' AND write_started=1 AND action='display'",
(device_id,),
):
self.reconcile(pending["id"], acknowledge=True)
request = DisplayRequest.model_validate(payload)
content = request.content
self.update(id, "RENDERING")
@@ -619,7 +717,7 @@ class DisplayService:
)
image_id = matches[0].image_id
else:
asset = self.content_asset(content, device_id, id)
asset = self.content_asset(content, device_id, id, staging=True)
self.assets.record_origin(asset["asset_id"], op)
image_id = None
self.db.execute(
@@ -654,7 +752,6 @@ class DisplayService:
driver.display(image_id)
result = {"image_id": image_id}
if asset:
self.assets.promote(asset["asset_id"])
result["rendered_asset_id"] = asset["asset_id"]
source = self.db.one(
"SELECT payload FROM asset_sources WHERE asset_id=?",
@@ -741,6 +838,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 +864,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"]})
@@ -77,7 +77,6 @@ class UploadReconciliation:
result["image_id"] = expected
result["upload_confirmed"] = True
service.update(op["id"], "UNKNOWN", result, json.loads(op["error"] or "{}"))
service.assets.promote(result["rendered_asset_id"])
source = service.db.one(
"SELECT payload FROM asset_sources WHERE asset_id=?",
(result["rendered_asset_id"],),
+10 -1
View File
@@ -10,6 +10,7 @@ from collections import deque
from urllib.parse import quote
import requests
from urllib3.exceptions import MaxRetryError, NewConnectionError
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
@@ -124,7 +125,15 @@ class SafeTransport:
),
"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
raise FlowError(
E.WRITE_RESULT_UNKNOWN if uncertain else E.CLOUD_UNAVAILABLE,
+1
View File
@@ -43,6 +43,7 @@ class LocalRetention:
"SELECT payload FROM jobs",
"SELECT payload FROM template_config",
"SELECT payload FROM operations WHERE status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')",
"SELECT payload FROM app_config WHERE key LIKE 'upload-retry:%'",
]:
payloads += [row["payload"] for row in db.all(query)]
for payload in payloads:
+4 -3
View File
@@ -366,7 +366,7 @@ class SettingsView(BaseModel):
class Health(BaseModel):
status: str
version: str = "0.1.9"
version: str = "0.1.17"
class Readiness(BaseModel):
@@ -375,6 +375,7 @@ class Readiness(BaseModel):
class Asset(BaseModel):
source_path: str | None = None
starred: bool = False
origin: Literal["manual", "task", "external", "template", "unknown"] = "unknown"
job_id: str | None = None
@@ -395,8 +396,8 @@ class RestoreRequest(StrictModel):
class Version(BaseModel):
product: str = "Pigway Frame Flow"
version: str = "0.1.9"
product: str = "PIGWay Frame Flow"
version: str = "0.1.17"
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
@@ -31,6 +32,7 @@ class PhotoSourceInput(StrictModel):
"local", "synology-photos", "webdav", "google-drive", "alipan", "baidu"
]
location: str = Field(min_length=1, max_length=2048)
nas_root_path: str | None = Field(default=None, max_length=2048, pattern=r"^/")
recursive: bool = True
enabled: bool = True
catalog_limit: int = Field(default=10000, ge=1, le=100000)
@@ -38,6 +40,8 @@ class PhotoSourceInput(StrictModel):
organized_only: bool = False
folder_caption: bool = False
orientation: Literal["auto", "landscape", "portrait"] | None = None
minimum_short_edge: int = Field(default=0, ge=0, le=50000)
minimum_long_edge: int = Field(default=0, ge=0, le=50000)
minimum_sharpness: float = Field(default=0, ge=0, le=10000)
minimum_brightness: float = Field(default=0, ge=0, le=255)
maximum_brightness: float = Field(default=255, ge=0, le=255)
@@ -59,6 +63,7 @@ class PhotoItem(StrictModel):
id: str
name: str
caption: dict[str, str] | None = None
source_path: str | None = None
provider_preview: bool = False
orientation: Literal["landscape", "portrait", "square"] | None = None
width: int | None = None
@@ -411,6 +416,8 @@ class PhotoSources:
height,
)
)
if not self.resolution_matches(source, width, height):
continue
direction = orientation(width, height)
if wanted and direction != wanted:
continue
@@ -477,6 +484,8 @@ class PhotoSources:
rotation = row.get("additional", {}).get("orientation", 1)
if rotation in (5, 6, 7, 8):
width, height = height, width
if not self.resolution_matches(source, width, height):
continue
direction = orientation(width, height)
if wanted and (not width or not height or direction != wanted):
continue
@@ -485,6 +494,13 @@ class PhotoSources:
"id": str(row["id"]),
"name": row["filename"],
"provider_preview": True,
"source_path": (
source.nas_root_path.rstrip("/")
+ "/"
+ relative.lstrip("/")
if source.nas_root_path
else "/" + relative.lstrip("/")
),
"caption": caption if source.folder_caption else None,
"width": width,
"height": height,
@@ -559,6 +575,11 @@ class PhotoSources:
self.cache[source_id] = (time.monotonic(), items, wanted, session_identity)
return items
@staticmethod
def resolution_matches(source, width, height):
short, long = sorted((width, height))
return short >= source.minimum_short_edge and long >= source.minimum_long_edge
def read(self, source_id, item_id=None, *, target_size=(1200, 1600)):
source = self.get(source_id)
items = self.catalog(source_id, target_size=target_size)
@@ -618,6 +639,21 @@ 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 {})
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": {k: v for k, v in caption.items() if v} or None,
}
elif source.kind in ("google-drive", "alipan", "baidu"):
from pigway_frame_flow.providers.cloud_drives import CloudDrives
@@ -626,4 +662,15 @@ class PhotoSources:
data = self.assets.fetch_url(item["id"], headers=self.headers(source))
if len(data) > self.settings.max_bytes:
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
if source.minimum_short_edge or source.minimum_long_edge:
from io import BytesIO
from PIL import Image
with Image.open(BytesIO(data)) as image:
if not self.resolution_matches(source, *image.size):
raise FlowError(
E.INVALID_REQUEST,
"Photo is below the configured minimum resolution",
)
return data, item["name"], item.get("caption")
@@ -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
+17 -2
View File
@@ -1,5 +1,6 @@
import base64
import ipaddress
import json
import socket
from io import BytesIO
from urllib.parse import urlsplit
@@ -115,9 +116,17 @@ class Assets:
def promote(self, asset_id):
row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
if row and row["kind"] == "cache":
target = self.settings.data_dir / "images" / (asset_id + ".jpg")
source = self.db.one(
"SELECT payload FROM asset_sources WHERE asset_id=?", (asset_id,)
)
kind = (
"generated"
if source and json.loads(source["payload"]).get("type") == "template"
else "images"
)
target = self.settings.data_dir / kind / (asset_id + ".jpg")
self.path(asset_id).replace(target)
self.db.execute("UPDATE assets SET kind='images' WHERE id=?", (asset_id,))
self.db.execute("UPDATE assets SET kind=? WHERE id=?", (kind, asset_id))
return self.info(asset_id)
def info(self, asset_id):
@@ -125,7 +134,13 @@ class Assets:
if not row:
raise FlowError(E.NOT_FOUND, "Asset not found", 404)
meta = self.db.one("SELECT * FROM asset_metadata WHERE asset_id=?", (asset_id,))
provenance = self.db.one(
"SELECT payload FROM asset_sources WHERE asset_id=?", (asset_id,)
)
return {
"source_path": json.loads(provenance["payload"]).get("source_path")
if provenance
else None,
"starred": bool(meta and meta["starred"]),
"origin": meta["origin"] if meta else "unknown",
"job_id": meta["job_id"] if meta else None,
+3 -3
View File
@@ -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")
]
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] + "…"
+11
View File
@@ -18,6 +18,7 @@ from pigway_frame_flow.models import (
PlaylistInput,
RestoreContent,
)
from pigway_frame_flow.scheduler.upload_retries import UploadRetries
from pigway_frame_flow.storage.database import encode, now_iso
@@ -60,6 +61,7 @@ class Scheduler:
self.thread = None
self.webhook_thread = None
self.tick_lock = threading.Lock()
self.upload_retries = UploadRetries(service)
def jobs(self):
rows = [
@@ -147,6 +149,7 @@ class Scheduler:
def tick(self, current=None):
current = current or datetime.now(UTC)
with self.tick_lock:
self.upload_retries.tick(current)
for row in self.db.all(
"SELECT * FROM jobs WHERE enabled=1 AND next_run<=?",
(current.isoformat(),),
@@ -183,6 +186,10 @@ class Scheduler:
),
).model_dump(mode="json")
)
if action == "display":
payload = self.upload_retries.scheduled_payload(
row["id"], payload
)
operation_id = self.service.submit(
action,
payload,
@@ -191,6 +198,10 @@ class Scheduler:
"schedule-" + uuid4().hex,
"job-" + row["id"] + "-" + slot,
)
if action == "display":
self.upload_retries.scheduled_submitted(
row["id"], operation_id
)
following = next_run(job.schedule, current)
enabled = bool(following)
updated = job.model_copy(update={"enabled": enabled}).model_dump(
@@ -0,0 +1,246 @@
"""Durable, job-local upload retries; scheduled execution remains authoritative."""
import json
from datetime import datetime, timedelta
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.storage.database import encode
class UploadRetries:
def __init__(self, service):
self.service, self.db = service, service.db
def load(self, job_id):
row = self.db.one(
"SELECT payload FROM app_config WHERE key=?", ("upload-retry:" + job_id,)
)
return json.loads(row["payload"]) if row else None
def save(self, job_id, plan):
if plan is None:
self.db.execute(
"DELETE FROM app_config WHERE key=?", ("upload-retry:" + job_id,)
)
else:
self.db.execute(
"INSERT OR REPLACE INTO app_config VALUES(?,?)",
("upload-retry:" + job_id, encode(plan)),
)
@staticmethod
def reuse(payload, asset_id):
return {**payload, "content": {"type": "image", "asset_id": asset_id}}
def scheduled_payload(self, job_id, payload):
plan = self.load(job_id)
if plan and plan["phase"] in ("extra", "scheduled", "scheduled_running"):
return self.reuse(payload, plan["asset_id"])
return payload
def scheduled_submitted(self, job_id, operation_id):
plan = self.load(job_id)
if plan and plan["phase"] in ("extra", "scheduled", "scheduled_running"):
plan.update(phase="scheduled_running", active=operation_id)
self.save(job_id, plan)
def close_unknown(self, operation):
if operation["status"] == "UNKNOWN":
raw = self.db.one(
"SELECT * FROM operations WHERE id=?", (operation["operation_id"],)
)
driver = self.service.registry.get(operation["device_id"])
self.service.upload_reconciliation.check(
raw, driver, driver.device(refresh=True)
)
self.service.reconcile(operation["operation_id"], acknowledge=True)
return self.service.operation(operation["operation_id"])
return operation
def discard(self, asset_id):
row = self.db.one("SELECT kind FROM assets WHERE id=?", (asset_id,))
meta = self.db.one(
"SELECT starred FROM asset_metadata WHERE asset_id=?", (asset_id,)
)
# Only disposable generated staging files are owned by this recovery.
if row and row["kind"] == "cache" and not (meta and meta["starred"]):
self.service.assets.path(asset_id).unlink(missing_ok=True)
with self.db.transaction() as tx:
for table, field in (
("assets", "id"),
("asset_sources", "asset_id"),
("asset_metadata", "asset_id"),
):
tx.execute(f"DELETE FROM {table} WHERE {field}=?", (asset_id,))
def tick(self, current):
for job in self.db.all("SELECT * FROM jobs WHERE enabled=1"):
try:
self.check(job, current)
except FlowError:
# A read failure leaves the durable plan in place for a later check.
continue
def check(self, job, current):
config = json.loads(job["payload"])
if config["source"]["type"] not in (
"photo_source",
"auto_collage",
"template",
"provider",
):
return
plan = self.load(job["id"])
if plan and plan["source"] != config["source"]:
self.save(job["id"], None)
plan = None
if not plan:
if not job["last_result"] or not job["next_run"]:
return
operation = self.service.operation(job["last_result"])
asset_id = (operation["result"] or {}).get("rendered_asset_id")
if operation["status"] not in ("FAILED", "UNKNOWN") or not asset_id:
return
deadline = datetime.fromisoformat(job["next_run"])
remaining = (deadline - current).total_seconds()
if remaining <= 0:
times = []
else:
# Five increasing delays occupy the first half of the available
# window, leaving time for upload/sync before the next schedule.
unit = remaining / 62
times = [
(current + timedelta(seconds=unit * n)).isoformat()
for n in (1, 3, 7, 15, 31)
]
plan = dict(
root=operation["operation_id"],
active=operation["operation_id"],
asset_id=asset_id,
source=config["source"],
times=times,
count=0,
deadline=deadline.isoformat(),
phase="extra",
)
self.save(job["id"], plan)
operation = self.service.operation(plan["active"])
deadline = min(
datetime.fromisoformat(plan["deadline"]),
datetime.fromisoformat(job["next_run"]),
)
if (
plan["phase"] == "extra"
and current >= deadline
and operation["status"] != "COMPLETED"
):
if operation["status"] == "SYNCING":
self.service.update(
operation["operation_id"],
"UNKNOWN",
operation["result"],
operation["error"],
)
operation = self.service.operation(operation["operation_id"])
operation = self.close_unknown(operation)
if operation["status"] == "QUEUED":
self.service.update(
operation["operation_id"],
"FAILED",
error={
"code": "CONFLICT",
"message": "Upload retry window closed",
"request_id": operation["request_id"],
"details": {"reason": "RETRY_WINDOW_CLOSED"},
},
)
if operation["status"] != "COMPLETED":
plan["phase"] = "scheduled"
self.save(job["id"], plan)
return
if operation["status"] == "COMPLETED":
self.save(job["id"], None)
# Extra retries are not job_runs and do not move the next plan time.
self.db.execute(
"UPDATE jobs SET last_result=? WHERE id=?",
(operation["operation_id"], job["id"]),
)
return
if operation["status"] not in ("FAILED", "UNKNOWN"):
return
if plan["phase"] == "replacement":
# A failed replacement gets its own bounded recovery window.
self.db.execute(
"UPDATE jobs SET last_result=? WHERE id=?",
(operation["operation_id"], job["id"]),
)
self.save(job["id"], None)
return
if plan["phase"] == "scheduled_running":
operation = self.close_unknown(operation)
if operation["status"] == "COMPLETED":
return
self.discard(plan["asset_id"])
payload = self.payload(config)
key = "replacement-" + plan["root"]
op_id = self.service.submit(
"display", payload, config["device_id"], "scheduler-retry", key, key
)
plan.update(phase="replacement", active=op_id)
self.save(job["id"], plan)
self.db.execute(
"UPDATE jobs SET last_result=? WHERE id=?", (op_id, job["id"])
)
self.db.event(
"upload.regenerated",
{
"job_id": job["id"],
"operation_id": op_id,
"failed_asset_id": plan["asset_id"],
},
)
return
if current >= deadline or plan["count"] >= len(plan["times"]):
plan["phase"] = "scheduled"
self.save(job["id"], plan)
return
if current < datetime.fromisoformat(plan["times"][plan["count"]]):
return
operation = self.close_unknown(operation)
if operation["status"] == "COMPLETED":
return
number = plan["count"] + 1
key = f"upload-retry-{plan['root']}-{number}"
op_id = self.service.submit(
"display",
self.reuse(self.payload(config), plan["asset_id"]),
config["device_id"],
"scheduler-retry",
key,
key,
)
plan.update(active=op_id, count=number)
self.save(job["id"], plan)
self.db.execute("UPDATE jobs SET last_result=? WHERE id=?", (op_id, job["id"]))
self.db.event(
"upload.retry",
{
"job_id": job["id"],
"operation_id": op_id,
"attempt": number,
"deadline": deadline.isoformat(),
},
)
@staticmethod
def payload(config):
return {
"device_id": config["device_id"],
"content": config["source"],
"options": {
"duration": config["display_duration"],
"restore_previous": config["restore_previous"],
"priority": config["priority"],
"cleanup_unstarred": config.get("cleanup_unstarred", False),
},
}
+9 -8
View File
@@ -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=>({'&':'&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 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');
@@ -117,7 +118,7 @@ async function templateEditor(selected){
const reset=document.createElement('button');reset.id='reset-template';reset.textContent=t('resetTemplate');$('#save-template').after(reset);bind('reset-template',()=>confirmDialog(t('resetConfirm'),async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'DELETE'});await navigate();}));
const save=$('#save-template');const oldSave=save.onclick;save.onclick=async e=>{if([...panel.querySelectorAll('textarea'),raw].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}await oldSave(e);};
}
function accountSettings(){const section=$('#logout').parentElement;const form=document.createElement('form');form.id='password-form';form.innerHTML=`<h3>${t('changePassword')}</h3><div class="field"><label for="current-password">${t('currentPassword')}</label><input id="current-password" type="password" required autocomplete="current-password"></div><div class="field"><label for="new-password">${t('newPassword')}</label><input id="new-password" type="password" minlength="8" required autocomplete="new-password"></div><button type="submit">${t('changePassword')}</button>`;section.append(form);form.onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/auth/password',{method:'PUT',body:{current_password:$('#current-password').value,new_password:$('#new-password').value}});forgetLogin();shell();login();toast(t('passwordChanged'));});}
function accountSettings(){const section=$('#logout').parentElement;const form=document.createElement('form');form.id='password-form';form.innerHTML=`<h3>${t('changePassword')}</h3><div class="form-grid password-fields"><div class="field"><label for="current-password">${t('currentPassword')}</label><input id="current-password" type="password" required autocomplete="current-password"></div><div class="field"><label for="new-password">${t('newPassword')}</label><input id="new-password" type="password" minlength="8" required autocomplete="new-password"></div></div><button type="submit">${t('changePassword')}</button>`;section.append(form);form.onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/auth/password',{method:'PUT',body:{current_password:$('#current-password').value,new_password:$('#new-password').value}});forgetLogin();shell();login();toast(t('passwordChanged'));});}
const views={
studio:()=>layoutStudioPage(),
connections:()=>connectionsPage(),
@@ -127,7 +128,7 @@ const views={
const destinations=galleryDestinations();if(!destinations.some(d=>d.id===state.tab))state.tab=destinations[0]?.id||'local';
const key=JSON.stringify([state.device,state.tab,state.assetOffset||0,state.assetOrigin||'all',state.assetJob||'']);
const cache=state.galleryCache||(state.galleryCache=new Map());const cached=cache.get(key);
const render=(items,pending=false)=>{if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`<div class="section-heading"><div class="tabs gallery-tabs">${destinations.map(d=>`<button id="${esc(d.id)}-tab" data-gallery-destination="${esc(d.id)}" class="${state.tab===d.id?'active':''}">${esc(galleryDestinationName(d))}</button>`).join('')}</div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}<span id="gallery-list-status" class="hint" role="status" ${pending?'':'hidden'}>${pending?t('galleryListLoading'):''}</span></div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${`<div class="gallery" aria-busy="${pending}">${items.map((x,i)=>`<article class="card image-card"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"><h3 title="${esc(x.name)}">${esc(x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:t('local')} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button data-original="${i}">${t('original')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`}${!pending&&!items.length?`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`:''}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});enhanceGallery();guardDeviceButtons();hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];await openImageViewer(x.url,x.name);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(state.tab==='local'?t('localDeleteConfirm'):x.current?t('deleteCurrent'):t('cloudDeleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});};
const render=(items,pending=false)=>{if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`<div class="section-heading"><div class="tabs gallery-tabs">${destinations.map(d=>`<button id="${esc(d.id)}-tab" data-gallery-destination="${esc(d.id)}" class="${state.tab===d.id?'active':''}">${esc(galleryDestinationName(d))}</button>`).join('')}</div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}<span id="gallery-list-status" class="hint" role="status" ${pending?'':'hidden'}>${pending?t('galleryListLoading'):''}</span></div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${`<div class="gallery" aria-busy="${pending}">${items.map((x,i)=>`<article class="card image-card"><button class="image-preview" data-original="${i}" aria-label="${t('original')} · ${esc(x.name)}"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"></button><h3 class="${state.tab==='local'?'source-path':''}" title="${esc(x.source_path||x.name)}">${esc(state.tab==='local'?(x.source_path||x.name):x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:t('local')} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`}${!pending&&!items.length?`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`:''}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});enhanceGallery();guardDeviceButtons();hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];await openImageViewer(x.url,x.name);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(state.tab==='local'?t('localDeleteConfirm'):x.current?t('deleteCurrent'):t('cloudDeleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});};
render(cached||[],!cached);
try{
const items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets?limit=200&offset='+(state.assetOffset||0)+'&origin='+encodeURIComponent(state.assetOrigin||'all')+(state.assetJob?'&job_id='+encodeURIComponent(state.assetJob):''));
@@ -142,13 +143,13 @@ const views={
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="badge ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||operationErrorLabel(o.error)||'—')}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;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(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);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()+`<div class="columns"><section class="card"><h3>${t('settings')}</h3><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${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)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#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();}
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||operationErrorLabel(o.error)||'—')}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;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(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);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()+`<div class="columns settings-columns"><section class="card"><h3>${t('settings')}</h3><div class="form-grid settings-preferences"><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${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)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#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,cleanup_unstarred:j.cleanup_unstarred||false,...changes};}
async function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`<h2>${t('playlists')}</h2><form id="playlist-form"><div class="field"><label for="playlist-name">${t('name')}</label><input id="playlist-name" required maxlength="120" value="${esc(p.name)}"></div><div class="field"><label for="playlist-images">${t('imageIds')}</label><input id="playlist-images" required value="${esc(p.image_ids.join(','))}" placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval">${t('interval')}</label><input id="playlist-interval" type="number" min="5" max="10080" value="${p.interval_minutes}"></div><div class="field"><label for="playlist-mode">${t('playbackMode')}</label><select id="playlist-mode"><option value="queue" ${p.mode==='queue'?'selected':''}>${t('queue')}</option><option value="random" ${p.mode==='random'?'selected':''}>${t('random')}</option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#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();shell();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=`<span>Pigway Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`).catch(()=>{});
applyTheme();shell();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=`<span>PIGWay Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`).catch(()=>{});
setInterval(()=>{if(state.token&&state.page==='dashboard'&&!$('#modal').open)navigate();},60000);
// Keep Cron available for exceptional schedules, but make ordinary schedules form-based.
@@ -174,7 +175,7 @@ async function jobEditor(job){
views.automation=async epoch=>{
const jobs=await api('/api/v1/jobs');
if(epoch!==state.epoch)return;
$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">${state.lang==='zh'?'上海时间':'Shanghai time'} · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table><thead><tr>${['name','schedule','status','nextRun','lastRun','result','action'].map(key=>`<th>${t(key)}</th>`).join('')}</tr></thead><tbody>${jobs.map((job,index)=>`<tr><td><strong>${esc(job.name)}</strong><br><small>${esc(deviceDisplayName(state.devices.find(d=>d.id===job.device_id)||{id:job.device_id,name:job.device_id}))}</small></td><td>${esc(scheduleSummary(job.schedule))}</td><td><span class="badge">${t(job.enabled?'enabled':'disabled')}</span></td><td>${fmt(job.next_run)}</td><td>${fmt(job.last_run)}</td><td><code>${esc(job.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${index}">${t('edit')}</button><button data-job-toggle="${index}">${t(job.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${index}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;
$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">${state.lang==='zh'?'上海时间':'Shanghai time'} · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table class="automation-table"><thead><tr>${['name','targetFrame','schedule','status','nextRun','lastRun','result','action'].map(key=>`<th>${key==='targetFrame'?tr('目标相框','Target frame'):t(key)}</th>`).join('')}</tr></thead><tbody>${jobs.map((job,index)=>`<tr><td><strong>${esc(job.name)}</strong></td><td>${esc(deviceDisplayName(state.devices.find(d=>d.id===job.device_id)||{id:job.device_id,name:job.device_id}))}</td><td>${esc(scheduleSummary(job.schedule))}</td><td><span class="automation-status">${t(job.enabled?'enabled':'disabled')}</span></td><td>${fmt(job.next_run)}</td><td>${fmt(job.last_run)}</td><td><code>${esc(job.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${index}">${t('edit')}</button><button data-job-toggle="${index}">${t(job.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${index}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;
bind('new-job',()=>jobEditor());
document.querySelectorAll('[data-job-edit]').forEach(button=>button.onclick=()=>jobEditor(jobs[button.dataset.jobEdit]));
document.querySelectorAll('[data-job-toggle]').forEach(button=>button.onclick=busy(async()=>{const job=jobs[button.dataset.jobToggle];await api('/api/v1/jobs/'+job.id,{method:'PUT',body:jobPayload(job,{enabled:!job.enabled})});await navigate();}));
+1 -1
View File
@@ -49,7 +49,7 @@ async function officialWidgetControls(info,data){
const send=cover_id=>confirmDialog(tr('此操作会改变官方订阅和画框内容,是否继续?','This changes the official subscription and frame content. Continue?'),async()=>{const args=cover_id?Object.fromEntries([...document.querySelectorAll('[data-widget-arg]')].filter(x=>x.value).map(x=>[x.dataset.widgetArg,x.value])):{};const r=await api('/api/v1/devices/'+state.device+'/widgets/selection',{method:'POST',body:{cover_id,args},headers:{'Idempotency-Key':'widget-'+clientUUID()}});await waitOperation(r.operation_id);await navigate('templates');});if(!disabled){$('#official-widget-use').onclick=busy(()=>send(Number($('#official-widget-choice').value)));$('#official-widget-cancel').onclick=busy(()=>send(null));}
}
function addPhotoQualityFields(source){
const box=document.createElement('section');box.innerHTML=`<div id="source-local-quality"><h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${tr('对 API 获取的预览图按清晰度、亮度和相似度筛选,不修改或删除 NAS 原照片;无法保证识别闭眼。建议先使用较低阈值。','Filters API previews by sharpness, brightness and similarity without modifying or deleting NAS originals. Closed-eye detection is not guaranteed. Start with low thresholds.')}</p><div class="field"><label for="source-people-space">${tr('照片空间','Photo space')}</label><select id="source-people-space"><option value="shared">${tr('共享空间','Shared space')}</option><option value="personal">${tr('个人空间','Personal space')}</option></select></div><div class="form-grid"><div class="field"><label for="source-sharpness">${tr('最低清晰度(0 不筛选)','Minimum sharpness (0 disables)')}</label><input id="source-sharpness" type="number" min="0" max="10000" value="${source?.minimum_sharpness||0}"></div><div class="field"><label for="source-bright-min">${tr('最低亮度','Minimum brightness')}</label><input id="source-bright-min" type="number" min="0" max="255" value="${source?.minimum_brightness||0}"></div><div class="field"><label for="source-bright-max">${tr('最高亮度','Maximum brightness')}</label><input id="source-bright-max" type="number" min="0" max="255" value="${source?.maximum_brightness??255}"></div></div><label class="check-row"><input id="source-dedup" type="checkbox" ${source?.avoid_duplicates?'checked':''}>${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}</label></div><div class="field"><label for="source-oauth">${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
const box=document.createElement('section');box.innerHTML=`<div id="source-local-quality"><h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${tr('对 API 获取的预览图按清晰度、亮度和相似度筛选,不修改或删除 NAS 原照片;无法保证识别闭眼。建议先使用较低阈值。','Filters API previews by sharpness, brightness and similarity without modifying or deleting NAS originals. Closed-eye detection is not guaranteed. Start with low thresholds.')}</p><div class="field"><label for="source-people-space">${tr('照片空间','Photo space')}</label><select id="source-people-space"><option value="shared">${tr('共享空间','Shared space')}</option><option value="personal">${tr('个人空间','Personal space')}</option></select></div><div class="form-grid">${[['short','minimum_short_edge',tr('最小短边(像素)','Minimum short edge (px)')],['long','minimum_long_edge',tr('最小长边(像素)','Minimum long edge (px)')]].map(([id,key,label])=>`<div class="field"><label for="source-min-${id}">${label}</label><input id="source-min-${id}" type="number" min="0" max="50000" step="1" value="${source?.[key]||0}"></div>`).join('')}</div><p class="hint">${tr('0 表示不限制;按源照片和实际取得的预览图像素筛选,横竖图通用。可从短边 1200、长边 1600 开始设置;分辨率不代表对焦清晰度。','0 disables. Checks source and downloaded preview dimensions regardless of orientation. Start with short edge 1200 and long edge 1600; pixel count does not measure focus.')}</p><div class="form-grid"><div class="field"><label for="source-sharpness">${tr('最低清晰度(0 不筛选)','Minimum sharpness (0 disables)')}</label><input id="source-sharpness" type="number" min="0" max="10000" value="${source?.minimum_sharpness||0}"></div><div class="field"><label for="source-bright-min">${tr('最低亮度','Minimum brightness')}</label><input id="source-bright-min" type="number" min="0" max="255" value="${source?.minimum_brightness||0}"></div><div class="field"><label for="source-bright-max">${tr('最高亮度','Maximum brightness')}</label><input id="source-bright-max" type="number" min="0" max="255" value="${source?.maximum_brightness??255}"></div></div><label class="check-row"><input id="source-dedup" type="checkbox" ${source?.avoid_duplicates?'checked':''}>${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}</label></div><div class="field"><label for="source-oauth">${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
}
async function aiImagePanel(){
+8 -8
View File
@@ -14,7 +14,7 @@ function editTemplateSource(source){state.selectedTemplate=source.template;state
async function enhanceDashboard(){
const epoch=state.epoch;const frame=$('.frame-demo');const panel=document.createElement('section');panel.className='card';$('#main').append(panel);
const current=await currentPanel(panel);if(epoch!==state.epoch)return;
if(current.preview_url){frame.innerHTML=`<button id="current-frame-preview" class="current-frame-preview" aria-label="${t('original')} · ${t('currentContent')} #${esc(current.image_id)}"><img id="current-frame-image" alt="${t('currentContent')}"></button><span class="frame-label">${t('currentContent')} #${esc(current.image_id)}</span>`;const original=new URL(current.preview_url,location.origin);original.searchParams.delete('thumbnail');bind('current-frame-preview',()=>openImageViewer(original.pathname+original.search,`${t('currentContent')} #${current.image_id}`));void blobImage($('#current-frame-image'),current.preview_url);}else{frame.innerHTML=`<div class="mini-art"><div class="sun"></div></div><span class="frame-label">${t('testLabel')}</span>`;}
if(current.preview_url){frame.innerHTML=`<button id="current-frame-preview" class="current-frame-preview" aria-label="${t('original')} · ${t('currentContent')} #${esc(current.image_id)}"><img id="current-frame-image" alt="${t('currentContent')}"></button><span class="frame-label">${t('currentContent')} #${esc(current.image_id)}</span>`;const original=new URL(current.preview_url,location.origin);original.searchParams.delete('thumbnail');bind('current-frame-preview',()=>openImageViewer(original.pathname+original.search,`${t('currentContent')} #${current.image_id}`));void blobImage($('#current-frame-image'),original.pathname+original.search);}else{frame.innerHTML=`<div class="mini-art"><div class="sun"></div></div><span class="frame-label">${t('testLabel')}</span>`;}
const weather=await api('/api/v1/providers/weather');if(epoch!==state.epoch)return;const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('weather')}</h3><p>${esc(weather.current.location)} · ${esc(weather.current.temperature)} · ${esc(weather.current.summary)}</p><p class="hint">${weather.current.mock?t('weatherMock'):t('weatherAttribution')}${weather.current.stale?' · '+t('stale'):''}</p>`;$('#main').append(box);
}
async function enhanceTemplates(){
@@ -33,14 +33,14 @@ function displayDialog(content){
}
function enhanceGallery(){
const grid=$('.gallery');if(!grid)return;
const controls=document.createElement('div');controls.className='form-grid';controls.innerHTML=`<div class="field"><label for="gallery-search">${t('searchImages')}</label><input id="gallery-search" type="search"></div><div class="field"><label for="gallery-sort">${t('sortOrder')}</label><select id="gallery-sort"><option value="newest">${t('sortNewest')}</option><option value="oldest">${t('sortOldest')}</option><option value="name">${t('sortName')}</option></select></div>`;grid.before(controls);
if(state.tab==='local'){const bar=document.createElement('div');bar.className='actions';bar.innerHTML=`<select id="asset-origin" aria-label="${t('originFilter')}">${['all','manual','task','external','template','unknown'].map(x=>`<option value="${x}">${t('origin_'+x)}</option>`).join('')}</select><select id="asset-job" aria-label="${t('taskOrigin')}"><option value="">${t('allTasks')}</option></select><button id="asset-prev">${t('previousPage')}</button><button id="asset-next">${t('nextPage')}</button><button id="asset-all">${t('selectAll')}</button><button id="asset-invert">${t('invertSelection')}</button><button id="asset-batch-star">${t('star')}</button><button id="asset-batch-unstar">${t('unstar')}</button><button id="asset-batch-delete" class="danger">${t('deleteSelected')}</button>`;grid.before(bar);const help=document.createElement('p');help.className='hint';help.id='gallery-selection-help';help.textContent=t('gallerySelectionHelp')+' '+t('starHelp');grid.before(help);bar.setAttribute('aria-describedby',help.id);$('#asset-origin').value=state.assetOrigin||'all';if(state.galleryJobsEpoch!==state.epoch){state.galleryJobsEpoch=state.epoch;state.galleryJobsRequest=api('/api/v1/jobs');}void state.galleryJobsRequest.then(jobs=>{if(!bar.isConnected)return;const picker=bar.querySelector('#asset-job');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;picker.append(option);}picker.value=state.assetJob||'';}).catch(error=>{if(bar.isConnected)toast(error.message);});$('#asset-origin').onchange=()=>{state.assetOffset=0;state.assetOrigin=$('#asset-origin').value;return navigate();};$('#asset-job').onchange=()=>{state.assetOffset=0;state.assetJob=$('#asset-job').value;state.assetOrigin=state.assetJob?'task':state.assetOrigin;return navigate();};$('#asset-prev').disabled=!(state.assetOffset||0);$('#asset-next').disabled=state.gallery.length<200;bind('asset-prev',()=>{state.assetOffset=Math.max(0,(state.assetOffset||0)-200);return navigate();});bind('asset-next',()=>{state.assetOffset=(state.assetOffset||0)+200;return navigate();});const visibleSelection=()=>[...grid.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);$('#asset-all').onclick=()=>{const boxes=visibleSelection();const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};$('#asset-invert').onclick=()=>visibleSelection().forEach(x=>x.checked=!x.checked);for(const action of ['star','unstar','delete']){bind('asset-batch-'+action,async()=>{const ids=[...document.querySelectorAll('.asset-select:checked')].map(x=>x.dataset.asset);if(!ids.length){toast(t('selectImagesFirst'));return;}if(action==='delete'&&!confirm(t('deleteSelectedConfirm')))return;const results=[];for(let i=0;i<ids.length;i+=100){const r=await api('/api/v1/assets/batch',{method:'POST',body:{asset_ids:ids.slice(i,i+100),action}});results.push(...r.results);}const failed=results.filter(x=>x.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}}
const controls=document.createElement('div');controls.className='form-grid gallery-filters';controls.innerHTML=`<div class="field"><label for="gallery-search">${t('searchImages')}</label><input id="gallery-search" type="search"></div><div class="field"><label for="gallery-sort">${t('sortOrder')}</label><select id="gallery-sort"><option value="newest">${t('sortNewest')}</option><option value="oldest">${t('sortOldest')}</option><option value="name">${t('sortName')}</option></select></div>`;grid.before(controls);
if(state.tab==='local'){controls.classList.add('gallery-filters-local');const bar=document.createElement('div');bar.className='actions';bar.innerHTML=`<select id="asset-origin" aria-label="${t('originFilter')}">${['all','manual','task','external','template','unknown'].map(x=>`<option value="${x}">${t('origin_'+x)}</option>`).join('')}</select><select id="asset-job" aria-label="${t('taskOrigin')}"><option value="">${t('allTasks')}</option></select><button id="asset-prev">${t('previousPage')}</button><button id="asset-next">${t('nextPage')}</button><button id="asset-all">${t('selectAll')}</button><button id="asset-invert">${t('invertSelection')}</button><button id="asset-batch-star">${t('star')}</button><button id="asset-batch-unstar">${t('unstar')}</button><button id="asset-batch-delete" class="danger">${t('deleteSelected')}</button>`;grid.before(bar);for(const [id,key] of [['asset-origin','originFilter'],['asset-job','taskOrigin']]){const field=document.createElement('div');field.className='field';const label=document.createElement('label');label.htmlFor=id;label.textContent=t(key);field.append(label,bar.querySelector('#'+id));controls.append(field);}const help=document.createElement('p');help.className='hint';help.id='gallery-selection-help';help.textContent=t('gallerySelectionHelp')+' '+t('starHelp');grid.before(help);bar.setAttribute('aria-describedby',help.id);$('#asset-origin').value=state.assetOrigin||'all';if(state.galleryJobsEpoch!==state.epoch){state.galleryJobsEpoch=state.epoch;state.galleryJobsRequest=api('/api/v1/jobs');}void state.galleryJobsRequest.then(jobs=>{if(!bar.isConnected)return;const picker=bar.querySelector('#asset-job');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;picker.append(option);}picker.value=state.assetJob||'';}).catch(error=>{if(bar.isConnected)toast(error.message);});$('#asset-origin').onchange=()=>{state.assetOffset=0;state.assetOrigin=$('#asset-origin').value;return navigate();};$('#asset-job').onchange=()=>{state.assetOffset=0;state.assetJob=$('#asset-job').value;state.assetOrigin=state.assetJob?'task':state.assetOrigin;return navigate();};$('#asset-prev').disabled=!(state.assetOffset||0);$('#asset-next').disabled=state.gallery.length<200;bind('asset-prev',()=>{state.assetOffset=Math.max(0,(state.assetOffset||0)-200);return navigate();});bind('asset-next',()=>{state.assetOffset=(state.assetOffset||0)+200;return navigate();});const visibleSelection=()=>[...grid.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);$('#asset-all').onclick=()=>{const boxes=visibleSelection();const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};$('#asset-invert').onclick=()=>visibleSelection().forEach(x=>x.checked=!x.checked);for(const action of ['star','unstar','delete']){bind('asset-batch-'+action,async()=>{const ids=[...document.querySelectorAll('.asset-select:checked')].map(x=>x.dataset.asset);if(!ids.length){toast(t('selectImagesFirst'));return;}if(action==='delete'&&!confirm(t('deleteSelectedConfirm')))return;const results=[];for(let i=0;i<ids.length;i+=100){const r=await api('/api/v1/assets/batch',{method:'POST',body:{asset_ids:ids.slice(i,i+100),action}});results.push(...r.results);}const failed=results.filter(x=>x.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}}
const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{state.galleryQuery=$('#gallery-search').value;const query=state.galleryQuery.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;state.galleryOrder=order;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(state.tab==='local'){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/assets/'+item.asset_id+'/star',{method:'PUT',body:{starred:!item.starred}});await navigate();});const label=document.createElement('small');label.textContent=item.origin==='task'?t('taskOrigin')+' · '+(item.job_name||item.job_id):t('origin_'+item.origin);card.querySelector('h3').after(label);const select=document.createElement('input');select.type='checkbox';select.className='asset-select';select.dataset.asset=item.asset_id;select.setAttribute('aria-label',t('selectImage'));card.prepend(select);}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('templateSourceUnavailable'));});}});
$('#gallery-search').value=state.galleryQuery||'';$('#gallery-sort').value=state.galleryOrder||'newest';$('#gallery-search').oninput();$('#gallery-sort').onchange();
}
async function weatherSettings(){
const weather=await api('/api/v1/providers/weather');const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('weatherSettings')}</h3><form id="weather-form"><div class="field"><label for="weather-provider">${t('weatherSettings')}</label><select id="weather-provider"><option value="mock">${t('weatherMock')}</option><option value="open-meteo">Open-Meteo</option></select></div><div class="field"><label for="weather-location">${t('location')}</label><input id="weather-location" required maxlength="120" value="${esc(weather.config.location)}"></div><div class="form-grid">${['latitude','longitude'].map(k=>`<div class="field"><label for="weather-${k}">${t(k)}</label><input id="weather-${k}" required type="number" step="any" min="${k==='latitude'?-90:-180}" max="${k==='latitude'?90:180}" value="${weather.config[k]}"></div>`).join('')}</div><button type="submit" class="primary">${t('save')}</button></form><div id="weather-result"></div>`;$('#main').append(box);$('#weather-provider').value=weather.config.provider;
const weather=await api('/api/v1/providers/weather');const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('weatherSettings')}</h3><form id="weather-form"><div class="field"><label for="weather-provider">${t('weatherSettings')}</label><select id="weather-provider"><option value="mock">${t('weatherMock')}</option><option value="open-meteo">Open-Meteo</option></select></div><div class="field"><label for="weather-location">${t('location')}</label><input id="weather-location" required maxlength="120" value="${esc(weather.config.location)}"></div><div class="form-grid weather-coordinates">${['latitude','longitude'].map(k=>`<div class="field"><label for="weather-${k}">${t(k)}</label><input id="weather-${k}" required type="number" step="any" min="${k==='latitude'?-90:-180}" max="${k==='latitude'?90:180}" value="${weather.config[k]}"></div>`).join('')}</div><button type="submit" class="primary">${t('save')}</button></form><div id="weather-result"></div>`;$('#main').append(box);$('#weather-provider').value=weather.config.provider;
function result(w){$('#weather-result').innerHTML=`<p>${esc(w.current.temperature)} · ${esc(w.current.summary)}</p><p class="hint">${w.current.mock?t('weatherMock'):t('weatherAttribution')}${w.current.stale?' · '+t('stale'):''}</p><h4>${t('forecast')}</h4>${w.forecast.map(x=>`<p>${esc(x.date)} · ${esc(x.summary)} · ${x.minimum_c}–${x.maximum_c}°C · ${x.rain_probability}%</p>`).join('')}`;}
result(weather);$('#weather-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/weather',{method:'PUT',body:{provider:$('#weather-provider').value,location:$('#weather-location').value,latitude:Number($('#weather-latitude').value),longitude:Number($('#weather-longitude').value)}});result(await api('/api/v1/providers/weather'));toast(t('done'));});
}
@@ -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>`);
$('#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');});
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" id="source-nas-root-field"><label for="source-nas-root">${tr('NAS 原照片根目录(仅用于显示路径)','NAS original root (display only)')}</label><input id="source-nas-root" pattern="/.*" maxlength="2048" placeholder="/volume1/photo" value="${esc(source?.nas_root_path||'')}"><p class="hint">${tr('共享空间通常为 /volume1/photo,请填写实际 NAS 根目录;留空时显示 Photos 空间内路径。','Enter the actual NAS root; shared space is commonly /volume1/photo. Blank shows the path within Photos.')}</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-nas-root-field').hidden=!photosAPI;$('#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,nas_root_path:$('#source-nas-root').value||null,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_short_edge:Number($('#source-min-short').value),minimum_long_edge:Number($('#source-min-long').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){
+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.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 and place after @. Subfolders inherit their nearest dated folder; unnamed people and original filenames 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.'});
+2 -2
View File
@@ -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>
<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>PIGWay Frame Flow<small>Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.17</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-quality-v0117"></script><script src="/ui/features.js?v=photo-quality-v0117"></script><script src="/ui/extensions.js?v=photo-quality-v0117"></script><script src="/ui/app.js?v=photo-quality-v0117"></script></body></html>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pigway-frame-flow"
version = "0.1.9"
version = "0.1.17"
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]
+1 -1
View File
@@ -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.17-" + commit[:12]
try:
run(
"docker",
+1 -1
View File
@@ -1,6 +1,6 @@
/* Isolated Mock only: dashboard and gallery share the same read-only image viewer. */
const assert=require('assert'),{chromium}=require('playwright');
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});let checks=0;
try{for(const width of [1440,768,390]){const p=await browser.newPage({viewport:{width,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.route('**/api/v1/devices/*/current-content',r=>r.fulfill({json:{image_id:42,preview_url:'/fixture-preview.svg?thumbnail=true',synced:true,source_known:false,overrides:[]}}));await p.route('**/fixture-preview.svg*',r=>r.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900"><rect width="1200" height="900" fill="#6c9c83"/><circle cx="600" cy="450" r="220" fill="#e9d9af"/></svg>'}));await p.route('**/api/v1/devices/*/images',r=>r.fulfill({json:[{image_id:42,name:'Fixture gallery',current:true,url:'/fixture-preview.svg'}]}));
try{for(const width of [1440,768,390]){const p=await browser.newPage({viewport:{width,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.route('**/api/v1/devices/*/current-content',r=>r.fulfill({json:{image_id:42,preview_url:'/fixture-preview.svg?thumbnail=true',synced:true,source_known:false,overrides:[]}}));await p.route('**/fixture-preview.svg*',r=>{return r.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="WIDTH" height="900"><rect width="1200" height="900" fill="#6c9c83"/><circle cx="600" cy="450" r="220" fill="#e9d9af"/></svg>'.replace('WIDTH',new URL(r.request().url()).searchParams.has('thumbnail')?'300':'1200')});});await p.route('**/api/v1/devices/*/images',r=>r.fulfill({json:[{image_id:42,name:'Fixture gallery',current:true,url:'/fixture-preview.svg'}]}));
await p.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await p.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await p.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await p.locator('#login-form button').click();await p.locator('#current-frame-preview').waitFor();assert(await p.evaluate(()=>state.settings.mock));
for(const lang of ['zh','en'])for(const theme of ['dark','light']){await p.evaluate(async({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;await navigate('dashboard')},{lang,theme});await p.waitForFunction(()=>document.querySelector('#current-frame-image')?.naturalWidth===1200);assert(await p.locator('#current-frame-preview').evaluate(e=>e.getBoundingClientRect().height)>=(width<=720?320:400));assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await p.locator('#current-frame-preview').focus();const fullImage=p.waitForRequest(r=>new URL(r.url()).pathname==='/fixture-preview.svg'&&!new URL(r.url()).searchParams.has('thumbnail'));await p.keyboard.press('Enter');await fullImage;await p.locator('dialog.image-viewer[open]').waitFor();await p.waitForFunction(()=>document.querySelector('#original-image')?.naturalWidth===1200);assert.equal(await p.locator('.image-viewer-name').textContent(),(lang==='zh'?'当前显示内容':'Currently displayed content')+' #42');assert.equal(await p.locator('.image-viewer-actions button').count(),3);const sizing=p.locator('.image-viewer-actions button[aria-pressed]');await sizing.nth(1).click();assert(await p.locator('.image-viewer-viewport').evaluate(e=>e.classList.contains('actual-size')));await sizing.nth(0).click();assert(!await p.locator('.image-viewer-viewport').evaluate(e=>e.classList.contains('actual-size')));await p.keyboard.press('Escape');assert(!await p.locator('#modal').evaluate(e=>e.open));await p.waitForFunction(()=>!document.body.classList.contains('image-viewer-open'));if(process.env.FRAME_FLOW_SCREENSHOT_DIR&&lang==='zh'&&theme==='dark'){require('fs').mkdirSync(process.env.FRAME_FLOW_SCREENSHOT_DIR,{recursive:true});await p.screenshot({path:process.env.FRAME_FLOW_SCREENSHOT_DIR+'/dashboard-'+width+'.png',fullPage:true});}await p.evaluate(()=>navigate('gallery'));await p.locator('[data-original="0"]').click();await p.locator('dialog.image-viewer[open]').waitFor();assert.equal(await p.locator('.image-viewer-actions button').count(),3);await p.locator('#modal-cancel').click();checks++;}assert.deepEqual(errors,[]);await p.close();}console.log('Dashboard preview: '+checks+' profiles PASS; larger preview, keyboard opening, shared gallery viewer, fit/actual size, Escape, no overflow');}finally{await browser.close()}})().catch(e=>{console.error(e);process.exit(1)});
+5 -5
View File
@@ -9,12 +9,12 @@ const assert=require('assert');const fs=require('fs');const {chromium}=require('
await page.route('**/api/v1/integrations/synology-photos',async route=>{if(route.request().method()==='POST'){assert.deepStrictEqual(route.request().postDataJSON(),{username:'fixture-nas',password:'fixture-only'});connected=true;logins++;}await route.fulfill({json:{configured:true,connected}});});
await page.route('**/api/v1/photo-sources',async route=>{assert(route.request().method()==='GET');await route.fulfill({json:[{id:'fixture-source',name:'NAS photos / NAS 照片',kind:'local',location:'.',enabled:false,people_filter:true,orientation:'auto'}]});});
await page.goto(base);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();
const viewports=process.env.FRAME_FLOW_VIEWPORTS?JSON.parse(process.env.FRAME_FLOW_VIEWPORTS):[1440,768,390].map(width=>({width,height:900}));
const viewports=process.env.FRAME_FLOW_VIEWPORTS?JSON.parse(process.env.FRAME_FLOW_VIEWPORTS):[1920,1440,768,390].map(width=>({width,height:900}));
let checks=0;const failures=[];const out=process.env.FRAME_FLOW_SCREENSHOT_DIR;if(out)fs.mkdirSync(out,{recursive:true});
async function inspect(label){
const result=await page.evaluate(()=>{
const root=document.querySelector('dialog[open]')||document.querySelector('#main');const controls=[...root.querySelectorAll('button,select,input:not([type=checkbox]):not([type=radio]):not([type=file]),textarea')].filter(e=>e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})&&!e.closest('[hidden]')).map(e=>({id:e.id||e.textContent.trim().slice(0,24),r:e.getBoundingClientRect()}));const close=[];
for(let i=0;i<controls.length;i++)for(let j=i+1;j<controls.length;j++){const a=controls[i].r,b=controls[j].r;const x=Math.min(a.right,b.right)-Math.max(a.left,b.left),y=Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top);const dx=Math.max(a.left,b.left)-Math.min(a.right,b.right),dy=Math.max(a.top,b.top)-Math.min(a.bottom,b.bottom);if((x>1&&dy<7.5)||(y>1&&dx<7.5))close.push([controls[i].id,controls[j].id]);}
const root=document.querySelector('dialog[open]')||document.querySelector('#main');const controls=[...root.querySelectorAll('button,select,input:not([type=checkbox]):not([type=radio]):not([type=file]),textarea')].filter(e=>e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})&&!e.closest('[hidden]')).map(e=>({id:e.id||e.textContent.trim().slice(0,24),compact:!!e.closest('.image-card .actions'),r:e.getBoundingClientRect()}));const close=[];
for(let i=0;i<controls.length;i++)for(let j=i+1;j<controls.length;j++){const a=controls[i].r,b=controls[j].r;const x=Math.min(a.right,b.right)-Math.max(a.left,b.left),y=Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top);const dx=Math.max(a.left,b.left)-Math.min(a.right,b.right),dy=Math.max(a.top,b.top)-Math.min(a.bottom,b.bottom);const gap=controls[i].compact&&controls[j].compact?5.5:7.5;if((x>1&&dy<gap)||(y>1&&dx<gap))close.push([controls[i].id,controls[j].id]);}
const dialog=document.querySelector('dialog[open]');const rect=dialog?.getBoundingClientRect();
const clippedDialog=!!rect&&(rect.left<0||rect.right>innerWidth||rect.top<0||rect.bottom>innerHeight);
const clippedNav=innerWidth<=720&&[...document.querySelectorAll('nav button')].some(button=>{const r=button.getBoundingClientRect();return button.scrollHeight>button.clientHeight||r.height<44;});
@@ -25,7 +25,7 @@ const assert=require('assert');const fs=require('fs');const {chromium}=require('
await page.setViewportSize({width,height});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});
for(const view of ['dashboard','gallery','photo-sources','upload','templates','automation','playlists','history','settings','studio','connections']){await page.evaluate(view=>navigate(view),view);await inspect(`${view}/${width}/${lang}/${theme}`);}
await page.evaluate(()=>navigate('studio'));await page.locator('.manual-collage summary').click();await inspect(`Manual collage/${width}/${lang}/${theme}`);await page.evaluate(()=>navigate('photo-sources'));await page.locator('#photos-login').click();await inspect(`Photos login/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
await page.locator('#add-photo-source').click();assert(await page.locator('#source-credential').isHidden());assert(await page.locator('#source-oauth').isHidden());await inspect(`NAS source/${width}/${lang}/${theme}`);await page.locator('#source-kind').selectOption('google-drive');assert(await page.locator('#source-oauth').isVisible());assert(await page.locator('#source-local-quality').isHidden());await inspect(`Drive source/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
await page.locator('#add-photo-source').click();assert(await page.locator('#source-credential').isHidden());assert(await page.locator('#source-oauth').isHidden());await inspect(`NAS source/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
await page.evaluate(()=>navigate('automation'));await page.locator('#new-job').click();await inspect(`Job/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
await page.evaluate(()=>navigate('playlists'));await page.locator('#new-playlist').click();await inspect(`Playlist/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
await page.evaluate(()=>displayDialog({type:'image',image_id:1}));await inspect(`Display options/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
@@ -33,6 +33,6 @@ const assert=require('assert');const fs=require('fs');const {chromium}=require('
if(out&&lang==='zh'&&theme==='dark'){await page.evaluate(()=>navigate('photo-sources'));await page.screenshot({path:out+`/photos-${width}.png`,fullPage:true});}
}
await page.evaluate(()=>navigate('photo-sources'));await page.locator('#photos-login').click();await page.locator('#photos-login-user').fill('fixture-nas');await page.locator('#photos-login-password').fill('fixture-only');await page.locator('#photos-login-form button[type=submit]').click();await page.locator('#photos-login').filter({hasText:'Sign in again'}).waitFor();assert(logins===1);checks++;
assert.deepStrictEqual(errors,[]);assert.deepStrictEqual(failures,[],JSON.stringify(failures));console.log('Layout audit:',checks,'passed; 11 pages plus local gallery, 6 modal states, '+(viewports.length*4)+' viewport/language/theme combinations; console errors:',errors.length);
assert.deepStrictEqual(errors,[]);assert.deepStrictEqual(failures,[],JSON.stringify(failures));console.log('Layout audit:',checks,'passed; 11 pages plus local gallery, 5 modal states, '+(viewports.length*4)+' viewport/language/theme combinations; console errors:',errors.length);
}finally{await browser.close();}
})().catch(e=>{console.error(e.message);process.exit(1)});
+65
View File
@@ -883,6 +883,19 @@
"minLength": 1,
"title": "Location"
},
"nas_root_path": {
"anyOf": [
{
"type": "string",
"maxLength": 2048,
"pattern": "^/"
},
{
"type": "null"
}
],
"title": "Nas Root Path"
},
"recursive": {
"type": "boolean",
"title": "Recursive",
@@ -931,6 +944,20 @@
],
"title": "Orientation"
},
"minimum_short_edge": {
"type": "integer",
"maximum": 50000.0,
"minimum": 0.0,
"title": "Minimum Short Edge",
"default": 0
},
"minimum_long_edge": {
"type": "integer",
"maximum": 50000.0,
"minimum": 0.0,
"title": "Minimum Long Edge",
"default": 0
},
"minimum_sharpness": {
"type": "number",
"maximum": 10000.0,
@@ -1036,6 +1063,19 @@
"minLength": 1,
"title": "Location"
},
"nas_root_path": {
"anyOf": [
{
"type": "string",
"maxLength": 2048,
"pattern": "^/"
},
{
"type": "null"
}
],
"title": "Nas Root Path"
},
"recursive": {
"type": "boolean",
"title": "Recursive",
@@ -1084,6 +1124,20 @@
],
"title": "Orientation"
},
"minimum_short_edge": {
"type": "integer",
"maximum": 50000.0,
"minimum": 0.0,
"title": "Minimum Short Edge",
"default": 0
},
"minimum_long_edge": {
"type": "integer",
"maximum": 50000.0,
"minimum": 0.0,
"title": "Minimum Long Edge",
"default": 0
},
"minimum_sharpness": {
"type": "number",
"maximum": 10000.0,
@@ -1192,6 +1246,17 @@
],
"title": "Caption"
},
"source_path": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Path"
},
"provider_preview": {
"type": "boolean",
"title": "Provider Preview",
+2 -1
View File
@@ -338,7 +338,8 @@ def test_unknown_same_idempotency_key_returns_original(client, service):
fresh = client.post(
"/api/v1/display", json=payload, headers={"Idempotency-Key": "new-key"}
)
assert fresh.status_code == 409
assert fresh.status_code == 202
assert fresh.json()["operation_id"] != original.json()["operation_id"]
def test_schema_contract(client):
+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)
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])
def test_connect_timeout_retries_then_succeeds(write, transport_clock):
s = session({"success": True})
+111 -10
View File
@@ -71,22 +71,24 @@ def test_nested_overrides(service):
assert service.registry.get("living-room").get_current_image() == 1
def test_no_retry_unknown(service):
def test_unknown_does_not_block_new_independent_upload(service):
driver = service.registry.get("living-room")
attempts = []
def timeout(*a):
def timeout(*args):
attempts.append(args)
raise FlowError(ErrorCode.WRITE_RESULT_UNKNOWN, "Write outcome unknown", 502)
driver.upload = timeout
id = submit(service)
first = submit(service)
service.process_one()
assert service.operation(id)["status"] == "UNKNOWN"
with pytest.raises(FlowError):
submit(service)
assert service.process_one() is False
assert service.reconcile(id)["resolved"] is False
assert service.reconcile(id, True)["resolved"] is True
assert service.operation(id)["status"] == "FAILED"
assert service.operation(first)["status"] == "UNKNOWN"
second = submit(service, priority=10)
assert second != first
assert service.process_one()
assert service.operation(first)["status"] == "FAILED"
assert service.operation(second)["status"] == "UNKNOWN"
assert len(attempts) == 2
def test_restart_preserves_override(service):
@@ -145,6 +147,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):
+28
View File
@@ -0,0 +1,28 @@
import io
from PIL import Image
def test_gallery_thumbnail_upgrades_existing_cache_and_reuses_it(client, settings):
cache = settings.data_dir / "cache"
original = cache / "living-room-1.original"
thumb = cache / "living-room-1-thumb.jpg"
Image.new("RGB", (1600, 1200), "blue").save(original, "JPEG")
Image.new("RGB", (300, 225), "blue").save(thumb, "JPEG")
url = "/api/v1/devices/living-room/images/1/file?thumbnail=true"
result = client.get(url)
assert result.status_code == 200
assert Image.open(io.BytesIO(result.content)).size == (860, 645)
modified = thumb.stat().st_mtime_ns
assert client.get(url).content == result.content
assert thumb.stat().st_mtime_ns == modified
assert len(list(cache.glob("living-room-1-thumb*"))) == 1
assert Image.open(original).size == (1600, 1200)
def test_gallery_thumbnail_does_not_upscale_small_source(client, settings):
original = settings.data_dir / "cache" / "living-room-1.original"
Image.new("RGBA", (200, 400), (255, 0, 0, 255)).save(original, "PNG")
result = client.get("/api/v1/devices/living-room/images/1/file?thumbnail=true")
assert result.status_code == 200
assert Image.open(io.BytesIO(result.content)).size == (200, 400)
+59
View File
@@ -0,0 +1,59 @@
import io
from PIL import Image
from pigway_frame_flow.models import PhotoSourceContent
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
def test_photo_path_survives_render_and_asset_listing(client, service, monkeypatch):
photos = service.photo_sources
photos.save(
"family",
PhotoSourceInput(
name="Family",
kind="synology-photos",
location=".",
nas_root_path="/volume1/photo",
),
)
monkeypatch.setattr(photos.synology, "sid", lambda: "fixture")
monkeypatch.setattr(
photos.synology,
"items",
lambda *a, **kw: [
{
"id": 42,
"filename": "original.PNG",
"additional": {
"folder": "/家庭/20190130",
"resolution": {"width": 1600, "height": 1200},
},
}
],
)
out = io.BytesIO()
Image.new("RGB", (1600, 1200)).save(out, "PNG")
monkeypatch.setattr(photos.synology, "download", lambda *a, **kw: out.getvalue())
assert (
photos.catalog("family")[0]["source_path"]
== "/volume1/photo/家庭/20190130/original.PNG"
)
asset = service.content_asset(
PhotoSourceContent(type="photo_source", source_id="family", item_id="42")
)
assert asset["source_path"] == "/volume1/photo/家庭/20190130/original.PNG"
assert client.get("/api/v1/assets").json()[0]["source_path"] == asset["source_path"]
assert (
client.get("/api/v1/assets/" + asset["asset_id"] + "/source").json()["source"][
"item_id"
]
== "42"
)
def test_missing_photo_path_is_not_invented(service):
out = io.BytesIO()
Image.new("RGB", (20, 20)).save(out, "JPEG")
asset = service.assets.put(out.getvalue(), "manual.jpg")
assert asset["source_path"] is None
+123
View File
@@ -0,0 +1,123 @@
import io
import pytest
from PIL import Image
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
def test_minimum_resolution_filters_catalog_and_persists(
client, service, settings, legacy_photo_engine
):
root = settings.data_dir.parent / "photos"
root.mkdir()
settings.photo_root = root
for name, size in [
("small.jpg", (160, 120)),
("wide.jpg", (800, 600)),
("tall.jpg", (600, 800)),
]:
Image.new("RGB", size).save(root / name)
response = client.put(
"/api/v1/photo-sources/family",
json={
"name": "Family",
"kind": "local",
"location": ".",
"minimum_short_edge": 600,
"minimum_long_edge": 800,
},
)
assert response.status_code == 200
assert response.json()["minimum_short_edge"] == 600
assert service.photo_sources.get("family").minimum_long_edge == 800
assert {x["name"] for x in service.photo_sources.catalog("family")} == {
"wide.jpg",
"tall.jpg",
}
response = client.put(
"/api/v1/photo-sources/family",
json={
"name": "Family",
"kind": "local",
"location": ".",
},
)
assert response.status_code == 200
assert len(service.photo_sources.catalog("family")) == 3
def test_resolution_rejects_small_synology_preview(service, monkeypatch):
photos = service.photo_sources
photos.save(
"family",
PhotoSourceInput(
name="Family",
kind="synology-photos",
location=".",
minimum_short_edge=600,
minimum_long_edge=800,
),
)
row = {"id": "1", "name": "photo.jpg"}
monkeypatch.setattr(photos, "catalog", lambda *args, **kwargs: [row])
photos.api_items["family"] = {"1": {}}
monkeypatch.setattr(photos.synology, "item_people", lambda *a, **kw: [])
def image(size):
out = io.BytesIO()
Image.new("RGB", size).save(out, "JPEG")
return out.getvalue()
monkeypatch.setattr(photos.synology, "download", lambda *a, **kw: image((300, 400)))
with pytest.raises(FlowError, match="minimum resolution"):
photos.read("family", "1")
monkeypatch.setattr(photos.synology, "download", lambda *a, **kw: image((800, 600)))
assert photos.read("family", "1")[1] == "photo.jpg"
def test_minimum_resolution_rejects_negative_value(client):
response = client.put(
"/api/v1/photo-sources/family",
json={
"name": "Family",
"kind": "synology-photos",
"location": ".",
"minimum_short_edge": -1,
},
)
assert response.status_code == 422
def test_synology_metadata_resolution_filters_before_catalog_limit(
client, service, monkeypatch
):
photos = service.photo_sources
monkeypatch.setattr(photos.synology, "sid", lambda: "fixture")
rows = [
{
"id": i,
"filename": f"{i}.jpg",
"additional": {
"folder": "/family",
"resolution": {"width": w, "height": h},
},
}
for i, w, h in [(1, 300, 400), (2, 800, 600), (3, 0, 0)]
]
monkeypatch.setattr(photos.synology, "items", lambda *a, **kw: rows)
response = client.put(
"/api/v1/photo-sources/family",
json={
"name": "Family",
"kind": "synology-photos",
"location": ".",
"minimum_short_edge": 600,
"minimum_long_edge": 800,
"catalog_limit": 1,
},
)
assert response.status_code == 200
assert response.json()["minimum_long_edge"] == 800
assert [x["id"] for x in photos.catalog("family")] == ["2"]
+28 -1
View File
@@ -1,12 +1,39 @@
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_omits_raw_filename_and_includes_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"]
@pytest.mark.parametrize("size", [(1600, 1200), (1200, 1600)])
@pytest.mark.parametrize("fit", ["contain", "cover", "smart"])
def test_watermark_has_no_letterboxing(service, size, fit):
+59
View File
@@ -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_folder_and_optional_people_without_filename(
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 "filename" not in caption
assert caption.get("people", "") == ("Alice & Bob" if labels_available else "")
def test_source_without_mount_pagination_scope_and_session(
client, service, settings, monkeypatch
):
+154
View File
@@ -0,0 +1,154 @@
from datetime import UTC, datetime, timedelta
from pigway_frame_flow.errors import ErrorCode, FlowError
from pigway_frame_flow.models import JobInput
from pigway_frame_flow.scheduler.engine import Scheduler
def failed_job(app, service, monkeypatch):
scheduler = app.state.scheduler
job = scheduler.save_job(
JobInput(
name="Retry",
schedule={"type": "interval", "interval_minutes": 120},
source={"type": "template", "template": "quote"},
)
)
now = datetime.now(UTC)
service.db.execute(
"UPDATE jobs SET next_run=? WHERE id=?",
((now - timedelta(seconds=1)).isoformat(), job["id"]),
)
driver = service.registry.get("living-room")
upload = driver.upload
def fail(*a):
raise FlowError(ErrorCode.UPLOAD_FAILED, "Rejected", 502)
monkeypatch.setattr(driver, "upload", fail)
scheduler.tick(now)
service.process_one()
scheduler.tick(now + timedelta(seconds=1))
return scheduler, job, now, driver, upload
def test_five_backoffs_then_one_scheduled_failure_regenerates(
app, service, monkeypatch, client
):
scheduler, job, now, driver, upload = failed_job(app, service, monkeypatch)
policy = scheduler.upload_retries
plan = policy.load(job["id"])
asset = plan["asset_id"]
path = service.assets.path(asset)
original = path.read_bytes()
assert client.get("/api/v1/assets").json() == []
assert (
service.db.one("SELECT kind FROM assets WHERE id=?", (asset,))["kind"]
== "cache"
)
times = [datetime.fromisoformat(x) for x in plan["times"]]
gaps = [(times[0] - now).total_seconds()] + [
(b - a).total_seconds() for a, b in zip(times, times[1:], strict=False)
]
assert gaps == sorted(gaps) and len(times) == 5
assert times[-1] < datetime.fromisoformat(plan["deadline"])
for number, when in enumerate(times, 1):
scheduler.tick(when + timedelta(milliseconds=1))
attempt = policy.load(job["id"])
assert attempt["count"] == number
op = service.operation(attempt["active"])
assert (
service.db.one(
"SELECT payload FROM operations WHERE id=?", (op["operation_id"],)
)["payload"].find(asset)
>= 0
)
service.process_one()
assert path.read_bytes() == original
assert len(service.db.all("SELECT * FROM job_runs")) == 1
# The plan survives a process restart, without resetting the five-attempt cap.
scheduler = Scheduler(service)
deadline = datetime.fromisoformat(plan["deadline"])
scheduler.tick(deadline + timedelta(milliseconds=1))
planned = scheduler.upload_retries.load(job["id"])
assert planned["phase"] == "scheduled_running"
service.process_one()
scheduler.tick(deadline + timedelta(seconds=1))
replacement = scheduler.upload_retries.load(job["id"])
assert replacement["phase"] == "replacement"
assert not path.exists()
assert service.db.one("SELECT id FROM assets WHERE id=?", (asset,)) is None
monkeypatch.setattr(driver, "upload", upload)
service.process_one()
service.sync()
op = service.operation(replacement["active"])
assert op["status"] == "COMPLETED"
assert op["result"]["rendered_asset_id"] != asset
assert len(service.db.all("SELECT * FROM job_runs")) == 2
def test_retry_success_cancels_rest_and_keeps_schedule(app, service, monkeypatch):
scheduler, job, now, driver, upload = failed_job(app, service, monkeypatch)
policy = scheduler.upload_retries
plan = policy.load(job["id"])
deadline = service.db.one("SELECT next_run FROM jobs WHERE id=?", (job["id"],))[
"next_run"
]
monkeypatch.setattr(driver, "upload", upload)
when = datetime.fromisoformat(plan["times"][0]) + timedelta(milliseconds=1)
scheduler.tick(when)
operation = policy.load(job["id"])["active"]
service.process_one()
service.sync()
scheduler.tick(when + timedelta(seconds=1))
assert policy.load(job["id"]) is None
assert service.operation(operation)["status"] == "COMPLETED"
assert (
service.db.one("SELECT next_run FROM jobs WHERE id=?", (job["id"],))["next_run"]
== deadline
)
assert (
service.db.one("SELECT kind FROM assets WHERE id=?", (plan["asset_id"],))[
"kind"
]
== "generated"
)
assert driver.client.uploads == 1
def test_expired_queued_retry_never_uploads(app, service, monkeypatch):
scheduler, job, now, driver, upload = failed_job(app, service, monkeypatch)
policy = scheduler.upload_retries
plan = policy.load(job["id"])
scheduler.tick(datetime.fromisoformat(plan["times"][0]) + timedelta(milliseconds=1))
plan = policy.load(job["id"])
plan["deadline"] = (datetime.now(UTC) - timedelta(seconds=1)).isoformat()
policy.save(job["id"], plan)
def unexpected(*a):
raise AssertionError("Expired retry must never upload")
monkeypatch.setattr(driver, "upload", unexpected)
service.process_one()
operation = service.operation(plan["active"])
assert operation["status"] == "FAILED"
assert operation["error"]["details"]["reason"] == "RETRY_WINDOW_CLOSED"
assert service.assets.path(plan["asset_id"]).exists()
def test_lost_response_is_reconciled_before_retry_without_duplicate(
app, service, monkeypatch
):
scheduler, job, now, driver, upload = failed_job(app, service, monkeypatch)
policy = scheduler.upload_retries
plan = policy.load(job["id"])
asset = service.assets.info(plan["asset_id"])
monkeypatch.setattr(driver, "upload", upload)
# The original request reached the cloud, but its response was lost.
driver.upload(service.assets.path(plan["asset_id"]), asset["name"])
service.update(plan["root"], "UNKNOWN")
scheduler.tick(datetime.fromisoformat(plan["times"][0]) + timedelta(milliseconds=1))
scheduler.tick(datetime.fromisoformat(plan["times"][0]) + timedelta(seconds=1))
assert service.operation(plan["root"])["status"] == "COMPLETED"
assert policy.load(job["id"]) is None
assert driver.client.uploads == 1