Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1494904b12 | ||
|
|
0859871363 | ||
|
|
ed3b61bc58 | ||
|
|
30bc5a0bad | ||
|
|
64bd8dd736 | ||
|
|
cca5838803 | ||
|
|
129cfc8024 | ||
|
|
83c1c07fbd | ||
|
|
d680ba77f9 | ||
|
|
6ebc5e7afb | ||
|
|
8ce4aafb93 | ||
|
|
deb3b89bf8 | ||
|
|
9188d1743e | ||
|
|
6277c9d563 | ||
|
|
081d8b6ee0 | ||
|
|
7178235022 | ||
|
|
d94031eb50 | ||
|
|
fdc3a886f7 | ||
|
|
35ccba3716 | ||
|
|
71cf2955ce | ||
|
|
3b9c3e8c15 | ||
|
|
95625c5857 | ||
|
|
b770626a66 | ||
|
|
00b754db28 | ||
|
|
b0a833f1ef | ||
|
|
383ed45185 | ||
|
|
e6a3974d08 | ||
|
|
4771db6f2a | ||
|
|
c3bed2edce | ||
|
|
f8fe8e8b14 | ||
|
|
50903564ad | ||
|
|
69ef87e8e9 | ||
|
|
09c6773d76 | ||
|
|
ecfa17607f | ||
|
|
a83591d2c8 | ||
|
|
beb0bb0eb8 | ||
|
|
cc3fd02eb7 | ||
|
|
3a5acaa72a | ||
|
|
8e66b6d741 | ||
|
|
acdf267e48 | ||
|
|
28e971c0ff | ||
|
|
a2e8a204e8 | ||
|
|
50bb41899d | ||
|
|
cb8ac32b74 | ||
|
|
dec17aeb1d | ||
|
|
0d691826b6 | ||
|
|
5b2763910e | ||
|
|
e92a7a51fa | ||
|
|
b1c41e91ea | ||
|
|
f39c7e535b | ||
|
|
51d76a196e | ||
|
|
038b858eb7 | ||
|
|
4e9eccd809 | ||
|
|
8aaff83b8d | ||
|
|
7393325b6e | ||
|
|
db0b408680 | ||
|
|
2af0aea2f6 | ||
|
|
a05260712e | ||
|
|
c0291050be | ||
|
|
3cff5045b2 | ||
|
|
9f1c15a4c4 | ||
|
|
443a60ec54 | ||
|
|
9b31c1f425 | ||
|
|
743c6afe0c | ||
|
|
7bf23e5687 | ||
|
|
8cd005fd89 | ||
|
|
ec14ca2f1d | ||
|
|
f0a42caf45 | ||
|
|
7579ee13d1 | ||
|
|
c153822479 | ||
|
|
7cb940cf36 | ||
|
|
54a8d37f5e | ||
|
|
b101bb2f2d | ||
|
|
f1ce7e2cb3 | ||
|
|
10534fe8b0 | ||
|
|
32e8d4066e | ||
|
|
83837a4511 | ||
|
|
2b5f36019e | ||
|
|
f7b6c78a10 | ||
|
|
ba91dde2e7 | ||
|
|
7c929cb955 | ||
|
|
41c6897151 | ||
|
|
d7600f8329 | ||
|
|
40671cae5d | ||
|
|
a86a9e2080 | ||
|
|
bda5229183 | ||
|
|
b95b8e98cd | ||
|
|
b87bf6f77e | ||
|
|
46a0429d5e | ||
|
|
815164843b | ||
|
|
66ed1e2c9b | ||
|
|
2275974cde | ||
|
|
d4543a9843 | ||
|
|
106b6f311f | ||
|
|
d3f40cfea3 | ||
|
|
a843d4de9b | ||
|
|
d2f02d8054 |
@@ -14,3 +14,6 @@ dist
|
||||
.mypy_cache
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
|
||||
# macOS metadata at any depth
|
||||
**/.DS_Store
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Community checks
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
# GitHub-hosted disposable runner only; never run forks on the NAS runner.
|
||||
if: github.server_url == 'https://github.com'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.14'
|
||||
- run: python -m pip install -e '.[test]'
|
||||
- run: python -m ruff check pigway_frame_flow tests scripts
|
||||
- run: python -m ruff format --check pigway_frame_flow tests scripts
|
||||
- run: python -m mypy pigway_frame_flow
|
||||
- run: python -m pytest -q
|
||||
- run: python scripts/ci-audit.py
|
||||
@@ -1,26 +1,22 @@
|
||||
name: Verify Frame Flow
|
||||
on:
|
||||
push:
|
||||
# Branch verification gates releases; tags do not duplicate the same build.
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
deploy_action:
|
||||
description: Production action (deploy requires explicit enabled Variable)
|
||||
type: choice
|
||||
options: [plan, bootstrap, deploy, rollback]
|
||||
default: plan
|
||||
jobs:
|
||||
verify:
|
||||
# The NAS Docker runner must never execute code from external forks.
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
# 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://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: https://github.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
@@ -45,53 +41,5 @@ jobs:
|
||||
python scripts/ci-compose-verify.py --cleanup
|
||||
fi
|
||||
|
||||
deploy:
|
||||
needs: verify
|
||||
if: success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: frame-flow-production
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
CD_ACTION: ${{ github.event.inputs.deploy_action }}
|
||||
PROD_DEPLOY_ENABLED: ${{ vars.PROD_DEPLOY_ENABLED }}
|
||||
PROD_PROJECT: ${{ vars.PROD_PROJECT }}
|
||||
PROD_CONTAINER: ${{ vars.PROD_CONTAINER }}
|
||||
PROD_IMAGE_REPO: ${{ vars.PROD_IMAGE_REPO }}
|
||||
PROD_DATA_DIR: ${{ secrets.PROD_DATA_DIR }}
|
||||
PROD_CONFIG_DIR: ${{ secrets.PROD_CONFIG_DIR }}
|
||||
PROD_PHOTOS_DIR: ${{ secrets.PROD_PHOTOS_DIR }}
|
||||
SYNOLOGY_PHOTOS_URL: ${{ secrets.SYNOLOGY_PHOTOS_URL }}
|
||||
SYNOLOGY_PHOTOS_CERT_SHA256: ${{ secrets.SYNOLOGY_PHOTOS_CERT_SHA256 }}
|
||||
PROD_WEB_BIND: ${{ secrets.PROD_WEB_BIND }}
|
||||
PROD_WEB_PORT: ${{ vars.PROD_WEB_PORT }}
|
||||
PROD_STATE_VOLUME: ${{ vars.PROD_STATE_VOLUME }}
|
||||
PROD_MOCK: ${{ vars.PROD_MOCK }}
|
||||
PROD_WRITE_ENABLED: ${{ vars.PROD_WRITE_ENABLED }}
|
||||
DOCKER_PIP_INDEX_URL: ${{ vars.DOCKER_PIP_INDEX_URL }}
|
||||
DOCKER_PIP_FALLBACK_INDEX_URL: ${{ vars.DOCKER_PIP_FALLBACK_INDEX_URL }}
|
||||
CI_PIP_INDEX_URL: ${{ vars.CI_PIP_INDEX_URL }}
|
||||
CI_PIP_FALLBACK_INDEX_URL: ${{ vars.CI_PIP_FALLBACK_INDEX_URL }}
|
||||
DEBIAN_MIRROR: ${{ vars.DEBIAN_MIRROR }}
|
||||
DEBIAN_SECURITY_MIRROR: ${{ vars.DEBIAN_SECURITY_MIRROR }}
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: https://github.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.14'
|
||||
- name: Install deploy tooling
|
||||
run: python scripts/ci-install.py
|
||||
- name: Plan or deploy production (default is plan)
|
||||
env:
|
||||
PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }}
|
||||
run: |
|
||||
. ./deploy/build-cn.env
|
||||
python scripts/production-deploy.py
|
||||
- name: Recover interrupted production deployment
|
||||
if: always()
|
||||
env:
|
||||
CD_ACTION: recover
|
||||
run: |
|
||||
if [ -n "${FRAME_FLOW_DEPLOY_OWNER:-}" ]; then
|
||||
python scripts/production-deploy.py
|
||||
fi
|
||||
# Production moved to Ubuntu Server. Release manually with scripts/server-release.py;
|
||||
# the shared Server runner performs isolated verification only.
|
||||
|
||||
@@ -16,11 +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/
|
||||
|
||||
@@ -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.
|
||||
@@ -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.29" org.opencontainers.image.revision=$BUILD_COMMIT
|
||||
USER 10333:10333
|
||||
EXPOSE 18787
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Pigway
|
||||
Copyright (c) 2026 PIGWay
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Notices
|
||||
|
||||
Pigway Frame Flow is an independent project. KoKonna is a supported device
|
||||
PIGWay Frame Flow is an independent project. KoKonna is a supported device
|
||||
integration and is not affiliated with this project unless otherwise stated.
|
||||
KoKonna names and marks belong to their respective owners.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
# Pigway Frame Flow
|
||||
<p><img src="docs/brand/pigway-logo.png" alt="PIGWay" width="96" height="96"></p>
|
||||
|
||||
# PIGWay Frame Flow
|
||||
|
||||
Development currently supports Python 3.14.x. See [contributing](CONTRIBUTING.md) and [security reporting](SECURITY.md).
|
||||
|
||||
[简体中文](README.zh-CN.md) | English
|
||||
|
||||
Pigway Frame Flow is a lightweight, self-hosted platform for programmable smart-frame automation. It runs independently on a NAS or server, with its own Web UI, image library, rendering, scheduler and REST API. KoKonna is a supported device integration; external applications do not need its credentials or SDK.
|
||||
PIGWay Frame Flow is a lightweight, self-hosted platform for programmable smart-frame automation. It runs independently on a NAS or server, with its own Web UI, image library, rendering, scheduler and REST API. KoKonna is a supported device integration; external applications do not need its credentials or SDK.
|
||||
|
||||
### Source and terminal libraries
|
||||
|
||||
Each photo source owns a library and generation plans. A terminal binds to one source and one plan within it; terminals may share a plan and its generated version. Source tasks select one picture and produce independent frame/watch versions. See the [library model and workflow](docs/SOURCE_LIBRARIES.md).
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -22,9 +30,9 @@ docker compose run --rm --no-deps -e PYTHONPATH=/app \
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:18787` and sign in with the account you created. The browser uses an HttpOnly session cookie; it does not require an API token. Remembered sessions last 30 days. Change the password or sign out in Settings. `FRAME_FLOW_TOKEN` authenticates external API clients and is separate from the KoKonna API key.
|
||||
Open `http://127.0.0.1:6005` and sign in with the account you created. The browser uses an HttpOnly session cookie; it does not require an API token. Remembered sessions last 30 days. Change the password or sign out in Settings. `FRAME_FLOW_TOKEN` authenticates external API clients and is separate from the KoKonna API key.
|
||||
|
||||
The published interface defaults to loopback. For LAN access, explicitly set `WEB_BIND` to your NAS LAN address and choose an available `WEB_PORT`. See [Synology deployment](docs/NAS.md), [build mirrors and caching](docs/BUILDING.md), and [Gitea deployment/rollback](docs/deployment/GITEA_CD.md). No firewall, router or reverse-proxy settings are changed automatically.
|
||||
The published interface defaults to loopback. For LAN access, explicitly set `WEB_BIND` to your NAS LAN address and choose an available `WEB_PORT`. See [Synology deployment](docs/NAS.md), [build mirrors and caching](docs/BUILDING.md). No firewall, router or reverse-proxy settings are changed automatically.
|
||||
|
||||
For a local mock demo, use Python 3.14:
|
||||
|
||||
@@ -37,15 +45,17 @@ python3.14 -m venv .venv
|
||||
|
||||
The demo forces mock mode and disables device writes. Data and configuration remain in `data/` and `config/` across restarts.
|
||||
|
||||
Synology Photos API sources work without `/photos` or NFS mounts, with shared/current-account personal spaces, people filters and bounded on-demand XL preview caching. Original downloads are not supported by this verified implementation. See [Photos API and Ubuntu migration candidate](docs/SYNOLOGY_PHOTOS_API.md).
|
||||
|
||||
### Features and boundaries
|
||||
|
||||
- Chinese and English Web UI, light/dark themes, account login, device dashboard and image previews.
|
||||
- Local staging and KoKonna cloud gallery, uploads, image switching, stars, source/task filters and batch actions.
|
||||
- Source libraries with source-scoped generation plans, terminal libraries with one source/plan binding per terminal, and paged NAS browsing with image previews and metadata filters. Legacy gallery pages are retired; existing task compatibility remains.
|
||||
- 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.
|
||||
- Read-only NAS folders, Synology Photos person filtering, WebDAV and optional cloud-drive connectors. Each source has its own configuration and authorization.
|
||||
- Synology Photos person filtering, WebDAV and optional cloud-drive connectors. Each source has its own folder and filter settings. People filtering uses one shared Synology Photos sign-in; WebDAV and cloud-drive credentials belong to their respective sources.
|
||||
- Optional weather, calendar, monitoring, AI, MQTT and webhook integrations. Provider configuration is required; an unconnected provider is not a working connection.
|
||||
|
||||
Templates **render images**, including text-only templates. Choose a template, edit its data, generate a preview, then display it. Preview generation and saving template settings do not update the frame. Photo + Text and Daily Dashboard can include a selected local photo; other templates do not automatically choose one. Scheduled template jobs render afresh on each run. See [photo sources](docs/PHOTO_SOURCES.md), [automatic collages](docs/AUTOMATIC_COLLAGE.md) and [integration setup/limits](docs/EXTENSIONS.md).
|
||||
@@ -91,3 +101,5 @@ Tests mock cloud/device calls. CI also verifies an isolated Docker build, readin
|
||||
Cloud synchronization does not prove the physical pixels refreshed. KoKonna cloud uploads activate immediately, so upload-only uses local staging. Native slideshow membership editing is unavailable; avoid running native and Frame Flow playback simultaneously. Smart cropping is a saliency heuristic, not guaranteed face detection. The service uses one worker and is not designed for multiple replicas. Browser OAuth, AI daily-art scheduling, additional calendar recurrence features and more device drivers remain roadmap items.
|
||||
|
||||
Related projects: [PIGWay Raspberry Pi Agent](https://tea.pigway.com/way/pigway-raspberry-pi-agent) · [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) · [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat)
|
||||
|
||||
[Documentation](docs/README.md)
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
# Pigway Frame Flow
|
||||
<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。
|
||||
|
||||
### 图库与终端
|
||||
|
||||
图库分为来源图库和终端图库。每个来源有自己的生成方案;每个终端单选一个来源及其中一个方案,同一方案可以被多个终端共用。任务一次选图,分别生成相框与手表版本。参见[使用与架构说明](docs/SOURCE_LIBRARIES.md)。
|
||||
|
||||
### 安装
|
||||
|
||||
使用 Docker Compose 部署。当前默认配置与已验证平台为 AMD64:
|
||||
@@ -22,9 +30,11 @@ docker compose run --rm --no-deps -e PYTHONPATH=/app \
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
打开 `http://127.0.0.1:18787`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。
|
||||
打开 `http://127.0.0.1:6005`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。
|
||||
|
||||
默认端口只绑定本机回环地址。需要局域网访问时,显式将 `WEB_BIND` 设置为 NAS 的局域网地址,并通过 `WEB_PORT` 选择空闲端口。参见[群晖部署](docs/NAS.md)、[构建镜像源与缓存](docs/BUILDING.md)和 [Gitea 部署与回滚](docs/deployment/GITEA_CD.md)。不会自动修改防火墙、路由器或反向代理。
|
||||
网页仅支持一个用户,不提供公开注册。密码为 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 演示:
|
||||
|
||||
@@ -37,15 +47,17 @@ python3.14 -m venv .venv
|
||||
|
||||
演示强制使用 Mock,并关闭设备写入。数据与配置保存在 `data/`、`config/`,重启后保留。
|
||||
|
||||
Synology Photos 可使用独立的 API 来源,无需 `/photos` 或 NFS 挂载;支持共享 / 当前账号个人空间、人物筛选和按需 XL 预览缓存。原图下载不在本次已验证支持范围内。见 [API 来源与 Ubuntu 候选迁移说明](docs/SYNOLOGY_PHOTOS_API.md)。
|
||||
|
||||
### 功能与边界
|
||||
|
||||
- 中英文 Web 界面、深浅色主题、账号登录、设备面板与图片预览。
|
||||
- 本地暂存和 KoKonna 云端图库,上传、切换、星标、来源/任务筛选及批量操作。
|
||||
- 来源图库管理源画面和来源内生成方案;终端图库按绑定管理成品。NAS 选图支持分页、预览及元数据筛选,旧图库页面已退役,已有任务兼容逻辑保留。
|
||||
- 保持比例的 contain/cover/smart 图片转换,彩色/灰度模式与照片数据源的横竖方向选择。
|
||||
- 七种模板:家庭早报、天气、日历、一句话、照片与文字、每日诗词、系统告警。
|
||||
- 拼图工作室支持 1–12 张图片的拼图与海报;可选 OpenAI 兼容接口排版,以及从数据源自动选图拼接。
|
||||
- 绑定设备的定时任务、顺序/随机播放列表、带优先级的临时覆盖、恢复与持久化操作历史。
|
||||
- 只读 NAS 文件夹、Synology Photos 人物筛选、WebDAV 和可选网盘连接器;每个数据源独立配置、独立授权。
|
||||
- Synology Photos 人物筛选、WebDAV 和可选网盘连接器;每个数据源独立配置、独立授权。
|
||||
- 可选天气、日历、监控、AI、MQTT 与 Webhook 集成。需要配置对应 Provider;尚未授权的数据源不会被当作已连接。
|
||||
|
||||
模板会**生成图片**,纯文字模板也是如此。使用顺序是:选择模板 → 编辑内容 → 生成预览 → 显示到画框。生成预览和保存模板设置都不会更新画框。“照片与文字”和“家庭早报”可以包含指定的本地照片,其他模板不会自动选图;定时模板任务每次运行会重新渲染。参见[照片数据源](docs/PHOTO_SOURCES.md)、[自动拼图](docs/AUTOMATIC_COLLAGE.md)和[集成配置及限制](docs/EXTENSIONS.md)。
|
||||
@@ -67,6 +79,8 @@ Web UI / Scheduler / REST API → DisplayService → FrameDeviceDriver → KoKon
|
||||
|
||||
Ticktar 集成为可选项,Frame Flow 不依赖 Ticktar 启动,也不访问其数据库。参见[外部集成契约](docs/integrations/TICKTAR.md)。
|
||||
|
||||
Console 监控接入提供独立只读 ZIP 插件,支持服务就绪、画框在线/电量及任务最近结果,见 [Console 插件安装说明](https://tea.pigway.com/way/pigway-console-adapter-frame-flow/src/branch/main/README.zh-CN.md)。
|
||||
|
||||
### 存储与安全
|
||||
|
||||
Docker 持久化 `/data/frameflow.db`、`/data` 下的图片/生成文件/缓存,以及 `/config` 下的私有配置和会话文件。容器以 UID/GID 10333 运行,根文件系统只读,使用独立桥接网络、`unless-stopped` 重启策略和有大小限制的标准输出日志;不需要 privileged、host network 或 Docker socket。
|
||||
@@ -91,3 +105,9 @@ KoKonna 是支持的设备集成。本项目与 KoKonna 无隶属关系,也不
|
||||
云端同步不等于已确认物理屏幕刷新。KoKonna 云端上传会立即切换,因此“仅上传”使用本地暂存。官方接口不支持编辑原生轮播成员,应避免原生轮播与 Frame Flow 播放同时运行。smart 裁切采用显著性启发式,不保证识别人脸。当前服务使用单 Worker,不面向多副本部署。浏览器 OAuth、AI 每日艺术调度、更多日历重复规则及设备驱动仍属于后续计划。
|
||||
|
||||
相关项目:[PIGWay Raspberry Pi Agent](https://tea.pigway.com/way/pigway-raspberry-pi-agent) · [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) · [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat)
|
||||
|
||||
[文档目录](docs/README.md)
|
||||
|
||||
历史记录支持服务端翻页、图片名称/请求 ID/任务 ID/来源搜索,以及状态、操作、设备和日期(上海时区)筛选;可选择当前页记录进行单条或批量删除,操作前需确认。删除仅从历史列表移除,内部执行凭据继续供图库来源、幂等及恢复使用,不会删除图片或取消任务;运行中的任务不能删除。
|
||||
|
||||
来源图库的源画面支持“星标保留”和“不再选用/恢复选用”,二者互斥;后续自动选图和自动拼图跳过被标记的照片。图片仍保留,可手动预览、生成或恢复。标记按来源和图片 ID 独立保存,不取消已提交任务,也不修改网盘原文件。
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# 当前开发修订
|
||||
|
||||
- 更新图片处理、证书信任和遥测依赖;不可信图片只交给允许的格式解析器。
|
||||
- 手动上传接入来源图库,保存源画面后可按方案生成并关联终端。
|
||||
- 补齐图片写权限;客户端权限选项使用服务端统一目录。
|
||||
- 图片文件和数据库变更加入事务、失败清理及重启恢复;缓存转成品也保留一致性。
|
||||
- 统一上传及图库文案,移除图库重复设备选择器;NAS 空批次支持明确继续读取提示。
|
||||
- 设置页显示磁盘容量和待清理文件;登录计算不再占用共享数据库锁。
|
||||
- 保持单用户、内网 HTTP 访问,无证书要求。
|
||||
|
||||
# pigway-frame-flow v0.1.29
|
||||
|
||||
完善星标与“不再选用”禁用状态的原因提示。
|
||||
|
||||
- 禁用按钮悬停时明确说明当前标记冲突,以及先取消星标或恢复选用的操作步骤。
|
||||
- 覆盖本地图库卡片、批量操作及照片源列表;未选择图片时也提供原因提示。
|
||||
- 提示支持键盘聚焦和 Esc 关闭,按屏幕边界定位;切换页面或改变状态时清除旧提示。
|
||||
- 保留互斥校验、按钮顺序及卡片底部对齐行为。
|
||||
|
||||
源码包保留 LICENSE、NOTICE 及第三方许可材料,不包含私有配置、数据库或会话。
|
||||
@@ -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 的部署限制继续保留。中文字体、第三方许可证和依赖元数据均保留;不以删除包清单掩盖告警。
|
||||
@@ -0,0 +1,250 @@
|
||||
c2792b2c75880ff09822804632b1b3b6dd6190b38841b8c3567bb1b7e7b59a8c .dockerignore
|
||||
d20031a8e861a0f3e19b7750ddb53bc84c480c469c44a70ecdf8261ec05431c0 .env.example
|
||||
0c7f4585d62a8d35bfc22e0bebdf7a479a179122b2b092bfe434e3038cddd3d5 .gitattributes
|
||||
b45d35d737f119a70bf40cb1f9626ff80772ed7913cb35f6e9b870d6ada01a15 .github/workflows/community.yml
|
||||
c8c545718e9b9880555c950a078cac160c82e0cd6e593afcf20bc13727539ec5 .github/workflows/verify.yml
|
||||
3931cc314019760a582e0914e2d9c0f3b862a004d0a30d374900edcad5f599e5 .gitignore
|
||||
4b4ed4926a350cbeb71436541b4566e4ba7d2494ac95236b88827a9f5dd171c2 CONTRIBUTING.md
|
||||
b12a3f0ff3f15db8d79b5163d0750a3dd10eb56aadc41ada20c5f8a10b46c118 Dockerfile
|
||||
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
|
||||
250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md
|
||||
31d523d1ed7465d8e7bf44011ca940e9ff0dd21af0e1bde5814c28dbfff53dd8 README.md
|
||||
1b809c686f4c1275bb9e04c1505020a3b5a9cb3a8e9298eb902ffe168bb17739 README.zh-CN.md
|
||||
4a5de9afa11143f35fb98cb4b97bde4ac1f8b67646810522483d30aecdda16d2 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
|
||||
09cf79be06991e3b482518a3059a6f26504cb68857bc98a8a8bf8628724a5a93 docs/NAS.md
|
||||
3280bddcbe2d2721bb7efeb0eb2ec03d9d5ef867c69f3e70066b69f1ad5926e2 docs/PHOTO_SOURCES.md
|
||||
edf44436fd044561539c48480c481e66c1a38164f51ba5226cde04c756f7bacb docs/PHOTO_SOURCE_CONNECTIONS.md
|
||||
b48f3ab6f28404bb34c188067a832384f4ba290e56912461ef3565528aa157d8 docs/PUBLICATION.md
|
||||
10842c477d34e6fde7af0a98c739ef513ef48480d52e2fe6d6874eed33461d15 docs/README.md
|
||||
5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md
|
||||
bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md
|
||||
5ba13866ad96cb931c9dcb5fcc141c60fc44103a969fe912636a44afefb6854f docs/SOURCE_LIBRARIES.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
|
||||
f1549ec1c51602a949261319c0483abb34d1ae6d272bdaf7e057ada7ebaa78a2 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
|
||||
ea7b720fa34ebe4039398eb34ac2d9457aafa46d14e273e6a01b257b2acdbb0e pigway_frame_flow/api/app.py
|
||||
1efe9b1fc774a97e5be72d81199b1b65b25c258956fdf166aebff732cc820d7c pigway_frame_flow/api/history.py
|
||||
a6f7336da1e4b90ec41a6f7cb2467e4b1bfa78551bf71b14b64e82d8ea08498f pigway_frame_flow/api/libraries.py
|
||||
3dda509a34daca61836913976dba35e59c2135c48bf04bc7617e923cc5cffdc1 pigway_frame_flow/api/thumbnails.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
|
||||
881698e97f4427ff2db025f08ee82d9d106ce36744ce8f17cae94f2e8a703e18 pigway_frame_flow/display/cloud_library.py
|
||||
94ac69244669b7ccc816ebac3e62f0bf44a0a076e1d7b8bd7bab332dad779c79 pigway_frame_flow/display/libraries.py
|
||||
5e7e272e671a6d01d9b677416c77967ca488b4e2aa559f4211cf0de4ba1e88bc pigway_frame_flow/display/recovery.py
|
||||
e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py
|
||||
52f1e4a1adb3dca8d47647e14e55479fe18664bfac63f220da76ff90e0a358b1 pigway_frame_flow/display/service.py
|
||||
de6b53b2911837af5e8e4ee3065ae513e4523436ddabde4a11503e432c3c1351 pigway_frame_flow/display/thumbnail_gallery.py
|
||||
18fc72b7f726c85d8b11bed78d88403c7867b7d71d2580d47afad6fa0242edc2 pigway_frame_flow/display/thumbnail_plans.py
|
||||
0975b24488be19f24e768c4c76d9f3c0348417c1351c4e410897da00f7be6656 pigway_frame_flow/display/upload_reconciliation.py
|
||||
a521e5d107f456e007050de65cd25e7ed3f269c27360e2bda6f768faa9b28841 pigway_frame_flow/display/wrist_thumbnail.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
|
||||
83fea5407cbb83ca426cda420cc995e9346955e747e0cda2f7e6546fbd50dc06 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
|
||||
17cbaba7fe46377bf62c003dadde9e6dbbe8d230a1c2675d0541770cc413e96d 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
|
||||
21353750457ecd5802c944b24a9976f05cbaa1920052785d26fda7e468ea3aae pigway_frame_flow/providers/photo_sources.py
|
||||
114adfb3fcd1ab050c1dd2f1fff9d91fdde192b8eced907dee3bc8086037e13d pigway_frame_flow/providers/photos_cache.py
|
||||
119427bc435ec8d4c91114713f37d07f22067eaeec391261d1441a1436baf5e0 pigway_frame_flow/providers/poetry.py
|
||||
b1266d22ea914aa4d52e0b80cc1e2948c72219ea766140eb5aa22f6d42531053 pigway_frame_flow/providers/source_browser.py
|
||||
935340780577fbc7a54f893e492f4284f1e8fbffdd397663b71c0137e93c174b 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
|
||||
95da34371ed6a46e63fb7f01dbaad3b9a73b520cdcaec1231048b81b01a0184e pigway_frame_flow/renderers/images.py
|
||||
0683436692d083c86b580aff71a9803a94e9fa07d45f384795020423b486494a 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
|
||||
d904579f2918c0bc97f05d2a84964ae5f0b3b9dfe6b5201c0866b4c4c5a1d304 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
|
||||
81a26552958e5c1132b69054b2ef4e8139c3ab6c6dc81ac179811cd5495a7cf2 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
|
||||
9eec4fe71cf1750a55bf9d8ebc9a24434c3951e8659dd40267b5184b20dcb37e pigway_frame_flow/web/features.js
|
||||
a07ea444ab4ac75ea11e1188a431b48195353b1e08f7e80f3b931e8097ea6221 pigway_frame_flow/web/i18n.js
|
||||
6385ec8b0383fa9bdbb23c4c685c8ef1afd7926fd74c8032811e647808548255 pigway_frame_flow/web/index.html
|
||||
3d3078b5eb74f3c48369deb1cc654e064dea97689cddff65fd2a44f362164e00 pigway_frame_flow/web/libraries.js
|
||||
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/logo-symbol.svg
|
||||
9dcf0419d0292efce8ead7309dc70b4afd27dd24fe82cf9e673682342650f4da pigway_frame_flow/web/source-browser.js
|
||||
d1e42232c50e3ed23912504997d776e70127f089daba944f514f68c90364c332 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
|
||||
0ea2cc7da61c89c7505f4a312aad692d52084df32fc279f5742c96c31d84d09e pigway_frame_flow/web/thumbnails.js
|
||||
5f392f94636d261ae2982815763b794ae9595a1b9226946b792105634ec3f559 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
|
||||
a39fd668383a948a9fbe1ecb00aebb23f375af52ea4dd3bc7049106925cfebb5 scripts/migrate-source-libraries.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
|
||||
3374deeef1e7678b02c7aa628a1e8940a7275489a01b8142e5fadf83feca9626 scripts/ui-dashboard-preview.cjs
|
||||
8260a11478e23f63bc56c119205a89df04efe7c8a9bfb25b80db24aa56fd302b scripts/ui-history-layout.cjs
|
||||
86cf401ffb8b78691b6dca0851925af3b3107acfffd6943d78027ca4e9a54df7 scripts/ui-history-localization.cjs
|
||||
9c91dd27fb13305e5d6b38fc2ab365cc50d0bdf3022afeccebd089d40d50b731 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
|
||||
7c9fc679880d39ef3af262dd43d58835740fdeee45112b848303ee88c15448de scripts/ui-libraries.cjs
|
||||
b8b6c94cce58efbcb97dd33fc9ce3f3525be1349679f0cf543a6c61af4bd7e6f scripts/ui-login-layout.cjs
|
||||
8574025d0b22f653c58846bdbb81cc10b4a7a7ca515d8e6b462083f6ca6aa39a scripts/ui-official-connection.cjs
|
||||
98a03f5fb7aeead34a50ad6c17ee4cd5f2cc61eeb6ca7460652835dc31255447 scripts/ui-photo-people.cjs
|
||||
a4ba9a6d5fbfff7aa05d77ce7eab74a6468c1e929a90305a4afa8abbc6c891a0 scripts/ui-photos-api-form.cjs
|
||||
7047f9f49886abe3c2f08b6b2bfaea35bf9682d4e3aaee773e90c5f3885375fc scripts/ui-photos-api.cjs
|
||||
6c40d6bd4ae1bd3ecbcf9cfa3cad65893350f178399e4464cf138a4cac3bdf4b 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
|
||||
771c825ff00147638627451ce90d4b7f8aab0c6844f8187afa7ab618f8dc235e tests/contract.json
|
||||
9b3632a93c68c057b2fb276aa4a1b2b514f74934f0183480930d951d91704cae tests/schema-contract.json
|
||||
23ffc7413f8388648c9d52d3c8f56e2966b73ca1bdc5e209e6c15469c790edba tests/test_accounts.py
|
||||
e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py
|
||||
d5bf4f0463a9e369557adf2f440ad401815cb8ffea84af49d106e916eee16501 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
|
||||
a821fbe5096b2fa5b781423e66673b8d5659b60fc37c5c10470afa2a818659c8 tests/test_source_browser.py
|
||||
d019a765860f82ee0249d34174c73dc56ca0073765d98e2891938178e80fbc3f tests/test_source_libraries.py
|
||||
2624fb6ca01f32d93499d7d771144dcaa2850e5f77b2ac3b7b566891b5e9d283 tests/test_synology_photos.py
|
||||
ca98469f6a6b8b49c36a00657ea1a81775c3496e1fbdd5e38eb4d5298b5936b4 tests/test_synology_photos_api.py
|
||||
a83390e9f580bc27a2928eb6f94d7377307ec46ab45ef71bfea1e5378cb7adb9 tests/test_thumbnail_plans.py
|
||||
33f05bc6279cf3573493335ae5bb1c91130754b927b52aa5f50e47f2d951d2ae tests/test_upload_reconciliation.py
|
||||
bdc3142f9c2291a91a5091b4b98b1addf9569cfb141810024ce6f85309704b9b tests/test_upload_retries.py
|
||||
8ec55ce5706d791a155f13445fe7e0828278e8ee1fa1b527591fdf24cc5b2e86 tests/test_weather.py
|
||||
e95eaa1f8a7416c5035ae6deae640b8d5defb2db36c0b8dac6d65bcceedc6750 tests/test_widgets.py
|
||||
2b3ab560a0211e45a7bc7e26104d3d9416c4615fbeccaf3bb5eec4747aaea4cf tests/test_wrist_thumbnail.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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Candidate only. Never start alongside a live-writing NAS instance.
|
||||
name: pigway-frame-flow
|
||||
services:
|
||||
pigway-frame-flow:
|
||||
image: ${FRAME_FLOW_IMAGE:?Set the verified immutable image reference}
|
||||
container_name: pigway-frame-flow
|
||||
platform: linux/amd64
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
user: "10333:10333"
|
||||
ports:
|
||||
- "${FRAME_FLOW_BIND:-127.0.0.1}:81:18787"
|
||||
env_file: ${FRAME_FLOW_ENV_FILE:?Set the private environment file path}
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
DATA_DIR: /data
|
||||
CONFIG_DIR: /config
|
||||
FRAME_FLOW_STORAGE_NAME: Ubuntu Server
|
||||
SYNOLOGY_PHOTOS_CACHE_BYTES: ${SYNOLOGY_PHOTOS_CACHE_BYTES:-268435456}
|
||||
# Migration starts read-only. Activation is a separate authorized action.
|
||||
KOKONNA_WRITE_ENABLED: "false"
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
TMPDIR: /data/tmp
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${FRAME_FLOW_APP_DIR:?Set the existing application directory}/data
|
||||
target: /data
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: ${FRAME_FLOW_APP_DIR:?Set the existing application directory}/config
|
||||
target: /config
|
||||
bind:
|
||||
create_host_path: false
|
||||
read_only: true
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
pids_limit: 100
|
||||
mem_limit: 768m
|
||||
healthcheck:
|
||||
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18787/ready',timeout=3)"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 20s
|
||||
retries: 3
|
||||
# Docker data-root and container logs must reside on the T7 too.
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 10m
|
||||
max-file: "3"
|
||||
@@ -1,14 +1,6 @@
|
||||
# Opt-in read-only photo mount. Set NAS_PHOTOS_DIR to the actual NAS folder.
|
||||
# Retired: filesystem photo mounts are forbidden. Use Photos API sources.
|
||||
# This compatibility filename intentionally grants no additional host access.
|
||||
services:
|
||||
pigway-frame-flow:
|
||||
environment:
|
||||
PHOTO_ROOT: /photos
|
||||
group_add:
|
||||
- "${NAS_PHOTOS_GID:-10333}"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${NAS_PHOTOS_DIR:?Set NAS_PHOTOS_DIR to your existing photo directory}
|
||||
target: /photos
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
SYNOLOGY_PHOTOS_CACHE_BYTES: ${SYNOLOGY_PHOTOS_CACHE_BYTES:-268435456}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,53 +2,48 @@
|
||||
|
||||
### 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. Production uses `scripts/build-verify.sh`; CI uses `scripts/ci-compose-verify.py`.
|
||||
Both build the same Dockerfile with BuildKit and retain `docker compose build --pull`.
|
||||
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.
|
||||
|
||||
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:
|
||||
`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 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.
|
||||
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 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`.
|
||||
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
|
||||
@@ -60,7 +55,7 @@ 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
|
||||
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.
|
||||
|
||||
@@ -94,10 +89,14 @@ 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.
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# 商业化审计(2026-10-23)
|
||||
|
||||
审计基线:`ed3b61b`,版本 0.1.29。目标定位为单用户、自托管、多终端图片服务;不把多租户、微服务或复杂权限系统作为当前必需项。本次审计不修改业务、不执行设备写入、不删除生产数据。
|
||||
|
||||
## 结论
|
||||
|
||||
当前“照片来源 → 来源图库 → 多个生成方案 → 终端绑定单个来源及方案”的模型合理,适合继续发展;来源画面、生成版本、上传回执、设备实际显示状态分开管理也是正确的。无需重写技术栈。
|
||||
|
||||
当前适合受控内网使用和继续迭代,尚不能据此宣布达到面向外部客户的商业交付标准。主要差距集中在依赖安全、传输安全、上传流程闭环、存储失败一致性和产品表达统一。未发现经过验证的未授权接管或数据外泄事件;也不据此声称不存在其他漏洞。
|
||||
|
||||
优先级:P1 为对外交付前处理;P2 为下一轮产品完善;P3 为演进建议。以下“确认”指代码或本地复现已证实,“风险”表示存在可解释路径但没有执行攻击或故障压测。
|
||||
|
||||
## 2026-10-24 范围确认与本轮整改
|
||||
|
||||
用户确认产品为单用户、内网 IP 访问,接受 HTTP,不使用证书。A02 在此部署范围内是已接受风险,不作为交付阻塞项;未来暴露到不可信网络时重新评估。下文保留原始审计依据,不能将其当作当前全部未修复事项。
|
||||
|
||||
本轮对应已确认的改进:依赖升级并重新扫描、图片插件白名单、上传接入来源图库、文件/数据库事务及失败恢复、统一 `images:write` 权限目录、上传/图库术语和设备选择器去重、NAS 空批次继续读取提示、设置页磁盘容量提示、登录 scrypt 不占用数据库锁。补充故障注入及中英文、桌面/移动端流程验证。
|
||||
|
||||
本轮不引入 HTTPS、多用户、微服务或新数据库;原审计中的其他商业运营、外部服务条款和扩展能力建议继续作为条件性建议,不声称已完成外部认证。
|
||||
|
||||
## 一、对外交付前的问题
|
||||
|
||||
### A01 · P1 · 锁定依赖存在新公开安全告警(确认,利用条件需分别判断)
|
||||
|
||||
`requirements.lock:4,11,13` 固定 certifi 2026.7.22、opentelemetry-api 1.45.0、Pillow 12.3.0。本次 `pip-audit -r requirements.lock` 返回非零:3 个包、37 条告警,其中 Pillow 35 条。不能把这个数字等同于 37 个可利用的应用漏洞。
|
||||
|
||||
已核对公告及代码路径:
|
||||
|
||||
- Pillow 的 PNG EXIF 解析复杂度问题关联当前允许的 PNG 输入,以及 `renderers/images.py:30` 后的 `ImageOps.exif_transpose()`、`display/libraries.py:178` 后的 EXIF 读取。文件和像素上限不能充分限制元数据解析耗时。[公告](https://github.com/advisories/GHSA-g4qv-2jxx-769h)
|
||||
- XBM 解析告警发生于 `Image.open()` 内;当前在打开之后检查格式,不等于阻止识别阶段调用其他插件。建议所有不可信图片入口明确传入允许的 `formats`,再做格式、像素和字节检查。[公告](https://github.com/advisories/GHSA-xgh8-jp6q-p5x8)
|
||||
- certifi 告警涉及被撤销的中间证书对应信任链,影响使用该 CA 包且攻击者能够干预网络的 TLS 请求;NAS 指纹固定路径不能代表所有外部 HTTPS 路径均受同等保护。[公告](https://github.com/advisories/GHSA-6q48-6c7w-9gw2)
|
||||
- OpenTelemetry 告警涉及特定 tracestate 解析路径,本次没有证明项目启用了对应的入站解析,不将其写成已证实远程攻击面。[公告](https://github.com/advisories/GHSA-xcq7-p3pf-p88h)
|
||||
|
||||
建议:先升级有修复版本且兼容的依赖、重建镜像,再逐条确认剩余公告可达性。扫描建议版本包括 certifi 2026.10.19、OpenTelemetry API 1.45.1、Pillow 13.0.0;不是未经兼容验证的直接升级指令。部分公告没有给出修复版本,需要限定输入路径或记录有依据的暂缓项。验收要求是锁文件与镜像一致、关键渲染回归通过、告警逐条有处置结论。
|
||||
|
||||
### A02 · P1 · 当前管理入口使用 HTTP(确认)
|
||||
|
||||
实际访问入口使用 HTTP,线上可读取 `/ready`。`core/accounts.py` 根据请求 scheme 决定 Cookie 的 Secure 标志,HTTP 登录不会得到 Secure Cookie。密码、会话和家庭照片在该网络段缺少传输机密性;这是部署安全边界,不能只靠密码哈希解决。
|
||||
|
||||
建议商业交付通过 HTTPS 入口服务;明确可信反向代理、转发协议和 Origin 配置,验证登录、CSRF、Cookie Secure、HTTP 跳转后再按域名条件启用 HSTS。不要直接改转发头信任而扩大来源伪造风险。验收包含整个会话的 HTTPS,而不只是登录页。
|
||||
|
||||
### A03 · P1 · 手动上传没有接入新图库闭环(确认)
|
||||
|
||||
`web/app.js:126` 的“仅保存到本地图库”仍执行设备 upload 操作;成功提示“已保存到服务器,可用于模板与任务”。该资产不等于 `source_images` 中的来源图片,当前来源/终端图库也不是通用 assets 浏览器。移除旧图库后,新用户无法按按钮承诺的位置找回这类图片。
|
||||
|
||||
建议采用明确的“手动上传来源”,保存后走现有来源入库及方案流程;或先将入口准确命名为“保存为模板素材”,提供可访问的素材管理入口。不要再引入一个没有生命周期定义的第三套图库。验收:上传、保存、刷新、离开重进、按方案生成、终端使用均能找回同一图片。
|
||||
|
||||
### A04 · P1 · 文件与多表数据变更缺乏完整失败一致性(代码确认,故障损失量未测试)
|
||||
|
||||
`renderers/images.py:86` 先写文件再插入 assets;`display/libraries.py:166` 入库和 `:310` 生成分别提交资产、来源或版本及溯源记录。`remove_asset():645` 先 unlink,再逐表删除。`storage/database.py:79` 单次 execute 提交,不会自动把整个业务操作合成事务。进程退出、磁盘满、SQL 失败可能留下孤儿文件/记录,删除中断也可能留下指向缺失文件的记录。
|
||||
|
||||
建议复用现有 transaction,覆盖一个业务动作的数据库变更;文件采用临时文件及失败补偿,删除流程记录可重试状态。数据库事务不能回滚文件,因此两者都要处理。无需为此引入分布式事务。验收应注入一次写文件失败、一次 SQL 失败及一次删除中断,并验证可恢复或可清理,不误删仍被任务/终端引用的资产。
|
||||
|
||||
### A05 · P1(商业使用时)· 外部服务商业授权边界未完成(确认存在约束)
|
||||
|
||||
项目 MIT 许可和第三方 NOTICE 已有基础;但 `NOTICE.md` 明确 Open-Meteo 公共 API 按家庭非商业部署使用,商业部署需重新审查服务条款。开源项目许可证不能替代外部天气 API、厂商账号/API 及品牌使用授权。
|
||||
|
||||
建议在出售或收费托管前确认所启用服务的商业套餐、额度、品牌说明和数据使用边界;不需要启用的可选服务默认关闭。此项不是法律合规认证,也不意味着现有家庭使用已经违规。
|
||||
|
||||
## 二、安全与权限
|
||||
|
||||
### A06 · P2 · 图片写入权限无法单独授予(本地复现)
|
||||
|
||||
`core/auth.py:14` 的 ALL_SCOPES 缺少 `images:write`,而 `api/app.py:363,672,693` 要求该权限。`extensions/api.py:234` 拒绝未知权限。
|
||||
|
||||
隔离 Mock 实测:创建 `images:read` 客户端返回 201;同结构创建 `images:write` 返回 400 / `Unknown permission scope`。管理员可使用接口,但最小权限集成被迫使用更宽权限。这是权限目录与接口契约不一致,不是已证实的越权。
|
||||
|
||||
建议统一权限注册表,并测试所有 require(scope) 均可被授权,前端选项与 OpenAPI 使用同一来源。
|
||||
|
||||
### A07 · P2 · 登录计算占用共享数据库锁(风险)
|
||||
|
||||
`core/accounts.py:110` 附近登录函数在 `with db.lock` 中执行 scrypt,包括不存在的用户名。数据库线程共用 RLock,慢密码计算会阻塞其他需要该锁的操作。已有 IP 和账号失败限流有效降低重复尝试,但不消除并发或分散用户名的资源消耗。
|
||||
|
||||
建议将昂贵校验移到锁外,短事务更新尝试次数;必要时限制并发认证数量。验收使用本地少量并发请求,验证正常读取和调度不被明显拖慢。本次未向生产发送压力请求。
|
||||
|
||||
### A08 · P2 · 商业账号能力还需补齐(设计差距)
|
||||
|
||||
已有安全哈希、登录限流、CSRF、退出和改密撤销会话。会话目前为固定绝对过期(12 小时或记住 30 天),没有空闲过期和会话列表/逐个撤销界面。单用户是明确需求,应保留;可增加“其他设备退出”和可信恢复流程,无需引入用户组织体系。
|
||||
|
||||
密码至少 8 位符合当前用户要求。若面向单因素商业登录,应重新确定最低长度、弱密码拦截和可选第二因素,而不是未经确认改现有规则。NIST 当前指导中单因素最低 15 位、多因素中的密码最低 8 位,可作为产品策略参考,不据此宣称项目违法。[NIST](https://pages.nist.gov/800-63-4/sp800-63b.html)
|
||||
|
||||
### A09 · P2 · CSP 仍允许 inline script 和外部 CDN(确认)
|
||||
|
||||
`api/app.py:227` 允许 script-src unsafe-inline 及 jsDelivr,虽然 Swagger 已本地化。这削弱注入后的防护边界;本次未证明存在 XSS。建议盘点实际 inline 需求后移除多余域名,并逐步使用独立脚本或 nonce;不要未经验证一次性收紧导致界面失效。
|
||||
|
||||
## 三、数据模型与架构
|
||||
|
||||
### A10 · P2 · 当前名称与生成快照应持续明确区分(已有改善,需约束)
|
||||
|
||||
`display/libraries.py:236` 版本列表已集中关联当前方案名称,同时保留生成时 plan 快照、原存储名称及参数一致性标记。方案改名不应篡改历史参数,也不应导致重新生成;参数改变则应产生新版本。这一方向正确。
|
||||
|
||||
后续必须保持:关联使用稳定 ID;列表显示当前名称;历史参数明确标注生成时;下载文件名与物理文件名可以不同。建议把这套展示对象复用于图库、详情、任务和下载,避免各页自行读取 payload.name。代码抽查与现有回归不等于对所有历史数据库状态的完整证明。
|
||||
|
||||
### A11 · P2 · 数据关系主要依赖应用维护(确认)
|
||||
|
||||
SQLite 表存在跨表 ID 引用,但未形成统一外键/迁移版本体系,结构初始化和演进分布在模块中。单实例 SQLite 对当前规模合理;问题是关系变化后,删除/迁移/清理更依赖人工枚举引用。
|
||||
|
||||
建议新增结构版本和小型可重复迁移机制,逐步明确来源、方案、版本、资产、任务、回执的删除约束;先检查已有数据再决定增加外键,不强行一次重构所有表。保留用于幂等和当前状态判断的回执,不能因“历史”标签就删除。
|
||||
|
||||
### A12 · P2 · 容量与运行边界未形成交付承诺(风险)
|
||||
|
||||
进程内锁、单 Uvicorn worker、SQLite 和本地文件适合单实例;不能直接扩为多个副本共享同一目录。缓存有界不代表长期来源资产及多方案版本有界。当前未建立客户可见的存储水位、版本清理规则和满盘处理闭环。
|
||||
|
||||
建议先公布单实例支持边界,加入磁盘余量提示和可预览的按引用清理,不需要立即更换数据库或增加队列。商业验收补充典型 NAS 库、并发预览及长任务下的响应时间、峰值内存和满盘行为。
|
||||
|
||||
## 四、导航、UI 和关键流程
|
||||
|
||||
### A13 · P2 · 一级导航职责需要分组(设计审计)
|
||||
|
||||
桌面当前 12 个平级入口将核心操作、创作工具和系统管理混在一起。“来源图库”和“照片数据源”有真实职责差异,但新用户需要先理解;“模板”和“拼版与海报”也缺少关系解释。
|
||||
|
||||
建议保留现有页面,按“图库 / 创作 / 运行 / 管理”分组;来源图库负责照片及输出,照片数据源负责 NAS/网盘连接和筛选规则,连接中心负责外部服务凭据。首次流程以“连接来源 → 选入照片 → 方案 → 绑定终端 → 查看结果”串联。避免只改菜单名、不改页面职责。
|
||||
|
||||
### A14 · P2 · 全局设备选择器与图库局部终端选择重复(确认,认知风险)
|
||||
|
||||
全局 shell 仍选择设备,终端图库又以标签选终端;来源图库不依赖某个终端却同样显示全局设备。手表无传输能力时,“设备/相框/终端”容易混用。
|
||||
|
||||
建议只在依赖目标设备的页面显示全局目标,或统一由终端图库标签控制,并明确设备与手表输出档案的能力区别。所有按钮都依据能力显示状态和原因;“已生成、已上传、已在终端显示”继续分开,不能将操作成功等同于实物屏幕已显示。
|
||||
|
||||
### A15 · P2 · 文案和本地图库旧概念仍残留(确认)
|
||||
|
||||
`web/app.js` 上传页和 `web/i18n.js` 的 staging、uploadNotice 等仍表达“本地图库”,但当前用户模型已转为来源图库/终端图库。多个词典覆盖和页面内 tr() 分散维护,增加中英文错位的机会。
|
||||
|
||||
建议统一术语:照片数据源、来源图片、生成方案、成品版本、终端;为保存、生成、上传、显示分别定义动作词。将重复文案收敛到现有词典,不必更换前端框架。验收覆盖中英文按钮、禁用原因、错误提示、空状态、确认框和帮助文本。
|
||||
|
||||
### A16 · P2 · NAS 分页筛选不是完整全库索引搜索(确认)
|
||||
|
||||
`providers/source_browser.py:196` 每次最多读取 4 批,每批默认 36 条,再对多项条件本地筛选;人物等可利用提供方能力。这个设计保护响应时间,但筛选后当前页为空并不等于整个 NAS 没有符合结果;也没有准确的匹配总数。
|
||||
|
||||
建议明确“本批未找到,可继续扫描”,显示已扫描量和继续状态;需要全库快速定位时优先使用提供方原生搜索能力,仅在需求明确且测量后考虑持久索引。不能为了准确总数回到每次扫描整库。验收至少包括跨多批才命中的筛选、空批后继续、筛选变化重置游标和重复/漏项检查。
|
||||
|
||||
### A17 · P2 · 移动端与无障碍覆盖还不够完整(部分验证)
|
||||
|
||||
本次在 1440×1000、390×844 打开来源图库、终端图库、上传、设置页面,未发现 document 横向溢出;这些页面 main 内 input/select 的关联标签抽查无缺失。截图确认基础配色、圆角和按钮体系统一,移动端会将导航变为底部条。
|
||||
|
||||
但上述仅为隔离 Mock 的部分页面,其中来源图库为空状态,不是全部真实卡片/数据/错误状态验证。移动导航入口较多,发现性与滚动提示还需用户验证;未完成屏幕阅读器、全键盘任务链、对比度量测、长方案名和全部弹窗矩阵。不能据此声称 WCAG 合规。建议保留当前视觉系统,补任务级测试和可见焦点,不做纯审美重绘。
|
||||
|
||||
## 五、交付、运维、隐私和维护
|
||||
|
||||
### A18 · P2 · 运维基础有,但缺少客户服务级证据(部分确认)
|
||||
|
||||
已有 /health、/ready;/ready 检查 SQLite、设备配置、调度器和认证,不能误称只有进程存活检查。当前线上四项均正常。还需客户可理解的磁盘/供应商不可用提示、脱敏诊断导出、关键任务失败通知和明确支持边界。
|
||||
|
||||
代码和版本可恢复不等于数据库、凭据、设备绑定和方案数据可恢复。当前遵循项目“不保留备份”的明确运维选择,本次不创建或修改备份策略;商业交付前需明确该选择对应的数据丢失承诺与恢复责任。
|
||||
|
||||
### A19 · P2 · 隐私和数据生命周期需要面向用户表达(交付差距)
|
||||
|
||||
照片、人物名称、日期位置以及 NAS/厂商账号均属于需要清晰说明的数据。应说明哪些数据留本地、哪些请求发给哪个服务、预览与源文件的区别、删除是否涉及 NAS 原文件/设备云端、保留期限及账号撤销方式。已有 SSRF、凭据脱敏和证书指纹措施有价值,但不能替代用户可理解的说明。本次未作法律合规认证或外部供应商全面审计。
|
||||
|
||||
### A20 · P3 · 前端全局状态与大型模块增加改动风险(确认结构,非重写理由)
|
||||
|
||||
`web/app.js`、`web/libraries.js`、词典和追加 CSS 依赖共享 state/全局函数,部分页面模板为很长单行;后端 app/service 也承载较多职责。现有按领域拆分 providers/renderers/display/extensions 已有良好基础。
|
||||
|
||||
建议随实际功能修改逐步抽取共享展示对象、API 包装、确认框和状态枚举,合并重复 CSS/词条;不要为了“商业化”一次迁移框架或拆微服务。保留真正使用的旧任务兼容路径,依据路由、调用和测试证据删代码,不能仅凭无导航入口判定整个内部模块无用。
|
||||
|
||||
## 已有基础与不应误判之处
|
||||
|
||||
- scrypt 使用随机盐、常量时间比较;当前 N=2^14,r=8,p=5 属于 OWASP 列出的可接受配置之一,不应只看 N 就判定哈希不合格。[OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
|
||||
- Cookie HttpOnly/SameSite Strict、浏览器 CSRF 校验、登录来源检查、账号/IP 限流、令牌哈希和改密撤销会话已有实现;仍应通过 HTTPS 完成传输保护。[会话建议](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
|
||||
- 图片 URL 有 HTTPS、允许域名、私网地址及 DNS 连接约束;NAS 凭据传输有证书指纹校验;已有字节/像素限制。依赖解析漏洞说明这些措施仍需分层补强,而非这些措施全部无效。
|
||||
- 真实设备写保护、幂等和操作结果未知状态、引用删除保护、生成快照以及当前显示状态核对值得保留。
|
||||
- 单用户、单实例和 SQLite 与当前产品定位匹配;不需要为了审计引入租户、计费、角色矩阵或分布式架构。
|
||||
- MIT、NOTICE、字体和 Swagger 等第三方声明已存在,不能写成“项目没有许可证”。
|
||||
|
||||
## 建议整改顺序与验收
|
||||
|
||||
1. 安全基线:A01 依赖与图片插件入口、A02 HTTPS;完善基于可达性的告警处置。
|
||||
2. 核心业务:A03 上传找回与生成闭环、A04 原子性/补偿、A06 权限目录一致性。
|
||||
3. 产品统一:A10 当前引用/快照契约、A13–A16 导航、目标设备、术语、分页语义;统一覆盖页面与任务。
|
||||
4. 交付完善:A07–A09 认证资源和会话、A11–A12 迁移及容量、A17–A19 可访问性和运维隐私;确认启用外部服务商业条款。
|
||||
|
||||
最低商业交付验收:新用户能完成连接来源至终端使用全过程;改名/改参数/解绑/删除无引用错乱;图片失败和满盘不产生不可解释状态;HTTPS 会话与最小权限 API 可用;供应商失败、未知写结果和恢复责任有明确界面与说明。
|
||||
|
||||
## 本次验证及限制
|
||||
|
||||
- 代码审读覆盖认证/授权、请求中间件、存储、图库/方案、图片处理、NAS 分页、主要 UI、部署及许可证声明。
|
||||
- 隔离 Mock 复现权限目录缺陷;Playwright 检查 4 页面×2 尺寸并查看实际截图,未修改生产配置。
|
||||
- 执行全量 pytest 与依赖扫描,结果见文末验证记录。没有运行恶意图片、生产压测、外网渗透、硬件显示验收或商业法律审查。
|
||||
- 本报告为基于当前代码与抽查的工程审计,不是穷尽证明、安全认证或所有页面及业务组合的完整覆盖。
|
||||
|
||||
验证记录:pytest **665 passed(124.93s)**;依赖扫描 **37 条告警/3 个锁定包,未通过**;Mock 权限测试返回 **201/400**;8 个页面尺寸组合未发现横向溢出;线上 `/ready` 返回 ready,4 项检查为 true;`git diff --check` 通过。测试通过不抵消安全扫描失败。仅提交本审计文档,无业务修复、依赖升级、部署或正式发布。
|
||||
@@ -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
|
||||
|
||||
@@ -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. 有目标一致且同步完成的证据才记为成功。处理后恢复原计划,错过的任务不补发。
|
||||
|
||||
仍可能因厂商无法给出明确写入结果而需要人工处理;本策略解决错误误判、可安全重试的暂时失败和长期阻塞缺乏解释的问题,不承诺网络故障时所有设备任务都能自动成功。
|
||||
@@ -1,7 +1,11 @@
|
||||
# Synology NAS deployment / 群晖部署
|
||||
|
||||
当前生产服务已迁至 Ubuntu Server(参见 `scripts/server-release.py`)。以下内容仅适用于自行选择群晖容器部署的安装,不代表当前生产环境。
|
||||
|
||||
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 +41,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 +69,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 +130,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.
|
||||
|
||||
@@ -4,6 +4,10 @@ Photo sources are independent read-only providers. They never call a frame drive
|
||||
SQLite persists source definitions and Scheduler jobs; passwords are not stored there.
|
||||
The shared DisplayService handles fitting, upload, idempotency, overrides and restore.
|
||||
|
||||
## Synology Photos API (no mount)
|
||||
|
||||
Use the explicit `synology-photos` source type for Ubuntu without `/photos`. Existing local sources are not silently converted. Shared and current-account personal spaces use the shared Photos login, with ID-based any/all people filters and bounded XL preview caching. See [verified capabilities, migration and recovery](SYNOLOGY_PHOTOS_API.md). Original API downloads remain unsupported.
|
||||
|
||||
## NAS local folder
|
||||
|
||||
Add the actual existing photo directory to `.env`:
|
||||
@@ -30,7 +34,7 @@ change NAS permissions. Unreadable roots return an explicit HTTP 403 rather than
|
||||
an empty catalog.
|
||||
Missing host folders fail explicitly rather than being created automatically.
|
||||
|
||||
Open **Gallery → Photo sources → Add source**, choose **NAS local folder**,
|
||||
Open **Photo sources → Add source**, choose **NAS local folder**,
|
||||
enter a relative folder (`.` for root or `family` for a subfolder).
|
||||
Supported formats: JPEG, PNG, WebP. Optional recursion excludes hidden directories,
|
||||
Synology `@eaDir` thumbnail trees, and symlinks. Original files remain unchanged.
|
||||
@@ -134,7 +138,7 @@ caption still comes from the original curated directory.
|
||||
|
||||
1. 将共享图库(如 `/volume1/photo`)挂载为 `/photos`,保持只读。
|
||||
2. 在 `.env` 配置 `SYNOLOGY_PHOTOS_URL=https://NAS_IP:9115` 和 `SYNOLOGY_PHOTOS_CERT_SHA256`(服务实际 TLS 证书的 SHA-256 指纹,从可信 NAS 配置核实)。重建容器后生效。指纹变化时必须重新核实;每次连接在发送登录信息前校验同一个 TLS socket 的证书。
|
||||
3. 照片数据源 → 编辑数据源 → 连接 / 选择人物。管理员输入 NAS 用户名、密码;只保存 session 到 `/config/synology-photos-session.json`(0600),不保存密码。会话过期后需要重新连接;断开连接删除本地 session。页面没有 password/token 回显。
|
||||
3. 照片数据源 → 顶部共享连接 → 连接 Synology Photos。输入有权限的 NAS 用户名、密码;只保存 session 到 `/config/synology-photos-session.json`(0600),不保存密码。会话过期后需要重新连接;断开连接删除本地 session。页面没有 password/token 回显。登录由所有人物筛选数据源共享;随后进入各数据源编辑器选择人物。
|
||||
4. 选择人物,启用筛选,选择“任意”或“同时包含所有”。默认只列出已命名人物,可搜索 ID、展开未命名分组。至少选择一个人物。
|
||||
5. 与当前本地目录、日期文件夹规则取交集,再随机选图。原文件夹标题、日期、地点仍保留。不匹配返回空图库;预览 / 定时任务失败,不回退到全图库。
|
||||
|
||||
|
||||
@@ -23,3 +23,22 @@ The UI labels the top card **Shared connection**, displays source IDs and paths
|
||||
on each source card, and explains each source's connection dependency. Shared
|
||||
session disconnect is available only in the shared connection card and asks for
|
||||
confirmation of its impact on all people-filtered sources.
|
||||
|
||||
|
||||
## Gallery destination labels / 图库目的地标签
|
||||
|
||||
图库标签描述可保存图片的具体目的地,照片来源连接不自动成为图库目的地。
|
||||
当前为 `KoKonna 云端图库` 与 `<FRAME_FLOW_STORAGE_NAME> 本地图库`;
|
||||
NAS 名称通过部署配置设置,空值回退为 NAS,不使用容器 ID 代替 NAS 名称。
|
||||
英文使用同一实例名称与 cloud gallery / Local gallery。
|
||||
|
||||
`GET /api/v1/settings` 的 `gallery_destinations` 提供目的地 ID、适配器、
|
||||
实例/服务商名称、存储类型、连接状态和保存能力。只有连接有效、明确支持上传且
|
||||
前端存在对应适配器的目的地才进入标签栏。能力默认关闭;全局写入保护仍单独生效,
|
||||
目的地的保存能力不等于当前用户获准写入。
|
||||
|
||||
后续网盘接入须先实现其读取/保存适配器、校验授权及能力,再注册目的地。
|
||||
命名规则为“服务商/实例名称 + 云端图库”,例如已实现保存能力后才可展示
|
||||
“百度网盘·家庭 云端图库”。多个账号/实例必须能从名称区分。
|
||||
目前 Google Drive、阿里云盘、百度网盘和 WebDAV 仅作为只读照片来源,
|
||||
不出现在可保存目的地标签栏,不显示尚未实现的上传入口。
|
||||
|
||||
@@ -4,17 +4,30 @@ Runtime keys, passwords, sessions, private photos, databases and local `.env` fi
|
||||
must remain outside Git. Examples use documentation-only addresses and identities.
|
||||
Personal deployment reports and screenshots are excluded from public history.
|
||||
|
||||
The NAS Docker runner accepts trusted repository events only. External fork PRs
|
||||
The Gitea 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.
|
||||
|
||||
Private deployment configuration is stored in Gitea Actions Secrets:
|
||||
`PROD_DATA_DIR`, `PROD_CONFIG_DIR`, `PROD_PHOTOS_DIR`, `PROD_WEB_BIND`,
|
||||
`SYNOLOGY_PHOTOS_URL`, `SYNOLOGY_PHOTOS_CERT_SHA256`, and `PRODUCTION_ENV`.
|
||||
Project/image names, ports, enable flags and mirror settings remain Variables.
|
||||
Never print complete Docker inspect environment or secret values.
|
||||
Public CI verifies source only. Runtime configuration belongs in private environment
|
||||
files or the deployment system's secret store. Never print complete Docker inspect
|
||||
environment or secret values.
|
||||
|
||||
Publishing requires scanning all refs, removing private historic files, pruning
|
||||
unreachable remote Git objects and archiving/removing historical Actions logs.
|
||||
A private original mirror is retained outside the repository. Existing clones must
|
||||
reclone after the privacy history rewrite; do not merge the original history back.
|
||||
Before publishing, scan the current source and distribution files for secrets and
|
||||
private receipts. Keep Git history and existing remote releases. If a real historic
|
||||
secret is found, revoke it and handle history remediation as a separate operation.
|
||||
|
||||
## Private deployment inputs
|
||||
|
||||
Do not publish host addresses, workstation paths, disk UUIDs, photo-library counts,
|
||||
or deployment receipts. Do not include deployment receipts or recovery bundles in published archives. Local
|
||||
obsolete artifacts may be removed after verification; active runtime configuration,
|
||||
databases and images must be preserved.
|
||||
|
||||
`server-release.py` reads `FRAME_FLOW_ROOT` (default `/srv/frameflow`), requires
|
||||
`FRAME_FLOW_MOUNT_UUID` and `FRAME_FLOW_ORIGIN`, and retains the mount-identity
|
||||
check before release actions. Supply verified values privately to the sudo process;
|
||||
existing installations must set their existing root explicitly. Do not use the
|
||||
generic default to migrate a deployment. The script still requires the existing
|
||||
mount guard and does not change host mounts, services, or storage automatically.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 文档目录
|
||||
|
||||
安装、连接及日常使用从[中文首页](../README.zh-CN.md)或[英文首页](../README.md)开始。本目录收录仍适用的功能、配置与开发说明;版本变化见[版本记录](../RELEASE_NOTES.md)。
|
||||
|
||||
- [来源图库、生成方案与终端](SOURCE_LIBRARIES.md)
|
||||
- [自动拼图](AUTOMATIC_COLLAGE.md)
|
||||
- [构建、缓存与 CI](BUILDING.md)
|
||||
- [可选连接与扩展](EXTENSIONS.md)
|
||||
- [厂商请求失败与未知写入处理](KOKONNA_RECOVERY.md)
|
||||
- [Docker 与群晖安装](NAS.md)
|
||||
- [照片来源配置](PHOTO_SOURCES.md)
|
||||
- [照片来源与共享连接](PHOTO_SOURCE_CONNECTIONS.md)
|
||||
- [公开源码与隐私边界](PUBLICATION.md)
|
||||
- [真实画框测试步骤](REAL_DEVICE_TEST.md)
|
||||
- [厂商接口研究概览](RESEARCH.md)
|
||||
- [Synology Photos API 与来源迁移](SYNOLOGY_PHOTOS_API.md)
|
||||
- [Ticktar 外部集成契约](integrations/TICKTAR.md)
|
||||
- [厂商账号能力研究](research/KOKONNA_ACCOUNT_CAPABILITIES.md)
|
||||
- [厂商内容订阅研究](research/KOKONNA_CONTENT_SUBSCRIPTIONS.md)
|
||||
- [厂商本地控制研究](research/KOKONNA_LOCAL_CONTROL.md)
|
||||
- [开发与贡献](../CONTRIBUTING.md)
|
||||
- [安全问题反馈](../SECURITY.md)
|
||||
- [第三方声明](../NOTICE.md)
|
||||
@@ -9,7 +9,7 @@
|
||||
duplicates, name/date/dimensions anomalies. No download-all action.
|
||||
3. Stop on discrepancy. Missing model fields must be reported as unavailable.
|
||||
4. Once explicitly authorized, locally render **one** 1200×1600 test image containing
|
||||
PIGWAY FRAME FLOW TEST and Shanghai timestamp, no private content.
|
||||
PIGWay Frame Flow TEST and Shanghai timestamp, no private content.
|
||||
5. Record an operation ID and previous image; one upload only, no write retries.
|
||||
6. Poll device read-only no more often than 60 seconds, respecting the 20/min budget.
|
||||
Confirm imageId and synced. If sent but response lost, mark UNKNOWN, block writes,
|
||||
@@ -41,3 +41,7 @@ minute apart record `DISPLAY_NOT_CONFIRMED` and unblock future scheduled work.
|
||||
Temporary overrides retain their restore behavior and are not automatically
|
||||
closed as failed. Legacy operations without durable upload evidence require
|
||||
manual acknowledgement; absence from the gallery alone never proves failure.
|
||||
|
||||
### 错误与有限重试
|
||||
|
||||
完整错误矩阵、重试边界、持久化恢复状态和人工处理见 [KoKonna 恢复策略](KOKONNA_RECOVERY.md)。自动重试只允许只读请求、连接建立超时或明确限流拒绝;读取超时等未知写入不会重发。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# KoKonna API research / 接口调查
|
||||
|
||||
Checked 2026-10-01 against https://kokonna.art/pages/openapi.
|
||||
Rechecked 2026-10-16 against https://kokonna.art/pages/openapi.
|
||||
错误分类、重试和未知写入处理见 [KoKonna 恢复策略](KOKONNA_RECOVERY.md)。
|
||||
Base: https://api.kokonna.art/openapi. No fallback to legacy galaxyguide.cn.
|
||||
|
||||
| Official endpoint | Frame Flow capability |
|
||||
@@ -29,7 +30,7 @@ headroom at 18. Device state has a 60-second cache, gallery is loaded on demand.
|
||||
Uploads automatically activate a new image; there is no upload-without-activation
|
||||
flag. Gallery-only uploads are therefore local staging, not a fictitious cloud
|
||||
operation. Maximum upstream request size is 50 MB; this service accepts 20 MiB
|
||||
image files with bounded decoded pixels. No non-idempotent cloud write is retried.
|
||||
image files with bounded decoded pixels. Only proven-unsent connections or explicit rate-limit rejections may retry a write, within a three-attempt budget. Ambiguous writes are never replayed.
|
||||
Cloud acknowledgement is not physical refresh confirmation; /device synced and
|
||||
imageId drive the operation state. Unconfirmed or interrupted writes become UNKNOWN
|
||||
and block later device writes until read-only reconciliation/explicit acknowledgement.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# 来源图库、生成方案与终端
|
||||
|
||||
平台图库分为两类:来源图库与终端图库。照片数据源的每条配置对应一个来源图库,可以建立多个。每个来源图库拥有自己的生成方案,方案不能跨来源移动。每个终端最多绑定一个来源图库及其中一个方案;同一来源、同一方案允许多个终端共用。未完成绑定的终端不参与来源统一输出。
|
||||
|
||||
## 使用顺序
|
||||
|
||||
1. 在“照片数据源”配置来源、人物、分辨率、质量、去重等选图条件。
|
||||
2. 在侧栏“来源图库”选择来源,选入照片,保存无文字叠加的源画面。
|
||||
3. 新增该来源内的生成方案,可使用相框横版、竖版、手表预设,或自定义尺寸、适配方式、裁切位置、质量、色彩与说明叠加。
|
||||
4. 来源图库标签为“源画面”和该来源的生成方案;未生成的版本明确显示未生成,生成后可预览、下载、查看参数快照、删除。归档方案仍可查看已有版本。
|
||||
5. 在“终端图库”对每个终端单选来源和方案。切换来源后必须重新选择方案,服务端检查归属和屏幕尺寸。绑定同一方案的终端共用生成文件。
|
||||
6. 需要自动化时,为照片任务勾选“按来源图库统一生成并推送”。新建照片任务默认使用该模式;旧任务不在启动时自动改写,确认绑定后可在编辑任务时切换。唯一明确的单来源、单照片任务也可使用 `scripts/migrate-source-libraries.py` 预检,再加 `--apply` 迁移;脚本保留启用状态、调度配置和下次执行时间,不推送设备。若当前设备有可靠的照片来源回执,会选入该画面并仅在本地生成两个版本。生成模式不再额外选择旧版小图方案。
|
||||
|
||||
## 从来源选入照片
|
||||
|
||||
“从来源选入照片”打开独立选图页面,使用照片卡片、按需预览和前后翻页。Synology Photos 每批显示最多 36 张照片,直接按偏移量读取 NAS 元数据,不再先遍历整个目录。一次请求最多检查四批;筛选较少见的照片时,当前批可能不足 36 张或为空,仍可继续下一页检索。页码表示当前扫描位置,不提供未经全库扫描的虚假总数;NAS 内容变动时可重置筛选重新浏览。
|
||||
|
||||
可按文件名、文件夹关键字、文件大小、像素宽高、日期范围及识别人物组合筛选。保留来源本身的目录、递归、人物、方向、最小分辨率等限制。日期使用系统时区:入库日期取 NAS `indexed_time`,照片日期取 `time`,不将入库时间冒充原文件创建时间。人物数据使用 NAS 现有识别结果,不执行新的人脸识别。
|
||||
|
||||
预览懒加载 NAS 中图(`m`),选入时才下载用于本地生成的 XL 画面。已浏览的选择记录缓存最多 2048 条、有效期 30 分钟,并绑定来源配置和 NAS 会话;来源范围或登录账号改变后不能复用旧预览。选入会使用已校验的页面记录,避免再次全库读取和遍历所有人物相册。其他云盘继续使用现有目录接口,高级筛选与快速分页仅支持 Synology Photos。
|
||||
|
||||
## 一次选图,多种版本
|
||||
|
||||
任务先选定一次源画面并保存到本地,再按来源的终端绑定生成版本。默认等比裁切铺满,不拉伸、不补黑边。主动选择完整画面时必要处补白;说明叠加使用来源提供的日期、标题、地点等元数据。源画面本身始终不加文字。Synology Photos 当前取得的是可用源预览,不称为原图;本地存储按画面原尺寸归一化为 JPEG 并处理 EXIF 朝向。
|
||||
|
||||
每份成品关联源画面、方案 ID 和生成时的完整参数快照。来源标签、终端绑定、成品卡片及下载名称均通过方案 ID 读取当前名称;生成参数窗口单独标示生成时的名称和原始文件名。仅改名不影响像素,不重复生成,也不改写历史快照和操作回执。方案渲染参数变更后,旧成品显示旧参数提示;重新生成才产生新版本。相同源画面和相同渲染参数复用已有版本(名称及来源名称不计入渲染版本);修改方案后重新生成产生新版本,不覆盖历史图片。任务持久保存选图和方案快照,进程中断后继续处理该画面,不重新随机选图或重复提交同一终端输出。
|
||||
|
||||
相框通过现有上传队列和设备确认流程传输;“已生成”“待推送”“等待设备确认”“已确认显示”分别记录。来源任务完成代表生成和提交完成,不代表所有终端已显示;具体结果看终端图库的独立操作。终端当前画面已同步确认且关联到该成品时,推送按钮显示“已在终端显示”并禁用;仅有历史推送成功记录不足以认定当前仍在显示,离线或同步状态未知时也不作该判断。手表当前只生成 410×502 成品,传输尚未接入。某个方案生成失败不撤销其他输出,源画面保留,可以单独重新生成或重新推送失败目标。
|
||||
|
||||
在来源图库手动点击“随机选入并按绑定生成”只生成,不自动推送。终端图库的相框推送需要明确点击并确认,受全局设备写入开关约束。更换终端绑定后,尚未执行的旧版本推送会在设备写入前重新校验,不符合当前绑定时停止。
|
||||
|
||||
## 保留与删除
|
||||
|
||||
来源文件放在 `data/source-images/`,版本放在 `data/renditions/`,不进入旧的临时缓存自动清理范围。来源图库的星标与不再选用沿用照片来源偏好,二者互斥;不再选用只影响后续自动选图。
|
||||
|
||||
删除本地源画面会同时删除其本地生成版本,执行前检查任务、模板和待处理操作的引用;不会删除上游照片或设备已接收的副本。删除方案采用归档,历史版本保留快照;被终端绑定的方案必须先解除或更换绑定。包含绑定、活动方案或本地源画面的来源配置禁止直接删除。
|
||||
|
||||
旧本地成品、旧小图及现有云端图片数据保留,但旧图库页面和入口已退役。来源图库与终端图库只展示按新模型关联的图片,不凭文件名或尺寸将旧成品猜测为源画面。照片数据源配置、任务执行及已有小图任务的兼容接口继续保留;新任务使用来源内生成方案和终端绑定。
|
||||
|
||||
## API 与存储
|
||||
|
||||
- `/api/v1/source-libraries`:来源图库;`/{id}/images`、`/import`、`/run` 用于列表、选入与统一生成。
|
||||
- `/api/v1/source-libraries/{id}/browse`:按批浏览 NAS 元数据;`/browse/{item_id}/preview` 读取已浏览照片的小预览。
|
||||
- `/api/v1/generation-plans`:按 `source_id` 查询与创建方案;`/{id}` 修改或归档。
|
||||
- `/api/v1/source-images/{id}`:源画面删除;`/generate`、`/preview`、`/star`、`/exclusion` 用于版本生成、预览和来源偏好。
|
||||
- `/api/v1/library-terminals`:终端;`/{id}/binding` 管理单一绑定,`/{id}/images` 查询成品,`/{id}/images/{asset_id}/send` 显式推送。
|
||||
- `/api/v1/library-versions/{asset_id}`:删除本地成品版本。
|
||||
- `PUT /api/v1/jobs/{job_id}/library-mode`:使用 `jobs:write` 切换模式,保留下次执行时间和启用状态。
|
||||
|
||||
新增 `library_plans`、`source_images`、`library_versions`、`terminal_bindings`、`terminal_deliveries` 表,复用现有 SQLite、资源鉴权、Pillow 渲染及任务队列。`terminal_bindings.terminal_id` 是主键,保证终端仅有一个绑定;服务端同步校验来源、方案、版本归属以及屏幕尺寸。读取需要 `images:read`,管理需要 `admin`,本地删除需要 `images:delete`,终端推送需要 `display:write`。旧 API 保留兼容。
|
||||
|
||||
## 手动上传与本地存储
|
||||
|
||||
“上传图片”可将无叠加的源画面保存到所选来源图库;默认“手动上传”来源在首次成功上传时创建,不需要 NAS 连接。保存后通过“查看来源图库”找回图片,再建立方案和终端绑定。手动来源的随机选图及任务沿用同一套来源流程,不创建另一套图库。上传到已有来源图库只保存本地副本,不写回 NAS 或网盘。源画面按输入像素尺寸及 EXIF 朝向归一化,预览的相框裁切、色彩不会改变保存的源画面;实际输出由生成方案决定。相同内容在同一来源重复保存会复用图片。
|
||||
|
||||
本地上传使用 `POST /api/v1/source-libraries/{id}/upload`,链接导入使用 `/upload-url`,二者要求 `images:write`,保留图片大小、格式及 URL 安全限制。`manual-uploads` 是内建手动来源的保留 ID;设置页管理本地存储,照片数据源页管理远程连接。
|
||||
|
||||
资产写入及相关来源/版本记录共享数据库事务。文件先落临时文件并同步后再提交资产;未完成创建和已提交删除通过持久化文件记录在启动/维护时恢复清理。缓存转为持久成品时也保留可恢复的源/目标状态。删除不会在数据库事务成功前移除图片文件;清理失败会留下可重试记录,设置页显示待清理数量。清理记录不是照片备份,不会保留已删除图片供恢复。
|
||||
|
||||
设置页显示数据盘总容量和可用容量;可用低于 512 MiB 或总容量的 5% 时提示空间不足。容量提示不自动开启清理策略,也不自动删除来源图片或成品。
|
||||
@@ -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 规则;本次没有删除。
|
||||
|
||||
实际部署回执和照片库统计应私下保管,不提交公共仓库。
|
||||
@@ -1,36 +0,0 @@
|
||||
# UI layout audit
|
||||
|
||||
The Mock-only `scripts/ui-layout-audit.cjs` checks all 11 navigation pages,
|
||||
local/cloud gallery layouts and six modal states plus expanded manual collage at 1440, 768 and 390 CSS pixels,
|
||||
in Chinese/English and light/dark themes. It checks page horizontal overflow and
|
||||
at least 8 pixels between visible input/select/textarea/button rectangles, and
|
||||
44-pixel control heights on mobile.
|
||||
Closed details, background content behind dialogs and hidden fields are excluded.
|
||||
Photos authentication is intercepted with fixture credentials; no NAS login or
|
||||
frame write occurs. Populated task/history fixtures remain in disposable Mock data.
|
||||
|
||||
This audit found and corrected missing upload preview spacing, nested task field
|
||||
spacing, small mobile action gaps and inconsistent dynamically inserted form
|
||||
spacing. NAS source forms now hide irrelevant cloud credential fields. Native
|
||||
drive forms expose their credential field and hide local-only quality controls.
|
||||
|
||||
Synology Photos has a dedicated connection card on the Photo Sources page, with
|
||||
status, an explanation of affected automation and a direct NAS sign-in dialog.
|
||||
Folder settings and person selection remain separate. Credentials are not saved;
|
||||
the existing protected session API is used. Successful login does not run a job
|
||||
or change frame content. Passwords are cleared from the dialog before submission.
|
||||
|
||||
Run against an isolated Mock server with the existing Playwright runtime:
|
||||
|
||||
```sh
|
||||
FRAME_FLOW_URL=http://127.0.0.1:18789 \
|
||||
FRAME_FLOW_ENV_FILE=/path/to/mock.env \
|
||||
FRAME_FLOW_TEST_USERNAME=test-ui \
|
||||
FRAME_FLOW_TEST_PASSWORD=test-only-password \
|
||||
node scripts/ui-layout-audit.cjs
|
||||
```
|
||||
|
||||
Set `NODE_PATH` to the installed Playwright runtime if needed. Optional
|
||||
`FRAME_FLOW_SCREENSHOT_DIR` saves fixture-only screenshots for visual review.
|
||||
The full audit passed 229 checks with no console errors. This complements
|
||||
`ui-smoke.cjs` and `ui-integrations.cjs`; it does not replace functional tests.
|
||||
@@ -1,83 +0,0 @@
|
||||
# Frame Flow 项目审计 — 2026-10-05
|
||||
|
||||
审计基线:794a7e1485cc9046be3e77f6557c75740305ac47。
|
||||
范围:本地源码、配置、Mock 测试与依赖审计;不包含本次 NAS 状态验证、真实账号登录或真实设备写入。未修改业务代码。
|
||||
|
||||
## 发现
|
||||
|
||||
### P1:队列扫描上限导致后续高优先级告警饥饿
|
||||
|
||||
`display/service.py:222` 只取按创建时间排序的前 50 个 QUEUED 操作,再跳过
|
||||
被 override 阻挡的低优先级请求。如果这 50 项全被阻挡,第 51 项更高优先级
|
||||
告警每一轮都无法被扫描到;其他设备请求与本地渲染也可能受影响。
|
||||
|
||||
Mock 复现:激活 priority=50 的一小时 override,排入 50 个 priority=10
|
||||
请求,再排 priority=90 请求,调用 process_one 后后者仍为 QUEUED。
|
||||
建议先筛选可执行请求,或分页扫描直到找到可执行操作;增加跨设备与优先级回归测试。
|
||||
|
||||
### P2:会话失效未更新连接状态,并误清第一方登录
|
||||
|
||||
`integrations/kokonna_official/rpc.py:44-51` 在官方返回“请重新登陆”时抛出
|
||||
UNAUTHORIZED,但没有使官方 session 失效。`connection.py:104-113` 只依据
|
||||
本地 token 非空,继续显示 authorized=true。`web/app.js:17` 将任意 API 401
|
||||
都当作 Frame Flow 登录失效并执行 forgetLogin,包括外部官方会话过期。
|
||||
|
||||
Mock 复现:RPC 返回 HTTP 500 / error -1 / 请重新登陆后,authorized 仍 true。
|
||||
建议区分第一方认证与外部账号过期,持久化失效状态,保留 Frame Flow 登录,
|
||||
在官方区域提供重新连接提示。
|
||||
|
||||
### P2:损坏的可选官方会话文件阻止整个服务启动
|
||||
|
||||
`connection.py:44-51` 在构造函数中直接解析 JSON 并索引字段,没有异常隔离。
|
||||
DeviceRegistry 在应用启动时构建该对象。损坏 JSON、缺字段或文件不可读会
|
||||
使可选功能故障升级为全服务启动失败,影响 API Key、UI 和 Scheduler。
|
||||
|
||||
Mock 复现:官方 secret 文件内容为 `{broken`,create_app 抛 JSONDecodeError。
|
||||
建议隔离并禁用该连接,记录不含文件内容的诊断;其他核心功能继续启动。
|
||||
|
||||
### P2:持久化失败导致断开状态与重启状态不同
|
||||
|
||||
`connection.py:122-127,146-157` 先修改内存,再保存。磁盘满、权限错误等
|
||||
导致保存失败时没有回滚。尤其 disconnect 已清内存,而磁盘仍保留旧 token,
|
||||
重启后又恢复旧连接;虽然原请求返回错误,随后状态查询却会显示未连接。
|
||||
|
||||
Mock 复现:让 _save 抛 OSError;当前 authorized=false,重新构造连接后 true。
|
||||
建议先原子持久化候选状态,再提交内存状态;失败时保持一致并提供明确错误。
|
||||
|
||||
### P2:Request ID 脱敏遗漏官方会话
|
||||
|
||||
`api/app.py:148-150` 仅检查 API Key、Frame Flow Token、registry.keys,
|
||||
没有检查用于官方会话的 settings.additional_secrets。符合 Request ID 长度和
|
||||
字符限制的会话值若被客户端误用为 X-Request-ID,可被响应头和错误模型原样回显。
|
||||
本问题不表示未授权者能凭空取到 token,而是违反已有 secret 不回显约束。
|
||||
|
||||
Mock 复现:完成模拟官方连接后,将 synthetic session `mock-session` 作为
|
||||
X-Request-ID 发给 /health,响应原样返回该值。建议统一使用完整 secret 集合。
|
||||
|
||||
## 验证
|
||||
|
||||
- pytest:154 passed,0 failed,0 warning。
|
||||
- Ruff:通过(应用和 tests)。
|
||||
- mypy:36 个源码文件通过。
|
||||
- pip-audit(requirements.lock):No known vulnerabilities found。
|
||||
- 五项专项 Mock 复现均确认上述行为,不调用真实 KoKonna。
|
||||
- 没有因发现问题而启用官方写方法;严格 RPC allowlist 仍阻止未启用方法。
|
||||
|
||||
## 边界与下一步
|
||||
|
||||
测试全绿不代表无缺陷:现有套件没有覆盖以上失败场景。
|
||||
本轮没有修复或部署;建议先修复队列饥饿,再统一外部账号状态与第一方认证,
|
||||
然后补齐会话文件容错、持久化事务一致性和 Request ID 脱敏测试。
|
||||
依赖审计仅表示本次查询未发现已知漏洞,不是全部依赖安全的证明。
|
||||
|
||||
## 修复与复验
|
||||
|
||||
上述五项已修复:队列使用 keyset 分页查找可执行操作;官方账号失效使用
|
||||
独立 `OFFICIAL_AUTH_REQUIRED` 错误码并清除会话,前端保留第一方登录且提供
|
||||
重新连接入口;可选 secret 解析失败不再阻止核心启动;持久化候选状态成功后
|
||||
才提交内存变更;Request ID 检查涵盖官方会话凭据。
|
||||
|
||||
复验:164 passed、0 warning(新增 10 项回归测试);Ruff 通过;mypy 36 文件
|
||||
通过;113 项布局检查通过;官方过期/第一方登录隔离浏览器检查通过。
|
||||
已知本地凭据对照历史扫描通过(278 个 blob);该扫描不等同于发现所有未知
|
||||
secret 的通用审计。本轮不部署 NAS,不执行真实设备写接口。
|
||||
@@ -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`
|
||||
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
@@ -1,380 +0,0 @@
|
||||
# Gitea Actions production CD v1
|
||||
|
||||
## Audit and adoption boundary
|
||||
|
||||
Production `docker-compose.yml` is unchanged: AMD64 image, UID/GID 10333,
|
||||
`unless-stopped`, read-only rootfs, `/tmp` tmpfs, dropped capabilities, limited
|
||||
logs, `/data` and `/config` bind mounts, default private bridge, and configurable
|
||||
host bind/port. SQLite/images/generated/cache live under `/data`; accounts,
|
||||
vendor sessions and configuration live under `/config`. Neither is disposable.
|
||||
The optional photos mount is read-only and has supplementary group access.
|
||||
|
||||
Historical repository receipts mention `/volume1/docker/pigway-frame-flow/data`,
|
||||
`/volume1/docker/pigway-frame-flow/config`, `/volume1/photo`, port 18787, and
|
||||
container/project `pigway-frame-flow`. These are **confirmation candidates**, not
|
||||
script defaults. The deploy action accepts only explicitly configured paths
|
||||
that match the existing running container on the Runner's Docker daemon.
|
||||
It rejects different projects, ports, unknown mounts, host/custom networking,
|
||||
missing photos confirmation, or different user/restart policy. The deploy action does not create NAS host directories or migrate/adopt an unrelated
|
||||
container. First-time directory creation is limited to the manual bootstrap below.
|
||||
|
||||
CI uses `ffci-*` projects, per-run image tags and named volumes, no host ports.
|
||||
Its cleanup does not refer to any production resource. Production uses fixed
|
||||
configured names and existing bind mounts, with `create_host_path: false`.
|
||||
|
||||
## Gitea configuration
|
||||
|
||||
Repository Settings → Actions configuration (private paths/addresses use Secrets):
|
||||
|
||||
| Variable or Secret | Value to explicitly confirm |
|
||||
| --- | --- |
|
||||
| `PROD_DEPLOY_ENABLED` | Initially `false`; change to `true` only after plan acceptance |
|
||||
| `PROD_PROJECT` | Existing Compose project label; candidate `pigway-frame-flow` |
|
||||
| `PROD_CONTAINER` | Existing container name; candidate `pigway-frame-flow` |
|
||||
| `PROD_IMAGE_REPO` | Fixed local image repository; suggested `pigway-frame-flow` |
|
||||
| `PROD_DATA_DIR` (Secret) | Existing absolute NAS host data path; no default |
|
||||
| `PROD_CONFIG_DIR` (Secret) | Existing absolute NAS host config path; no default |
|
||||
| `PROD_PHOTOS_DIR` (Secret) | Existing read-only photos source if present; otherwise empty |
|
||||
| `PROD_WEB_BIND` (Secret) | Exact existing Docker HostIp (`0.0.0.0` or NAS LAN IP etc.) |
|
||||
| `PROD_WEB_PORT` | Exact existing mapped port |
|
||||
| `PROD_STATE_VOLUME` | Dedicated new deployment journal volume, e.g. `pigway-frame-flow-cd-state` |
|
||||
| `PROD_MOCK` | Explicit `true`/`false`, consistent with intended production mode |
|
||||
| `PROD_WRITE_ENABLED` | Explicit `true`/`false`; preserve owner's existing write policy |
|
||||
|
||||
The state volume must be separate from application/CI storage. An existing
|
||||
volume without the matching `frameflow.deploy.project` label is rejected.
|
||||
Supplementary groups are read from the existing container; no host ACL changes.
|
||||
Keep existing `DEBIAN_MIRROR`, `DEBIAN_SECURITY_MIRROR`, `CI_PIP_INDEX_URL`, and
|
||||
`CI_PIP_FALLBACK_INDEX_URL` settings from Verify; CI/production builds share the
|
||||
Dockerfile, pinned base, lockfile, and BuildKit caches.
|
||||
|
||||
Repository Settings → Actions → Secrets:
|
||||
|
||||
`PRODUCTION_ENV`: multiline **unquoted** `KEY=value` entries, without shell
|
||||
expansion. At least:
|
||||
|
||||
```dotenv
|
||||
FRAME_FLOW_TOKEN=<existing Frame Flow administrator token>
|
||||
KOKONNA_API_KEY=<existing KoKonna key, or empty when mock>
|
||||
```
|
||||
|
||||
Optional additional runtime secret variables can be included. No key values are
|
||||
committed, passed to image builds, or printed. Nonsecret mock/write flags use
|
||||
Variables. Other existing runtime environment values are retained. The script
|
||||
sets commit/build time and internal data/config paths explicitly. Dollar signs
|
||||
in secret values are escaped before Compose interpolation. Snapshots containing
|
||||
old runtime secrets are stored only in a private journal file (0600, directory
|
||||
0700) on the dedicated Docker volume; Docker administrators can access it.
|
||||
Never upload that journal or a full Docker inspect as a public CI artifact.
|
||||
|
||||
## First safe trial
|
||||
|
||||
1. On NAS, inspect only the necessary fields (use existing Docker/sudo access):
|
||||
```sh
|
||||
docker inspect pigway-frame-flow --format '{{index .Config.Labels "com.docker.compose.project"}}'
|
||||
docker inspect pigway-frame-flow --format '{{json .Mounts}}'
|
||||
docker inspect pigway-frame-flow --format '{{json .HostConfig.PortBindings}} {{.HostConfig.NetworkMode}}'
|
||||
```
|
||||
Confirm the Variables/Secrets against these actual values, including photos.
|
||||
2. Configure the Secret privately. Keep `PROD_DEPLOY_ENABLED=false`.
|
||||
3. Manually run **Verify Frame Flow** on `main`, `deploy_action=plan`.
|
||||
Verify must pass first. Plan checks resource identity, paths/port, required
|
||||
Secret keys, current `/ready`, smoke endpoints and commit. It performs no
|
||||
build, stop, restart, tagging, lock creation, volume creation, or data writes.
|
||||
If no production Variables exist, it reports configuration missing and does
|
||||
nothing; that is an unconfigured plan, not production acceptance.
|
||||
4. Review `PLAN ONLY` output and arrange a maintenance window for the first real
|
||||
deploy/restart. Set `PROD_DEPLOY_ENABLED=true`, manually run `deploy_action=deploy`.
|
||||
5. After `DEPLOY VERIFIED`, subsequent pushes to `main` run Verify → deploy.
|
||||
Pushes to other branches and pull requests never deploy. A manual run defaults
|
||||
to plan, even when automatic deployment is enabled.
|
||||
|
||||
The deploy action supports updates to an existing healthy deployment. For first-time
|
||||
creation use the explicit manual bootstrap flow below. No real production deployment or rollback drill has
|
||||
been performed during implementation; those are first-run acceptance steps.
|
||||
|
||||
## Deployment transaction and rollback
|
||||
|
||||
1. Verify job must succeed completely, including isolated Docker tests/cleanup.
|
||||
2. Deploy job checks out the same revision and prepares tools. An atomic
|
||||
`docker create` reservation named `<PROD_CONTAINER>-deploy-lock` serializes
|
||||
production changes on the daemon. Job `concurrency` is also declared, but
|
||||
correctness does not depend on Gitea implementing it. A competing deploy
|
||||
fails safely instead of changing production. No other job may remove a lock
|
||||
whose owner label differs from its own.
|
||||
3. Re-read the current baseline under the lock; confirm ready/smoke. Record exact
|
||||
image ID, runtime Compose/environment, version/commit. Pin the old image with
|
||||
an immutable rollback tag so normal dangling-image cleanup cannot remove it.
|
||||
4. Refuse a stale checked-out `main` revision that no longer equals remote main.
|
||||
Build an immutable `release-<commit>-<owner>` tag locally, without a registry
|
||||
then point `latest` at the candidate; CI image tags are never changed.
|
||||
5. Persist a `prepared` journal with old/new runtime configs. Create an additive
|
||||
SQLite online backup under `/data/deploy-backups/<owner>.db`. No data/config
|
||||
directory or file is removed, replaced or recursively chowned.
|
||||
6. `compose up -d --no-build --pull never` replaces only the configured service.
|
||||
Check `/ready`, the unchanged nine-endpoint read-only smoke test, and exact
|
||||
`/api/v1/version` commit. Only all checks passing produce `DEPLOY VERIFIED`.
|
||||
7. Any activation/verification failure runs `up` again with the saved exact old
|
||||
image ID and old runtime environment/mounts, then repeats ready/smoke/commit
|
||||
verification. A failed deploy remains a failed workflow even if rollback
|
||||
succeeds. If rollback fails, the journal/lock are retained, failure is explicit,
|
||||
and redacted scoped diagnostics are emitted.
|
||||
8. An `always()` step uses the recorded owner to recover an interrupted prepared
|
||||
transaction and release only its own lock. Abrupt host loss or termination of
|
||||
the entire runner can prevent both handlers: inspect the journal/lock before
|
||||
manually recovering; never automatically force-unlock an unknown owner.
|
||||
|
||||
Rollback is an **image/config rollback**, not an automatic database rewind.
|
||||
User data accumulated during deployment remains. The SQLite backup is for
|
||||
operator-reviewed recovery; destructive or backward-incompatible future schema
|
||||
changes must not be auto-deployed without a separate migration/backup plan.
|
||||
Baseline artifacts and additive backups are retained for the operator; there is
|
||||
no automated pruning of production rollback history or data.
|
||||
|
||||
After enabling CD, use its workflow/script as the production lifecycle owner.
|
||||
The old NAS checkout's Compose/.env files are not rewritten remotely by CD and
|
||||
may describe an older deployment. Do not concurrently recreate production from
|
||||
that old checkout. The repository's standalone Compose remains usable for an
|
||||
independent deployment; the private journal records the CD runtime configuration.
|
||||
|
||||
## Actual commit and manual rollback
|
||||
|
||||
```sh
|
||||
curl -fsS http://NAS_LAN_IP:PORT/api/v1/version
|
||||
# Check `commit`, `version`, and `build_time` against successful deployment output.
|
||||
docker inspect PRODUCTION_CONTAINER --format '{{.Image}}'
|
||||
```
|
||||
|
||||
For a manual rollback: keep CD enabled and run **Verify Frame Flow** on `main`
|
||||
with `deploy_action=rollback`. After Verify passes, the script locks production,
|
||||
loads the last journal's `previous` image/config and verifies recovery. This is
|
||||
last-baseline rollback, not an arbitrary image selector. Never use `down -v`,
|
||||
volume pruning, or delete data/config to roll back.
|
||||
|
||||
If a failed run retained a lock, inspect only its owner label:
|
||||
|
||||
```sh
|
||||
docker inspect PRODUCTION_CONTAINER-deploy-lock --format '{{index .Config.Labels "frameflow.deploy.owner"}}'
|
||||
```
|
||||
|
||||
With the documented Variables configured in a trusted Docker-enabled shell:
|
||||
|
||||
```sh
|
||||
FRAME_FLOW_DEPLOY_OWNER=<confirmed failed owner> CD_ACTION=recover python scripts/production-deploy.py
|
||||
```
|
||||
|
||||
`recover` retries that owner's journaled rollback and retains the lock again if
|
||||
recovery fails. If the very first attempt stopped before a journal was written,
|
||||
confirm that the original image is still running and healthy before an operator
|
||||
removes the stale lock. Do not delete a live lock or any persistence volume.
|
||||
|
||||
## First-time production bootstrap (explicit manual action)
|
||||
|
||||
The confirmed NAS residue is a `created`, never-started container with dead
|
||||
`/workspace/way/...` bind paths. It is not an adoption/rollback baseline.
|
||||
No migration from those paths is performed. Production Compose remains independently usable.
|
||||
|
||||
Set the ordinary repository Actions Variables below; set paths and addresses as Secrets (see docs/PUBLICATION.md):
|
||||
|
||||
```text
|
||||
PROD_DEPLOY_ENABLED=true
|
||||
PROD_PROJECT=pigway-frame-flow
|
||||
PROD_CONTAINER=pigway-frame-flow
|
||||
PROD_IMAGE_REPO=pigway-frame-flow
|
||||
PROD_DATA_DIR=/volume1/docker/pigway-frame-flow/data
|
||||
PROD_CONFIG_DIR=/volume1/docker/pigway-frame-flow/config
|
||||
PROD_WEB_BIND=192.0.2.10
|
||||
PROD_WEB_PORT=18787
|
||||
PROD_STATE_VOLUME=pigway-frame-flow-cd-state
|
||||
PROD_MOCK=true
|
||||
PROD_WRITE_ENABLED=false
|
||||
```
|
||||
|
||||
Leave `PROD_PHOTOS_DIR` unset/empty. Existing mirror Variables still apply.
|
||||
Enabling CD does not enable real frame writes. Before the first successful bootstrap,
|
||||
ordinary main pushes report `production not bootstrapped / bootstrap required`.
|
||||
They cannot create production. `bootstrap` additionally requires the Actions event
|
||||
`workflow_dispatch`; the default action remains `plan`. Every action is gated by
|
||||
successful complete Verify on main.
|
||||
|
||||
Create repository Actions **Secret** `PRODUCTION_ENV` as multiline bare `KEY=value`:
|
||||
|
||||
```dotenv
|
||||
FRAME_FLOW_TOKEN=replace-with-a-long-random-private-token
|
||||
KOKONNA_API_KEY=
|
||||
```
|
||||
|
||||
Do not add shell quotes, `export`, interpolation or spaces around `=`. An existing
|
||||
KoKonna key may be supplied but first bootstrap forcibly uses Mock with writes off.
|
||||
Optional application secrets/settings may be added in the same format; bootstrap
|
||||
always overrides Mock/write flags, data/config paths, timezone and build identity.
|
||||
This token remains useful for API/smoke checks even if Web UI uses account login.
|
||||
Do not paste the real Secret into logs or repository files.
|
||||
|
||||
### Safety and cleanup
|
||||
|
||||
Bootstrap builds the verified main revision first, then acquires the same atomic
|
||||
Docker daemon deployment lock used by deploy. Host path checks run in a short-lived,
|
||||
network-disabled helper with **read-only** NAS root mounted, not CI workspace paths.
|
||||
Synology does not automatically create missing bind sources with `-v`. A temporary
|
||||
network-disabled helper mounts the explicitly configured existing parent directories
|
||||
and creates only their data/config leaves, owned `10333:10333`, mode `0750`. It never
|
||||
mounts NAS root writable, recursively changes parent permissions, or deletes contents.
|
||||
Parents must already exist; missing parents fail closed. Nonempty directories, symlinks
|
||||
(including ancestor symlinks), overlapping paths and unknown journal resources fail closed.
|
||||
|
||||
A same-name residue can be removed **without force** only after checking all of:
|
||||
Compose project/service labels; `Status=created`; `Running=false`;
|
||||
`StartedAt=0001-01-01T00:00:00Z`; exactly two bind mounts `/data` and `/config`;
|
||||
and both old host sources absent. A running, previously started, unknown or data-bearing
|
||||
container is refused. An existing default network must be owned by this Compose
|
||||
project and contain no endpoints other than the validated residue.
|
||||
|
||||
The production service uses `create_host_path=false`, the explicit NAS LAN bind,
|
||||
non-root UID, `unless-stopped`, normal bridge network and existing resource/security
|
||||
limits. Acceptance runs ready, all read-only smoke endpoints, version/commit,
|
||||
preflight mount/port/project/user/restart checks, one restart, repeated checks,
|
||||
Docker health and exact built-image ID before `BOOTSTRAP VERIFIED`.
|
||||
|
||||
On failure after transaction preparation, cleanup removes only this transaction's
|
||||
owner-labelled new container and its newly created, empty, project-labelled network.
|
||||
The previously validated never-started residue has no rollback value and is not recreated.
|
||||
**Data/config files, images/build cache and the private CD journal volume are retained.**
|
||||
No `down -v`, volume prune or removal of unrelated/CI resources is used.
|
||||
Cleanup failure retains the lock so the always recovery step can retry using the journal.
|
||||
Only an explicitly dispatched bootstrap may retry a failed first-install journal, and
|
||||
only when its project/container/mounts/port match, its volume ownership matches, and
|
||||
target directories remain empty or absent. Prepared/success/foreign journals are refused.
|
||||
On a new transaction, the prior journal is archived as `deploy.<owner>.json` inside
|
||||
the private state volume before replacing the active journal; generated data is never
|
||||
automatically emptied and journals are never deleted.
|
||||
Abrupt host/runner loss still requires operator review of the lock/journal.
|
||||
The journal is private (0700/0600) and may contain prior/candidate secrets; never export it
|
||||
as an Actions artifact. First bootstrap has no prior healthy version to roll back to.
|
||||
|
||||
### First run and acceptance
|
||||
|
||||
1. In Gitea repository **Actions → Verify Frame Flow → Run workflow**, choose branch
|
||||
`main`, action **plan**. This is read-only and reports bootstrap required for the
|
||||
absent/created instance; it is not a bootstrap safety acceptance. Detailed host
|
||||
residue/directory checks happen under the lock in bootstrap.
|
||||
2. Run the same workflow on `main` with action **bootstrap**. Verify runs in full first.
|
||||
No NAS bootstrap is executed merely by committing this implementation.
|
||||
3. Require `BOOTSTRAP VERIFIED` in the deploy log. In Container Manager confirm
|
||||
container `pigway-frame-flow` is running/healthy, user 10333, restart unless-stopped,
|
||||
exact `/volume1/docker/pigway-frame-flow/{data,config}` mounts and
|
||||
`192.0.2.10:18787` binding (not loopback).
|
||||
4. Open `http://192.0.2.10:18787/api/v1/version`; its `commit` must equal the full
|
||||
workflow checkout commit. Check `/ready` returns HTTP 200. The immutable image tag
|
||||
is `pigway-frame-flow:latest`; compare its image ID in
|
||||
Container Manager with the running container.
|
||||
5. Keep `PROD_DEPLOY_ENABLED=true`. Subsequent main pushes Verify then deploy using
|
||||
this same runtime and journal mechanism. The first update records bootstrap as
|
||||
its rollback baseline. Repeating bootstrap against this instance is refused.
|
||||
Enable real-device read-only access and write access only through separate explicit
|
||||
operator decisions; no automatic change is made by bootstrap.
|
||||
|
||||
Docker tests are mocked locally. The first manual NAS Runner bootstrap, including
|
||||
real host mounts and restart, remains the required live acceptance.
|
||||
|
||||
## Actions download transport
|
||||
|
||||
Verify and deploy reference official GitHub Actions directly, pinned to immutable
|
||||
commits rather than moving tags:
|
||||
|
||||
| Action | Official upstream commit |
|
||||
| --- | --- |
|
||||
| actions/checkout v4 | 11d5960a326750d5838078e36cf38b85af677262 |
|
||||
| actions/setup-python v5 | a26af69be951a213d495a4c3e4e4022e16d87065 |
|
||||
|
||||
The workflow uses explicit `https://github.com/actions/...` URLs so Gitea's
|
||||
configured default Actions host cannot redirect downloads to the project owner's
|
||||
server. Forks download Actions through their own Runner/network; no public action
|
||||
mirror is served by this project. The former Tea auxiliary repository is private
|
||||
and is no longer required by current workflows.
|
||||
|
||||
Keep the Runner's action cache and Python toolcache persistent. For Gitea Runner
|
||||
versions supporting `cache.offline_mode`, use a private Runner configuration:
|
||||
|
||||
```yaml
|
||||
runner:
|
||||
file: /data/.runner
|
||||
cache:
|
||||
offline_mode: true
|
||||
```
|
||||
|
||||
In the Runner's own Compose service (not the application), set `HOME=/data` and
|
||||
`CONFIG_FILE=/data/config.yaml`, with `/data` already persisted. Before recreating
|
||||
an idle Runner, back up its configuration and copy the existing action cache from
|
||||
`/root/.cache/act` to `/data/.cache/act`. Preserve registration, labels, image,
|
||||
mounts and networks; do not interrupt active jobs or recreate other services.
|
||||
Verify cached actions against the pinned upstream commit and tree before reuse.
|
||||
This is administrator configuration, not a public workflow dependency.
|
||||
|
||||
Offline reuse avoids refreshing cached actions from GitHub on each job. Moving
|
||||
tags/branches may remain at the cached version; this workflow deliberately pins
|
||||
immutable SHAs. A new action URL or commit requires an initial download or verified
|
||||
administrator prewarming; an old mirror subtree is not automatically equivalent
|
||||
to an upstream Git repository. Do not prune Runner caches between jobs. Python
|
||||
toolcache misses still download Python from its upstream release source. Network
|
||||
failures remain failures, never reasons to skip validation or silently fall back
|
||||
to the owner's Tea server.
|
||||
|
||||
Administrators needing a mainland-specific download route can configure their
|
||||
own Runner/network cache privately. Such settings must not introduce public
|
||||
workflow references to a personal mirror. This change does not modify NAS VPN,
|
||||
proxy, firewall or other applications. All validation and deploy gates remain.
|
||||
|
||||
### Synology Photos dependencies
|
||||
|
||||
For a production photo source using Synology Photos person filtering, configure
|
||||
`SYNOLOGY_PHOTOS_URL` and `SYNOLOGY_PHOTOS_CERT_SHA256` as Gitea Secrets. The
|
||||
certificate fingerprint must match the configured HTTPS endpoint. Keep the Photos
|
||||
session in the persistent `/config` directory; never put session credentials in
|
||||
Variables or repository files.
|
||||
|
||||
`PROD_PHOTOS_DIR` must identify the actual NAS photo directory mounted read-only
|
||||
at `/photos`. Adoption requires that mount to exist on the current container;
|
||||
changing this Variable alone does not create a new mount. Existing supplementary
|
||||
permission groups are preserved by deployment.
|
||||
|
||||
Restoring SQLite and session files does not restore these runtime dependencies.
|
||||
Production smoke checks now reject an enabled people-filtered source if its
|
||||
Photos configuration or stored connection is missing. These checks do not prove
|
||||
session validity or scan the complete catalog: after restoring a source, also
|
||||
verify its read-only `/api/v1/photo-sources/{id}/images?limit=1` endpoint before
|
||||
running an automation task.
|
||||
|
||||
### Advisory-service network resilience
|
||||
|
||||
CI dependency installation uses the domestic `CI_PIP_INDEX_URL` first and the
|
||||
`CI_PIP_FALLBACK_INDEX_URL` sequentially on failure. These package indexes do not
|
||||
replace PyPI's vulnerability JSON API.
|
||||
|
||||
`python scripts/ci-audit.py` retains the pinned `requirements.lock`, PyPI advisory
|
||||
service and `--no-deps --disable-pip` checks. It uses a 60-second socket timeout,
|
||||
at most three attempts, and 5/10-second backoff only for network failures. Known
|
||||
vulnerabilities, malformed/incomplete reports and skipped dependencies fail
|
||||
immediately. Every attempt has a five-minute process limit. A timeout or failed
|
||||
final attempt still blocks deployment.
|
||||
|
||||
The advisory HTTP cache is reused within the job (including retries) and follows
|
||||
the audit client's normal HTTP freshness rules. It is not a permanent offline
|
||||
snapshot or a cross-job cache. Raw network exceptions are omitted because proxy
|
||||
URLs may contain credentials. Logs identify the advisory host and whether the
|
||||
complete audit succeeded.
|
||||
|
||||
### Production image retention
|
||||
|
||||
Production uses `pigway-frame-flow:latest`. Builds first create a unique release
|
||||
image, then tag it as latest before activation. The old image is pinned during
|
||||
an update so a failed deployment can restore it. Only after the new version
|
||||
passes verification are old release/rollback images and duplicate tags removed.
|
||||
After successful deployment, only the current production image remains; there
|
||||
is no retained historical image for manual rollback. Rebuild a historical
|
||||
revision before manually reverting a successful deployment.
|
||||
|
||||
Images referenced by other containers (including stopped containers), images
|
||||
with unrelated tags, and CI resources remain protected. Cleanup never prunes
|
||||
volumes, data, or shared build cache. Cleanup failure is a warning and does not
|
||||
roll back a verified deployment. Failed deployments do not run cleanup.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -33,19 +39,23 @@ from pigway_frame_flow.models import (
|
||||
CurrentDisplay,
|
||||
DeleteRequest,
|
||||
Device,
|
||||
DeviceIdentity,
|
||||
DisplayRequest,
|
||||
ErrorResponse,
|
||||
Fit,
|
||||
GalleryDestination,
|
||||
GalleryImage,
|
||||
Health,
|
||||
History,
|
||||
ImageContent,
|
||||
ImageExclusion,
|
||||
ImageStar,
|
||||
ImageURLConfig,
|
||||
Job,
|
||||
JobInput,
|
||||
Operation,
|
||||
Options,
|
||||
PhotoPreference,
|
||||
PhotoSourceContent,
|
||||
Playlist,
|
||||
PlaylistInput,
|
||||
@@ -122,14 +132,21 @@ 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.2",
|
||||
version="0.1.29",
|
||||
lifespan=lifespan,
|
||||
responses=error_responses,
|
||||
)
|
||||
install_extensions(app, service)
|
||||
install_accounts(app, db)
|
||||
install_history(app, service)
|
||||
from pigway_frame_flow.api.thumbnails import install_thumbnails
|
||||
|
||||
install_thumbnails(app, service)
|
||||
from pigway_frame_flow.api.libraries import install_libraries
|
||||
|
||||
install_libraries(app, service)
|
||||
app.state.service = service
|
||||
app.state.worker_enabled = start_worker
|
||||
app.state.scheduler = scheduler
|
||||
@@ -303,6 +320,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
|
||||
@@ -407,7 +425,9 @@ def create_app(settings=None, start_worker=True):
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
with Image.open(BytesIO(data)) as source:
|
||||
with Image.open(
|
||||
BytesIO(data), formats=("JPEG", "PNG", "WEBP")
|
||||
) as source:
|
||||
if source.width * source.height > 25_000_000:
|
||||
raise ValueError()
|
||||
if source.format not in {"JPEG", "PNG", "WEBP"}:
|
||||
@@ -418,16 +438,23 @@ def create_app(settings=None, start_worker=True):
|
||||
raise FlowError(E.INVALID_REQUEST, "Invalid cloud image") from None
|
||||
if thumbnail:
|
||||
thumb = cached.with_name(cached.stem + "-thumb.jpg")
|
||||
if not thumb.exists():
|
||||
from PIL import Image
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(cached) as img:
|
||||
img.thumbnail((300, 400))
|
||||
img.save(thumb, "JPEG")
|
||||
with Image.open(cached, formats=("JPEG", "PNG", "WEBP")) as img:
|
||||
# The 430px cards need up to 860px on 2x displays. Replace the
|
||||
# existing cache in place so old 300px previews are upgraded.
|
||||
expected_edge = min(860, max(img.size))
|
||||
regenerate = not thumb.exists()
|
||||
if not regenerate:
|
||||
with Image.open(thumb, formats=("JPEG", "PNG", "WEBP")) 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
|
||||
|
||||
with Image.open(cached) as original:
|
||||
with Image.open(cached, formats=("JPEG", "PNG", "WEBP")) as original:
|
||||
media_type = Image.MIME.get(
|
||||
original.format or "", "application/octet-stream"
|
||||
)
|
||||
@@ -550,15 +577,15 @@ def create_app(settings=None, start_worker=True):
|
||||
if len(data) > settings.max_bytes:
|
||||
raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB")
|
||||
name = Path(file.filename or "image").name[:120]
|
||||
asset = assets.put(
|
||||
with assets.creating(
|
||||
await run_in_threadpool(convert, data, fit, profile),
|
||||
settings.sanitize(name),
|
||||
kind="cache",
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO asset_metadata(asset_id,origin) VALUES(?,?)",
|
||||
(asset["asset_id"], "manual"),
|
||||
)
|
||||
) as asset:
|
||||
db.execute(
|
||||
"INSERT INTO asset_metadata(asset_id,origin) VALUES(?,?)",
|
||||
(asset["asset_id"], "manual"),
|
||||
)
|
||||
return assets.info(asset["asset_id"])
|
||||
|
||||
@app.get(
|
||||
@@ -579,7 +606,8 @@ def create_app(settings=None, start_worker=True):
|
||||
return [
|
||||
assets.info(r["id"])
|
||||
for r in db.all(
|
||||
"SELECT assets.id FROM assets LEFT JOIN asset_metadata ON asset_metadata.asset_id=assets.id WHERE kind!='cache' AND "
|
||||
"SELECT assets.id FROM assets LEFT JOIN asset_metadata ON asset_metadata.asset_id=assets.id WHERE kind NOT IN ('cache','source-images','renditions') "
|
||||
"AND NOT EXISTS (SELECT 1 FROM asset_sources s WHERE s.asset_id=assets.id AND json_extract(s.payload,'$.type')='thumbnail') AND "
|
||||
"(?='all' OR (?='uploaded' AND kind='images') OR (?='generated' AND kind='generated')) "
|
||||
"AND (?='all' OR COALESCE(origin,'unknown')=?) AND (? IS NULL OR job_id=?) "
|
||||
"ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
@@ -594,7 +622,14 @@ def create_app(settings=None, start_worker=True):
|
||||
dependencies=[Depends(require("images:delete"))],
|
||||
)
|
||||
def delete_asset(asset_id: str):
|
||||
path = assets.path(asset_id)
|
||||
assets.path(asset_id)
|
||||
if db.one(
|
||||
"SELECT id FROM assets WHERE id=? AND kind IN ('source-images','renditions')",
|
||||
(asset_id,),
|
||||
):
|
||||
raise FlowError(
|
||||
E.CONFLICT, "Delete library images through their source library", 409
|
||||
)
|
||||
reference = "%" + asset_id + "%"
|
||||
if (
|
||||
db.one(
|
||||
@@ -612,10 +647,17 @@ def create_app(settings=None, start_worker=True):
|
||||
"Asset is referenced by a task, template or pending operation",
|
||||
409,
|
||||
)
|
||||
path.unlink(missing_ok=True)
|
||||
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,))
|
||||
service.libraries.remove_asset(asset_id)
|
||||
for row in db.all(
|
||||
"SELECT key,payload FROM app_config WHERE key LIKE 'wrist-thumbnail:%'"
|
||||
):
|
||||
import json
|
||||
|
||||
if json.loads(row["payload"]).get("asset_id") == asset_id:
|
||||
db.execute(
|
||||
"DELETE FROM app_config WHERE key=? AND payload=?",
|
||||
(row["key"], row["payload"]),
|
||||
)
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.put(
|
||||
@@ -625,16 +667,30 @@ def create_app(settings=None, start_worker=True):
|
||||
dependencies=[Depends(require("images:write"))],
|
||||
)
|
||||
def star_asset(asset_id: str, payload: ImageStar):
|
||||
with service.lock:
|
||||
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)),
|
||||
)
|
||||
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)
|
||||
db.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(
|
||||
"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),
|
||||
)
|
||||
set_flags(db, "@assets", asset_id, excluded=payload.excluded)
|
||||
return assets.info(asset_id)
|
||||
|
||||
@app.post(
|
||||
@@ -649,6 +705,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"})
|
||||
@@ -744,7 +804,13 @@ def create_app(settings=None, start_worker=True):
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def delete_photo_source(source_id: str):
|
||||
service.photo_sources.delete(source_id)
|
||||
with service.lock:
|
||||
service.libraries.check_source_delete(source_id)
|
||||
service.photo_sources.delete(source_id)
|
||||
db.execute(
|
||||
"DELETE FROM library_plans WHERE source_id=? AND archived=1",
|
||||
(source_id,),
|
||||
)
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.get(
|
||||
@@ -770,13 +836,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,
|
||||
@@ -832,7 +923,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),
|
||||
)
|
||||
]
|
||||
@@ -920,7 +1011,8 @@ def create_app(settings=None, start_worker=True):
|
||||
dependencies=[Depends(require("jobs:write"))],
|
||||
)
|
||||
def create_job(payload: JobInput):
|
||||
return scheduler.save_job(payload)
|
||||
with service.lock:
|
||||
return scheduler.save_job(payload)
|
||||
|
||||
@app.put(
|
||||
"/api/v1/jobs/{job_id}",
|
||||
@@ -931,7 +1023,8 @@ def create_app(settings=None, start_worker=True):
|
||||
def update_job(job_id: str, payload: JobInput):
|
||||
if not db.one("SELECT id FROM jobs WHERE id=?", (job_id,)):
|
||||
raise FlowError(E.NOT_FOUND, "Job not found", 404)
|
||||
return scheduler.save_job(payload, job_id)
|
||||
with service.lock:
|
||||
return scheduler.save_job(payload, job_id)
|
||||
|
||||
@app.delete(
|
||||
"/api/v1/jobs/{job_id}",
|
||||
@@ -941,6 +1034,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(
|
||||
@@ -990,7 +1084,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",
|
||||
@@ -1000,7 +1097,44 @@ def create_app(settings=None, start_worker=True):
|
||||
)
|
||||
def settings_view():
|
||||
return SettingsView(
|
||||
version="0.1.2",
|
||||
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="watch",
|
||||
adapter="watch-thumbnails",
|
||||
name="Watch",
|
||||
storage="local",
|
||||
connected=True,
|
||||
supports_upload=True,
|
||||
),
|
||||
GalleryDestination(
|
||||
id="local",
|
||||
adapter="local-assets",
|
||||
name=settings.storage_name,
|
||||
storage="local",
|
||||
connected=True,
|
||||
supports_upload=True,
|
||||
),
|
||||
],
|
||||
version="0.1.29",
|
||||
mock=settings.mock,
|
||||
write_enabled=settings.write_enabled,
|
||||
api_key_configured=bool(settings.api_key)
|
||||
@@ -1229,7 +1363,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"/>',
|
||||
@@ -1267,12 +1401,19 @@ def create_app(settings=None, start_worker=True):
|
||||
def static(filename: str):
|
||||
if filename not in [
|
||||
"app.js",
|
||||
"thumbnails.js",
|
||||
"libraries.js",
|
||||
"source-browser.js",
|
||||
"features.js",
|
||||
"extensions.js",
|
||||
"style.css",
|
||||
"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)
|
||||
|
||||
@@ -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}
|
||||
@@ -0,0 +1,380 @@
|
||||
import hashlib
|
||||
|
||||
from fastapi import Depends, File, Query, Request, UploadFile
|
||||
from fastapi.responses import Response
|
||||
from pydantic import Field
|
||||
|
||||
from pigway_frame_flow.core.auth import require
|
||||
from pigway_frame_flow.core.photo_preferences import set_flags
|
||||
from pigway_frame_flow.display.libraries import GenerationPlan
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.models import ImageExclusion, ImageStar, StrictModel
|
||||
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
||||
from pigway_frame_flow.providers.source_browser import BrowseFilters
|
||||
|
||||
|
||||
class BindingInput(StrictModel):
|
||||
source_id: str = Field(min_length=1, max_length=120)
|
||||
plan_id: str = Field(pattern=r"^[a-f0-9]{32}$")
|
||||
|
||||
|
||||
class UploadURLInput(StrictModel):
|
||||
url: str = Field(min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class ImportInput(StrictModel):
|
||||
item_id: str = Field(min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class RunInput(StrictModel):
|
||||
item_id: str | None = Field(default=None, min_length=1, max_length=2048)
|
||||
deliver: bool = False
|
||||
|
||||
|
||||
class LibraryModeInput(StrictModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class GenerateInput(StrictModel):
|
||||
plan_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$")
|
||||
|
||||
|
||||
def install_libraries(app, service):
|
||||
libraries, db = service.libraries, service.db
|
||||
|
||||
@app.put(
|
||||
"/api/v1/jobs/{job_id}/library-mode",
|
||||
dependencies=[Depends(require("jobs:write"))],
|
||||
)
|
||||
def set_mode(job_id: str, payload: LibraryModeInput):
|
||||
return app.state.scheduler.set_library_mode(job_id, payload.enabled)
|
||||
|
||||
@app.get("/api/v1/source-libraries", dependencies=[Depends(require("images:read"))])
|
||||
def sources():
|
||||
return [
|
||||
{
|
||||
**source,
|
||||
"image_count": db.one(
|
||||
"SELECT COUNT(*) AS n FROM source_images WHERE source_id=?",
|
||||
(source["id"],),
|
||||
)["n"],
|
||||
}
|
||||
for source in service.photo_sources.list()
|
||||
]
|
||||
|
||||
def save_upload(source_id, data, name):
|
||||
if len(data) > service.settings.max_bytes:
|
||||
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
|
||||
with service.lock:
|
||||
if source_id == "manual-uploads" and not db.one(
|
||||
"SELECT key FROM app_config WHERE key='photo_source:manual-uploads'"
|
||||
):
|
||||
from pigway_frame_flow.renderers.images import convert
|
||||
|
||||
convert(data, size=(1, 1))
|
||||
service.photo_sources.save(
|
||||
source_id,
|
||||
PhotoSourceInput(name="手动上传", kind="uploads", location="."),
|
||||
)
|
||||
if not service.photo_sources.get(source_id).enabled:
|
||||
raise FlowError(E.CONFLICT, "Photo source is disabled", 409)
|
||||
return libraries.ingest(
|
||||
source_id, "upload:" + hashlib.sha256(data).hexdigest(), data, name, {}
|
||||
)
|
||||
|
||||
@app.post(
|
||||
"/api/v1/source-libraries/{source_id}/upload",
|
||||
dependencies=[Depends(require("images:write"))],
|
||||
)
|
||||
def upload(source_id: str, file: UploadFile = File(...)):
|
||||
return save_upload(
|
||||
source_id,
|
||||
file.file.read(service.settings.max_bytes + 1),
|
||||
file.filename or "upload.jpg",
|
||||
)
|
||||
|
||||
@app.post(
|
||||
"/api/v1/source-libraries/{source_id}/upload-url",
|
||||
dependencies=[Depends(require("images:write"))],
|
||||
)
|
||||
def upload_url(source_id: str, payload: UploadURLInput):
|
||||
return save_upload(
|
||||
source_id, service.assets.fetch_url(payload.url), "url-upload.jpg"
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/source-libraries/{source_id}/images",
|
||||
dependencies=[Depends(require("images:read"))],
|
||||
)
|
||||
def pictures(
|
||||
source_id: str,
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
service.photo_sources.get(source_id)
|
||||
rows = db.all(
|
||||
"SELECT id FROM source_images WHERE source_id=? ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?",
|
||||
(source_id, limit, offset),
|
||||
)
|
||||
return {
|
||||
"items": [libraries.picture(row["id"]) for row in rows],
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"total": db.one(
|
||||
"SELECT COUNT(*) AS n FROM source_images WHERE source_id=?",
|
||||
(source_id,),
|
||||
)["n"],
|
||||
}
|
||||
|
||||
@app.get(
|
||||
"/api/v1/source-libraries/{source_id}/browse",
|
||||
dependencies=[Depends(require("images:read"))],
|
||||
)
|
||||
def browse(
|
||||
source_id: str,
|
||||
filters: BrowseFilters = Query(),
|
||||
):
|
||||
return service.photo_sources.browser.page(
|
||||
source_id, filters.offset, filters.limit, filters
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/source-libraries/{source_id}/browse/{item_id}/preview",
|
||||
dependencies=[Depends(require("images:read"))],
|
||||
)
|
||||
def browse_preview(source_id: str, item_id: str):
|
||||
data = service.photo_sources.browser.preview(source_id, item_id)
|
||||
return Response(
|
||||
data,
|
||||
media_type="image/jpeg",
|
||||
headers={"Cache-Control": "private, no-store"},
|
||||
)
|
||||
|
||||
@app.post(
|
||||
"/api/v1/source-libraries/{source_id}/import",
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def import_picture(source_id: str, payload: ImportInput):
|
||||
return libraries.import_picture(source_id, payload.item_id)
|
||||
|
||||
@app.post(
|
||||
"/api/v1/source-libraries/{source_id}/run",
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def run(source_id: str, payload: RunInput, request: Request):
|
||||
with service.lock:
|
||||
return libraries.pipeline(
|
||||
source_id,
|
||||
payload.item_id,
|
||||
deliver=payload.deliver,
|
||||
caller=request.state.client_id,
|
||||
)
|
||||
|
||||
@app.get("/api/v1/generation-plans", dependencies=[Depends(require("images:read"))])
|
||||
def plans(source_id: str):
|
||||
return libraries.plans(source_id)
|
||||
|
||||
@app.post("/api/v1/generation-plans", dependencies=[Depends(require("admin"))])
|
||||
def create_plan(payload: GenerationPlan):
|
||||
return libraries.save_plan(payload)
|
||||
|
||||
@app.put(
|
||||
"/api/v1/generation-plans/{plan_id}", dependencies=[Depends(require("admin"))]
|
||||
)
|
||||
def update_plan(plan_id: str, payload: GenerationPlan):
|
||||
return libraries.save_plan(payload, plan_id)
|
||||
|
||||
@app.delete(
|
||||
"/api/v1/generation-plans/{plan_id}",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def delete_plan(plan_id: str):
|
||||
libraries.archive(plan_id)
|
||||
|
||||
@app.post(
|
||||
"/api/v1/source-images/{picture_id}/generate",
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def generate(picture_id: str, payload: GenerateInput):
|
||||
with service.lock:
|
||||
picture = libraries.picture(picture_id)
|
||||
ids = (
|
||||
[payload.plan_id]
|
||||
if payload.plan_id
|
||||
else [
|
||||
p["id"]
|
||||
for p in libraries.plans(picture["source_id"])
|
||||
if not p["archived"]
|
||||
]
|
||||
)
|
||||
return [libraries.generate(picture_id, plan_id) for plan_id in ids]
|
||||
|
||||
@app.post(
|
||||
"/api/v1/source-images/{picture_id}/preview",
|
||||
dependencies=[Depends(require("images:read"))],
|
||||
)
|
||||
def preview(picture_id: str, payload: GenerationPlan):
|
||||
picture = libraries.picture(picture_id)
|
||||
if picture["source_id"] != payload.source_id:
|
||||
raise FlowError(
|
||||
E.CONFLICT, "Picture and plan must share a source library", 409
|
||||
)
|
||||
data = libraries.render(
|
||||
service.assets.path(picture_id).read_bytes(), picture["caption"], payload
|
||||
)
|
||||
return Response(
|
||||
data, media_type="image/jpeg", headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
|
||||
@app.put(
|
||||
"/api/v1/source-images/{picture_id}/star",
|
||||
dependencies=[Depends(require("images:write"))],
|
||||
)
|
||||
def star(picture_id: str, payload: ImageStar):
|
||||
picture = libraries.picture(picture_id)
|
||||
return set_flags(
|
||||
db, picture["source_id"], picture["item_id"], starred=payload.starred
|
||||
)
|
||||
|
||||
@app.put(
|
||||
"/api/v1/source-images/{picture_id}/exclusion",
|
||||
dependencies=[Depends(require("images:write"))],
|
||||
)
|
||||
def exclude(picture_id: str, payload: ImageExclusion):
|
||||
picture = libraries.picture(picture_id)
|
||||
return set_flags(
|
||||
db, picture["source_id"], picture["item_id"], excluded=payload.excluded
|
||||
)
|
||||
|
||||
@app.delete(
|
||||
"/api/v1/source-images/{picture_id}",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require("images:delete"))],
|
||||
)
|
||||
def delete_picture(picture_id: str):
|
||||
libraries.delete_picture(picture_id)
|
||||
|
||||
@app.delete(
|
||||
"/api/v1/library-versions/{asset_id}",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require("images:delete"))],
|
||||
)
|
||||
def delete_version(asset_id: str):
|
||||
with service.lock:
|
||||
if not db.one(
|
||||
"SELECT asset_id FROM library_versions WHERE asset_id=?", (asset_id,)
|
||||
):
|
||||
raise FlowError(E.NOT_FOUND, "Generated image not found", 404)
|
||||
libraries.check_asset_references(asset_id)
|
||||
libraries.remove_asset(asset_id)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/library-terminals", dependencies=[Depends(require("images:read"))]
|
||||
)
|
||||
def terminals():
|
||||
return libraries.terminals()
|
||||
|
||||
@app.put(
|
||||
"/api/v1/library-terminals/{terminal_id}/binding",
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def bind(terminal_id: str, payload: BindingInput):
|
||||
return libraries.bind(terminal_id, payload.source_id, payload.plan_id)
|
||||
|
||||
@app.delete(
|
||||
"/api/v1/library-terminals/{terminal_id}/binding",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def unbind(terminal_id: str):
|
||||
with service.lock:
|
||||
db.execute(
|
||||
"DELETE FROM terminal_bindings WHERE terminal_id=?", (terminal_id,)
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/library-terminals/{terminal_id}/images",
|
||||
dependencies=[Depends(require("images:read"))],
|
||||
)
|
||||
def terminal_images(
|
||||
terminal_id: str,
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
terminal = next(
|
||||
(t for t in libraries.terminals() if t["id"] == terminal_id), None
|
||||
)
|
||||
if terminal is None:
|
||||
raise FlowError(E.NOT_FOUND, "Terminal not found", 404)
|
||||
binding = libraries.binding(terminal_id)
|
||||
if not binding:
|
||||
return {"items": [], "total": 0, "offset": offset, "limit": limit}
|
||||
where = "FROM library_versions v JOIN source_images s ON s.id=v.source_image_id WHERE v.plan_id=? AND s.source_id=?"
|
||||
args = (binding["plan_id"], binding["source_id"])
|
||||
rows = db.all(
|
||||
"SELECT v.asset_id,v.source_image_id "
|
||||
+ where
|
||||
+ " ORDER BY v.created_at DESC,v.asset_id LIMIT ? OFFSET ?",
|
||||
(*args, limit, offset),
|
||||
)
|
||||
current_asset = None
|
||||
if rows and terminal["transport_supported"]:
|
||||
try:
|
||||
device = service.registry.get(terminal_id).device()
|
||||
if device.status == "online":
|
||||
current = service.current_content(terminal_id, device=device)
|
||||
if current["synced"] and current["source_known"]:
|
||||
current_asset = current["asset_id"]
|
||||
except FlowError:
|
||||
# Offline devices must not hide locally generated versions.
|
||||
pass
|
||||
items = []
|
||||
versions_by_picture = {}
|
||||
for row in rows:
|
||||
picture_id = row["source_image_id"]
|
||||
if picture_id not in versions_by_picture:
|
||||
versions_by_picture[picture_id] = {
|
||||
v["asset_id"]: v for v in libraries.versions(picture_id)
|
||||
}
|
||||
version = versions_by_picture[picture_id][row["asset_id"]]
|
||||
delivery = db.one(
|
||||
"SELECT operation_id FROM terminal_deliveries WHERE terminal_id=? AND asset_id=? ORDER BY created_at DESC,operation_id DESC LIMIT 1",
|
||||
(terminal_id, row["asset_id"]),
|
||||
)
|
||||
operation = (
|
||||
db.one(
|
||||
"SELECT id AS operation_id,status,created_at,updated_at FROM operations WHERE id=?",
|
||||
(delivery["operation_id"],),
|
||||
)
|
||||
if delivery and delivery["operation_id"]
|
||||
else None
|
||||
)
|
||||
items.append(
|
||||
{
|
||||
**version,
|
||||
"operation": operation,
|
||||
"current": row["asset_id"] == current_asset,
|
||||
"delivery_status": operation["status"]
|
||||
if operation
|
||||
else "GENERATED",
|
||||
}
|
||||
)
|
||||
return {
|
||||
"items": items,
|
||||
"total": db.one("SELECT COUNT(*) AS n " + where, args)["n"],
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
@app.post(
|
||||
"/api/v1/library-terminals/{terminal_id}/images/{asset_id}/send",
|
||||
dependencies=[Depends(require("display:write"))],
|
||||
)
|
||||
def send(terminal_id: str, asset_id: str, request: Request):
|
||||
return {
|
||||
"operation_id": libraries.send(
|
||||
terminal_id, asset_id, client_id=request.state.client_id
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Depends, Query
|
||||
from fastapi.responses import Response
|
||||
from pydantic import Field
|
||||
|
||||
from pigway_frame_flow.core.auth import require
|
||||
from pigway_frame_flow.display.thumbnail_plans import (
|
||||
ThumbnailPlan,
|
||||
get_plan,
|
||||
render,
|
||||
save_plan,
|
||||
)
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.models import StrictModel
|
||||
|
||||
|
||||
class ThumbnailRequest(StrictModel):
|
||||
source_id: str = Field(min_length=1, max_length=120)
|
||||
item_id: str = Field(min_length=1, max_length=2048)
|
||||
plan: ThumbnailPlan
|
||||
|
||||
|
||||
def install_thumbnails(app, service):
|
||||
from pigway_frame_flow.display.thumbnail_gallery import record, recover
|
||||
|
||||
recover(service)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/thumbnail-devices", dependencies=[Depends(require("images:read"))]
|
||||
)
|
||||
def devices():
|
||||
return [
|
||||
{
|
||||
"id": "watch",
|
||||
"name": "Watch",
|
||||
"width": 410,
|
||||
"height": 502,
|
||||
"transport_connected": False,
|
||||
"status": "generation_only",
|
||||
}
|
||||
]
|
||||
|
||||
@app.get("/api/v1/thumbnails", dependencies=[Depends(require("images:read"))])
|
||||
def gallery(offset: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200)):
|
||||
rows = service.db.all(
|
||||
"SELECT assets.id,asset_sources.payload FROM assets JOIN asset_sources ON asset_sources.asset_id=assets.id "
|
||||
"WHERE json_extract(asset_sources.payload,'$.type')='thumbnail' "
|
||||
"ORDER BY assets.created_at DESC,assets.id DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
)
|
||||
total = service.db.one(
|
||||
"SELECT COUNT(*) AS count FROM assets JOIN asset_sources ON asset_sources.asset_id=assets.id "
|
||||
"WHERE json_extract(asset_sources.payload,'$.type')='thumbnail'"
|
||||
)["count"]
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
**service.assets.info(row["id"]),
|
||||
"thumbnail": json.loads(row["payload"]),
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
@app.get("/api/v1/thumbnail-plans", dependencies=[Depends(require("images:read"))])
|
||||
def plans():
|
||||
return [
|
||||
dict(
|
||||
id=r["key"].removeprefix("thumbnail-plan:"), **json.loads(r["payload"])
|
||||
)
|
||||
for r in service.db.all(
|
||||
"SELECT key,payload FROM app_config WHERE key LIKE 'thumbnail-plan:%' ORDER BY key"
|
||||
)
|
||||
]
|
||||
|
||||
@app.post("/api/v1/thumbnail-plans", dependencies=[Depends(require("admin"))])
|
||||
def create(plan: ThumbnailPlan):
|
||||
plan_id = uuid4().hex
|
||||
save_plan(service, plan_id, plan)
|
||||
return dict(id=plan_id, **plan.model_dump())
|
||||
|
||||
@app.get(
|
||||
"/api/v1/thumbnail-plans/{plan_id}",
|
||||
dependencies=[Depends(require("images:read"))],
|
||||
)
|
||||
def detail(plan_id: str):
|
||||
return dict(id=plan_id, **get_plan(service, plan_id).model_dump())
|
||||
|
||||
@app.put(
|
||||
"/api/v1/thumbnail-plans/{plan_id}", dependencies=[Depends(require("admin"))]
|
||||
)
|
||||
def update(plan_id: str, plan: ThumbnailPlan):
|
||||
with service.lock:
|
||||
get_plan(service, plan_id)
|
||||
save_plan(service, plan_id, plan)
|
||||
return dict(id=plan_id, **plan.model_dump())
|
||||
|
||||
@app.delete(
|
||||
"/api/v1/thumbnail-plans/{plan_id}",
|
||||
dependencies=[Depends(require("admin"))],
|
||||
status_code=204,
|
||||
)
|
||||
def delete(plan_id: str):
|
||||
with service.lock:
|
||||
get_plan(service, plan_id)
|
||||
for row in service.db.all("SELECT payload FROM jobs"):
|
||||
job = json.loads(row["payload"])
|
||||
if job.get("thumbnail_plan_id") == plan_id:
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Thumbnail plan is used by a task",
|
||||
409,
|
||||
{"reason": "thumbnailPlanInUse"},
|
||||
)
|
||||
for row in service.db.all(
|
||||
"SELECT payload FROM operations WHERE status IN ('QUEUED','RENDERING','UPLOADING','UNKNOWN')"
|
||||
):
|
||||
if (
|
||||
json.loads(row["payload"])
|
||||
.get("options", {})
|
||||
.get("thumbnail_plan_id")
|
||||
== plan_id
|
||||
):
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Thumbnail plan is used by a pending operation",
|
||||
409,
|
||||
{"reason": "thumbnailPlanInUse"},
|
||||
)
|
||||
service.db.execute(
|
||||
"DELETE FROM app_config WHERE key=?", ("thumbnail-plan:" + plan_id,)
|
||||
)
|
||||
|
||||
def image(request):
|
||||
data, _, _ = service.photo_sources.read(
|
||||
request.source_id,
|
||||
request.item_id,
|
||||
target_size=(request.plan.width, request.plan.height),
|
||||
)
|
||||
return render(data, request.plan)
|
||||
|
||||
@app.post(
|
||||
"/api/v1/thumbnails/preview", dependencies=[Depends(require("images:read"))]
|
||||
)
|
||||
def preview(request: ThumbnailRequest):
|
||||
return Response(
|
||||
image(request),
|
||||
media_type="image/jpeg",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
@app.post("/api/v1/thumbnails/generate", dependencies=[Depends(require("admin"))])
|
||||
def generate(request: ThumbnailRequest):
|
||||
asset = service.assets.put(
|
||||
image(request), request.plan.name + ".jpg", "generated"
|
||||
)
|
||||
record(
|
||||
service,
|
||||
asset["asset_id"],
|
||||
{
|
||||
"width": request.plan.width,
|
||||
"height": request.plan.height,
|
||||
"plan": request.plan.model_dump(),
|
||||
"generated_at": asset["created_at"],
|
||||
"source": {"source_id": request.source_id, "item_id": request.item_id},
|
||||
},
|
||||
)
|
||||
return asset
|
||||
@@ -4,6 +4,7 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
@@ -21,16 +22,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 +64,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 +73,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 +97,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"]
|
||||
):
|
||||
@@ -92,62 +106,109 @@ def session_user(request: Request):
|
||||
|
||||
|
||||
def install_accounts(app, db):
|
||||
# Serialize authentication work without holding the database lock during scrypt.
|
||||
login_lock = threading.Lock()
|
||||
|
||||
@app.post("/api/v1/auth/login", response_model=AccountView, tags=["Accounts"])
|
||||
def login(payload: Login, request: Request, response: Response):
|
||||
identity = hashlib.sha256(
|
||||
(request.client.host if request.client else "unknown").encode()
|
||||
).hexdigest()
|
||||
attempt = db.one("SELECT * FROM login_attempts WHERE identity=?", (identity,))
|
||||
if attempt and attempt["blocked_until"] > time.time():
|
||||
raise FlowError(
|
||||
E.RATE_LIMITED, "Too many login attempts; try again later", 429
|
||||
with login_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()
|
||||
# Account throttling also applies when an attacker changes source IPs.
|
||||
identities = (
|
||||
identity,
|
||||
"account:" + hashlib.sha256(payload.username.encode()).hexdigest(),
|
||||
)
|
||||
row = db.one(
|
||||
"SELECT * FROM users WHERE username=? AND enabled=1", (payload.username,)
|
||||
)
|
||||
# Perform a password calculation even for unknown usernames.
|
||||
fallback = "scrypt$" + "00" * 16 + "$" + "00" * 64
|
||||
valid = verify_password(
|
||||
payload.password, row["password_hash"] if row else fallback
|
||||
)
|
||||
if not row or not valid:
|
||||
failures = (
|
||||
attempt["failures"] if attempt and attempt["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),
|
||||
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
|
||||
)
|
||||
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
|
||||
db.execute("DELETE FROM login_attempts WHERE identity=?", (identity,))
|
||||
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(),))
|
||||
old = request.cookies.get(COOKIE)
|
||||
if old:
|
||||
db.execute(
|
||||
"DELETE FROM sessions WHERE token_hash=?",
|
||||
(hashlib.sha256(old.encode()).hexdigest(),),
|
||||
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,),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO sessions VALUES(?,?,?,?)",
|
||||
(
|
||||
hashlib.sha256(raw.encode()).hexdigest(),
|
||||
row["username"],
|
||||
csrf,
|
||||
time.time() + lifetime,
|
||||
),
|
||||
)
|
||||
response.set_cookie(
|
||||
COOKIE,
|
||||
raw,
|
||||
max_age=lifetime if payload.remember else None,
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
secure=request.url.scheme == "https",
|
||||
path="/",
|
||||
)
|
||||
return AccountView(username=row["username"], csrf_token=csrf)
|
||||
# Perform a password calculation even for unknown usernames.
|
||||
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 = (
|
||||
previous["failures"]
|
||||
if previous and previous["blocked_until"] == 0
|
||||
else 0
|
||||
) + 1
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)",
|
||||
(key, failures, time.time() + 300 if failures >= 5 else 0),
|
||||
)
|
||||
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
|
||||
upgraded = (
|
||||
password_hash(payload.password)
|
||||
if row["password_hash"].startswith("scrypt$")
|
||||
else None
|
||||
)
|
||||
with db.lock:
|
||||
# A concurrent password change must not create a session from old credentials.
|
||||
current_row = db.one(
|
||||
"SELECT * FROM users WHERE username=? AND enabled=1",
|
||||
(payload.username,),
|
||||
)
|
||||
if (
|
||||
not current_row
|
||||
or current_row["password_hash"] != row["password_hash"]
|
||||
):
|
||||
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
|
||||
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=?",
|
||||
(upgraded, 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(),))
|
||||
old = request.cookies.get(COOKIE)
|
||||
if old:
|
||||
db.execute(
|
||||
"DELETE FROM sessions WHERE token_hash=?",
|
||||
(hashlib.sha256(old.encode()).hexdigest(),),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO sessions VALUES(?,?,?,?)",
|
||||
(
|
||||
hashlib.sha256(raw.encode()).hexdigest(),
|
||||
row["username"],
|
||||
csrf,
|
||||
time.time() + lifetime,
|
||||
),
|
||||
)
|
||||
response.set_cookie(
|
||||
COOKIE,
|
||||
raw,
|
||||
max_age=lifetime if payload.remember else None,
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
secure=request.url.scheme == "https",
|
||||
path="/",
|
||||
)
|
||||
return AccountView(username=row["username"], csrf_token=csrf)
|
||||
|
||||
def current(request):
|
||||
row = session_user(request)
|
||||
@@ -187,9 +248,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(
|
||||
"UPDATE users SET password_hash=? WHERE username=?",
|
||||
(password_hash(payload.new_password), session["username"]),
|
||||
)
|
||||
db.execute("DELETE FROM sessions WHERE username=?", (session["username"],))
|
||||
with db.transaction() as connection:
|
||||
connection.execute(
|
||||
"UPDATE users SET password_hash=? WHERE username=?",
|
||||
(password_hash(payload.new_password), session["username"]),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM sessions WHERE username=?", (session["username"],)
|
||||
)
|
||||
response.delete_cookie(COOKIE, path="/")
|
||||
|
||||
@@ -14,6 +14,7 @@ bearer = HTTPBearer(auto_error=False)
|
||||
ALL_SCOPES = [
|
||||
"devices:read",
|
||||
"images:read",
|
||||
"images:write",
|
||||
"images:delete",
|
||||
"display:write",
|
||||
"templates:read",
|
||||
|
||||
@@ -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]
|
||||
@@ -13,18 +13,34 @@ 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"] == "library-terminal":
|
||||
job = self.db.one(
|
||||
"SELECT job_runs.job_id FROM terminal_deliveries JOIN job_runs ON job_runs.operation_id=terminal_deliveries.parent_operation_id WHERE terminal_deliveries.operation_id=?",
|
||||
(operation_id,),
|
||||
)
|
||||
if not job and operation["client_id"] == "scheduler-retry":
|
||||
job = self.db.one(
|
||||
"SELECT job_id FROM asset_metadata WHERE asset_id=? AND job_id IS NOT NULL",
|
||||
(asset_id,),
|
||||
)
|
||||
job_config = (
|
||||
self.db.one("SELECT payload FROM jobs WHERE id=?", (job["job_id"],))
|
||||
if job
|
||||
else None
|
||||
)
|
||||
source = json.loads(job_config["payload"])["source"] if job_config else content
|
||||
job_id = (
|
||||
job["job_id"]
|
||||
if job
|
||||
and content.get("type") in ("photo_source", "auto_collage")
|
||||
and content.get("item_id") is None
|
||||
and source.get("type") in ("photo_source", "auto_collage")
|
||||
and source.get("item_id") is None
|
||||
else None
|
||||
)
|
||||
self.db.execute(
|
||||
|
||||
@@ -0,0 +1,714 @@
|
||||
"""Source pictures, source-scoped render plans, and single terminal bindings."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from PIL import Image
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from pigway_frame_flow.core.photo_preferences import flags
|
||||
from pigway_frame_flow.display.thumbnail_plans import ThumbnailPlan
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.models import PhotoSourceContent
|
||||
from pigway_frame_flow.renderers.images import MAX_IMAGE_PIXELS, convert
|
||||
from pigway_frame_flow.renderers.photo_caption import render_captioned_photo
|
||||
from pigway_frame_flow.storage.database import encode, now_iso
|
||||
|
||||
|
||||
class GenerationPlan(ThumbnailPlan):
|
||||
source_id: str = Field(min_length=1, max_length=120)
|
||||
caption: bool = False
|
||||
profile: str = Field(default="color", pattern="^(color|grayscale)$")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def caption_size(self):
|
||||
if self.caption and min(self.width, self.height) < 128:
|
||||
raise ValueError(
|
||||
"Caption output requires both dimensions to be at least 128 pixels"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class Libraries:
|
||||
def __init__(self, service):
|
||||
self.service, self.db = service, service.db
|
||||
for kind in ("source-images", "renditions"):
|
||||
(service.settings.data_dir / kind).mkdir(exist_ok=True)
|
||||
for sql in [
|
||||
"CREATE TABLE IF NOT EXISTS library_plans(id TEXT PRIMARY KEY,source_id TEXT NOT NULL,payload TEXT NOT NULL,archived INTEGER NOT NULL DEFAULT 0)",
|
||||
"CREATE TABLE IF NOT EXISTS source_images(id TEXT PRIMARY KEY,source_id TEXT NOT NULL,item_id TEXT NOT NULL,digest TEXT NOT NULL,caption TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(source_id,item_id,digest))",
|
||||
"CREATE TABLE IF NOT EXISTS library_versions(asset_id TEXT PRIMARY KEY,source_image_id TEXT NOT NULL,plan_id TEXT NOT NULL,revision TEXT NOT NULL,plan TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(source_image_id,plan_id,revision))",
|
||||
"CREATE TABLE IF NOT EXISTS terminal_bindings(terminal_id TEXT PRIMARY KEY,source_id TEXT NOT NULL,plan_id TEXT NOT NULL)",
|
||||
"CREATE TABLE IF NOT EXISTS terminal_deliveries(terminal_id TEXT NOT NULL,asset_id TEXT NOT NULL,operation_id TEXT PRIMARY KEY,parent_operation_id TEXT,created_at TEXT NOT NULL)",
|
||||
]:
|
||||
self.db.execute(sql)
|
||||
service.operation_handlers["library-generate"] = self.execute
|
||||
|
||||
def terminals(self):
|
||||
result = []
|
||||
for terminal_id, driver in self.service.registry.drivers.items():
|
||||
cached = getattr(driver, "cached", None)
|
||||
result.append(
|
||||
{
|
||||
"id": terminal_id,
|
||||
"name": getattr(cached, "name", terminal_id),
|
||||
"kind": "frame",
|
||||
"transport_supported": True,
|
||||
"binding": self.binding(terminal_id),
|
||||
}
|
||||
)
|
||||
result.append(
|
||||
{
|
||||
"id": "watch",
|
||||
"name": "Watch",
|
||||
"kind": "watch",
|
||||
"width": 410,
|
||||
"height": 502,
|
||||
"transport_supported": False,
|
||||
"binding": self.binding("watch"),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def binding(self, terminal_id):
|
||||
return self.db.one(
|
||||
"SELECT * FROM terminal_bindings WHERE terminal_id=?", (terminal_id,)
|
||||
)
|
||||
|
||||
def plan(self, plan_id, active=True):
|
||||
row = self.db.one("SELECT * FROM library_plans WHERE id=?", (plan_id,))
|
||||
if not row or (active and row["archived"]):
|
||||
raise FlowError(E.NOT_FOUND, "Generation plan not found", 404)
|
||||
return GenerationPlan.model_validate_json(row["payload"])
|
||||
|
||||
def plans(self, source_id):
|
||||
self.service.photo_sources.get(source_id)
|
||||
return [
|
||||
{**json.loads(r["payload"]), "id": r["id"], "archived": bool(r["archived"])}
|
||||
for r in self.db.all(
|
||||
"SELECT * FROM library_plans WHERE source_id=? ORDER BY archived,id",
|
||||
(source_id,),
|
||||
)
|
||||
]
|
||||
|
||||
def save_plan(self, plan, plan_id=None):
|
||||
with self.service.lock:
|
||||
self.service.photo_sources.get(plan.source_id)
|
||||
if plan_id:
|
||||
previous = self.plan(plan_id)
|
||||
if previous.source_id != plan.source_id:
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"A generation plan cannot move between source libraries",
|
||||
409,
|
||||
)
|
||||
for row in self.db.all(
|
||||
"SELECT terminal_id FROM terminal_bindings WHERE plan_id=?",
|
||||
(plan_id,),
|
||||
):
|
||||
self.validate_size(row["terminal_id"], plan)
|
||||
plan_id = plan_id or uuid4().hex
|
||||
self.db.execute(
|
||||
"INSERT INTO library_plans VALUES(?,?,?,0) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload",
|
||||
(plan_id, plan.source_id, encode(plan.model_dump())),
|
||||
)
|
||||
return {"id": plan_id, **plan.model_dump(), "archived": False}
|
||||
|
||||
def archive(self, plan_id):
|
||||
with self.service.lock:
|
||||
self.plan(plan_id)
|
||||
if self.db.one(
|
||||
"SELECT terminal_id FROM terminal_bindings WHERE plan_id=?", (plan_id,)
|
||||
):
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Unbind terminals before deleting this generation plan",
|
||||
409,
|
||||
{"reason": "libraryPlanBound"},
|
||||
)
|
||||
self.db.execute(
|
||||
"UPDATE library_plans SET archived=1 WHERE id=?", (plan_id,)
|
||||
)
|
||||
|
||||
def validate_size(self, terminal_id, plan):
|
||||
if terminal_id == "watch":
|
||||
allowed = {(410, 502)}
|
||||
else:
|
||||
display = self.service.registry.get(terminal_id).device().display
|
||||
allowed = {(display.width, display.height), (display.height, display.width)}
|
||||
if (plan.width, plan.height) not in allowed:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Generation dimensions must match the terminal screen",
|
||||
422,
|
||||
{"reason": "librarySizeMismatch"},
|
||||
)
|
||||
|
||||
def bind(self, terminal_id, source_id, plan_id):
|
||||
with self.service.lock:
|
||||
self.service.photo_sources.get(source_id)
|
||||
plan = self.plan(plan_id)
|
||||
if plan.source_id != source_id:
|
||||
raise FlowError(
|
||||
E.CONFLICT, "Generation plan belongs to another source library", 409
|
||||
)
|
||||
self.validate_size(terminal_id, plan)
|
||||
self.db.execute(
|
||||
"INSERT INTO terminal_bindings VALUES(?,?,?) ON CONFLICT(terminal_id) DO UPDATE SET source_id=excluded.source_id,plan_id=excluded.plan_id",
|
||||
(terminal_id, source_id, plan_id),
|
||||
)
|
||||
return self.binding(terminal_id)
|
||||
|
||||
def ingest(self, source_id, item_id, data, name, caption):
|
||||
if self.service.photo_sources.get(source_id).kind == "uploads":
|
||||
existing = self.db.one(
|
||||
"SELECT id FROM source_images WHERE source_id=? AND item_id=?",
|
||||
(source_id, item_id),
|
||||
)
|
||||
if existing:
|
||||
return self.picture(existing["id"])
|
||||
digest = hashlib.sha256(
|
||||
data + b"\0" + encode(caption or {}).encode()
|
||||
).hexdigest()
|
||||
row = self.db.one(
|
||||
"SELECT id FROM source_images WHERE source_id=? AND item_id=? AND digest=?",
|
||||
(source_id, item_id, digest),
|
||||
)
|
||||
if row:
|
||||
return self.picture(row["id"])
|
||||
# Normalize EXIF and JPEG storage without resizing or adding decorations.
|
||||
try:
|
||||
with Image.open(BytesIO(data), formats=("JPEG", "PNG", "WEBP")) as image:
|
||||
if (
|
||||
image.format not in {"JPEG", "PNG", "WEBP"}
|
||||
or image.width * image.height > MAX_IMAGE_PIXELS
|
||||
):
|
||||
raise ValueError("Unsupported source image")
|
||||
size = image.size
|
||||
if image.getexif().get(274, 1) in (5, 6, 7, 8):
|
||||
size = (image.height, image.width)
|
||||
normalized = convert(data, "cover", size=size, quality=95)
|
||||
except (OSError, ValueError, Image.DecompressionBombError):
|
||||
raise FlowError(E.INVALID_REQUEST, "Invalid source image") from None
|
||||
with self.service.assets.creating(normalized, name, "source-images") as asset:
|
||||
self.db.execute(
|
||||
"INSERT INTO source_images VALUES(?,?,?,?,?,?)",
|
||||
(
|
||||
asset["asset_id"],
|
||||
source_id,
|
||||
item_id,
|
||||
digest,
|
||||
encode(caption or {}),
|
||||
now_iso(),
|
||||
),
|
||||
)
|
||||
self.db.execute(
|
||||
"INSERT INTO asset_sources VALUES(?,?)",
|
||||
(
|
||||
asset["asset_id"],
|
||||
encode(
|
||||
{
|
||||
"type": "library_source",
|
||||
"source_id": source_id,
|
||||
"item_id": item_id,
|
||||
"source_rendition": "upload"
|
||||
if item_id.startswith("upload:")
|
||||
else "preview"
|
||||
if self.service.photo_sources.get(source_id).kind
|
||||
== "synology-photos"
|
||||
else "source",
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
return self.picture(asset["asset_id"])
|
||||
|
||||
def import_picture(self, source_id, item_id):
|
||||
with self.service.lock:
|
||||
data, name, caption = self.service.photo_sources.read(source_id, item_id)
|
||||
return self.ingest(source_id, item_id, data, name, caption)
|
||||
|
||||
def picture(self, picture_id):
|
||||
row = self.db.one("SELECT * FROM source_images WHERE id=?", (picture_id,))
|
||||
if not row:
|
||||
raise FlowError(E.NOT_FOUND, "Source picture not found", 404)
|
||||
return {
|
||||
**self.service.assets.info(picture_id),
|
||||
**row,
|
||||
"caption": json.loads(row["caption"]),
|
||||
**flags(self.db, row["source_id"], row["item_id"]),
|
||||
"versions": self.versions(picture_id),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def render_parameters(plan):
|
||||
return plan.model_dump(exclude={"name", "source_id"})
|
||||
|
||||
def versions(self, picture_id):
|
||||
result = []
|
||||
rows = self.db.all(
|
||||
"SELECT v.*,p.payload AS current_plan,p.archived,a.name AS source_name "
|
||||
"FROM library_versions v JOIN library_plans p ON p.id=v.plan_id "
|
||||
"JOIN source_images s ON s.id=v.source_image_id JOIN assets a ON a.id=s.id "
|
||||
"WHERE v.source_image_id=? ORDER BY v.created_at DESC,v.asset_id DESC",
|
||||
(picture_id,),
|
||||
)
|
||||
for row in rows:
|
||||
current = GenerationPlan.model_validate_json(row["current_plan"])
|
||||
snapshot = GenerationPlan.model_validate_json(row["plan"])
|
||||
asset = self.service.assets.info(row["asset_id"])
|
||||
display_name = (
|
||||
Path(Path(row["source_name"]).stem + "-" + current.name).name[:110]
|
||||
+ ".jpg"
|
||||
)
|
||||
result.append(
|
||||
{
|
||||
**asset,
|
||||
"name": display_name,
|
||||
"stored_name": asset["name"],
|
||||
"source_image_id": row["source_image_id"],
|
||||
"plan_id": row["plan_id"],
|
||||
"plan_name": current.name,
|
||||
"plan_archived": bool(row["archived"]),
|
||||
"matches_current_plan": self.render_parameters(current)
|
||||
== self.render_parameters(snapshot),
|
||||
"revision": row["revision"],
|
||||
"plan": json.loads(row["plan"]),
|
||||
"terminal_uses": self.terminal_uses(
|
||||
row["plan_id"], row["asset_id"]
|
||||
),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def terminal_uses(self, plan_id, asset_id):
|
||||
return self.db.all(
|
||||
"SELECT b.terminal_id,d.operation_id,COALESCE(o.status,'GENERATED') AS status "
|
||||
"FROM terminal_bindings b LEFT JOIN terminal_deliveries d ON d.operation_id=(SELECT operation_id FROM terminal_deliveries latest "
|
||||
"WHERE latest.terminal_id=b.terminal_id AND latest.asset_id=? ORDER BY latest.created_at DESC,latest.operation_id DESC LIMIT 1) "
|
||||
"LEFT JOIN operations o ON o.id=d.operation_id WHERE b.plan_id=? ORDER BY b.terminal_id",
|
||||
(asset_id, plan_id),
|
||||
)
|
||||
|
||||
def render(self, data, caption, plan):
|
||||
if plan.caption and caption:
|
||||
return render_captioned_photo(
|
||||
data,
|
||||
caption,
|
||||
self.service.renderer.font,
|
||||
plan.fit,
|
||||
plan.profile,
|
||||
size=(plan.width, plan.height),
|
||||
centering=(plan.center_x, plan.center_y),
|
||||
quality=plan.quality,
|
||||
respect_fit=True,
|
||||
)
|
||||
return convert(
|
||||
data,
|
||||
plan.fit,
|
||||
plan.profile,
|
||||
size=(plan.width, plan.height),
|
||||
centering=(plan.center_x, plan.center_y),
|
||||
quality=plan.quality,
|
||||
)
|
||||
|
||||
def generate(self, picture_id, plan_id, snapshot=None):
|
||||
with self.service.lock:
|
||||
picture = self.picture(picture_id)
|
||||
plan = (
|
||||
GenerationPlan.model_validate(snapshot)
|
||||
if snapshot
|
||||
else self.plan(plan_id)
|
||||
)
|
||||
if picture["source_id"] != plan.source_id:
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Picture and generation plan must share a source library",
|
||||
409,
|
||||
)
|
||||
parameters = self.render_parameters(plan)
|
||||
revision = hashlib.sha256(encode(parameters).encode()).hexdigest()
|
||||
row = self.db.one(
|
||||
"SELECT asset_id FROM library_versions WHERE source_image_id=? AND plan_id=? AND revision=?",
|
||||
(picture_id, plan_id, revision),
|
||||
)
|
||||
existing = self.versions(picture_id)
|
||||
if row:
|
||||
return next(v for v in existing if v["asset_id"] == row["asset_id"])
|
||||
# Older revisions hashed the display name too. Reuse matching pixels'
|
||||
# settings without rewriting historical snapshots or receipt references.
|
||||
for version in existing:
|
||||
if (
|
||||
version["plan_id"] == plan_id
|
||||
and self.render_parameters(
|
||||
GenerationPlan.model_validate(version["plan"])
|
||||
)
|
||||
== parameters
|
||||
):
|
||||
return version
|
||||
data = self.service.assets.path(picture_id).read_bytes()
|
||||
with self.service.assets.creating(
|
||||
self.render(data, picture["caption"], plan),
|
||||
picture["name"].removesuffix(".jpg") + "-" + plan.name,
|
||||
"renditions",
|
||||
) as asset:
|
||||
self.db.execute(
|
||||
"INSERT INTO library_versions VALUES(?,?,?,?,?,?)",
|
||||
(
|
||||
asset["asset_id"],
|
||||
picture_id,
|
||||
plan_id,
|
||||
revision,
|
||||
encode(plan.model_dump()),
|
||||
now_iso(),
|
||||
),
|
||||
)
|
||||
self.db.execute(
|
||||
"INSERT INTO asset_sources VALUES(?,?)",
|
||||
(
|
||||
asset["asset_id"],
|
||||
encode(
|
||||
{
|
||||
"type": "library_version",
|
||||
"source_image_id": picture_id,
|
||||
"source_id": plan.source_id,
|
||||
"plan_id": plan_id,
|
||||
"revision": revision,
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
return next(
|
||||
v
|
||||
for v in self.versions(picture_id)
|
||||
if v["asset_id"] == asset["asset_id"]
|
||||
)
|
||||
|
||||
def validate_asset_binding(self, terminal_id, asset_id):
|
||||
if self.db.one("SELECT id FROM source_images WHERE id=?", (asset_id,)):
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Generate a terminal version before sending source pixels",
|
||||
409,
|
||||
{"reason": "libraryGenerateFirst"},
|
||||
)
|
||||
version = self.db.one(
|
||||
"SELECT v.plan_id,s.source_id FROM library_versions v JOIN source_images s ON s.id=v.source_image_id WHERE v.asset_id=?",
|
||||
(asset_id,),
|
||||
)
|
||||
if version:
|
||||
binding = self.binding(terminal_id)
|
||||
if (
|
||||
not binding
|
||||
or binding["source_id"] != version["source_id"]
|
||||
or binding["plan_id"] != version["plan_id"]
|
||||
):
|
||||
raise FlowError(
|
||||
E.CONFLICT, "Image no longer matches the terminal binding", 409
|
||||
)
|
||||
|
||||
def send(
|
||||
self,
|
||||
terminal_id,
|
||||
asset_id,
|
||||
options=None,
|
||||
client_id="admin",
|
||||
parent_operation_id=None,
|
||||
):
|
||||
with self.service.lock:
|
||||
binding = self.binding(terminal_id)
|
||||
version = self.db.one(
|
||||
"SELECT v.*,s.source_id FROM library_versions v JOIN source_images s ON s.id=v.source_image_id WHERE v.asset_id=?",
|
||||
(asset_id,),
|
||||
)
|
||||
if (
|
||||
not binding
|
||||
or not version
|
||||
or binding["source_id"] != version["source_id"]
|
||||
or binding["plan_id"] != version["plan_id"]
|
||||
):
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Image does not match the terminal source and plan binding",
|
||||
409,
|
||||
)
|
||||
if terminal_id == "watch":
|
||||
raise FlowError(
|
||||
E.UNSUPPORTED_CAPABILITY, "Watch transfer is not connected", 422
|
||||
)
|
||||
request = {
|
||||
"device_id": terminal_id,
|
||||
"content": {"type": "image", "asset_id": asset_id},
|
||||
"options": {
|
||||
**(options or {}),
|
||||
"cleanup_unstarred": False,
|
||||
"generate_thumbnail": False,
|
||||
"thumbnail_plan_id": None,
|
||||
},
|
||||
}
|
||||
from pigway_frame_flow.models import DisplayRequest
|
||||
|
||||
request_model = DisplayRequest.model_validate(request)
|
||||
request = request_model.model_dump(mode="json")
|
||||
previous = self.db.one(
|
||||
"SELECT operation_id FROM terminal_deliveries WHERE terminal_id=? AND asset_id=? "
|
||||
+ ("AND parent_operation_id=? " if parent_operation_id else "")
|
||||
+ "ORDER BY created_at DESC,operation_id DESC LIMIT 1",
|
||||
(terminal_id, asset_id, parent_operation_id)
|
||||
if parent_operation_id
|
||||
else (terminal_id, asset_id),
|
||||
)
|
||||
if previous:
|
||||
op = self.db.one(
|
||||
"SELECT id,status,payload FROM operations WHERE id=?",
|
||||
(previous["operation_id"],),
|
||||
)
|
||||
if op and (
|
||||
parent_operation_id
|
||||
or (
|
||||
op["status"]
|
||||
in ("QUEUED", "RENDERING", "UPLOADING", "SYNCING", "UNKNOWN")
|
||||
and json.loads(op["payload"]) == request
|
||||
)
|
||||
):
|
||||
return op["id"]
|
||||
self.service.validate(request_model)
|
||||
# Reuse the queue's durable idempotency receipt if a restart occurs
|
||||
# between submitting a child operation and linking its delivery row.
|
||||
key = (
|
||||
"library-output-"
|
||||
+ hashlib.sha256(
|
||||
encode([parent_operation_id, terminal_id, asset_id]).encode()
|
||||
).hexdigest()
|
||||
if parent_operation_id
|
||||
else None
|
||||
)
|
||||
operation = self.service.submit(
|
||||
"display",
|
||||
request,
|
||||
terminal_id,
|
||||
client_id,
|
||||
"library-" + uuid4().hex,
|
||||
key=key,
|
||||
)
|
||||
self.db.execute(
|
||||
"INSERT INTO terminal_deliveries VALUES(?,?,?,?,?)",
|
||||
(terminal_id, asset_id, operation, parent_operation_id, now_iso()),
|
||||
)
|
||||
return operation
|
||||
|
||||
def pipeline(
|
||||
self,
|
||||
source_id,
|
||||
item_id=None,
|
||||
options=None,
|
||||
deliver=False,
|
||||
operation_id=None,
|
||||
caller="admin",
|
||||
):
|
||||
root_operation = (
|
||||
(self.db.one("SELECT * FROM operations WHERE id=?", (operation_id,)) or {})
|
||||
if operation_id
|
||||
else {}
|
||||
)
|
||||
previous = (
|
||||
json.loads(root_operation["result"] or "{}") if root_operation else {}
|
||||
)
|
||||
bindings = previous.get("binding_snapshot")
|
||||
if bindings is None:
|
||||
bindings = self.db.all(
|
||||
"SELECT * FROM terminal_bindings WHERE source_id=? ORDER BY terminal_id",
|
||||
(source_id,),
|
||||
)
|
||||
bindings = [
|
||||
{**b, "plan_snapshot": self.plan(b["plan_id"]).model_dump()}
|
||||
for b in bindings
|
||||
]
|
||||
if not bindings:
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Bind at least one terminal to this source library first",
|
||||
409,
|
||||
{"reason": "libraryBindFirst"},
|
||||
)
|
||||
if operation_id:
|
||||
self.service.update(operation_id, "RENDERING")
|
||||
if previous.get("source_image_id"):
|
||||
selected = self.picture(previous["source_image_id"])
|
||||
if selected["source_id"] != source_id:
|
||||
raise FlowError(
|
||||
E.CONFLICT, "Saved selection belongs to another source library", 409
|
||||
)
|
||||
else:
|
||||
selected = self.service.content_asset(
|
||||
PhotoSourceContent(
|
||||
type="photo_source", source_id=source_id, item_id=item_id
|
||||
),
|
||||
operation_id=operation_id,
|
||||
source_only=True,
|
||||
source_size=max(
|
||||
(
|
||||
(b["plan_snapshot"]["width"], b["plan_snapshot"]["height"])
|
||||
for b in bindings
|
||||
),
|
||||
key=lambda size: size[0] * size[1],
|
||||
),
|
||||
)
|
||||
result = {
|
||||
"source_image_id": selected["asset_id"],
|
||||
"binding_snapshot": bindings,
|
||||
"outputs": [],
|
||||
"errors": [],
|
||||
}
|
||||
if operation_id:
|
||||
self.service.assets.record_origin(selected["asset_id"], root_operation)
|
||||
self.service.update(operation_id, "RENDERING", result)
|
||||
versions = {}
|
||||
for binding in bindings:
|
||||
terminal_id, plan_id = binding["terminal_id"], binding["plan_id"]
|
||||
try:
|
||||
if plan_id not in versions:
|
||||
versions[plan_id] = self.generate(
|
||||
selected["asset_id"], plan_id, binding["plan_snapshot"]
|
||||
)
|
||||
version = versions[plan_id]
|
||||
if operation_id:
|
||||
self.service.assets.record_origin(
|
||||
version["asset_id"], root_operation
|
||||
)
|
||||
output = {
|
||||
"terminal_id": terminal_id,
|
||||
"asset_id": version["asset_id"],
|
||||
"plan_id": plan_id,
|
||||
"status": "GENERATED",
|
||||
}
|
||||
if deliver and terminal_id != "watch":
|
||||
output["operation_id"] = self.send(
|
||||
terminal_id,
|
||||
version["asset_id"],
|
||||
options,
|
||||
"library-terminal" if operation_id else caller,
|
||||
parent_operation_id=operation_id,
|
||||
)
|
||||
output["status"] = "QUEUED"
|
||||
result["outputs"].append(output)
|
||||
if operation_id:
|
||||
self.service.update(operation_id, "RENDERING", result)
|
||||
except (FlowError, OSError) as exc:
|
||||
result["errors"].append(
|
||||
{
|
||||
"terminal_id": terminal_id,
|
||||
"plan_id": plan_id,
|
||||
"message": str(exc),
|
||||
}
|
||||
)
|
||||
if operation_id:
|
||||
self.service.update(
|
||||
operation_id,
|
||||
"COMPLETED" if not result["errors"] else "FAILED",
|
||||
result,
|
||||
error={
|
||||
"code": E.INVALID_REQUEST,
|
||||
"message": "Some terminal outputs failed",
|
||||
"request_id": root_operation["request_id"],
|
||||
"details": {"reason": "libraryOutputPartial"},
|
||||
}
|
||||
if result["errors"]
|
||||
else None,
|
||||
)
|
||||
return result
|
||||
|
||||
def execute(self, operation, payload):
|
||||
result = self.pipeline(
|
||||
payload["content"]["source_id"],
|
||||
payload["content"].get("item_id"),
|
||||
payload.get("options"),
|
||||
deliver=True,
|
||||
operation_id=operation["id"],
|
||||
)
|
||||
self.db.history(operation, "COMPLETED" if not result["errors"] else "FAILED")
|
||||
|
||||
def check_asset_references(self, asset_id):
|
||||
reference = "%" + asset_id + "%"
|
||||
if (
|
||||
self.db.one(
|
||||
"SELECT id FROM operations WHERE (payload LIKE ? OR result LIKE ?) AND status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')",
|
||||
(reference, reference),
|
||||
)
|
||||
or self.db.one("SELECT id FROM jobs WHERE payload LIKE ?", (reference,))
|
||||
or self.db.one(
|
||||
"SELECT template_id FROM template_config WHERE payload LIKE ?",
|
||||
(reference,),
|
||||
)
|
||||
):
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Image is used by a task, template or pending operation",
|
||||
409,
|
||||
)
|
||||
|
||||
def remove_assets(self, asset_ids):
|
||||
with self.db.transaction():
|
||||
for asset_id in asset_ids:
|
||||
row = self.db.one("SELECT kind FROM assets WHERE id=?", (asset_id,))
|
||||
if not row:
|
||||
continue
|
||||
self.db.execute(
|
||||
"INSERT OR REPLACE INTO asset_file_journal VALUES(?,?)",
|
||||
(asset_id, row["kind"]),
|
||||
)
|
||||
for table, key in [
|
||||
("library_versions", "asset_id"),
|
||||
("terminal_deliveries", "asset_id"),
|
||||
("source_images", "id"),
|
||||
("asset_sources", "asset_id"),
|
||||
("asset_metadata", "asset_id"),
|
||||
("assets", "id"),
|
||||
]:
|
||||
self.db.execute(f"DELETE FROM {table} WHERE {key}=?", (asset_id,))
|
||||
self.db.execute(
|
||||
"DELETE FROM photo_preferences WHERE source_id='@assets' AND item_id=?",
|
||||
(asset_id,),
|
||||
)
|
||||
self.service.assets.recover_files()
|
||||
|
||||
def remove_asset(self, asset_id):
|
||||
self.remove_assets([asset_id])
|
||||
|
||||
def delete_picture(self, picture_id):
|
||||
with self.service.lock:
|
||||
self.picture(picture_id)
|
||||
ids = [
|
||||
r["asset_id"]
|
||||
for r in self.db.all(
|
||||
"SELECT asset_id FROM library_versions WHERE source_image_id=?",
|
||||
(picture_id,),
|
||||
)
|
||||
] + [picture_id]
|
||||
for asset_id in ids:
|
||||
self.check_asset_references(asset_id)
|
||||
self.remove_assets(ids)
|
||||
|
||||
def check_source_delete(self, source_id):
|
||||
if (
|
||||
self.db.one(
|
||||
"SELECT terminal_id FROM terminal_bindings WHERE source_id=?",
|
||||
(source_id,),
|
||||
)
|
||||
or self.db.one(
|
||||
"SELECT id FROM library_plans WHERE source_id=? AND archived=0",
|
||||
(source_id,),
|
||||
)
|
||||
or self.db.one(
|
||||
"SELECT id FROM source_images WHERE source_id=?", (source_id,)
|
||||
)
|
||||
):
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Remove terminal bindings, generation plans and local source pictures first",
|
||||
409,
|
||||
)
|
||||
@@ -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},
|
||||
)
|
||||
@@ -5,7 +5,9 @@ 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
|
||||
@@ -50,7 +52,11 @@ class DisplayService:
|
||||
self.lock = threading.RLock()
|
||||
self.retention = Retention(self)
|
||||
self.cloud_library = CloudLibrary(self)
|
||||
self.recovery = RecoveryMonitor(self)
|
||||
self.upload_reconciliation = UploadReconciliation(self)
|
||||
from pigway_frame_flow.display.libraries import Libraries
|
||||
|
||||
self.libraries = Libraries(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.
|
||||
@@ -64,6 +70,18 @@ class DisplayService:
|
||||
|
||||
def validate(self, request, writing=True):
|
||||
self.registry.get(request.device_id)
|
||||
if (
|
||||
writing
|
||||
and isinstance(request.content, ImageContent)
|
||||
and request.content.asset_id
|
||||
):
|
||||
self.libraries.validate_asset_binding(
|
||||
request.device_id, request.content.asset_id
|
||||
)
|
||||
if request.options.thumbnail_plan_id:
|
||||
from pigway_frame_flow.display.thumbnail_plans import get_plan
|
||||
|
||||
get_plan(self, request.options.thumbnail_plan_id)
|
||||
if isinstance(request.content, FutureContent):
|
||||
raise FlowError(
|
||||
E.UNSUPPORTED_CAPABILITY, "Content type is unsupported", 422
|
||||
@@ -113,7 +131,14 @@ class DisplayService:
|
||||
)
|
||||
return existing["operation_id"]
|
||||
if (
|
||||
action not in ("render", "render-ai", "local-upload")
|
||||
action
|
||||
not in (
|
||||
"display",
|
||||
"render",
|
||||
"render-ai",
|
||||
"local-upload",
|
||||
"library-generate",
|
||||
)
|
||||
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,),
|
||||
@@ -150,6 +175,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:
|
||||
@@ -160,18 +210,29 @@ 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 {}
|
||||
for key in ("photo_attempts", "upload_intent"):
|
||||
for key in (
|
||||
"photo_attempts",
|
||||
"upload_intent",
|
||||
"sync_observations",
|
||||
"wrist_thumbnail",
|
||||
):
|
||||
if key in previous:
|
||||
result = {**result, key: previous[key]}
|
||||
self.db.execute(
|
||||
@@ -184,6 +245,15 @@ class DisplayService:
|
||||
id,
|
||||
),
|
||||
)
|
||||
if status == "COMPLETED":
|
||||
row = self.db.one("SELECT result FROM operations WHERE id=?", (id,))
|
||||
asset_id = json.loads(row["result"] or "{}").get("rendered_asset_id")
|
||||
if asset_id and self.db.one(
|
||||
"SELECT id FROM assets WHERE id=?", (asset_id,)
|
||||
):
|
||||
self.assets.promote(asset_id)
|
||||
if status in ("COMPLETED", "FAILED"):
|
||||
self.recovery.resolve(id)
|
||||
|
||||
def blocked(self, device_id):
|
||||
return (
|
||||
@@ -200,12 +270,46 @@ 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,
|
||||
source_image=None,
|
||||
source_only=False,
|
||||
source_size=None,
|
||||
):
|
||||
if isinstance(content, AutoCollageContent):
|
||||
from pigway_frame_flow.extensions.auto_collage import prepare
|
||||
|
||||
return self.content_asset(
|
||||
prepare(self, content, device_id), device_id, operation_id
|
||||
prepare(self, content, device_id),
|
||||
device_id,
|
||||
operation_id,
|
||||
staging=staging,
|
||||
)
|
||||
if isinstance(content, ProviderContent):
|
||||
values = {**content.data, **self.providers.get(content.provider_id)}
|
||||
@@ -218,31 +322,41 @@ 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")
|
||||
source = {
|
||||
"type": "template",
|
||||
"template": content.template,
|
||||
"data": self.settings.sanitize(values),
|
||||
"profile": content.profile,
|
||||
}
|
||||
self.db.execute(
|
||||
"INSERT OR REPLACE INTO asset_sources VALUES(?,?)",
|
||||
(asset["asset_id"], encode(source)),
|
||||
)
|
||||
with self.assets.creating(
|
||||
data, content.template + ".jpg", "cache" if staging else "generated"
|
||||
) as asset:
|
||||
source = {
|
||||
"type": "template",
|
||||
"template": content.template,
|
||||
"data": self.settings.sanitize(values),
|
||||
"profile": content.profile,
|
||||
}
|
||||
self.db.execute(
|
||||
"INSERT OR REPLACE INTO asset_sources VALUES(?,?)",
|
||||
(asset["asset_id"], encode(source)),
|
||||
)
|
||||
return asset
|
||||
if isinstance(content, PhotoSourceContent):
|
||||
source = self.photo_sources.get(content.source_id)
|
||||
display = (
|
||||
self.registry.get(device_id or self.settings.device_id).device().display
|
||||
)
|
||||
size = frame_canvas(display, source.orientation)
|
||||
if source_only:
|
||||
size = source_size or (1200, 1600)
|
||||
else:
|
||||
display = (
|
||||
self.registry.get(device_id or self.settings.device_id)
|
||||
.device()
|
||||
.display
|
||||
)
|
||||
size = frame_canvas(display, source.orientation)
|
||||
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
|
||||
@@ -317,6 +431,17 @@ class DisplayService:
|
||||
),
|
||||
),
|
||||
)
|
||||
if source_only:
|
||||
selected = self.libraries.ingest(
|
||||
content.source_id, item["id"], data, name, caption
|
||||
)
|
||||
attempt["status"] = "PREPARED"
|
||||
if operation_id:
|
||||
self.db.execute(
|
||||
"UPDATE operations SET result=? WHERE id=?",
|
||||
(encode({"photo_attempts": attempts}), operation_id),
|
||||
)
|
||||
return selected
|
||||
rendered = (
|
||||
render_captioned_photo(
|
||||
data,
|
||||
@@ -345,13 +470,32 @@ class DisplayService:
|
||||
):
|
||||
raise
|
||||
continue
|
||||
if source_image is not None:
|
||||
source_image["data"] = data
|
||||
attempt["status"] = "PREPARED"
|
||||
if operation_id:
|
||||
self.db.execute(
|
||||
"UPDATE operations SET result=? WHERE id=?",
|
||||
(encode({"photo_attempts": attempts}), operation_id),
|
||||
)
|
||||
return self.assets.put(rendered, name)
|
||||
with self.assets.creating(
|
||||
rendered, name, "cache" if staging else "images"
|
||||
) as asset:
|
||||
self.db.execute(
|
||||
"INSERT OR REPLACE INTO asset_sources VALUES(?,?)",
|
||||
(
|
||||
asset["asset_id"],
|
||||
encode(
|
||||
{
|
||||
"type": "photo_source",
|
||||
"source_id": content.source_id,
|
||||
"item_id": item["id"],
|
||||
"source_path": item.get("source_path"),
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
return self.assets.info(asset["asset_id"])
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "No decodable photo found within 5 candidates"
|
||||
)
|
||||
@@ -364,11 +508,15 @@ 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()
|
||||
def current_content(self, device_id, *, device=None):
|
||||
if device is None:
|
||||
device = self.registry.get(device_id).device()
|
||||
self.clear_self_restore_reference(device_id, device)
|
||||
image_id = device.current_content.id
|
||||
row = self.db.one(
|
||||
"SELECT * FROM image_sources WHERE device_id=? AND image_id=?",
|
||||
@@ -419,16 +567,70 @@ 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",
|
||||
"library-generate",
|
||||
) 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",
|
||||
"library-terminal",
|
||||
"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"])
|
||||
@@ -442,7 +644,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
|
||||
@@ -475,6 +677,9 @@ class DisplayService:
|
||||
|
||||
def _execute(self, op, payload):
|
||||
id, device_id, action = op["id"], op["device_id"], op["action"]
|
||||
# A source run can generate watch versions without any frame driver.
|
||||
if action == "library-generate":
|
||||
return self.operation_handlers[action](op, payload)
|
||||
driver = self.registry.get(device_id)
|
||||
if action in self.operation_handlers:
|
||||
return self.operation_handlers[action](op, payload)
|
||||
@@ -484,15 +689,26 @@ class DisplayService:
|
||||
|
||||
from pigway_frame_flow.models import Content
|
||||
|
||||
source_image: dict[str, bytes] = {}
|
||||
asset = self.content_asset(
|
||||
TypeAdapter(Content).validate_python(payload), device_id, id
|
||||
TypeAdapter(Content).validate_python(payload),
|
||||
device_id,
|
||||
id,
|
||||
source_image=source_image,
|
||||
)
|
||||
self.assets.record_origin(asset["asset_id"], op)
|
||||
if action == "local-upload":
|
||||
asset = self.assets.promote(asset["asset_id"])
|
||||
self.update(
|
||||
id, "COMPLETED", dict(asset_id=asset["asset_id"], url=asset["url"])
|
||||
)
|
||||
result = dict(asset_id=asset["asset_id"], url=asset["url"])
|
||||
if action == "local-upload":
|
||||
from pigway_frame_flow.display.wrist_thumbnail import publish
|
||||
|
||||
thumbnail = publish(
|
||||
self, device_id, asset["asset_id"], source_image.get("data")
|
||||
)
|
||||
if thumbnail:
|
||||
result["wrist_thumbnail"] = thumbnail
|
||||
self.update(id, "COMPLETED", result)
|
||||
self.db.history(op, "COMPLETED")
|
||||
return
|
||||
device = driver.device(refresh=True)
|
||||
@@ -506,6 +722,8 @@ class DisplayService:
|
||||
previous = device.current_content.id
|
||||
state = self.state(device_id)
|
||||
if action == "delete":
|
||||
self.clear_self_restore_reference(device_id, device)
|
||||
state = self.state(device_id)
|
||||
image_id = payload["image_id"]
|
||||
if payload.get("cloud_cleanup"):
|
||||
if (
|
||||
@@ -544,7 +762,12 @@ class DisplayService:
|
||||
)
|
||||
}
|
||||
if image_id in protected and not payload.get("purge"):
|
||||
raise FlowError(E.CONFLICT, "Image is needed for restoration", 409)
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Image is needed for restoration",
|
||||
409,
|
||||
{"reason": "RESTORE_IMAGE_PROTECTED"},
|
||||
)
|
||||
if image_id == previous and not payload["confirm_current"]:
|
||||
raise FlowError(E.CONFLICT, "Current image needs confirmation", 409)
|
||||
self._start_write(id)
|
||||
@@ -598,10 +821,24 @@ 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)
|
||||
if request.options.thumbnail_plan_id:
|
||||
from pigway_frame_flow.display.thumbnail_plans import get_plan
|
||||
|
||||
get_plan(self, request.options.thumbnail_plan_id)
|
||||
content = request.content
|
||||
if isinstance(content, ImageContent) and content.asset_id:
|
||||
self.libraries.validate_asset_binding(device_id, content.asset_id)
|
||||
self.update(id, "RENDERING")
|
||||
asset = None
|
||||
source_image = {}
|
||||
if isinstance(content, ImageContent) and (
|
||||
content.image_id or content.image_name
|
||||
):
|
||||
@@ -614,7 +851,9 @@ 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, source_image=source_image
|
||||
)
|
||||
self.assets.record_origin(asset["asset_id"], op)
|
||||
image_id = None
|
||||
self.db.execute(
|
||||
@@ -648,8 +887,19 @@ class DisplayService:
|
||||
else:
|
||||
driver.display(image_id)
|
||||
result = {"image_id": image_id}
|
||||
if asset and request.options.generate_thumbnail:
|
||||
from pigway_frame_flow.display.wrist_thumbnail import publish
|
||||
|
||||
thumbnail = publish(
|
||||
self,
|
||||
device_id,
|
||||
asset["asset_id"],
|
||||
source_image.get("data"),
|
||||
plan_id=request.options.thumbnail_plan_id,
|
||||
)
|
||||
if thumbnail:
|
||||
result["wrist_thumbnail"] = thumbnail
|
||||
if asset:
|
||||
self.assets.promote(asset["asset_id"])
|
||||
result["rendered_asset_id"] = asset["asset_id"]
|
||||
source = self.db.one(
|
||||
"SELECT payload FROM asset_sources WHERE asset_id=?",
|
||||
@@ -702,42 +952,70 @@ class DisplayService:
|
||||
# Late acknowledgements only resolve a known display target. Never replay
|
||||
# writes or infer success from gallery presence alone.
|
||||
for op in self.db.all(
|
||||
"SELECT * FROM operations WHERE status='UNKNOWN' AND action='display' AND write_started=1"
|
||||
"SELECT * FROM operations WHERE status='UNKNOWN' AND action!='render-ai' AND write_started=1"
|
||||
):
|
||||
expected = json.loads(op["result"] or "{}").get("image_id")
|
||||
intent = json.loads(op["result"] or "{}").get("upload_intent")
|
||||
if not expected and not intent:
|
||||
continue
|
||||
checked = time.monotonic()
|
||||
if checked - self.unknown_checked.get(op["id"], float("-inf")) < 60:
|
||||
continue
|
||||
self.unknown_checked[op["id"]] = checked
|
||||
if op["action"] != "display" or (not expected and not intent):
|
||||
self.recovery.observe(
|
||||
op,
|
||||
"missing_upload_evidence"
|
||||
if op["action"] == "display"
|
||||
else "manual_reconciliation_required",
|
||||
)
|
||||
continue
|
||||
try:
|
||||
driver = self.registry.get(op["device_id"])
|
||||
device = driver.device(refresh=True)
|
||||
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)
|
||||
except FlowError:
|
||||
pass
|
||||
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"]})
|
||||
@@ -751,11 +1029,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:
|
||||
@@ -861,7 +1141,20 @@ 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":
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Separate watch gallery metadata; image files retain existing asset access rules."""
|
||||
|
||||
import json
|
||||
|
||||
from pigway_frame_flow.storage.database import encode
|
||||
|
||||
TARGET_ID = "watch"
|
||||
|
||||
|
||||
def record(service, asset_id, metadata):
|
||||
service.db.execute(
|
||||
"INSERT OR REPLACE INTO asset_sources VALUES(?,?)",
|
||||
(asset_id, encode({**metadata, "type": "thumbnail", "target_id": TARGET_ID})),
|
||||
)
|
||||
|
||||
|
||||
def recover(service):
|
||||
# Earlier automatic thumbnails have authoritative operation receipts. Never
|
||||
# guess that an unrelated image is a thumbnail from its name or dimensions.
|
||||
rows = service.db.all("SELECT result FROM operations WHERE result IS NOT NULL")
|
||||
receipts = [json.loads(row["result"]).get("wrist_thumbnail") for row in rows]
|
||||
receipts += [
|
||||
json.loads(row["payload"])
|
||||
for row in service.db.all(
|
||||
"SELECT payload FROM app_config WHERE key LIKE 'wrist-thumbnail:%'"
|
||||
)
|
||||
]
|
||||
for receipt in receipts:
|
||||
if not isinstance(receipt, dict) or not receipt.get("asset_id"):
|
||||
continue
|
||||
asset_id = receipt["asset_id"]
|
||||
if service.db.one(
|
||||
"SELECT id FROM assets WHERE id=?", (asset_id,)
|
||||
) and not service.db.one(
|
||||
"SELECT asset_id FROM asset_sources WHERE asset_id=?", (asset_id,)
|
||||
):
|
||||
record(service, asset_id, receipt)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Saved thumbnail rendering parameters; source pixels are never decorated."""
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.models import StrictModel
|
||||
from pigway_frame_flow.renderers.images import convert
|
||||
from pigway_frame_flow.storage.database import encode
|
||||
|
||||
|
||||
class ThumbnailPlan(StrictModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
width: int = Field(default=410, ge=16, le=2048)
|
||||
height: int = Field(default=502, ge=16, le=2048)
|
||||
fit: str = Field(default="cover", pattern="^(cover|contain)$")
|
||||
center_x: float = Field(default=0.5, ge=0, le=1, allow_inf_nan=False)
|
||||
center_y: float = Field(default=0.5, ge=0, le=1, allow_inf_nan=False)
|
||||
quality: int = Field(default=85, ge=40, le=95)
|
||||
|
||||
|
||||
def get_plan(service, plan_id):
|
||||
row = service.db.one(
|
||||
"SELECT payload FROM app_config WHERE key=?", ("thumbnail-plan:" + plan_id,)
|
||||
)
|
||||
if not row:
|
||||
raise FlowError(E.NOT_FOUND, "Thumbnail plan not found", 404)
|
||||
return ThumbnailPlan.model_validate_json(row["payload"])
|
||||
|
||||
|
||||
def render(data, plan):
|
||||
return convert(
|
||||
data,
|
||||
plan.fit,
|
||||
size=(plan.width, plan.height),
|
||||
centering=(plan.center_x, plan.center_y),
|
||||
quality=plan.quality,
|
||||
)
|
||||
|
||||
|
||||
def save_plan(service, plan_id, plan):
|
||||
service.db.execute(
|
||||
"INSERT OR REPLACE INTO app_config VALUES(?,?)",
|
||||
("thumbnail-plan:" + plan_id, encode(plan.model_dump())),
|
||||
)
|
||||
@@ -59,6 +59,12 @@ class UploadReconciliation:
|
||||
candidates.append(image)
|
||||
# Ambiguous matches never release a write lock.
|
||||
if len(candidates) != 1:
|
||||
service.recovery.observe(
|
||||
op,
|
||||
"gallery_match_missing"
|
||||
if not candidates
|
||||
else "gallery_match_ambiguous",
|
||||
)
|
||||
return None
|
||||
expected = candidates[0].image_id
|
||||
data = driver.download(expected)
|
||||
@@ -66,11 +72,11 @@ class UploadReconciliation:
|
||||
len(data) != intent["size"]
|
||||
or hashlib.sha256(data).hexdigest() != intent["sha256"]
|
||||
):
|
||||
service.recovery.observe(op, "content_mismatch")
|
||||
return None
|
||||
result["image_id"] = expected
|
||||
result["upload_confirmed"] = True
|
||||
service.update(op["id"], "UNKNOWN", result, json.loads(op["error"] or "{}"))
|
||||
service.assets.promote(result["rendered_asset_id"])
|
||||
source = service.db.one(
|
||||
"SELECT payload FROM asset_sources WHERE asset_id=?",
|
||||
(result["rendered_asset_id"],),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Source-only rendition for the current 410 × 502 watch display."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.renderers.images import convert
|
||||
from pigway_frame_flow.storage.database import encode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
SIZE = (410, 502)
|
||||
|
||||
|
||||
def publish(service, device_id, asset_id, data=None, plan_id=None):
|
||||
# Templates and old gallery assets have no verified undecorated source.
|
||||
row = service.db.one(
|
||||
"SELECT payload FROM asset_sources WHERE asset_id=?", (asset_id,)
|
||||
)
|
||||
source = json.loads(row["payload"]) if row else {}
|
||||
if source.get("type") != "photo_source":
|
||||
return None
|
||||
try:
|
||||
from pigway_frame_flow.display.thumbnail_plans import get_plan, render
|
||||
|
||||
plan = get_plan(service, plan_id) if plan_id else None
|
||||
size = (plan.width, plan.height) if plan else SIZE
|
||||
if data is None:
|
||||
data, _, _ = service.photo_sources.read(
|
||||
source["source_id"], source["item_id"], target_size=size
|
||||
)
|
||||
rendered = (
|
||||
render(data, plan) if plan else convert(data, "cover", "color", size=SIZE)
|
||||
)
|
||||
asset = service.assets.put(rendered, "watch.jpg", "cache")
|
||||
now = datetime.now(ZoneInfo(service.settings.timezone))
|
||||
result = {
|
||||
"asset_id": asset["asset_id"],
|
||||
"url": asset["url"],
|
||||
"width": size[0],
|
||||
"height": size[1],
|
||||
"date": now.date().isoformat(),
|
||||
"timezone": service.settings.timezone,
|
||||
"generated_at": now.isoformat(),
|
||||
"sha256": hashlib.sha256(rendered).hexdigest(),
|
||||
"bytes": len(rendered),
|
||||
"source": source,
|
||||
"fit": plan.fit if plan else "cover",
|
||||
"plan_id": plan_id,
|
||||
"plan": plan.model_dump() if plan else None,
|
||||
"source_rendition": "preview"
|
||||
if service.photo_sources.get(source["source_id"]).kind == "synology-photos"
|
||||
else "source",
|
||||
}
|
||||
from pigway_frame_flow.display.thumbnail_gallery import record
|
||||
|
||||
record(service, asset["asset_id"], {**result, "source_frame_id": device_id})
|
||||
service.db.execute(
|
||||
"INSERT OR REPLACE INTO app_config VALUES(?,?)",
|
||||
("wrist-thumbnail:" + device_id, encode(result)),
|
||||
)
|
||||
return result
|
||||
except (FlowError, OSError, ValueError):
|
||||
# An auxiliary rendition must never cause a successful upload to replay.
|
||||
logger.warning("Watch thumbnail generation failed for asset %s", asset_id)
|
||||
return None
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
raise FlowError(
|
||||
code,
|
||||
"Device cloud request failed",
|
||||
429 if code == E.RATE_LIMITED else 502,
|
||||
)
|
||||
if binary:
|
||||
if len(response.content) > 20 * 1024 * 1024:
|
||||
raise FlowError(E.INVALID_REQUEST, "Cloud image exceeds size limit")
|
||||
return response.content
|
||||
try:
|
||||
value = response.json()
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError()
|
||||
# Cloud errors may echo credentials: never return raw messages.
|
||||
if value.get("success") is False or (
|
||||
"message" in value and "id" not in value
|
||||
value = None
|
||||
if not binary or response.status_code != 200:
|
||||
try:
|
||||
value = response.json()
|
||||
except ValueError:
|
||||
pass
|
||||
message = value.get("message") if isinstance(value, dict) else None
|
||||
status = response.status_code
|
||||
# Only exact documented business failures establish rejection. Generic
|
||||
# 5xx responses may arrive after a write; those remain UNKNOWN.
|
||||
business = None
|
||||
if status in (404, 500) and isinstance(message, str):
|
||||
if message == "image not found":
|
||||
business = (
|
||||
E.NOT_FOUND,
|
||||
"Cloud image not found",
|
||||
404,
|
||||
"image_not_found",
|
||||
)
|
||||
elif status == 500 and message == "imageId is empty":
|
||||
business = (
|
||||
E.INVALID_REQUEST,
|
||||
"Cloud image ID is invalid",
|
||||
400,
|
||||
"invalid_image_id",
|
||||
)
|
||||
elif status == 500 and message.startswith("can not find robot "):
|
||||
business = (
|
||||
E.CONFIG_REQUIRED,
|
||||
"Device key does not identify a frame",
|
||||
503,
|
||||
"device_key_invalid",
|
||||
)
|
||||
elif status == 404 and message.startswith("can not find method "):
|
||||
business = (
|
||||
E.UNSUPPORTED_CAPABILITY,
|
||||
"Cloud method not found",
|
||||
422,
|
||||
"method_not_found",
|
||||
)
|
||||
if business:
|
||||
code, safe_message, local_status, kind = business
|
||||
raise FlowError(
|
||||
code,
|
||||
safe_message,
|
||||
local_status,
|
||||
details={
|
||||
"http_status": status,
|
||||
"failure_kind": kind,
|
||||
"retryable": False,
|
||||
"write_outcome": "rejected" if write else "read_only",
|
||||
},
|
||||
)
|
||||
if status != 200:
|
||||
limited = status == 429
|
||||
rejected = 400 <= status < 500
|
||||
uncertain = write and not rejected
|
||||
details = {
|
||||
"http_status": status,
|
||||
"retryable": limited or (not write and status >= 500),
|
||||
"write_outcome": "unknown"
|
||||
if uncertain
|
||||
else ("rejected" if write else "read_only"),
|
||||
}
|
||||
if limited:
|
||||
delay = self._retry_after(response.headers.get("Retry-After"))
|
||||
details["retry_after_seconds"] = delay
|
||||
with self.lock:
|
||||
self.cooldown_until = max(
|
||||
self.cooldown_until, time.monotonic() + delay
|
||||
)
|
||||
raise FlowError(
|
||||
E.RATE_LIMITED
|
||||
if limited
|
||||
else (E.WRITE_RESULT_UNKNOWN if uncertain else E.CLOUD_UNAVAILABLE),
|
||||
"Device cloud request failed",
|
||||
429 if limited else 502,
|
||||
details=details,
|
||||
)
|
||||
if binary:
|
||||
if len(response.content) > 20 * 1024 * 1024:
|
||||
raise FlowError(E.INVALID_REQUEST, "Cloud image exceeds size limit")
|
||||
return response.content
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("success") is False
|
||||
or ("message" in value and "id" not in value)
|
||||
):
|
||||
raise ValueError()
|
||||
raise FlowError(
|
||||
E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE,
|
||||
"Invalid device cloud response",
|
||||
502,
|
||||
details={
|
||||
"failure_kind": "invalid_response",
|
||||
"retryable": not write,
|
||||
"write_outcome": "unknown" if write else "read_only",
|
||||
},
|
||||
)
|
||||
return value
|
||||
except ValueError:
|
||||
raise FlowError(
|
||||
E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE,
|
||||
"Invalid device cloud response",
|
||||
502,
|
||||
) from None
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
|
||||
class KokonnaClient:
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -23,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)
|
||||
|
||||
@@ -155,7 +155,9 @@ def install_ai_images(app, service):
|
||||
image_data = base64.b64decode(result[0]["b64_json"], validate=True)
|
||||
else:
|
||||
image_data = service.assets.fetch_url(result[0]["url"])
|
||||
with Image.open(BytesIO(image_data)) as image:
|
||||
with Image.open(
|
||||
BytesIO(image_data), formats=("JPEG", "PNG", "WEBP")
|
||||
) as image:
|
||||
if image.width * image.height > MAX_IMAGE_PIXELS:
|
||||
raise ValueError()
|
||||
rgba = ImageOps.exif_transpose(image).convert("RGBA")
|
||||
|
||||
@@ -76,7 +76,9 @@ def install_extensions(app, service):
|
||||
if len(data) > settings.max_bytes:
|
||||
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
|
||||
try:
|
||||
with Image.open(BytesIO(data)) as image:
|
||||
with Image.open(
|
||||
BytesIO(data), formats=("JPEG", "PNG", "WEBP")
|
||||
) as image:
|
||||
if (
|
||||
image.format not in {"JPEG", "PNG", "WEBP"}
|
||||
or image.width * image.height > MAX_IMAGE_PIXELS
|
||||
@@ -92,11 +94,11 @@ def install_extensions(app, service):
|
||||
raise FlowError(E.INVALID_REQUEST, "Invalid image") from None
|
||||
result = []
|
||||
for data, name in prepared:
|
||||
asset = service.assets.put(data, name)
|
||||
db.execute(
|
||||
"INSERT INTO asset_metadata(asset_id,origin) VALUES(?,'manual')",
|
||||
(asset["asset_id"],),
|
||||
)
|
||||
with service.assets.creating(data, name) as asset:
|
||||
db.execute(
|
||||
"INSERT INTO asset_metadata(asset_id,origin) VALUES(?,'manual')",
|
||||
(asset["asset_id"],),
|
||||
)
|
||||
result.append(asset)
|
||||
return result
|
||||
|
||||
@@ -220,6 +222,10 @@ def install_extensions(app, service):
|
||||
for row in db.all("SELECT * FROM clients")
|
||||
]
|
||||
|
||||
@app.get("/api/v1/client-scopes", dependencies=[Depends(require("admin"))])
|
||||
def client_scopes():
|
||||
return ALL_SCOPES
|
||||
|
||||
@app.post(
|
||||
"/api/v1/clients",
|
||||
status_code=201,
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -19,7 +20,11 @@ def prepare(service, content, device_id=None):
|
||||
from pigway_frame_flow.extensions.quality import passes, photo_quality
|
||||
|
||||
source = service.photo_sources.get(content.source_id)
|
||||
items = service.photo_sources.catalog(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:
|
||||
@@ -32,7 +37,9 @@ def prepare(service, content, device_id=None):
|
||||
if source.avoid_duplicates and quality["fingerprint"] in hashes:
|
||||
continue
|
||||
hashes.update([quality["sha256"], quality["fingerprint"]])
|
||||
with Image.open(BytesIO(data)) as image:
|
||||
with Image.open(
|
||||
BytesIO(data), formats=("JPEG", "PNG", "WEBP")
|
||||
) as image:
|
||||
photo = ImageOps.exif_transpose(image).convert("RGB")
|
||||
photo.thumbnail((2400, 2400))
|
||||
output = BytesIO()
|
||||
@@ -46,7 +53,7 @@ def prepare(service, content, device_id=None):
|
||||
break
|
||||
else:
|
||||
rows = service.db.all(
|
||||
"SELECT id AS asset_id FROM assets WHERE kind='images' ORDER BY created_at DESC LIMIT 10000"
|
||||
"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))
|
||||
|
||||
@@ -126,7 +126,9 @@ class LayoutStudio:
|
||||
plan = local_plan(request)
|
||||
metadata = []
|
||||
for asset_id in request.asset_ids:
|
||||
with Image.open(self.renderer.assets.path(asset_id)) as image:
|
||||
with Image.open(
|
||||
self.renderer.assets.path(asset_id), formats=("JPEG", "PNG", "WEBP")
|
||||
) as image:
|
||||
metadata.append(
|
||||
{"asset_id": asset_id, "width": image.width, "height": image.height}
|
||||
)
|
||||
@@ -203,7 +205,10 @@ class LayoutStudio:
|
||||
plan = LayoutPlan.model_validate(data["layout"])
|
||||
canvas = Image.new("RGB", (plan.width, plan.height), "white")
|
||||
for region in plan.regions:
|
||||
with Image.open(self.renderer.assets.path(region.asset_id)) as source:
|
||||
with Image.open(
|
||||
self.renderer.assets.path(region.asset_id),
|
||||
formats=("JPEG", "PNG", "WEBP"),
|
||||
) as source:
|
||||
image = ImageOps.exif_transpose(source).convert("RGB")
|
||||
image = ImageOps.fit(
|
||||
image,
|
||||
|
||||
@@ -7,7 +7,7 @@ from PIL import Image, ImageFilter, ImageOps, ImageStat
|
||||
|
||||
|
||||
def photo_quality(data):
|
||||
with Image.open(BytesIO(data)) as source:
|
||||
with Image.open(BytesIO(data), formats=("JPEG", "PNG", "WEBP")) as source:
|
||||
image = ImageOps.exif_transpose(source).convert("L")
|
||||
image.thumbnail((256, 256))
|
||||
brightness = ImageStat.Stat(image).mean[0]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Opt-in local retention, protecting stars, current content and durable references."""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
@@ -43,6 +44,8 @@ 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:%'",
|
||||
"SELECT payload FROM app_config WHERE key LIKE 'wrist-thumbnail:%'",
|
||||
]:
|
||||
payloads += [row["payload"] for row in db.all(query)]
|
||||
for payload in payloads:
|
||||
@@ -78,7 +81,19 @@ class LocalRetention:
|
||||
def stats(self):
|
||||
rows = self.inspect()
|
||||
protected = self.protected()
|
||||
disk = shutil.disk_usage(self.service.settings.data_dir)
|
||||
low_space = disk.free < max(512 * 1024 * 1024, int(disk.total * 0.05))
|
||||
return {
|
||||
"disk": {
|
||||
"total_bytes": disk.total,
|
||||
"used_bytes": disk.used,
|
||||
"free_bytes": disk.free,
|
||||
"low_space": low_space,
|
||||
},
|
||||
"pending_file_cleanup": len(
|
||||
self.service.db.all("SELECT id FROM asset_file_journal")
|
||||
)
|
||||
+ len(self.service.db.all("SELECT id FROM asset_file_moves")),
|
||||
"policy": self.config().model_dump(),
|
||||
"total_bytes": sum(x[2] for x in rows),
|
||||
"asset_count": len(rows),
|
||||
@@ -90,6 +105,7 @@ class LocalRetention:
|
||||
return {}
|
||||
if not dry_run:
|
||||
self.last = time.monotonic()
|
||||
self.service.assets.recover_files()
|
||||
config = self.config()
|
||||
if not config.enabled:
|
||||
return {"deleted": 0, "bytes_freed": 0, "disabled": True}
|
||||
@@ -99,7 +115,7 @@ class LocalRetention:
|
||||
total = sum(x[2] for x in rows)
|
||||
removed = []
|
||||
freed = 0
|
||||
for row, path, size in rows:
|
||||
for row, _path, size in rows:
|
||||
# Manual uploaded photos are never removed by automatic retention.
|
||||
if row["id"] in protected or row["kind"] not in ("generated", "cache"):
|
||||
continue
|
||||
@@ -117,15 +133,7 @@ class LocalRetention:
|
||||
total -= size
|
||||
freed += size
|
||||
if not dry_run:
|
||||
path.unlink(missing_ok=True)
|
||||
for table, key in [
|
||||
("asset_metadata", "asset_id"),
|
||||
("asset_sources", "asset_id"),
|
||||
("assets", "id"),
|
||||
]:
|
||||
self.service.db.execute(
|
||||
f"DELETE FROM {table} WHERE {key}=?", (row["id"],)
|
||||
)
|
||||
self.service.libraries.remove_asset(row["id"])
|
||||
result = {"deleted": len(removed), "bytes_freed": freed, "dry_run": dry_run}
|
||||
if not dry_run:
|
||||
self.service.db.execute(
|
||||
|
||||
@@ -138,6 +138,7 @@ def install_webhooks(app, service):
|
||||
allowed = {
|
||||
"operation.completed",
|
||||
"operation.failed",
|
||||
"operation.blocked",
|
||||
"display.changed",
|
||||
"device.online",
|
||||
"device.offline",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -107,6 +107,8 @@ Content = Annotated[
|
||||
|
||||
|
||||
class Options(StrictModel):
|
||||
generate_thumbnail: bool = True
|
||||
thumbnail_plan_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$")
|
||||
cleanup_unstarred: bool = False
|
||||
duration: int = Field(default=0, ge=0, le=604800)
|
||||
restore_previous: bool = False
|
||||
@@ -168,6 +170,12 @@ class ErrorResponse(BaseModel):
|
||||
|
||||
|
||||
class OperationResult(BaseModel):
|
||||
source_image_id: str | None = None
|
||||
outputs: list[dict[str, Any]] = Field(default_factory=list)
|
||||
errors: list[dict[str, Any]] = Field(default_factory=list)
|
||||
wrist_thumbnail: dict[str, Any] | None = None
|
||||
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
|
||||
@@ -177,7 +185,17 @@ class OperationResult(BaseModel):
|
||||
deferred: bool = False
|
||||
|
||||
|
||||
class RecoveryState(BaseModel):
|
||||
state: Literal["checking", "manual_review", "resolved"]
|
||||
reason: str
|
||||
first_checked_at: str
|
||||
last_checked_at: str
|
||||
checks: int
|
||||
requires_manual_review: bool
|
||||
|
||||
|
||||
class Operation(BaseModel):
|
||||
recovery: RecoveryState | None = None
|
||||
operation_id: str
|
||||
status: OperationStatus
|
||||
device_id: str
|
||||
@@ -255,7 +273,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
|
||||
@@ -266,6 +287,9 @@ class RestoreContent(StrictModel):
|
||||
|
||||
|
||||
class JobInput(StrictModel):
|
||||
library_pipeline: bool = False
|
||||
generate_thumbnail: bool = False
|
||||
thumbnail_plan_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$")
|
||||
cleanup_unstarred: bool = False
|
||||
enabled: bool = True
|
||||
device_id: str = "living-room"
|
||||
@@ -278,6 +302,23 @@ class JobInput(StrictModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_options(self):
|
||||
if self.library_pipeline and (
|
||||
not isinstance(self.source, PhotoSourceContent)
|
||||
or self.generate_thumbnail
|
||||
or self.cleanup_unstarred
|
||||
):
|
||||
raise ValueError(
|
||||
"Source-library tasks require a photo source and use terminal-bound generation plans"
|
||||
)
|
||||
if self.generate_thumbnail and (
|
||||
not self.thumbnail_plan_id
|
||||
or not isinstance(self.source, PhotoSourceContent)
|
||||
):
|
||||
raise ValueError(
|
||||
"Thumbnail generation requires a photo source and one saved plan"
|
||||
)
|
||||
if not self.generate_thumbnail and self.thumbnail_plan_id:
|
||||
raise ValueError("Enable thumbnail generation before selecting a plan")
|
||||
if self.cleanup_unstarred and not (
|
||||
isinstance(self.source, PhotoSourceContent) and self.source.item_id is None
|
||||
):
|
||||
@@ -323,7 +364,24 @@ class History(BaseModel):
|
||||
timestamp: str
|
||||
|
||||
|
||||
class GalleryDestination(BaseModel):
|
||||
id: str
|
||||
adapter: str
|
||||
name: str
|
||||
storage: Literal["cloud", "local"]
|
||||
connected: bool = False
|
||||
supports_upload: bool = False
|
||||
|
||||
|
||||
class DeviceIdentity(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class SettingsView(BaseModel):
|
||||
storage_name: str = "NAS"
|
||||
gallery_destinations: list[GalleryDestination] = Field(default_factory=list)
|
||||
device_catalog: list[DeviceIdentity] = Field(default_factory=list)
|
||||
version: str
|
||||
mock: bool
|
||||
write_enabled: bool
|
||||
@@ -336,7 +394,7 @@ class SettingsView(BaseModel):
|
||||
|
||||
class Health(BaseModel):
|
||||
status: str
|
||||
version: str = "0.1.2"
|
||||
version: str = "0.1.29"
|
||||
|
||||
|
||||
class Readiness(BaseModel):
|
||||
@@ -345,6 +403,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
|
||||
@@ -365,8 +425,8 @@ class RestoreRequest(StrictModel):
|
||||
|
||||
|
||||
class Version(BaseModel):
|
||||
product: str = "Pigway Frame Flow"
|
||||
version: str = "0.1.2"
|
||||
product: str = "PIGWay Frame Flow"
|
||||
version: str = "0.1.29"
|
||||
api_version: str = "v1"
|
||||
commit: str = "development"
|
||||
build_time: str = "unknown"
|
||||
@@ -469,10 +529,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"]
|
||||
|
||||
@@ -23,7 +23,7 @@ def dimensions(root: Path, path: Path):
|
||||
with os.fdopen(fd, "rb") as stream:
|
||||
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
||||
return None
|
||||
with Image.open(stream) as image:
|
||||
with Image.open(stream, formats=("JPEG", "PNG", "WEBP")) as image:
|
||||
width, height = image.size
|
||||
if image.getexif().get(274, 1) in (5, 6, 7, 8):
|
||||
width, height = height, width
|
||||
|
||||
@@ -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,17 @@ 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",
|
||||
"uploads",
|
||||
]
|
||||
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 +47,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 +67,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 +93,10 @@ class PhotoSources:
|
||||
def __init__(self, db, settings, assets):
|
||||
self.db, self.settings, self.assets = db, settings, assets
|
||||
self.cache = {}
|
||||
self.api_items = {}
|
||||
from pigway_frame_flow.providers.source_browser import SourceBrowser
|
||||
|
||||
self.browser = SourceBrowser(self)
|
||||
self.synology = SynologyPhotos(settings)
|
||||
# Register provider secrets before request IDs or errors can be reflected.
|
||||
for row in self.list():
|
||||
@@ -106,22 +126,39 @@ 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.kind == "uploads" and (
|
||||
source_id != "manual-uploads" or source.location != "."
|
||||
):
|
||||
raise FlowError(E.INVALID_REQUEST, "Invalid manual upload source")
|
||||
if source_id == "manual-uploads" and source.kind != "uploads":
|
||||
raise FlowError(E.CONFLICT, "Reserved manual upload 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 +177,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 +221,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 +238,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 +319,26 @@ 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)
|
||||
if source.kind == "uploads":
|
||||
return [
|
||||
{
|
||||
"id": row["item_id"],
|
||||
"name": row["name"],
|
||||
"caption": json.loads(row["caption"]),
|
||||
"starred": bool(row["starred"]),
|
||||
"excluded": bool(row["excluded"]),
|
||||
}
|
||||
for row in self.db.all(
|
||||
"SELECT s.item_id,s.caption,a.name,COALESCE(p.starred,0) AS starred,COALESCE(p.excluded,0) AS excluded FROM source_images s JOIN assets a ON a.id=s.id LEFT JOIN photo_preferences p ON p.source_id=s.source_id AND p.item_id=s.item_id WHERE s.source_id=? ORDER BY s.created_at DESC,s.id DESC",
|
||||
(source_id,),
|
||||
)
|
||||
]
|
||||
session_identity = (
|
||||
self.synology.sid() if source.kind == "synology-photos" else None
|
||||
)
|
||||
wanted = (
|
||||
orientation(*target_size)
|
||||
if source.orientation == "auto"
|
||||
@@ -277,6 +350,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 +360,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 +449,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 +482,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 +603,31 @@ 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)
|
||||
browsed = (
|
||||
self.browser.cached(source_id, item_id)
|
||||
if item_id and source.kind == "synology-photos"
|
||||
else None
|
||||
)
|
||||
items = (
|
||||
[browsed[1]]
|
||||
if browsed
|
||||
else 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)
|
||||
@@ -485,6 +636,18 @@ class PhotoSources:
|
||||
)
|
||||
if item is None:
|
||||
raise FlowError(E.NOT_FOUND, "Photo is not in this source", 404)
|
||||
if source.kind == "uploads":
|
||||
row = self.db.one(
|
||||
"SELECT id,caption FROM source_images WHERE source_id=? AND item_id=?",
|
||||
(source_id, item["id"]),
|
||||
)
|
||||
if not row:
|
||||
raise FlowError(E.NOT_FOUND, "Uploaded source picture not found", 404)
|
||||
return (
|
||||
self.assets.path(row["id"]).read_bytes(),
|
||||
item["name"],
|
||||
json.loads(row["caption"]),
|
||||
)
|
||||
if source.kind == "local":
|
||||
root = self.settings.photo_root.resolve()
|
||||
path = root / item["id"]
|
||||
@@ -526,6 +689,30 @@ 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(
|
||||
browsed[2] if browsed else 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(
|
||||
[p["name"] for p in item.get("people", []) if p["name"]]
|
||||
if browsed
|
||||
else 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 +721,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), formats=("JPEG", "PNG", "WEBP")) 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, formats=("JPEG", "PNG", "WEBP")) 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, formats=("JPEG", "PNG", "WEBP")) 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)
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Bounded provider browsing: page metadata first, download only selected photos."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import time as day_time
|
||||
from typing import Literal
|
||||
from urllib.parse import quote
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.models import StrictModel
|
||||
from pigway_frame_flow.providers.photo_names import path_caption
|
||||
from pigway_frame_flow.providers.photo_orientation import orientation
|
||||
|
||||
|
||||
class BrowseFilters(StrictModel):
|
||||
offset: int = Field(default=0, ge=0, le=10000000)
|
||||
limit: int = Field(default=36, ge=1, le=60)
|
||||
filename: str = Field(default="", max_length=200)
|
||||
folder: str = Field(default="", max_length=500)
|
||||
min_bytes: int = Field(default=0, ge=0, le=10**12)
|
||||
max_bytes: int | None = Field(default=None, ge=0, le=10**12)
|
||||
min_width: int = Field(default=0, ge=0, le=100000)
|
||||
max_width: int | None = Field(default=None, ge=0, le=100000)
|
||||
min_height: int = Field(default=0, ge=0, le=100000)
|
||||
max_height: int | None = Field(default=None, ge=0, le=100000)
|
||||
date_from: date | None = None
|
||||
date_to: date | None = None
|
||||
date_field: Literal["time", "indexed_time"] = "indexed_time"
|
||||
person_id: int | None = Field(default=None, ge=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def ranges(self):
|
||||
for low, high in (
|
||||
(self.min_bytes, self.max_bytes),
|
||||
(self.min_width, self.max_width),
|
||||
(self.min_height, self.max_height),
|
||||
):
|
||||
if low is not None and high is not None and low > high:
|
||||
raise ValueError("Filter range is reversed")
|
||||
if self.date_from and self.date_to and self.date_from > self.date_to:
|
||||
raise ValueError("Filter date range is reversed")
|
||||
return self
|
||||
|
||||
|
||||
class SourceBrowser:
|
||||
def __init__(self, sources):
|
||||
self.sources = sources
|
||||
self.rows: OrderedDict[tuple, tuple] = OrderedDict()
|
||||
|
||||
def identity(self, source_id):
|
||||
source = self.sources.get(source_id)
|
||||
self.sources.require_api_source(source)
|
||||
if not source.enabled:
|
||||
raise FlowError(E.CONFLICT, "Photo source is disabled", 409)
|
||||
session = (
|
||||
self.sources.synology.sid() if source.kind == "synology-photos" else None
|
||||
)
|
||||
return source, (source_id, source.model_dump_json(), session)
|
||||
|
||||
def item(self, source, row):
|
||||
folder = row.get("additional", {}).get("folder")
|
||||
if isinstance(folder, dict):
|
||||
folder = folder.get("name")
|
||||
if row.get("type") != "photo" or not isinstance(folder, str):
|
||||
return None
|
||||
name = row.get("filename")
|
||||
if not isinstance(name, str):
|
||||
return None
|
||||
folder = folder.strip("/")
|
||||
location = source.location.strip("/") if source.location != "." else ""
|
||||
if location and not (folder == location or folder.startswith(location + "/")):
|
||||
return None
|
||||
if not source.recursive and folder != location:
|
||||
return None
|
||||
path = folder + "/" + name
|
||||
caption = path_caption(path)
|
||||
if source.organized_only and caption is None:
|
||||
return None
|
||||
size = row.get("additional", {}).get("resolution", {})
|
||||
width, height = size.get("width", 0), size.get("height", 0)
|
||||
if not self.sources.resolution_matches(source, width, height):
|
||||
return None
|
||||
wanted = "portrait" if source.orientation == "auto" else source.orientation
|
||||
if wanted and (not width or not height or orientation(width, height) != wanted):
|
||||
return None
|
||||
people = row.get("additional", {}).get("person", [])
|
||||
ids = {str(p["id"]) for p in people if isinstance(p, dict) and "id" in p}
|
||||
if source.people_filter:
|
||||
selected = {str(i) for i in source.person_ids}
|
||||
if not (
|
||||
selected <= ids if source.person_match == "all" else selected & ids
|
||||
):
|
||||
return None
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"name": name,
|
||||
"folder": folder,
|
||||
"source_path": (source.nas_root_path or "").rstrip("/")
|
||||
+ "/"
|
||||
+ path.lstrip("/"),
|
||||
"caption": caption if source.folder_caption else None,
|
||||
"provider_preview": True,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"orientation": orientation(width, height),
|
||||
"filesize": row.get("filesize"),
|
||||
"time": row.get("time"),
|
||||
"indexed_time": row.get("indexed_time"),
|
||||
"people": [
|
||||
{"id": p["id"], "name": p.get("name") or ""}
|
||||
for p in people
|
||||
if isinstance(p, dict) and "id" in p
|
||||
],
|
||||
}
|
||||
|
||||
def matches(self, item, filters):
|
||||
for key in ("filename", "folder"):
|
||||
value = item["name" if key == "filename" else key]
|
||||
if getattr(filters, key).casefold() not in value.casefold():
|
||||
return False
|
||||
for key, low, high in (
|
||||
("filesize", filters.min_bytes, filters.max_bytes),
|
||||
("width", filters.min_width, filters.max_width),
|
||||
("height", filters.min_height, filters.max_height),
|
||||
):
|
||||
value = item.get(key)
|
||||
if (low or high is not None) and (
|
||||
value is None or value < low or (high is not None and value > high)
|
||||
):
|
||||
return False
|
||||
if filters.person_id and filters.person_id not in {
|
||||
p["id"] for p in item["people"]
|
||||
}:
|
||||
return False
|
||||
zone = ZoneInfo(self.sources.settings.timezone)
|
||||
value = item.get(filters.date_field)
|
||||
if filters.date_from and (
|
||||
not value
|
||||
or value < datetime.combine(filters.date_from, day_time(), zone).timestamp()
|
||||
):
|
||||
return False
|
||||
if (
|
||||
filters.date_to
|
||||
and filters.date_to < date.max
|
||||
and (
|
||||
not value
|
||||
or value
|
||||
>= datetime.combine(
|
||||
filters.date_to + timedelta(days=1), day_time(), zone
|
||||
).timestamp()
|
||||
)
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
def remember(self, identity, item, row):
|
||||
key = (*identity, item["id"])
|
||||
self.rows[key] = (time.monotonic(), item, row)
|
||||
self.rows.move_to_end(key)
|
||||
while len(self.rows) > 2048:
|
||||
self.rows.popitem(last=False)
|
||||
|
||||
def cached(self, source_id, item_id):
|
||||
if not self.rows:
|
||||
return None
|
||||
source, identity = self.identity(source_id)
|
||||
cached = self.rows.get((*identity, item_id))
|
||||
if cached and time.monotonic() - cached[0] < 1800:
|
||||
return source, cached[1], cached[2]
|
||||
return None
|
||||
|
||||
def page(self, source_id, offset, limit, filters):
|
||||
source, identity = self.identity(source_id)
|
||||
if source.kind != "synology-photos":
|
||||
# Other providers retain their established catalog implementation.
|
||||
items = self.sources.catalog(source_id)
|
||||
if filters.model_dump(exclude_defaults=True, exclude={"offset", "limit"}):
|
||||
raise FlowError(
|
||||
E.UNSUPPORTED_CAPABILITY, "These filters require Synology Photos"
|
||||
)
|
||||
return {
|
||||
"items": items[offset : offset + limit],
|
||||
"next_offset": offset + limit if offset + limit < len(items) else None,
|
||||
"scanned": 0,
|
||||
"paged_provider": False,
|
||||
}
|
||||
items = []
|
||||
scanned = 0
|
||||
prefix = "SYNO.FotoTeam" if source.people_space == "shared" else "SYNO.Foto"
|
||||
for _ in range(4):
|
||||
data = self.sources.synology.request(
|
||||
prefix + ".Browse.Item",
|
||||
"list",
|
||||
7,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
_sid=identity[2],
|
||||
additional=json.dumps(
|
||||
["folder", "thumbnail", "resolution", "orientation", "person"]
|
||||
),
|
||||
**({"person_id": filters.person_id} if filters.person_id else {}),
|
||||
)
|
||||
rows = data.get("list", [])
|
||||
for index, row in enumerate(rows):
|
||||
offset += 1
|
||||
scanned += 1
|
||||
item = self.item(source, row)
|
||||
if item and self.matches(item, filters):
|
||||
self.remember(identity, item, row)
|
||||
item = {
|
||||
**item,
|
||||
"preview_url": f"/api/v1/source-libraries/{quote(source_id, safe='')}/browse/{quote(item['id'], safe='')}/preview",
|
||||
}
|
||||
items.append(item)
|
||||
if len(items) == limit:
|
||||
more = (
|
||||
offset if index + 1 < len(rows) or len(rows) == limit else None
|
||||
)
|
||||
return {
|
||||
"items": items,
|
||||
"next_offset": more,
|
||||
"scanned": scanned,
|
||||
"paged_provider": True,
|
||||
}
|
||||
if len(rows) < limit:
|
||||
return {
|
||||
"items": items,
|
||||
"next_offset": None,
|
||||
"scanned": scanned,
|
||||
"paged_provider": True,
|
||||
}
|
||||
return {
|
||||
"items": items,
|
||||
"next_offset": offset,
|
||||
"scanned": scanned,
|
||||
"paged_provider": True,
|
||||
}
|
||||
|
||||
def preview(self, source_id, item_id):
|
||||
entry = self.cached(source_id, item_id)
|
||||
if not entry:
|
||||
raise FlowError(
|
||||
E.NOT_FOUND, "Photo preview expired; refresh the browser page", 404
|
||||
)
|
||||
source, _, row = entry
|
||||
return self.sources.synology.download(row, space=source.people_space, size="m")
|
||||
@@ -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,181 @@ 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", size="xl"):
|
||||
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 size not in ("m", "xl"):
|
||||
raise FlowError(E.INVALID_REQUEST, "Invalid Photos preview size")
|
||||
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=size,
|
||||
type="unit",
|
||||
)
|
||||
else:
|
||||
raise FlowError(
|
||||
E.UNSUPPORTED_CAPABILITY,
|
||||
"Only verified Photos previews 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,
|
||||
size,
|
||||
row.get("indexed_time"),
|
||||
row.get("filesize"),
|
||||
row.get("additional", {}).get("thumbnail", {}).get("cache_key"),
|
||||
]
|
||||
)
|
||||
return self.image_cache.read(
|
||||
key,
|
||||
lambda handle, cap, deadline: self._download(
|
||||
api, method, params, sid, handle, cap, deadline
|
||||
),
|
||||
)
|
||||
|
||||
def _download(self, api, method, params, sid, handle, cap, deadline):
|
||||
if (api, method) not in {
|
||||
("SYNO.FotoTeam.Thumbnail", "get"),
|
||||
("SYNO.Foto.Thumbnail", "get"),
|
||||
}:
|
||||
raise FlowError(E.FORBIDDEN, "Unsupported Photos download", 403)
|
||||
url, pin = self.endpoint()
|
||||
connection = http.client.HTTPSConnection(
|
||||
url.hostname,
|
||||
url.port or 443,
|
||||
timeout=30,
|
||||
context=ssl._create_unverified_context(),
|
||||
)
|
||||
try:
|
||||
connection.connect()
|
||||
certificate = connection.sock.getpeercert(binary_form=True)
|
||||
if not certificate or hashlib.sha256(certificate).hexdigest() != pin:
|
||||
raise FlowError(E.FORBIDDEN, "Synology Photos certificate changed", 403)
|
||||
connection.request(
|
||||
"POST",
|
||||
"/webapi/entry.cgi",
|
||||
urlencode(dict(api=api, method=method, version=2, _sid=sid, **params)),
|
||||
{"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
if response.status != 200:
|
||||
raise ValueError()
|
||||
if "json" in (response.getheader("Content-Type") or "").lower():
|
||||
result = json.loads(response.read(65537))
|
||||
if result.get("error", {}).get("code") in (106, 107, 119):
|
||||
self.disconnect()
|
||||
raise FlowError(
|
||||
E.CONFIG_REQUIRED, "Reconnect Synology Photos account", 409
|
||||
)
|
||||
if result.get("error", {}).get("code") == 105:
|
||||
raise FlowError(
|
||||
E.FORBIDDEN, "Photos account cannot download this photo", 403
|
||||
)
|
||||
raise FlowError(
|
||||
E.CLOUD_UNAVAILABLE, "Photos download was rejected", 502
|
||||
)
|
||||
expected = response.getheader("Content-Length")
|
||||
if expected and int(expected) > cap:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Photos image exceeds download limit; select thumbnail",
|
||||
)
|
||||
total = 0
|
||||
while True:
|
||||
if time.monotonic() > deadline:
|
||||
raise TimeoutError()
|
||||
chunk = response.read(min(65536, cap - total + 1))
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > cap:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Photos image exceeds download limit; select thumbnail",
|
||||
)
|
||||
handle.write(chunk)
|
||||
if expected and total != int(expected):
|
||||
raise ValueError()
|
||||
except (OSError, ValueError, http.client.HTTPException):
|
||||
raise FlowError(
|
||||
E.CLOUD_UNAVAILABLE,
|
||||
"Photos download interrupted; retry explicitly",
|
||||
502,
|
||||
) from None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import base64
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from io import BytesIO
|
||||
from urllib.parse import urlsplit
|
||||
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
|
||||
@@ -15,9 +21,17 @@ MAX_IMAGE_PIXELS = 25_000_000
|
||||
Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS
|
||||
|
||||
|
||||
def convert(data, fit="contain", profile="color", *, size=(1200, 1600)):
|
||||
def convert(
|
||||
data,
|
||||
fit="contain",
|
||||
profile="color",
|
||||
*,
|
||||
size=(1200, 1600),
|
||||
centering=(0.5, 0.5),
|
||||
quality=93,
|
||||
):
|
||||
try:
|
||||
with Image.open(BytesIO(data)) as source:
|
||||
with Image.open(BytesIO(data), formats=("JPEG", "PNG", "WEBP")) as source:
|
||||
if source.format not in {"JPEG", "PNG", "WEBP"}:
|
||||
raise ValueError()
|
||||
if source.width * source.height > MAX_IMAGE_PIXELS:
|
||||
@@ -31,7 +45,7 @@ def convert(data, fit="contain", profile="color", *, size=(1200, 1600)):
|
||||
img, size, method=Image.Resampling.LANCZOS, color="white"
|
||||
)
|
||||
else:
|
||||
center = (0.5, 0.5)
|
||||
center = centering
|
||||
if fit == "smart":
|
||||
# Lightweight edge saliency: slide an aspect-correct window
|
||||
# over a thumbnail; retain the region with most detail.
|
||||
@@ -63,7 +77,7 @@ def convert(data, fit="contain", profile="color", *, size=(1200, 1600)):
|
||||
if profile == "grayscale":
|
||||
result = ImageOps.grayscale(result).convert("RGB")
|
||||
out = BytesIO()
|
||||
result.save(out, "JPEG", quality=93, optimize=True)
|
||||
result.save(out, "JPEG", quality=quality, optimize=True)
|
||||
return out.getvalue()
|
||||
except (OSError, ValueError, Image.DecompressionBombError):
|
||||
raise FlowError(E.INVALID_REQUEST, "Unsupported or invalid image") from None
|
||||
@@ -72,17 +86,99 @@ def convert(data, fit="contain", profile="color", *, size=(1200, 1600)):
|
||||
class Assets:
|
||||
def __init__(self, db, settings):
|
||||
self.db, self.settings = db, settings
|
||||
self.file_lock = threading.RLock()
|
||||
db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS asset_file_journal(id TEXT PRIMARY KEY,kind TEXT NOT NULL)"
|
||||
)
|
||||
db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS asset_file_moves(id TEXT PRIMARY KEY,source_kind TEXT NOT NULL,target_kind TEXT NOT NULL)"
|
||||
)
|
||||
self.recover_files()
|
||||
|
||||
def put(self, data, name="image.jpg", kind="images"):
|
||||
@staticmethod
|
||||
def sync_directory(directory):
|
||||
fd = os.open(directory, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def recover_files(self):
|
||||
with self.file_lock:
|
||||
self._recover_files()
|
||||
|
||||
def _recover_files(self):
|
||||
for row in self.db.all("SELECT * FROM asset_file_journal"):
|
||||
path = self.settings.data_dir / row["kind"] / (row["id"] + ".jpg")
|
||||
try:
|
||||
if not self.db.one("SELECT id FROM assets WHERE id=?", (row["id"],)):
|
||||
path.unlink(missing_ok=True)
|
||||
path.with_suffix(".part").unlink(missing_ok=True)
|
||||
if path.parent.exists():
|
||||
self.sync_directory(path.parent)
|
||||
self.db.execute(
|
||||
"DELETE FROM asset_file_journal WHERE id=?", (row["id"],)
|
||||
)
|
||||
except OSError:
|
||||
logging.getLogger(__name__).warning("Asset file cleanup pending")
|
||||
|
||||
for row in self.db.all("SELECT * FROM asset_file_moves"):
|
||||
asset = self.db.one("SELECT kind FROM assets WHERE id=?", (row["id"],))
|
||||
obsolete = (
|
||||
row["source_kind"]
|
||||
if asset and asset["kind"] == row["target_kind"]
|
||||
else row["target_kind"]
|
||||
)
|
||||
path = self.settings.data_dir / obsolete / (row["id"] + ".jpg")
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
self.sync_directory(path.parent)
|
||||
self.db.execute("DELETE FROM asset_file_moves WHERE id=?", (row["id"],))
|
||||
except OSError:
|
||||
logging.getLogger(__name__).warning("Asset promotion cleanup pending")
|
||||
|
||||
@contextmanager
|
||||
def creating(self, data, name="image.jpg", kind="images"):
|
||||
with self.file_lock:
|
||||
with self._creating(data, name, kind) as asset:
|
||||
yield asset
|
||||
|
||||
@contextmanager
|
||||
def _creating(self, data, name="image.jpg", kind="images"):
|
||||
from pathlib import Path
|
||||
|
||||
if kind not in {"images", "generated", "cache", "source-images", "renditions"}:
|
||||
raise ValueError("Invalid asset kind")
|
||||
name = Path(name).stem[:110] + ".jpg"
|
||||
asset_id = uuid4().hex
|
||||
(self.settings.data_dir / kind / (asset_id + ".jpg")).write_bytes(data)
|
||||
self.db.execute(
|
||||
"INSERT INTO assets VALUES(?,?,?,?)", (asset_id, name, kind, now_iso())
|
||||
)
|
||||
return self.info(asset_id)
|
||||
path = self.settings.data_dir / kind / (asset_id + ".jpg")
|
||||
path.parent.mkdir(exist_ok=True)
|
||||
self.db.execute("INSERT INTO asset_file_journal VALUES(?,?)", (asset_id, kind))
|
||||
temporary = path.with_suffix(".part")
|
||||
try:
|
||||
with temporary.open("xb") as handle:
|
||||
handle.write(data)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
temporary.replace(path)
|
||||
self.sync_directory(path.parent)
|
||||
with self.db.transaction():
|
||||
self.db.execute(
|
||||
"INSERT INTO assets VALUES(?,?,?,?)",
|
||||
(asset_id, name, kind, now_iso()),
|
||||
)
|
||||
yield self.info(asset_id)
|
||||
self.db.execute(
|
||||
"DELETE FROM asset_file_journal WHERE id=?", (asset_id,)
|
||||
)
|
||||
except BaseException:
|
||||
# The journal remains durable if filesystem cleanup also fails.
|
||||
self.recover_files()
|
||||
raise
|
||||
|
||||
def put(self, data, name="image.jpg", kind="images"):
|
||||
with self.creating(data, name, kind) as asset:
|
||||
return asset
|
||||
|
||||
def record_origin(self, asset_id, operation):
|
||||
job = self.db.one(
|
||||
@@ -113,25 +209,58 @@ 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")
|
||||
self.path(asset_id).replace(target)
|
||||
self.db.execute("UPDATE assets SET kind='images' WHERE id=?", (asset_id,))
|
||||
return self.info(asset_id)
|
||||
with self.file_lock:
|
||||
row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
|
||||
if row and row["kind"] == "cache":
|
||||
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.recover_files()
|
||||
self.db.execute(
|
||||
"INSERT INTO asset_file_moves VALUES(?,?,?)",
|
||||
(asset_id, "cache", kind),
|
||||
)
|
||||
try:
|
||||
# Keep the source valid until the database points to the durable target.
|
||||
os.link(self.path(asset_id), target)
|
||||
self.sync_directory(target.parent)
|
||||
self.db.execute(
|
||||
"UPDATE assets SET kind=? WHERE id=?", (kind, asset_id)
|
||||
)
|
||||
except BaseException:
|
||||
self.recover_files()
|
||||
raise
|
||||
self.recover_files()
|
||||
return self.info(asset_id)
|
||||
|
||||
def info(self, asset_id):
|
||||
row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
|
||||
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,
|
||||
"asset_id": row["id"],
|
||||
"source": "generated" if row["kind"] == "generated" else "uploaded",
|
||||
"source": "generated"
|
||||
if row["kind"] in ("generated", "renditions")
|
||||
else "uploaded",
|
||||
"name": row["name"],
|
||||
"url": f"/api/v1/assets/{row['id']}",
|
||||
"created_at": row["created_at"],
|
||||
|
||||
@@ -17,6 +17,9 @@ def render_captioned_photo(
|
||||
*,
|
||||
size=(1200, 1600),
|
||||
layout_provider=None,
|
||||
centering=(0.5, 0.5),
|
||||
quality=93,
|
||||
respect_fit=False,
|
||||
):
|
||||
width, height = size
|
||||
layout = (layout_provider or WatermarkLayout()).layout(size, caption)
|
||||
@@ -31,17 +34,19 @@ def render_captioned_photo(
|
||||
# keeping aspect ratio and cropping rather than stretching or letterboxing.
|
||||
photo_data = convert(
|
||||
data,
|
||||
"smart" if fit == "smart" else "cover",
|
||||
fit if respect_fit else ("smart" if fit == "smart" else "cover"),
|
||||
profile,
|
||||
size=(width, photo_height),
|
||||
centering=centering,
|
||||
quality=quality,
|
||||
)
|
||||
canvas = Image.new("RGB", size, "white")
|
||||
with Image.open(BytesIO(photo_data)) as photo:
|
||||
with Image.open(BytesIO(photo_data), formats=("JPEG", "PNG", "WEBP")) as photo:
|
||||
canvas.paste(photo, (0, 0))
|
||||
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 +56,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] + "…"
|
||||
@@ -64,5 +69,5 @@ def render_captioned_photo(
|
||||
anchor="rm",
|
||||
)
|
||||
out = BytesIO()
|
||||
canvas.save(out, "JPEG", quality=93, optimize=True)
|
||||
canvas.save(out, "JPEG", quality=quality, optimize=True)
|
||||
return out.getvalue()
|
||||
|
||||
@@ -177,7 +177,9 @@ class TemplateRenderer:
|
||||
elif block["kind"] == "photo":
|
||||
asset_id = values.get("photo_id")
|
||||
if asset_id:
|
||||
with Image.open(self.assets.path(asset_id)) as photo:
|
||||
with Image.open(
|
||||
self.assets.path(asset_id), formats=("JPEG", "PNG", "WEBP")
|
||||
) as photo:
|
||||
img.paste(ImageOps.fit(photo.convert("RGB"), (w, h)), (x, y))
|
||||
else:
|
||||
draw.rectangle((x, y, x + w, y + h), outline="black", width=5)
|
||||
|
||||
@@ -15,14 +15,20 @@ from pigway_frame_flow.models import (
|
||||
ImageContent,
|
||||
JobInput,
|
||||
Options,
|
||||
PhotoSourceContent,
|
||||
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 +62,7 @@ class Scheduler:
|
||||
self.thread = None
|
||||
self.webhook_thread = None
|
||||
self.tick_lock = threading.Lock()
|
||||
self.upload_retries = UploadRetries(service)
|
||||
|
||||
def jobs(self):
|
||||
rows = [
|
||||
@@ -80,13 +87,64 @@ class Scheduler:
|
||||
row["last_result"] = operation["status"]
|
||||
return rows
|
||||
|
||||
def save_job(self, job, id=None):
|
||||
self.service.registry.get(job.device_id)
|
||||
if job.source.type != "restore":
|
||||
def validate_job(self, job):
|
||||
if not job.library_pipeline:
|
||||
self.service.registry.get(job.device_id)
|
||||
if job.library_pipeline and not self.db.one(
|
||||
"SELECT terminal_id FROM terminal_bindings WHERE source_id=?",
|
||||
(job.source.source_id,),
|
||||
):
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Bind terminals to the source library before saving this task",
|
||||
409,
|
||||
{"reason": "libraryBindFirst"},
|
||||
)
|
||||
if job.generate_thumbnail:
|
||||
from pigway_frame_flow.display.thumbnail_plans import get_plan
|
||||
|
||||
get_plan(self.service, job.thumbnail_plan_id)
|
||||
if job.library_pipeline:
|
||||
self.service.photo_sources.get(job.source.source_id)
|
||||
elif job.source.type != "restore":
|
||||
self.service.validate(
|
||||
DisplayRequest(device_id=job.device_id, content=job.source),
|
||||
writing=False,
|
||||
)
|
||||
|
||||
def set_library_mode(self, job_id, enabled):
|
||||
with self.service.lock:
|
||||
row = self.db.one("SELECT payload FROM jobs WHERE id=?", (job_id,))
|
||||
if not row:
|
||||
raise FlowError(E.NOT_FOUND, "Job not found", 404)
|
||||
values = json.loads(row["payload"])
|
||||
if enabled and values.get("source", {}).get("type") != "photo_source":
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Only photo-source tasks support source-library mode",
|
||||
422,
|
||||
)
|
||||
values["library_pipeline"] = enabled
|
||||
if enabled:
|
||||
values.update(
|
||||
generate_thumbnail=False,
|
||||
thumbnail_plan_id=None,
|
||||
cleanup_unstarred=False,
|
||||
)
|
||||
job = JobInput.model_validate(values)
|
||||
self.validate_job(job)
|
||||
self.db.execute(
|
||||
"UPDATE jobs SET payload=?,updated_at=? WHERE id=?",
|
||||
(
|
||||
encode(self.service.settings.sanitize(job.model_dump(mode="json"))),
|
||||
now_iso(),
|
||||
job_id,
|
||||
),
|
||||
)
|
||||
return next(j for j in self.jobs() if j["id"] == job_id)
|
||||
|
||||
def save_job(self, job, id=None):
|
||||
self.validate_job(job)
|
||||
upcoming = next_run(job.schedule)
|
||||
if job.enabled and upcoming is None:
|
||||
raise FlowError(E.INVALID_REQUEST, "One-time task must be in the future")
|
||||
@@ -143,6 +201,7 @@ class Scheduler:
|
||||
def tick(self, current=None):
|
||||
current = current or datetime.now(UTC)
|
||||
with self.tick_lock:
|
||||
self.upload_retries.tick(current)
|
||||
for row in self.db.all(
|
||||
"SELECT * FROM jobs WHERE enabled=1 AND next_run<=?",
|
||||
(current.isoformat(),),
|
||||
@@ -176,17 +235,32 @@ class Scheduler:
|
||||
restore_previous=job.restore_previous,
|
||||
priority=job.priority,
|
||||
cleanup_unstarred=job.cleanup_unstarred,
|
||||
generate_thumbnail=job.generate_thumbnail,
|
||||
thumbnail_plan_id=job.thumbnail_plan_id,
|
||||
),
|
||||
).model_dump(mode="json")
|
||||
)
|
||||
if job.library_pipeline:
|
||||
action = "library-generate"
|
||||
if action == "display":
|
||||
payload = self.upload_retries.scheduled_payload(
|
||||
row["id"], payload
|
||||
)
|
||||
operation_id = self.service.submit(
|
||||
action,
|
||||
payload,
|
||||
job.device_id,
|
||||
("source:" + job.source.source_id)
|
||||
if job.library_pipeline
|
||||
and isinstance(job.source, PhotoSourceContent)
|
||||
else job.device_id,
|
||||
"scheduler",
|
||||
"schedule-" + uuid4().hex,
|
||||
"job-" + row["id"] + "-" + slot,
|
||||
)
|
||||
if action == "display":
|
||||
self.upload_retries.scheduled_submitted(
|
||||
row["id"], operation_id
|
||||
)
|
||||
following = next_run(job.schedule, current)
|
||||
enabled = bool(following)
|
||||
updated = job.model_copy(update={"enabled": enabled}).model_dump(
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""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.libraries.remove_asset(asset_id)
|
||||
|
||||
def tick(self, current):
|
||||
for job in self.db.all("SELECT * FROM jobs WHERE enabled=1"):
|
||||
try:
|
||||
self.check(job, current)
|
||||
except FlowError:
|
||||
# A read failure leaves the durable plan in place for a later check.
|
||||
continue
|
||||
|
||||
def check(self, job, current):
|
||||
config = json.loads(job["payload"])
|
||||
if config["source"]["type"] not in (
|
||||
"photo_source",
|
||||
"auto_collage",
|
||||
"template",
|
||||
"provider",
|
||||
):
|
||||
return
|
||||
plan = self.load(job["id"])
|
||||
if plan and plan["source"] != config["source"]:
|
||||
self.save(job["id"], None)
|
||||
plan = None
|
||||
if not plan:
|
||||
if not job["last_result"] or not job["next_run"]:
|
||||
return
|
||||
operation = self.service.operation(job["last_result"])
|
||||
asset_id = (operation["result"] or {}).get("rendered_asset_id")
|
||||
if operation["status"] not in ("FAILED", "UNKNOWN") or not asset_id:
|
||||
return
|
||||
deadline = datetime.fromisoformat(job["next_run"])
|
||||
remaining = (deadline - current).total_seconds()
|
||||
if remaining <= 0:
|
||||
times = []
|
||||
else:
|
||||
# Five increasing delays occupy the first half of the available
|
||||
# window, leaving time for upload/sync before the next schedule.
|
||||
unit = remaining / 62
|
||||
times = [
|
||||
(current + timedelta(seconds=unit * n)).isoformat()
|
||||
for n in (1, 3, 7, 15, 31)
|
||||
]
|
||||
plan = dict(
|
||||
root=operation["operation_id"],
|
||||
active=operation["operation_id"],
|
||||
asset_id=asset_id,
|
||||
source=config["source"],
|
||||
times=times,
|
||||
count=0,
|
||||
deadline=deadline.isoformat(),
|
||||
phase="extra",
|
||||
)
|
||||
self.save(job["id"], plan)
|
||||
operation = self.service.operation(plan["active"])
|
||||
deadline = min(
|
||||
datetime.fromisoformat(plan["deadline"]),
|
||||
datetime.fromisoformat(job["next_run"]),
|
||||
)
|
||||
if (
|
||||
plan["phase"] == "extra"
|
||||
and current >= deadline
|
||||
and operation["status"] != "COMPLETED"
|
||||
):
|
||||
if operation["status"] == "SYNCING":
|
||||
self.service.update(
|
||||
operation["operation_id"],
|
||||
"UNKNOWN",
|
||||
operation["result"],
|
||||
operation["error"],
|
||||
)
|
||||
operation = self.service.operation(operation["operation_id"])
|
||||
operation = self.close_unknown(operation)
|
||||
if operation["status"] == "QUEUED":
|
||||
self.service.update(
|
||||
operation["operation_id"],
|
||||
"FAILED",
|
||||
error={
|
||||
"code": "CONFLICT",
|
||||
"message": "Upload retry window closed",
|
||||
"request_id": operation["request_id"],
|
||||
"details": {"reason": "RETRY_WINDOW_CLOSED"},
|
||||
},
|
||||
)
|
||||
if operation["status"] != "COMPLETED":
|
||||
plan["phase"] = "scheduled"
|
||||
self.save(job["id"], plan)
|
||||
return
|
||||
if operation["status"] == "COMPLETED":
|
||||
self.save(job["id"], None)
|
||||
# Extra retries are not job_runs and do not move the next plan time.
|
||||
self.db.execute(
|
||||
"UPDATE jobs SET last_result=? WHERE id=?",
|
||||
(operation["operation_id"], job["id"]),
|
||||
)
|
||||
return
|
||||
if operation["status"] not in ("FAILED", "UNKNOWN"):
|
||||
return
|
||||
if plan["phase"] == "replacement":
|
||||
# A failed replacement gets its own bounded recovery window.
|
||||
self.db.execute(
|
||||
"UPDATE jobs SET last_result=? WHERE id=?",
|
||||
(operation["operation_id"], job["id"]),
|
||||
)
|
||||
self.save(job["id"], None)
|
||||
return
|
||||
if plan["phase"] == "scheduled_running":
|
||||
operation = self.close_unknown(operation)
|
||||
if operation["status"] == "COMPLETED":
|
||||
return
|
||||
self.discard(plan["asset_id"])
|
||||
payload = self.payload(config)
|
||||
key = "replacement-" + plan["root"]
|
||||
op_id = self.service.submit(
|
||||
"display", payload, config["device_id"], "scheduler-retry", key, key
|
||||
)
|
||||
plan.update(phase="replacement", active=op_id)
|
||||
self.save(job["id"], plan)
|
||||
self.db.execute(
|
||||
"UPDATE jobs SET last_result=? WHERE id=?", (op_id, job["id"])
|
||||
)
|
||||
self.db.event(
|
||||
"upload.regenerated",
|
||||
{
|
||||
"job_id": job["id"],
|
||||
"operation_id": op_id,
|
||||
"failed_asset_id": plan["asset_id"],
|
||||
},
|
||||
)
|
||||
return
|
||||
if current >= deadline or plan["count"] >= len(plan["times"]):
|
||||
plan["phase"] = "scheduled"
|
||||
self.save(job["id"], plan)
|
||||
return
|
||||
if current < datetime.fromisoformat(plan["times"][plan["count"]]):
|
||||
return
|
||||
operation = self.close_unknown(operation)
|
||||
if operation["status"] == "COMPLETED":
|
||||
return
|
||||
number = plan["count"] + 1
|
||||
key = f"upload-retry-{plan['root']}-{number}"
|
||||
op_id = self.service.submit(
|
||||
"display",
|
||||
self.reuse(self.payload(config), plan["asset_id"]),
|
||||
config["device_id"],
|
||||
"scheduler-retry",
|
||||
key,
|
||||
key,
|
||||
)
|
||||
plan.update(active=op_id, count=number)
|
||||
self.save(job["id"], plan)
|
||||
self.db.execute("UPDATE jobs SET last_result=? WHERE id=?", (op_id, job["id"]))
|
||||
self.db.event(
|
||||
"upload.retry",
|
||||
{
|
||||
"job_id": job["id"],
|
||||
"operation_id": op_id,
|
||||
"attempt": number,
|
||||
"deadline": deadline.isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def payload(config):
|
||||
return {
|
||||
"device_id": config["device_id"],
|
||||
"content": config["source"],
|
||||
"options": {
|
||||
"duration": config["display_duration"],
|
||||
"restore_previous": config["restore_previous"],
|
||||
"priority": config["priority"],
|
||||
"cleanup_unstarred": config.get("cleanup_unstarred", False),
|
||||
},
|
||||
}
|
||||
@@ -27,6 +27,11 @@ class Settings:
|
||||
default_factory=lambda: secret("FRAME_FLOW_TOKEN"), repr=False
|
||||
)
|
||||
additional_secrets: list[str] = field(default_factory=list, repr=False)
|
||||
storage_name: str = field(
|
||||
default_factory=lambda: (
|
||||
os.getenv("FRAME_FLOW_STORAGE_NAME", "NAS").strip() or "NAS"
|
||||
)
|
||||
)
|
||||
device_id: str = "living-room"
|
||||
timezone: str = "Asia/Shanghai"
|
||||
cors_origins: list[str] = field(
|
||||
@@ -51,6 +56,12 @@ class Settings:
|
||||
photo_root: Path = field(
|
||||
default_factory=lambda: Path(os.getenv("PHOTO_ROOT", "/photos"))
|
||||
)
|
||||
photos_cache_bytes: int = field(
|
||||
default_factory=lambda: max(
|
||||
1024 * 1024,
|
||||
int(os.getenv("SYNOLOGY_PHOTOS_CACHE_BYTES", str(256 * 1024 * 1024))),
|
||||
)
|
||||
)
|
||||
max_bytes: int = 20 * 1024 * 1024
|
||||
idempotency_hours: int = 168
|
||||
sync_timeout: int = 600
|
||||
|
||||
@@ -17,6 +17,7 @@ def encode(value):
|
||||
class Database:
|
||||
def __init__(self, path):
|
||||
self.lock = threading.RLock()
|
||||
self._transaction_depth = 0
|
||||
self.conn = sqlite3.connect(path, check_same_thread=False)
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
self.conn.executescript("""
|
||||
@@ -24,6 +25,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 +33,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 +47,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);
|
||||
@@ -65,17 +69,32 @@ class Database:
|
||||
@contextmanager
|
||||
def transaction(self):
|
||||
with self.lock:
|
||||
self.conn.execute("BEGIN IMMEDIATE")
|
||||
nested = self._transaction_depth > 0
|
||||
savepoint = "tx_" + str(self._transaction_depth)
|
||||
self.conn.execute("SAVEPOINT " + savepoint if nested else "BEGIN IMMEDIATE")
|
||||
self._transaction_depth += 1
|
||||
try:
|
||||
yield self.conn
|
||||
self.conn.commit()
|
||||
except Exception:
|
||||
self.conn.rollback()
|
||||
if nested:
|
||||
self.conn.execute("RELEASE SAVEPOINT " + savepoint)
|
||||
else:
|
||||
self.conn.commit()
|
||||
except BaseException:
|
||||
if nested:
|
||||
self.conn.execute("ROLLBACK TO SAVEPOINT " + savepoint)
|
||||
self.conn.execute("RELEASE SAVEPOINT " + savepoint)
|
||||
else:
|
||||
self.conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._transaction_depth -= 1
|
||||
|
||||
def execute(self, query, args=()):
|
||||
with self.lock, self.conn:
|
||||
return self.conn.execute(query, args)
|
||||
with self.lock:
|
||||
if self._transaction_depth:
|
||||
return self.conn.execute(query, args)
|
||||
with self.conn:
|
||||
return self.conn.execute(query, args)
|
||||
|
||||
def one(self, query, args=()):
|
||||
with self.lock:
|
||||
|
||||
@@ -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};
|
||||
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:[],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={dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'};
|
||||
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:'▧','terminal-gallery':'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'};
|
||||
const t=k=>window.MESSAGES[state.lang][k]||k;
|
||||
function operationErrorMessage(error){const reason=error?.details?.reason;if(reason&&t(reason)!==reason)return t(reason);return error?.code&&t(error.code)!==error.code?t(error.code):(error?.message||t('error'));}
|
||||
const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const fmt=v=>v?new Date(v).toLocaleString(state.lang==='zh'?'zh-CN':'en-GB',{timeZone:'Asia/Shanghai'}):'—';
|
||||
const padTime=(hour,minute)=>`${String(hour).padStart(2,'0')}:${String(minute).padStart(2,'0')}`;
|
||||
function normaliseSchedule(schedule){const copy={...schedule};if(copy.type!=='cron')return copy;const match=/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+(\*|1-5|6,0|0,6)$/.exec(copy.cron||'');if(!match)return copy;const type=match[3]==='*'?'daily':match[3]==='1-5'?'weekdays':'weekends';return {type,time:padTime(match[2],match[1])};}
|
||||
function scheduleTypeLabel(type){if(type!=='cron')return t(type);return state.lang==='zh'?'高级规则(Cron)':'Advanced rule (Cron)';}
|
||||
function scheduleSummary(schedule){const value=normaliseSchedule(schedule);if(['daily','weekdays','weekends'].includes(value.type))return `${scheduleTypeLabel(value.type)} · ${value.time}(${state.lang==='zh'?'上海时间':'Shanghai time'})`;if(value.type==='cron')return `${scheduleTypeLabel('cron')} · ${value.cron}`;return `${scheduleTypeLabel(value.type)} · ${fmt(value.at)}`;}
|
||||
function scheduleHint(type){if(type==='cron')return state.lang==='zh'?'仅在需要特殊规则时使用;例如“每天 08:00”请选择“每天”。':'Use only for special rules; choose Daily for a regular time.';if(['date','temporary'].includes(type))return state.lang==='zh'?'选择一次性执行的日期和时间。':'Choose the date and time for this one-time task.';return state.lang==='zh'?'选择执行时间(上海时区)。':'Choose a time in Shanghai time.';}
|
||||
function normaliseSchedule(schedule){const copy={...schedule};if(copy.type==='interval')return {type:copy.interval_minutes%60===0?'hours':'minutes',every:copy.interval_minutes%60===0?copy.interval_minutes/60:copy.interval_minutes};if(copy.type!=='cron')return copy;const match=/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+(\*|1-5|6,0|0,6)$/.exec(copy.cron||'');if(!match)return copy;const type=match[3]==='*'?'daily':match[3]==='1-5'?'weekdays':'weekends';return {type,time:padTime(match[2],match[1])};}
|
||||
function scheduleTypeLabel(type){if(type==='hours')return state.lang==='zh'?'每隔若干小时':'Every N hours';if(type==='minutes')return state.lang==='zh'?'每隔若干分钟':'Every N minutes';if(type!=='cron')return t(type);return state.lang==='zh'?'高级规则(Cron)':'Advanced rule (Cron)';}
|
||||
function scheduleSummary(schedule){const value=normaliseSchedule(schedule);if(['hours','minutes'].includes(value.type))return state.lang==='zh'?`每 ${value.every} ${value.type==='hours'?'小时':'分钟'}`:`Every ${value.every} ${value.type}`;if(['daily','weekdays','weekends'].includes(value.type))return `${scheduleTypeLabel(value.type)} · ${value.time}(${state.lang==='zh'?'上海时间':'Shanghai time'})`;if(value.type==='cron')return `${scheduleTypeLabel('cron')} · ${value.cron}`;return `${scheduleTypeLabel(value.type)} · ${fmt(value.at)}`;}
|
||||
function scheduleHint(type){if(['hours','minutes'].includes(type))return state.lang==='zh'?'从保存起等待一个间隔后首次执行,此后按间隔重复;不是图片显示时长。':'First runs one interval after saving, then repeats. This is separate from display duration.';if(type==='cron')return state.lang==='zh'?'仅在需要特殊规则时使用;例如“每天 08:00”请选择“每天”。':'Use only for special rules; choose Daily for a regular time.';if(['date','temporary'].includes(type))return state.lang==='zh'?'选择一次性执行的日期和时间。':'Choose the date and time for this one-time task.';return state.lang==='zh'?'选择执行时间(上海时区)。':'Choose a time in Shanghai time.';}
|
||||
const buttons=(id,text,primary=false)=>`<button type="button" id="${id}" class="${primary?'primary':''}">${t(text)}</button>`;
|
||||
let toastTimer;
|
||||
function toast(message){$('#toast').textContent=message;$('#toast').style.display='block';clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('#toast').style.display='none',6000);}
|
||||
@@ -20,28 +26,64 @@ async function api(path,options={}){
|
||||
if(options.body&&!(options.body instanceof FormData)){headers['Content-Type']='application/json';options.body=JSON.stringify(options.body);}
|
||||
const response=await fetch(path,{...options,headers});
|
||||
if(!response.ok){let error;try{error=(await response.json()).error;}catch{}if(response.status===401&&error?.code!=='OFFICIAL_AUTH_REQUIRED')forgetLogin();
|
||||
const e=new Error((error?.code||response.status)+': '+(error?.code&&t(error.code)!==error.code?t(error.code):(error?.message||t('error'))));e.code=error?.code;throw e;}
|
||||
const e=new Error((error?.code||response.status)+': '+operationErrorMessage(error));e.code=error?.code;throw e;}
|
||||
if(response.status===204)return null;
|
||||
return options.binary?await response.blob():await response.json();
|
||||
}
|
||||
function bind(id,fn){const element=$('#'+id);if(element)element.onclick=async event=>{try{element.disabled=true;await fn(event);}catch(e){toast(e.message);}finally{element.disabled=false;}};}
|
||||
function busy(fn){return async event=>{try{await fn(event);}catch(e){toast(e.message);}};}
|
||||
function applyTheme(){const dark=state.theme==='dark'||(state.theme==='system'&&matchMedia('(prefers-color-scheme:dark)').matches);document.documentElement.dataset.theme=dark?'dark':'light';}
|
||||
function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).map(([id,icon])=>`<button data-page="${id}" class="${id===state.page?'active':''}"><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${d.id===state.device?'selected':''}>${esc(d.name)}</option>`).join('');}
|
||||
function clearBlobs(){for(const url of state.blobs)URL.revokeObjectURL(url);state.blobs=[];}
|
||||
async function blobImage(img,path){try{const blob=await api(path,{binary:true});if(!img.isConnected)return;const url=URL.createObjectURL(blob);state.blobs.push(url);img.src=url;}catch{img.alt=t('apiError');}}
|
||||
function hydrateImages(){const observer=new IntersectionObserver(entries=>entries.forEach(entry=>{if(entry.isIntersecting){observer.unobserve(entry.target);blobImage(entry.target,entry.target.dataset.src);}}),{rootMargin:'100px'});document.querySelectorAll('img[data-src]').forEach(img=>observer.observe(img));}
|
||||
function notice(){return state.settings?.mock?`<div class="notice">${t('mock')}</div>`:'';}
|
||||
async function navigate(page=state.page){state.page=icons[page]?page:'dashboard';location.hash=state.page;shell();clearBlobs();state.epoch++;const epoch=state.epoch;
|
||||
if(!state.token){login();return;}
|
||||
$('#main').innerHTML='<div class="spinner"></div>';
|
||||
try{await views[state.page](epoch);if(epoch!==state.epoch)return;const enhance={dashboard:enhanceDashboard,templates:enhanceTemplates,gallery:enhanceGallery,settings:enhanceSettings,upload:enhanceUpload,history:enhanceHistory,automation:enhanceAutomation}[state.page];if(enhance)await enhance();guardDeviceButtons();}catch(e){if(epoch!==state.epoch)return;if(!state.token){shell();login();toast(e.message);return;}$('#main').innerHTML=`<div class="card empty"><strong>${t('apiError')}</strong><p>${esc(e.message)}</p>${buttons('retry','refresh')}</div>`;bind('retry',()=>navigate());}
|
||||
function revealCurrentNavigation(){const active=$('#nav .active'),sidebar=$('.sidebar');if(active&&matchMedia('(max-width:720px)').matches)sidebar.scrollLeft=active.offsetLeft-(sidebar.clientWidth-active.offsetWidth)/2;}
|
||||
window.addEventListener('resize',revealCurrentNavigation);
|
||||
function deviceNameForId(id){return deviceDisplayName(state.devices.find(d=>d.id===id)||{id,name:id});}
|
||||
function operationActionLabel(action){const labels={'render':['生成预览','Generate preview'],'render-ai':['AI 生成图片','Generate AI image'],'official-widget':['更新官方订阅','Update official widget'],'delete':['删除图片','Delete image'],'display':['显示图片','Display image'],'restore':['恢复图片','Restore image'],'library-generate':['来源图库统一输出','Source-library outputs'],'local-upload':['准备本地成品','Prepare local output'],'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==='library-terminal'?tr('来源图库输出','Source-library output'):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(r.source_image_id)parts.push(`<div>${tr('已选定源画面','Source picture selected')}</div><small>${tr('输出:','Outputs: ')}${(r.outputs||[]).map(x=>esc(x.terminal_id==='watch'?tr('手表','Watch'):deviceNameForId(x.terminal_id))).join('、')||'—'}</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 deviceDisplayName(device){const name=device.name||device.id;return device.id==='living-room'&&['living-room','Living room'].includes(name)?t('livingRoom'):name;}
|
||||
function renderFooter(){const v=state.versionInfo;if(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>`;}
|
||||
function shell(){renderFooter();document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).filter(([id])=>id!=='source-browser').map(([id,icon])=>`<button data-page="${id}" class="${(id===state.page||id==='gallery'&&state.page==='source-browser')?'active':''}" ${(id===state.page||id==='gallery'&&state.page==='source-browser')?'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').hidden=['gallery','terminal-gallery','source-browser','photo-sources','settings','connections','history'].includes(state.page);const watchSelected=state.page==='terminal-gallery'&&state.tab==='watch';$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${!watchSelected&&d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('')+`<option value="@watch" ${watchSelected?'selected':''}>${tr('手表 · 410×502','Watch · 410×502')}</option>`;revealCurrentNavigation();}
|
||||
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){hideDisabledMarkTooltip();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,10 +101,11 @@ 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;}
|
||||
function guardDeviceButtons(){if(!liveWriteAllowed())document.querySelectorAll('[data-show],'+(state.tab==='cloud'?'.image-card .danger,':'')+'#show-name,#display-template,#display-upload,#restore').forEach(b=>{b.disabled=true;b.title=t('readOnlyWarning');});}
|
||||
function guardDeviceButtons(){if(!liveWriteAllowed())document.querySelectorAll('#display-template,#display-upload,#restore').forEach(b=>{b.disabled=true;b.title=t('readOnlyWarning');});}
|
||||
async function templateEditor(selected){
|
||||
const raw=$('#template-data');const assets=await api('/api/v1/assets?source=uploaded&limit=200');let values=JSON.parse(raw.value);
|
||||
const panel=document.createElement('div');panel.id='template-fields';raw.before(panel);
|
||||
@@ -75,25 +118,32 @@ 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 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 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 upload(epoch){
|
||||
state.preview=null;
|
||||
const sources=await api('/api/v1/source-libraries');if(epoch!==state.epoch)return;
|
||||
const options=[...sources];if(!options.some(x=>x.id==='manual-uploads'))options.push({id:'manual-uploads',name:tr('手动上传','Manual uploads')});
|
||||
const selected=options.some(x=>x.id===libraryUI.source)?libraryUI.source:'manual-uploads';
|
||||
$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="field"><label for="upload-source">${tr('保存到来源图库','Save in source library')}</label><select id="upload-source">${librarySelectOptions(options,selected,'')}</select><p class="hint">${tr('保存无叠加的源画面,不写回 NAS 或网盘。生成尺寸、裁切与文字由图库中的生成方案决定。','Save source pixels without overlays, without writing back to NAS or cloud drives. Library generation plans control output size, crop and captions.')}</p></div><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">${['cover','contain','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><button id="view-upload-library" hidden>${tr('查看来源图库','View source library')}</button></div><p class="hint">${tr('预览仅用于直接显示到相框;保存到图库会使用源画面。','Preview settings apply to direct frame display; saving in the library uses source pixels.')}</p></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);const preview=await api('/api/v1/preview',{method:'POST',body:form});if(!$('#file')||$('#file').files[0]!==file)return;state.preview=preview;state.preview.uploadFile=file;$('#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=!$('#file').files[0];$('#display-upload').disabled=true;$('#view-upload-library').hidden=true;};
|
||||
bind('save-local',async()=>{const sourceId=$('#upload-source').value,file=state.preview?.uploadFile||$('#file').files[0];let picture;if(state.preview?.sourceUrl){picture=await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/upload-url',{method:'POST',body:{url:state.preview.sourceUrl}});}else{if(!file)throw new Error(t('selectFile'));const body=new FormData();body.append('file',file);picture=await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/upload',{method:'POST',body});}libraryUI.source=picture.source_id;libraryUI.plan=null;libraryUI.offset=0;$('#view-upload-library').hidden=false;toast(tr('已保存源画面,可在来源图库按方案生成','Source pixels saved. Generate plan versions in the source library.'));});
|
||||
bind('view-upload-library',()=>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(o.action==='library-generate'?tr('按来源绑定终端','Source-bound terminals'):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);}
|
||||
$('#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(()=>{});
|
||||
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,library_pipeline:j.library_pipeline||false,cleanup_unstarred:j.cleanup_unstarred||false,generate_thumbnail:j.generate_thumbnail||false,thumbnail_plan_id:j.thumbnail_plan_id||null,...changes};}
|
||||
async function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`<h2>${t('playlists')}</h2><form id="playlist-form"><div class="field"><label for="playlist-name">${t('name')}</label><input id="playlist-name" required maxlength="120" value="${esc(p.name)}"></div><div class="field"><label for="playlist-images">${t('imageIds')}</label><input id="playlist-images" required value="${esc(p.image_ids.join(','))}" placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval">${t('interval')}</label><input id="playlist-interval" type="number" min="5" max="10080" value="${p.interval_minutes}"></div><div class="field"><label for="playlist-mode">${t('playbackMode')}</label><select id="playlist-mode"><option value="queue" ${p.mode==='queue'?'selected':''}>${t('queue')}</option><option value="random" ${p.mode==='random'?'selected':''}>${t('random')}</option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});await playlistImagePicker(p);}
|
||||
$('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme);
|
||||
applyTheme();shell();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>{state.versionInfo=v;renderFooter();}).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 +151,27 @@ 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,library_pipeline:$('#job-library-pipeline').checked,generate_thumbnail:!$('#job-library-pipeline').checked&&$('#job-thumbnail').checked,thumbnail_plan_id:!$('#job-library-pipeline').checked&&$('#job-thumbnail').checked?$('#job-thumbnail-plan').value:null,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);
|
||||
await thumbnailJobFields(j);
|
||||
await libraryJobFields(j,!job?.id);
|
||||
}
|
||||
|
||||
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('输出目标','Output target'):t(key)}</th>`).join('')}</tr></thead><tbody>${jobs.map((job,index)=>`<tr><td><strong>${esc(job.name)}</strong></td><td>${esc(job.library_pipeline?tr('按来源绑定终端','Source-bound terminals'):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.library_pipeline&&job.last_result==='COMPLETED'?tr('生成与提交完成','Generated and submitted'):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();}));
|
||||
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -2,60 +2,61 @@
|
||||
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'));
|
||||
}
|
||||
async function connectionsPage(){
|
||||
$('#main').innerHTML=`<div class="notice">${tr('可选连接不会影响 NAS 照片和现有设备。账号密钥保存在受限配置文件中,外部客户端密钥仅显示一次。','Optional connections do not affect NAS photos or existing devices. Provider keys use restricted config files; client tokens are shown once.')}</div>`;
|
||||
await aiSettingsPanel();await aiImagePanel();await storageSettingsPanel();await clientsPanel();await webhooksPanel();await externalProvidersPanel();await mqttPanel();
|
||||
await aiSettingsPanel();await aiImagePanel();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 and cleanup')}</h3>${data.disk?`<p class="${data.disk.low_space?'notice warning':'hint'}">${tr('磁盘可用','Disk available')}: ${(data.disk.free_bytes/1024**3).toFixed(2)} GiB / ${(data.disk.total_bytes/1024**3).toFixed(2)} GiB${data.disk.low_space?tr(';空间不足,请清理无用文件或扩容,来源图片和成品不会自动删除。','; low space. Remove unused files or expand storage. Source pictures and versions are not automatically deleted.'):''}</p>`:''}${data.pending_file_cleanup?`<p class="notice warning">${tr('待重试文件清理','Pending file cleanup')}: ${data.pending_file_cleanup}</p>`:''}<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,scopes]=await Promise.all([api('/api/v1/clients'),api('/api/v1/client-scopes')]);const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('外部客户端权限','External client permissions')}</h3><p class="hint">${tr('网页继续使用账号登录。这里只为其他程序创建独立凭据,可随时撤销。','The web UI still uses account login. These credentials are for other programs and can be revoked.')}</p>${clients.map(c=>`<p>${esc(c.name)} · ${esc(c.client_id)} · ${t(c.enabled?'enabled':'disabled')} ${c.client_id!=='admin'&&c.enabled?`<button data-revoke="${esc(c.client_id)}">${tr('撤销','Revoke')}</button>`:''}</p>`).join('')}<form id="client-form"><div class="field"><label for="client-id">Client ID</label><input id="client-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label for="client-name">${t('name')}</label><input id="client-name" required></div>${scopes.map(s=>`<label class="check-row"><input type="checkbox" name="client-scope" value="${s}" ${['devices:read','display:write','templates:read','render:write'].includes(s)?'checked':''}>${s}</label>`).join('')}<button type="submit">${tr('创建客户端','Create client')}</button><div id="client-secret" class="notice" hidden></div></form>`;$('#main').append(card);document.querySelectorAll('[data-revoke]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/clients/'+b.dataset.revoke,{method:'DELETE'});await navigate();}));$('#client-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/clients',{method:'POST',body:{client_id:$('#client-id').value,name:$('#client-name').value,permissions:[...document.querySelectorAll('[name="client-scope"]:checked')].map(x=>x.value)}});$('#client-secret').hidden=false;$('#client-secret').textContent=tr('仅显示一次,请保存:','Shown once; save it: ')+r.token;});
|
||||
}
|
||||
async function webhooksPanel(){
|
||||
const hooks=await api('/api/v1/webhooks');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>Webhook</h3><p class="hint">${tr('HTTPS 地址须先加入设置中的允许主机。事件带 HMAC 签名,最多重试三次;接收端应按事件 ID 去重。','Add the HTTPS host to the Settings allowlist first. Events are HMAC signed, with up to three attempts; deduplicate by event ID.')}</p>${hooks.map(h=>`<p>${esc(h.callback_url)} <button data-hook-delete="${h.id}">${t('delete')}</button></p>`).join('')}<form id="hook-form"><div class="field"><label>Callback URL</label><input id="hook-url" type="url" required></div><div class="field"><label>${tr('事件(逗号分隔)','Events (comma separated)')}</label><input id="hook-events" value="operation.completed,operation.failed"></div><button type="submit">${t('save')}</button><p id="hook-secret"></p></form>`;$('#main').append(card);document.querySelectorAll('[data-hook-delete]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/webhooks/'+b.dataset.hookDelete,{method:'DELETE'});await navigate();}));$('#hook-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/webhooks',{method:'POST',body:{callback_url:$('#hook-url').value,event_types:$('#hook-events').value.split(',').map(x=>x.trim()).filter(Boolean)}});$('#hook-secret').textContent=tr('签名密钥仅显示一次:','Signing key shown once: ')+r.signing_secret;});
|
||||
const hooks=await api('/api/v1/webhooks');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>Webhook</h3><p class="hint">${tr('HTTPS 地址须先加入设置中的允许主机。事件带 HMAC 签名,最多重试三次;接收端应按事件 ID 去重。','Add the HTTPS host to the Settings allowlist first. Events are HMAC signed, with up to three attempts; deduplicate by event ID.')}</p>${hooks.map(h=>`<p>${esc(h.callback_url)} <button data-hook-delete="${h.id}">${t('delete')}</button></p>`).join('')}<form id="hook-form"><div class="field"><label for="hook-url">Callback URL</label><input id="hook-url" type="url" required></div><div class="field"><label for="hook-events">${tr('事件(逗号分隔)','Events (comma separated)')}</label><input id="hook-events" value="operation.completed,operation.failed"></div><button type="submit">${t('save')}</button><p id="hook-secret"></p></form>`;$('#main').append(card);document.querySelectorAll('[data-hook-delete]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/webhooks/'+b.dataset.hookDelete,{method:'DELETE'});await navigate();}));$('#hook-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/webhooks',{method:'POST',body:{callback_url:$('#hook-url').value,event_types:$('#hook-events').value.split(',').map(x=>x.trim()).filter(Boolean)}});$('#hook-secret').textContent=tr('签名密钥仅显示一次:','Signing key shown once: ')+r.signing_secret;});
|
||||
}
|
||||
async function externalProvidersPanel(){
|
||||
const providers=await api('/api/v1/providers/external');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('日历、家庭状态与监控','Calendars, home state and monitoring')}</h3><p class="hint">${tr('Google Calendar 使用 OAuth Token;家庭日历可用 ICS。Home Assistant 和 NAS/树莓派/服务器监控可以通过只读 JSON 接口提供数据,不访问外部数据库。LAN 访问必须明确开启。','Google Calendar uses an OAuth token; family calendars can use ICS. Home Assistant and NAS/Pi/server monitoring supply read-only JSON data without database access. LAN access requires explicit opt-in.')}</p>${providers.map(p=>`<p>${esc(p.name)} · ${esc(p.kind)} <button data-provider-read="${p.id}">${tr('读取','Read')}</button><button data-provider-job="${p.id}">${tr('创建展示任务','Create display job')}</button></p>`).join('')}<form id="provider-form"><div class="field"><label>ID</label><input id="provider-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label>${t('name')}</label><input id="provider-name" required></div><div class="field"><label>${tr('类型','Type')}</label><select id="provider-kind"><option value="google-calendar">Google Calendar</option><option value="ics">ICS · ${tr('家庭日历','Family calendar')}</option><option value="home-assistant">Home Assistant</option><option value="http-json">HTTP JSON · NAS / Pi / Server</option></select></div><div class="field"><label>${tr('地址(Google 日历填写 Calendar ID)','URL (Calendar ID for Google)')}</label><input id="provider-url" required></div><div class="field"><label>${tr('Token(可选)','Token (optional)')}</label><input id="provider-key" type="password" autocomplete="off"></div><label class="check-row"><input id="provider-lan" type="checkbox">${tr('允许访问指定 LAN 地址','Allow access to this LAN address')}</label><button type="submit">${t('save')}</button></form><pre id="provider-result"></pre>`;$('#main').append(card);$('#provider-form').onsubmit=busy(async e=>{e.preventDefault();const id=$('#provider-id').value;await api('/api/v1/providers/external/'+id,{method:'PUT',body:{kind:$('#provider-kind').value,name:$('#provider-name').value,endpoint:$('#provider-url').value,allow_insecure_lan:$('#provider-lan').checked}});const key=$('#provider-key').value;$('#provider-key').value='';if(key)await api('/api/v1/providers/external/'+id+'/key',{method:'PUT',body:{key}});await navigate();});document.querySelectorAll('[data-provider-read]').forEach(b=>b.onclick=busy(async()=>{$('#provider-result').textContent=JSON.stringify(await api('/api/v1/providers/external/'+b.dataset.providerRead+'/data'),null,2);}));document.querySelectorAll('[data-provider-job]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/jobs',{method:'POST',body:{device_id:state.device,name:tr('外部信息板','External dashboard'),enabled:false,schedule:{type:'daily',time:'07:00'},source:{type:'provider',provider_id:b.dataset.providerJob,template:'daily-dashboard',data:{title:tr('家庭信息','Home information')}},display_duration:0,restore_previous:false,priority:50}});await navigate('automation');}));
|
||||
const providers=await api('/api/v1/providers/external');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('日历、家庭状态与监控','Calendars, home state and monitoring')}</h3><p class="hint">${tr('Google Calendar 使用 OAuth Token;家庭日历可用 ICS。Home Assistant 和 NAS/树莓派/服务器监控可以通过只读 JSON 接口提供数据,不访问外部数据库。LAN 访问必须明确开启。','Google Calendar uses an OAuth token; family calendars can use ICS. Home Assistant and NAS/Pi/server monitoring supply read-only JSON data without database access. LAN access requires explicit opt-in.')}</p>${providers.map(p=>`<p>${esc(p.name)} · ${esc(p.kind)} <button data-provider-read="${p.id}">${tr('读取','Read')}</button><button data-provider-job="${p.id}">${tr('创建展示任务','Create display job')}</button></p>`).join('')}<form id="provider-form"><div class="field"><label for="provider-id">ID</label><input id="provider-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label for="provider-name">${t('name')}</label><input id="provider-name" required></div><div class="field"><label for="provider-kind">${tr('类型','Type')}</label><select id="provider-kind"><option value="google-calendar">Google Calendar</option><option value="ics">ICS · ${tr('家庭日历','Family calendar')}</option><option value="home-assistant">Home Assistant</option><option value="http-json">HTTP JSON · NAS / Pi / Server</option></select></div><div class="field"><label for="provider-url">${tr('地址(Google 日历填写 Calendar ID)','URL (Calendar ID for Google)')}</label><input id="provider-url" required></div><div class="field"><label for="provider-key">${tr('Token(可选)','Token (optional)')}</label><input id="provider-key" type="password" autocomplete="off"></div><label class="check-row"><input id="provider-lan" type="checkbox">${tr('允许访问指定 LAN 地址','Allow access to this LAN address')}</label><button type="submit">${t('save')}</button></form><pre id="provider-result"></pre>`;$('#main').append(card);$('#provider-form').onsubmit=busy(async e=>{e.preventDefault();const id=$('#provider-id').value;await api('/api/v1/providers/external/'+id,{method:'PUT',body:{kind:$('#provider-kind').value,name:$('#provider-name').value,endpoint:$('#provider-url').value,allow_insecure_lan:$('#provider-lan').checked}});const key=$('#provider-key').value;$('#provider-key').value='';if(key)await api('/api/v1/providers/external/'+id+'/key',{method:'PUT',body:{key}});await navigate();});document.querySelectorAll('[data-provider-read]').forEach(b=>b.onclick=busy(async()=>{$('#provider-result').textContent=JSON.stringify(await api('/api/v1/providers/external/'+b.dataset.providerRead+'/data'),null,2);}));document.querySelectorAll('[data-provider-job]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/jobs',{method:'POST',body:{device_id:state.device,name:tr('外部信息板','External dashboard'),enabled:false,schedule:{type:'daily',time:'07:00'},source:{type:'provider',provider_id:b.dataset.providerJob,template:'daily-dashboard',data:{title:tr('家庭信息','Home information')}},display_duration:0,restore_previous:false,priority:50}});await navigate('automation');}));
|
||||
}
|
||||
async function officialWidgetControls(info,data){
|
||||
const panel=document.createElement('section');panel.className='card';panel.innerHTML=`<h3>${tr('订阅管理','Subscription management')}</h3><p class="hint">${tr('这与本地模板独立。请选择官方目录中的小组件,填写其参数后再确认。取消订阅不会自动恢复之前的照片。','This is separate from local templates. Choose a widget from the official catalog and fill its parameters before confirming. Cancelling does not automatically restore a previous photo.')}</p><div class="field"><label>${tr('官方小组件','Official widget')}</label><select id="official-widget-choice">${data.catalog.map(w=>`<option value="${w.id}">${esc(w.name)}</option>`).join('')}</select></div><div id="official-widget-args"></div><div class="actions"><button id="official-widget-use" class="primary">${tr('确认使用','Apply widget')}</button><button id="official-widget-cancel">${tr('取消订阅','Cancel subscription')}</button></div>`;info.append(panel);const disabled=!data.configured||!liveWriteAllowed();$('#official-widget-use').disabled=disabled;$('#official-widget-cancel').disabled=disabled;
|
||||
const panel=document.createElement('section');panel.className='card';panel.innerHTML=`<h3>${tr('订阅管理','Subscription management')}</h3><p class="hint">${tr('这与本地模板独立。请选择官方目录中的小组件,填写其参数后再确认。取消订阅不会自动恢复之前的照片。','This is separate from local templates. Choose a widget from the official catalog and fill its parameters before confirming. Cancelling does not automatically restore a previous photo.')}</p><div class="field"><label for="official-widget-choice">${tr('官方小组件','Official widget')}</label><select id="official-widget-choice">${data.catalog.map(w=>`<option value="${w.id}">${esc(w.name)}</option>`).join('')}</select></div><div id="official-widget-args"></div><div class="actions"><button id="official-widget-use" class="primary">${tr('确认使用','Apply widget')}</button><button id="official-widget-cancel">${tr('取消订阅','Cancel subscription')}</button></div>`;info.append(panel);const disabled=!data.configured||!liveWriteAllowed();$('#official-widget-use').disabled=disabled;$('#official-widget-cancel').disabled=disabled;
|
||||
const fields=()=>{const w=data.catalog.find(w=>w.id===Number($('#official-widget-choice').value));$('#official-widget-args').innerHTML=(w?.arg_keys||[]).map(k=>`<div class="field"><label>${esc(k)}</label><input data-widget-arg="${esc(k)}" maxlength="2000"></div>`).join('');};if(data.current)$('#official-widget-choice').value=data.current.id;fields();$('#official-widget-choice').onchange=fields;
|
||||
const send=cover_id=>confirmDialog(tr('此操作会改变官方订阅和画框内容,是否继续?','This changes the official subscription and frame content. Continue?'),async()=>{const args=cover_id?Object.fromEntries([...document.querySelectorAll('[data-widget-arg]')].filter(x=>x.value).map(x=>[x.dataset.widgetArg,x.value])):{};const r=await api('/api/v1/devices/'+state.device+'/widgets/selection',{method:'POST',body:{cover_id,args},headers:{'Idempotency-Key':'widget-'+clientUUID()}});await waitOperation(r.operation_id);await navigate('templates');});if(!disabled){$('#official-widget-use').onclick=busy(()=>send(Number($('#official-widget-choice').value)));$('#official-widget-cancel').onclick=busy(()=>send(null));}
|
||||
}
|
||||
function addPhotoQualityFields(source){
|
||||
const box=document.createElement('section');box.innerHTML=`<div id="source-local-quality"><h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${tr('按小图清晰度、亮度和相似度筛选,不改动或删除 NAS 原照片;无法保证识别闭眼。建议先用低阈值。个人空间还需要将相应目录只读挂载到 /photos。','Filters thumbnail sharpness, brightness and similarity without modifying or deleting originals. Closed-eye detection is not guaranteed. Start with low thresholds. Personal space also requires mounting its folder read-only at /photos.')}</p><div class="field"><label>${tr('人物空间','People space')}</label><select id="source-people-space"><option value="shared">${tr('共享空间','Shared space')}</option><option value="personal">${tr('个人空间','Personal space')}</option></select></div><div class="form-grid"><div class="field"><label>${tr('最低清晰度(0 不筛选)','Minimum sharpness (0 disables)')}</label><input id="source-sharpness" type="number" min="0" max="10000" value="${source?.minimum_sharpness||0}"></div><div class="field"><label>${tr('最低亮度','Minimum brightness')}</label><input id="source-bright-min" type="number" min="0" max="255" value="${source?.minimum_brightness||0}"></div><div class="field"><label>${tr('最高亮度','Maximum brightness')}</label><input id="source-bright-max" type="number" min="0" max="255" value="${source?.maximum_brightness??255}"></div></div><label class="check-row"><input id="source-dedup" type="checkbox" ${source?.avoid_duplicates?'checked':''}>${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}</label></div><div class="field"><label>${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
|
||||
const box=document.createElement('section');box.innerHTML=`<div id="source-local-quality"><h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${tr('对 API 获取的预览图按清晰度、亮度和相似度筛选,不修改或删除 NAS 原照片;无法保证识别闭眼。建议先使用较低阈值。','Filters API previews by sharpness, brightness and similarity without modifying or deleting NAS originals. Closed-eye detection is not guaranteed. Start with low thresholds.')}</p><div class="field"><label for="source-people-space">${tr('照片空间','Photo space')}</label><select id="source-people-space"><option value="shared">${tr('共享空间','Shared space')}</option><option value="personal">${tr('个人空间','Personal space')}</option></select></div><div class="form-grid">${[['short','minimum_short_edge',tr('最小短边(像素)','Minimum short edge (px)')],['long','minimum_long_edge',tr('最小长边(像素)','Minimum long edge (px)')]].map(([id,key,label])=>`<div class="field"><label for="source-min-${id}">${label}</label><input id="source-min-${id}" type="number" min="0" max="50000" step="1" value="${source?.[key]||0}"></div>`).join('')}</div><p class="hint">${tr('0 表示不限制;按源照片和实际取得的预览图像素筛选,横竖图通用。可从短边 1200、长边 1600 开始设置;分辨率不代表对焦清晰度。','0 disables. Checks source and downloaded preview dimensions regardless of orientation. Start with short edge 1200 and long edge 1600; pixel count does not measure focus.')}</p><div class="form-grid"><div class="field"><label for="source-sharpness">${tr('最低清晰度(0 不筛选)','Minimum sharpness (0 disables)')}</label><input id="source-sharpness" type="number" min="0" max="10000" value="${source?.minimum_sharpness||0}"></div><div class="field"><label for="source-bright-min">${tr('最低亮度','Minimum brightness')}</label><input id="source-bright-min" type="number" min="0" max="255" value="${source?.minimum_brightness||0}"></div><div class="field"><label for="source-bright-max">${tr('最高亮度','Maximum brightness')}</label><input id="source-bright-max" type="number" min="0" max="255" value="${source?.maximum_brightness??255}"></div></div><label class="check-row"><input id="source-dedup" type="checkbox" ${source?.avoid_duplicates?'checked':''}>${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}</label></div><div class="field"><label for="source-oauth">${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
|
||||
|
||||
}
|
||||
async function aiImagePanel(){
|
||||
const info=await api('/api/v1/settings/ai-images');const conf=info.config||{};const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('AI 生图','AI image generation')}</h3><p class="hint">${tr('独立配置 images/generations 接口。每次仅生成一张并保存本地,不会自动显示到画框。请求可能收费,失败不自动重试。','Configure a separate images/generations endpoint. One image is saved locally per request, never automatically displayed. Requests may be billed; failures are not retried.')}</p><form id="image-ai-config"><div class="field"><label>Endpoint</label><input id="image-ai-url" type="url" required value="${esc(conf.endpoint||'')}"></div><div class="field"><label>Model</label><input id="image-ai-model" required value="${esc(conf.model||'')}"></div><div class="field"><label>Size</label><select id="image-ai-size"><option>1024x1536</option><option>1536x1024</option><option>1024x1024</option></select></div><div class="field"><label>API Key</label><input id="image-ai-key" type="password" autocomplete="off"><p class="hint">${t('keyConfigured')}: ${t(info.key_configured?'yes':'no')}</p></div><button type="submit">${t('save')}</button></form><form id="image-ai-generate"><div class="field"><label>${tr('画面描述','Image prompt')}</label><textarea id="image-ai-prompt" required maxlength="4000"></textarea></div><button type="submit">${tr('生成一张(可能收费)','Generate one image (may incur charges)')}</button></form><div id="image-ai-preview"></div>`;$('#main').append(card);$('#image-ai-size').value=conf.size||'1024x1536';$('#image-ai-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-images',{method:'PUT',body:{endpoint:$('#image-ai-url').value,model:$('#image-ai-model').value,size:$('#image-ai-size').value}});const key=$('#image-ai-key').value;$('#image-ai-key').value='';if(key)await api('/api/v1/settings/ai-images/key',{method:'PUT',body:{key}});toast(t('done'));});$('#image-ai-generate').onsubmit=busy(async e=>{e.preventDefault();const result=await api('/api/v1/ai/images',{method:'POST',body:{prompt:$('#image-ai-prompt').value},headers:{'Idempotency-Key':'ai-image-'+clientUUID()}});const op=await waitOperation(result.operation_id);$('#image-ai-preview').innerHTML='<img class="preview" id="image-ai-result" alt="Generated image">';await blobImage($('#image-ai-result'),op.result.url);toast(tr('已存入本地图库','Saved to local gallery'));});
|
||||
const info=await api('/api/v1/settings/ai-images');const conf=info.config||{};const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('AI 生图','AI image generation')}</h3><p class="hint">${tr('独立配置 images/generations 接口。每次仅生成一张并保存本地,不会自动显示到画框。请求可能收费,失败不自动重试。','Configure a separate images/generations endpoint. One image is saved locally per request, never automatically displayed. Requests may be billed; failures are not retried.')}</p><form id="image-ai-config"><div class="field"><label for="image-ai-url">Endpoint</label><input id="image-ai-url" type="url" required value="${esc(conf.endpoint||'')}"></div><div class="field"><label for="image-ai-model">Model</label><input id="image-ai-model" required value="${esc(conf.model||'')}"></div><div class="field"><label for="image-ai-size">Size</label><select id="image-ai-size"><option>1024x1536</option><option>1536x1024</option><option>1024x1024</option></select></div><div class="field"><label for="image-ai-key">API Key</label><input id="image-ai-key" type="password" autocomplete="off"><p class="hint">${t('apiKeyConfigured')}: ${t(info.key_configured?'yes':'no')}</p></div><button type="submit">${t('save')}</button></form><form id="image-ai-generate"><div class="field"><label for="image-ai-prompt">${tr('画面描述','Image prompt')}</label><textarea id="image-ai-prompt" required maxlength="4000"></textarea></div><button type="submit">${tr('生成一张(可能收费)','Generate one image (may incur charges)')}</button></form><div id="image-ai-preview"></div>`;$('#main').append(card);$('#image-ai-size').value=conf.size||'1024x1536';$('#image-ai-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-images',{method:'PUT',body:{endpoint:$('#image-ai-url').value,model:$('#image-ai-model').value,size:$('#image-ai-size').value}});const key=$('#image-ai-key').value;$('#image-ai-key').value='';if(key)await api('/api/v1/settings/ai-images/key',{method:'PUT',body:{key}});toast(t('done'));});$('#image-ai-generate').onsubmit=busy(async e=>{e.preventDefault();const result=await api('/api/v1/ai/images',{method:'POST',body:{prompt:$('#image-ai-prompt').value},headers:{'Idempotency-Key':'ai-image-'+clientUUID()}});const op=await waitOperation(result.operation_id);$('#image-ai-preview').innerHTML='<img class="preview" id="image-ai-result" alt="Generated image">';await blobImage($('#image-ai-result'),op.result.url);toast(tr('已存入本地图库','Saved to local gallery'));});
|
||||
}
|
||||
async function mqttPanel(){
|
||||
const status=await api('/api/v1/integrations/mqtt');const c=status.config;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>MQTT · ${t(status.connected?'connected':'disconnected')}</h3><p class="hint">${tr('默认关闭。须配置环境 Secret FRAME_FLOW_MQTT_TOKEN(有 display:write 权限的客户端 Token),以及可选 FRAME_FLOW_MQTT_PASSWORD。消息必须携带 request_id;retained 消息被拒绝。结果发送到命令主题的 /result。','Disabled by default. Configure FRAME_FLOW_MQTT_TOKEN with display:write scope and optional FRAME_FLOW_MQTT_PASSWORD as environment secrets. Commands require request_id; retained commands are rejected. Results are published to the command topic plus /result.')}</p><form id="mqtt-config"><label class="check-row"><input id="mqtt-enabled" type="checkbox" ${c.enabled?'checked':''}>${t('enabled')}</label><div class="field"><label>Host</label><input id="mqtt-host" value="${esc(c.host)}"></div><div class="field"><label>Port</label><input id="mqtt-port" type="number" min="1" max="65535" value="${c.port}"></div><div class="field"><label>Topic</label><input id="mqtt-topic" value="${esc(c.topic)}"></div><div class="field"><label>${t('username')}</label><input id="mqtt-username" value="${esc(c.username)}"></div><label class="check-row"><input id="mqtt-tls" type="checkbox" ${c.tls?'checked':''}>TLS</label><label class="check-row"><input id="mqtt-lan" type="checkbox" ${c.allow_insecure_lan?'checked':''}>${tr('明确允许 LAN 明文连接','Explicitly allow plaintext LAN connection')}</label><button type="submit">${t('save')}</button></form>`;$('#main').append(card);$('#mqtt-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/integrations/mqtt',{method:'PUT',body:{enabled:$('#mqtt-enabled').checked,host:$('#mqtt-host').value,port:Number($('#mqtt-port').value),topic:$('#mqtt-topic').value,username:$('#mqtt-username').value,tls:$('#mqtt-tls').checked,allow_insecure_lan:$('#mqtt-lan').checked}});toast(t('done'));});
|
||||
const status=await api('/api/v1/integrations/mqtt');const c=status.config;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>MQTT · ${t(status.connected?'connected':'disconnected')}</h3><p class="hint">${tr('默认关闭。须配置环境 Secret FRAME_FLOW_MQTT_TOKEN(有 display:write 权限的客户端 Token),以及可选 FRAME_FLOW_MQTT_PASSWORD。消息必须携带 request_id;retained 消息被拒绝。结果发送到命令主题的 /result。','Disabled by default. Configure FRAME_FLOW_MQTT_TOKEN with display:write scope and optional FRAME_FLOW_MQTT_PASSWORD as environment secrets. Commands require request_id; retained commands are rejected. Results are published to the command topic plus /result.')}</p><form id="mqtt-config"><label class="check-row"><input id="mqtt-enabled" type="checkbox" ${c.enabled?'checked':''}>${t('enabled')}</label><div class="field"><label for="mqtt-host">Host</label><input id="mqtt-host" value="${esc(c.host)}"></div><div class="field"><label for="mqtt-port">Port</label><input id="mqtt-port" type="number" min="1" max="65535" value="${c.port}"></div><div class="field"><label for="mqtt-topic">Topic</label><input id="mqtt-topic" value="${esc(c.topic)}"></div><div class="field"><label for="mqtt-username">${t('username')}</label><input id="mqtt-username" value="${esc(c.username)}"></div><label class="check-row"><input id="mqtt-tls" type="checkbox" ${c.tls?'checked':''}>TLS</label><label class="check-row"><input id="mqtt-lan" type="checkbox" ${c.allow_insecure_lan?'checked':''}>${tr('明确允许 LAN 明文连接','Explicitly allow plaintext LAN connection')}</label><button type="submit">${t('save')}</button></form>`;$('#main').append(card);$('#mqtt-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/integrations/mqtt',{method:'PUT',body:{enabled:$('#mqtt-enabled').checked,host:$('#mqtt-host').value,port:Number($('#mqtt-port').value),topic:$('#mqtt-topic').value,username:$('#mqtt-username').value,tls:$('#mqtt-tls').checked,allow_insecure_lan:$('#mqtt-lan').checked}});toast(t('done'));});
|
||||
}
|
||||
|
||||
async function autoCollagePanel(){
|
||||
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
@@ -14,7 +14,7 @@ function editTemplateSource(source){state.selectedTemplate=source.template;state
|
||||
async function enhanceDashboard(){
|
||||
const epoch=state.epoch;const frame=$('.frame-demo');const panel=document.createElement('section');panel.className='card';$('#main').append(panel);
|
||||
const current=await currentPanel(panel);if(epoch!==state.epoch)return;
|
||||
if(current.preview_url){frame.innerHTML=`<img id="current-frame-image" alt="${t('currentContent')}" style="width:100%;height:100%;object-fit:contain"><span class="frame-label">${t('currentContent')} #${current.image_id}</span>`;await blobImage($('#current-frame-image'),current.preview_url);}
|
||||
if(current.preview_url){frame.innerHTML=`<button id="current-frame-preview" class="current-frame-preview" aria-label="${t('original')} · ${t('currentContent')} #${esc(current.image_id)}"><img id="current-frame-image" alt="${t('currentContent')}"></button><span class="frame-label">${t('currentContent')} #${esc(current.image_id)}</span>`;const original=new URL(current.preview_url,location.origin);original.searchParams.delete('thumbnail');bind('current-frame-preview',()=>openImageViewer(original.pathname+original.search,`${t('currentContent')} #${current.image_id}`));void blobImage($('#current-frame-image'),original.pathname+original.search);}else{frame.innerHTML=`<div class="mini-art"><div class="sun"></div></div><span class="frame-label">${t('testLabel')}</span>`;}
|
||||
const weather=await api('/api/v1/providers/weather');if(epoch!==state.epoch)return;const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('weather')}</h3><p>${esc(weather.current.location)} · ${esc(weather.current.temperature)} · ${esc(weather.current.summary)}</p><p class="hint">${weather.current.mock?t('weatherMock'):t('weatherAttribution')}${weather.current.stale?' · '+t('stale'):''}</p>`;$('#main').append(box);
|
||||
}
|
||||
async function enhanceTemplates(){
|
||||
@@ -28,23 +28,17 @@ 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(){
|
||||
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'));});}});
|
||||
}
|
||||
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(['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);});}});
|
||||
}
|
||||
@@ -66,8 +60,8 @@ async function playlistImagePicker(playlist){
|
||||
const raw=$('#playlist-images');const box=document.createElement('div');raw.parentElement.after(box);box.innerHTML=`<p>${t('imageChoice')}</p><div id="playlist-image-choices">${t('loading')}</div>`;const images=await api('/api/v1/devices/'+state.device+'/images');if(!box.isConnected)return;const chosen=new Set(playlist.image_ids);
|
||||
$('#playlist-image-choices').innerHTML=images.map(image=>`<label class="check-row"><input type="checkbox" data-playlist-image="${image.image_id}" ${chosen.has(image.image_id)?'checked':''}>${esc(image.name)} · #${image.image_id}${image.current?' · '+t('current'):''}</label>`).join('');box.querySelectorAll('[data-playlist-image]').forEach(el=>el.onchange=()=>{const id=Number(el.dataset.playlistImage);if(el.checked)chosen.add(id);else chosen.delete(id);raw.value=[...chosen].join(',');});raw.oninput=()=>{chosen.clear();raw.value.split(',').map(Number).filter(x=>x>0).forEach(x=>chosen.add(x));box.querySelectorAll('[data-playlist-image]').forEach(el=>el.checked=chosen.has(Number(el.dataset.playlistImage)));};
|
||||
}
|
||||
async function enhanceSettings(){await weatherSettings();const config=await api('/api/v1/settings/image-url');const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('urlSettings')}</h3><form id="image-host-form"><div class="field"><label for="image-hosts">${t('allowedHosts')}</label><textarea id="image-hosts" rows="3">${esc(config.allowed_hosts.join('\n'))}</textarea><p class="hint">${t('urlHint')}</p></div><button type="submit">${t('save')}</button></form>`;$('#main').append(box);$('#image-host-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/image-url',{method:'PUT',body:{allowed_hosts:$('#image-hosts').value.split('\n').map(x=>x.trim()).filter(Boolean)}});toast(t('done'));});}
|
||||
function enhanceUpload(){const box=document.createElement('div');box.innerHTML=`<h3>${t('uploadURL')}</h3><div class="field"><label for="upload-url">${t('imageURL')}</label><input id="upload-url" type="url" placeholder="https://…"><p class="hint">${t('urlHint')}</p></div>${buttons('preview-url','uploadURL')}`;$('#make-preview').parentElement.append(box);bind('preview-url',async()=>{if(!$('#upload-url').checkValidity())return;const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image_url',url:$('#upload-url').value,fit:$('#fit').value,profile:$('#profile').value});const op=await waitOperation(id);state.preview={asset_id:op.result.asset_id,url:op.result.url};$('#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();});}
|
||||
async function enhanceSettings(){await storageSettingsPanel();await weatherSettings();const config=await api('/api/v1/settings/image-url');const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('urlSettings')}</h3><form id="image-host-form"><div class="field"><label for="image-hosts">${t('allowedHosts')}</label><textarea id="image-hosts" rows="3">${esc(config.allowed_hosts.join('\n'))}</textarea><p class="hint">${t('urlHint')}</p></div><button type="submit">${t('save')}</button></form>`;$('#main').append(box);$('#image-host-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/image-url',{method:'PUT',body:{allowed_hosts:$('#image-hosts').value.split('\n').map(x=>x.trim()).filter(Boolean)}});toast(t('done'));});}
|
||||
function enhanceUpload(){const box=document.createElement('div');box.innerHTML=`<h3>${t('uploadURL')}</h3><div class="field"><label for="upload-url">${t('imageURL')}</label><input id="upload-url" type="url" placeholder="https://…"><p class="hint">${t('urlHint')}</p></div>${buttons('preview-url','uploadURL')}`;$('#make-preview').parentElement.append(box);$('#upload-url').oninput=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('preview-url',async()=>{if(!$('#upload-url').checkValidity())return;const sourceUrl=$('#upload-url').value;const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image_url',url:sourceUrl,fit:$('#fit').value,profile:$('#profile').value});const op=await waitOperation(id);if(!box.isConnected||$('#upload-url').value!==sourceUrl)return;state.preview={asset_id:op.result.asset_id,url:op.result.url,sourceUrl};$('#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();});}
|
||||
async function poetryControls(){
|
||||
if(state.selectedTemplate!=='daily-poetry')return;
|
||||
const provider=await api('/api/v1/providers/poetry');if(!$('#template-fields'))return;
|
||||
@@ -138,30 +132,67 @@ function photosLoginDialog(){
|
||||
|
||||
async function photoSourcesPanel(){
|
||||
const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section);
|
||||
const sources=await api('/api/v1/photo-sources');if(!section.isConnected)return;
|
||||
section.innerHTML=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><section id="photos-connection" class="photos-connection" aria-label="Synology Photos"></section><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint source-identity">${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}</p><p class="hint">${s.people_filter?t('sourceSharedPhotos'):s.kind==='local'?t('sourceLocalOnly'):t('sourceOwnCredentials')}</p><p class="hint">${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}</p><div class="actions"><button data-source-browse="${esc(s.id)}">${t('browse')}</button><button data-source-random="${esc(s.id)}" ${s.enabled?'':'disabled'}>${t('randomPreview')}</button><button data-source-job="${esc(s.id)}">${t('sourceJob')}</button><button data-source-edit="${esc(s.id)}">${t('edit')}</button><button class="danger" data-source-delete="${esc(s.id)}">${t('remove')}</button></div></article>`).join(''):`<p class="hint">${t('noSources')}</p>`}</div><div id="source-browser"></div>`;
|
||||
const sources=(await api('/api/v1/photo-sources')).filter(s=>s.kind!=='uploads');if(!section.isConnected)return;
|
||||
section.innerHTML=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><section id="photos-connection" class="photos-connection" aria-label="Synology Photos"></section><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint source-identity">${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}</p><p class="hint">${s.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>`;
|
||||
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');}));
|
||||
section.querySelectorAll('[data-source-random]').forEach(b=>b.onclick=busy(()=>previewSource(b.dataset.sourceRandom)));
|
||||
section.querySelectorAll('[data-source-browse]').forEach(b=>b.onclick=busy(()=>browseSource(b.dataset.sourceBrowse)));
|
||||
section.querySelectorAll('[data-source-browse]').forEach(b=>b.onclick=()=>openSourceBrowser(b.dataset.sourceBrowse));
|
||||
section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob));
|
||||
}
|
||||
async function photoSourceForm(source=null){
|
||||
showModal(`<h2>${t(source?'edit':'addSource')}</h2><p class="notice">${t('sourceIndependentHelp')}</p><form id="photo-source-form"><div class="field"><label for="source-id">ID</label><input id="source-id" pattern="[a-z0-9-]{1,60}" required value="${esc(source?.id||'')}" ${source?'readonly':''}></div><div class="field"><label for="source-name">${t('name')}</label><input id="source-name" required maxlength="120" value="${esc(source?.name||'')}"></div><div class="field"><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="local">${t('nasDirectory')}</option><option value="webdav">WebDAV</option><option value="google-drive">Google Drive</option><option value="alipan">阿里云盘 / AliPan</option><option value="baidu">百度网盘 / Baidu</option></select></div><div class="field"><label for="source-location">${t('sourceLocation')}</label><input id="source-location" required maxlength="2048" value="${esc(source?.location||'.')}"><p class="hint">${t('sourceLocationHint')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${source?.folder_caption?'checked':''}>${t('folderCaption')}</label><label class="check-row"><input id="source-synology-previews" type="checkbox" ${source?.synology_previews?'checked':''}>${t('synologyPreviews')}</label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
|
||||
$('#source-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'local';const update=()=>{const dav=$('#source-kind').value!=='local';$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-credential').closest('.field').hidden=$('#source-kind').value!=='webdav';const oauth=$('#source-oauth');if(oauth)oauth.closest('.field').hidden=!['google-drive','alipan','baidu'].includes($('#source-kind').value);const quality=$('#source-local-quality');if(quality)quality.hidden=dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=dav;$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};addPhotoQualityFields(source);update();$('#source-kind').onchange=update;$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';};
|
||||
$('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=$('#source-kind').value!=='local';await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,recursive:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:!dav&&$('#source-synology-previews').checked,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');});
|
||||
}
|
||||
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.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));
|
||||
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 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>`+['library-generate','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();});}
|
||||
|
||||
function hideDisabledMarkTooltip(){const tooltip=document.getElementById('disabled-mark-tooltip');if(tooltip)tooltip.hidden=true;}
|
||||
function disabledMarkHints(root){
|
||||
hideDisabledMarkTooltip();
|
||||
for(const button of root.querySelectorAll('[data-library-star],[data-library-exclude]')){
|
||||
let wrapper=button.parentElement.classList.contains('disabled-mark-hint')?button.parentElement:null;
|
||||
const reason=button.disabled?(button.title||wrapper?.dataset.reason||''):'';
|
||||
if(!reason){if(wrapper)wrapper.replaceWith(button);continue;}
|
||||
if(!wrapper){wrapper=document.createElement('span');wrapper.className='disabled-mark-hint';button.before(wrapper);wrapper.append(button);}
|
||||
button.removeAttribute('title');wrapper.dataset.reason=reason;wrapper.tabIndex=0;wrapper.setAttribute('role','group');wrapper.setAttribute('aria-label',button.textContent+':'+reason);
|
||||
wrapper.onmouseenter=wrapper.onfocus=()=>{
|
||||
let tooltip=document.getElementById('disabled-mark-tooltip');if(!tooltip){tooltip=document.createElement('div');tooltip.id='disabled-mark-tooltip';tooltip.setAttribute('role','tooltip');document.body.append(tooltip);}
|
||||
tooltip.textContent=wrapper.dataset.reason;tooltip.hidden=false;const anchor=wrapper.getBoundingClientRect(),rect=tooltip.getBoundingClientRect();tooltip.style.left=Math.max(12,Math.min(anchor.left,innerWidth-rect.width-12))+'px';tooltip.style.top=Math.max(12,Math.min(anchor.top>=rect.height+20?anchor.top-rect.height-8:anchor.bottom+8,innerHeight-rect.height-12))+'px';
|
||||
};
|
||||
wrapper.onmouseleave=wrapper.onblur=hideDisabledMarkTooltip;
|
||||
wrapper.onkeydown=event=>{if(event.key==='Escape')hideDisabledMarkTooltip();};
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('resize',hideDisabledMarkTooltip);
|
||||
|
||||
@@ -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.2</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.29</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.29"></script><script src="/ui/features.js?v=0.1.29"></script><script src="/ui/extensions.js?v=0.1.29"></script><script src="/ui/app.js?v=0.1.29"></script><script src="/ui/thumbnails.js?v=0.1.29"></script><script src="/ui/libraries.js?v=0.1.29"></script><script src="/ui/source-browser.js?v=0.1.29"></script></body></html>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
'use strict';
|
||||
// Source pixels, generated versions and terminal bindings share one gallery model.
|
||||
Object.assign(MESSAGES.zh,{libraryOutputPartial:'部分终端输出失败,源画面与成功版本已保留,可单独重试失败目标。',libraryGenerateFirst:'请先按终端生成方案生成成品,源画面不能直接推送。',librarySourceMismatch:'方案不属于所选来源图库,请重新选择。',librarySizeMismatch:'方案尺寸与终端屏幕不匹配;相框使用设备屏幕尺寸,当前手表为 410×502。',libraryPlanBound:'此方案正在被终端使用,请先解除绑定或更换方案。',libraryBindFirst:'请先在终端图库中绑定此来源及其中的生成方案。',libraryBindingChanged:'终端绑定已改变,此版本不再匹配,已停止推送。'});Object.assign(MESSAGES.en,{libraryOutputPartial:'Some outputs failed. Source pixels and successful versions are kept; retry failed targets individually.',libraryGenerateFirst:'Generate a terminal version before sending source pixels.',librarySourceMismatch:'The plan belongs to another source library.',librarySizeMismatch:'Plan dimensions must match the terminal screen. The current watch uses 410×502.',libraryPlanBound:'Unbind terminals or select another plan first.',libraryBindFirst:'Bind a terminal to this source and one of its plans first.',libraryBindingChanged:'The terminal binding changed; this version was not sent.'});
|
||||
Object.assign(MESSAGES.zh,{gallery:'来源图库','terminal-gallery':'终端图库'});
|
||||
Object.assign(MESSAGES.en,{gallery:'Source library','terminal-gallery':'Terminal library'});
|
||||
const libraryUI={kind:'source',source:null,plan:null,terminal:null,offset:0};
|
||||
|
||||
function libraryStatus(status){return ({GENERATED:tr('已生成','Generated'),QUEUED:tr('待推送','Queued'),RENDERING:tr('准备推送','Preparing'),UPLOADING:tr('上传中','Uploading'),SYNCING:tr('等待设备确认','Awaiting confirmation'),COMPLETED:tr('已确认显示','Display confirmed'),FAILED:tr('推送失败','Failed'),UNKNOWN:tr('结果待核对','Outcome unknown')})[status]||status;}
|
||||
function terminalLabel(terminal){return terminal.id==='watch'?tr('手表','Watch'):deviceDisplayName(terminal.name?terminal:state.devices.find(d=>d.id===terminal.id)||terminal);}
|
||||
function librarySelectOptions(items,selected,label){return `<option value="">${label}</option>`+items.map(p=>`<option value="${esc(p.id)}" ${p.id===selected?'selected':''}>${esc(p.kind==='uploads'?tr('手动上传','Manual uploads'):p.name)}</option>`).join('');}
|
||||
views.gallery=epoch=>libraryPage(epoch,'source');
|
||||
views['terminal-gallery']=epoch=>libraryPage(epoch,'terminal');
|
||||
async function libraryPage(epoch,kind){
|
||||
if(libraryUI.kind!==kind)libraryUI.offset=0;
|
||||
libraryUI.kind=kind;
|
||||
const [sources,terminals]=await Promise.all([api('/api/v1/source-libraries'),api('/api/v1/library-terminals')]);
|
||||
if(epoch!==state.epoch)return;
|
||||
state.devices=state.devices.map(device=>{const terminal=terminals.find(t=>t.id===device.id&&t.name&&t.name!==t.id);return terminal?{...device,name:terminal.name}:device;});shell();
|
||||
$('#main').innerHTML=`<div class="section-heading"><span></span><button id="library-refresh">${t('refresh')}</button></div><div id="library-content"></div>`;
|
||||
bind('library-refresh',()=>navigate());
|
||||
if(kind==='source')await sourceLibraryPage(epoch,sources,terminals);else await terminalLibraryPage(epoch,sources,terminals);
|
||||
}
|
||||
|
||||
async function sourceLibraryPage(epoch,sources,terminals){
|
||||
const panel=$('#library-content');
|
||||
if(!sources.length){panel.innerHTML=`<section class="card empty"><p>${tr('连接照片数据源或手动上传照片,再在来源图库建立生成方案。','Connect a photo source or upload photos, then create generation plans in its library.')}</p><button id="library-add-source">${tr('添加照片来源','Add photo source')}</button></section>`;panel.querySelector('section').insertAdjacentHTML('beforeend',`<button id="library-first-upload">${tr('上传照片到来源图库','Upload photos to source library')}</button>`);bind('library-first-upload',()=>navigate('upload'));bind('library-add-source',()=>navigate('photo-sources'));return;}
|
||||
if(!sources.some(s=>s.id===libraryUI.source)){libraryUI.source=sources[0].id;libraryUI.plan=null;libraryUI.offset=0;}
|
||||
const sourceId=libraryUI.source,manual=sources.find(s=>s.id===sourceId)?.kind==='uploads';
|
||||
const [plans,page]=await Promise.all([api('/api/v1/generation-plans?source_id='+encodeURIComponent(sourceId)),api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/images?offset='+libraryUI.offset+'&limit=30')]);
|
||||
if(epoch!==state.epoch)return;
|
||||
if(libraryUI.plan&&!plans.some(p=>p.id===libraryUI.plan))libraryUI.plan=null;
|
||||
const plan=plans.find(p=>p.id===libraryUI.plan),uses=terminals.filter(t=>t.binding?.source_id===sourceId&&(!plan||t.binding.plan_id===plan.id));
|
||||
panel.innerHTML=`<section class="card library-source-controls"><div class="library-source-toolbar"><div class="field"><label for="library-source">${tr('来源图库','Source library')}</label><select id="library-source">${librarySelectOptions(sources,sourceId,tr('选择来源','Choose source'))}</select></div><div class="actions"><button id="library-import">${manual?tr('上传照片','Upload photos'):tr('从来源选入照片','Import from source')}</button><button id="library-run">${tr('随机选入并按绑定生成','Pick once and generate bound plans')}</button>${manual?'':`<button id="library-source-settings">${tr('来源设置','Source settings')}</button>`}</div></div><p class="hint">${tr('源画面先保存到本地;同一张照片按不同方案生成版本,多个终端可共用一份版本。Photos 画面可能是源预览。','Source pixels are stored locally first. Plans create versions of the same photo; terminals may share one version. Photos may supply source previews.')}</p></section><section class="card library-output-panel"><div class="library-plan-toolbar"><div class="tabs gallery-tabs library-plan-tabs" role="tablist" aria-label="${tr('源画面与生成方案','Source pixels and generation plans')}"><button id="library-plan-tab-source" role="tab" aria-controls="library-plan-content" aria-selected="${!plan}" tabindex="${!plan?0:-1}" data-library-plan="" class="${!plan?'active':''}">${tr('源画面','Source pixels')}</button>${plans.map(p=>`<button id="library-plan-tab-${esc(p.id)}" role="tab" aria-controls="library-plan-content" aria-selected="${p.id===plan?.id}" tabindex="${p.id===plan?.id?0:-1}" data-library-plan="${esc(p.id)}" class="${p.id===plan?.id?'active':''}">${esc(p.name)}${p.archived?tr('(已归档)',' (archived)'):''}</button>`).join('')}</div><div class="actions"><button id="library-new-plan">${tr('新增生成方案','New generation plan')}</button>${plan&&!plan.archived?`<button id="library-edit-plan">${t('edit')}</button><button id="library-delete-plan" class="danger">${tr('删除方案','Delete plan')}</button>`:''}</div></div><div id="library-plan-content" role="tabpanel" aria-labelledby="library-plan-tab-${plan?esc(plan.id):'source'}"><p class="hint">${tr('使用终端:','Used by: ')}${uses.map(terminalLabel).map(esc).join('、')||tr('尚未绑定','No terminals bound')}</p><div class="gallery source-library-gallery">${page.items.map((picture,i)=>{
|
||||
const version=plan?picture.versions.find(v=>v.plan_id===plan.id):picture;
|
||||
return `<article class="card image-card">${version?`<button class="image-preview" data-library-preview="${i}"><img class="thumb" alt="${esc(picture.name)}" data-src="${esc(version.url)}" style="object-fit:contain;${plan?`aspect-ratio:${version.plan.width}/${version.plan.height}`:''}"></button>`:`<div class="empty">${tr('此方案尚未生成','Not generated for this plan')}</div>`}<h3>${esc(picture.name)}</h3><small>${plan?version?`${esc(version.plan_name)} · ${version.plan.width} × ${version.plan.height}`:tr('未生成','Not generated'):tr('无叠加的源画面','Source pixels without overlays')}</small><small>${esc(fmt(version?.created_at||picture.created_at))}</small>${!plan&&picture.starred?`<small class="photo-mark">${tr('已星标保留','Starred for retention')}</small>`:''}${!plan&&picture.excluded?`<small class="photo-mark">${tr('已标记不再选用','Excluded from automatic selection')}</small>`:''}${plan&&version?.matches_current_plan===false?`<small class="hint">${tr('方案参数已更新,此图片为旧参数版本','Plan settings changed; this image uses earlier settings')}</small>`:''}${version?.terminal_uses?.length?`<small>${version.terminal_uses.map(u=>esc(terminalLabel(terminals.find(t=>t.id===u.terminal_id)||{id:u.terminal_id}))+' · '+esc(libraryStatus(u.status))).join(' / ')}</small>`:''}<div class="actions">${version?`<button data-library-preview="${i}">${tr('预览','Preview')}</button><button data-library-download="${i}">${tr('下载图片','Download image')}</button>${plan?`<button data-library-settings="${i}">${tr('生成参数','Generation settings')}</button>`:''}`:''}${!plan?.archived?`<button data-library-generate="${i}">${plan?tr('按此方案生成','Generate this plan'):tr('生成各方案版本','Generate plan versions')}</button>`:''}${!plan?`<button data-library-star="${i}" ${picture.excluded?'disabled':''} title="${picture.excluded?esc(t('clearExclusionFirst')):''}">${t(picture.starred?'unstar':'star')}</button><button data-library-exclude="${i}" ${picture.starred?'disabled':''} title="${picture.starred?esc(t('clearStarFirst')):''}">${t(picture.excluded?'includePhoto':'excludePhoto')}</button>`:''}<button class="danger" data-library-delete="${i}" ${plan&&!version?'disabled':''}>${t('delete')}</button></div></article>`;
|
||||
}).join('')}</div>${page.items.length?'':`<section class="card empty">${manual?tr('尚未上传照片。上传后可按方案生成。','No uploads yet. Upload a photo, then generate its versions.'):tr('尚未选入源画面。先从来源选入照片,再按方案生成。','No source pixels imported yet. Import a photo, then generate its versions.')}</section>`}<div class="actions library-pagination"><button id="library-prev" ${libraryUI.offset===0?'disabled':''}>${tr('上一页','Previous')}</button><button id="library-next" ${libraryUI.offset+30>=page.total?'disabled':''}>${tr('下一页','Next')}</button><span>${tr('共','Total')} ${page.total} ${tr('张来源照片','source pictures')}</span></div></div></section>`;
|
||||
$('#library-source').onchange=()=>{libraryUI.source=$('#library-source').value;libraryUI.plan=null;libraryUI.offset=0;return navigate();};
|
||||
document.querySelectorAll('[data-library-plan]').forEach(b=>{
|
||||
b.onclick=()=>{libraryUI.plan=b.dataset.libraryPlan||null;return navigate();};
|
||||
b.onkeydown=async e=>{const tabs=[...document.querySelectorAll('[data-library-plan]')],index=tabs.indexOf(b);let next;if(e.key==='ArrowRight')next=(index+1)%tabs.length;else if(e.key==='ArrowLeft')next=(index+tabs.length-1)%tabs.length;else if(e.key==='Home')next=0;else if(e.key==='End')next=tabs.length-1;else return;e.preventDefault();libraryUI.plan=tabs[next].dataset.libraryPlan||null;await navigate();document.querySelector('[data-library-plan][aria-selected="true"]')?.focus();};
|
||||
});
|
||||
bind('library-import',()=>manual?navigate('upload'):libraryImportDialog(sourceId));bind('library-source-settings',()=>navigate('photo-sources'));
|
||||
bind('library-new-plan',()=>generationPlanEditor(sourceId,null,page.items));bind('library-edit-plan',()=>generationPlanEditor(sourceId,plan,page.items));
|
||||
bind('library-delete-plan',()=>confirmDialog(tr(`确认删除生成方案“${plan.name}”?已有生成版本保留参数快照;正在被终端使用的方案不能删除。`,`Delete generation plan “${plan.name}”? Existing versions keep their snapshots. Bound plans cannot be deleted.`),async()=>{await api('/api/v1/generation-plans/'+plan.id,{method:'DELETE'});await navigate();}));
|
||||
bind('library-run',async()=>{const result=await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/run',{method:'POST',body:{deliver:false}});toast(result.errors.length?result.errors.map(e=>e.message).join('; '):tr('已从同一来源画面生成各终端版本,尚未推送','Generated terminal versions from one source picture; not sent'));libraryUI.offset=0;await navigate();});
|
||||
bind('library-prev',()=>{libraryUI.offset=Math.max(0,libraryUI.offset-30);return navigate();});bind('library-next',()=>{libraryUI.offset+=30;return navigate();});
|
||||
const selected=i=>{const p=page.items[i];return plan?p.versions.find(v=>v.plan_id===plan.id):p;};
|
||||
document.querySelectorAll('[data-library-preview]').forEach(b=>b.onclick=busy(()=>{const v=selected(b.dataset.libraryPreview);return openImageViewer(v.url,v.name);}));
|
||||
document.querySelectorAll('[data-library-download]').forEach(b=>b.onclick=busy(()=>{const v=selected(b.dataset.libraryDownload);return downloadImage(v.url,v.name);}));
|
||||
document.querySelectorAll('[data-library-settings]').forEach(b=>b.onclick=()=>generationVersionDetails(selected(b.dataset.librarySettings)));
|
||||
document.querySelectorAll('[data-library-generate]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/source-images/'+page.items[b.dataset.libraryGenerate].id+'/generate',{method:'POST',body:{plan_id:plan?.id||null}});await navigate();}));
|
||||
document.querySelectorAll('[data-library-star]').forEach(b=>b.onclick=busy(async()=>{const p=page.items[b.dataset.libraryStar];await api('/api/v1/source-images/'+p.id+'/star',{method:'PUT',body:{starred:!p.starred}});await navigate();}));
|
||||
document.querySelectorAll('[data-library-exclude]').forEach(b=>b.onclick=busy(async()=>{const p=page.items[b.dataset.libraryExclude];await api('/api/v1/source-images/'+p.id+'/exclusion',{method:'PUT',body:{excluded:!p.excluded}});await navigate();}));
|
||||
document.querySelectorAll('[data-library-delete]').forEach(b=>b.onclick=()=>{const p=page.items[b.dataset.libraryDelete],v=selected(b.dataset.libraryDelete);if(plan&&!v)return;return confirmDialog(plan?tr('删除这个本地生成版本?不会删除源画面或设备上已接收的图片。','Delete this local version? Source pixels and device copies are kept.'):tr('删除本地源画面及其所有生成版本?不会删除照片数据源中的照片或设备副本。','Delete this local source picture and all generated versions? Provider photos and device copies are kept.'),async()=>{await api(plan?'/api/v1/library-versions/'+v.asset_id:'/api/v1/source-images/'+p.id,{method:'DELETE'});await navigate();});});
|
||||
disabledMarkHints(panel);hydrateImages();
|
||||
}
|
||||
|
||||
async function generationPlanEditor(sourceId,plan,pictures){
|
||||
const p=plan||{name:'',width:410,height:502,fit:'cover',quality:85,center_x:0.5,center_y:0.5,caption:false,profile:'color'};
|
||||
showModal(`<h2>${tr('来源图库生成方案','Source-library generation plan')}</h2><form id="generation-form">${!plan?`<div class="actions"><button type="button" data-generation-preset="frame-portrait">${tr('相框竖版','Portrait frame')}</button><button type="button" data-generation-preset="frame-landscape">${tr('相框横版','Landscape frame')}</button><button type="button" data-generation-preset="watch">${tr('手表','Watch')}</button></div>`:''}<div class="field"><label for="generation-name">${t('name')}</label><input id="generation-name" required maxlength="120" value="${esc(p.name)}"></div><div class="form-grid">${[['width',tr('宽度','Width'),16,2048,1],['height',tr('高度','Height'),16,2048,1],['quality',tr('JPEG 质量','JPEG quality'),40,95,1],['center_x',tr('水平裁切位置(0–1)','Crop X (0–1)'),0,1,0.01],['center_y',tr('垂直裁切位置(0–1)','Crop Y (0–1)'),0,1,0.01]].map(([key,label,min,max,step])=>`<div class="field"><label for="generation-${key}">${label}</label><input id="generation-${key}" type="number" required min="${min}" max="${max}" step="${step}" value="${p[key]}"></div>`).join('')}<div class="field"><label for="generation-fit">${tr('适配方式','Fit')}</label><select id="generation-fit"><option value="cover">${tr('等比裁切铺满','Proportional fill')}</option><option value="contain">${tr('完整画面,必要时补白','Full picture, white padding')}</option></select></div><div class="field"><label for="generation-profile">${t('profile')}</label><select id="generation-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div></div><label class="check-row"><input id="generation-caption" type="checkbox" ${p.caption?'checked':''}>${tr('叠加来源提供的日期与说明','Add date and caption supplied by the source')}</label><p class="hint">${tr('默认不拉伸、不补黑边。相框常用 1200×1600 或 1600×1200,当前手表为 410×502。绑定终端时校验尺寸。修改方案只影响后续生成,旧版本保留参数快照。','No stretching or black padding by default. Frames use 1200×1600 or 1600×1200; the current watch uses 410×502. Binding checks screen dimensions. Existing versions keep parameter snapshots.')}</p><div class="field"><label for="generation-picture">${tr('用本地源画面预览','Preview with a local source picture')}</label><select id="generation-picture">${librarySelectOptions(pictures.map(x=>({id:x.id,name:x.name})),pictures[0]?.id,tr('选择照片','Choose picture'))}</select></div><div id="generation-preview-box"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="button" id="generation-preview">${tr('预览','Preview')}</button><button type="submit" class="primary">${t('save')}</button></div></form>`);
|
||||
$('#generation-fit').value=p.fit;$('#generation-profile').value=p.profile;
|
||||
const fill=values=>{for(const key of ['name','width','height','quality','center_x','center_y','fit'])$('#generation-'+key).value=values[key];$('#generation-caption').checked=values.caption||false;$('#generation-profile').value=values.profile||'color';};
|
||||
document.querySelectorAll('[data-generation-preset]').forEach(b=>b.onclick=()=>{const type=b.dataset.generationPreset;fill({...p,name:type==='watch'?tr('手表','Watch'):type==='frame-portrait'?tr('相框竖版','Portrait frame'):tr('相框横版','Landscape frame'),width:type==='watch'?410:type==='frame-portrait'?1200:1600,height:type==='watch'?502:type==='frame-portrait'?1600:1200,caption:type!=='watch'});});
|
||||
const read=()=>({source_id:sourceId,name:$('#generation-name').value,width:Number($('#generation-width').value),height:Number($('#generation-height').value),quality:Number($('#generation-quality').value),center_x:Number($('#generation-center_x').value),center_y:Number($('#generation-center_y').value),fit:$('#generation-fit').value,profile:$('#generation-profile').value,caption:$('#generation-caption').checked});
|
||||
bind('generation-preview',async()=>{if(!$('#generation-form').reportValidity())return;const id=$('#generation-picture').value;if(!id)throw new Error(tr('请先选入源画面','Import a source picture first'));const blob=await api('/api/v1/source-images/'+id+'/preview',{method:'POST',body:read(),binary:true});if(!$('#generation-preview-box'))return;const url=URL.createObjectURL(blob);state.blobs.push(url);$('#generation-preview-box').innerHTML=`<img alt="${tr('生成预览','Generation preview')}" style="max-width:100%;max-height:360px" src="${url}">`;});
|
||||
$('#generation-form').onsubmit=busy(async e=>{e.preventDefault();const saved=await api('/api/v1/generation-plans'+(plan?'/'+plan.id:''),{method:plan?'PUT':'POST',body:read()});libraryUI.plan=saved.id;$('#modal').close();await navigate();});
|
||||
}
|
||||
|
||||
function libraryImportDialog(sourceId){return openSourceBrowser(sourceId);}
|
||||
|
||||
async function terminalLibraryPage(epoch,sources,terminals){
|
||||
if(!terminals.some(t=>t.id===libraryUI.terminal))libraryUI.terminal=state.tab==='watch'?'watch':state.device;
|
||||
const terminal=terminals.find(t=>t.id===libraryUI.terminal)||terminals[0];libraryUI.terminal=terminal.id;state.tab=terminal.id==='watch'?'watch':'cloud';if(terminal.id!=='watch')state.device=terminal.id;shell();
|
||||
const [page,plans]=await Promise.all([api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/images?limit=30&offset='+libraryUI.offset),terminal.binding?api('/api/v1/generation-plans?source_id='+encodeURIComponent(terminal.binding.source_id)):Promise.resolve([])]);
|
||||
if(epoch!==state.epoch)return;
|
||||
const source=sources.find(s=>s.id===terminal.binding?.source_id),plan=plans.find(p=>p.id===terminal.binding?.plan_id);
|
||||
$('#library-content').innerHTML=`<section class="card"><div class="tabs gallery-tabs terminal-choice" role="group" aria-label="${tr('终端','Terminal')}">${terminals.map(t=>`<button type="button" data-library-terminal="${esc(t.id)}" class="${t.id===terminal.id?'active':''}" aria-pressed="${t.id===terminal.id}">${esc(terminalLabel(t))}</button>`).join('')}</div><h2>${esc(terminalLabel(terminal))}</h2><p>${tr('来源图库:','Source library: ')}${esc(source?.name||tr('未绑定','Not bound'))} · ${tr('生成方案:','Generation plan: ')}${esc(plan?.name||tr('未选择','Not selected'))}</p><p class="hint">${terminal.transport_supported?tr('生成、上传和设备确认分别记录;已生成不等于已显示。','Generation, upload and device confirmation are tracked separately. Generated does not mean displayed.'):tr('当前手表屏幕 410×502,传输尚未接入;这里管理生成成品。','Current watch screen: 410×502. Transfer is not connected; this library manages generated versions.')}</p><div class="actions"><button id="library-binding" class="primary">${tr('设置来源与方案(各单选)','Bind one source and one plan')}</button>${terminal.binding?`<button id="library-unbind">${tr('解除绑定','Unbind')}</button><button id="library-to-source">${tr('查看来源与生成版本','View source and versions')}</button>`:''}</div></section><div class="gallery terminal-library-gallery">${page.items.map((v,i)=>`<article class="card image-card"><button class="image-preview" data-terminal-preview="${i}"><img class="thumb" alt="${esc(v.name)}" data-src="${esc(v.url)}" style="object-fit:contain;aspect-ratio:${v.plan.width}/${v.plan.height}"></button><h3>${esc(v.name)}</h3><small>${v.plan.width} × ${v.plan.height} · ${esc(v.plan_name)}</small><small>${esc(libraryStatus(v.delivery_status))} · ${esc(fmt(v.created_at))}</small>${v.matches_current_plan===false?`<small class="hint">${tr('方案参数已更新,此图片为旧参数版本','Plan settings changed; this image uses earlier settings')}</small>`:''}<div class="actions"><button data-terminal-preview="${i}">${tr('预览','Preview')}</button><button data-terminal-download="${i}">${tr('下载图片','Download image')}</button><button data-terminal-settings="${i}">${tr('生成参数','Generation settings')}</button>${v.operation?`<button data-terminal-operation="${i}">${tr('推送记录','Delivery history')}</button>`:''}<button data-terminal-send="${i}" ${v.current||!terminal.transport_supported||!liveWriteAllowed()||['QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN'].includes(v.delivery_status)?'disabled':''} title="${terminal.transport_supported?(liveWriteAllowed()?'':esc(t('readOnlyWarning'))):tr('手表传输尚未接入','Watch transfer is not connected')}">${v.current?tr('已在终端显示','Already displayed on terminal'):tr('推送到终端','Send to terminal')}</button></div></article>`).join('')}</div>${page.items.length?'':`<section class="card empty">${tr('尚无对应成品。绑定来源与方案后,在来源图库选入照片并生成。','No matching versions. Bind a source and plan, then import and generate in the source library.')}</section>`}<div class="actions library-pagination"><button id="terminal-prev" ${libraryUI.offset===0?'disabled':''}>${tr('上一页','Previous')}</button><button id="terminal-next" ${libraryUI.offset+30>=page.total?'disabled':''}>${tr('下一页','Next')}</button><span>${tr('共','Total')} ${page.total} ${tr('个成品版本','generated versions')}</span></div>`;
|
||||
document.querySelectorAll('[data-library-terminal]').forEach(b=>b.onclick=()=>{if(b.dataset.libraryTerminal===libraryUI.terminal)return;libraryUI.terminal=b.dataset.libraryTerminal;libraryUI.offset=0;state.tab=libraryUI.terminal==='watch'?'watch':'cloud';if(libraryUI.terminal!=='watch')state.device=libraryUI.terminal;return navigate();});
|
||||
bind('library-binding',()=>terminalBindingEditor(terminal,sources));bind('library-unbind',()=>confirmDialog(tr(`确认解除“${terminalLabel(terminal)}”与来源图库及生成方案的绑定?已有图片保留,后续来源任务不再向此终端输出。`,`Unbind “${terminalLabel(terminal)}” from its source library and generation plan? Images are kept; future source tasks will not output to it.`),async()=>{await api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/binding',{method:'DELETE'});await navigate();}));
|
||||
bind('library-to-source',()=>{libraryUI.source=terminal.binding.source_id;libraryUI.plan=terminal.binding.plan_id;libraryUI.offset=0;state.tab='local';return navigate('gallery');});
|
||||
bind('terminal-prev',()=>{libraryUI.offset=Math.max(0,libraryUI.offset-30);return navigate();});bind('terminal-next',()=>{libraryUI.offset+=30;return navigate();});
|
||||
document.querySelectorAll('[data-terminal-preview]').forEach(b=>b.onclick=busy(()=>{const v=page.items[b.dataset.terminalPreview];return openImageViewer(v.url,v.name);}));
|
||||
document.querySelectorAll('[data-terminal-download]').forEach(b=>b.onclick=busy(()=>{const v=page.items[b.dataset.terminalDownload];return downloadImage(v.url,v.name);}));
|
||||
document.querySelectorAll('[data-terminal-settings]').forEach(b=>b.onclick=()=>generationVersionDetails(page.items[b.dataset.terminalSettings]));
|
||||
document.querySelectorAll('[data-terminal-operation]').forEach(b=>b.onclick=()=>{state.historyFilters={q:page.items[b.dataset.terminalOperation].operation.operation_id};state.historyPage=1;return navigate('history');});
|
||||
document.querySelectorAll('[data-terminal-send]').forEach(b=>b.onclick=()=>{const v=page.items[b.dataset.terminalSend];if(v.current)return;return confirmDialog(tr('将此版本推送到终端并显示?','Send and display this version on the terminal?'),async()=>{await api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/images/'+v.asset_id+'/send',{method:'POST'});toast(t('accepted'));await navigate();});});hydrateImages();
|
||||
}
|
||||
|
||||
async function terminalBindingEditor(terminal,sources){
|
||||
showModal(`<h2>${esc(terminalLabel(terminal))} · ${tr('来源与生成方案','Source and generation plan')}</h2><form id="terminal-binding-form"><div class="field"><label for="binding-source">${tr('来源图库(单选)','Source library (one)')}</label><select id="binding-source" required>${librarySelectOptions(sources,terminal.binding?.source_id,tr('请选择来源','Choose source'))}</select></div><div class="field"><label for="binding-plan">${tr('该来源内的生成方案(单选)','Generation plan in this source (one)')}</label><select id="binding-plan" required></select></div><p class="hint">${tr('切换来源必须重新选择方案。方案可被多个终端共用,但尺寸必须匹配各终端屏幕。请先在来源图库中创建方案。','Changing source clears the plan selection. Terminals may share a plan when screen dimensions match. Create plans in the source library first.')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}<button class="primary" type="submit">${t('save')}</button></div></form>`);
|
||||
let request=0;
|
||||
async function load(selected){const source=$('#binding-source').value,ticket=++request;$('#binding-plan').innerHTML='<option value="">—</option>';if(!source)return;const plans=await api('/api/v1/generation-plans?source_id='+encodeURIComponent(source));if(ticket!==request||!$('#binding-plan'))return;$('#binding-plan').innerHTML=librarySelectOptions(plans.filter(p=>!p.archived),selected,tr('请选择生成方案','Choose generation plan'));}
|
||||
$('#binding-source').onchange=busy(()=>load(null));$('#terminal-binding-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/binding',{method:'PUT',body:{source_id:$('#binding-source').value,plan_id:$('#binding-plan').value}});$('#modal').close();await navigate();});await load(terminal.binding?.plan_id);
|
||||
}
|
||||
|
||||
$('#device').onchange=()=>{const id=$('#device').value;if(id==='@watch'||state.page==='terminal-gallery'){libraryUI.kind='terminal';libraryUI.terminal=id==='@watch'?'watch':id;libraryUI.offset=0;state.tab=id==='@watch'?'watch':'cloud';if(id!=='@watch')state.device=id;return navigate('terminal-gallery');}state.device=id;return navigate();};
|
||||
|
||||
async function libraryJobFields(job,isNew){
|
||||
const box=document.createElement('section');box.innerHTML=`<label class="check-row"><input id="job-library-pipeline" type="checkbox" ${job.library_pipeline||(isNew&&job.source?.type==='photo_source')?'checked':''}>${tr('按来源图库统一生成并推送','Generate and send through source-library bindings')}</label><p class="hint">${tr('选一次照片并保存源画面,按来源内的终端绑定分别生成;同一方案共用成品。相框继续走上传和显示确认,手表仅生成。此模式不再单独选择“小图方案”。旧任务默认保留原模式,勾选后切换。','Select one photo, save source pixels and generate each bound plan. Terminals sharing a plan share its version. Frames upload and confirm display; watches generate only. Legacy tasks keep their old mode until enabled.')}</p>`;
|
||||
$('#job-form .modal-actions').before(box);
|
||||
const kind=$('#job-content-type'),pipeline=$('#job-library-pipeline');
|
||||
const update=()=>{const supported=kind.value==='photo_source';box.hidden=!supported;$('#legacy-thumbnail-job-fields').hidden=!supported||pipeline.checked||(!job.generate_thumbnail&&!job.thumbnail_plan_id);pipeline.disabled=!supported;if(!supported)pipeline.checked=false;if(pipeline.checked){$('#job-thumbnail').checked=false;$('#job-thumbnail').disabled=true;$('#job-thumbnail-plan').required=false;$('#job-thumbnail-plan').disabled=true;}else{ $('#job-thumbnail').disabled=!supported;}};
|
||||
kind.addEventListener('change',()=>{if(isNew&&kind.value==='photo_source')pipeline.checked=true;update();});pipeline.onchange=update;update();
|
||||
}
|
||||
|
||||
if(state.settings&&state.page==='gallery')void navigate('gallery');else shell();
|
||||
|
||||
function generationVersionDetails(version){
|
||||
const p=version.plan;
|
||||
showModal(`<h2>${esc(version.plan_name)}</h2><div class="detail-grid">${detail(tr('生成时方案名称','Plan name at generation'),p.name)}${detail(tr('原始生成文件名','Stored filename'),version.stored_name)}${detail(tr('输出尺寸','Output size'),p.width+' × '+p.height)}${detail(tr('JPEG 质量','JPEG quality'),p.quality)}${detail(tr('适配方式','Fit'),p.fit==='cover'?tr('等比铺满','Proportional fill'):tr('完整画面','Full picture'))}${detail(tr('裁切位置','Crop position'),p.center_x+' / '+p.center_y)}${detail(tr('叠加说明','Caption'),p.caption?tr('开启','Enabled'):tr('关闭','Disabled'))}${detail(tr('生成时间','Generated at'),fmt(version.created_at))}</div><p class="hint">${tr('这是生成此版本时的参数快照。修改方案不会改变已有图片。','This snapshot records the settings used for this version. Editing a plan does not change existing images.')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.9 KiB |
@@ -0,0 +1,36 @@
|
||||
'use strict';
|
||||
Object.assign(MESSAGES.zh,{'source-browser':'从来源选入照片'});
|
||||
Object.assign(MESSAGES.en,{'source-browser':'Import from source'});
|
||||
icons['source-browser']='▧';
|
||||
const sourceBrowser={source:null,filters:{},offset:0,previous:[]};
|
||||
function openSourceBrowser(sourceId){sourceBrowser.source=sourceId;sourceBrowser.offset=0;sourceBrowser.previous=[];return navigate('source-browser');}
|
||||
views['source-browser']=async function(epoch){
|
||||
const sources=await api('/api/v1/source-libraries');if(epoch!==state.epoch)return;
|
||||
if(!sources.length)return navigate('gallery');
|
||||
if(!sources.some(s=>s.id===sourceBrowser.source)){sourceBrowser.source=sources[0].id;sourceBrowser.filters={};sourceBrowser.offset=0;sourceBrowser.previous=[];}
|
||||
const source=sources.find(s=>s.id===sourceBrowser.source),nas=source.kind==='synology-photos',f=sourceBrowser.filters;
|
||||
const input=(name,label,type='text',extra='')=>`<div class="field"><label for="browse-${name}">${label}</label><input id="browse-${name}" name="${name}" type="${type}" value="${esc(f[name]||'')}" ${extra}></div>`;
|
||||
$('#main').innerHTML=`<section class="card"><div class="library-source-toolbar"><div class="field"><label for="browse-source">${tr('照片来源','Photo source')}</label><select id="browse-source">${librarySelectOptions(sources,source.id,'')}</select></div><button id="browse-back">${tr('返回来源图库','Back to source library')}</button></div><p class="hint">${tr('按批读取照片信息,预览仅在进入可视区域时加载;选入后保存在本地,不会推送设备。','Reads metadata in bounded batches and loads previews on demand. Import stores locally without sending to devices.')}</p><form id="source-browser-filters"><fieldset ${nas?'':'disabled'}><div class="source-browser-filters">${input('filename',tr('文件名关键字','Filename keyword'),'search','maxlength="200"')}${input('folder',tr('文件夹关键字','Folder keyword'),'search','maxlength="500"')}${input('min_mb',tr('最小文件大小(MB)','Min file size (MB)'),'number','min="0" step="0.1" max="1000000"')}${input('max_mb',tr('最大文件大小(MB)','Max file size (MB)'),'number','min="0" step="0.1" max="1000000"')}${input('min_width',tr('最小宽度(px)','Min width (px)'),'number','min="0" max="100000"')}${input('max_width',tr('最大宽度(px)','Max width (px)'),'number','min="0" max="100000"')}${input('min_height',tr('最小高度(px)','Min height (px)'),'number','min="0" max="100000"')}${input('max_height',tr('最大高度(px)','Max height (px)'),'number','min="0" max="100000"')}<div class="field"><label for="browse-date_field">${tr('日期类型','Date type')}</label><select name="date_field" id="browse-date_field"><option value="indexed_time" ${f.date_field!=='time'?'selected':''}>${tr('NAS 创建/入库日期','NAS indexed date')}</option><option value="time" ${f.date_field==='time'?'selected':''}>${tr('照片拍摄日期','Photo date')}</option></select></div>${input('date_from',tr('开始日期','From date'),'date')}${input('date_to',tr('结束日期(含当天)','Through date (inclusive)'),'date')}<div class="field"><label for="browse-person_id">${tr('人物识别','Recognized person')}</label><select id="browse-person_id" name="person_id"><option value="">${tr('不限人物','Any person')}</option>${f.person_id?`<option value="${esc(f.person_id)}" selected>${esc(f.person_name||f.person_id)}</option>`:''}</select><small id="browse-people-status" class="hint"></small></div></div><div class="actions"><button type="submit" class="primary">${tr('搜索 / 筛选','Search / Filter')}</button><button type="reset">${tr('重置','Reset')}</button></div></fieldset></form><p class="hint">${nas?tr('筛选在当前来源设置范围内进行;日期使用系统时区,入库日期不等同于原文件的创建时间。筛选较少见照片时,可继续下一页检索,页面不会等待全库扫描。','Filters stay within this source. Dates use the system timezone; indexed dates are not original file creation dates. Continue through pages for rare matches without waiting for a full scan.'):tr('此来源使用现有目录接口;高级筛选和快速分页目前支持 Synology Photos。','This provider uses its existing catalog. Advanced filters and fast paging currently support Synology Photos.')}</p></section><p id="browse-status" role="status">${tr('正在读取本批照片…','Reading this batch…')}</p><div id="browse-gallery" class="gallery source-browser-gallery" aria-busy="true"></div><div class="actions library-pagination"><button id="browse-prev" ${sourceBrowser.previous.length?'':'disabled'}>${tr('上一页','Previous')}</button><button id="browse-next" disabled>${tr('下一页','Next')}</button></div>`;
|
||||
bind('browse-back',()=>{libraryUI.source=source.id;libraryUI.offset=0;return navigate('gallery');});
|
||||
$('#browse-source').onchange=()=>{sourceBrowser.filters={};return openSourceBrowser($('#browse-source').value);};
|
||||
$('#source-browser-filters').onsubmit=busy(async e=>{e.preventDefault();const values=Object.fromEntries(new FormData(e.currentTarget));const people=$('#browse-person_id');sourceBrowser.filters={...values,person_name:people.selectedOptions[0]?.textContent||''};sourceBrowser.offset=0;sourceBrowser.previous=[];await navigate();});
|
||||
$('#source-browser-filters').onreset=e=>{e.preventDefault();sourceBrowser.filters={};sourceBrowser.offset=0;sourceBrowser.previous=[];navigate();};
|
||||
const params=new URLSearchParams({offset:sourceBrowser.offset,limit:36});for(const [key,value] of Object.entries(f)){if(value!==''&&key!=='person_name'&&!key.endsWith('_mb'))params.set(key,value);}for(const [key,target] of [['min_mb','min_bytes'],['max_mb','max_bytes']])if(f[key]!==undefined&&f[key]!=='')params.set(target,Math.round(Number(f[key])*1024*1024));
|
||||
if(nas)loadBrowserPeople(source,epoch);
|
||||
let page;try{page=await api('/api/v1/source-libraries/'+encodeURIComponent(source.id)+'/browse?'+params);}catch(error){if(epoch!==state.epoch)return;$('#browse-status').textContent=error.message;$('#browse-gallery').setAttribute('aria-busy','false');return;}
|
||||
if(epoch!==state.epoch)return;
|
||||
$('#browse-status').textContent=tr(`本批找到 ${page.items.length} 张照片${page.paged_provider?`,检查 ${page.scanned} 条记录`:''}。${!page.items.length&&page.next_offset!==null?'可继续下一页检索。':''}`,`${page.items.length} matches in this batch${page.paged_provider?`, ${page.scanned} records checked`:''}.${!page.items.length&&page.next_offset!==null?' Continue to the next batch.':''}`);
|
||||
$('#browse-gallery').innerHTML=page.items.map((item,i)=>`<article class="card image-card"><button class="image-preview" data-browse-preview="${i}" ${item.preview_url?'':'disabled'}>${item.preview_url?`<img class="thumb" alt="${esc(item.name)}" data-src="${esc(item.preview_url)}">`:`<span class="empty">${tr('选入后预览','Preview after import')}</span>`}</button><h3 title="${esc(item.name)}">${esc(item.name)}</h3><small class="browse-folder">${esc(item.folder||item.source_path||'')}</small><small>${item.width&&item.height?`${item.width} × ${item.height} px · `:''}${item.filesize?`${(item.filesize/1024/1024).toFixed(2)} MB`:''}</small><small>${item.indexed_time?tr('入库:','Indexed: ')+esc(fmt(new Date(item.indexed_time*1000).toISOString())):''}</small><small>${item.time?tr('拍摄:','Photo: ')+esc(fmt(new Date(item.time*1000).toISOString())):''}</small><small>${esc((item.people||[]).map(p=>p.name).filter(Boolean).join('、'))}</small><div class="actions"><button data-browse-preview="${i}" ${item.preview_url?'':'disabled'}>${tr('预览','Preview')}</button><button class="primary" data-browse-import="${i}">${tr('选入本地','Import locally')}</button></div></article>`).join('');$('#browse-gallery').setAttribute('aria-busy','false');
|
||||
$('#browse-next').disabled=page.next_offset===null;
|
||||
$('#browse-next').textContent=tr('继续读取','Read next batch');
|
||||
if(!page.items.length)$('#browse-gallery').innerHTML=`<section class="card empty"><p>${page.next_offset!==null?tr('本批未找到匹配照片,不代表整个来源没有结果。请继续读取下一批。','No matches in this batch. This does not mean the entire source has no results. Read the next batch.'):tr('已读至来源末尾,当前筛选范围内没有更多结果。','End of source reached. No further results for these filters.')}</p></section>`;
|
||||
|
||||
bind('browse-prev',()=>{sourceBrowser.offset=sourceBrowser.previous.pop()||0;return navigate();});
|
||||
bind('browse-next',()=>{if(page.next_offset===null)return;sourceBrowser.previous.push(sourceBrowser.offset);sourceBrowser.offset=page.next_offset;return navigate();});
|
||||
document.querySelectorAll('[data-browse-preview]').forEach(b=>b.onclick=busy(()=>{const item=page.items[b.dataset.browsePreview];return openImageViewer(item.preview_url,item.name);}));
|
||||
document.querySelectorAll('[data-browse-import]').forEach(b=>b.onclick=busy(async()=>{const item=page.items[b.dataset.browseImport];await api('/api/v1/source-libraries/'+encodeURIComponent(source.id)+'/import',{method:'POST',body:{item_id:item.id}});if(epoch!==state.epoch)return;b.textContent=tr('已选入','Imported');b.disabled=true;libraryUI.offset=0;toast(tr('已保存到来源图库','Saved in source library'));}));
|
||||
hydrateImages();
|
||||
};
|
||||
async function loadBrowserPeople(source,epoch){
|
||||
try{const people=await api('/api/v1/integrations/synology-photos/people?space='+encodeURIComponent(source.people_space||'shared'));if(epoch!==state.epoch)return;$('#browse-person_id').innerHTML='<option value="">'+tr('不限人物','Any person')+'</option>'+people.map(p=>`<option value="${p.id}" ${String(p.id)===String(sourceBrowser.filters.person_id)?'selected':''}>${esc(p.named?p.name:tr('未命名人物 ','Unnamed person ')+p.id)} (${p.count})</option>`).join('');}catch(error){if(epoch===state.epoch)$('#browse-people-status').textContent=tr('人物列表暂不可用,可继续浏览照片','People unavailable; photo browsing remains available');}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
'use strict';
|
||||
// Compatibility controls for existing thumbnail tasks; legacy gallery pages are retired.
|
||||
async function thumbnailJobFields(job){
|
||||
const plans=await api('/api/v1/thumbnail-plans');if(!$('#job-form'))return;
|
||||
const box=document.createElement('section');box.id='legacy-thumbnail-job-fields';box.hidden=!job.generate_thumbnail&&!job.thumbnail_plan_id;box.innerHTML=`<label class="check-row"><input type="checkbox" id="job-thumbnail" ${job.generate_thumbnail?'checked':''}>${tr('生成小图','Generate thumbnail')}</label><div class="field"><label for="job-thumbnail-plan">${tr('小图生成方案(单选)','Thumbnail plan (choose one)')}</label><select id="job-thumbnail-plan"><option value="">${tr('请选择已保存方案','Choose a saved plan')}</option>${plans.map(p=>`<option value="${esc(p.id)}" ${job.thumbnail_plan_id===p.id?'selected':''}>${esc(p.name)} · ${p.width}×${p.height}</option>`).join('')}</select></div><p class="hint">${tr('仅支持照片数据源任务;上传成功后使用源画面生成。新任务请使用来源图库生成方案与终端绑定。','For photo-source tasks only; generates from source pixels after upload succeeds. New tasks should use source-library plans and terminal bindings.')}</p>`;
|
||||
$('#job-form .modal-actions').before(box);
|
||||
const update=()=>{const supported=$('#job-content-type')?.value==='photo_source';$('#job-thumbnail').disabled=!supported;if(!supported)$('#job-thumbnail').checked=false;$('#job-thumbnail-plan').disabled=!$('#job-thumbnail').checked;$('#job-thumbnail-plan').required=$('#job-thumbnail').checked;};
|
||||
$('#job-thumbnail').onchange=update;$('#job-content-type')?.addEventListener('change',update);update();
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
[project]
|
||||
name = "pigway-frame-flow"
|
||||
version = "0.1.2"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["paho-mqtt==2.1.0", "python-dateutil==2.9.0.post0", "fastapi==0.142.2", "uvicorn==0.54.0", "pillow==12.3.0", "requests==2.34.2", "croniter==6.2.4", "python-multipart==0.0.32", "pyyaml==6.0.3"]
|
||||
version = "0.1.29"
|
||||
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==13.0.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"]
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
[tool.ruff]
|
||||
# Preserve existing syntax/formatting independently of runtime support.
|
||||
target-version = "py312"
|
||||
exclude = ["vendor", ".venv"]
|
||||
[build-system]
|
||||
requires = ["setuptools>=75"]
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
annotated-doc==0.0.5
|
||||
annotated-types==0.8.0
|
||||
anyio==4.15.1
|
||||
certifi==2026.7.22
|
||||
certifi==2026.10.23
|
||||
charset-normalizer==3.5.2
|
||||
click==8.5.0
|
||||
croniter==6.2.4
|
||||
fastapi==0.142.2
|
||||
h11==0.16.0
|
||||
idna==3.20
|
||||
opentelemetry-api==1.45.0
|
||||
opentelemetry-api==1.45.1
|
||||
paho-mqtt==2.1.0
|
||||
pillow==12.3.0
|
||||
pillow==13.0.0
|
||||
pydantic-core==2.46.5
|
||||
pydantic==2.13.5
|
||||
python-dateutil==2.9.0.post0
|
||||
|
||||
@@ -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}}'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Opt-in migration of an unambiguous single photo-source task; no device writes."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
from pigway_frame_flow.renderers.frame_geometry import frame_canvas
|
||||
from pigway_frame_flow.settings import Settings
|
||||
|
||||
|
||||
def migrate(call, apply=False):
|
||||
jobs = call("GET", "/api/v1/jobs")
|
||||
candidates = [j for j in jobs if j["source"]["type"] == "photo_source"]
|
||||
sources = call("GET", "/api/v1/source-libraries")
|
||||
if len(candidates) != 1 or len(sources) != 1:
|
||||
raise RuntimeError(
|
||||
"Migration requires exactly one photo task and one source; configure ambiguous bindings in the UI"
|
||||
)
|
||||
job, source = candidates[0], sources[0]
|
||||
if job.get("library_pipeline"):
|
||||
return {"already_configured": True}
|
||||
if job["source"]["source_id"] != source["id"] or job["source"].get("item_id"):
|
||||
raise RuntimeError(
|
||||
"Migration requires a random photo task from the sole source"
|
||||
)
|
||||
if job.get("next_run") and datetime.fromisoformat(job["next_run"]) <= datetime.now(
|
||||
UTC
|
||||
) + timedelta(minutes=2):
|
||||
raise RuntimeError(
|
||||
"Task is due soon; retry after it completes without changing its schedule"
|
||||
)
|
||||
for status in ["QUEUED", "RENDERING", "UPLOADING", "SYNCING", "UNKNOWN"]:
|
||||
if call("GET", "/api/v1/operation-history?status=" + status + "&page_size=1")[
|
||||
"total"
|
||||
]:
|
||||
raise RuntimeError(
|
||||
"Pending operations exist; resolve them before migrating"
|
||||
)
|
||||
terminals = call("GET", "/api/v1/library-terminals")
|
||||
terminal_ids = {t["id"] for t in terminals}
|
||||
if job["device_id"] not in terminal_ids or "watch" not in terminal_ids:
|
||||
raise RuntimeError("The task frame and watch must both exist")
|
||||
device = call("GET", "/api/v1/devices/" + job["device_id"])
|
||||
width, height = frame_canvas(
|
||||
SimpleNamespace(**device["display"]), source.get("orientation")
|
||||
)
|
||||
frame = {
|
||||
"source_id": source["id"],
|
||||
"name": "现有相框方案",
|
||||
"width": width,
|
||||
"height": height,
|
||||
"fit": "cover"
|
||||
if source["folder_caption"]
|
||||
else job["source"].get("fit", "contain"),
|
||||
"caption": source["folder_caption"],
|
||||
"profile": job["source"].get("profile", "color"),
|
||||
"quality": 93,
|
||||
"center_x": 0.5,
|
||||
"center_y": 0.5,
|
||||
}
|
||||
if frame["fit"] == "smart" or job["source"].get("fit") == "smart":
|
||||
raise RuntimeError("Smart crop requires an explicit generation-plan choice")
|
||||
watch = {
|
||||
"source_id": source["id"],
|
||||
"name": "手表默认方案",
|
||||
"width": 410,
|
||||
"height": 502,
|
||||
"fit": "cover",
|
||||
"caption": False,
|
||||
"profile": "color",
|
||||
"quality": 85,
|
||||
"center_x": 0.5,
|
||||
"center_y": 0.5,
|
||||
}
|
||||
if job.get("thumbnail_plan_id"):
|
||||
previous = call("GET", "/api/v1/thumbnail-plans/" + job["thumbnail_plan_id"])
|
||||
if (previous["width"], previous["height"]) != (410, 502):
|
||||
raise RuntimeError(
|
||||
"The previous thumbnail plan does not match the watch screen"
|
||||
)
|
||||
watch.update(
|
||||
{
|
||||
k: previous[k]
|
||||
for k in ["width", "height", "fit", "quality", "center_x", "center_y"]
|
||||
}
|
||||
)
|
||||
plans = call("GET", "/api/v1/generation-plans?source_id=" + source["id"])
|
||||
expected_names = {frame["name"], watch["name"]}
|
||||
if any(not p["archived"] and p["name"] not in expected_names for p in plans):
|
||||
raise RuntimeError(
|
||||
"Custom generation plans already exist; configure bindings explicitly"
|
||||
)
|
||||
known = {}
|
||||
for target in [frame, watch]:
|
||||
match = [p for p in plans if not p["archived"] and p["name"] == target["name"]]
|
||||
if match and any(match[0].get(k) != v for k, v in target.items()):
|
||||
raise RuntimeError(
|
||||
"Existing migration plan parameters differ; configure bindings explicitly"
|
||||
)
|
||||
if match:
|
||||
known[target["name"]] = match[0]
|
||||
for terminal in terminals:
|
||||
if terminal["id"] in [job["device_id"], "watch"] and terminal["binding"]:
|
||||
expected = known.get(
|
||||
frame["name"] if terminal["id"] == job["device_id"] else watch["name"]
|
||||
)
|
||||
if (
|
||||
not expected
|
||||
or terminal["binding"]["source_id"] != source["id"]
|
||||
or terminal["binding"]["plan_id"] != expected["id"]
|
||||
):
|
||||
raise RuntimeError("A terminal already has another binding")
|
||||
if not apply:
|
||||
return {
|
||||
"ready": True,
|
||||
"frame_size": [width, height],
|
||||
"watch_size": [410, 502],
|
||||
"next_run": job["next_run"],
|
||||
}
|
||||
for target in [frame, watch]:
|
||||
if target["name"] not in known:
|
||||
known[target["name"]] = call("POST", "/api/v1/generation-plans", target)
|
||||
for terminal_id, target in [(job["device_id"], frame), ("watch", watch)]:
|
||||
call(
|
||||
"PUT",
|
||||
"/api/v1/library-terminals/" + terminal_id + "/binding",
|
||||
{"source_id": source["id"], "plan_id": known[target["name"]]["id"]},
|
||||
)
|
||||
migrated = call(
|
||||
"PUT", "/api/v1/jobs/" + job["id"] + "/library-mode", {"enabled": True}
|
||||
)
|
||||
if (
|
||||
migrated["next_run"] != job["next_run"]
|
||||
or migrated["enabled"] != job["enabled"]
|
||||
or migrated["schedule"] != job["schedule"]
|
||||
):
|
||||
raise RuntimeError("Task scheduling changed concurrently; review the task")
|
||||
seeded = False
|
||||
try:
|
||||
current = call(
|
||||
"GET", "/api/v1/devices/" + job["device_id"] + "/current-content"
|
||||
)
|
||||
origin = current.get("source") or {}
|
||||
if (
|
||||
current.get("source_known")
|
||||
and origin.get("type") == "photo_source"
|
||||
and origin.get("source_id") == source["id"]
|
||||
and origin.get("item_id")
|
||||
):
|
||||
picture = call(
|
||||
"POST",
|
||||
"/api/v1/source-libraries/" + source["id"] + "/import",
|
||||
{"item_id": origin["item_id"]},
|
||||
)
|
||||
call("POST", "/api/v1/source-images/" + picture["id"] + "/generate", {})
|
||||
seeded = True
|
||||
except urllib.error.HTTPError:
|
||||
# Current pixels may no longer be available upstream. The binding and
|
||||
# schedule remain valid; never guess a substitute or write to a device.
|
||||
pass
|
||||
return {
|
||||
"migrated": True,
|
||||
"terminals_bound": 2,
|
||||
"current_source_generated_locally": seeded,
|
||||
"next_run": migrated["next_run"],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
args = parser.parse_args()
|
||||
settings = Settings()
|
||||
origin = os.environ.get(
|
||||
"FRAME_FLOW_MIGRATION_URL", "http://127.0.0.1:18787"
|
||||
).rstrip("/")
|
||||
|
||||
def call(method, path, payload=None):
|
||||
request = urllib.request.Request(
|
||||
origin + path,
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": "Bearer " + settings.api_token,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data=json.dumps(payload).encode() if payload is not None else None,
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
return json.load(response) if response.status != 204 else None
|
||||
|
||||
print(json.dumps(migrate(call, args.apply), ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -10,7 +10,7 @@ from urllib.request import Request, urlopen
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("mode", choices=["record", "check"])
|
||||
args = parser.parse_args()
|
||||
base = os.getenv("FRAME_FLOW_URL", "http://127.0.0.1:18787").rstrip("/")
|
||||
base = os.getenv("FRAME_FLOW_URL", "http://127.0.0.1:6005").rstrip("/")
|
||||
token = os.getenv("FRAME_FLOW_TOKEN", "")
|
||||
if not token:
|
||||
for line in Path(".env").read_text().splitlines():
|
||||
|
||||
@@ -70,6 +70,13 @@ class Deploy:
|
||||
return json.loads(self.run("inspect", name).stdout)[0]
|
||||
|
||||
def settings(self):
|
||||
if any(
|
||||
self.env.get(key, "").startswith("/volume2/docker/pigway-frame-flow")
|
||||
for key in ("PROD_DATA_DIR", "PROD_CONFIG_DIR")
|
||||
):
|
||||
raise ValueError(
|
||||
"NAS Frame deployment is retired; target the Ubuntu Server"
|
||||
)
|
||||
missing = [key for key in REQUIRED if not self.env.get(key)]
|
||||
if missing:
|
||||
raise ValueError("Missing Variables: " + ", ".join(missing))
|
||||
@@ -118,20 +125,9 @@ class Deploy:
|
||||
or not m.get("RW")
|
||||
):
|
||||
raise ValueError("Production mount mismatch: " + key)
|
||||
photos = self.env.get("PROD_PHOTOS_DIR")
|
||||
if "/photos" in mounts and (
|
||||
not photos
|
||||
or mounts["/photos"]["Source"] != photos
|
||||
or mounts["/photos"]["RW"]
|
||||
):
|
||||
raise ValueError(
|
||||
"Confirm PROD_PHOTOS_DIR for the existing read-only photo mount"
|
||||
)
|
||||
if photos and "/photos" not in mounts:
|
||||
raise ValueError(
|
||||
"Configured photos directory does not match current container"
|
||||
)
|
||||
if set(mounts) != {"/data", "/config"} | ({"/photos"} if photos else set()):
|
||||
if self.env.get("PROD_PHOTOS_DIR") or "/photos" in mounts:
|
||||
raise ValueError("Filesystem photo mounts are retired; use the Photos API")
|
||||
if set(mounts) != {"/data", "/config"}:
|
||||
raise ValueError("Unrecognized persistent mount; review before adopting CD")
|
||||
bindings = old["HostConfig"].get("PortBindings", {}).get("18787/tcp")
|
||||
if bindings != [
|
||||
@@ -180,16 +176,6 @@ class Deploy:
|
||||
)
|
||||
]
|
||||
service["group_add"] = old["HostConfig"].get("GroupAdd") or []
|
||||
if photos:
|
||||
service["volumes"].append(
|
||||
{
|
||||
"type": "bind",
|
||||
"source": photos,
|
||||
"target": "/photos",
|
||||
"read_only": True,
|
||||
"bind": {"create_host_path": False},
|
||||
}
|
||||
)
|
||||
# Adoption must preserve runtime constraints, not silently change them.
|
||||
if (
|
||||
old["Config"]["User"] != service["user"]
|
||||
@@ -389,9 +375,7 @@ 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():
|
||||
@@ -403,7 +387,11 @@ class Deploy:
|
||||
service["image"] = latest
|
||||
proposed = self.secret_config()
|
||||
service["environment"].update(proposed)
|
||||
for key in ("SYNOLOGY_PHOTOS_URL", "SYNOLOGY_PHOTOS_CERT_SHA256"):
|
||||
for key in (
|
||||
"SYNOLOGY_PHOTOS_URL",
|
||||
"SYNOLOGY_PHOTOS_CERT_SHA256",
|
||||
"FRAME_FLOW_STORAGE_NAME",
|
||||
):
|
||||
if self.env.get(key):
|
||||
service["environment"][key] = self.env[key]
|
||||
service["environment"].update(
|
||||
|
||||
@@ -40,5 +40,8 @@ os.environ["BUILD_COMMIT"] = (
|
||||
|
||||
os.environ["BUILD_TIME"] = datetime.now(UTC).isoformat()
|
||||
uvicorn.run(
|
||||
"pigway_frame_flow.main:app", host="127.0.0.1", port=18787, access_log=False
|
||||
"pigway_frame_flow.main:app",
|
||||
host="127.0.0.1",
|
||||
port=int(os.getenv("WEB_PORT", "6005")),
|
||||
access_log=False,
|
||||
)
|
||||
|
||||