Compare commits

..
Author SHA1 Message Date
渡口浪人 71cf2955ce release: v0.1.28 photo selection and gallery polish
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 7m10s
2026-10-20 19:47:28 +08:00
渡口浪人 3b9c3e8c15 fix: anchor gallery card actions to bottom left
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 6m10s
2026-10-20 19:42:33 +08:00
渡口浪人 95625c5857 fix: make photo retention and exclusion mutually exclusive
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 6m10s
2026-10-20 19:19:30 +08:00
渡口浪人 b770626a66 fix: clarify gallery image download label
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 7m7s
2026-10-20 19:09:31 +08:00
渡口浪人 00b754db28 fix: restore delete as last local gallery action
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m55s
2026-10-20 19:07:18 +08:00
渡口浪人 b0a833f1ef fix: simplify local gallery card actions
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m57s
2026-10-20 19:01:23 +08:00
渡口浪人 383ed45185 feat: exclude source photos from automatic selection
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m53s
2026-10-20 18:50:43 +08:00
渡口浪人 e6a3974d08 release: v0.1.27 history management
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 6m38s
2026-10-20 16:58:00 +08:00
渡口浪人 4771db6f2a feat: paginate filter and delete operation history
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 7m29s
2026-10-20 16:53:27 +08:00
渡口浪人 c3bed2edce release: v0.1.26 account security and settings polish
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m47s
2026-10-20 16:33:28 +08:00
渡口浪人 f8fe8e8b14 fix: retain logo beside stacked sidebar wordmark
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m31s
2026-10-20 16:26:57 +08:00
渡口浪人 50903564ad fix: use stacked sidebar brand wordmark
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 9m57s
2026-10-20 16:23:16 +08:00
渡口浪人 69ef87e8e9 fix: confirm new password and compact settings layout
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m45s
2026-10-20 16:19:50 +08:00
渡口浪人 09c6773d76 feat: harden single-user account authentication
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m47s
2026-10-20 16:06:24 +08:00
渡口浪人 ecfa17607f release: v0.1.25 official PIGWay branding
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m52s
2026-10-19 19:22:34 +08:00
渡口浪人 a83591d2c8 feat: integrate official PIGWay v1.1 brand assets
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m18s
2026-10-19 19:03:08 +08:00
渡口浪人 beb0bb0eb8 fix: vertically center every history table column
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m25s
2026-10-18 23:23:17 +08:00
渡口浪人 cc3fd02eb7 fix: identify history images and keep failure reasons visible
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 7m29s
2026-10-18 22:58:48 +08:00
渡口浪人 3a5acaa72a fix: allow a pinned CI base-image mirror on Server
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m51s
2026-10-18 21:58:10 +08:00
渡口浪人 8e66b6d741 docs: move trusted CI runner from NAS to Server
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 5m55s
2026-10-18 21:31:03 +08:00
渡口浪人 acdf267e48 fix: align deployment labels and refresh v0.1.23 audit
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m23s
2026-10-18 20:59:32 +08:00
渡口浪人 28e971c0ff fix: defer background cleanup until pending display is confirmed
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 7m44s
2026-10-18 20:32:37 +08:00
渡口浪人 a2e8a204e8 fix: defer automatic uploads while device synchronization is pending
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m30s
2026-10-18 20:21:52 +08:00
渡口浪人 50bb41899d fix: verify full prebuilt images before offline production deployment
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m37s
2026-10-18 19:45:30 +08:00
渡口浪人 cb8ac32b74 fix: close unconfirmed displays superseded by confirmed updates
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 6m29s
2026-10-18 19:27:35 +08:00
渡口浪人 dec17aeb1d fix: replace vulnerable container bases and release v0.1.21
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 6m49s
2026-10-18 19:10:00 +08:00
渡口浪人 0d691826b6 release: v0.1.20 audit delivery and refresh container security updates
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m41s
2026-10-18 18:48:47 +08:00
渡口浪人 5b2763910e style: show read-only reconciliation as a dashed text action
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m2s
2026-10-18 18:41:31 +08:00
渡口浪人 e92a7a51fa docs: organize project guides and consolidate historical records
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 4m49s
2026-10-18 17:59:20 +08:00
渡口浪人 b1c41e91ea fix: avoid rotating Synology display dimensions twice
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 6m41s
2026-10-18 17:59:02 +08:00
渡口浪人 f39c7e535b docs: align repository names and bilingual entry points; remove obsolete reports
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 4m57s
2026-10-18 17:53:43 +08:00
渡口浪人 51d76a196e fix: honor Synology photo orientation before landscape rendering
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m25s
2026-10-18 17:43:25 +08:00
渡口浪人 038b858eb7 fix: remove private deployment metadata and retain release licenses
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m8s
2026-10-18 16:14:56 +08:00
渡口浪人 4e9eccd809 fix: complete open-source delivery and isolate community CI 2026-10-18 15:56:16 +08:00
渡口浪人 8aaff83b8d release: v0.1.17 photo clarity provenance and layout refinements [skip ci] 2026-10-18 15:51:43 +08:00
渡口浪人 7393325b6e Revert "fix: fit image viewer dialog width to its image"
This reverts commit db0b408680.
2026-10-18 15:46:53 +08:00
渡口浪人 db0b408680 fix: fit image viewer dialog width to its image 2026-10-18 15:34:30 +08:00
渡口浪人 2af0aea2f6 feat: show original NAS photo paths in local gallery cards 2026-10-18 15:30:38 +08:00
渡口浪人 a05260712e fix: resize gallery thumbnails for current high density cards 2026-10-18 15:17:55 +08:00
渡口浪人 c0291050be fix: use original images in cloud gallery cards 2026-10-18 15:06:01 +08:00
渡口浪人 3cff5045b2 feat: add minimum photo resolution filters for sources 2026-10-18 14:59:35 +08:00
渡口浪人 9f1c15a4c4 fix: use full resolution image for dashboard preview 2026-10-18 14:48:21 +08:00
渡口浪人 443a60ec54 fix: remove space from PIGWay sidebar brand 2026-10-18 14:41:21 +08:00
渡口浪人 9b31c1f425 fix: simplify sidebar branding with PIG Way title 2026-10-18 14:38:56 +08:00
渡口浪人 743c6afe0c fix: align settings and authentication card heights 2026-10-18 14:29:45 +08:00
渡口浪人 7bf23e5687 fix: vertically center history statuses results and actions 2026-10-18 14:28:23 +08:00
渡口浪人 8cd005fd89 fix: show target frame in a dedicated automation column 2026-10-18 14:26:59 +08:00
渡口浪人 ec14ca2f1d release: v0.1.16 with compact layouts and corrected settings columns [skip ci] 2026-10-18 14:15:46 +08:00
渡口浪人 f0a42caf45 fix: collapse empty connection result placeholders 2026-10-18 14:11:19 +08:00
渡口浪人 7579ee13d1 fix: stack settings information below account controls 2026-10-18 14:09:12 +08:00
渡口浪人 c153822479 fix: compact connection and collage controls across responsive pages 2026-10-18 14:05:02 +08:00
渡口浪人 7cb940cf36 fix: compact related settings controls into shared rows 2026-10-18 13:57:16 +08:00
渡口浪人 54a8d37f5e fix: rebalance history columns and simplify status text 2026-10-18 13:54:03 +08:00
渡口浪人 b101bb2f2d fix: simplify automation status and center row actions 2026-10-18 13:52:30 +08:00
渡口浪人 f1ce7e2cb3 fix: align gallery search and filters in one desktop row 2026-10-18 13:49:30 +08:00
渡口浪人 10534fe8b0 fix: widen shared workspace for three gallery columns 2026-10-18 13:45:24 +08:00
渡口浪人 32e8d4066e fix: size gallery cards to fit a single action row 2026-10-18 13:40:57 +08:00
渡口浪人 83837a4511 fix: widen gallery cards and compact action rows 2026-10-18 13:36:37 +08:00
渡口浪人 2b5f36019e docs: align copyright brand capitalization 2026-10-18 13:35:31 +08:00
渡口浪人 f7b6c78a10 fix: standardize brand capitalization as PIGWay 2026-10-18 13:35:10 +08:00
渡口浪人 ba91dde2e7 fix: preview gallery images on click with square thumbnails 2026-10-18 13:31:07 +08:00
渡口浪人 7c929cb955 feat: recover failed scheduled uploads with bounded persistent retries [skip ci] 2026-10-18 08:42:05 +08:00
渡口浪人 41c6897151 fix: classify unsent upload connections and omit raw photo filenames [skip ci] 2026-10-18 08:19:03 +08:00
渡口浪人 d7600f8329 fix: join recognized photo people with ampersands [skip ci] 2026-10-18 00:12:28 +08:00
渡口浪人 40671cae5d fix: release consumed restore references and explain deletion protection [skip ci] 2026-10-17 23:44:40 +08:00
渡口浪人 a86a9e2080 fix: restore photo captions and place recognized people after date [skip ci] 2026-10-17 23:28:04 +08:00
渡口浪人 bda5229183 build: exclude packaging tools from production images [skip ci] 2026-10-17 13:41:29 +08:00
渡口浪人 b95b8e98cd Fix Server release checks for the LAN-only listener [skip ci] 2026-10-17 06:10:44 +08:00
渡口浪人 b87bf6f77e Release v0.1.9: enforce Photos API boundary and finalize Server migration 2026-10-17 01:39:23 +08:00
渡口浪人 46a0429d5e Fix README heading placement for Photos API notes 2026-10-16 22:35:02 +08:00
渡口浪人 815164843b Document verified Photos API candidate image and migration boundaries 2026-10-16 22:34:37 +08:00
渡口浪人 66ed1e2c9b Add mount-free Synology Photos API sources with bounded image cache 2026-10-16 22:31:13 +08:00
渡口浪人 2275974cde Release v0.1.8: improve gallery selection, image viewing and UI wording [skip ci] 2026-10-16 21:34:48 +08:00
渡口浪人 d4543a9843 Release v0.1.7: render gallery controls before cloud metadata and status [skip ci] 2026-10-16 19:21:20 +08:00
渡口浪人 106b6f311f Release v0.1.6: identify gallery destinations by provider and NAS name [skip ci] 2026-10-16 18:58:01 +08:00
渡口浪人 d3f40cfea3 Release v0.1.5: unify image loading and mobile layouts on port 6005 [skip ci] 2026-10-16 16:25:42 +08:00
渡口浪人 a843d4de9b Add documented KoKonna failure policy, bounded retries and durable recovery escalation [skip ci] 2026-10-16 12:25:16 +08:00
渡口浪人 d2f02d8054 Preserve uncertain-write evidence and safe transport diagnostics for v0.1.3 [skip ci] 2026-10-16 10:30:08 +08:00
渡口浪人 8a3c616b5f Fix webhook starvation and isolate scheduled job failures for v0.1.2 [skip ci] 2026-10-16 09:48:51 +08:00
渡口浪人 e1b001a431 Reconcile lost upload responses without replaying frame writes 2026-10-14 13:10:33 +08:00
渡口浪人 e9d7b12c66 Release deployment lock before pruning old production image 2026-10-12 21:12:44 +08:00
渡口浪人 b46fbea4c9 Retain only the verified production latest image 2026-10-12 20:51:08 +08:00
渡口浪人 df1eba74e8 Keep one rollback tag derived from original release 2026-10-11 17:15:56 +08:00
渡口浪人 b41163965e docs: clarify Raspberry Pi project names and integration scope 2026-10-11 16:29:30 +08:00
渡口浪人 aabbff8127 Release version 0.1.1 [skip ci] 2026-10-11 14:43:29 +08:00
渡口浪人 7cdf712829 Reconcile delayed frame sync before scheduled writes 2026-10-11 14:24:32 +08:00
渡口浪人 327fa53d7b Retain current production image and one rollback baseline 2026-10-11 09:14:23 +08:00
Pigway 80c6bf3b7d Document persistent offline reuse of pinned Runner Actions [skip ci] 2026-10-11 07:57:21 +08:00
Pigway b682bdd5b0 Use pinned official Actions without a public Tea mirror 2026-10-11 07:45:43 +08:00
Pigway d109ad6ff1 Clarify verified architecture in bilingual installation guides 2026-10-11 07:29:11 +08:00
Pigway f45fce7d69 Align project introduction with bilingual PIGWay READMEs 2026-10-11 07:24:32 +08:00
Pigway a11a88456c Keep private captures ignored and clarify protected deployment settings 2026-10-11 01:17:05 +08:00
Pigway c9c950de1b Prepare public repository privacy and trusted runner gates 2026-10-11 01:13:55 +08:00
Pigway 9c9697dc6e Clarify shared Photos session and independent source settings 2026-10-10 09:38:27 +08:00
Pigway a869f0b042 Make collage selection automatic and restore dashboard navigation order 2026-10-10 09:28:37 +08:00
Pigway cc4126d23c Improve Photos connection guidance and audit responsive UI spacing 2026-10-10 08:36:23 +08:00
Pigway 4fdb7a4407 Run initial retention checks regardless of host uptime 2026-10-10 01:35:32 +08:00
Pigway 7d10d5b601 Sync published OpenAPI snapshot with integration endpoints 2026-10-10 01:29:07 +08:00
Pigway 2c2ec81d51 Wait for confirmed template display in browser smoke 2026-10-10 01:28:27 +08:00
Pigway 4acd616555 Align integration contract and image generation validation 2026-10-10 01:25:32 +08:00
149 changed files with 14117 additions and 1447 deletions
+3
View File
@@ -14,3 +14,6 @@ dist
.mypy_cache
**/__pycache__
**/*.pyc
# macOS metadata at any depth
**/.DS_Store
+5 -9
View File
@@ -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=
@@ -31,5 +28,4 @@ SYNOLOGY_PHOTOS_CERT_SHA256=
# Build-only mirror hosts; official defaults work outside mainland China.
# For mainland NAS builds: source ./deploy/build-cn.env before build-verify.sh.
DEBIAN_MIRROR=deb.debian.org
DEBIAN_SECURITY_MIRROR=deb.debian.org
ALPINE_MIRROR=dl-cdn.alpinelinux.org
+26
View File
@@ -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
+10 -60
View File
@@ -1,27 +1,25 @@
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 self-hosted Docker runner must never execute code from external forks.
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 }}
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 }}
CI_PYTHON_BASE: ${{ vars.CI_PYTHON_BASE }}
ALPINE_MIRROR: ${{ vars.ALPINE_MIRROR }}
steps:
- uses: https://tea.pigway.com/way/frame-flow-ci-actions/checkout@bada0aa1b7e0d23fe9da66ce2ec9b193c211ea46 # v4
- uses: https://tea.pigway.com/way/frame-flow-ci-actions/setup-python@bada0aa1b7e0d23fe9da66ce2ec9b193c211ea46 # v5
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: https://github.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.14'
- name: Install CI dependencies with index diagnostics and fallback
@@ -43,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: ${{ vars.PROD_DATA_DIR }}
PROD_CONFIG_DIR: ${{ vars.PROD_CONFIG_DIR }}
PROD_PHOTOS_DIR: ${{ vars.PROD_PHOTOS_DIR }}
SYNOLOGY_PHOTOS_URL: ${{ vars.SYNOLOGY_PHOTOS_URL }}
SYNOLOGY_PHOTOS_CERT_SHA256: ${{ vars.SYNOLOGY_PHOTOS_CERT_SHA256 }}
PROD_WEB_BIND: ${{ vars.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://tea.pigway.com/way/frame-flow-ci-actions/checkout@bada0aa1b7e0d23fe9da66ce2ec9b193c211ea46 # v4
- uses: https://tea.pigway.com/way/frame-flow-ci-actions/setup-python@bada0aa1b7e0d23fe9da66ce2ec9b193c211ea46 # 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 Server runner performs isolated verification only.
+7 -2
View File
@@ -16,7 +16,12 @@ dist/
.mypy_cache/
build-cache/debs/*
!build-cache/debs/.gitkeep
build-cache/wheels/*
!build-cache/wheels/.gitkeep
# Private browser captures and platform metadata
docs/screenshots/
.DS_Store
# Private deployment receipts belong outside public source control.
docs/verification/
+19
View File
@@ -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.
+26 -22
View File
@@ -1,35 +1,24 @@
ARG PYTHON_BASE=python:3.14.5-slim-bookworm@sha256:ec58d916f9e24a6035cab2bdf07f6206c4cc092a16613c60597534711332d9d6
FROM ${PYTHON_BASE}
ARG DEBIAN_MIRROR=deb.debian.org
ARG DEBIAN_SECURITY_MIRROR=deb.debian.org
ARG PYTHON_BASE=python:3.14.8-alpine3.24@sha256:f6a589d43c42b9e7f7dc67a12d37132491f362859a5d750607710cc56da3bc72
FROM ${PYTHON_BASE} AS prepared
ARG ALPINE_MIRROR=dl-cdn.alpinelinux.org
ARG TARGETARCH
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 TZ=Asia/Shanghai \
DATA_DIR=/data CONFIG_DIR=/config
WORKDIR /app
# CJK fonts intentionally retained for readable Chinese rendering.
RUN --mount=type=cache,id=frameflow-bookworm-apt-${TARGETARCH},target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=frameflow-bookworm-lists-${TARGETARCH},target=/var/lib/apt/lists,sharing=locked \
--mount=type=bind,source=build-cache/debs,target=/build-debs,readonly \
rm -f /etc/apt/apt.conf.d/docker-clean \
&& 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 \
&& 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}'); \
test -n "$font_sha"; \
echo "$font_sha /build-debs/fonts-noto-cjk.deb" | sha256sum -c -; \
apt-get install -y --no-install-recommends tzdata; \
dpkg -i /build-debs/fonts-noto-cjk.deb; \
else apt-get install -y --no-install-recommends fonts-noto-cjk tzdata; fi \
&& groupadd --gid 10333 frameflow && useradd --uid 10333 --gid 10333 --no-create-home frameflow \
# Preserve the existing FONT_PATH contract for deployments and Chinese rendering.
RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|https://${ALPINE_MIRROR}/alpine|g" /etc/apk/repositories \
&& apk upgrade --no-cache \
&& apk add --no-cache font-noto-cjk tzdata \
&& mkdir -p /usr/share/fonts/opentype/noto \
&& ln -s /usr/share/fonts/noto/NotoSansCJK-Regular.ttc /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc \
&& addgroup -g 10333 frameflow && adduser -D -H -u 10333 -G frameflow frameflow \
&& mkdir -p /data/images /data/generated /data/cache /config \
&& chown -R 10333:10333 /data /config
COPY requirements.lock ./
COPY scripts/ci-install.py /build-tools/install.py
ARG DOCKER_PIP_INDEX_URL=https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
ARG DOCKER_PIP_FALLBACK_INDEX_URL=https://pypi.org/simple
RUN --mount=type=cache,id=frameflow-pip-py314-${TARGETARCH},target=/root/.cache/pip,sharing=locked \
RUN --mount=type=cache,id=frameflow-pip-py314-musl-${TARGETARCH},target=/root/.cache/pip,sharing=locked \
--mount=type=bind,source=build-cache/wheels,target=/build-wheels,readonly \
if [ -f /build-wheels/SHA256SUMS ]; then \
(cd /build-wheels && sha256sum -c SHA256SUMS) \
@@ -37,9 +26,24 @@ 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
LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.28" org.opencontainers.image.revision=$BUILD_COMMIT
USER 10333:10333
EXPOSE 18787
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Pigway
Copyright (c) 2026 PIGWay
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+3 -2
View File
@@ -1,6 +1,6 @@
# Notices
Pigway Frame Flow is an independent project. KoKonna is a supported device
PIGWay Frame Flow is an independent project. KoKonna is a supported device
integration and is not affiliated with this project unless otherwise stated.
KoKonna names and marks belong to their respective owners.
@@ -21,7 +21,8 @@ for endpoint coverage. Their source code was not incorporated into runtime.
FastAPI, Starlette, Uvicorn, Requests, croniter, PyYAML, Pydantic and their transitive
dependencies retain their respective upstream licenses. Pillow uses its upstream
HPND license. Debian Noto CJK fonts retain their font package license.
HPND license. Noto CJK fonts use SIL OFL 1.1; the license is included in
`vendor/noto-cjk-OFL-1.1.txt`, with copyright notices retained in font metadata.
Development dependencies (pytest, Ruff, mypy, pip-audit, HTTPX2, Playwright) retain
their upstream licenses and are not shipped in the runtime image.
+60 -318
View File
@@ -1,359 +1,101 @@
# Pigway Frame Flow
<p><img src="docs/brand/pigway-logo.png" alt="PIGWay" width="96" height="96"></p>
Pigway Frame Flow is a lightweight self-hosted platform for programmable smart-frame automation.
# PIGWay Frame Flow
独立运行的智能画框服务:中英文 Web UI、深浅色模式、设备驱动抽象、KoKonna 全部已公开接口、图片处理、模板、SQLite 调度与播放列表、临时覆盖恢复和 REST API。
Development currently supports Python 3.14.x. See [contributing](CONTRIBUTING.md) and [security reporting](SECURITY.md).
KoKonna is a supported device integration and is not affiliated with this project unless otherwise stated.
[简体中文](README.zh-CN.md) | English
## Quick Start / 快速开始
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.
```sh
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.lock
.venv/bin/python scripts/run_local.py
```
### Installation
Open http://127.0.0.1:18787. The local demo creates a private `.env` with a random
FRAME_FLOW_TOKEN. Read that token locally in an editor and enter it on the login
screen. Do not paste a KoKonna API Key into Web UI. Demo always forces mock mode
and disables real writes. It leaves data persistent in ./data and ./config.
Deploy with Docker Compose. The current default configuration and verified platform are AMD64:
网页使用账号密码登录。通过 `python scripts/create-account.py --username YOUR_NAME` 创建账号,再启动服务;密码不会写入代码或明文配置。勾选记住登录可保持 30 天,设置页面可修改密码或退出。
## Architecture / 架构
```
providers → templates / renderers → generated image
↓
Web UI / external REST → DisplayService → FrameDeviceDriver → KokonnaDriver
Scheduler / Playlist ─────────────┘
SQLite: operations / history / jobs / overrides / assets
```
Package: pigway_frame_flow. Device composition is in core/registry.py;
vendor-specific code is in drivers/kokonna. One process, one worker, no Redis,
message broker, OpenClaw, Home Assistant or external application dependency.
## Configuration / 配置
Copy `.env.example` to `.env`, set a strong FRAME_FLOW_TOKEN, chmod 600 .env.
- KOKONNA_MOCK=true: independent persistent demo, no cloud calls.
- KOKONNA_WRITE_ENABLED=false: live write protection, mandatory before verification.
- KOKONNA_API_KEY: device key, never stored in DB or returned to Web UI.
- *_FILE: optional Docker Secret equivalent.
- WEB_BIND/WEB_PORT: Compose host bind and port. Internal app binds container network.
- CORS_ALLOWED_ORIGINS: exact comma-separated origins; default empty.
- IMAGE_URL_ALLOWED_HOSTS: exact public HTTPS hosts; default disabled. Redirects denied.
- TZ=Asia/Shanghai; DATA_DIR=/data; CONFIG_DIR=/config.
For multiple devices copy deploy/devices.yml.example into config/devices.yml,
reference a separate environment key per device. Jobs bind a device_id. Layout
currently targets 1200 × 1600; incompatible geometry is refused before writes.
## Docker
```sh
```bash
cp .env.example .env
# Configure a token; keep API key empty, mock true, writes false.
# Set a strong FRAME_FLOW_TOKEN; keep mock=true and writes=false initially.
chmod 600 .env
mkdir -p data/images data/generated data/cache config
sudo chown -R 10333:10333 data config
./scripts/build-verify.sh
# Create the browser administrator account; enter its password interactively.
docker compose run --rm --no-deps -e PYTHONPATH=/app \
-v "$PWD/scripts:/bootstrap:ro" --entrypoint python pigway-frame-flow \
/bootstrap/create-account.py --username admin --config-dir /config
docker compose up -d
python3 scripts/smoke-test.py
```
Image pigway-frame-flow:latest, service/container pigway-frame-flow, AMD64,
Python 3.14.5, pinned runtime dependencies, CJK fonts, non-root UID/GID 10333,
read-only root filesystem, writable /data and /config, no capabilities, no host
network, no Docker socket. Restart unless-stopped. JSON-file logs capped 10m × 3.
Healthcheck uses service /health, not cloud availability. No second log database.
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.
## Synology NAS
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.
Target `/volume2/docker/pigway-frame-flow/`. See [NAS deployment](docs/NAS.md).
Set WEB_BIND to the NAS LAN IP to restrict the published interface, or explicitly
0.0.0.0 when desired. Port 18787 must be free. No router/firewall/reverse proxy
rules are modified. Source and persistent volumes stay in this project directory.
No artificial logs directory is required. Compose creates its own bridge network.
For a local mock demo, use Python 3.14:
## KoKonna Setup / 真实设备
```bash
python3.14 -m venv .venv
.venv/bin/python -m pip install -r requirements.lock
.venv/bin/python scripts/create-account.py --username admin
.venv/bin/python scripts/run_local.py
```
First deploy Mock and verify persistence. Then set KOKONNA_MOCK=false and fill
KOKONNA_API_KEY through private environment/secret. Keep writes false. /ready checks
configuration/DB/worker/token without a cloud call. Query device and images metadata;
compare model, geometry, timezone, heartbeat, battery, imageId, coverId, sync.
If different, stop and report. **Do not enable writes or perform the single test
until the owner explicitly authorizes it.** Test design: [REAL_DEVICE_TEST.md](docs/REAL_DEVICE_TEST.md).
The demo forces mock mode and disables device writes. Data and configuration remain in `data/` and `config/` across restarts.
## Security
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).
Protected /api/v1 capabilities accept an administrator account session, or external
Frame Flow Bearer auth with scope checks.
SQLite stores token hashes. Device keys live only in env/secret and runtime memory.
Browser login uses SQLite accounts with salted scrypt password hashes and revocable,
HttpOnly/SameSite=Strict cookies (30 days when remembered, 12 hours otherwise).
Mutations require a session CSRF header. Logout and password changes revoke sessions.
Passwords and browser sessions are never stored in localStorage. Bootstrap hashes
in private `config/accounts.json` seed new accounts only; restarting does not undo
a password change. Five failed logins per source IP cause a five-minute lockout.
The browser needs no API Token. Bearer authentication remains compatible for
external clients; the KoKonna key is never used for account login.
The LAN deployment uses HTTP; use an explicitly configured HTTPS endpoint if
transport encryption is needed. No reverse proxy or firewall is changed automatically.
### Features and boundaries
## API
- Chinese and English Web UI, light/dark themes, account login, device dashboard and image previews.
- Local staging and KoKonna cloud gallery, uploads, image switching, stars, source/task filters and batch actions.
- Aspect-ratio-preserving contain/cover/smart conversion, color/grayscale profiles and orientation selection for photo sources.
- 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.
- 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.
OpenAPI 3.x at /openapi.json; Swagger at /docs; stable /api/v1. Display/render/upload
return 202 + operation_id. Poll /api/v1/operations/{id}; completion requires matching
image ID and cloud synced flag. Idempotency-Key retained seven days. X-Request-ID
traces history. UNKNOWN writes block later mutations; read-only reconciliation
and explicit admin acknowledgement release the block, never replay a write.
The older /api/display route is retained and deprecated (same v1 payload).
See [external integration contract](docs/integrations/TICKTAR.md).
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).
## Scheduler / Playback
Real weather uses the configured provider/location and is refreshed when rendered; it does not update an already displayed image by itself. Official KoKonna widgets are separate from Frame Flow templates and require optional vendor-account authorization. API-key access remains independent.
Cron (5 fields), daily, weekdays, weekends, date and temporary tasks; Shanghai time.
Use source {"type":"restore"} for explicit restore jobs. Unique job/slot and persistent
idempotency prevent duplicate dispatch. Overdue tasks >5 minutes are skipped rather
than unexpectedly overwriting artwork after downtime. State persists across restart.
Overrides use priority then recency; lower priorities wait without uploading.
If another app changes the frame, expired overrides do not overwrite its artwork.
Frame Flow playlists support ordered/random cloud image IDs and >=5-minute intervals;
only one enabled playlist per device. They pause for overrides/unknown writes.
Native cloud slideshow mode/interval are readable; membership/editing are unavailable
in the official API. Avoid having native and Frame Flow playback compete.
### Device integration and API
## Templates / 图片
```text
Providers → Templates / Renderers → Generated image
Web UI / Scheduler / REST API → DisplayService → FrameDeviceDriver → KoKonna
↓
SQLite: jobs, operations, history, overrides, assets
```
Seven JSON layout templates: daily-dashboard, weather, calendar, quote, photo-text,
daily-poetry and system-alert, with color and grayscale profiles. Every template
renders an image, including templates that contain only text.
Configure the device key privately through `.env` or a secret file. Start with `KOKONNA_MOCK=true` and `KOKONNA_WRITE_ENABLED=false`; after mock verification, connect read-only before explicitly enabling writes. Multi-device definitions use `config/devices.yml` and device-specific key references. Vendor code stays in `drivers/kokonna`; other applications use the same service API as the Web UI.
使用流程:选择模板 → 编辑数据 → 生成预览 → 显示到画框。生成预览只创建本地图片;
保存默认数据只保存配置,两者都不会更新设备。自动化模板任务每次运行重新生成图片。
The public API is `/api/v1`, with OpenAPI at `/openapi.json` and Swagger UI at `/docs`. Display/render/upload operations return HTTP 202 and an operation ID; poll `/api/v1/operations/{id}` for the outcome. `Idempotency-Key` prevents duplicate dispatch, and `X-Request-ID` links requests to history. An uncertain write blocks subsequent mutations until read-only reconciliation or explicit administrator acknowledgement; it is never automatically replayed.
- **照片与文字 / Photo + Text**:标题、中央照片区域、下方正文;与照片数据源任务的底部水印布局不同。
- **家庭早报 / Daily Dashboard**:日期、天气、事项、一句话与所选照片区域。
- **天气 / Weather**:当前天气与三天预报;天气 JSON 留空时使用配置的 Provider。
- **日历、每日诗词、一句话、系统告警**:将相应信息排版为图片,不自动选择图库照片。
Ticktar integration is optional. Frame Flow has no runtime dependency on Ticktar and never accesses its database. See the [external integration contract](docs/integrations/TICKTAR.md).
Photos use a local asset ID (`photo_id`); without a selected photo, a placeholder
is rendered. Configure Open-Meteo and location coordinates in **Settings → Weather
source** for real weather. Forecasts are fetched on demand and cached for ten minutes;
unavailable/stale data is labeled explicitly. Weather remains static on the frame
until the next render and display, so use an Automation job for regular updates.
Official KoKonna widgets are a separate integration, not these local templates.
No unreliable lunar date is fabricated.
Contain preserves composition with margins; cover crops centrally; smart is an
edge-saliency heuristic. Always inspect preview; face-aware guarantees are not claimed.
Cloud uploads activate immediately, so gallery-only means **local staging**.
### Storage and security
## Development / Testing
Docker persists `/data/frameflow.db`, images/generated/cache under `/data`, and private configuration/session files under `/config`. It runs as UID/GID 10333 with a read-only root filesystem, its own bridge network, `unless-stopped` restart policy and bounded stdout/stderr logs. No privileged mode, host network or Docker socket is required.
```sh
Passwords are hashed; browser mutations require CSRF protection. API keys and provider credentials stay server-side and must never enter Git or logs. CORS is disabled unless origins are explicitly configured. `/health` checks the service; `/ready` checks local dependencies without making cloud availability a restart trigger. See [public repository privacy](docs/PUBLICATION.md), [LICENSE](LICENSE) and [NOTICE](NOTICE.md).
KoKonna is a supported integration. This project is not affiliated with or endorsed by KoKonna.
### Development and known limitations
```bash
.venv/bin/python -m pip install -e '.[test]'
.venv/bin/python -m pytest -q
.venv/bin/python -m ruff check pigway_frame_flow tests scripts
.venv/bin/python -m ruff format --check pigway_frame_flow tests scripts
.venv/bin/python -m mypy pigway_frame_flow
.venv/bin/python -m pytest -q
.venv/bin/python -m pip_audit -r requirements.lock --no-deps --disable-pip
```
Tests mock every cloud request; no real-device writes. scripts/ui-smoke.cjs runs an
isolated headless browser and checks settings.mock before acting. CI builds AMD64,
validates Compose, starts a container, checks health/ready and restarts for persistence.
Tests mock cloud/device calls. CI also verifies an isolated Docker build, readiness, smoke tests, restart and cleanup. External fork PRs do not execute on the production-capable NAS runner.
## Known Limitations / 已知边界
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.
Cloud acknowledgements do not prove physical display pixels. Model fields may be
omitted and are explicitly marked configured. No native slideshow membership API,
no cloud upload-only API, no automatic retries after uncertain writes. Opt-in local retention protects manual uploads, stars and active references; its quota is a cleanup target, not a hard disk limit. Rendering assets are local, limited
to 25M pixels/20 MiB. This release uses a single worker and is not multi-replica.
Gallery reads have no pagination because upstream documents none. Web UI hides
secrets and lazy-loads thumbnails; generating a first thumbnail fetches one original.
Scoped client provisioning and bounded signed webhook delivery are available in Connections. Docker build/NAS
status is recorded honestly in docs/ACCEPTANCE.md.
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)
## Roadmap
Browser OAuth login, AI daily-art scheduling, custom ICS timezones/EXRULE and additional hardware-specific adapters remain pending. MQTT, bounded recurrence, token refresh, transition events and single-image AI generation are optional capabilities. See [optional capabilities and exact limits](docs/EXTENSIONS.md).
## Layout Studio and optional integrations
Bilingual Layout Studio imports 1–12 images, edits bounded regions and renders a collage or poster. Optional OpenAI-compatible AI plans receive text and dimensions only. Connections configures provider credentials, calendars/state, retention, client scopes and webhooks. Photo Sources includes NAS, WebDAV, Google Drive, AliPan and Baidu readers. Authorization is separate from implementation; unconnected providers return explicit errors. See [setup and limitations](docs/EXTENSIONS.md).
## Template editing
Templates provide bilingual form fields, event lists, local photo selection, advanced
JSON, color/grayscale previews, persistent defaults and reset to built-in defaults.
Changing content invalidates the previous preview. Weather uses the labeled Mock
provider until a real provider is configured. Live display controls stay disabled
until real writes are separately approved.
Browser smoke tests use a disposable Mock account via FRAME_FLOW_TEST_USERNAME
and FRAME_FLOW_TEST_PASSWORD; never run the mutation smoke suite against live devices.
## Current content and completed page workflows
Dashboard displays the actual current image and recorded content source. Frame Flow
stores immutable template/data/profile snapshots when it renders an asset and maps
successful upload results to the device/image ID. The current-content endpoint
matches the live device image ID; vendor images with no recorded source remain
explicitly unknown. Switching externally never makes the previous template appear
as the current one. Restore also restores the matching content provenance.
Templates can load the current recorded template or continue editing a generated
local image. They support direct photo import and protected image downloads.
Gallery provides search, sorting, dimensions and downloads. Display confirmation
supports duration, priority and restoration. History includes operation details.
Automation offers content pickers, template profiles, previews and manual execution;
playlists offer gallery image checkboxes. Real writes remain blocked by the server
regardless of the UI controls.
Settings persists weather coordinates/provider and image URL host allowlists in
SQLite. Open-Meteo provides current model weather and a three-day forecast, cached
for ten minutes on demand. Network failures show unavailable/stale data and use a
two-minute backoff, without replacing it with fictitious live data. Default remains
Mock until configured in Settings. Open-Meteo attribution is included in the UI and
weather/dashboard renders. Image URL import is HTTPS-only with explicit hostnames,
public-address validation and pinned DNS; redirects and private addresses are blocked.
Additional v1 endpoints: `/devices/{id}/current-content`, `/assets/{id}/source`,
`/providers/weather`, `/settings/weather`, `/settings/image-url`, `/jobs/{id}`,
`/jobs/{id}/preview`, `/jobs/{id}/run` (all under `/api/v1`). Existing contracts remain
compatible; new endpoints are included in generated OpenAPI and contract tests.
## Daily Poetry / 每日诗词
The daily-poetry template includes 20 public-domain classical Chinese poems, selected deterministically by Shanghai date, or manually. Create a daily job from its editor; new poetry jobs are paused by default until you enable them. Custom title, author and verses are editable. This is an independent Frame Flow content provider, not the KoKonna mini-program subscription. Official public API does not expose mini-program subscription/template settings.
每日诗词支持按上海日期选诗、手选 20 首古典诗词、自定义文本以及每日任务。模板显示操作使用统一上传/显示服务;真实写入须开启 KOKONNA_WRITE_ENABLED,结果未知不会自动重试。官方小程序原有每日诗词订阅不能通过当前公开 API 查询或修改。
### 官方账号连接 / Official account connection
在 Templates → 官方小组件中,由管理员输入 KoKonna 官方账号和密码,查询账号下
的设备后手动关联当前 Frame Flow 设备。此流程不绑定或解绑云端设备,也不改变
图片或订阅。现有 API Key 接入继续负责设备、图库和显示能力。
密码不保存;会话与关联信息保存在 `/config/official-{device_id}.secret`,权限 0600。
容器重启可恢复连接。会话过期需要重新登录;断开连接会清除本地会话,不撤销
官方服务器上的其他登录。局域网访问沿用现有部署方式。
Official account connection is admin-only and separate from API Key access.
Login, device discovery, local association and disconnect are available through
`/api/v1/devices/{device_id}/official/connection`. Passwords are never persisted;
server-side sessions are stored as restricted secret files. No official widget
mutation or other extra RPC write is enabled by connecting.
### Photo sources / 照片数据源
Gallery supports read-only NAS folders and HTTPS WebDAV sources, random photo
preview, and device-bound scheduled random photos. Source definitions are persisted
in SQLite. See [setup and limits](docs/PHOTO_SOURCES.md).
NAS 本地目录需要显式只读挂载;网盘先支持通用 WebDAV,也可使用同步到 NAS 的目录。
### Build networking and cache
The Dockerfile retains the pinned Python base, locked Python dependencies,
`fonts-noto-cjk`, Debian repository signature checks, and offline artifact
SHA256 verification. Both production and CI use `scripts/build-verify.sh`,
which enables BuildKit and retains `docker compose build --pull`.
APT mirror hosts are configurable through `DEBIAN_MIRROR` and
`DEBIAN_SECURITY_MIRROR` (hosts only, without scheme or path). Defaults are
`deb.debian.org`; HTTPS is used for both Debian and security repositories.
For mainland-China NAS builds, use the shared profile:
```sh
. ./deploy/build-cn.env
./scripts/build-verify.sh
```
CI loads this same profile, using Tsinghua TUNA for both repositories unless
Gitea repository variables `DEBIAN_MIRROR` / `DEBIAN_SECURITY_MIRROR` override
it. Production may also set the two hosts directly in `.env` when calling the
build script without the profile. An alternate mainland mirror is
`mirrors.ustc.edu.cn`; availability and throughput depend on the Runner network.
No fallback disables signature verification or removes packages.
Layer order stays: pinned base → APT/font dependencies → `requirements.lock`
→ pip dependencies → application/vendor source → build metadata.
A source-only change therefore reuses the APT and pip layers, provided the
same builder, base digest, mirror arguments, and optional offline artifacts
are used. Changing the lockfile rebuilds pip and later layers, not APT.
Changing the mirror or APT instructions rebuilds APT and later layers.
Previously an APT layer miss redownloaded indexes/packages, and pip used
`--no-cache-dir`. Now locked BuildKit mounts retain APT indexes, downloaded
`.deb` files, and pip downloads/wheels outside the final image. Debian's
`docker-clean` hook is disabled so it cannot discard cached packages.
Re-executed APT steps still run `apt-get update` and validate repository
signatures; cached packages and pip artifacts can avoid repeat large downloads.
The initial build after this Dockerfile change will miss dependency layers;
subsequent builds benefit. No measured speedup is claimed before CI completes.
Caches belong to the Docker builder, not the ephemeral CI workspace. Keep the
NAS Runner's builder storage across jobs; removing the builder, pruning caches,
or using a fresh Docker daemon loses the benefit. Cache mounts are disposable:
an empty cache still builds correctly. `--pull` remains enabled; a pinned base
digest check alone does not invalidate dependency layers. To deliberately
refresh installed APT versions, use `docker compose build --pull --no-cache`.
### CI with a host Docker socket
Run `python scripts/ci-compose-verify.py` after loading the build mirror profile.
The script derives a temporary Compose document from production settings, with
an absolute client-side build context, a UUID project name, a unique CI image
tag, and project-scoped `data` / `config` named volumes. It removes production
bind mounts, the fixed container name, host ports, and the production `.env`
reference. A fresh test token, mock driver, and disabled device writes are
explicitly configured. Docker initializes empty named volumes from the image's
owned `/data` and `/config` directories; the service remains UID/GID 10333.
Build contexts are sent by the Docker client, so the job workspace can remain
inside the job container. Runtime storage is managed by the daemon; no matching
NAS host `/workspace` directory is needed. HTTP readiness and the unmodified
nine-endpoint smoke script run inside the service with `compose exec -T`.
Restart is followed by another readiness check and the same smoke test.
A `finally` block removes the test containers, network, volumes, and unique
image tag on success or failure. The workflow records the private temporary
Compose path in `GITHUB_ENV` and repeats cleanup in an `always()` step, covering
normal cancellation/interruption. Failed cleanup retains the manifest so the
fallback can retry. Abrupt host/daemon loss or a forcibly terminated entire job
can prevent both cleanup paths; no CI script can guarantee cleanup in that case.
No global prune or production resource removal is performed. Keep builder
caches: only the run-specific image tag is removed.
### CI Job pip indexes versus Docker build indexes
The Job bootstrap runs `python scripts/ci-install.py` before Ruff/mypy/tests.
It needs only the Python standard library, so it runs before PyYAML or tooling
is installed. `CI_PIP_INDEX_URL` and `CI_PIP_FALLBACK_INDEX_URL` are optional
Gitea repository variables. Defaults are the TUNA HTTPS PyPI simple endpoint
and official `https://pypi.org/simple`. Each is attempted separately; the
fallback repeats the unchanged editable `.[test]` installation, including
PEP 517 build isolation and `setuptools>=75`. No simultaneous extra index is
used, no build dependency is removed, and both failures fail CI.
Logs show inherited index/config/proxy hosts and the selected index host for
each attempt, with URL credentials and query strings removed. Bootstrap
disables inherited pip config files via `PIP_CONFIG_FILE=/dev/null`, explicitly
sets the selected index, and clears no-index/extra-index/find-links, insecure
trusted-host, and no-build-isolation environment settings. Constraints, TLS
certificate environment settings, and proxy settings remain in effect.
These `CI_PIP_*` settings apply only to the Job's pip install. They are not
passed into Docker build arguments or persisted into the production image.
The Dockerfile keeps its own cached/offline dependency installation and logs
the Docker pip index host for online installs. Debian mirror variables affect
APT only and are loaded later in the Docker verification step.
If dependency installation fails, the always() cleanup step still executes.
It skips the Python/PyYAML cleanup script when no CI Compose manifest exists,
as no Docker verification resources have been created at that point.
Production CD: [safe trial, configuration, and rollback](docs/deployment/GITEA_CD.md).
[Documentation](docs/README.md)
+109
View File
@@ -0,0 +1,109 @@
<p><img src="docs/brand/pigway-logo.png" alt="PIGWay" width="96" height="96"></p>
# PIGWay Frame Flow
开发环境目前支持 Python 3.14.x。参见[贡献指南](CONTRIBUTING.md)和[安全问题报告](SECURITY.md)。
简体中文 | [English](README.md)
轻量、自托管的智能画框自动化平台,可独立运行在 NAS 或服务器上,提供自己的 Web 界面、图库、图片渲染、调度器和 REST API。KoKonna 是支持的设备集成之一;外部应用无需持有它的凭据或引入其 SDK。
### 安装
使用 Docker Compose 部署。当前默认配置与已验证平台为 AMD64:
```bash
cp .env.example .env
# 设置强 FRAME_FLOW_TOKEN;首次保持 mock=true、writes=false。
chmod 600 .env
mkdir -p data/images data/generated data/cache config
sudo chown -R 10333:10333 data config
./scripts/build-verify.sh
# 创建网页管理员账号,密码通过交互输入。
docker compose run --rm --no-deps -e PYTHONPATH=/app \
-v "$PWD/scripts:/bootstrap:ro" --entrypoint python pigway-frame-flow \
/bootstrap/create-account.py --username admin --config-dir /config
docker compose up -d
```
打开 `http://127.0.0.1:6005`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。
网页仅支持一个用户,不提供公开注册。密码为 8–256 位,支持密码管理器;使用带随机盐的 scrypt(N=16384、r=8、p=5)保存,旧哈希登录成功后自动升级。连续 5 次失败后按账号和来源 IP 限制登录 5 分钟。修改密码需验证当前密码,成功后撤销全部会话;写入请求校验 CSRF Token,登录拒绝跨来源请求。公网访问应通过 HTTPS,HTTPS 下会话 Cookie 自动启用 Secure。
默认端口只绑定本机回环地址。需要局域网访问时,显式将 `WEB_BIND` 设置为 NAS 的局域网地址,并通过 `WEB_PORT` 选择空闲端口。参见[群晖部署](docs/NAS.md)、[构建镜像源与缓存](docs/BUILDING.md)。不会自动修改防火墙、路由器或反向代理。
使用 Python 3.14 运行本地 Mock 演示:
```bash
python3.14 -m venv .venv
.venv/bin/python -m pip install -r requirements.lock
.venv/bin/python scripts/create-account.py --username admin
.venv/bin/python scripts/run_local.py
```
演示强制使用 Mock,并关闭设备写入。数据与配置保存在 `data/`、`config/`,重启后保留。
Synology Photos 可使用独立的 API 来源,无需 `/photos` 或 NFS 挂载;支持共享 / 当前账号个人空间、人物筛选和按需 XL 预览缓存。原图下载不在本次已验证支持范围内。见 [API 来源与 Ubuntu 候选迁移说明](docs/SYNOLOGY_PHOTOS_API.md)。
### 功能与边界
- 中英文 Web 界面、深浅色主题、账号登录、设备面板与图片预览。
- NAS 本地图库和 KoKonna 云端图库;星标、来源/任务筛选及批量操作位于本地图库,上传到云端并显示需单独操作。
- 保持比例的 contain/cover/smart 图片转换,彩色/灰度模式与照片数据源的横竖方向选择。
- 七种模板:家庭早报、天气、日历、一句话、照片与文字、每日诗词、系统告警。
- 拼图工作室支持 1–12 张图片的拼图与海报;可选 OpenAI 兼容接口排版,以及从数据源自动选图拼接。
- 绑定设备的定时任务、顺序/随机播放列表、带优先级的临时覆盖、恢复与持久化操作历史。
- Synology Photos 人物筛选、WebDAV 和可选网盘连接器;每个数据源独立配置、独立授权。
- 可选天气、日历、监控、AI、MQTT 与 Webhook 集成。需要配置对应 Provider;尚未授权的数据源不会被当作已连接。
模板会**生成图片**,纯文字模板也是如此。使用顺序是:选择模板 → 编辑内容 → 生成预览 → 显示到画框。生成预览和保存模板设置都不会更新画框。“照片与文字”和“家庭早报”可以包含指定的本地照片,其他模板不会自动选图;定时模板任务每次运行会重新渲染。参见[照片数据源](docs/PHOTO_SOURCES.md)、[自动拼图](docs/AUTOMATIC_COLLAGE.md)和[集成配置及限制](docs/EXTENSIONS.md)。
天气使用配置的服务和地点,在渲染时更新,不会自行刷新已显示的图片。KoKonna 官方小组件与 Frame Flow 模板是两项独立能力;使用官方小组件需要额外授权厂商账号,API Key 接入仍可单独使用。
### 设备集成与 API
```text
Provider → 模板 / Renderer → 生成图片
Web UI / Scheduler / REST API → DisplayService → FrameDeviceDriver → KoKonna
↓
SQLite:任务、操作、历史、覆盖状态、图片
```
设备 Key 通过私有 `.env` 或 Secret 文件配置。首次保持 `KOKONNA_MOCK=true`、`KOKONNA_WRITE_ENABLED=false`,Mock 验证后先只读连接,再明确开启写入。多设备使用 `config/devices.yml` 配置,每台设备引用自己的 Key。厂商代码位于 `drivers/kokonna`;Web 界面与外部程序使用相同业务服务。
正式 API 为 `/api/v1`,OpenAPI 位于 `/openapi.json`,Swagger 位于 `/docs`。显示、渲染、上传返回 HTTP 202 和操作 ID,通过 `/api/v1/operations/{id}` 查询结果。`Idempotency-Key` 防止重复派发,`X-Request-ID` 串联请求与历史。写入结果不确定时会阻止后续修改,必须只读核对或由管理员明确确认,不会自动重放写请求。
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。
账号密码保存为哈希,网页修改操作有 CSRF 校验。API Key 和 Provider 凭据仅留在服务端,不得提交 Git 或写入日志。CORS 默认不开启,需明确配置来源。`/health` 检查服务自身,`/ready` 检查本地依赖,不以云端可用性触发重启。参见[公开仓库隐私](docs/PUBLICATION.md)、[LICENSE](LICENSE) 和 [NOTICE](NOTICE.md)。
KoKonna 是支持的设备集成。本项目与 KoKonna 无隶属关系,也不代表其官方认可。
### 开发与已知限制
```bash
.venv/bin/python -m pip install -e '.[test]'
.venv/bin/python -m ruff check pigway_frame_flow tests scripts
.venv/bin/python -m ruff format --check pigway_frame_flow tests scripts
.venv/bin/python -m mypy pigway_frame_flow
.venv/bin/python -m pytest -q
.venv/bin/python -m pip_audit -r requirements.lock --no-deps --disable-pip
```
测试使用 Mock 云端/设备调用;CI 还验证隔离的 Docker 构建、就绪检查、smoke test、重启与清理。外部 Fork PR 不会在可访问生产环境的 NAS Runner 上执行。
云端同步不等于已确认物理屏幕刷新。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)
历史记录支持服务端翻页、图片名称/请求 ID/任务 ID/来源搜索,以及状态、操作、设备和日期(上海时区)筛选;可选择当前页记录进行单条或批量删除,操作前需确认。删除仅从历史列表移除,内部执行凭据继续供图库来源、幂等及恢复使用,不会删除图片或取消任务;运行中的任务不能删除。
源图库选用标记:本地图库支持单张和批量“不再选用/恢复选用”,照片源(Synology Photos、WebDAV、网盘等)的浏览列表支持独立星标及选用标记。后续自动选图和自动拼图跳过被标记的图片,星标保留与不再选用互斥,需先取消原标记再切换;图片仍保留,可手动预览、使用或恢复。标记按图库和图片 ID 独立保存,不取消已提交任务,也不修改网盘原文件。
+11
View File
@@ -0,0 +1,11 @@
# pigway-frame-flow v0.1.28
源图库增加自动选图排除标记,并完善图库卡片交互。
- 本地图库支持单张、批量“不再选用/恢复选用”;网盘等照片源支持星标及选用标记,按图库与图片 ID 保存。
- 后续自动选图和自动拼图跳过被排除的图片;图片仍保留,可手动使用或恢复,不取消已提交任务。
- 星标与不再选用互斥,前端禁用冲突按钮,后端校验单张、批量、照片源及并发提交,拒绝非法布尔值。
- 卡片分别显示来源、星标/排除标记和时间;按钮按“显示到画框、星标保留、不再选用、下载图片、删除”排列,保持贴底、靠左及同行底边对齐。
- 修正图库任务筛选控件空引用提示,保留删除确认和批量操作。
源码包保留 LICENSE、NOTICE 和第三方许可材料,不包含私有配置、数据库或会话。
+21
View File
@@ -0,0 +1,21 @@
# 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.
## 当前容器映像检查
v0.1.23 使用官方 Python Alpine 3.24 基础映像并固定摘要,构建时应用 APK 安全更新。2026-10-18 使用 Trivy 0.76.0 扫描与当前生产运行 ID 一致的 Linux AMD64 映像,涵盖操作系统和 Python 包、全部严重级别,未检出已知漏洞;未配置漏洞忽略规则。该结果是当日漏洞库快照,不是未来无漏洞的保证,也不代表旧运行容器已升级。
基础映像更新后须重新构建并复扫。默认非 root、只读根目录、清空 capabilities 和 no-new-privileges 的部署限制继续保留。中文字体、第三方许可证和依赖元数据均保留;不以删除包清单掩盖告警。
+240
View File
@@ -0,0 +1,240 @@
c2792b2c75880ff09822804632b1b3b6dd6190b38841b8c3567bb1b7e7b59a8c .dockerignore
d20031a8e861a0f3e19b7750ddb53bc84c480c469c44a70ecdf8261ec05431c0 .env.example
0c7f4585d62a8d35bfc22e0bebdf7a479a179122b2b092bfe434e3038cddd3d5 .gitattributes
b45d35d737f119a70bf40cb1f9626ff80772ed7913cb35f6e9b870d6ada01a15 .github/workflows/community.yml
c8c545718e9b9880555c950a078cac160c82e0cd6e593afcf20bc13727539ec5 .github/workflows/verify.yml
3931cc314019760a582e0914e2d9c0f3b862a004d0a30d374900edcad5f599e5 .gitignore
4b4ed4926a350cbeb71436541b4566e4ba7d2494ac95236b88827a9f5dd171c2 CONTRIBUTING.md
784ddd90ad45e4de196a7459bd3b7cb76daf57c374cd4a503be31a0e95e2b2a1 Dockerfile
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md
b7a41a2c00df1a0dbe3869d059d65c3bcaee8c50e51217c358527c4f1d3d6c98 README.md
94184f76401192678ec420c2ccd23142d90e07a8ccc882a4a0d4701a0c278afc README.zh-CN.md
441c9bf48ed54c32dc21e103a657c3f87b497cb96e358ca2c62f31b2d058ec14 RELEASE_NOTES.md
3dd364e47fdbd2ce9ce2df83342c523adc21583f093a5439d501b3424878ce2f SECURITY.md
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 config/.gitkeep
dff739157875c8a70235c4994df868afc6824e0d3861b2716ac28014bb38144f 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
f78d6c3d40e6c5246601bb99c2b46eef91e883a8f83841d85c925394708af0c0 docker-compose.yml
30d3e705870ebb012ce122bd7d8f95192d86664c4d2bb45a3169c86988fbaf72 docs/AUTOMATIC_COLLAGE.md
93b6e60570d9ca2ce14a454641a32885ca84d16a82db05db22cd62e488191dff docs/BUILDING.md
d9f07164c75c757e85f4ace2b55deaf82b8b904216c5222be6c0e3ab7b28cb36 docs/EXTENSIONS.md
1d8c5f79eff3115b1cd13d8f63fff1332b80d1fe1bec6b0f2713b5f80b0cbf67 docs/KOKONNA_RECOVERY.md
0fe1896a53cef4cfd2e380a0b979eceda493733082a9b56c3d7e89e7f62a65b3 docs/NAS.md
3280bddcbe2d2721bb7efeb0eb2ec03d9d5ef867c69f3e70066b69f1ad5926e2 docs/PHOTO_SOURCES.md
edf44436fd044561539c48480c481e66c1a38164f51ba5226cde04c756f7bacb docs/PHOTO_SOURCE_CONNECTIONS.md
b48f3ab6f28404bb34c188067a832384f4ba290e56912461ef3565528aa157d8 docs/PUBLICATION.md
5fe4641e75e0746929f71256c6ff11116c777cca8d4288ccc2d7aee91bd7a6ac docs/README.md
5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md
bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md
fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md
f8ef7749c58d4396781bbc21371048f6561e791efbd489570224dc51173bffff docs/brand/README.md
f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3 docs/brand/pigway-avatar.png
da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7 docs/brand/pigway-logo.png
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 docs/brand/pigway-logo.svg
ac0481196e593ff9cca2e5133ff41fb3614268e6a10728a0deca3ad3bfb80b34 docs/integrations/TICKTAR.md
bd906c2e74d95360880f3c96fd78474458be243f7ac01bd6cbc6e737719bb9ad 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
a2561122d40be5ed49bff2a4ad6cae628f9180ef28489853dd9bf1812e224ac6 pigway_frame_flow/api/app.py
1efe9b1fc774a97e5be72d81199b1b65b25c258956fdf166aebff732cc820d7c pigway_frame_flow/api/history.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py
0b8402603c2f397f4f42491873b27bf363959194b05a0c67c7b1d49596288b95 pigway_frame_flow/core/accounts.py
818e8fb29d22c13159a6cb22bcbc6b9e211a8a1301588126eb192ac4ed71441b pigway_frame_flow/core/auth.py
2f03a04c1f0f32d95bfd3d27d37ca98f9b79daaa20453999024b16fc8f34cd5d pigway_frame_flow/core/photo_preferences.py
8f2220fa90b0dc2e45df2023dc5ac695efe55361644ef266829553c99151949b pigway_frame_flow/core/registry.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/display/__init__.py
81effa34d6c452579cfa65781909f7c5a9e8ddad136e4c47ee0704939851879a pigway_frame_flow/display/cloud_library.py
5e7e272e671a6d01d9b677416c77967ca488b4e2aa559f4211cf0de4ba1e88bc pigway_frame_flow/display/recovery.py
e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py
d974caaa2772b013cc6f080f997d37cd0d2212b29fa9524002f3fb87dd937364 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
2f10b3644652369a367d3100114dc8b6fe3abaceeeb6d09fc2f55522e1298f9d 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
f16e9ae17da8bddf95622b4d8ef2626342a88cd9da2f97913db5bdaec5c409df 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
885d1945dacd611143eb6992da4621ce32ae304d80514f150d55318bdcb10e0f 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
e80029d9edc781e54e523570940110c948a0b0e564dcacf64f2fe1425f4509ea 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
2c306ca452b15588b83f15df46a39d33564954519b941690f3d07a75147f3f1e 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
26294166cdf50cd41885b5e71d1314b2e885b212e1efe7496d401d424b2beda4 pigway_frame_flow/web/app.js
f332fac63f611572597812a06ae0cc266a59e5677efda0d6e9b6925acf9f9309 pigway_frame_flow/web/apple-touch-icon.png
d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js
94558dbf524a1068e83283eb814c1b1501512d4b7e75e092b52e9aabfea720e7 pigway_frame_flow/web/favicon.ico
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/favicon.svg
4c7a6d81a4397896e5dcca9020e0a162e60116ff05a840f161834afdf6ea54c5 pigway_frame_flow/web/features.js
4995020f673252abfd745ac8a1a6e9c5081cf9bfa89dd4b7d1ab30a41e0b6eb9 pigway_frame_flow/web/i18n.js
3f241f7180861aac148f4713e1538178e252b375172759e7d6bfdfa84042236b pigway_frame_flow/web/index.html
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/logo-symbol.svg
ff769c592cc616a983fdf6e92114ebe3779cf8f40a2a7bc235687e7cbdfdaed0 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
0d444fe33713346788104b3473c4b413fd261e0abda77dae6cf174442b71812c pyproject.toml
d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock
008af5f4adf616c86e805febc016faad60be0008abf5dddad6e955f1d03606cf 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
eb94843315f0671ca14fac66fc0c6e7380866f28dc822cce14312fa548bb5c82 scripts/create-account.py
cdabf30144ea089ae8e64228f6762951f53d4fe8c191673fb5676331d249473d scripts/migrate-photos-api.py
80813607835c19d321085fac4cabb17d865d98ed62a09b7f45195ac48bec94a8 scripts/package-nas.py
b0b877cfbb2bdf830e2e41097c2057538bb9d0b61a3c39952fe505d910cd5c29 scripts/persistence-check.py
b7247c5590fe17ae461311f2fbdbeda0daa8a34c0804cef22a314c7ff8f4c389 scripts/production-deploy.py
11627ecbd8a3be01aa079bdf6166b4368e670f5ad4a165b5971754b8187bbc2c scripts/run_local.py
5a0f8560d44352f2c5595875455ab1b2397d4eb8158a8160b5587a89a934f712 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
8260a11478e23f63bc56c119205a89df04efe7c8a9bfb25b80db24aa56fd302b scripts/ui-history-layout.cjs
86cf401ffb8b78691b6dca0851925af3b3107acfffd6943d78027ca4e9a54df7 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
964a29221c6f7a4185067cb686e1ea003b99ce4609d6fb4ee4bacb70c9fe5ce8 tests/contract.json
86aba7819e70b6ea9372eecc17ab5dfee3aa433e38417ebe81785d3450b4ad0d tests/schema-contract.json
23ffc7413f8388648c9d52d3c8f56e2966b73ca1bdc5e209e6c15469c790edba tests/test_accounts.py
e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py
a6edb781f4d081b2db45ffe2b02eed52a10f9b87a70e1e201b5d2f7c6813b93b 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
5dc4027bcdad63c4a3e02c7735e6b8445c69edb43f2a3c0cd8902ef6aa1c6342 tests/test_history_images.py
1f548f3ac8023da49c3cfd1e61ed986d949229e67e5b34b5e4947852ce3b5ca4 tests/test_history_management.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
3138c696c64d0e67d0cb03b18b79b0d0e1a4217f391231af27941a9e560c8050 tests/test_photo_preferences.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
915992a2ef9aeb8ae5c688d5dd50c6acd37ba726eb5aff1a0df991ffe374d94e tests/test_recovery.py
e0a426eed6f1b6f59207b4de7d8286b6852550bfa28ad30e20f7e911ad9865ee tests/test_retention.py
ab2cd30163453fe571b55c30cb26dc047a104a536ec01d2c64aa2bc91996ba11 tests/test_scheduler.py
0cccc08adf35a06dbf704eb5c35966bc1c3de0ce39d3014efd7ca751253ce79f 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
6a73f9541c2de74158c0e7cf6b0a58ef774f5a780bf191f2d7ec9cc53efe2bf2 vendor/noto-cjk-OFL-1.1.txt
e8cf7d54ea46c19aba793983889b7f7425e1ebfcaaccec764a7db091646e203c vendor/paho-mqtt-EDL-1.0.txt
57938f47d5f05f7de119b17fcecd4509c5d13644c8d5f43db30e45684d09d07e vendor/swagger-metadata.json
View File
+3 -5
View File
@@ -1,5 +1,3 @@
# Shared mainland-China build profile; source this file before Compose builds.
# Nonempty environment / Gitea repository variables override these defaults.
DEBIAN_MIRROR=${DEBIAN_MIRROR:-mirrors.tuna.tsinghua.edu.cn}
DEBIAN_SECURITY_MIRROR=${DEBIAN_SECURITY_MIRROR:-mirrors.tuna.tsinghua.edu.cn}
export DEBIAN_MIRROR DEBIAN_SECURITY_MIRROR
# Shared mainland-China build profile; source before Compose builds.
ALPINE_MIRROR=${ALPINE_MIRROR:-mirrors.tuna.tsinghua.edu.cn}
export ALPINE_MIRROR
+51
View File
@@ -0,0 +1,51 @@
# Candidate only. Never start alongside a live-writing NAS instance.
name: pigway-frame-flow
services:
pigway-frame-flow:
image: ${FRAME_FLOW_IMAGE:?Set the verified immutable image reference}
container_name: pigway-frame-flow
platform: linux/amd64
restart: unless-stopped
init: true
user: "10333:10333"
ports:
- "${FRAME_FLOW_BIND:-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"
+3 -11
View File
@@ -1,14 +1,6 @@
# Opt-in read-only photo mount. Set NAS_PHOTOS_DIR to the actual NAS folder.
# Retired: filesystem photo mounts are forbidden. Use Photos API sources.
# This compatibility filename intentionally grants no additional host access.
services:
pigway-frame-flow:
environment:
PHOTO_ROOT: /photos
group_add:
- "${NAS_PHOTOS_GID:-10333}"
volumes:
- type: bind
source: ${NAS_PHOTOS_DIR:?Set NAS_PHOTOS_DIR to your existing photo directory}
target: /photos
read_only: true
bind:
create_host_path: false
SYNOLOGY_PHOTOS_CACHE_BYTES: ${SYNOLOGY_PHOTOS_CACHE_BYTES:-268435456}
+3 -3
View File
@@ -7,8 +7,7 @@ services:
build:
context: .
args:
DEBIAN_MIRROR: ${DEBIAN_MIRROR:-deb.debian.org}
DEBIAN_SECURITY_MIRROR: ${DEBIAN_SECURITY_MIRROR:-deb.debian.org}
ALPINE_MIRROR: ${ALPINE_MIRROR:-dl-cdn.alpinelinux.org}
DOCKER_PIP_INDEX_URL: ${DOCKER_PIP_INDEX_URL:-https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple}
DOCKER_PIP_FALLBACK_INDEX_URL: ${DOCKER_PIP_FALLBACK_INDEX_URL:-https://pypi.org/simple}
BUILD_COMMIT: ${BUILD_COMMIT:-development}
@@ -17,12 +16,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
+55
View File
@@ -0,0 +1,55 @@
# Automatic collage
The navigation starts with Dashboard, followed by Gallery, Photo Sources, Upload,
Templates, Layout Studio, Automation, Playlists, History, Connections and Settings.
## Web workflow / 界面操作
在「拼图」选择照片来源与数量(1–12),填写可选标题或排版要求,点击
「随机选图并生成预览」。默认使用启用的数据源;没有数据源时使用本地上传照片。
AI 只接收图片尺寸、内部资产 ID 和排版文字,不发送照片。
AI 未配置或请求失败会明确报错,不会偷偷切换引擎;取消 AI 勾选可用自动网格。
预览不会上传或切换画框,确认效果后再点「显示到画框」。
Choose a source and count, then create a preview. Only dimensions, asset IDs and
layout instructions are sent to AI. AI configuration/errors are explicit; uncheck
AI for deterministic grid layout. Preview never changes the frame. Canvas
orientation follows the target device. Manual layout is under Advanced.
「创建自动化任务」保存来源和数量,而不是固定图片列表。任务初始暂停,
请在自动化中编辑执行时间后启用。每次运行重新随机选图,但不同运行之间可能重复;
单次不重复选同一照片。照片不足时失败,不重复图片凑数。
NAS 来源继续使用人物、方向与质量筛选;一次最多检查数量的五倍候选图片。
输入图缓存与生成图在 NAS;原始 NAS 照片不会被修改或删除。
定时拼图沿用云库每任务保留最近一张成功图的策略,下一张确认成功前不删除上一张。
Create automation stores the source/count, initially paused. Edit its schedule
and enable it. Each execution selects new candidates; repeats across executions
are possible. Too few usable photos fails before upload. Existing unknown-write,
restoration and cloud-retention safeguards apply.
## Public API
`POST /api/v1/render` accepts this additive content type, returning HTTP 202 and
an operation ID. `POST /api/v1/display` uses the same content inside its existing
request envelope. Scheduler `source` accepts the same object; job previews are
read-only.
```json
{
"type": "auto_collage",
"source_id": "nas-photos",
"count": 4,
"title": "Our moments",
"orientation": "auto",
"style": "grid",
"use_ai": true,
"prompt": "Feature one photo with smaller supporting photos",
"profile": "color"
}
```
`source_id: null` chooses local uploaded assets (generated and cache images are
excluded). AI configuration is shared with existing Layout Studio. No new API key
or vendor dependency is introduced. The OpenAPI contract test records the new
content schema while keeping all existing schema fields unchanged.
+102
View File
@@ -0,0 +1,102 @@
# Build networking, cache and CI
### Build networking and cache
The runtime uses an official Python 3.14 Alpine 3.24 image pinned by digest,
locked Python dependencies, and `font-noto-cjk` for Chinese rendering. APK
repository signatures and offline wheel SHA256 checks remain enabled.
Production uses `scripts/build-verify.sh`; CI uses `scripts/ci-compose-verify.py`.
Both build the same Dockerfile with BuildKit and `docker compose build --pull`.
For a CI host that cannot reach Docker Hub, the optional `CI_PYTHON_BASE`
repository variable passes a registry mirror reference through the build script.
Keep the same official Python image digest; an unset variable preserves the
Dockerfile default. This setting does not change the shared Docker daemon.
`ALPINE_MIRROR` accepts a mirror host (without scheme or path), defaulting to
`dl-cdn.alpinelinux.org`. For mainland-China builds:
```sh
. ./deploy/build-cn.env
./scripts/build-verify.sh
```
CI uses this profile; the Gitea `ALPINE_MIRROR` repository variable can override
it. APK upgrades available packages during a fresh dependency-layer build.
Use `docker compose build --pull --no-cache` to refresh OS packages explicitly.
Layer order is pinned base → APK/fonts → requirements → Python dependencies
→ application source → metadata. BuildKit caches pip downloads separately for
musl and architecture. Optional `build-cache/wheels/` must contain compatible
musllinux wheels and their SHA256 manifest; empty-cache builds work normally.
Do not reuse Debian/manylinux wheels on Alpine. Caches are disposable builder
resources and are not runtime mounts or dependencies.
### CI with a host Docker socket
The managed CI runner runs on Ubuntu Server; the NAS no longer executes jobs.
It retains `ubuntu-latest` compatibility and fixed runner/job image digests,
accepts one job at a time, and limits each task container to 4 CPUs and 8 GiB.
The runner's private state resides on the guarded business disk. Its systemd
service checks the mount identity before starting. Registration credentials are
supplied through a temporary file and removed after registration; the daemon
container does not retain the reusable registration token in its environment.
Keep the current runner and job images even when no task is executing: they are
active CI dependencies. Only trusted repository events may reach this runner's
Docker socket; CI does not automatically deploy production or write to frames.
Run `python scripts/ci-compose-verify.py` after loading the build mirror profile.
The script derives a temporary Compose document from production settings, with
an absolute client-side build context, a UUID project name, a unique CI image
tag, and project-scoped `data` / `config` named volumes. It removes production
bind mounts, the fixed container name, host ports, and the production `.env`
reference. A fresh test token, mock driver, and disabled device writes are
explicitly configured. Docker initializes empty named volumes from the image's
owned `/data` and `/config` directories; the service remains UID/GID 10333.
Build contexts are sent by the Docker client, so the job workspace can remain
inside the job container. Runtime storage is managed by the daemon; no matching
Docker host `/workspace` directory is needed. HTTP readiness and the unmodified
nine-endpoint smoke script run inside the service with `compose exec -T`.
Restart is followed by another readiness check and the same smoke test.
A `finally` block removes the test containers, network, volumes, and unique
image tag on success or failure. The workflow records the private temporary
Compose path in `GITHUB_ENV` and repeats cleanup in an `always()` step, covering
normal cancellation/interruption. Failed cleanup retains the manifest so the
fallback can retry. Abrupt host/daemon loss or a forcibly terminated entire job
can prevent both cleanup paths; no CI script can guarantee cleanup in that case.
No global prune or production resource removal is performed. Keep builder
caches: only the run-specific image tag is removed.
### CI Job pip indexes versus Docker build indexes
The Job bootstrap runs `python scripts/ci-install.py` before Ruff/mypy/tests.
It needs only the Python standard library, so it runs before PyYAML or tooling
is installed. `CI_PIP_INDEX_URL` and `CI_PIP_FALLBACK_INDEX_URL` are optional
Gitea repository variables. Defaults are the TUNA HTTPS PyPI simple endpoint
and official `https://pypi.org/simple`. Each is attempted separately; the
fallback repeats the unchanged editable `.[test]` installation, including
PEP 517 build isolation and `setuptools>=75`. No simultaneous extra index is
used, no build dependency is removed, and both failures fail CI.
Logs show inherited index/config/proxy hosts and the selected index host for
each attempt, with URL credentials and query strings removed. Bootstrap
disables inherited pip config files via `PIP_CONFIG_FILE=/dev/null`, explicitly
sets the selected index, and clears no-index/extra-index/find-links, insecure
trusted-host, and no-build-isolation environment settings. Constraints, TLS
certificate environment settings, and proxy settings remain in effect.
These `CI_PIP_*` settings apply only to the Job's pip install. They are not
passed into Docker build arguments or persisted into the production image.
The Dockerfile keeps its own cached/offline dependency installation and logs
the Docker pip index host for online installs. The Alpine mirror variable affects
APK only and are loaded later in the Docker verification step.
If dependency installation fails, the always() cleanup step still executes.
It skips the Python/PyYAML cleanup script when no CI Compose manifest exists,
as no Docker verification resources have been created at that point.
### Verified prebuilt production image
When the production host cannot reach the registry, build the complete Dockerfile in a trusted build environment and transfer it with `docker image save` / `docker image load`. Supply the immutable `sha256:` image ID privately as `FRAME_FLOW_PREBUILT_IMAGE_ID` to `scripts/server-release.py`. The expected version/commit tag must already exist. The script checks the ID, version and revision labels, then verifies runtime source and license hashes in an isolated container without production mounts before switching the service. Existing storage checks, backups, post-deployment verification and rollback remain in effect.
+1 -1
View File
@@ -81,7 +81,7 @@ A failed mirror installation retries the same locked requirements against the
fallback, without extra indexes, version downgrades, or disabling build isolation.
Logs show index hosts and redact URL credentials/query parameters.
APT/font layers remain cached when Python dependencies change. The Python layer
APK/font layers remain cached when Python dependencies change. The Python layer
changes only when the lock, installer, base image, or mirror arguments change;
application source changes reuse it. BuildKit pip cache survives layer misses.
An explicitly supplied, checksum-verified offline wheel directory still takes
+61
View File
@@ -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. 有目标一致且同步完成的证据才记为成功。处理后恢复原计划,错过的任务不补发。
仍可能因厂商无法给出明确写入结果而需要人工处理;本策略解决错误误判、可安全重试的暂时失败和长期阻塞缺乏解释的问题,不承诺网络故障时所有设备任务都能自动成功。
+13 -11
View File
@@ -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
@@ -122,23 +128,19 @@ all uncompressed rootfs diff IDs before loading. With the verified local tag loa
```sh
sudo env DOCKER_BUILDKIT=1 /usr/local/bin/docker compose build \
--build-arg PYTHON_BASE=python:3.14.5-slim-bookworm
--build-arg PYTHON_BASE=python:3.14.8-alpine3.24
```
This per-build override does not change global registry or network configuration.
A slow Debian CDN can be overridden only for this build, retaining APT GPG
A slow Alpine CDN can be overridden for this build, retaining APK signature
verification and changing no NAS system settings:
```sh
sudo /usr/local/bin/docker compose build --build-arg DEBIAN_MIRROR=mirrors.aliyun.com
sudo /usr/local/bin/docker compose build --build-arg ALPINE_MIRROR=mirrors.tuna.tsinghua.edu.cn
```
Optional `build-cache/debs/fonts-noto-cjk.deb` avoids a slow build-network download.
Dockerfile verifies it against SHA-256 from the GPG-authenticated APT index before use.
The cache is not a runtime mount or committed dependency, and empty-cache builds work.
Optional `build-cache/wheels/` can hold all 24 Linux AMD64/Python 3.14 wheels
Optional `build-cache/wheels/` can hold all locked Linux AMD64/Python 3.14 musllinux wheels
and `SHA256SUMS`, verified against official PyPI release metadata. Builds verify
the manifest and use pip `--no-index`; incomplete caches fail explicitly.
Build commit/time are set after dependency installation to preserve build cache.
+6 -2
View File
@@ -4,6 +4,10 @@ Photo sources are independent read-only providers. They never call a frame drive
SQLite persists source definitions and Scheduler jobs; passwords are not stored there.
The shared DisplayService handles fitting, upload, idempotency, overrides and restore.
## Synology Photos API (no mount)
Use the explicit `synology-photos` source type for Ubuntu without `/photos`. Existing local sources are not silently converted. Shared and current-account personal spaces use the shared Photos login, with ID-based any/all people filters and bounded XL preview caching. See [verified capabilities, migration and recovery](SYNOLOGY_PHOTOS_API.md). Original API downloads remain unsupported.
## NAS local folder
Add the actual existing photo directory to `.env`:
@@ -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. 与当前本地目录、日期文件夹规则取交集,再随机选图。原文件夹标题、日期、地点仍保留。不匹配返回空图库;预览 / 定时任务失败,不回退到全图库。
+44
View File
@@ -0,0 +1,44 @@
# Photo sources and shared connections
Each photo source has a unique ID and independently persisted directory/URL,
orientation, caption, quality, duplicate and person-selection settings. Jobs refer
to the source ID. Updating one source does not change another source.
Local sources read folders under the configured NAS mount. Ordinary file reads
and local Synology thumbnails do not require a Photos login. Person filtering
uses the shared Synology Photos session, intersected with that source's folder
catalog. Shared/personal space and person IDs are saved separately per source.
The current service supports one NAS Photos endpoint and one active account.
Signing in with another account or disconnecting affects every people-filtered
source. Personal-space person IDs may differ between accounts: recheck each
source after an account switch. Multiple independent NAS/account connections
are not supported. Do not interpret multiple folder sources as multiple logins.
WebDAV credentials and native drive credentials belong to their respective
sources and do not use the shared NAS Photos login. Explicitly reusing a WebDAV
credential variable shares that credential, but does not share filter settings.
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 仅作为只读照片来源,
不出现在可保存目的地标签栏,不显示尚未实现的上传入口。
+33
View File
@@ -0,0 +1,33 @@
# Public repository and deployment privacy
Runtime keys, passwords, sessions, private photos, databases and local `.env` files
must remain outside Git. Examples use documentation-only addresses and identities.
Personal deployment reports and screenshots are excluded from public history.
The Gitea Server 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.
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.
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.
+22
View File
@@ -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)
+21 -1
View File
@@ -9,7 +9,7 @@
duplicates, name/date/dimensions anomalies. No download-all action.
3. Stop on discrepancy. Missing model fields must be reported as unavailable.
4. Once explicitly authorized, locally render **one** 1200×1600 test image containing
PIGWAY FRAME FLOW TEST and Shanghai timestamp, no private content.
PIGWay Frame Flow TEST and Shanghai timestamp, no private content.
5. Record an operation ID and previous image; one upload only, no write retries.
6. Poll device read-only no more often than 60 seconds, respecting the 20/min budget.
Confirm imageId and synced. If sent but response lost, mark UNKNOWN, block writes,
@@ -25,3 +25,23 @@
The API cannot set coverId. Restoration guarantees a requested image switch when
confirmed, not arbitrary cover metadata restoration. Physical screen refresh has
no documented pixel telemetry; visual owner observation supplements cloud synced.
### Upload response reconciliation
Before an upload, the service persists its asset ID, exact SHA256/size, filename,
existing gallery IDs and timestamp. When the response is lost, read-only queries
look for one new image in the upload time window; only an exact downloaded-byte
match identifies it. Ambiguous, missing or altered content remains locked for
manual review. No upload or switch is replayed by reconciliation.
A proven upload completes the display only when the device reports that exact
image and `synced=true`. If a normal display remains on its previous synced
image beyond the sync timeout plus five minutes, two observations at least one
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
View File
@@ -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.
+62
View File
@@ -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 完整构建,包含固定基础映像、系统安全更新和锁定依赖,避免继承旧映像中的系统漏洞。
## 旧来源转换与恢复
**停止目标实例、复制数据、转换真实配置及切换部署均需另行授权,本次没有执行。**
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 规则;本次没有删除。
实际部署回执和照片库统计应私下保管,不提交公共仓库。
-83
View File
@@ -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,不执行真实设备写接口。
+15
View File
@@ -0,0 +1,15 @@
# PIGWAY 品牌资源 / Brand assets
来源:PIGWAY Brand Production v1.1 正式交付包。图形、颜色与比例保持原文件,不使用重绘、滤镜或替代字标。
- `pigway-logo.svg`:正式 Web 图形标。
- `pigway-logo.png`:正式 256px 透明图形标,用于项目介绍页。
- `pigway-avatar.png`:正式 512px 平台图标,用于 Tea 项目头像。
本项目的产品名称保持独立。品牌物料版本不跟随软件版本变化;网页小图标使用同一正式包的 Favicon 和 Apple Touch Icon。
## SHA-256
- `0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210` `pigway-logo.svg`
- `da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7` `pigway-logo.png`
- `f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3` `pigway-avatar.png`
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

-342
View File
@@ -1,342 +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 `/volume2/docker/pigway-frame-flow/data`,
`/volume2/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 → Variables (ordinary configuration):
| Variable | 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` | Existing absolute NAS host data path; no default |
| `PROD_CONFIG_DIR` | Existing absolute NAS host config path; no default |
| `PROD_PHOTOS_DIR` | Existing read-only photos source if present; otherwise empty |
| `PROD_WEB_BIND` | 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 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
and without changing `latest` or CI image tags.
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 these repository Actions **Variables** (not Secrets):
```text
PROD_DEPLOY_ENABLED=true
PROD_PROJECT=pigway-frame-flow
PROD_CONTAINER=pigway-frame-flow
PROD_IMAGE_REPO=pigway-frame-flow
PROD_DATA_DIR=/volume2/docker/pigway-frame-flow/data
PROD_CONFIG_DIR=/volume2/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 `/volume2/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:release-<commit>-<transaction>`; 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 fetch commit-pinned Actions from the supporting repository
`https://tea.pigway.com/way/frame-flow-ci-actions`, subdirectories `checkout`
and `setup-python`, at commit `bada0aa1b7e0d23fe9da66ce2ec9b193c211ea46`.
This avoids unstable NAS downloads from GitHub (EOF/timeouts in runs 27/28)
and slow external Gitea Action preparation (run 29, superseded).
The cache contains unmodified official upstream source, including MIT licenses:
| Action | Official upstream commit | Identical Git tree |
| --- | --- | --- |
| actions/checkout v4 | 11d5960a326750d5838078e36cf38b85af677262 | f8a7b72dc00648d050099727d25ca92a43ad1162 |
| actions/setup-python v5 | a26af69be951a213d495a4c3e4e4022e16d87065 | 568c6310706725a8b725497dbe6b3b909cbcf6cd |
`UPSTREAM.json` records provenance. Upstream tag SHAs were checked using the official
GitHub API; each cached subtree hash was compared to the upstream commit tree.
No application secrets are stored in this public upstream-source cache; its own
Actions are disabled. Intentional action updates must repeat tree verification
and change the workflow pin. All validation steps remain unchanged.
Python toolcache misses may still require setup-python to download Python from
its upstream release source. This removes Action source download dependence,
not every possible external download. No Runner or NAS network configuration is changed.
### Synology Photos dependencies
For a production photo source using Synology Photos person filtering, configure
`SYNOLOGY_PHOTOS_URL` and `SYNOLOGY_PHOTOS_CERT_SHA256` as Gitea Variables. 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.
+17 -8
View File
@@ -17,8 +17,11 @@ Authorization: Bearer FRAME_FLOW_TOKEN. The bootstrap token is provided through
client_id, name, permissions, enabled, created_at, last_used_at. Permissions:
devices:read, images:read, images:delete, display:write, templates:read, render:write,
jobs:read, jobs:write, history:read, settings:write, admin.
0.1.0 seeds one admin token; provisioning scoped client rows is supported by middleware
but multi-token management UI is not implemented. Use a trusted server-to-server
The deployment token seeds an administrator. Administrators can create separate
scoped clients through Connections or POST /api/v1/clients. The returned token is
shown once; GET /api/v1/clients never returns tokens. DELETE /api/v1/clients/{id}
revokes access. For Ticktar, grant devices:read, templates:read, render:write and
display:write; add images:read or jobs permissions only when needed. Use a trusted server-to-server
connection; CORS is opt-in by exact origins, never wildcard by default.
## Discovery and capabilities
@@ -86,13 +89,19 @@ The authenticated client is authoritative; source.client is optional metadata.
History stores client_id/request_id/operation_id/device_id/action/result/timestamp.
No internal endpoints, headers, stacks, paths or secrets are returned.
## Future webhook
## Webhooks
SQLite events and webhook_subscriptions reserve operation.completed/failed,
display.changed, device.online/offline/low_battery events. 0.1.0 emits operation/display
events only; no callback delivery, retry worker or registration API is implemented.
Future deliveries should use signed payloads and bounded retries.
OAuth, tenancy, RBAC management and generated SDKs are future work.
Administrators register callbacks through POST /api/v1/webhooks. Allowed public
HTTPS hosts must be configured explicitly. Durable callbacks cover
operation.completed, operation.failed, display.changed and observed
device.online/device.offline/device.low_battery transitions. Device observation
runs at most once per minute; failed cloud queries do not imply offline.
X-Frame-Flow-Signature: sha256=<hex> is HMAC-SHA256 over the exact request body.
Signing secrets are shown once and stored in mode-0600 files. Verify signatures
and deduplicate event IDs. Delivery retries at most three times; events before
registration are not replayed. Polling Operations remains available.
OAuth, tenancy and generated SDKs are future work.
## Compatibility
+5872 -141
View File
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
# KoKonna local-control investigation
Reviewed 2026-10-06. No frame writes, network changes, or firmware changes.
## Sources and limits
- Official FAQ: https://kokonna.art/pages/faq explicitly describes Bluetooth
upload and switching. This is a product-level claim, not verified B101 behavior.
- Public OpenAPI: https://kokonna.art/pages/openapi documents cloud HTTPS calls.
- Existing official Android nightly 1.6.5 APK from the prior download:
SHA256 `8f611bf74bbda70e7e7a5d087e4065dc6000849dcd2f12538d5ff5017c91e1c0`.
Static inspection of its packaged app-service.js; no vendor code is copied here.
- Searches found community API clients, but no verified B101 firmware source or
documented bring-your-own-server configuration. Absence from search is not
proof that firmware source or another configuration path does not exist.
## Observed client behavior
The account client uses `https://robot.galaxyguide.cn/api/rpc`. Its Bluetooth
upload flow calls `appFrame.uploadImage` with base64 and `synced: true`, receives
an image ID, downloads the resulting image from the vendor server, and sends
the bytes to the frame over BLE. Therefore this observed flow still exposes
the original image to the vendor cloud.
BLE service UUID: `4fafc201-1fb5-459e-8fcc-c5c9c331914b`.
Characteristic UUID: `beb5483e-36e1-4688-b7f5-ea07361b26a7`.
Observed JSON commands include `get_info`, `refresh_image`, and `set_image`.
Image transfer uses start metadata (ID, rotation, type, size, chunk count),
numbered binary chunks, sync acknowledgements/missing-chunk information, and
an end command. The client chooses `y4` for firmware starting with B103 and
`jpg` otherwise. That makes local JPEG transmission a plausible B101 research
path, not a proven implementation: cloud-independent IDs, processing, firmware
acceptance, pairing and completion semantics still require validation.
The observed `set_config` payload contains `slideType`, `slideMinute`, and
`nickname`; `set_wifi` contains SSID and password. No server-host setter was
found in these paths or common host-setting symbol searches.
## Host replacement assessment
Changing Frame Flow's API base URL changes where Frame Flow sends requests,
not where the frame connects. Replacing the frame's cloud server requires its
device-side protocol and a supported address-setting mechanism or firmware
change. DNS redirection alone may fail HTTPS certificate validation and still
requires a compatible server implementation. TLS/pinning and actual device
polling transport have not been established from this APK.
Next read-only step: inspect BLE `get_info` and installed firmware compatibility.
Any subsequent local image write needs a separate controlled test. No BLE driver
is enabled by this investigation.
## Wi-Fi-only follow-up
The user excluded Bluetooth and additional gateways. The target is NAS-to-frame
Wi-Fi control with the original hardware.
GitHub's public repository search for `kokonna` returned three repositories:
`patrickchen914/kokonna`, `nixihz/kokonna-skill`, and
`zhengweiran66-eng/kokonna-product.html`. These provide a Home Assistant cloud
integration, an OpenAPI CLI, and a product page, respectively; no verified B101
device firmware or Wi-Fi local-server implementation was identified.
Static APK checks found no hard-coded private IPv4 target, WebSocket URL,
custom image-URL setter, or common server-host setter symbols in app-service.js.
This does not exclude hidden firmware features or native-library code paths.
`app.getFirmwares` is called with `robotId`. Upgrade submission passes a firmware
name, locale and distributor ID through `app.setUpgradeFirmware`; the inspected
UI does not supply a user-selected firmware download URL. Neither method was
called during this investigation. Existing account RPC allowlists remain intact.
Candidate paths, none yet verified:
1. A device-local HTTP/TCP service: requires a confirmed frame LAN address and
bounded service inspection while the frame is awake.
2. A firmware-supported custom server or image URL: requires configuration or
firmware evidence, not simply replacing the application's API host.
3. A NAS implementation of the device's cloud protocol: requires the actual
polling/download protocol and address/TLS behavior. Passive observation at
the existing gateway may provide evidence; a normal NAS on a switched LAN
cannot assume it sees another device's outbound packets.
No DNS redirection, router interception, certificate replacement, firmware
upgrade, reset, or automatic display change was performed.
+122 -16
View File
@@ -14,8 +14,14 @@ from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.responses import FileResponse, JSONResponse, Response
from starlette.exceptions import HTTPException
from pigway_frame_flow.api.history import install_history
from pigway_frame_flow.core.accounts import install_accounts, seed_accounts
from pigway_frame_flow.core.auth import require, seed_client
from pigway_frame_flow.core.photo_preferences import (
annotate,
ensure_compatible,
set_flags,
)
from pigway_frame_flow.core.registry import DeviceRegistry
from pigway_frame_flow.display.service import DisplayService
from pigway_frame_flow.errors import ErrorCode as E
@@ -29,22 +35,27 @@ from pigway_frame_flow.models import (
Accepted,
Asset,
AssetBatch,
AutoCollageContent,
CurrentDisplay,
DeleteRequest,
Device,
DeviceIdentity,
DisplayRequest,
ErrorResponse,
Fit,
GalleryDestination,
GalleryImage,
Health,
History,
ImageContent,
ImageExclusion,
ImageStar,
ImageURLConfig,
Job,
JobInput,
Operation,
Options,
PhotoPreference,
PhotoSourceContent,
Playlist,
PlaylistInput,
@@ -121,14 +132,15 @@ 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.0",
version="0.1.28",
lifespan=lifespan,
responses=error_responses,
)
install_extensions(app, service)
install_accounts(app, db)
install_history(app, service)
app.state.service = service
app.state.worker_enabled = start_worker
app.state.scheduler = scheduler
@@ -302,6 +314,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
@@ -417,12 +430,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
with Image.open(cached) as img:
img.thumbnail((300, 400))
img.save(thumb, "JPEG")
# 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
@@ -485,7 +505,7 @@ def create_app(settings=None, start_worker=True):
dependencies=[Depends(require("render:write"))],
)
def render(
payload: TemplateContent,
payload: TemplateContent | AutoCollageContent,
request: Request,
idempotency_key: str | None = Header(default=None),
):
@@ -612,6 +632,10 @@ def create_app(settings=None, start_worker=True):
409,
)
path.unlink(missing_ok=True)
db.execute(
"DELETE FROM photo_preferences WHERE source_id='@assets' AND item_id=?",
(asset_id,),
)
db.execute("DELETE FROM asset_metadata WHERE asset_id=?", (asset_id,))
db.execute("DELETE FROM asset_sources WHERE asset_id=?", (asset_id,))
db.execute("DELETE FROM assets WHERE id=?", (asset_id,))
@@ -625,17 +649,31 @@ def create_app(settings=None, start_worker=True):
)
def star_asset(asset_id: str, payload: ImageStar):
with service.lock:
assets.info(asset_id)
db.execute(
with db.transaction() as connection:
current = assets.info(asset_id)
ensure_compatible(payload.starred, current["excluded"])
connection.execute(
"INSERT INTO asset_metadata(asset_id,starred) VALUES(?,?) ON CONFLICT(asset_id) DO UPDATE SET starred=excluded.starred",
(asset_id, int(payload.starred)),
)
db.execute(
connection.execute(
"UPDATE image_retention SET starred=? WHERE (device_id,image_id) IN (SELECT device_id,image_id FROM image_sources WHERE asset_id=?)",
(int(payload.starred), asset_id),
)
return assets.info(asset_id)
@app.put(
"/api/v1/assets/{asset_id}/exclusion",
response_model=Asset,
tags=["Images"],
dependencies=[Depends(require("images:write"))],
)
def exclude_asset(asset_id: str, payload: ImageExclusion):
with service.lock:
assets.info(asset_id)
set_flags(db, "@assets", asset_id, excluded=payload.excluded)
return assets.info(asset_id)
@app.post(
"/api/v1/assets/batch",
tags=["Images"],
@@ -648,6 +686,10 @@ def create_app(settings=None, start_worker=True):
try:
if payload.action == "delete":
delete_asset(id)
elif payload.action in {"exclude", "include"}:
exclude_asset(
id, ImageExclusion(excluded=payload.action == "exclude")
)
else:
star_asset(id, ImageStar(starred=payload.action == "star"))
results.append({"asset_id": id, "status": "completed"})
@@ -769,13 +811,38 @@ def create_app(settings=None, start_worker=True):
source_id, refresh=refresh, target_size=size
)
return {
"items": items[offset : offset + limit],
"items": annotate(db, source_id, items[offset : offset + limit]),
"total": len(items),
"offset": offset,
"limit": limit,
"catalog_limit": service.photo_sources.get(source_id).catalog_limit,
}
@app.put(
"/api/v1/photo-sources/{source_id}/preferences",
tags=["Photo sources"],
dependencies=[Depends(require("admin"))],
)
def source_preference(
source_id: str, payload: PhotoPreference, device_id: str | None = None
):
source = service.photo_sources.get(source_id)
size = (1200, 1600)
if source.orientation == "auto":
size = frame_canvas(
registry.get(device_id or settings.device_id).device().display
)
items = service.photo_sources.catalog(source_id, target_size=size)
if not any(item["id"] == payload.item_id for item in items):
raise FlowError(E.NOT_FOUND, "Photo is not in this source", 404)
return set_flags(
db,
source_id,
payload.item_id,
starred=payload.starred,
excluded=payload.excluded,
)
@app.delete(
"/api/v1/devices/{device_id}/images/{image_id}",
response_model=Accepted,
@@ -831,7 +898,7 @@ def create_app(settings=None, start_worker=True):
return [
service.operation(x["id"])
for x in db.all(
"SELECT id FROM operations WHERE (? OR client_id=?) ORDER BY created_at DESC LIMIT ?",
"SELECT id FROM operations WHERE (? OR client_id=?) AND NOT EXISTS (SELECT 1 FROM hidden_history h WHERE h.operation_id=operations.id) ORDER BY created_at DESC,rowid DESC LIMIT ?",
("admin" in request.state.permissions, request.state.client_id, limit),
)
]
@@ -940,6 +1007,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(
@@ -989,7 +1057,10 @@ def create_app(settings=None, start_worker=True):
dependencies=[Depends(require("history:read"))],
)
def history(limit: int = Query(default=100, ge=1, le=200)):
return db.all("SELECT * FROM history ORDER BY id DESC LIMIT ?", (limit,))
return db.all(
"SELECT * FROM history WHERE NOT EXISTS (SELECT 1 FROM hidden_history h WHERE h.operation_id=history.operation_id) ORDER BY id DESC LIMIT ?",
(limit,),
)
@app.get(
"/api/v1/settings",
@@ -999,7 +1070,36 @@ def create_app(settings=None, start_worker=True):
)
def settings_view():
return SettingsView(
version="0.1.0",
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.28",
mock=settings.mock,
write_enabled=settings.write_enabled,
api_key_configured=bool(settings.api_key)
@@ -1175,9 +1275,11 @@ def create_app(settings=None, start_worker=True):
idempotency_key: str | None = Header(default=None),
):
source = get_job(job_id)["source"]
if source["type"] != "template":
if source["type"] not in ("template", "auto_collage"):
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "Job preview requires a template source", 422
E.UNSUPPORTED_CAPABILITY,
"Job preview requires template or auto-collage content",
422,
)
return submit(
request, "render", source, get_job(job_id)["device_id"], idempotency_key
@@ -1226,7 +1328,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"/>',
@@ -1270,6 +1372,10 @@ def create_app(settings=None, start_worker=True):
"i18n.js",
"swagger-ui-bundle.js",
"swagger-ui.css",
"favicon.svg",
"favicon.ico",
"apple-touch-icon.png",
"logo-symbol.svg",
]:
raise FlowError(E.NOT_FOUND, "Resource not found", 404)
return FileResponse(root / filename)
+121
View File
@@ -0,0 +1,121 @@
"""Paginated history without removing operation receipts used by other services."""
from datetime import UTC, date, datetime, time, timedelta
from zoneinfo import ZoneInfo
from fastapi import Depends, Query, Request
from pydantic import BaseModel, Field
from pigway_frame_flow.core.auth import require
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import Operation, OperationStatus
class HistoryPage(BaseModel):
items: list[Operation]
total: int
page: int
page_size: int
class HistoryDelete(BaseModel):
operation_ids: list[str] = Field(min_length=1, max_length=200)
def install_history(app, service):
db = service.db
@app.get(
"/api/v1/operation-history",
response_model=HistoryPage,
tags=["Operations"],
dependencies=[Depends(require("history:read"))],
)
def list_history(
request: Request,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: OperationStatus | None = None,
action: str = Query("", max_length=80),
device_id: str = Query("", max_length=120),
q: str = Query("", max_length=200),
start: date | None = None,
end: date | None = None,
):
if start and end and start > end:
raise FlowError(
E.INVALID_REQUEST, "Start date must not follow end date", 422
)
clauses = [
"NOT EXISTS (SELECT 1 FROM hidden_history h WHERE h.operation_id=o.id)"
]
args: list = []
if "admin" not in request.state.permissions:
clauses.append("o.client_id=?")
args.append(request.state.client_id)
for column, value in [
("status", status),
("action", action),
("device_id", device_id),
]:
if value:
clauses.append(f"o.{column}=?")
args.append(value)
if q.strip():
clauses.append(
"instr(lower(o.id||' '||o.request_id||' '||o.client_id||' '||coalesce(o.result,'')),lower(?))>0"
)
args.append(q.strip())
for day, operator, offset in [(start, ">=", 0), (end, "<", 1)]:
if day:
boundary = datetime.combine(
day, time(), ZoneInfo("Asia/Shanghai")
) + timedelta(days=offset)
clauses.append(f"o.created_at{operator}?")
args.append(boundary.astimezone(UTC).isoformat())
where = " AND ".join(clauses)
# Keep count and rows consistent if a worker finishes or a deletion occurs.
with db.lock:
total = db.one(
"SELECT count(*) AS total FROM operations o WHERE " + where, args
)["total"]
page = min(page, max(1, (total + page_size - 1) // page_size))
rows = db.all(
"SELECT o.id FROM operations o WHERE "
+ where
+ " ORDER BY o.created_at DESC,o.rowid DESC LIMIT ? OFFSET ?",
[*args, page_size, (page - 1) * page_size],
)
return HistoryPage(
items=[service.operation(row["id"]) for row in rows],
total=total,
page=page,
page_size=page_size,
)
@app.delete(
"/api/v1/operation-history",
tags=["Operations"],
dependencies=[Depends(require("admin"))],
)
def delete_history(payload: HistoryDelete):
ids = list(dict.fromkeys(payload.operation_ids))
placeholders = ",".join("?" for _ in ids)
with db.transaction() as connection:
rows = connection.execute(
f"SELECT id,status FROM operations WHERE id IN ({placeholders})", ids
).fetchall()
if len(rows) != len(ids):
raise FlowError(E.NOT_FOUND, "History record not found", 404)
if any(
row["status"] not in {"COMPLETED", "FAILED", "UNKNOWN"} for row in rows
):
raise FlowError(E.CONFLICT, "Running operations cannot be deleted", 409)
before = connection.total_changes
connection.executemany(
"INSERT OR IGNORE INTO hidden_history(operation_id) VALUES(?)",
[(key,) for key in ids],
)
deleted = connection.total_changes - before
return {"deleted": deleted}
+58 -14
View File
@@ -21,16 +21,22 @@ cookie = APIKeyCookie(name=COOKIE, auto_error=False, scheme_name="AccountSession
def password_hash(password: str) -> str:
salt = secrets.token_hex(16)
digest = hashlib.scrypt(
password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1
password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=5
).hex()
return f"scrypt${salt}${digest}"
return f"scrypt-v2${salt}${digest}"
def verify_password(password: str, encoded: str) -> bool:
try:
_, salt, expected = encoded.split("$")
algorithm, salt, expected = encoded.split("$")
if algorithm not in {"scrypt", "scrypt-v2"}:
return False
actual = hashlib.scrypt(
password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1
password.encode(),
salt=bytes.fromhex(salt),
n=16384,
r=8,
p=5 if algorithm == "scrypt-v2" else 1,
).hex()
return hmac.compare_digest(actual, expected)
except (ValueError, TypeError):
@@ -57,6 +63,7 @@ def seed_accounts(db, config_dir: Path):
db.execute(
"CREATE TABLE IF NOT EXISTS users (username TEXT PRIMARY KEY, password_hash TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1)"
)
db.execute("CREATE UNIQUE INDEX IF NOT EXISTS single_browser_account ON users((1))")
db.execute(
"CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, username TEXT NOT NULL, csrf TEXT NOT NULL, expires_at REAL NOT NULL)"
)
@@ -65,7 +72,13 @@ def seed_accounts(db, config_dir: Path):
)
path = config_dir / "accounts.json"
if path.exists():
for user in json.loads(path.read_text()).get("users", []):
users = json.loads(path.read_text()).get("users", [])
if len(users) > 1:
raise ValueError("Only one browser account is supported")
for user in users:
existing = db.one("SELECT username FROM users LIMIT 1")
if existing and existing["username"] != user["username"]:
raise ValueError("A browser account already exists")
db.execute(
"INSERT OR IGNORE INTO users VALUES(?,?,1)",
(user["username"], user["password_hash"]),
@@ -83,7 +96,7 @@ def session_user(request: Request):
)
if not row or not row["enabled"] or row["expires_at"] <= time.time():
return None
if request.method in {"POST", "PUT", "DELETE"}:
if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
if not hmac.compare_digest(
request.headers.get("X-CSRF-Token", ""), row["csrf"]
):
@@ -94,32 +107,60 @@ def session_user(request: Request):
def install_accounts(app, db):
@app.post("/api/v1/auth/login", response_model=AccountView, tags=["Accounts"])
def login(payload: Login, request: Request, response: Response):
with db.lock:
# Reject login CSRF before checking credentials. Never trust forwarded hosts.
origin = request.headers.get("origin")
if request.headers.get("sec-fetch-site") == "cross-site" or (
origin and origin != str(request.base_url).rstrip("/")
):
raise FlowError(E.FORBIDDEN, "Cross-origin login is not allowed", 403)
identity = hashlib.sha256(
(request.client.host if request.client else "unknown").encode()
).hexdigest()
attempt = db.one("SELECT * FROM login_attempts WHERE identity=?", (identity,))
# Account throttling also applies when an attacker changes source IPs.
identities = (
identity,
"account:" + hashlib.sha256(payload.username.encode()).hexdigest(),
)
attempts = [
db.one("SELECT * FROM login_attempts WHERE identity=?", (key,))
for key in identities
]
attempt = next(
(a for a in attempts if a and a["blocked_until"] > time.time()), None
)
if attempt and attempt["blocked_until"] > time.time():
raise FlowError(
E.RATE_LIMITED, "Too many login attempts; try again later", 429
)
row = db.one(
"SELECT * FROM users WHERE username=? AND enabled=1", (payload.username,)
"SELECT * FROM users WHERE username=? AND enabled=1",
(payload.username,),
)
# Perform a password calculation even for unknown usernames.
fallback = "scrypt$" + "00" * 16 + "$" + "00" * 64
fallback = "scrypt-v2$" + "00" * 16 + "$" + "00" * 64
valid = verify_password(
payload.password, row["password_hash"] if row else fallback
)
if not row or not valid:
for key, previous in zip(identities, attempts, strict=True):
failures = (
attempt["failures"] if attempt and attempt["blocked_until"] == 0 else 0
previous["failures"]
if previous and previous["blocked_until"] == 0
else 0
) + 1
db.execute(
"INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)",
(identity, failures, time.time() + 300 if failures >= 5 else 0),
(key, failures, time.time() + 300 if failures >= 5 else 0),
)
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
db.execute("DELETE FROM login_attempts WHERE identity=?", (identity,))
for key in identities:
db.execute("DELETE FROM login_attempts WHERE identity=?", (key,))
if row["password_hash"].startswith("scrypt$"):
db.execute(
"UPDATE users SET password_hash=? WHERE username=?",
(password_hash(payload.password), row["username"]),
)
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
lifetime = 30 * 86400 if payload.remember else 12 * 3600
db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),))
@@ -187,9 +228,12 @@ def install_accounts(app, db):
user = db.one("SELECT * FROM users WHERE username=?", (session["username"],))
if not verify_password(payload.current_password, user["password_hash"]):
raise FlowError(E.UNAUTHORIZED, "Current password is incorrect", 401)
db.execute(
with db.transaction() as connection:
connection.execute(
"UPDATE users SET password_hash=? WHERE username=?",
(password_hash(payload.new_password), session["username"]),
)
db.execute("DELETE FROM sessions WHERE username=?", (session["username"],))
connection.execute(
"DELETE FROM sessions WHERE username=?", (session["username"],)
)
response.delete_cookie(COOKIE, path="/")
@@ -0,0 +1,78 @@
"""Persistent selection preferences scoped to a library and its stable item ID."""
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
def ensure_compatible(starred, excluded):
if starred and excluded:
raise FlowError(
E.CONFLICT,
"Starred photos cannot be excluded; clear the existing mark first",
409,
)
def flags(db, source_id, item_id):
row = db.one(
"SELECT starred,excluded FROM photo_preferences WHERE source_id=? AND item_id=?",
(source_id, item_id),
)
return {key: bool(row and row[key]) for key in ("starred", "excluded")}
def set_flags(db, source_id, item_id, *, starred=None, excluded=None):
with db.transaction() as connection:
current = flags(db, source_id, item_id)
if source_id == "@assets":
meta = db.one(
"SELECT starred FROM asset_metadata WHERE asset_id=?", (item_id,)
)
current["starred"] = bool(meta and meta["starred"])
ensure_compatible(
current["starred"] if starred is None else starred,
current["excluded"] if excluded is None else excluded,
)
connection.execute(
"INSERT INTO photo_preferences(source_id,item_id,starred,excluded) VALUES(?,?,?,?) ON CONFLICT(source_id,item_id) DO UPDATE SET starred=coalesce(?,starred),excluded=coalesce(?,excluded)",
(
source_id,
item_id,
int(starred or False),
int(excluded or False),
None if starred is None else int(starred),
None if excluded is None else int(excluded),
),
)
return flags(db, source_id, item_id)
def annotate(db, source_id, items):
preferences = {
row["item_id"]: row
for row in db.all(
"SELECT item_id,starred,excluded FROM photo_preferences WHERE source_id=?",
(source_id,),
)
}
return [
{
**item,
**{
key: bool(preferences.get(item["id"], {}).get(key))
for key in ("starred", "excluded")
},
}
for item in items
]
def eligible(db, source_id, items):
excluded = {
row["item_id"]
for row in db.all(
"SELECT item_id FROM photo_preferences WHERE source_id=? AND excluded=1",
(source_id,),
)
}
return [item for item in items if item["id"] not in excluded]
+15 -4
View File
@@ -13,18 +13,29 @@ class CloudLibrary:
def register(self, device_id, image_id, asset_id, operation_id):
operation = self.db.one(
"SELECT payload FROM operations WHERE id=?", (operation_id,)
"SELECT payload,client_id FROM operations WHERE id=?", (operation_id,)
)
payload = json.loads(operation["payload"])
content = payload.get("content", {})
job = self.db.one(
"SELECT job_id FROM job_runs WHERE operation_id=?", (operation_id,)
)
if not job and operation["client_id"] == "scheduler-retry":
job = self.db.one(
"SELECT job_id FROM asset_metadata WHERE asset_id=? AND job_id IS NOT NULL",
(asset_id,),
)
job_config = (
self.db.one("SELECT payload FROM jobs WHERE id=?", (job["job_id"],))
if job
else None
)
source = json.loads(job_config["payload"])["source"] if job_config else content
job_id = (
job["job_id"]
if job
and content.get("type") == "photo_source"
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(
@@ -101,7 +112,7 @@ class CloudLibrary:
return True
def sweep(self):
if time.monotonic() - self.checked < 60:
if self.checked and time.monotonic() - self.checked < 60:
return
self.checked = time.monotonic()
with self.service.lock:
+164
View File
@@ -0,0 +1,164 @@
"""Durable, read-only recovery progress for writes with uncertain outcomes."""
import json
from datetime import UTC, datetime
from uuid import uuid4
from pigway_frame_flow.errors import ErrorCode
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
def observe_device(self, op, *, device=None, error=None):
row = self.db.one("SELECT result FROM operations WHERE id=?", (op["id"],))
result = json.loads(row["result"] or "{}")
observation = {"checked_at": now_iso()}
if device is not None:
observation.update(
image_id=device.current_content.id,
synced=device.current_content.synced,
online=device.status == "online",
heartbeat=device.last_seen_at,
)
else:
observation["read_error"] = {
"code": error.code,
**{
k: v
for k, v in error.details.items()
if k
in ("transport_error", "http_status", "attempts", "retry_exhausted")
},
}
samples = result.get("sync_observations", [])
if samples:
old = samples[-1]
changed = {k: v for k, v in old.items() if k != "checked_at"} != {
k: v for k, v in observation.items() if k != "checked_at"
}
if (
not changed
and (
datetime.now(UTC) - datetime.fromisoformat(old["checked_at"])
).total_seconds()
< 60
):
return observation
samples.append(observation)
result["sync_observations"] = (
[samples[0], *samples[-11:]] if len(samples) > 12 else samples
)
# Evidence must not reset updated_at: that is the sync timeout's clock.
self.db.execute(
"UPDATE operations SET result=? WHERE id=?", (encode(result), op["id"])
)
return observation
def close_superseded_displays(self):
for op in self.db.all(
"SELECT * FROM operations WHERE action='display' AND write_started=1 "
"AND status IN ('SYNCING','UNKNOWN')"
):
result = json.loads(op["result"] or "{}")
if not result.get("image_id") or json.loads(op["payload"]).get(
"options", {}
).get("restore_previous"):
continue
newer = self.db.one(
"SELECT id,result FROM operations WHERE device_id=? AND action='display' "
"AND status='COMPLETED' AND write_started=1 AND created_at>? "
"AND COALESCE(json_extract(payload,'$.options.restore_previous'),0)=0 "
"AND json_extract(result,'$.image_id') IS NOT NULL "
"ORDER BY created_at DESC LIMIT 1",
(op["device_id"], op["created_at"]),
)
if (
not newer
or json.loads(newer["result"])["image_id"] == result["image_id"]
):
continue
# A later confirmed permanent display ends this attempt, but cannot
# prove whether the earlier image was ever shown. Never replay it.
self.service.update(
op["id"],
"FAILED",
error={
"code": ErrorCode.DISPLAY_NOT_CONFIRMED,
"message": "Superseded by a later confirmed display; earlier display unconfirmed",
"request_id": op["request_id"],
"details": {
"superseded_by_operation_id": newer["id"],
"previous_error": json.loads(op["error"] or "null"),
},
},
)
self.db.history(op, "DISPLAY_NOT_CONFIRMED")
self.db.event(
"operation.failed",
{"operation_id": op["id"], "code": ErrorCode.DISPLAY_NOT_CONFIRMED},
)
+1 -1
View File
@@ -90,7 +90,7 @@ class Retention:
)
def sweep(self):
if time.monotonic() - self.checked < 3600:
if self.checked and time.monotonic() - self.checked < 3600:
return
self.checked = time.monotonic()
with self.service.lock:
+268 -16
View File
@@ -5,12 +5,16 @@ import time
from datetime import UTC, datetime, timedelta
from uuid import uuid4
from pigway_frame_flow.core.photo_preferences import eligible
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
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import (
AutoCollageContent,
DisplayRequest,
FutureContent,
ImageContent,
@@ -48,7 +52,10 @@ 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] = {}
# A killed write cannot be replayed safely; pre-write work can resume.
db.execute(
"UPDATE operations SET status='UNKNOWN', updated_at=? WHERE write_started=1 AND status IN ('UPLOADING','RENDERING')",
@@ -81,6 +88,11 @@ class DisplayService:
) from None
for region in plan.regions:
self.assets.path(region.asset_id)
if (
isinstance(request.content, AutoCollageContent)
and request.content.source_id
):
self.photo_sources.get(request.content.source_id)
if isinstance(request.content, PhotoSourceContent):
self.photo_sources.get(request.content.source_id)
if writing and not self.settings.mock and not self.settings.write_enabled:
@@ -104,7 +116,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,),
@@ -141,6 +153,31 @@ class DisplayService:
)
return id
def operation_image(self, device_id, result):
result = dict(result or {})
asset_id = result.get("rendered_asset_id") or result.get("asset_id")
if not asset_id and result.get("image_id") is not None:
# Cloud cleanup removes image_sources; the original upload receipt survives.
row = self.db.one(
"SELECT result FROM operations WHERE device_id=? "
"AND json_extract(result,'$.image_id')=? "
"AND json_extract(result,'$.rendered_asset_id') IS NOT NULL "
"ORDER BY created_at DESC LIMIT 1",
(device_id, result["image_id"]),
)
if row:
original = json.loads(row["result"])
asset_id = original["rendered_asset_id"]
result.setdefault("image_name", original.get("image_name"))
if asset_id:
result.setdefault("asset_id", asset_id)
asset = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
if asset:
result["image_name"] = result.get("image_name") or asset["name"]
if self.assets.path(asset_id).is_file():
result["preview_url"] = f"/api/v1/assets/{asset_id}"
return result or None
def operation(self, id):
op = self.db.one("SELECT * FROM operations WHERE id=?", (id,))
if not op:
@@ -151,19 +188,26 @@ class DisplayService:
device_id=op["device_id"],
created_at=op["created_at"],
updated_at=op["updated_at"],
result=json.loads(op["result"]) if op["result"] else None,
result=self.operation_image(
op["device_id"], json.loads(op["result"]) if op["result"] else None
),
error=json.loads(op["error"]) if op["error"] else None,
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):
if result:
op = self.db.one("SELECT device_id FROM operations WHERE id=?", (id,))
result = self.operation_image(op["device_id"], result)
result.pop("preview_url", None)
row = self.db.one("SELECT result FROM operations WHERE id=?", (id,))
previous = json.loads(row["result"]) if row and row["result"] else {}
if "photo_attempts" in previous:
result = {**result, "photo_attempts": previous["photo_attempts"]}
for key in ("photo_attempts", "upload_intent", "sync_observations"):
if key in previous:
result = {**result, key: previous[key]}
self.db.execute(
"UPDATE operations SET status=?,updated_at=?,result=COALESCE(?,result),error=? WHERE id=?",
(
@@ -174,6 +218,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 (
@@ -190,7 +243,39 @@ 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,
staging=staging,
)
if isinstance(content, ProviderContent):
values = {**content.data, **self.providers.get(content.provider_id)}
return self.content_asset(
@@ -202,11 +287,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,
@@ -227,6 +315,8 @@ class DisplayService:
import random
items = self.photo_sources.catalog(content.source_id, target_size=size)
if not content.item_id:
items = eligible(self.db, content.source_id, items)
candidates = (
[item for item in items if item["id"] == content.item_id]
if content.item_id
@@ -335,7 +425,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"
)
@@ -348,11 +455,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=?",
@@ -403,16 +513,63 @@ 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",
) and self.blocked(op["device_id"]):
continue
payload = json.loads(op["payload"])
if (
op["action"] in ("display", "delete", "restore")
and op["client_id"]
in ("scheduler", "scheduler-retry", "cloud-library", "retention")
and self.db.one(
"SELECT id FROM operations WHERE device_id=? AND action='display' "
"AND status='SYNCING' AND write_started=1 AND updated_at>? LIMIT 1",
(
op["device_id"],
(
datetime.now(UTC)
- timedelta(seconds=self.settings.sync_timeout)
).isoformat(),
),
)
):
# Both upload and deletion update the cloud frame counter.
# Automatic writes must not invalidate a pending display ack.
continue
if op["action"] == "display":
priority = payload["options"]["priority"]
top = self.top(op["device_id"])
@@ -426,7 +583,7 @@ class DisplayService:
"code": exc.code,
"message": exc.message,
"request_id": op["request_id"],
"details": {},
"details": exc.details,
}
self.update(
op["id"], "UNKNOWN" if uncertain else "FAILED", error=error
@@ -490,6 +647,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 (
@@ -528,7 +687,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)
@@ -582,6 +746,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")
@@ -598,7 +769,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(
@@ -614,6 +785,8 @@ class DisplayService:
raise FlowError(
E.CONFLICT, "Temporary override requires an existing image", 409
)
if asset:
self.upload_reconciliation.prepare(id, driver, asset, previous)
self._start_write(id)
if asset:
self.update(id, "UPLOADING")
@@ -631,7 +804,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=?",
@@ -677,15 +849,77 @@ class DisplayService:
)
def sync(self):
with self.lock:
self._sync()
def _sync(self):
# 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!='render-ai' AND write_started=1"
):
expected = json.loads(op["result"] or "{}").get("image_id")
intent = json.loads(op["result"] or "{}").get("upload_intent")
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)
self.recovery.observe_device(op, device=device)
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 as exc:
self.recovery.observe_device(op, error=exc)
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()
observation = self.recovery.observe_device(op, device=device)
target = json.loads(op["result"])["image_id"]
if (
device.current_content.id == target
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"]})
@@ -699,11 +933,13 @@ class DisplayService:
"code": E.WRITE_RESULT_UNKNOWN,
"message": "Frame refresh was not confirmed",
"request_id": op["request_id"],
"details": {},
"details": {"last_device_observation": observation},
},
)
except FlowError:
pass
except FlowError as exc:
self.recovery.observe_device(op, error=exc)
self.recovery.close_superseded_displays()
def expire(self):
if not self.settings.mock and not self.settings.write_enabled:
@@ -809,9 +1045,25 @@ class DisplayService:
self.update(
id,
"COMPLETED" if confirmed else "FAILED",
{"image_id": device.current_content.id},
op["result"],
error=None
if confirmed
else {
"code": E.DISPLAY_NOT_CONFIRMED
if op["action"] == "display"
else E.WRITE_RESULT_UNKNOWN,
"message": "Manually closed unconfirmed write; no write replayed",
"request_id": op["request_id"],
"details": {
"observed_image_id": device.current_content.id,
"previous_error": op["error"],
},
},
)
resolved = True
if confirmed and op["action"] == "display":
self.cloud_library.confirm(id)
self.db.event("operation.completed", {"operation_id": id})
self.db.history(
dict(
id=id,
@@ -0,0 +1,163 @@
"""Resolve missing upload responses using read-only, exact-content evidence."""
import hashlib
import json
from datetime import UTC, datetime, timedelta
from pigway_frame_flow.errors import ErrorCode
from pigway_frame_flow.storage.database import encode, now_iso
class UploadReconciliation:
def __init__(self, service):
self.service = service
def prepare(self, operation_id, driver, asset, previous):
path = self.service.assets.path(asset["asset_id"])
self.service.update(
operation_id,
"RENDERING",
{
"rendered_asset_id": asset["asset_id"],
"upload_intent": {
"name": asset["name"],
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
"size": path.stat().st_size,
"before_ids": [image.image_id for image in driver.images()],
"previous_image_id": previous,
"started_at": now_iso(),
},
},
)
def check(self, op, driver, device):
service = self.service
result = json.loads(op["result"] or "{}")
intent = result.get("upload_intent")
if not intent:
return None
expected = result.get("image_id")
if not expected:
start = datetime.fromisoformat(intent["started_at"])
candidates = []
for image in driver.images():
if (
image.image_id in intent["before_ids"]
or image.name != intent["name"]
):
continue
if image.size_bytes is not None and image.size_bytes != intent["size"]:
continue
if not image.created_at:
continue
try:
created = datetime.fromisoformat(image.created_at)
age = (created - start).total_seconds()
except (ValueError, TypeError):
continue
if 0 <= age <= service.settings.sync_timeout + 300:
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)
if (
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 "{}"))
source = service.db.one(
"SELECT payload FROM asset_sources WHERE asset_id=?",
(result["rendered_asset_id"],),
)
service.db.execute(
"INSERT OR REPLACE INTO image_sources VALUES(?,?,?,?,?)",
(
op["device_id"],
expected,
result["rendered_asset_id"],
op["id"],
source["payload"] if source else encode({"type": "image"}),
),
)
if not service.db.one(
"SELECT image_id FROM managed_cloud_uploads WHERE operation_id=?",
(op["id"],),
):
service.cloud_library.register(
op["device_id"], expected, result["rendered_asset_id"], op["id"]
)
if device.current_content.id == expected and device.current_content.synced:
options = json.loads(op["payload"]).get("options", {})
service.db.execute(
"UPDATE device_state SET managed_image_id=? WHERE device_id=?",
(expected, op["device_id"]),
)
if options.get("restore_previous"):
expires = datetime.fromisoformat(intent["started_at"]) + timedelta(
seconds=options["duration"]
)
service.db.execute(
"INSERT OR IGNORE INTO overrides VALUES(?,?,?,?,?,?)",
(
op["id"],
op["device_id"],
expected,
options["priority"],
expires.isoformat(),
intent["started_at"],
),
)
return expected
elapsed = (
datetime.now(UTC) - datetime.fromisoformat(intent["started_at"])
).total_seconds()
if (
result.get("upload_confirmed")
and not json.loads(op["payload"]).get("options", {}).get("restore_previous")
and elapsed > service.settings.sync_timeout + 300
and device.current_content.id == intent["previous_image_id"]
and device.current_content.synced
):
# Upload is proven; display is not. Two separated stable observations
# close the failed display, without replaying either vendor write.
observed = result.get("unchanged_observed_at")
if not observed:
result["unchanged_observed_at"] = now_iso()
service.update(
op["id"], "UNKNOWN", result, json.loads(op["error"] or "{}")
)
elif (
datetime.now(UTC) - datetime.fromisoformat(observed)
).total_seconds() >= 60:
service.update(
op["id"],
"FAILED",
result,
{
"code": ErrorCode.DISPLAY_NOT_CONFIRMED,
"message": "Upload confirmed; frame remained on previous image",
"request_id": op["request_id"],
"details": {},
},
)
service.db.history(op, "DISPLAY_NOT_CONFIRMED")
service.db.event(
"operation.failed",
{"operation_id": op["id"], "code": ErrorCode.DISPLAY_NOT_CONFIRMED},
)
elif result.get("unchanged_observed_at"):
result.pop("unchanged_observed_at")
service.update(op["id"], "UNKNOWN", result, json.loads(op["error"] or "{}"))
return expected
+188 -31
View File
@@ -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 {
@@ -51,49 +109,148 @@ class SafeTransport:
timeout=(5, 120 if endpoint == "/upload" else 30),
allow_redirects=False,
)
except requests.RequestException:
except requests.RequestException as exc:
# Never retain exception text or a URL: binary URLs contain the key.
category = next(
(
label
for kind, label in (
(requests.ConnectTimeout, "connect_timeout"),
(requests.ReadTimeout, "read_timeout"),
(requests.exceptions.SSLError, "tls_error"),
(requests.ConnectionError, "connection_error"),
(requests.Timeout, "timeout"),
)
if isinstance(exc, kind)
),
"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,
"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
try:
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 code == E.RATE_LIMITED else 502,
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
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
if (
not isinstance(value, dict)
or value.get("success") is False
or ("message" in value and "id" not in value)
):
raise ValueError()
return value
except ValueError:
raise FlowError(
E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE,
"Invalid device cloud response",
502,
) from None
details={
"failure_kind": "invalid_response",
"retryable": not write,
"write_outcome": "unknown" if write else "read_only",
},
)
return value
finally:
response.close()
class KokonnaClient:
@@ -118,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"]
+3 -1
View File
@@ -11,6 +11,7 @@ class ErrorCode(StrEnum):
UNSUPPORTED_CAPABILITY = "UNSUPPORTED_CAPABILITY"
RENDER_FAILED = "RENDER_FAILED"
UPLOAD_FAILED = "UPLOAD_FAILED"
DISPLAY_NOT_CONFIRMED = "DISPLAY_NOT_CONFIRMED"
WRITE_RESULT_UNKNOWN = "WRITE_RESULT_UNKNOWN"
RATE_LIMITED = "RATE_LIMITED"
INTERNAL_ERROR = "INTERNAL_ERROR"
@@ -22,6 +23,7 @@ class ErrorCode(StrEnum):
class FlowError(Exception):
def __init__(self, code: ErrorCode, message: str, status: int = 400):
def __init__(self, code: ErrorCode, message: str, status: int = 400, details=None):
self.code, self.message, self.status = code, message, status
self.details = details or {}
super().__init__(message)
@@ -146,6 +146,7 @@ def install_ai_images(app, service):
}
).encode(),
limit=service.settings.max_bytes,
timeout=180,
)
result = json.loads(raw)["data"]
if len(result) != 1:
@@ -0,0 +1,85 @@
"""Random selection and layout shared by preview, display and scheduled jobs."""
import random
from io import BytesIO
from PIL import Image, ImageOps
from pigway_frame_flow.core.photo_preferences import eligible
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.extensions.layout import LayoutInput, LayoutStudio
from pigway_frame_flow.models import TemplateContent
def prepare(service, content, device_id=None):
rng = random.SystemRandom()
ids = []
hashes = set()
if content.source_id:
from pigway_frame_flow.extensions.quality import passes, photo_quality
source = service.photo_sources.get(content.source_id)
items = eligible(
service.db,
content.source_id,
service.photo_sources.catalog(content.source_id),
)
candidates = rng.sample(items, min(len(items), content.count * 5))
for item in candidates:
try:
data, name, _ = service.photo_sources.read(
content.source_id, item["id"]
)
quality = photo_quality(data)
if not passes(quality, source) or quality["sha256"] in hashes:
continue
if source.avoid_duplicates and quality["fingerprint"] in hashes:
continue
hashes.update([quality["sha256"], quality["fingerprint"]])
with Image.open(BytesIO(data)) as image:
photo = ImageOps.exif_transpose(image).convert("RGB")
photo.thumbnail((2400, 2400))
output = BytesIO()
photo.save(output, "JPEG", quality=92)
asset = service.assets.put(output.getvalue(), name, "cache")
if asset["asset_id"] not in ids:
ids.append(asset["asset_id"])
except (OSError, ValueError):
continue
if len(ids) == content.count:
break
else:
rows = service.db.all(
"SELECT id AS asset_id FROM assets WHERE kind='images' AND NOT EXISTS (SELECT 1 FROM photo_preferences p WHERE p.source_id='@assets' AND p.item_id=assets.id AND p.excluded=1) ORDER BY created_at DESC LIMIT 10000"
)
ids = rng.sample(
[row["asset_id"] for row in rows], min(content.count, len(rows))
)
if len(ids) != content.count:
raise FlowError(
E.INVALID_REQUEST, "Not enough distinct photos for requested count", 422
)
values = content.model_dump(exclude={"type", "source_id", "count", "profile"})
if content.orientation == "auto":
from pigway_frame_flow.renderers.frame_geometry import frame_canvas
display = (
service.registry.get(device_id or service.settings.device_id)
.device()
.display
)
width, height = frame_canvas(display)
values["orientation"] = "landscape" if width > height else "portrait"
plan = LayoutStudio(service.renderer).plan(
LayoutInput(
asset_ids=ids,
**values,
)
)
return TemplateContent(
type="template",
template="poster" if content.style == "poster" else "collage",
data={"layout": plan.model_dump()},
profile=content.profile,
)
+2 -1
View File
@@ -43,6 +43,7 @@ class LocalRetention:
"SELECT payload FROM jobs",
"SELECT payload FROM template_config",
"SELECT payload FROM operations WHERE status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')",
"SELECT payload FROM app_config WHERE key LIKE 'upload-retry:%'",
]:
payloads += [row["payload"] for row in db.all(query)]
for payload in payloads:
@@ -86,7 +87,7 @@ class LocalRetention:
}
def sweep(self, force=False, dry_run=False):
if not force and time.monotonic() - self.last < 3600:
if not force and self.last and time.monotonic() - self.last < 3600:
return {}
if not dry_run:
self.last = time.monotonic()
+7 -5
View File
@@ -39,12 +39,13 @@ class Webhooks:
db = self.service.db
for sub in db.all("SELECT * FROM webhook_subscriptions WHERE enabled=1"):
types = json.loads(sub["event_types"])
for event in db.all(
"SELECT * FROM events e WHERE created_at>=? AND NOT EXISTS (SELECT 1 FROM webhook_deliveries d WHERE d.event_id=e.id AND d.subscription_id=?) ORDER BY created_at LIMIT 500",
(sub["created_at"], sub["id"]),
):
if event["event_type"] not in types:
if not types:
continue
placeholders = ",".join("?" for _ in types)
for event in db.all(
f"SELECT * FROM events e WHERE created_at>=? AND event_type IN ({placeholders}) AND NOT EXISTS (SELECT 1 FROM webhook_deliveries d WHERE d.event_id=e.id AND d.subscription_id=?) ORDER BY created_at LIMIT 500",
(sub["created_at"], *types, sub["id"]),
):
db.execute(
"INSERT OR IGNORE INTO webhook_deliveries VALUES(?,?,'QUEUED',0,?,NULL)",
(sub["id"], event["id"], now_iso()),
@@ -137,6 +138,7 @@ def install_webhooks(app, service):
allowed = {
"operation.completed",
"operation.failed",
"operation.blocked",
"display.changed",
"device.online",
"device.offline",
+65 -7
View File
@@ -2,7 +2,7 @@ from datetime import datetime
from enum import StrEnum
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, StrictBool, model_validator
from pigway_frame_flow.errors import ErrorCode
@@ -68,6 +68,19 @@ class PhotoSourceContent(StrictModel):
profile: Profile = Profile.color
class AutoCollageContent(StrictModel):
type: Literal["auto_collage"]
source_id: str | None = Field(default=None, pattern=r"^[a-z0-9-]{1,60}$")
count: int = Field(default=4, ge=1, le=12)
title: str = Field(default="", max_length=100)
message: str = Field(default="", max_length=500)
orientation: Literal["auto", "portrait", "landscape"] = "auto"
style: Literal["grid", "hero", "poster"] = "grid"
use_ai: bool = True
prompt: str = Field(default="", max_length=1500)
profile: Profile = Profile.color
class ProviderContent(StrictModel):
type: Literal["provider"]
provider_id: str = Field(pattern=r"^[a-z0-9-]{1,60}$")
@@ -86,6 +99,7 @@ Content = Annotated[
| URLContent
| TemplateContent
| PhotoSourceContent
| AutoCollageContent
| ProviderContent
| FutureContent,
Field(discriminator="type"),
@@ -154,6 +168,8 @@ class ErrorResponse(BaseModel):
class OperationResult(BaseModel):
image_name: str | None = None
preview_url: str | None = None
cover_id: int | None = None
photo_attempts: list[dict[str, str]] = Field(default_factory=list)
image_id: int | None = None
@@ -163,7 +179,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
@@ -241,7 +267,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
@@ -309,7 +338,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
@@ -322,7 +368,7 @@ class SettingsView(BaseModel):
class Health(BaseModel):
status: str
version: str = "0.1.0"
version: str = "0.1.28"
class Readiness(BaseModel):
@@ -331,6 +377,8 @@ class Readiness(BaseModel):
class Asset(BaseModel):
excluded: bool = False
source_path: str | None = None
starred: bool = False
origin: Literal["manual", "task", "external", "template", "unknown"] = "unknown"
job_id: str | None = None
@@ -351,8 +399,8 @@ class RestoreRequest(StrictModel):
class Version(BaseModel):
product: str = "Pigway Frame Flow"
version: str = "0.1.0"
product: str = "PIGWay Frame Flow"
version: str = "0.1.28"
api_version: str = "v1"
commit: str = "development"
build_time: str = "unknown"
@@ -455,10 +503,20 @@ class WidgetState(StrictModel):
writable: Literal[False] = False
class ImageExclusion(StrictModel):
excluded: StrictBool
class PhotoPreference(StrictModel):
item_id: str = Field(min_length=1, max_length=4096)
starred: StrictBool | None = None
excluded: StrictBool | None = None
class ImageStar(StrictModel):
starred: bool
starred: StrictBool
class AssetBatch(StrictModel):
asset_ids: list[str] = Field(min_length=1, max_length=100)
action: Literal["delete", "star", "unstar"]
action: Literal["delete", "star", "unstar", "exclude", "include"]
+164 -7
View File
@@ -2,6 +2,7 @@
import base64
import json
import logging
import os
import posixpath
import random
@@ -14,6 +15,7 @@ from xml.etree import ElementTree
from pydantic import Field
from pigway_frame_flow.core.photo_preferences import eligible
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import StrictModel
@@ -27,8 +29,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 +41,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)
@@ -54,9 +61,12 @@ class PhotoSourceView(PhotoSourceInput):
class PhotoItem(StrictModel):
starred: bool = False
excluded: bool = False
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 +87,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 +117,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 +162,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 +206,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):
@@ -185,7 +223,9 @@ class PhotoSources:
self.db.execute(
"DELETE FROM app_config WHERE key=?", ("photo_source:" + source_id,)
)
self.db.execute("DELETE FROM photo_preferences WHERE source_id=?", (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 +304,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 +321,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 +331,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 +420,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 +453,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,15 +574,22 @@ 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)
if not item_id:
items = eligible(self.db, source_id, items)
if not items:
raise FlowError(
E.NOT_FOUND, "Photo source contains no supported images", 404
E.NOT_FOUND, "Photo source contains no eligible images", 404
)
item = (
next((x for x in items if x["id"] == item_id), None)
@@ -526,6 +639,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 +666,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)
+188 -2
View File
@@ -119,12 +119,23 @@ class SynologyPhotos:
result = json.loads(raw)
if not result.get("success"):
code = result.get("error", {}).get("code")
if api == "SYNO.API.Auth" or code in (105, 106, 107, 119):
if api == "SYNO.API.Auth" or code in (106, 107, 119):
if api != "SYNO.API.Auth":
self.disconnect()
raise FlowError(
E.CONFIG_REQUIRED, "Reconnect Synology Photos account", 409
)
if code in (102, 103, 104):
raise FlowError(
E.UNSUPPORTED_CAPABILITY,
"This NAS Photos API or method is unavailable",
)
if code == 105:
raise FlowError(
E.FORBIDDEN,
"Photos account cannot access the selected space",
403,
)
raise FlowError(
E.CLOUD_UNAVAILABLE, "Synology Photos query was rejected", 502
)
@@ -141,7 +152,7 @@ class SynologyPhotos:
sid = json.loads(self.path.read_text())["sid"]
if not isinstance(sid, str) or not sid:
raise ValueError()
except (OSError, ValueError, KeyError):
except (OSError, ValueError, KeyError, TypeError):
raise FlowError(
E.CONFIG_REQUIRED, "Connect Synology Photos account first", 409
) from None
@@ -269,3 +280,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()
+23 -5
View File
@@ -1,5 +1,6 @@
import base64
import ipaddress
import json
import socket
from io import BytesIO
from urllib.parse import urlsplit
@@ -7,6 +8,7 @@ from uuid import uuid4
from PIL import Image, ImageFilter, ImageOps, ImageStat
from pigway_frame_flow.core.photo_preferences import flags
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.storage.database import now_iso
@@ -97,7 +99,7 @@ class Assets:
"task"
if job
else "template"
if content.get("type") == "template"
if content.get("type") in ("template", "auto_collage")
else "manual"
if operation["client_id"].startswith("user:")
else "external"
@@ -115,9 +117,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,8 +135,15 @@ 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"]),
"excluded": flags(self.db, "@assets", asset_id)["excluded"],
"origin": meta["origin"] if meta else "unknown",
"job_id": meta["job_id"] if meta else None,
"job_name": meta["job_name"] if meta else None,
@@ -161,6 +178,7 @@ class Assets:
body=None,
limit=None,
accepted_status=(200,),
timeout=10,
):
limit = limit or self.settings.max_bytes
parsed = urlsplit(url)
@@ -193,12 +211,12 @@ class Assets:
class PinnedHTTPS(http.client.HTTPSConnection):
def connect(self):
sock = socket.create_connection((ip, 443), timeout=10)
sock = socket.create_connection((ip, 443), timeout=timeout)
self.sock = ssl.create_default_context().wrap_socket(
sock, server_hostname=parsed.hostname
)
connection = PinnedHTTPS(parsed.hostname, timeout=10)
connection = PinnedHTTPS(parsed.hostname, timeout=timeout)
try:
connection.request(
method,
+3 -3
View File
@@ -41,7 +41,7 @@ def render_captioned_photo(
draw = ImageDraw.Draw(canvas)
values = [
" ".join(str(caption.get(key, "")).split())
for key in ("title", "date", "place")
for key in ("date", "people", "title", "place")
]
text = " · ".join(value for value in values if value)
font_size = layout.font_size
@@ -51,8 +51,8 @@ def render_captioned_photo(
font_size -= 2
font = font_loader(font_size)
# Shorten the title before touching the date/location. Never wrap the band.
while draw.textlength(text, font=font) > available and len(values[0]) > 2:
values[0] = values[0].rstrip("…")[:-1] + "…"
while draw.textlength(text, font=font) > available and len(values[2]) > 2:
values[2] = values[2].rstrip("…")[:-1] + "…"
text = " · ".join(value for value in values if value)
while draw.textlength(text, font=font) > available and len(text) > 2:
text = text.rstrip("…")[:-1] + "…"
+31 -2
View File
@@ -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,10 +149,12 @@ 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(),),
):
try:
job = JobInput.model_validate_json(row["payload"])
slot = row["next_run"]
if self.db.one(
@@ -156,10 +164,14 @@ class Scheduler:
continue
# Skip overdue tasks (>5 minutes): avoid an unexpected artwork
# overwrite after downtime. One-time tasks are retired too.
late = (current - datetime.fromisoformat(slot)).total_seconds() > 300
late = (
current - datetime.fromisoformat(slot)
).total_seconds() > 300
operation_id = None
if not late:
action = "restore" if job.source.type == "restore" else "display"
action = (
"restore" if job.source.type == "restore" else "display"
)
payload = (
{}
if isinstance(job.source, RestoreContent)
@@ -174,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,
@@ -182,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(
@@ -203,6 +223,12 @@ class Scheduler:
row["id"],
),
)
except Exception:
# Keep this slot pending for retry or the existing overdue policy.
# A single device must not prevent other jobs or queue consumption.
logging.getLogger("frame-flow").error(
"Scheduled job failed; details suppressed"
)
for row in self.db.all(
"SELECT * FROM playlists WHERE next_run<=?", (current.isoformat(),)
):
@@ -269,6 +295,9 @@ class Scheduler:
def run(self):
while not self.stop_event.is_set():
try:
# Resolve late sync before submitting due jobs: a write lock
# otherwise makes tick fail before reconciliation can run.
self.service.sync()
self.tick()
self.service.expire()
self.service.cloud_library.sweep()
@@ -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),
},
}
+11
View File
@@ -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
+3
View File
@@ -24,6 +24,7 @@ class Database:
PRAGMA busy_timeout=5000;
PRAGMA synchronous=FULL;
CREATE TABLE IF NOT EXISTS managed_cloud_uploads (device_id TEXT, image_id INTEGER, asset_id TEXT, job_id TEXT, operation_id TEXT, uploaded_at TEXT, confirmed_at TEXT, cleanup_operation TEXT, PRIMARY KEY(device_id,image_id));
CREATE TABLE IF NOT EXISTS photo_preferences (source_id TEXT NOT NULL, item_id TEXT NOT NULL, starred INTEGER NOT NULL DEFAULT 0, excluded INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(source_id,item_id));
CREATE TABLE IF NOT EXISTS asset_metadata (asset_id TEXT PRIMARY KEY, starred INTEGER NOT NULL DEFAULT 0, origin TEXT NOT NULL DEFAULT 'unknown', job_id TEXT, job_name TEXT);
CREATE TABLE IF NOT EXISTS image_retention (device_id TEXT NOT NULL, image_id INTEGER NOT NULL, starred INTEGER NOT NULL DEFAULT 0, expires_at TEXT, upload_operation TEXT, cleanup_operation TEXT, PRIMARY KEY(device_id,image_id));
CREATE TABLE IF NOT EXISTS photo_dimensions (path TEXT PRIMARY KEY, mtime INTEGER NOT NULL, bytes INTEGER NOT NULL, width INTEGER NOT NULL, height INTEGER NOT NULL);
@@ -31,6 +32,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,
@@ -44,6 +46,7 @@ class Database:
last_run TEXT, last_result TEXT);
CREATE TABLE IF NOT EXISTS job_runs (job_id TEXT, scheduled_at TEXT,
operation_id TEXT, PRIMARY KEY(job_id,scheduled_at));
CREATE TABLE IF NOT EXISTS hidden_history (operation_id TEXT PRIMARY KEY);
CREATE TABLE IF NOT EXISTS history (id INTEGER PRIMARY KEY, client_id TEXT,
request_id TEXT, operation_id TEXT, device_id TEXT, action TEXT,
result TEXT, timestamp TEXT);
+97 -33
View File
@@ -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;}
const icons={studio:'▦',connections:'↔',dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',automation:'◷',playlists:'▤',history:'↺',settings:'⚙'};
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmt=v=>v?new Date(v).toLocaleString(state.lang==='zh'?'zh-CN':'en-GB',{timeZone:'Asia/Shanghai'}):'—';
const padTime=(hour,minute)=>`${String(hour).padStart(2,'0')}:${String(minute).padStart(2,'0')}`;
function normaliseSchedule(schedule){const copy={...schedule};if(copy.type!=='cron')return copy;const match=/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+(\*|1-5|6,0|0,6)$/.exec(copy.cron||'');if(!match)return copy;const type=match[3]==='*'?'daily':match[3]==='1-5'?'weekdays':'weekends';return {type,time:padTime(match[2],match[1])};}
function scheduleTypeLabel(type){if(type!=='cron')return t(type);return state.lang==='zh'?'高级规则(Cron)':'Advanced rule (Cron)';}
function scheduleSummary(schedule){const value=normaliseSchedule(schedule);if(['daily','weekdays','weekends'].includes(value.type))return `${scheduleTypeLabel(value.type)} · ${value.time}(${state.lang==='zh'?'上海时间':'Shanghai time'})`;if(value.type==='cron')return `${scheduleTypeLabel('cron')} · ${value.cron}`;return `${scheduleTypeLabel(value.type)} · ${fmt(value.at)}`;}
function scheduleHint(type){if(type==='cron')return state.lang==='zh'?'仅在需要特殊规则时使用;例如“每天 08:00”请选择“每天”。':'Use only for special rules; choose Daily for a regular time.';if(['date','temporary'].includes(type))return state.lang==='zh'?'选择一次性执行的日期和时间。':'Choose the date and time for this one-time task.';return state.lang==='zh'?'选择执行时间(上海时区)。':'Choose a time in Shanghai time.';}
function normaliseSchedule(schedule){const copy={...schedule};if(copy.type==='interval')return {type:copy.interval_minutes%60===0?'hours':'minutes',every:copy.interval_minutes%60===0?copy.interval_minutes/60:copy.interval_minutes};if(copy.type!=='cron')return copy;const match=/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+(\*|1-5|6,0|0,6)$/.exec(copy.cron||'');if(!match)return copy;const type=match[3]==='*'?'daily':match[3]==='1-5'?'weekdays':'weekends';return {type,time:padTime(match[2],match[1])};}
function scheduleTypeLabel(type){if(type==='hours')return state.lang==='zh'?'每隔若干小时':'Every N hours';if(type==='minutes')return state.lang==='zh'?'每隔若干分钟':'Every N minutes';if(type!=='cron')return t(type);return state.lang==='zh'?'高级规则(Cron)':'Advanced rule (Cron)';}
function scheduleSummary(schedule){const value=normaliseSchedule(schedule);if(['hours','minutes'].includes(value.type))return state.lang==='zh'?`每 ${value.every} ${value.type==='hours'?'小时':'分钟'}`:`Every ${value.every} ${value.type}`;if(['daily','weekdays','weekends'].includes(value.type))return `${scheduleTypeLabel(value.type)} · ${value.time}(${state.lang==='zh'?'上海时间':'Shanghai time'})`;if(value.type==='cron')return `${scheduleTypeLabel('cron')} · ${value.cron}`;return `${scheduleTypeLabel(value.type)} · ${fmt(value.at)}`;}
function scheduleHint(type){if(['hours','minutes'].includes(type))return state.lang==='zh'?'从保存起等待一个间隔后首次执行,此后按间隔重复;不是图片显示时长。':'First runs one interval after saving, then repeats. This is separate from display duration.';if(type==='cron')return state.lang==='zh'?'仅在需要特殊规则时使用;例如“每天 08:00”请选择“每天”。':'Use only for special rules; choose Daily for a regular time.';if(['date','temporary'].includes(type))return state.lang==='zh'?'选择一次性执行的日期和时间。':'Choose the date and time for this one-time task.';return state.lang==='zh'?'选择执行时间(上海时区)。':'Choose a time in Shanghai time.';}
const buttons=(id,text,primary=false)=>`<button type="button" id="${id}" class="${primary?'primary':''}">${t(text)}</button>`;
let toastTimer;
function toast(message){$('#toast').textContent=message;$('#toast').style.display='block';clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('#toast').style.display='none',6000);}
@@ -20,28 +26,72 @@ 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 operationResultHTML(o){
const r=o.result||{},parts=[];
if(r.image_name)parts.push(`<div>${esc(r.image_name)}</div>`);
if(r.image_id!=null)parts.push(`<small>${t('cloudImageId')}: ${esc(r.image_id)}</small>`);
else if(r.asset_id||r.rendered_asset_id)parts.push(`<small>${t('localAssetId')}: ${esc(r.asset_id||r.rendered_asset_id)}</small>`);
if(o.error)parts.push(`<div class="history-status bad">${esc(operationErrorMessage(o.error))}</div>`);
return parts.join('')||'—';
}
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 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 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 +109,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 +126,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 class="field"><label for="confirm-password">${t('confirmPassword')}</label><input id="confirm-password" type="password" minlength="8" required autocomplete="new-password"></div></div><button type="submit">${t('changePassword')}</button>`;section.append(form);const confirm=$('#confirm-password');const validate=()=>confirm.setCustomValidity(confirm.value!==$('#new-password').value?t('passwordMismatch'):'');confirm.oninput=validate;$('#new-password').oninput=validate;form.onsubmit=busy(async e=>{e.preventDefault();validate();if(!form.reportValidity())return;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 data=await api('/api/v1/operation-history?'+historyQuery());if(epoch!==state.epoch)return;state.historyPageData=data;state.historyPage=data.page;const ops=data.items;$('#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 data-operation-id="${esc(o.operation_id)}"><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>${operationResultHTML(o)}</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 +165,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();}));
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+33 -19
View File
@@ -2,23 +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'));
}
@@ -27,32 +29,44 @@ 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=`<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 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":"…"}'></textarea></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(){
const sources=await api('/api/v1/photo-sources');const card=document.createElement('section');card.className='card auto-collage';
card.innerHTML=`<h2>${tr('自动拼图','Automatic collage')}</h2><p class="hint">${tr('选择照片来源和数量,系统每次随机选取不同照片,并自动排版生成一张图片。无需手动拼接。预览不会上传或切换画框。','Choose a photo source and count. Each run randomly selects distinct photos and automatically creates one composition. Preview never uploads to or changes the frame.')}</p><form id="auto-collage-form"><div class="form-grid"><div class="field"><label for="collage-source">${tr('照片来源','Photo source')}</label><select id="collage-source"><option value="">${tr('本地手动上传照片','Locally uploaded photos')}</option>${sources.filter(x=>x.enabled).map(x=>`<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label for="collage-count">${tr('拼图数量(1–12)','Photo count (1–12)')}</label><input id="collage-count" type="number" min="1" max="12" value="4" required></div></div><div class="field"><label for="collage-title">${tr('标题(可选)','Title (optional)')}</label><input id="collage-title" maxlength="100"></div><div class="field"><label for="collage-prompt">${tr('排版要求(可选)','Layout instructions (optional)')}</label><textarea id="collage-prompt" maxlength="1500"></textarea></div><label class="check-row"><input id="collage-ai" type="checkbox" checked>${tr('AI 自动排版','AI layout')}</label><p class="hint">${tr('AI 需先在连接中心配置,只接收尺寸与文字,不发送照片。未配置时可取消勾选,使用自动网格。','Configure AI in Connections first. Only dimensions and text are sent, never photos. Uncheck AI to use an automatic grid.')}</p><div class="actions"><button type="submit" class="primary">${tr('随机选图并生成预览','Pick photos and create preview')}</button><button id="collage-job" type="button">${tr('创建自动化任务','Create automation')}</button></div></form><div id="collage-preview"></div><div class="actions"><button id="collage-display" hidden>${t('display')}</button></div>`;
$('#main').prepend(card);if(sources.some(x=>x.enabled))$('#collage-source').value=sources.find(x=>x.enabled).id;let asset=null;
$('#auto-collage-form').oninput=()=>{asset=null;$('#collage-display').hidden=true;};
const content=()=>({type:'auto_collage',source_id:$('#collage-source').value||null,count:Number($('#collage-count').value),title:$('#collage-title').value,prompt:$('#collage-prompt').value,use_ai:$('#collage-ai').checked});
$('#auto-collage-form').onsubmit=busy(async e=>{e.preventDefault();asset=null;$('#collage-display').hidden=true;$('#collage-preview').textContent=t('previewLoading');try{const id=await accepted('/api/v1/render',content());const op=await waitOperation(id);asset=op.result;$('#collage-preview').innerHTML='<img class="preview" id="collage-image" alt="Collage">';await blobImage($('#collage-image'),asset.url);$('#collage-display').hidden=false;$('#collage-display').disabled=!liveWriteAllowed();}catch(error){$('#collage-preview').textContent=error.message;}});
bind('collage-display',()=>displayDialog({type:'image',asset_id:asset.asset_id}));
bind('collage-job',async()=>{if(!$('#auto-collage-form').reportValidity())return;await api('/api/v1/jobs',{method:'POST',body:{device_id:state.device,name:$('#collage-title').value||tr('自动随机拼图','Automatic random collage'),enabled:false,schedule:{type:'daily',time:'08:00'},source:content(),display_duration:0,restore_previous:false,priority:50}});await navigate('automation');toast(tr('任务已创建并暂停;编辑时间后启用。每次执行重新随机选图。','Created paused. Edit the schedule, then enable. Each run picks new photos.'));});
const manual=document.createElement('details');manual.className='card manual-collage';manual.innerHTML=`<summary>${tr('高级:手动选图与调整布局','Advanced: manual photos and layout')}</summary>`;const columns=$('#main>.columns');columns.before(manual);manual.append(columns);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

+69 -21
View File
@@ -1,7 +1,9 @@
'use strict';
// Page features share the same authenticated API and display operation pipeline.
async function currentPanel(container){
const epoch=state.epoch;
const content=await api('/api/v1/devices/'+state.device+'/current-content');
if(epoch!==state.epoch||!container.isConnected)return content;
state.currentDisplay=content;
const known=content.source_known&&content.source?.type==='template';
container.innerHTML=`<h3>${t('currentContent')}</h3><p>#${esc(content.image_id??'—')} · ${t(content.synced?'synced':'syncing')}</p><p>${known?`${t('currentTemplate')}: <strong>${esc(content.source.template)}</strong>`:content.source_known?t('sourceKnown'):t('sourceUnknown')}</p>${known?buttons('edit-current-template','editCurrent'):''}${content.overrides.length?`<div class="notice"><strong>${t('temporaryDisplay')}</strong>${content.overrides.map(x=>`<p>#${esc(x.image_id)} · ${t('priority')}: ${x.priority} · ${fmt(x.expires_at)}</p>`).join('')}</div>`:''}`;
@@ -12,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(){
@@ -26,34 +28,42 @@ 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-exclude">${t('excludePhoto')}</button><button id="asset-batch-include">${t('includePhoto')}</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')+' '+t('excludePhotoHelp');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=controls.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','exclude','include','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));star.disabled=Boolean(item.excluded);star.title=item.excluded?t('clearExclusionFirst'):'';card.querySelector('.actions').append(star);const exclude=document.createElement('button');exclude.textContent=t(item.excluded?'includePhoto':'excludePhoto');exclude.setAttribute('aria-pressed',String(Boolean(item.excluded)));exclude.disabled=Boolean(item.starred);exclude.title=item.starred?t('clearStarFirst'):'';card.querySelector('.actions').append(exclude);exclude.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/exclusion',{method:'PUT',body:{excluded:!item.excluded}});await navigate();});if(item.starred){const kept=document.createElement('small');kept.className='photo-mark';kept.textContent=t('photoStarred');card.querySelector('h3').after(kept);}if(item.excluded){const badge=document.createElement('small');badge.className='excluded-photo';badge.textContent=t('photoExcluded');card.querySelector('h3').after(badge);}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(state.tab==='local'?'downloadShort':'download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local')card.querySelector('.actions').append(card.querySelector('[data-delete]'));});
if(state.tab==='local'){
const updateMarks=()=>{const selected=[...grid.querySelectorAll('.asset-select:checked')].map(box=>state.gallery.find(item=>item.asset_id===box.dataset.asset));const starred=selected.some(item=>item?.starred),excluded=selected.some(item=>item?.excluded);$('#asset-batch-star').disabled=!selected.length||excluded;$('#asset-batch-star').title=excluded?t('clearExclusionFirst'):'';$('#asset-batch-exclude').disabled=!selected.length||starred;$('#asset-batch-exclude').title=starred?t('clearStarFirst'):'';};
grid.querySelectorAll('.asset-select').forEach(box=>box.onchange=updateMarks);
for(const id of ['asset-all','asset-invert']){const previous=$('#'+id).onclick;$('#'+id).onclick=event=>{previous(event);updateMarks();};}updateMarks();
}
$('#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);});});}
historyControls();
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')];rows.forEach(row=>{const operationId=row.dataset.operationId;if(!operationId)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);const op=state.historyPageData?.items.find(x=>x.operation_id===operationId);if(op&&['COMPLETED','FAILED','UNKNOWN'].includes(op.status)){const remove=document.createElement('button');remove.textContent=t('delete');remove.className='danger';remove.onclick=()=>deleteHistory([operationId]);actions.append(remove);}button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+operationId);showModal(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(operationActionLabel(current.action))}</p>${operationResultHTML(current)}<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?.preview_url||current.result?.url)?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);if(current.result?.preview_url||current.result?.url)await blobImage($('#operation-preview'),current.result.preview_url||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(job.source.type==='template'){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);});}});
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);});}});
}
async function jobSourceEditor(job){
const raw=$('#job-source');const advanced=document.createElement('details');advanced.innerHTML=`<summary>${t('advanced')}</summary>`;raw.parentElement.before(advanced);advanced.append(raw.parentElement);
const box=document.createElement('div');advanced.before(box);let source=structuredClone(job.source);let images=[],assets=[],photoSources=[];try{[images,assets,photoSources]=await Promise.all([api('/api/v1/devices/'+state.device+'/images'),api('/api/v1/assets'),api('/api/v1/photo-sources')]);}catch(e){toast(e.message);}if(!raw.isConnected)return;
box.innerHTML=`<div class="field"><label for="job-content-type">${t('sourceType')}</label><select id="job-content-type">${['template','image','image_url','photo_source','restore'].map(k=>`<option value="${k}">${k==='template'?t('template'):k==='image'?t('imageChoice'):k==='restore'?t('restoreSource'):k==='photo_source'?t('photoSources'):t('imageURL')}</option>`).join('')}</select></div><div id="job-content-fields"></div>`;
box.innerHTML=`<div class="field"><label for="job-content-type">${t('sourceType')}</label><select id="job-content-type">${['template','image','image_url','photo_source','auto_collage','restore'].map(k=>`<option value="${k}">${k==='auto_collage'?(state.lang==='zh'?'自动随机拼图':'Automatic collage'):k==='template'?t('template'):k==='image'?t('imageChoice'):k==='restore'?t('restoreSource'):k==='photo_source'?t('photoSources'):t('imageURL')}</option>`).join('')}</select></div><div id="job-content-fields"></div>`;
$('#job-content-type').value=source.type;const old=$('#job-template')?.parentElement;if(old)old.remove();
function update(){raw.value=JSON.stringify(source,null,2);}
function fields(){const type=$('#job-content-type').value;const panel=$('#job-content-fields');if(type==='restore'){source={type};panel.innerHTML='';$('#job-restore').checked=false;$('#job-duration').value=0;update();return;}
if(type==='template'){const selected=state.templates.find(tpl=>tpl.id===source.template)||state.templates[0];source={type,template:selected.id,data:source.type==='template'?source.data||{}:{},profile:source.profile||'color'};panel.innerHTML=`<div class="field"><label for="job-template">${t('template')}</label><select id="job-template">${state.templates.map(x=>`<option value="${x.id}" ${selected.id===x.id?'selected':''}>${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label for="job-source-profile">${t('profile')}</label><select id="job-source-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><div id="job-data-fields"></div>`;$('#job-source-profile').value=source.profile;$('#job-source-profile').onchange=()=>{source.profile=$('#job-source-profile').value;update();};const defaults={...selected.defaults,...source.data};const keys=[...new Set([...Object.keys(defaults).filter(k=>typeof defaults[k]!=='object'||Array.isArray(defaults[k])),...(['calendar','daily-dashboard'].includes(selected.id)?['events']:[])])];$('#job-data-fields').innerHTML=keys.map(k=>`<div class="field"><label for="job-field-${k}">${esc({title:state.lang==='zh'?'标题':'Title',message:state.lang==='zh'?'正文':'Message',quote:state.lang==='zh'?'一句话':'Quote',author:state.lang==='zh'?'作者':'Author',events:t('eventsHelp')}[k]||k)}</label><textarea id="job-field-${k}" data-job-field="${k}" rows="2">${esc(Array.isArray(defaults[k])?defaults[k].map(x=>typeof x==='object'?x.title:x).join('\n'):defaults[k]||'')}</textarea></div>`).join('');$('#job-template').onchange=()=>{source={type:'template',template:$('#job-template').value,data:{}};fields();};panel.querySelectorAll('[data-job-field]').forEach(el=>el.oninput=()=>{source.data[el.dataset.jobField]=el.dataset.jobField==='events'?el.value.split('\n').filter(x=>x.trim()):el.value;update();});update();}
else if(type==='auto_collage'){source={count:4,use_ai:true,...source,type};panel.innerHTML=`<div class="field"><label for="job-collage-source">${t('photoSources')}</label><select id="job-collage-source"><option value="">${state.lang==='zh'?'本地手动上传照片':'Locally uploaded photos'}</option>${photoSources.filter(x=>x.enabled).map(x=>`<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label for="job-collage-count">${state.lang==='zh'?'拼图数量(1–12)':'Photo count (1–12)'}</label><input id="job-collage-count" type="number" min="1" max="12" required value="${source.count}"></div><div class="field"><label for="job-collage-title">${state.lang==='zh'?'标题':'Title'}</label><input id="job-collage-title" maxlength="100" value="${esc(source.title||'')}"></div><div class="field"><label for="job-collage-prompt">${state.lang==='zh'?'排版要求':'Layout instructions'}</label><textarea id="job-collage-prompt" maxlength="1500">${esc(source.prompt||'')}</textarea></div><label class="check-row"><input id="job-collage-ai" type="checkbox" ${source.use_ai?'checked':''}>${state.lang==='zh'?'AI 自动排版(需先配置连接中心)':'AI layout (configure Connections first)'}</label><p class="hint">${state.lang==='zh'?'每次执行重新随机选图;取消 AI 使用自动网格。':'Each run picks new photos; uncheck AI for an automatic grid.'}</p>`;$('#job-collage-source').value=source.source_id||'';for(const id of ['source','count','title','prompt','ai'])$('#job-collage-'+id).oninput=()=>{source={...source,source_id:$('#job-collage-source').value||null,count:Number($('#job-collage-count').value),title:$('#job-collage-title').value,prompt:$('#job-collage-prompt').value,use_ai:$('#job-collage-ai').checked};update();};update();}
else if(type==='image'){const selected=source.image_id?'cloud:'+source.image_id:source.asset_id?'local:'+source.asset_id:'';panel.innerHTML=`<div class="field"><label for="job-image">${t('imageChoice')}</label><select id="job-image" required><option value="">—</option>${images.map(i=>`<option value="cloud:${i.image_id}">${esc(i.name)} · #${i.image_id}</option>`).join('')}${assets.map(a=>`<option value="local:${a.asset_id}">${esc(a.name)} · LOCAL</option>`).join('')}</select></div>`;$('#job-image').value=selected;$('#job-image').onchange=()=>{const [kind,id]=$('#job-image').value.split(':');source=kind==='cloud'?{type:'image',image_id:Number(id)}:{type:'image',asset_id:id};update();};}
else if(type==='photo_source'){panel.innerHTML=`<div class="field"><label for="job-photo-source">${t('photoSources')}</label><select id="job-photo-source" required><option value="">—</option>${photoSources.map(x=>`<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select><p class="hint">${t('sourceJobHint')}</p></div>`;$('#job-photo-source').value=source.source_id||'';$('#job-photo-source').onchange=()=>{source={type:'photo_source',source_id:$('#job-photo-source').value,fit:'contain',profile:'color'};update();};}
else{panel.innerHTML=`<div class="field"><label for="job-url">${t('imageURL')}</label><input id="job-url" type="url" placeholder="https://…" value="${esc(source.type==='image_url'?source.url:'')}" required></div>`;$('#job-url').oninput=()=>{source={type:'image_url',url:$('#job-url').value};update();};}}
@@ -110,19 +120,34 @@ async function photosPeoplePicker(selected){
const status=await api(path);
if(!status.configured){target.innerHTML=`<p class="hint">${t('photosConfigure')}</p>`;return;}
if(!status.connected){
target.innerHTML=`<div class="field"><label for="photos-username">${t('username')}</label><input id="photos-username" autocomplete="username"></div><div class="field"><label for="photos-password">${t('password')}</label><input id="photos-password" type="password" autocomplete="current-password"></div><p class="hint">${t('photosSessionHint')}</p><button type="button" id="photos-connect">${t('photosConnect')}</button>`;
target.innerHTML=`<div class="field"><label for="photos-username">${t('nasUsername')}</label><input id="photos-username" autocomplete="username"></div><div class="field"><label for="photos-password">${t('nasPassword')}</label><input id="photos-password" type="password" autocomplete="current-password"></div><p class="hint">${t('photosSessionHint')}</p><button type="button" id="photos-connect">${t('photosConnect')}</button>`;
$('#photos-connect').onclick=busy(async()=>{const password=$('#photos-password').value;$('#photos-password').value='';await api(path,{method:'POST',body:{username:$('#photos-username').value,password}});await photosPeoplePicker(selected);});return;
}
const people=await api(path+'/people?space='+encodeURIComponent($('#source-people-space')?.value||'shared'));
target.innerHTML=`<div class="field"><label for="people-search">${t('peopleSearch')}</label><input id="people-search" type="search"></div><label class="check-row"><input id="people-unnamed" type="checkbox">${t('peopleUnnamed')}</label><div id="people-list" style="max-height:260px;overflow:auto"></div><p id="people-selected" class="hint"></p><button type="button" id="photos-disconnect">${t('photosDisconnect')}</button>`;
target.innerHTML=`<div class="field"><label for="people-search">${t('peopleSearch')}</label><input id="people-search" type="search"></div><label class="check-row"><input id="people-unnamed" type="checkbox">${t('peopleUnnamed')}</label><div id="people-list" style="max-height:260px;overflow:auto"></div><p id="people-selected" class="hint"></p>`;
const render=()=>{const query=$('#people-search').value.toLowerCase();const rows=people.filter(p=>(p.named||selected.has(p.id)||$('#people-unnamed').checked)&&(p.name.toLowerCase().includes(query)||String(p.id).includes(query)));$('#people-list').innerHTML=rows.map(p=>`<label class="check-row"><input type="checkbox" data-person="${p.id}" ${selected.has(p.id)?'checked':''}>${esc(p.name)} · ${p.count}</label>`).join('');$('#people-selected').textContent=t('peopleSelected')+selected.size;$('#people-list').querySelectorAll('[data-person]').forEach(box=>box.onchange=()=>{const id=Number(box.dataset.person);box.checked?selected.add(id):selected.delete(id);$('#people-selected').textContent=t('peopleSelected')+selected.size;});};
$('#people-search').oninput=render;$('#people-unnamed').onchange=render;$('#photos-disconnect').onclick=busy(async()=>{await api(path,{method:'DELETE'});await photosPeoplePicker(selected);});render();
$('#people-search').oninput=render;$('#people-unnamed').onchange=render;render();
}
async function photosConnectionPanel(){
const status=await api('/api/v1/integrations/synology-photos');
const box=$('#photos-connection');if(!box)return;
box.innerHTML=`<div class="section-heading"><div><h3>Synology Photos · ${t('photosSharedConnection')}</h3><p class="hint">${t('photosConnectionHelp')}</p></div><span class="badge ${status.connected?'ok':'warning'}">${t(status.connected?'photosConnected':'photosDisconnected')}</span></div><p class="notice ${status.connected?'':'warning'}">${t(!status.configured?'photosSetupHelp':status.connected?'photosConnectedHelp':'photosReconnectHelp')}</p><div class="actions">${buttons('photos-login',status.connected?'photosReconnect':'photosConnect',true)}${buttons('photos-status-refresh','refresh')}${status.connected?buttons('photos-disconnect','photosDisconnect'):''}</div>`;
$('#photos-login').disabled=!status.configured;
bind('photos-login',()=>photosLoginDialog());
bind('photos-status-refresh',()=>photosConnectionPanel());
bind('photos-disconnect',()=>confirmDialog(t('photosSharedDisconnectWarning'),async()=>{await api('/api/v1/integrations/synology-photos',{method:'DELETE'});await navigate('photo-sources');}));
}
function photosLoginDialog(){
showModal(`<h2>${t('photosConnect')}</h2><p class="hint">${t('photosLoginHelp')}</p><form id="photos-login-form"><div class="field"><label for="photos-login-user">${t('nasUsername')}</label><input id="photos-login-user" required autocomplete="username"></div><div class="field"><label for="photos-login-password">${t('nasPassword')}</label><input id="photos-login-password" required type="password" autocomplete="current-password"></div><p class="hint">${t('photosSessionHint')}</p><p id="photos-login-error" role="alert" hidden></p><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('photosConnect')}</button></div></form>`);
$('#photos-login-form').onsubmit=busy(async e=>{e.preventDefault();const password=$('#photos-login-password').value;$('#photos-login-password').value='';try{await api('/api/v1/integrations/synology-photos',{method:'POST',body:{username:$('#photos-login-user').value,password}});$('#modal').close();await photosConnectionPanel();toast(t('photosConnectedHelp'));}catch(error){const box=$('#photos-login-error');box.hidden=false;box.className='notice warning';box.textContent=error.message;}});
}
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><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><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)));
section.querySelectorAll('[data-source-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/photo-sources/'+b.dataset.sourceDelete,{method:'DELETE'});await navigate('photo-sources');}));
@@ -131,19 +156,42 @@ 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><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-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;};update();$('#source-kind').onchange=update;addPhotoQualityFields(source);$('#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')} ${t('excludePhotoHelp')}</p><div class="source-items">${result.items.map((x,i)=>`<article class="source-photo"><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><div class="actions"><button data-source-star="${i}" aria-pressed="${Boolean(x.starred)}" ${x.excluded?'disabled':''} title="${x.excluded?t('clearExclusionFirst'):''}">${t(x.starred?'unstar':'star')}</button><button data-source-exclude="${i}" aria-pressed="${Boolean(x.excluded)}" ${x.starred?'disabled':''} title="${x.starred?t('clearStarFirst'):''}">${t(x.excluded?'includePhoto':'excludePhoto')}</button></div>${x.starred?`<small class="photo-mark">${t('photoStarred')}</small>`:''}${x.excluded?`<small class="excluded-photo">${t('photoExcluded')}</small>`:''}</article>`).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>`;
for(const key of ['star','exclude'])box.querySelectorAll('[data-source-'+key+']').forEach(button=>button.onclick=busy(async()=>{const item=result.items[Number(button.dataset[key==='star'?'sourceStar':'sourceExclude'])],field=key==='star'?'starred':'excluded';await api('/api/v1/photo-sources/'+id+'/preferences?device_id='+encodeURIComponent(state.device),{method:'PUT',body:{item_id:item.id,[field]:!item[field]}});await browseSource(id,offset);}));
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});
}
function historyQuery(){return new URLSearchParams({...state.historyFilters,page:state.historyPage||1,page_size:state.historyPageSize||20});}
function historyControls(){
const data=state.historyPageData;if(!data)return;
const filters=state.historyFilters||{};
const form=document.createElement('form');form.id='history-filters';form.className='card history-filters';
const select=(id,label,options)=>`<div class="field"><label for="${id}">${t(label)}</label><select id="${id}">${options}</select></div>`;
form.innerHTML=`<div class="field"><label for="history-search">${t('searchHistory')}</label><input id="history-search" type="search" maxlength="200" value="${esc(filters.q||'')}"></div>`+select('history-status','status',`<option value="">${t('allStatuses')}</option>`+['QUEUED','RENDERING','UPLOADING','SYNCING','COMPLETED','FAILED','UNKNOWN'].map(x=>`<option value="${x}">${t(x)}</option>`).join(''))+select('history-action','action',`<option value="">${t('allActions')}</option>`+['display','render','render-ai','local-upload','delete','restore','official-widget','reconcile'].map(x=>`<option value="${x}">${esc(operationActionLabel(x))}</option>`).join(''))+select('history-device','device',`<option value="">${t('allDevices')}</option>`+state.devices.map(d=>`<option value="${esc(d.id)}">${esc(deviceNameForId(d.id))}</option>`).join(''))+`<div class="field"><label for="history-start">${t('startDate')}</label><input id="history-start" type="date" value="${esc(filters.start||'')}"></div><div class="field"><label for="history-end">${t('endDate')}</label><input id="history-end" type="date" value="${esc(filters.end||'')}"></div><div class="actions"><button type="submit" class="primary">${t('filter')}</button><button id="history-reset" type="button">${t('resetFilters')}</button></div>`;
const wrap=$('#main .table-wrap');wrap.before(form);
for(const key of ['status','action','device'])$('#history-'+key).value=filters[key==='device'?'device_id':key]||'';
form.onsubmit=busy(async e=>{e.preventDefault();const start=$('#history-start'),end=$('#history-end');end.setCustomValidity(start.value&&end.value&&start.value>end.value?t('dateRangeInvalid'):'');if(!form.reportValidity())return;state.historyFilters={};for(const [id,key] of [['search','q'],['status','status'],['action','action'],['device','device_id'],['start','start'],['end','end']]){const value=$('#history-'+id).value;if(value)state.historyFilters[key]=value;}state.historyPage=1;await navigate();});
$('#history-end').oninput=()=>$('#history-end').setCustomValidity('');$('#history-start').oninput=()=>$('#history-end').setCustomValidity('');
bind('history-reset',async()=>{state.historyFilters={};state.historyPage=1;await navigate();});
const bar=document.createElement('div');bar.className='actions history-toolbar';bar.innerHTML=`<button id="history-all">${t('selectAll')}</button><button id="history-invert">${t('invertSelection')}</button><button id="history-delete" class="danger" disabled>${t('deleteSelected')}</button><span id="history-selected" class="hint" role="status"></span>`;wrap.before(bar);
const pager=document.createElement('div');pager.className='actions history-pagination';const pages=Math.max(1,Math.ceil(data.total/data.page_size));pager.innerHTML=`<span>${t('historyTotal').replace('{count}',data.total)} · ${data.page} / ${pages}</span><button id="history-prev" ${data.page<=1?'disabled':''}>${t('previousPage')}</button><button id="history-next" ${data.page>=pages?'disabled':''}>${t('nextPage')}</button><label for="history-page-size">${t('pageSize')}</label><select id="history-page-size">${[20,50,100].map(n=>`<option value="${n}" ${n===data.page_size?'selected':''}>${n}</option>`).join('')}</select>`;wrap.after(pager);
bind('history-prev',async()=>{state.historyPage=data.page-1;await navigate();});bind('history-next',async()=>{state.historyPage=data.page+1;await navigate();});$('#history-page-size').onchange=busy(async()=>{state.historyPageSize=Number($('#history-page-size').value);state.historyPage=1;await navigate();});
const checkboxes=()=>[...document.querySelectorAll('.history-select:not(:disabled)')];
const update=()=>{const selected=checkboxes().filter(x=>x.checked);$('#history-delete').disabled=!selected.length;$('#history-selected').textContent=t('historySelected').replace('{count}',selected.length);};
for(const op of data.items){const row=[...wrap.querySelectorAll('tbody tr')].find(r=>r.dataset.operationId===op.operation_id);if(!row)continue;const box=document.createElement('input');box.type='checkbox';box.className='history-select';box.value=op.operation_id;box.setAttribute('aria-label',t('selectHistory')+' '+op.operation_id);box.disabled=!['COMPLETED','FAILED','UNKNOWN'].includes(op.status);box.onchange=update;row.cells[0].prepend(box);}
bind('history-all',()=>{const boxes=checkboxes(),checked=boxes.length>0&&boxes.every(x=>x.checked);boxes.forEach(x=>x.checked=!checked);update();});bind('history-invert',()=>{checkboxes().forEach(x=>x.checked=!x.checked);update();});bind('history-delete',()=>deleteHistory(checkboxes().filter(x=>x.checked).map(x=>x.value)));update();
}
async function deleteHistory(ids){await confirmDialog(t('deleteHistoryConfirm').replace('{count}',ids.length),async()=>{await api('/api/v1/operation-history',{method:'DELETE',body:{operation_ids:ids}});await navigate();});}
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,3 +1,3 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><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.0</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="icon" type="image/svg+xml" href="/ui/favicon.svg"><link rel="icon" href="/ui/favicon.ico" sizes="16x16 32x32 48x48"><link rel="apple-touch-icon" sizes="180x180" href="/ui/apple-touch-icon.png"><link rel="stylesheet" href="/ui/style.css"></head>
<body><aside class="sidebar"><a class="brand" href="/" aria-label="PIGWay Frame Flow"><img class="pigway-logo" src="/ui/logo-symbol.svg" alt="" width="56" height="56"><span>PIGWAY<small>FRAME FLOW</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.28</small></div></aside><div class="workspace"><header><div class="workspace-heading"><img class="mobile-brand-logo pigway-logo" src="/ui/logo-symbol.svg" alt="PIGWay" width="56" height="56"><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div></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=0.1.28"></script><script src="/ui/features.js?v=0.1.28"></script><script src="/ui/extensions.js?v=0.1.28"></script><script src="/ui/app.js?v=0.1.28"></script></body></html>
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

File diff suppressed because one or more lines are too long
+6 -2
View File
@@ -1,7 +1,9 @@
[project]
name = "pigway-frame-flow"
version = "0.1.0"
requires-python = ">=3.12"
version = "0.1.28"
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"]
+5 -1
View File
@@ -6,5 +6,9 @@ cd "$(dirname "$0")/.."
DOCKER_BIN=${DOCKER_BIN:-docker}
"$DOCKER_BIN" info >/dev/null
"$DOCKER_BIN" compose config --quiet
"$DOCKER_BIN" compose build --pull
set -- --pull
if [ -n "${CI_PYTHON_BASE:-}" ]; then
set -- "$@" --build-arg "PYTHON_BASE=$CI_PYTHON_BASE"
fi
"$DOCKER_BIN" compose build "$@"
"$DOCKER_BIN" image inspect "${FRAME_FLOW_IMAGE:-pigway-frame-flow:latest}" --format 'image={{.Id}} size_bytes={{.Size}} architecture={{.Architecture}}'
+2 -2
View File
@@ -19,8 +19,8 @@ if len(password) < 8 or password != getpass.getpass("Confirm password: "):
args.config_dir.mkdir(parents=True, exist_ok=True)
path = args.config_dir / "accounts.json"
users = json.loads(path.read_text()).get("users", []) if path.exists() else []
if any(u["username"] == args.username for u in users):
sys.exit("Bootstrap user already exists. Change the password in Settings.")
if users:
sys.exit("Single-user account already exists. Change the password in Settings.")
users.append({"username": args.username, "password_hash": password_hash(password)})
path.write_text(json.dumps({"users": users}) + "\n")
path.chmod(0o600)
+83
View File
@@ -0,0 +1,83 @@
"""Offline, explicit local-to-Photos-API source conversion; defaults to dry run."""
import argparse
import json
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
def convert(database, source_ids, *, apply=False):
connection = sqlite3.connect(f"file:{database}?mode=rw", uri=True)
try:
updates = []
for source_id in source_ids:
key = "photo_source:" + source_id
row = connection.execute(
"SELECT payload FROM app_config WHERE key=?", (key,)
).fetchone()
if not row:
raise ValueError("Source not found: " + source_id)
source = PhotoSourceInput.model_validate_json(row[0])
if source.kind != "local":
raise ValueError(
"Only explicitly selected local sources can be converted"
)
location = source.location.strip("/")
if (
source.location.startswith("/")
or "\\" in location
or ".." in location.split("/")
):
raise ValueError(
"Review the source's relative Photos folder path first"
)
payload = source.model_copy(
update={"kind": "synology-photos", "synology_previews": False}
)
updates.append((payload.model_dump_json(), key))
backup = None
if apply:
backup = database.with_name(
database.name
+ ".before-photos-api-"
+ datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
)
with sqlite3.connect(backup) as snapshot:
connection.backup(snapshot)
backup.chmod(0o600)
with connection:
connection.executemany(
"UPDATE app_config SET payload=? WHERE key=?", updates
)
return {
"applied": apply,
"sources": source_ids,
"backup": str(backup) if backup else None,
}
finally:
connection.close()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--database", type=Path, required=True)
parser.add_argument("--source", action="append", required=True)
parser.add_argument("--apply", action="store_true")
parser.add_argument(
"--service-stopped",
action="store_true",
help="Confirm the target service is stopped before changing its database",
)
args = parser.parse_args()
if args.apply and not args.service_stopped:
parser.error(
"Stop the target service and pass --service-stopped before applying"
)
print(json.dumps(convert(args.database, args.source, apply=args.apply), indent=2))
if __name__ == "__main__":
main()
+1 -1
View File
@@ -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():
+114 -30
View File
@@ -37,7 +37,19 @@ class Deploy:
self.lock_id = None
self.state = None
self.old_image = None
self.secret_values = []
self.old_image_tag = ""
self.secret_values = [
self.env[key]
for key in (
"PROD_DATA_DIR",
"PROD_CONFIG_DIR",
"PROD_PHOTOS_DIR",
"PROD_WEB_BIND",
"SYNOLOGY_PHOTOS_URL",
"SYNOLOGY_PHOTOS_CERT_SHA256",
)
if self.env.get(key)
]
self.docker = self.env.get("DOCKER_BIN", "docker")
def run(self, *args, data=None, check=True):
@@ -58,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))
@@ -106,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 != [
@@ -168,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"]
@@ -185,6 +183,7 @@ class Deploy:
):
raise ValueError("Production user/restart policy differs from repository")
self.old_image = old["Image"]
self.old_image_tag = old["Config"].get("Image", "")
volume = self.run(
"volume", "inspect", self.env["PROD_STATE_VOLUME"], check=False
)
@@ -324,6 +323,12 @@ class Deploy:
def rollback(self, previous):
self.activate(previous)
self.verify(previous)
self.run(
"image",
"tag",
previous["services"]["pigway-frame-flow"]["image"],
self.env["PROD_IMAGE_REPO"] + ":latest",
)
print("ROLLBACK VERIFIED", flush=True)
def secret_config(self):
@@ -370,19 +375,23 @@ class Deploy:
"DOCKER_PIP_FALLBACK_INDEX_URL"
)
or "https://pypi.org/simple",
"DEBIAN_MIRROR": self.env.get("DEBIAN_MIRROR") or "deb.debian.org",
"DEBIAN_SECURITY_MIRROR": self.env.get("DEBIAN_SECURITY_MIRROR")
or "deb.debian.org",
"ALPINE_MIRROR": self.env.get("ALPINE_MIRROR") or "dl-cdn.alpinelinux.org",
"BUILD_COMMIT": commit,
"BUILD_TIME": now,
}.items():
command += ["--build-arg", key + "=" + value]
self.run(*command, str(ROOT))
service = config["services"]["pigway-frame-flow"]
service["image"] = image
latest = self.env["PROD_IMAGE_REPO"] + ":latest"
self.run("image", "tag", image, latest)
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(
@@ -653,6 +662,7 @@ class Deploy:
self.state["status"] = "success"
self.state_io(self.state)
print("BOOTSTRAP VERIFIED", flush=True)
self.cleanup_images()
except BaseException:
print("BOOTSTRAP FAILED", flush=True)
self.diagnostics()
@@ -712,6 +722,68 @@ class Deploy:
text = re.sub(r"https?://[^\s\"'<>]+", "[REDACTED-URL]", text)
print("Scoped container logs (redacted):", text, flush=True)
def cleanup_images(self):
"""Keep only latest after verification; protect unrelated container images."""
try:
protected = set()
referenced_tags = set()
container_ids = self.run("ps", "-aq").stdout.decode().split()
if container_ids:
containers = json.loads(self.run("inspect", *container_ids).stdout)
protected.update(container["Image"] for container in containers)
referenced_tags.update(
container.get("Config", {}).get("Image", "")
for container in containers
)
repo = self.env["PROD_IMAGE_REPO"]
image_ids = (
self.run("image", "ls", "--no-trunc", "-q", repo)
.stdout.decode()
.split()
)
if not image_ids:
return
images = json.loads(
self.run("image", "inspect", *sorted(set(image_ids))).stdout
)
removed = 0
for image in images:
tags = image.get("RepoTags") or []
# Shared images and unrelated tags are never cleanup targets.
if not tags:
continue
if not all(
re.fullmatch(
re.escape(repo)
+ r":(?:latest|release-[0-9a-f]{40}-[0-9a-f]{8}(?:-rollback)?|rollback-[0-9a-f]{32})",
tag,
)
for tag in tags
):
continue
latest = repo + ":latest"
if image["Id"] in protected:
# Current production uses latest. Other containers retain
# their referenced tags and images, even when stopped.
if latest not in tags:
continue
tags = [
tag
for tag in tags
if tag != latest and tag not in referenced_tags
]
if not tags:
continue
result = self.run("image", "rm", *tags, check=False)
if result.returncode:
print("IMAGE RETENTION: referenced image retained", flush=True)
else:
removed += 1
print(f"IMAGE RETENTION: removed {removed} obsolete images", flush=True)
except Exception:
# Cleanup failure must never roll back an already verified deployment.
print("IMAGE RETENTION WARNING: cleanup deferred", flush=True)
def deploy(self):
previous = self.preflight()
self.lock()
@@ -721,6 +793,14 @@ class Deploy:
# Refresh baseline under the daemon-level lock.
previous = self.preflight()
version = self.verify(previous)
release = self.old_image_tag.removesuffix("-rollback")
if re.fullmatch(
re.escape(self.env["PROD_IMAGE_REPO"])
+ r":release-[0-9a-f]{40}-[0-9a-f]{8}",
release,
):
pin = release + "-rollback"
else:
pin = self.env["PROD_IMAGE_REPO"] + ":rollback-" + self.owner
self.run("image", "tag", self.old_image, pin)
candidate = self.build(json.loads(json.dumps(previous)))
@@ -780,6 +860,10 @@ class Deploy:
if safe_unlock:
self.unlock()
# The deployment lock itself references the old image. Release it
# before retention; other container references remain protected.
self.cleanup_images()
def recover(self, manual=False):
self.settings()
lock = self.run(
+4 -1
View File
@@ -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,
)
+305
View File
@@ -0,0 +1,305 @@
"""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 tomllib
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 verify_prebuilt_image(image, commit, version, hashes):
expected = os.getenv("FRAME_FLOW_PREBUILT_IMAGE_ID", "")
if not expected:
return False
assert re.fullmatch(r"sha256:[0-9a-f]{64}", expected), "Require immutable image ID"
metadata = json.loads(run("docker", "image", "inspect", image))[0]
assert metadata["Id"] == expected, "Prebuilt image ID mismatch"
labels = metadata["Config"].get("Labels") or {}
assert labels.get("org.opencontainers.image.revision") == commit
assert labels.get("org.opencontainers.image.version") == version
project = {
n: h
for n, h in hashes.items()
if n.startswith(("pigway_frame_flow/", "vendor/"))
or n in {"requirements.lock", "LICENSE", "NOTICE.md"}
}
# Validate the artifact before it can access production config or data.
code = "import hashlib,json,pathlib,sys; h=json.load(sys.stdin); assert h and all(hashlib.sha256((pathlib.Path('/app')/n).read_bytes()).hexdigest()==v for n,v in h.items())"
subprocess.run(
[
"docker",
"run",
"--rm",
"-i",
"--network=none",
"--read-only",
"--cap-drop=ALL",
"--security-opt=no-new-privileges",
"--user=10333:10333",
"--entrypoint=python",
expected,
"-c",
code,
],
input=json.dumps(project),
text=True,
check=True,
timeout=60,
)
return True
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"])
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"
version = tomllib.loads((ctx / "pyproject.toml").read_text())["project"]["version"]
assert re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version)
image = "pigway-frame-flow:" + version + "-" + commit[:12]
if not verify_prebuilt_image(image, commit, version, hashes):
# A source-only overlay would retain vulnerable packages from the old image.
run(
"docker",
"build",
"--pull",
"-f",
str(ctx / "Dockerfile"),
"--build-arg",
"BUILD_COMMIT=" + commit,
"--build-arg",
"BUILD_TIME=" + stamp,
"--build-arg",
"ALPINE_MIRROR=" + os.getenv("ALPINE_MIRROR", "dl-cdn.alpinelinux.org"),
"-t",
image,
str(ctx),
)
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
labels = service.get("labels") or {}
if isinstance(labels, list):
labels = {item.partition("=")[0]: item.partition("=")[2] for item in labels}
service["labels"] = {
**labels,
"org.opencontainers.image.version": version,
"org.opencontainers.image.revision": commit,
}
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 -1
View File
@@ -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)});
+14
View File
@@ -0,0 +1,14 @@
/* Isolated Mock-only automatic collage workflow. */
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);const headers={Authorization:'Bearer '+token};assert((await(await fetch(base+'/api/v1/settings',{headers})).json()).mock===true);
const form=new FormData();for(let i=0;i<4;i++)form.append('files',new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a1OIAAAAASUVORK5CYII=','base64')],{type:'image/png'}),'fixture-'+i+'.png');assert((await fetch(base+'/api/v1/layout/images',{method:'POST',headers,body:form})).status===201);
const browser=await chromium.launch({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();assert(await page.locator('#nav button').first().getAttribute('data-page')==='dashboard');
await page.evaluate(()=>navigate('studio'));assert(await page.locator('#studio-plan').isHidden());await page.locator('#collage-ai').uncheck();await page.locator('#collage-count').fill('3');await page.locator('#collage-title').fill('Collage fixture');await page.locator('#auto-collage-form button[type=submit]').click();await page.locator('#collage-image').waitFor({timeout:30000});assert(await page.locator('#collage-display').isVisible());
const uploads=await(await fetch(base+'/api/v1/devices/living-room/images',{headers})).json();assert(uploads.length===1,'Preview must not upload');
await page.locator('#collage-job').click();await page.locator('[data-job-edit]').last().click();await page.locator('#job-collage-count').waitFor();assert(await page.locator('#job-collage-count').inputValue()==='3');assert(await page.locator('#job-collage-ai').isChecked()===false);await page.locator('#modal-cancel').click();
const jobs=await(await fetch(base+'/api/v1/jobs',{headers})).json();const job=jobs.find(j=>j.name==='Collage fixture');assert(job&&job.source.type==='auto_collage'&&job.source.count===3&&!job.enabled);
assert.deepStrictEqual(errors,[]);console.log('Automatic collage: 8 workflow checks passed; no frame writes or console errors');
}finally{await browser.close();}
})().catch(e=>{console.error(e.message);process.exit(1)});
+5
View File
@@ -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)});
+6
View File
@@ -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)});
+10
View File
@@ -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)});
+1 -1
View File
@@ -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++;
}
+18
View File
@@ -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 -3
View File
@@ -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 -1
View File
@@ -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/operation-history?*',r=>r.fulfill({json:{items:[op,{...op,operation_id:'d'.repeat(64)}],total:2,page:1,page_size:20}}));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)});
+18
View File
@@ -0,0 +1,18 @@
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(u.pathname==='/api/v1/assets/fixture')return r.fulfill({body:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jBz8AAAAASUVORK5CYII=','base64'),contentType:'image/png'});
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 op={operation_id:'fixture',action:'display',device_id:'living-room',status:'FAILED',client_id:'scheduler',result:{image_id:999,image_name:'example <photo>.jpg',preview_url:'/api/v1/assets/fixture'},error:{code:'WRITE_DISABLED'},request_id:'fixture'};api=async(path,options)=>options?.binary?(await fetch(path)).blob():path.endsWith('/fixture')?op:{items:[op],total:1,page:1,page_size:20};state.devices=[{id:'living-room',name:'living-room'}];document.body.classList.remove('login-mode');shell();await views.history(state.epoch);await enhanceHistory();});
const text=await p.locator('#main').innerText();assert(!text.includes('living-room'));assert(!text.includes('WRITE_DISABLED'));assert(text.includes('example <photo>.jpg'));assert(text.includes('999'));assert(text.includes(lang==='zh'?'云端图片 ID':'Cloud image ID'));assert(await p.locator('td div.bad').count()===1);assert(text.includes(lang==='zh'?'客厅':'Living room'));assert(text.includes(lang==='zh'?'显示图片':'Display image'));assert(text.includes(lang==='zh'?'自动调度':'Scheduler'));await p.locator('.history-actions button').first().click();await p.locator('#operation-preview').waitFor();assert(await p.locator('#operation-preview').evaluate(img=>img.complete&&img.naturalWidth>0));assert(await p.locator('#modal-content').innerText().then(x=>x.includes('example <photo>.jpg')));assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));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)});
+39
View File
@@ -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)});
+2 -2
View File
@@ -12,13 +12,13 @@ const assert=require('assert');const fs=require('fs');const {chromium}=require('
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 view of ['studio','connections']){
await page.evaluate(view=>navigate(view),view);await page.locator(view==='studio'?'#studio-plan':'#mqtt-config').waitFor();
await page.evaluate(view=>navigate(view),view);if(view==='studio')await page.locator('.manual-collage summary').click();await page.locator(view==='studio'?'#studio-plan':'#mqtt-config').waitFor();
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth),view+' overflow '+width);checks++;
const gaps=await page.locator('.actions').evaluateAll(rows=>rows.flatMap(row=>{const nodes=[...row.querySelectorAll('button')];return nodes.slice(1).map((node,i)=>{const a=nodes[i].getBoundingClientRect(),b=node.getBoundingClientRect();return Math.abs(a.top-b.top)<4?b.left-a.right:b.top-a.bottom;});}));
assert(gaps.every(g=>g>=8),view+' buttons touch '+width);checks++;
}
}
await page.evaluate(()=>navigate('studio'));await page.locator('#studio-files').setInputFiles({name:'fixture.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a1OIAAAAASUVORK5CYII=','base64')});
await page.evaluate(()=>navigate('studio'));await page.locator('.manual-collage summary').click();await page.locator('#studio-files').setInputFiles({name:'fixture.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a1OIAAAAASUVORK5CYII=','base64')});
await page.locator('#studio-assets input:checked').waitFor();await page.locator('#studio-plan').click();await page.locator('#studio-layout img').waitFor();await page.locator('#studio-render').click();await page.locator('#studio-result').waitFor({timeout:30000});assert(await page.locator('#studio-download').isEnabled());checks++;
await page.locator('#studio-title').fill('Changed');assert(await page.locator('#studio-display').isDisabled());checks++;
assert.deepStrictEqual(errors,[]);checks++;
+28
View File
@@ -0,0 +1,28 @@
const {chromium}=require('playwright');const fs=require('fs'),path=require('path'),assert=require('assert');
const root=path.join(__dirname,'../pigway_frame_flow/web');
(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});try{for(const width of [1440,768,390])for(const lang of ['zh','en']){
const p=await b.newPage({viewport:{width,height:900}});let errors=[];p.on('pageerror',e=>errors.push(e.message));let saved;
await p.addInitScript(lang=>localStorage.setItem('ff-language',lang),lang);
await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){
if(['PUT','POST'].includes(r.request().method())){saved=r.request().postDataJSON();return r.fulfill({json:{}})}
if(['/api/v1/jobs','/api/v1/assets','/api/v1/devices/living-room/images'].includes(u.pathname))return r.fulfill({json:[]});
if(u.pathname==='/api/v1/photo-sources')return r.fulfill({json:[]});
if(u.pathname==='/api/v1/integrations/synology-photos')return r.fulfill({json:{configured:true,connected:true}});
if(u.pathname.endsWith('/people'))return r.fulfill({json:[{id:1,name:'Test Person',count:5}]});
return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}
const f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');return r.fulfill({body:fs.readFileSync(path.join(root,f)),contentType:f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/html'});});
await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();
await p.evaluate(async()=>{state.templates=[{id:'daily-dashboard',name:'Dashboard',defaults:{}}];await jobEditor();});
for(const unit of ['hours','minutes']){
await p.locator('#job-schedule').selectOption(unit);await p.locator('#schedule-value').fill('2');
assert.equal(await p.locator('#schedule-value').getAttribute('type'),'number');
assert(await p.locator('#schedule-hint').innerText());
}
assert(!await p.evaluate(()=>document.documentElement.scrollWidth>innerWidth));
await p.locator('#job-name').fill('Test interval');
await p.locator('#job-form button[type=submit]').click();await p.waitForTimeout(100);
assert(saved);assert.deepEqual(saved.schedule,{type:'interval',interval_minutes:2});
await p.evaluate(async()=>{await jobEditor({id:'existing',name:'Existing',schedule:{type:'interval',interval_minutes:120},source:{type:'template',template:'daily-dashboard',data:{}},enabled:false,display_duration:0,restore_previous:false,priority:50});});
assert.equal(await p.locator('#job-schedule').inputValue(),'hours');assert.equal(await p.locator('#schedule-value').inputValue(),'2');
await p.evaluate(()=>{state.devices=[{id:'living-room',name:'我的相框'}];});assert.equal(await p.evaluate(()=>deviceDisplayName(state.devices[0])),'我的相框');assert.equal(await p.evaluate(()=>deviceDisplayName({id:'living-room',name:'living-room'})),lang==='zh'?'客厅':'Living room');await p.locator('#modal-cancel').click();await p.evaluate(async()=>{const previous=api;api=async()=>[{id:'fixture',name:'Family',device_id:'living-room',schedule:{type:'interval',interval_minutes:120},enabled:false}];try{await views.automation(state.epoch);}finally{api=previous;}});assert.equal(await p.locator('tbody td small').first().innerText(),'我的相框');assert.equal(errors.length,0,errors.join('\n'));console.log('PASS',width,lang);await p.close();
}}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
+38
View File
@@ -0,0 +1,38 @@
/* Full-page and modal layout audit in an isolated Mock service only. */
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));let connected=false;let logins=0;
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),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,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('#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();
await page.evaluate(()=>{state.tab='local';return navigate('gallery');});await inspect(`Local gallery/${width}/${lang}/${theme}`);await page.evaluate(()=>{state.tab='cloud';});
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, 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 -1
View File
@@ -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)});
+17
View File
@@ -0,0 +1,17 @@
const {chromium}=require('playwright');const fs=require('fs');const path=require('path');const assert=require('assert');
const root=path.resolve(__dirname,'../pigway_frame_flow/web');const out=process.env.FRAME_FLOW_SCREENSHOT_DIR;if(out)fs.mkdirSync(out,{recursive:true});
(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});let count=0;
try{for(const width of [1440,768,390,320])for(const lang of ['zh','en'])for(const theme of ['dark','light']){
const p=await b.newPage({viewport:{width,height:900}});const errors=[];p.on('pageerror',e=>errors.push(e.message));
await p.addInitScript(({lang,theme})=>{localStorage.setItem('ff-language',lang);localStorage.setItem('ff-theme',theme)},{lang,theme});
await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){if(u.pathname==='/api/v1/version')return r.fulfill({json:{version:'0.1.9',commit:'ba9424a-ui',build_time:'validation'}});return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}let f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');const file=path.join(root,f);if(!file.startsWith(root)||!fs.existsSync(file))return r.fulfill({status:404,body:''});return r.fulfill({body:fs.readFileSync(file),contentType:f.endsWith('.css')?'text/css':f.endsWith('.js')?'application/javascript':'text/html'});});
await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();await p.waitForTimeout(60);
assert.equal(await p.locator('#device option:checked').innerText(),lang==='zh'?'客厅':'Living room');
const layout=await p.evaluate(()=>{const q=s=>document.querySelector(s).getBoundingClientRect();return {overflow:document.documentElement.scrollWidth>innerWidth,card:q('.login-card').toJSON(),form:q('#login-form').toJSON(),user:q('#username').toJSON(),password:q('#password').toJSON(),remember:document.querySelector('#remember-login').closest('label').getBoundingClientRect().toJSON(),button:q('#login-form button').toJSON(),label:document.querySelector('#remember-login').labels[0].innerText}});
assert(!layout.overflow,JSON.stringify({width,lang,theme,layout}));assert(layout.password.top-layout.user.bottom>=24);assert(layout.button.top-layout.remember.bottom>=20);assert(layout.button.width>=layout.form.width-1);assert.equal(errors.length,0,errors.join('\n'));
// Language switching and custom names must not change the internal device ID.
await p.evaluate(()=>{state.devices=[{id:'living-room',name:'我的画框'}];shell()});assert.equal(await p.locator('#device option:checked').innerText(),'我的画框');
await p.evaluate(()=>{state.devices=[];shell();login()});
if(out&&lang==='zh'&&theme==='dark'&&[1440,390].includes(width))await p.screenshot({path:path.join(out,`login-${width}.png`),fullPage:true});
console.log('PASS',width,lang,theme);count++;await p.close();}
console.log('TOTAL',count,'profiles');}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
+1 -1
View File
@@ -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)});

Some files were not shown because too many files have changed in this diff Show More