Compare commits

...
9 Commits
62 changed files with 2149 additions and 178 deletions
+3
View File
@@ -14,3 +14,6 @@ dist
.mypy_cache
**/__pycache__
**/*.pyc
# macOS metadata at any depth
**/.DS_Store
+2 -57
View File
@@ -5,12 +5,6 @@ on:
branches: ["**"]
pull_request:
workflow_dispatch:
inputs:
deploy_action:
description: Production action (deploy requires explicit enabled Variable)
type: choice
options: [plan, bootstrap, deploy, rollback]
default: plan
jobs:
verify:
# The NAS Docker runner must never execute code from external forks.
@@ -47,54 +41,5 @@ jobs:
python scripts/ci-compose-verify.py --cleanup
fi
deploy:
needs: verify
if: success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
concurrency:
group: frame-flow-production
cancel-in-progress: false
env:
CD_ACTION: ${{ github.event.inputs.deploy_action }}
PROD_DEPLOY_ENABLED: ${{ vars.PROD_DEPLOY_ENABLED }}
PROD_PROJECT: ${{ vars.PROD_PROJECT }}
PROD_CONTAINER: ${{ vars.PROD_CONTAINER }}
PROD_IMAGE_REPO: ${{ vars.PROD_IMAGE_REPO }}
PROD_DATA_DIR: ${{ secrets.PROD_DATA_DIR }}
PROD_CONFIG_DIR: ${{ secrets.PROD_CONFIG_DIR }}
PROD_PHOTOS_DIR: ${{ secrets.PROD_PHOTOS_DIR }}
SYNOLOGY_PHOTOS_URL: ${{ secrets.SYNOLOGY_PHOTOS_URL }}
SYNOLOGY_PHOTOS_CERT_SHA256: ${{ secrets.SYNOLOGY_PHOTOS_CERT_SHA256 }}
PROD_WEB_BIND: ${{ secrets.PROD_WEB_BIND }}
PROD_WEB_PORT: ${{ vars.PROD_WEB_PORT }}
FRAME_FLOW_STORAGE_NAME: ${{ vars.FRAME_FLOW_STORAGE_NAME }}
PROD_STATE_VOLUME: ${{ vars.PROD_STATE_VOLUME }}
PROD_MOCK: ${{ vars.PROD_MOCK }}
PROD_WRITE_ENABLED: ${{ vars.PROD_WRITE_ENABLED }}
DOCKER_PIP_INDEX_URL: ${{ vars.DOCKER_PIP_INDEX_URL }}
DOCKER_PIP_FALLBACK_INDEX_URL: ${{ vars.DOCKER_PIP_FALLBACK_INDEX_URL }}
CI_PIP_INDEX_URL: ${{ vars.CI_PIP_INDEX_URL }}
CI_PIP_FALLBACK_INDEX_URL: ${{ vars.CI_PIP_FALLBACK_INDEX_URL }}
DEBIAN_MIRROR: ${{ vars.DEBIAN_MIRROR }}
DEBIAN_SECURITY_MIRROR: ${{ vars.DEBIAN_SECURITY_MIRROR }}
steps:
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: https://github.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.14'
- name: Install deploy tooling
run: python scripts/ci-install.py
- name: Plan or deploy production (default is plan)
env:
PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }}
run: |
. ./deploy/build-cn.env
python scripts/production-deploy.py
- name: Recover interrupted production deployment
if: always()
env:
CD_ACTION: recover
run: |
if [ -n "${FRAME_FLOW_DEPLOY_OWNER:-}" ]; then
python scripts/production-deploy.py
fi
# Production moved to Ubuntu Server. Release manually with scripts/server-release.py;
# the shared NAS runner performs isolated verification only.
+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
+2
View File
@@ -37,6 +37,8 @@ python3.14 -m venv .venv
The demo forces mock mode and disables device writes. Data and configuration remain in `data/` and `config/` across restarts.
Synology Photos API sources work without `/photos` or NFS mounts, with shared/current-account personal spaces, people filters and bounded on-demand XL preview caching. Original downloads are not supported by this verified implementation. See [Photos API and Ubuntu migration candidate](docs/SYNOLOGY_PHOTOS_API.md).
### Features and boundaries
- Chinese and English Web UI, light/dark themes, account login, device dashboard and image previews.
+2
View File
@@ -37,6 +37,8 @@ python3.14 -m venv .venv
演示强制使用 Mock,并关闭设备写入。数据与配置保存在 `data/`、`config/`,重启后保留。
Synology Photos 可使用独立的 API 来源,无需 `/photos` 或 NFS 挂载;支持共享 / 当前账号个人空间、人物筛选和按需 XL 预览缓存。原图下载不在本次已验证支持范围内。见 [API 来源与 Ubuntu 候选迁移说明](docs/SYNOLOGY_PHOTOS_API.md)。
### 功能与边界
- 中英文 Web 界面、深浅色主题、账号登录、设备面板与图片预览。
+32
View File
@@ -0,0 +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} 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.13" org.opencontainers.image.revision=$BUILD_COMMIT
+51
View File
@@ -0,0 +1,51 @@
# Candidate only. Never start alongside a live-writing NAS instance.
name: pigway-frame-flow
services:
pigway-frame-flow:
image: ${FRAME_FLOW_IMAGE:?Set the verified immutable image reference}
container_name: pigway-frame-flow
platform: linux/amd64
restart: unless-stopped
init: true
user: "10333:10333"
ports:
- "${FRAME_FLOW_BIND:-192.0.2.10}:81:18787"
env_file: ${FRAME_FLOW_ENV_FILE:-/srv/pigway/apps/pigway-frame-flow/.env}
environment:
TZ: Asia/Shanghai
DATA_DIR: /data
CONFIG_DIR: /config
FRAME_FLOW_STORAGE_NAME: Ubuntu Server
SYNOLOGY_PHOTOS_CACHE_BYTES: ${SYNOLOGY_PHOTOS_CACHE_BYTES:-268435456}
# Migration starts read-only. Activation is a separate authorized action.
KOKONNA_WRITE_ENABLED: "false"
PYTHONDONTWRITEBYTECODE: "1"
TMPDIR: /data/tmp
volumes:
- type: bind
source: /srv/pigway/apps/pigway-frame-flow/data
target: /data
bind:
create_host_path: false
- type: bind
source: /srv/pigway/apps/pigway-frame-flow/config
target: /config
bind:
create_host_path: false
read_only: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
pids_limit: 100
mem_limit: 768m
healthcheck:
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18787/ready',timeout=3)"]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
# Docker data-root and container logs must reside on the T7 too.
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
+3 -11
View File
@@ -1,14 +1,6 @@
# Opt-in read-only photo mount. Set NAS_PHOTOS_DIR to the actual NAS folder.
# Retired: filesystem photo mounts are forbidden. Use Photos API sources.
# This compatibility filename intentionally grants no additional host access.
services:
pigway-frame-flow:
environment:
PHOTO_ROOT: /photos
group_add:
- "${NAS_PHOTOS_GID:-10333}"
volumes:
- type: bind
source: ${NAS_PHOTOS_DIR:?Set NAS_PHOTOS_DIR to your existing photo directory}
target: /photos
read_only: true
bind:
create_host_path: false
SYNOLOGY_PHOTOS_CACHE_BYTES: ${SYNOLOGY_PHOTOS_CACHE_BYTES:-268435456}
+4
View File
@@ -4,6 +4,10 @@ Photo sources are independent read-only providers. They never call a frame drive
SQLite persists source definitions and Scheduler jobs; passwords are not stored there.
The shared DisplayService handles fitting, upload, idempotency, overrides and restore.
## Synology Photos API (no mount)
Use the explicit `synology-photos` source type for Ubuntu without `/photos`. Existing local sources are not silently converted. Shared and current-account personal spaces use the shared Photos login, with ID-based any/all people filters and bounded XL preview caching. See [verified capabilities, migration and recovery](SYNOLOGY_PHOTOS_API.md). Original API downloads remain unsupported.
## NAS local folder
Add the actual existing photo directory to `.env`:
+65
View File
@@ -0,0 +1,65 @@
# Synology Photos API 来源与 Ubuntu 迁移候选
本功能从 NAS 的 Photos API 按需读取照片,不需要 NFS、SMB 或 `/photos` 文件挂载。NAS 原照片不复制整库到 Server。
## 已支持与边界
- 新来源类型 `synology-photos`,与 `local` 挂载目录明确分开。
- 共享空间 `SYNO.FotoTeam`、当前登录账号的个人空间 `SYNO.Foto`;不是任意 NAS 用户的个人空间。
- 人物列表分批读取,照片按 ID 做 any 并集 / all 交集;无匹配时明确为空,不回退全库。
- 支持目录相对路径、递归开关、日期文件夹 / 标题、方向及现有质量筛选。`.` 表示所选空间根目录;路径以 Photos 返回的目录名为准,不是 Ubuntu 绝对路径。
- 图片使用经过实测的 `Thumbnail.get` v2、`size=xl`。原始文件名不代表下载的是原图。对 NAS 当前原图下载接口的探测被拒绝,原图下载**未宣称支持**。
- 照片枚举 `Browse.Item.list` v7,人物 `Browse.Person.list` v3;每批 500 条,最多 100000 条,超过明确失败,界面目录上限仍由 `catalog_limit` 控制。不支持 NAS 未暴露或账号无权访问的相册。
- 旧 `local` 来源保留挂载读取方式,不自动变成 API 来源。新建 / 编辑来源时可明确选择 API 类型。
## 安全与缓存
保持 HTTPS SHA-256 证书指纹校验,在同一 TLS socket 验证证书后才发送 SID。仅调用固定 API 与只读方法,不使用通用 RPC 转发、不跟随重定向。密码不持久化,会话文件保持 0600。会话过期返回明确重连提示;不保存密码以自动重登,也不对 NAS 原会话发 logout。权限不足与会话失效分开处理。
缓存为 `/data/cache/synology-photos`,默认总图片字节上限 256 MiB,可用 `SYNOLOGY_PHOTOS_CACHE_BYTES` 调整(环境配置最小 1 MiB),单张不超过应用 20 MiB 限制。只下载选中的图片。文件锁合并跨线程、跨进程下载,预留容量后写 0600 临时文件,完成长度核对及 Pillow 验证 / 解码后原子提交;超时、中断及无效数据清理临时文件,下次持锁读取清理进程异常退出遗留的 `.part`。按最近使用淘汰。缓存身份包含端点、会话摘要、空间、照片 ID、索引版本及缩略图缓存键,避免账号 / 空间混用。
容量上限针对图片和下载临时文件的逻辑字节,不含 SQLite、锁文件与文件系统元数据;并发串行缓存锁会等待正在进行的单张下载。网络 socket 超时 30 秒,流读取检查 90 秒期限,已阻塞的单次 socket 读取最多再等待其超时。
## Ubuntu 候选配置
正式代码在 `/home/developer/Documents/Projects/pigway-frame-flow` 的 `codex/synology-photos-api` 分支开发,不能以迁移临时解包目录作为开发仓库。
候选文件:`deploy/compose.ubuntu-photos-api.yml`。外部 `192.0.2.10:81`,内部 `18787`,UID/GID `10333:10333`,数据和配置固定为:
- `/srv/pigway/apps/pigway-frame-flow/data:/data`
- `/srv/pigway/apps/pigway-frame-flow/config:/config`
无 `/photos` 绑定。`create_host_path: false` 防止在 T7 未挂载时悄悄创建目录;但仍需在启动前检查 `findmnt -T /srv/pigway` 的实际源设备。
`.env` 需从已确认的配置安全迁移,保留 Photos URL、证书 pin 与已有 Frame Flow / 相框鉴权;不要把密钥写入仓库。模板默认 `KOKONNA_WRITE_ENABLED=false`。创建 `data/tmp`,属主保持 10333:10333,`TMPDIR=/data/tmp`。Docker data-root 和 containerd root 也必须在 T7,不能仅检查 bind mount。
```sh
# 仅检查候选配置;不会启动应用。
FRAME_FLOW_IMAGE='<已验证的镜像标签>' docker compose -f deploy/compose.ubuntu-photos-api.yml config --quiet
```
完整构建使用根目录 Dockerfile。若使用 `deploy/Dockerfile.runtime-update`,必须指定已审核的基础镜像,构建步骤会检查依赖锁与基础镜像相同;不相同时必须完整构建。本次依赖未新增,已比对现有 0.1.8 基础镜像的锁文件。
## 旧来源转换与恢复
**停止目标实例、复制数据、转换真实配置及切换部署均需另行授权,本次没有执行。**
1. 确认 T7 挂载,备份 NAS 原有完整 `/data`、`/config`、旧镜像及 Compose。SQLite 必须停服务后复制,或使用 SQLite backup API;不要只复制有活动 WAL 的主文件。
2. 将备份恢复到上述 Server 目录,保持私密文件权限,创建 `data/tmp`。保留旧 NAS 备份直至切换验收。
3. 对每个需要摆脱挂载的来源显式 dry run:
```sh
python scripts/migrate-photos-api.py --database /data/frameflow.db --source family
# 已获授权、目标服务已停止时才执行:
python scripts/migrate-photos-api.py --database /data/frameflow.db --source family --apply --service-stopped
```
工具保持来源 ID、任务引用、人物空间、any/all、目录、方向及筛选设置,只修改来源类型并关闭旧本地 `@eaDir` 预览开关;自动在同一数据目录生成 0600 SQLite 备份。转换不会验证该账号在 Photos 中是否可访问相同目录,必须再做真实只读预览。不要转换确实依赖挂载且 Photos 未收录的目录。
4. 已获得单独授权后才停旧实例、以只读候选配置启动 Server,并逐个来源验收。没有授权前不执行 `compose up` 或开启真实写入。
5. 回退时停止 Server,恢复已备份的数据库和配置(停服务时处理对应 WAL/SHM),恢复旧来源类型与 NAS 原部署,保证只有一个实例有写权限。原 NAS 照片不受缓存或来源转换影响。
## NFS 授权判断
API 来源的共享 / 个人空间取图已经可以完全不依赖 NFS,因此这条新取图链路不需要为 `192.0.2.10` 保留 DSM `photo` NFS 授权。但目前旧来源和待迁移配置没有全部切换验收,不能据此宣称迁移完成或旧本地来源已可脱离挂载。确认没有其他已批准用途后,可以单独授权删除该特定 NFS 规则;本次没有删除。
实际版本、镜像标识、通过 / 失败 / 未执行项见本任务验证记录。
+5 -3
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "Pigway Frame Flow API",
"version": "0.1.8"
"version": "0.1.9"
},
"paths": {
"/api/v1/devices/{device_id}/widgets/selection": {
@@ -15216,7 +15216,7 @@
"version": {
"type": "string",
"title": "Version",
"default": "0.1.8"
"default": "0.1.9"
}
},
"type": "object",
@@ -16332,6 +16332,7 @@
"type": "string",
"enum": [
"local",
"synology-photos",
"webdav",
"google-drive",
"alipan",
@@ -16484,6 +16485,7 @@
"type": "string",
"enum": [
"local",
"synology-photos",
"webdav",
"google-drive",
"alipan",
@@ -17412,7 +17414,7 @@
"version": {
"type": "string",
"title": "Version",
"default": "0.1.8"
"default": "0.1.9"
},
"api_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 实际相册归属,未命名人物不显示;历史生成图片不自动重绘。
+19
View File
@@ -0,0 +1,19 @@
# v0.1.9
照片来源通过 Synology Photos API 按账号权限读取,共享/个人空间、人物 any/all 筛选和有界 XL 预览缓存可用。生产拒绝文件系统照片来源及 /photos 挂载;本地上传、生成图片和 Server 本地图库继续保留。NAS 原图下载接口曾被拒绝,本版不宣称支持原图下载。
自动化新增 1~10080 分钟的真实间隔,跨整点与跨天不按 Cron 字段重置,既有 Cron 任务保持原样。显示时长明确为到期恢复期限。统一来源快捷创建任务编辑器,改善登录及来源表单响应式布局、设备显示名与历史记录中英文;诊断详情保留原始标识。
## 生产部署
生产已迁至 Ubuntu Server,由 systemd 管理 Docker;业务、Docker 数据和恢复资料位于 T7,并有 UUID 与挂载依赖保护。NAS 旧 Frame 专属资源已清理且独立复核 PASS。CI 保留隔离验证,移除 NAS 自动生产部署任务,不再向 NAS 发布。
正式发布使用 scripts/server-release.py:仅在既有 Ubuntu Server 环境运行,从已提交源码包构建,校验依赖锁、源码哈希、T7 UUID、容器安全约束和真实写入设置;备份 SQLite、配置及 Compose 后切换镜像,失败恢复旧 Compose,不自动回写旧数据库。它不执行上传、显示、删除或恢复图片等设备操作。成功后记录正式提交号、保存源码包及审计回执,删除本次暂存目录;白名单内的 Frame 旧迁移输入归档到本次 recovery,清除无恢复引用的临时镜像别名,共享 Console 资料不动。旧镜像及历史恢复资料保留供回滚,不执行全局 prune。
脚本要求由 git archive 生成的源码放在 /srv/pigway/migration-inbox 的独立子目录;目录内同时提供 RELEASE_COMMIT、RELEASE_HASHES.json 和原始 source.tar.gz。哈希清单涵盖源码与源码包,不含自身。执行 sudo python3 <暂存目录>/scripts/server-release.py;成功后读取 /srv/pigway/apps/pigway-frame-flow/release-verification.json 并独立检查线上接口。
## 验收范围
完整后端测试、静态检查与桌面/平板/手机的中英文浏览器回归纳入发布检查。线上历史补丁已核验,真实写入保持开启;本轮正式发布不重复真实设备写入测试。隔离浏览器验证与真实设备写入证据分别记录于 docs/verification/2026-10-17-final-audit.md。
局域网 HTTPS、独立加密备份及恢复演练仍是后续工作;T7 recovery 不属于独立备份。没有执行 USB 断连、服务器重启或压力测试。
+84
View File
@@ -0,0 +1,84 @@
# KoKonna local-control investigation
Reviewed 2026-10-06. No frame writes, network changes, or firmware changes.
## Sources and limits
- Official FAQ: https://kokonna.art/pages/faq explicitly describes Bluetooth
upload and switching. This is a product-level claim, not verified B101 behavior.
- Public OpenAPI: https://kokonna.art/pages/openapi documents cloud HTTPS calls.
- Existing official Android nightly 1.6.5 APK from the prior download:
SHA256 `8f611bf74bbda70e7e7a5d087e4065dc6000849dcd2f12538d5ff5017c91e1c0`.
Static inspection of its packaged app-service.js; no vendor code is copied here.
- Searches found community API clients, but no verified B101 firmware source or
documented bring-your-own-server configuration. Absence from search is not
proof that firmware source or another configuration path does not exist.
## Observed client behavior
The account client uses `https://robot.galaxyguide.cn/api/rpc`. Its Bluetooth
upload flow calls `appFrame.uploadImage` with base64 and `synced: true`, receives
an image ID, downloads the resulting image from the vendor server, and sends
the bytes to the frame over BLE. Therefore this observed flow still exposes
the original image to the vendor cloud.
BLE service UUID: `4fafc201-1fb5-459e-8fcc-c5c9c331914b`.
Characteristic UUID: `beb5483e-36e1-4688-b7f5-ea07361b26a7`.
Observed JSON commands include `get_info`, `refresh_image`, and `set_image`.
Image transfer uses start metadata (ID, rotation, type, size, chunk count),
numbered binary chunks, sync acknowledgements/missing-chunk information, and
an end command. The client chooses `y4` for firmware starting with B103 and
`jpg` otherwise. That makes local JPEG transmission a plausible B101 research
path, not a proven implementation: cloud-independent IDs, processing, firmware
acceptance, pairing and completion semantics still require validation.
The observed `set_config` payload contains `slideType`, `slideMinute`, and
`nickname`; `set_wifi` contains SSID and password. No server-host setter was
found in these paths or common host-setting symbol searches.
## Host replacement assessment
Changing Frame Flow's API base URL changes where Frame Flow sends requests,
not where the frame connects. Replacing the frame's cloud server requires its
device-side protocol and a supported address-setting mechanism or firmware
change. DNS redirection alone may fail HTTPS certificate validation and still
requires a compatible server implementation. TLS/pinning and actual device
polling transport have not been established from this APK.
Next read-only step: inspect BLE `get_info` and installed firmware compatibility.
Any subsequent local image write needs a separate controlled test. No BLE driver
is enabled by this investigation.
## Wi-Fi-only follow-up
The user excluded Bluetooth and additional gateways. The target is NAS-to-frame
Wi-Fi control with the original hardware.
GitHub's public repository search for `kokonna` returned three repositories:
`patrickchen914/kokonna`, `nixihz/kokonna-skill`, and
`zhengweiran66-eng/kokonna-product.html`. These provide a Home Assistant cloud
integration, an OpenAPI CLI, and a product page, respectively; no verified B101
device firmware or Wi-Fi local-server implementation was identified.
Static APK checks found no hard-coded private IPv4 target, WebSocket URL,
custom image-URL setter, or common server-host setter symbols in app-service.js.
This does not exclude hidden firmware features or native-library code paths.
`app.getFirmwares` is called with `robotId`. Upgrade submission passes a firmware
name, locale and distributor ID through `app.setUpgradeFirmware`; the inspected
UI does not supply a user-selected firmware download URL. Neither method was
called during this investigation. Existing account RPC allowlists remain intact.
Candidate paths, none yet verified:
1. A device-local HTTP/TCP service: requires a confirmed frame LAN address and
bounded service inspection while the frame is awake.
2. A firmware-supported custom server or image URL: requires configuration or
firmware evidence, not simply replacing the application's API host.
3. A NAS implementation of the device's cloud protocol: requires the actual
polling/download protocol and address/TLS behavior. Passive observation at
the existing gateway may provide evidence; a normal NAS on a switched LAN
cannot assume it sees another device's outbound packets.
No DNS redirection, router interception, certificate replacement, firmware
upgrade, reset, or automatic display change was performed.
+3 -2
View File
@@ -126,7 +126,7 @@ def create_app(settings=None, start_worker=True):
app = FastAPI(
title="Pigway Frame Flow API",
docs_url=None,
version="0.1.8",
version="0.1.13",
lifespan=lifespan,
responses=error_responses,
)
@@ -305,6 +305,7 @@ def create_app(settings=None, start_worker=True):
)
def device(device_id: str, refresh: bool = False):
d = registry.get(device_id).device(refresh)
service.clear_self_restore_reference(device_id, d)
state = db.one("SELECT * FROM device_state WHERE device_id=?", (device_id,))
possible = bool(
state
@@ -1031,7 +1032,7 @@ def create_app(settings=None, start_worker=True):
supports_upload=True,
),
],
version="0.1.8",
version="0.1.13",
mock=settings.mock,
write_enabled=settings.write_enabled,
api_key_configured=bool(settings.api_key)
+34 -1
View File
@@ -205,6 +205,27 @@ class DisplayService:
)
return self.db.one("SELECT * FROM device_state WHERE device_id=?", (id,))
def clear_self_restore_reference(self, device_id, device):
# A confirmed return to the managed image consumes its restore target.
# Keep references while an override or an uncertain write still needs them.
image_id = device.current_content.id
if (
device.status == "offline"
or not device.current_content.synced
or image_id is None
):
return
with self.lock:
self.db.execute(
"""UPDATE device_state SET previous_image_id=NULL
WHERE device_id=? AND previous_image_id=? AND managed_image_id=?
AND base_image_id IS NULL
AND NOT EXISTS (SELECT 1 FROM overrides WHERE device_id=?)
AND NOT EXISTS (SELECT 1 FROM operations WHERE device_id=?
AND write_started=1 AND status IN ('UPLOADING','SYNCING','UNKNOWN'))""",
(device_id, image_id, image_id, device_id, device_id),
)
def content_asset(self, content, device_id=None, operation_id=None):
if isinstance(content, AutoCollageContent):
from pigway_frame_flow.extensions.auto_collage import prepare
@@ -275,6 +296,8 @@ class DisplayService:
data, name, caption = self.photo_sources.read(
content.source_id, item["id"], target_size=size
)
if source.folder_caption:
caption = {**(caption or {}), "filename": name}
if (
source.minimum_sharpness
or source.minimum_brightness
@@ -374,6 +397,7 @@ class DisplayService:
def current_content(self, device_id):
device = self.registry.get(device_id).device()
self.clear_self_restore_reference(device_id, device)
image_id = device.current_content.id
row = self.db.one(
"SELECT * FROM image_sources WHERE device_id=? AND image_id=?",
@@ -511,6 +535,8 @@ class DisplayService:
previous = device.current_content.id
state = self.state(device_id)
if action == "delete":
self.clear_self_restore_reference(device_id, device)
state = self.state(device_id)
image_id = payload["image_id"]
if payload.get("cloud_cleanup"):
if (
@@ -549,7 +575,12 @@ class DisplayService:
)
}
if image_id in protected and not payload.get("purge"):
raise FlowError(E.CONFLICT, "Image is needed for restoration", 409)
raise FlowError(
E.CONFLICT,
"Image is needed for restoration",
409,
{"reason": "RESTORE_IMAGE_PROTECTED"},
)
if image_id == previous and not payload["confirm_current"]:
raise FlowError(E.CONFLICT, "Current image needs confirmation", 409)
self._start_write(id)
@@ -741,6 +772,7 @@ class DisplayService:
and device.current_content.synced
):
self.update(op["id"], "COMPLETED")
self.clear_self_restore_reference(op["device_id"], device)
self.cloud_library.confirm(op["id"])
self.db.history(op, "COMPLETED")
self.db.event("operation.completed", {"operation_id": op["id"]})
@@ -766,6 +798,7 @@ class DisplayService:
and device.current_content.synced
):
self.update(op["id"], "COMPLETED")
self.clear_self_restore_reference(op["device_id"], device)
self.cloud_library.confirm(op["id"])
self.db.history(op, "COMPLETED")
self.db.event("operation.completed", {"operation_id": op["id"]})
+6 -3
View File
@@ -265,7 +265,10 @@ class GalleryImage(BaseModel):
class Schedule(StrictModel):
type: Literal["cron", "daily", "weekdays", "weekends", "date", "temporary"]
type: Literal[
"cron", "daily", "weekdays", "weekends", "date", "temporary", "interval"
]
interval_minutes: int | None = Field(default=None, ge=1, le=10080)
cron: str | None = None
time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
at: datetime | None = None
@@ -363,7 +366,7 @@ class SettingsView(BaseModel):
class Health(BaseModel):
status: str
version: str = "0.1.8"
version: str = "0.1.13"
class Readiness(BaseModel):
@@ -393,7 +396,7 @@ class RestoreRequest(StrictModel):
class Version(BaseModel):
product: str = "Pigway Frame Flow"
version: str = "0.1.8"
version: str = "0.1.13"
api_version: str = "v1"
commit: str = "development"
build_time: str = "unknown"
+112 -6
View File
@@ -2,6 +2,7 @@
import base64
import json
import logging
import os
import posixpath
import random
@@ -27,7 +28,9 @@ from pigway_frame_flow.storage.database import encode
class PhotoSourceInput(StrictModel):
name: str = Field(min_length=1, max_length=120)
kind: Literal["local", "webdav", "google-drive", "alipan", "baidu"]
kind: Literal[
"local", "synology-photos", "webdav", "google-drive", "alipan", "baidu"
]
location: str = Field(min_length=1, max_length=2048)
recursive: bool = True
enabled: bool = True
@@ -77,6 +80,7 @@ class PhotoSources:
def __init__(self, db, settings, assets):
self.db, self.settings, self.assets = db, settings, assets
self.cache = {}
self.api_items = {}
self.synology = SynologyPhotos(settings)
# Register provider secrets before request IDs or errors can be reflected.
for row in self.list():
@@ -106,22 +110,33 @@ class PhotoSources:
raise FlowError(E.NOT_FOUND, "Photo source not found", 404)
return PhotoSourceInput.model_validate_json(row["payload"])
@staticmethod
def require_api_source(source):
# Block persisted legacy sources as well as new requests, before any disk read.
if source.kind == "local":
raise FlowError(
E.FORBIDDEN,
"Filesystem photo sources are disabled; use the Synology Photos API",
403,
)
def save(self, source_id, source):
self.require_api_source(source)
if source.minimum_brightness > source.maximum_brightness:
raise FlowError(E.INVALID_REQUEST, "Brightness range is reversed")
if source.kind != "local" and (
if source.kind not in ("local", "synology-photos") and (
source.organized_only or source.folder_caption or source.synology_previews
):
raise FlowError(
E.UNSUPPORTED_CAPABILITY,
"Folder captions and Synology previews require a local photo source",
)
if source.orientation and source.kind != "local":
if source.orientation and source.kind not in ("local", "synology-photos"):
raise FlowError(
E.UNSUPPORTED_CAPABILITY,
"Orientation filtering requires a local photo source",
)
if source.people_filter and source.kind != "local":
if source.people_filter and source.kind not in ("local", "synology-photos"):
raise FlowError(
E.INVALID_REQUEST,
"Person filtering requires a local Synology photo source",
@@ -140,6 +155,21 @@ class PhotoSources:
raise FlowError(
E.INVALID_REQUEST, "Local sources do not use credentials"
)
elif source.kind == "synology-photos":
location = source.location.strip("/")
if location == ".":
location = ""
if "\\" in location or any(p in (".", "..") for p in location.split("/")):
raise FlowError(
E.INVALID_REQUEST,
"Photos API folder must be a relative folder path or root",
)
if source.credential_env or source.synology_previews:
raise FlowError(
E.INVALID_REQUEST,
"Photos API sources use the shared Photos session and XL thumbnail download",
)
source = source.model_copy(update={"location": location or "."})
elif source.kind == "webdav":
source = source.model_copy(
update={
@@ -169,6 +199,7 @@ class PhotoSources:
("photo_source:" + source_id, encode(source.model_dump())),
)
self.cache.pop(source_id, None)
self.api_items.pop(source_id, None)
return dict(source.model_dump(), id=source_id)
def delete(self, source_id):
@@ -186,6 +217,7 @@ class PhotoSources:
"DELETE FROM app_config WHERE key=?", ("photo_source:" + source_id,)
)
self.cache.pop(source_id, None)
self.api_items.pop(source_id, None)
def local_directory(self, location):
root = self.settings.photo_root.resolve()
@@ -264,8 +296,12 @@ class PhotoSources:
def catalog(self, source_id, refresh=False, *, target_size=(1200, 1600)):
source = self.get(source_id)
self.require_api_source(source)
if not source.enabled:
raise FlowError(E.CONFLICT, "Photo source is disabled", 409)
session_identity = (
self.synology.sid() if source.kind == "synology-photos" else None
)
wanted = (
orientation(*target_size)
if source.orientation == "auto"
@@ -277,6 +313,7 @@ class PhotoSources:
and not refresh
and time.monotonic() - cached[0] < 60
and cached[2] == wanted
and cached[3] == session_identity
):
return cached[1]
allowed = (
@@ -286,7 +323,7 @@ class PhotoSources:
refresh=refresh,
space=source.people_space,
)
if source.people_filter
if source.people_filter and source.kind == "local"
else None
)
items = []
@@ -406,6 +443,58 @@ class PhotoSources:
break
if len(items) >= source.catalog_limit:
break
elif source.kind == "synology-photos":
self.synology.sid()
rows = self.synology.items(
source.person_ids if source.people_filter else [],
source.person_match,
space=source.people_space,
limit=100000,
)
location = "" if source.location == "." else source.location.strip("/")
self.api_items[source_id] = {}
for row in rows:
folder = row.get("additional", {}).get("folder")
if isinstance(folder, dict):
folder = folder.get("name")
if not isinstance(folder, str) or not isinstance(
row.get("filename"), str
):
raise FlowError(
E.CLOUD_UNAVAILABLE,
"Photos API response lacks folder or filename",
502,
)
relative = folder.strip("/") + "/" + row["filename"]
if location and not relative.startswith(location + "/"):
continue
if not source.recursive and folder.strip("/") != location:
continue
caption = path_caption(relative)
if source.organized_only and caption is None:
continue
size = row.get("additional", {}).get("resolution", {})
width, height = size.get("width", 0), size.get("height", 0)
rotation = row.get("additional", {}).get("orientation", 1)
if rotation in (5, 6, 7, 8):
width, height = height, width
direction = orientation(width, height)
if wanted and (not width or not height or direction != wanted):
continue
items.append(
{
"id": str(row["id"]),
"name": row["filename"],
"provider_preview": True,
"caption": caption if source.folder_caption else None,
"width": width,
"height": height,
"orientation": direction,
}
)
self.api_items[source_id][str(row["id"])] = row
if len(items) >= source.catalog_limit:
break
elif source.kind in ("google-drive", "alipan", "baidu"):
from pigway_frame_flow.providers.cloud_drives import CloudDrives
@@ -468,7 +557,7 @@ class PhotoSources:
"INSERT OR REPLACE INTO photo_dimensions(path,mtime,bytes,width,height) VALUES(?,?,?,?,?)",
size_updates,
)
self.cache[source_id] = (time.monotonic(), items, wanted)
self.cache[source_id] = (time.monotonic(), items, wanted, session_identity)
return items
def read(self, source_id, item_id=None, *, target_size=(1200, 1600)):
@@ -526,6 +615,23 @@ class PhotoSources:
os.close(parent_fd)
except OSError:
raise FlowError(E.NOT_FOUND, "Photo is unavailable", 404) from None
elif source.kind == "synology-photos":
data = self.synology.download(
self.api_items[source_id][item["id"]], space=source.people_space
)
if source.folder_caption:
caption = dict(item.get("caption") or {})
caption["filename"] = item["name"]
try:
caption["people"] = " & ".join(
self.synology.item_people(item["id"], space=source.people_space)
)
except FlowError:
# Optional labels must not prevent displaying a valid photo.
logging.getLogger(__name__).warning(
"Photos person labels unavailable; keeping photo caption"
)
item = {**item, "caption": caption}
elif source.kind in ("google-drive", "alipan", "baidu"):
from pigway_frame_flow.providers.cloud_drives import CloudDrives
@@ -0,0 +1,84 @@
"""Bounded, process-safe cache for individually requested Photos images."""
import fcntl
import hashlib
import os
import tempfile
import time
from pathlib import Path
from PIL import Image
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.renderers.images import MAX_IMAGE_PIXELS
class PhotosCache:
def __init__(self, settings):
self.root = settings.data_dir / "cache" / "synology-photos"
self.capacity = settings.photos_cache_bytes
self.max_bytes = min(settings.max_bytes, self.capacity)
self.hits = 0
self.downloads = 0
def _prune(self, reserve=0):
entries = []
for path in self.root.iterdir():
if path.name.endswith(".part"):
path.unlink(missing_ok=True)
elif path.suffix == ".image" and not path.is_symlink():
entries.append(path)
entries.sort(key=lambda p: p.stat().st_mtime_ns)
total = sum(p.stat().st_size for p in entries)
for path in entries:
if total + reserve <= self.capacity:
break
total -= path.stat().st_size
path.unlink()
def read(self, key, download):
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
identity = hashlib.sha256(key.encode()).hexdigest()
path = self.root / (identity + ".image")
# One lock bounds total disk use and coalesces same-item downloads across
# threads/processes. The lock file is constant, not one file per photo.
with (self.root / ".lock").open("a+b") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
self._prune()
if path.is_file() and not path.is_symlink():
data = path.read_bytes()
os.utime(path, None)
self.hits += 1
return data
self._prune(self.max_bytes)
fd, temporary = tempfile.mkstemp(suffix=".part", dir=self.root)
part = Path(temporary)
try:
with os.fdopen(fd, "wb") as handle:
download(handle, self.max_bytes, time.monotonic() + 90)
handle.flush()
os.fsync(handle.fileno())
if not 0 < part.stat().st_size <= self.max_bytes:
raise ValueError()
with Image.open(part) as image:
if image.format not in ("JPEG", "PNG", "WEBP"):
raise ValueError()
if image.width * image.height > MAX_IMAGE_PIXELS:
raise ValueError()
image.verify()
# Decode before committing; truncated/compressed-bomb payloads
# must never become a reusable cache hit.
with Image.open(part) as image:
image.load()
part.replace(path)
self.downloads += 1
return path.read_bytes()
except (OSError, ValueError, Image.DecompressionBombError):
raise FlowError(
E.CLOUD_UNAVAILABLE,
"Photos download is incomplete or not a supported image",
502,
) from None
finally:
part.unlink(missing_ok=True)
+186 -2
View File
@@ -119,12 +119,23 @@ class SynologyPhotos:
result = json.loads(raw)
if not result.get("success"):
code = result.get("error", {}).get("code")
if api == "SYNO.API.Auth" or code in (105, 106, 107, 119):
if api == "SYNO.API.Auth" or code in (106, 107, 119):
if api != "SYNO.API.Auth":
self.disconnect()
raise FlowError(
E.CONFIG_REQUIRED, "Reconnect Synology Photos account", 409
)
if code in (102, 103, 104):
raise FlowError(
E.UNSUPPORTED_CAPABILITY,
"This NAS Photos API or method is unavailable",
)
if code == 105:
raise FlowError(
E.FORBIDDEN,
"Photos account cannot access the selected space",
403,
)
raise FlowError(
E.CLOUD_UNAVAILABLE, "Synology Photos query was rejected", 502
)
@@ -141,7 +152,7 @@ class SynologyPhotos:
sid = json.loads(self.path.read_text())["sid"]
if not isinstance(sid, str) or not sid:
raise ValueError()
except (OSError, ValueError, KeyError):
except (OSError, ValueError, KeyError, TypeError):
raise FlowError(
E.CONFIG_REQUIRED, "Connect Synology Photos account first", 409
) from None
@@ -269,3 +280,176 @@ class SynologyPhotos:
)
self.cache[key] = (time.monotonic(), selected)
return selected
def items(self, person_ids=(), mode="any", *, space="shared", limit=10000):
"""Intersect identities, not filenames, for any/all people selection."""
prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto"
if mode not in ("any", "all") or space not in ("shared", "personal"):
raise FlowError(E.INVALID_REQUEST, "Invalid Photos selection")
with self.lock:
self.sid()
if person_ids:
known = {x["id"] for x in self.people(space)}
if not set(person_ids) <= known:
raise FlowError(
E.INVALID_REQUEST, "Selected person no longer exists"
)
groups = []
for person in person_ids or [None]:
rows = self.pages(
prefix + ".Browse.Item",
7,
additional=json.dumps(["folder", "thumbnail", "resolution"]),
**({"person_id": person} if person is not None else {}),
)
groups.append(
{str(r["id"]): r for r in rows if r.get("type") == "photo"}
)
selected = set(groups[0])
for group in groups[1:]:
selected = (
selected & group.keys()
if mode == "all"
else selected | group.keys()
)
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
sid = self.sid()
url, pin = self.endpoint()
prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto"
if rendition == "thumbnail":
thumb = row.get("additional", {}).get("thumbnail", {})
if not thumb.get("cache_key") or not thumb.get("unit_id"):
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "Photos thumbnail is unavailable"
)
api, method = prefix + ".Thumbnail", "get"
params = dict(
id=thumb["unit_id"],
cache_key=thumb["cache_key"],
size="xl",
type="unit",
)
else:
raise FlowError(
E.UNSUPPORTED_CAPABILITY,
"Only verified XL thumbnails are supported; original download is unavailable",
)
if not hasattr(self, "image_cache"):
self.image_cache = PhotosCache(self.settings)
# Session/endpoint scoping prevents a different account from reusing a
# previous account's cached photo. Cache keys never contain raw secrets.
identity = hashlib.sha256((sid + str(url.geturl()) + pin).encode()).hexdigest()
key = json.dumps(
[
identity,
space,
row["id"],
rendition,
row.get("indexed_time"),
row.get("filesize"),
row.get("additional", {}).get("thumbnail", {}).get("cache_key"),
]
)
return self.image_cache.read(
key,
lambda handle, cap, deadline: self._download(
api, method, params, sid, handle, cap, deadline
),
)
def _download(self, api, method, params, sid, handle, cap, deadline):
if (api, method) not in {
("SYNO.FotoTeam.Thumbnail", "get"),
("SYNO.Foto.Thumbnail", "get"),
}:
raise FlowError(E.FORBIDDEN, "Unsupported Photos download", 403)
url, pin = self.endpoint()
connection = http.client.HTTPSConnection(
url.hostname,
url.port or 443,
timeout=30,
context=ssl._create_unverified_context(),
)
try:
connection.connect()
certificate = connection.sock.getpeercert(binary_form=True)
if not certificate or hashlib.sha256(certificate).hexdigest() != pin:
raise FlowError(E.FORBIDDEN, "Synology Photos certificate changed", 403)
connection.request(
"POST",
"/webapi/entry.cgi",
urlencode(dict(api=api, method=method, version=2, _sid=sid, **params)),
{"Content-Type": "application/x-www-form-urlencoded"},
)
response = connection.getresponse()
if response.status != 200:
raise ValueError()
if "json" in (response.getheader("Content-Type") or "").lower():
result = json.loads(response.read(65537))
if result.get("error", {}).get("code") in (106, 107, 119):
self.disconnect()
raise FlowError(
E.CONFIG_REQUIRED, "Reconnect Synology Photos account", 409
)
if result.get("error", {}).get("code") == 105:
raise FlowError(
E.FORBIDDEN, "Photos account cannot download this photo", 403
)
raise FlowError(
E.CLOUD_UNAVAILABLE, "Photos download was rejected", 502
)
expected = response.getheader("Content-Length")
if expected and int(expected) > cap:
raise FlowError(
E.INVALID_REQUEST,
"Photos image exceeds download limit; select thumbnail",
)
total = 0
while True:
if time.monotonic() > deadline:
raise TimeoutError()
chunk = response.read(min(65536, cap - total + 1))
if not chunk:
break
total += len(chunk)
if total > cap:
raise FlowError(
E.INVALID_REQUEST,
"Photos image exceeds download limit; select thumbnail",
)
handle.write(chunk)
if expected and total != int(expected):
raise ValueError()
except (OSError, ValueError, http.client.HTTPException):
raise FlowError(
E.CLOUD_UNAVAILABLE,
"Photos download interrupted; retry explicitly",
502,
) from None
finally:
connection.close()
+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", "filename")
]
text = " · ".join(value for value in values if value)
font_size = layout.font_size
@@ -51,8 +51,8 @@ def render_captioned_photo(
font_size -= 2
font = font_loader(font_size)
# Shorten the title before touching the date/location. Never wrap the band.
while draw.textlength(text, font=font) > available and len(values[0]) > 2:
values[0] = values[0].rstrip("…")[:-1] + "…"
while draw.textlength(text, font=font) > available and len(values[2]) > 2:
values[2] = values[2].rstrip("…")[:-1] + "…"
text = " · ".join(value for value in values if value)
while draw.textlength(text, font=font) > available and len(text) > 2:
text = text.rstrip("…")[:-1] + "…"
+4
View File
@@ -23,6 +23,10 @@ from pigway_frame_flow.storage.database import encode, now_iso
def next_run(schedule, after=None):
after = after or datetime.now(UTC)
if schedule.type == "interval":
if schedule.interval_minutes is None:
raise FlowError(E.INVALID_REQUEST, "Interval requires interval_minutes")
return (after + timedelta(minutes=schedule.interval_minutes)).isoformat()
if schedule.type in ("date", "temporary"):
if not schedule.at or schedule.at.tzinfo is None:
raise FlowError(E.INVALID_REQUEST, "One-time date must include timezone")
+6
View File
@@ -56,6 +56,12 @@ class Settings:
photo_root: Path = field(
default_factory=lambda: Path(os.getenv("PHOTO_ROOT", "/photos"))
)
photos_cache_bytes: int = field(
default_factory=lambda: max(
1024 * 1024,
int(os.getenv("SYNOLOGY_PHOTOS_CACHE_BYTES", str(256 * 1024 * 1024))),
)
)
max_bytes: int = 20 * 1024 * 1024
idempotency_hours: int = 168
sync_timeout: int = 600
+21 -17
View File
@@ -10,13 +10,14 @@ 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')}`;
function normaliseSchedule(schedule){const copy={...schedule};if(copy.type!=='cron')return copy;const match=/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+(\*|1-5|6,0|0,6)$/.exec(copy.cron||'');if(!match)return copy;const type=match[3]==='*'?'daily':match[3]==='1-5'?'weekdays':'weekends';return {type,time:padTime(match[2],match[1])};}
function scheduleTypeLabel(type){if(type!=='cron')return t(type);return state.lang==='zh'?'高级规则(Cron)':'Advanced rule (Cron)';}
function scheduleSummary(schedule){const value=normaliseSchedule(schedule);if(['daily','weekdays','weekends'].includes(value.type))return `${scheduleTypeLabel(value.type)} · ${value.time}(${state.lang==='zh'?'上海时间':'Shanghai time'})`;if(value.type==='cron')return `${scheduleTypeLabel('cron')} · ${value.cron}`;return `${scheduleTypeLabel(value.type)} · ${fmt(value.at)}`;}
function scheduleHint(type){if(type==='cron')return state.lang==='zh'?'仅在需要特殊规则时使用;例如“每天 08:00”请选择“每天”。':'Use only for special rules; choose Daily for a regular time.';if(['date','temporary'].includes(type))return state.lang==='zh'?'选择一次性执行的日期和时间。':'Choose the date and time for this one-time task.';return state.lang==='zh'?'选择执行时间(上海时区)。':'Choose a time in Shanghai time.';}
function normaliseSchedule(schedule){const copy={...schedule};if(copy.type==='interval')return {type:copy.interval_minutes%60===0?'hours':'minutes',every:copy.interval_minutes%60===0?copy.interval_minutes/60:copy.interval_minutes};if(copy.type!=='cron')return copy;const match=/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+(\*|1-5|6,0|0,6)$/.exec(copy.cron||'');if(!match)return copy;const type=match[3]==='*'?'daily':match[3]==='1-5'?'weekdays':'weekends';return {type,time:padTime(match[2],match[1])};}
function scheduleTypeLabel(type){if(type==='hours')return state.lang==='zh'?'每隔若干小时':'Every N hours';if(type==='minutes')return state.lang==='zh'?'每隔若干分钟':'Every N minutes';if(type!=='cron')return t(type);return state.lang==='zh'?'高级规则(Cron)':'Advanced rule (Cron)';}
function scheduleSummary(schedule){const value=normaliseSchedule(schedule);if(['hours','minutes'].includes(value.type))return state.lang==='zh'?`每 ${value.every} ${value.type==='hours'?'小时':'分钟'}`:`Every ${value.every} ${value.type}`;if(['daily','weekdays','weekends'].includes(value.type))return `${scheduleTypeLabel(value.type)} · ${value.time}(${state.lang==='zh'?'上海时间':'Shanghai time'})`;if(value.type==='cron')return `${scheduleTypeLabel('cron')} · ${value.cron}`;return `${scheduleTypeLabel(value.type)} · ${fmt(value.at)}`;}
function scheduleHint(type){if(['hours','minutes'].includes(type))return state.lang==='zh'?'从保存起等待一个间隔后首次执行,此后按间隔重复;不是图片显示时长。':'First runs one interval after saving, then repeats. This is separate from display duration.';if(type==='cron')return state.lang==='zh'?'仅在需要特殊规则时使用;例如“每天 08:00”请选择“每天”。':'Use only for special rules; choose Daily for a regular time.';if(['date','temporary'].includes(type))return state.lang==='zh'?'选择一次性执行的日期和时间。':'Choose the date and time for this one-time task.';return state.lang==='zh'?'选择执行时间(上海时区)。':'Choose a time in Shanghai time.';}
const buttons=(id,text,primary=false)=>`<button type="button" id="${id}" class="${primary?'primary':''}">${t(text)}</button>`;
let toastTimer;
function toast(message){$('#toast').textContent=message;$('#toast').style.display='block';clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('#toast').style.display='none',6000);}
@@ -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();
}
@@ -34,7 +35,12 @@ function busy(fn){return async event=>{try{await fn(event);}catch(e){toast(e.mes
function applyTheme(){const dark=state.theme==='dark'||(state.theme==='system'&&matchMedia('(prefers-color-scheme:dark)').matches);document.documentElement.dataset.theme=dark?'dark':'light';}
function revealCurrentNavigation(){const active=$('#nav .active'),sidebar=$('.sidebar');if(active&&matchMedia('(max-width:720px)').matches)sidebar.scrollLeft=active.offsetLeft-(sidebar.clientWidth-active.offsetWidth)/2;}
window.addEventListener('resize',revealCurrentNavigation);
function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).map(([id,icon])=>`<button data-page="${id}" class="${id===state.page?'active':''}" ${id===state.page?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${d.id===state.device?'selected':''}>${esc(d.name)}</option>`).join('');revealCurrentNavigation();}
function deviceNameForId(id){return deviceDisplayName(state.devices.find(d=>d.id===id)||{id,name:id});}
function operationActionLabel(action){const labels={'render':['生成预览','Generate preview'],'render-ai':['AI 生成图片','Generate AI image'],'official-widget':['更新官方订阅','Update official widget'],'delete':['删除图片','Delete image'],'display':['显示图片','Display image'],'restore':['恢复图片','Restore image'],'local-upload':['保存到图库','Save to gallery'],'reconcile':['核对状态','Reconcile']};return labels[action]?.[state.lang==='zh'?0:1]||t('operation');}
function operationClientLabel(client){return client==='admin'?tr('管理员','Administrator'):client==='scheduler'?tr('自动调度','Scheduler'):client;}
function operationErrorLabel(error){if(!error?.code)return '';const label=t(error.code);return label===error.code?t('error'):label;}
function deviceDisplayName(device){const name=device.name||device.id;return device.id==='living-room'&&['living-room','Living room'].includes(name)?t('livingRoom'):name;}
function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).map(([id,icon])=>`<button data-page="${id}" class="${id===state.page?'active':''}" ${id===state.page?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('');revealCurrentNavigation();}
// A destination appears only when its connection and read/write adapter are available.
// Future cloud-drive destinations must implement their adapter before registration here.
const galleryDestinationAdapters=new Set(['kokonna','local-assets']);
@@ -67,17 +73,17 @@ async function blobImage(img,path){
}
function hydrateImages(){const observer=new IntersectionObserver(entries=>entries.forEach(entry=>{if(entry.isIntersecting){observer.unobserve(entry.target);blobImage(entry.target,entry.target.dataset.src);}}),{rootMargin:'100px'});document.querySelectorAll('img[data-src]').forEach(img=>{imageLoading(img);observer.observe(img);});}
function notice(){return state.settings?.mock?`<div class="notice">${t('mock')}</div>`:'';}
async function navigate(page=state.page){const previousPage=state.page;state.page=icons[page]?page:'dashboard';location.hash=state.page;shell();state.epoch++;const epoch=state.epoch;
async function navigate(page=state.page){document.body.classList.toggle('login-mode',!state.token);const previousPage=state.page;state.page=icons[page]?page:'dashboard';location.hash=state.page;shell();state.epoch++;const epoch=state.epoch;
if(!state.token){login();return;}
if(previousPage!==state.page)$('#main').replaceChildren();
const previousBlobs=state.blobs;state.blobs=[];
try{await views[state.page](epoch);for(const url of previousBlobs)URL.revokeObjectURL(url);if(epoch!==state.epoch)return;const enhance={dashboard:enhanceDashboard,templates:enhanceTemplates,settings:enhanceSettings,upload:enhanceUpload,history:enhanceHistory,automation:enhanceAutomation}[state.page];if(enhance)await enhance();guardDeviceButtons();}catch(e){if(epoch!==state.epoch)return;if(!state.token){shell();login();toast(e.message);return;}$('#main').innerHTML=`<div class="card empty"><strong>${t('apiError')}</strong><p>${esc(e.message)}</p>${buttons('retry','refresh')}</div>`;bind('retry',()=>navigate());}
}
function login(){ $('#main').innerHTML=`<div class="card" style="max-width:560px;margin:40px auto"><span class="badge">FRAME FLOW</span><h2 style="margin-top:20px">${t('connect')}</h2><p class="hint">${t('tokenHint')}</p><form id="login-form"><div class="field"><label for="username">${t('username')}</label><input id="username" required autocomplete="username"></div><div class="field"><label for="password">${t('password')}</label><input id="password" type="password" required autocomplete="current-password"></div><label class="check-row"><input id="remember-login" type="checkbox" checked>${t('rememberLogin')}</label><button class="primary" type="submit">${t('connect')}</button></form></div>`;$('#login-form').onsubmit=busy(async e=>{e.preventDefault();try{const account=await api('/api/v1/auth/login',{method:'POST',body:{username:$('#username').value.trim(),password:$('#password').value,remember:$('#remember-login').checked}});state.token=account.username;state.csrf=account.csrf_token;await loadConnection();await navigate();}catch(error){forgetLogin();shell();login();toast(error.message);}});}
function login(){document.body.classList.add('login-mode');$('#page-title').textContent=t('signIn');$('#main').innerHTML=`<section class="card login-card" aria-labelledby="login-title"><span class="badge">FRAME FLOW</span><h2 id="login-title">${t('connect')}</h2><p class="hint">${t('tokenHint')}</p><form id="login-form"><div class="field"><label for="username">${t('username')}</label><input id="username" required autocomplete="username"></div><div class="field"><label for="password">${t('password')}</label><input id="password" type="password" required autocomplete="current-password"></div><label class="check-row"><input id="remember-login" type="checkbox" checked><span>${t('rememberLogin')}</span></label><button class="primary" type="submit">${t('connect')}</button></form></section>`;$('#login-form').onsubmit=busy(async e=>{e.preventDefault();try{const account=await api('/api/v1/auth/login',{method:'POST',body:{username:$('#username').value.trim(),password:$('#password').value,remember:$('#remember-login').checked}});state.token=account.username;state.csrf=account.csrf_token;await loadConnection();await navigate();}catch(error){forgetLogin();shell();login();toast(error.message);}});}
// 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');
@@ -136,13 +142,11 @@ 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 automation(epoch){const jobs=await api('/api/v1/jobs');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">Asia/Shanghai · ${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(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${jobs.map((j,i)=>`<tr><td><strong>${esc(j.name)}</strong><br><small>${esc(j.device_id)}</small></td><td>${t(j.schedule.type)}<br>${esc(j.schedule.time||j.schedule.cron||fmt(j.schedule.at))}</td><td><span class="badge">${t(j.enabled?'enabled':'disabled')}</span></td><td>${fmt(j.next_run)}</td><td>${fmt(j.last_run)}</td><td><code>${esc(j.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${i}">${t('edit')}</button><button data-job-toggle="${i}">${t(j.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${i}">${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(b=>b.onclick=()=>jobEditor(jobs[b.dataset.jobEdit]));document.querySelectorAll('[data-job-toggle]').forEach(b=>b.onclick=busy(async()=>{const j=jobs[b.dataset.jobToggle];await api('/api/v1/jobs/'+j.id,{method:'PUT',body:jobPayload(j,{enabled:!j.enabled})});await navigate();}));document.querySelectorAll('[data-job-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/jobs/'+jobs[b.dataset.jobDelete].id,{method:'DELETE'});await navigate();}));},
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(o.action)}<br><small>${esc(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||o.error?.code||'—')}</td><td>${esc(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 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();}
};
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...changes};}
async function jobEditor(job){const j=job||{name:'',enabled:true,device_id:state.device,schedule:{type:'daily',time:'07:00'},source:{type:'template',template:'daily-dashboard',data:{}},display_duration:5400,restore_previous:true,priority:50};showModal(`<h2>${t(job?'edit':'newJob')}</h2><form id="job-form"><div class="field"><label for="job-name">${t('name')}</label><input id="job-name" required maxlength="120" value="${esc(j.name)}"></div><div class="form-grid"><div class="field"><label for="job-schedule">${t('schedule')}</label><select id="job-schedule">${['daily','weekdays','weekends','cron','date','temporary'].map(x=>`<option ${j.schedule.type===x?'selected':''} value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label id="schedule-label" for="schedule-value">${t('time')}</label><input id="schedule-value" required value="${esc(j.schedule.time||j.schedule.cron||(j.schedule.at?j.schedule.at.slice(0,16):'07:00'))}"></div></div><div class="field"><label for="job-source">${t('source')} (JSON)</label><textarea id="job-source">${esc(JSON.stringify(j.source,null,2))}</textarea></div><div class="form-grid"><div class="field"><label for="job-duration">${t('duration')}</label><input id="job-duration" type="number" min="0" max="604800" value="${j.display_duration}"></div><div class="field"><label for="job-priority">${t('priority')}</label><input id="job-priority" type="number" min="0" max="100" value="${j.priority}"></div></div><label class="check-row"><input type="checkbox" id="job-enabled" ${j.enabled?'checked':''}>${t('enabled')}</label><label class="check-row"><input type="checkbox" id="job-restore" ${j.restore_previous?'checked':''}>${t('restoreAfter')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);const source=$('#job-source');const choose=document.createElement('div');choose.className='field';choose.innerHTML=`<label for="job-template">${t('template')}</label><select id="job-template"><option value="">${t('advanced')}</option>${state.templates.map(x=>`<option value="${esc(x.id)}" ${j.source.template===x.id?'selected':''}>${esc(x.name)}</option>`).join('')}</select>`;source.parentElement.before(choose);$('#job-template').onchange=()=>{const id=$('#job-template').value;if(id)source.value=JSON.stringify({type:'template',template:id,data:{}},null,2);};function update(){const type=$('#job-schedule').value;$('#schedule-label').textContent=t(type==='cron'?'cron':['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';}$('#job-schedule').onchange=()=>{update();$('#schedule-value').value='';};update();$('#job-form').onsubmit=busy(async e=>{e.preventDefault();const type=$('#job-schedule').value,value=$('#schedule-value').value;const schedule={type};if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse($('#job-source').value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:false,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?'/'+job.id:''),{method:job?'PUT':'POST',body:payload});$('#modal').close();await navigate();});await jobSourceEditor(j);}
async function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`<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(()=>{});
@@ -153,25 +157,25 @@ async function jobEditor(job){
const base={name:'',enabled:true,device_id:state.device,schedule:{type:'daily',time:'07:00'},source:{type:'template',template:'daily-dashboard',data:{}},display_duration:5400,restore_previous:true,priority:50};
const original=job||base;
const j={...base,...original,schedule:normaliseSchedule(original.schedule||base.schedule)};
const scheduleValue=j.schedule.time||j.schedule.cron||(j.schedule.at?j.schedule.at.slice(0,16):'07:00');
showModal(`<h2>${t(job?'edit':'newJob')}</h2><form id="job-form"><div class="field"><label for="job-name">${t('name')}</label><input id="job-name" required maxlength="120" value="${esc(j.name)}"></div><div class="form-grid"><div class="field"><label for="job-schedule">${t('schedule')}</label><select id="job-schedule">${['daily','weekdays','weekends','date','temporary','cron'].map(type=>`<option value="${type}" ${j.schedule.type===type?'selected':''}>${scheduleTypeLabel(type)}</option>`).join('')}</select></div><div class="field"><label id="schedule-label" for="schedule-value">${t('time')}</label><input id="schedule-value" required value="${esc(scheduleValue)}"></div></div><p class="hint" id="schedule-hint"></p><div class="field"><label for="job-source">${t('source')} (JSON)</label><textarea id="job-source">${esc(JSON.stringify(j.source,null,2))}</textarea></div><div class="form-grid"><div class="field"><label for="job-duration">${t('duration')}</label><input id="job-duration" type="number" min="0" max="604800" value="${j.display_duration}"></div><div class="field"><label for="job-priority">${t('priority')}</label><input id="job-priority" type="number" min="0" max="100" value="${j.priority}"></div></div><label class="check-row"><input type="checkbox" id="job-enabled" ${j.enabled?'checked':''}>${t('enabled')}</label><label class="check-row"><input type="checkbox" id="job-restore" ${j.restore_previous?'checked':''}>${t('restoreAfter')}</label><p class="hint">${t('cloudKeepLatest')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
const scheduleValue=j.schedule.every||j.schedule.time||j.schedule.cron||(j.schedule.at?j.schedule.at.slice(0,16):'07:00');
showModal(`<h2>${t(job?.id?'edit':'newJob')}</h2><form id="job-form"><div class="field"><label for="job-name">${t('name')}</label><input id="job-name" required maxlength="120" value="${esc(j.name)}"></div><div class="form-grid"><div class="field"><label for="job-schedule">${t('schedule')}</label><select id="job-schedule">${['minutes','hours','daily','weekdays','weekends','date','temporary','cron'].map(type=>`<option value="${type}" ${j.schedule.type===type?'selected':''}>${scheduleTypeLabel(type)}</option>`).join('')}</select></div><div class="field"><label id="schedule-label" for="schedule-value">${t('time')}</label><input id="schedule-value" required value="${esc(scheduleValue)}"></div></div><p class="hint" id="schedule-hint"></p><div class="field"><label for="job-source">${t('source')} (JSON)</label><textarea id="job-source">${esc(JSON.stringify(j.source,null,2))}</textarea></div><div class="form-grid"><div class="field"><label for="job-duration">${t('duration')}</label><input id="job-duration" type="number" min="0" max="604800" value="${j.display_duration}"></div><div class="field"><label for="job-priority">${t('priority')}</label><input id="job-priority" type="number" min="0" max="100" value="${j.priority}"></div></div><label class="check-row"><input type="checkbox" id="job-enabled" ${j.enabled?'checked':''}>${t('enabled')}</label><label class="check-row"><input type="checkbox" id="job-restore" ${j.restore_previous?'checked':''}>${t('restoreAfter')}</label><p class="hint">${t('temporaryOptionsHint')}</p><p class="hint">${t('cloudKeepLatest')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
const source=$('#job-source');
const choose=document.createElement('div');
choose.className='field';
choose.innerHTML=`<label for="job-template">${t('template')}</label><select id="job-template"><option value="">${t('advanced')}</option>${state.templates.map(template=>`<option value="${esc(template.id)}" ${j.source.template===template.id?'selected':''}>${esc(template.name)}</option>`).join('')}</select>`;
source.parentElement.before(choose);
$('#job-template').onchange=()=>{const id=$('#job-template').value;if(id)source.value=JSON.stringify({type:'template',template:id,data:{}},null,2);};
function updateScheduleField(){const type=$('#job-schedule').value;$('#schedule-label').textContent=type==='cron'?scheduleTypeLabel(type):t(['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';$('#schedule-hint').textContent=scheduleHint(type);}
function updateScheduleField(){const type=$('#job-schedule').value;const interval=['hours','minutes'].includes(type);$('#schedule-value').min=interval?'1':'';$('#schedule-value').max=type==='hours'?'168':type==='minutes'?'10080':'';$('#schedule-value').step=interval?'1':'';$('#schedule-label').textContent=interval?tr('间隔数值','Interval'):type==='cron'?scheduleTypeLabel(type):t(['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=interval?'number':type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';$('#schedule-hint').textContent=scheduleHint(type);}
$('#job-schedule').onchange=()=>{updateScheduleField();$('#schedule-value').value='';};
updateScheduleField();
$('#job-form').onsubmit=busy(async event=>{event.preventDefault();const type=$('#job-schedule').value;const value=$('#schedule-value').value;const schedule={type};if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse(source.value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:false,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?'/'+job.id:''),{method:job?'PUT':'POST',body:payload});$('#modal').close();await navigate();});
$('#job-form').onsubmit=busy(async event=>{event.preventDefault();const type=$('#job-schedule').value;const value=$('#schedule-value').value;const schedule=['hours','minutes'].includes(type)?{type:'interval',interval_minutes:Number(value)*(type==='hours'?60:1)}:{type};if(['hours','minutes'].includes(type)){}else if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse(source.value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:false,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?.id?'/'+job.id:''),{method:job?.id?'PUT':'POST',body:payload});$('#modal').close();await navigate();});
await jobSourceEditor(j);
}
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">Asia/Shanghai · ${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(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><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>`;
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('按小图清晰度、亮度和相似度筛选,不改动或删除 NAS 原照片;无法保证识别闭眼。建议先用低阈值。个人空间还需要将相应目录只读挂载到 /photos。','Filters thumbnail sharpness, brightness and similarity without modifying or deleting originals. Closed-eye detection is not guaranteed. Start with low thresholds. Personal space also requires mounting its folder read-only at /photos.')}</p><div class="field"><label for="source-people-space">${tr('人物空间','People 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"><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
@@ -45,7 +45,7 @@ async function weatherSettings(){
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'));});
}
async function enhanceHistory(){
const table=document.querySelector('#main table');if(!table)return;table.classList.add('history-table');const heading=document.createElement('th');heading.textContent=t('operation');table.querySelector('thead tr').append(heading);const rows=[...table.querySelectorAll('tbody tr')];const ops=await api('/api/v1/operations');rows.forEach((row,i)=>{const op=ops[i];if(!op)return;const button=document.createElement('button');button.textContent=t('details');const actions=document.createElement('td');actions.className='history-actions';actions.append(button);row.append(actions);button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+op.operation_id);showModal(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(current.action)}</p><p>${esc(current.error?.message||'')}</p><pre style="white-space:pre-wrap;overflow-wrap:anywhere">${esc(JSON.stringify({operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}</pre>${current.result?.url?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);if(current.result?.url)await blobImage($('#operation-preview'),current.result.url);});});}
const table=document.querySelector('#main table');if(!table)return;table.classList.add('history-table');const heading=document.createElement('th');heading.textContent=t('operation');table.querySelector('thead tr').append(heading);const rows=[...table.querySelectorAll('tbody tr')];const ops=await api('/api/v1/operations');rows.forEach((row,i)=>{const op=ops[i];if(!op)return;const button=document.createElement('button');button.textContent=t('details');const actions=document.createElement('td');actions.className='history-actions';actions.append(button);row.append(actions);button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+op.operation_id);showModal(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(operationActionLabel(current.action))}</p><p>${esc(operationErrorLabel(current.error)||current.error?.message||'')}</p><pre style="white-space:pre-wrap;overflow-wrap:anywhere">${esc(JSON.stringify({action:current.action,device_id:current.device_id,error_code:current.error?.code,operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}</pre>${current.result?.url?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);if(current.result?.url)await blobImage($('#operation-preview'),current.result.url);});});}
async function enhanceAutomation(){
const jobs=await api('/api/v1/jobs');const rows=[...document.querySelectorAll('tbody tr')];rows.forEach((row,i)=>{const job=jobs[i];if(!job)return;const actions=row.querySelector('.row-actions');const run=document.createElement('button');run.textContent=t('runNow');run.disabled=!liveWriteAllowed();run.title=run.disabled?t('readOnlyWarning'):'';actions.append(run);run.onclick=()=>confirmDialog(t('runConfirm'),async()=>{await accepted('/api/v1/jobs/'+job.id+'/run',{});await navigate('history');});if(['template','auto_collage'].includes(job.source.type)){const preview=document.createElement('button');preview.textContent=t('jobPreview');actions.append(preview);preview.onclick=busy(async()=>{const id=await accepted('/api/v1/jobs/'+job.id+'/preview',{});const op=await waitOperation(id);showModal(`<h2>${t('jobPreview')}</h2><img id="job-preview-image" class="preview" alt="Preview"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#job-preview-image'),op.result.url);});}});
}
@@ -140,7 +140,7 @@ function photosLoginDialog(){
async function photoSourcesPanel(){
const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section);
const sources=await api('/api/v1/photo-sources');if(!section.isConnected)return;
section.innerHTML=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><section id="photos-connection" class="photos-connection" aria-label="Synology Photos"></section><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint source-identity">${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}</p><p class="hint">${s.people_filter?t('sourceSharedPhotos'):s.kind==='local'?t('sourceLocalOnly'):t('sourceOwnCredentials')}</p><p class="hint">${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}</p><div class="actions"><button data-source-browse="${esc(s.id)}">${t('browse')}</button><button data-source-random="${esc(s.id)}" ${s.enabled?'':'disabled'}>${t('randomPreview')}</button><button data-source-job="${esc(s.id)}">${t('sourceJob')}</button><button data-source-edit="${esc(s.id)}">${t('edit')}</button><button class="danger" data-source-delete="${esc(s.id)}">${t('remove')}</button></div></article>`).join(''):`<p class="hint">${t('noSources')}</p>`}</div><div id="source-browser"></div>`;
section.innerHTML=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><section id="photos-connection" class="photos-connection" aria-label="Synology Photos"></section><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint source-identity">${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}</p><p class="hint">${s.kind==='synology-photos'?t('sourcePhotosAPI'):t('sourceOwnCredentials')}</p><p class="hint">${esc(s.kind==='synology-photos'?'Synology Photos':s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}</p><div class="actions"><button data-source-browse="${esc(s.id)}">${t('browse')}</button><button data-source-random="${esc(s.id)}" ${s.enabled?'':'disabled'}>${t('randomPreview')}</button><button data-source-job="${esc(s.id)}">${t('sourceJob')}</button><button data-source-edit="${esc(s.id)}">${t('edit')}</button><button class="danger" data-source-delete="${esc(s.id)}">${t('remove')}</button></div></article>`).join(''):`<p class="hint">${t('noSources')}</p>`}</div><div id="source-browser"></div>`;
await photosConnectionPanel();
bind('add-photo-source',()=>photoSourceForm());
section.querySelectorAll('[data-source-edit]').forEach(b=>b.onclick=()=>photoSourceForm(sources.find(s=>s.id===b.dataset.sourceEdit)));
@@ -150,19 +150,19 @@ 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"><div class="field"><label for="source-id">ID</label><input id="source-id" pattern="[a-z0-9-]{1,60}" required value="${esc(source?.id||'')}" ${source?'readonly':''}></div><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"><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="local">${t('nasDirectory')}</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 class="check-row"><input id="source-synology-previews" type="checkbox" ${source?.synology_previews?'checked':''}>${t('synologyPreviews')}</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||'local';const update=()=>{const dav=$('#source-kind').value!=='local';$('#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=dav;$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};addPhotoQualityFields(source);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=$('#source-kind').value!=='local';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:!dav&&$('#source-synology-previews').checked,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"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${!source||source.folder_caption?'checked':''}>${t('folderCaption')}</label><label hidden><input id="source-synology-previews" type="checkbox" disabled></label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
$('#source-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'synology-photos';const update=()=>{const photosAPI=$('#source-kind').value==='synology-photos';$('#source-location').labels[0].textContent=photosAPI?tr('照片目录','Photo folder'):t('sourceLocation');$('#source-location').nextElementSibling.textContent=photosAPI?tr('填写所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。','Folder path within the selected space; . means the whole space. Downloads XL previews on demand, not originals.'):t('sourceLocationHint');const dav=!['local','synology-photos'].includes($('#source-kind').value);$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-credential').closest('.field').hidden=$('#source-kind').value!=='webdav';const oauth=$('#source-oauth');if(oauth)oauth.closest('.field').hidden=!['google-drive','alipan','baidu'].includes($('#source-kind').value);const quality=$('#source-local-quality');if(quality)quality.hidden=dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=$('#source-kind').value!=='local';$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};addPhotoQualityFields(source);$('#source-location').closest('.field').before($('#source-people-space').closest('.field'));update();$('#source-kind').onchange=update;$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';};
$('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=!['local','synology-photos'].includes($('#source-kind').value);await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,recursive:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:false,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');});
}
async function browseSource(id,offset=0,refresh=false){
const result=await api('/api/v1/photo-sources/'+id+'/images?device_id='+encodeURIComponent(state.device)+'&offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return;
box.innerHTML=`<h4>${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}</h4><p class="hint">${t('sourceBrowseHint')}</p><div class="source-items">${result.items.map((x,i)=>`<button data-source-item="${i}">${esc(x.name)}${x.width?`<small>${x.width} × ${x.height}</small>`:''}${x.caption?`<small>${esc([x.caption.title,x.caption.date,x.caption.place].filter(Boolean).join(' · '))}</small>`:''}</button>`).join('')||t('noSourcePhotos')}</div><div class="actions"><button id="source-prev" ${offset?'':'disabled'}>${t('previousPage')}</button><button id="source-next" ${offset+result.items.length<result.total?'':'disabled'}>${t('nextPage')}</button>${buttons('source-refresh','refresh')}</div>`;
box.innerHTML=`<h4>${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}</h4><p class="hint">${t('sourceBrowseHint')}</p><div class="source-items">${result.items.map((x,i)=>`<button data-source-item="${i}">${esc(x.name)}${x.width?`<small>${x.width} × ${x.height}</small>`:''}${x.caption?`<small>${esc([x.caption.date,x.caption.people,x.caption.title,x.caption.place,x.caption.filename].filter(Boolean).join(' · '))}</small>`:''}</button>`).join('')||t('noSourcePhotos')}</div><div class="actions"><button id="source-prev" ${offset?'':'disabled'}>${t('previousPage')}</button><button id="source-next" ${offset+result.items.length<result.total?'':'disabled'}>${t('nextPage')}</button>${buttons('source-refresh','refresh')}</div>`;
box.querySelectorAll('[data-source-item]').forEach(b=>b.onclick=busy(()=>previewSource(id,result.items[Number(b.dataset.sourceItem)].id)));bind('source-prev',()=>browseSource(id,Math.max(0,offset-50)));bind('source-next',()=>browseSource(id,offset+50));bind('source-refresh',()=>browseSource(id,0,true));
}
async function previewSource(id,item_id=null){
const acceptedResult=await api('/api/v1/devices/'+state.device+'/upload',{method:'POST',body:{type:'photo_source',source_id:id,item_id,fit:'contain',profile:'color'},headers:{'Idempotency-Key':clientUUID()}});const op=await waitOperation(acceptedResult.operation_id);
showModal(`<h2>${t('preview')}</h2><img id="source-preview" class="preview" alt="${t('preview')}"><p class="hint">${t('sourcePreviewHint')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('source-display','display',true)}</div>`);await blobImage($('#source-preview'),op.result.url);$('#source-display').disabled=!liveWriteAllowed();bind('source-display',()=>displayDialog({type:'image',asset_id:op.result.asset_id}));
}
function sourceJobForm(id){
showModal(`<h2>${t('sourceJob')}</h2><form id="source-job-form"><div class="field"><label for="source-job-name">${t('name')}</label><input id="source-job-name" required maxlength="120" value="${t('randomPhoto')}"></div><div class="field"><label for="source-job-cron">Cron · Asia/Shanghai</label><input id="source-job-cron" required value="0 8 * * *"><p class="hint">${t('sourceJobHint')}</p></div><div class="field"><label class="check-row"><input id="source-job-enabled" type="checkbox">${t('enabled')}</label></div><p class="hint">${t('cloudKeepLatest')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}<button class="primary" type="submit">${t('save')}</button></div></form>`);$('#source-job-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/jobs',{method:'POST',body:{name:$('#source-job-name').value,device_id:state.device,enabled:$('#source-job-enabled').checked,cleanup_unstarred:false,schedule:{type:'cron',cron:$('#source-job-cron').value},source:{type:'photo_source',source_id:id,fit:'contain',profile:'color'}}});$('#modal').close();await navigate('automation');});
async function sourceJobForm(id){
await jobEditor({name:t('randomPhoto'),device_id:state.device,enabled:false,schedule:{type:'interval',interval_minutes:120},source:{type:'photo_source',source_id:id,fit:'contain',profile:'color'},display_duration:0,restore_previous:false,priority:50});
}
+25 -15
View File
@@ -17,16 +17,16 @@ Object.assign(window.MESSAGES.zh,{poetryNotice:'Frame Flow 每日诗词:内置
Object.assign(MESSAGES.zh,{officialContentTitle:'KoKonna 官方小组件与订阅',officialContentDescription:'官方产品资料列出的内容类型;下列功能与 Frame Flow 自建模板分开管理。',officialCalendarWeather:'日历与当地天气',officialWordAlmanacTravel:'每日一词、传统黄历、全球旅行推荐',officialNews:'每日新闻',officialCreative:'AI 语音 / 文字 / 涂鸦 / 照片转艺术;照片轮播',officialSubscriptionUnavailable:'公开 OpenAPI 尚未列出订阅目录、当前订阅、开通或取消订阅接口。官方订阅请继续在官方小程序中管理;Frame Flow 不会读取或修改这些设置。你使用的每日诗词属于官方小程序内容,当前无法通过公开 API 确认完整类型列表。',officialProductDocs:'官方内容介绍 ↗',officialAPIDocs:'官方 OpenAPI ↗'});
Object.assign(MESSAGES.en,{officialContentTitle:'KoKonna official widgets and subscriptions',officialContentDescription:'Content types listed in official product materials; managed separately from Frame Flow templates.',officialCalendarWeather:'Calendar and local weather',officialWordAlmanacTravel:'Word of the day, traditional almanac and global travel recommendations',officialNews:'Daily news',officialCreative:'AI voice / text / sketch / photo-to-art; photo rotation',officialSubscriptionUnavailable:'Public OpenAPI does not list subscription discovery, current subscription, subscribe or unsubscribe endpoints. Manage official subscriptions in the official mini-program. Frame Flow cannot read or modify these settings. Your daily-poetry subscription is mini-program content; its complete catalog cannot currently be verified through public API.',officialProductDocs:'Official content overview ↗',officialAPIDocs:'Official OpenAPI ↗'});
Object.assign(MESSAGES.zh,{photoSources:'照片数据源',sourceHint:'从只读 NAS 目录或 WebDAV 选图;预览不会改变画框。',addSource:'添加数据源',browse:'浏览照片',randomPreview:'随机选图预览',sourceJob:'定时随机照片',noSources:'暂无照片,请添加数据源或检查目录。',sourceKind:'数据源类型',nasDirectory:'NAS 本地目录',sourceLocation:'目录 / WebDAV URL',sourceLocationHint:'本地填写挂载照片根目录下的相对路径,如 . 或 family。WebDAV 填写 HTTPS 目录 URL,并先在设置中允许该域名。',sourceCredential:'WebDAV 凭据环境变量名',sourceCredentialHint:'仅填写变量名,禁止填写密码。管理员通过环境变量或 Secret 配置 JSON username/password。',sourceRecursive:'包含子目录(本地目录)',sourceImages:'可用照片',sourceBrowseHint:'按需读取目录,点击文件名才生成预览;不会下载全部原图。',previousPage:'上一页',nextPage:'下一页',sourcePreviewHint:'这是已选中照片的预览,显示时使用同一张照片。',randomPhoto:'随机照片',sourceJobHint:'每次执行重新随机选图。默认暂停;启用后会按时改变画框显示。'});
Object.assign(MESSAGES.en,{photoSources:'Photo sources',sourceHint:'Choose photos from a read-only NAS folder or WebDAV. Preview does 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',nasDirectory:'NAS local folder',sourceLocation:'Folder / WebDAV URL',sourceLocationHint:'Local: relative folder under the mounted photo root, e.g. . or family. WebDAV: HTTPS directory URL; allow its hostname in Settings first.',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 (local)',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,{photoSources:'照片数据源',sourceHint:'通过 Synology Photos API 管理照片来源、人物和筛选规则。浏览与预览不会改变画框。',addSource:'添加数据源',browse:'浏览照片',randomPreview:'随机选图预览',sourceJob:'定时随机照片',noSources:'暂无照片,请添加数据源或检查目录。',sourceKind:'数据源类型',sourceLocation:'目录 / WebDAV URL',sourceLocationHint:'Synology Photos:所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。其他云盘使用对应目录地址或 ID。',sourceCredential:'WebDAV 凭据环境变量名',sourceCredentialHint:'仅填写变量名,禁止填写密码。管理员通过环境变量或 Secret 配置 JSON username/password。',sourceRecursive:'包含子目录',sourceImages:'可用照片',sourceBrowseHint:'按需读取目录,点击文件名才生成预览;不会下载全部原图。',previousPage:'上一页',nextPage:'下一页',sourcePreviewHint:'这是已选中照片的预览,显示时使用同一张照片。',randomPhoto:'随机照片',sourceJobHint:'每次执行重新随机选图。默认暂停;启用后会按时改变画框显示。'});
Object.assign(MESSAGES.en,{photoSources:'Photo sources',sourceHint:'Manage photo sources, people and filters through the Synology Photos API. Browsing and previewing do not change the frame.',addSource:'Add source',browse:'Browse photos',randomPreview:'Preview random photo',sourceJob:'Schedule random photos',noSources:'No photos yet. Add a source or check the folder.',sourceKind:'Source type',sourceLocation:'Folder / WebDAV URL',sourceLocationHint:'Synology Photos: folder path within the selected space; . means the whole space. Downloads XL previews on demand, not originals. Other drives use their folder URL or ID.',sourceCredential:'WebDAV credential environment name',sourceCredentialHint:'Enter a variable name, never a password. Admin configures JSON username/password via environment or Secret.',sourceRecursive:'Include subfolders',sourceImages:'Available photos',sourceBrowseHint:'Catalog loaded on demand. Click a filename to preview; originals are not downloaded in bulk.',previousPage:'Previous page',nextPage:'Next page',sourcePreviewHint:'Display uses this exact previewed photo.',randomPhoto:'Random photo',sourceJobHint:'Choose a fresh random photo each run. Paused by default; enabling changes the frame on schedule.'});
Object.assign(MESSAGES.zh,{organizedPhotos:'仅日期开头文件夹中的照片',folderCaption:'照片下方显示文件夹文字',folderCaptionHint:'第一行:去掉日期和地点后的主题;第二行:日期;第三行:@ 后的地点(没有地点则省略)。子目录沿用上级日期文件夹。'});
Object.assign(MESSAGES.en,{organizedPhotos:'Only photos in date-prefixed folders',folderCaption:'Show folder caption below the photo',folderCaptionHint:'Line 1: title without date/place. Line 2: date. Line 3: place after @, omitted when absent. Subfolders inherit their nearest dated folder.'});
Object.assign(MESSAGES.zh,{organizedPhotos:'仅日期开头文件夹中的照片',folderCaption:'照片下方显示文件夹文字',folderCaptionHint:'底部单行显示日期、已命名人物、主题、@ 后的地点和文件名。子目录沿用最近的日期文件夹;没有日期文件夹时保留文件名,未命名人物不显示。'});
Object.assign(MESSAGES.en,{organizedPhotos:'Only photos in date-prefixed folders',folderCaption:'Show folder caption below the photo',folderCaptionHint:'One footer line shows date, named people, title, place after @ and filename. Subfolders inherit their nearest dated folder; otherwise the filename is kept. Unnamed people are omitted.'});
Object.assign(MESSAGES.zh,{synologyPreviews:'超大图片使用 Synology Photos 的现有预览图'});Object.assign(MESSAGES.en,{synologyPreviews:'Use existing Synology Photos previews for oversized images'});
Object.assign(MESSAGES.zh,{});Object.assign(MESSAGES.en,{});
Object.assign(MESSAGES.zh,{peopleFilter:'按 Synology Photos 人物筛选',peopleHint:'只从当前目录中选择符合人物条件的照片。所选空间的人物相册需要当前 Photos 账号有权访问。',choosePeople:'连接 / 选择人物',personMatch:'人物匹配方式',personAny:'包含任意所选人物',personAll:'同时包含所有所选人物',photosConfigure:'管理员需配置 SYNOLOGY_PHOTOS_URL 和 SYNOLOGY_PHOTOS_CERT_SHA256,并将共享 photo 目录挂载为照片根目录。',photosSessionHint:'仅保存受保护的登录会话,不保存 NAS 密码。会话失效后需要重新连接。',photosConnect:'连接 Synology Photos',photosDisconnect:'断开连接',peopleSearch:'搜索人物名称或 ID',peopleUnnamed:'显示未命名人物分组',peopleSelected:'已选择:'});
Object.assign(MESSAGES.en,{peopleFilter:'Filter by Synology Photos people',peopleHint:'Choose matching people photos only within this folder. The Photos account needs access to People albums in the selected space.',choosePeople:'Connect / choose people',personMatch:'Person matching',personAny:'Any selected person',personAll:'All selected people together',photosConfigure:'Admin must configure SYNOLOGY_PHOTOS_URL and SYNOLOGY_PHOTOS_CERT_SHA256, and mount the shared photo folder as the photo root.',photosSessionHint:'Only a protected login session is stored, never your NAS password. Reconnect when the session expires.',photosConnect:'Connect Synology Photos',photosDisconnect:'Disconnect',peopleSearch:'Search person name or ID',peopleUnnamed:'Show unnamed person groups',peopleSelected:'Selected: '});
Object.assign(MESSAGES.zh,{peopleFilter:'按 Synology Photos 人物筛选',peopleHint:'只从当前目录中选择符合人物条件的照片。所选空间的人物相册需要当前 Photos 账号有权访问。',choosePeople:'连接 / 选择人物',personMatch:'人物匹配方式',personAny:'包含任意所选人物',personAll:'同时包含所有所选人物',photosConfigure:'管理员需配置 SYNOLOGY_PHOTOS_URL 和 SYNOLOGY_PHOTOS_CERT_SHA256。照片和人物均通过 API 读取。',photosSessionHint:'仅保存受保护的登录会话,不保存 NAS 密码。会话失效后需要重新连接。',photosConnect:'连接 Synology Photos',photosDisconnect:'断开连接',peopleSearch:'搜索人物名称或 ID',peopleUnnamed:'显示未命名人物分组',peopleSelected:'已选择:'});
Object.assign(MESSAGES.en,{peopleFilter:'Filter by Synology Photos people',peopleHint:'Choose matching people photos only within this folder. The Photos account needs access to People albums in the selected space.',choosePeople:'Connect / choose people',personMatch:'Person matching',personAny:'Any selected person',personAll:'All selected people together',photosConfigure:'Configure SYNOLOGY_PHOTOS_URL and SYNOLOGY_PHOTOS_CERT_SHA256. Photos and people are read through the API.',photosSessionHint:'Only a protected login session is stored, never your NAS password. Reconnect when the session expires.',photosConnect:'Connect Synology Photos',photosDisconnect:'Disconnect',peopleSearch:'Search person name or ID',peopleUnnamed:'Show unnamed person groups',peopleSelected:'Selected: '});
Object.assign(MESSAGES.zh,{'photo-sources':'照片数据源'});
Object.assign(MESSAGES.en,{'photo-sources':'Photo sources'});
@@ -117,25 +117,35 @@ Object.assign(window.MESSAGES.en,{
Object.assign(window.MESSAGES.zh,{studio:'拼版与海报',connections:'连接中心'});
Object.assign(window.MESSAGES.en,{studio:'Layout Studio',connections:'Connections'});
Object.assign(MESSAGES.zh,{photosConnectionHelp:'NAS 照片登录与文件夹设置分开管理。人物筛选和 Photos 预览需要保持连接。',photosConnected:'已连接',photosDisconnected:'未连接',photosReconnect:'重新登录',photosReconnectHelp:'尚未登录或会话已失效。依赖 Synology Photos 的自动化会失败,请先登录 NAS 账号。无需填写配置变量。',photosConnectedHelp:'连接已保存。相关自动化将在下次计划时间使用照片,不会立即切换画框。',photosSetupHelp:'服务尚未配置 Photos 地址和证书。请管理员完成部署设置后再登录;这里不需要填写网盘变量。',photosLoginHelp:'使用登录群晖 NAS / Synology Photos 的账号密码,不是 Frame Flow 账号,也不是 KoKonna Key。',nasUsername:'NAS 用户名',nasPassword:'NAS 密码'});
Object.assign(MESSAGES.en,{photosConnectionHelp:'Manage NAS sign-in separately from photo folders. People filtering and Photos previews require a connection.',photosConnected:'Connected',photosDisconnected:'Not connected',photosReconnect:'Sign in again',photosReconnectHelp:'Not signed in or the session expired. Automation using Synology Photos will fail until you sign in. No configuration variable is required here.',photosConnectedHelp:'Connection saved. Automation will use photos at its next scheduled time; the frame is not changed now.',photosSetupHelp:'The Photos address and certificate are not configured. Ask the administrator to finish deployment setup. Drive variables are not needed here.',photosLoginHelp:'Use your Synology NAS / Photos account, not your Frame Flow account or KoKonna key.',nasUsername:'NAS username',nasPassword:'NAS password'});
Object.assign(MESSAGES.zh,{sourceHint:'管理 NAS 照片目录、WebDAV 和网盘来源。浏览与生成预览不会改变画框。'});
Object.assign(MESSAGES.en,{sourceHint:'Manage NAS folders, WebDAV and cloud drives. Browsing and generating previews do not change the frame.'});
Object.assign(MESSAGES.zh,{photosConnectionHelp:'此账号通过 Synology Photos API 授权读取照片和人物。连接成功后,请添加照片来源并设置空间和人物;更换账号会影响所有 Photos 来源。',photosConnected:'已连接',photosDisconnected:'未连接',photosReconnect:'重新登录',photosReconnectHelp:'尚未登录或会话已失效。依赖 Synology Photos 的自动化会失败,请先登录 NAS 账号。无需填写配置变量。',photosConnectedHelp:'连接已保存。相关自动化将在下次计划时间使用照片,不会立即切换画框。',photosSetupHelp:'服务尚未配置 Photos 地址和证书。请管理员完成部署设置后再登录;这里不需要填写网盘变量。',photosLoginHelp:'使用登录群晖 NAS / Synology Photos 的账号密码,不是 Frame Flow 账号,也不是 KoKonna Key。',nasUsername:'NAS 用户名',nasPassword:'NAS 密码'});
Object.assign(MESSAGES.en,{photosConnectionHelp:'This account authorizes photo and people access through the Synology Photos API. After connecting, add a photo source and choose its space and people. Changing accounts affects all Photos sources.',photosConnected:'Connected',photosDisconnected:'Not connected',photosReconnect:'Sign in again',photosReconnectHelp:'Not signed in or the session expired. Automation using Synology Photos will fail until you sign in. No configuration variable is required here.',photosConnectedHelp:'Connection saved. Automation will use photos at its next scheduled time; the frame is not changed now.',photosSetupHelp:'The Photos address and certificate are not configured. Ask the administrator to finish deployment setup. Drive variables are not needed here.',photosLoginHelp:'Use your Synology NAS / Photos account, not your Frame Flow account or KoKonna key.',nasUsername:'NAS username',nasPassword:'NAS password'});
Object.assign(MESSAGES.zh,{sourceHint:'通过 Synology Photos API 管理照片来源、人物和筛选规则。浏览与预览不会改变画框。'});
Object.assign(MESSAGES.en,{sourceHint:'Manage photo sources, people and filters through the Synology Photos API. Browsing and previewing do not change the frame.'});
Object.assign(MESSAGES.zh,{choosePeople:'选择人物'});
Object.assign(MESSAGES.en,{choosePeople:'Choose people'});
Object.assign(MESSAGES.zh,{photosSharedConnection:'共享连接',photosConnectionHelp:'当前仅支持一台 NAS、一个 Photos 登录账号。下方文件夹分别保存设置;此登录不是任何一张数据源卡片的专属连接。人物筛选使用此共享连接,普通目录读取和本地缩略图不依赖此账号。',photosLoginHelp:'使用群晖 NAS / Photos 账号,不是 Frame Flow 账号或 KoKonna Key。更换账号会影响所有启用人物筛选的数据源;个人空间的人物可能不同,更换后请逐个重新核对人物选择。',sourceIndependentHelp:'这里只保存当前数据源的目录、人物与筛选规则,不会修改其他数据源,也不会更换顶部共享 NAS 账号。任务通过数据源 ID 引用这些设置。',sourceIdLabel:'数据源 ID',sourceSharedPhotos:'关联:顶部 Synology Photos 共享连接(人物筛选)',sourceLocalOnly:'关联:NAS 挂载目录;读取文件无需顶部 Photos 登录',sourceOwnCredentials:'关联:此数据源自己的网盘/WebDAV 凭据;不使用顶部 NAS 登录'});
Object.assign(MESSAGES.en,{photosSharedConnection:'Shared connection',photosConnectionHelp:'Currently one NAS and one Photos account are supported. Each folder stores its own settings; this sign-in is not owned by any source card. People filtering uses this shared session. Ordinary folder reads and local thumbnails do not use this account.',photosLoginHelp:'Use a Synology NAS / Photos account, not a Frame Flow account or KoKonna key. Changing accounts affects all people-filtered sources. Personal-space people may differ; recheck each source after switching accounts.',sourceIndependentHelp:'These folder, people and filter settings apply only to this source. Other sources and the shared NAS account are unchanged. Jobs refer to these settings by source ID.',sourceIdLabel:'Source ID',sourceSharedPhotos:'Connection: shared Synology Photos session above (people filtering)',sourceLocalOnly:'Connection: mounted NAS folder; file reads do not require Photos sign-in',sourceOwnCredentials:'Connection: this source’s drive/WebDAV credentials; independent of NAS sign-in'});
Object.assign(MESSAGES.zh,{photosSharedConnection:'共享连接',photosConnectionHelp:'此账号通过 Synology Photos API 授权读取照片和人物。连接成功后,请添加照片来源并设置空间和人物;更换账号会影响所有 Photos 来源。',photosLoginHelp:'使用群晖 NAS / Photos 账号,不是 Frame Flow 账号或 KoKonna Key。更换账号会影响所有启用人物筛选的数据源;个人空间的人物可能不同,更换后请逐个重新核对人物选择。',sourceIndependentHelp:'这里只保存当前数据源的目录、人物与筛选规则,不会修改其他数据源,也不会更换顶部共享 NAS 账号。任务通过数据源 ID 引用这些设置。',sourceIdLabel:'数据源 ID',sourceSharedPhotos:'关联:Synology Photos API 共享账号',sourceOwnCredentials:'关联:此数据源自己的网盘/WebDAV 凭据;不使用顶部 NAS 登录'});
Object.assign(MESSAGES.en,{photosSharedConnection:'Shared connection',photosConnectionHelp:'This account authorizes photo and people access through the Synology Photos API. After connecting, add a photo source and choose its space and people. Changing accounts affects all Photos sources.',photosLoginHelp:'Use a Synology NAS / Photos account, not a Frame Flow account or KoKonna key. Changing accounts affects all people-filtered sources. Personal-space people may differ; recheck each source after switching accounts.',sourceIndependentHelp:'These folder, people and filter settings apply only to this source. Other sources and the shared NAS account are unchanged. Jobs refer to these settings by source ID.',sourceIdLabel:'Source ID',sourceSharedPhotos:'Connection: shared Synology Photos API account',sourceOwnCredentials:'Connection: this source’s drive/WebDAV credentials; independent of NAS sign-in'});
Object.assign(MESSAGES.zh,{photosDisconnect:'断开共享连接',photosSharedDisconnectWarning:'这将断开所有人物筛选数据源共用的 Photos 登录,相关任务会无法读取人物照片。文件夹设置和网盘连接不变。确定断开吗?'});
Object.assign(MESSAGES.en,{photosDisconnect:'Disconnect shared session',photosSharedDisconnectWarning:'This disconnects the Photos session used by all people-filtered sources. Their tasks will fail to read people photos. Folder settings and drive connections remain unchanged. Disconnect?'});
Object.assign(MESSAGES.zh,{weather:'天气',connected:'已连接',disconnected:'未连接',sortOrder:'排序方式',playbackMode:'播放顺序',apiKeyConfigured:'API 密钥已配置',templateSourceUnavailable:'这张图片没有可编辑的模板来源。可编辑模板仅适用于保留了模板配置的生成图片。',localDeleteConfirm:'确认删除本地图库中的这张图片?不会删除 NAS 原始照片或云端副本,也不会切换画框。此操作不可撤销。',cloudDeleteConfirm:'确认删除这张图片的云端副本?不会删除 NAS 原始照片或本地图库副本。此操作不可撤销。',gallerySelectionHelp:'全选和反选只作用于当前页可见图片;批量操作处理当前页全部已勾选图片,包括搜索后隐藏的勾选项。',starHelp:'星标保存在服务端,用于防止自动清理本地图及其最近一次成功上传的云端副本。星标本身不会上传或切换画框;手动删除仍有效。',sourceLocationHint:'NAS 目录:填写挂载照片根目录下的相对路径,如 . 或 family。WebDAV:填写 HTTPS 目录地址,并在设置中允许其域名。Google Drive / 阿里云盘:目录 ID;百度网盘:目录路径。',sourceCredential:'WebDAV / 网盘凭据环境变量名',sourceCredentialHint:'填写变量名,不要填写密码。管理员可通过环境变量配置凭据;原生网盘也可使用下方 OAuth 凭据。'});
Object.assign(MESSAGES.en,{weather:'Weather',connected:'Connected',disconnected:'Not connected',sortOrder:'Sort order',playbackMode:'Playback order',apiKeyConfigured:'API key configured',templateSourceUnavailable:'No editable template source is available for this image. Template editing requires a generated image with saved template settings.',localDeleteConfirm:'Delete this local gallery image? NAS originals and cloud copies remain intact, and the frame display is unchanged. This cannot be undone.',cloudDeleteConfirm:'Delete this cloud image? NAS originals and local gallery copies remain intact. This cannot be undone.',gallerySelectionHelp:'Select all and invert affect visible images on this page. Batch actions include every checked image on this page, including items hidden by search.',starHelp:'Stars are saved on the server and protect the local image and its latest successfully uploaded cloud copy from automatic cleanup. Starring does not upload or change the frame; manual deletion still applies.',sourceLocationHint:'NAS: a relative folder under the mounted photo root, such as . or family. WebDAV: an HTTPS folder URL whose hostname is allowed in Settings. Google Drive / AliPan: folder ID. Baidu: folder path.',sourceCredential:'WebDAV / drive credential environment variable',sourceCredentialHint:'Enter a variable name, never a password. An administrator can configure credentials through environment variables; native drives can also use the OAuth credentials below.'});
Object.assign(MESSAGES.zh,{weather:'天气',connected:'已连接',disconnected:'未连接',sortOrder:'排序方式',playbackMode:'播放顺序',apiKeyConfigured:'API 密钥已配置',templateSourceUnavailable:'这张图片没有可编辑的模板来源。可编辑模板仅适用于保留了模板配置的生成图片。',localDeleteConfirm:'确认删除本地图库中的这张图片?不会删除 NAS 原始照片或云端副本,也不会切换画框。此操作不可撤销。',cloudDeleteConfirm:'确认删除这张图片的云端副本?不会删除 NAS 原始照片或本地图库副本。此操作不可撤销。',gallerySelectionHelp:'全选和反选只作用于当前页可见图片;批量操作处理当前页全部已勾选图片,包括搜索后隐藏的勾选项。',starHelp:'星标保存在服务端,用于防止自动清理本地图及其最近一次成功上传的云端副本。星标本身不会上传或切换画框;手动删除仍有效。',sourceLocationHint:'Synology Photos:所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。其他云盘使用对应目录地址或 ID。',sourceCredential:'WebDAV / 网盘凭据环境变量名',sourceCredentialHint:'填写变量名,不要填写密码。管理员可通过环境变量配置凭据;原生网盘也可使用下方 OAuth 凭据。'});
Object.assign(MESSAGES.en,{weather:'Weather',connected:'Connected',disconnected:'Not connected',sortOrder:'Sort order',playbackMode:'Playback order',apiKeyConfigured:'API key configured',templateSourceUnavailable:'No editable template source is available for this image. Template editing requires a generated image with saved template settings.',localDeleteConfirm:'Delete this local gallery image? NAS originals and cloud copies remain intact, and the frame display is unchanged. This cannot be undone.',cloudDeleteConfirm:'Delete this cloud image? NAS originals and local gallery copies remain intact. This cannot be undone.',gallerySelectionHelp:'Select all and invert affect visible images on this page. Batch actions include every checked image on this page, including items hidden by search.',starHelp:'Stars are saved on the server and protect the local image and its latest successfully uploaded cloud copy from automatic cleanup. Starring does not upload or change the frame; manual deletion still applies.',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 / drive credential environment variable',sourceCredentialHint:'Enter a variable name, never a password. An administrator can configure credentials through environment variables; native drives can also use the OAuth credentials below.'});
Object.assign(MESSAGES.zh,{currentImageId:'当前图片 ID',modelSource:'型号信息来源',modelVerified:'设备 API 返回',modelConfigured:'设备配置文件',switchInterval:'切换间隔',sync:'云端同步状态',synced:'云端已确认',syncing:'等待云端确认'});
Object.assign(MESSAGES.en,{currentImageId:'Current image ID',modelSource:'Model information source',modelVerified:'Device API',modelConfigured:'Device configuration',switchInterval:'Switch interval',sync:'Cloud sync status',synced:'Cloud confirmed',syncing:'Awaiting cloud confirmation'});
Object.assign(MESSAGES.zh,{noSources:'尚未配置照片数据源,请先添加。',noSourcePhotos:'当前目录或筛选条件下没有可用照片。',sourceLocation:'目录路径 / 地址 / ID',temporaryOptionsHint:'勾选到期恢复时,显示时长和优先级用于临时显示;未勾选时持续显示,直到其他操作切换图片。'});
Object.assign(MESSAGES.en,{noSources:'No photo sources configured. Add a source first.',noSourcePhotos:'No photos match this folder and its filters.',sourceLocation:'Folder path / URL / ID',temporaryOptionsHint:'With restore enabled, duration and priority apply to the temporary display. Otherwise, the image stays until another action changes it.'});
Object.assign(MESSAGES.zh,{synologyAPISource:'Synology Photos API',sourcePhotosAPI:'通过共享 Photos 账号读取 XL 预览图与人物,遵循账号访问权限。',sourceLocationHint:'Synology Photos:所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。其他云盘使用对应目录地址或 ID。',photosConnectionHelp:'此账号通过 Synology Photos API 授权读取照片和人物。连接成功后,请添加照片来源并设置空间和人物;更换账号会影响所有 Photos 来源。',sourceRecursive:'包含子目录'});
Object.assign(MESSAGES.en,{synologyAPISource:'Synology Photos API',sourcePhotosAPI:'Reads XL previews and people through the shared Photos account, respecting its access permissions.',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.',photosConnectionHelp:'This account authorizes photo and people access through the Synology Photos API. After connecting, add a photo source and choose its space and people. Changing accounts affects all Photos sources.',sourceRecursive:'Include subfolders'});
Object.assign(MESSAGES.zh,{photosReconnectHelp:'尚未连接或会话已失效。Photos API 来源及依赖人物筛选的任务需要重新登录后才能读取照片。',photosSharedDisconnectWarning:'这将断开所有 Photos API 来源和人物筛选共用的登录,相关任务将无法读取照片。目录配置和独立网盘连接不会删除。确定断开吗?',photosConfigure:'管理员需配置 SYNOLOGY_PHOTOS_URL 和 SYNOLOGY_PHOTOS_CERT_SHA256。照片和人物均通过 API 读取。'});
Object.assign(MESSAGES.en,{photosReconnectHelp:'Not connected or session expired. Photos API sources and people-filtered tasks need a new sign-in before reading photos.',photosSharedDisconnectWarning:'Disconnect the shared session used by all Photos API sources and people filters? Their tasks cannot read photos until reconnected. Folder settings and separate drive connections are retained.',photosConfigure:'Configure SYNOLOGY_PHOTOS_URL and SYNOLOGY_PHOTOS_CERT_SHA256. Photos and people are read through the API.'});
Object.assign(window.MESSAGES.zh,{signIn:'登录',livingRoom:'客厅'});Object.assign(window.MESSAGES.en,{signIn:'Sign in',livingRoom:'Living room'});
Object.assign(MESSAGES.zh,{RESTORE_IMAGE_PROTECTED:'这张图片仍用于恢复显示,暂时不能删除。请先结束临时展示,或显示另一张图片以更新恢复目标。'});
Object.assign(MESSAGES.en,{RESTORE_IMAGE_PROTECTED:'This image is still needed to restore the display. End the temporary display or show another image to update the restore target before deleting it.'});
+1 -1
View File
@@ -1,3 +1,3 @@
<!doctype html>
<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.8</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"></script><script src="/ui/features.js"></script><script src="/ui/extensions.js"></script><script src="/ui/app.js"></script></body></html>
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.13</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=people-caption-v0113"></script><script src="/ui/features.js?v=people-caption-v0113"></script><script src="/ui/extensions.js?v=people-caption-v0113"></script><script src="/ui/app.js?v=people-caption-v0113"></script></body></html>
+13
View File
@@ -153,3 +153,16 @@ form>.modal-actions{margin-top:8px}
.gallery-tabs button{min-width:0;white-space:normal;overflow-wrap:anywhere}
.gallery:empty{display:none}
/* The signed-out page needs its own form rhythm and a viewport-sized content area. */
body.login-mode .workspace{min-height:100dvh;display:flex;flex-direction:column}
body.login-mode #main{flex:1;display:grid;place-items:center;padding:24px 0 40px}
body.login-mode header,body.login-mode footer{flex-shrink:0}
.login-card{width:min(100%,480px);padding:32px}
.login-card h2{margin:20px 0 8px;font-size:24px;line-height:1.35}
.login-card>.hint{margin:0 0 24px;line-height:1.7}
#login-form{display:flex;flex-direction:column;gap:20px}
#login-form .check-row{align-items:center;gap:10px;font-weight:400;line-height:1.5}
#login-form .check-row input{margin:0;width:18px;height:18px}
#login-form>.primary{width:100%;min-height:46px;margin-top:4px}
@media(max-width:720px){body.login-mode .workspace{min-height:calc(100dvh - 92px)}body.login-mode #main{padding:16px 0 24px}.login-card{padding:24px}}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pigway-frame-flow"
version = "0.1.8"
version = "0.1.13"
requires-python = ">=3.12"
dependencies = ["paho-mqtt==2.1.0", "python-dateutil==2.9.0.post0", "fastapi==0.142.2", "uvicorn==0.54.0", "pillow==12.3.0", "requests==2.34.2", "croniter==6.2.4", "python-multipart==0.0.32", "pyyaml==6.0.3"]
[project.optional-dependencies]
+83
View File
@@ -0,0 +1,83 @@
"""Offline, explicit local-to-Photos-API source conversion; defaults to dry run."""
import argparse
import json
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
def convert(database, source_ids, *, apply=False):
connection = sqlite3.connect(f"file:{database}?mode=rw", uri=True)
try:
updates = []
for source_id in source_ids:
key = "photo_source:" + source_id
row = connection.execute(
"SELECT payload FROM app_config WHERE key=?", (key,)
).fetchone()
if not row:
raise ValueError("Source not found: " + source_id)
source = PhotoSourceInput.model_validate_json(row[0])
if source.kind != "local":
raise ValueError(
"Only explicitly selected local sources can be converted"
)
location = source.location.strip("/")
if (
source.location.startswith("/")
or "\\" in location
or ".." in location.split("/")
):
raise ValueError(
"Review the source's relative Photos folder path first"
)
payload = source.model_copy(
update={"kind": "synology-photos", "synology_previews": False}
)
updates.append((payload.model_dump_json(), key))
backup = None
if apply:
backup = database.with_name(
database.name
+ ".before-photos-api-"
+ datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
)
with sqlite3.connect(backup) as snapshot:
connection.backup(snapshot)
backup.chmod(0o600)
with connection:
connection.executemany(
"UPDATE app_config SET payload=? WHERE key=?", updates
)
return {
"applied": apply,
"sources": source_ids,
"backup": str(backup) if backup else None,
}
finally:
connection.close()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--database", type=Path, required=True)
parser.add_argument("--source", action="append", required=True)
parser.add_argument("--apply", action="store_true")
parser.add_argument(
"--service-stopped",
action="store_true",
help="Confirm the target service is stopped before changing its database",
)
args = parser.parse_args()
if args.apply and not args.service_stopped:
parser.error(
"Stop the target service and pass --service-stopped before applying"
)
print(json.dumps(convert(args.database, args.source, apply=args.apply), indent=2))
if __name__ == "__main__":
main()
+10 -24
View File
@@ -70,6 +70,13 @@ class Deploy:
return json.loads(self.run("inspect", name).stdout)[0]
def settings(self):
if any(
self.env.get(key, "").startswith("/volume2/docker/pigway-frame-flow")
for key in ("PROD_DATA_DIR", "PROD_CONFIG_DIR")
):
raise ValueError(
"NAS Frame deployment is retired; target the Ubuntu Server"
)
missing = [key for key in REQUIRED if not self.env.get(key)]
if missing:
raise ValueError("Missing Variables: " + ", ".join(missing))
@@ -118,20 +125,9 @@ class Deploy:
or not m.get("RW")
):
raise ValueError("Production mount mismatch: " + key)
photos = self.env.get("PROD_PHOTOS_DIR")
if "/photos" in mounts and (
not photos
or mounts["/photos"]["Source"] != photos
or mounts["/photos"]["RW"]
):
raise ValueError(
"Confirm PROD_PHOTOS_DIR for the existing read-only photo mount"
)
if photos and "/photos" not in mounts:
raise ValueError(
"Configured photos directory does not match current container"
)
if set(mounts) != {"/data", "/config"} | ({"/photos"} if photos else set()):
if self.env.get("PROD_PHOTOS_DIR") or "/photos" in mounts:
raise ValueError("Filesystem photo mounts are retired; use the Photos API")
if set(mounts) != {"/data", "/config"}:
raise ValueError("Unrecognized persistent mount; review before adopting CD")
bindings = old["HostConfig"].get("PortBindings", {}).get("18787/tcp")
if bindings != [
@@ -180,16 +176,6 @@ class Deploy:
)
]
service["group_add"] = old["HostConfig"].get("GroupAdd") or []
if photos:
service["volumes"].append(
{
"type": "bind",
"source": photos,
"target": "/photos",
"read_only": True,
"bind": {"create_host_path": False},
}
)
# Adoption must preserve runtime constraints, not silently change them.
if (
old["Config"]["User"] != service["user"]
+255
View File
@@ -0,0 +1,255 @@
"""Release a verified source bundle on the Ubuntu T7 host; never write to frames."""
import datetime
import fcntl
import hashlib
import json
import os
import re
import shutil
import sqlite3
import subprocess
import time
import urllib.request
from pathlib import Path
APP = Path("/srv/pigway/apps/pigway-frame-flow")
RECOVERY = Path("/srv/pigway/recovery/frame-flow")
CONTAINER = "pigway-frame-flow"
ORIGIN = "http://192.0.2.10:81"
def run(*args):
return subprocess.check_output(args, text=True, timeout=600).strip()
def inspect():
return json.loads(run("docker", "inspect", CONTAINER))[0]
def archive_staging(backup, ctx):
# These are retired Frame-only inputs, never shared console migration files.
names = [
"frame-schedule-ui",
"frame-photos-ui",
"frame-final-status.sh",
"frame-compose.candidate.private.json",
"frame-fresh-compose.private.json",
"frame-image.tar",
"frame-prepare.sh",
"frame-nfs-setup.sh",
"frame.unit",
"compose.ubuntu-photos-api.yml",
]
archive = backup / "retired-frame-staging"
archive.mkdir(mode=0o700)
moved = []
for name in names:
source = ctx.parent / name
if source.exists():
assert not source.is_symlink(), name
source.rename(archive / name)
moved.append(name)
for p in archive.rglob("*"):
if not p.is_symlink():
p.chmod(0o700 if p.is_dir() else 0o600)
# Remove only disposable aliases, preserving every recovery reference and image.
references = "\n".join(
p.read_text(errors="replace")
for p in RECOVERY.rglob("*")
if p.is_file() and p.suffix in {".json", ".txt"}
)
removed = []
for tag in [
"pigway-frame-flow:history-ui-local-base",
"pigway-frame-flow:final-audit-local-base",
"pigway-frame-flow:0.1.9-5d2171b9be3e",
]:
tags = run(
"docker", "image", "ls", "--format", "{{.Repository}}:{{.Tag}}"
).splitlines()
if (
tag in tags
and tag not in references
and not run("docker", "ps", "-aq", "--filter", "ancestor=" + tag)
):
run("docker", "image", "rm", tag)
removed.append(tag)
return {
"archived_frame_inputs": moved,
"removed_temporary_aliases": removed,
"shared_inputs_and_recovery_preserved": True,
}
def wait_ready():
for attempt in range(30):
try:
with urllib.request.urlopen(ORIGIN + "/ready", timeout=4) as r:
assert json.load(r)["status"] == "ready"
break
except Exception:
if attempt == 29:
raise
time.sleep(2)
def verify_runtime(commit):
c = inspect()
env = dict(s.split("=", 1) for s in c["Config"]["Env"])
assert env["KOKONNA_MOCK"] == "false"
assert env["KOKONNA_WRITE_ENABLED"] == "true"
assert env["BUILD_COMMIT"] == commit
assert c["State"]["Running"]
assert c["Config"]["User"] == "10333:10333"
assert c["HostConfig"]["ReadonlyRootfs"]
assert set(c["HostConfig"]["CapDrop"]) == {"ALL"}
assert {m["Destination"] for m in c["Mounts"]} == {"/data", "/config"}
for mount in c["Mounts"]:
assert Path(mount["Source"]).resolve() == APP / mount["Destination"][1:]
return c
def main():
assert os.geteuid() == 0, "Run with sudo on the Ubuntu Server"
os.umask(0o077)
run("/usr/local/libexec/pigway-guard-mount")
assert run("findmnt", "-n", "-o", "UUID", "/srv/pigway") == (
"00000000-0000-4000-8000-000000000001"
)
with (RECOVERY / ".release.lock").open("a") as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
release(lock)
def release(lock):
ctx = Path(__file__).resolve().parent.parent
assert ctx.parent == Path("/srv/pigway/migration-inbox")
commit = (ctx / "RELEASE_COMMIT").read_text().strip()
assert re.fullmatch("[0-9a-f]{40}", commit)
hashes = json.loads((ctx / "RELEASE_HASHES.json").read_text())
for name, expected in hashes.items():
p = (ctx / name).resolve()
assert p.is_relative_to(ctx)
assert hashlib.sha256(p.read_bytes()).hexdigest() == expected, name
before = inspect()
old_env = dict(s.split("=", 1) for s in before["Config"]["Env"])
verify_runtime(old_env["BUILD_COMMIT"])
assert run("docker", "exec", CONTAINER, "cat", "/app/requirements.lock") == (
(ctx / "requirements.lock").read_text().strip()
), "Dependencies differ; full build required"
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
backup = RECOVERY / ("release-" + stamp + "-" + commit[:12])
backup.mkdir(mode=0o700)
shutil.copy2(APP / "compose.json", backup / "compose.before.json")
shutil.copytree(APP / "config", backup / "config")
with sqlite3.connect(
"file:" + str(APP / "data/frameflow.db") + "?mode=ro", uri=True
) as src:
with sqlite3.connect(backup / "frameflow.before.db") as dst:
src.backup(dst)
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.13-" + commit[:12]
try:
run(
"docker",
"build",
"--pull=false",
"--network=none",
"-f",
str(ctx / "deploy/Dockerfile.runtime-update"),
"--build-arg",
"BASE_IMAGE=" + base,
"--build-arg",
"BUILD_COMMIT=" + commit,
"--build-arg",
"BUILD_TIME=" + stamp,
"-t",
image,
str(ctx),
)
finally:
run("docker", "image", "rm", base)
compose = json.loads((APP / "compose.json").read_text())
service = compose["services"][CONTAINER]
service["image"] = image
service["environment"]["BUILD_COMMIT"] = commit
service["environment"]["BUILD_TIME"] = stamp
changed = False
try:
changed = True
(APP / "compose.json").write_text(json.dumps(compose, indent=2) + "\n")
print("Restarting Frame Flow; checking " + ORIGIN + "/ready", flush=True)
run("systemctl", "restart", "pigway-frame-flow.service")
wait_ready()
current = verify_runtime(commit)
assert current["Image"] == run(
"docker", "image", "inspect", "--format", "{{.Id}}", image
)
# Verify all deployed project files, not just a version string or an image tag.
project_hashes = {
n: h
for n, h in hashes.items()
if n.startswith(("pigway_frame_flow/", "vendor/"))
or n == "requirements.lock"
}
code = 'import hashlib,json,pathlib,sys; h=json.load(sys.stdin); assert all(hashlib.sha256((pathlib.Path("/app")/n).read_bytes()).hexdigest()==v for n,v in h.items())'
subprocess.run(
["docker", "exec", "-i", CONTAINER, "python", "-c", code],
input=json.dumps(project_hashes),
text=True,
check=True,
timeout=60,
)
for name in [
"app.js",
"features.js",
"extensions.js",
"i18n.js",
"index.html",
"style.css",
]:
url = ORIGIN + "/" + ("" if name == "index.html" else "ui/" + name)
with urllib.request.urlopen(url, timeout=10) as response:
assert (
response.read()
== (ctx / "pigway_frame_flow/web" / name).read_bytes()
)
run("systemctl", "is-active", "pigway-frame-flow.service")
except BaseException:
print("Release verification failed; restoring previous Compose", flush=True)
if changed:
shutil.copy2(backup / "compose.before.json", APP / "compose.json")
run("systemctl", "restart", "pigway-frame-flow.service")
raise
shutil.copy2(APP / "compose.json", RECOVERY / "compose.json")
(RECOVERY / "image.txt").write_text(image + "\n")
shutil.copy2(ctx / "source.tar.gz", backup / "source.tar.gz")
cleanup = archive_staging(backup, ctx)
receipt = {
"result": "PASS",
"commit": commit,
"image": image,
"image_id": current["Image"],
"backup": str(backup),
"mock": False,
"write_enabled": True,
"checked_at": stamp,
"cleanup": cleanup,
}
output = APP / "release-verification.json"
output.write_text(json.dumps(receipt, indent=2) + "\n")
owner = ctx.stat()
os.chown(output, owner.st_uid, owner.st_gid)
shutil.copy2(output, backup / "release-verification.json")
public_receipt = ctx.parent / "frame-release-verification.json"
shutil.copy2(output, public_receipt)
os.chown(public_receipt, owner.st_uid, owner.st_gid)
shutil.rmtree(ctx)
print(json.dumps(receipt, indent=2))
if __name__ == "__main__":
main()
+17
View File
@@ -0,0 +1,17 @@
const {chromium}=require('playwright');const fs=require('fs'),path=require('path'),assert=require('assert');
const root=path.join(__dirname,'../pigway_frame_flow/web');
(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});try{for(const width of [1440,768,390])for(const lang of ['zh','en']){
const p=await b.newPage({viewport:{width,height:900}});let errors=[];p.on('pageerror',e=>errors.push(e.message));let saved;
await p.addInitScript(lang=>localStorage.setItem('ff-language',lang),lang);
await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){
if(['PUT','POST'].includes(r.request().method())){saved=r.request().postDataJSON();return r.fulfill({json:{}})}
if(['/api/v1/jobs','/api/v1/assets','/api/v1/devices/living-room/images'].includes(u.pathname))return r.fulfill({json:[]});
if(u.pathname==='/api/v1/photo-sources')return r.fulfill({json:[]});
if(u.pathname==='/api/v1/integrations/synology-photos')return r.fulfill({json:{configured:true,connected:true}});
if(u.pathname.endsWith('/people'))return r.fulfill({json:[{id:1,name:'Test Person',count:5}]});
return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}
const f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');return r.fulfill({body:fs.readFileSync(path.join(root,f)),contentType:f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/html'});});
await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();
await p.evaluate(async()=>{const previous=api;api=async()=>[{operation_id:'fixture',action:'display',device_id:'living-room',status:'FAILED',client_id:'scheduler',error:{code:'WRITE_DISABLED'},request_id:'fixture'}];state.devices=[{id:'living-room',name:'living-room'}];try{await views.history(state.epoch);}finally{api=previous;}});
const text=await p.locator('#main').innerText();assert(!text.includes('living-room'));assert(!text.includes('WRITE_DISABLED'));assert(text.includes(lang==='zh'?'客厅':'Living room'));assert(text.includes(lang==='zh'?'显示图片':'Display image'));assert(text.includes(lang==='zh'?'自动调度':'Scheduler'));assert.equal(errors.length,0);console.log('PASS history',width,lang);await p.close();
}}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
+28
View File
@@ -0,0 +1,28 @@
const {chromium}=require('playwright');const fs=require('fs'),path=require('path'),assert=require('assert');
const root=path.join(__dirname,'../pigway_frame_flow/web');
(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});try{for(const width of [1440,768,390])for(const lang of ['zh','en']){
const p=await b.newPage({viewport:{width,height:900}});let errors=[];p.on('pageerror',e=>errors.push(e.message));let saved;
await p.addInitScript(lang=>localStorage.setItem('ff-language',lang),lang);
await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){
if(['PUT','POST'].includes(r.request().method())){saved=r.request().postDataJSON();return r.fulfill({json:{}})}
if(['/api/v1/jobs','/api/v1/assets','/api/v1/devices/living-room/images'].includes(u.pathname))return r.fulfill({json:[]});
if(u.pathname==='/api/v1/photo-sources')return r.fulfill({json:[]});
if(u.pathname==='/api/v1/integrations/synology-photos')return r.fulfill({json:{configured:true,connected:true}});
if(u.pathname.endsWith('/people'))return r.fulfill({json:[{id:1,name:'Test Person',count:5}]});
return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}
const f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');return r.fulfill({body:fs.readFileSync(path.join(root,f)),contentType:f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/html'});});
await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();
await p.evaluate(async()=>{state.templates=[{id:'daily-dashboard',name:'Dashboard',defaults:{}}];await jobEditor();});
for(const unit of ['hours','minutes']){
await p.locator('#job-schedule').selectOption(unit);await p.locator('#schedule-value').fill('2');
assert.equal(await p.locator('#schedule-value').getAttribute('type'),'number');
assert(await p.locator('#schedule-hint').innerText());
}
assert(!await p.evaluate(()=>document.documentElement.scrollWidth>innerWidth));
await p.locator('#job-name').fill('Test interval');
await p.locator('#job-form button[type=submit]').click();await p.waitForTimeout(100);
assert(saved);assert.deepEqual(saved.schedule,{type:'interval',interval_minutes:2});
await p.evaluate(async()=>{await jobEditor({id:'existing',name:'Existing',schedule:{type:'interval',interval_minutes:120},source:{type:'template',template:'daily-dashboard',data:{}},enabled:false,display_duration:0,restore_previous:false,priority:50});});
assert.equal(await p.locator('#job-schedule').inputValue(),'hours');assert.equal(await p.locator('#schedule-value').inputValue(),'2');
await p.evaluate(()=>{state.devices=[{id:'living-room',name:'我的相框'}];});assert.equal(await p.evaluate(()=>deviceDisplayName(state.devices[0])),'我的相框');assert.equal(await p.evaluate(()=>deviceDisplayName({id:'living-room',name:'living-room'})),lang==='zh'?'客厅':'Living room');await p.locator('#modal-cancel').click();await p.evaluate(async()=>{const previous=api;api=async()=>[{id:'fixture',name:'Family',device_id:'living-room',schedule:{type:'interval',interval_minutes:120},enabled:false}];try{await views.automation(state.epoch);}finally{api=previous;}});assert.equal(await p.locator('tbody td small').first().innerText(),'我的相框');assert.equal(errors.length,0,errors.join('\n'));console.log('PASS',width,lang);await p.close();
}}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
+17
View File
@@ -0,0 +1,17 @@
const {chromium}=require('playwright');const fs=require('fs');const path=require('path');const assert=require('assert');
const root=path.resolve(__dirname,'../pigway_frame_flow/web');const out=process.env.FRAME_FLOW_SCREENSHOT_DIR;if(out)fs.mkdirSync(out,{recursive:true});
(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});let count=0;
try{for(const width of [1440,768,390,320])for(const lang of ['zh','en'])for(const theme of ['dark','light']){
const p=await b.newPage({viewport:{width,height:900}});const errors=[];p.on('pageerror',e=>errors.push(e.message));
await p.addInitScript(({lang,theme})=>{localStorage.setItem('ff-language',lang);localStorage.setItem('ff-theme',theme)},{lang,theme});
await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){if(u.pathname==='/api/v1/version')return r.fulfill({json:{version:'0.1.9',commit:'ba9424a-ui',build_time:'validation'}});return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}let f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');const file=path.join(root,f);if(!file.startsWith(root)||!fs.existsSync(file))return r.fulfill({status:404,body:''});return r.fulfill({body:fs.readFileSync(file),contentType:f.endsWith('.css')?'text/css':f.endsWith('.js')?'application/javascript':'text/html'});});
await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();await p.waitForTimeout(60);
assert.equal(await p.locator('#device option:checked').innerText(),lang==='zh'?'客厅':'Living room');
const layout=await p.evaluate(()=>{const q=s=>document.querySelector(s).getBoundingClientRect();return {overflow:document.documentElement.scrollWidth>innerWidth,card:q('.login-card').toJSON(),form:q('#login-form').toJSON(),user:q('#username').toJSON(),password:q('#password').toJSON(),remember:document.querySelector('#remember-login').closest('label').getBoundingClientRect().toJSON(),button:q('#login-form button').toJSON(),label:document.querySelector('#remember-login').labels[0].innerText}});
assert(!layout.overflow,JSON.stringify({width,lang,theme,layout}));assert(layout.password.top-layout.user.bottom>=24);assert(layout.button.top-layout.remember.bottom>=20);assert(layout.button.width>=layout.form.width-1);assert.equal(errors.length,0,errors.join('\n'));
// Language switching and custom names must not change the internal device ID.
await p.evaluate(()=>{state.devices=[{id:'living-room',name:'我的画框'}];shell()});assert.equal(await p.locator('#device option:checked').innerText(),'我的画框');
await p.evaluate(()=>{state.devices=[];shell();login()});
if(out&&lang==='zh'&&theme==='dark'&&[1440,390].includes(width))await p.screenshot({path:path.join(out,`login-${width}.png`),fullPage:true});
console.log('PASS',width,lang,theme);count++;await p.close();}
console.log('TOTAL',count,'profiles');}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
+23
View File
@@ -0,0 +1,23 @@
const {chromium}=require('playwright');const fs=require('fs'),path=require('path'),assert=require('assert');
const root=path.join(__dirname,'../pigway_frame_flow/web');
(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});try{for(const width of [1440,768,390])for(const lang of ['zh','en']){
const p=await b.newPage({viewport:{width,height:900}});let errors=[];p.on('pageerror',e=>errors.push(e.message));let saved;
await p.addInitScript(lang=>localStorage.setItem('ff-language',lang),lang);
await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){
if(r.request().method()==='PUT'){saved=r.request().postDataJSON();return r.fulfill({json:{}})}
if(u.pathname==='/api/v1/photo-sources')return r.fulfill({json:[]});
if(u.pathname==='/api/v1/integrations/synology-photos')return r.fulfill({json:{configured:true,connected:true}});
if(u.pathname.endsWith('/people'))return r.fulfill({json:[{id:1,name:'Test Person',count:5}]});
return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}
const f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');return r.fulfill({body:fs.readFileSync(path.join(root,f)),contentType:f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/html'});});
await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();await p.evaluate(()=>photoSourceForm());
assert(!(await p.locator('#source-kind').isVisible()));assert(!(await p.locator('#source-id').isVisible()));assert(!(await p.locator('#source-synology-previews').isVisible()));
assert.equal(await p.locator('#source-kind').inputValue(),'synology-photos');assert.match(await p.locator('#source-id').inputValue(),/^photos-[a-z0-9-]+$/);
const text=await p.locator('#modal').innerText();assert(!/\/photos|本地目录|local.folder|mounted/i.test(text));
assert(await p.locator('#source-people-space').isVisible());await p.locator('#source-people-load').click();await p.locator('[data-person-id], #source-people-panel input[type=checkbox]').last().waitFor();
assert(!await p.evaluate(()=>document.documentElement.scrollWidth>innerWidth));
await p.locator('#source-name').fill('Family');
await p.locator('#photo-source-form button[type=submit]').click();await p.waitForTimeout(100);
assert(saved);assert.equal(saved.kind,'synology-photos');assert.equal(saved.synology_previews,false);
await p.evaluate(()=>photoSourceForm({id:'family-photos',name:'Family',kind:'synology-photos',location:'.',people_filter:true,person_ids:[1],people_space:'personal',person_match:'all'}));assert.equal(await p.locator('#source-id').inputValue(),'family-photos');assert.equal(await p.locator('#source-people-space').inputValue(),'personal');assert.equal(await p.locator('#source-person-match').inputValue(),'all');assert(await p.locator('#source-people-filter').isChecked());if(lang==='zh')await p.screenshot({path:'/tmp/frame-source-'+width+'.png',fullPage:true});assert.equal(errors.length,0,errors.join('\n'));console.log('PASS',width,lang);await p.close();
}}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
+4
View File
@@ -0,0 +1,4 @@
/* Isolated Mock UI only: explicit API type, people spaces, payload and no mount wording. */
const assert=require('assert'),{chromium}=require('playwright');
(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});let checks=0;
try{for(const width of [1440,390])for(const lang of ['zh','en'])for(const theme of ['light','dark']){const p=await b.newPage({viewport:{width,height:1000}}),errors=[];let saved;const spaces=[];p.on('pageerror',e=>errors.push(e.message));await p.route('**/api/v1/photo-sources',r=>r.fulfill({json:[]}));await p.route('**/api/v1/photo-sources/api-fixture',r=>{saved=r.request().postDataJSON();return r.fulfill({json:{...saved,id:'api-fixture'}})});await p.route('**/api/v1/integrations/synology-photos',r=>r.fulfill({json:{configured:true,connected:true}}));await p.route('**/api/v1/integrations/synology-photos/people?*',r=>{spaces.push(new URL(r.request().url()).searchParams.get('space'));return r.fulfill({json:[{id:7,name:'Fixture person',count:3,named:true}]})});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('.hero').waitFor();assert(await p.evaluate(()=>state.settings.mock));await p.evaluate(async({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;await navigate('photo-sources')},{lang,theme});await p.locator('#add-photo-source').click();await p.locator('#source-kind').selectOption('synology-photos');await p.locator('#source-id').fill('api-fixture');await p.locator('#source-name').fill('Photos API');assert(await p.locator('#source-credential').isHidden());assert(await p.locator('#source-oauth').isHidden());assert(await p.locator('#source-synology-previews').isHidden());assert(await p.locator('#source-orientation').isEnabled());await p.locator('#source-people-filter').check();await p.locator('#source-people-load').click();await p.locator('[data-person="7"]').check();await p.locator('#source-people-space').selectOption('personal');await p.locator('#source-people-load').click();assert(!await p.locator('[data-person="7"]').isChecked());await p.locator('[data-person="7"]').check();await p.locator('#source-person-match').selectOption('all');assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));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+'/photos-api-'+width+'.png'});}await p.locator('#photo-source-form button[type="submit"]').click();await p.waitForFunction(()=>!document.querySelector('#modal').open);assert.equal(saved.kind,'synology-photos');assert.equal(saved.people_space,'personal');assert.equal(saved.person_match,'all');assert.deepEqual(saved.person_ids,[7]);assert.equal(saved.credential_env,null);assert.equal(saved.synology_previews,false);assert.deepEqual(spaces,['shared','personal']);assert.deepEqual(errors,[]);await p.close();checks++;}console.log('Photos API UI:',checks,'profiles PASS; type/space/person any-all/config and responsive layout verified');}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
+45
View File
@@ -0,0 +1,45 @@
"""Smoke-check an already running isolated Mock container, via its loopback HTTP."""
import json
import os
import time
import urllib.request
from pathlib import Path
def main():
assert os.getuid() == 10333 and os.getgid() == 10333
assert not Path("/photos").exists()
for _ in range(30):
try:
with urllib.request.urlopen(
"http://127.0.0.1:18787/ready", timeout=2
) as response:
assert json.load(response)["status"] == "ready"
break
except OSError:
time.sleep(1)
else:
raise SystemExit("Mock readiness failed")
with urllib.request.urlopen(
"http://127.0.0.1:18787/api/v1/version", timeout=2
) as response:
version = json.load(response)
assert version["version"] == "0.1.9"
assert Path("/data/frameflow.db").is_file()
print(
json.dumps(
{
"ready": True,
"version": version["version"],
"commit": version["commit"],
"uid": os.getuid(),
"gid": os.getgid(),
"no_photos_mount": True,
}
)
)
if __name__ == "__main__":
main()
+112
View File
@@ -0,0 +1,112 @@
"""Read-only live Photos check. Never creates an app, worker, job or frame driver."""
import argparse
import io
import json
import os
from pathlib import Path
from PIL import Image
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput, PhotoSources
from pigway_frame_flow.settings import Settings
from pigway_frame_flow.storage.database import Database
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--bootstrap", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
if Path("/photos").exists():
raise SystemExit("Verification requires /photos to be absent")
bootstrap = json.loads(args.bootstrap.read_text())
os.environ["SYNOLOGY_PHOTOS_URL"] = bootstrap["url"]
os.environ["SYNOLOGY_PHOTOS_CERT_SHA256"] = bootstrap["pin"]
settings = Settings(mock=True, write_enabled=False, api_key="", api_token="")
settings.data_dir.mkdir(parents=True, exist_ok=True)
settings.config_dir.mkdir(parents=True, exist_ok=True)
session = settings.config_dir / "synology-photos-session.json"
session.write_text(json.dumps(bootstrap["session"]))
session.chmod(0o600)
db = Database(settings.data_dir / "verification.db")
photos = PhotoSources(db, settings, None)
report = {"no_photos_mount": True, "device_writes": 0, "spaces": {}}
try:
for space in ("shared", "personal"):
people = photos.synology.people(space)
candidates = sorted(
(p for p in people if p["count"] > 1), key=lambda p: p["count"]
)
if len(candidates) < 2:
raise RuntimeError("Need two accessible populated person albums")
selected = [p["id"] for p in candidates[:2]]
one = photos.synology.items(selected[:1], space=space)
two = photos.synology.items(selected[1:], space=space)
ids_one, ids_two = {r["id"] for r in one}, {r["id"] for r in two}
any_rows = photos.synology.items(selected, "any", space=space)
all_rows = photos.synology.items(selected, "all", space=space)
assert {r["id"] for r in any_rows} == ids_one | ids_two
assert {r["id"] for r in all_rows} == ids_one & ids_two
source_id = "live-" + space
photos.save(
source_id,
PhotoSourceInput(
name="Read-only verification",
kind="synology-photos",
location=".",
people_space=space,
people_filter=True,
person_ids=selected,
),
)
catalog = photos.catalog(source_id)
assert catalog
data, _, _ = photos.read(source_id, catalog[0]["id"])
with Image.open(io.BytesIO(data)) as image:
image.load()
decoded = {
"width": image.width,
"height": image.height,
"format": image.format,
}
cache = photos.synology.image_cache
before = cache.downloads
assert photos.read(source_id, catalog[0]["id"])[0] == data
assert cache.downloads == before
report["spaces"][space] = {
"people": len(people),
"any_count": len(any_rows),
"all_count": len(all_rows),
"catalog_count": len(catalog),
"bytes": len(data),
"decoded": decoded,
"cache_reused": True,
}
# A deliberately invalid session is isolated to this verification config;
# no logout request or change is made to the original NAS session.
photos.synology.path.write_text(
json.dumps({"sid": "frameflow-deliberately-invalid-session"})
)
try:
photos.synology.people()
except FlowError as error:
assert error.status == 409
report["expired_session"] = "explicit_reconnect_required"
else:
raise RuntimeError("Invalid session unexpectedly accepted")
report["cache_bytes"] = sum(
p.stat().st_size
for p in (settings.data_dir / "cache/synology-photos").glob("*.image")
)
assert report["cache_bytes"] <= settings.photos_cache_bytes
report["original_download"] = "not_supported_by_this_verified_implementation"
args.output.write_text(json.dumps(report, indent=2) + "\n")
print(json.dumps(report, indent=2))
finally:
session.unlink(missing_ok=True)
if __name__ == "__main__":
main()
+14
View File
@@ -30,3 +30,17 @@ def client(app):
@pytest.fixture
def service(app):
return app.state.service
@pytest.fixture
def legacy_photo_engine(monkeypatch):
"""Exercise retained legacy internals without weakening production policy.
These fixtures cover rendering, retention and migration algorithms that used
filesystem inputs historically. API boundary tests never use this override.
"""
from pigway_frame_flow.providers.photo_sources import PhotoSources
monkeypatch.setattr(
PhotoSources, "require_api_source", staticmethod(lambda source: None)
)
+2
View File
@@ -869,6 +869,7 @@
"type": "string",
"enum": [
"local",
"synology-photos",
"webdav",
"google-drive",
"alipan",
@@ -1021,6 +1022,7 @@
"type": "string",
"enum": [
"local",
"synology-photos",
"webdav",
"google-drive",
"alipan",
+99
View File
@@ -145,6 +145,105 @@ def test_delete_restore_reference_protected(service):
service.process_one()
assert service.operation(delete)["status"] == "FAILED"
assert service.operation(delete)["error"]["code"] == "CONFLICT"
assert (
service.operation(delete)["error"]["details"]["reason"]
== "RESTORE_IMAGE_PROTECTED"
)
@pytest.mark.parametrize("automatic", [True, False])
def test_completed_restore_consumes_self_reference(client, service, automatic):
display = submit(service, 60, True)
service.process_one()
service.sync()
if automatic:
expired(service, display)
service.expire()
else:
service.submit("restore", {}, "living-room", "admin", "restore-test")
service.process_one()
service.sync()
assert service.state("living-room")["previous_image_id"] is None
assert not client.get("/api/v1/devices/living-room").json()["can_restore"]
delete = service.submit(
"delete",
{"image_id": 1, "confirm_current": True},
"living-room",
"admin",
"delete-restored",
)
service.process_one()
assert service.operation(delete)["status"] == "COMPLETED"
@pytest.mark.parametrize("via_delete", [False, True])
def test_existing_self_reference_is_repaired(client, service, via_delete):
service.state("living-room")
service.db.execute(
"UPDATE device_state SET previous_image_id=1,managed_image_id=1 WHERE device_id=?",
("living-room",),
)
if via_delete:
operation = service.submit(
"delete",
{"image_id": 1, "confirm_current": True},
"living-room",
"admin",
"delete-stale-reference",
)
service.process_one()
assert service.operation(operation)["status"] == "COMPLETED"
else:
assert not client.get("/api/v1/devices/living-room?refresh=true").json()[
"can_restore"
]
assert service.state("living-room")["previous_image_id"] is None
@pytest.mark.parametrize(
"protection",
["override", "base", "unsynced", "unknown", "syncing", "other_previous"],
)
def test_self_reference_cleanup_preserves_real_restore_requirements(
service, protection
):
service.state("living-room")
service.db.execute(
"UPDATE device_state SET previous_image_id=1,managed_image_id=1 WHERE device_id=?",
("living-room",),
)
device = service.registry.get("living-room").device()
if protection == "override":
service.db.execute(
"INSERT INTO overrides VALUES(?,?,?,?,?,?)",
("active", "living-room", 1, 50, "2099-01-01", "2026-01-01"),
)
elif protection == "base":
service.db.execute(
"UPDATE device_state SET base_image_id=1 WHERE device_id=?",
("living-room",),
)
elif protection == "unsynced":
device = device.model_copy(
update={
"current_content": device.current_content.model_copy(
update={"synced": False}
)
}
)
elif protection in ("unknown", "syncing"):
operation = submit(service)
service._start_write(operation)
service.update(operation, protection.upper())
else:
service.db.execute(
"UPDATE device_state SET previous_image_id=2 WHERE device_id=?",
("living-room",),
)
service.clear_self_restore_reference("living-room", device)
assert service.state("living-room")["previous_image_id"] == (
2 if protection == "other_previous" else 1
)
def test_offline(service):
+4
View File
@@ -222,3 +222,7 @@ def test_unreadable_source_is_forbidden_not_empty(
response = client.get("/api/v1/photo-sources/family/images")
assert response.status_code == 403
assert response.json()["error"]["code"] == "FORBIDDEN"
# Legacy algorithms remain covered; production filesystem access is tested separately.
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
+30
View File
@@ -0,0 +1,30 @@
from datetime import UTC, datetime, timedelta
import pytest
from pydantic import ValidationError
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import Schedule
from pigway_frame_flow.scheduler.engine import next_run
@pytest.mark.parametrize("minutes", [1, 7, 60, 120, 420, 10080])
def test_interval_crosses_calendar_boundaries(minutes):
start = datetime(2026, 10, 16, 23, 58, tzinfo=UTC)
schedule = Schedule(type="interval", interval_minutes=minutes)
first = datetime.fromisoformat(next_run(schedule, start))
assert first == start + timedelta(minutes=minutes)
assert datetime.fromisoformat(next_run(schedule, first)) == first + timedelta(
minutes=minutes
)
@pytest.mark.parametrize("minutes", [0, -1, 10081])
def test_invalid_interval(minutes):
with pytest.raises(ValidationError):
Schedule(type="interval", interval_minutes=minutes)
def test_missing_interval_fails():
with pytest.raises(FlowError):
next_run(Schedule(type="interval"))
+5
View File
@@ -1,5 +1,6 @@
from io import BytesIO
import pytest
from PIL import Image
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
@@ -168,3 +169,7 @@ def test_manual_display_does_not_replace_task_head(client, service, settings):
"SELECT * FROM managed_cloud_uploads WHERE image_id=?", (second,)
)
)
# Legacy algorithms remain covered; production filesystem access is tested separately.
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
+4
View File
@@ -138,3 +138,7 @@ def test_auto_preview_uses_requested_device_not_default(
assert op["status"] == "COMPLETED"
with Image.open(BytesIO(client.get(op["result"]["url"]).content)) as image:
assert image.size == (1200, 1600)
# Legacy algorithms remain covered; production filesystem access is tested separately.
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
+4
View File
@@ -159,3 +159,7 @@ def test_oversized_synology_photo_uses_read_only_cached_preview(service, setting
"synology", source.model_copy(update={"synology_previews": False})
)
assert service.photo_sources.catalog("synology") == []
# Legacy algorithms remain covered; production filesystem access is tested separately.
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
+5
View File
@@ -1,3 +1,4 @@
import pytest
from PIL import Image
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
@@ -101,3 +102,7 @@ def test_random_bad_images_bounded_to_five(client, service, settings, monkeypatc
"SELECT write_started FROM operations WHERE id=?", (op["operation_id"],)
)
assert row["write_started"] == 0
# Legacy algorithms remain covered; production filesystem access is tested separately.
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
+9 -1
View File
@@ -62,12 +62,16 @@ def test_cached_dimensions_invalidation_and_no_fallback(
),
)
assert len(oriented.catalog("family")) == 1
from pigway_frame_flow.providers.photo_sources import dimensions
monkeypatch.setattr(
"pigway_frame_flow.providers.photo_sources.dimensions",
lambda *a: pytest.fail("unchanged dimensions should be cached"),
)
assert len(oriented.catalog("family", refresh=True)) == 1
monkeypatch.undo()
monkeypatch.setattr(
"pigway_frame_flow.providers.photo_sources.dimensions", dimensions
)
Image.new("RGB", (50, 100)).save(settings.photo_root / "20240101 Day" / "wide.jpg")
assert oriented.catalog("family", refresh=True) == []
with pytest.raises(FlowError):
@@ -110,3 +114,7 @@ def test_orientation_validation_and_api(client, oriented):
).json()["error"]["code"]
== "UNSUPPORTED_CAPABILITY"
)
# Legacy algorithms remain covered; production filesystem access is tested separately.
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
+7
View File
@@ -1,3 +1,6 @@
import pytest
def test_uploaded_photos_exclude_generated_templates_before_limit(client, service):
photo = service.assets.put(b"fixture", "family.jpg", "images")
for _ in range(5):
@@ -66,3 +69,7 @@ def test_multiple_sources_keep_separate_people_and_folder_settings(
assert service.photo_sources.get("travel").model_dump() == before
assert service.photo_sources.get("family").person_ids == [3]
assert service.photo_sources.get("travel").person_ids == [2]
# Legacy algorithms remain covered; production filesystem access is tested separately.
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
+26 -1
View File
@@ -1,12 +1,37 @@
from io import BytesIO
import pytest
from PIL import Image
from PIL import Image, ImageDraw
from pigway_frame_flow.renderers.photo_caption import render_captioned_photo
from pigway_frame_flow.renderers.photo_layout import PhotoLayout, WatermarkLayout
def test_watermark_includes_filename_and_recognized_people(service, monkeypatch):
drawn = []
original = ImageDraw.ImageDraw.text
def text(draw, xy, value, *args, **kwargs):
drawn.append(value)
return original(draw, xy, value, *args, **kwargs)
monkeypatch.setattr(ImageDraw.ImageDraw, "text", text)
stream = BytesIO()
Image.new("RGB", (160, 120), "blue").save(stream, "JPEG")
render_captioned_photo(
stream.getvalue(),
{
"date": "2026-10-17",
"title": "Family",
"filename": "one.jpg",
"people": "Alice & Bob",
},
service.renderer.font,
size=(1600, 1200),
)
assert drawn == ["2026-10-17 · Alice & Bob · Family · one.jpg"]
@pytest.mark.parametrize("size", [(1600, 1200), (1200, 1600)])
@pytest.mark.parametrize("fit", ["contain", "cover", "smart"])
def test_watermark_has_no_letterboxing(service, size, fit):
+23
View File
@@ -0,0 +1,23 @@
import json
def test_filesystem_source_cannot_be_created(client):
response = client.put(
"/api/v1/photo-sources/legacy",
json={"name": "NAS directory", "kind": "local", "location": "."},
)
assert response.status_code == 403
assert "Filesystem photo sources are disabled" in response.text
def test_persisted_filesystem_source_cannot_be_read(client, service):
service.db.execute(
"INSERT INTO app_config(key,payload) VALUES (?,?)",
(
"photo_source:legacy",
json.dumps({"name": "Legacy", "kind": "local", "location": "."}),
),
)
response = client.get("/api/v1/photo-sources/legacy/images")
assert response.status_code == 403
assert "Filesystem photo sources are disabled" in response.text
+2 -6
View File
@@ -248,18 +248,14 @@ def test_actual_verify_rejects_ready_smoke_and_commit_failure(
tool.verify(config)
def test_workflow_bootstrap_manual_only_and_ci_namespace_separate(tool):
def test_retired_workflow_has_no_bootstrap_and_ci_namespace_separate(tool):
from pathlib import Path
import yaml
root = Path(__file__).parents[1]
workflow = yaml.safe_load((root / ".github/workflows/verify.yml").read_text())
inputs = workflow[True]["workflow_dispatch"]["inputs"]["deploy_action"]
assert inputs["options"] == ["plan", "bootstrap", "deploy", "rollback"]
assert inputs["default"] == "plan"
assert workflow["jobs"]["deploy"]["needs"] == "verify"
assert "success()" in workflow["jobs"]["deploy"]["if"]
assert set(workflow["jobs"]) == {"verify"}
tool.env["PROD_PROJECT"] = "ffci-invalid"
with pytest.raises(ValueError):
tool.settings()
+24 -10
View File
@@ -164,21 +164,14 @@ def test_activate_escapes_dollar_secrets_and_never_down(tool, monkeypatch):
assert len(calls) == 2 and "up" in calls[1] and "--no-build" in calls[1]
def test_workflow_deploy_is_verify_gated_and_manual_plan_default(tool):
def test_workflow_verifies_without_legacy_production_deploy(tool):
import yaml
workflow = yaml.safe_load(
(Path(__file__).parents[1] / ".github/workflows/verify.yml").read_text()
)
job = workflow["jobs"]["deploy"]
assert job["needs"] == "verify" and "success()" in job["if"]
assert "refs/heads/main" in job["if"] and "pull_request" not in job["if"]
assert job["env"]["PROD_DEPLOY_ENABLED"] == "${{ vars.PROD_DEPLOY_ENABLED }}"
assert (
workflow[True]["workflow_dispatch"]["inputs"]["deploy_action"]["default"]
== "plan"
)
assert job["steps"][-1]["if"] == "always()"
assert set(workflow["jobs"]) == {"verify"}
assert workflow[True]["workflow_dispatch"] is None
def test_recovery_handles_missing_or_crashed_production_container(tool, monkeypatch):
@@ -311,3 +304,24 @@ def test_image_retention_failure_does_not_fail_verified_deploy(
output = capsys.readouterr().out
assert "cleanup deferred" in output
assert "private diagnostic" not in output
def test_retired_nas_target_is_rejected(tool):
tool.env["PROD_DATA_DIR"] = "/volume2/docker/pigway-frame-flow/data"
with pytest.raises(ValueError, match="retired"):
tool.settings()
def test_preflight_rejects_legacy_photo_mount(tool, monkeypatch):
old = existing(tool)
old["Mounts"].append(
{
"Type": "bind",
"Source": "/volume1/photo",
"Destination": "/photos",
"RW": False,
}
)
monkeypatch.setattr(tool, "inspect", lambda _: old)
with pytest.raises(ValueError, match="retired"):
tool.preflight()
+4 -4
View File
@@ -6,10 +6,10 @@ def test_external_forks_do_not_execute_on_nas_runner():
assert (
"github.event.pull_request.head.repo.full_name == github.repository" in workflow
)
assert "needs: verify" in workflow
assert "python scripts/production-deploy.py" not in workflow
def test_private_production_configuration_uses_secrets():
def test_verification_runner_receives_no_production_configuration():
workflow = Path(".github/workflows/verify.yml").read_text()
for key in (
"PROD_DATA_DIR",
@@ -19,7 +19,7 @@ def test_private_production_configuration_uses_secrets():
"SYNOLOGY_PHOTOS_URL",
"SYNOLOGY_PHOTOS_CERT_SHA256",
):
assert "${{ secrets." + key + " }}" in workflow
assert "${{ secrets." + key + " }}" not in workflow
assert "${{ vars." + key + " }}" not in workflow
@@ -28,7 +28,7 @@ def test_actions_downloads_do_not_depend_on_personal_server():
workflow = Path(".github/workflows/verify.yml").read_text()
actions = re.findall(r"uses:\s*(\S+)", workflow)
assert len(actions) == 4
assert len(actions) == 2
for action in actions:
assert re.fullmatch(
r"https://github\.com/actions/(checkout|setup-python)@[0-9a-f]{40}",
+4
View File
@@ -172,3 +172,7 @@ def test_first_cleanup_runs_after_recent_host_boot(
]
== 1
)
# Legacy algorithms remain covered; production filesystem access is tested separately.
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
+73
View File
@@ -0,0 +1,73 @@
"""Production release preflight must reject unsafe inherited container settings."""
import importlib.util
from pathlib import Path
import pytest
@pytest.fixture
def release_tool():
spec = importlib.util.spec_from_file_location(
"server_release", Path(__file__).parents[1] / "scripts/server-release.py"
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.mark.parametrize(
"unsafe", ["mock", "writes", "photos", "system_disk", "root", "caps"]
)
def test_release_rejects_unsafe_runtime(release_tool, monkeypatch, unsafe):
container = {
"Config": {
"User": "10333:10333",
"Env": [
"KOKONNA_MOCK=false",
"KOKONNA_WRITE_ENABLED=true",
"BUILD_COMMIT=test",
],
},
"State": {"Running": True},
"HostConfig": {"ReadonlyRootfs": True, "CapDrop": ["ALL"]},
"Mounts": [
{"Destination": "/data", "Source": str(release_tool.APP / "data")},
{"Destination": "/config", "Source": str(release_tool.APP / "config")},
],
}
if unsafe == "mock":
container["Config"]["Env"][0] = "KOKONNA_MOCK=true"
elif unsafe == "writes":
container["Config"]["Env"][1] = "KOKONNA_WRITE_ENABLED=false"
elif unsafe == "photos":
container["Mounts"].append(
{"Destination": "/photos", "Source": "/volume1/photo"}
)
elif unsafe == "system_disk":
container["Mounts"][0]["Source"] = "/var/lib/frame-flow"
elif unsafe == "root":
container["Config"]["User"] = "root"
else:
container["HostConfig"]["CapDrop"] = []
monkeypatch.setattr(release_tool, "inspect", lambda: container)
with pytest.raises(AssertionError):
release_tool.verify_runtime("test")
def test_release_readiness_retries_on_actual_listener(release_tool, monkeypatch):
import io
import urllib.error
calls = []
def listener(url, timeout):
calls.append(url)
if url != "http://192.0.2.10:81/ready" or len(calls) == 1:
raise urllib.error.URLError("Connection refused during restart")
return io.BytesIO(b'{"status":"ready"}')
monkeypatch.setattr(release_tool.urllib.request, "urlopen", listener)
monkeypatch.setattr(release_tool.time, "sleep", lambda _: None)
release_tool.wait_ready()
assert len(calls) == 2
+4
View File
@@ -152,3 +152,7 @@ def test_people_api_auth_and_secret_response(client, service, monkeypatch):
assert r.status_code == 422 and "input" not in r.text
client.headers.pop("Authorization")
assert client.get("/api/v1/integrations/synology-photos/people").status_code == 401
# Legacy algorithms remain covered; production filesystem access is tested separately.
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
+403
View File
@@ -0,0 +1,403 @@
import hashlib
import io
import json
from concurrent.futures import ThreadPoolExecutor
import pytest
from PIL import Image
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
from pigway_frame_flow.providers.photos_cache import PhotosCache
from pigway_frame_flow.providers.synology_photos import SynologyPhotos
def jpeg(color="red"):
out = io.BytesIO()
Image.new("RGB", (24, 16), color).save(out, "JPEG")
return out.getvalue()
def session(settings, value="fixture-session"):
settings.config_dir.mkdir(parents=True, exist_ok=True)
(settings.config_dir / "synology-photos-session.json").write_text(
json.dumps({"sid": value})
)
def row(id, folder="/family", name="same.jpg"):
return {
"id": id,
"filename": name,
"type": "photo",
"filesize": 9000,
"indexed_time": 42,
"additional": {
"folder": folder,
"resolution": {"width": 24, "height": 16},
"thumbnail": {"unit_id": id, "cache_key": "fixture-key"},
},
}
@pytest.mark.parametrize(
"space,prefix", [("shared", "SYNO.FotoTeam"), ("personal", "SYNO.Foto")]
)
@pytest.mark.parametrize("mode,expected", [("any", ["1", "2", "3"]), ("all", ["2"])])
def test_api_identity_selection(settings, monkeypatch, space, prefix, mode, expected):
session(settings)
p = SynologyPhotos(settings)
monkeypatch.setattr(p, "people", lambda space: [{"id": 10}, {"id": 20}])
def pages(api, version, **params):
assert api == prefix + ".Browse.Item"
return [row(i) for i in ([1, 2] if params["person_id"] == 10 else [2, 3])]
monkeypatch.setattr(p, "pages", pages)
assert [str(x["id"]) for x in p.items([10, 20], mode, space=space)] == expected
with pytest.raises(FlowError):
p.items([99], space=space)
def test_people_pagination_does_not_stop_at_500(settings, monkeypatch):
session(settings)
p = SynologyPhotos(settings)
calls = []
def request(api, method, version, **params):
calls.append(params["offset"])
return {
"list": [
{"id": i}
for i in range(params["offset"], min(1334, params["offset"] + 500))
]
}
monkeypatch.setattr(p, "request", request)
assert len(p.people()) == 1334
assert calls == [0, 500, 1000]
def test_caption_people_use_actual_membership_and_cache(settings, monkeypatch):
session(settings)
provider = SynologyPhotos(settings)
monkeypatch.setattr(
provider,
"people",
lambda space: [
{"id": 1, "name": "Alice", "named": True},
{"id": 2, "name": "Bob", "named": True},
{"id": 3, "name": "3", "named": False},
],
)
calls = []
def pages(api, version, **params):
calls.append(api)
return [{"id": params["person_id"]}, {"id": 10}]
monkeypatch.setattr(provider, "pages", pages)
assert provider.item_people(1) == ["Alice"]
assert provider.item_people(10) == ["Alice", "Bob"]
assert provider.item_people(99) == []
assert calls == ["SYNO.FotoTeam.Browse.Item"] * 2
assert provider.item_people(10, space="personal") == ["Alice", "Bob"]
assert calls[-2:] == ["SYNO.Foto.Browse.Item"] * 2
@pytest.mark.parametrize("labels_available", [True, False])
def test_api_caption_keeps_filename_and_folder_with_optional_people(
service, settings, monkeypatch, labels_available
):
session(settings)
provider = service.photo_sources
monkeypatch.setattr(
provider.synology,
"items",
lambda *a, **k: [row(1, "/20261017 Family@Park", "one.jpg")],
)
monkeypatch.setattr(provider.synology, "download", lambda *a, **k: jpeg())
def names(*a, **k):
if not labels_available:
raise FlowError("CLOUD_UNAVAILABLE", "unavailable", 502)
return ["Alice", "Bob"]
monkeypatch.setattr(provider.synology, "item_people", names)
provider.save(
"captions",
PhotoSourceInput(
name="Family", kind="synology-photos", location=".", folder_caption=True
),
)
_, _, caption = provider.read("captions", "1")
assert caption["date"] == "2026-10-17"
assert caption["title"] == "Family"
assert caption["filename"] == "one.jpg"
assert caption.get("people", "") == ("Alice & Bob" if labels_available else "")
def test_source_without_mount_pagination_scope_and_session(
client, service, settings, monkeypatch
):
session(settings)
settings.photo_root = settings.data_dir / "must-not-exist"
p = service.photo_sources
monkeypatch.setattr(
p.synology,
"items",
lambda *a, **k: [row(1), row(2, "/family/child"), row(3, "/other")],
)
monkeypatch.setattr(p.synology, "download", lambda *a, **k: jpeg())
source = PhotoSourceInput(
name="API",
kind="synology-photos",
location="family",
people_filter=True,
person_ids=[10],
orientation="landscape",
)
p.save("api", source)
response = client.get("/api/v1/photo-sources/api/images?offset=1&limit=1")
assert response.status_code == 200, response.text
assert response.json()["total"] == 2
assert response.json()["items"][0]["id"] == "2"
assert "cache_key" not in response.text and "_photos" not in response.text
data, name, _ = p.read("api", "1")
Image.open(io.BytesIO(data)).load()
assert name == "same.jpg" and not settings.photo_root.exists()
with pytest.raises(FlowError):
p.read("api", "3")
p.synology.disconnect()
with pytest.raises(FlowError) as error:
p.catalog("api")
assert error.value.status == 409
session(settings, "different-account")
monkeypatch.setattr(p.synology, "items", lambda *a, **k: [])
assert p.catalog("api") == []
def test_existing_local_sources_are_not_silently_converted(service, settings):
settings.photo_root = settings.data_dir / "missing"
with pytest.raises(FlowError):
service.photo_sources.save(
"local", PhotoSourceInput(name="Legacy", kind="local", location=".")
)
with pytest.raises(FlowError):
service.photo_sources.save(
"api",
PhotoSourceInput(name="API", kind="synology-photos", location="../secrets"),
)
def test_cache_coalesces_concurrency_and_recovers_partial_files(settings):
settings.photos_cache_bytes = 2000
settings.max_bytes = 1000
cache = PhotosCache(settings)
def download(handle, cap, deadline):
handle.write(jpeg())
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(lambda _: cache.read("one", download), range(8)))
assert all(x == jpeg() for x in results)
assert cache.downloads == 1 and cache.hits == 7
(cache.root / "interrupted.part").write_bytes(b"partial")
cache.read("two", download)
cache.read("three", download)
assert sum(p.stat().st_size for p in cache.root.glob("*.image")) <= 2000
assert not list(cache.root.glob("*.part"))
assert cache.read("three", download) == jpeg()
@pytest.mark.parametrize("failure", ["interrupt", "invalid", "oversize"])
def test_cache_failures_never_commit(settings, failure):
settings.max_bytes = 1000
cache = PhotosCache(settings)
def download(handle, cap, deadline):
handle.write(
jpeg()
if failure == "interrupt"
else b"wrong"
if failure == "invalid"
else b"x" * 1001
)
if failure == "interrupt":
raise TimeoutError()
with pytest.raises(FlowError):
cache.read("bad", download)
assert not list(cache.root.glob("*.part"))
assert not list(cache.root.glob("*.image"))
def transport(
settings, monkeypatch, *, bad_pin=False, expired=False, interrupted=False
):
monkeypatch.setenv("SYNOLOGY_PHOTOS_URL", "https://nas.test:9115")
monkeypatch.setenv(
"SYNOLOGY_PHOTOS_CERT_SHA256",
"a" * 64 if bad_pin else hashlib.sha256(b"certificate").hexdigest(),
)
sent = []
class Socket:
def getpeercert(self, **kw):
return b"certificate"
class Response:
status = 200
def __init__(self):
self.buffer = io.BytesIO(
b'{"success":false,"error":{"code":106}}' if expired else jpeg()
)
def getheader(self, name):
return (
("application/json" if expired else "image/jpeg")
if name == "Content-Type"
else str(len(self.buffer.getvalue()))
)
def read(self, size):
if interrupted:
raise TimeoutError()
return self.buffer.read(size)
class Connection:
sock = Socket()
def __init__(self, *a, **kw):
pass
def connect(self):
pass
def request(self, *args):
sent.append(args)
def getresponse(self):
return Response()
def close(self):
pass
monkeypatch.setattr("http.client.HTTPSConnection", Connection)
session(settings)
return SynologyPhotos(settings), sent
def test_binary_transport_pin_before_secret_and_read_allowlist(settings, monkeypatch):
p, sent = transport(settings, monkeypatch, bad_pin=True)
with pytest.raises(FlowError):
p.download(row(1))
assert not sent
with pytest.raises(FlowError):
p._download(
"SYNO.FotoTeam.Browse.Item",
"delete",
{},
"fixture",
io.BytesIO(),
1000,
100,
)
assert not sent
def test_download_decodes_cache_reuse_and_rendition_rejection(settings, monkeypatch):
p, sent = transport(settings, monkeypatch)
assert p.download(row(1)) == jpeg()
assert p.download(row(1)) == jpeg()
assert len(sent) == 1
assert "SYNO.FotoTeam.Thumbnail" in sent[0][2]
assert p.download(row(1), space="personal") == jpeg()
assert len(sent) == 2
session(settings, "different-session")
assert p.download(row(1)) == jpeg()
assert len(sent) == 3
with pytest.raises(FlowError):
p.download(row(1), rendition="original")
def test_download_session_error_and_interruption(settings, monkeypatch):
p, _ = transport(settings, monkeypatch, expired=True)
with pytest.raises(FlowError) as error:
p.download(row(1))
assert error.value.status == 409
assert not p.path.exists()
assert not list(p.image_cache.root.glob("*.part"))
p, _ = transport(settings, monkeypatch, interrupted=True)
with pytest.raises(FlowError):
p.download(row(1))
assert not list(p.image_cache.root.glob("*.part"))
def cache_process(settings, marker):
cache = PhotosCache(settings)
def download(handle, cap, deadline):
with open(marker, "ab") as counter:
counter.write(b"download\n")
handle.write(jpeg())
assert cache.read("shared-process-key", download) == jpeg()
def test_cache_coalesces_across_processes(settings, tmp_path):
import multiprocessing
context = multiprocessing.get_context("spawn")
marker = tmp_path / "downloads"
processes = [
context.Process(target=cache_process, args=(settings, marker)) for _ in range(3)
]
for process in processes:
process.start()
for process in processes:
process.join(15)
assert process.exitcode == 0
assert marker.read_bytes() == b"download\n"
def test_explicit_offline_conversion_preserves_task_reference(service, settings):
import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location(
"photos_migration", Path(__file__).parents[1] / "scripts/migrate-photos-api.py"
)
migration = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration)
# The production service uses this filename; inspect the fixture connection
# rather than modifying an unrelated database.
path = Path(service.db.conn.execute("PRAGMA database_list").fetchone()[2])
source = PhotoSourceInput(
name="Existing",
kind="local",
location="family",
people_filter=True,
person_ids=[5],
synology_previews=True,
people_space="personal",
person_match="all",
)
service.db.execute(
"INSERT INTO app_config VALUES(?,?)",
("photo_source:existing", source.model_dump_json()),
)
before = service.photo_sources.get("existing")
assert not migration.convert(path, ["existing"])["applied"]
assert service.photo_sources.get("existing") == before
result = migration.convert(path, ["existing"], apply=True)
assert Path(result["backup"]).is_file()
after = service.photo_sources.get("existing")
assert after.kind == "synology-photos" and not after.synology_previews
assert (
after.person_ids == [5]
and after.people_space == "personal"
and after.person_match == "all"
and after.location == "family"
)