Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d691826b6 | ||
|
|
5b2763910e | ||
|
|
e92a7a51fa | ||
|
|
b1c41e91ea | ||
|
|
f39c7e535b | ||
|
|
51d76a196e | ||
|
|
038b858eb7 | ||
|
|
4e9eccd809 | ||
|
|
8aaff83b8d | ||
|
|
7393325b6e | ||
|
|
db0b408680 | ||
|
|
2af0aea2f6 | ||
|
|
a05260712e | ||
|
|
c0291050be | ||
|
|
3cff5045b2 | ||
|
|
9f1c15a4c4 | ||
|
|
443a60ec54 | ||
|
|
9b31c1f425 | ||
|
|
743c6afe0c | ||
|
|
7bf23e5687 | ||
|
|
8cd005fd89 | ||
|
|
ec14ca2f1d | ||
|
|
f0a42caf45 | ||
|
|
7579ee13d1 | ||
|
|
c153822479 | ||
|
|
7cb940cf36 | ||
|
|
54a8d37f5e | ||
|
|
b101bb2f2d | ||
|
|
f1ce7e2cb3 | ||
|
|
10534fe8b0 | ||
|
|
32e8d4066e | ||
|
|
83837a4511 | ||
|
|
2b5f36019e | ||
|
|
f7b6c78a10 | ||
|
|
ba91dde2e7 | ||
|
|
7c929cb955 | ||
|
|
41c6897151 | ||
|
|
d7600f8329 | ||
|
|
40671cae5d | ||
|
|
a86a9e2080 | ||
|
|
bda5229183 | ||
|
|
b95b8e98cd | ||
|
|
b87bf6f77e | ||
|
|
46a0429d5e | ||
|
|
815164843b | ||
|
|
66ed1e2c9b | ||
|
|
2275974cde | ||
|
|
d4543a9843 | ||
|
|
106b6f311f | ||
|
|
d3f40cfea3 | ||
|
|
a843d4de9b |
@@ -14,3 +14,6 @@ dist
|
||||
.mypy_cache
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
|
||||
# macOS metadata at any depth
|
||||
**/.DS_Store
|
||||
|
||||
+4
-7
@@ -2,7 +2,9 @@ TZ=Asia/Shanghai
|
||||
# Default local access. For NAS LAN access set this to the NAS LAN IP (recommended)
|
||||
# or explicitly 0.0.0.0. No router, reverse proxy or firewall rules are modified.
|
||||
WEB_BIND=127.0.0.1
|
||||
WEB_PORT=18787
|
||||
WEB_PORT=6005
|
||||
# Display name of the NAS/server storing the local gallery.
|
||||
FRAME_FLOW_STORAGE_NAME=NAS
|
||||
KOKONNA_MOCK=true
|
||||
KOKONNA_WRITE_ENABLED=false
|
||||
KOKONNA_API_KEY=
|
||||
@@ -15,15 +17,10 @@ IMAGE_URL_ALLOWED_HOSTS=
|
||||
BUILD_COMMIT=development
|
||||
BUILD_TIME=unknown
|
||||
|
||||
# Optional read-only NAS photo mount; use deploy/docker-compose.photos.yml.
|
||||
# NAS_PHOTOS_DIR=/volume2/photo
|
||||
# PHOTO_ROOT=/photos
|
||||
# Filesystem/NFS photo sources are retired. Use Synology Photos API or WebDAV.
|
||||
# Optional WebDAV credential reference; prefer a secret file under /config.
|
||||
# FRAME_FLOW_SOURCE_CLOUD_FILE=/config/webdav-cloud.secret
|
||||
|
||||
# Optional existing group with photo read permission; does not change NAS ACLs.
|
||||
# NAS_PHOTOS_GID=101
|
||||
|
||||
# Optional Synology Photos shared-space people filter. Configure a pinned NAS endpoint.
|
||||
# Login in the photo-source editor; only the session is saved in /config (mode 0600).
|
||||
SYNOLOGY_PHOTOS_URL=
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Community checks
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
# GitHub-hosted disposable runner only; never run forks on the NAS runner.
|
||||
if: github.server_url == 'https://github.com'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.14'
|
||||
- run: python -m pip install -e '.[test]'
|
||||
- run: python -m ruff check pigway_frame_flow tests scripts
|
||||
- run: python -m ruff format --check pigway_frame_flow tests scripts
|
||||
- run: python -m mypy pigway_frame_flow
|
||||
- run: python -m pytest -q
|
||||
- run: python scripts/ci-audit.py
|
||||
@@ -1,18 +1,14 @@
|
||||
name: Verify Frame Flow
|
||||
on:
|
||||
push:
|
||||
# Branch verification gates releases; tags do not duplicate the same build.
|
||||
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.
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
if: github.server_url != 'https://github.com' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_PIP_INDEX_URL: ${{ vars.DOCKER_PIP_INDEX_URL }}
|
||||
@@ -45,53 +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 }}
|
||||
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.
|
||||
|
||||
@@ -24,3 +24,6 @@ build-cache/wheels/*
|
||||
# Private browser captures and platform metadata
|
||||
docs/screenshots/
|
||||
.DS_Store
|
||||
|
||||
# Private deployment receipts belong outside public source control.
|
||||
docs/verification/
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Contributing
|
||||
|
||||
Use Python 3.14.x and follow the development commands in README.md. Run Ruff
|
||||
lint/format checks, mypy and pytest before submitting a pull request. Keep changes
|
||||
focused and include a regression test for behavior fixes where practical.
|
||||
|
||||
Use mock mode with device writes disabled. Do not commit `.env`, credentials,
|
||||
private configuration, databases, photos, or production receipts. Tests must not
|
||||
contact real frames or mutate production services.
|
||||
|
||||
GitHub mirrors run community checks on disposable GitHub-hosted runners, including
|
||||
fork PRs. Gitea uses the existing trusted NAS runner and rejects fork PRs; maintainers
|
||||
review contributions before moving them to a trusted branch. Docker build/smoke
|
||||
verification remains in Gitea CI. Production releases are maintainer-controlled;
|
||||
contributors do not need access to production infrastructure.
|
||||
|
||||
For security issues, follow SECURITY.md instead of posting exploit details publicly.
|
||||
Contributions are provided under the project's MIT license; preserve third-party
|
||||
licenses and provenance when introducing external code or assets.
|
||||
+16
-1
@@ -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
|
||||
@@ -14,6 +14,7 @@ RUN --mount=type=cache,id=frameflow-bookworm-apt-${TARGETARCH},target=/var/cache
|
||||
&& printf 'Binary::apt::APT::Keep-Downloaded-Packages "true";\n' > /etc/apt/apt.conf.d/keep-cache \
|
||||
&& sed -i "s|http://deb.debian.org/debian-security|https://${DEBIAN_SECURITY_MIRROR}/debian-security|g; s|http://deb.debian.org/debian|https://${DEBIAN_MIRROR}/debian|g" /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get -o Acquire::Retries=3 -o Acquire::https::Timeout=60 update \
|
||||
&& apt-get -y --no-install-recommends upgrade \
|
||||
&& if [ -f /build-debs/fonts-noto-cjk.deb ]; then \
|
||||
font_version=$(apt-cache policy fonts-noto-cjk | awk '$1=="Candidate:" {print $2}'); \
|
||||
font_sha=$(apt-cache show "fonts-noto-cjk=$font_version" | awk '$1=="SHA256:" {print $2;exit}'); \
|
||||
@@ -37,6 +38,20 @@ 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
|
||||
COPY LICENSE NOTICE.md ./
|
||||
# Publish only the cleaned filesystem, without retaining build-stage layers.
|
||||
RUN rm -rf /usr/local/lib/python*/site-packages/pip \
|
||||
/usr/local/lib/python*/site-packages/pip-*.dist-info \
|
||||
/usr/local/lib/python*/ensurepip \
|
||||
&& rm -f /usr/local/bin/pip /usr/local/bin/pip[0-9]* \
|
||||
&& find / -name .DS_Store -type f -delete
|
||||
|
||||
FROM scratch
|
||||
COPY --from=prepared / /
|
||||
ENV PATH=/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 TZ=Asia/Shanghai \
|
||||
DATA_DIR=/data CONFIG_DIR=/config
|
||||
WORKDIR /app
|
||||
ARG BUILD_COMMIT=development
|
||||
ARG BUILD_TIME=unknown
|
||||
ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Pigway
|
||||
Copyright (c) 2026 PIGWay
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Notices
|
||||
|
||||
Pigway Frame Flow is an independent project. KoKonna is a supported device
|
||||
PIGWay Frame Flow is an independent project. KoKonna is a supported device
|
||||
integration and is not affiliated with this project unless otherwise stated.
|
||||
KoKonna names and marks belong to their respective owners.
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Pigway Frame Flow
|
||||
# PIGWay Frame Flow
|
||||
|
||||
Development currently supports Python 3.14.x. See [contributing](CONTRIBUTING.md) and [security reporting](SECURITY.md).
|
||||
|
||||
[简体中文](README.zh-CN.md) | English
|
||||
|
||||
Pigway Frame Flow is a lightweight, self-hosted platform for programmable smart-frame automation. It runs independently on a NAS or server, with its own Web UI, image library, rendering, scheduler and REST API. KoKonna is a supported device integration; external applications do not need its credentials or SDK.
|
||||
PIGWay Frame Flow is a lightweight, self-hosted platform for programmable smart-frame automation. It runs independently on a NAS or server, with its own Web UI, image library, rendering, scheduler and REST API. KoKonna is a supported device integration; external applications do not need its credentials or SDK.
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -22,9 +24,9 @@ docker compose run --rm --no-deps -e PYTHONPATH=/app \
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:18787` and sign in with the account you created. The browser uses an HttpOnly session cookie; it does not require an API token. Remembered sessions last 30 days. Change the password or sign out in Settings. `FRAME_FLOW_TOKEN` authenticates external API clients and is separate from the KoKonna API key.
|
||||
Open `http://127.0.0.1:6005` and sign in with the account you created. The browser uses an HttpOnly session cookie; it does not require an API token. Remembered sessions last 30 days. Change the password or sign out in Settings. `FRAME_FLOW_TOKEN` authenticates external API clients and is separate from the KoKonna API key.
|
||||
|
||||
The published interface defaults to loopback. For LAN access, explicitly set `WEB_BIND` to your NAS LAN address and choose an available `WEB_PORT`. See [Synology deployment](docs/NAS.md), [build mirrors and caching](docs/BUILDING.md), and [Gitea deployment/rollback](docs/deployment/GITEA_CD.md). No firewall, router or reverse-proxy settings are changed automatically.
|
||||
The published interface defaults to loopback. For LAN access, explicitly set `WEB_BIND` to your NAS LAN address and choose an available `WEB_PORT`. See [Synology deployment](docs/NAS.md), [build mirrors and caching](docs/BUILDING.md). No firewall, router or reverse-proxy settings are changed automatically.
|
||||
|
||||
For a local mock demo, use Python 3.14:
|
||||
|
||||
@@ -37,6 +39,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.
|
||||
@@ -45,7 +49,7 @@ The demo forces mock mode and disables device writes. Data and configuration rem
|
||||
- Seven templates: Daily Dashboard, Weather, Calendar, Quote, Photo + Text, Daily Poetry and System Alert.
|
||||
- Layout Studio for collages/posters with 1–12 images; optional OpenAI-compatible layout planning and source-driven automatic collages.
|
||||
- Device-bound schedules, ordered/random playlists, temporary priority overrides, restore and persistent operation history.
|
||||
- Read-only NAS folders, Synology Photos person filtering, WebDAV and optional cloud-drive connectors. Each source has its own configuration and authorization.
|
||||
- Synology Photos person filtering, WebDAV and optional cloud-drive connectors. Each source has its own folder and filter settings. People filtering uses one shared Synology Photos sign-in; WebDAV and cloud-drive credentials belong to their respective sources.
|
||||
- Optional weather, calendar, monitoring, AI, MQTT and webhook integrations. Provider configuration is required; an unconnected provider is not a working connection.
|
||||
|
||||
Templates **render images**, including text-only templates. Choose a template, edit its data, generate a preview, then display it. Preview generation and saving template settings do not update the frame. Photo + Text and Daily Dashboard can include a selected local photo; other templates do not automatically choose one. Scheduled template jobs render afresh on each run. See [photo sources](docs/PHOTO_SOURCES.md), [automatic collages](docs/AUTOMATIC_COLLAGE.md) and [integration setup/limits](docs/EXTENSIONS.md).
|
||||
@@ -91,3 +95,5 @@ Tests mock cloud/device calls. CI also verifies an isolated Docker build, readin
|
||||
Cloud synchronization does not prove the physical pixels refreshed. KoKonna cloud uploads activate immediately, so upload-only uses local staging. Native slideshow membership editing is unavailable; avoid running native and Frame Flow playback simultaneously. Smart cropping is a saliency heuristic, not guaranteed face detection. The service uses one worker and is not designed for multiple replicas. Browser OAuth, AI daily-art scheduling, additional calendar recurrence features and more device drivers remain roadmap items.
|
||||
|
||||
Related projects: [PIGWay Raspberry Pi Agent](https://tea.pigway.com/way/pigway-raspberry-pi-agent) · [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) · [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat)
|
||||
|
||||
[Documentation](docs/README.md)
|
||||
|
||||
+13
-5
@@ -1,4 +1,6 @@
|
||||
# Pigway Frame Flow
|
||||
# PIGWay Frame Flow
|
||||
|
||||
开发环境目前支持 Python 3.14.x。参见[贡献指南](CONTRIBUTING.md)和[安全问题报告](SECURITY.md)。
|
||||
|
||||
简体中文 | [English](README.md)
|
||||
|
||||
@@ -22,9 +24,9 @@ docker compose run --rm --no-deps -e PYTHONPATH=/app \
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
打开 `http://127.0.0.1:18787`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。
|
||||
打开 `http://127.0.0.1:6005`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。
|
||||
|
||||
默认端口只绑定本机回环地址。需要局域网访问时,显式将 `WEB_BIND` 设置为 NAS 的局域网地址,并通过 `WEB_PORT` 选择空闲端口。参见[群晖部署](docs/NAS.md)、[构建镜像源与缓存](docs/BUILDING.md)和 [Gitea 部署与回滚](docs/deployment/GITEA_CD.md)。不会自动修改防火墙、路由器或反向代理。
|
||||
默认端口只绑定本机回环地址。需要局域网访问时,显式将 `WEB_BIND` 设置为 NAS 的局域网地址,并通过 `WEB_PORT` 选择空闲端口。参见[群晖部署](docs/NAS.md)、[构建镜像源与缓存](docs/BUILDING.md)。不会自动修改防火墙、路由器或反向代理。
|
||||
|
||||
使用 Python 3.14 运行本地 Mock 演示:
|
||||
|
||||
@@ -37,15 +39,17 @@ python3.14 -m venv .venv
|
||||
|
||||
演示强制使用 Mock,并关闭设备写入。数据与配置保存在 `data/`、`config/`,重启后保留。
|
||||
|
||||
Synology Photos 可使用独立的 API 来源,无需 `/photos` 或 NFS 挂载;支持共享 / 当前账号个人空间、人物筛选和按需 XL 预览缓存。原图下载不在本次已验证支持范围内。见 [API 来源与 Ubuntu 候选迁移说明](docs/SYNOLOGY_PHOTOS_API.md)。
|
||||
|
||||
### 功能与边界
|
||||
|
||||
- 中英文 Web 界面、深浅色主题、账号登录、设备面板与图片预览。
|
||||
- 本地暂存和 KoKonna 云端图库,上传、切换、星标、来源/任务筛选及批量操作。
|
||||
- NAS 本地图库和 KoKonna 云端图库;星标、来源/任务筛选及批量操作位于本地图库,上传到云端并显示需单独操作。
|
||||
- 保持比例的 contain/cover/smart 图片转换,彩色/灰度模式与照片数据源的横竖方向选择。
|
||||
- 七种模板:家庭早报、天气、日历、一句话、照片与文字、每日诗词、系统告警。
|
||||
- 拼图工作室支持 1–12 张图片的拼图与海报;可选 OpenAI 兼容接口排版,以及从数据源自动选图拼接。
|
||||
- 绑定设备的定时任务、顺序/随机播放列表、带优先级的临时覆盖、恢复与持久化操作历史。
|
||||
- 只读 NAS 文件夹、Synology Photos 人物筛选、WebDAV 和可选网盘连接器;每个数据源独立配置、独立授权。
|
||||
- Synology Photos 人物筛选、WebDAV 和可选网盘连接器;每个数据源独立配置、独立授权。
|
||||
- 可选天气、日历、监控、AI、MQTT 与 Webhook 集成。需要配置对应 Provider;尚未授权的数据源不会被当作已连接。
|
||||
|
||||
模板会**生成图片**,纯文字模板也是如此。使用顺序是:选择模板 → 编辑内容 → 生成预览 → 显示到画框。生成预览和保存模板设置都不会更新画框。“照片与文字”和“家庭早报”可以包含指定的本地照片,其他模板不会自动选图;定时模板任务每次运行会重新渲染。参见[照片数据源](docs/PHOTO_SOURCES.md)、[自动拼图](docs/AUTOMATIC_COLLAGE.md)和[集成配置及限制](docs/EXTENSIONS.md)。
|
||||
@@ -67,6 +71,8 @@ Web UI / Scheduler / REST API → DisplayService → FrameDeviceDriver → KoKon
|
||||
|
||||
Ticktar 集成为可选项,Frame Flow 不依赖 Ticktar 启动,也不访问其数据库。参见[外部集成契约](docs/integrations/TICKTAR.md)。
|
||||
|
||||
Console 监控接入提供独立只读 ZIP 插件,支持服务就绪、画框在线/电量及任务最近结果,见 [Console 插件安装说明](https://tea.pigway.com/way/pigway-console-adapter-frame-flow/src/branch/main/README.zh-CN.md)。
|
||||
|
||||
### 存储与安全
|
||||
|
||||
Docker 持久化 `/data/frameflow.db`、`/data` 下的图片/生成文件/缓存,以及 `/config` 下的私有配置和会话文件。容器以 UID/GID 10333 运行,根文件系统只读,使用独立桥接网络、`unless-stopped` 重启策略和有大小限制的标准输出日志;不需要 privileged、host network 或 Docker socket。
|
||||
@@ -91,3 +97,5 @@ KoKonna 是支持的设备集成。本项目与 KoKonna 无隶属关系,也不
|
||||
云端同步不等于已确认物理屏幕刷新。KoKonna 云端上传会立即切换,因此“仅上传”使用本地暂存。官方接口不支持编辑原生轮播成员,应避免原生轮播与 Frame Flow 播放同时运行。smart 裁切采用显著性启发式,不保证识别人脸。当前服务使用单 Worker,不面向多副本部署。浏览器 OAuth、AI 每日艺术调度、更多日历重复规则及设备驱动仍属于后续计划。
|
||||
|
||||
相关项目:[PIGWay Raspberry Pi Agent](https://tea.pigway.com/way/pigway-raspberry-pi-agent) · [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) · [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat)
|
||||
|
||||
[文档目录](docs/README.md)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# pigway-frame-flow v0.1.20
|
||||
|
||||
统一开源许可证、贡献和安全反馈入口;完善当前版本的文档导航、源码分发和校验和。插件 ZIP 携带许可证。运行功能边界保持项目首页所述。
|
||||
|
||||
本轮 585 项 Python 测试通过。源码语法、文档入口、打包和校验和检查通过;生产运行状态以实际部署验收为准。
|
||||
|
||||
Ruff、格式、mypy、25 项锁定依赖的漏洞检查通过;AMD64 Docker 构建和两轮隔离启动/重启只读冒烟验证通过。
|
||||
|
||||
Docker 构建增加发行版安全更新,清除可修复系统包告警;仍有上游未修复包级告警,分析及默认部署边界见 [安全说明](SECURITY.md),不宣称映像零漏洞。
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Security policy
|
||||
|
||||
Security fixes target the latest release. Older releases have no guaranteed
|
||||
backport support. Upgrade before verifying whether an issue is still present.
|
||||
|
||||
Do not publish credentials, private photos, production logs, or working exploit
|
||||
payloads in public issues. Contact the repository owner privately through the
|
||||
hosting platform when available. If no private contact channel is available,
|
||||
open an issue asking for a confidential reporting channel without technical
|
||||
vulnerability details. There is currently no dedicated security mailbox or
|
||||
promised response SLA.
|
||||
|
||||
A useful private report includes the affected version, deployment mode, required
|
||||
permissions, impact, and minimal steps using mock data. Do not test on another
|
||||
person's deployment or write to real frames without explicit authorization.
|
||||
|
||||
## 当前容器基础系统扫描
|
||||
|
||||
2026-10-18 使用 Trivy 0.76.0 扫描当前映像。在构建阶段应用 Debian 安全更新后,可修复的 HIGH/CRITICAL 项已清除;仍有 63 条发行版包级告警,合并为 16 个 CVE,当前 Bookworm 无可用修复版本。不能将 Python 依赖检查通过描述为整个映像零漏洞。
|
||||
|
||||
- [CVE-2023-45853](https://security-tracker.debian.org/tracker/CVE-2023-45853) 针对 MiniZip;Debian 明确 Bookworm 的 zlib 未构建相应代码,本映像也未安装 MiniZip。
|
||||
- [CVE-2025-7458](https://security-tracker.debian.org/tracker/CVE-2025-7458) 需要攻击者执行特制 SQL;Frame Flow 不接受任意 SQL 或上传外部 SQLite 数据库。FTS5 相关 [CVE-2026-11822](https://security-tracker.debian.org/tracker/CVE-2026-11822)、[CVE-2026-11824](https://security-tracker.debian.org/tracker/CVE-2026-11824) 的匹配查询也未被应用使用。此结论不适用于自行替换数据库或修改服务 SQL 的部署。
|
||||
- 其他告警涉及挂载/命名空间工具、systemd-homed、ACL、ncurses、Perl/LZH CLI 及 DTLS;应用未调用这些路径。默认 UID/GID 10333、只读根目录、清空 capabilities、禁提权和独立数据卷仍是必要边界。
|
||||
|
||||
本轮未发现这些遗留项在默认应用路径中的可达利用入口,不将该判断扩展为基础系统已修复。后续基础映像更新仍须复扫;不得为消除扫描数字而混装其他 Debian 发行版包或删除许可声明。
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
c2792b2c75880ff09822804632b1b3b6dd6190b38841b8c3567bb1b7e7b59a8c .dockerignore
|
||||
fe4b8e57c14d6f4df261de0ec274f33153def558227a47f38c3f91d5cfc77f80 .env.example
|
||||
0c7f4585d62a8d35bfc22e0bebdf7a479a179122b2b092bfe434e3038cddd3d5 .gitattributes
|
||||
b45d35d737f119a70bf40cb1f9626ff80772ed7913cb35f6e9b870d6ada01a15 .github/workflows/community.yml
|
||||
c93afa220096b0a3a27115e74617db9dbe97e98c6a3735a0abfb528cdca7e668 .github/workflows/verify.yml
|
||||
342242379194ca16021542722407a42858f22685293d16162d0e6fe004cbd62c .gitignore
|
||||
4b4ed4926a350cbeb71436541b4566e4ba7d2494ac95236b88827a9f5dd171c2 CONTRIBUTING.md
|
||||
6588694aee24d5e662061be78027a049f6726d50dd99ef00a60fba05051ef299 Dockerfile
|
||||
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
|
||||
ef0d1ecd6427325fc92324032fbf7b880624a68629acbaf430cd8be80c73b698 NOTICE.md
|
||||
26ad0d9063c0ee843f0f532604b8fd66e8b39fd72d129595ed078677c0d6d13c README.md
|
||||
ee00cb267b38485f1e574d610f0a57b51d4ae575731a18e57d06a7c43dc0a8b0 README.zh-CN.md
|
||||
3c0ab38958a3bdb17ee95b5642c2ee691affc3da44252a5c4a3048ebf7860fe4 RELEASE_NOTES.md
|
||||
d1233db9c8da6d391028e1e515813cd45ce8d8f97e48087daa325a61789987bc SECURITY.md
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/debs/.gitkeep
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 config/.gitkeep
|
||||
a897bf4a9f95f3e7b96b286a4bf7d124bb4a50fd47a16c6a3d9fbb70bce47f15 deploy/Dockerfile.runtime-update
|
||||
4cc0b0813f527ac0ca311993f791005de5c8e89e6810ecbf5cf058e58e2fe572 deploy/build-cn.env
|
||||
3683a3cfb63f08cc9e6144162117ab68c607d38bdeaae0e82cb418e812a450f2 deploy/compose.ubuntu-photos-api.yml
|
||||
d68d727e01c592c098fde71a900bcdd234a9ca6610d0799019a6f585983b4494 deploy/devices.yml.example
|
||||
fd53125a5359dcae1d81e95b8447aac755270f9aeb354d3e1140d7fccb57c42e deploy/docker-compose.photos.yml
|
||||
4464545a9d91bbc9fff9cd9941db63f44fa61120caca426687d1ed06635cc3e6 deploy/organized-photos-source.json
|
||||
582dae3db4a08192d714e81e5e8234b170b4ac4231faf251b2c26eeec54f0942 docker-compose.yml
|
||||
30d3e705870ebb012ce122bd7d8f95192d86664c4d2bb45a3169c86988fbaf72 docs/AUTOMATIC_COLLAGE.md
|
||||
6b602b686db5b25060f91cfecf05a840a09ee5b2a6d5099636cb3f47880a4a4d docs/BUILDING.md
|
||||
c14983d5c65775ad80244fcbb77fe5c0c4a413cc27945b7184bbc923a53d38a8 docs/EXTENSIONS.md
|
||||
1d8c5f79eff3115b1cd13d8f63fff1332b80d1fe1bec6b0f2713b5f80b0cbf67 docs/KOKONNA_RECOVERY.md
|
||||
3b4d24d633dea8474a6a7b8868c5afdd37a5205df32356d3967cf838bcafdba9 docs/NAS.md
|
||||
3280bddcbe2d2721bb7efeb0eb2ec03d9d5ef867c69f3e70066b69f1ad5926e2 docs/PHOTO_SOURCES.md
|
||||
edf44436fd044561539c48480c481e66c1a38164f51ba5226cde04c756f7bacb docs/PHOTO_SOURCE_CONNECTIONS.md
|
||||
4b00d08eb5a2425b205af13b87f9be04dd6870a734b42463a3e075658039dac9 docs/PUBLICATION.md
|
||||
5fe4641e75e0746929f71256c6ff11116c777cca8d4288ccc2d7aee91bd7a6ac docs/README.md
|
||||
5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md
|
||||
bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md
|
||||
6772ddfd79b28bb52ce469e40188f8781f66275bdf4b84861f81c32a87aea6eb docs/SYNOLOGY_PHOTOS_API.md
|
||||
ac0481196e593ff9cca2e5133ff41fb3614268e6a10728a0deca3ad3bfb80b34 docs/integrations/TICKTAR.md
|
||||
757a4d87aec789054dc5268d314cd218f6d7cab49082cfacef55f9860d2362b0 docs/openapi.json
|
||||
54bf7061e0626900ce35e19e81efc5c32c8fd6a0438bdad68f494e1ce81616d4 docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md
|
||||
a07cbe71571c6e2d5a1b94897bd63321c74675c05d15438c4964eef39db0fd76 docs/research/KOKONNA_CONTENT_SUBSCRIPTIONS.md
|
||||
ec53d587b855be84283c50f8fb45be62a4c1caba78b28d436a8260fd171943a2 docs/research/KOKONNA_LOCAL_CONTROL.md
|
||||
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__init__.py
|
||||
632e8be140d1c206388b84e6e08039fc42d6449e83ea23b58d7a57c3404a8747 pigway_frame_flow/api/app.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py
|
||||
fbadf6beaf25b9670dcbf125ec9d192188cf9e076ee4d4a0b5decdaf3834417c pigway_frame_flow/core/accounts.py
|
||||
818e8fb29d22c13159a6cb22bcbc6b9e211a8a1301588126eb192ac4ed71441b pigway_frame_flow/core/auth.py
|
||||
8f2220fa90b0dc2e45df2023dc5ac695efe55361644ef266829553c99151949b pigway_frame_flow/core/registry.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/display/__init__.py
|
||||
81effa34d6c452579cfa65781909f7c5a9e8ddad136e4c47ee0704939851879a pigway_frame_flow/display/cloud_library.py
|
||||
558d2e2111e3dea1917eaa4f81d7c2dac3b47af3eda1cdc9426af4bf9bc570dd pigway_frame_flow/display/recovery.py
|
||||
e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py
|
||||
1ee9ee8745dc497dadcc20e5e238c78f6c228f7f8a4ed310ced32f9be55b512e pigway_frame_flow/display/service.py
|
||||
0975b24488be19f24e768c4c76d9f3c0348417c1351c4e410897da00f7be6656 pigway_frame_flow/display/upload_reconciliation.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/drivers/__init__.py
|
||||
9a8386ef368a6df7fd8cb23bcc442c28e784729a5ce4a78911ad86245d5c5794 pigway_frame_flow/drivers/base.py
|
||||
54cff8782e21e2ee4b8540259a4868380c58a0aa656c9d23796a049ab96a1b4f pigway_frame_flow/drivers/generic_http.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/drivers/kokonna/__init__.py
|
||||
32d221f49ad28a309941856a8b1e2ba1806ec90b80e48ff51aefd2b5466cb8b5 pigway_frame_flow/drivers/kokonna/client.py
|
||||
74306c44ef49223d5b2011fb667d4e19a5c8974742076df75d8a6196a96ac7f4 pigway_frame_flow/drivers/kokonna/driver.py
|
||||
58c7daeaea8eb942d9e5d4f4d484e624b99c6c986a530f6af22cf05cf40cceb7 pigway_frame_flow/drivers/kokonna/mock.py
|
||||
1a139e6f5169c23ce8339fe35fb39bb104a8f0a45f8c1222eda9996df459dd5d pigway_frame_flow/drivers/kokonna/widgets.py
|
||||
f548a5a2b01d298eb3543cf6a1ac41177357bc8eb81b5578293347a15310b700 pigway_frame_flow/errors.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/extensions/__init__.py
|
||||
4655fb6cdf9550efd90d33b8de64511d8d6ff5a96d19782fc52d99498fe2a6c0 pigway_frame_flow/extensions/ai_images.py
|
||||
394459895a060f43d0ace27a4be0ea06ba1590df5de026b5661b06d42f2b31f9 pigway_frame_flow/extensions/api.py
|
||||
cf05d8bf20ad1ddb399ba0fc244fe1f36c895133579660f5ab7fc6716f9e66fc pigway_frame_flow/extensions/auto_collage.py
|
||||
46dab9596ba605da4d306ea941b7f19c4f3cde1f714fe1889c9f15bd8c087e42 pigway_frame_flow/extensions/device_events.py
|
||||
d463465ca3aea449e67386bc84a5f25b58535f74d89d7858c5b9bca0d93189d3 pigway_frame_flow/extensions/layout.py
|
||||
13bea510a6b5ae1958b3e095936429516c3e3bee6b06c3676a7eb31f311766e0 pigway_frame_flow/extensions/mqtt.py
|
||||
95da4664d2ceb47ce8f45184f9924d8e61c63bb54b09e1844c55dfe35beb83db pigway_frame_flow/extensions/official.py
|
||||
6d70e410d3559f370e4489aa92b54eea91d11407e8039c9adf31612de0456df1 pigway_frame_flow/extensions/providers.py
|
||||
ab3081bd1afd2a4cd501355f10771cd92f3432439d9bc240d0790c2e76c2a508 pigway_frame_flow/extensions/quality.py
|
||||
8e7d7da457d3b82811dc98cf6c3a93fcd7cbe8a4addb5682f48b1acd4983de82 pigway_frame_flow/extensions/secrets.py
|
||||
49f2d6f7a35675fedaf03ca648f046bde6e49aebb21d6e7afb39f1b8a523fbb2 pigway_frame_flow/extensions/storage.py
|
||||
1f2db3a58e7ebe5a4531f4550356fc9bcb331a2701f0bc25edf0ea8b13a57d97 pigway_frame_flow/extensions/webhooks.py
|
||||
b0855f8d22665fb208dfe47856f374b76a3861c15efb312d4ada81e84fee64e5 pigway_frame_flow/integrations/__init__.py
|
||||
d3025e294466d72c9332a7c577c3262816c6a12d12c7c2f0c241a7e423034eed pigway_frame_flow/integrations/kokonna_official/README.md
|
||||
21536166e5fe9e15bfd4ea4865c85bcbdf79838aedc002e6c3148b1667bc8519 pigway_frame_flow/integrations/kokonna_official/__init__.py
|
||||
a22723f461a9b420d1dc11ea0b794a41bb4621771b52465db21cdc0f199d51e8 pigway_frame_flow/integrations/kokonna_official/capabilities.py
|
||||
c2ac7edcc5112131f093a9a33b31d632186d5ca7517ca6b26407c8099bdcba03 pigway_frame_flow/integrations/kokonna_official/connection.py
|
||||
a518965aa4db9abeb60865c315229c3053c8e9edd26d80f24dc0af0528bad9e6 pigway_frame_flow/integrations/kokonna_official/rpc.py
|
||||
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 pigway_frame_flow/integrations/kokonna_official/widget_catalog.json
|
||||
aea594fb4e4c9a6e2094d4d7973eb627ac02fb632811b2d1a558a0ee1cd12e77 pigway_frame_flow/integrations/kokonna_official/widgets.py
|
||||
1fe53f4bfb407b8db774fe730131ecf1213e16d752a3506c4c56b46365ea14e8 pigway_frame_flow/main.py
|
||||
fb41e20b181815a62e213a1d179f6479b155586fc7000c0a103737ddc16b1c83 pigway_frame_flow/models.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/providers/__init__.py
|
||||
b7a6cb8eb40d8dedfbce18777f8fbd433a174f14bcdadf498bd834132aa568fd pigway_frame_flow/providers/cloud_drives.py
|
||||
58b070f0c814d639a4a912d079479d751d459816d59f2199d0be03e4d573ec3b pigway_frame_flow/providers/external.py
|
||||
e90b6becc3e19baa83fab763cb156eb8882a96594c04040305dcb315f68e85d0 pigway_frame_flow/providers/ical.py
|
||||
69e7419e67e8416bf73f37e257a2586d2a1aa4d2706da8f7b42c5cb8ca7f1eb3 pigway_frame_flow/providers/oauth.py
|
||||
abff8ee4b4eb1575451bcd5addf54451056af665d7aaa90173b5ed69a75a4d8e pigway_frame_flow/providers/photo_names.py
|
||||
7d6fd8ff3ae99798b26d7a82644353e2c9ce5439e18c64393dc261c395ac6f4c pigway_frame_flow/providers/photo_orientation.py
|
||||
322cf299b1811b61636771b925aacfd57aa1cab0b0d84a245b70a2989eda66c0 pigway_frame_flow/providers/photo_sources.py
|
||||
114adfb3fcd1ab050c1dd2f1fff9d91fdde192b8eced907dee3bc8086037e13d pigway_frame_flow/providers/photos_cache.py
|
||||
119427bc435ec8d4c91114713f37d07f22067eaeec391261d1441a1436baf5e0 pigway_frame_flow/providers/poetry.py
|
||||
c44bf2b4a3776bebe3689b251ae3e8c352a43088829ae8c3f6fd379b64dea05e pigway_frame_flow/providers/synology_photos.py
|
||||
7dfc5eab241122c4758d8b0c027369049231451dc8e0e1b49f7da56532a8e87d pigway_frame_flow/providers/weather.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/renderers/__init__.py
|
||||
ce1e3d9dd7d8dc8d76d6206ab9cd05974bd2e81e1bbf28711f4b461332bf9236 pigway_frame_flow/renderers/frame_geometry.py
|
||||
34a22709b300e7e36813d37ce449cebe5a5051bf6711c2c28f59a649b5e0bdc7 pigway_frame_flow/renderers/images.py
|
||||
b70197edc25101b7ee73434b253ae947cddc8a9d3e55545369ac7189e117b2a2 pigway_frame_flow/renderers/photo_caption.py
|
||||
241f7d80997a6b6c7d421723d5375d4890f64eea8c70680abefb0fa5d15b9502 pigway_frame_flow/renderers/photo_layout.py
|
||||
d36258296d04b6575d78dd9831f8d3f66cf8b1e9182e2e90a589d10fa0067a04 pigway_frame_flow/renderers/templates.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/scheduler/__init__.py
|
||||
ee80352e10eb871293bd8700837fab8d5f2e0da777d22e32811e935cf7572edc pigway_frame_flow/scheduler/engine.py
|
||||
d54facc006da1345a150780c414830efaa2e0ab75fa0c9673cf5d39e912472b1 pigway_frame_flow/scheduler/upload_retries.py
|
||||
77464a08816507a2423f08327c7767a9c019f55ea8f232856fa647e72e1e0246 pigway_frame_flow/settings.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/storage/__init__.py
|
||||
36cfc19b9583809f59a2276da3ba0f023a747013b6903d3a11fbb5941931681a pigway_frame_flow/storage/database.py
|
||||
44c0c47d852ffd80cb05969965ee216479c1047daffabf9c3bdc8fe2b036db12 pigway_frame_flow/templates/calendar.json
|
||||
1b01fcda4b1cba11c3b152dfc2223d4119f1879ddd0b7eaf6a2e2731a6f6bf6d pigway_frame_flow/templates/collage.json
|
||||
7bfe623e0569beae25b44261d949679c95d42c8bd095ef6b5cca25cf1b0d4c40 pigway_frame_flow/templates/daily-dashboard.json
|
||||
f9e777980ea5bb3ea8fb2357f23530802811ea2674d25100af9b728cbc9f83a9 pigway_frame_flow/templates/daily-poetry.json
|
||||
990181518d89d8a7c5021e477849eea387906b45fe2e2db3735b85349974d0c8 pigway_frame_flow/templates/photo-text.json
|
||||
2fcc77fce9d50475b822ea27345f350525adae9bce76613f11fc097797eaf7d6 pigway_frame_flow/templates/poster.json
|
||||
8f37296f52a88c11a000a5a0767310354550da34e1d4c0c7260393ec00cd0d9b pigway_frame_flow/templates/quote.json
|
||||
82c17f4e7eaac95483592add181f549186dea9d7c0375fa9cad3554e3ccd1794 pigway_frame_flow/templates/system-alert.json
|
||||
d6f705b9d183b57d50759e283db2161f8493c4e3969459e74da635252014c925 pigway_frame_flow/templates/weather.json
|
||||
c43faa235848bec6370d7a3d8a7b3475cbd8eb4a6e494518dfaf160ca8f77bfa pigway_frame_flow/web/app.js
|
||||
d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js
|
||||
fe19e508ad60fab0ab4ca61f5d966f8340143e89f1572de00e78b3f1fed8384b pigway_frame_flow/web/features.js
|
||||
3c4e3be6321d40547d45724fc3c54af090ebbbc2a41dec717966596577c72c5d pigway_frame_flow/web/i18n.js
|
||||
0ff0e5c80f6cfb3dc71a63813e01fd8cde5355b0f135a4fbb2781d43383a569f pigway_frame_flow/web/index.html
|
||||
0609e796b5ca8346405b1bb6e65ac9f88ee163f7a93024d96d54d4e6ab96ba41 pigway_frame_flow/web/style.css
|
||||
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE
|
||||
050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js
|
||||
1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css
|
||||
a363adb54463d0345cd4e8d0ebe6cac758856e8c0e1d4da49468ddee1ca1aec6 pyproject.toml
|
||||
d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock
|
||||
ae264ad19391bb2515def85963f39458cfa75a8611ff54e5329106c35bd8cc6e scripts/build-verify.sh
|
||||
43e8345ac5f29c2a5d1fa751c0e7c963b00b5391817393541e72c52919f0f2b4 scripts/check-compose-remote.py
|
||||
8186d4d9c2542d42d786404b096fbbdee22e4c74edae78ddebab5bd3f632764c scripts/ci-audit.py
|
||||
b0ec44fba0adfceca3655628021683030469be77c9e303c3f5cf830e8fd0cee3 scripts/ci-compose-verify.py
|
||||
6ebe82488d7eb2f10f69cf8bf4ed4e1ee272a216413fc5be1329f05889ed49a7 scripts/ci-install.py
|
||||
a11e660a0343f2631c1a3dd0aa28471db827fb1f43d1af4217f51209aa3afe32 scripts/create-account.py
|
||||
cdabf30144ea089ae8e64228f6762951f53d4fe8c191673fb5676331d249473d scripts/migrate-photos-api.py
|
||||
80813607835c19d321085fac4cabb17d865d98ed62a09b7f45195ac48bec94a8 scripts/package-nas.py
|
||||
b0b877cfbb2bdf830e2e41097c2057538bb9d0b61a3c39952fe505d910cd5c29 scripts/persistence-check.py
|
||||
3213ef2c17dbcf7423b04440794b842c691dc65616d56c1e1a85dd286ee34ada scripts/production-deploy.py
|
||||
11627ecbd8a3be01aa079bdf6166b4368e670f5ad4a165b5971754b8187bbc2c scripts/run_local.py
|
||||
0fe19e94ffa4e9ff56d4f4a079b19f74991b35f0e18679ac793e8de38ecc3833 scripts/server-release.py
|
||||
1fc16c43485a60a05012322cb1e4a219309dbd7e11f002f510602beea743ce30 scripts/smoke-test.py
|
||||
1236d3b2d27559cbe73a27dcf56248ef3c60404c6fb21453caa7225570d4ac45 scripts/ui-audit-regressions.cjs
|
||||
57a79a534a71442f5e3a70ed9ff96f426007d9f6dd0bb9b1f5d79eac773d2254 scripts/ui-auto-collage.cjs
|
||||
5b61836a89db45cbd22274b9e0b035283d29d4b4926c05e203bd5da85a3bb270 scripts/ui-copy-audit.cjs
|
||||
6b602f617ba845fb64335ececc90ca3fac46073f13555707652f2f080be73b2e scripts/ui-dashboard-preview.cjs
|
||||
67b87d9155b74e2591ab459cc785aa4f2ff6e30c0ad6acef297593261f34ed00 scripts/ui-gallery-bootstrap.cjs
|
||||
f981da8a8b4a043b4d39686feadd0e107e008c4e9e6804c45b92607777d2dbbc scripts/ui-gallery-current.cjs
|
||||
01991b94630c9b4af9f52d304b3b7e7b121c59e32d5765f778b53d238cbc6c06 scripts/ui-gallery-destinations.cjs
|
||||
bc47ba7a196d84a155d9df5f0e2e23e892256979faa435e01b0a63816562fa01 scripts/ui-gallery-stars.cjs
|
||||
02bae6aea8e9b6aef99afc690ad6ddf1fe796501fd83667109cedfe67d5c2586 scripts/ui-history-layout.cjs
|
||||
cb0ccb04e087e2117f2a2e7ebe7f1a6b116e39e2bfc197ea996675ef628c98ae scripts/ui-history-localization.cjs
|
||||
1f87c8b0c6d403c96bf91ca62b6679d1ccdfa3d8cca3f13a006d73f669fe7b09 scripts/ui-image-loading.cjs
|
||||
742197504264a547bcf5f121ed7fae447d8d31f51672d41278b4b984e94b5129 scripts/ui-integrations.cjs
|
||||
66a12251ea36f372072923ee0b8c7bfebac222fe1dbb702b22242a3859512a9a scripts/ui-interval-schedule.cjs
|
||||
70837acf16586f2c97e4e0ba30c1de00484b12113caeedda54ddb226ccaad510 scripts/ui-layout-audit.cjs
|
||||
39969a70bc473ff3f7496490a2096a0de4aaf98e389953b2be4a9bd1fef3f006 scripts/ui-layout.cjs
|
||||
b8b6c94cce58efbcb97dd33fc9ce3f3525be1349679f0cf543a6c61af4bd7e6f scripts/ui-login-layout.cjs
|
||||
8574025d0b22f653c58846bdbb81cc10b4a7a7ca515d8e6b462083f6ca6aa39a scripts/ui-official-connection.cjs
|
||||
98a03f5fb7aeead34a50ad6c17ee4cd5f2cc61eeb6ca7460652835dc31255447 scripts/ui-photo-people.cjs
|
||||
19ec17efbcef3dd162d3698e0b30d55f4182ae44d2058eb94552d2ef44562e04 scripts/ui-photo-retention.cjs
|
||||
f721682b12291d232bf727b1c999a7231fb1f4fc5030303baea04ad3b3bfdf2a scripts/ui-photo-sources.cjs
|
||||
a4ba9a6d5fbfff7aa05d77ce7eab74a6468c1e929a90305a4afa8abbc6c891a0 scripts/ui-photos-api-form.cjs
|
||||
7047f9f49886abe3c2f08b6b2bfaea35bf9682d4e3aaee773e90c5f3885375fc scripts/ui-photos-api.cjs
|
||||
d390c1c9a76ddc167a17bfd7afb0bd1a7169809c8a0b30a26a5be4d18c3032de scripts/ui-progressive-loading.cjs
|
||||
8eee6d087ae3d65208c423d40623052a9bfbf3db4907299b832e81fbe835fb71 scripts/ui-smoke.cjs
|
||||
4d1c13a81d28f681aa1401953636d702cecfaed81274b347876c39e09931bb53 scripts/ui-studio-image-loading.cjs
|
||||
8a1a3ea2b4deb25ac656a0089e085e9837330ce9a386672f302326ac0cb71306 scripts/ui-widgets.cjs
|
||||
e474740bdb7dbe0617f7c6b170ada918e51f5eed8edbb9c2c47c4590a5dc2359 scripts/verify-photos-api-container.py
|
||||
b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify-photos-api.py
|
||||
d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py
|
||||
41f128b9bf09fd160b8e99f3c811b73443b765b019c71306ada3dc18b83a31ca tests/contract.json
|
||||
c44080785bb2cca54bb17ac9e25f2e91a36839688bc70e40faecaea42d75e9f8 tests/schema-contract.json
|
||||
14a7c56c40ea4b520585c20341b3ec70fa3ba52518144a10ba3494ceceb30758 tests/test_accounts.py
|
||||
e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py
|
||||
94c01e6d8bdbfffbff72edb91823eaf39a325ad8782aba1d8e2804402e256aec tests/test_api.py
|
||||
24fca799b0f5e33c800c6310764b18f6b39fc210be769684171388cf14b95746 tests/test_audit_regressions.py
|
||||
7001a2209473e6f6bf1050d33212985f63f4844defeb5df8e75d9f2a96c0d83c tests/test_auto_collage.py
|
||||
f267ebcdb3cf1fcb01ef9fd9ef7c5c8c4daca11d02543f84bfbe56359f35b78a tests/test_ci_audit.py
|
||||
ad0f05ee4b1a9bb384869cc7f95294b23fc2fb1968ceb125ca306a87d9930207 tests/test_ci_compose.py
|
||||
f256706ccd7ded02fd2c1cc4460600080eef4263b13ef986d46484e7d6dc3c03 tests/test_ci_install.py
|
||||
d4469052fc3a6213c5353312e9826655491ff9c1cbd8cd2151e3a1f4ef67b7af tests/test_client.py
|
||||
fc7a3b37e43ae6716c3afeeeddc0631c932175d527bd8adfea6e5e0acb166311 tests/test_content_provenance.py
|
||||
40d0962edd395f57342e88e6da5467bce6cad0661ba11a5aeae51351cee72ab4 tests/test_device_events.py
|
||||
764a1fcb0cc024c69416d170fcc62b3f01ef54c573981be5b9a611b3137bc3bf tests/test_display.py
|
||||
f5d737520934371fa8291727458285ccbc6285d8612f286e424c0f0ea6c34666 tests/test_extensions.py
|
||||
f695398aabf0ef7a5c86a23bef44421a09eed8b24c655987d87cd793df6a8a32 tests/test_external_photo_sources.py
|
||||
2ab265986f89df625b8e9c8e057ffa2e421e1c6e9bb02884b355b1b71cb918f7 tests/test_gallery_thumbnail.py
|
||||
cbc44ca3bd0eab93b39f0aabfe5c9d923105f4556df7fd7b86593c06daddcae5 tests/test_ical.py
|
||||
787bb5ca8a03b8c94496b00e5a21957dea756b9ba1a4daf354b9e27a5f56d7ef tests/test_images.py
|
||||
95892c597f80aed1e5217c81ce187c3eae09a18542093c02c97fd2d950cc76c6 tests/test_interval_schedule.py
|
||||
1f2a28f4f9fc336fe843ced3ac14e1c4442bd6b4dcdb6460cd20451e6cf7fa57 tests/test_local_cloud_library.py
|
||||
15fd0eab1474aae281729b66b703236625d832adc66e8bbfa791febb7d700eb8 tests/test_mqtt.py
|
||||
9f005ec78c2309098cdb10c03d4167aa0e020afcc28af17a23b7afa056ad2d1e tests/test_oauth.py
|
||||
69de025192b4d481d29c38271736293e8b3c4de8bd061f80521548e299cfba10 tests/test_official_connection.py
|
||||
5f308c16e716cd47364b031e62509ad6bc430855f2d2d4d7f46db5f743f4e0aa tests/test_official_module.py
|
||||
239444c6d6fae4cf5212d077cfc064832dde8223222b10d3679e8df2e4b30979 tests/test_photo_canvas.py
|
||||
5e45cfda1fdc9f1580a1082721e64c27cc6b4e28f4e73e0afb7d875c96b46391 tests/test_photo_captions.py
|
||||
cad91937e8429647319cd01fd1925132b7afe057d5420be9586c5fe63f79e498 tests/test_photo_failures.py
|
||||
2bfc64fe11c882dd7de0ad0d9cc5d73a3f5a3394771adc84e7b010243a452a9c tests/test_photo_orientation.py
|
||||
f14bad45534041ff0b7dead21d2952ec9d6c60e9f718ab5393bdf5a47b2a6d3e tests/test_photo_paths.py
|
||||
64117766bf85dbd2a5fac77a55ba24303c1067d5e7d92571385210816dd3e85c tests/test_photo_resolution.py
|
||||
8226d48f02aadcc60b7d951dc60808d559891d64bf7de7f434731dcf0a90cd44 tests/test_photo_sources.py
|
||||
1e5f544dd4abb4e360efa7d7057f9ccafef7c2e15d19d1eb68db059f5bf55a90 tests/test_photo_watermark.py
|
||||
530050aa636b6ddb6c2ed989bf84bbe6c9c5849a1efb3da68c4b8e8e9aa8cc3b tests/test_photos_api_boundary.py
|
||||
9ab51167f18ae5c75efbf8bec2c479557e38a0e7d55cf43665d2e01d4233d816 tests/test_poetry.py
|
||||
ab773ca6868ec007786e7142d5425663a5c8da35b9aa90837ac8e46b51e51507 tests/test_production_bootstrap.py
|
||||
fee152c5f6eaeda712d38cf88a79213f5e885ba323eb15dfa5a12cf4c88028c3 tests/test_production_deploy.py
|
||||
f0601f5ed89e8a14a4584a9683611aa7510a964f50a642348b8ab6c62df72278 tests/test_publication_security.py
|
||||
0ed189d3469097aa7538325d418425e0c7a3ccc8a285bcf4c2ac10658af4b0df tests/test_recovery.py
|
||||
072e8ca2fa68ca73fb078b107ced96ea683652410b286017db883c97608d78f6 tests/test_retention.py
|
||||
ab2cd30163453fe571b55c30cb26dc047a104a536ec01d2c64aa2bc91996ba11 tests/test_scheduler.py
|
||||
5f25f608f13f96b245d6963c02a98add679c6e7d70d398ebb5c3c49b38fbeda8 tests/test_server_release.py
|
||||
a7ae1b4a64bfcbcf2b7ff795b30ba91db24ee2ad7544073dca2bf43d021cd266 tests/test_smoke_dependencies.py
|
||||
2624fb6ca01f32d93499d7d771144dcaa2850e5f77b2ac3b7b566891b5e9d283 tests/test_synology_photos.py
|
||||
ca98469f6a6b8b49c36a00657ea1a81775c3496e1fbdd5e38eb4d5298b5936b4 tests/test_synology_photos_api.py
|
||||
33f05bc6279cf3573493335ae5bb1c91130754b927b52aa5f50e47f2d951d2ae tests/test_upload_reconciliation.py
|
||||
bdc3142f9c2291a91a5091b4b98b1addf9569cfb141810024ce6f85309704b9b tests/test_upload_retries.py
|
||||
8ec55ce5706d791a155f13445fe7e0828278e8ee1fa1b527591fdf24cc5b2e86 tests/test_weather.py
|
||||
e95eaa1f8a7416c5035ae6deae640b8d5defb2db36c0b8dac6d65bcceedc6750 tests/test_widgets.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 vendor/__init__.py
|
||||
4cae8606769fdb590c427bc1716a871017bf22d91aabf8a52513bf126cfb847f vendor/guooscar_kokonna.py
|
||||
63c7a2f1a6adf98e7fb83a83ae2d6a2dddcecd7bce4d7de25dd6f26bd496019f vendor/guooscar_protocol.py
|
||||
e5f5b7a5955f2438da131859a08c9d78cda3718ada8a9365a74cbbb9ac5e7853 vendor/metadata.json
|
||||
e8cf7d54ea46c19aba793983889b7f7425e1ebfcaaccec764a7db091646e203c vendor/paho-mqtt-EDL-1.0.txt
|
||||
57938f47d5f05f7de119b17fcecd4509c5d13644c8d5f43db30e45684d09d07e vendor/swagger-metadata.json
|
||||
@@ -0,0 +1,33 @@
|
||||
# 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
|
||||
COPY LICENSE NOTICE.md /app/
|
||||
# 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.19" org.opencontainers.image.revision=$BUILD_COMMIT
|
||||
@@ -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:-127.0.0.1}:81:18787"
|
||||
env_file: ${FRAME_FLOW_ENV_FILE:?Set the private environment file path}
|
||||
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: ${FRAME_FLOW_APP_DIR:?Set the existing application directory}/data
|
||||
target: /data
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: ${FRAME_FLOW_APP_DIR:?Set the existing application directory}/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"
|
||||
@@ -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}
|
||||
|
||||
+2
-1
@@ -17,12 +17,13 @@ services:
|
||||
init: true
|
||||
user: "10333:10333"
|
||||
ports:
|
||||
- "${WEB_BIND:-127.0.0.1}:${WEB_PORT:-18787}:18787"
|
||||
- "${WEB_BIND:-127.0.0.1}:${WEB_PORT:-6005}:18787"
|
||||
env_file: .env
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
DATA_DIR: /data
|
||||
CONFIG_DIR: /config
|
||||
FRAME_FLOW_STORAGE_NAME: ${FRAME_FLOW_STORAGE_NAME:-NAS}
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./config:/config
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# KoKonna 错误、重试与阻塞处理
|
||||
|
||||
核对日期:2026-10-16。依据:[KoKonna OpenAPI 官方文档](https://kokonna.art/pages/openapi)、[Requests 异常定义](https://requests.readthedocs.io/en/latest/_modules/requests/exceptions/)。本页区分厂商契约与 Frame Flow 的本地策略;没有文档保证的行为不作为自动重发依据。
|
||||
|
||||
## 官方接口契约
|
||||
|
||||
- 设备 API Key 使用 Bearer 鉴权;单设备每分钟上限 20 次,请求超限返回 429。
|
||||
- 上传成功返回新图片 ID 和计数,并立即激活新图;接口不提供幂等键或上传状态查询契约。
|
||||
- 删除可能改变当前图片。切换与删除都不能视为天然可重放。
|
||||
- 404 可表示方法不存在;图片下载时也可表示目标图片不存在。
|
||||
- 500 既可能是通用服务故障,也可能明确表示设备密钥无效、图片不存在或图片 ID 参数错误。实现只匹配官方明确列出的签名,其他 500 不猜测是否产生副作用。
|
||||
- 上传请求体上限 50 MB;本服务原图限制仍为 20 MiB,并保留像素上限和 base64 检查。
|
||||
- 官方文档未承诺服务处理时长、超时后的取消、事务回滚或 Retry-After 返回;不能靠等待固定时间证明写入没有执行。
|
||||
- Requests 将连接建立超时定义为可安全重试;读取超时不提供相同保证。一般连接异常也不能推定请求未发送。
|
||||
|
||||
## 已落实的本地策略
|
||||
|
||||
| 结果 | 处理 | 自动重试 | 设备阻塞 |
|
||||
| --- | --- | --- | --- |
|
||||
| 未配置密钥 | CONFIG_REQUIRED | 无 | 无 |
|
||||
| 明确的设备密钥无效 | CONFIG_REQUIRED,保存固定错误分类 | 无 | 无 |
|
||||
| 明确的图片不存在 | NOT_FOUND | 无 | 无 |
|
||||
| 明确的图片 ID 参数错误 | INVALID_REQUEST | 无 | 无 |
|
||||
| 明确的方法不存在 | UNSUPPORTED_CAPABILITY | 无 | 无 |
|
||||
| 其他 HTTP 4xx 拒绝 | 保存状态码,结束失败 | 除 429 外无 | 无 |
|
||||
| 本地请求预算耗尽 | 等待预算窗口 | 同一请求最多 3 次尝试 | 不创建 UNKNOWN |
|
||||
| HTTP 429 | RATE_LIMITED,尊重服务端冷却时间 | 最多 3 次尝试 | 耗尽后失败,不创建 UNKNOWN |
|
||||
| ConnectTimeout | 确定请求未发送 | 读写均最多 3 次尝试 | 耗尽后失败,不创建 UNKNOWN |
|
||||
| 只读查询的临时网络错误、通用 5xx、无效 JSON | CLOUD_UNAVAILABLE | 最多 3 次尝试 | 不创建 UNKNOWN |
|
||||
| TLS 异常 | 保存固定分类 | 无 | 写请求仍保守标为 UNKNOWN |
|
||||
| 写请求读取超时、一般连接异常、通用 5xx | 无法确定写入结果 | 不重发,只读核对 | UNKNOWN,保留保护 |
|
||||
| 写请求无效响应或无有效上传 ID | 无法确认成功 | 不重发,只读核对 | UNKNOWN,保留保护 |
|
||||
|
||||
“最多 3 次”包含首次请求,重试发生在同一传输请求内,不重新渲染、不重新执行整个操作。普通退避为 1、2 秒;每次实际请求都计入本地 18 次/分钟预算。429 优先使用 Retry-After 秒数或 HTTP 日期;缺失/无效时按 60 秒。单次等待不超过 60 秒,超过此值保留冷却并结束当前尝试,不提前请求。没有无限循环,也不在重启后重放不确定的写入。
|
||||
|
||||
操作错误的 details 保存 attempts、retryable、write_outcome,以及 transport_error / http_status / failure_kind 等固定分类。不会保存异常原文、完整下载 URL、带密钥的厂商消息或响应正文。是否重试由结果类别决定,不能仅依据 HTTP 500。
|
||||
|
||||
## 未知写入的核对与升级
|
||||
|
||||
上传前记录资产、哈希、字节数、名称、上传前图库和时间。响应丢失后只读查找并校验唯一候选;图片相同且设备已同步才能记为成功。已证明上传、但设备持续停留原图时,沿用两次稳定观察的失败关闭规则。
|
||||
|
||||
没有候选、候选不唯一、内容不符、设备未同步、查询失败或缺少历史证据,都有独立恢复原因。图库没有目标图片不等于厂商保证从未接受请求,因此不自动把它改为失败或重传。
|
||||
|
||||
`GET /api/v1/operations/{operation_id}` 的 `recovery` 返回:
|
||||
|
||||
- `state`:checking / manual_review / resolved。
|
||||
- `reason`:gallery_match_missing、gallery_match_ambiguous、content_mismatch、waiting_device_sync、target_mismatch、read_unavailable、reconciliation_error、missing_upload_evidence 或 manual_reconciliation_required。
|
||||
- `first_checked_at`、`last_checked_at`、`checks`、`requires_manual_review`。
|
||||
|
||||
只读核对每条操作至多每分钟一次。操作持续未知超过 sync_timeout + 300 秒(默认约 15 分钟)时,持久化一次 `operation.blocked` 事件和 `MANUAL_REVIEW_REQUIRED` 历史记录;服务重启、重复检查或原因改变均不会重复升级同一条未解决操作。既有检查继续运行,不自动取消设备保护。
|
||||
|
||||
管理员可在 Webhooks 中订阅 `operation.blocked`;未配置订阅不会自动向外发送通知。事件和 API 记录始终保留,但它们不能替代已配置的通知渠道。单个任务失败、单条未知操作的元数据异常均不应中断其他任务处理。
|
||||
|
||||
## 人工处理
|
||||
|
||||
1. 根据 operation_id 查看原始错误、恢复原因、当前设备和图库;不点击重新执行来猜测结果。
|
||||
2. 调用现有 reconcile 接口并传 `acknowledge=false` 进行核对。
|
||||
3. 无法证明成功、但管理员确认可以结束旧操作时,传 `acknowledge=true`:结束为失败,保留原始目标、上传意图和此前错误,记录观察到的图片与人工处理历史。该接口不会重发设备写入。
|
||||
4. 有目标一致且同步完成的证据才记为成功。处理后恢复原计划,错过的任务不补发。
|
||||
|
||||
仍可能因厂商无法给出明确写入结果而需要人工处理;本策略解决错误误判、可安全重试的暂时失败和长期阻塞缺乏解释的问题,不承诺网络故障时所有设备任务都能自动成功。
|
||||
+9
-3
@@ -1,7 +1,9 @@
|
||||
# Synology NAS deployment / 群晖部署
|
||||
|
||||
Target: DS923+, DSM 7.4.1 (verified on target; originally specified 7.3.1), AMD64, Container Manager.
|
||||
Directory: /volume2/docker/pigway-frame-flow. Port: 18787.
|
||||
Directory: /volume2/docker/pigway-frame-flow. Default published port: 6005.
|
||||
The container still listens on 18787; Compose maps host 6005 to container 18787.
|
||||
Existing installations must update their own WEB_PORT and recreate the service to activate the change.
|
||||
|
||||
## 1. Package and upload / 打包上传
|
||||
|
||||
@@ -37,10 +39,14 @@ chmod 600 .env
|
||||
Edit `.env` on the NAS. Set FRAME_FLOW_TOKEN to a strong random token. API key
|
||||
stays empty. KOKONNA_MOCK=true and KOKONNA_WRITE_ENABLED=false.
|
||||
Set WEB_BIND to the actual NAS LAN address (recommended) or explicitly 0.0.0.0;
|
||||
WEB_PORT=18787. The latter binds every NAS interface, so use the LAN IP when
|
||||
WEB_PORT=6005. The latter binds every NAS interface, so use the LAN IP when
|
||||
strict LAN-only binding is required. No firewall/router/proxy edits are performed.
|
||||
Copy deploy/devices.yml.example to config/devices.yml if multiple devices are desired.
|
||||
|
||||
Set `FRAME_FLOW_STORAGE_NAME` to the NAS display name. The gallery tab shows
|
||||
this name followed by “本地图库” (or “Local gallery”). For Actions deployment,
|
||||
set the repository Variable of the same name. It is a display label, not a path.
|
||||
|
||||
## 3. Validate, build, start / 验证构建启动
|
||||
|
||||
Synology Docker is /usr/local/bin/docker. The SSH user may require sudo. Do not
|
||||
@@ -61,7 +67,7 @@ sudo /usr/local/bin/docker image inspect pigway-frame-flow:latest \
|
||||
Do not print unfiltered `docker inspect`: its environment can contain keys/tokens.
|
||||
Likewise do not print `docker compose config` without --quiet on a live installation.
|
||||
Run the read-only smoke checker from a machine with Python and set FRAME_FLOW_URL
|
||||
to http://NAS_LAN_IP:18787; FRAME_FLOW_TOKEN stays in private env/file.
|
||||
to http://NAS_LAN_IP:6005; FRAME_FLOW_TOKEN stays in private env/file.
|
||||
|
||||
Health: GET /health → 200. Readiness: GET /ready → 200 when SQLite, driver config,
|
||||
auth and Scheduler are ready. Missing credentials in live mode → 503. The cloud
|
||||
|
||||
@@ -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`:
|
||||
@@ -30,7 +34,7 @@ change NAS permissions. Unreadable roots return an explicit HTTP 403 rather than
|
||||
an empty catalog.
|
||||
Missing host folders fail explicitly rather than being created automatically.
|
||||
|
||||
Open **Gallery → Photo sources → Add source**, choose **NAS local folder**,
|
||||
Open **Photo sources → Add source**, choose **NAS local folder**,
|
||||
enter a relative folder (`.` for root or `family` for a subfolder).
|
||||
Supported formats: JPEG, PNG, WebP. Optional recursion excludes hidden directories,
|
||||
Synology `@eaDir` thumbnail trees, and symlinks. Original files remain unchanged.
|
||||
@@ -134,7 +138,7 @@ caption still comes from the original curated directory.
|
||||
|
||||
1. 将共享图库(如 `/volume1/photo`)挂载为 `/photos`,保持只读。
|
||||
2. 在 `.env` 配置 `SYNOLOGY_PHOTOS_URL=https://NAS_IP:9115` 和 `SYNOLOGY_PHOTOS_CERT_SHA256`(服务实际 TLS 证书的 SHA-256 指纹,从可信 NAS 配置核实)。重建容器后生效。指纹变化时必须重新核实;每次连接在发送登录信息前校验同一个 TLS socket 的证书。
|
||||
3. 照片数据源 → 编辑数据源 → 连接 / 选择人物。管理员输入 NAS 用户名、密码;只保存 session 到 `/config/synology-photos-session.json`(0600),不保存密码。会话过期后需要重新连接;断开连接删除本地 session。页面没有 password/token 回显。
|
||||
3. 照片数据源 → 顶部共享连接 → 连接 Synology Photos。输入有权限的 NAS 用户名、密码;只保存 session 到 `/config/synology-photos-session.json`(0600),不保存密码。会话过期后需要重新连接;断开连接删除本地 session。页面没有 password/token 回显。登录由所有人物筛选数据源共享;随后进入各数据源编辑器选择人物。
|
||||
4. 选择人物,启用筛选,选择“任意”或“同时包含所有”。默认只列出已命名人物,可搜索 ID、展开未命名分组。至少选择一个人物。
|
||||
5. 与当前本地目录、日期文件夹规则取交集,再随机选图。原文件夹标题、日期、地点仍保留。不匹配返回空图库;预览 / 定时任务失败,不回退到全图库。
|
||||
|
||||
|
||||
@@ -23,3 +23,22 @@ The UI labels the top card **Shared connection**, displays source IDs and paths
|
||||
on each source card, and explains each source's connection dependency. Shared
|
||||
session disconnect is available only in the shared connection card and asks for
|
||||
confirmation of its impact on all people-filtered sources.
|
||||
|
||||
|
||||
## Gallery destination labels / 图库目的地标签
|
||||
|
||||
图库标签描述可保存图片的具体目的地,照片来源连接不自动成为图库目的地。
|
||||
当前为 `KoKonna 云端图库` 与 `<FRAME_FLOW_STORAGE_NAME> 本地图库`;
|
||||
NAS 名称通过部署配置设置,空值回退为 NAS,不使用容器 ID 代替 NAS 名称。
|
||||
英文使用同一实例名称与 cloud gallery / Local gallery。
|
||||
|
||||
`GET /api/v1/settings` 的 `gallery_destinations` 提供目的地 ID、适配器、
|
||||
实例/服务商名称、存储类型、连接状态和保存能力。只有连接有效、明确支持上传且
|
||||
前端存在对应适配器的目的地才进入标签栏。能力默认关闭;全局写入保护仍单独生效,
|
||||
目的地的保存能力不等于当前用户获准写入。
|
||||
|
||||
后续网盘接入须先实现其读取/保存适配器、校验授权及能力,再注册目的地。
|
||||
命名规则为“服务商/实例名称 + 云端图库”,例如已实现保存能力后才可展示
|
||||
“百度网盘·家庭 云端图库”。多个账号/实例必须能从名称区分。
|
||||
目前 Google Drive、阿里云盘、百度网盘和 WebDAV 仅作为只读照片来源,
|
||||
不出现在可保存目的地标签栏,不显示尚未实现的上传入口。
|
||||
|
||||
+23
-10
@@ -4,17 +4,30 @@ Runtime keys, passwords, sessions, private photos, databases and local `.env` fi
|
||||
must remain outside Git. Examples use documentation-only addresses and identities.
|
||||
Personal deployment reports and screenshots are excluded from public history.
|
||||
|
||||
The NAS Docker runner accepts trusted repository events only. External fork PRs
|
||||
The Gitea NAS Docker runner accepts trusted repository events only. GitHub mirrors
|
||||
use the separate community workflow on GitHub-hosted disposable runners, with
|
||||
read-only repository permissions and no deployment configuration. External fork PRs
|
||||
are not executed on this runner; maintainers must review them before copying changes
|
||||
to a trusted branch. All lint, type, test, audit and isolated Docker checks remain.
|
||||
|
||||
Private deployment configuration is stored in Gitea Actions Secrets:
|
||||
`PROD_DATA_DIR`, `PROD_CONFIG_DIR`, `PROD_PHOTOS_DIR`, `PROD_WEB_BIND`,
|
||||
`SYNOLOGY_PHOTOS_URL`, `SYNOLOGY_PHOTOS_CERT_SHA256`, and `PRODUCTION_ENV`.
|
||||
Project/image names, ports, enable flags and mirror settings remain Variables.
|
||||
Never print complete Docker inspect environment or secret values.
|
||||
Public CI verifies source only. Runtime configuration belongs in private environment
|
||||
files or the deployment system's secret store. Never print complete Docker inspect
|
||||
environment or secret values.
|
||||
|
||||
Publishing requires scanning all refs, removing private historic files, pruning
|
||||
unreachable remote Git objects and archiving/removing historical Actions logs.
|
||||
A private original mirror is retained outside the repository. Existing clones must
|
||||
reclone after the privacy history rewrite; do not merge the original history back.
|
||||
Before publishing, scan the current source and distribution files for secrets and
|
||||
private receipts. Keep Git history and existing remote releases. If a real historic
|
||||
secret is found, revoke it and handle history remediation as a separate operation.
|
||||
|
||||
## Private deployment inputs
|
||||
|
||||
Do not publish host addresses, workstation paths, disk UUIDs, photo-library counts,
|
||||
or deployment receipts. Do not include deployment receipts or recovery bundles in published archives. Local
|
||||
obsolete artifacts may be removed after verification; active runtime configuration,
|
||||
databases and images must be preserved.
|
||||
|
||||
`server-release.py` reads `FRAME_FLOW_ROOT` (default `/srv/frameflow`), requires
|
||||
`FRAME_FLOW_MOUNT_UUID` and `FRAME_FLOW_ORIGIN`, and retains the mount-identity
|
||||
check before release actions. Supply verified values privately to the sudo process;
|
||||
existing installations must set their existing root explicitly. Do not use the
|
||||
generic default to migrate a deployment. The script still requires the existing
|
||||
mount guard and does not change host mounts, services, or storage automatically.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# 文档目录
|
||||
|
||||
安装、连接及日常使用从[中文首页](../README.zh-CN.md)或[英文首页](../README.md)开始。本目录收录仍适用的功能、配置与开发说明;版本变化见[版本记录](../RELEASE_NOTES.md)。
|
||||
|
||||
- [自动拼图](AUTOMATIC_COLLAGE.md)
|
||||
- [构建、缓存与 CI](BUILDING.md)
|
||||
- [可选连接与扩展](EXTENSIONS.md)
|
||||
- [厂商请求失败与未知写入处理](KOKONNA_RECOVERY.md)
|
||||
- [Docker 与群晖安装](NAS.md)
|
||||
- [照片来源配置](PHOTO_SOURCES.md)
|
||||
- [照片来源与共享连接](PHOTO_SOURCE_CONNECTIONS.md)
|
||||
- [公开源码与隐私边界](PUBLICATION.md)
|
||||
- [真实画框测试步骤](REAL_DEVICE_TEST.md)
|
||||
- [厂商接口研究概览](RESEARCH.md)
|
||||
- [Synology Photos API 与来源迁移](SYNOLOGY_PHOTOS_API.md)
|
||||
- [Ticktar 外部集成契约](integrations/TICKTAR.md)
|
||||
- [厂商账号能力研究](research/KOKONNA_ACCOUNT_CAPABILITIES.md)
|
||||
- [厂商内容订阅研究](research/KOKONNA_CONTENT_SUBSCRIPTIONS.md)
|
||||
- [厂商本地控制研究](research/KOKONNA_LOCAL_CONTROL.md)
|
||||
- [开发与贡献](../CONTRIBUTING.md)
|
||||
- [安全问题反馈](../SECURITY.md)
|
||||
- [第三方声明](../NOTICE.md)
|
||||
@@ -9,7 +9,7 @@
|
||||
duplicates, name/date/dimensions anomalies. No download-all action.
|
||||
3. Stop on discrepancy. Missing model fields must be reported as unavailable.
|
||||
4. Once explicitly authorized, locally render **one** 1200×1600 test image containing
|
||||
PIGWAY FRAME FLOW TEST and Shanghai timestamp, no private content.
|
||||
PIGWay Frame Flow TEST and Shanghai timestamp, no private content.
|
||||
5. Record an operation ID and previous image; one upload only, no write retries.
|
||||
6. Poll device read-only no more often than 60 seconds, respecting the 20/min budget.
|
||||
Confirm imageId and synced. If sent but response lost, mark UNKNOWN, block writes,
|
||||
@@ -41,3 +41,7 @@ minute apart record `DISPLAY_NOT_CONFIRMED` and unblock future scheduled work.
|
||||
Temporary overrides retain their restore behavior and are not automatically
|
||||
closed as failed. Legacy operations without durable upload evidence require
|
||||
manual acknowledgement; absence from the gallery alone never proves failure.
|
||||
|
||||
### 错误与有限重试
|
||||
|
||||
完整错误矩阵、重试边界、持久化恢复状态和人工处理见 [KoKonna 恢复策略](KOKONNA_RECOVERY.md)。自动重试只允许只读请求、连接建立超时或明确限流拒绝;读取超时等未知写入不会重发。
|
||||
|
||||
+3
-2
@@ -1,6 +1,7 @@
|
||||
# KoKonna API research / 接口调查
|
||||
|
||||
Checked 2026-10-01 against https://kokonna.art/pages/openapi.
|
||||
Rechecked 2026-10-16 against https://kokonna.art/pages/openapi.
|
||||
错误分类、重试和未知写入处理见 [KoKonna 恢复策略](KOKONNA_RECOVERY.md)。
|
||||
Base: https://api.kokonna.art/openapi. No fallback to legacy galaxyguide.cn.
|
||||
|
||||
| Official endpoint | Frame Flow capability |
|
||||
@@ -29,7 +30,7 @@ headroom at 18. Device state has a 60-second cache, gallery is loaded on demand.
|
||||
Uploads automatically activate a new image; there is no upload-without-activation
|
||||
flag. Gallery-only uploads are therefore local staging, not a fictitious cloud
|
||||
operation. Maximum upstream request size is 50 MB; this service accepts 20 MiB
|
||||
image files with bounded decoded pixels. No non-idempotent cloud write is retried.
|
||||
image files with bounded decoded pixels. Only proven-unsent connections or explicit rate-limit rejections may retry a write, within a three-attempt budget. Ambiguous writes are never replayed.
|
||||
Cloud acknowledgement is not physical refresh confirmation; /device synced and
|
||||
imageId drive the operation state. Unconfirmed or interrupted writes become UNKNOWN
|
||||
and block later device writes until read-only reconciliation/explicit acknowledgement.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# 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` 来源已停用;旧配置需显式转换为 Photos 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 候选配置
|
||||
|
||||
在正式 Git 仓库开发;不要把迁移临时解包目录作为开发仓库。
|
||||
|
||||
候选文件:`deploy/compose.ubuntu-photos-api.yml`。内部端口 `18787`,UID/GID `10333:10333`。通过私有环境变量设置 `FRAME_FLOW_APP_DIR`(已有应用目录)、`FRAME_FLOW_ENV_FILE`(已有私有配置文件)和可选 `FRAME_FLOW_BIND`(默认回环地址),不在仓库保存真实主机地址或部署目录。
|
||||
|
||||
无 `/photos` 绑定。`create_host_path: false` 防止在 T7 未挂载时悄悄创建目录;但仍需在启动前检查 `findmnt -T "$FRAME_FLOW_APP_DIR"` 的实际源设备。
|
||||
|
||||
`.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,因此这条新取图链路不需要为旧客户端保留 DSM `photo` NFS 授权。但目前旧来源和待迁移配置没有全部切换验收,不能据此宣称迁移完成或旧本地来源已可脱离挂载。确认没有其他已批准用途后,可以单独授权删除该特定 NFS 规则;本次没有删除。
|
||||
|
||||
实际部署回执和照片库统计应私下保管,不提交公共仓库。
|
||||
@@ -1,36 +0,0 @@
|
||||
# UI layout audit
|
||||
|
||||
The Mock-only `scripts/ui-layout-audit.cjs` checks all 11 navigation pages,
|
||||
local/cloud gallery layouts and six modal states plus expanded manual collage at 1440, 768 and 390 CSS pixels,
|
||||
in Chinese/English and light/dark themes. It checks page horizontal overflow and
|
||||
at least 8 pixels between visible input/select/textarea/button rectangles, and
|
||||
44-pixel control heights on mobile.
|
||||
Closed details, background content behind dialogs and hidden fields are excluded.
|
||||
Photos authentication is intercepted with fixture credentials; no NAS login or
|
||||
frame write occurs. Populated task/history fixtures remain in disposable Mock data.
|
||||
|
||||
This audit found and corrected missing upload preview spacing, nested task field
|
||||
spacing, small mobile action gaps and inconsistent dynamically inserted form
|
||||
spacing. NAS source forms now hide irrelevant cloud credential fields. Native
|
||||
drive forms expose their credential field and hide local-only quality controls.
|
||||
|
||||
Synology Photos has a dedicated connection card on the Photo Sources page, with
|
||||
status, an explanation of affected automation and a direct NAS sign-in dialog.
|
||||
Folder settings and person selection remain separate. Credentials are not saved;
|
||||
the existing protected session API is used. Successful login does not run a job
|
||||
or change frame content. Passwords are cleared from the dialog before submission.
|
||||
|
||||
Run against an isolated Mock server with the existing Playwright runtime:
|
||||
|
||||
```sh
|
||||
FRAME_FLOW_URL=http://127.0.0.1:18789 \
|
||||
FRAME_FLOW_ENV_FILE=/path/to/mock.env \
|
||||
FRAME_FLOW_TEST_USERNAME=test-ui \
|
||||
FRAME_FLOW_TEST_PASSWORD=test-only-password \
|
||||
node scripts/ui-layout-audit.cjs
|
||||
```
|
||||
|
||||
Set `NODE_PATH` to the installed Playwright runtime if needed. Optional
|
||||
`FRAME_FLOW_SCREENSHOT_DIR` saves fixture-only screenshots for visual review.
|
||||
The full audit passed 229 checks with no console errors. This complements
|
||||
`ui-smoke.cjs` and `ui-integrations.cjs`; it does not replace functional tests.
|
||||
@@ -1,83 +0,0 @@
|
||||
# Frame Flow 项目审计 — 2026-10-05
|
||||
|
||||
审计基线:794a7e1485cc9046be3e77f6557c75740305ac47。
|
||||
范围:本地源码、配置、Mock 测试与依赖审计;不包含本次 NAS 状态验证、真实账号登录或真实设备写入。未修改业务代码。
|
||||
|
||||
## 发现
|
||||
|
||||
### P1:队列扫描上限导致后续高优先级告警饥饿
|
||||
|
||||
`display/service.py:222` 只取按创建时间排序的前 50 个 QUEUED 操作,再跳过
|
||||
被 override 阻挡的低优先级请求。如果这 50 项全被阻挡,第 51 项更高优先级
|
||||
告警每一轮都无法被扫描到;其他设备请求与本地渲染也可能受影响。
|
||||
|
||||
Mock 复现:激活 priority=50 的一小时 override,排入 50 个 priority=10
|
||||
请求,再排 priority=90 请求,调用 process_one 后后者仍为 QUEUED。
|
||||
建议先筛选可执行请求,或分页扫描直到找到可执行操作;增加跨设备与优先级回归测试。
|
||||
|
||||
### P2:会话失效未更新连接状态,并误清第一方登录
|
||||
|
||||
`integrations/kokonna_official/rpc.py:44-51` 在官方返回“请重新登陆”时抛出
|
||||
UNAUTHORIZED,但没有使官方 session 失效。`connection.py:104-113` 只依据
|
||||
本地 token 非空,继续显示 authorized=true。`web/app.js:17` 将任意 API 401
|
||||
都当作 Frame Flow 登录失效并执行 forgetLogin,包括外部官方会话过期。
|
||||
|
||||
Mock 复现:RPC 返回 HTTP 500 / error -1 / 请重新登陆后,authorized 仍 true。
|
||||
建议区分第一方认证与外部账号过期,持久化失效状态,保留 Frame Flow 登录,
|
||||
在官方区域提供重新连接提示。
|
||||
|
||||
### P2:损坏的可选官方会话文件阻止整个服务启动
|
||||
|
||||
`connection.py:44-51` 在构造函数中直接解析 JSON 并索引字段,没有异常隔离。
|
||||
DeviceRegistry 在应用启动时构建该对象。损坏 JSON、缺字段或文件不可读会
|
||||
使可选功能故障升级为全服务启动失败,影响 API Key、UI 和 Scheduler。
|
||||
|
||||
Mock 复现:官方 secret 文件内容为 `{broken`,create_app 抛 JSONDecodeError。
|
||||
建议隔离并禁用该连接,记录不含文件内容的诊断;其他核心功能继续启动。
|
||||
|
||||
### P2:持久化失败导致断开状态与重启状态不同
|
||||
|
||||
`connection.py:122-127,146-157` 先修改内存,再保存。磁盘满、权限错误等
|
||||
导致保存失败时没有回滚。尤其 disconnect 已清内存,而磁盘仍保留旧 token,
|
||||
重启后又恢复旧连接;虽然原请求返回错误,随后状态查询却会显示未连接。
|
||||
|
||||
Mock 复现:让 _save 抛 OSError;当前 authorized=false,重新构造连接后 true。
|
||||
建议先原子持久化候选状态,再提交内存状态;失败时保持一致并提供明确错误。
|
||||
|
||||
### P2:Request ID 脱敏遗漏官方会话
|
||||
|
||||
`api/app.py:148-150` 仅检查 API Key、Frame Flow Token、registry.keys,
|
||||
没有检查用于官方会话的 settings.additional_secrets。符合 Request ID 长度和
|
||||
字符限制的会话值若被客户端误用为 X-Request-ID,可被响应头和错误模型原样回显。
|
||||
本问题不表示未授权者能凭空取到 token,而是违反已有 secret 不回显约束。
|
||||
|
||||
Mock 复现:完成模拟官方连接后,将 synthetic session `mock-session` 作为
|
||||
X-Request-ID 发给 /health,响应原样返回该值。建议统一使用完整 secret 集合。
|
||||
|
||||
## 验证
|
||||
|
||||
- pytest:154 passed,0 failed,0 warning。
|
||||
- Ruff:通过(应用和 tests)。
|
||||
- mypy:36 个源码文件通过。
|
||||
- pip-audit(requirements.lock):No known vulnerabilities found。
|
||||
- 五项专项 Mock 复现均确认上述行为,不调用真实 KoKonna。
|
||||
- 没有因发现问题而启用官方写方法;严格 RPC allowlist 仍阻止未启用方法。
|
||||
|
||||
## 边界与下一步
|
||||
|
||||
测试全绿不代表无缺陷:现有套件没有覆盖以上失败场景。
|
||||
本轮没有修复或部署;建议先修复队列饥饿,再统一外部账号状态与第一方认证,
|
||||
然后补齐会话文件容错、持久化事务一致性和 Request ID 脱敏测试。
|
||||
依赖审计仅表示本次查询未发现已知漏洞,不是全部依赖安全的证明。
|
||||
|
||||
## 修复与复验
|
||||
|
||||
上述五项已修复:队列使用 keyset 分页查找可执行操作;官方账号失效使用
|
||||
独立 `OFFICIAL_AUTH_REQUIRED` 错误码并清除会话,前端保留第一方登录且提供
|
||||
重新连接入口;可选 secret 解析失败不再阻止核心启动;持久化候选状态成功后
|
||||
才提交内存变更;Request ID 检查涵盖官方会话凭据。
|
||||
|
||||
复验:164 passed、0 warning(新增 10 项回归测试);Ruff 通过;mypy 36 文件
|
||||
通过;113 项布局检查通过;官方过期/第一方登录隔离浏览器检查通过。
|
||||
已知本地凭据对照历史扫描通过(278 个 blob);该扫描不等同于发现所有未知
|
||||
secret 的通用审计。本轮不部署 NAS,不执行真实设备写接口。
|
||||
@@ -1,380 +0,0 @@
|
||||
# Gitea Actions production CD v1
|
||||
|
||||
## Audit and adoption boundary
|
||||
|
||||
Production `docker-compose.yml` is unchanged: AMD64 image, UID/GID 10333,
|
||||
`unless-stopped`, read-only rootfs, `/tmp` tmpfs, dropped capabilities, limited
|
||||
logs, `/data` and `/config` bind mounts, default private bridge, and configurable
|
||||
host bind/port. SQLite/images/generated/cache live under `/data`; accounts,
|
||||
vendor sessions and configuration live under `/config`. Neither is disposable.
|
||||
The optional photos mount is read-only and has supplementary group access.
|
||||
|
||||
Historical repository receipts mention `/volume1/docker/pigway-frame-flow/data`,
|
||||
`/volume1/docker/pigway-frame-flow/config`, `/volume1/photo`, port 18787, and
|
||||
container/project `pigway-frame-flow`. These are **confirmation candidates**, not
|
||||
script defaults. The deploy action accepts only explicitly configured paths
|
||||
that match the existing running container on the Runner's Docker daemon.
|
||||
It rejects different projects, ports, unknown mounts, host/custom networking,
|
||||
missing photos confirmation, or different user/restart policy. The deploy action does not create NAS host directories or migrate/adopt an unrelated
|
||||
container. First-time directory creation is limited to the manual bootstrap below.
|
||||
|
||||
CI uses `ffci-*` projects, per-run image tags and named volumes, no host ports.
|
||||
Its cleanup does not refer to any production resource. Production uses fixed
|
||||
configured names and existing bind mounts, with `create_host_path: false`.
|
||||
|
||||
## Gitea configuration
|
||||
|
||||
Repository Settings → Actions configuration (private paths/addresses use Secrets):
|
||||
|
||||
| Variable or Secret | Value to explicitly confirm |
|
||||
| --- | --- |
|
||||
| `PROD_DEPLOY_ENABLED` | Initially `false`; change to `true` only after plan acceptance |
|
||||
| `PROD_PROJECT` | Existing Compose project label; candidate `pigway-frame-flow` |
|
||||
| `PROD_CONTAINER` | Existing container name; candidate `pigway-frame-flow` |
|
||||
| `PROD_IMAGE_REPO` | Fixed local image repository; suggested `pigway-frame-flow` |
|
||||
| `PROD_DATA_DIR` (Secret) | Existing absolute NAS host data path; no default |
|
||||
| `PROD_CONFIG_DIR` (Secret) | Existing absolute NAS host config path; no default |
|
||||
| `PROD_PHOTOS_DIR` (Secret) | Existing read-only photos source if present; otherwise empty |
|
||||
| `PROD_WEB_BIND` (Secret) | Exact existing Docker HostIp (`0.0.0.0` or NAS LAN IP etc.) |
|
||||
| `PROD_WEB_PORT` | Exact existing mapped port |
|
||||
| `PROD_STATE_VOLUME` | Dedicated new deployment journal volume, e.g. `pigway-frame-flow-cd-state` |
|
||||
| `PROD_MOCK` | Explicit `true`/`false`, consistent with intended production mode |
|
||||
| `PROD_WRITE_ENABLED` | Explicit `true`/`false`; preserve owner's existing write policy |
|
||||
|
||||
The state volume must be separate from application/CI storage. An existing
|
||||
volume without the matching `frameflow.deploy.project` label is rejected.
|
||||
Supplementary groups are read from the existing container; no host ACL changes.
|
||||
Keep existing `DEBIAN_MIRROR`, `DEBIAN_SECURITY_MIRROR`, `CI_PIP_INDEX_URL`, and
|
||||
`CI_PIP_FALLBACK_INDEX_URL` settings from Verify; CI/production builds share the
|
||||
Dockerfile, pinned base, lockfile, and BuildKit caches.
|
||||
|
||||
Repository Settings → Actions → Secrets:
|
||||
|
||||
`PRODUCTION_ENV`: multiline **unquoted** `KEY=value` entries, without shell
|
||||
expansion. At least:
|
||||
|
||||
```dotenv
|
||||
FRAME_FLOW_TOKEN=<existing Frame Flow administrator token>
|
||||
KOKONNA_API_KEY=<existing KoKonna key, or empty when mock>
|
||||
```
|
||||
|
||||
Optional additional runtime secret variables can be included. No key values are
|
||||
committed, passed to image builds, or printed. Nonsecret mock/write flags use
|
||||
Variables. Other existing runtime environment values are retained. The script
|
||||
sets commit/build time and internal data/config paths explicitly. Dollar signs
|
||||
in secret values are escaped before Compose interpolation. Snapshots containing
|
||||
old runtime secrets are stored only in a private journal file (0600, directory
|
||||
0700) on the dedicated Docker volume; Docker administrators can access it.
|
||||
Never upload that journal or a full Docker inspect as a public CI artifact.
|
||||
|
||||
## First safe trial
|
||||
|
||||
1. On NAS, inspect only the necessary fields (use existing Docker/sudo access):
|
||||
```sh
|
||||
docker inspect pigway-frame-flow --format '{{index .Config.Labels "com.docker.compose.project"}}'
|
||||
docker inspect pigway-frame-flow --format '{{json .Mounts}}'
|
||||
docker inspect pigway-frame-flow --format '{{json .HostConfig.PortBindings}} {{.HostConfig.NetworkMode}}'
|
||||
```
|
||||
Confirm the Variables/Secrets against these actual values, including photos.
|
||||
2. Configure the Secret privately. Keep `PROD_DEPLOY_ENABLED=false`.
|
||||
3. Manually run **Verify Frame Flow** on `main`, `deploy_action=plan`.
|
||||
Verify must pass first. Plan checks resource identity, paths/port, required
|
||||
Secret keys, current `/ready`, smoke endpoints and commit. It performs no
|
||||
build, stop, restart, tagging, lock creation, volume creation, or data writes.
|
||||
If no production Variables exist, it reports configuration missing and does
|
||||
nothing; that is an unconfigured plan, not production acceptance.
|
||||
4. Review `PLAN ONLY` output and arrange a maintenance window for the first real
|
||||
deploy/restart. Set `PROD_DEPLOY_ENABLED=true`, manually run `deploy_action=deploy`.
|
||||
5. After `DEPLOY VERIFIED`, subsequent pushes to `main` run Verify → deploy.
|
||||
Pushes to other branches and pull requests never deploy. A manual run defaults
|
||||
to plan, even when automatic deployment is enabled.
|
||||
|
||||
The deploy action supports updates to an existing healthy deployment. For first-time
|
||||
creation use the explicit manual bootstrap flow below. No real production deployment or rollback drill has
|
||||
been performed during implementation; those are first-run acceptance steps.
|
||||
|
||||
## Deployment transaction and rollback
|
||||
|
||||
1. Verify job must succeed completely, including isolated Docker tests/cleanup.
|
||||
2. Deploy job checks out the same revision and prepares tools. An atomic
|
||||
`docker create` reservation named `<PROD_CONTAINER>-deploy-lock` serializes
|
||||
production changes on the daemon. Job `concurrency` is also declared, but
|
||||
correctness does not depend on Gitea implementing it. A competing deploy
|
||||
fails safely instead of changing production. No other job may remove a lock
|
||||
whose owner label differs from its own.
|
||||
3. Re-read the current baseline under the lock; confirm ready/smoke. Record exact
|
||||
image ID, runtime Compose/environment, version/commit. Pin the old image with
|
||||
an immutable rollback tag so normal dangling-image cleanup cannot remove it.
|
||||
4. Refuse a stale checked-out `main` revision that no longer equals remote main.
|
||||
Build an immutable `release-<commit>-<owner>` tag locally, without a registry
|
||||
then point `latest` at the candidate; CI image tags are never changed.
|
||||
5. Persist a `prepared` journal with old/new runtime configs. Create an additive
|
||||
SQLite online backup under `/data/deploy-backups/<owner>.db`. No data/config
|
||||
directory or file is removed, replaced or recursively chowned.
|
||||
6. `compose up -d --no-build --pull never` replaces only the configured service.
|
||||
Check `/ready`, the unchanged nine-endpoint read-only smoke test, and exact
|
||||
`/api/v1/version` commit. Only all checks passing produce `DEPLOY VERIFIED`.
|
||||
7. Any activation/verification failure runs `up` again with the saved exact old
|
||||
image ID and old runtime environment/mounts, then repeats ready/smoke/commit
|
||||
verification. A failed deploy remains a failed workflow even if rollback
|
||||
succeeds. If rollback fails, the journal/lock are retained, failure is explicit,
|
||||
and redacted scoped diagnostics are emitted.
|
||||
8. An `always()` step uses the recorded owner to recover an interrupted prepared
|
||||
transaction and release only its own lock. Abrupt host loss or termination of
|
||||
the entire runner can prevent both handlers: inspect the journal/lock before
|
||||
manually recovering; never automatically force-unlock an unknown owner.
|
||||
|
||||
Rollback is an **image/config rollback**, not an automatic database rewind.
|
||||
User data accumulated during deployment remains. The SQLite backup is for
|
||||
operator-reviewed recovery; destructive or backward-incompatible future schema
|
||||
changes must not be auto-deployed without a separate migration/backup plan.
|
||||
Baseline artifacts and additive backups are retained for the operator; there is
|
||||
no automated pruning of production rollback history or data.
|
||||
|
||||
After enabling CD, use its workflow/script as the production lifecycle owner.
|
||||
The old NAS checkout's Compose/.env files are not rewritten remotely by CD and
|
||||
may describe an older deployment. Do not concurrently recreate production from
|
||||
that old checkout. The repository's standalone Compose remains usable for an
|
||||
independent deployment; the private journal records the CD runtime configuration.
|
||||
|
||||
## Actual commit and manual rollback
|
||||
|
||||
```sh
|
||||
curl -fsS http://NAS_LAN_IP:PORT/api/v1/version
|
||||
# Check `commit`, `version`, and `build_time` against successful deployment output.
|
||||
docker inspect PRODUCTION_CONTAINER --format '{{.Image}}'
|
||||
```
|
||||
|
||||
For a manual rollback: keep CD enabled and run **Verify Frame Flow** on `main`
|
||||
with `deploy_action=rollback`. After Verify passes, the script locks production,
|
||||
loads the last journal's `previous` image/config and verifies recovery. This is
|
||||
last-baseline rollback, not an arbitrary image selector. Never use `down -v`,
|
||||
volume pruning, or delete data/config to roll back.
|
||||
|
||||
If a failed run retained a lock, inspect only its owner label:
|
||||
|
||||
```sh
|
||||
docker inspect PRODUCTION_CONTAINER-deploy-lock --format '{{index .Config.Labels "frameflow.deploy.owner"}}'
|
||||
```
|
||||
|
||||
With the documented Variables configured in a trusted Docker-enabled shell:
|
||||
|
||||
```sh
|
||||
FRAME_FLOW_DEPLOY_OWNER=<confirmed failed owner> CD_ACTION=recover python scripts/production-deploy.py
|
||||
```
|
||||
|
||||
`recover` retries that owner's journaled rollback and retains the lock again if
|
||||
recovery fails. If the very first attempt stopped before a journal was written,
|
||||
confirm that the original image is still running and healthy before an operator
|
||||
removes the stale lock. Do not delete a live lock or any persistence volume.
|
||||
|
||||
## First-time production bootstrap (explicit manual action)
|
||||
|
||||
The confirmed NAS residue is a `created`, never-started container with dead
|
||||
`/workspace/way/...` bind paths. It is not an adoption/rollback baseline.
|
||||
No migration from those paths is performed. Production Compose remains independently usable.
|
||||
|
||||
Set the ordinary repository Actions Variables below; set paths and addresses as Secrets (see docs/PUBLICATION.md):
|
||||
|
||||
```text
|
||||
PROD_DEPLOY_ENABLED=true
|
||||
PROD_PROJECT=pigway-frame-flow
|
||||
PROD_CONTAINER=pigway-frame-flow
|
||||
PROD_IMAGE_REPO=pigway-frame-flow
|
||||
PROD_DATA_DIR=/volume1/docker/pigway-frame-flow/data
|
||||
PROD_CONFIG_DIR=/volume1/docker/pigway-frame-flow/config
|
||||
PROD_WEB_BIND=192.0.2.10
|
||||
PROD_WEB_PORT=18787
|
||||
PROD_STATE_VOLUME=pigway-frame-flow-cd-state
|
||||
PROD_MOCK=true
|
||||
PROD_WRITE_ENABLED=false
|
||||
```
|
||||
|
||||
Leave `PROD_PHOTOS_DIR` unset/empty. Existing mirror Variables still apply.
|
||||
Enabling CD does not enable real frame writes. Before the first successful bootstrap,
|
||||
ordinary main pushes report `production not bootstrapped / bootstrap required`.
|
||||
They cannot create production. `bootstrap` additionally requires the Actions event
|
||||
`workflow_dispatch`; the default action remains `plan`. Every action is gated by
|
||||
successful complete Verify on main.
|
||||
|
||||
Create repository Actions **Secret** `PRODUCTION_ENV` as multiline bare `KEY=value`:
|
||||
|
||||
```dotenv
|
||||
FRAME_FLOW_TOKEN=replace-with-a-long-random-private-token
|
||||
KOKONNA_API_KEY=
|
||||
```
|
||||
|
||||
Do not add shell quotes, `export`, interpolation or spaces around `=`. An existing
|
||||
KoKonna key may be supplied but first bootstrap forcibly uses Mock with writes off.
|
||||
Optional application secrets/settings may be added in the same format; bootstrap
|
||||
always overrides Mock/write flags, data/config paths, timezone and build identity.
|
||||
This token remains useful for API/smoke checks even if Web UI uses account login.
|
||||
Do not paste the real Secret into logs or repository files.
|
||||
|
||||
### Safety and cleanup
|
||||
|
||||
Bootstrap builds the verified main revision first, then acquires the same atomic
|
||||
Docker daemon deployment lock used by deploy. Host path checks run in a short-lived,
|
||||
network-disabled helper with **read-only** NAS root mounted, not CI workspace paths.
|
||||
Synology does not automatically create missing bind sources with `-v`. A temporary
|
||||
network-disabled helper mounts the explicitly configured existing parent directories
|
||||
and creates only their data/config leaves, owned `10333:10333`, mode `0750`. It never
|
||||
mounts NAS root writable, recursively changes parent permissions, or deletes contents.
|
||||
Parents must already exist; missing parents fail closed. Nonempty directories, symlinks
|
||||
(including ancestor symlinks), overlapping paths and unknown journal resources fail closed.
|
||||
|
||||
A same-name residue can be removed **without force** only after checking all of:
|
||||
Compose project/service labels; `Status=created`; `Running=false`;
|
||||
`StartedAt=0001-01-01T00:00:00Z`; exactly two bind mounts `/data` and `/config`;
|
||||
and both old host sources absent. A running, previously started, unknown or data-bearing
|
||||
container is refused. An existing default network must be owned by this Compose
|
||||
project and contain no endpoints other than the validated residue.
|
||||
|
||||
The production service uses `create_host_path=false`, the explicit NAS LAN bind,
|
||||
non-root UID, `unless-stopped`, normal bridge network and existing resource/security
|
||||
limits. Acceptance runs ready, all read-only smoke endpoints, version/commit,
|
||||
preflight mount/port/project/user/restart checks, one restart, repeated checks,
|
||||
Docker health and exact built-image ID before `BOOTSTRAP VERIFIED`.
|
||||
|
||||
On failure after transaction preparation, cleanup removes only this transaction's
|
||||
owner-labelled new container and its newly created, empty, project-labelled network.
|
||||
The previously validated never-started residue has no rollback value and is not recreated.
|
||||
**Data/config files, images/build cache and the private CD journal volume are retained.**
|
||||
No `down -v`, volume prune or removal of unrelated/CI resources is used.
|
||||
Cleanup failure retains the lock so the always recovery step can retry using the journal.
|
||||
Only an explicitly dispatched bootstrap may retry a failed first-install journal, and
|
||||
only when its project/container/mounts/port match, its volume ownership matches, and
|
||||
target directories remain empty or absent. Prepared/success/foreign journals are refused.
|
||||
On a new transaction, the prior journal is archived as `deploy.<owner>.json` inside
|
||||
the private state volume before replacing the active journal; generated data is never
|
||||
automatically emptied and journals are never deleted.
|
||||
Abrupt host/runner loss still requires operator review of the lock/journal.
|
||||
The journal is private (0700/0600) and may contain prior/candidate secrets; never export it
|
||||
as an Actions artifact. First bootstrap has no prior healthy version to roll back to.
|
||||
|
||||
### First run and acceptance
|
||||
|
||||
1. In Gitea repository **Actions → Verify Frame Flow → Run workflow**, choose branch
|
||||
`main`, action **plan**. This is read-only and reports bootstrap required for the
|
||||
absent/created instance; it is not a bootstrap safety acceptance. Detailed host
|
||||
residue/directory checks happen under the lock in bootstrap.
|
||||
2. Run the same workflow on `main` with action **bootstrap**. Verify runs in full first.
|
||||
No NAS bootstrap is executed merely by committing this implementation.
|
||||
3. Require `BOOTSTRAP VERIFIED` in the deploy log. In Container Manager confirm
|
||||
container `pigway-frame-flow` is running/healthy, user 10333, restart unless-stopped,
|
||||
exact `/volume1/docker/pigway-frame-flow/{data,config}` mounts and
|
||||
`192.0.2.10:18787` binding (not loopback).
|
||||
4. Open `http://192.0.2.10:18787/api/v1/version`; its `commit` must equal the full
|
||||
workflow checkout commit. Check `/ready` returns HTTP 200. The immutable image tag
|
||||
is `pigway-frame-flow:latest`; compare its image ID in
|
||||
Container Manager with the running container.
|
||||
5. Keep `PROD_DEPLOY_ENABLED=true`. Subsequent main pushes Verify then deploy using
|
||||
this same runtime and journal mechanism. The first update records bootstrap as
|
||||
its rollback baseline. Repeating bootstrap against this instance is refused.
|
||||
Enable real-device read-only access and write access only through separate explicit
|
||||
operator decisions; no automatic change is made by bootstrap.
|
||||
|
||||
Docker tests are mocked locally. The first manual NAS Runner bootstrap, including
|
||||
real host mounts and restart, remains the required live acceptance.
|
||||
|
||||
## Actions download transport
|
||||
|
||||
Verify and deploy reference official GitHub Actions directly, pinned to immutable
|
||||
commits rather than moving tags:
|
||||
|
||||
| Action | Official upstream commit |
|
||||
| --- | --- |
|
||||
| actions/checkout v4 | 11d5960a326750d5838078e36cf38b85af677262 |
|
||||
| actions/setup-python v5 | a26af69be951a213d495a4c3e4e4022e16d87065 |
|
||||
|
||||
The workflow uses explicit `https://github.com/actions/...` URLs so Gitea's
|
||||
configured default Actions host cannot redirect downloads to the project owner's
|
||||
server. Forks download Actions through their own Runner/network; no public action
|
||||
mirror is served by this project. The former Tea auxiliary repository is private
|
||||
and is no longer required by current workflows.
|
||||
|
||||
Keep the Runner's action cache and Python toolcache persistent. For Gitea Runner
|
||||
versions supporting `cache.offline_mode`, use a private Runner configuration:
|
||||
|
||||
```yaml
|
||||
runner:
|
||||
file: /data/.runner
|
||||
cache:
|
||||
offline_mode: true
|
||||
```
|
||||
|
||||
In the Runner's own Compose service (not the application), set `HOME=/data` and
|
||||
`CONFIG_FILE=/data/config.yaml`, with `/data` already persisted. Before recreating
|
||||
an idle Runner, back up its configuration and copy the existing action cache from
|
||||
`/root/.cache/act` to `/data/.cache/act`. Preserve registration, labels, image,
|
||||
mounts and networks; do not interrupt active jobs or recreate other services.
|
||||
Verify cached actions against the pinned upstream commit and tree before reuse.
|
||||
This is administrator configuration, not a public workflow dependency.
|
||||
|
||||
Offline reuse avoids refreshing cached actions from GitHub on each job. Moving
|
||||
tags/branches may remain at the cached version; this workflow deliberately pins
|
||||
immutable SHAs. A new action URL or commit requires an initial download or verified
|
||||
administrator prewarming; an old mirror subtree is not automatically equivalent
|
||||
to an upstream Git repository. Do not prune Runner caches between jobs. Python
|
||||
toolcache misses still download Python from its upstream release source. Network
|
||||
failures remain failures, never reasons to skip validation or silently fall back
|
||||
to the owner's Tea server.
|
||||
|
||||
Administrators needing a mainland-specific download route can configure their
|
||||
own Runner/network cache privately. Such settings must not introduce public
|
||||
workflow references to a personal mirror. This change does not modify NAS VPN,
|
||||
proxy, firewall or other applications. All validation and deploy gates remain.
|
||||
|
||||
### Synology Photos dependencies
|
||||
|
||||
For a production photo source using Synology Photos person filtering, configure
|
||||
`SYNOLOGY_PHOTOS_URL` and `SYNOLOGY_PHOTOS_CERT_SHA256` as Gitea Secrets. The
|
||||
certificate fingerprint must match the configured HTTPS endpoint. Keep the Photos
|
||||
session in the persistent `/config` directory; never put session credentials in
|
||||
Variables or repository files.
|
||||
|
||||
`PROD_PHOTOS_DIR` must identify the actual NAS photo directory mounted read-only
|
||||
at `/photos`. Adoption requires that mount to exist on the current container;
|
||||
changing this Variable alone does not create a new mount. Existing supplementary
|
||||
permission groups are preserved by deployment.
|
||||
|
||||
Restoring SQLite and session files does not restore these runtime dependencies.
|
||||
Production smoke checks now reject an enabled people-filtered source if its
|
||||
Photos configuration or stored connection is missing. These checks do not prove
|
||||
session validity or scan the complete catalog: after restoring a source, also
|
||||
verify its read-only `/api/v1/photo-sources/{id}/images?limit=1` endpoint before
|
||||
running an automation task.
|
||||
|
||||
### Advisory-service network resilience
|
||||
|
||||
CI dependency installation uses the domestic `CI_PIP_INDEX_URL` first and the
|
||||
`CI_PIP_FALLBACK_INDEX_URL` sequentially on failure. These package indexes do not
|
||||
replace PyPI's vulnerability JSON API.
|
||||
|
||||
`python scripts/ci-audit.py` retains the pinned `requirements.lock`, PyPI advisory
|
||||
service and `--no-deps --disable-pip` checks. It uses a 60-second socket timeout,
|
||||
at most three attempts, and 5/10-second backoff only for network failures. Known
|
||||
vulnerabilities, malformed/incomplete reports and skipped dependencies fail
|
||||
immediately. Every attempt has a five-minute process limit. A timeout or failed
|
||||
final attempt still blocks deployment.
|
||||
|
||||
The advisory HTTP cache is reused within the job (including retries) and follows
|
||||
the audit client's normal HTTP freshness rules. It is not a permanent offline
|
||||
snapshot or a cross-job cache. Raw network exceptions are omitted because proxy
|
||||
URLs may contain credentials. Logs identify the advisory host and whether the
|
||||
complete audit succeeded.
|
||||
|
||||
### Production image retention
|
||||
|
||||
Production uses `pigway-frame-flow:latest`. Builds first create a unique release
|
||||
image, then tag it as latest before activation. The old image is pinned during
|
||||
an update so a failed deployment can restore it. Only after the new version
|
||||
passes verification are old release/rollback images and duplicate tags removed.
|
||||
After successful deployment, only the current production image remains; there
|
||||
is no retained historical image for manual rollback. Rebuild a historical
|
||||
revision before manually reverting a successful deployment.
|
||||
|
||||
Images referenced by other containers (including stopped containers), images
|
||||
with unrelated tags, and CI resources remain protected. Cleanup never prunes
|
||||
volumes, data, or shared build cache. Cleanup failure is a warning and does not
|
||||
roll back a verified deployment. Failed deployments do not run cleanup.
|
||||
+318
-6
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Pigway Frame Flow API",
|
||||
"version": "0.1.0"
|
||||
"title": "PIGWay Frame Flow API",
|
||||
"version": "0.1.19"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/devices/{device_id}/widgets/selection": {
|
||||
@@ -6397,7 +6397,15 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TemplateContent"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/TemplateContent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/AutoCollageContent"
|
||||
}
|
||||
],
|
||||
"title": "Payload"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14195,6 +14203,17 @@
|
||||
},
|
||||
"Asset": {
|
||||
"properties": {
|
||||
"source_path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source Path"
|
||||
},
|
||||
"starred": {
|
||||
"type": "boolean",
|
||||
"title": "Starred",
|
||||
@@ -14298,6 +14317,87 @@
|
||||
],
|
||||
"title": "AssetBatch"
|
||||
},
|
||||
"AutoCollageContent": {
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "auto_collage",
|
||||
"title": "Type"
|
||||
},
|
||||
"source_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]{1,60}$"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source Id"
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"maximum": 12.0,
|
||||
"minimum": 1.0,
|
||||
"title": "Count",
|
||||
"default": 4
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 100,
|
||||
"title": "Title",
|
||||
"default": ""
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"maxLength": 500,
|
||||
"title": "Message",
|
||||
"default": ""
|
||||
},
|
||||
"orientation": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"portrait",
|
||||
"landscape"
|
||||
],
|
||||
"title": "Orientation",
|
||||
"default": "auto"
|
||||
},
|
||||
"style": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"grid",
|
||||
"hero",
|
||||
"poster"
|
||||
],
|
||||
"title": "Style",
|
||||
"default": "grid"
|
||||
},
|
||||
"use_ai": {
|
||||
"type": "boolean",
|
||||
"title": "Use Ai",
|
||||
"default": true
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"maxLength": 1500,
|
||||
"title": "Prompt",
|
||||
"default": ""
|
||||
},
|
||||
"profile": {
|
||||
"$ref": "#/components/schemas/Profile",
|
||||
"default": "color"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"title": "AutoCollageContent"
|
||||
},
|
||||
"Body_import_images_api_v1_layout_images_post": {
|
||||
"properties": {
|
||||
"files": {
|
||||
@@ -14730,6 +14830,24 @@
|
||||
],
|
||||
"title": "Device"
|
||||
},
|
||||
"DeviceIdentity": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"title": "Id"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Name"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"title": "DeviceIdentity"
|
||||
},
|
||||
"DisplayGeometry": {
|
||||
"properties": {
|
||||
"width": {
|
||||
@@ -14778,6 +14896,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/PhotoSourceContent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/AutoCollageContent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ProviderContent"
|
||||
},
|
||||
@@ -14789,6 +14910,7 @@
|
||||
"discriminator": {
|
||||
"propertyName": "type",
|
||||
"mapping": {
|
||||
"auto_collage": "#/components/schemas/AutoCollageContent",
|
||||
"html": "#/components/schemas/FutureContent",
|
||||
"image": "#/components/schemas/ImageContent",
|
||||
"image_url": "#/components/schemas/URLContent",
|
||||
@@ -14852,6 +14974,7 @@
|
||||
"UNSUPPORTED_CAPABILITY",
|
||||
"RENDER_FAILED",
|
||||
"UPLOAD_FAILED",
|
||||
"DISPLAY_NOT_CONFIRMED",
|
||||
"WRITE_RESULT_UNKNOWN",
|
||||
"RATE_LIMITED",
|
||||
"INTERNAL_ERROR",
|
||||
@@ -14953,6 +15076,48 @@
|
||||
],
|
||||
"title": "FutureContent"
|
||||
},
|
||||
"GalleryDestination": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"title": "Id"
|
||||
},
|
||||
"adapter": {
|
||||
"type": "string",
|
||||
"title": "Adapter"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Name"
|
||||
},
|
||||
"storage": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"cloud",
|
||||
"local"
|
||||
],
|
||||
"title": "Storage"
|
||||
},
|
||||
"connected": {
|
||||
"type": "boolean",
|
||||
"title": "Connected",
|
||||
"default": false
|
||||
},
|
||||
"supports_upload": {
|
||||
"type": "boolean",
|
||||
"title": "Supports Upload",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"adapter",
|
||||
"name",
|
||||
"storage"
|
||||
],
|
||||
"title": "GalleryDestination"
|
||||
},
|
||||
"GalleryImage": {
|
||||
"properties": {
|
||||
"starred": {
|
||||
@@ -15062,7 +15227,7 @@
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version",
|
||||
"default": "0.1.0"
|
||||
"default": "0.1.19"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -15324,6 +15489,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/PhotoSourceContent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/AutoCollageContent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ProviderContent"
|
||||
},
|
||||
@@ -15334,6 +15502,7 @@
|
||||
"discriminator": {
|
||||
"propertyName": "type",
|
||||
"mapping": {
|
||||
"auto_collage": "#/components/schemas/AutoCollageContent",
|
||||
"html": "#/components/schemas/FutureContent",
|
||||
"image": "#/components/schemas/ImageContent",
|
||||
"image_url": "#/components/schemas/URLContent",
|
||||
@@ -15469,6 +15638,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/PhotoSourceContent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/AutoCollageContent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ProviderContent"
|
||||
},
|
||||
@@ -15479,6 +15651,7 @@
|
||||
"discriminator": {
|
||||
"propertyName": "type",
|
||||
"mapping": {
|
||||
"auto_collage": "#/components/schemas/AutoCollageContent",
|
||||
"html": "#/components/schemas/FutureContent",
|
||||
"image": "#/components/schemas/ImageContent",
|
||||
"image_url": "#/components/schemas/URLContent",
|
||||
@@ -15787,6 +15960,16 @@
|
||||
},
|
||||
"Operation": {
|
||||
"properties": {
|
||||
"recovery": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/RecoveryState"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"operation_id": {
|
||||
"type": "string",
|
||||
"title": "Operation Id"
|
||||
@@ -16055,6 +16238,17 @@
|
||||
],
|
||||
"title": "Caption"
|
||||
},
|
||||
"source_path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source Path"
|
||||
},
|
||||
"provider_preview": {
|
||||
"type": "boolean",
|
||||
"title": "Provider Preview",
|
||||
@@ -16160,6 +16354,7 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"local",
|
||||
"synology-photos",
|
||||
"webdav",
|
||||
"google-drive",
|
||||
"alipan",
|
||||
@@ -16173,6 +16368,19 @@
|
||||
"minLength": 1,
|
||||
"title": "Location"
|
||||
},
|
||||
"nas_root_path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 2048,
|
||||
"pattern": "^/"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Nas Root Path"
|
||||
},
|
||||
"recursive": {
|
||||
"type": "boolean",
|
||||
"title": "Recursive",
|
||||
@@ -16221,6 +16429,20 @@
|
||||
],
|
||||
"title": "Orientation"
|
||||
},
|
||||
"minimum_short_edge": {
|
||||
"type": "integer",
|
||||
"maximum": 50000.0,
|
||||
"minimum": 0.0,
|
||||
"title": "Minimum Short Edge",
|
||||
"default": 0
|
||||
},
|
||||
"minimum_long_edge": {
|
||||
"type": "integer",
|
||||
"maximum": 50000.0,
|
||||
"minimum": 0.0,
|
||||
"title": "Minimum Long Edge",
|
||||
"default": 0
|
||||
},
|
||||
"minimum_sharpness": {
|
||||
"type": "number",
|
||||
"maximum": 10000.0,
|
||||
@@ -16312,6 +16534,7 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"local",
|
||||
"synology-photos",
|
||||
"webdav",
|
||||
"google-drive",
|
||||
"alipan",
|
||||
@@ -16325,6 +16548,19 @@
|
||||
"minLength": 1,
|
||||
"title": "Location"
|
||||
},
|
||||
"nas_root_path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 2048,
|
||||
"pattern": "^/"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Nas Root Path"
|
||||
},
|
||||
"recursive": {
|
||||
"type": "boolean",
|
||||
"title": "Recursive",
|
||||
@@ -16373,6 +16609,20 @@
|
||||
],
|
||||
"title": "Orientation"
|
||||
},
|
||||
"minimum_short_edge": {
|
||||
"type": "integer",
|
||||
"maximum": 50000.0,
|
||||
"minimum": 0.0,
|
||||
"title": "Minimum Short Edge",
|
||||
"default": 0
|
||||
},
|
||||
"minimum_long_edge": {
|
||||
"type": "integer",
|
||||
"maximum": 50000.0,
|
||||
"minimum": 0.0,
|
||||
"title": "Minimum Long Edge",
|
||||
"default": 0
|
||||
},
|
||||
"minimum_sharpness": {
|
||||
"type": "number",
|
||||
"maximum": 10000.0,
|
||||
@@ -16808,6 +17058,49 @@
|
||||
],
|
||||
"title": "Reconciliation"
|
||||
},
|
||||
"RecoveryState": {
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"checking",
|
||||
"manual_review",
|
||||
"resolved"
|
||||
],
|
||||
"title": "State"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"title": "Reason"
|
||||
},
|
||||
"first_checked_at": {
|
||||
"type": "string",
|
||||
"title": "First Checked At"
|
||||
},
|
||||
"last_checked_at": {
|
||||
"type": "string",
|
||||
"title": "Last Checked At"
|
||||
},
|
||||
"checks": {
|
||||
"type": "integer",
|
||||
"title": "Checks"
|
||||
},
|
||||
"requires_manual_review": {
|
||||
"type": "boolean",
|
||||
"title": "Requires Manual Review"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"state",
|
||||
"reason",
|
||||
"first_checked_at",
|
||||
"last_checked_at",
|
||||
"checks",
|
||||
"requires_manual_review"
|
||||
],
|
||||
"title": "RecoveryState"
|
||||
},
|
||||
"Region": {
|
||||
"properties": {
|
||||
"asset_id": {
|
||||
@@ -16936,6 +17229,25 @@
|
||||
},
|
||||
"SettingsView": {
|
||||
"properties": {
|
||||
"storage_name": {
|
||||
"type": "string",
|
||||
"title": "Storage Name",
|
||||
"default": "NAS"
|
||||
},
|
||||
"gallery_destinations": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/GalleryDestination"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Gallery Destinations"
|
||||
},
|
||||
"device_catalog": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DeviceIdentity"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Device Catalog"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version"
|
||||
@@ -17173,12 +17485,12 @@
|
||||
"product": {
|
||||
"type": "string",
|
||||
"title": "Product",
|
||||
"default": "Pigway Frame Flow"
|
||||
"default": "PIGWay Frame Flow"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version",
|
||||
"default": "0.1.0"
|
||||
"default": "0.1.19"
|
||||
},
|
||||
"api_version": {
|
||||
"type": "string",
|
||||
|
||||
@@ -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.
|
||||
@@ -33,9 +33,11 @@ from pigway_frame_flow.models import (
|
||||
CurrentDisplay,
|
||||
DeleteRequest,
|
||||
Device,
|
||||
DeviceIdentity,
|
||||
DisplayRequest,
|
||||
ErrorResponse,
|
||||
Fit,
|
||||
GalleryDestination,
|
||||
GalleryImage,
|
||||
Health,
|
||||
History,
|
||||
@@ -122,9 +124,9 @@ def create_app(settings=None, start_worker=True):
|
||||
for code in [400, 401, 403, 404, 409, 413, 422, 429, 500, 502, 503]
|
||||
}
|
||||
app = FastAPI(
|
||||
title="Pigway Frame Flow API",
|
||||
title="PIGWay Frame Flow API",
|
||||
docs_url=None,
|
||||
version="0.1.3",
|
||||
version="0.1.20",
|
||||
lifespan=lifespan,
|
||||
responses=error_responses,
|
||||
)
|
||||
@@ -303,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
|
||||
@@ -418,12 +421,19 @@ def create_app(settings=None, start_worker=True):
|
||||
raise FlowError(E.INVALID_REQUEST, "Invalid cloud image") from None
|
||||
if thumbnail:
|
||||
thumb = cached.with_name(cached.stem + "-thumb.jpg")
|
||||
if not thumb.exists():
|
||||
from PIL import Image
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(cached) as img:
|
||||
img.thumbnail((300, 400))
|
||||
img.save(thumb, "JPEG")
|
||||
with Image.open(cached) as img:
|
||||
# The 430px cards need up to 860px on 2x displays. Replace the
|
||||
# existing cache in place so old 300px previews are upgraded.
|
||||
expected_edge = min(860, max(img.size))
|
||||
regenerate = not thumb.exists()
|
||||
if not regenerate:
|
||||
with Image.open(thumb) as existing:
|
||||
regenerate = max(existing.size) != expected_edge
|
||||
if regenerate:
|
||||
img.thumbnail((860, 860), Image.Resampling.LANCZOS)
|
||||
img.convert("RGB").save(thumb, "JPEG", quality=88, optimize=True)
|
||||
return FileResponse(thumb, media_type="image/jpeg")
|
||||
from PIL import Image
|
||||
|
||||
@@ -941,6 +951,7 @@ def create_app(settings=None, start_worker=True):
|
||||
)
|
||||
def delete_job(job_id: str):
|
||||
db.execute("DELETE FROM jobs WHERE id=?", (job_id,))
|
||||
db.execute("DELETE FROM app_config WHERE key=?", ("upload-retry:" + job_id,))
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.get(
|
||||
@@ -1000,7 +1011,36 @@ def create_app(settings=None, start_worker=True):
|
||||
)
|
||||
def settings_view():
|
||||
return SettingsView(
|
||||
version="0.1.3",
|
||||
storage_name=settings.storage_name,
|
||||
# Page navigation needs device identity, not a fresh cloud status request.
|
||||
device_catalog=[
|
||||
DeviceIdentity(
|
||||
id=id,
|
||||
name=getattr(getattr(driver, "cached", None), "name", id),
|
||||
)
|
||||
for id, driver in registry.drivers.items()
|
||||
],
|
||||
gallery_destinations=[
|
||||
GalleryDestination(
|
||||
id="cloud",
|
||||
adapter="kokonna",
|
||||
name="KoKonna",
|
||||
storage="cloud",
|
||||
supports_upload=True,
|
||||
connected=settings.mock
|
||||
or bool(settings.api_key)
|
||||
or any(registry.keys),
|
||||
),
|
||||
GalleryDestination(
|
||||
id="local",
|
||||
adapter="local-assets",
|
||||
name=settings.storage_name,
|
||||
storage="local",
|
||||
connected=True,
|
||||
supports_upload=True,
|
||||
),
|
||||
],
|
||||
version="0.1.20",
|
||||
mock=settings.mock,
|
||||
write_enabled=settings.write_enabled,
|
||||
api_key_configured=bool(settings.api_key)
|
||||
@@ -1229,7 +1269,7 @@ def create_app(settings=None, start_worker=True):
|
||||
def api_docs():
|
||||
response = get_swagger_ui_html(
|
||||
openapi_url="/openapi.json",
|
||||
title="Pigway Frame Flow · API Docs",
|
||||
title="PIGWay Frame Flow · API Docs",
|
||||
swagger_js_url="/ui/swagger-ui-bundle.js",
|
||||
swagger_css_url="/ui/swagger-ui.css",
|
||||
swagger_favicon_url='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
|
||||
@@ -13,18 +13,29 @@ class CloudLibrary:
|
||||
|
||||
def register(self, device_id, image_id, asset_id, operation_id):
|
||||
operation = self.db.one(
|
||||
"SELECT payload FROM operations WHERE id=?", (operation_id,)
|
||||
"SELECT payload,client_id FROM operations WHERE id=?", (operation_id,)
|
||||
)
|
||||
payload = json.loads(operation["payload"])
|
||||
content = payload.get("content", {})
|
||||
job = self.db.one(
|
||||
"SELECT job_id FROM job_runs WHERE operation_id=?", (operation_id,)
|
||||
)
|
||||
if not job and operation["client_id"] == "scheduler-retry":
|
||||
job = self.db.one(
|
||||
"SELECT job_id FROM asset_metadata WHERE asset_id=? AND job_id IS NOT NULL",
|
||||
(asset_id,),
|
||||
)
|
||||
job_config = (
|
||||
self.db.one("SELECT payload FROM jobs WHERE id=?", (job["job_id"],))
|
||||
if job
|
||||
else None
|
||||
)
|
||||
source = json.loads(job_config["payload"])["source"] if job_config else content
|
||||
job_id = (
|
||||
job["job_id"]
|
||||
if job
|
||||
and content.get("type") in ("photo_source", "auto_collage")
|
||||
and content.get("item_id") is None
|
||||
and source.get("type") in ("photo_source", "auto_collage")
|
||||
and source.get("item_id") is None
|
||||
else None
|
||||
)
|
||||
self.db.execute(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Durable, read-only recovery progress for writes with uncertain outcomes."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from pigway_frame_flow.storage.database import encode, now_iso
|
||||
|
||||
|
||||
class RecoveryMonitor:
|
||||
def __init__(self, service):
|
||||
self.service, self.db = service, service.db
|
||||
|
||||
def observe(self, op, reason):
|
||||
timestamp = now_iso()
|
||||
age = (
|
||||
datetime.now(UTC) - datetime.fromisoformat(op["created_at"])
|
||||
).total_seconds()
|
||||
review = age >= self.service.settings.sync_timeout + 300
|
||||
with self.db.transaction() as tx:
|
||||
previous = tx.execute(
|
||||
"SELECT * FROM operation_recovery WHERE operation_id=?", (op["id"],)
|
||||
).fetchone()
|
||||
# Once escalated, wall-clock corrections must not clear the alert.
|
||||
review = review or bool(previous and previous["state"] == "manual_review")
|
||||
state = "manual_review" if review else "checking"
|
||||
tx.execute(
|
||||
"INSERT INTO operation_recovery VALUES(?,?,?,?,?,1) ON CONFLICT(operation_id) DO UPDATE SET state=excluded.state,reason=excluded.reason,last_checked_at=excluded.last_checked_at,checks=operation_recovery.checks+1",
|
||||
(op["id"], state, reason, timestamp, timestamp),
|
||||
)
|
||||
if review and (not previous or previous["state"] != "manual_review"):
|
||||
tx.execute(
|
||||
"INSERT INTO events VALUES(?,?,?,?)",
|
||||
(
|
||||
uuid4().hex,
|
||||
"operation.blocked",
|
||||
encode(
|
||||
{
|
||||
"operation_id": op["id"],
|
||||
"device_id": op["device_id"],
|
||||
"reason": reason,
|
||||
"requires_manual_review": True,
|
||||
}
|
||||
),
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
tx.execute(
|
||||
"INSERT INTO history(client_id,request_id,operation_id,device_id,action,result,timestamp) VALUES(?,?,?,?,?,?,?)",
|
||||
(
|
||||
op["client_id"],
|
||||
op["request_id"],
|
||||
op["id"],
|
||||
op["device_id"],
|
||||
op["action"],
|
||||
"MANUAL_REVIEW_REQUIRED",
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
|
||||
def resolve(self, operation_id):
|
||||
self.db.execute(
|
||||
"UPDATE operation_recovery SET state='resolved' WHERE operation_id=? AND state!='resolved'",
|
||||
(operation_id,),
|
||||
)
|
||||
|
||||
def info(self, operation_id):
|
||||
row = self.db.one(
|
||||
"SELECT state,reason,first_checked_at,last_checked_at,checks FROM operation_recovery WHERE operation_id=?",
|
||||
(operation_id,),
|
||||
)
|
||||
if row:
|
||||
row["requires_manual_review"] = row["state"] == "manual_review"
|
||||
return row
|
||||
@@ -6,6 +6,7 @@ from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from pigway_frame_flow.display.cloud_library import CloudLibrary
|
||||
from pigway_frame_flow.display.recovery import RecoveryMonitor
|
||||
from pigway_frame_flow.display.retention import Retention
|
||||
from pigway_frame_flow.display.upload_reconciliation import UploadReconciliation
|
||||
from pigway_frame_flow.drivers.base import DriverRegistry
|
||||
@@ -50,6 +51,7 @@ class DisplayService:
|
||||
self.lock = threading.RLock()
|
||||
self.retention = Retention(self)
|
||||
self.cloud_library = CloudLibrary(self)
|
||||
self.recovery = RecoveryMonitor(self)
|
||||
self.upload_reconciliation = UploadReconciliation(self)
|
||||
self.expiration_checked: dict[str, float] = {}
|
||||
self.unknown_checked: dict[str, float] = {}
|
||||
@@ -113,7 +115,7 @@ class DisplayService:
|
||||
)
|
||||
return existing["operation_id"]
|
||||
if (
|
||||
action not in ("render", "render-ai", "local-upload")
|
||||
action not in ("display", "render", "render-ai", "local-upload")
|
||||
and tx.execute(
|
||||
"SELECT id FROM operations WHERE device_id=? AND status='UNKNOWN' AND write_started=1 AND action!='render-ai' LIMIT 1",
|
||||
(device_id,),
|
||||
@@ -165,6 +167,7 @@ class DisplayService:
|
||||
request_id=op["request_id"],
|
||||
client_id=op["client_id"],
|
||||
action=op["action"],
|
||||
recovery=self.recovery.info(id),
|
||||
)
|
||||
|
||||
def update(self, id, status, result=None, error=None):
|
||||
@@ -184,6 +187,15 @@ class DisplayService:
|
||||
id,
|
||||
),
|
||||
)
|
||||
if status == "COMPLETED":
|
||||
row = self.db.one("SELECT result FROM operations WHERE id=?", (id,))
|
||||
asset_id = json.loads(row["result"] or "{}").get("rendered_asset_id")
|
||||
if asset_id and self.db.one(
|
||||
"SELECT id FROM assets WHERE id=?", (asset_id,)
|
||||
):
|
||||
self.assets.promote(asset_id)
|
||||
if status in ("COMPLETED", "FAILED"):
|
||||
self.recovery.resolve(id)
|
||||
|
||||
def blocked(self, device_id):
|
||||
return (
|
||||
@@ -200,12 +212,38 @@ class DisplayService:
|
||||
)
|
||||
return self.db.one("SELECT * FROM device_state WHERE device_id=?", (id,))
|
||||
|
||||
def content_asset(self, content, device_id=None, operation_id=None):
|
||||
def clear_self_restore_reference(self, device_id, device):
|
||||
# A confirmed return to the managed image consumes its restore target.
|
||||
# Keep references while an override or an uncertain write still needs them.
|
||||
image_id = device.current_content.id
|
||||
if (
|
||||
device.status == "offline"
|
||||
or not device.current_content.synced
|
||||
or image_id is None
|
||||
):
|
||||
return
|
||||
with self.lock:
|
||||
self.db.execute(
|
||||
"""UPDATE device_state SET previous_image_id=NULL
|
||||
WHERE device_id=? AND previous_image_id=? AND managed_image_id=?
|
||||
AND base_image_id IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM overrides WHERE device_id=?)
|
||||
AND NOT EXISTS (SELECT 1 FROM operations WHERE device_id=?
|
||||
AND write_started=1 AND status IN ('UPLOADING','SYNCING','UNKNOWN'))""",
|
||||
(device_id, image_id, image_id, device_id, device_id),
|
||||
)
|
||||
|
||||
def content_asset(
|
||||
self, content, device_id=None, operation_id=None, *, staging=False
|
||||
):
|
||||
if isinstance(content, AutoCollageContent):
|
||||
from pigway_frame_flow.extensions.auto_collage import prepare
|
||||
|
||||
return self.content_asset(
|
||||
prepare(self, content, device_id), device_id, operation_id
|
||||
prepare(self, content, device_id),
|
||||
device_id,
|
||||
operation_id,
|
||||
staging=staging,
|
||||
)
|
||||
if isinstance(content, ProviderContent):
|
||||
values = {**content.data, **self.providers.get(content.provider_id)}
|
||||
@@ -218,11 +256,14 @@ class DisplayService:
|
||||
),
|
||||
device_id,
|
||||
operation_id,
|
||||
staging=staging,
|
||||
)
|
||||
if isinstance(content, TemplateContent):
|
||||
values = self.renderer.resolve(content.template, content.data)
|
||||
data = self.renderer.render(content.template, values, content.profile)
|
||||
asset = self.assets.put(data, content.template + ".jpg", "generated")
|
||||
asset = self.assets.put(
|
||||
data, content.template + ".jpg", "cache" if staging else "generated"
|
||||
)
|
||||
source = {
|
||||
"type": "template",
|
||||
"template": content.template,
|
||||
@@ -351,7 +392,24 @@ class DisplayService:
|
||||
"UPDATE operations SET result=? WHERE id=?",
|
||||
(encode({"photo_attempts": attempts}), operation_id),
|
||||
)
|
||||
return self.assets.put(rendered, name)
|
||||
asset = self.assets.put(
|
||||
rendered, name, "cache" if staging else "images"
|
||||
)
|
||||
self.db.execute(
|
||||
"INSERT OR REPLACE INTO asset_sources VALUES(?,?)",
|
||||
(
|
||||
asset["asset_id"],
|
||||
encode(
|
||||
{
|
||||
"type": "photo_source",
|
||||
"source_id": content.source_id,
|
||||
"item_id": item["id"],
|
||||
"source_path": item.get("source_path"),
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
return self.assets.info(asset["asset_id"])
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "No decodable photo found within 5 candidates"
|
||||
)
|
||||
@@ -364,11 +422,14 @@ class DisplayService:
|
||||
else:
|
||||
raise FlowError(E.INVALID_REQUEST, "Content requires renderable image data")
|
||||
return self.assets.put(
|
||||
convert(data, content.fit, content.profile), content.name
|
||||
convert(data, content.fit, content.profile),
|
||||
content.name,
|
||||
"cache" if staging else "images",
|
||||
)
|
||||
|
||||
def current_content(self, device_id):
|
||||
device = self.registry.get(device_id).device()
|
||||
self.clear_self_restore_reference(device_id, device)
|
||||
image_id = device.current_content.id
|
||||
row = self.db.one(
|
||||
"SELECT * FROM image_sources WHERE device_id=? AND image_id=?",
|
||||
@@ -419,10 +480,38 @@ class DisplayService:
|
||||
yield from rows
|
||||
cursor_time, cursor_id = rows[-1]["created_at"], rows[-1]["id"]
|
||||
|
||||
def upload_retry_expired(self, op):
|
||||
if op["client_id"] != "scheduler-retry" or not op["request_id"].startswith(
|
||||
"upload-retry-"
|
||||
):
|
||||
return False
|
||||
root = op["request_id"].split("-")[-2]
|
||||
for row in self.db.all(
|
||||
"SELECT payload FROM app_config WHERE key LIKE 'upload-retry:%'"
|
||||
):
|
||||
plan = json.loads(row["payload"])
|
||||
if plan["root"] == root:
|
||||
return now_iso() >= plan["deadline"]
|
||||
return True
|
||||
|
||||
def process_one(self):
|
||||
with self.lock:
|
||||
for op in self.queued_operations():
|
||||
if self.upload_retry_expired(op):
|
||||
self.update(
|
||||
op["id"],
|
||||
"FAILED",
|
||||
error={
|
||||
"code": E.CONFLICT,
|
||||
"message": "Upload retry window closed",
|
||||
"request_id": op["request_id"],
|
||||
"details": {"reason": "RETRY_WINDOW_CLOSED"},
|
||||
},
|
||||
)
|
||||
self.db.history(op, "RETRY_WINDOW_CLOSED")
|
||||
continue
|
||||
if op["action"] not in (
|
||||
"display",
|
||||
"render",
|
||||
"render-ai",
|
||||
"local-upload",
|
||||
@@ -506,6 +595,8 @@ class DisplayService:
|
||||
previous = device.current_content.id
|
||||
state = self.state(device_id)
|
||||
if action == "delete":
|
||||
self.clear_self_restore_reference(device_id, device)
|
||||
state = self.state(device_id)
|
||||
image_id = payload["image_id"]
|
||||
if payload.get("cloud_cleanup"):
|
||||
if (
|
||||
@@ -544,7 +635,12 @@ class DisplayService:
|
||||
)
|
||||
}
|
||||
if image_id in protected and not payload.get("purge"):
|
||||
raise FlowError(E.CONFLICT, "Image is needed for restoration", 409)
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Image is needed for restoration",
|
||||
409,
|
||||
{"reason": "RESTORE_IMAGE_PROTECTED"},
|
||||
)
|
||||
if image_id == previous and not payload["confirm_current"]:
|
||||
raise FlowError(E.CONFLICT, "Current image needs confirmation", 409)
|
||||
self._start_write(id)
|
||||
@@ -598,6 +694,13 @@ class DisplayService:
|
||||
self.update(id, "SYNCING", {"image_id": target})
|
||||
self.db.history(op, "ACCEPTED")
|
||||
return
|
||||
# A new upload is independent of a previous failed write. Read back its
|
||||
# outcome before closing the old attempt; never replay that operation.
|
||||
for pending in self.db.all(
|
||||
"SELECT id FROM operations WHERE device_id=? AND status='UNKNOWN' AND write_started=1 AND action='display'",
|
||||
(device_id,),
|
||||
):
|
||||
self.reconcile(pending["id"], acknowledge=True)
|
||||
request = DisplayRequest.model_validate(payload)
|
||||
content = request.content
|
||||
self.update(id, "RENDERING")
|
||||
@@ -614,7 +717,7 @@ class DisplayService:
|
||||
)
|
||||
image_id = matches[0].image_id
|
||||
else:
|
||||
asset = self.content_asset(content, device_id, id)
|
||||
asset = self.content_asset(content, device_id, id, staging=True)
|
||||
self.assets.record_origin(asset["asset_id"], op)
|
||||
image_id = None
|
||||
self.db.execute(
|
||||
@@ -649,7 +752,6 @@ class DisplayService:
|
||||
driver.display(image_id)
|
||||
result = {"image_id": image_id}
|
||||
if asset:
|
||||
self.assets.promote(asset["asset_id"])
|
||||
result["rendered_asset_id"] = asset["asset_id"]
|
||||
source = self.db.one(
|
||||
"SELECT payload FROM asset_sources WHERE asset_id=?",
|
||||
@@ -702,33 +804,57 @@ class DisplayService:
|
||||
# Late acknowledgements only resolve a known display target. Never replay
|
||||
# writes or infer success from gallery presence alone.
|
||||
for op in self.db.all(
|
||||
"SELECT * FROM operations WHERE status='UNKNOWN' AND action='display' AND write_started=1"
|
||||
"SELECT * FROM operations WHERE status='UNKNOWN' AND action!='render-ai' AND write_started=1"
|
||||
):
|
||||
expected = json.loads(op["result"] or "{}").get("image_id")
|
||||
intent = json.loads(op["result"] or "{}").get("upload_intent")
|
||||
if not expected and not intent:
|
||||
continue
|
||||
checked = time.monotonic()
|
||||
if checked - self.unknown_checked.get(op["id"], float("-inf")) < 60:
|
||||
continue
|
||||
self.unknown_checked[op["id"]] = checked
|
||||
if op["action"] != "display" or (not expected and not intent):
|
||||
self.recovery.observe(
|
||||
op,
|
||||
"missing_upload_evidence"
|
||||
if op["action"] == "display"
|
||||
else "manual_reconciliation_required",
|
||||
)
|
||||
continue
|
||||
try:
|
||||
driver = self.registry.get(op["device_id"])
|
||||
device = driver.device(refresh=True)
|
||||
if intent:
|
||||
expected = self.upload_reconciliation.check(op, driver, device)
|
||||
if (
|
||||
self.db.one(
|
||||
"SELECT status FROM operations WHERE id=?", (op["id"],)
|
||||
)["status"]
|
||||
== "FAILED"
|
||||
):
|
||||
continue
|
||||
if (
|
||||
expected
|
||||
and device.current_content.id == expected
|
||||
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"]})
|
||||
self.unknown_checked.pop(op["id"], None)
|
||||
elif expected:
|
||||
self.recovery.observe(
|
||||
op,
|
||||
"waiting_device_sync"
|
||||
if device.current_content.id == expected
|
||||
else "target_mismatch",
|
||||
)
|
||||
except FlowError:
|
||||
pass
|
||||
self.recovery.observe(op, "read_unavailable")
|
||||
except Exception:
|
||||
# Bad vendor metadata for one operation must not stop the worker.
|
||||
self.recovery.observe(op, "reconciliation_error")
|
||||
for op in self.db.all("SELECT * FROM operations WHERE status='SYNCING'"):
|
||||
try:
|
||||
device = self.registry.get(op["device_id"]).device()
|
||||
@@ -738,6 +864,7 @@ class DisplayService:
|
||||
and device.current_content.synced
|
||||
):
|
||||
self.update(op["id"], "COMPLETED")
|
||||
self.clear_self_restore_reference(op["device_id"], device)
|
||||
self.cloud_library.confirm(op["id"])
|
||||
self.db.history(op, "COMPLETED")
|
||||
self.db.event("operation.completed", {"operation_id": op["id"]})
|
||||
|
||||
@@ -59,6 +59,12 @@ class UploadReconciliation:
|
||||
candidates.append(image)
|
||||
# Ambiguous matches never release a write lock.
|
||||
if len(candidates) != 1:
|
||||
service.recovery.observe(
|
||||
op,
|
||||
"gallery_match_missing"
|
||||
if not candidates
|
||||
else "gallery_match_ambiguous",
|
||||
)
|
||||
return None
|
||||
expected = candidates[0].image_id
|
||||
data = driver.download(expected)
|
||||
@@ -66,11 +72,11 @@ class UploadReconciliation:
|
||||
len(data) != intent["size"]
|
||||
or hashlib.sha256(data).hexdigest() != intent["sha256"]
|
||||
):
|
||||
service.recovery.observe(op, "content_mismatch")
|
||||
return None
|
||||
result["image_id"] = expected
|
||||
result["upload_confirmed"] = True
|
||||
service.update(op["id"], "UNKNOWN", result, json.loads(op["error"] or "{}"))
|
||||
service.assets.promote(result["rendered_asset_id"])
|
||||
source = service.db.one(
|
||||
"SELECT payload FROM asset_sources WHERE asset_id=?",
|
||||
(result["rendered_asset_id"],),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Hardened adapter over the MIT-0 guooscar 1.2.0 device/upload methods.
|
||||
|
||||
No UI, scheduler, renderer, environment lookup, or constructor cloud calls.
|
||||
Never retry a write; a timeout may have occurred after cloud acceptance.
|
||||
Retry only read-only requests or writes proven unsent/rejected; never replay ambiguous writes.
|
||||
"""
|
||||
|
||||
import threading
|
||||
@@ -10,6 +10,7 @@ from collections import deque
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from urllib3.exceptions import MaxRetryError, NewConnectionError
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
@@ -19,28 +20,85 @@ BASE_URL = "https://api.kokonna.art/openapi"
|
||||
|
||||
|
||||
class SafeTransport:
|
||||
MAX_ATTEMPTS = 3
|
||||
MAX_RETRY_DELAY = 60
|
||||
|
||||
def __init__(self, key, session=None):
|
||||
self.key = key
|
||||
self.session = session or requests.Session()
|
||||
self.calls: deque[float] = deque()
|
||||
self.lock = threading.Lock()
|
||||
self.cooldown_until = 0.0
|
||||
|
||||
def request(self, endpoint, payload=None, write=False, binary=False):
|
||||
if not self.key:
|
||||
raise FlowError(E.CONFIG_REQUIRED, "KoKonna configuration required", 503)
|
||||
for attempt in range(1, self.MAX_ATTEMPTS + 1):
|
||||
try:
|
||||
return self._request_once(endpoint, payload, write, binary)
|
||||
except FlowError as exc:
|
||||
exc.details["attempts"] = attempt
|
||||
delay = max(
|
||||
2 ** (attempt - 1), exc.details.get("retry_after_seconds", 0)
|
||||
)
|
||||
if (
|
||||
not exc.details.get("retryable")
|
||||
or attempt == self.MAX_ATTEMPTS
|
||||
or delay > self.MAX_RETRY_DELAY
|
||||
):
|
||||
if exc.details.get("retryable"):
|
||||
exc.details["retry_exhausted"] = attempt == self.MAX_ATTEMPTS
|
||||
exc.details["retry_deferred"] = delay > self.MAX_RETRY_DELAY
|
||||
raise
|
||||
time.sleep(delay)
|
||||
raise AssertionError("Retry loop exhausted")
|
||||
|
||||
def _reserve(self):
|
||||
with self.lock:
|
||||
current = time.monotonic()
|
||||
while self.calls and current - self.calls[0] >= 60:
|
||||
self.calls.popleft()
|
||||
# Reserve capacity beneath the official 20/minute ceiling.
|
||||
delay = max(0.0, self.cooldown_until - current)
|
||||
if len(self.calls) >= 18:
|
||||
raise FlowError(E.RATE_LIMITED, "Device request budget exhausted", 429)
|
||||
delay = max(delay, 60 - (current - self.calls[0]))
|
||||
if delay:
|
||||
raise FlowError(
|
||||
E.RATE_LIMITED,
|
||||
"Device request budget exhausted",
|
||||
429,
|
||||
details={
|
||||
"retryable": True,
|
||||
"retry_after_seconds": delay,
|
||||
"write_outcome": "not_sent",
|
||||
"failure_kind": "local_rate_limit",
|
||||
},
|
||||
)
|
||||
self.calls.append(current)
|
||||
url = BASE_URL + endpoint
|
||||
|
||||
@staticmethod
|
||||
def _retry_after(value):
|
||||
from datetime import UTC, datetime
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
try:
|
||||
seconds = float(value)
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
seconds = (
|
||||
parsedate_to_datetime(value) - datetime.now(UTC)
|
||||
).total_seconds()
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return 60.0
|
||||
import math
|
||||
|
||||
return max(0.0, seconds) if math.isfinite(seconds) else 60.0
|
||||
|
||||
def _request_once(self, endpoint, payload, write, binary):
|
||||
if not self.key:
|
||||
raise FlowError(E.CONFIG_REQUIRED, "KoKonna configuration required", 503)
|
||||
self._reserve()
|
||||
try:
|
||||
response = self.session.request(
|
||||
"GET" if binary else "POST",
|
||||
url,
|
||||
BASE_URL + endpoint,
|
||||
headers={}
|
||||
if binary
|
||||
else {
|
||||
@@ -52,8 +110,7 @@ class SafeTransport:
|
||||
allow_redirects=False,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
# Persist only fixed categories: exception text and download URLs may
|
||||
# contain credentials. Unknown writes must still never be replayed.
|
||||
# Never retain exception text or a URL: binary URLs contain the key.
|
||||
category = next(
|
||||
(
|
||||
label
|
||||
@@ -68,50 +125,132 @@ class SafeTransport:
|
||||
),
|
||||
"request_error",
|
||||
)
|
||||
# A socket/DNS establishment failure precedes the HTTP write. A
|
||||
# dropped established connection still has an uncertain outcome.
|
||||
not_sent = isinstance(exc, requests.ConnectTimeout) or any(
|
||||
isinstance(cause, MaxRetryError)
|
||||
and isinstance(cause.reason, NewConnectionError)
|
||||
for cause in exc.args
|
||||
)
|
||||
if not_sent and category == "connection_error":
|
||||
category = "connect_error"
|
||||
uncertain = write and not not_sent
|
||||
raise FlowError(
|
||||
E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE,
|
||||
E.WRITE_RESULT_UNKNOWN if uncertain else E.CLOUD_UNAVAILABLE,
|
||||
"Cloud write outcome is unknown"
|
||||
if write
|
||||
if uncertain
|
||||
else "Device cloud is unavailable",
|
||||
502,
|
||||
details={"transport_error": category},
|
||||
details={
|
||||
"transport_error": category,
|
||||
"retryable": (not write and category != "tls_error") or not_sent,
|
||||
"write_outcome": "unknown"
|
||||
if uncertain
|
||||
else ("not_sent" if write else "read_only"),
|
||||
},
|
||||
) from None
|
||||
if response.status_code != 200:
|
||||
code = (
|
||||
E.RATE_LIMITED
|
||||
if response.status_code == 429
|
||||
else (
|
||||
E.WRITE_RESULT_UNKNOWN
|
||||
if write and response.status_code >= 500
|
||||
else E.CLOUD_UNAVAILABLE
|
||||
)
|
||||
)
|
||||
raise FlowError(
|
||||
code,
|
||||
"Device cloud request failed",
|
||||
429 if code == E.RATE_LIMITED else 502,
|
||||
details={"http_status": response.status_code},
|
||||
)
|
||||
if binary:
|
||||
if len(response.content) > 20 * 1024 * 1024:
|
||||
raise FlowError(E.INVALID_REQUEST, "Cloud image exceeds size limit")
|
||||
return response.content
|
||||
try:
|
||||
value = response.json()
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError()
|
||||
# Cloud errors may echo credentials: never return raw messages.
|
||||
if value.get("success") is False or (
|
||||
"message" in value and "id" not in value
|
||||
value = None
|
||||
if not binary or response.status_code != 200:
|
||||
try:
|
||||
value = response.json()
|
||||
except ValueError:
|
||||
pass
|
||||
message = value.get("message") if isinstance(value, dict) else None
|
||||
status = response.status_code
|
||||
# Only exact documented business failures establish rejection. Generic
|
||||
# 5xx responses may arrive after a write; those remain UNKNOWN.
|
||||
business = None
|
||||
if status in (404, 500) and isinstance(message, str):
|
||||
if message == "image not found":
|
||||
business = (
|
||||
E.NOT_FOUND,
|
||||
"Cloud image not found",
|
||||
404,
|
||||
"image_not_found",
|
||||
)
|
||||
elif status == 500 and message == "imageId is empty":
|
||||
business = (
|
||||
E.INVALID_REQUEST,
|
||||
"Cloud image ID is invalid",
|
||||
400,
|
||||
"invalid_image_id",
|
||||
)
|
||||
elif status == 500 and message.startswith("can not find robot "):
|
||||
business = (
|
||||
E.CONFIG_REQUIRED,
|
||||
"Device key does not identify a frame",
|
||||
503,
|
||||
"device_key_invalid",
|
||||
)
|
||||
elif status == 404 and message.startswith("can not find method "):
|
||||
business = (
|
||||
E.UNSUPPORTED_CAPABILITY,
|
||||
"Cloud method not found",
|
||||
422,
|
||||
"method_not_found",
|
||||
)
|
||||
if business:
|
||||
code, safe_message, local_status, kind = business
|
||||
raise FlowError(
|
||||
code,
|
||||
safe_message,
|
||||
local_status,
|
||||
details={
|
||||
"http_status": status,
|
||||
"failure_kind": kind,
|
||||
"retryable": False,
|
||||
"write_outcome": "rejected" if write else "read_only",
|
||||
},
|
||||
)
|
||||
if status != 200:
|
||||
limited = status == 429
|
||||
rejected = 400 <= status < 500
|
||||
uncertain = write and not rejected
|
||||
details = {
|
||||
"http_status": status,
|
||||
"retryable": limited or (not write and status >= 500),
|
||||
"write_outcome": "unknown"
|
||||
if uncertain
|
||||
else ("rejected" if write else "read_only"),
|
||||
}
|
||||
if limited:
|
||||
delay = self._retry_after(response.headers.get("Retry-After"))
|
||||
details["retry_after_seconds"] = delay
|
||||
with self.lock:
|
||||
self.cooldown_until = max(
|
||||
self.cooldown_until, time.monotonic() + delay
|
||||
)
|
||||
raise FlowError(
|
||||
E.RATE_LIMITED
|
||||
if limited
|
||||
else (E.WRITE_RESULT_UNKNOWN if uncertain else E.CLOUD_UNAVAILABLE),
|
||||
"Device cloud request failed",
|
||||
429 if limited else 502,
|
||||
details=details,
|
||||
)
|
||||
if binary:
|
||||
if len(response.content) > 20 * 1024 * 1024:
|
||||
raise FlowError(E.INVALID_REQUEST, "Cloud image exceeds size limit")
|
||||
return response.content
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("success") is False
|
||||
or ("message" in value and "id" not in value)
|
||||
):
|
||||
raise ValueError()
|
||||
raise FlowError(
|
||||
E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE,
|
||||
"Invalid device cloud response",
|
||||
502,
|
||||
details={
|
||||
"failure_kind": "invalid_response",
|
||||
"retryable": not write,
|
||||
"write_outcome": "unknown" if write else "read_only",
|
||||
},
|
||||
)
|
||||
return value
|
||||
except ValueError:
|
||||
raise FlowError(
|
||||
E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE,
|
||||
"Invalid device cloud response",
|
||||
502,
|
||||
) from None
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
|
||||
class KokonnaClient:
|
||||
@@ -136,7 +275,7 @@ class KokonnaClient:
|
||||
def upload(self, path, name):
|
||||
self.guard()
|
||||
value = guooscar_protocol.upload(self.transport, path.read_bytes(), name)
|
||||
if not isinstance(value.get("id"), int) or value["id"] <= 0:
|
||||
if type(value.get("id")) is not int or value["id"] <= 0:
|
||||
raise FlowError(E.WRITE_RESULT_UNKNOWN, "Upload ID not confirmed", 502)
|
||||
return value["id"]
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ class LocalRetention:
|
||||
"SELECT payload FROM jobs",
|
||||
"SELECT payload FROM template_config",
|
||||
"SELECT payload FROM operations WHERE status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')",
|
||||
"SELECT payload FROM app_config WHERE key LIKE 'upload-retry:%'",
|
||||
]:
|
||||
payloads += [row["payload"] for row in db.all(query)]
|
||||
for payload in payloads:
|
||||
|
||||
@@ -138,6 +138,7 @@ def install_webhooks(app, service):
|
||||
allowed = {
|
||||
"operation.completed",
|
||||
"operation.failed",
|
||||
"operation.blocked",
|
||||
"display.changed",
|
||||
"device.online",
|
||||
"device.offline",
|
||||
|
||||
@@ -177,7 +177,17 @@ class OperationResult(BaseModel):
|
||||
deferred: bool = False
|
||||
|
||||
|
||||
class RecoveryState(BaseModel):
|
||||
state: Literal["checking", "manual_review", "resolved"]
|
||||
reason: str
|
||||
first_checked_at: str
|
||||
last_checked_at: str
|
||||
checks: int
|
||||
requires_manual_review: bool
|
||||
|
||||
|
||||
class Operation(BaseModel):
|
||||
recovery: RecoveryState | None = None
|
||||
operation_id: str
|
||||
status: OperationStatus
|
||||
device_id: str
|
||||
@@ -255,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
|
||||
@@ -323,7 +336,24 @@ class History(BaseModel):
|
||||
timestamp: str
|
||||
|
||||
|
||||
class GalleryDestination(BaseModel):
|
||||
id: str
|
||||
adapter: str
|
||||
name: str
|
||||
storage: Literal["cloud", "local"]
|
||||
connected: bool = False
|
||||
supports_upload: bool = False
|
||||
|
||||
|
||||
class DeviceIdentity(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class SettingsView(BaseModel):
|
||||
storage_name: str = "NAS"
|
||||
gallery_destinations: list[GalleryDestination] = Field(default_factory=list)
|
||||
device_catalog: list[DeviceIdentity] = Field(default_factory=list)
|
||||
version: str
|
||||
mock: bool
|
||||
write_enabled: bool
|
||||
@@ -336,7 +366,7 @@ class SettingsView(BaseModel):
|
||||
|
||||
class Health(BaseModel):
|
||||
status: str
|
||||
version: str = "0.1.3"
|
||||
version: str = "0.1.20"
|
||||
|
||||
|
||||
class Readiness(BaseModel):
|
||||
@@ -345,6 +375,7 @@ class Readiness(BaseModel):
|
||||
|
||||
|
||||
class Asset(BaseModel):
|
||||
source_path: str | None = None
|
||||
starred: bool = False
|
||||
origin: Literal["manual", "task", "external", "template", "unknown"] = "unknown"
|
||||
job_id: str | None = None
|
||||
@@ -365,8 +396,8 @@ class RestoreRequest(StrictModel):
|
||||
|
||||
|
||||
class Version(BaseModel):
|
||||
product: str = "Pigway Frame Flow"
|
||||
version: str = "0.1.3"
|
||||
product: str = "PIGWay Frame Flow"
|
||||
version: str = "0.1.20"
|
||||
api_version: str = "v1"
|
||||
commit: str = "development"
|
||||
build_time: str = "unknown"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import random
|
||||
@@ -27,8 +28,11 @@ 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)
|
||||
nas_root_path: str | None = Field(default=None, max_length=2048, pattern=r"^/")
|
||||
recursive: bool = True
|
||||
enabled: bool = True
|
||||
catalog_limit: int = Field(default=10000, ge=1, le=100000)
|
||||
@@ -36,6 +40,8 @@ class PhotoSourceInput(StrictModel):
|
||||
organized_only: bool = False
|
||||
folder_caption: bool = False
|
||||
orientation: Literal["auto", "landscape", "portrait"] | None = None
|
||||
minimum_short_edge: int = Field(default=0, ge=0, le=50000)
|
||||
minimum_long_edge: int = Field(default=0, ge=0, le=50000)
|
||||
minimum_sharpness: float = Field(default=0, ge=0, le=10000)
|
||||
minimum_brightness: float = Field(default=0, ge=0, le=255)
|
||||
maximum_brightness: float = Field(default=255, ge=0, le=255)
|
||||
@@ -57,6 +63,7 @@ class PhotoItem(StrictModel):
|
||||
id: str
|
||||
name: str
|
||||
caption: dict[str, str] | None = None
|
||||
source_path: str | None = None
|
||||
provider_preview: bool = False
|
||||
orientation: Literal["landscape", "portrait", "square"] | None = None
|
||||
width: int | None = None
|
||||
@@ -77,6 +84,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 +114,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 +159,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 +203,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 +221,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 +300,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 +317,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 +327,7 @@ class PhotoSources:
|
||||
refresh=refresh,
|
||||
space=source.people_space,
|
||||
)
|
||||
if source.people_filter
|
||||
if source.people_filter and source.kind == "local"
|
||||
else None
|
||||
)
|
||||
items = []
|
||||
@@ -375,6 +416,8 @@ class PhotoSources:
|
||||
height,
|
||||
)
|
||||
)
|
||||
if not self.resolution_matches(source, width, height):
|
||||
continue
|
||||
direction = orientation(width, height)
|
||||
if wanted and direction != wanted:
|
||||
continue
|
||||
@@ -406,6 +449,65 @@ 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)
|
||||
# Photos resolution is already oriented; EXIF must not swap it twice.
|
||||
if not self.resolution_matches(source, width, height):
|
||||
continue
|
||||
direction = orientation(width, height)
|
||||
if wanted and (not width or not height or direction != wanted):
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"id": str(row["id"]),
|
||||
"name": row["filename"],
|
||||
"provider_preview": True,
|
||||
"source_path": (
|
||||
source.nas_root_path.rstrip("/")
|
||||
+ "/"
|
||||
+ relative.lstrip("/")
|
||||
if source.nas_root_path
|
||||
else "/" + relative.lstrip("/")
|
||||
),
|
||||
"caption": caption if source.folder_caption else None,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"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,9 +570,14 @@ 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
|
||||
|
||||
@staticmethod
|
||||
def resolution_matches(source, width, height):
|
||||
short, long = sorted((width, height))
|
||||
return short >= source.minimum_short_edge and long >= source.minimum_long_edge
|
||||
|
||||
def read(self, source_id, item_id=None, *, target_size=(1200, 1600)):
|
||||
source = self.get(source_id)
|
||||
items = self.catalog(source_id, target_size=target_size)
|
||||
@@ -526,6 +633,25 @@ 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 {})
|
||||
try:
|
||||
caption["people"] = " & ".join(
|
||||
self.synology.item_people(item["id"], space=source.people_space)
|
||||
)
|
||||
except FlowError:
|
||||
# Optional labels must not prevent displaying a valid photo.
|
||||
logging.getLogger(__name__).warning(
|
||||
"Photos person labels unavailable; keeping photo caption"
|
||||
)
|
||||
item = {
|
||||
**item,
|
||||
"caption": {k: v for k, v in caption.items() if v} or None,
|
||||
}
|
||||
elif source.kind in ("google-drive", "alipan", "baidu"):
|
||||
from pigway_frame_flow.providers.cloud_drives import CloudDrives
|
||||
|
||||
@@ -534,4 +660,29 @@ class PhotoSources:
|
||||
data = self.assets.fetch_url(item["id"], headers=self.headers(source))
|
||||
if len(data) > self.settings.max_bytes:
|
||||
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
|
||||
if source.orientation or source.minimum_short_edge or source.minimum_long_edge:
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(BytesIO(data)) as image:
|
||||
width, height = image.size
|
||||
if image.getexif().get(274, 1) in (5, 6, 7, 8):
|
||||
width, height = height, width
|
||||
wanted = (
|
||||
orientation(*target_size)
|
||||
if source.orientation == "auto"
|
||||
else source.orientation
|
||||
)
|
||||
# Validate the fetched rendition before a crop can hide a mismatch.
|
||||
if wanted and orientation(width, height) != wanted:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Photo direction does not match the source orientation",
|
||||
)
|
||||
if not self.resolution_matches(source, width, height):
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Photo is below the configured minimum resolution",
|
||||
)
|
||||
return data, item["name"], item.get("caption")
|
||||
|
||||
@@ -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)
|
||||
@@ -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,178 @@ 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", "orientation"]
|
||||
),
|
||||
**({"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()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import base64
|
||||
import ipaddress
|
||||
import json
|
||||
import socket
|
||||
from io import BytesIO
|
||||
from urllib.parse import urlsplit
|
||||
@@ -115,9 +116,17 @@ class Assets:
|
||||
def promote(self, asset_id):
|
||||
row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
|
||||
if row and row["kind"] == "cache":
|
||||
target = self.settings.data_dir / "images" / (asset_id + ".jpg")
|
||||
source = self.db.one(
|
||||
"SELECT payload FROM asset_sources WHERE asset_id=?", (asset_id,)
|
||||
)
|
||||
kind = (
|
||||
"generated"
|
||||
if source and json.loads(source["payload"]).get("type") == "template"
|
||||
else "images"
|
||||
)
|
||||
target = self.settings.data_dir / kind / (asset_id + ".jpg")
|
||||
self.path(asset_id).replace(target)
|
||||
self.db.execute("UPDATE assets SET kind='images' WHERE id=?", (asset_id,))
|
||||
self.db.execute("UPDATE assets SET kind=? WHERE id=?", (kind, asset_id))
|
||||
return self.info(asset_id)
|
||||
|
||||
def info(self, asset_id):
|
||||
@@ -125,7 +134,13 @@ class Assets:
|
||||
if not row:
|
||||
raise FlowError(E.NOT_FOUND, "Asset not found", 404)
|
||||
meta = self.db.one("SELECT * FROM asset_metadata WHERE asset_id=?", (asset_id,))
|
||||
provenance = self.db.one(
|
||||
"SELECT payload FROM asset_sources WHERE asset_id=?", (asset_id,)
|
||||
)
|
||||
return {
|
||||
"source_path": json.loads(provenance["payload"]).get("source_path")
|
||||
if provenance
|
||||
else None,
|
||||
"starred": bool(meta and meta["starred"]),
|
||||
"origin": meta["origin"] if meta else "unknown",
|
||||
"job_id": meta["job_id"] if meta else None,
|
||||
|
||||
@@ -41,7 +41,7 @@ def render_captioned_photo(
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
values = [
|
||||
" ".join(str(caption.get(key, "")).split())
|
||||
for key in ("title", "date", "place")
|
||||
for key in ("date", "people", "title", "place")
|
||||
]
|
||||
text = " · ".join(value for value in values if value)
|
||||
font_size = layout.font_size
|
||||
@@ -51,8 +51,8 @@ def render_captioned_photo(
|
||||
font_size -= 2
|
||||
font = font_loader(font_size)
|
||||
# Shorten the title before touching the date/location. Never wrap the band.
|
||||
while draw.textlength(text, font=font) > available and len(values[0]) > 2:
|
||||
values[0] = values[0].rstrip("…")[:-1] + "…"
|
||||
while draw.textlength(text, font=font) > available and len(values[2]) > 2:
|
||||
values[2] = values[2].rstrip("…")[:-1] + "…"
|
||||
text = " · ".join(value for value in values if value)
|
||||
while draw.textlength(text, font=font) > available and len(text) > 2:
|
||||
text = text.rstrip("…")[:-1] + "…"
|
||||
|
||||
@@ -18,11 +18,16 @@ from pigway_frame_flow.models import (
|
||||
PlaylistInput,
|
||||
RestoreContent,
|
||||
)
|
||||
from pigway_frame_flow.scheduler.upload_retries import UploadRetries
|
||||
from pigway_frame_flow.storage.database import encode, now_iso
|
||||
|
||||
|
||||
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")
|
||||
@@ -56,6 +61,7 @@ class Scheduler:
|
||||
self.thread = None
|
||||
self.webhook_thread = None
|
||||
self.tick_lock = threading.Lock()
|
||||
self.upload_retries = UploadRetries(service)
|
||||
|
||||
def jobs(self):
|
||||
rows = [
|
||||
@@ -143,6 +149,7 @@ class Scheduler:
|
||||
def tick(self, current=None):
|
||||
current = current or datetime.now(UTC)
|
||||
with self.tick_lock:
|
||||
self.upload_retries.tick(current)
|
||||
for row in self.db.all(
|
||||
"SELECT * FROM jobs WHERE enabled=1 AND next_run<=?",
|
||||
(current.isoformat(),),
|
||||
@@ -179,6 +186,10 @@ class Scheduler:
|
||||
),
|
||||
).model_dump(mode="json")
|
||||
)
|
||||
if action == "display":
|
||||
payload = self.upload_retries.scheduled_payload(
|
||||
row["id"], payload
|
||||
)
|
||||
operation_id = self.service.submit(
|
||||
action,
|
||||
payload,
|
||||
@@ -187,6 +198,10 @@ class Scheduler:
|
||||
"schedule-" + uuid4().hex,
|
||||
"job-" + row["id"] + "-" + slot,
|
||||
)
|
||||
if action == "display":
|
||||
self.upload_retries.scheduled_submitted(
|
||||
row["id"], operation_id
|
||||
)
|
||||
following = next_run(job.schedule, current)
|
||||
enabled = bool(following)
|
||||
updated = job.model_copy(update={"enabled": enabled}).model_dump(
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Durable, job-local upload retries; scheduled execution remains authoritative."""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.storage.database import encode
|
||||
|
||||
|
||||
class UploadRetries:
|
||||
def __init__(self, service):
|
||||
self.service, self.db = service, service.db
|
||||
|
||||
def load(self, job_id):
|
||||
row = self.db.one(
|
||||
"SELECT payload FROM app_config WHERE key=?", ("upload-retry:" + job_id,)
|
||||
)
|
||||
return json.loads(row["payload"]) if row else None
|
||||
|
||||
def save(self, job_id, plan):
|
||||
if plan is None:
|
||||
self.db.execute(
|
||||
"DELETE FROM app_config WHERE key=?", ("upload-retry:" + job_id,)
|
||||
)
|
||||
else:
|
||||
self.db.execute(
|
||||
"INSERT OR REPLACE INTO app_config VALUES(?,?)",
|
||||
("upload-retry:" + job_id, encode(plan)),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def reuse(payload, asset_id):
|
||||
return {**payload, "content": {"type": "image", "asset_id": asset_id}}
|
||||
|
||||
def scheduled_payload(self, job_id, payload):
|
||||
plan = self.load(job_id)
|
||||
if plan and plan["phase"] in ("extra", "scheduled", "scheduled_running"):
|
||||
return self.reuse(payload, plan["asset_id"])
|
||||
return payload
|
||||
|
||||
def scheduled_submitted(self, job_id, operation_id):
|
||||
plan = self.load(job_id)
|
||||
if plan and plan["phase"] in ("extra", "scheduled", "scheduled_running"):
|
||||
plan.update(phase="scheduled_running", active=operation_id)
|
||||
self.save(job_id, plan)
|
||||
|
||||
def close_unknown(self, operation):
|
||||
if operation["status"] == "UNKNOWN":
|
||||
raw = self.db.one(
|
||||
"SELECT * FROM operations WHERE id=?", (operation["operation_id"],)
|
||||
)
|
||||
driver = self.service.registry.get(operation["device_id"])
|
||||
self.service.upload_reconciliation.check(
|
||||
raw, driver, driver.device(refresh=True)
|
||||
)
|
||||
self.service.reconcile(operation["operation_id"], acknowledge=True)
|
||||
return self.service.operation(operation["operation_id"])
|
||||
return operation
|
||||
|
||||
def discard(self, asset_id):
|
||||
row = self.db.one("SELECT kind FROM assets WHERE id=?", (asset_id,))
|
||||
meta = self.db.one(
|
||||
"SELECT starred FROM asset_metadata WHERE asset_id=?", (asset_id,)
|
||||
)
|
||||
# Only disposable generated staging files are owned by this recovery.
|
||||
if row and row["kind"] == "cache" and not (meta and meta["starred"]):
|
||||
self.service.assets.path(asset_id).unlink(missing_ok=True)
|
||||
with self.db.transaction() as tx:
|
||||
for table, field in (
|
||||
("assets", "id"),
|
||||
("asset_sources", "asset_id"),
|
||||
("asset_metadata", "asset_id"),
|
||||
):
|
||||
tx.execute(f"DELETE FROM {table} WHERE {field}=?", (asset_id,))
|
||||
|
||||
def tick(self, current):
|
||||
for job in self.db.all("SELECT * FROM jobs WHERE enabled=1"):
|
||||
try:
|
||||
self.check(job, current)
|
||||
except FlowError:
|
||||
# A read failure leaves the durable plan in place for a later check.
|
||||
continue
|
||||
|
||||
def check(self, job, current):
|
||||
config = json.loads(job["payload"])
|
||||
if config["source"]["type"] not in (
|
||||
"photo_source",
|
||||
"auto_collage",
|
||||
"template",
|
||||
"provider",
|
||||
):
|
||||
return
|
||||
plan = self.load(job["id"])
|
||||
if plan and plan["source"] != config["source"]:
|
||||
self.save(job["id"], None)
|
||||
plan = None
|
||||
if not plan:
|
||||
if not job["last_result"] or not job["next_run"]:
|
||||
return
|
||||
operation = self.service.operation(job["last_result"])
|
||||
asset_id = (operation["result"] or {}).get("rendered_asset_id")
|
||||
if operation["status"] not in ("FAILED", "UNKNOWN") or not asset_id:
|
||||
return
|
||||
deadline = datetime.fromisoformat(job["next_run"])
|
||||
remaining = (deadline - current).total_seconds()
|
||||
if remaining <= 0:
|
||||
times = []
|
||||
else:
|
||||
# Five increasing delays occupy the first half of the available
|
||||
# window, leaving time for upload/sync before the next schedule.
|
||||
unit = remaining / 62
|
||||
times = [
|
||||
(current + timedelta(seconds=unit * n)).isoformat()
|
||||
for n in (1, 3, 7, 15, 31)
|
||||
]
|
||||
plan = dict(
|
||||
root=operation["operation_id"],
|
||||
active=operation["operation_id"],
|
||||
asset_id=asset_id,
|
||||
source=config["source"],
|
||||
times=times,
|
||||
count=0,
|
||||
deadline=deadline.isoformat(),
|
||||
phase="extra",
|
||||
)
|
||||
self.save(job["id"], plan)
|
||||
operation = self.service.operation(plan["active"])
|
||||
deadline = min(
|
||||
datetime.fromisoformat(plan["deadline"]),
|
||||
datetime.fromisoformat(job["next_run"]),
|
||||
)
|
||||
if (
|
||||
plan["phase"] == "extra"
|
||||
and current >= deadline
|
||||
and operation["status"] != "COMPLETED"
|
||||
):
|
||||
if operation["status"] == "SYNCING":
|
||||
self.service.update(
|
||||
operation["operation_id"],
|
||||
"UNKNOWN",
|
||||
operation["result"],
|
||||
operation["error"],
|
||||
)
|
||||
operation = self.service.operation(operation["operation_id"])
|
||||
operation = self.close_unknown(operation)
|
||||
if operation["status"] == "QUEUED":
|
||||
self.service.update(
|
||||
operation["operation_id"],
|
||||
"FAILED",
|
||||
error={
|
||||
"code": "CONFLICT",
|
||||
"message": "Upload retry window closed",
|
||||
"request_id": operation["request_id"],
|
||||
"details": {"reason": "RETRY_WINDOW_CLOSED"},
|
||||
},
|
||||
)
|
||||
if operation["status"] != "COMPLETED":
|
||||
plan["phase"] = "scheduled"
|
||||
self.save(job["id"], plan)
|
||||
return
|
||||
if operation["status"] == "COMPLETED":
|
||||
self.save(job["id"], None)
|
||||
# Extra retries are not job_runs and do not move the next plan time.
|
||||
self.db.execute(
|
||||
"UPDATE jobs SET last_result=? WHERE id=?",
|
||||
(operation["operation_id"], job["id"]),
|
||||
)
|
||||
return
|
||||
if operation["status"] not in ("FAILED", "UNKNOWN"):
|
||||
return
|
||||
if plan["phase"] == "replacement":
|
||||
# A failed replacement gets its own bounded recovery window.
|
||||
self.db.execute(
|
||||
"UPDATE jobs SET last_result=? WHERE id=?",
|
||||
(operation["operation_id"], job["id"]),
|
||||
)
|
||||
self.save(job["id"], None)
|
||||
return
|
||||
if plan["phase"] == "scheduled_running":
|
||||
operation = self.close_unknown(operation)
|
||||
if operation["status"] == "COMPLETED":
|
||||
return
|
||||
self.discard(plan["asset_id"])
|
||||
payload = self.payload(config)
|
||||
key = "replacement-" + plan["root"]
|
||||
op_id = self.service.submit(
|
||||
"display", payload, config["device_id"], "scheduler-retry", key, key
|
||||
)
|
||||
plan.update(phase="replacement", active=op_id)
|
||||
self.save(job["id"], plan)
|
||||
self.db.execute(
|
||||
"UPDATE jobs SET last_result=? WHERE id=?", (op_id, job["id"])
|
||||
)
|
||||
self.db.event(
|
||||
"upload.regenerated",
|
||||
{
|
||||
"job_id": job["id"],
|
||||
"operation_id": op_id,
|
||||
"failed_asset_id": plan["asset_id"],
|
||||
},
|
||||
)
|
||||
return
|
||||
if current >= deadline or plan["count"] >= len(plan["times"]):
|
||||
plan["phase"] = "scheduled"
|
||||
self.save(job["id"], plan)
|
||||
return
|
||||
if current < datetime.fromisoformat(plan["times"][plan["count"]]):
|
||||
return
|
||||
operation = self.close_unknown(operation)
|
||||
if operation["status"] == "COMPLETED":
|
||||
return
|
||||
number = plan["count"] + 1
|
||||
key = f"upload-retry-{plan['root']}-{number}"
|
||||
op_id = self.service.submit(
|
||||
"display",
|
||||
self.reuse(self.payload(config), plan["asset_id"]),
|
||||
config["device_id"],
|
||||
"scheduler-retry",
|
||||
key,
|
||||
key,
|
||||
)
|
||||
plan.update(active=op_id, count=number)
|
||||
self.save(job["id"], plan)
|
||||
self.db.execute("UPDATE jobs SET last_result=? WHERE id=?", (op_id, job["id"]))
|
||||
self.db.event(
|
||||
"upload.retry",
|
||||
{
|
||||
"job_id": job["id"],
|
||||
"operation_id": op_id,
|
||||
"attempt": number,
|
||||
"deadline": deadline.isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def payload(config):
|
||||
return {
|
||||
"device_id": config["device_id"],
|
||||
"content": config["source"],
|
||||
"options": {
|
||||
"duration": config["display_duration"],
|
||||
"restore_previous": config["restore_previous"],
|
||||
"priority": config["priority"],
|
||||
"cleanup_unstarred": config.get("cleanup_unstarred", False),
|
||||
},
|
||||
}
|
||||
@@ -27,6 +27,11 @@ class Settings:
|
||||
default_factory=lambda: secret("FRAME_FLOW_TOKEN"), repr=False
|
||||
)
|
||||
additional_secrets: list[str] = field(default_factory=list, repr=False)
|
||||
storage_name: str = field(
|
||||
default_factory=lambda: (
|
||||
os.getenv("FRAME_FLOW_STORAGE_NAME", "NAS").strip() or "NAS"
|
||||
)
|
||||
)
|
||||
device_id: str = "living-room"
|
||||
timezone: str = "Asia/Shanghai"
|
||||
cors_origins: list[str] = field(
|
||||
@@ -51,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
|
||||
|
||||
@@ -31,6 +31,7 @@ class Database:
|
||||
CREATE TABLE IF NOT EXISTS image_sources (device_id TEXT, image_id INTEGER, asset_id TEXT, operation_id TEXT, payload TEXT NOT NULL, PRIMARY KEY(device_id,image_id));
|
||||
CREATE TABLE IF NOT EXISTS app_config (key TEXT PRIMARY KEY, payload TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS playlists (id TEXT PRIMARY KEY, payload TEXT, position INTEGER, next_run TEXT);
|
||||
CREATE TABLE IF NOT EXISTS operation_recovery (operation_id TEXT PRIMARY KEY, state TEXT NOT NULL, reason TEXT NOT NULL, first_checked_at TEXT NOT NULL, last_checked_at TEXT NOT NULL, checks INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS operations (
|
||||
id TEXT PRIMARY KEY, status TEXT NOT NULL, action TEXT NOT NULL,
|
||||
payload TEXT NOT NULL, device_id TEXT NOT NULL, client_id TEXT NOT NULL,
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
'use strict';
|
||||
const $=s=>document.querySelector(s);
|
||||
const state={lang:localStorage.getItem('ff-language')||'zh',theme:localStorage.getItem('ff-theme')||'system',page:location.hash.slice(1)||'dashboard',token:'',csrf:'',device:'living-room',settings:null,devices:[],templates:[],gallery:[],assets:[],blobs:[],tab:'cloud',preview:null,templatePreview:null,selectedTemplate:null,epoch:0};
|
||||
function forgetLogin(){localStorage.removeItem('ff-login');state.token='';state.csrf='';state.settings=null;state.devices=[];state.templates=[];}
|
||||
async function loadConnection(){state.settings=await api('/api/v1/settings');try{state.devices=await api('/api/v1/devices');}catch(error){state.devices=[];toast(error.message);}state.templates=await api('/api/v1/templates');state.device=state.devices[0]?.id||state.device;}
|
||||
function forgetLogin(){state.galleryQuery='';state.galleryOrder='newest';state.galleryJobsRequest=null;state.galleryCache?.clear();localStorage.removeItem('ff-login');state.token='';state.csrf='';state.settings=null;state.devices=[];state.templates=[];}
|
||||
async function loadConnection(){
|
||||
const [settings,templates]=await Promise.all([api('/api/v1/settings'),api('/api/v1/templates')]);
|
||||
const devices=settings.device_catalog||await api('/api/v1/devices').catch(error=>{toast(error.message);return [];});
|
||||
state.settings=settings;state.devices=devices;state.templates=templates;state.device=devices[0]?.id||settings.device_id||state.device;
|
||||
if(settings.device_catalog&&state.page!=='dashboard')void api('/api/v1/devices').then(details=>{if(state.settings!==settings||!state.token)return;state.devices=details;shell();}).catch(error=>{if(state.settings===settings&&state.token)toast(error.message);});
|
||||
}
|
||||
const icons={dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'};
|
||||
const t=k=>window.MESSAGES[state.lang][k]||k;
|
||||
function operationErrorMessage(error){const reason=error?.details?.reason;if(reason&&t(reason)!==reason)return t(reason);return error?.code&&t(error.code)!==error.code?t(error.code):(error?.message||t('error'));}
|
||||
const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const fmt=v=>v?new Date(v).toLocaleString(state.lang==='zh'?'zh-CN':'en-GB',{timeZone:'Asia/Shanghai'}):'—';
|
||||
const padTime=(hour,minute)=>`${String(hour).padStart(2,'0')}:${String(minute).padStart(2,'0')}`;
|
||||
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);}
|
||||
@@ -20,28 +26,64 @@ 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();
|
||||
}
|
||||
function bind(id,fn){const element=$('#'+id);if(element)element.onclick=async event=>{try{element.disabled=true;await fn(event);}catch(e){toast(e.message);}finally{element.disabled=false;}};}
|
||||
function busy(fn){return async event=>{try{await fn(event);}catch(e){toast(e.message);}};}
|
||||
function applyTheme(){const dark=state.theme==='dark'||(state.theme==='system'&&matchMedia('(prefers-color-scheme:dark)').matches);document.documentElement.dataset.theme=dark?'dark':'light';}
|
||||
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':''}"><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('');}
|
||||
function clearBlobs(){for(const url of state.blobs)URL.revokeObjectURL(url);state.blobs=[];}
|
||||
async function blobImage(img,path){try{const blob=await api(path,{binary:true});if(!img.isConnected)return;const url=URL.createObjectURL(blob);state.blobs.push(url);img.src=url;}catch{img.alt=t('apiError');}}
|
||||
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=>observer.observe(img));}
|
||||
function notice(){return state.settings?.mock?`<div class="notice">${t('mock')}</div>`:'';}
|
||||
async function navigate(page=state.page){state.page=icons[page]?page:'dashboard';location.hash=state.page;shell();clearBlobs();state.epoch++;const epoch=state.epoch;
|
||||
if(!state.token){login();return;}
|
||||
$('#main').innerHTML='<div class="spinner"></div>';
|
||||
try{await views[state.page](epoch);if(epoch!==state.epoch)return;const enhance={dashboard:enhanceDashboard,templates:enhanceTemplates,gallery:enhanceGallery,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 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 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']);
|
||||
function galleryDestinations(){
|
||||
const configured=state.settings?.gallery_destinations;
|
||||
const destinations=Array.isArray(configured)?configured:[{id:'cloud',adapter:'kokonna',name:'KoKonna',storage:'cloud',connected:true,supports_upload:true},{id:'local',adapter:'local-assets',name:state.settings?.storage_name||'NAS',storage:'local',connected:true,supports_upload:true}];
|
||||
return destinations.filter(d=>d.connected&&d.supports_upload&&galleryDestinationAdapters.has(d.adapter));
|
||||
}
|
||||
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 galleryDestinationName(destination){return `${destination.name} ${t(destination.storage==='local'?'local':'cloudGallery')}`;}
|
||||
function imageLoading(img){
|
||||
if(img.parentElement?.classList.contains('image-loading'))return img.parentElement;
|
||||
const box=document.createElement('div');box.className='image-loading';
|
||||
if(img.classList.contains('thumb'))box.classList.add('image-loading-thumb');
|
||||
if(img.closest('.frame-demo, .image-viewer-viewport, .studio-region'))box.classList.add('image-loading-fill');
|
||||
box.setAttribute('aria-busy','true');img.before(box);box.append(img);
|
||||
const status=document.createElement('span');status.className='image-loading-status';status.setAttribute('role','status');status.setAttribute('aria-label',t('loading'));
|
||||
const spinner=document.createElement('span');spinner.className='spinner';spinner.setAttribute('aria-hidden','true');status.append(spinner);box.append(status);return box;
|
||||
}
|
||||
async function blobImage(img,path){
|
||||
const box=imageLoading(img);
|
||||
try{
|
||||
const blob=await api(path,{binary:true});if(!img.isConnected)return;
|
||||
const url=URL.createObjectURL(blob);state.blobs.push(url);
|
||||
// Keep the placeholder until the browser has decoded the image as well as fetched it.
|
||||
img.src=url;await img.decode();if(!img.isConnected)return;box.replaceWith(img);
|
||||
}catch{
|
||||
box.setAttribute('aria-busy','false');box.classList.add('image-load-error');
|
||||
const status=box.querySelector('.image-loading-status');status.removeAttribute('aria-label');status.textContent=t('imageLoadError');
|
||||
}
|
||||
}
|
||||
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){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(){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');
|
||||
@@ -59,6 +101,7 @@ function showModal(html){
|
||||
}
|
||||
dialog.showModal();bind('modal-cancel',()=>dialog.close());
|
||||
}
|
||||
async function openImageViewer(path,name){showModal(`<h2>${esc(name)}</h2><img id="original-image" class="preview" alt="${esc(name)}"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#original-image'),path);}
|
||||
async function confirmDialog(message,fn){showModal(`<h2>${t('confirm')}</h2><p>${esc(message)}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('modal-confirm','confirm',true)}</div>`);bind('modal-confirm',async()=>{await fn();$('#modal').close();});}
|
||||
function detail(label,value){return `<div><small>${t(label)}</small><strong>${esc(value)}</strong></div>`;}
|
||||
function liveWriteAllowed(){return state.settings?.mock||state.settings?.write_enabled;}
|
||||
@@ -75,25 +118,38 @@ async function templateEditor(selected){
|
||||
const reset=document.createElement('button');reset.id='reset-template';reset.textContent=t('resetTemplate');$('#save-template').after(reset);bind('reset-template',()=>confirmDialog(t('resetConfirm'),async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'DELETE'});await navigate();}));
|
||||
const save=$('#save-template');const oldSave=save.onclick;save.onclick=async e=>{if([...panel.querySelectorAll('textarea'),raw].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}await oldSave(e);};
|
||||
}
|
||||
function accountSettings(){const section=$('#logout').parentElement;const form=document.createElement('form');form.id='password-form';form.innerHTML=`<h3>${t('changePassword')}</h3><div class="field"><label for="current-password">${t('currentPassword')}</label><input id="current-password" type="password" required autocomplete="current-password"></div><div class="field"><label for="new-password">${t('newPassword')}</label><input id="new-password" type="password" minlength="8" required autocomplete="new-password"></div><button type="submit">${t('changePassword')}</button>`;section.append(form);form.onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/auth/password',{method:'PUT',body:{current_password:$('#current-password').value,new_password:$('#new-password').value}});forgetLogin();shell();login();toast(t('passwordChanged'));});}
|
||||
function accountSettings(){const section=$('#logout').parentElement;const form=document.createElement('form');form.id='password-form';form.innerHTML=`<h3>${t('changePassword')}</h3><div class="form-grid password-fields"><div class="field"><label for="current-password">${t('currentPassword')}</label><input id="current-password" type="password" required autocomplete="current-password"></div><div class="field"><label for="new-password">${t('newPassword')}</label><input id="new-password" type="password" minlength="8" required autocomplete="new-password"></div></div><button type="submit">${t('changePassword')}</button>`;section.append(form);form.onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/auth/password',{method:'PUT',body:{current_password:$('#current-password').value,new_password:$('#new-password').value}});forgetLogin();shell();login();toast(t('passwordChanged'));});}
|
||||
const views={
|
||||
studio:()=>layoutStudioPage(),
|
||||
connections:()=>connectionsPage(),
|
||||
'photo-sources':async()=>{ $('#main').innerHTML='';await photoSourcesPanel(); },
|
||||
async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`<div class="notice warning">${t('lowBattery')}</div>`:''}<section class="card hero"><div class="hero-info"><span class="badge ${d.status==='offline'?'bad':''}">● ${t(d.status).toUpperCase()}</span><h2>${esc(d.name)}</h2><p>${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}</p><div class="actions">${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}</div><p class="hint" style="margin-top:28px">${t('refreshInterval')}</p></div><div class="frame-demo"><div class="mini-art"><div class="sun"></div></div><span class="frame-label">${t('testLabel')}</span></div></section><div class="stats">${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['current',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`<div class="card stat"><div class="label">${t(l)}</div><div class="value">${esc(v)}</div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('device')}</h3><div class="detail-grid">${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('model',t(d.model_verified?'modelVerified':'modelConfigured'))}</div></section><section class="card"><h3>${t('cloudRotation')}</h3><div class="detail-grid">${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}</div><p class="hint">${t('rotationNotice')}</p><a href="#playlists" id="go-playlists">${t('playlists')} →</a></section></div>`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};},
|
||||
async gallery(epoch){let items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets?limit=200&offset='+(state.assetOffset||0)+'&origin='+encodeURIComponent(state.assetOrigin||'all')+(state.assetJob?'&job_id='+encodeURIComponent(state.assetJob):''));if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`<div class="section-heading"><div class="tabs"><button id="cloud-tab" class="${state.tab==='cloud'?'active':''}">${t('cloud')}</button><button id="local-tab" class="${state.tab==='local'?'active':''}">${t('local')}</button></div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}</div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${items.length?`<div class="gallery">${items.map((x,i)=>`<article class="card image-card"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"><h3 title="${esc(x.name)}">${esc(x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:'LOCAL'} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button data-original="${i}">${t('original')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`:`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];showModal(`<h2>${esc(x.name)}</h2><img id="original-image" class="preview" alt="${esc(x.name)}"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#original-image'),x.url);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(x.current?t('deleteCurrent'):t('deleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});},
|
||||
async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;state.devices=state.devices.map(x=>x.id===d.id?{...x,name:d.name}:x);shell();const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`<div class="notice warning">${t('lowBattery')}</div>`:''}<section class="card hero"><div class="hero-info"><span class="badge ${d.status==='offline'?'bad':''}">● ${t(d.status).toUpperCase()}</span><h2>${esc(d.name)}</h2><p>${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}</p><div class="actions">${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}</div><p class="hint" style="margin-top:28px">${t('refreshInterval')}</p></div><div class="frame-demo"></div></section><div class="stats">${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['currentImageId',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`<div class="card stat"><div class="label">${t(l)}</div><div class="value">${esc(v)}</div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('device')}</h3><div class="detail-grid">${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('modelSource',t(d.model_verified?'modelVerified':'modelConfigured'))}</div></section><section class="card"><h3>${t('cloudRotation')}</h3><div class="detail-grid">${detail('playbackMode',s.mode?t(s.mode):'—')}${detail('switchInterval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}</div><p class="hint">${t('rotationNotice')}</p><a href="#playlists" id="go-playlists">${t('playlists')} →</a></section></div>`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};},
|
||||
async gallery(epoch){
|
||||
const destinations=galleryDestinations();if(!destinations.some(d=>d.id===state.tab))state.tab=destinations[0]?.id||'local';
|
||||
const key=JSON.stringify([state.device,state.tab,state.assetOffset||0,state.assetOrigin||'all',state.assetJob||'']);
|
||||
const cache=state.galleryCache||(state.galleryCache=new Map());const cached=cache.get(key);
|
||||
const render=(items,pending=false)=>{if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`<div class="section-heading"><div class="tabs gallery-tabs">${destinations.map(d=>`<button id="${esc(d.id)}-tab" data-gallery-destination="${esc(d.id)}" class="${state.tab===d.id?'active':''}">${esc(galleryDestinationName(d))}</button>`).join('')}</div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}<span id="gallery-list-status" class="hint" role="status" ${pending?'':'hidden'}>${pending?t('galleryListLoading'):''}</span></div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${`<div class="gallery" aria-busy="${pending}">${items.map((x,i)=>`<article class="card image-card"><button class="image-preview" data-original="${i}" aria-label="${t('original')} · ${esc(x.name)}"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"></button><h3 class="${state.tab==='local'?'source-path':''}" title="${esc(x.source_path||x.name)}">${esc(state.tab==='local'?(x.source_path||x.name):x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:t('local')} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`}${!pending&&!items.length?`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`:''}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});enhanceGallery();guardDeviceButtons();hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];await openImageViewer(x.url,x.name);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(state.tab==='local'?t('localDeleteConfirm'):x.current?t('deleteCurrent'):t('cloudDeleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});};
|
||||
render(cached||[],!cached);
|
||||
try{
|
||||
const items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets?limit=200&offset='+(state.assetOffset||0)+'&origin='+encodeURIComponent(state.assetOrigin||'all')+(state.assetJob?'&job_id='+encodeURIComponent(state.assetJob):''));
|
||||
if(epoch!==state.epoch)return;cache.set(key,items);
|
||||
if(!cached||JSON.stringify(cached)!==JSON.stringify(items))render(items);
|
||||
}catch(error){
|
||||
if(epoch!==state.epoch)return;if(!state.token)throw error;
|
||||
const status=$('#gallery-list-status');status.hidden=false;status.setAttribute('role','alert');status.textContent=error.message;
|
||||
$('.gallery')?.setAttribute('aria-busy','false');
|
||||
}
|
||||
},
|
||||
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 settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns"><section class="card"><h3>${t('settings')}</h3><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();}
|
||||
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||operationErrorLabel(o.error)||'—')}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
||||
async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns settings-columns"><section class="card"><h3>${t('settings')}</h3><div class="form-grid settings-preferences"><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();}
|
||||
};
|
||||
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...changes};}
|
||||
async function 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('mode')}</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);}
|
||||
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();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>$('#footer').innerHTML=`<span>Pigway Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`).catch(()=>{});
|
||||
applyTheme();shell();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>$('#footer').innerHTML=`<span>PIGWay Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`).catch(()=>{});
|
||||
setInterval(()=>{if(state.token&&state.page==='dashboard'&&!$('#modal').open)navigate();},60000);
|
||||
|
||||
// Keep Cron available for exceptional schedules, but make ordinary schedules form-based.
|
||||
@@ -101,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 class="automation-table"><thead><tr>${['name','targetFrame','schedule','status','nextRun','lastRun','result','action'].map(key=>`<th>${key==='targetFrame'?tr('目标相框','Target frame'):t(key)}</th>`).join('')}</tr></thead><tbody>${jobs.map((job,index)=>`<tr><td><strong>${esc(job.name)}</strong></td><td>${esc(deviceDisplayName(state.devices.find(d=>d.id===job.device_id)||{id:job.device_id,name:job.device_id}))}</td><td>${esc(scheduleSummary(job.schedule))}</td><td><span class="automation-status">${t(job.enabled?'enabled':'disabled')}</span></td><td>${fmt(job.next_run)}</td><td>${fmt(job.last_run)}</td><td><code>${esc(job.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${index}">${t('edit')}</button><button data-job-toggle="${index}">${t(job.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${index}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;
|
||||
bind('new-job',()=>jobEditor());
|
||||
document.querySelectorAll('[data-job-edit]').forEach(button=>button.onclick=()=>jobEditor(jobs[button.dataset.jobEdit]));
|
||||
document.querySelectorAll('[data-job-toggle]').forEach(button=>button.onclick=busy(async()=>{const job=jobs[button.dataset.jobToggle];await api('/api/v1/jobs/'+job.id,{method:'PUT',body:jobPayload(job,{enabled:!job.enabled})});await navigate();}));
|
||||
|
||||
@@ -2,24 +2,25 @@
|
||||
const tr=(zh,en)=>state.lang==='zh'?zh:en;
|
||||
async function layoutStudioPage(){
|
||||
const assets=await api('/api/v1/assets?source=uploaded&limit=200');let selected=new Set(),plan=null;
|
||||
$('#main').innerHTML=`<div class="notice">${tr('一次选择 1–12 张图片,生成多图拼版或海报。AI 只接收文字与尺寸,不接收照片。预览不会上传到画框。','Choose 1–12 photos for a collage or poster. AI receives text and dimensions, never photos. Preview does not upload to the frame.')}</div><div class="columns"><section class="card"><h3>${tr('选择照片','Choose photos')}</h3><input id="studio-files" type="file" multiple accept="image/jpeg,image/png,image/webp"><p class="hint">${tr('可以一次上传多张,保留原始比例。或勾选已有本地照片。','Upload several photos at once, preserving aspect ratio, or select local photos.')}</p><div id="studio-assets" class="studio-assets">${assets.map(a=>`<label class="check-row"><input type="checkbox" value="${a.asset_id}">${esc(a.name)}</label>`).join('')}</div><div class="field"><label>${tr('标题','Title')}</label><input id="studio-title" maxlength="100"></div><div class="field"><label>${tr('底部文字','Footer text')}</label><textarea id="studio-message" maxlength="500"></textarea></div><div class="form-grid"><div class="field"><label>${tr('布局','Layout')}</label><select id="studio-style"><option value="grid">${tr('均匀拼版','Grid')}</option><option value="hero">${tr('一张主图+多张附图','Hero with supporting photos')}</option><option value="poster">${tr('海报','Poster')}</option></select></div><div class="field"><label>${tr('方向','Orientation')}</label><select id="studio-orientation"><option value="portrait">${tr('竖向','Portrait')}</option><option value="landscape">${tr('横向','Landscape')}</option></select></div></div><div class="field"><label>${t('profile')}</label><select id="studio-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><label class="check-row"><input id="studio-ai" type="checkbox">${tr('使用 AI 排版(先在连接中心配置)','Use AI layout (configure in Connections first)')}</label><div class="field"><label>${tr('AI 排版要求','AI layout instructions')}</label><textarea id="studio-prompt" maxlength="1500" placeholder="${tr('例如:第一张作为主图,其余大小错落','Example: feature the first photo with smaller supporting photos')}"></textarea></div><div class="actions"><button id="studio-plan" class="primary">${tr('规划布局','Plan layout')}</button><button id="studio-render" disabled>${t('render')}</button></div><p class="hint">${tr('规划后可拖动照片区域或修改坐标尺寸。区域不能重叠,超出范围的布局会被拒绝。','After planning, drag photo regions or edit coordinates and sizes. Overlapping or out-of-bounds layouts are rejected.')}</p><div id="studio-region-editor"></div></section><section class="card"><h3>${t('preview')}</h3><div id="studio-layout"></div><div id="studio-preview" class="empty">${tr('先规划布局,再生成最终图片','Plan the layout, then render the final image')}</div><div class="actions"><button id="studio-display" disabled>${t('display')}</button><button id="studio-download" disabled>${t('download')}</button></div></section></div>`;
|
||||
$('#main').innerHTML=`<div class="notice">${tr('一次选择 1–12 张图片,生成多图拼版或海报。AI 只接收文字与尺寸,不接收照片。预览不会上传到画框。','Choose 1–12 photos for a collage or poster. AI receives text and dimensions, never photos. Preview does not upload to the frame.')}</div><div class="columns"><section class="card"><h3>${tr('选择照片','Choose photos')}</h3><input id="studio-files" type="file" multiple accept="image/jpeg,image/png,image/webp"><p class="hint">${tr('可以一次上传多张,保留原始比例。或勾选已有本地照片。','Upload several photos at once, preserving aspect ratio, or select local photos.')}</p><div id="studio-assets" class="studio-assets">${assets.map(a=>`<label class="check-row"><input type="checkbox" value="${a.asset_id}">${esc(a.name)}</label>`).join('')}</div><div class="field"><label for="studio-title">${tr('标题','Title')}</label><input id="studio-title" maxlength="100"></div><div class="field"><label for="studio-message">${tr('底部文字','Footer text')}</label><textarea id="studio-message" maxlength="500"></textarea></div><div class="form-grid"><div class="field"><label for="studio-style">${tr('布局','Layout')}</label><select id="studio-style"><option value="grid">${tr('均匀拼版','Grid')}</option><option value="hero">${tr('一张主图+多张附图','Hero with supporting photos')}</option><option value="poster">${tr('海报','Poster')}</option></select></div><div class="field"><label for="studio-orientation">${tr('方向','Orientation')}</label><select id="studio-orientation"><option value="portrait">${tr('竖向','Portrait')}</option><option value="landscape">${tr('横向','Landscape')}</option></select></div></div><div class="field"><label for="studio-profile">${t('profile')}</label><select id="studio-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><label class="check-row"><input id="studio-ai" type="checkbox">${tr('使用 AI 排版(先在连接中心配置)','Use AI layout (configure in Connections first)')}</label><div class="field"><label for="studio-prompt">${tr('AI 排版要求','AI layout instructions')}</label><textarea id="studio-prompt" maxlength="1500" placeholder="${tr('例如:第一张作为主图,其余大小错落','Example: feature the first photo with smaller supporting photos')}"></textarea></div><div class="actions"><button id="studio-plan" class="primary">${tr('规划布局','Plan layout')}</button><button id="studio-render" disabled>${t('render')}</button></div><p class="hint">${tr('规划后可拖动照片区域或修改坐标尺寸。区域不能重叠,超出范围的布局会被拒绝。','After planning, drag photo regions or edit coordinates and sizes. Overlapping or out-of-bounds layouts are rejected.')}</p><div id="studio-region-editor"></div></section><section class="card"><h3>${t('preview')}</h3><div id="studio-layout"></div><div id="studio-preview" class="empty">${tr('先规划布局,再生成最终图片','Plan the layout, then render the final image')}</div><div class="actions"><button id="studio-display" disabled>${t('display')}</button><button id="studio-download" disabled>${t('download')}</button></div></section></div>`;
|
||||
await autoCollagePanel();
|
||||
let rendered=null;
|
||||
const invalidate=()=>{rendered=null;$('#studio-display').disabled=true;$('#studio-download').disabled=true;};
|
||||
$('#studio-assets').onchange=()=>{selected=new Set([...document.querySelectorAll('#studio-assets input:checked')].map(x=>x.value));plan=null;$('#studio-render').disabled=true;invalidate();};
|
||||
$('#studio-files').onchange=busy(async()=>{const files=[...$('#studio-files').files];if(!files.length)return;const body=new FormData();files.forEach(f=>body.append('files',f));const uploaded=await api('/api/v1/layout/images',{method:'POST',body});for(const a of uploaded){const label=document.createElement('label');label.className='check-row';label.innerHTML=`<input type="checkbox" checked value="${a.asset_id}">${esc(a.name)}`;$('#studio-assets').append(label);}$('#studio-assets').onchange();toast(t('done'));});
|
||||
async function showPlan(){
|
||||
const box=$('#studio-layout');box.innerHTML='';box.className='studio-canvas';box.style.aspectRatio=plan.width+'/'+plan.height;
|
||||
$('#studio-region-editor').innerHTML=plan.regions.map((r,i)=>`<div class="field"><label>${tr('图片区域','Photo region')} ${i+1}</label><div class="region-fields">${['x','y','width','height'].map(k=>`<label>${k}<input data-region="${i}" data-key="${k}" type="number" value="${r[k]}" min="0"></label>`).join('')}</div></div>`).join('');
|
||||
const update=(el,r)=>{el.style.left=(r.x/plan.width*100)+'%';el.style.top=(r.y/plan.height*100)+'%';el.style.width=(r.width/plan.width*100)+'%';el.style.height=(r.height/plan.height*100)+'%';};
|
||||
for(const [i,r] of plan.regions.entries()){
|
||||
const img=document.createElement('img');img.alt=tr('拖动图片区域','Drag photo region')+' '+(i+1);img.draggable=false;update(img,r);box.append(img);await blobImage(img,'/api/v1/assets/'+r.asset_id);
|
||||
img.onpointerdown=e=>{e.preventDefault();img.setPointerCapture(e.pointerId);const start={x:e.clientX,y:e.clientY,rx:r.x,ry:r.y};img.onpointermove=ev=>{r.x=Math.max(0,Math.min(plan.width-r.width,Math.round(start.rx+(ev.clientX-start.x)*plan.width/box.clientWidth)));r.y=Math.max(150,Math.min(plan.height-120-r.height,Math.round(start.ry+(ev.clientY-start.y)*plan.height/box.clientHeight)));update(img,r);for(const el of document.querySelectorAll(`[data-region="${i}"]`))el.value=r[el.dataset.key];invalidate();};img.onpointerup=()=>{img.onpointermove=null;};};
|
||||
function showPlan(){
|
||||
const layout=plan;
|
||||
const box=$('#studio-layout');box.innerHTML='';box.className='studio-canvas';box.style.aspectRatio=layout.width+'/'+layout.height;
|
||||
$('#studio-region-editor').innerHTML=layout.regions.map((r,i)=>`<div class="field"><label>${tr('图片区域','Photo region')} ${i+1}</label><div class="region-fields">${['x','y','width','height'].map(k=>`<label>${k}<input data-region="${i}" data-key="${k}" type="number" value="${r[k]}" min="0"></label>`).join('')}</div></div>`).join('');
|
||||
const update=(el,r)=>{el.style.left=(r.x/layout.width*100)+'%';el.style.top=(r.y/layout.height*100)+'%';el.style.width=(r.width/layout.width*100)+'%';el.style.height=(r.height/layout.height*100)+'%';};
|
||||
for(const [i,r] of layout.regions.entries()){
|
||||
const region=document.createElement('div');region.className='studio-region';region.dataset.regionIndex=String(i);update(region,r);box.append(region);const img=document.createElement('img');img.alt=tr('拖动图片区域','Drag photo region')+' '+(i+1);img.draggable=false;region.append(img);void blobImage(img,'/api/v1/assets/'+r.asset_id);
|
||||
region.onpointerdown=e=>{if(plan!==layout)return;e.preventDefault();region.setPointerCapture(e.pointerId);const start={x:e.clientX,y:e.clientY,rx:r.x,ry:r.y};region.onpointermove=ev=>{if(plan!==layout)return;r.x=Math.max(0,Math.min(layout.width-r.width,Math.round(start.rx+(ev.clientX-start.x)*layout.width/box.clientWidth)));r.y=Math.max(150,Math.min(layout.height-120-r.height,Math.round(start.ry+(ev.clientY-start.y)*layout.height/box.clientHeight)));update(region,r);for(const el of document.querySelectorAll(`[data-region="${i}"]`))el.value=r[el.dataset.key];invalidate();};region.onpointerup=region.onpointercancel=()=>{region.onpointermove=null;};};
|
||||
}
|
||||
document.querySelectorAll('[data-region]').forEach(el=>el.oninput=()=>{const r=plan.regions[Number(el.dataset.region)];r[el.dataset.key]=Number(el.value);update(box.children[Number(el.dataset.region)],r);invalidate();});
|
||||
document.querySelectorAll('[data-region]').forEach(el=>el.oninput=()=>{if(plan!==layout)return;const r=layout.regions[Number(el.dataset.region)];r[el.dataset.key]=Number(el.value);update(box.children[Number(el.dataset.region)],r);invalidate();});
|
||||
}
|
||||
for(const id of ['studio-title','studio-message','studio-style','studio-orientation','studio-ai','studio-prompt','studio-profile'])$('#'+id).oninput=()=>{plan=null;$('#studio-render').disabled=true;invalidate();};
|
||||
bind('studio-plan',async()=>{plan=await api('/api/v1/layout/plan',{method:'POST',body:{asset_ids:[...selected],title:$('#studio-title').value,message:$('#studio-message').value,orientation:$('#studio-orientation').value,style:$('#studio-style').value,use_ai:$('#studio-ai').checked,prompt:$('#studio-prompt').value}});invalidate();await showPlan();$('#studio-render').disabled=false;});
|
||||
bind('studio-plan',async()=>{plan=await api('/api/v1/layout/plan',{method:'POST',body:{asset_ids:[...selected],title:$('#studio-title').value,message:$('#studio-message').value,orientation:$('#studio-orientation').value,style:$('#studio-style').value,use_ai:$('#studio-ai').checked,prompt:$('#studio-prompt').value}});invalidate();showPlan();$('#studio-render').disabled=false;});
|
||||
bind('studio-render',async()=>{if(!plan)return;const accepted=await api('/api/v1/render',{method:'POST',body:{type:'template',template:$('#studio-style').value==='poster'?'poster':'collage',data:{layout:plan},profile:$('#studio-profile').value}});const op=await waitOperation(accepted.operation_id);rendered=op.result;$('#studio-preview').className='';$('#studio-preview').innerHTML='<img class="preview" id="studio-result" alt="Preview">';await blobImage($('#studio-result'),rendered.url);$('#studio-display').disabled=!liveWriteAllowed();$('#studio-download').disabled=false;});
|
||||
bind('studio-display',()=>displayDialog({type:'image',asset_id:rendered.asset_id}));bind('studio-download',()=>downloadImage(rendered.url,'frame-flow-layout.jpg'));
|
||||
}
|
||||
@@ -28,34 +29,34 @@ async function connectionsPage(){
|
||||
await aiSettingsPanel();await aiImagePanel();await storageSettingsPanel();await clientsPanel();await webhooksPanel();await externalProvidersPanel();await mqttPanel();
|
||||
}
|
||||
async function aiSettingsPanel(){
|
||||
const config=await api('/api/v1/settings/ai-layout');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('AI 排版 · OpenAI 兼容','AI layout · OpenAI compatible')}</h3><p class="hint">${tr('只发送文字、尺寸和排版要求,不发送照片。填写完整 chat/completions 地址;使用服务商的模型名称。','Only text, dimensions and instructions are sent; never photos. Enter the full chat/completions URL and provider model name.')}</p><form id="ai-settings-form"><div class="field"><label>Endpoint</label><input id="ai-endpoint" type="url" value="${esc(config.endpoint)}" placeholder="https://…/v1/chat/completions"></div><div class="field"><label>Model</label><input id="ai-model" value="${esc(config.model)}"></div><div class="field"><label>${tr('API Key(留空保留已有密钥)','API key (blank keeps existing)')}</label><input id="ai-key" type="password" autocomplete="off"><p class="hint">${t('keyConfigured')}: ${t(config.key_configured?'yes':'no')}</p></div><button type="submit">${t('save')}</button></form>`;$('#main').append(card);$('#ai-settings-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-layout',{method:'PUT',body:{endpoint:$('#ai-endpoint').value,model:$('#ai-model').value}});const key=$('#ai-key').value;$('#ai-key').value='';if(key)await api('/api/v1/settings/ai-layout/key',{method:'PUT',body:{key}});toast(t('done'));});
|
||||
const config=await api('/api/v1/settings/ai-layout');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('AI 排版 · OpenAI 兼容','AI layout · OpenAI compatible')}</h3><p class="hint">${tr('只发送文字、尺寸和排版要求,不发送照片。填写完整 chat/completions 地址;使用服务商的模型名称。','Only text, dimensions and instructions are sent; never photos. Enter the full chat/completions URL and provider model name.')}</p><form id="ai-settings-form"><div class="field"><label for="ai-endpoint">Endpoint</label><input id="ai-endpoint" type="url" value="${esc(config.endpoint)}" placeholder="https://…/v1/chat/completions"></div><div class="field"><label for="ai-model">Model</label><input id="ai-model" value="${esc(config.model)}"></div><div class="field"><label for="ai-key">${tr('API Key(留空保留已有密钥)','API key (blank keeps existing)')}</label><input id="ai-key" type="password" autocomplete="off"><p class="hint">${t('apiKeyConfigured')}: ${t(config.key_configured?'yes':'no')}</p></div><button type="submit">${t('save')}</button></form>`;$('#main').append(card);$('#ai-settings-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-layout',{method:'PUT',body:{endpoint:$('#ai-endpoint').value,model:$('#ai-model').value}});const key=$('#ai-key').value;$('#ai-key').value='';if(key)await api('/api/v1/settings/ai-layout/key',{method:'PUT',body:{key}});toast(t('done'));});
|
||||
}
|
||||
async function storageSettingsPanel(){
|
||||
const data=await api('/api/v1/storage');const p=data.policy;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('本地图库保留策略','Local storage retention')}</h3><p>${data.asset_count} ${t('count')} · ${(data.total_bytes/1024/1024).toFixed(1)} MiB · ${tr('受保护','Protected')}: ${data.protected_count}</p><p class="hint">${tr('仅清理生成图和预览缓存;手动上传、星标、当前/恢复图片以及任务/模板引用会保留。不会删除 NAS 原照片或云端图片。先查看清理预估,再开启自动清理。','Only generated images and preview cache are cleaned. Manual uploads, stars, current/restore content and task/template references are retained. NAS originals and cloud images are untouched. Preview cleanup before enabling it.')}</p><form id="storage-policy"><label class="check-row"><input id="storage-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="form-grid"><div class="field"><label>${tr('生成图保留天数','Generated image retention days')}</label><input id="storage-days" type="number" min="1" value="${p.generated_days}"></div><div class="field"><label>${tr('缓存保留天数','Cache retention days')}</label><input id="storage-cache-days" type="number" min="1" value="${p.cache_days}"></div><div class="field"><label>${tr('容量目标 MiB','Target size MiB')}</label><input id="storage-size" type="number" min="1" value="${p.max_bytes/1024/1024}"></div></div><div class="actions"><button type="submit">${t('save')}</button><button id="storage-preview" type="button">${tr('预估清理','Preview cleanup')}</button></div><p id="storage-estimate"></p></form>`;$('#main').append(card);$('#storage-policy').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/storage/policy',{method:'PUT',body:{enabled:$('#storage-enabled').checked,generated_days:Number($('#storage-days').value),cache_days:Number($('#storage-cache-days').value),max_bytes:Number($('#storage-size').value)*1024*1024}});toast(t('done'));});bind('storage-preview',async()=>{const result=await api('/api/v1/storage/cleanup?dry_run=true',{method:'POST'});$('#storage-estimate').textContent=result.disabled?tr('策略未启用;不会清理','Policy disabled; nothing is removed'):tr('可清理 ','Eligible: ')+result.deleted+' · '+(result.bytes_freed/1024/1024).toFixed(1)+' MiB';});
|
||||
const data=await api('/api/v1/storage');const p=data.policy;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('本地图库保留策略','Local storage retention')}</h3><p>${data.asset_count} ${t('count')} · ${(data.total_bytes/1024/1024).toFixed(1)} MiB · ${tr('受保护','Protected')}: ${data.protected_count}</p><p class="hint">${tr('仅清理生成图和预览缓存;手动上传、星标、当前/恢复图片以及任务/模板引用会保留。不会删除 NAS 原照片或云端图片。先查看清理预估,再开启自动清理。','Only generated images and preview cache are cleaned. Manual uploads, stars, current/restore content and task/template references are retained. NAS originals and cloud images are untouched. Preview cleanup before enabling it.')}</p><form id="storage-policy"><label class="check-row"><input id="storage-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="form-grid"><div class="field"><label for="storage-days">${tr('生成图保留天数','Generated image retention days')}</label><input id="storage-days" type="number" min="1" value="${p.generated_days}"></div><div class="field"><label for="storage-cache-days">${tr('缓存保留天数','Cache retention days')}</label><input id="storage-cache-days" type="number" min="1" value="${p.cache_days}"></div><div class="field"><label for="storage-size">${tr('容量目标 MiB','Target size MiB')}</label><input id="storage-size" type="number" min="1" value="${p.max_bytes/1024/1024}"></div></div><div class="actions"><button type="submit">${t('save')}</button><button id="storage-preview" type="button">${tr('预估清理','Preview cleanup')}</button></div><p id="storage-estimate"></p></form>`;$('#main').append(card);$('#storage-policy').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/storage/policy',{method:'PUT',body:{enabled:$('#storage-enabled').checked,generated_days:Number($('#storage-days').value),cache_days:Number($('#storage-cache-days').value),max_bytes:Number($('#storage-size').value)*1024*1024}});toast(t('done'));});bind('storage-preview',async()=>{const result=await api('/api/v1/storage/cleanup?dry_run=true',{method:'POST'});$('#storage-estimate').textContent=result.disabled?tr('策略未启用;不会清理','Policy disabled; nothing is removed'):tr('可清理 ','Eligible: ')+result.deleted+' · '+(result.bytes_freed/1024/1024).toFixed(1)+' MiB';});
|
||||
}
|
||||
async function clientsPanel(){
|
||||
const clients=await api('/api/v1/clients');const scopes=['devices:read','images:read','display:write','templates:read','render:write','jobs:read','jobs:write','history:read'];const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('外部客户端权限','External client permissions')}</h3><p class="hint">${tr('网页继续使用账号登录。这里只为其他程序创建独立凭据,可随时撤销。','The web UI still uses account login. These credentials are for other programs and can be revoked.')}</p>${clients.map(c=>`<p>${esc(c.name)} · ${esc(c.client_id)} · ${t(c.enabled?'enabled':'disabled')} ${c.client_id!=='admin'&&c.enabled?`<button data-revoke="${esc(c.client_id)}">${tr('撤销','Revoke')}</button>`:''}</p>`).join('')}<form id="client-form"><div class="field"><label>Client ID</label><input id="client-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label>${t('name')}</label><input id="client-name" required></div>${scopes.map(s=>`<label class="check-row"><input type="checkbox" name="client-scope" value="${s}" ${['devices:read','display:write','templates:read','render:write'].includes(s)?'checked':''}>${s}</label>`).join('')}<button type="submit">${tr('创建客户端','Create client')}</button><div id="client-secret" class="notice" hidden></div></form>`;$('#main').append(card);document.querySelectorAll('[data-revoke]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/clients/'+b.dataset.revoke,{method:'DELETE'});await navigate();}));$('#client-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/clients',{method:'POST',body:{client_id:$('#client-id').value,name:$('#client-name').value,permissions:[...document.querySelectorAll('[name="client-scope"]:checked')].map(x=>x.value)}});$('#client-secret').hidden=false;$('#client-secret').textContent=tr('仅显示一次,请保存:','Shown once; save it: ')+r.token;});
|
||||
const clients=await api('/api/v1/clients');const scopes=['devices:read','images:read','display:write','templates:read','render:write','jobs:read','jobs:write','history:read'];const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('外部客户端权限','External client permissions')}</h3><p class="hint">${tr('网页继续使用账号登录。这里只为其他程序创建独立凭据,可随时撤销。','The web UI still uses account login. These credentials are for other programs and can be revoked.')}</p>${clients.map(c=>`<p>${esc(c.name)} · ${esc(c.client_id)} · ${t(c.enabled?'enabled':'disabled')} ${c.client_id!=='admin'&&c.enabled?`<button data-revoke="${esc(c.client_id)}">${tr('撤销','Revoke')}</button>`:''}</p>`).join('')}<form id="client-form"><div class="field"><label for="client-id">Client ID</label><input id="client-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label for="client-name">${t('name')}</label><input id="client-name" required></div>${scopes.map(s=>`<label class="check-row"><input type="checkbox" name="client-scope" value="${s}" ${['devices:read','display:write','templates:read','render:write'].includes(s)?'checked':''}>${s}</label>`).join('')}<button type="submit">${tr('创建客户端','Create client')}</button><div id="client-secret" class="notice" hidden></div></form>`;$('#main').append(card);document.querySelectorAll('[data-revoke]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/clients/'+b.dataset.revoke,{method:'DELETE'});await navigate();}));$('#client-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/clients',{method:'POST',body:{client_id:$('#client-id').value,name:$('#client-name').value,permissions:[...document.querySelectorAll('[name="client-scope"]:checked')].map(x=>x.value)}});$('#client-secret').hidden=false;$('#client-secret').textContent=tr('仅显示一次,请保存:','Shown once; save it: ')+r.token;});
|
||||
}
|
||||
async function webhooksPanel(){
|
||||
const hooks=await api('/api/v1/webhooks');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>Webhook</h3><p class="hint">${tr('HTTPS 地址须先加入设置中的允许主机。事件带 HMAC 签名,最多重试三次;接收端应按事件 ID 去重。','Add the HTTPS host to the Settings allowlist first. Events are HMAC signed, with up to three attempts; deduplicate by event ID.')}</p>${hooks.map(h=>`<p>${esc(h.callback_url)} <button data-hook-delete="${h.id}">${t('delete')}</button></p>`).join('')}<form id="hook-form"><div class="field"><label>Callback URL</label><input id="hook-url" type="url" required></div><div class="field"><label>${tr('事件(逗号分隔)','Events (comma separated)')}</label><input id="hook-events" value="operation.completed,operation.failed"></div><button type="submit">${t('save')}</button><p id="hook-secret"></p></form>`;$('#main').append(card);document.querySelectorAll('[data-hook-delete]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/webhooks/'+b.dataset.hookDelete,{method:'DELETE'});await navigate();}));$('#hook-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/webhooks',{method:'POST',body:{callback_url:$('#hook-url').value,event_types:$('#hook-events').value.split(',').map(x=>x.trim()).filter(Boolean)}});$('#hook-secret').textContent=tr('签名密钥仅显示一次:','Signing key shown once: ')+r.signing_secret;});
|
||||
const hooks=await api('/api/v1/webhooks');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>Webhook</h3><p class="hint">${tr('HTTPS 地址须先加入设置中的允许主机。事件带 HMAC 签名,最多重试三次;接收端应按事件 ID 去重。','Add the HTTPS host to the Settings allowlist first. Events are HMAC signed, with up to three attempts; deduplicate by event ID.')}</p>${hooks.map(h=>`<p>${esc(h.callback_url)} <button data-hook-delete="${h.id}">${t('delete')}</button></p>`).join('')}<form id="hook-form"><div class="field"><label for="hook-url">Callback URL</label><input id="hook-url" type="url" required></div><div class="field"><label for="hook-events">${tr('事件(逗号分隔)','Events (comma separated)')}</label><input id="hook-events" value="operation.completed,operation.failed"></div><button type="submit">${t('save')}</button><p id="hook-secret"></p></form>`;$('#main').append(card);document.querySelectorAll('[data-hook-delete]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/webhooks/'+b.dataset.hookDelete,{method:'DELETE'});await navigate();}));$('#hook-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/webhooks',{method:'POST',body:{callback_url:$('#hook-url').value,event_types:$('#hook-events').value.split(',').map(x=>x.trim()).filter(Boolean)}});$('#hook-secret').textContent=tr('签名密钥仅显示一次:','Signing key shown once: ')+r.signing_secret;});
|
||||
}
|
||||
async function externalProvidersPanel(){
|
||||
const providers=await api('/api/v1/providers/external');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('日历、家庭状态与监控','Calendars, home state and monitoring')}</h3><p class="hint">${tr('Google Calendar 使用 OAuth Token;家庭日历可用 ICS。Home Assistant 和 NAS/树莓派/服务器监控可以通过只读 JSON 接口提供数据,不访问外部数据库。LAN 访问必须明确开启。','Google Calendar uses an OAuth token; family calendars can use ICS. Home Assistant and NAS/Pi/server monitoring supply read-only JSON data without database access. LAN access requires explicit opt-in.')}</p>${providers.map(p=>`<p>${esc(p.name)} · ${esc(p.kind)} <button data-provider-read="${p.id}">${tr('读取','Read')}</button><button data-provider-job="${p.id}">${tr('创建展示任务','Create display job')}</button></p>`).join('')}<form id="provider-form"><div class="field"><label>ID</label><input id="provider-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label>${t('name')}</label><input id="provider-name" required></div><div class="field"><label>${tr('类型','Type')}</label><select id="provider-kind"><option value="google-calendar">Google Calendar</option><option value="ics">ICS · ${tr('家庭日历','Family calendar')}</option><option value="home-assistant">Home Assistant</option><option value="http-json">HTTP JSON · NAS / Pi / Server</option></select></div><div class="field"><label>${tr('地址(Google 日历填写 Calendar ID)','URL (Calendar ID for Google)')}</label><input id="provider-url" required></div><div class="field"><label>${tr('Token(可选)','Token (optional)')}</label><input id="provider-key" type="password" autocomplete="off"></div><label class="check-row"><input id="provider-lan" type="checkbox">${tr('允许访问指定 LAN 地址','Allow access to this LAN address')}</label><button type="submit">${t('save')}</button></form><pre id="provider-result"></pre>`;$('#main').append(card);$('#provider-form').onsubmit=busy(async e=>{e.preventDefault();const id=$('#provider-id').value;await api('/api/v1/providers/external/'+id,{method:'PUT',body:{kind:$('#provider-kind').value,name:$('#provider-name').value,endpoint:$('#provider-url').value,allow_insecure_lan:$('#provider-lan').checked}});const key=$('#provider-key').value;$('#provider-key').value='';if(key)await api('/api/v1/providers/external/'+id+'/key',{method:'PUT',body:{key}});await navigate();});document.querySelectorAll('[data-provider-read]').forEach(b=>b.onclick=busy(async()=>{$('#provider-result').textContent=JSON.stringify(await api('/api/v1/providers/external/'+b.dataset.providerRead+'/data'),null,2);}));document.querySelectorAll('[data-provider-job]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/jobs',{method:'POST',body:{device_id:state.device,name:tr('外部信息板','External dashboard'),enabled:false,schedule:{type:'daily',time:'07:00'},source:{type:'provider',provider_id:b.dataset.providerJob,template:'daily-dashboard',data:{title:tr('家庭信息','Home information')}},display_duration:0,restore_previous:false,priority:50}});await navigate('automation');}));
|
||||
const providers=await api('/api/v1/providers/external');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('日历、家庭状态与监控','Calendars, home state and monitoring')}</h3><p class="hint">${tr('Google Calendar 使用 OAuth Token;家庭日历可用 ICS。Home Assistant 和 NAS/树莓派/服务器监控可以通过只读 JSON 接口提供数据,不访问外部数据库。LAN 访问必须明确开启。','Google Calendar uses an OAuth token; family calendars can use ICS. Home Assistant and NAS/Pi/server monitoring supply read-only JSON data without database access. LAN access requires explicit opt-in.')}</p>${providers.map(p=>`<p>${esc(p.name)} · ${esc(p.kind)} <button data-provider-read="${p.id}">${tr('读取','Read')}</button><button data-provider-job="${p.id}">${tr('创建展示任务','Create display job')}</button></p>`).join('')}<form id="provider-form"><div class="field"><label for="provider-id">ID</label><input id="provider-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label for="provider-name">${t('name')}</label><input id="provider-name" required></div><div class="field"><label for="provider-kind">${tr('类型','Type')}</label><select id="provider-kind"><option value="google-calendar">Google Calendar</option><option value="ics">ICS · ${tr('家庭日历','Family calendar')}</option><option value="home-assistant">Home Assistant</option><option value="http-json">HTTP JSON · NAS / Pi / Server</option></select></div><div class="field"><label for="provider-url">${tr('地址(Google 日历填写 Calendar ID)','URL (Calendar ID for Google)')}</label><input id="provider-url" required></div><div class="field"><label for="provider-key">${tr('Token(可选)','Token (optional)')}</label><input id="provider-key" type="password" autocomplete="off"></div><label class="check-row"><input id="provider-lan" type="checkbox">${tr('允许访问指定 LAN 地址','Allow access to this LAN address')}</label><button type="submit">${t('save')}</button></form><pre id="provider-result"></pre>`;$('#main').append(card);$('#provider-form').onsubmit=busy(async e=>{e.preventDefault();const id=$('#provider-id').value;await api('/api/v1/providers/external/'+id,{method:'PUT',body:{kind:$('#provider-kind').value,name:$('#provider-name').value,endpoint:$('#provider-url').value,allow_insecure_lan:$('#provider-lan').checked}});const key=$('#provider-key').value;$('#provider-key').value='';if(key)await api('/api/v1/providers/external/'+id+'/key',{method:'PUT',body:{key}});await navigate();});document.querySelectorAll('[data-provider-read]').forEach(b=>b.onclick=busy(async()=>{$('#provider-result').textContent=JSON.stringify(await api('/api/v1/providers/external/'+b.dataset.providerRead+'/data'),null,2);}));document.querySelectorAll('[data-provider-job]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/jobs',{method:'POST',body:{device_id:state.device,name:tr('外部信息板','External dashboard'),enabled:false,schedule:{type:'daily',time:'07:00'},source:{type:'provider',provider_id:b.dataset.providerJob,template:'daily-dashboard',data:{title:tr('家庭信息','Home information')}},display_duration:0,restore_previous:false,priority:50}});await navigate('automation');}));
|
||||
}
|
||||
async function officialWidgetControls(info,data){
|
||||
const panel=document.createElement('section');panel.className='card';panel.innerHTML=`<h3>${tr('订阅管理','Subscription management')}</h3><p class="hint">${tr('这与本地模板独立。请选择官方目录中的小组件,填写其参数后再确认。取消订阅不会自动恢复之前的照片。','This is separate from local templates. Choose a widget from the official catalog and fill its parameters before confirming. Cancelling does not automatically restore a previous photo.')}</p><div class="field"><label>${tr('官方小组件','Official widget')}</label><select id="official-widget-choice">${data.catalog.map(w=>`<option value="${w.id}">${esc(w.name)}</option>`).join('')}</select></div><div id="official-widget-args"></div><div class="actions"><button id="official-widget-use" class="primary">${tr('确认使用','Apply widget')}</button><button id="official-widget-cancel">${tr('取消订阅','Cancel subscription')}</button></div>`;info.append(panel);const disabled=!data.configured||!liveWriteAllowed();$('#official-widget-use').disabled=disabled;$('#official-widget-cancel').disabled=disabled;
|
||||
const panel=document.createElement('section');panel.className='card';panel.innerHTML=`<h3>${tr('订阅管理','Subscription management')}</h3><p class="hint">${tr('这与本地模板独立。请选择官方目录中的小组件,填写其参数后再确认。取消订阅不会自动恢复之前的照片。','This is separate from local templates. Choose a widget from the official catalog and fill its parameters before confirming. Cancelling does not automatically restore a previous photo.')}</p><div class="field"><label for="official-widget-choice">${tr('官方小组件','Official widget')}</label><select id="official-widget-choice">${data.catalog.map(w=>`<option value="${w.id}">${esc(w.name)}</option>`).join('')}</select></div><div id="official-widget-args"></div><div class="actions"><button id="official-widget-use" class="primary">${tr('确认使用','Apply widget')}</button><button id="official-widget-cancel">${tr('取消订阅','Cancel subscription')}</button></div>`;info.append(panel);const disabled=!data.configured||!liveWriteAllowed();$('#official-widget-use').disabled=disabled;$('#official-widget-cancel').disabled=disabled;
|
||||
const fields=()=>{const w=data.catalog.find(w=>w.id===Number($('#official-widget-choice').value));$('#official-widget-args').innerHTML=(w?.arg_keys||[]).map(k=>`<div class="field"><label>${esc(k)}</label><input data-widget-arg="${esc(k)}" maxlength="2000"></div>`).join('');};if(data.current)$('#official-widget-choice').value=data.current.id;fields();$('#official-widget-choice').onchange=fields;
|
||||
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>${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>${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>${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>${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>${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
|
||||
const box=document.createElement('section');box.innerHTML=`<div id="source-local-quality"><h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${tr('对 API 获取的预览图按清晰度、亮度和相似度筛选,不修改或删除 NAS 原照片;无法保证识别闭眼。建议先使用较低阈值。','Filters API previews by sharpness, brightness and similarity without modifying or deleting NAS originals. Closed-eye detection is not guaranteed. Start with low thresholds.')}</p><div class="field"><label for="source-people-space">${tr('照片空间','Photo space')}</label><select id="source-people-space"><option value="shared">${tr('共享空间','Shared space')}</option><option value="personal">${tr('个人空间','Personal space')}</option></select></div><div class="form-grid">${[['short','minimum_short_edge',tr('最小短边(像素)','Minimum short edge (px)')],['long','minimum_long_edge',tr('最小长边(像素)','Minimum long edge (px)')]].map(([id,key,label])=>`<div class="field"><label for="source-min-${id}">${label}</label><input id="source-min-${id}" type="number" min="0" max="50000" step="1" value="${source?.[key]||0}"></div>`).join('')}</div><p class="hint">${tr('0 表示不限制;按源照片和实际取得的预览图像素筛选,横竖图通用。可从短边 1200、长边 1600 开始设置;分辨率不代表对焦清晰度。','0 disables. Checks source and downloaded preview dimensions regardless of orientation. Start with short edge 1200 and long edge 1600; pixel count does not measure focus.')}</p><div class="form-grid"><div class="field"><label for="source-sharpness">${tr('最低清晰度(0 不筛选)','Minimum sharpness (0 disables)')}</label><input id="source-sharpness" type="number" min="0" max="10000" value="${source?.minimum_sharpness||0}"></div><div class="field"><label for="source-bright-min">${tr('最低亮度','Minimum brightness')}</label><input id="source-bright-min" type="number" min="0" max="255" value="${source?.minimum_brightness||0}"></div><div class="field"><label for="source-bright-max">${tr('最高亮度','Maximum brightness')}</label><input id="source-bright-max" type="number" min="0" max="255" value="${source?.maximum_brightness??255}"></div></div><label class="check-row"><input id="source-dedup" type="checkbox" ${source?.avoid_duplicates?'checked':''}>${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}</label></div><div class="field"><label for="source-oauth">${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
|
||||
|
||||
}
|
||||
async function aiImagePanel(){
|
||||
const info=await api('/api/v1/settings/ai-images');const conf=info.config||{};const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('AI 生图','AI image generation')}</h3><p class="hint">${tr('独立配置 images/generations 接口。每次仅生成一张并保存本地,不会自动显示到画框。请求可能收费,失败不自动重试。','Configure a separate images/generations endpoint. One image is saved locally per request, never automatically displayed. Requests may be billed; failures are not retried.')}</p><form id="image-ai-config"><div class="field"><label>Endpoint</label><input id="image-ai-url" type="url" required value="${esc(conf.endpoint||'')}"></div><div class="field"><label>Model</label><input id="image-ai-model" required value="${esc(conf.model||'')}"></div><div class="field"><label>Size</label><select id="image-ai-size"><option>1024x1536</option><option>1536x1024</option><option>1024x1024</option></select></div><div class="field"><label>API Key</label><input id="image-ai-key" type="password" autocomplete="off"><p class="hint">${t('keyConfigured')}: ${t(info.key_configured?'yes':'no')}</p></div><button type="submit">${t('save')}</button></form><form id="image-ai-generate"><div class="field"><label>${tr('画面描述','Image prompt')}</label><textarea id="image-ai-prompt" required maxlength="4000"></textarea></div><button type="submit">${tr('生成一张(可能收费)','Generate one image (may incur charges)')}</button></form><div id="image-ai-preview"></div>`;$('#main').append(card);$('#image-ai-size').value=conf.size||'1024x1536';$('#image-ai-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-images',{method:'PUT',body:{endpoint:$('#image-ai-url').value,model:$('#image-ai-model').value,size:$('#image-ai-size').value}});const key=$('#image-ai-key').value;$('#image-ai-key').value='';if(key)await api('/api/v1/settings/ai-images/key',{method:'PUT',body:{key}});toast(t('done'));});$('#image-ai-generate').onsubmit=busy(async e=>{e.preventDefault();const result=await api('/api/v1/ai/images',{method:'POST',body:{prompt:$('#image-ai-prompt').value},headers:{'Idempotency-Key':'ai-image-'+clientUUID()}});const op=await waitOperation(result.operation_id);$('#image-ai-preview').innerHTML='<img class="preview" id="image-ai-result" alt="Generated image">';await blobImage($('#image-ai-result'),op.result.url);toast(tr('已存入本地图库','Saved to local gallery'));});
|
||||
const info=await api('/api/v1/settings/ai-images');const conf=info.config||{};const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('AI 生图','AI image generation')}</h3><p class="hint">${tr('独立配置 images/generations 接口。每次仅生成一张并保存本地,不会自动显示到画框。请求可能收费,失败不自动重试。','Configure a separate images/generations endpoint. One image is saved locally per request, never automatically displayed. Requests may be billed; failures are not retried.')}</p><form id="image-ai-config"><div class="field"><label for="image-ai-url">Endpoint</label><input id="image-ai-url" type="url" required value="${esc(conf.endpoint||'')}"></div><div class="field"><label for="image-ai-model">Model</label><input id="image-ai-model" required value="${esc(conf.model||'')}"></div><div class="field"><label for="image-ai-size">Size</label><select id="image-ai-size"><option>1024x1536</option><option>1536x1024</option><option>1024x1024</option></select></div><div class="field"><label for="image-ai-key">API Key</label><input id="image-ai-key" type="password" autocomplete="off"><p class="hint">${t('apiKeyConfigured')}: ${t(info.key_configured?'yes':'no')}</p></div><button type="submit">${t('save')}</button></form><form id="image-ai-generate"><div class="field"><label for="image-ai-prompt">${tr('画面描述','Image prompt')}</label><textarea id="image-ai-prompt" required maxlength="4000"></textarea></div><button type="submit">${tr('生成一张(可能收费)','Generate one image (may incur charges)')}</button></form><div id="image-ai-preview"></div>`;$('#main').append(card);$('#image-ai-size').value=conf.size||'1024x1536';$('#image-ai-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-images',{method:'PUT',body:{endpoint:$('#image-ai-url').value,model:$('#image-ai-model').value,size:$('#image-ai-size').value}});const key=$('#image-ai-key').value;$('#image-ai-key').value='';if(key)await api('/api/v1/settings/ai-images/key',{method:'PUT',body:{key}});toast(t('done'));});$('#image-ai-generate').onsubmit=busy(async e=>{e.preventDefault();const result=await api('/api/v1/ai/images',{method:'POST',body:{prompt:$('#image-ai-prompt').value},headers:{'Idempotency-Key':'ai-image-'+clientUUID()}});const op=await waitOperation(result.operation_id);$('#image-ai-preview').innerHTML='<img class="preview" id="image-ai-result" alt="Generated image">';await blobImage($('#image-ai-result'),op.result.url);toast(tr('已存入本地图库','Saved to local gallery'));});
|
||||
}
|
||||
async function mqttPanel(){
|
||||
const status=await api('/api/v1/integrations/mqtt');const c=status.config;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>MQTT · ${t(status.connected?'connected':'disconnected')}</h3><p class="hint">${tr('默认关闭。须配置环境 Secret FRAME_FLOW_MQTT_TOKEN(有 display:write 权限的客户端 Token),以及可选 FRAME_FLOW_MQTT_PASSWORD。消息必须携带 request_id;retained 消息被拒绝。结果发送到命令主题的 /result。','Disabled by default. Configure FRAME_FLOW_MQTT_TOKEN with display:write scope and optional FRAME_FLOW_MQTT_PASSWORD as environment secrets. Commands require request_id; retained commands are rejected. Results are published to the command topic plus /result.')}</p><form id="mqtt-config"><label class="check-row"><input id="mqtt-enabled" type="checkbox" ${c.enabled?'checked':''}>${t('enabled')}</label><div class="field"><label>Host</label><input id="mqtt-host" value="${esc(c.host)}"></div><div class="field"><label>Port</label><input id="mqtt-port" type="number" min="1" max="65535" value="${c.port}"></div><div class="field"><label>Topic</label><input id="mqtt-topic" value="${esc(c.topic)}"></div><div class="field"><label>${t('username')}</label><input id="mqtt-username" value="${esc(c.username)}"></div><label class="check-row"><input id="mqtt-tls" type="checkbox" ${c.tls?'checked':''}>TLS</label><label class="check-row"><input id="mqtt-lan" type="checkbox" ${c.allow_insecure_lan?'checked':''}>${tr('明确允许 LAN 明文连接','Explicitly allow plaintext LAN connection')}</label><button type="submit">${t('save')}</button></form>`;$('#main').append(card);$('#mqtt-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/integrations/mqtt',{method:'PUT',body:{enabled:$('#mqtt-enabled').checked,host:$('#mqtt-host').value,port:Number($('#mqtt-port').value),topic:$('#mqtt-topic').value,username:$('#mqtt-username').value,tls:$('#mqtt-tls').checked,allow_insecure_lan:$('#mqtt-lan').checked}});toast(t('done'));});
|
||||
const status=await api('/api/v1/integrations/mqtt');const c=status.config;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>MQTT · ${t(status.connected?'connected':'disconnected')}</h3><p class="hint">${tr('默认关闭。须配置环境 Secret FRAME_FLOW_MQTT_TOKEN(有 display:write 权限的客户端 Token),以及可选 FRAME_FLOW_MQTT_PASSWORD。消息必须携带 request_id;retained 消息被拒绝。结果发送到命令主题的 /result。','Disabled by default. Configure FRAME_FLOW_MQTT_TOKEN with display:write scope and optional FRAME_FLOW_MQTT_PASSWORD as environment secrets. Commands require request_id; retained commands are rejected. Results are published to the command topic plus /result.')}</p><form id="mqtt-config"><label class="check-row"><input id="mqtt-enabled" type="checkbox" ${c.enabled?'checked':''}>${t('enabled')}</label><div class="field"><label for="mqtt-host">Host</label><input id="mqtt-host" value="${esc(c.host)}"></div><div class="field"><label for="mqtt-port">Port</label><input id="mqtt-port" type="number" min="1" max="65535" value="${c.port}"></div><div class="field"><label for="mqtt-topic">Topic</label><input id="mqtt-topic" value="${esc(c.topic)}"></div><div class="field"><label for="mqtt-username">${t('username')}</label><input id="mqtt-username" value="${esc(c.username)}"></div><label class="check-row"><input id="mqtt-tls" type="checkbox" ${c.tls?'checked':''}>TLS</label><label class="check-row"><input id="mqtt-lan" type="checkbox" ${c.allow_insecure_lan?'checked':''}>${tr('明确允许 LAN 明文连接','Explicitly allow plaintext LAN connection')}</label><button type="submit">${t('save')}</button></form>`;$('#main').append(card);$('#mqtt-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/integrations/mqtt',{method:'PUT',body:{enabled:$('#mqtt-enabled').checked,host:$('#mqtt-host').value,port:Number($('#mqtt-port').value),topic:$('#mqtt-topic').value,username:$('#mqtt-username').value,tls:$('#mqtt-tls').checked,allow_insecure_lan:$('#mqtt-lan').checked}});toast(t('done'));});
|
||||
}
|
||||
|
||||
async function autoCollagePanel(){
|
||||
|
||||
@@ -14,7 +14,7 @@ function editTemplateSource(source){state.selectedTemplate=source.template;state
|
||||
async function enhanceDashboard(){
|
||||
const epoch=state.epoch;const frame=$('.frame-demo');const panel=document.createElement('section');panel.className='card';$('#main').append(panel);
|
||||
const current=await currentPanel(panel);if(epoch!==state.epoch)return;
|
||||
if(current.preview_url){frame.innerHTML=`<img id="current-frame-image" alt="${t('currentContent')}" style="width:100%;height:100%;object-fit:contain"><span class="frame-label">${t('currentContent')} #${current.image_id}</span>`;await blobImage($('#current-frame-image'),current.preview_url);}
|
||||
if(current.preview_url){frame.innerHTML=`<button id="current-frame-preview" class="current-frame-preview" aria-label="${t('original')} · ${t('currentContent')} #${esc(current.image_id)}"><img id="current-frame-image" alt="${t('currentContent')}"></button><span class="frame-label">${t('currentContent')} #${esc(current.image_id)}</span>`;const original=new URL(current.preview_url,location.origin);original.searchParams.delete('thumbnail');bind('current-frame-preview',()=>openImageViewer(original.pathname+original.search,`${t('currentContent')} #${current.image_id}`));void blobImage($('#current-frame-image'),original.pathname+original.search);}else{frame.innerHTML=`<div class="mini-art"><div class="sun"></div></div><span class="frame-label">${t('testLabel')}</span>`;}
|
||||
const weather=await api('/api/v1/providers/weather');if(epoch!==state.epoch)return;const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('weather')}</h3><p>${esc(weather.current.location)} · ${esc(weather.current.temperature)} · ${esc(weather.current.summary)}</p><p class="hint">${weather.current.mock?t('weatherMock'):t('weatherAttribution')}${weather.current.stale?' · '+t('stale'):''}</p>`;$('#main').append(box);
|
||||
}
|
||||
async function enhanceTemplates(){
|
||||
@@ -28,23 +28,24 @@ async function enhanceTemplates(){
|
||||
async function downloadImage(path,name){const blob=await api(path,{binary:true});const url=URL.createObjectURL(blob);const link=document.createElement('a');link.href=url;link.download=name;document.body.append(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),10000);}
|
||||
function displayDialog(content){
|
||||
if(!liveWriteAllowed()){toast(t('readOnlyWarning'));return;}
|
||||
showModal(`<h2>${t('displayOptions')}</h2><form id="display-options"><label class="check-row"><input id="override-restore" type="checkbox">${t('restoreAfter')}</label><div class="form-grid"><div class="field"><label for="override-duration">${t('duration')}</label><input id="override-duration" type="number" min="1" max="604800" value="1800"></div><div class="field"><label for="override-priority">${t('priority')}</label><input id="override-priority" type="number" min="0" max="100" value="50"></div></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button id="confirm-display" type="submit" class="primary">${t('display')}</button></div></form>`);
|
||||
showModal(`<h2>${t('displayOptions')}</h2><form id="display-options"><p class="hint">${t('temporaryOptionsHint')}</p><label class="check-row"><input id="override-restore" type="checkbox">${t('restoreAfter')}</label><div class="form-grid"><div class="field"><label for="override-duration">${t('duration')}</label><input id="override-duration" type="number" min="1" max="604800" value="1800"></div><div class="field"><label for="override-priority">${t('priority')}</label><input id="override-priority" type="number" min="0" max="100" value="50"></div></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button id="confirm-display" type="submit" class="primary">${t('display')}</button></div></form>`);
|
||||
$('#display-options').onsubmit=busy(async e=>{e.preventDefault();const restore=$('#override-restore').checked;const options={duration:restore?Number($('#override-duration').value):0,restore_previous:restore,priority:Number($('#override-priority').value)};await accepted('/api/v1/display',{device_id:state.device,content,options});$('#modal').close();await navigate('history');});
|
||||
}
|
||||
async function enhanceGallery(){
|
||||
function enhanceGallery(){
|
||||
const grid=$('.gallery');if(!grid)return;
|
||||
const controls=document.createElement('div');controls.className='form-grid';controls.innerHTML=`<div class="field"><label for="gallery-search">${t('searchImages')}</label><input id="gallery-search" type="search"></div><div class="field"><label for="gallery-sort">${t('mode')}</label><select id="gallery-sort"><option value="newest">${t('sortNewest')}</option><option value="oldest">${t('sortOldest')}</option><option value="name">${t('sortName')}</option></select></div>`;grid.before(controls);
|
||||
if(state.tab==='local'){const bar=document.createElement('div');bar.className='actions';bar.innerHTML=`<select id="asset-origin" aria-label="${t('originFilter')}">${['all','manual','task','external','template','unknown'].map(x=>`<option value="${x}">${t('origin_'+x)}</option>`).join('')}</select><select id="asset-job" aria-label="${t('taskOrigin')}"><option value="">${t('allTasks')}</option></select><button id="asset-prev">${t('previousPage')}</button><button id="asset-next">${t('nextPage')}</button><button id="asset-all">${t('selectAll')}</button><button id="asset-batch-star">${t('star')}</button><button id="asset-batch-unstar">${t('unstar')}</button><button id="asset-batch-delete" class="danger">${t('deleteSelected')}</button>`;grid.before(bar);$('#asset-origin').value=state.assetOrigin||'all';const jobs=await api('/api/v1/jobs');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;$('#asset-job').append(option);}$('#asset-job').value=state.assetJob||'';$('#asset-origin').onchange=()=>{state.assetOffset=0;state.assetOrigin=$('#asset-origin').value;return navigate();};$('#asset-job').onchange=()=>{state.assetOffset=0;state.assetJob=$('#asset-job').value;state.assetOrigin=state.assetJob?'task':state.assetOrigin;return navigate();};$('#asset-prev').disabled=!(state.assetOffset||0);$('#asset-next').disabled=state.gallery.length<200;bind('asset-prev',()=>{state.assetOffset=Math.max(0,(state.assetOffset||0)-200);return navigate();});bind('asset-next',()=>{state.assetOffset=(state.assetOffset||0)+200;return navigate();});$('#asset-all').onclick=()=>{const boxes=[...document.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};for(const action of ['star','unstar','delete']){bind('asset-batch-'+action,async()=>{const ids=[...document.querySelectorAll('.asset-select:checked')].map(x=>x.dataset.asset);if(!ids.length){toast(t('selectImagesFirst'));return;}if(action==='delete'&&!confirm(t('deleteSelectedConfirm')))return;const results=[];for(let i=0;i<ids.length;i+=100){const r=await api('/api/v1/assets/batch',{method:'POST',body:{asset_ids:ids.slice(i,i+100),action}});results.push(...r.results);}const failed=results.filter(x=>x.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}}
|
||||
const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{const query=$('#gallery-search').value.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;const indexes=cards.map((card,index)=>({card,item:state.gallery[index]}));indexes.sort((a,b)=>order==='name'?a.item.name.localeCompare(b.item.name):order==='oldest'?String(a.item.created_at).localeCompare(String(b.item.created_at)):String(b.item.created_at).localeCompare(String(a.item.created_at)));indexes.forEach(x=>grid.append(x.card));};
|
||||
cards.forEach((card,i)=>{const item=state.gallery[i];if(state.tab==='local'){const star=document.createElement('button');star.textContent=t(item.starred?'unstar':'star');star.setAttribute('aria-pressed',String(item.starred));card.querySelector('.actions').append(star);star.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/star',{method:'PUT',body:{starred:!item.starred}});await navigate();});const label=document.createElement('small');label.textContent=item.origin==='task'?t('taskOrigin')+' · '+(item.job_name||item.job_id):t('origin_'+item.origin);card.querySelector('h3').after(label);const select=document.createElement('input');select.type='checkbox';select.className='asset-select';select.dataset.asset=item.asset_id;select.setAttribute('aria-label',t('selectImage'));card.prepend(select);}if(item.width&&item.height){const dimensions=document.createElement('small');dimensions.textContent=item.width+' × '+item.height;card.querySelector('h3').after(dimensions);}const download=document.createElement('button');download.textContent=t('download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local'){const edit=document.createElement('button');edit.textContent=t('continueEdit');card.querySelector('.actions').append(edit);edit.onclick=busy(async()=>{const meta=await api('/api/v1/assets/'+item.asset_id+'/source');if(meta.source?.type==='template')await editTemplateSource(meta.source);else toast(t('sourceUnknown'));});}});
|
||||
const controls=document.createElement('div');controls.className='form-grid gallery-filters';controls.innerHTML=`<div class="field"><label for="gallery-search">${t('searchImages')}</label><input id="gallery-search" type="search"></div><div class="field"><label for="gallery-sort">${t('sortOrder')}</label><select id="gallery-sort"><option value="newest">${t('sortNewest')}</option><option value="oldest">${t('sortOldest')}</option><option value="name">${t('sortName')}</option></select></div>`;grid.before(controls);
|
||||
if(state.tab==='local'){controls.classList.add('gallery-filters-local');const bar=document.createElement('div');bar.className='actions';bar.innerHTML=`<select id="asset-origin" aria-label="${t('originFilter')}">${['all','manual','task','external','template','unknown'].map(x=>`<option value="${x}">${t('origin_'+x)}</option>`).join('')}</select><select id="asset-job" aria-label="${t('taskOrigin')}"><option value="">${t('allTasks')}</option></select><button id="asset-prev">${t('previousPage')}</button><button id="asset-next">${t('nextPage')}</button><button id="asset-all">${t('selectAll')}</button><button id="asset-invert">${t('invertSelection')}</button><button id="asset-batch-star">${t('star')}</button><button id="asset-batch-unstar">${t('unstar')}</button><button id="asset-batch-delete" class="danger">${t('deleteSelected')}</button>`;grid.before(bar);for(const [id,key] of [['asset-origin','originFilter'],['asset-job','taskOrigin']]){const field=document.createElement('div');field.className='field';const label=document.createElement('label');label.htmlFor=id;label.textContent=t(key);field.append(label,bar.querySelector('#'+id));controls.append(field);}const help=document.createElement('p');help.className='hint';help.id='gallery-selection-help';help.textContent=t('gallerySelectionHelp')+' '+t('starHelp');grid.before(help);bar.setAttribute('aria-describedby',help.id);$('#asset-origin').value=state.assetOrigin||'all';if(state.galleryJobsEpoch!==state.epoch){state.galleryJobsEpoch=state.epoch;state.galleryJobsRequest=api('/api/v1/jobs');}void state.galleryJobsRequest.then(jobs=>{if(!bar.isConnected)return;const picker=bar.querySelector('#asset-job');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;picker.append(option);}picker.value=state.assetJob||'';}).catch(error=>{if(bar.isConnected)toast(error.message);});$('#asset-origin').onchange=()=>{state.assetOffset=0;state.assetOrigin=$('#asset-origin').value;return navigate();};$('#asset-job').onchange=()=>{state.assetOffset=0;state.assetJob=$('#asset-job').value;state.assetOrigin=state.assetJob?'task':state.assetOrigin;return navigate();};$('#asset-prev').disabled=!(state.assetOffset||0);$('#asset-next').disabled=state.gallery.length<200;bind('asset-prev',()=>{state.assetOffset=Math.max(0,(state.assetOffset||0)-200);return navigate();});bind('asset-next',()=>{state.assetOffset=(state.assetOffset||0)+200;return navigate();});const visibleSelection=()=>[...grid.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);$('#asset-all').onclick=()=>{const boxes=visibleSelection();const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};$('#asset-invert').onclick=()=>visibleSelection().forEach(x=>x.checked=!x.checked);for(const action of ['star','unstar','delete']){bind('asset-batch-'+action,async()=>{const ids=[...document.querySelectorAll('.asset-select:checked')].map(x=>x.dataset.asset);if(!ids.length){toast(t('selectImagesFirst'));return;}if(action==='delete'&&!confirm(t('deleteSelectedConfirm')))return;const results=[];for(let i=0;i<ids.length;i+=100){const r=await api('/api/v1/assets/batch',{method:'POST',body:{asset_ids:ids.slice(i,i+100),action}});results.push(...r.results);}const failed=results.filter(x=>x.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}}
|
||||
const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{state.galleryQuery=$('#gallery-search').value;const query=state.galleryQuery.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;state.galleryOrder=order;const indexes=cards.map((card,index)=>({card,item:state.gallery[index]}));indexes.sort((a,b)=>order==='name'?a.item.name.localeCompare(b.item.name):order==='oldest'?String(a.item.created_at).localeCompare(String(b.item.created_at)):String(b.item.created_at).localeCompare(String(a.item.created_at)));indexes.forEach(x=>grid.append(x.card));};
|
||||
cards.forEach((card,i)=>{const item=state.gallery[i];if(state.tab==='local'){const star=document.createElement('button');star.textContent=t(item.starred?'unstar':'star');star.setAttribute('aria-pressed',String(item.starred));card.querySelector('.actions').append(star);star.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/star',{method:'PUT',body:{starred:!item.starred}});await navigate();});const label=document.createElement('small');label.textContent=item.origin==='task'?t('taskOrigin')+' · '+(item.job_name||item.job_id):t('origin_'+item.origin);card.querySelector('h3').after(label);const select=document.createElement('input');select.type='checkbox';select.className='asset-select';select.dataset.asset=item.asset_id;select.setAttribute('aria-label',t('selectImage'));card.prepend(select);}if(item.width&&item.height){const dimensions=document.createElement('small');dimensions.textContent=item.width+' × '+item.height;card.querySelector('h3').after(dimensions);}const download=document.createElement('button');download.textContent=t('download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local'){const edit=document.createElement('button');edit.textContent=t('continueEdit');card.querySelector('.actions').append(edit);edit.onclick=busy(async()=>{const meta=await api('/api/v1/assets/'+item.asset_id+'/source');if(meta.source?.type==='template')await editTemplateSource(meta.source);else toast(t('templateSourceUnavailable'));});}});
|
||||
$('#gallery-search').value=state.galleryQuery||'';$('#gallery-sort').value=state.galleryOrder||'newest';$('#gallery-search').oninput();$('#gallery-sort').onchange();
|
||||
}
|
||||
async function weatherSettings(){
|
||||
const weather=await api('/api/v1/providers/weather');const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('weatherSettings')}</h3><form id="weather-form"><div class="field"><label for="weather-provider">Provider</label><select id="weather-provider"><option value="mock">${t('weatherMock')}</option><option value="open-meteo">Open-Meteo</option></select></div><div class="field"><label for="weather-location">${t('location')}</label><input id="weather-location" required maxlength="120" value="${esc(weather.config.location)}"></div><div class="form-grid">${['latitude','longitude'].map(k=>`<div class="field"><label for="weather-${k}">${t(k)}</label><input id="weather-${k}" required type="number" step="any" min="${k==='latitude'?-90:-180}" max="${k==='latitude'?90:180}" value="${weather.config[k]}"></div>`).join('')}</div><button type="submit" class="primary">${t('save')}</button></form><div id="weather-result"></div>`;$('#main').append(box);$('#weather-provider').value=weather.config.provider;
|
||||
const weather=await api('/api/v1/providers/weather');const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('weatherSettings')}</h3><form id="weather-form"><div class="field"><label for="weather-provider">${t('weatherSettings')}</label><select id="weather-provider"><option value="mock">${t('weatherMock')}</option><option value="open-meteo">Open-Meteo</option></select></div><div class="field"><label for="weather-location">${t('location')}</label><input id="weather-location" required maxlength="120" value="${esc(weather.config.location)}"></div><div class="form-grid weather-coordinates">${['latitude','longitude'].map(k=>`<div class="field"><label for="weather-${k}">${t(k)}</label><input id="weather-${k}" required type="number" step="any" min="${k==='latitude'?-90:-180}" max="${k==='latitude'?90:180}" value="${weather.config[k]}"></div>`).join('')}</div><button type="submit" class="primary">${t('save')}</button></form><div id="weather-result"></div>`;$('#main').append(box);$('#weather-provider').value=weather.config.provider;
|
||||
function result(w){$('#weather-result').innerHTML=`<p>${esc(w.current.temperature)} · ${esc(w.current.summary)}</p><p class="hint">${w.current.mock?t('weatherMock'):t('weatherAttribution')}${w.current.stale?' · '+t('stale'):''}</p><h4>${t('forecast')}</h4>${w.forecast.map(x=>`<p>${esc(x.date)} · ${esc(x.summary)} · ${x.minimum_c}–${x.maximum_c}°C · ${x.rain_probability}%</p>`).join('')}`;}
|
||||
result(weather);$('#weather-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/weather',{method:'PUT',body:{provider:$('#weather-provider').value,location:$('#weather-location').value,latitude:Number($('#weather-latitude').value),longitude:Number($('#weather-longitude').value)}});result(await api('/api/v1/providers/weather'));toast(t('done'));});
|
||||
}
|
||||
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);});}});
|
||||
}
|
||||
@@ -139,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)));
|
||||
@@ -149,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" id="source-nas-root-field"><label for="source-nas-root">${tr('NAS 原照片根目录(仅用于显示路径)','NAS original root (display only)')}</label><input id="source-nas-root" pattern="/.*" maxlength="2048" placeholder="/volume1/photo" value="${esc(source?.nas_root_path||'')}"><p class="hint">${tr('共享空间通常为 /volume1/photo,请填写实际 NAS 根目录;留空时显示 Photos 空间内路径。','Enter the actual NAS root; shared space is commonly /volume1/photo. Blank shows the path within Photos.')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${!source||source.folder_caption?'checked':''}>${t('folderCaption')}</label><label hidden><input id="source-synology-previews" type="checkbox" disabled></label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
|
||||
$('#source-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'synology-photos';const update=()=>{const photosAPI=$('#source-kind').value==='synology-photos';$('#source-nas-root-field').hidden=!photosAPI;$('#source-location').labels[0].textContent=photosAPI?tr('照片目录','Photo folder'):t('sourceLocation');$('#source-location').nextElementSibling.textContent=photosAPI?tr('填写所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。','Folder path within the selected space; . means the whole space. Downloads XL previews on demand, not originals.'):t('sourceLocationHint');const dav=!['local','synology-photos'].includes($('#source-kind').value);$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-credential').closest('.field').hidden=$('#source-kind').value!=='webdav';const oauth=$('#source-oauth');if(oauth)oauth.closest('.field').hidden=!['google-drive','alipan','baidu'].includes($('#source-kind').value);const quality=$('#source-local-quality');if(quality)quality.hidden=dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=$('#source-kind').value!=='local';$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};addPhotoQualityFields(source);$('#source-location').closest('.field').before($('#source-people-space').closest('.field'));update();$('#source-kind').onchange=update;$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';};
|
||||
$('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=!['local','synology-photos'].includes($('#source-kind').value);await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,nas_root_path:$('#source-nas-root').value||null,recursive:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:false,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_short_edge:Number($('#source-min-short').value),minimum_long_edge:Number($('#source-min-long').value),minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');});
|
||||
}
|
||||
async function browseSource(id,offset=0,refresh=false){
|
||||
const result=await api('/api/v1/photo-sources/'+id+'/images?device_id='+encodeURIComponent(state.device)+'&offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return;
|
||||
box.innerHTML=`<h4>${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}</h4><p class="hint">${t('sourceBrowseHint')}</p><div class="source-items">${result.items.map((x,i)=>`<button data-source-item="${i}">${esc(x.name)}${x.width?`<small>${x.width} × ${x.height}</small>`:''}${x.caption?`<small>${esc([x.caption.title,x.caption.date,x.caption.place].filter(Boolean).join(' · '))}</small>`:''}</button>`).join('')||t('noSources')}</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});
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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"><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.3</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>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
|
||||
<body><aside class="sidebar"><a class="brand" href="/"><span>PIGWay Frame Flow<small>Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.19</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=photo-quality-v0117"></script><script src="/ui/features.js?v=photo-quality-v0117"></script><script src="/ui/extensions.js?v=photo-quality-v0117"></script><script src="/ui/app.js?v=photo-quality-v0117"></script></body></html>
|
||||
|
||||
File diff suppressed because one or more lines are too long
+6
-2
@@ -1,7 +1,9 @@
|
||||
[project]
|
||||
name = "pigway-frame-flow"
|
||||
version = "0.1.3"
|
||||
requires-python = ">=3.12"
|
||||
version = "0.1.20"
|
||||
readme = "README.md"
|
||||
license = {file = "LICENSE"}
|
||||
requires-python = ">=3.14,<3.15"
|
||||
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]
|
||||
test = ["pytest==9.1.1", "httpx2==2.13.1", "mypy==2.3.1", "pip-audit==2.10.1", "types-requests==2.33.0.20260906", "types-PyYAML==6.0.12.20260906", "types-croniter==6.2.4.20260711", "ruff==0.16.9"]
|
||||
@@ -9,6 +11,8 @@ test = ["pytest==9.1.1", "httpx2==2.13.1", "mypy==2.3.1", "pip-audit==2.10.1", "
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
[tool.ruff]
|
||||
# Preserve existing syntax/formatting independently of runtime support.
|
||||
target-version = "py312"
|
||||
exclude = ["vendor", ".venv"]
|
||||
[build-system]
|
||||
requires = ["setuptools>=75"]
|
||||
|
||||
@@ -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,7 +10,7 @@ from urllib.request import Request, urlopen
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("mode", choices=["record", "check"])
|
||||
args = parser.parse_args()
|
||||
base = os.getenv("FRAME_FLOW_URL", "http://127.0.0.1:18787").rstrip("/")
|
||||
base = os.getenv("FRAME_FLOW_URL", "http://127.0.0.1:6005").rstrip("/")
|
||||
token = os.getenv("FRAME_FLOW_TOKEN", "")
|
||||
if not token:
|
||||
for line in Path(".env").read_text().splitlines():
|
||||
|
||||
@@ -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"]
|
||||
@@ -403,7 +389,11 @@ class Deploy:
|
||||
service["image"] = latest
|
||||
proposed = self.secret_config()
|
||||
service["environment"].update(proposed)
|
||||
for key in ("SYNOLOGY_PHOTOS_URL", "SYNOLOGY_PHOTOS_CERT_SHA256"):
|
||||
for key in (
|
||||
"SYNOLOGY_PHOTOS_URL",
|
||||
"SYNOLOGY_PHOTOS_CERT_SHA256",
|
||||
"FRAME_FLOW_STORAGE_NAME",
|
||||
):
|
||||
if self.env.get(key):
|
||||
service["environment"][key] = self.env[key]
|
||||
service["environment"].update(
|
||||
|
||||
@@ -40,5 +40,8 @@ os.environ["BUILD_COMMIT"] = (
|
||||
|
||||
os.environ["BUILD_TIME"] = datetime.now(UTC).isoformat()
|
||||
uvicorn.run(
|
||||
"pigway_frame_flow.main:app", host="127.0.0.1", port=18787, access_log=False
|
||||
"pigway_frame_flow.main:app",
|
||||
host="127.0.0.1",
|
||||
port=int(os.getenv("WEB_PORT", "6005")),
|
||||
access_log=False,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
"""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
|
||||
|
||||
ROOT = Path(os.getenv("FRAME_FLOW_ROOT", "/srv/frameflow"))
|
||||
APP = ROOT / "apps/pigway-frame-flow"
|
||||
RECOVERY = ROOT / "recovery/frame-flow"
|
||||
CONTAINER = "pigway-frame-flow"
|
||||
ORIGIN = os.getenv("FRAME_FLOW_ORIGIN", "http://127.0.0.1:6005").rstrip("/")
|
||||
|
||||
|
||||
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"
|
||||
expected_uuid = os.environ.get("FRAME_FLOW_MOUNT_UUID", "")
|
||||
assert expected_uuid, "Set FRAME_FLOW_MOUNT_UUID to the verified storage UUID"
|
||||
assert os.environ.get("FRAME_FLOW_ORIGIN"), (
|
||||
"Set FRAME_FLOW_ORIGIN to the verified listener"
|
||||
)
|
||||
os.umask(0o077)
|
||||
run("/usr/local/libexec/pigway-guard-mount")
|
||||
assert run("findmnt", "-n", "-o", "UUID", str(ROOT)) == expected_uuid
|
||||
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 == ROOT / "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.19-" + 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 in {"requirements.lock", "LICENSE", "NOTICE.md"}
|
||||
}
|
||||
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()
|
||||
@@ -1,3 +1,3 @@
|
||||
/* Mock vendor expiration must preserve first-party login and show reconnect. */
|
||||
const assert=require('assert'),{chromium}=require('playwright');
|
||||
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));await page.route('**/api/v1/devices/*/widgets',r=>r.fulfill({status:401,json:{error:{code:'OFFICIAL_AUTH_REQUIRED',message:'Official account session expired',request_id:'fixture',details:{}}}}));await page.route('**/api/v1/devices/*/official/connection',r=>r.fulfill({json:{authorized:false,username:'fixture',robot_id:null,mock:true,devices:[]}}));await page.locator('[data-page="templates"]').click();await page.locator('.official-content-info summary').click();await page.locator('#official-login-form').waitFor();assert(await page.evaluate(()=>Boolean(state.token&&state.csrf)));assert(await page.evaluate(async()=>Boolean((await api('/api/v1/auth/me')).username)));await page.route('**/api/v1/test-first-party-expired',r=>r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Expired',request_id:'fixture',details:{}}}}));await page.evaluate(async()=>{try{await api('/api/v1/test-first-party-expired');}catch{}});assert(await page.evaluate(()=>state.token===''));assert(errors.length===0);await browser.close();console.log('Audit UI: official expiry preserves login, reconnect visible, own expiry clears login; PASS');})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));await page.route('**/api/v1/devices/*/widgets',r=>r.fulfill({status:401,json:{error:{code:'OFFICIAL_AUTH_REQUIRED',message:'Official account session expired',request_id:'fixture',details:{}}}}));await page.route('**/api/v1/devices/*/official/connection',r=>r.fulfill({json:{authorized:false,username:'fixture',robot_id:null,mock:true,devices:[]}}));await page.locator('[data-page="templates"]').click();await page.locator('.official-content-info summary').click();await page.locator('#official-login-form').waitFor();assert(await page.evaluate(()=>Boolean(state.token&&state.csrf)));assert(await page.evaluate(async()=>Boolean((await api('/api/v1/auth/me')).username)));await page.route('**/api/v1/test-first-party-expired',r=>r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Expired',request_id:'fixture',details:{}}}}));await page.evaluate(async()=>{try{await api('/api/v1/test-first-party-expired');}catch{}});assert(await page.evaluate(()=>state.token===''));assert(errors.length===0);await browser.close();console.log('Audit UI: official expiry preserves login, reconnect visible, own expiry clears login; PASS');})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Copy must describe actual storage targets and actions; run against isolated Mock only. */
|
||||
const assert=require('assert'),fs=require('fs'),vm=require('vm'),{chromium}=require('playwright');
|
||||
const context={};context.window=context;vm.createContext(context);vm.runInContext(fs.readFileSync('pigway_frame_flow/web/i18n.js','utf8'),context);
|
||||
for(const file of ['app.js','features.js','extensions.js'])for(const match of fs.readFileSync('pigway_frame_flow/web/'+file,'utf8').matchAll(/\bt\('([^']+)'\)/g))for(const lang of ['zh','en'])assert(context.MESSAGES[lang][match[1]],`${file}: missing ${lang}.${match[1]}`);
|
||||
(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});try{const p=await b.newPage();let deletes=0;await p.route('**/api/v1/assets?*',r=>r.fulfill({json:[{asset_id:'fixture-local',name:'Local fixture',origin:'manual',starred:false,current:true,url:'/fixture.jpg'}]}));p.on('request',r=>{if(r.method()==='DELETE')deletes++;});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));for(const lang of ['zh','en']){await p.evaluate(async lang=>{state.lang=lang;state.tab='local';await navigate('gallery')},lang);await p.locator('[data-delete="0"]').click();assert(await p.locator('#modal-content').textContent().then(x=>x.includes(lang==='zh'?'不会切换画框':'frame display is unchanged')));await p.locator('#modal-cancel').click();assert.equal(await p.locator('label[for="gallery-sort"]').textContent(),lang==='zh'?'排序方式':'Sort order');assert(await p.locator('#gallery-selection-help').textContent().then(x=>x.includes(lang==='zh'?'手动删除仍有效':'manual deletion still applies')));await p.evaluate(()=>navigate('playlists'));await p.locator('#new-playlist').click();assert.equal(await p.locator('label[for="playlist-mode"]').textContent(),lang==='zh'?'播放顺序':'Playback order');await p.locator('#modal-cancel').click();await p.evaluate(()=>navigate('dashboard'));await p.waitForFunction(()=>document.querySelector('#main').textContent.includes(t('weather')));assert(await p.locator('#main').textContent().then(x=>x.includes(lang==='zh'?'型号信息来源':'Model information source')));}assert.equal(deletes,0);console.log('Copy audit PASS: all literal translations present; bilingual sort/playback/model labels, local deletion and star scope verified; no deletions');}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -0,0 +1,6 @@
|
||||
/* Isolated Mock only: dashboard and gallery share the same read-only image viewer. */
|
||||
const assert=require('assert'),{chromium}=require('playwright');
|
||||
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});let checks=0;
|
||||
try{for(const width of [1440,768,390]){const p=await browser.newPage({viewport:{width,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.route('**/api/v1/devices/*/current-content',r=>r.fulfill({json:{image_id:42,preview_url:'/fixture-preview.svg?thumbnail=true',synced:true,source_known:false,overrides:[]}}));await p.route('**/fixture-preview.svg*',r=>{return r.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="WIDTH" height="900"><rect width="1200" height="900" fill="#6c9c83"/><circle cx="600" cy="450" r="220" fill="#e9d9af"/></svg>'.replace('WIDTH',new URL(r.request().url()).searchParams.has('thumbnail')?'300':'1200')});});await p.route('**/api/v1/devices/*/images',r=>r.fulfill({json:[{image_id:42,name:'Fixture gallery',current:true,url:'/fixture-preview.svg'}]}));
|
||||
await p.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await p.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await p.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await p.locator('#login-form button').click();await p.locator('#current-frame-preview').waitFor();assert(await p.evaluate(()=>state.settings.mock));
|
||||
for(const lang of ['zh','en'])for(const theme of ['dark','light']){await p.evaluate(async({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;await navigate('dashboard')},{lang,theme});await p.waitForFunction(()=>document.querySelector('#current-frame-image')?.naturalWidth===1200);assert(await p.locator('#current-frame-preview').evaluate(e=>e.getBoundingClientRect().height)>=(width<=720?320:400));assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await p.locator('#current-frame-preview').focus();const fullImage=p.waitForRequest(r=>new URL(r.url()).pathname==='/fixture-preview.svg'&&!new URL(r.url()).searchParams.has('thumbnail'));await p.keyboard.press('Enter');await fullImage;await p.locator('dialog.image-viewer[open]').waitFor();await p.waitForFunction(()=>document.querySelector('#original-image')?.naturalWidth===1200);assert.equal(await p.locator('.image-viewer-name').textContent(),(lang==='zh'?'当前显示内容':'Currently displayed content')+' #42');assert.equal(await p.locator('.image-viewer-actions button').count(),3);const sizing=p.locator('.image-viewer-actions button[aria-pressed]');await sizing.nth(1).click();assert(await p.locator('.image-viewer-viewport').evaluate(e=>e.classList.contains('actual-size')));await sizing.nth(0).click();assert(!await p.locator('.image-viewer-viewport').evaluate(e=>e.classList.contains('actual-size')));await p.keyboard.press('Escape');assert(!await p.locator('#modal').evaluate(e=>e.open));await p.waitForFunction(()=>!document.body.classList.contains('image-viewer-open'));if(process.env.FRAME_FLOW_SCREENSHOT_DIR&&lang==='zh'&&theme==='dark'){require('fs').mkdirSync(process.env.FRAME_FLOW_SCREENSHOT_DIR,{recursive:true});await p.screenshot({path:process.env.FRAME_FLOW_SCREENSHOT_DIR+'/dashboard-'+width+'.png',fullPage:true});}await p.evaluate(()=>navigate('gallery'));await p.locator('[data-original="0"]').click();await p.locator('dialog.image-viewer[open]').waitFor();assert.equal(await p.locator('.image-viewer-actions button').count(),3);await p.locator('#modal-cancel').click();checks++;}assert.deepEqual(errors,[]);await p.close();}console.log('Dashboard preview: '+checks+' profiles PASS; larger preview, keyboard opening, shared gallery viewer, fit/actual size, Escape, no overflow');}finally{await browser.close()}})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -0,0 +1,10 @@
|
||||
/* Mock-only: slow device status and cloud metadata must not block real gallery controls. */
|
||||
const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright');
|
||||
(async()=>{const base=process.env.FRAME_FLOW_URL;assert(base);const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);assert((await(await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}})).json()).mock===true);const b=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
||||
try{const p=await b.newPage({extraHTTPHeaders:{Authorization:'Bearer '+token}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.route('**/api/v1/auth/me',r=>r.fulfill({json:{username:'fixture',csrf_token:''}}));let releaseStatus,releaseList;const pending=[];let checks=0;
|
||||
await p.route('**/api/v1/devices',async r=>{pending.push('device');await new Promise(resolve=>releaseStatus=resolve);await r.fulfill({json:[{id:'living-room',name:'Fixture frame'}]});});
|
||||
await p.route('**/api/v1/devices/*/images',async r=>{pending.push('list');await new Promise(resolve=>releaseList=resolve);await r.fulfill({json:[]});});
|
||||
await p.goto(base+'/#gallery');await p.locator('#gallery-search').waitFor({timeout:5000});for(let i=0;i<100&&pending.length<2;i++)await p.waitForTimeout(20);assert.deepEqual(pending.sort(),['device','list']);assert(await p.locator('#gallery-upload').isEnabled());assert(await p.locator('#local-tab').isEnabled());assert.equal(await p.locator('.image-card,.placeholder-line,.image-pending,.page-loading').count(),0);checks++;if(process.env.FRAME_FLOW_SCREENSHOT_DIR){fs.mkdirSync(process.env.FRAME_FLOW_SCREENSHOT_DIR,{recursive:true});await p.screenshot({path:process.env.FRAME_FLOW_SCREENSHOT_DIR+'/gallery-list-pending.png'});}
|
||||
await p.locator('#gallery-search').fill('kept query');await p.locator('#gallery-sort').selectOption('name');releaseStatus();releaseList();await p.waitForFunction(()=>document.querySelector('.gallery')?.getAttribute('aria-busy')==='false');assert.equal(await p.locator('#gallery-search').inputValue(),'kept query');assert.equal(await p.locator('#gallery-sort').inputValue(),'name');assert.equal(await p.locator('.card.empty').count(),1);checks++;
|
||||
assert.deepEqual(errors,[]);console.log('Cold gallery bootstrap:',checks,'checks PASS; device/cloud requests blocked independently, real controls ready, input preserved');
|
||||
}finally{await b.close();}})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
@@ -5,7 +5,7 @@ const assert=require('assert'),{chromium}=require('playwright');
|
||||
await page.route('**/api/v1/devices/*/images',r=>r.fulfill({json:[{image_id:1,name:'Current fixture',current:true,url:'/mock-image.jpg',created_at:null},{image_id:2,name:'Other fixture',current:false,url:'/mock-image.jpg',created_at:null}]}));
|
||||
page.on('request',r=>{if(r.method()==='POST'&&r.url().endsWith('/display'))writes++;});
|
||||
try{
|
||||
await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));
|
||||
await page.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));
|
||||
for(const lang of ['zh','en'])for(const width of [1440,390])for(const theme of ['dark','light']){
|
||||
await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="gallery"]').click();await page.locator('[data-show="0"]').waitFor();assert(await page.locator('[data-show="0"]').isDisabled());assert(await page.locator('[data-show="0"]').textContent()===(lang==='zh'?'当前图片':'Current image'));assert(await page.locator('[data-show="1"]').isEnabled());await page.locator('[data-show="0"]').evaluate(b=>b.onclick());assert(!await page.locator('#modal').evaluate(d=>d.open));assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/* Mock-only: real writable destinations, concrete names and responsive labels. */
|
||||
const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright');
|
||||
(async()=>{
|
||||
const base=process.env.FRAME_FLOW_URL;assert(base);const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);
|
||||
assert((await(await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}})).json()).mock===true);
|
||||
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
||||
try{
|
||||
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto(base);await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();
|
||||
await page.evaluate(()=>{state.settings.storage_name='FixtureNAS';state.settings.gallery_destinations=[{id:'cloud',adapter:'kokonna',name:'KoKonna',storage:'cloud',connected:true,supports_upload:true},{id:'local',adapter:'local-assets',name:'FixtureNAS',storage:'local',connected:true,supports_upload:true},{id:'read-only-drive',adapter:'kokonna',name:'Read-only fixture',storage:'cloud',connected:true,supports_upload:false},{id:'offline-drive',adapter:'kokonna',name:'Offline fixture',storage:'cloud',connected:false,supports_upload:true},{id:'unimplemented-drive',adapter:'unimplemented',name:'Future fixture',storage:'cloud',connected:true,supports_upload:true}];});let checks=0;
|
||||
for(const width of [320,390,768,1440])for(const lang of ['zh','en'])for(const theme of ['light','dark']){
|
||||
await page.setViewportSize({width,height:900});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;return navigate('gallery');},{lang,theme});
|
||||
assert.equal(await page.locator('#cloud-tab').innerText(),lang==='zh'?'KoKonna 云端图库':'KoKonna cloud gallery');assert.equal(await page.locator('#local-tab').innerText(),lang==='zh'?'FixtureNAS 本地图库':'FixtureNAS Local gallery');assert.equal(await page.locator('[data-gallery-destination]').count(),2);assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++;
|
||||
}
|
||||
await page.setViewportSize({width:320,height:740});await page.evaluate(()=>{state.settings.gallery_destinations.find(d=>d.id==='local').name='家庭照片存储服务器名称很长的NAS';return navigate('gallery');});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++;
|
||||
await page.locator('#local-tab').click();assert.equal(await page.evaluate(()=>state.tab),'local');await page.locator('#cloud-tab').click();assert.equal(await page.evaluate(()=>state.tab),'cloud');checks++;
|
||||
assert.deepEqual(errors,[]);console.log('Gallery destinations:',checks,'checks PASS; concrete labels, capability/connection/adapter gates, responsive layout and tab switching');
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
@@ -6,10 +6,13 @@ const assert=require('assert'),{chromium}=require('playwright');
|
||||
await page.route('**/api/v1/assets/local-1/star',r=>{starred=r.request().postDataJSON().starred;return r.fulfill({json:{starred,expires_at:null}});});
|
||||
page.on('request',r=>{if(r.method()==='POST'&&r.url().endsWith('/display'))writes++;});
|
||||
try{
|
||||
await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));
|
||||
await page.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));
|
||||
for(const lang of ['zh','en'])for(const width of [1440,390])for(const theme of ['dark','light']){
|
||||
await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="gallery"]').click();await page.locator('#local-tab').click();await page.locator('[data-show="0"]').waitFor();const star=page.locator('.image-card').first().locator('button[aria-pressed]');assert(await star.count()===1);const original=await star.getAttribute('aria-pressed');await star.click();await page.waitForFunction(expected=>document.querySelector('.image-card button[aria-pressed]')?.getAttribute('aria-pressed')===expected,original==='true'?'false':'true');assert(await page.locator('[data-show="0"]').isDisabled());assert(await page.locator('[data-show="0"]').textContent()===(lang==='zh'?'当前图片':'Current image'));assert(await page.locator('[data-show="1"]').isEnabled());await page.locator('[data-show="0"]').evaluate(b=>b.onclick());assert(!await page.locator('#modal').evaluate(d=>d.open));assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++;
|
||||
await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="gallery"]').click();await page.locator('#local-tab').click();await page.locator('[data-show="0"]').waitFor();const boxes=page.locator('.asset-select');const selected=()=>boxes.evaluateAll(xs=>xs.map(x=>x.checked));
|
||||
assert.equal(await page.locator('#asset-invert').textContent(),lang==='zh'?'反选':'Invert selection');await boxes.nth(0).check();await page.locator('#asset-invert').click();assert.deepEqual(await selected(),[false,true]);await page.locator('#asset-invert').click();assert.deepEqual(await selected(),[true,false]);
|
||||
await page.locator('#gallery-search').fill('Current fixture');await page.locator('#asset-invert').click();assert.deepEqual(await selected(),[false,false]);await page.locator('#asset-invert').click();assert.deepEqual(await selected(),[true,false]);await page.locator('#gallery-search').fill('no matching fixture');await page.locator('#asset-invert').click();assert.deepEqual(await selected(),[true,false]);await page.locator('#gallery-search').fill('');await page.locator('#asset-all').click();assert.deepEqual(await selected(),[true,true]);await page.locator('#asset-invert').click();assert.deepEqual(await selected(),[false,false]);
|
||||
const star=page.locator('.image-card').first().locator('button[aria-pressed]');assert(await star.count()===1);const original=await star.getAttribute('aria-pressed');await star.click();await page.waitForFunction(expected=>document.querySelector('.image-card button[aria-pressed]')?.getAttribute('aria-pressed')===expected,original==='true'?'false':'true');assert(await page.locator('[data-show="0"]').isDisabled());assert(await page.locator('[data-show="0"]').textContent()===(lang==='zh'?'当前图片':'Current image'));assert(await page.locator('[data-show="1"]').isEnabled());await page.locator('[data-show="0"]').evaluate(b=>b.onclick());assert(!await page.locator('#modal').evaluate(d=>d.open));assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++;
|
||||
}
|
||||
assert(writes===0);console.log('Current-image gallery button: '+checks+' profiles passed; disabled label, other image enabled, no display request');
|
||||
assert(writes===0);console.log('Gallery inversion and current-image star: '+checks+' profiles passed; disabled label, other image enabled, no display request');
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e);process.exit(1)});
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
/* Mock history fixtures only; verifies long identifiers and detail actions. */
|
||||
const assert=require('assert'),{chromium}=require('playwright');
|
||||
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();const op={operation_id:'a'.repeat(64),request_id:'b'.repeat(64),action:'render',device_id:'living-room',client_id:'user:way',created_at:'2026-10-05T00:28:05Z',status:'COMPLETED',result:{asset_id:'c'.repeat(64)}};await page.route('**/api/v1/operations',r=>r.fulfill({json:[op,op]}));await page.route('**/api/v1/operations/'+op.operation_id,r=>r.fulfill({json:op}));await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();let checks=0;for(const lang of ['zh','en'])for(const width of [1440,768,390])for(const theme of ['dark','light']){await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="history"]').click();await page.locator('.history-actions button').first().waitFor();assert(await page.evaluate(()=>{const table=document.querySelector('.history-table');return table.querySelectorAll('th').length===7&&[...table.querySelectorAll('tbody tr')].every(row=>row.children.length===7&&row.lastElementChild.classList.contains('history-actions')&&row.lastElementChild.previousElementSibling.querySelector('button')===null)&&document.documentElement.scrollWidth<=innerWidth;}));await page.locator('.history-actions button').first().click();await page.locator('#modal[open]').waitFor();await page.locator('#modal-cancel').click();checks++;}await browser.close();console.log('History layout and detail dialog: '+checks+' passed');})().catch(e=>{console.error(e);process.exit(1)});
|
||||
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();const op={operation_id:'a'.repeat(64),request_id:'b'.repeat(64),action:'render',device_id:'living-room',client_id:'user:way',created_at:'2026-10-05T00:28:05Z',status:'COMPLETED',result:{asset_id:'c'.repeat(64)}};await page.route('**/api/v1/operations',r=>r.fulfill({json:[op,op]}));await page.route('**/api/v1/operations/'+op.operation_id,r=>r.fulfill({json:op}));await page.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();let checks=0;for(const lang of ['zh','en'])for(const width of [1440,768,390])for(const theme of ['dark','light']){await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="history"]').click();await page.locator('.history-actions button').first().waitFor();assert(await page.evaluate(()=>{const table=document.querySelector('.history-table');return table.querySelectorAll('th').length===7&&[...table.querySelectorAll('tbody tr')].every(row=>row.children.length===7&&row.lastElementChild.classList.contains('history-actions')&&row.lastElementChild.previousElementSibling.querySelector('button')===null)&&document.documentElement.scrollWidth<=innerWidth;}));await page.locator('.history-actions button').first().click();await page.locator('#modal[open]').waitFor();await page.locator('#modal-cancel').click();checks++;}await browser.close();console.log('History layout and detail dialog: '+checks+' passed');})().catch(e=>{console.error(e);process.exit(1)});
|
||||
|
||||
@@ -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)});
|
||||
@@ -0,0 +1,39 @@
|
||||
/* Isolated Mock UI audit: delayed images stay inside their own placeholders. */
|
||||
const assert=require('assert');
|
||||
const fs=require('fs');
|
||||
const {chromium}=require('playwright');
|
||||
(async()=>{
|
||||
const base=process.env.FRAME_FLOW_URL;assert(base);
|
||||
const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);
|
||||
assert((await(await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}})).json()).mock===true);
|
||||
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
||||
try{
|
||||
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.route('**/api/v1/devices/*/images',r=>r.fulfill({json:[{image_id:1,name:'Loading fixture',url:'/ui-audit-image.svg',current:false}]}));
|
||||
let release;let requested;
|
||||
await page.route('**/ui-audit-image.svg*',async r=>{requested();await new Promise(resolve=>release=resolve);await r.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"><rect width="300" height="400" fill="#167253"/></svg>'});});
|
||||
await page.goto(base);await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();
|
||||
let checks=0;
|
||||
async function centered(){
|
||||
const result=await page.locator('.image-loading').evaluate(box=>{
|
||||
const spinner=box.querySelector('.spinner'),img=box.querySelector('img'),a=box.getBoundingClientRect(),b=spinner.getBoundingClientRect();
|
||||
return {centered:Math.abs(a.x+a.width/2-b.x-b.width/2)<1&&Math.abs(a.y+a.height/2-b.y-b.height/2)<1,hidden:getComputedStyle(img).visibility==='hidden',contained:box.querySelector('.image-loading-status').offsetParent===box,overflow:document.documentElement.scrollWidth>innerWidth};
|
||||
});assert.deepStrictEqual(result,{centered:true,hidden:true,contained:true,overflow:false});checks++;
|
||||
assert.equal(await page.locator('.spinner:not(.image-loading .spinner)').count(),0);checks++;
|
||||
}
|
||||
for(const width of [1440,768,390])for(const theme of ['light','dark']){
|
||||
await page.setViewportSize({width,height:900});await page.evaluate(theme=>document.documentElement.dataset.theme=theme,theme);
|
||||
const request=new Promise(resolve=>requested=resolve);await page.evaluate(()=>navigate('gallery'));await request;await centered();
|
||||
const before=await page.locator('.image-loading').boundingBox();release();await page.waitForFunction(()=>document.querySelector('.thumb')?.naturalWidth===300&&!document.querySelector('.image-loading'));
|
||||
const after=await page.locator('.thumb').boundingBox();assert(Math.abs(before.height-after.height)<1,'Thumbnail placeholder changes height');checks++;
|
||||
const originalRequest=new Promise(resolve=>requested=resolve);await page.locator('[data-original]').click();await originalRequest;await centered();release();await page.waitForFunction(()=>document.querySelector('#original-image')?.naturalWidth===300&&!document.querySelector('.image-loading'));await page.locator('#modal-cancel').click();checks++;
|
||||
await page.evaluate(async()=>{const img=document.querySelector('.thumb');await blobImage(img,'/ui-audit-missing-image');});
|
||||
assert.equal(await page.locator('.image-load-error').count(),1);assert.equal(await page.locator('.image-load-error .spinner').count(),0);assert(await page.locator('.image-load-error').innerText());checks++;
|
||||
}
|
||||
await page.evaluate(()=>navigate('connections'));
|
||||
assert.deepStrictEqual(await page.locator('#main input,#main select,#main textarea').evaluateAll(nodes=>nodes.filter(e=>e.checkVisibility()&&!e.labels.length&&!e.getAttribute('aria-label')&&!e.getAttribute('aria-labelledby')).map(e=>e.id)),[]);checks++;
|
||||
assert.equal(await page.locator('nav [aria-current="page"]').count(),1);checks++;
|
||||
const nav=await page.locator('nav button').evaluateAll(nodes=>nodes.map(e=>({height:e.getBoundingClientRect().height,nowrap:getComputedStyle(e).whiteSpace})));assert(nav.every(e=>e.height>=44&&e.nowrap==='nowrap'));checks++;
|
||||
assert.deepStrictEqual(errors,[]);console.log('Image/UI consistency audit:',checks,'passed; desktop/tablet/mobile, light/dark; no page errors');
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -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)});
|
||||
@@ -9,19 +9,23 @@ const assert=require('assert');const fs=require('fs');const {chromium}=require('
|
||||
await page.route('**/api/v1/integrations/synology-photos',async route=>{if(route.request().method()==='POST'){assert.deepStrictEqual(route.request().postDataJSON(),{username:'fixture-nas',password:'fixture-only'});connected=true;logins++;}await route.fulfill({json:{configured:true,connected}});});
|
||||
await page.route('**/api/v1/photo-sources',async route=>{assert(route.request().method()==='GET');await route.fulfill({json:[{id:'fixture-source',name:'NAS photos / NAS 照片',kind:'local',location:'.',enabled:false,people_filter:true,orientation:'auto'}]});});
|
||||
await page.goto(base);await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();
|
||||
const viewports=process.env.FRAME_FLOW_VIEWPORTS?JSON.parse(process.env.FRAME_FLOW_VIEWPORTS):[1920,1440,768,390].map(width=>({width,height:900}));
|
||||
let checks=0;const failures=[];const out=process.env.FRAME_FLOW_SCREENSHOT_DIR;if(out)fs.mkdirSync(out,{recursive:true});
|
||||
async function inspect(label){
|
||||
const result=await page.evaluate(()=>{
|
||||
const root=document.querySelector('dialog[open]')||document.querySelector('#main');const controls=[...root.querySelectorAll('button,select,input:not([type=checkbox]):not([type=radio]):not([type=file]),textarea')].filter(e=>e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})&&!e.closest('[hidden]')).map(e=>({id:e.id||e.textContent.trim().slice(0,24),r:e.getBoundingClientRect()}));const close=[];
|
||||
for(let i=0;i<controls.length;i++)for(let j=i+1;j<controls.length;j++){const a=controls[i].r,b=controls[j].r;const x=Math.min(a.right,b.right)-Math.max(a.left,b.left),y=Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top);const dx=Math.max(a.left,b.left)-Math.min(a.right,b.right),dy=Math.max(a.top,b.top)-Math.min(a.bottom,b.bottom);if((x>1&&dy<7.5)||(y>1&&dx<7.5))close.push([controls[i].id,controls[j].id]);}
|
||||
return {overflow:document.documentElement.scrollWidth>innerWidth,close,small:innerWidth<=720?controls.filter(e=>e.r.height<43.5).map(e=>e.id):[]};
|
||||
});checks++;if(result.overflow||result.close.length||result.small.length)failures.push({label,...result});
|
||||
const root=document.querySelector('dialog[open]')||document.querySelector('#main');const controls=[...root.querySelectorAll('button,select,input:not([type=checkbox]):not([type=radio]):not([type=file]),textarea')].filter(e=>e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})&&!e.closest('[hidden]')).map(e=>({id:e.id||e.textContent.trim().slice(0,24),compact:!!e.closest('.image-card .actions'),r:e.getBoundingClientRect()}));const close=[];
|
||||
for(let i=0;i<controls.length;i++)for(let j=i+1;j<controls.length;j++){const a=controls[i].r,b=controls[j].r;const x=Math.min(a.right,b.right)-Math.max(a.left,b.left),y=Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top);const dx=Math.max(a.left,b.left)-Math.min(a.right,b.right),dy=Math.max(a.top,b.top)-Math.min(a.bottom,b.bottom);const gap=controls[i].compact&&controls[j].compact?5.5:7.5;if((x>1&&dy<gap)||(y>1&&dx<gap))close.push([controls[i].id,controls[j].id]);}
|
||||
const dialog=document.querySelector('dialog[open]');const rect=dialog?.getBoundingClientRect();
|
||||
const clippedDialog=!!rect&&(rect.left<0||rect.right>innerWidth||rect.top<0||rect.bottom>innerHeight);
|
||||
const clippedNav=innerWidth<=720&&[...document.querySelectorAll('nav button')].some(button=>{const r=button.getBoundingClientRect();return button.scrollHeight>button.clientHeight||r.height<44;});
|
||||
return {clippedDialog,clippedNav,overflow:document.documentElement.scrollWidth>innerWidth,close,small:innerWidth<=720?controls.filter(e=>e.r.height<43.5).map(e=>e.id):[]};
|
||||
});checks++;if(result.clippedDialog||result.clippedNav||result.overflow||result.close.length||result.small.length)failures.push({label,...result});
|
||||
}
|
||||
for(const width of [1440,768,390])for(const lang of ['zh','en'])for(const theme of ['light','dark']){
|
||||
await page.setViewportSize({width,height:900});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});
|
||||
for(const {width,height} of viewports)for(const lang of ['zh','en'])for(const theme of ['light','dark']){
|
||||
await page.setViewportSize({width,height});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});
|
||||
for(const view of ['dashboard','gallery','photo-sources','upload','templates','automation','playlists','history','settings','studio','connections']){await page.evaluate(view=>navigate(view),view);await inspect(`${view}/${width}/${lang}/${theme}`);}
|
||||
await page.evaluate(()=>navigate('studio'));await page.locator('.manual-collage summary').click();await inspect(`Manual collage/${width}/${lang}/${theme}`);await page.evaluate(()=>navigate('photo-sources'));await page.locator('#photos-login').click();await inspect(`Photos login/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
|
||||
await page.locator('#add-photo-source').click();assert(await page.locator('#source-credential').isHidden());assert(await page.locator('#source-oauth').isHidden());await inspect(`NAS source/${width}/${lang}/${theme}`);await page.locator('#source-kind').selectOption('google-drive');assert(await page.locator('#source-oauth').isVisible());assert(await page.locator('#source-local-quality').isHidden());await inspect(`Drive source/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
|
||||
await page.locator('#add-photo-source').click();assert(await page.locator('#source-credential').isHidden());assert(await page.locator('#source-oauth').isHidden());await inspect(`NAS source/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
|
||||
await page.evaluate(()=>navigate('automation'));await page.locator('#new-job').click();await inspect(`Job/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
|
||||
await page.evaluate(()=>navigate('playlists'));await page.locator('#new-playlist').click();await inspect(`Playlist/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
|
||||
await page.evaluate(()=>displayDialog({type:'image',image_id:1}));await inspect(`Display options/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
|
||||
@@ -29,6 +33,6 @@ const assert=require('assert');const fs=require('fs');const {chromium}=require('
|
||||
if(out&&lang==='zh'&&theme==='dark'){await page.evaluate(()=>navigate('photo-sources'));await page.screenshot({path:out+`/photos-${width}.png`,fullPage:true});}
|
||||
}
|
||||
await page.evaluate(()=>navigate('photo-sources'));await page.locator('#photos-login').click();await page.locator('#photos-login-user').fill('fixture-nas');await page.locator('#photos-login-password').fill('fixture-only');await page.locator('#photos-login-form button[type=submit]').click();await page.locator('#photos-login').filter({hasText:'Sign in again'}).waitFor();assert(logins===1);checks++;
|
||||
assert.deepStrictEqual(errors,[]);assert.deepStrictEqual(failures,[],JSON.stringify(failures));console.log('Layout audit:',checks,'passed; 11 pages plus local gallery, 6 modal states, 12 viewport/language/theme combinations; console errors:',errors.length);
|
||||
assert.deepStrictEqual(errors,[]);assert.deepStrictEqual(failures,[],JSON.stringify(failures));console.log('Layout audit:',checks,'passed; 11 pages plus local gallery, 5 modal states, '+(viewports.length*4)+' viewport/language/theme combinations; console errors:',errors.length);
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* All-page layout and daily-poetry checks; Mock only, no real device writes. */
|
||||
const assert=require('assert'),{chromium}=require('playwright');
|
||||
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();let unintended=0;page.on('request',r=>{if(r.method()==='POST'&&/\/(jobs|playlists|display)$/.test(r.url()))unintended++;});const base='http://127.0.0.1:18787';await page.goto(base);await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));let checks=0;for(const lang of ['zh','en'])for(const writable of [false,true]){const clean=await page.evaluate(({lang,writable})=>{const previous={lang:state.lang,settings:state.settings};state.lang=lang;state.settings={...state.settings,mock:false,write_enabled:writable};shell();const clean=notice()===''&&!document.querySelector('#connection')&&!document.querySelector('.sidebar-bottom .live-dot');state.lang=previous.lang;state.settings=previous.settings;shell();return clean;},{lang,writable});assert(clean,'Live-device status must not appear in either language');checks++;}for(const width of [1440,390])for(const theme of ['light','dark']){await page.setViewportSize({width,height:1000});await page.evaluate(theme=>{localStorage.setItem('ff-theme',theme);document.documentElement.dataset.theme=theme;},theme);for(const name of ['dashboard','gallery','photo-sources','upload','templates','automation','playlists','history','settings']){await page.locator(`[data-page="${name}"]`).click();await page.waitForTimeout(350);if(name==='settings')await page.locator('#image-host-form').waitFor();const result=await page.evaluate(()=>{const children=[...document.querySelector('#main').children].filter(x=>x.getBoundingClientRect().height>0);return {overflow:document.documentElement.scrollWidth>innerWidth,buttonGaps:[...document.querySelectorAll('#main button')].flatMap(b=>{const next=b.nextElementSibling;if(next?.tagName!=='BUTTON')return [];const a=b.getBoundingClientRect(),c=next.getBoundingClientRect();if(!a.width||!c.width)return [];return [Math.abs(a.top-c.top)<4?c.left-a.right:c.top-a.bottom];}),gaps:children.slice(1).map((x,i)=>x.getBoundingClientRect().top-children[i].getBoundingClientRect().bottom)};});const controls=await page.evaluate(()=>{const nodes=[...document.querySelectorAll('#main input:not([type="checkbox"]):not([type="radio"]),#main select,#main textarea,#main button')].filter(x=>{const r=x.getBoundingClientRect();return r.width&&r.height&&getComputedStyle(x).visibility!=='hidden';});const issues=[];for(let i=0;i<nodes.length;i++)for(let j=i+1;j<nodes.length;j++){const a=nodes[i].getBoundingClientRect(),b=nodes[j].getBoundingClientRect();const ox=Math.min(a.right,b.right)-Math.max(a.left,b.left),oy=Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top);const dy=Math.max(a.top,b.top)-Math.min(a.bottom,b.bottom),dx=Math.max(a.left,b.left)-Math.min(a.right,b.right);if((ox>Math.min(a.width,b.width)/2&&dy>=0&&dy<8)||(oy>Math.min(a.height,b.height)/2&&dx>=0&&dx<8))issues.push({a:nodes[i].id,b:nodes[j].id,gap:ox>0?dy:dx});}return issues;});assert(controls.length===0,`${name} ${width} ${theme} controls ${JSON.stringify(controls)}`);checks++;
|
||||
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();let unintended=0;page.on('request',r=>{if(r.method()==='POST'&&/\/(jobs|playlists|display)$/.test(r.url()))unintended++;});const base=process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005';await page.goto(base);await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));let checks=0;for(const lang of ['zh','en'])for(const writable of [false,true]){const clean=await page.evaluate(({lang,writable})=>{const previous={lang:state.lang,settings:state.settings};state.lang=lang;state.settings={...state.settings,mock:false,write_enabled:writable};shell();const clean=notice()===''&&!document.querySelector('#connection')&&!document.querySelector('.sidebar-bottom .live-dot');state.lang=previous.lang;state.settings=previous.settings;shell();return clean;},{lang,writable});assert(clean,'Live-device status must not appear in either language');checks++;}for(const width of [1440,390])for(const theme of ['light','dark']){await page.setViewportSize({width,height:1000});await page.evaluate(theme=>{localStorage.setItem('ff-theme',theme);document.documentElement.dataset.theme=theme;},theme);for(const name of ['dashboard','gallery','photo-sources','upload','templates','automation','playlists','history','settings']){await page.locator(`[data-page="${name}"]`).click();await page.waitForTimeout(350);if(name==='settings')await page.locator('#image-host-form').waitFor();const result=await page.evaluate(()=>{const children=[...document.querySelector('#main').children].filter(x=>x.getBoundingClientRect().height>0);return {overflow:document.documentElement.scrollWidth>innerWidth,buttonGaps:[...document.querySelectorAll('#main button')].flatMap(b=>{const next=b.nextElementSibling;if(next?.tagName!=='BUTTON')return [];const a=b.getBoundingClientRect(),c=next.getBoundingClientRect();if(!a.width||!c.width)return [];return [Math.abs(a.top-c.top)<4?c.left-a.right:c.top-a.bottom];}),gaps:children.slice(1).map((x,i)=>x.getBoundingClientRect().top-children[i].getBoundingClientRect().bottom)};});const controls=await page.evaluate(()=>{const nodes=[...document.querySelectorAll('#main input:not([type="checkbox"]):not([type="radio"]),#main select,#main textarea,#main button')].filter(x=>{const r=x.getBoundingClientRect();return r.width&&r.height&&getComputedStyle(x).visibility!=='hidden';});const issues=[];for(let i=0;i<nodes.length;i++)for(let j=i+1;j<nodes.length;j++){const a=nodes[i].getBoundingClientRect(),b=nodes[j].getBoundingClientRect();const ox=Math.min(a.right,b.right)-Math.max(a.left,b.left),oy=Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top);const dy=Math.max(a.top,b.top)-Math.min(a.bottom,b.bottom),dx=Math.max(a.left,b.left)-Math.min(a.right,b.right);if((ox>Math.min(a.width,b.width)/2&&dy>=0&&dy<8)||(oy>Math.min(a.height,b.height)/2&&dx>=0&&dx<8))issues.push({a:nodes[i].id,b:nodes[j].id,gap:ox>0?dy:dx});}return issues;});assert(controls.length===0,`${name} ${width} ${theme} controls ${JSON.stringify(controls)}`);checks++;
|
||||
assert(result.buttonGaps.every(x=>x>=8),`${name} ${width} button gaps ${result.buttonGaps}`);assert(!result.overflow,`${name} ${width} overflow`);assert(result.gaps.every(x=>x>=16),`${name} ${width} gaps ${result.gaps}`);checks++;if(name==='templates'||name==='settings')await page.screenshot({path:`docs/screenshots/${name}-${width}-${theme}.png`,fullPage:true});}}
|
||||
await page.locator('[data-page="templates"]').click();await page.locator('[data-template="daily-poetry"]').click();await page.locator('#poem-choice').waitFor();assert(await page.locator('#poem-choice option').count()===21);checks++;await page.locator('#poem-choice').selectOption('jing-ye-si');await page.locator('#render-template').click();await page.locator('#template-image').waitFor();await page.waitForFunction(()=>document.querySelector('#template-image')?.naturalWidth===1200);checks++;await page.locator('#daily-poetry-job').click();assert(!await page.locator('#poetry-job-enabled').isChecked());checks++;const gap=await page.evaluate(()=>{const a=document.querySelector('#daily-poetry-job').getBoundingClientRect(),b=document.querySelector('#template-fields').getBoundingClientRect();return b.top-a.bottom;});assert(gap>=20,'Poetry action touches fields');checks++;await page.locator('#modal-cancel').click();await page.screenshot({path:'docs/screenshots/daily-poetry-controls-mobile-dark.png',fullPage:true});for(const width of [1440,390])for(const theme of ['light','dark']){await page.setViewportSize({width,height:1000});await page.evaluate(theme=>document.documentElement.dataset.theme=theme,theme);await page.locator('[data-page="templates"]').click();for(const id of ['calendar','daily-dashboard','daily-poetry','photo-text','quote','system-alert','weather']){await page.locator(`[data-template="${id}"]`).click();await page.waitForTimeout(250);await page.locator('#download-template').waitFor();if(id==='photo-text'){const photoGap=await page.evaluate(()=>document.querySelector('#template-photo-upload').getBoundingClientRect().top-document.querySelector('#field-photo_id').getBoundingClientRect().bottom);assert(photoGap>=12,`${width} ${theme} photo controls touch`);checks++;}const gap=await page.evaluate(()=>{const a=document.querySelector('#display-template').getBoundingClientRect(),b=document.querySelector('#download-template').getBoundingClientRect();return Math.abs(a.top-b.top)<4?b.left-a.right:b.top-a.bottom;});assert(gap>=8,`${id} ${width} preview buttons touch`);checks++;}for(const [name,button] of [['automation','new-job'],['playlists','new-playlist'],['templates','daily-poetry-job']]){await page.locator(`[data-page="${name}"]`).click();if(name==='templates'){await page.locator('[data-template="daily-poetry"]').click();await page.locator('#poem-choice').waitFor();}await page.locator('#'+button).click();const gap=await page.evaluate(()=>{const [a,b]=[...document.querySelectorAll('#modal .modal-actions button')].map(x=>x.getBoundingClientRect());return Math.abs(a.top-b.top)<4?b.left-a.right:b.top-a.bottom;});assert(gap>=8,`${name} ${width} modal buttons touch`);checks++;await page.locator('#modal-cancel').click();}}assert(unintended===0,`Cancel submitted ${unintended} requests`);checks++;await browser.close();console.log(`Layout/poetry browser: ${checks} passed (32 page/profile combinations)`);})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
|
||||
@@ -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)});
|
||||
@@ -1,3 +1,3 @@
|
||||
/* Local Mock only. No real official account or frame writes. */
|
||||
const assert=require('assert'),{chromium}=require('playwright');
|
||||
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();const errors=[];let frameWrites=0;page.on('pageerror',e=>errors.push(e.message));await page.route('**/api/**',route=>{const r=route.request();if(r.method()!=='GET'&&!r.url().includes('/auth/')&&!r.url().endsWith('/official/connection')){frameWrites++;return route.abort();}return route.continue();});await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));await page.locator('[data-page="templates"]').click();await page.locator('.official-content-info summary').click();await page.waitForTimeout(300);if(await page.locator('#official-disconnect').count())await page.locator('#official-disconnect').click();await page.locator('#official-username').fill('mock-official-user');await page.locator('#official-password').fill('mock-password-not-saved');await page.locator('#official-login-form button').click();await page.locator('#official-device').waitFor();await page.locator('#official-select-form button').click();await page.locator('.official-account-panel').waitFor();await page.reload();await page.locator('.official-content-info summary').click();await page.locator('#official-disconnect').waitFor();assert((await page.locator('.official-account-panel').textContent()).includes('已关联画框'));for(const width of [1440,390]){await page.setViewportSize({width,height:1000});assert(!await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth));}await page.locator('#official-disconnect').click();await page.locator('#official-login-form').waitFor();assert(await page.locator('#official-password').inputValue()==='');assert(!await page.evaluate(()=>JSON.stringify(localStorage).includes('mock-password-not-saved')));assert(errors.length===0&&frameWrites===0);await browser.close();console.log('Official connection UI: login, discover, select, reload, disconnect, 2 sizes PASS; 0 page errors, 0 frame writes');})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();const errors=[];let frameWrites=0;page.on('pageerror',e=>errors.push(e.message));await page.route('**/api/**',route=>{const r=route.request();if(r.method()!=='GET'&&!r.url().includes('/auth/')&&!r.url().endsWith('/official/connection')){frameWrites++;return route.abort();}return route.continue();});await page.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));await page.locator('[data-page="templates"]').click();await page.locator('.official-content-info summary').click();await page.waitForTimeout(300);if(await page.locator('#official-disconnect').count())await page.locator('#official-disconnect').click();await page.locator('#official-username').fill('mock-official-user');await page.locator('#official-password').fill('mock-password-not-saved');await page.locator('#official-login-form button').click();await page.locator('#official-device').waitFor();await page.locator('#official-select-form button').click();await page.locator('.official-account-panel').waitFor();await page.reload();await page.locator('.official-content-info summary').click();await page.locator('#official-disconnect').waitFor();assert((await page.locator('.official-account-panel').textContent()).includes('已关联画框'));for(const width of [1440,390]){await page.setViewportSize({width,height:1000});assert(!await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth));}await page.locator('#official-disconnect').click();await page.locator('#official-login-form').waitFor();assert(await page.locator('#official-password').inputValue()==='');assert(!await page.evaluate(()=>JSON.stringify(localStorage).includes('mock-password-not-saved')));assert(errors.length===0&&frameWrites===0);await browser.close();console.log('Official connection UI: login, discover, select, reload, disconnect, 2 sizes PASS; 0 page errors, 0 frame writes');})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
|
||||
@@ -7,7 +7,7 @@ const assert=require('assert'),{chromium}=require('playwright');
|
||||
await page.route('**/api/v1/integrations/synology-photos',route=>route.fulfill({json:{configured:true,connected:true}}));
|
||||
await page.route('**/api/v1/integrations/synology-photos/people',route=>route.fulfill({json:[{id:1,name:'Family A',count:20,named:true},{id:2,name:'Family B',count:15,named:true},{id:3,name:'3',count:2,named:false}]}));
|
||||
try{
|
||||
await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));
|
||||
await page.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));
|
||||
for(const lang of ['zh','en'])for(const width of [1440,390])for(const theme of ['dark','light']){
|
||||
await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="photo-sources"]').click();await page.locator('#add-photo-source').click();await page.locator('#source-id').fill(id);await page.locator('#source-name').fill('People fixture');await page.locator('#source-people-load').click();await page.locator('[data-person="1"]').waitFor();assert(await page.locator('[data-person]').count()===2);await page.locator('#people-unnamed').check();assert(await page.locator('[data-person]').count()===3);await page.locator('#people-search').fill('Family B');assert(await page.locator('[data-person]').count()===1);await page.locator('[data-person="2"]').check();await page.locator('#source-people-filter').check();await page.locator('#source-person-match').selectOption('all');assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
|
||||
if(checks===0){await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-edit="${id}"]`).waitFor();const saved=await page.evaluate(async id=>(await api('/api/v1/photo-sources')).find(s=>s.id===id),id);assert(saved.people_filter&&saved.person_match==='all'&&saved.person_ids.join(',')==='2');await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-people-filter').isChecked());assert(await page.locator('#source-person-match').inputValue()==='all');}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
/* Local mock server only; no vendor/cloud writes. PHOTO_ROOT must contain a fixture image. */
|
||||
const assert=require('assert'),{chromium}=require('playwright');
|
||||
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));const id='ui-photo-'+Date.now();let jobId;let checks=0;try{await page.locator('[data-page="photo-sources"]').click();await page.locator('#add-photo-source').click();await page.locator('#source-id').fill(id);await page.locator('#source-name').fill('UI photo fixture');await page.locator('#source-location').fill('.');await page.locator('#source-orientation').selectOption('landscape');await page.locator('#source-organized').check();await page.locator('#source-caption').check();await page.locator('#source-synology-previews').check();await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-browse="${id}"]`).waitFor();for(const lang of ['zh','en'])for(const width of [1440,390])for(const theme of ['dark','light']){await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="photo-sources"]').click();await page.locator(`[data-source-browse="${id}"]`).click();await page.locator('[data-source-item]').first().waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-id').inputValue()===id);assert(await page.locator('#source-orientation').inputValue()==='landscape');assert(await page.locator('#source-organized').isChecked());assert(await page.locator('#source-caption').isChecked());assert(await page.locator('#source-synology-previews').isChecked());await page.locator('#modal-cancel').click();checks++;}await page.locator(`[data-source-random="${id}"]`).click();await page.locator('#source-preview').waitFor();await page.waitForFunction(()=>document.querySelector('#source-preview')?.naturalWidth>0);assert(await page.locator('#source-preview').evaluate(img=>img.naturalWidth===1600&&img.naturalHeight===1200));const before=await page.evaluate(()=>api('/api/v1/devices/'+state.device));await page.locator('#modal-cancel').click();await page.locator(`[data-source-job="${id}"]`).click();assert(!await page.locator('#source-job-enabled').isChecked());await page.locator('#source-job-name').fill(id);await page.locator('#source-job-form button[type="submit"]').click();await page.locator('#new-job').waitFor();jobId=await page.evaluate(async id=>(await api('/api/v1/jobs')).find(j=>j.name===id).id,id);await page.evaluate(async jobId=>{const job=(await api('/api/v1/jobs')).find(j=>j.id===jobId);await jobEditor(job);},jobId);await page.locator('#job-photo-source').waitFor();assert(await page.locator('#job-form').textContent().then(x=>x.includes('最新成功图')||x.includes('latest confirmed')));assert(await page.locator('#job-content-type').inputValue()==='photo_source');assert(await page.locator('#job-photo-source').inputValue()===id);await page.locator('#modal-cancel').click();const after=await page.evaluate(()=>api('/api/v1/devices/'+state.device));assert(before.current_content.id===after.current_content.id);checks+=3;console.log('Photo sources browser: '+checks+' passed (8 language/size/theme profiles, preview and scheduler editing)');}finally{await page.evaluate(async({id,jobId})=>{if(jobId)await api('/api/v1/jobs/'+jobId,{method:'DELETE'});await api('/api/v1/photo-sources/'+id,{method:'DELETE'});},{id,jobId});await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
|
||||
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();await page.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));const id='ui-photo-'+Date.now();let jobId;let checks=0;try{await page.locator('[data-page="photo-sources"]').click();await page.locator('#add-photo-source').click();await page.locator('#source-id').fill(id);await page.locator('#source-name').fill('UI photo fixture');await page.locator('#source-location').fill('.');await page.locator('#source-orientation').selectOption('landscape');await page.locator('#source-organized').check();await page.locator('#source-caption').check();await page.locator('#source-synology-previews').check();await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-browse="${id}"]`).waitFor();for(const lang of ['zh','en'])for(const width of [1440,390])for(const theme of ['dark','light']){await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="photo-sources"]').click();await page.locator(`[data-source-browse="${id}"]`).click();await page.locator('[data-source-item]').first().waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-id').inputValue()===id);assert(await page.locator('#source-orientation').inputValue()==='landscape');assert(await page.locator('#source-organized').isChecked());assert(await page.locator('#source-caption').isChecked());assert(await page.locator('#source-synology-previews').isChecked());await page.locator('#modal-cancel').click();checks++;}await page.locator(`[data-source-random="${id}"]`).click();await page.locator('#source-preview').waitFor();await page.waitForFunction(()=>document.querySelector('#source-preview')?.naturalWidth>0);assert(await page.locator('#source-preview').evaluate(img=>img.naturalWidth===1600&&img.naturalHeight===1200));const before=await page.evaluate(()=>api('/api/v1/devices/'+state.device));await page.locator('#modal-cancel').click();await page.locator(`[data-source-job="${id}"]`).click();assert(!await page.locator('#source-job-enabled').isChecked());await page.locator('#source-job-name').fill(id);await page.locator('#source-job-form button[type="submit"]').click();await page.locator('#new-job').waitFor();jobId=await page.evaluate(async id=>(await api('/api/v1/jobs')).find(j=>j.name===id).id,id);await page.evaluate(async jobId=>{const job=(await api('/api/v1/jobs')).find(j=>j.id===jobId);await jobEditor(job);},jobId);await page.locator('#job-photo-source').waitFor();assert(await page.locator('#job-form').textContent().then(x=>x.includes('最新成功图')||x.includes('latest confirmed')));assert(await page.locator('#job-content-type').inputValue()==='photo_source');assert(await page.locator('#job-photo-source').inputValue()===id);await page.locator('#modal-cancel').click();const after=await page.evaluate(()=>api('/api/v1/devices/'+state.device));assert(before.current_content.id===after.current_content.id);checks+=3;console.log('Photo sources browser: '+checks+' passed (8 language/size/theme profiles, preview and scheduler editing)');}finally{await page.evaluate(async({id,jobId})=>{if(jobId)await api('/api/v1/jobs/'+jobId,{method:'DELETE'});await api('/api/v1/photo-sources/'+id,{method:'DELETE'});},{id,jobId});await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
/* Local mock server only; no vendor/cloud writes. PHOTO_ROOT must contain a fixture image. */
|
||||
const assert=require('assert'),{chromium}=require('playwright');
|
||||
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));const id='ui-photo-'+Date.now();let jobId;let checks=0;try{await page.locator('[data-page="photo-sources"]').click();await page.locator('#add-photo-source').click();await page.locator('#source-id').fill(id);await page.locator('#source-name').fill('UI photo fixture');await page.locator('#source-location').fill('.');await page.locator('#source-orientation').selectOption('landscape');await page.locator('#source-organized').check();await page.locator('#source-caption').check();await page.locator('#source-synology-previews').check();await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-browse="${id}"]`).waitFor();for(const lang of ['zh','en'])for(const width of [1440,390])for(const theme of ['dark','light']){await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="photo-sources"]').click();await page.locator(`[data-source-browse="${id}"]`).click();await page.locator('[data-source-item]').first().waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-id').inputValue()===id);assert(await page.locator('#source-orientation').inputValue()==='landscape');assert(await page.locator('#source-organized').isChecked());assert(await page.locator('#source-caption').isChecked());assert(await page.locator('#source-synology-previews').isChecked());await page.locator('#modal-cancel').click();checks++;}await page.locator(`[data-source-random="${id}"]`).click();await page.locator('#source-preview').waitFor();await page.waitForFunction(()=>document.querySelector('#source-preview')?.naturalWidth>0);assert(await page.locator('#source-preview').evaluate(img=>img.naturalWidth===1600&&img.naturalHeight===1200));const before=await page.evaluate(()=>api('/api/v1/devices/'+state.device));await page.locator('#modal-cancel').click();await page.locator(`[data-source-job="${id}"]`).click();assert(!await page.locator('#source-job-enabled').isChecked());await page.locator('#source-job-name').fill(id);await page.locator('#source-job-form button[type="submit"]').click();await page.locator('#new-job').waitFor();jobId=await page.evaluate(async id=>(await api('/api/v1/jobs')).find(j=>j.name===id).id,id);await page.evaluate(async jobId=>{const job=(await api('/api/v1/jobs')).find(j=>j.id===jobId);await jobEditor(job);},jobId);await page.locator('#job-photo-source').waitFor();assert(await page.locator('#job-content-type').inputValue()==='photo_source');assert(await page.locator('#job-photo-source').inputValue()===id);await page.locator('#modal-cancel').click();const after=await page.evaluate(()=>api('/api/v1/devices/'+state.device));assert(before.current_content.id===after.current_content.id);checks+=3;console.log('Photo sources browser: '+checks+' passed (8 language/size/theme profiles, preview and scheduler editing)');}finally{await page.evaluate(async({id,jobId})=>{if(jobId)await api('/api/v1/jobs/'+jobId,{method:'DELETE'});await api('/api/v1/photo-sources/'+id,{method:'DELETE'});},{id,jobId});await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
|
||||
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();await page.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));const id='ui-photo-'+Date.now();let jobId;let checks=0;try{await page.locator('[data-page="photo-sources"]').click();await page.locator('#add-photo-source').click();await page.locator('#source-id').fill(id);await page.locator('#source-name').fill('UI photo fixture');await page.locator('#source-location').fill('.');await page.locator('#source-orientation').selectOption('landscape');await page.locator('#source-organized').check();await page.locator('#source-caption').check();await page.locator('#source-synology-previews').check();await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-browse="${id}"]`).waitFor();for(const lang of ['zh','en'])for(const width of [1440,390])for(const theme of ['dark','light']){await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="photo-sources"]').click();await page.locator(`[data-source-browse="${id}"]`).click();await page.locator('[data-source-item]').first().waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-id').inputValue()===id);assert(await page.locator('#source-orientation').inputValue()==='landscape');assert(await page.locator('#source-organized').isChecked());assert(await page.locator('#source-caption').isChecked());assert(await page.locator('#source-synology-previews').isChecked());await page.locator('#modal-cancel').click();checks++;}await page.locator(`[data-source-random="${id}"]`).click();await page.locator('#source-preview').waitFor();await page.waitForFunction(()=>document.querySelector('#source-preview')?.naturalWidth>0);assert(await page.locator('#source-preview').evaluate(img=>img.naturalWidth===1600&&img.naturalHeight===1200));const before=await page.evaluate(()=>api('/api/v1/devices/'+state.device));await page.locator('#modal-cancel').click();await page.locator(`[data-source-job="${id}"]`).click();assert(!await page.locator('#source-job-enabled').isChecked());await page.locator('#source-job-name').fill(id);await page.locator('#source-job-form button[type="submit"]').click();await page.locator('#new-job').waitFor();jobId=await page.evaluate(async id=>(await api('/api/v1/jobs')).find(j=>j.name===id).id,id);await page.evaluate(async jobId=>{const job=(await api('/api/v1/jobs')).find(j=>j.id===jobId);await jobEditor(job);},jobId);await page.locator('#job-photo-source').waitFor();assert(await page.locator('#job-content-type').inputValue()==='photo_source');assert(await page.locator('#job-photo-source').inputValue()===id);await page.locator('#modal-cancel').click();const after=await page.evaluate(()=>api('/api/v1/devices/'+state.device));assert(before.current_content.id===after.current_content.id);checks+=3;console.log('Photo sources browser: '+checks+' passed (8 language/size/theme profiles, preview and scheduler editing)');}finally{await page.evaluate(async({id,jobId})=>{if(jobId)await api('/api/v1/jobs/'+jobId,{method:'DELETE'});await api('/api/v1/photo-sources/'+id,{method:'DELETE'});},{id,jobId});await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
|
||||
|
||||
@@ -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)});
|
||||
@@ -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)});
|
||||
@@ -0,0 +1,54 @@
|
||||
/* Mock-only: known image metadata renders real slots; no synthetic skeletons. */
|
||||
const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright');
|
||||
(async()=>{
|
||||
const base=process.env.FRAME_FLOW_URL;assert(base);
|
||||
const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);
|
||||
assert((await(await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}})).json()).mock===true);
|
||||
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
||||
try{
|
||||
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
let releaseAccount;await page.route('**/api/v1/auth/me',async r=>{await new Promise(resolve=>releaseAccount=resolve);await r.continue();});
|
||||
await page.goto(base);await page.locator('#nav button').first().waitFor();assert.equal(await page.locator('.image-pending,.placeholder-line,.page-placeholder').count(),0);assert.equal(await page.locator('.page-loading').count(),0);releaseAccount();
|
||||
await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();
|
||||
let checks=1;
|
||||
for(const width of [1440,390]){
|
||||
await page.setViewportSize({width,height:844});await page.evaluate(()=>state.galleryCache?.clear());let releaseMetadata,metadataRequested,releaseImage,imageRequested;
|
||||
await page.route('**/api/v1/devices/*/images',async r=>{metadataRequested();await new Promise(resolve=>releaseMetadata=resolve);await r.fulfill({json:[{image_id:1,name:'Progressive fixture',url:'/progressive-fixture.svg',current:false}]});});
|
||||
await page.route('**/progressive-fixture.svg*',async r=>{assert.equal(await page.locator('#gallery-search').count(),1,'Search must precede image requests');assert(await page.locator('#gallery-search').evaluate(e=>typeof e.oninput==='function'));imageRequested();await new Promise(resolve=>releaseImage=resolve);await r.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"/>'});});
|
||||
const metadata=new Promise(resolve=>metadataRequested=resolve);const image=new Promise(resolve=>imageRequested=resolve);
|
||||
await page.evaluate(()=>{window.navigation=navigate('gallery');});await metadata;
|
||||
assert.equal(await page.locator('.image-pending,.placeholder-line,.page-placeholder').count(),0);assert.equal(await page.locator('.image-card').count(),0);assert.equal(await page.locator('.page-loading').count(),0);checks++;
|
||||
assert(await page.locator('#cloud-tab').isVisible());assert(await page.locator('#local-tab').isEnabled());assert(await page.locator('#gallery-upload').isEnabled());assert(await page.locator('#gallery-search').isVisible());assert(await page.locator('#gallery-sort').isEnabled());assert(await page.locator('#gallery-list-status').isVisible());assert.equal(await page.locator('.card.empty').count(),0);await page.locator('#gallery-search').fill('Progressive');await page.locator('#gallery-sort').selectOption('name');checks++;
|
||||
releaseMetadata();await image;await page.evaluate(()=>window.navigation);
|
||||
assert.equal(await page.locator('#gallery-search').inputValue(),'Progressive');assert.equal(await page.locator('#gallery-sort').inputValue(),'name');assert(await page.locator('#gallery-upload').isEnabled());assert.equal(await page.locator('.image-loading .spinner').count(),1);assert.equal(await page.locator('.image-pending').count(),0);checks++;
|
||||
assert(await page.locator('.image-loading').evaluate(box=>{const a=box.getBoundingClientRect(),b=box.querySelector('.spinner').getBoundingClientRect();return Math.abs(a.x+a.width/2-b.x-b.width/2)<1&&Math.abs(a.y+a.height/2-b.y-b.height/2)<1;}));checks++;
|
||||
releaseImage();await page.waitForFunction(()=>document.querySelector('.thumb')?.naturalWidth===300&&!document.querySelector('.image-loading'));checks++;
|
||||
await page.evaluate(()=>navigate('upload'));
|
||||
const cachedMetadata=new Promise(resolve=>metadataRequested=resolve);const cachedImage=new Promise(resolve=>imageRequested=resolve);
|
||||
await page.evaluate(()=>{window.navigation=navigate('gallery');});await cachedMetadata;await cachedImage;
|
||||
assert.equal(await page.locator('#gallery-search').count(),1,'Search must exist while cached metadata refresh is pending');assert(await page.locator('#gallery-search').evaluate(e=>typeof e.oninput==='function'),'Search must be interactive');assert.equal(await page.locator('.image-card').count(),1);assert.equal(await page.locator('.image-loading .spinner').count(),1);assert.equal(await page.locator('.image-pending,.placeholder-line,.page-placeholder').count(),0);checks++;
|
||||
releaseMetadata();await page.evaluate(()=>window.navigation);releaseImage();await page.waitForFunction(()=>document.querySelector('.thumb')?.naturalWidth===300&&!document.querySelector('.image-loading'));
|
||||
|
||||
await page.unroute('**/api/v1/devices/*/images');await page.unroute('**/progressive-fixture.svg*');
|
||||
let releaseDevice,deviceRequested;await page.route('**/api/v1/devices/living-room',async r=>{deviceRequested();await new Promise(resolve=>releaseDevice=resolve);await r.continue();});
|
||||
const device=new Promise(resolve=>deviceRequested=resolve);await page.evaluate(()=>{window.navigation=navigate('dashboard');});await device;
|
||||
assert.equal(await page.locator('.image-pending,.placeholder-line,.page-placeholder').count(),0);assert.equal(await page.locator('.page-loading').count(),0);checks++;
|
||||
releaseDevice();await page.evaluate(()=>window.navigation);await page.unroute('**/api/v1/devices/living-room');
|
||||
}
|
||||
await page.evaluate(()=>{state.galleryCache?.clear();state.tab='cloud';});
|
||||
let releaseSlow,slowRequested;const slow=new Promise(resolve=>slowRequested=resolve);
|
||||
await page.route('**/api/v1/devices/*/images',async r=>{slowRequested();await new Promise(resolve=>releaseSlow=resolve);await r.fulfill({json:[]});});
|
||||
await page.evaluate(()=>{window.slowNavigation=navigate('gallery');});await slow;await page.locator('#local-tab').click();await page.waitForFunction(()=>state.tab==='local'&&document.querySelector('#local-tab')?.classList.contains('active'));releaseSlow();await page.evaluate(()=>window.slowNavigation);assert.equal(await page.evaluate(()=>state.tab),'local');assert(await page.locator('#gallery-search').isVisible());checks++;
|
||||
await page.unroute('**/api/v1/devices/*/images');
|
||||
await page.evaluate(()=>{state.galleryCache?.clear();state.tab='cloud';});
|
||||
await page.route('**/api/v1/devices/*/images',r=>r.fulfill({status:503,json:{error:{code:'CLOUD_UNAVAILABLE',message:'fixture unavailable'}}}));
|
||||
await page.evaluate(()=>navigate('gallery'));assert(await page.locator('#gallery-search').isVisible());assert(await page.locator('#local-tab').isEnabled());assert.equal(await page.locator('#gallery-list-status').getAttribute('role'),'alert');checks++;
|
||||
await page.unroute('**/api/v1/devices/*/images');
|
||||
let releaseJobs,jobsRequested;await page.route('**/api/v1/jobs',async r=>{jobsRequested();await new Promise(resolve=>releaseJobs=resolve);await r.fulfill({json:[]});});
|
||||
await page.route('**/api/v1/assets?*',r=>r.fulfill({json:[{asset_id:'local-fixture',name:'Local fixture',url:'/local-fixture.svg',origin:'manual',starred:false}]}));
|
||||
await page.route('**/local-fixture.svg',r=>r.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"/>'}));
|
||||
const jobs=new Promise(resolve=>jobsRequested=resolve);await page.evaluate(()=>{state.tab='local';return navigate('gallery');});await jobs;
|
||||
assert.equal(await page.locator('.asset-select').count(),1);assert(await page.locator('#gallery-search').evaluate(e=>typeof e.oninput==='function'));await page.locator('#gallery-search').fill('no-match');assert(await page.locator('.image-card').evaluate(e=>e.hidden));releaseJobs();checks++;
|
||||
assert.deepEqual(errors,[]);console.log('Known image positions without skeletons:',checks,'checks PASS; delayed bootstrap, metadata and images; no page errors');
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
@@ -2,7 +2,7 @@
|
||||
const fs=require('fs');const path=require('path');const assert=require('assert');const {chromium}=require('playwright');
|
||||
(async()=>{
|
||||
const uploadName='browser-upload-'+Date.now();const root=path.resolve(__dirname,'..');const env=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE||path.join(root,'.env'),'utf8');const token=env.split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice('FRAME_FLOW_TOKEN='.length);
|
||||
const base=process.env.FRAME_FLOW_URL||'http://127.0.0.1:18787';const response=await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}});const settings=await response.json();assert(settings.mock===true,'UI smoke requires Mock mode');
|
||||
const base=process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005';const response=await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}});const settings=await response.json();assert(settings.mock===true,'UI smoke requires Mock mode');
|
||||
const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
||||
const context=await browser.newContext({viewport:{width:1440,height:1000}});const page=await context.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message.replaceAll(token,'[REDACTED]')));let checks=0;
|
||||
await page.addInitScript(()=>Object.defineProperty(window.crypto,'randomUUID',{value:undefined,configurable:true}));await page.goto(base);await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.locator('h2').first().innerText()==='客厅相框');checks++;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/* Mock-only editor regression: parallel image requests, stable regions and live controls. */
|
||||
const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright');
|
||||
(async()=>{
|
||||
const base=process.env.FRAME_FLOW_URL;assert(base);const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);
|
||||
assert((await(await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}})).json()).mock===true);
|
||||
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
||||
try{
|
||||
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.route('**/api/v1/assets?*',r=>r.fulfill({json:[{asset_id:'studio-fixture-a',name:'A'},{asset_id:'studio-fixture-b',name:'B'}]}));
|
||||
await page.route('**/api/v1/layout/plan',r=>r.fulfill({json:{width:1200,height:1600,regions:[{asset_id:'studio-fixture-a',x:20,y:200,width:550,height:900},{asset_id:'studio-fixture-b',x:620,y:200,width:550,height:900}]}}));
|
||||
let requests,releases,failSecond=false;
|
||||
await page.route('**/api/v1/assets/studio-fixture-*',async r=>{const key=r.request().url().split('-').pop();requests.add(key);await new Promise(resolve=>releases[key]=resolve);await r.fulfill({contentType:'image/svg+xml',body:failSecond&&key==='b'?'invalid image':'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"><rect width="300" height="400" fill="#167253"/></svg>'});});
|
||||
await page.goto(base);await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();let checks=0;
|
||||
for(const width of [1440,390])for(const theme of ['light','dark']){
|
||||
requests=new Set();releases={};await page.setViewportSize({width,height:900});await page.evaluate(theme=>{document.documentElement.dataset.theme=theme;return navigate('studio');},theme);await page.locator('.manual-collage summary').click();
|
||||
await page.locator('#studio-assets input').nth(0).check();await page.locator('#studio-assets input').nth(1).check();await page.locator('#studio-plan').click();
|
||||
await page.waitForFunction(()=>document.querySelectorAll('.studio-region .spinner').length===2);for(let n=0;n<100&&requests.size<2;n++)await page.waitForTimeout(20);assert.equal(requests.size,2,'Both requests must start before either finishes');assert(await page.locator('#studio-render').isEnabled());checks++;
|
||||
assert(await page.locator('.studio-region').evaluateAll(regions=>regions.every(region=>{const a=region.getBoundingClientRect(),b=region.querySelector('.spinner').getBoundingClientRect();return Math.abs(a.x+a.width/2-b.x-b.width/2)<1&&Math.abs(a.y+a.height/2-b.y-b.height/2)<1;})));checks++;
|
||||
const field=page.locator('[data-region="0"][data-key="x"]');await field.fill('40');await field.dispatchEvent('input');assert(await page.locator('.studio-region').first().evaluate(e=>Math.abs(parseFloat(e.style.left)-40/1200*100)<0.01));checks++;
|
||||
const before=await page.locator('.studio-region').nth(1).boundingBox();releases.b();await page.waitForFunction(()=>document.querySelectorAll('.studio-region')[1].querySelector('img').naturalWidth===300&&!document.querySelectorAll('.studio-region')[1].querySelector('.image-loading'));
|
||||
assert.equal(await page.locator('.studio-region').first().locator('.spinner').count(),1);assert.deepEqual(await page.locator('.studio-region').nth(1).boundingBox(),before);checks++;
|
||||
const region=page.locator('.studio-region').nth(1);await region.scrollIntoViewIfNeeded();const point=await region.boundingBox();const old=await page.locator('[data-region="1"][data-key="x"]').inputValue();await page.mouse.move(point.x+point.width/2,point.y+Math.min(point.height/2,80));await page.mouse.down();await page.mouse.move(point.x+point.width/2-10,point.y+Math.min(point.height/2,80)+5,{steps:3});await page.mouse.up();assert.notEqual(await page.locator('[data-region="1"][data-key="x"]').inputValue(),old);checks++;
|
||||
releases.a();await page.waitForFunction(()=>document.querySelectorAll('.studio-region img').length===2&&[...document.querySelectorAll('.studio-region img')].every(img=>img.naturalWidth===300)&&!document.querySelector('.studio-region .image-loading'));checks++;
|
||||
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++;
|
||||
}
|
||||
requests=new Set();releases={};failSecond=true;await page.locator('#studio-plan').click();for(let n=0;n<100&&requests.size<2;n++)await page.waitForTimeout(20);assert.equal(requests.size,2);
|
||||
releases.b();await page.locator('.studio-region .image-load-error').waitFor();assert.equal(await page.locator('.studio-region').first().locator('.spinner').count(),1);assert(await page.locator('#studio-render').isEnabled());checks++;
|
||||
const errorField=page.locator('[data-region="1"][data-key="x"]');await errorField.fill('600');await errorField.dispatchEvent('input');assert.equal(await page.locator('.studio-region').nth(1).evaluate(e=>e.style.left),'50%');checks++;
|
||||
releases.a();await page.waitForFunction(()=>document.querySelector('.studio-region img')?.naturalWidth===300);assert.equal(await page.locator('.studio-region .spinner').count(),0);checks++;
|
||||
assert.deepEqual(errors,[]);console.log('Studio image loading:',checks,'checks PASS; parallel requests, independent completion, local spinners, coordinate editing and dragging; no page errors');
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
@@ -4,7 +4,7 @@ const assert=require('assert'),{chromium}=require('playwright');
|
||||
const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
||||
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));let status='AUTH_REQUIRED',checks=0;
|
||||
await page.route('**/api/v1/devices/*/widgets',route=>route.fulfill({json:{source:status==='AUTH_REQUIRED'?'snapshot':'live',configured:status!=='AUTH_REQUIRED',status,observed_at:'2026-10-02',catalog:[],current:null,current_verified:status==='NO_WIDGET',writable:false}}));
|
||||
await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();
|
||||
await page.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();
|
||||
for(const lang of ['zh','en'])for(const value of ['AUTH_REQUIRED','NO_WIDGET','CURRENT_UNVERIFIED']){
|
||||
status=value;await page.evaluate(lang=>{state.lang=lang;return navigate('templates');},lang);
|
||||
await page.locator('.widget-auth-notice').waitFor({state:'attached'});
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -613,6 +613,16 @@
|
||||
},
|
||||
"Operation": {
|
||||
"properties": {
|
||||
"recovery": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/RecoveryState"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"operation_id": {
|
||||
"type": "string",
|
||||
"title": "Operation Id"
|
||||
@@ -859,6 +869,7 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"local",
|
||||
"synology-photos",
|
||||
"webdav",
|
||||
"google-drive",
|
||||
"alipan",
|
||||
@@ -872,6 +883,19 @@
|
||||
"minLength": 1,
|
||||
"title": "Location"
|
||||
},
|
||||
"nas_root_path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 2048,
|
||||
"pattern": "^/"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Nas Root Path"
|
||||
},
|
||||
"recursive": {
|
||||
"type": "boolean",
|
||||
"title": "Recursive",
|
||||
@@ -920,6 +944,20 @@
|
||||
],
|
||||
"title": "Orientation"
|
||||
},
|
||||
"minimum_short_edge": {
|
||||
"type": "integer",
|
||||
"maximum": 50000.0,
|
||||
"minimum": 0.0,
|
||||
"title": "Minimum Short Edge",
|
||||
"default": 0
|
||||
},
|
||||
"minimum_long_edge": {
|
||||
"type": "integer",
|
||||
"maximum": 50000.0,
|
||||
"minimum": 0.0,
|
||||
"title": "Minimum Long Edge",
|
||||
"default": 0
|
||||
},
|
||||
"minimum_sharpness": {
|
||||
"type": "number",
|
||||
"maximum": 10000.0,
|
||||
@@ -1011,6 +1049,7 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"local",
|
||||
"synology-photos",
|
||||
"webdav",
|
||||
"google-drive",
|
||||
"alipan",
|
||||
@@ -1024,6 +1063,19 @@
|
||||
"minLength": 1,
|
||||
"title": "Location"
|
||||
},
|
||||
"nas_root_path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 2048,
|
||||
"pattern": "^/"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Nas Root Path"
|
||||
},
|
||||
"recursive": {
|
||||
"type": "boolean",
|
||||
"title": "Recursive",
|
||||
@@ -1072,6 +1124,20 @@
|
||||
],
|
||||
"title": "Orientation"
|
||||
},
|
||||
"minimum_short_edge": {
|
||||
"type": "integer",
|
||||
"maximum": 50000.0,
|
||||
"minimum": 0.0,
|
||||
"title": "Minimum Short Edge",
|
||||
"default": 0
|
||||
},
|
||||
"minimum_long_edge": {
|
||||
"type": "integer",
|
||||
"maximum": 50000.0,
|
||||
"minimum": 0.0,
|
||||
"title": "Minimum Long Edge",
|
||||
"default": 0
|
||||
},
|
||||
"minimum_sharpness": {
|
||||
"type": "number",
|
||||
"maximum": 10000.0,
|
||||
@@ -1180,6 +1246,17 @@
|
||||
],
|
||||
"title": "Caption"
|
||||
},
|
||||
"source_path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source Path"
|
||||
},
|
||||
"provider_preview": {
|
||||
"type": "boolean",
|
||||
"title": "Provider Preview",
|
||||
@@ -1422,5 +1499,48 @@
|
||||
"type"
|
||||
],
|
||||
"title": "AutoCollageContent"
|
||||
},
|
||||
"RecoveryState": {
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"checking",
|
||||
"manual_review",
|
||||
"resolved"
|
||||
],
|
||||
"title": "State"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"title": "Reason"
|
||||
},
|
||||
"first_checked_at": {
|
||||
"type": "string",
|
||||
"title": "First Checked At"
|
||||
},
|
||||
"last_checked_at": {
|
||||
"type": "string",
|
||||
"title": "Last Checked At"
|
||||
},
|
||||
"checks": {
|
||||
"type": "integer",
|
||||
"title": "Checks"
|
||||
},
|
||||
"requires_manual_review": {
|
||||
"type": "boolean",
|
||||
"title": "Requires Manual Review"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"state",
|
||||
"reason",
|
||||
"first_checked_at",
|
||||
"last_checked_at",
|
||||
"checks",
|
||||
"requires_manual_review"
|
||||
],
|
||||
"title": "RecoveryState"
|
||||
}
|
||||
}
|
||||
|
||||
+34
-1
@@ -338,7 +338,8 @@ def test_unknown_same_idempotency_key_returns_original(client, service):
|
||||
fresh = client.post(
|
||||
"/api/v1/display", json=payload, headers={"Idempotency-Key": "new-key"}
|
||||
)
|
||||
assert fresh.status_code == 409
|
||||
assert fresh.status_code == 202
|
||||
assert fresh.json()["operation_id"] != original.json()["operation_id"]
|
||||
|
||||
|
||||
def test_schema_contract(client):
|
||||
@@ -423,3 +424,35 @@ def test_scoped_client_operation_isolation(client, service):
|
||||
assert [op["operation_id"] for op in client.get("/api/v1/operations").json()] == [
|
||||
own
|
||||
]
|
||||
|
||||
|
||||
def test_settings_exposes_storage_display_name(client, service):
|
||||
service.settings.storage_name = "Family NAS"
|
||||
result = client.get("/api/v1/settings").json()
|
||||
assert result["storage_name"] == "Family NAS"
|
||||
destinations = {d["id"]: d for d in result["gallery_destinations"]}
|
||||
assert set(destinations) == {"cloud", "local"}
|
||||
assert destinations["cloud"]["name"] == "KoKonna"
|
||||
assert destinations["local"]["name"] == "Family NAS"
|
||||
assert all(d["connected"] and d["supports_upload"] for d in destinations.values())
|
||||
assert "test-frame-token" not in str(result)
|
||||
|
||||
|
||||
def test_storage_display_name_environment(monkeypatch):
|
||||
monkeypatch.setenv("FRAME_FLOW_STORAGE_NAME", " Family NAS ")
|
||||
assert Settings().storage_name == "Family NAS"
|
||||
monkeypatch.setenv("FRAME_FLOW_STORAGE_NAME", " ")
|
||||
assert Settings().storage_name == "NAS"
|
||||
|
||||
|
||||
def test_settings_device_catalog_does_not_fetch_cloud_status(
|
||||
client, service, monkeypatch
|
||||
):
|
||||
registry = service.registry
|
||||
for driver in registry.drivers.values():
|
||||
monkeypatch.setattr(
|
||||
driver, "device", lambda *a, **kw: pytest.fail("Cloud status fetched")
|
||||
)
|
||||
result = client.get("/api/v1/settings")
|
||||
assert result.status_code == 200
|
||||
assert {d["id"] for d in result.json()["device_catalog"]} == set(registry.drivers)
|
||||
|
||||
+195
-8
@@ -1,3 +1,4 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
@@ -7,11 +8,28 @@ from pigway_frame_flow.drivers.kokonna.client import KokonnaClient
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def transport_clock(monkeypatch):
|
||||
import pigway_frame_flow.drivers.kokonna.client as module
|
||||
|
||||
clock = SimpleNamespace(now=0.0, waits=[])
|
||||
|
||||
def sleep(seconds):
|
||||
clock.waits.append(seconds)
|
||||
clock.now += seconds
|
||||
|
||||
monkeypatch.setattr(
|
||||
module, "time", SimpleNamespace(monotonic=lambda: clock.now, sleep=sleep)
|
||||
)
|
||||
return clock
|
||||
|
||||
|
||||
def session(body=None, status=200):
|
||||
s = Mock()
|
||||
s.request.return_value.status_code = status
|
||||
s.request.return_value.json.return_value = body or {}
|
||||
s.request.return_value.content = b"image"
|
||||
s.request.return_value.headers = {}
|
||||
return s
|
||||
|
||||
|
||||
@@ -88,7 +106,7 @@ def test_timeout_redaction_no_retry(write):
|
||||
c.display(1) if write else c.get_device()
|
||||
assert "secret" not in str(e.value)
|
||||
assert e.value.code == ("WRITE_RESULT_UNKNOWN" if write else "CLOUD_UNAVAILABLE")
|
||||
assert s.request.call_count == 1
|
||||
assert s.request.call_count == (1 if write else 3)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [401, 429, 500, 530])
|
||||
@@ -109,15 +127,14 @@ def test_invalid_upload_id(tmp_path):
|
||||
assert e.value.code == "WRITE_RESULT_UNKNOWN"
|
||||
|
||||
|
||||
def test_rate_budget():
|
||||
def test_rate_budget(transport_clock):
|
||||
s = session({"nickname": "x"})
|
||||
c = KokonnaClient("secret", session=s)
|
||||
for _ in range(18):
|
||||
c.get_device()
|
||||
with pytest.raises(FlowError) as e:
|
||||
c.get_device()
|
||||
assert e.value.code == "RATE_LIMITED"
|
||||
assert s.request.call_count == 18
|
||||
c.get_device()
|
||||
assert s.request.call_count == 19
|
||||
assert transport_clock.waits == [60]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -137,7 +154,177 @@ def test_transport_failure_records_safe_category_without_replay(exception, categ
|
||||
c = KokonnaClient("secret", True, s)
|
||||
with pytest.raises(FlowError) as error:
|
||||
c.display(1)
|
||||
assert error.value.code == "WRITE_RESULT_UNKNOWN"
|
||||
assert error.value.details == {"transport_error": category}
|
||||
assert error.value.code == (
|
||||
"CLOUD_UNAVAILABLE" if category == "connect_timeout" else "WRITE_RESULT_UNKNOWN"
|
||||
)
|
||||
assert error.value.details["transport_error"] == category
|
||||
assert error.value.details["attempts"] == (
|
||||
3 if category == "connect_timeout" else 1
|
||||
)
|
||||
assert "secret" not in str(error.value.details)
|
||||
assert s.request.call_count == (3 if category == "connect_timeout" else 1)
|
||||
|
||||
|
||||
def test_connection_establishment_failure_is_unsent_and_bounded(transport_clock):
|
||||
from urllib3.exceptions import MaxRetryError, NewConnectionError
|
||||
|
||||
s = session()
|
||||
s.request.side_effect = requests.ConnectionError(
|
||||
MaxRetryError(None, "/private", NewConnectionError(None, "private"))
|
||||
)
|
||||
c = KokonnaClient("secret", True, s)
|
||||
with pytest.raises(FlowError) as error:
|
||||
c.display(1)
|
||||
assert error.value.code == "CLOUD_UNAVAILABLE"
|
||||
assert error.value.details["write_outcome"] == "not_sent"
|
||||
assert error.value.details["transport_error"] == "connect_error"
|
||||
assert s.request.call_count == 3
|
||||
assert "private" not in str(error.value.details)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("write", [False, True])
|
||||
def test_connect_timeout_retries_then_succeeds(write, transport_clock):
|
||||
s = session({"success": True})
|
||||
response = s.request.return_value
|
||||
s.request.side_effect = [requests.ConnectTimeout("private"), response]
|
||||
c = KokonnaClient("private", True, s)
|
||||
c.display(1) if write else c.get_device()
|
||||
assert s.request.call_count == 2
|
||||
assert transport_clock.waits == [1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message,code,kind",
|
||||
[
|
||||
("image not found", "NOT_FOUND", "image_not_found"),
|
||||
("imageId is empty", "INVALID_REQUEST", "invalid_image_id"),
|
||||
("can not find robot private", "CONFIG_REQUIRED", "device_key_invalid"),
|
||||
],
|
||||
)
|
||||
def test_documented_500_rejection_does_not_lock_or_retry(message, code, kind):
|
||||
s = session({"message": message}, 500)
|
||||
c = KokonnaClient("private", True, s)
|
||||
with pytest.raises(FlowError) as error:
|
||||
c.display(1)
|
||||
assert error.value.code == code
|
||||
assert error.value.details["write_outcome"] == "rejected"
|
||||
assert error.value.details["failure_kind"] == kind
|
||||
assert "private" not in str(error.value.details)
|
||||
assert s.request.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [500, 502, 503, 530])
|
||||
def test_generic_5xx_read_retries_but_write_never_replays(status):
|
||||
for write in [False, True]:
|
||||
s = session({"message": "unknown failure private"}, status)
|
||||
c = KokonnaClient("private", True, s)
|
||||
with pytest.raises(FlowError) as error:
|
||||
c.display(1) if write else c.get_device()
|
||||
assert s.request.call_count == (1 if write else 3)
|
||||
assert error.value.code == (
|
||||
"WRITE_RESULT_UNKNOWN" if write else "CLOUD_UNAVAILABLE"
|
||||
)
|
||||
assert "private" not in str(error.value.details)
|
||||
|
||||
|
||||
def test_rate_limit_honors_retry_after_and_closes_response(transport_clock):
|
||||
s = session({"message": "rate limited"}, 429)
|
||||
first = s.request.return_value
|
||||
first.headers = {"Retry-After": "7"}
|
||||
second = session({"success": True}).request.return_value
|
||||
s.request.side_effect = [first, second]
|
||||
KokonnaClient("private", True, s).display(1)
|
||||
assert s.request.call_count == 2
|
||||
assert transport_clock.waits == [7]
|
||||
first.close.assert_called_once()
|
||||
second.close.assert_called_once()
|
||||
|
||||
|
||||
def test_long_retry_after_preserves_cooldown_without_early_request(transport_clock):
|
||||
s = session({"message": "rate limited"}, 429)
|
||||
s.request.return_value.headers = {"Retry-After": "120"}
|
||||
c = KokonnaClient("private", True, s)
|
||||
for _ in range(2):
|
||||
with pytest.raises(FlowError) as error:
|
||||
c.display(1)
|
||||
assert error.value.code == "RATE_LIMITED"
|
||||
assert s.request.call_count == 1
|
||||
assert transport_clock.waits == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("header", [None, "bad", "nan", "inf"])
|
||||
def test_invalid_retry_after_defaults_to_one_minute(header):
|
||||
from pigway_frame_flow.drivers.kokonna.client import SafeTransport
|
||||
|
||||
assert SafeTransport._retry_after(header) == 60
|
||||
|
||||
|
||||
def test_retry_after_accepts_http_date():
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from email.utils import format_datetime
|
||||
|
||||
from pigway_frame_flow.drivers.kokonna.client import SafeTransport
|
||||
|
||||
future = format_datetime(datetime.now(UTC) + timedelta(seconds=30), usegmt=True)
|
||||
assert 28 <= SafeTransport._retry_after(future) <= 30
|
||||
|
||||
|
||||
def test_invalid_write_response_is_never_retried():
|
||||
s = session({"success": False, "message": "private"})
|
||||
with pytest.raises(FlowError) as error:
|
||||
KokonnaClient("private", True, s).display(1)
|
||||
assert error.value.code == "WRITE_RESULT_UNKNOWN"
|
||||
assert error.value.details["failure_kind"] == "invalid_response"
|
||||
assert s.request.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"failure,expected",
|
||||
[
|
||||
("connect_timeout", "CLOUD_UNAVAILABLE"),
|
||||
("business_rejection", "CONFIG_REQUIRED"),
|
||||
("rate_limit", "RATE_LIMITED"),
|
||||
],
|
||||
)
|
||||
def test_proven_upload_failures_end_failed_without_device_lock(
|
||||
service, monkeypatch, failure, expected
|
||||
):
|
||||
from test_display import submit
|
||||
|
||||
s = session({"message": "can not find robot private"}, 500)
|
||||
if failure == "rate_limit":
|
||||
s.request.return_value.status_code = 429
|
||||
if failure == "connect_timeout":
|
||||
s.request.side_effect = requests.ConnectTimeout("private")
|
||||
c = KokonnaClient("private", True, s)
|
||||
monkeypatch.setattr(service.registry.get("living-room"), "upload", c.upload)
|
||||
operation = submit(service)
|
||||
service.process_one()
|
||||
op = service.operation(operation)
|
||||
assert op["status"] == "FAILED"
|
||||
assert op["error"]["code"] == expected
|
||||
assert not service.blocked("living-room")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [True, False, 0, -1, "12", None])
|
||||
def test_invalid_upload_id_never_becomes_confirmed(tmp_path, value):
|
||||
path = tmp_path / "image.jpg"
|
||||
path.write_bytes(b"jpeg")
|
||||
s = session({"id": value})
|
||||
with pytest.raises(FlowError) as error:
|
||||
KokonnaClient("private", True, s).upload(path, "image.jpg")
|
||||
assert error.value.code == "WRITE_RESULT_UNKNOWN"
|
||||
assert s.request.call_count == 1
|
||||
|
||||
|
||||
def test_ambiguous_failure_after_safe_rejection_stops_retrying(transport_clock):
|
||||
s = session({"message": "limited"}, 429)
|
||||
first = s.request.return_value
|
||||
first.headers = {"Retry-After": "1"}
|
||||
s.request.side_effect = [first, requests.ReadTimeout("private")]
|
||||
with pytest.raises(FlowError) as error:
|
||||
KokonnaClient("private", True, s).display(1)
|
||||
assert error.value.code == "WRITE_RESULT_UNKNOWN"
|
||||
assert error.value.details["attempts"] == 2
|
||||
assert s.request.call_count == 2
|
||||
assert transport_clock.waits == [1]
|
||||
|
||||
+111
-10
@@ -71,22 +71,24 @@ def test_nested_overrides(service):
|
||||
assert service.registry.get("living-room").get_current_image() == 1
|
||||
|
||||
|
||||
def test_no_retry_unknown(service):
|
||||
def test_unknown_does_not_block_new_independent_upload(service):
|
||||
driver = service.registry.get("living-room")
|
||||
attempts = []
|
||||
|
||||
def timeout(*a):
|
||||
def timeout(*args):
|
||||
attempts.append(args)
|
||||
raise FlowError(ErrorCode.WRITE_RESULT_UNKNOWN, "Write outcome unknown", 502)
|
||||
|
||||
driver.upload = timeout
|
||||
id = submit(service)
|
||||
first = submit(service)
|
||||
service.process_one()
|
||||
assert service.operation(id)["status"] == "UNKNOWN"
|
||||
with pytest.raises(FlowError):
|
||||
submit(service)
|
||||
assert service.process_one() is False
|
||||
assert service.reconcile(id)["resolved"] is False
|
||||
assert service.reconcile(id, True)["resolved"] is True
|
||||
assert service.operation(id)["status"] == "FAILED"
|
||||
assert service.operation(first)["status"] == "UNKNOWN"
|
||||
second = submit(service, priority=10)
|
||||
assert second != first
|
||||
assert service.process_one()
|
||||
assert service.operation(first)["status"] == "FAILED"
|
||||
assert service.operation(second)["status"] == "UNKNOWN"
|
||||
assert len(attempts) == 2
|
||||
|
||||
|
||||
def test_restart_preserves_override(service):
|
||||
@@ -145,6 +147,105 @@ def test_delete_restore_reference_protected(service):
|
||||
service.process_one()
|
||||
assert service.operation(delete)["status"] == "FAILED"
|
||||
assert service.operation(delete)["error"]["code"] == "CONFLICT"
|
||||
assert (
|
||||
service.operation(delete)["error"]["details"]["reason"]
|
||||
== "RESTORE_IMAGE_PROTECTED"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("automatic", [True, False])
|
||||
def test_completed_restore_consumes_self_reference(client, service, automatic):
|
||||
display = submit(service, 60, True)
|
||||
service.process_one()
|
||||
service.sync()
|
||||
if automatic:
|
||||
expired(service, display)
|
||||
service.expire()
|
||||
else:
|
||||
service.submit("restore", {}, "living-room", "admin", "restore-test")
|
||||
service.process_one()
|
||||
service.sync()
|
||||
assert service.state("living-room")["previous_image_id"] is None
|
||||
assert not client.get("/api/v1/devices/living-room").json()["can_restore"]
|
||||
delete = service.submit(
|
||||
"delete",
|
||||
{"image_id": 1, "confirm_current": True},
|
||||
"living-room",
|
||||
"admin",
|
||||
"delete-restored",
|
||||
)
|
||||
service.process_one()
|
||||
assert service.operation(delete)["status"] == "COMPLETED"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via_delete", [False, True])
|
||||
def test_existing_self_reference_is_repaired(client, service, via_delete):
|
||||
service.state("living-room")
|
||||
service.db.execute(
|
||||
"UPDATE device_state SET previous_image_id=1,managed_image_id=1 WHERE device_id=?",
|
||||
("living-room",),
|
||||
)
|
||||
if via_delete:
|
||||
operation = service.submit(
|
||||
"delete",
|
||||
{"image_id": 1, "confirm_current": True},
|
||||
"living-room",
|
||||
"admin",
|
||||
"delete-stale-reference",
|
||||
)
|
||||
service.process_one()
|
||||
assert service.operation(operation)["status"] == "COMPLETED"
|
||||
else:
|
||||
assert not client.get("/api/v1/devices/living-room?refresh=true").json()[
|
||||
"can_restore"
|
||||
]
|
||||
assert service.state("living-room")["previous_image_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"protection",
|
||||
["override", "base", "unsynced", "unknown", "syncing", "other_previous"],
|
||||
)
|
||||
def test_self_reference_cleanup_preserves_real_restore_requirements(
|
||||
service, protection
|
||||
):
|
||||
service.state("living-room")
|
||||
service.db.execute(
|
||||
"UPDATE device_state SET previous_image_id=1,managed_image_id=1 WHERE device_id=?",
|
||||
("living-room",),
|
||||
)
|
||||
device = service.registry.get("living-room").device()
|
||||
if protection == "override":
|
||||
service.db.execute(
|
||||
"INSERT INTO overrides VALUES(?,?,?,?,?,?)",
|
||||
("active", "living-room", 1, 50, "2099-01-01", "2026-01-01"),
|
||||
)
|
||||
elif protection == "base":
|
||||
service.db.execute(
|
||||
"UPDATE device_state SET base_image_id=1 WHERE device_id=?",
|
||||
("living-room",),
|
||||
)
|
||||
elif protection == "unsynced":
|
||||
device = device.model_copy(
|
||||
update={
|
||||
"current_content": device.current_content.model_copy(
|
||||
update={"synced": False}
|
||||
)
|
||||
}
|
||||
)
|
||||
elif protection in ("unknown", "syncing"):
|
||||
operation = submit(service)
|
||||
service._start_write(operation)
|
||||
service.update(operation, protection.upper())
|
||||
else:
|
||||
service.db.execute(
|
||||
"UPDATE device_state SET previous_image_id=2 WHERE device_id=?",
|
||||
("living-room",),
|
||||
)
|
||||
service.clear_self_restore_reference("living-room", device)
|
||||
assert service.state("living-room")["previous_image_id"] == (
|
||||
2 if protection == "other_previous" else 1
|
||||
)
|
||||
|
||||
|
||||
def test_offline(service):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def test_gallery_thumbnail_upgrades_existing_cache_and_reuses_it(client, settings):
|
||||
cache = settings.data_dir / "cache"
|
||||
original = cache / "living-room-1.original"
|
||||
thumb = cache / "living-room-1-thumb.jpg"
|
||||
Image.new("RGB", (1600, 1200), "blue").save(original, "JPEG")
|
||||
Image.new("RGB", (300, 225), "blue").save(thumb, "JPEG")
|
||||
url = "/api/v1/devices/living-room/images/1/file?thumbnail=true"
|
||||
result = client.get(url)
|
||||
assert result.status_code == 200
|
||||
assert Image.open(io.BytesIO(result.content)).size == (860, 645)
|
||||
modified = thumb.stat().st_mtime_ns
|
||||
assert client.get(url).content == result.content
|
||||
assert thumb.stat().st_mtime_ns == modified
|
||||
assert len(list(cache.glob("living-room-1-thumb*"))) == 1
|
||||
assert Image.open(original).size == (1600, 1200)
|
||||
|
||||
|
||||
def test_gallery_thumbnail_does_not_upscale_small_source(client, settings):
|
||||
original = settings.data_dir / "cache" / "living-room-1.original"
|
||||
Image.new("RGBA", (200, 400), (255, 0, 0, 255)).save(original, "PNG")
|
||||
result = client.get("/api/v1/devices/living-room/images/1/file?thumbnail=true")
|
||||
assert result.status_code == 200
|
||||
assert Image.open(io.BytesIO(result.content)).size == (200, 400)
|
||||
@@ -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"))
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user