Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a843d4de9b | ||
|
|
d2f02d8054 | ||
|
|
8a3c616b5f | ||
|
|
e1b001a431 | ||
|
|
e9d7b12c66 | ||
|
|
b46fbea4c9 | ||
|
|
df1eba74e8 | ||
|
|
b41163965e | ||
|
|
aabbff8127 | ||
|
|
7cdf712829 | ||
|
|
327fa53d7b | ||
|
|
80c6bf3b7d | ||
|
|
b682bdd5b0 | ||
|
|
d109ad6ff1 | ||
|
|
f45fce7d69 | ||
|
|
a11a88456c | ||
|
|
c9c950de1b | ||
|
|
9c9697dc6e | ||
|
|
a869f0b042 | ||
|
|
cc4126d23c | ||
|
|
4fdb7a4407 | ||
|
|
7d10d5b601 | ||
|
|
2c2ec81d51 | ||
|
|
4acd616555 |
@@ -11,6 +11,8 @@ on:
|
||||
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
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_PIP_INDEX_URL: ${{ vars.DOCKER_PIP_INDEX_URL }}
|
||||
@@ -20,8 +22,8 @@ jobs:
|
||||
DEBIAN_MIRROR: ${{ vars.DEBIAN_MIRROR }}
|
||||
DEBIAN_SECURITY_MIRROR: ${{ vars.DEBIAN_SECURITY_MIRROR }}
|
||||
steps:
|
||||
- uses: https://tea.pigway.com/way/frame-flow-ci-actions/checkout@bada0aa1b7e0d23fe9da66ce2ec9b193c211ea46 # v4
|
||||
- uses: https://tea.pigway.com/way/frame-flow-ci-actions/setup-python@bada0aa1b7e0d23fe9da66ce2ec9b193c211ea46 # v5
|
||||
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: https://github.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.14'
|
||||
- name: Install CI dependencies with index diagnostics and fallback
|
||||
@@ -56,12 +58,12 @@ jobs:
|
||||
PROD_PROJECT: ${{ vars.PROD_PROJECT }}
|
||||
PROD_CONTAINER: ${{ vars.PROD_CONTAINER }}
|
||||
PROD_IMAGE_REPO: ${{ vars.PROD_IMAGE_REPO }}
|
||||
PROD_DATA_DIR: ${{ vars.PROD_DATA_DIR }}
|
||||
PROD_CONFIG_DIR: ${{ vars.PROD_CONFIG_DIR }}
|
||||
PROD_PHOTOS_DIR: ${{ vars.PROD_PHOTOS_DIR }}
|
||||
SYNOLOGY_PHOTOS_URL: ${{ vars.SYNOLOGY_PHOTOS_URL }}
|
||||
SYNOLOGY_PHOTOS_CERT_SHA256: ${{ vars.SYNOLOGY_PHOTOS_CERT_SHA256 }}
|
||||
PROD_WEB_BIND: ${{ vars.PROD_WEB_BIND }}
|
||||
PROD_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 }}
|
||||
@@ -73,8 +75,8 @@ jobs:
|
||||
DEBIAN_MIRROR: ${{ vars.DEBIAN_MIRROR }}
|
||||
DEBIAN_SECURITY_MIRROR: ${{ vars.DEBIAN_SECURITY_MIRROR }}
|
||||
steps:
|
||||
- uses: https://tea.pigway.com/way/frame-flow-ci-actions/checkout@bada0aa1b7e0d23fe9da66ce2ec9b193c211ea46 # v4
|
||||
- uses: https://tea.pigway.com/way/frame-flow-ci-actions/setup-python@bada0aa1b7e0d23fe9da66ce2ec9b193c211ea46 # v5
|
||||
- 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
|
||||
|
||||
@@ -20,3 +20,7 @@ build-cache/debs/*
|
||||
!build-cache/debs/.gitkeep
|
||||
build-cache/wheels/*
|
||||
!build-cache/wheels/.gitkeep
|
||||
|
||||
# Private browser captures and platform metadata
|
||||
docs/screenshots/
|
||||
.DS_Store
|
||||
|
||||
@@ -1,359 +1,93 @@
|
||||
# Pigway Frame Flow
|
||||
|
||||
Pigway Frame Flow is a lightweight self-hosted platform for programmable smart-frame automation.
|
||||
[简体中文](README.zh-CN.md) | English
|
||||
|
||||
独立运行的智能画框服务:中英文 Web UI、深浅色模式、设备驱动抽象、KoKonna 全部已公开接口、图片处理、模板、SQLite 调度与播放列表、临时覆盖恢复和 REST API。
|
||||
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.
|
||||
|
||||
KoKonna is a supported device integration and is not affiliated with this project unless otherwise stated.
|
||||
### Installation
|
||||
|
||||
## Quick Start / 快速开始
|
||||
Deploy with Docker Compose. The current default configuration and verified platform are AMD64:
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv
|
||||
.venv/bin/python -m pip install -r requirements.lock
|
||||
.venv/bin/python scripts/run_local.py
|
||||
```
|
||||
|
||||
Open http://127.0.0.1:18787. The local demo creates a private `.env` with a random
|
||||
FRAME_FLOW_TOKEN. Read that token locally in an editor and enter it on the login
|
||||
screen. Do not paste a KoKonna API Key into Web UI. Demo always forces mock mode
|
||||
and disables real writes. It leaves data persistent in ./data and ./config.
|
||||
|
||||
网页使用账号密码登录。通过 `python scripts/create-account.py --username YOUR_NAME` 创建账号,再启动服务;密码不会写入代码或明文配置。勾选记住登录可保持 30 天,设置页面可修改密码或退出。
|
||||
|
||||
## Architecture / 架构
|
||||
|
||||
```
|
||||
providers → templates / renderers → generated image
|
||||
↓
|
||||
Web UI / external REST → DisplayService → FrameDeviceDriver → KokonnaDriver
|
||||
Scheduler / Playlist ─────────────┘
|
||||
SQLite: operations / history / jobs / overrides / assets
|
||||
```
|
||||
|
||||
Package: pigway_frame_flow. Device composition is in core/registry.py;
|
||||
vendor-specific code is in drivers/kokonna. One process, one worker, no Redis,
|
||||
message broker, OpenClaw, Home Assistant or external application dependency.
|
||||
|
||||
## Configuration / 配置
|
||||
|
||||
Copy `.env.example` to `.env`, set a strong FRAME_FLOW_TOKEN, chmod 600 .env.
|
||||
- KOKONNA_MOCK=true: independent persistent demo, no cloud calls.
|
||||
- KOKONNA_WRITE_ENABLED=false: live write protection, mandatory before verification.
|
||||
- KOKONNA_API_KEY: device key, never stored in DB or returned to Web UI.
|
||||
- *_FILE: optional Docker Secret equivalent.
|
||||
- WEB_BIND/WEB_PORT: Compose host bind and port. Internal app binds container network.
|
||||
- CORS_ALLOWED_ORIGINS: exact comma-separated origins; default empty.
|
||||
- IMAGE_URL_ALLOWED_HOSTS: exact public HTTPS hosts; default disabled. Redirects denied.
|
||||
- TZ=Asia/Shanghai; DATA_DIR=/data; CONFIG_DIR=/config.
|
||||
|
||||
For multiple devices copy deploy/devices.yml.example into config/devices.yml,
|
||||
reference a separate environment key per device. Jobs bind a device_id. Layout
|
||||
currently targets 1200 × 1600; incompatible geometry is refused before writes.
|
||||
|
||||
## Docker
|
||||
|
||||
```sh
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Configure a token; keep API key empty, mock true, writes false.
|
||||
# Set a strong FRAME_FLOW_TOKEN; keep mock=true and writes=false initially.
|
||||
chmod 600 .env
|
||||
mkdir -p data/images data/generated data/cache config
|
||||
sudo chown -R 10333:10333 data config
|
||||
./scripts/build-verify.sh
|
||||
# Create the browser administrator account; enter its password interactively.
|
||||
docker compose run --rm --no-deps -e PYTHONPATH=/app \
|
||||
-v "$PWD/scripts:/bootstrap:ro" --entrypoint python pigway-frame-flow \
|
||||
/bootstrap/create-account.py --username admin --config-dir /config
|
||||
docker compose up -d
|
||||
python3 scripts/smoke-test.py
|
||||
```
|
||||
|
||||
Image pigway-frame-flow:latest, service/container pigway-frame-flow, AMD64,
|
||||
Python 3.14.5, pinned runtime dependencies, CJK fonts, non-root UID/GID 10333,
|
||||
read-only root filesystem, writable /data and /config, no capabilities, no host
|
||||
network, no Docker socket. Restart unless-stopped. JSON-file logs capped 10m × 3.
|
||||
Healthcheck uses service /health, not cloud availability. No second log database.
|
||||
Open `http://127.0.0.1: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.
|
||||
|
||||
## Synology NAS
|
||||
The published interface defaults to loopback. For LAN access, explicitly set `WEB_BIND` to your NAS LAN address and choose an available `WEB_PORT`. See [Synology deployment](docs/NAS.md), [build mirrors and caching](docs/BUILDING.md), and [Gitea deployment/rollback](docs/deployment/GITEA_CD.md). No firewall, router or reverse-proxy settings are changed automatically.
|
||||
|
||||
Target `/volume2/docker/pigway-frame-flow/`. See [NAS deployment](docs/NAS.md).
|
||||
Set WEB_BIND to the NAS LAN IP to restrict the published interface, or explicitly
|
||||
0.0.0.0 when desired. Port 18787 must be free. No router/firewall/reverse proxy
|
||||
rules are modified. Source and persistent volumes stay in this project directory.
|
||||
No artificial logs directory is required. Compose creates its own bridge network.
|
||||
For a local mock demo, use Python 3.14:
|
||||
|
||||
## KoKonna Setup / 真实设备
|
||||
```bash
|
||||
python3.14 -m venv .venv
|
||||
.venv/bin/python -m pip install -r requirements.lock
|
||||
.venv/bin/python scripts/create-account.py --username admin
|
||||
.venv/bin/python scripts/run_local.py
|
||||
```
|
||||
|
||||
First deploy Mock and verify persistence. Then set KOKONNA_MOCK=false and fill
|
||||
KOKONNA_API_KEY through private environment/secret. Keep writes false. /ready checks
|
||||
configuration/DB/worker/token without a cloud call. Query device and images metadata;
|
||||
compare model, geometry, timezone, heartbeat, battery, imageId, coverId, sync.
|
||||
If different, stop and report. **Do not enable writes or perform the single test
|
||||
until the owner explicitly authorizes it.** Test design: [REAL_DEVICE_TEST.md](docs/REAL_DEVICE_TEST.md).
|
||||
The demo forces mock mode and disables device writes. Data and configuration remain in `data/` and `config/` across restarts.
|
||||
|
||||
## Security
|
||||
### Features and boundaries
|
||||
|
||||
Protected /api/v1 capabilities accept an administrator account session, or external
|
||||
Frame Flow Bearer auth with scope checks.
|
||||
SQLite stores token hashes. Device keys live only in env/secret and runtime memory.
|
||||
Browser login uses SQLite accounts with salted scrypt password hashes and revocable,
|
||||
HttpOnly/SameSite=Strict cookies (30 days when remembered, 12 hours otherwise).
|
||||
Mutations require a session CSRF header. Logout and password changes revoke sessions.
|
||||
Passwords and browser sessions are never stored in localStorage. Bootstrap hashes
|
||||
in private `config/accounts.json` seed new accounts only; restarting does not undo
|
||||
a password change. Five failed logins per source IP cause a five-minute lockout.
|
||||
The browser needs no API Token. Bearer authentication remains compatible for
|
||||
external clients; the KoKonna key is never used for account login.
|
||||
The LAN deployment uses HTTP; use an explicitly configured HTTPS endpoint if
|
||||
transport encryption is needed. No reverse proxy or firewall is changed automatically.
|
||||
- Chinese and English Web UI, light/dark themes, account login, device dashboard and image previews.
|
||||
- Local staging and KoKonna cloud gallery, uploads, image switching, stars, source/task filters and batch actions.
|
||||
- Aspect-ratio-preserving contain/cover/smart conversion, color/grayscale profiles and orientation selection for photo sources.
|
||||
- Seven templates: Daily Dashboard, Weather, Calendar, Quote, Photo + Text, Daily Poetry and System Alert.
|
||||
- Layout Studio for collages/posters with 1–12 images; optional OpenAI-compatible layout planning and source-driven automatic collages.
|
||||
- Device-bound schedules, ordered/random playlists, temporary priority overrides, restore and persistent operation history.
|
||||
- Read-only NAS folders, Synology Photos person filtering, WebDAV and optional cloud-drive connectors. Each source has its own configuration and authorization.
|
||||
- Optional weather, calendar, monitoring, AI, MQTT and webhook integrations. Provider configuration is required; an unconnected provider is not a working connection.
|
||||
|
||||
## API
|
||||
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).
|
||||
|
||||
OpenAPI 3.x at /openapi.json; Swagger at /docs; stable /api/v1. Display/render/upload
|
||||
return 202 + operation_id. Poll /api/v1/operations/{id}; completion requires matching
|
||||
image ID and cloud synced flag. Idempotency-Key retained seven days. X-Request-ID
|
||||
traces history. UNKNOWN writes block later mutations; read-only reconciliation
|
||||
and explicit admin acknowledgement release the block, never replay a write.
|
||||
The older /api/display route is retained and deprecated (same v1 payload).
|
||||
See [external integration contract](docs/integrations/TICKTAR.md).
|
||||
Real weather uses the configured provider/location and is refreshed when rendered; it does not update an already displayed image by itself. Official KoKonna widgets are separate from Frame Flow templates and require optional vendor-account authorization. API-key access remains independent.
|
||||
|
||||
## Scheduler / Playback
|
||||
### Device integration and API
|
||||
|
||||
Cron (5 fields), daily, weekdays, weekends, date and temporary tasks; Shanghai time.
|
||||
Use source {"type":"restore"} for explicit restore jobs. Unique job/slot and persistent
|
||||
idempotency prevent duplicate dispatch. Overdue tasks >5 minutes are skipped rather
|
||||
than unexpectedly overwriting artwork after downtime. State persists across restart.
|
||||
Overrides use priority then recency; lower priorities wait without uploading.
|
||||
If another app changes the frame, expired overrides do not overwrite its artwork.
|
||||
Frame Flow playlists support ordered/random cloud image IDs and >=5-minute intervals;
|
||||
only one enabled playlist per device. They pause for overrides/unknown writes.
|
||||
Native cloud slideshow mode/interval are readable; membership/editing are unavailable
|
||||
in the official API. Avoid having native and Frame Flow playback compete.
|
||||
```text
|
||||
Providers → Templates / Renderers → Generated image
|
||||
Web UI / Scheduler / REST API → DisplayService → FrameDeviceDriver → KoKonna
|
||||
↓
|
||||
SQLite: jobs, operations, history, overrides, assets
|
||||
```
|
||||
|
||||
## Templates / 图片
|
||||
Configure the device key privately through `.env` or a secret file. Start with `KOKONNA_MOCK=true` and `KOKONNA_WRITE_ENABLED=false`; after mock verification, connect read-only before explicitly enabling writes. Multi-device definitions use `config/devices.yml` and device-specific key references. Vendor code stays in `drivers/kokonna`; other applications use the same service API as the Web UI.
|
||||
|
||||
Seven JSON layout templates: daily-dashboard, weather, calendar, quote, photo-text,
|
||||
daily-poetry and system-alert, with color and grayscale profiles. Every template
|
||||
renders an image, including templates that contain only text.
|
||||
The public API is `/api/v1`, with OpenAPI at `/openapi.json` and Swagger UI at `/docs`. Display/render/upload operations return HTTP 202 and an operation ID; poll `/api/v1/operations/{id}` for the outcome. `Idempotency-Key` prevents duplicate dispatch, and `X-Request-ID` links requests to history. An uncertain write blocks subsequent mutations until read-only reconciliation or explicit administrator acknowledgement; it is never automatically replayed.
|
||||
|
||||
使用流程:选择模板 → 编辑数据 → 生成预览 → 显示到画框。生成预览只创建本地图片;
|
||||
保存默认数据只保存配置,两者都不会更新设备。自动化模板任务每次运行重新生成图片。
|
||||
Ticktar integration is optional. Frame Flow has no runtime dependency on Ticktar and never accesses its database. See the [external integration contract](docs/integrations/TICKTAR.md).
|
||||
|
||||
- **照片与文字 / Photo + Text**:标题、中央照片区域、下方正文;与照片数据源任务的底部水印布局不同。
|
||||
- **家庭早报 / Daily Dashboard**:日期、天气、事项、一句话与所选照片区域。
|
||||
- **天气 / Weather**:当前天气与三天预报;天气 JSON 留空时使用配置的 Provider。
|
||||
- **日历、每日诗词、一句话、系统告警**:将相应信息排版为图片,不自动选择图库照片。
|
||||
### Storage and security
|
||||
|
||||
Photos use a local asset ID (`photo_id`); without a selected photo, a placeholder
|
||||
is rendered. Configure Open-Meteo and location coordinates in **Settings → Weather
|
||||
source** for real weather. Forecasts are fetched on demand and cached for ten minutes;
|
||||
unavailable/stale data is labeled explicitly. Weather remains static on the frame
|
||||
until the next render and display, so use an Automation job for regular updates.
|
||||
Official KoKonna widgets are a separate integration, not these local templates.
|
||||
No unreliable lunar date is fabricated.
|
||||
Contain preserves composition with margins; cover crops centrally; smart is an
|
||||
edge-saliency heuristic. Always inspect preview; face-aware guarantees are not claimed.
|
||||
Cloud uploads activate immediately, so gallery-only means **local staging**.
|
||||
Docker persists `/data/frameflow.db`, images/generated/cache under `/data`, and private configuration/session files under `/config`. It runs as UID/GID 10333 with a read-only root filesystem, its own bridge network, `unless-stopped` restart policy and bounded stdout/stderr logs. No privileged mode, host network or Docker socket is required.
|
||||
|
||||
## Development / Testing
|
||||
Passwords are hashed; browser mutations require CSRF protection. API keys and provider credentials stay server-side and must never enter Git or logs. CORS is disabled unless origins are explicitly configured. `/health` checks the service; `/ready` checks local dependencies without making cloud availability a restart trigger. See [public repository privacy](docs/PUBLICATION.md), [LICENSE](LICENSE) and [NOTICE](NOTICE.md).
|
||||
|
||||
```sh
|
||||
KoKonna is a supported integration. This project is not affiliated with or endorsed by KoKonna.
|
||||
|
||||
### Development and known limitations
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pip install -e '.[test]'
|
||||
.venv/bin/python -m pytest -q
|
||||
.venv/bin/python -m ruff check pigway_frame_flow tests scripts
|
||||
.venv/bin/python -m ruff format --check pigway_frame_flow tests scripts
|
||||
.venv/bin/python -m mypy pigway_frame_flow
|
||||
.venv/bin/python -m pytest -q
|
||||
.venv/bin/python -m pip_audit -r requirements.lock --no-deps --disable-pip
|
||||
```
|
||||
|
||||
Tests mock every cloud request; no real-device writes. scripts/ui-smoke.cjs runs an
|
||||
isolated headless browser and checks settings.mock before acting. CI builds AMD64,
|
||||
validates Compose, starts a container, checks health/ready and restarts for persistence.
|
||||
Tests mock cloud/device calls. CI also verifies an isolated Docker build, readiness, smoke tests, restart and cleanup. External fork PRs do not execute on the production-capable NAS runner.
|
||||
|
||||
## Known Limitations / 已知边界
|
||||
Cloud synchronization does not prove the physical pixels refreshed. KoKonna cloud uploads activate immediately, so upload-only uses local staging. Native slideshow membership editing is unavailable; avoid running native and Frame Flow playback simultaneously. Smart cropping is a saliency heuristic, not guaranteed face detection. The service uses one worker and is not designed for multiple replicas. Browser OAuth, AI daily-art scheduling, additional calendar recurrence features and more device drivers remain roadmap items.
|
||||
|
||||
Cloud acknowledgements do not prove physical display pixels. Model fields may be
|
||||
omitted and are explicitly marked configured. No native slideshow membership API,
|
||||
no cloud upload-only API, no automatic retries after uncertain writes. Opt-in local retention protects manual uploads, stars and active references; its quota is a cleanup target, not a hard disk limit. Rendering assets are local, limited
|
||||
to 25M pixels/20 MiB. This release uses a single worker and is not multi-replica.
|
||||
Gallery reads have no pagination because upstream documents none. Web UI hides
|
||||
secrets and lazy-loads thumbnails; generating a first thumbnail fetches one original.
|
||||
Scoped client provisioning and bounded signed webhook delivery are available in Connections. Docker build/NAS
|
||||
status is recorded honestly in docs/ACCEPTANCE.md.
|
||||
|
||||
## Roadmap
|
||||
|
||||
Browser OAuth login, AI daily-art scheduling, custom ICS timezones/EXRULE and additional hardware-specific adapters remain pending. MQTT, bounded recurrence, token refresh, transition events and single-image AI generation are optional capabilities. See [optional capabilities and exact limits](docs/EXTENSIONS.md).
|
||||
|
||||
## Layout Studio and optional integrations
|
||||
|
||||
Bilingual Layout Studio imports 1–12 images, edits bounded regions and renders a collage or poster. Optional OpenAI-compatible AI plans receive text and dimensions only. Connections configures provider credentials, calendars/state, retention, client scopes and webhooks. Photo Sources includes NAS, WebDAV, Google Drive, AliPan and Baidu readers. Authorization is separate from implementation; unconnected providers return explicit errors. See [setup and limitations](docs/EXTENSIONS.md).
|
||||
|
||||
## Template editing
|
||||
|
||||
Templates provide bilingual form fields, event lists, local photo selection, advanced
|
||||
JSON, color/grayscale previews, persistent defaults and reset to built-in defaults.
|
||||
Changing content invalidates the previous preview. Weather uses the labeled Mock
|
||||
provider until a real provider is configured. Live display controls stay disabled
|
||||
until real writes are separately approved.
|
||||
|
||||
Browser smoke tests use a disposable Mock account via FRAME_FLOW_TEST_USERNAME
|
||||
and FRAME_FLOW_TEST_PASSWORD; never run the mutation smoke suite against live devices.
|
||||
|
||||
## Current content and completed page workflows
|
||||
|
||||
Dashboard displays the actual current image and recorded content source. Frame Flow
|
||||
stores immutable template/data/profile snapshots when it renders an asset and maps
|
||||
successful upload results to the device/image ID. The current-content endpoint
|
||||
matches the live device image ID; vendor images with no recorded source remain
|
||||
explicitly unknown. Switching externally never makes the previous template appear
|
||||
as the current one. Restore also restores the matching content provenance.
|
||||
|
||||
Templates can load the current recorded template or continue editing a generated
|
||||
local image. They support direct photo import and protected image downloads.
|
||||
Gallery provides search, sorting, dimensions and downloads. Display confirmation
|
||||
supports duration, priority and restoration. History includes operation details.
|
||||
Automation offers content pickers, template profiles, previews and manual execution;
|
||||
playlists offer gallery image checkboxes. Real writes remain blocked by the server
|
||||
regardless of the UI controls.
|
||||
|
||||
Settings persists weather coordinates/provider and image URL host allowlists in
|
||||
SQLite. Open-Meteo provides current model weather and a three-day forecast, cached
|
||||
for ten minutes on demand. Network failures show unavailable/stale data and use a
|
||||
two-minute backoff, without replacing it with fictitious live data. Default remains
|
||||
Mock until configured in Settings. Open-Meteo attribution is included in the UI and
|
||||
weather/dashboard renders. Image URL import is HTTPS-only with explicit hostnames,
|
||||
public-address validation and pinned DNS; redirects and private addresses are blocked.
|
||||
|
||||
Additional v1 endpoints: `/devices/{id}/current-content`, `/assets/{id}/source`,
|
||||
`/providers/weather`, `/settings/weather`, `/settings/image-url`, `/jobs/{id}`,
|
||||
`/jobs/{id}/preview`, `/jobs/{id}/run` (all under `/api/v1`). Existing contracts remain
|
||||
compatible; new endpoints are included in generated OpenAPI and contract tests.
|
||||
|
||||
## Daily Poetry / 每日诗词
|
||||
|
||||
The daily-poetry template includes 20 public-domain classical Chinese poems, selected deterministically by Shanghai date, or manually. Create a daily job from its editor; new poetry jobs are paused by default until you enable them. Custom title, author and verses are editable. This is an independent Frame Flow content provider, not the KoKonna mini-program subscription. Official public API does not expose mini-program subscription/template settings.
|
||||
|
||||
每日诗词支持按上海日期选诗、手选 20 首古典诗词、自定义文本以及每日任务。模板显示操作使用统一上传/显示服务;真实写入须开启 KOKONNA_WRITE_ENABLED,结果未知不会自动重试。官方小程序原有每日诗词订阅不能通过当前公开 API 查询或修改。
|
||||
|
||||
|
||||
### 官方账号连接 / Official account connection
|
||||
|
||||
在 Templates → 官方小组件中,由管理员输入 KoKonna 官方账号和密码,查询账号下
|
||||
的设备后手动关联当前 Frame Flow 设备。此流程不绑定或解绑云端设备,也不改变
|
||||
图片或订阅。现有 API Key 接入继续负责设备、图库和显示能力。
|
||||
|
||||
密码不保存;会话与关联信息保存在 `/config/official-{device_id}.secret`,权限 0600。
|
||||
容器重启可恢复连接。会话过期需要重新登录;断开连接会清除本地会话,不撤销
|
||||
官方服务器上的其他登录。局域网访问沿用现有部署方式。
|
||||
|
||||
Official account connection is admin-only and separate from API Key access.
|
||||
Login, device discovery, local association and disconnect are available through
|
||||
`/api/v1/devices/{device_id}/official/connection`. Passwords are never persisted;
|
||||
server-side sessions are stored as restricted secret files. No official widget
|
||||
mutation or other extra RPC write is enabled by connecting.
|
||||
|
||||
### Photo sources / 照片数据源
|
||||
|
||||
Gallery supports read-only NAS folders and HTTPS WebDAV sources, random photo
|
||||
preview, and device-bound scheduled random photos. Source definitions are persisted
|
||||
in SQLite. See [setup and limits](docs/PHOTO_SOURCES.md).
|
||||
NAS 本地目录需要显式只读挂载;网盘先支持通用 WebDAV,也可使用同步到 NAS 的目录。
|
||||
|
||||
### Build networking and cache
|
||||
|
||||
The Dockerfile retains the pinned Python base, locked Python dependencies,
|
||||
`fonts-noto-cjk`, Debian repository signature checks, and offline artifact
|
||||
SHA256 verification. Both production and CI use `scripts/build-verify.sh`,
|
||||
which enables BuildKit and retains `docker compose build --pull`.
|
||||
|
||||
APT mirror hosts are configurable through `DEBIAN_MIRROR` and
|
||||
`DEBIAN_SECURITY_MIRROR` (hosts only, without scheme or path). Defaults are
|
||||
`deb.debian.org`; HTTPS is used for both Debian and security repositories.
|
||||
For mainland-China NAS builds, use the shared profile:
|
||||
|
||||
```sh
|
||||
. ./deploy/build-cn.env
|
||||
./scripts/build-verify.sh
|
||||
```
|
||||
|
||||
CI loads this same profile, using Tsinghua TUNA for both repositories unless
|
||||
Gitea repository variables `DEBIAN_MIRROR` / `DEBIAN_SECURITY_MIRROR` override
|
||||
it. Production may also set the two hosts directly in `.env` when calling the
|
||||
build script without the profile. An alternate mainland mirror is
|
||||
`mirrors.ustc.edu.cn`; availability and throughput depend on the Runner network.
|
||||
No fallback disables signature verification or removes packages.
|
||||
|
||||
Layer order stays: pinned base → APT/font dependencies → `requirements.lock`
|
||||
→ pip dependencies → application/vendor source → build metadata.
|
||||
A source-only change therefore reuses the APT and pip layers, provided the
|
||||
same builder, base digest, mirror arguments, and optional offline artifacts
|
||||
are used. Changing the lockfile rebuilds pip and later layers, not APT.
|
||||
Changing the mirror or APT instructions rebuilds APT and later layers.
|
||||
|
||||
Previously an APT layer miss redownloaded indexes/packages, and pip used
|
||||
`--no-cache-dir`. Now locked BuildKit mounts retain APT indexes, downloaded
|
||||
`.deb` files, and pip downloads/wheels outside the final image. Debian's
|
||||
`docker-clean` hook is disabled so it cannot discard cached packages.
|
||||
Re-executed APT steps still run `apt-get update` and validate repository
|
||||
signatures; cached packages and pip artifacts can avoid repeat large downloads.
|
||||
The initial build after this Dockerfile change will miss dependency layers;
|
||||
subsequent builds benefit. No measured speedup is claimed before CI completes.
|
||||
|
||||
Caches belong to the Docker builder, not the ephemeral CI workspace. Keep the
|
||||
NAS Runner's builder storage across jobs; removing the builder, pruning caches,
|
||||
or using a fresh Docker daemon loses the benefit. Cache mounts are disposable:
|
||||
an empty cache still builds correctly. `--pull` remains enabled; a pinned base
|
||||
digest check alone does not invalidate dependency layers. To deliberately
|
||||
refresh installed APT versions, use `docker compose build --pull --no-cache`.
|
||||
|
||||
### CI with a host Docker socket
|
||||
|
||||
Run `python scripts/ci-compose-verify.py` after loading the build mirror profile.
|
||||
The script derives a temporary Compose document from production settings, with
|
||||
an absolute client-side build context, a UUID project name, a unique CI image
|
||||
tag, and project-scoped `data` / `config` named volumes. It removes production
|
||||
bind mounts, the fixed container name, host ports, and the production `.env`
|
||||
reference. A fresh test token, mock driver, and disabled device writes are
|
||||
explicitly configured. Docker initializes empty named volumes from the image's
|
||||
owned `/data` and `/config` directories; the service remains UID/GID 10333.
|
||||
|
||||
Build contexts are sent by the Docker client, so the job workspace can remain
|
||||
inside the job container. Runtime storage is managed by the daemon; no matching
|
||||
NAS host `/workspace` directory is needed. HTTP readiness and the unmodified
|
||||
nine-endpoint smoke script run inside the service with `compose exec -T`.
|
||||
Restart is followed by another readiness check and the same smoke test.
|
||||
|
||||
A `finally` block removes the test containers, network, volumes, and unique
|
||||
image tag on success or failure. The workflow records the private temporary
|
||||
Compose path in `GITHUB_ENV` and repeats cleanup in an `always()` step, covering
|
||||
normal cancellation/interruption. Failed cleanup retains the manifest so the
|
||||
fallback can retry. Abrupt host/daemon loss or a forcibly terminated entire job
|
||||
can prevent both cleanup paths; no CI script can guarantee cleanup in that case.
|
||||
No global prune or production resource removal is performed. Keep builder
|
||||
caches: only the run-specific image tag is removed.
|
||||
|
||||
### CI Job pip indexes versus Docker build indexes
|
||||
|
||||
The Job bootstrap runs `python scripts/ci-install.py` before Ruff/mypy/tests.
|
||||
It needs only the Python standard library, so it runs before PyYAML or tooling
|
||||
is installed. `CI_PIP_INDEX_URL` and `CI_PIP_FALLBACK_INDEX_URL` are optional
|
||||
Gitea repository variables. Defaults are the TUNA HTTPS PyPI simple endpoint
|
||||
and official `https://pypi.org/simple`. Each is attempted separately; the
|
||||
fallback repeats the unchanged editable `.[test]` installation, including
|
||||
PEP 517 build isolation and `setuptools>=75`. No simultaneous extra index is
|
||||
used, no build dependency is removed, and both failures fail CI.
|
||||
|
||||
Logs show inherited index/config/proxy hosts and the selected index host for
|
||||
each attempt, with URL credentials and query strings removed. Bootstrap
|
||||
disables inherited pip config files via `PIP_CONFIG_FILE=/dev/null`, explicitly
|
||||
sets the selected index, and clears no-index/extra-index/find-links, insecure
|
||||
trusted-host, and no-build-isolation environment settings. Constraints, TLS
|
||||
certificate environment settings, and proxy settings remain in effect.
|
||||
|
||||
These `CI_PIP_*` settings apply only to the Job's pip install. They are not
|
||||
passed into Docker build arguments or persisted into the production image.
|
||||
The Dockerfile keeps its own cached/offline dependency installation and logs
|
||||
the Docker pip index host for online installs. Debian mirror variables affect
|
||||
APT only and are loaded later in the Docker verification step.
|
||||
|
||||
If dependency installation fails, the always() cleanup step still executes.
|
||||
It skips the Python/PyYAML cleanup script when no CI Compose manifest exists,
|
||||
as no Docker verification resources have been created at that point.
|
||||
|
||||
Production CD: [safe trial, configuration, and rollback](docs/deployment/GITEA_CD.md).
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Pigway Frame Flow
|
||||
|
||||
简体中文 | [English](README.md)
|
||||
|
||||
轻量、自托管的智能画框自动化平台,可独立运行在 NAS 或服务器上,提供自己的 Web 界面、图库、图片渲染、调度器和 REST API。KoKonna 是支持的设备集成之一;外部应用无需持有它的凭据或引入其 SDK。
|
||||
|
||||
### 安装
|
||||
|
||||
使用 Docker Compose 部署。当前默认配置与已验证平台为 AMD64:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 设置强 FRAME_FLOW_TOKEN;首次保持 mock=true、writes=false。
|
||||
chmod 600 .env
|
||||
mkdir -p data/images data/generated data/cache config
|
||||
sudo chown -R 10333:10333 data config
|
||||
./scripts/build-verify.sh
|
||||
# 创建网页管理员账号,密码通过交互输入。
|
||||
docker compose run --rm --no-deps -e PYTHONPATH=/app \
|
||||
-v "$PWD/scripts:/bootstrap:ro" --entrypoint python pigway-frame-flow \
|
||||
/bootstrap/create-account.py --username admin --config-dir /config
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
打开 `http://127.0.0.1:18787`,使用刚创建的账号登录。网页使用 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)。不会自动修改防火墙、路由器或反向代理。
|
||||
|
||||
使用 Python 3.14 运行本地 Mock 演示:
|
||||
|
||||
```bash
|
||||
python3.14 -m venv .venv
|
||||
.venv/bin/python -m pip install -r requirements.lock
|
||||
.venv/bin/python scripts/create-account.py --username admin
|
||||
.venv/bin/python scripts/run_local.py
|
||||
```
|
||||
|
||||
演示强制使用 Mock,并关闭设备写入。数据与配置保存在 `data/`、`config/`,重启后保留。
|
||||
|
||||
### 功能与边界
|
||||
|
||||
- 中英文 Web 界面、深浅色主题、账号登录、设备面板与图片预览。
|
||||
- 本地暂存和 KoKonna 云端图库,上传、切换、星标、来源/任务筛选及批量操作。
|
||||
- 保持比例的 contain/cover/smart 图片转换,彩色/灰度模式与照片数据源的横竖方向选择。
|
||||
- 七种模板:家庭早报、天气、日历、一句话、照片与文字、每日诗词、系统告警。
|
||||
- 拼图工作室支持 1–12 张图片的拼图与海报;可选 OpenAI 兼容接口排版,以及从数据源自动选图拼接。
|
||||
- 绑定设备的定时任务、顺序/随机播放列表、带优先级的临时覆盖、恢复与持久化操作历史。
|
||||
- 只读 NAS 文件夹、Synology Photos 人物筛选、WebDAV 和可选网盘连接器;每个数据源独立配置、独立授权。
|
||||
- 可选天气、日历、监控、AI、MQTT 与 Webhook 集成。需要配置对应 Provider;尚未授权的数据源不会被当作已连接。
|
||||
|
||||
模板会**生成图片**,纯文字模板也是如此。使用顺序是:选择模板 → 编辑内容 → 生成预览 → 显示到画框。生成预览和保存模板设置都不会更新画框。“照片与文字”和“家庭早报”可以包含指定的本地照片,其他模板不会自动选图;定时模板任务每次运行会重新渲染。参见[照片数据源](docs/PHOTO_SOURCES.md)、[自动拼图](docs/AUTOMATIC_COLLAGE.md)和[集成配置及限制](docs/EXTENSIONS.md)。
|
||||
|
||||
天气使用配置的服务和地点,在渲染时更新,不会自行刷新已显示的图片。KoKonna 官方小组件与 Frame Flow 模板是两项独立能力;使用官方小组件需要额外授权厂商账号,API Key 接入仍可单独使用。
|
||||
|
||||
### 设备集成与 API
|
||||
|
||||
```text
|
||||
Provider → 模板 / Renderer → 生成图片
|
||||
Web UI / Scheduler / REST API → DisplayService → FrameDeviceDriver → KoKonna
|
||||
↓
|
||||
SQLite:任务、操作、历史、覆盖状态、图片
|
||||
```
|
||||
|
||||
设备 Key 通过私有 `.env` 或 Secret 文件配置。首次保持 `KOKONNA_MOCK=true`、`KOKONNA_WRITE_ENABLED=false`,Mock 验证后先只读连接,再明确开启写入。多设备使用 `config/devices.yml` 配置,每台设备引用自己的 Key。厂商代码位于 `drivers/kokonna`;Web 界面与外部程序使用相同业务服务。
|
||||
|
||||
正式 API 为 `/api/v1`,OpenAPI 位于 `/openapi.json`,Swagger 位于 `/docs`。显示、渲染、上传返回 HTTP 202 和操作 ID,通过 `/api/v1/operations/{id}` 查询结果。`Idempotency-Key` 防止重复派发,`X-Request-ID` 串联请求与历史。写入结果不确定时会阻止后续修改,必须只读核对或由管理员明确确认,不会自动重放写请求。
|
||||
|
||||
Ticktar 集成为可选项,Frame Flow 不依赖 Ticktar 启动,也不访问其数据库。参见[外部集成契约](docs/integrations/TICKTAR.md)。
|
||||
|
||||
### 存储与安全
|
||||
|
||||
Docker 持久化 `/data/frameflow.db`、`/data` 下的图片/生成文件/缓存,以及 `/config` 下的私有配置和会话文件。容器以 UID/GID 10333 运行,根文件系统只读,使用独立桥接网络、`unless-stopped` 重启策略和有大小限制的标准输出日志;不需要 privileged、host network 或 Docker socket。
|
||||
|
||||
账号密码保存为哈希,网页修改操作有 CSRF 校验。API Key 和 Provider 凭据仅留在服务端,不得提交 Git 或写入日志。CORS 默认不开启,需明确配置来源。`/health` 检查服务自身,`/ready` 检查本地依赖,不以云端可用性触发重启。参见[公开仓库隐私](docs/PUBLICATION.md)、[LICENSE](LICENSE) 和 [NOTICE](NOTICE.md)。
|
||||
|
||||
KoKonna 是支持的设备集成。本项目与 KoKonna 无隶属关系,也不代表其官方认可。
|
||||
|
||||
### 开发与已知限制
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pip install -e '.[test]'
|
||||
.venv/bin/python -m ruff check pigway_frame_flow tests scripts
|
||||
.venv/bin/python -m ruff format --check pigway_frame_flow tests scripts
|
||||
.venv/bin/python -m mypy pigway_frame_flow
|
||||
.venv/bin/python -m pytest -q
|
||||
.venv/bin/python -m pip_audit -r requirements.lock --no-deps --disable-pip
|
||||
```
|
||||
|
||||
测试使用 Mock 云端/设备调用;CI 还验证隔离的 Docker 构建、就绪检查、smoke test、重启与清理。外部 Fork PR 不会在可访问生产环境的 NAS Runner 上执行。
|
||||
|
||||
云端同步不等于已确认物理屏幕刷新。KoKonna 云端上传会立即切换,因此“仅上传”使用本地暂存。官方接口不支持编辑原生轮播成员,应避免原生轮播与 Frame Flow 播放同时运行。smart 裁切采用显著性启发式,不保证识别人脸。当前服务使用单 Worker,不面向多副本部署。浏览器 OAuth、AI 每日艺术调度、更多日历重复规则及设备驱动仍属于后续计划。
|
||||
|
||||
相关项目:[PIGWay Raspberry Pi Agent](https://tea.pigway.com/way/pigway-raspberry-pi-agent) · [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) · [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat)
|
||||
@@ -0,0 +1,55 @@
|
||||
# Automatic collage
|
||||
|
||||
The navigation starts with Dashboard, followed by Gallery, Photo Sources, Upload,
|
||||
Templates, Layout Studio, Automation, Playlists, History, Connections and Settings.
|
||||
|
||||
## Web workflow / 界面操作
|
||||
|
||||
在「拼图」选择照片来源与数量(1–12),填写可选标题或排版要求,点击
|
||||
「随机选图并生成预览」。默认使用启用的数据源;没有数据源时使用本地上传照片。
|
||||
AI 只接收图片尺寸、内部资产 ID 和排版文字,不发送照片。
|
||||
AI 未配置或请求失败会明确报错,不会偷偷切换引擎;取消 AI 勾选可用自动网格。
|
||||
预览不会上传或切换画框,确认效果后再点「显示到画框」。
|
||||
|
||||
Choose a source and count, then create a preview. Only dimensions, asset IDs and
|
||||
layout instructions are sent to AI. AI configuration/errors are explicit; uncheck
|
||||
AI for deterministic grid layout. Preview never changes the frame. Canvas
|
||||
orientation follows the target device. Manual layout is under Advanced.
|
||||
|
||||
「创建自动化任务」保存来源和数量,而不是固定图片列表。任务初始暂停,
|
||||
请在自动化中编辑执行时间后启用。每次运行重新随机选图,但不同运行之间可能重复;
|
||||
单次不重复选同一照片。照片不足时失败,不重复图片凑数。
|
||||
NAS 来源继续使用人物、方向与质量筛选;一次最多检查数量的五倍候选图片。
|
||||
输入图缓存与生成图在 NAS;原始 NAS 照片不会被修改或删除。
|
||||
定时拼图沿用云库每任务保留最近一张成功图的策略,下一张确认成功前不删除上一张。
|
||||
|
||||
Create automation stores the source/count, initially paused. Edit its schedule
|
||||
and enable it. Each execution selects new candidates; repeats across executions
|
||||
are possible. Too few usable photos fails before upload. Existing unknown-write,
|
||||
restoration and cloud-retention safeguards apply.
|
||||
|
||||
## Public API
|
||||
|
||||
`POST /api/v1/render` accepts this additive content type, returning HTTP 202 and
|
||||
an operation ID. `POST /api/v1/display` uses the same content inside its existing
|
||||
request envelope. Scheduler `source` accepts the same object; job previews are
|
||||
read-only.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "auto_collage",
|
||||
"source_id": "nas-photos",
|
||||
"count": 4,
|
||||
"title": "Our moments",
|
||||
"orientation": "auto",
|
||||
"style": "grid",
|
||||
"use_ai": true,
|
||||
"prompt": "Feature one photo with smaller supporting photos",
|
||||
"profile": "color"
|
||||
}
|
||||
```
|
||||
|
||||
`source_id: null` chooses local uploaded assets (generated and cache images are
|
||||
excluded). AI configuration is shared with existing Layout Studio. No new API key
|
||||
or vendor dependency is introduced. The OpenAPI contract test records the new
|
||||
content schema while keeping all existing schema fields unchanged.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Build networking, cache and CI
|
||||
|
||||
### 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`.
|
||||
|
||||
APT mirror hosts are configurable through `DEBIAN_MIRROR` and
|
||||
`DEBIAN_SECURITY_MIRROR` (hosts only, without scheme or path). Defaults are
|
||||
`deb.debian.org`; HTTPS is used for both Debian and security repositories.
|
||||
For mainland-China NAS builds, use the shared profile:
|
||||
|
||||
```sh
|
||||
. ./deploy/build-cn.env
|
||||
./scripts/build-verify.sh
|
||||
```
|
||||
|
||||
CI loads this same profile, using Tsinghua TUNA for both repositories unless
|
||||
Gitea repository variables `DEBIAN_MIRROR` / `DEBIAN_SECURITY_MIRROR` override
|
||||
it. Production may also set the two hosts directly in `.env` when calling the
|
||||
build script without the profile. An alternate mainland mirror is
|
||||
`mirrors.ustc.edu.cn`; availability and throughput depend on the Runner network.
|
||||
No fallback disables signature verification or removes packages.
|
||||
|
||||
Layer order stays: pinned base → APT/font dependencies → `requirements.lock`
|
||||
→ pip dependencies → application/vendor source → build metadata.
|
||||
A source-only change therefore reuses the APT and pip layers, provided the
|
||||
same builder, base digest, mirror arguments, and optional offline artifacts
|
||||
are used. Changing the lockfile rebuilds pip and later layers, not APT.
|
||||
Changing the mirror or APT instructions rebuilds APT and later layers.
|
||||
|
||||
Previously an APT layer miss redownloaded indexes/packages, and pip used
|
||||
`--no-cache-dir`. Now locked BuildKit mounts retain APT indexes, downloaded
|
||||
`.deb` files, and pip downloads/wheels outside the final image. Debian's
|
||||
`docker-clean` hook is disabled so it cannot discard cached packages.
|
||||
Re-executed APT steps still run `apt-get update` and validate repository
|
||||
signatures; cached packages and pip artifacts can avoid repeat large downloads.
|
||||
The initial build after this Dockerfile change will miss dependency layers;
|
||||
subsequent builds benefit. No measured speedup is claimed before CI completes.
|
||||
|
||||
Caches belong to the Docker builder, not the ephemeral CI workspace. Keep the
|
||||
NAS Runner's builder storage across jobs; removing the builder, pruning caches,
|
||||
or using a fresh Docker daemon loses the benefit. Cache mounts are disposable:
|
||||
an empty cache still builds correctly. `--pull` remains enabled; a pinned base
|
||||
digest check alone does not invalidate dependency layers. To deliberately
|
||||
refresh installed APT versions, use `docker compose build --pull --no-cache`.
|
||||
|
||||
### CI with a host Docker socket
|
||||
|
||||
Run `python scripts/ci-compose-verify.py` after loading the build mirror profile.
|
||||
The script derives a temporary Compose document from production settings, with
|
||||
an absolute client-side build context, a UUID project name, a unique CI image
|
||||
tag, and project-scoped `data` / `config` named volumes. It removes production
|
||||
bind mounts, the fixed container name, host ports, and the production `.env`
|
||||
reference. A fresh test token, mock driver, and disabled device writes are
|
||||
explicitly configured. Docker initializes empty named volumes from the image's
|
||||
owned `/data` and `/config` directories; the service remains UID/GID 10333.
|
||||
|
||||
Build contexts are sent by the Docker client, so the job workspace can remain
|
||||
inside the job container. Runtime storage is managed by the daemon; no matching
|
||||
NAS host `/workspace` directory is needed. HTTP readiness and the unmodified
|
||||
nine-endpoint smoke script run inside the service with `compose exec -T`.
|
||||
Restart is followed by another readiness check and the same smoke test.
|
||||
|
||||
A `finally` block removes the test containers, network, volumes, and unique
|
||||
image tag on success or failure. The workflow records the private temporary
|
||||
Compose path in `GITHUB_ENV` and repeats cleanup in an `always()` step, covering
|
||||
normal cancellation/interruption. Failed cleanup retains the manifest so the
|
||||
fallback can retry. Abrupt host/daemon loss or a forcibly terminated entire job
|
||||
can prevent both cleanup paths; no CI script can guarantee cleanup in that case.
|
||||
No global prune or production resource removal is performed. Keep builder
|
||||
caches: only the run-specific image tag is removed.
|
||||
|
||||
### CI Job pip indexes versus Docker build indexes
|
||||
|
||||
The Job bootstrap runs `python scripts/ci-install.py` before Ruff/mypy/tests.
|
||||
It needs only the Python standard library, so it runs before PyYAML or tooling
|
||||
is installed. `CI_PIP_INDEX_URL` and `CI_PIP_FALLBACK_INDEX_URL` are optional
|
||||
Gitea repository variables. Defaults are the TUNA HTTPS PyPI simple endpoint
|
||||
and official `https://pypi.org/simple`. Each is attempted separately; the
|
||||
fallback repeats the unchanged editable `.[test]` installation, including
|
||||
PEP 517 build isolation and `setuptools>=75`. No simultaneous extra index is
|
||||
used, no build dependency is removed, and both failures fail CI.
|
||||
|
||||
Logs show inherited index/config/proxy hosts and the selected index host for
|
||||
each attempt, with URL credentials and query strings removed. Bootstrap
|
||||
disables inherited pip config files via `PIP_CONFIG_FILE=/dev/null`, explicitly
|
||||
sets the selected index, and clears no-index/extra-index/find-links, insecure
|
||||
trusted-host, and no-build-isolation environment settings. Constraints, TLS
|
||||
certificate environment settings, and proxy settings remain in effect.
|
||||
|
||||
These `CI_PIP_*` settings apply only to the Job's pip install. They are not
|
||||
passed into Docker build arguments or persisted into the production image.
|
||||
The Dockerfile keeps its own cached/offline dependency installation and logs
|
||||
the Docker pip index host for online installs. Debian mirror variables affect
|
||||
APT only and are loaded later in the Docker verification step.
|
||||
|
||||
If dependency installation fails, the always() cleanup step still executes.
|
||||
It skips the Python/PyYAML cleanup script when no CI Compose manifest exists,
|
||||
as no Docker verification resources have been created at that point.
|
||||
|
||||
@@ -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. 有目标一致且同步完成的证据才记为成功。处理后恢复原计划,错过的任务不补发。
|
||||
|
||||
仍可能因厂商无法给出明确写入结果而需要人工处理;本策略解决错误误判、可安全重试的暂时失败和长期阻塞缺乏解释的问题,不承诺网络故障时所有设备任务都能自动成功。
|
||||
@@ -0,0 +1,25 @@
|
||||
# Photo sources and shared connections
|
||||
|
||||
Each photo source has a unique ID and independently persisted directory/URL,
|
||||
orientation, caption, quality, duplicate and person-selection settings. Jobs refer
|
||||
to the source ID. Updating one source does not change another source.
|
||||
|
||||
Local sources read folders under the configured NAS mount. Ordinary file reads
|
||||
and local Synology thumbnails do not require a Photos login. Person filtering
|
||||
uses the shared Synology Photos session, intersected with that source's folder
|
||||
catalog. Shared/personal space and person IDs are saved separately per source.
|
||||
|
||||
The current service supports one NAS Photos endpoint and one active account.
|
||||
Signing in with another account or disconnecting affects every people-filtered
|
||||
source. Personal-space person IDs may differ between accounts: recheck each
|
||||
source after an account switch. Multiple independent NAS/account connections
|
||||
are not supported. Do not interpret multiple folder sources as multiple logins.
|
||||
|
||||
WebDAV credentials and native drive credentials belong to their respective
|
||||
sources and do not use the shared NAS Photos login. Explicitly reusing a WebDAV
|
||||
credential variable shares that credential, but does not share filter settings.
|
||||
|
||||
The UI labels the top card **Shared connection**, displays source IDs and paths
|
||||
on each source card, and explains each source's connection dependency. Shared
|
||||
session disconnect is available only in the shared connection card and asks for
|
||||
confirmation of its impact on all people-filtered sources.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Public repository and deployment privacy
|
||||
|
||||
Runtime keys, passwords, sessions, private photos, databases and local `.env` files
|
||||
must remain outside Git. Examples use documentation-only addresses and identities.
|
||||
Personal deployment reports and screenshots are excluded from public history.
|
||||
|
||||
The NAS Docker runner accepts trusted repository events only. 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.
|
||||
|
||||
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.
|
||||
@@ -25,3 +25,23 @@
|
||||
The API cannot set coverId. Restoration guarantees a requested image switch when
|
||||
confirmed, not arbitrary cover metadata restoration. Physical screen refresh has
|
||||
no documented pixel telemetry; visual owner observation supplements cloud synced.
|
||||
|
||||
### Upload response reconciliation
|
||||
|
||||
Before an upload, the service persists its asset ID, exact SHA256/size, filename,
|
||||
existing gallery IDs and timestamp. When the response is lost, read-only queries
|
||||
look for one new image in the upload time window; only an exact downloaded-byte
|
||||
match identifies it. Ambiguous, missing or altered content remains locked for
|
||||
manual review. No upload or switch is replayed by reconciliation.
|
||||
|
||||
A proven upload completes the display only when the device reports that exact
|
||||
image and `synced=true`. If a normal display remains on its previous synced
|
||||
image beyond the sync timeout plus five minutes, two observations at least one
|
||||
minute apart record `DISPLAY_NOT_CONFIRMED` and unblock future scheduled work.
|
||||
Temporary overrides retain their restore behavior and are not automatically
|
||||
closed as failed. Legacy operations without durable upload evidence require
|
||||
manual acknowledgement; absence from the gallery alone never proves failure.
|
||||
|
||||
### 错误与有限重试
|
||||
|
||||
完整错误矩阵、重试边界、持久化恢复状态和人工处理见 [KoKonna 恢复策略](KOKONNA_RECOVERY.md)。自动重试只允许只读请求、连接建立超时或明确限流拒绝;读取超时等未知写入不会重发。
|
||||
|
||||
+3
-2
@@ -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,36 @@
|
||||
# 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.
|
||||
+72
-34
@@ -9,8 +9,8 @@ host bind/port. SQLite/images/generated/cache live under `/data`; accounts,
|
||||
vendor sessions and configuration live under `/config`. Neither is disposable.
|
||||
The optional photos mount is read-only and has supplementary group access.
|
||||
|
||||
Historical repository receipts mention `/volume2/docker/pigway-frame-flow/data`,
|
||||
`/volume2/docker/pigway-frame-flow/config`, `/volume1/photo`, port 18787, and
|
||||
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.
|
||||
@@ -24,18 +24,18 @@ configured names and existing bind mounts, with `create_host_path: false`.
|
||||
|
||||
## Gitea configuration
|
||||
|
||||
Repository Settings → Actions → Variables (ordinary configuration):
|
||||
Repository Settings → Actions configuration (private paths/addresses use Secrets):
|
||||
|
||||
| Variable | Value to explicitly confirm |
|
||||
| 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` | Existing absolute NAS host data path; no default |
|
||||
| `PROD_CONFIG_DIR` | Existing absolute NAS host config path; no default |
|
||||
| `PROD_PHOTOS_DIR` | Existing read-only photos source if present; otherwise empty |
|
||||
| `PROD_WEB_BIND` | Exact existing Docker HostIp (`0.0.0.0` or NAS LAN IP etc.) |
|
||||
| `PROD_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 |
|
||||
@@ -75,7 +75,7 @@ Never upload that journal or a full Docker inspect as a public CI artifact.
|
||||
docker inspect pigway-frame-flow --format '{{json .Mounts}}'
|
||||
docker inspect pigway-frame-flow --format '{{json .HostConfig.PortBindings}} {{.HostConfig.NetworkMode}}'
|
||||
```
|
||||
Confirm the Variables against these actual values, including photos.
|
||||
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
|
||||
@@ -107,7 +107,7 @@ been performed during implementation; those are first-run acceptance steps.
|
||||
an immutable rollback tag so normal dangling-image cleanup cannot remove it.
|
||||
4. Refuse a stale checked-out `main` revision that no longer equals remote main.
|
||||
Build an immutable `release-<commit>-<owner>` tag locally, without a registry
|
||||
and without changing `latest` or CI image tags.
|
||||
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.
|
||||
@@ -174,15 +174,15 @@ The confirmed NAS residue is a `created`, never-started container with dead
|
||||
`/workspace/way/...` bind paths. It is not an adoption/rollback baseline.
|
||||
No migration from those paths is performed. Production Compose remains independently usable.
|
||||
|
||||
Set these repository Actions **Variables** (not Secrets):
|
||||
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=/volume2/docker/pigway-frame-flow/data
|
||||
PROD_CONFIG_DIR=/volume2/docker/pigway-frame-flow/config
|
||||
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
|
||||
@@ -262,11 +262,11 @@ as an Actions artifact. First bootstrap has no prior healthy version to roll bac
|
||||
No NAS bootstrap is executed merely by committing this implementation.
|
||||
3. Require `BOOTSTRAP VERIFIED` in the deploy log. In Container Manager confirm
|
||||
container `pigway-frame-flow` is running/healthy, user 10333, restart unless-stopped,
|
||||
exact `/volume2/docker/pigway-frame-flow/{data,config}` mounts and
|
||||
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:release-<commit>-<transaction>`; compare its image ID in
|
||||
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
|
||||
@@ -279,33 +279,56 @@ real host mounts and restart, remains the required live acceptance.
|
||||
|
||||
## Actions download transport
|
||||
|
||||
Verify and deploy fetch commit-pinned Actions from the supporting repository
|
||||
`https://tea.pigway.com/way/frame-flow-ci-actions`, subdirectories `checkout`
|
||||
and `setup-python`, at commit `bada0aa1b7e0d23fe9da66ce2ec9b193c211ea46`.
|
||||
This avoids unstable NAS downloads from GitHub (EOF/timeouts in runs 27/28)
|
||||
and slow external Gitea Action preparation (run 29, superseded).
|
||||
Verify and deploy reference official GitHub Actions directly, pinned to immutable
|
||||
commits rather than moving tags:
|
||||
|
||||
The cache contains unmodified official upstream source, including MIT licenses:
|
||||
| Action | Official upstream commit |
|
||||
| --- | --- |
|
||||
| actions/checkout v4 | 11d5960a326750d5838078e36cf38b85af677262 |
|
||||
| actions/setup-python v5 | a26af69be951a213d495a4c3e4e4022e16d87065 |
|
||||
|
||||
| Action | Official upstream commit | Identical Git tree |
|
||||
| --- | --- | --- |
|
||||
| actions/checkout v4 | 11d5960a326750d5838078e36cf38b85af677262 | f8a7b72dc00648d050099727d25ca92a43ad1162 |
|
||||
| actions/setup-python v5 | a26af69be951a213d495a4c3e4e4022e16d87065 | 568c6310706725a8b725497dbe6b3b909cbcf6cd |
|
||||
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.
|
||||
|
||||
`UPSTREAM.json` records provenance. Upstream tag SHAs were checked using the official
|
||||
GitHub API; each cached subtree hash was compared to the upstream commit tree.
|
||||
No application secrets are stored in this public upstream-source cache; its own
|
||||
Actions are disabled. Intentional action updates must repeat tree verification
|
||||
and change the workflow pin. All validation steps remain unchanged.
|
||||
Keep the Runner's action cache and Python toolcache persistent. For Gitea Runner
|
||||
versions supporting `cache.offline_mode`, use a private Runner configuration:
|
||||
|
||||
Python toolcache misses may still require setup-python to download Python from
|
||||
its upstream release source. This removes Action source download dependence,
|
||||
not every possible external download. No Runner or NAS network configuration is changed.
|
||||
```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 Variables. The
|
||||
`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.
|
||||
@@ -340,3 +363,18 @@ 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.
|
||||
|
||||
@@ -17,8 +17,11 @@ Authorization: Bearer FRAME_FLOW_TOKEN. The bootstrap token is provided through
|
||||
client_id, name, permissions, enabled, created_at, last_used_at. Permissions:
|
||||
devices:read, images:read, images:delete, display:write, templates:read, render:write,
|
||||
jobs:read, jobs:write, history:read, settings:write, admin.
|
||||
0.1.0 seeds one admin token; provisioning scoped client rows is supported by middleware
|
||||
but multi-token management UI is not implemented. Use a trusted server-to-server
|
||||
The deployment token seeds an administrator. Administrators can create separate
|
||||
scoped clients through Connections or POST /api/v1/clients. The returned token is
|
||||
shown once; GET /api/v1/clients never returns tokens. DELETE /api/v1/clients/{id}
|
||||
revokes access. For Ticktar, grant devices:read, templates:read, render:write and
|
||||
display:write; add images:read or jobs permissions only when needed. Use a trusted server-to-server
|
||||
connection; CORS is opt-in by exact origins, never wildcard by default.
|
||||
|
||||
## Discovery and capabilities
|
||||
@@ -86,13 +89,19 @@ The authenticated client is authoritative; source.client is optional metadata.
|
||||
History stores client_id/request_id/operation_id/device_id/action/result/timestamp.
|
||||
No internal endpoints, headers, stacks, paths or secrets are returned.
|
||||
|
||||
## Future webhook
|
||||
## Webhooks
|
||||
|
||||
SQLite events and webhook_subscriptions reserve operation.completed/failed,
|
||||
display.changed, device.online/offline/low_battery events. 0.1.0 emits operation/display
|
||||
events only; no callback delivery, retry worker or registration API is implemented.
|
||||
Future deliveries should use signed payloads and bounded retries.
|
||||
OAuth, tenancy, RBAC management and generated SDKs are future work.
|
||||
Administrators register callbacks through POST /api/v1/webhooks. Allowed public
|
||||
HTTPS hosts must be configured explicitly. Durable callbacks cover
|
||||
operation.completed, operation.failed, display.changed and observed
|
||||
device.online/device.offline/device.low_battery transitions. Device observation
|
||||
runs at most once per minute; failed cloud queries do not imply offline.
|
||||
|
||||
X-Frame-Flow-Signature: sha256=<hex> is HMAC-SHA256 over the exact request body.
|
||||
Signing secrets are shown once and stored in mode-0600 files. Verify signatures
|
||||
and deduplicate event IDs. Delivery retries at most three times; events before
|
||||
registration are not replayed. Polling Operations remains available.
|
||||
OAuth, tenancy and generated SDKs are future work.
|
||||
|
||||
## Compatibility
|
||||
|
||||
|
||||
+4868
-138
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ from pigway_frame_flow.models import (
|
||||
Accepted,
|
||||
Asset,
|
||||
AssetBatch,
|
||||
AutoCollageContent,
|
||||
CurrentDisplay,
|
||||
DeleteRequest,
|
||||
Device,
|
||||
@@ -123,7 +124,7 @@ def create_app(settings=None, start_worker=True):
|
||||
app = FastAPI(
|
||||
title="Pigway Frame Flow API",
|
||||
docs_url=None,
|
||||
version="0.1.0",
|
||||
version="0.1.4",
|
||||
lifespan=lifespan,
|
||||
responses=error_responses,
|
||||
)
|
||||
@@ -485,7 +486,7 @@ def create_app(settings=None, start_worker=True):
|
||||
dependencies=[Depends(require("render:write"))],
|
||||
)
|
||||
def render(
|
||||
payload: TemplateContent,
|
||||
payload: TemplateContent | AutoCollageContent,
|
||||
request: Request,
|
||||
idempotency_key: str | None = Header(default=None),
|
||||
):
|
||||
@@ -999,7 +1000,7 @@ def create_app(settings=None, start_worker=True):
|
||||
)
|
||||
def settings_view():
|
||||
return SettingsView(
|
||||
version="0.1.0",
|
||||
version="0.1.4",
|
||||
mock=settings.mock,
|
||||
write_enabled=settings.write_enabled,
|
||||
api_key_configured=bool(settings.api_key)
|
||||
@@ -1175,9 +1176,11 @@ def create_app(settings=None, start_worker=True):
|
||||
idempotency_key: str | None = Header(default=None),
|
||||
):
|
||||
source = get_job(job_id)["source"]
|
||||
if source["type"] != "template":
|
||||
if source["type"] not in ("template", "auto_collage"):
|
||||
raise FlowError(
|
||||
E.UNSUPPORTED_CAPABILITY, "Job preview requires a template source", 422
|
||||
E.UNSUPPORTED_CAPABILITY,
|
||||
"Job preview requires template or auto-collage content",
|
||||
422,
|
||||
)
|
||||
return submit(
|
||||
request, "render", source, get_job(job_id)["device_id"], idempotency_key
|
||||
|
||||
@@ -23,7 +23,7 @@ class CloudLibrary:
|
||||
job_id = (
|
||||
job["job_id"]
|
||||
if job
|
||||
and content.get("type") == "photo_source"
|
||||
and content.get("type") in ("photo_source", "auto_collage")
|
||||
and content.get("item_id") is None
|
||||
else None
|
||||
)
|
||||
@@ -101,7 +101,7 @@ class CloudLibrary:
|
||||
return True
|
||||
|
||||
def sweep(self):
|
||||
if time.monotonic() - self.checked < 60:
|
||||
if self.checked and time.monotonic() - self.checked < 60:
|
||||
return
|
||||
self.checked = time.monotonic()
|
||||
with self.service.lock:
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Durable, read-only recovery progress for writes with uncertain outcomes."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from pigway_frame_flow.storage.database import encode, now_iso
|
||||
|
||||
|
||||
class RecoveryMonitor:
|
||||
def __init__(self, service):
|
||||
self.service, self.db = service, service.db
|
||||
|
||||
def observe(self, op, reason):
|
||||
timestamp = now_iso()
|
||||
age = (
|
||||
datetime.now(UTC) - datetime.fromisoformat(op["created_at"])
|
||||
).total_seconds()
|
||||
review = age >= self.service.settings.sync_timeout + 300
|
||||
with self.db.transaction() as tx:
|
||||
previous = tx.execute(
|
||||
"SELECT * FROM operation_recovery WHERE operation_id=?", (op["id"],)
|
||||
).fetchone()
|
||||
# Once escalated, wall-clock corrections must not clear the alert.
|
||||
review = review or bool(previous and previous["state"] == "manual_review")
|
||||
state = "manual_review" if review else "checking"
|
||||
tx.execute(
|
||||
"INSERT INTO operation_recovery VALUES(?,?,?,?,?,1) ON CONFLICT(operation_id) DO UPDATE SET state=excluded.state,reason=excluded.reason,last_checked_at=excluded.last_checked_at,checks=operation_recovery.checks+1",
|
||||
(op["id"], state, reason, timestamp, timestamp),
|
||||
)
|
||||
if review and (not previous or previous["state"] != "manual_review"):
|
||||
tx.execute(
|
||||
"INSERT INTO events VALUES(?,?,?,?)",
|
||||
(
|
||||
uuid4().hex,
|
||||
"operation.blocked",
|
||||
encode(
|
||||
{
|
||||
"operation_id": op["id"],
|
||||
"device_id": op["device_id"],
|
||||
"reason": reason,
|
||||
"requires_manual_review": True,
|
||||
}
|
||||
),
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
tx.execute(
|
||||
"INSERT INTO history(client_id,request_id,operation_id,device_id,action,result,timestamp) VALUES(?,?,?,?,?,?,?)",
|
||||
(
|
||||
op["client_id"],
|
||||
op["request_id"],
|
||||
op["id"],
|
||||
op["device_id"],
|
||||
op["action"],
|
||||
"MANUAL_REVIEW_REQUIRED",
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
|
||||
def resolve(self, operation_id):
|
||||
self.db.execute(
|
||||
"UPDATE operation_recovery SET state='resolved' WHERE operation_id=? AND state!='resolved'",
|
||||
(operation_id,),
|
||||
)
|
||||
|
||||
def info(self, operation_id):
|
||||
row = self.db.one(
|
||||
"SELECT state,reason,first_checked_at,last_checked_at,checks FROM operation_recovery WHERE operation_id=?",
|
||||
(operation_id,),
|
||||
)
|
||||
if row:
|
||||
row["requires_manual_review"] = row["state"] == "manual_review"
|
||||
return row
|
||||
@@ -90,7 +90,7 @@ class Retention:
|
||||
)
|
||||
|
||||
def sweep(self):
|
||||
if time.monotonic() - self.checked < 3600:
|
||||
if self.checked and time.monotonic() - self.checked < 3600:
|
||||
return
|
||||
self.checked = time.monotonic()
|
||||
with self.service.lock:
|
||||
|
||||
@@ -6,11 +6,14 @@ from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from pigway_frame_flow.display.cloud_library import CloudLibrary
|
||||
from pigway_frame_flow.display.recovery import RecoveryMonitor
|
||||
from pigway_frame_flow.display.retention import Retention
|
||||
from pigway_frame_flow.display.upload_reconciliation import UploadReconciliation
|
||||
from pigway_frame_flow.drivers.base import DriverRegistry
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.models import (
|
||||
AutoCollageContent,
|
||||
DisplayRequest,
|
||||
FutureContent,
|
||||
ImageContent,
|
||||
@@ -48,7 +51,10 @@ class DisplayService:
|
||||
self.lock = threading.RLock()
|
||||
self.retention = Retention(self)
|
||||
self.cloud_library = CloudLibrary(self)
|
||||
self.recovery = RecoveryMonitor(self)
|
||||
self.upload_reconciliation = UploadReconciliation(self)
|
||||
self.expiration_checked: dict[str, float] = {}
|
||||
self.unknown_checked: dict[str, float] = {}
|
||||
# A killed write cannot be replayed safely; pre-write work can resume.
|
||||
db.execute(
|
||||
"UPDATE operations SET status='UNKNOWN', updated_at=? WHERE write_started=1 AND status IN ('UPLOADING','RENDERING')",
|
||||
@@ -81,6 +87,11 @@ class DisplayService:
|
||||
) from None
|
||||
for region in plan.regions:
|
||||
self.assets.path(region.asset_id)
|
||||
if (
|
||||
isinstance(request.content, AutoCollageContent)
|
||||
and request.content.source_id
|
||||
):
|
||||
self.photo_sources.get(request.content.source_id)
|
||||
if isinstance(request.content, PhotoSourceContent):
|
||||
self.photo_sources.get(request.content.source_id)
|
||||
if writing and not self.settings.mock and not self.settings.write_enabled:
|
||||
@@ -156,14 +167,16 @@ class DisplayService:
|
||||
request_id=op["request_id"],
|
||||
client_id=op["client_id"],
|
||||
action=op["action"],
|
||||
recovery=self.recovery.info(id),
|
||||
)
|
||||
|
||||
def update(self, id, status, result=None, error=None):
|
||||
if result:
|
||||
row = self.db.one("SELECT result FROM operations WHERE id=?", (id,))
|
||||
previous = json.loads(row["result"]) if row and row["result"] else {}
|
||||
if "photo_attempts" in previous:
|
||||
result = {**result, "photo_attempts": previous["photo_attempts"]}
|
||||
for key in ("photo_attempts", "upload_intent"):
|
||||
if key in previous:
|
||||
result = {**result, key: previous[key]}
|
||||
self.db.execute(
|
||||
"UPDATE operations SET status=?,updated_at=?,result=COALESCE(?,result),error=? WHERE id=?",
|
||||
(
|
||||
@@ -174,6 +187,8 @@ class DisplayService:
|
||||
id,
|
||||
),
|
||||
)
|
||||
if status in ("COMPLETED", "FAILED"):
|
||||
self.recovery.resolve(id)
|
||||
|
||||
def blocked(self, device_id):
|
||||
return (
|
||||
@@ -191,6 +206,12 @@ 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):
|
||||
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
|
||||
)
|
||||
if isinstance(content, ProviderContent):
|
||||
values = {**content.data, **self.providers.get(content.provider_id)}
|
||||
return self.content_asset(
|
||||
@@ -426,7 +447,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
|
||||
@@ -614,6 +635,8 @@ class DisplayService:
|
||||
raise FlowError(
|
||||
E.CONFLICT, "Temporary override requires an existing image", 409
|
||||
)
|
||||
if asset:
|
||||
self.upload_reconciliation.prepare(id, driver, asset, previous)
|
||||
self._start_write(id)
|
||||
if asset:
|
||||
self.update(id, "UPLOADING")
|
||||
@@ -677,6 +700,63 @@ class DisplayService:
|
||||
)
|
||||
|
||||
def sync(self):
|
||||
with self.lock:
|
||||
self._sync()
|
||||
|
||||
def _sync(self):
|
||||
# Late acknowledgements only resolve a known display target. Never replay
|
||||
# writes or infer success from gallery presence alone.
|
||||
for op in self.db.all(
|
||||
"SELECT * FROM operations WHERE status='UNKNOWN' AND action!='render-ai' AND write_started=1"
|
||||
):
|
||||
expected = json.loads(op["result"] or "{}").get("image_id")
|
||||
intent = json.loads(op["result"] or "{}").get("upload_intent")
|
||||
checked = time.monotonic()
|
||||
if checked - self.unknown_checked.get(op["id"], float("-inf")) < 60:
|
||||
continue
|
||||
self.unknown_checked[op["id"]] = checked
|
||||
if op["action"] != "display" or (not expected and not intent):
|
||||
self.recovery.observe(
|
||||
op,
|
||||
"missing_upload_evidence"
|
||||
if op["action"] == "display"
|
||||
else "manual_reconciliation_required",
|
||||
)
|
||||
continue
|
||||
try:
|
||||
driver = self.registry.get(op["device_id"])
|
||||
device = driver.device(refresh=True)
|
||||
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.cloud_library.confirm(op["id"])
|
||||
self.db.history(op, "COMPLETED")
|
||||
self.db.event("operation.completed", {"operation_id": op["id"]})
|
||||
self.unknown_checked.pop(op["id"], None)
|
||||
elif expected:
|
||||
self.recovery.observe(
|
||||
op,
|
||||
"waiting_device_sync"
|
||||
if device.current_content.id == expected
|
||||
else "target_mismatch",
|
||||
)
|
||||
except FlowError:
|
||||
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()
|
||||
@@ -809,9 +889,25 @@ class DisplayService:
|
||||
self.update(
|
||||
id,
|
||||
"COMPLETED" if confirmed else "FAILED",
|
||||
{"image_id": device.current_content.id},
|
||||
op["result"],
|
||||
error=None
|
||||
if confirmed
|
||||
else {
|
||||
"code": E.DISPLAY_NOT_CONFIRMED
|
||||
if op["action"] == "display"
|
||||
else E.WRITE_RESULT_UNKNOWN,
|
||||
"message": "Manually closed unconfirmed write; no write replayed",
|
||||
"request_id": op["request_id"],
|
||||
"details": {
|
||||
"observed_image_id": device.current_content.id,
|
||||
"previous_error": op["error"],
|
||||
},
|
||||
},
|
||||
)
|
||||
resolved = True
|
||||
if confirmed and op["action"] == "display":
|
||||
self.cloud_library.confirm(id)
|
||||
self.db.event("operation.completed", {"operation_id": id})
|
||||
self.db.history(
|
||||
dict(
|
||||
id=id,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Resolve missing upload responses using read-only, exact-content evidence."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode
|
||||
from pigway_frame_flow.storage.database import encode, now_iso
|
||||
|
||||
|
||||
class UploadReconciliation:
|
||||
def __init__(self, service):
|
||||
self.service = service
|
||||
|
||||
def prepare(self, operation_id, driver, asset, previous):
|
||||
path = self.service.assets.path(asset["asset_id"])
|
||||
self.service.update(
|
||||
operation_id,
|
||||
"RENDERING",
|
||||
{
|
||||
"rendered_asset_id": asset["asset_id"],
|
||||
"upload_intent": {
|
||||
"name": asset["name"],
|
||||
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
"size": path.stat().st_size,
|
||||
"before_ids": [image.image_id for image in driver.images()],
|
||||
"previous_image_id": previous,
|
||||
"started_at": now_iso(),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def check(self, op, driver, device):
|
||||
service = self.service
|
||||
result = json.loads(op["result"] or "{}")
|
||||
intent = result.get("upload_intent")
|
||||
if not intent:
|
||||
return None
|
||||
expected = result.get("image_id")
|
||||
if not expected:
|
||||
start = datetime.fromisoformat(intent["started_at"])
|
||||
candidates = []
|
||||
for image in driver.images():
|
||||
if (
|
||||
image.image_id in intent["before_ids"]
|
||||
or image.name != intent["name"]
|
||||
):
|
||||
continue
|
||||
if image.size_bytes is not None and image.size_bytes != intent["size"]:
|
||||
continue
|
||||
if not image.created_at:
|
||||
continue
|
||||
try:
|
||||
created = datetime.fromisoformat(image.created_at)
|
||||
age = (created - start).total_seconds()
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if 0 <= age <= service.settings.sync_timeout + 300:
|
||||
candidates.append(image)
|
||||
# Ambiguous matches never release a write lock.
|
||||
if len(candidates) != 1:
|
||||
service.recovery.observe(
|
||||
op,
|
||||
"gallery_match_missing"
|
||||
if not candidates
|
||||
else "gallery_match_ambiguous",
|
||||
)
|
||||
return None
|
||||
expected = candidates[0].image_id
|
||||
data = driver.download(expected)
|
||||
if (
|
||||
len(data) != intent["size"]
|
||||
or hashlib.sha256(data).hexdigest() != intent["sha256"]
|
||||
):
|
||||
service.recovery.observe(op, "content_mismatch")
|
||||
return None
|
||||
result["image_id"] = expected
|
||||
result["upload_confirmed"] = True
|
||||
service.update(op["id"], "UNKNOWN", result, json.loads(op["error"] or "{}"))
|
||||
service.assets.promote(result["rendered_asset_id"])
|
||||
source = service.db.one(
|
||||
"SELECT payload FROM asset_sources WHERE asset_id=?",
|
||||
(result["rendered_asset_id"],),
|
||||
)
|
||||
service.db.execute(
|
||||
"INSERT OR REPLACE INTO image_sources VALUES(?,?,?,?,?)",
|
||||
(
|
||||
op["device_id"],
|
||||
expected,
|
||||
result["rendered_asset_id"],
|
||||
op["id"],
|
||||
source["payload"] if source else encode({"type": "image"}),
|
||||
),
|
||||
)
|
||||
if not service.db.one(
|
||||
"SELECT image_id FROM managed_cloud_uploads WHERE operation_id=?",
|
||||
(op["id"],),
|
||||
):
|
||||
service.cloud_library.register(
|
||||
op["device_id"], expected, result["rendered_asset_id"], op["id"]
|
||||
)
|
||||
if device.current_content.id == expected and device.current_content.synced:
|
||||
options = json.loads(op["payload"]).get("options", {})
|
||||
service.db.execute(
|
||||
"UPDATE device_state SET managed_image_id=? WHERE device_id=?",
|
||||
(expected, op["device_id"]),
|
||||
)
|
||||
if options.get("restore_previous"):
|
||||
expires = datetime.fromisoformat(intent["started_at"]) + timedelta(
|
||||
seconds=options["duration"]
|
||||
)
|
||||
service.db.execute(
|
||||
"INSERT OR IGNORE INTO overrides VALUES(?,?,?,?,?,?)",
|
||||
(
|
||||
op["id"],
|
||||
op["device_id"],
|
||||
expected,
|
||||
options["priority"],
|
||||
expires.isoformat(),
|
||||
intent["started_at"],
|
||||
),
|
||||
)
|
||||
return expected
|
||||
elapsed = (
|
||||
datetime.now(UTC) - datetime.fromisoformat(intent["started_at"])
|
||||
).total_seconds()
|
||||
if (
|
||||
result.get("upload_confirmed")
|
||||
and not json.loads(op["payload"]).get("options", {}).get("restore_previous")
|
||||
and elapsed > service.settings.sync_timeout + 300
|
||||
and device.current_content.id == intent["previous_image_id"]
|
||||
and device.current_content.synced
|
||||
):
|
||||
# Upload is proven; display is not. Two separated stable observations
|
||||
# close the failed display, without replaying either vendor write.
|
||||
observed = result.get("unchanged_observed_at")
|
||||
if not observed:
|
||||
result["unchanged_observed_at"] = now_iso()
|
||||
service.update(
|
||||
op["id"], "UNKNOWN", result, json.loads(op["error"] or "{}")
|
||||
)
|
||||
elif (
|
||||
datetime.now(UTC) - datetime.fromisoformat(observed)
|
||||
).total_seconds() >= 60:
|
||||
service.update(
|
||||
op["id"],
|
||||
"FAILED",
|
||||
result,
|
||||
{
|
||||
"code": ErrorCode.DISPLAY_NOT_CONFIRMED,
|
||||
"message": "Upload confirmed; frame remained on previous image",
|
||||
"request_id": op["request_id"],
|
||||
"details": {},
|
||||
},
|
||||
)
|
||||
service.db.history(op, "DISPLAY_NOT_CONFIRMED")
|
||||
service.db.event(
|
||||
"operation.failed",
|
||||
{"operation_id": op["id"], "code": ErrorCode.DISPLAY_NOT_CONFIRMED},
|
||||
)
|
||||
elif result.get("unchanged_observed_at"):
|
||||
result.pop("unchanged_observed_at")
|
||||
service.update(op["id"], "UNKNOWN", result, json.loads(op["error"] or "{}"))
|
||||
return expected
|
||||
@@ -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
|
||||
@@ -19,28 +19,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 +108,140 @@ 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",
|
||||
)
|
||||
not_sent = isinstance(exc, requests.ConnectTimeout)
|
||||
uncertain = write and not not_sent
|
||||
raise FlowError(
|
||||
E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE,
|
||||
E.WRITE_RESULT_UNKNOWN if uncertain else E.CLOUD_UNAVAILABLE,
|
||||
"Cloud write outcome is unknown"
|
||||
if write
|
||||
if uncertain
|
||||
else "Device cloud is unavailable",
|
||||
502,
|
||||
details={
|
||||
"transport_error": category,
|
||||
"retryable": (not write and category != "tls_error") or not_sent,
|
||||
"write_outcome": "unknown"
|
||||
if uncertain
|
||||
else ("not_sent" if write else "read_only"),
|
||||
},
|
||||
) from None
|
||||
if response.status_code != 200:
|
||||
code = (
|
||||
E.RATE_LIMITED
|
||||
if response.status_code == 429
|
||||
else (
|
||||
E.WRITE_RESULT_UNKNOWN
|
||||
if write and response.status_code >= 500
|
||||
else E.CLOUD_UNAVAILABLE
|
||||
try:
|
||||
value = None
|
||||
if not binary or response.status_code != 200:
|
||||
try:
|
||||
value = response.json()
|
||||
except ValueError:
|
||||
pass
|
||||
message = value.get("message") if isinstance(value, dict) else None
|
||||
status = response.status_code
|
||||
# Only exact documented business failures establish rejection. Generic
|
||||
# 5xx responses may arrive after a write; those remain UNKNOWN.
|
||||
business = None
|
||||
if status in (404, 500) and isinstance(message, str):
|
||||
if message == "image not found":
|
||||
business = (
|
||||
E.NOT_FOUND,
|
||||
"Cloud image not found",
|
||||
404,
|
||||
"image_not_found",
|
||||
)
|
||||
elif status == 500 and message == "imageId is empty":
|
||||
business = (
|
||||
E.INVALID_REQUEST,
|
||||
"Cloud image ID is invalid",
|
||||
400,
|
||||
"invalid_image_id",
|
||||
)
|
||||
elif status == 500 and message.startswith("can not find robot "):
|
||||
business = (
|
||||
E.CONFIG_REQUIRED,
|
||||
"Device key does not identify a frame",
|
||||
503,
|
||||
"device_key_invalid",
|
||||
)
|
||||
elif status == 404 and message.startswith("can not find method "):
|
||||
business = (
|
||||
E.UNSUPPORTED_CAPABILITY,
|
||||
"Cloud method not found",
|
||||
422,
|
||||
"method_not_found",
|
||||
)
|
||||
if business:
|
||||
code, safe_message, local_status, kind = business
|
||||
raise FlowError(
|
||||
code,
|
||||
safe_message,
|
||||
local_status,
|
||||
details={
|
||||
"http_status": status,
|
||||
"failure_kind": kind,
|
||||
"retryable": False,
|
||||
"write_outcome": "rejected" if write else "read_only",
|
||||
},
|
||||
)
|
||||
if status != 200:
|
||||
limited = status == 429
|
||||
rejected = 400 <= status < 500
|
||||
uncertain = write and not rejected
|
||||
details = {
|
||||
"http_status": status,
|
||||
"retryable": limited or (not write and status >= 500),
|
||||
"write_outcome": "unknown"
|
||||
if uncertain
|
||||
else ("rejected" if write else "read_only"),
|
||||
}
|
||||
if limited:
|
||||
delay = self._retry_after(response.headers.get("Retry-After"))
|
||||
details["retry_after_seconds"] = delay
|
||||
with self.lock:
|
||||
self.cooldown_until = max(
|
||||
self.cooldown_until, time.monotonic() + delay
|
||||
)
|
||||
raise FlowError(
|
||||
E.RATE_LIMITED
|
||||
if limited
|
||||
else (E.WRITE_RESULT_UNKNOWN if uncertain else E.CLOUD_UNAVAILABLE),
|
||||
"Device cloud request failed",
|
||||
429 if code == E.RATE_LIMITED else 502,
|
||||
429 if limited else 502,
|
||||
details=details,
|
||||
)
|
||||
if binary:
|
||||
if len(response.content) > 20 * 1024 * 1024:
|
||||
raise FlowError(E.INVALID_REQUEST, "Cloud image exceeds size limit")
|
||||
return response.content
|
||||
try:
|
||||
value = response.json()
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError()
|
||||
# Cloud errors may echo credentials: never return raw messages.
|
||||
if value.get("success") is False or (
|
||||
"message" in value and "id" not in value
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("success") is False
|
||||
or ("message" in value and "id" not in value)
|
||||
):
|
||||
raise ValueError()
|
||||
return value
|
||||
except ValueError:
|
||||
raise FlowError(
|
||||
E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE,
|
||||
"Invalid device cloud response",
|
||||
502,
|
||||
) from None
|
||||
details={
|
||||
"failure_kind": "invalid_response",
|
||||
"retryable": not write,
|
||||
"write_outcome": "unknown" if write else "read_only",
|
||||
},
|
||||
)
|
||||
return value
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
|
||||
class KokonnaClient:
|
||||
@@ -118,7 +266,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"]
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ class ErrorCode(StrEnum):
|
||||
UNSUPPORTED_CAPABILITY = "UNSUPPORTED_CAPABILITY"
|
||||
RENDER_FAILED = "RENDER_FAILED"
|
||||
UPLOAD_FAILED = "UPLOAD_FAILED"
|
||||
DISPLAY_NOT_CONFIRMED = "DISPLAY_NOT_CONFIRMED"
|
||||
WRITE_RESULT_UNKNOWN = "WRITE_RESULT_UNKNOWN"
|
||||
RATE_LIMITED = "RATE_LIMITED"
|
||||
INTERNAL_ERROR = "INTERNAL_ERROR"
|
||||
@@ -22,6 +23,7 @@ class ErrorCode(StrEnum):
|
||||
|
||||
|
||||
class FlowError(Exception):
|
||||
def __init__(self, code: ErrorCode, message: str, status: int = 400):
|
||||
def __init__(self, code: ErrorCode, message: str, status: int = 400, details=None):
|
||||
self.code, self.message, self.status = code, message, status
|
||||
self.details = details or {}
|
||||
super().__init__(message)
|
||||
|
||||
@@ -146,6 +146,7 @@ def install_ai_images(app, service):
|
||||
}
|
||||
).encode(),
|
||||
limit=service.settings.max_bytes,
|
||||
timeout=180,
|
||||
)
|
||||
result = json.loads(raw)["data"]
|
||||
if len(result) != 1:
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Random selection and layout shared by preview, display and scheduled jobs."""
|
||||
|
||||
import random
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.extensions.layout import LayoutInput, LayoutStudio
|
||||
from pigway_frame_flow.models import TemplateContent
|
||||
|
||||
|
||||
def prepare(service, content, device_id=None):
|
||||
rng = random.SystemRandom()
|
||||
ids = []
|
||||
hashes = set()
|
||||
if content.source_id:
|
||||
from pigway_frame_flow.extensions.quality import passes, photo_quality
|
||||
|
||||
source = service.photo_sources.get(content.source_id)
|
||||
items = service.photo_sources.catalog(content.source_id)
|
||||
candidates = rng.sample(items, min(len(items), content.count * 5))
|
||||
for item in candidates:
|
||||
try:
|
||||
data, name, _ = service.photo_sources.read(
|
||||
content.source_id, item["id"]
|
||||
)
|
||||
quality = photo_quality(data)
|
||||
if not passes(quality, source) or quality["sha256"] in hashes:
|
||||
continue
|
||||
if source.avoid_duplicates and quality["fingerprint"] in hashes:
|
||||
continue
|
||||
hashes.update([quality["sha256"], quality["fingerprint"]])
|
||||
with Image.open(BytesIO(data)) as image:
|
||||
photo = ImageOps.exif_transpose(image).convert("RGB")
|
||||
photo.thumbnail((2400, 2400))
|
||||
output = BytesIO()
|
||||
photo.save(output, "JPEG", quality=92)
|
||||
asset = service.assets.put(output.getvalue(), name, "cache")
|
||||
if asset["asset_id"] not in ids:
|
||||
ids.append(asset["asset_id"])
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if len(ids) == content.count:
|
||||
break
|
||||
else:
|
||||
rows = service.db.all(
|
||||
"SELECT id AS asset_id FROM assets WHERE kind='images' ORDER BY created_at DESC LIMIT 10000"
|
||||
)
|
||||
ids = rng.sample(
|
||||
[row["asset_id"] for row in rows], min(content.count, len(rows))
|
||||
)
|
||||
if len(ids) != content.count:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "Not enough distinct photos for requested count", 422
|
||||
)
|
||||
values = content.model_dump(exclude={"type", "source_id", "count", "profile"})
|
||||
if content.orientation == "auto":
|
||||
from pigway_frame_flow.renderers.frame_geometry import frame_canvas
|
||||
|
||||
display = (
|
||||
service.registry.get(device_id or service.settings.device_id)
|
||||
.device()
|
||||
.display
|
||||
)
|
||||
width, height = frame_canvas(display)
|
||||
values["orientation"] = "landscape" if width > height else "portrait"
|
||||
plan = LayoutStudio(service.renderer).plan(
|
||||
LayoutInput(
|
||||
asset_ids=ids,
|
||||
**values,
|
||||
)
|
||||
)
|
||||
return TemplateContent(
|
||||
type="template",
|
||||
template="poster" if content.style == "poster" else "collage",
|
||||
data={"layout": plan.model_dump()},
|
||||
profile=content.profile,
|
||||
)
|
||||
@@ -86,7 +86,7 @@ class LocalRetention:
|
||||
}
|
||||
|
||||
def sweep(self, force=False, dry_run=False):
|
||||
if not force and time.monotonic() - self.last < 3600:
|
||||
if not force and self.last and time.monotonic() - self.last < 3600:
|
||||
return {}
|
||||
if not dry_run:
|
||||
self.last = time.monotonic()
|
||||
|
||||
@@ -39,12 +39,13 @@ class Webhooks:
|
||||
db = self.service.db
|
||||
for sub in db.all("SELECT * FROM webhook_subscriptions WHERE enabled=1"):
|
||||
types = json.loads(sub["event_types"])
|
||||
for event in db.all(
|
||||
"SELECT * FROM events e WHERE created_at>=? AND NOT EXISTS (SELECT 1 FROM webhook_deliveries d WHERE d.event_id=e.id AND d.subscription_id=?) ORDER BY created_at LIMIT 500",
|
||||
(sub["created_at"], sub["id"]),
|
||||
):
|
||||
if event["event_type"] not in types:
|
||||
if not types:
|
||||
continue
|
||||
placeholders = ",".join("?" for _ in types)
|
||||
for event in db.all(
|
||||
f"SELECT * FROM events e WHERE created_at>=? AND event_type IN ({placeholders}) AND NOT EXISTS (SELECT 1 FROM webhook_deliveries d WHERE d.event_id=e.id AND d.subscription_id=?) ORDER BY created_at LIMIT 500",
|
||||
(sub["created_at"], *types, sub["id"]),
|
||||
):
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO webhook_deliveries VALUES(?,?,'QUEUED',0,?,NULL)",
|
||||
(sub["id"], event["id"], now_iso()),
|
||||
@@ -137,6 +138,7 @@ def install_webhooks(app, service):
|
||||
allowed = {
|
||||
"operation.completed",
|
||||
"operation.failed",
|
||||
"operation.blocked",
|
||||
"display.changed",
|
||||
"device.online",
|
||||
"device.offline",
|
||||
|
||||
@@ -68,6 +68,19 @@ class PhotoSourceContent(StrictModel):
|
||||
profile: Profile = Profile.color
|
||||
|
||||
|
||||
class AutoCollageContent(StrictModel):
|
||||
type: Literal["auto_collage"]
|
||||
source_id: str | None = Field(default=None, pattern=r"^[a-z0-9-]{1,60}$")
|
||||
count: int = Field(default=4, ge=1, le=12)
|
||||
title: str = Field(default="", max_length=100)
|
||||
message: str = Field(default="", max_length=500)
|
||||
orientation: Literal["auto", "portrait", "landscape"] = "auto"
|
||||
style: Literal["grid", "hero", "poster"] = "grid"
|
||||
use_ai: bool = True
|
||||
prompt: str = Field(default="", max_length=1500)
|
||||
profile: Profile = Profile.color
|
||||
|
||||
|
||||
class ProviderContent(StrictModel):
|
||||
type: Literal["provider"]
|
||||
provider_id: str = Field(pattern=r"^[a-z0-9-]{1,60}$")
|
||||
@@ -86,6 +99,7 @@ Content = Annotated[
|
||||
| URLContent
|
||||
| TemplateContent
|
||||
| PhotoSourceContent
|
||||
| AutoCollageContent
|
||||
| ProviderContent
|
||||
| FutureContent,
|
||||
Field(discriminator="type"),
|
||||
@@ -163,7 +177,17 @@ class OperationResult(BaseModel):
|
||||
deferred: bool = False
|
||||
|
||||
|
||||
class RecoveryState(BaseModel):
|
||||
state: Literal["checking", "manual_review", "resolved"]
|
||||
reason: str
|
||||
first_checked_at: str
|
||||
last_checked_at: str
|
||||
checks: int
|
||||
requires_manual_review: bool
|
||||
|
||||
|
||||
class Operation(BaseModel):
|
||||
recovery: RecoveryState | None = None
|
||||
operation_id: str
|
||||
status: OperationStatus
|
||||
device_id: str
|
||||
@@ -322,7 +346,7 @@ class SettingsView(BaseModel):
|
||||
|
||||
class Health(BaseModel):
|
||||
status: str
|
||||
version: str = "0.1.0"
|
||||
version: str = "0.1.4"
|
||||
|
||||
|
||||
class Readiness(BaseModel):
|
||||
@@ -352,7 +376,7 @@ class RestoreRequest(StrictModel):
|
||||
|
||||
class Version(BaseModel):
|
||||
product: str = "Pigway Frame Flow"
|
||||
version: str = "0.1.0"
|
||||
version: str = "0.1.4"
|
||||
api_version: str = "v1"
|
||||
commit: str = "development"
|
||||
build_time: str = "unknown"
|
||||
|
||||
@@ -97,7 +97,7 @@ class Assets:
|
||||
"task"
|
||||
if job
|
||||
else "template"
|
||||
if content.get("type") == "template"
|
||||
if content.get("type") in ("template", "auto_collage")
|
||||
else "manual"
|
||||
if operation["client_id"].startswith("user:")
|
||||
else "external"
|
||||
@@ -161,6 +161,7 @@ class Assets:
|
||||
body=None,
|
||||
limit=None,
|
||||
accepted_status=(200,),
|
||||
timeout=10,
|
||||
):
|
||||
limit = limit or self.settings.max_bytes
|
||||
parsed = urlsplit(url)
|
||||
@@ -193,12 +194,12 @@ class Assets:
|
||||
|
||||
class PinnedHTTPS(http.client.HTTPSConnection):
|
||||
def connect(self):
|
||||
sock = socket.create_connection((ip, 443), timeout=10)
|
||||
sock = socket.create_connection((ip, 443), timeout=timeout)
|
||||
self.sock = ssl.create_default_context().wrap_socket(
|
||||
sock, server_hostname=parsed.hostname
|
||||
)
|
||||
|
||||
connection = PinnedHTTPS(parsed.hostname, timeout=10)
|
||||
connection = PinnedHTTPS(parsed.hostname, timeout=timeout)
|
||||
try:
|
||||
connection.request(
|
||||
method,
|
||||
|
||||
@@ -147,6 +147,7 @@ class Scheduler:
|
||||
"SELECT * FROM jobs WHERE enabled=1 AND next_run<=?",
|
||||
(current.isoformat(),),
|
||||
):
|
||||
try:
|
||||
job = JobInput.model_validate_json(row["payload"])
|
||||
slot = row["next_run"]
|
||||
if self.db.one(
|
||||
@@ -156,10 +157,14 @@ class Scheduler:
|
||||
continue
|
||||
# Skip overdue tasks (>5 minutes): avoid an unexpected artwork
|
||||
# overwrite after downtime. One-time tasks are retired too.
|
||||
late = (current - datetime.fromisoformat(slot)).total_seconds() > 300
|
||||
late = (
|
||||
current - datetime.fromisoformat(slot)
|
||||
).total_seconds() > 300
|
||||
operation_id = None
|
||||
if not late:
|
||||
action = "restore" if job.source.type == "restore" else "display"
|
||||
action = (
|
||||
"restore" if job.source.type == "restore" else "display"
|
||||
)
|
||||
payload = (
|
||||
{}
|
||||
if isinstance(job.source, RestoreContent)
|
||||
@@ -203,6 +208,12 @@ class Scheduler:
|
||||
row["id"],
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
# Keep this slot pending for retry or the existing overdue policy.
|
||||
# A single device must not prevent other jobs or queue consumption.
|
||||
logging.getLogger("frame-flow").error(
|
||||
"Scheduled job failed; details suppressed"
|
||||
)
|
||||
for row in self.db.all(
|
||||
"SELECT * FROM playlists WHERE next_run<=?", (current.isoformat(),)
|
||||
):
|
||||
@@ -269,6 +280,9 @@ class Scheduler:
|
||||
def run(self):
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
# Resolve late sync before submitting due jobs: a write lock
|
||||
# otherwise makes tick fail before reconciliation can run.
|
||||
self.service.sync()
|
||||
self.tick()
|
||||
self.service.expire()
|
||||
self.service.cloud_library.sweep()
|
||||
|
||||
@@ -31,6 +31,7 @@ class Database:
|
||||
CREATE TABLE IF NOT EXISTS image_sources (device_id TEXT, image_id INTEGER, asset_id TEXT, operation_id TEXT, payload TEXT NOT NULL, PRIMARY KEY(device_id,image_id));
|
||||
CREATE TABLE IF NOT EXISTS app_config (key TEXT PRIMARY KEY, payload TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS playlists (id TEXT PRIMARY KEY, payload TEXT, position INTEGER, next_run TEXT);
|
||||
CREATE TABLE IF NOT EXISTS operation_recovery (operation_id TEXT PRIMARY KEY, state TEXT NOT NULL, reason TEXT NOT NULL, first_checked_at TEXT NOT NULL, last_checked_at TEXT NOT NULL, checks INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS operations (
|
||||
id TEXT PRIMARY KEY, status TEXT NOT NULL, action TEXT NOT NULL,
|
||||
payload TEXT NOT NULL, device_id TEXT NOT NULL, client_id TEXT NOT NULL,
|
||||
|
||||
@@ -3,7 +3,7 @@ const $=s=>document.querySelector(s);
|
||||
const state={lang:localStorage.getItem('ff-language')||'zh',theme:localStorage.getItem('ff-theme')||'system',page:location.hash.slice(1)||'dashboard',token:'',csrf:'',device:'living-room',settings:null,devices:[],templates:[],gallery:[],assets:[],blobs:[],tab:'cloud',preview:null,templatePreview:null,selectedTemplate:null,epoch:0};
|
||||
function forgetLogin(){localStorage.removeItem('ff-login');state.token='';state.csrf='';state.settings=null;state.devices=[];state.templates=[];}
|
||||
async function loadConnection(){state.settings=await api('/api/v1/settings');try{state.devices=await api('/api/v1/devices');}catch(error){state.devices=[];toast(error.message);}state.templates=await api('/api/v1/templates');state.device=state.devices[0]?.id||state.device;}
|
||||
const icons={studio:'▦',connections:'↔',dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',automation:'◷',playlists:'▤',history:'↺',settings:'⚙'};
|
||||
const icons={dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'};
|
||||
const t=k=>window.MESSAGES[state.lang][k]||k;
|
||||
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'}):'—';
|
||||
|
||||
@@ -3,6 +3,7 @@ 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>`;
|
||||
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();};
|
||||
@@ -47,7 +48,7 @@ async function officialWidgetControls(info,data){
|
||||
const send=cover_id=>confirmDialog(tr('此操作会改变官方订阅和画框内容,是否继续?','This changes the official subscription and frame content. Continue?'),async()=>{const args=cover_id?Object.fromEntries([...document.querySelectorAll('[data-widget-arg]')].filter(x=>x.value).map(x=>[x.dataset.widgetArg,x.value])):{};const r=await api('/api/v1/devices/'+state.device+'/widgets/selection',{method:'POST',body:{cover_id,args},headers:{'Idempotency-Key':'widget-'+clientUUID()}});await waitOperation(r.operation_id);await navigate('templates');});if(!disabled){$('#official-widget-use').onclick=busy(()=>send(Number($('#official-widget-choice').value)));$('#official-widget-cancel').onclick=busy(()=>send(null));}
|
||||
}
|
||||
function addPhotoQualityFields(source){
|
||||
const box=document.createElement('section');box.innerHTML=`<h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${tr('按小图清晰度、亮度和相似度筛选,不改动或删除 NAS 原照片;无法保证识别闭眼。建议先用低阈值。个人空间还需要将相应目录只读挂载到 /photos。','Filters thumbnail sharpness, brightness and similarity without modifying or deleting originals. Closed-eye detection is not guaranteed. Start with low thresholds. Personal space also requires mounting its folder read-only at /photos.')}</p><div class="field"><label>${tr('人物空间','People space')}</label><select id="source-people-space"><option value="shared">${tr('共享空间','Shared space')}</option><option value="personal">${tr('个人空间','Personal space')}</option></select></div><div class="form-grid"><div class="field"><label>${tr('最低清晰度(0 不筛选)','Minimum sharpness (0 disables)')}</label><input id="source-sharpness" type="number" min="0" max="10000" value="${source?.minimum_sharpness||0}"></div><div class="field"><label>${tr('最低亮度','Minimum brightness')}</label><input id="source-bright-min" type="number" min="0" max="255" value="${source?.minimum_brightness||0}"></div><div class="field"><label>${tr('最高亮度','Maximum brightness')}</label><input id="source-bright-max" type="number" min="0" max="255" value="${source?.maximum_brightness??255}"></div></div><label class="check-row"><input id="source-dedup" type="checkbox" ${source?.avoid_duplicates?'checked':''}>${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}</label><div class="field"><label>${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></textarea></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
|
||||
const box=document.createElement('section');box.innerHTML=`<div id="source-local-quality"><h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${tr('按小图清晰度、亮度和相似度筛选,不改动或删除 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';
|
||||
|
||||
}
|
||||
async function aiImagePanel(){
|
||||
@@ -56,3 +57,15 @@ async function aiImagePanel(){
|
||||
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'));});
|
||||
}
|
||||
|
||||
async function autoCollagePanel(){
|
||||
const sources=await api('/api/v1/photo-sources');const card=document.createElement('section');card.className='card auto-collage';
|
||||
card.innerHTML=`<h2>${tr('自动拼图','Automatic collage')}</h2><p class="hint">${tr('选择照片来源和数量,系统每次随机选取不同照片,并自动排版生成一张图片。无需手动拼接。预览不会上传或切换画框。','Choose a photo source and count. Each run randomly selects distinct photos and automatically creates one composition. Preview never uploads to or changes the frame.')}</p><form id="auto-collage-form"><div class="form-grid"><div class="field"><label for="collage-source">${tr('照片来源','Photo source')}</label><select id="collage-source"><option value="">${tr('本地手动上传照片','Locally uploaded photos')}</option>${sources.filter(x=>x.enabled).map(x=>`<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label for="collage-count">${tr('拼图数量(1–12)','Photo count (1–12)')}</label><input id="collage-count" type="number" min="1" max="12" value="4" required></div></div><div class="field"><label for="collage-title">${tr('标题(可选)','Title (optional)')}</label><input id="collage-title" maxlength="100"></div><div class="field"><label for="collage-prompt">${tr('排版要求(可选)','Layout instructions (optional)')}</label><textarea id="collage-prompt" maxlength="1500"></textarea></div><label class="check-row"><input id="collage-ai" type="checkbox" checked>${tr('AI 自动排版','AI layout')}</label><p class="hint">${tr('AI 需先在连接中心配置,只接收尺寸与文字,不发送照片。未配置时可取消勾选,使用自动网格。','Configure AI in Connections first. Only dimensions and text are sent, never photos. Uncheck AI to use an automatic grid.')}</p><div class="actions"><button type="submit" class="primary">${tr('随机选图并生成预览','Pick photos and create preview')}</button><button id="collage-job" type="button">${tr('创建自动化任务','Create automation')}</button></div></form><div id="collage-preview"></div><div class="actions"><button id="collage-display" hidden>${t('display')}</button></div>`;
|
||||
$('#main').prepend(card);if(sources.some(x=>x.enabled))$('#collage-source').value=sources.find(x=>x.enabled).id;let asset=null;
|
||||
$('#auto-collage-form').oninput=()=>{asset=null;$('#collage-display').hidden=true;};
|
||||
const content=()=>({type:'auto_collage',source_id:$('#collage-source').value||null,count:Number($('#collage-count').value),title:$('#collage-title').value,prompt:$('#collage-prompt').value,use_ai:$('#collage-ai').checked});
|
||||
$('#auto-collage-form').onsubmit=busy(async e=>{e.preventDefault();asset=null;$('#collage-display').hidden=true;$('#collage-preview').textContent=t('previewLoading');try{const id=await accepted('/api/v1/render',content());const op=await waitOperation(id);asset=op.result;$('#collage-preview').innerHTML='<img class="preview" id="collage-image" alt="Collage">';await blobImage($('#collage-image'),asset.url);$('#collage-display').hidden=false;$('#collage-display').disabled=!liveWriteAllowed();}catch(error){$('#collage-preview').textContent=error.message;}});
|
||||
bind('collage-display',()=>displayDialog({type:'image',asset_id:asset.asset_id}));
|
||||
bind('collage-job',async()=>{if(!$('#auto-collage-form').reportValidity())return;await api('/api/v1/jobs',{method:'POST',body:{device_id:state.device,name:$('#collage-title').value||tr('自动随机拼图','Automatic random collage'),enabled:false,schedule:{type:'daily',time:'08:00'},source:content(),display_duration:0,restore_previous:false,priority:50}});await navigate('automation');toast(tr('任务已创建并暂停;编辑时间后启用。每次执行重新随机选图。','Created paused. Edit the schedule, then enable. Each run picks new photos.'));});
|
||||
const manual=document.createElement('details');manual.className='card manual-collage';manual.innerHTML=`<summary>${tr('高级:手动选图与调整布局','Advanced: manual photos and layout')}</summary>`;const columns=$('#main>.columns');columns.before(manual);manual.append(columns);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use strict';
|
||||
// Page features share the same authenticated API and display operation pipeline.
|
||||
async function currentPanel(container){
|
||||
const epoch=state.epoch;
|
||||
const content=await api('/api/v1/devices/'+state.device+'/current-content');
|
||||
if(epoch!==state.epoch||!container.isConnected)return content;
|
||||
state.currentDisplay=content;
|
||||
const known=content.source_known&&content.source?.type==='template';
|
||||
container.innerHTML=`<h3>${t('currentContent')}</h3><p>#${esc(content.image_id??'—')} · ${t(content.synced?'synced':'syncing')}</p><p>${known?`${t('currentTemplate')}: <strong>${esc(content.source.template)}</strong>`:content.source_known?t('sourceKnown'):t('sourceUnknown')}</p>${known?buttons('edit-current-template','editCurrent'):''}${content.overrides.length?`<div class="notice"><strong>${t('temporaryDisplay')}</strong>${content.overrides.map(x=>`<p>#${esc(x.image_id)} · ${t('priority')}: ${x.priority} · ${fmt(x.expires_at)}</p>`).join('')}</div>`:''}`;
|
||||
@@ -44,16 +46,17 @@ async function weatherSettings(){
|
||||
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);});});}
|
||||
async function enhanceAutomation(){
|
||||
const jobs=await api('/api/v1/jobs');const rows=[...document.querySelectorAll('tbody tr')];rows.forEach((row,i)=>{const job=jobs[i];if(!job)return;const actions=row.querySelector('.row-actions');const run=document.createElement('button');run.textContent=t('runNow');run.disabled=!liveWriteAllowed();run.title=run.disabled?t('readOnlyWarning'):'';actions.append(run);run.onclick=()=>confirmDialog(t('runConfirm'),async()=>{await accepted('/api/v1/jobs/'+job.id+'/run',{});await navigate('history');});if(job.source.type==='template'){const preview=document.createElement('button');preview.textContent=t('jobPreview');actions.append(preview);preview.onclick=busy(async()=>{const id=await accepted('/api/v1/jobs/'+job.id+'/preview',{});const op=await waitOperation(id);showModal(`<h2>${t('jobPreview')}</h2><img id="job-preview-image" class="preview" alt="Preview"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#job-preview-image'),op.result.url);});}});
|
||||
const jobs=await api('/api/v1/jobs');const rows=[...document.querySelectorAll('tbody tr')];rows.forEach((row,i)=>{const job=jobs[i];if(!job)return;const actions=row.querySelector('.row-actions');const run=document.createElement('button');run.textContent=t('runNow');run.disabled=!liveWriteAllowed();run.title=run.disabled?t('readOnlyWarning'):'';actions.append(run);run.onclick=()=>confirmDialog(t('runConfirm'),async()=>{await accepted('/api/v1/jobs/'+job.id+'/run',{});await navigate('history');});if(['template','auto_collage'].includes(job.source.type)){const preview=document.createElement('button');preview.textContent=t('jobPreview');actions.append(preview);preview.onclick=busy(async()=>{const id=await accepted('/api/v1/jobs/'+job.id+'/preview',{});const op=await waitOperation(id);showModal(`<h2>${t('jobPreview')}</h2><img id="job-preview-image" class="preview" alt="Preview"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#job-preview-image'),op.result.url);});}});
|
||||
}
|
||||
async function jobSourceEditor(job){
|
||||
const raw=$('#job-source');const advanced=document.createElement('details');advanced.innerHTML=`<summary>${t('advanced')}</summary>`;raw.parentElement.before(advanced);advanced.append(raw.parentElement);
|
||||
const box=document.createElement('div');advanced.before(box);let source=structuredClone(job.source);let images=[],assets=[],photoSources=[];try{[images,assets,photoSources]=await Promise.all([api('/api/v1/devices/'+state.device+'/images'),api('/api/v1/assets'),api('/api/v1/photo-sources')]);}catch(e){toast(e.message);}if(!raw.isConnected)return;
|
||||
box.innerHTML=`<div class="field"><label for="job-content-type">${t('sourceType')}</label><select id="job-content-type">${['template','image','image_url','photo_source','restore'].map(k=>`<option value="${k}">${k==='template'?t('template'):k==='image'?t('imageChoice'):k==='restore'?t('restoreSource'):k==='photo_source'?t('photoSources'):t('imageURL')}</option>`).join('')}</select></div><div id="job-content-fields"></div>`;
|
||||
box.innerHTML=`<div class="field"><label for="job-content-type">${t('sourceType')}</label><select id="job-content-type">${['template','image','image_url','photo_source','auto_collage','restore'].map(k=>`<option value="${k}">${k==='auto_collage'?(state.lang==='zh'?'自动随机拼图':'Automatic collage'):k==='template'?t('template'):k==='image'?t('imageChoice'):k==='restore'?t('restoreSource'):k==='photo_source'?t('photoSources'):t('imageURL')}</option>`).join('')}</select></div><div id="job-content-fields"></div>`;
|
||||
$('#job-content-type').value=source.type;const old=$('#job-template')?.parentElement;if(old)old.remove();
|
||||
function update(){raw.value=JSON.stringify(source,null,2);}
|
||||
function fields(){const type=$('#job-content-type').value;const panel=$('#job-content-fields');if(type==='restore'){source={type};panel.innerHTML='';$('#job-restore').checked=false;$('#job-duration').value=0;update();return;}
|
||||
if(type==='template'){const selected=state.templates.find(tpl=>tpl.id===source.template)||state.templates[0];source={type,template:selected.id,data:source.type==='template'?source.data||{}:{},profile:source.profile||'color'};panel.innerHTML=`<div class="field"><label for="job-template">${t('template')}</label><select id="job-template">${state.templates.map(x=>`<option value="${x.id}" ${selected.id===x.id?'selected':''}>${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label for="job-source-profile">${t('profile')}</label><select id="job-source-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><div id="job-data-fields"></div>`;$('#job-source-profile').value=source.profile;$('#job-source-profile').onchange=()=>{source.profile=$('#job-source-profile').value;update();};const defaults={...selected.defaults,...source.data};const keys=[...new Set([...Object.keys(defaults).filter(k=>typeof defaults[k]!=='object'||Array.isArray(defaults[k])),...(['calendar','daily-dashboard'].includes(selected.id)?['events']:[])])];$('#job-data-fields').innerHTML=keys.map(k=>`<div class="field"><label for="job-field-${k}">${esc({title:state.lang==='zh'?'标题':'Title',message:state.lang==='zh'?'正文':'Message',quote:state.lang==='zh'?'一句话':'Quote',author:state.lang==='zh'?'作者':'Author',events:t('eventsHelp')}[k]||k)}</label><textarea id="job-field-${k}" data-job-field="${k}" rows="2">${esc(Array.isArray(defaults[k])?defaults[k].map(x=>typeof x==='object'?x.title:x).join('\n'):defaults[k]||'')}</textarea></div>`).join('');$('#job-template').onchange=()=>{source={type:'template',template:$('#job-template').value,data:{}};fields();};panel.querySelectorAll('[data-job-field]').forEach(el=>el.oninput=()=>{source.data[el.dataset.jobField]=el.dataset.jobField==='events'?el.value.split('\n').filter(x=>x.trim()):el.value;update();});update();}
|
||||
else if(type==='auto_collage'){source={count:4,use_ai:true,...source,type};panel.innerHTML=`<div class="field"><label for="job-collage-source">${t('photoSources')}</label><select id="job-collage-source"><option value="">${state.lang==='zh'?'本地手动上传照片':'Locally uploaded photos'}</option>${photoSources.filter(x=>x.enabled).map(x=>`<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label for="job-collage-count">${state.lang==='zh'?'拼图数量(1–12)':'Photo count (1–12)'}</label><input id="job-collage-count" type="number" min="1" max="12" required value="${source.count}"></div><div class="field"><label for="job-collage-title">${state.lang==='zh'?'标题':'Title'}</label><input id="job-collage-title" maxlength="100" value="${esc(source.title||'')}"></div><div class="field"><label for="job-collage-prompt">${state.lang==='zh'?'排版要求':'Layout instructions'}</label><textarea id="job-collage-prompt" maxlength="1500">${esc(source.prompt||'')}</textarea></div><label class="check-row"><input id="job-collage-ai" type="checkbox" ${source.use_ai?'checked':''}>${state.lang==='zh'?'AI 自动排版(需先配置连接中心)':'AI layout (configure Connections first)'}</label><p class="hint">${state.lang==='zh'?'每次执行重新随机选图;取消 AI 使用自动网格。':'Each run picks new photos; uncheck AI for an automatic grid.'}</p>`;$('#job-collage-source').value=source.source_id||'';for(const id of ['source','count','title','prompt','ai'])$('#job-collage-'+id).oninput=()=>{source={...source,source_id:$('#job-collage-source').value||null,count:Number($('#job-collage-count').value),title:$('#job-collage-title').value,prompt:$('#job-collage-prompt').value,use_ai:$('#job-collage-ai').checked};update();};update();}
|
||||
else if(type==='image'){const selected=source.image_id?'cloud:'+source.image_id:source.asset_id?'local:'+source.asset_id:'';panel.innerHTML=`<div class="field"><label for="job-image">${t('imageChoice')}</label><select id="job-image" required><option value="">—</option>${images.map(i=>`<option value="cloud:${i.image_id}">${esc(i.name)} · #${i.image_id}</option>`).join('')}${assets.map(a=>`<option value="local:${a.asset_id}">${esc(a.name)} · LOCAL</option>`).join('')}</select></div>`;$('#job-image').value=selected;$('#job-image').onchange=()=>{const [kind,id]=$('#job-image').value.split(':');source=kind==='cloud'?{type:'image',image_id:Number(id)}:{type:'image',asset_id:id};update();};}
|
||||
else if(type==='photo_source'){panel.innerHTML=`<div class="field"><label for="job-photo-source">${t('photoSources')}</label><select id="job-photo-source" required><option value="">—</option>${photoSources.map(x=>`<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select><p class="hint">${t('sourceJobHint')}</p></div>`;$('#job-photo-source').value=source.source_id||'';$('#job-photo-source').onchange=()=>{source={type:'photo_source',source_id:$('#job-photo-source').value,fit:'contain',profile:'color'};update();};}
|
||||
else{panel.innerHTML=`<div class="field"><label for="job-url">${t('imageURL')}</label><input id="job-url" type="url" placeholder="https://…" value="${esc(source.type==='image_url'?source.url:'')}" required></div>`;$('#job-url').oninput=()=>{source={type:'image_url',url:$('#job-url').value};update();};}}
|
||||
@@ -110,19 +113,34 @@ async function photosPeoplePicker(selected){
|
||||
const status=await api(path);
|
||||
if(!status.configured){target.innerHTML=`<p class="hint">${t('photosConfigure')}</p>`;return;}
|
||||
if(!status.connected){
|
||||
target.innerHTML=`<div class="field"><label for="photos-username">${t('username')}</label><input id="photos-username" autocomplete="username"></div><div class="field"><label for="photos-password">${t('password')}</label><input id="photos-password" type="password" autocomplete="current-password"></div><p class="hint">${t('photosSessionHint')}</p><button type="button" id="photos-connect">${t('photosConnect')}</button>`;
|
||||
target.innerHTML=`<div class="field"><label for="photos-username">${t('nasUsername')}</label><input id="photos-username" autocomplete="username"></div><div class="field"><label for="photos-password">${t('nasPassword')}</label><input id="photos-password" type="password" autocomplete="current-password"></div><p class="hint">${t('photosSessionHint')}</p><button type="button" id="photos-connect">${t('photosConnect')}</button>`;
|
||||
$('#photos-connect').onclick=busy(async()=>{const password=$('#photos-password').value;$('#photos-password').value='';await api(path,{method:'POST',body:{username:$('#photos-username').value,password}});await photosPeoplePicker(selected);});return;
|
||||
}
|
||||
const people=await api(path+'/people?space='+encodeURIComponent($('#source-people-space')?.value||'shared'));
|
||||
target.innerHTML=`<div class="field"><label for="people-search">${t('peopleSearch')}</label><input id="people-search" type="search"></div><label class="check-row"><input id="people-unnamed" type="checkbox">${t('peopleUnnamed')}</label><div id="people-list" style="max-height:260px;overflow:auto"></div><p id="people-selected" class="hint"></p><button type="button" id="photos-disconnect">${t('photosDisconnect')}</button>`;
|
||||
target.innerHTML=`<div class="field"><label for="people-search">${t('peopleSearch')}</label><input id="people-search" type="search"></div><label class="check-row"><input id="people-unnamed" type="checkbox">${t('peopleUnnamed')}</label><div id="people-list" style="max-height:260px;overflow:auto"></div><p id="people-selected" class="hint"></p>`;
|
||||
const render=()=>{const query=$('#people-search').value.toLowerCase();const rows=people.filter(p=>(p.named||selected.has(p.id)||$('#people-unnamed').checked)&&(p.name.toLowerCase().includes(query)||String(p.id).includes(query)));$('#people-list').innerHTML=rows.map(p=>`<label class="check-row"><input type="checkbox" data-person="${p.id}" ${selected.has(p.id)?'checked':''}>${esc(p.name)} · ${p.count}</label>`).join('');$('#people-selected').textContent=t('peopleSelected')+selected.size;$('#people-list').querySelectorAll('[data-person]').forEach(box=>box.onchange=()=>{const id=Number(box.dataset.person);box.checked?selected.add(id):selected.delete(id);$('#people-selected').textContent=t('peopleSelected')+selected.size;});};
|
||||
$('#people-search').oninput=render;$('#people-unnamed').onchange=render;$('#photos-disconnect').onclick=busy(async()=>{await api(path,{method:'DELETE'});await photosPeoplePicker(selected);});render();
|
||||
$('#people-search').oninput=render;$('#people-unnamed').onchange=render;render();
|
||||
}
|
||||
|
||||
async function photosConnectionPanel(){
|
||||
const status=await api('/api/v1/integrations/synology-photos');
|
||||
const box=$('#photos-connection');if(!box)return;
|
||||
box.innerHTML=`<div class="section-heading"><div><h3>Synology Photos · ${t('photosSharedConnection')}</h3><p class="hint">${t('photosConnectionHelp')}</p></div><span class="badge ${status.connected?'ok':'warning'}">${t(status.connected?'photosConnected':'photosDisconnected')}</span></div><p class="notice ${status.connected?'':'warning'}">${t(!status.configured?'photosSetupHelp':status.connected?'photosConnectedHelp':'photosReconnectHelp')}</p><div class="actions">${buttons('photos-login',status.connected?'photosReconnect':'photosConnect',true)}${buttons('photos-status-refresh','refresh')}${status.connected?buttons('photos-disconnect','photosDisconnect'):''}</div>`;
|
||||
$('#photos-login').disabled=!status.configured;
|
||||
bind('photos-login',()=>photosLoginDialog());
|
||||
bind('photos-status-refresh',()=>photosConnectionPanel());
|
||||
bind('photos-disconnect',()=>confirmDialog(t('photosSharedDisconnectWarning'),async()=>{await api('/api/v1/integrations/synology-photos',{method:'DELETE'});await navigate('photo-sources');}));
|
||||
}
|
||||
function photosLoginDialog(){
|
||||
showModal(`<h2>${t('photosConnect')}</h2><p class="hint">${t('photosLoginHelp')}</p><form id="photos-login-form"><div class="field"><label for="photos-login-user">${t('nasUsername')}</label><input id="photos-login-user" required autocomplete="username"></div><div class="field"><label for="photos-login-password">${t('nasPassword')}</label><input id="photos-login-password" required type="password" autocomplete="current-password"></div><p class="hint">${t('photosSessionHint')}</p><p id="photos-login-error" role="alert" hidden></p><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('photosConnect')}</button></div></form>`);
|
||||
$('#photos-login-form').onsubmit=busy(async e=>{e.preventDefault();const password=$('#photos-login-password').value;$('#photos-login-password').value='';try{await api('/api/v1/integrations/synology-photos',{method:'POST',body:{username:$('#photos-login-user').value,password}});$('#modal').close();await photosConnectionPanel();toast(t('photosConnectedHelp'));}catch(error){const box=$('#photos-login-error');box.hidden=false;box.className='notice warning';box.textContent=error.message;}});
|
||||
}
|
||||
|
||||
async function photoSourcesPanel(){
|
||||
const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section);
|
||||
const sources=await api('/api/v1/photo-sources');if(!section.isConnected)return;
|
||||
section.innerHTML=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint">${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}</p><div class="actions"><button data-source-browse="${esc(s.id)}">${t('browse')}</button><button data-source-random="${esc(s.id)}" ${s.enabled?'':'disabled'}>${t('randomPreview')}</button><button data-source-job="${esc(s.id)}">${t('sourceJob')}</button><button data-source-edit="${esc(s.id)}">${t('edit')}</button><button class="danger" data-source-delete="${esc(s.id)}">${t('remove')}</button></div></article>`).join(''):`<p class="hint">${t('noSources')}</p>`}</div><div id="source-browser"></div>`;
|
||||
section.innerHTML=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><section id="photos-connection" class="photos-connection" aria-label="Synology Photos"></section><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint source-identity">${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}</p><p class="hint">${s.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>`;
|
||||
await photosConnectionPanel();
|
||||
bind('add-photo-source',()=>photoSourceForm());
|
||||
section.querySelectorAll('[data-source-edit]').forEach(b=>b.onclick=()=>photoSourceForm(sources.find(s=>s.id===b.dataset.sourceEdit)));
|
||||
section.querySelectorAll('[data-source-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/photo-sources/'+b.dataset.sourceDelete,{method:'DELETE'});await navigate('photo-sources');}));
|
||||
@@ -131,8 +149,8 @@ async function photoSourcesPanel(){
|
||||
section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob));
|
||||
}
|
||||
async function photoSourceForm(source=null){
|
||||
showModal(`<h2>${t(source?'edit':'addSource')}</h2><form id="photo-source-form"><div class="field"><label for="source-id">ID</label><input id="source-id" pattern="[a-z0-9-]{1,60}" required value="${esc(source?.id||'')}" ${source?'readonly':''}></div><div class="field"><label for="source-name">${t('name')}</label><input id="source-name" required maxlength="120" value="${esc(source?.name||'')}"></div><div class="field"><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="local">${t('nasDirectory')}</option><option value="webdav">WebDAV</option><option value="google-drive">Google Drive</option><option value="alipan">阿里云盘 / AliPan</option><option value="baidu">百度网盘 / Baidu</option></select></div><div class="field"><label for="source-location">${t('sourceLocation')}</label><input id="source-location" required maxlength="2048" value="${esc(source?.location||'.')}"><p class="hint">${t('sourceLocationHint')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${source?.folder_caption?'checked':''}>${t('folderCaption')}</label><label class="check-row"><input id="source-synology-previews" type="checkbox" ${source?.synology_previews?'checked':''}>${t('synologyPreviews')}</label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
|
||||
$('#source-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'local';const update=()=>{const dav=$('#source-kind').value!=='local';$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=dav;$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};update();$('#source-kind').onchange=update;addPhotoQualityFields(source);$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';};
|
||||
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){
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>Pigway Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
|
||||
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.0</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js"></script><script src="/ui/features.js"></script><script src="/ui/extensions.js"></script><script src="/ui/app.js"></script></body></html>
|
||||
<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.4</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>
|
||||
|
||||
@@ -75,3 +75,32 @@ dialog.image-viewer h2.image-viewer-name{font-size:16px;min-width:0;flex:1}
|
||||
|
||||
@media(max-width:600px){.image-viewer-footer{flex-wrap:wrap;gap:8px}.image-viewer-name{flex-basis:100%!important}.image-viewer-actions{width:100%;justify-content:flex-end;gap:6px}.image-viewer-actions button{padding:8px 10px}}
|
||||
.studio-assets{max-height:240px;overflow:auto;margin:18px 0}.studio-canvas{position:relative;width:100%;background:white;margin-bottom:20px}.studio-canvas img{position:absolute;object-fit:cover;cursor:move;touch-action:none;border:2px solid var(--accent)}.region-fields{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}.region-fields input{width:100%}#client-secret,#hook-secret{overflow-wrap:anywhere}
|
||||
|
||||
/* Shared control rhythm for static forms and dynamically inserted panels. */
|
||||
[hidden]{display:none!important}
|
||||
form:not(#login-form){display:flex;flex-direction:column;gap:18px}
|
||||
form>.field,form>.check-row,form>.hint,form>.notice{margin:0}
|
||||
.field{display:flex;flex-direction:column;gap:10px}
|
||||
.field>label,.field>.hint{margin:0}
|
||||
.field>:is(input,select,textarea)+:is(input,select,textarea){margin-top:0}
|
||||
.field>.check-row{margin:0}
|
||||
.form-grid>.field{margin-bottom:0}
|
||||
.actions,.row-actions,.image-viewer-actions{gap:12px}
|
||||
#source-people-panel:not(:empty){margin:18px 0;display:flex;flex-direction:column;gap:16px}
|
||||
#source-people-panel>.field,#source-people-panel>.hint{margin:0}
|
||||
#source-people-panel>button{align-self:flex-start}
|
||||
.photos-connection{margin:24px 0;padding:24px;border:1px solid var(--border);border-radius:14px;background:var(--soft)}
|
||||
.photos-connection .section-heading{margin-bottom:16px}
|
||||
.photos-connection .notice{margin:0 0 18px}
|
||||
#source-local-quality{margin-bottom:20px}
|
||||
@media(max-width:720px){.photos-connection{padding:18px}.actions,.row-actions,.image-viewer-actions{gap:10px}}
|
||||
|
||||
.card>.form-grid+button{margin-top:18px}
|
||||
:not(form)>.field+.form-grid{margin-top:18px}
|
||||
.photos-connection .badge.warning{color:var(--bad);background:transparent;border:1px solid var(--bad)}
|
||||
form>.modal-actions{margin-top:8px}
|
||||
@media(max-width:720px){button,input:not([type=checkbox]):not([type=radio]),select{min-height:44px}.image-card button,.row-actions button,.history-actions button{font-size:14px;padding:9px 12px}.check-row{min-height:44px;align-items:center}}
|
||||
|
||||
.auto-collage{margin-bottom:24px}.auto-collage h2{margin-bottom:12px}.auto-collage>p{margin-bottom:20px}.auto-collage #collage-preview:not(:empty){margin-top:20px}.auto-collage>.actions{margin-top:16px}.manual-collage>summary{cursor:pointer;font-weight:600}.manual-collage[open]>summary{margin-bottom:24px}
|
||||
|
||||
.source-identity{overflow-wrap:anywhere}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pigway-frame-flow"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["paho-mqtt==2.1.0", "python-dateutil==2.9.0.post0", "fastapi==0.142.2", "uvicorn==0.54.0", "pillow==12.3.0", "requests==2.34.2", "croniter==6.2.4", "python-multipart==0.0.32", "pyyaml==6.0.3"]
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -37,7 +37,19 @@ class Deploy:
|
||||
self.lock_id = None
|
||||
self.state = None
|
||||
self.old_image = None
|
||||
self.secret_values = []
|
||||
self.old_image_tag = ""
|
||||
self.secret_values = [
|
||||
self.env[key]
|
||||
for key in (
|
||||
"PROD_DATA_DIR",
|
||||
"PROD_CONFIG_DIR",
|
||||
"PROD_PHOTOS_DIR",
|
||||
"PROD_WEB_BIND",
|
||||
"SYNOLOGY_PHOTOS_URL",
|
||||
"SYNOLOGY_PHOTOS_CERT_SHA256",
|
||||
)
|
||||
if self.env.get(key)
|
||||
]
|
||||
self.docker = self.env.get("DOCKER_BIN", "docker")
|
||||
|
||||
def run(self, *args, data=None, check=True):
|
||||
@@ -185,6 +197,7 @@ class Deploy:
|
||||
):
|
||||
raise ValueError("Production user/restart policy differs from repository")
|
||||
self.old_image = old["Image"]
|
||||
self.old_image_tag = old["Config"].get("Image", "")
|
||||
volume = self.run(
|
||||
"volume", "inspect", self.env["PROD_STATE_VOLUME"], check=False
|
||||
)
|
||||
@@ -324,6 +337,12 @@ class Deploy:
|
||||
def rollback(self, previous):
|
||||
self.activate(previous)
|
||||
self.verify(previous)
|
||||
self.run(
|
||||
"image",
|
||||
"tag",
|
||||
previous["services"]["pigway-frame-flow"]["image"],
|
||||
self.env["PROD_IMAGE_REPO"] + ":latest",
|
||||
)
|
||||
print("ROLLBACK VERIFIED", flush=True)
|
||||
|
||||
def secret_config(self):
|
||||
@@ -379,7 +398,9 @@ class Deploy:
|
||||
command += ["--build-arg", key + "=" + value]
|
||||
self.run(*command, str(ROOT))
|
||||
service = config["services"]["pigway-frame-flow"]
|
||||
service["image"] = image
|
||||
latest = self.env["PROD_IMAGE_REPO"] + ":latest"
|
||||
self.run("image", "tag", image, latest)
|
||||
service["image"] = latest
|
||||
proposed = self.secret_config()
|
||||
service["environment"].update(proposed)
|
||||
for key in ("SYNOLOGY_PHOTOS_URL", "SYNOLOGY_PHOTOS_CERT_SHA256"):
|
||||
@@ -653,6 +674,7 @@ class Deploy:
|
||||
self.state["status"] = "success"
|
||||
self.state_io(self.state)
|
||||
print("BOOTSTRAP VERIFIED", flush=True)
|
||||
self.cleanup_images()
|
||||
except BaseException:
|
||||
print("BOOTSTRAP FAILED", flush=True)
|
||||
self.diagnostics()
|
||||
@@ -712,6 +734,68 @@ class Deploy:
|
||||
text = re.sub(r"https?://[^\s\"'<>]+", "[REDACTED-URL]", text)
|
||||
print("Scoped container logs (redacted):", text, flush=True)
|
||||
|
||||
def cleanup_images(self):
|
||||
"""Keep only latest after verification; protect unrelated container images."""
|
||||
try:
|
||||
protected = set()
|
||||
referenced_tags = set()
|
||||
container_ids = self.run("ps", "-aq").stdout.decode().split()
|
||||
if container_ids:
|
||||
containers = json.loads(self.run("inspect", *container_ids).stdout)
|
||||
protected.update(container["Image"] for container in containers)
|
||||
referenced_tags.update(
|
||||
container.get("Config", {}).get("Image", "")
|
||||
for container in containers
|
||||
)
|
||||
repo = self.env["PROD_IMAGE_REPO"]
|
||||
image_ids = (
|
||||
self.run("image", "ls", "--no-trunc", "-q", repo)
|
||||
.stdout.decode()
|
||||
.split()
|
||||
)
|
||||
if not image_ids:
|
||||
return
|
||||
images = json.loads(
|
||||
self.run("image", "inspect", *sorted(set(image_ids))).stdout
|
||||
)
|
||||
removed = 0
|
||||
for image in images:
|
||||
tags = image.get("RepoTags") or []
|
||||
# Shared images and unrelated tags are never cleanup targets.
|
||||
if not tags:
|
||||
continue
|
||||
if not all(
|
||||
re.fullmatch(
|
||||
re.escape(repo)
|
||||
+ r":(?:latest|release-[0-9a-f]{40}-[0-9a-f]{8}(?:-rollback)?|rollback-[0-9a-f]{32})",
|
||||
tag,
|
||||
)
|
||||
for tag in tags
|
||||
):
|
||||
continue
|
||||
latest = repo + ":latest"
|
||||
if image["Id"] in protected:
|
||||
# Current production uses latest. Other containers retain
|
||||
# their referenced tags and images, even when stopped.
|
||||
if latest not in tags:
|
||||
continue
|
||||
tags = [
|
||||
tag
|
||||
for tag in tags
|
||||
if tag != latest and tag not in referenced_tags
|
||||
]
|
||||
if not tags:
|
||||
continue
|
||||
result = self.run("image", "rm", *tags, check=False)
|
||||
if result.returncode:
|
||||
print("IMAGE RETENTION: referenced image retained", flush=True)
|
||||
else:
|
||||
removed += 1
|
||||
print(f"IMAGE RETENTION: removed {removed} obsolete images", flush=True)
|
||||
except Exception:
|
||||
# Cleanup failure must never roll back an already verified deployment.
|
||||
print("IMAGE RETENTION WARNING: cleanup deferred", flush=True)
|
||||
|
||||
def deploy(self):
|
||||
previous = self.preflight()
|
||||
self.lock()
|
||||
@@ -721,6 +805,14 @@ class Deploy:
|
||||
# Refresh baseline under the daemon-level lock.
|
||||
previous = self.preflight()
|
||||
version = self.verify(previous)
|
||||
release = self.old_image_tag.removesuffix("-rollback")
|
||||
if re.fullmatch(
|
||||
re.escape(self.env["PROD_IMAGE_REPO"])
|
||||
+ r":release-[0-9a-f]{40}-[0-9a-f]{8}",
|
||||
release,
|
||||
):
|
||||
pin = release + "-rollback"
|
||||
else:
|
||||
pin = self.env["PROD_IMAGE_REPO"] + ":rollback-" + self.owner
|
||||
self.run("image", "tag", self.old_image, pin)
|
||||
candidate = self.build(json.loads(json.dumps(previous)))
|
||||
@@ -780,6 +872,10 @@ class Deploy:
|
||||
if safe_unlock:
|
||||
self.unlock()
|
||||
|
||||
# The deployment lock itself references the old image. Release it
|
||||
# before retention; other container references remain protected.
|
||||
self.cleanup_images()
|
||||
|
||||
def recover(self, manual=False):
|
||||
self.settings()
|
||||
lock = self.run(
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/* Isolated Mock-only automatic collage workflow. */
|
||||
const assert=require('assert');const fs=require('fs');const{chromium}=require('playwright');
|
||||
(async()=>{
|
||||
const base=process.env.FRAME_FLOW_URL;assert(base);const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);const headers={Authorization:'Bearer '+token};assert((await(await fetch(base+'/api/v1/settings',{headers})).json()).mock===true);
|
||||
const form=new FormData();for(let i=0;i<4;i++)form.append('files',new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a1OIAAAAASUVORK5CYII=','base64')],{type:'image/png'}),'fixture-'+i+'.png');assert((await fetch(base+'/api/v1/layout/images',{method:'POST',headers,body:form})).status===201);
|
||||
const browser=await chromium.launch({executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});try{
|
||||
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto(base);await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.locator('#nav button').first().getAttribute('data-page')==='dashboard');
|
||||
await page.evaluate(()=>navigate('studio'));assert(await page.locator('#studio-plan').isHidden());await page.locator('#collage-ai').uncheck();await page.locator('#collage-count').fill('3');await page.locator('#collage-title').fill('Collage fixture');await page.locator('#auto-collage-form button[type=submit]').click();await page.locator('#collage-image').waitFor({timeout:30000});assert(await page.locator('#collage-display').isVisible());
|
||||
const uploads=await(await fetch(base+'/api/v1/devices/living-room/images',{headers})).json();assert(uploads.length===1,'Preview must not upload');
|
||||
await page.locator('#collage-job').click();await page.locator('[data-job-edit]').last().click();await page.locator('#job-collage-count').waitFor();assert(await page.locator('#job-collage-count').inputValue()==='3');assert(await page.locator('#job-collage-ai').isChecked()===false);await page.locator('#modal-cancel').click();
|
||||
const jobs=await(await fetch(base+'/api/v1/jobs',{headers})).json();const job=jobs.find(j=>j.name==='Collage fixture');assert(job&&job.source.type==='auto_collage'&&job.source.count===3&&!job.enabled);
|
||||
assert.deepStrictEqual(errors,[]);console.log('Automatic collage: 8 workflow checks passed; no frame writes or console errors');
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
@@ -12,13 +12,13 @@ const assert=require('assert');const fs=require('fs');const {chromium}=require('
|
||||
for(const width of [1440,768,390])for(const lang of ['zh','en'])for(const theme of ['light','dark']){
|
||||
await page.setViewportSize({width,height:900});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});
|
||||
for(const view of ['studio','connections']){
|
||||
await page.evaluate(view=>navigate(view),view);await page.locator(view==='studio'?'#studio-plan':'#mqtt-config').waitFor();
|
||||
await page.evaluate(view=>navigate(view),view);if(view==='studio')await page.locator('.manual-collage summary').click();await page.locator(view==='studio'?'#studio-plan':'#mqtt-config').waitFor();
|
||||
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth),view+' overflow '+width);checks++;
|
||||
const gaps=await page.locator('.actions').evaluateAll(rows=>rows.flatMap(row=>{const nodes=[...row.querySelectorAll('button')];return nodes.slice(1).map((node,i)=>{const a=nodes[i].getBoundingClientRect(),b=node.getBoundingClientRect();return Math.abs(a.top-b.top)<4?b.left-a.right:b.top-a.bottom;});}));
|
||||
assert(gaps.every(g=>g>=8),view+' buttons touch '+width);checks++;
|
||||
}
|
||||
}
|
||||
await page.evaluate(()=>navigate('studio'));await page.locator('#studio-files').setInputFiles({name:'fixture.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a1OIAAAAASUVORK5CYII=','base64')});
|
||||
await page.evaluate(()=>navigate('studio'));await page.locator('.manual-collage summary').click();await page.locator('#studio-files').setInputFiles({name:'fixture.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a1OIAAAAASUVORK5CYII=','base64')});
|
||||
await page.locator('#studio-assets input:checked').waitFor();await page.locator('#studio-plan').click();await page.locator('#studio-layout img').waitFor();await page.locator('#studio-render').click();await page.locator('#studio-result').waitFor({timeout:30000});assert(await page.locator('#studio-download').isEnabled());checks++;
|
||||
await page.locator('#studio-title').fill('Changed');assert(await page.locator('#studio-display').isDisabled());checks++;
|
||||
assert.deepStrictEqual(errors,[]);checks++;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/* Full-page and modal layout audit in an isolated Mock service only. */
|
||||
const assert=require('assert');const fs=require('fs');const {chromium}=require('playwright');
|
||||
(async()=>{
|
||||
const base=process.env.FRAME_FLOW_URL;assert(base);const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);
|
||||
assert((await (await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}})).json()).mock===true);
|
||||
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
||||
try{
|
||||
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));let connected=false;let logins=0;
|
||||
await page.route('**/api/v1/integrations/synology-photos',async route=>{if(route.request().method()==='POST'){assert.deepStrictEqual(route.request().postDataJSON(),{username:'fixture-nas',password:'fixture-only'});connected=true;logins++;}await route.fulfill({json:{configured:true,connected}});});
|
||||
await page.route('**/api/v1/photo-sources',async route=>{assert(route.request().method()==='GET');await route.fulfill({json:[{id:'fixture-source',name:'NAS photos / NAS 照片',kind:'local',location:'.',enabled:false,people_filter:true,orientation:'auto'}]});});
|
||||
await page.goto(base);await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();
|
||||
let checks=0;const failures=[];const out=process.env.FRAME_FLOW_SCREENSHOT_DIR;if(out)fs.mkdirSync(out,{recursive:true});
|
||||
async function inspect(label){
|
||||
const result=await page.evaluate(()=>{
|
||||
const root=document.querySelector('dialog[open]')||document.querySelector('#main');const controls=[...root.querySelectorAll('button,select,input:not([type=checkbox]):not([type=radio]):not([type=file]),textarea')].filter(e=>e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})&&!e.closest('[hidden]')).map(e=>({id:e.id||e.textContent.trim().slice(0,24),r:e.getBoundingClientRect()}));const close=[];
|
||||
for(let i=0;i<controls.length;i++)for(let j=i+1;j<controls.length;j++){const a=controls[i].r,b=controls[j].r;const x=Math.min(a.right,b.right)-Math.max(a.left,b.left),y=Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top);const dx=Math.max(a.left,b.left)-Math.min(a.right,b.right),dy=Math.max(a.top,b.top)-Math.min(a.bottom,b.bottom);if((x>1&&dy<7.5)||(y>1&&dx<7.5))close.push([controls[i].id,controls[j].id]);}
|
||||
return {overflow:document.documentElement.scrollWidth>innerWidth,close,small:innerWidth<=720?controls.filter(e=>e.r.height<43.5).map(e=>e.id):[]};
|
||||
});checks++;if(result.overflow||result.close.length||result.small.length)failures.push({label,...result});
|
||||
}
|
||||
for(const width of [1440,768,390])for(const lang of ['zh','en'])for(const theme of ['light','dark']){
|
||||
await page.setViewportSize({width,height:900});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});
|
||||
for(const view of ['dashboard','gallery','photo-sources','upload','templates','automation','playlists','history','settings','studio','connections']){await page.evaluate(view=>navigate(view),view);await inspect(`${view}/${width}/${lang}/${theme}`);}
|
||||
await page.evaluate(()=>navigate('studio'));await page.locator('.manual-collage summary').click();await inspect(`Manual collage/${width}/${lang}/${theme}`);await page.evaluate(()=>navigate('photo-sources'));await page.locator('#photos-login').click();await inspect(`Photos login/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
|
||||
await page.locator('#add-photo-source').click();assert(await page.locator('#source-credential').isHidden());assert(await page.locator('#source-oauth').isHidden());await inspect(`NAS source/${width}/${lang}/${theme}`);await page.locator('#source-kind').selectOption('google-drive');assert(await page.locator('#source-oauth').isVisible());assert(await page.locator('#source-local-quality').isHidden());await inspect(`Drive source/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
|
||||
await page.evaluate(()=>navigate('automation'));await page.locator('#new-job').click();await inspect(`Job/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
|
||||
await page.evaluate(()=>navigate('playlists'));await page.locator('#new-playlist').click();await inspect(`Playlist/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
|
||||
await page.evaluate(()=>displayDialog({type:'image',image_id:1}));await inspect(`Display options/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
|
||||
await page.evaluate(()=>{state.tab='local';return navigate('gallery');});await inspect(`Local gallery/${width}/${lang}/${theme}`);await page.evaluate(()=>{state.tab='cloud';});
|
||||
if(out&&lang==='zh'&&theme==='dark'){await page.evaluate(()=>navigate('photo-sources'));await page.screenshot({path:out+`/photos-${width}.png`,fullPage:true});}
|
||||
}
|
||||
await page.evaluate(()=>navigate('photo-sources'));await page.locator('#photos-login').click();await page.locator('#photos-login-user').fill('fixture-nas');await page.locator('#photos-login-password').fill('fixture-only');await page.locator('#photos-login-form button[type=submit]').click();await page.locator('#photos-login').filter({hasText:'Sign in again'}).waitFor();assert(logins===1);checks++;
|
||||
assert.deepStrictEqual(errors,[]);assert.deepStrictEqual(failures,[],JSON.stringify(failures));console.log('Layout audit:',checks,'passed; 11 pages plus local gallery, 6 modal states, 12 viewport/language/theme combinations; console errors:',errors.length);
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
@@ -17,9 +17,9 @@ const fs=require('fs');const path=require('path');const assert=require('assert')
|
||||
await page.locator('#make-preview').click();await page.waitForFunction(()=>!document.querySelector('#save-local').disabled);checks++;
|
||||
await page.locator('#save-local').click();const uploaded=page.locator('.image-card').filter({hasText:uploadName+'.jpg'});await uploaded.waitFor();checks++;
|
||||
await uploaded.locator('[data-delete]').click();await page.locator('#modal-confirm').click();await uploaded.waitFor({state:'hidden'});checks++;
|
||||
await page.locator('[data-page="templates"]').click();await page.locator('.template-card').first().waitFor();assert(await page.locator('.template-card').count()===7);checks++;
|
||||
await page.locator('[data-page="templates"]').click();await page.locator('.template-card').first().waitFor();assert(await page.locator('.template-card').count()===9);checks++;
|
||||
await page.locator('#render-template').click();await page.locator('#template-image').waitFor({timeout:30000});assert(await page.locator('#display-template').isEnabled());checks++;
|
||||
await page.locator('#field-title').fill('Edited title');assert(await page.locator('#display-template').isDisabled());checks++;await page.locator('#save-template').click();await page.reload();await page.locator('#field-title').waitFor();assert(await page.locator('#field-title').inputValue()==='Edited title');checks++;await page.locator('#reset-template').click();await page.locator('#modal-confirm').click();await page.waitForFunction(()=>document.querySelector('#field-title')?.value!=='Edited title'&&document.querySelector('#field-title'));assert(await page.locator('#field-title').inputValue()!=='Edited title');checks++;await page.locator('#field-title').fill('Provenance UI title');await page.locator('#render-template').click();await page.locator('#template-image').waitFor();await page.locator('#display-template').click();await page.locator('#confirm-display').click();await page.locator('[data-page="history"]').click();await page.locator('tbody tr').first().waitFor();checks++;await page.locator('tbody tr button').first().click();await page.locator('#modal-cancel').click();checks++;await page.locator('[data-page="dashboard"]').click();await page.locator('#edit-current-template').waitFor({timeout:30000});await page.locator('#edit-current-template').click();await page.locator('#field-title').waitFor();assert(await page.locator('#field-title').inputValue()==='Provenance UI title');checks++;
|
||||
await page.locator('#field-title').fill('Edited title');assert(await page.locator('#display-template').isDisabled());checks++;await page.locator('#save-template').click();await page.reload();await page.locator('#field-title').waitFor();assert(await page.locator('#field-title').inputValue()==='Edited title');checks++;await page.locator('#reset-template').click();await page.locator('#modal-confirm').click();await page.waitForFunction(()=>document.querySelector('#field-title')?.value!=='Edited title'&&document.querySelector('#field-title'));assert(await page.locator('#field-title').inputValue()!=='Edited title');checks++;await page.locator('#field-title').fill('Provenance UI title');await page.locator('#render-template').click();await page.locator('#template-image').waitFor();await page.locator('#display-template').click();await page.locator('#confirm-display').click();await page.locator('[data-page="history"]').click();await page.locator('tbody tr').first().waitFor();checks++;await page.locator('tbody tr button').first().click();await page.locator('#modal-cancel').click();checks++;await page.waitForFunction(async()=>{const response=await fetch('/api/v1/devices/living-room/current-content');const content=await response.json();return content.source?.data?.title==='Provenance UI title';});await page.locator('[data-page="dashboard"]').click();await page.locator('#edit-current-template').waitFor({timeout:30000});await page.locator('#edit-current-template').click();await page.locator('#field-title').waitFor();assert(await page.locator('#field-title').inputValue()==='Provenance UI title');checks++;
|
||||
await page.locator('[data-page="automation"]').click();await page.locator('#new-job').click();await page.locator('#job-content-type').waitFor();await page.locator('#job-template').selectOption('quote');checks++;await page.locator('#job-name').fill('UI acceptance');await page.locator('#job-form button[type=submit]').click();await page.locator('td strong').filter({hasText:'UI acceptance'}).waitFor();checks++;
|
||||
await page.locator('[data-job-toggle]').first().click();await page.getByText('Paused',{exact:true}).first().waitFor();checks++;
|
||||
await page.locator('[data-job-delete]').first().click();await page.locator('#modal-confirm').click();await page.locator('td strong').filter({hasText:'UI acceptance'}).waitFor({state:'hidden'});checks++;
|
||||
|
||||
@@ -373,6 +373,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/PhotoSourceContent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/AutoCollageContent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ProviderContent"
|
||||
},
|
||||
@@ -384,6 +387,7 @@
|
||||
"discriminator": {
|
||||
"propertyName": "type",
|
||||
"mapping": {
|
||||
"auto_collage": "#/components/schemas/AutoCollageContent",
|
||||
"html": "#/components/schemas/FutureContent",
|
||||
"image": "#/components/schemas/ImageContent",
|
||||
"image_url": "#/components/schemas/URLContent",
|
||||
@@ -609,6 +613,16 @@
|
||||
},
|
||||
"Operation": {
|
||||
"properties": {
|
||||
"recovery": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/RecoveryState"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"operation_id": {
|
||||
"type": "string",
|
||||
"title": "Operation Id"
|
||||
@@ -1337,5 +1351,129 @@
|
||||
"name"
|
||||
],
|
||||
"title": "PhotosPerson"
|
||||
},
|
||||
"AutoCollageContent": {
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "auto_collage",
|
||||
"title": "Type"
|
||||
},
|
||||
"source_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]{1,60}$"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source Id"
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"maximum": 12.0,
|
||||
"minimum": 1.0,
|
||||
"title": "Count",
|
||||
"default": 4
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 100,
|
||||
"title": "Title",
|
||||
"default": ""
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"maxLength": 500,
|
||||
"title": "Message",
|
||||
"default": ""
|
||||
},
|
||||
"orientation": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"portrait",
|
||||
"landscape"
|
||||
],
|
||||
"title": "Orientation",
|
||||
"default": "auto"
|
||||
},
|
||||
"style": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"grid",
|
||||
"hero",
|
||||
"poster"
|
||||
],
|
||||
"title": "Style",
|
||||
"default": "grid"
|
||||
},
|
||||
"use_ai": {
|
||||
"type": "boolean",
|
||||
"title": "Use Ai",
|
||||
"default": true
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"maxLength": 1500,
|
||||
"title": "Prompt",
|
||||
"default": ""
|
||||
},
|
||||
"profile": {
|
||||
"$ref": "#/components/schemas/Profile",
|
||||
"default": "color"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"title": "AutoCollageContent"
|
||||
},
|
||||
"RecoveryState": {
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"checking",
|
||||
"manual_review",
|
||||
"resolved"
|
||||
],
|
||||
"title": "State"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"title": "Reason"
|
||||
},
|
||||
"first_checked_at": {
|
||||
"type": "string",
|
||||
"title": "First Checked At"
|
||||
},
|
||||
"last_checked_at": {
|
||||
"type": "string",
|
||||
"title": "Last Checked At"
|
||||
},
|
||||
"checks": {
|
||||
"type": "integer",
|
||||
"title": "Checks"
|
||||
},
|
||||
"requires_manual_review": {
|
||||
"type": "boolean",
|
||||
"title": "Requires Manual Review"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"state",
|
||||
"reason",
|
||||
"first_checked_at",
|
||||
"last_checked_at",
|
||||
"checks",
|
||||
"requires_manual_review"
|
||||
],
|
||||
"title": "RecoveryState"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ def test_generation_queued_once_and_never_displays(client, service, monkeypatch)
|
||||
operation = service.operation(first.json()["operation_id"])
|
||||
assert operation["status"] == "COMPLETED"
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["timeout"] == 180
|
||||
assert json.loads(calls[0]["body"])["n"] == 1
|
||||
assert service.registry.get("living-room").client.uploads == 0
|
||||
assert "private-ai-key" not in client.get("/api/v1/settings/ai-images").text
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from pydantic import ValidationError
|
||||
|
||||
from pigway_frame_flow.models import AutoCollageContent
|
||||
|
||||
|
||||
def photos(service, count=4):
|
||||
for i in range(count):
|
||||
data = BytesIO()
|
||||
Image.new("RGB", (320 + i, 200), (i * 50, 60, 100)).save(data, "JPEG")
|
||||
service.assets.put(data.getvalue(), f"photo-{i}.jpg")
|
||||
|
||||
|
||||
def test_auto_preview_selects_exact_distinct_photos_without_frame_write(
|
||||
client, service
|
||||
):
|
||||
photos(service)
|
||||
result = client.post(
|
||||
"/api/v1/render", json={"type": "auto_collage", "count": 3, "use_ai": False}
|
||||
)
|
||||
assert result.status_code == 202
|
||||
service.process_one()
|
||||
operation = service.operation(result.json()["operation_id"])
|
||||
assert operation["status"] == "COMPLETED"
|
||||
asset = operation["result"]["asset_id"]
|
||||
import json
|
||||
|
||||
source = json.loads(
|
||||
service.db.one("SELECT payload FROM asset_sources WHERE asset_id=?", (asset,))[
|
||||
"payload"
|
||||
]
|
||||
)
|
||||
regions = source["data"]["layout"]["regions"]
|
||||
assert len({x["asset_id"] for x in regions}) == 3
|
||||
assert service.registry.get("living-room").client.uploads == 0
|
||||
|
||||
|
||||
def test_insufficient_photos_and_unconfigured_ai_fail_before_upload(service):
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
|
||||
photos(service, 1)
|
||||
for content in [
|
||||
AutoCollageContent(type="auto_collage", count=2, use_ai=False),
|
||||
AutoCollageContent(type="auto_collage", count=1),
|
||||
]:
|
||||
with pytest.raises(FlowError):
|
||||
service.content_asset(content)
|
||||
assert service.registry.get("living-room").client.uploads == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [0, 13])
|
||||
def test_count_bounds(count):
|
||||
with pytest.raises(ValidationError):
|
||||
AutoCollageContent(type="auto_collage", count=count)
|
||||
|
||||
|
||||
def test_auto_collage_job_contract_and_preview(client, service):
|
||||
photos(service, 2)
|
||||
job = client.post(
|
||||
"/api/v1/jobs",
|
||||
json={
|
||||
"name": "Collage",
|
||||
"device_id": "living-room",
|
||||
"enabled": False,
|
||||
"schedule": {"type": "daily", "time": "08:00"},
|
||||
"source": {"type": "auto_collage", "count": 2, "use_ai": False},
|
||||
},
|
||||
)
|
||||
assert job.status_code == 201
|
||||
result = client.post("/api/v1/jobs/" + job.json()["id"] + "/preview")
|
||||
assert result.status_code == 202
|
||||
service.process_one()
|
||||
assert service.operation(result.json()["operation_id"])["status"] == "COMPLETED"
|
||||
assert service.registry.get("living-room").client.uploads == 0
|
||||
|
||||
|
||||
def test_source_selection_respects_quality_and_keeps_inputs_out_of_manual_library(
|
||||
service, monkeypatch
|
||||
):
|
||||
from types import SimpleNamespace
|
||||
|
||||
source = SimpleNamespace(
|
||||
minimum_sharpness=0,
|
||||
minimum_brightness=50,
|
||||
maximum_brightness=255,
|
||||
avoid_duplicates=False,
|
||||
)
|
||||
monkeypatch.setattr(service.photo_sources, "get", lambda _: source)
|
||||
monkeypatch.setattr(
|
||||
service.photo_sources, "catalog", lambda _: [{"id": str(i)} for i in range(3)]
|
||||
)
|
||||
|
||||
def read(_, item):
|
||||
data = BytesIO()
|
||||
Image.new("RGB", (320, 240), (int(item) * 80,) * 3).save(data, "JPEG")
|
||||
return data.getvalue(), item + ".jpg", None
|
||||
|
||||
monkeypatch.setattr(service.photo_sources, "read", read)
|
||||
content = AutoCollageContent(
|
||||
type="auto_collage", source_id="nas", count=2, use_ai=False
|
||||
)
|
||||
for _ in range(2):
|
||||
asset = service.content_asset(content)
|
||||
assert asset["source"] == "generated"
|
||||
assert (
|
||||
service.db.one("SELECT COUNT(*) AS n FROM assets WHERE kind='images'")["n"] == 0
|
||||
)
|
||||
assert (
|
||||
service.db.one("SELECT COUNT(*) AS n FROM assets WHERE kind='cache'")["n"] == 4
|
||||
)
|
||||
|
||||
|
||||
def test_ai_receives_only_selected_asset_metadata(service, monkeypatch):
|
||||
from pigway_frame_flow.extensions.layout import LayoutStudio, local_plan
|
||||
|
||||
photos(service, 3)
|
||||
calls = []
|
||||
|
||||
def plan(_, request):
|
||||
calls.append(request)
|
||||
assert request.use_ai is True
|
||||
assert len(set(request.asset_ids)) == 3
|
||||
return local_plan(request)
|
||||
|
||||
monkeypatch.setattr(LayoutStudio, "plan", plan)
|
||||
service.content_asset(
|
||||
AutoCollageContent(type="auto_collage", count=3, title="Family")
|
||||
)
|
||||
assert len(calls) == 1
|
||||
assert calls[0].title == "Family"
|
||||
assert service.registry.get("living-room").client.uploads == 0
|
||||
|
||||
|
||||
def test_scheduled_collage_executes_once_and_tracks_cloud_task_head(app, service):
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from pigway_frame_flow.models import JobInput
|
||||
|
||||
photos(service, 3)
|
||||
scheduler = app.state.scheduler
|
||||
job = scheduler.save_job(
|
||||
JobInput(
|
||||
name="Scheduled collage",
|
||||
schedule={"type": "daily", "time": "08:00"},
|
||||
source={"type": "auto_collage", "count": 3, "use_ai": False},
|
||||
)
|
||||
)
|
||||
due = (datetime.now(UTC) - timedelta(seconds=2)).isoformat()
|
||||
service.db.execute("UPDATE jobs SET next_run=? WHERE id=?", (due, job["id"]))
|
||||
scheduler.tick()
|
||||
scheduler.tick()
|
||||
assert len(service.db.all("SELECT * FROM job_runs")) == 1
|
||||
service.process_one()
|
||||
service.sync()
|
||||
assert service.registry.get("living-room").client.uploads == 1
|
||||
row = service.db.one("SELECT job_id FROM managed_cloud_uploads")
|
||||
assert row["job_id"] == job["id"]
|
||||
+198
-5
@@ -1,3 +1,4 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
@@ -7,11 +8,28 @@ from pigway_frame_flow.drivers.kokonna.client import KokonnaClient
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def transport_clock(monkeypatch):
|
||||
import pigway_frame_flow.drivers.kokonna.client as module
|
||||
|
||||
clock = SimpleNamespace(now=0.0, waits=[])
|
||||
|
||||
def sleep(seconds):
|
||||
clock.waits.append(seconds)
|
||||
clock.now += seconds
|
||||
|
||||
monkeypatch.setattr(
|
||||
module, "time", SimpleNamespace(monotonic=lambda: clock.now, sleep=sleep)
|
||||
)
|
||||
return clock
|
||||
|
||||
|
||||
def session(body=None, status=200):
|
||||
s = Mock()
|
||||
s.request.return_value.status_code = status
|
||||
s.request.return_value.json.return_value = body or {}
|
||||
s.request.return_value.content = b"image"
|
||||
s.request.return_value.headers = {}
|
||||
return s
|
||||
|
||||
|
||||
@@ -88,7 +106,7 @@ def test_timeout_redaction_no_retry(write):
|
||||
c.display(1) if write else c.get_device()
|
||||
assert "secret" not in str(e.value)
|
||||
assert e.value.code == ("WRITE_RESULT_UNKNOWN" if write else "CLOUD_UNAVAILABLE")
|
||||
assert s.request.call_count == 1
|
||||
assert s.request.call_count == (1 if write else 3)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [401, 429, 500, 530])
|
||||
@@ -109,12 +127,187 @@ def test_invalid_upload_id(tmp_path):
|
||||
assert e.value.code == "WRITE_RESULT_UNKNOWN"
|
||||
|
||||
|
||||
def test_rate_budget():
|
||||
def test_rate_budget(transport_clock):
|
||||
s = session({"nickname": "x"})
|
||||
c = KokonnaClient("secret", session=s)
|
||||
for _ in range(18):
|
||||
c.get_device()
|
||||
with pytest.raises(FlowError) as e:
|
||||
c.get_device()
|
||||
assert e.value.code == "RATE_LIMITED"
|
||||
assert s.request.call_count == 18
|
||||
assert s.request.call_count == 19
|
||||
assert transport_clock.waits == [60]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception,category",
|
||||
[
|
||||
(requests.ConnectTimeout, "connect_timeout"),
|
||||
(requests.ReadTimeout, "read_timeout"),
|
||||
(requests.exceptions.SSLError, "tls_error"),
|
||||
(requests.ConnectionError, "connection_error"),
|
||||
(requests.Timeout, "timeout"),
|
||||
(requests.RequestException, "request_error"),
|
||||
],
|
||||
)
|
||||
def test_transport_failure_records_safe_category_without_replay(exception, category):
|
||||
s = session()
|
||||
s.request.side_effect = exception("secret https://host/secret")
|
||||
c = KokonnaClient("secret", True, s)
|
||||
with pytest.raises(FlowError) as error:
|
||||
c.display(1)
|
||||
assert error.value.code == (
|
||||
"CLOUD_UNAVAILABLE" if category == "connect_timeout" else "WRITE_RESULT_UNKNOWN"
|
||||
)
|
||||
assert error.value.details["transport_error"] == category
|
||||
assert error.value.details["attempts"] == (
|
||||
3 if category == "connect_timeout" else 1
|
||||
)
|
||||
assert "secret" not in str(error.value.details)
|
||||
assert s.request.call_count == (3 if category == "connect_timeout" else 1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("write", [False, True])
|
||||
def test_connect_timeout_retries_then_succeeds(write, transport_clock):
|
||||
s = session({"success": True})
|
||||
response = s.request.return_value
|
||||
s.request.side_effect = [requests.ConnectTimeout("private"), response]
|
||||
c = KokonnaClient("private", True, s)
|
||||
c.display(1) if write else c.get_device()
|
||||
assert s.request.call_count == 2
|
||||
assert transport_clock.waits == [1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message,code,kind",
|
||||
[
|
||||
("image not found", "NOT_FOUND", "image_not_found"),
|
||||
("imageId is empty", "INVALID_REQUEST", "invalid_image_id"),
|
||||
("can not find robot private", "CONFIG_REQUIRED", "device_key_invalid"),
|
||||
],
|
||||
)
|
||||
def test_documented_500_rejection_does_not_lock_or_retry(message, code, kind):
|
||||
s = session({"message": message}, 500)
|
||||
c = KokonnaClient("private", True, s)
|
||||
with pytest.raises(FlowError) as error:
|
||||
c.display(1)
|
||||
assert error.value.code == code
|
||||
assert error.value.details["write_outcome"] == "rejected"
|
||||
assert error.value.details["failure_kind"] == kind
|
||||
assert "private" not in str(error.value.details)
|
||||
assert s.request.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [500, 502, 503, 530])
|
||||
def test_generic_5xx_read_retries_but_write_never_replays(status):
|
||||
for write in [False, True]:
|
||||
s = session({"message": "unknown failure private"}, status)
|
||||
c = KokonnaClient("private", True, s)
|
||||
with pytest.raises(FlowError) as error:
|
||||
c.display(1) if write else c.get_device()
|
||||
assert s.request.call_count == (1 if write else 3)
|
||||
assert error.value.code == (
|
||||
"WRITE_RESULT_UNKNOWN" if write else "CLOUD_UNAVAILABLE"
|
||||
)
|
||||
assert "private" not in str(error.value.details)
|
||||
|
||||
|
||||
def test_rate_limit_honors_retry_after_and_closes_response(transport_clock):
|
||||
s = session({"message": "rate limited"}, 429)
|
||||
first = s.request.return_value
|
||||
first.headers = {"Retry-After": "7"}
|
||||
second = session({"success": True}).request.return_value
|
||||
s.request.side_effect = [first, second]
|
||||
KokonnaClient("private", True, s).display(1)
|
||||
assert s.request.call_count == 2
|
||||
assert transport_clock.waits == [7]
|
||||
first.close.assert_called_once()
|
||||
second.close.assert_called_once()
|
||||
|
||||
|
||||
def test_long_retry_after_preserves_cooldown_without_early_request(transport_clock):
|
||||
s = session({"message": "rate limited"}, 429)
|
||||
s.request.return_value.headers = {"Retry-After": "120"}
|
||||
c = KokonnaClient("private", True, s)
|
||||
for _ in range(2):
|
||||
with pytest.raises(FlowError) as error:
|
||||
c.display(1)
|
||||
assert error.value.code == "RATE_LIMITED"
|
||||
assert s.request.call_count == 1
|
||||
assert transport_clock.waits == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("header", [None, "bad", "nan", "inf"])
|
||||
def test_invalid_retry_after_defaults_to_one_minute(header):
|
||||
from pigway_frame_flow.drivers.kokonna.client import SafeTransport
|
||||
|
||||
assert SafeTransport._retry_after(header) == 60
|
||||
|
||||
|
||||
def test_retry_after_accepts_http_date():
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from email.utils import format_datetime
|
||||
|
||||
from pigway_frame_flow.drivers.kokonna.client import SafeTransport
|
||||
|
||||
future = format_datetime(datetime.now(UTC) + timedelta(seconds=30), usegmt=True)
|
||||
assert 28 <= SafeTransport._retry_after(future) <= 30
|
||||
|
||||
|
||||
def test_invalid_write_response_is_never_retried():
|
||||
s = session({"success": False, "message": "private"})
|
||||
with pytest.raises(FlowError) as error:
|
||||
KokonnaClient("private", True, s).display(1)
|
||||
assert error.value.code == "WRITE_RESULT_UNKNOWN"
|
||||
assert error.value.details["failure_kind"] == "invalid_response"
|
||||
assert s.request.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"failure,expected",
|
||||
[
|
||||
("connect_timeout", "CLOUD_UNAVAILABLE"),
|
||||
("business_rejection", "CONFIG_REQUIRED"),
|
||||
("rate_limit", "RATE_LIMITED"),
|
||||
],
|
||||
)
|
||||
def test_proven_upload_failures_end_failed_without_device_lock(
|
||||
service, monkeypatch, failure, expected
|
||||
):
|
||||
from test_display import submit
|
||||
|
||||
s = session({"message": "can not find robot private"}, 500)
|
||||
if failure == "rate_limit":
|
||||
s.request.return_value.status_code = 429
|
||||
if failure == "connect_timeout":
|
||||
s.request.side_effect = requests.ConnectTimeout("private")
|
||||
c = KokonnaClient("private", True, s)
|
||||
monkeypatch.setattr(service.registry.get("living-room"), "upload", c.upload)
|
||||
operation = submit(service)
|
||||
service.process_one()
|
||||
op = service.operation(operation)
|
||||
assert op["status"] == "FAILED"
|
||||
assert op["error"]["code"] == expected
|
||||
assert not service.blocked("living-room")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [True, False, 0, -1, "12", None])
|
||||
def test_invalid_upload_id_never_becomes_confirmed(tmp_path, value):
|
||||
path = tmp_path / "image.jpg"
|
||||
path.write_bytes(b"jpeg")
|
||||
s = session({"id": value})
|
||||
with pytest.raises(FlowError) as error:
|
||||
KokonnaClient("private", True, s).upload(path, "image.jpg")
|
||||
assert error.value.code == "WRITE_RESULT_UNKNOWN"
|
||||
assert s.request.call_count == 1
|
||||
|
||||
|
||||
def test_ambiguous_failure_after_safe_rejection_stops_retrying(transport_clock):
|
||||
s = session({"message": "limited"}, 429)
|
||||
first = s.request.return_value
|
||||
first.headers = {"Retry-After": "1"}
|
||||
s.request.side_effect = [first, requests.ReadTimeout("private")]
|
||||
with pytest.raises(FlowError) as error:
|
||||
KokonnaClient("private", True, s).display(1)
|
||||
assert error.value.code == "WRITE_RESULT_UNKNOWN"
|
||||
assert error.value.details["attempts"] == 2
|
||||
assert s.request.call_count == 2
|
||||
assert transport_clock.waits == [1]
|
||||
|
||||
@@ -152,3 +152,55 @@ def test_offline(service):
|
||||
id = submit(service)
|
||||
service.process_one()
|
||||
assert service.operation(id)["error"]["code"] == "DEVICE_OFFLINE"
|
||||
|
||||
|
||||
def test_late_sync_resolves_unknown_without_upload_retry(service, monkeypatch):
|
||||
id = submit(service)
|
||||
service.process_one()
|
||||
service.update(id, "UNKNOWN")
|
||||
driver = service.registry.get("living-room")
|
||||
calls = []
|
||||
device = driver.device
|
||||
monkeypatch.setattr(driver, "device", lambda **kw: calls.append(kw) or device(**kw))
|
||||
service.sync()
|
||||
assert service.operation(id)["status"] == "COMPLETED"
|
||||
assert driver.client.uploads == 1
|
||||
assert calls == [{"refresh": True}]
|
||||
assert not service.blocked("living-room")
|
||||
assert service.db.one(
|
||||
"SELECT confirmed_at FROM managed_cloud_uploads WHERE operation_id=?", (id,)
|
||||
)["confirmed_at"]
|
||||
|
||||
|
||||
def test_unknown_wrong_target_stays_locked_and_queries_at_most_once_per_minute(
|
||||
service, monkeypatch
|
||||
):
|
||||
id = submit(service)
|
||||
service.process_one()
|
||||
service.update(id, "UNKNOWN")
|
||||
driver = service.registry.get("living-room")
|
||||
driver.client.display(1)
|
||||
driver.invalidate()
|
||||
calls = []
|
||||
device = driver.device
|
||||
monkeypatch.setattr(driver, "device", lambda **kw: calls.append(kw) or device(**kw))
|
||||
service.sync()
|
||||
service.sync()
|
||||
assert service.operation(id)["status"] == "UNKNOWN"
|
||||
assert service.blocked("living-room")
|
||||
assert driver.client.uploads == 1
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_unknown_matching_image_without_sync_stays_locked(service, monkeypatch):
|
||||
id = submit(service)
|
||||
service.process_one()
|
||||
service.update(id, "UNKNOWN")
|
||||
driver = service.registry.get("living-room")
|
||||
device = driver.device()
|
||||
device.current_content.synced = False
|
||||
monkeypatch.setattr(driver, "device", lambda **kw: device)
|
||||
service.sync()
|
||||
assert service.operation(id)["status"] == "UNKNOWN"
|
||||
assert service.blocked("living-room")
|
||||
assert driver.client.uploads == 1
|
||||
|
||||
@@ -373,3 +373,30 @@ def test_ai_layout_sends_metadata_only_and_validates_response(
|
||||
assert b"test-ai-secret" not in calls[0]["body"]
|
||||
assert image.getvalue() not in calls[0]["body"]
|
||||
assert calls[0]["headers"]["Authorization"] == "Bearer test-ai-secret"
|
||||
|
||||
|
||||
def test_webhook_matching_event_after_unrelated_backlog(client, service, monkeypatch):
|
||||
import json
|
||||
|
||||
service.settings.image_url_hosts.append("example.com")
|
||||
response = client.post(
|
||||
"/api/v1/webhooks",
|
||||
json={
|
||||
"callback_url": "https://example.com/events",
|
||||
"event_types": ["operation.failed"],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
for _ in range(500):
|
||||
service.db.event("display.changed", {})
|
||||
service.db.event("operation.failed", {"operation_id": "expected"})
|
||||
sent = []
|
||||
monkeypatch.setattr(
|
||||
service.assets, "fetch_url", lambda url, **kwargs: sent.append(kwargs) or b""
|
||||
)
|
||||
service.webhooks.tick()
|
||||
service.webhooks.last = 0
|
||||
service.webhooks.tick()
|
||||
assert len(sent) == 1
|
||||
assert json.loads(sent[0]["body"])["data"] == {"operation_id": "expected"}
|
||||
assert len(service.db.all("SELECT * FROM webhook_deliveries")) == 1
|
||||
|
||||
@@ -17,3 +17,52 @@ def test_filename_does_not_determine_photo_source(client, service):
|
||||
client.get("/api/v1/assets?source=uploaded").json()[0]["name"] == "weather.jpg"
|
||||
)
|
||||
assert client.get("/api/v1/assets?source=invalid").status_code == 422
|
||||
|
||||
|
||||
def test_multiple_sources_keep_separate_people_and_folder_settings(
|
||||
service, settings, tmp_path
|
||||
):
|
||||
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
||||
|
||||
root = tmp_path / "photos"
|
||||
root.mkdir()
|
||||
(root / "family").mkdir()
|
||||
(root / "travel").mkdir()
|
||||
settings.photo_root = root
|
||||
service.photo_sources.save(
|
||||
"family",
|
||||
PhotoSourceInput(
|
||||
name="Family",
|
||||
kind="local",
|
||||
location="family",
|
||||
people_filter=True,
|
||||
person_ids=[1],
|
||||
orientation="portrait",
|
||||
),
|
||||
)
|
||||
service.photo_sources.save(
|
||||
"travel",
|
||||
PhotoSourceInput(
|
||||
name="Travel",
|
||||
kind="local",
|
||||
location="travel",
|
||||
people_filter=True,
|
||||
person_ids=[2],
|
||||
orientation="landscape",
|
||||
),
|
||||
)
|
||||
before = service.photo_sources.get("travel").model_dump()
|
||||
service.photo_sources.save(
|
||||
"family",
|
||||
PhotoSourceInput(
|
||||
name="Family",
|
||||
kind="local",
|
||||
location="family",
|
||||
people_filter=True,
|
||||
person_ids=[3],
|
||||
orientation="auto",
|
||||
),
|
||||
)
|
||||
assert service.photo_sources.get("travel").model_dump() == before
|
||||
assert service.photo_sources.get("family").person_ids == [3]
|
||||
assert service.photo_sources.get("travel").person_ids == [2]
|
||||
|
||||
@@ -98,6 +98,7 @@ def test_preflight_rejects_wrong_host_resources(tool, monkeypatch, change):
|
||||
def test_deploy_rolls_back_exact_baseline_without_deleting_data(
|
||||
tool, monkeypatch, failure
|
||||
):
|
||||
tool.old_image_tag = "pigway-frame-flow:release-" + "a" * 40 + "-" + "b" * 8
|
||||
old = {"services": {"pigway-frame-flow": {"image": "sha256:previous"}}}
|
||||
new = {"services": {"pigway-frame-flow": {"image": "candidate"}}}
|
||||
activated, calls, saved = [], [], []
|
||||
@@ -111,6 +112,10 @@ def test_deploy_rolls_back_exact_baseline_without_deleting_data(
|
||||
monkeypatch.setattr(
|
||||
tool, "state_io", lambda state: saved.append(json.loads(json.dumps(state)))
|
||||
)
|
||||
cleanup = []
|
||||
monkeypatch.setattr(
|
||||
tool, "cleanup_images", lambda: cleanup.append(saved[-1]["status"])
|
||||
)
|
||||
verifies = []
|
||||
|
||||
def verify(config):
|
||||
@@ -125,13 +130,17 @@ def test_deploy_rolls_back_exact_baseline_without_deleting_data(
|
||||
if failure:
|
||||
with pytest.raises(RuntimeError):
|
||||
tool.deploy()
|
||||
assert cleanup == []
|
||||
assert activated == [new, old]
|
||||
assert saved[0]["previous"] == old
|
||||
assert ("unlock" in calls) is (failure != "rollback")
|
||||
else:
|
||||
tool.deploy()
|
||||
assert cleanup == ["success"]
|
||||
assert "unlock" in calls
|
||||
assert activated == [new]
|
||||
assert saved[-1]["status"] == "success"
|
||||
assert saved[-1]["rollback_tag"] == tool.old_image_tag + "-rollback"
|
||||
assert not any(
|
||||
isinstance(c, tuple) and ("down" in c or "prune" in c) for c in calls
|
||||
)
|
||||
@@ -243,3 +252,64 @@ def test_lock_conflict_never_activates_production(tool, monkeypatch):
|
||||
monkeypatch.setattr(tool, "run", fail)
|
||||
with pytest.raises(RuntimeError):
|
||||
tool.deploy()
|
||||
|
||||
|
||||
def test_image_retention_keeps_latest_and_protects_other_containers(tool, monkeypatch):
|
||||
tool.state = {"previous_image": "previous"}
|
||||
release = "pigway-frame-flow:release-" + "a" * 40 + "-" + "b" * 8
|
||||
rollback = "pigway-frame-flow:rollback-" + "c" * 32
|
||||
tool.state["rollback_tag"] = release + "-rollback"
|
||||
images = [
|
||||
{"Id": "current", "RepoTags": ["pigway-frame-flow:latest", release]},
|
||||
{"Id": "previous", "RepoTags": [release + "-rollback", release, rollback]},
|
||||
{"Id": "stopped", "RepoTags": [release]},
|
||||
{"Id": "obsolete", "RepoTags": [release, rollback]},
|
||||
{"Id": "shared", "RepoTags": [release, "other:latest"]},
|
||||
{"Id": "ci", "RepoTags": ["ffci-fixture:latest"]},
|
||||
]
|
||||
calls = []
|
||||
|
||||
def run(*args, **kwargs):
|
||||
calls.append(args)
|
||||
if args == ("ps", "-aq"):
|
||||
data = "container1 container2"
|
||||
elif args[0] == "inspect":
|
||||
data = json.dumps(
|
||||
[
|
||||
{
|
||||
"Image": "current",
|
||||
"Config": {"Image": "pigway-frame-flow:latest"},
|
||||
},
|
||||
{"Image": "stopped"},
|
||||
]
|
||||
)
|
||||
elif args[:2] == ("image", "ls"):
|
||||
data = "current previous stopped obsolete shared ci"
|
||||
elif args[:2] == ("image", "inspect"):
|
||||
data = json.dumps(images)
|
||||
else:
|
||||
data = ""
|
||||
return SimpleNamespace(stdout=data.encode(), returncode=0)
|
||||
|
||||
monkeypatch.setattr(tool, "run", run)
|
||||
tool.cleanup_images()
|
||||
assert [c for c in calls if c[:2] == ("image", "rm")] == [
|
||||
("image", "rm", release),
|
||||
("image", "rm", release + "-rollback", release, rollback),
|
||||
("image", "rm", release, rollback),
|
||||
]
|
||||
|
||||
|
||||
def test_image_retention_failure_does_not_fail_verified_deploy(
|
||||
tool, monkeypatch, capsys
|
||||
):
|
||||
tool.state = {"previous_image": "previous"}
|
||||
|
||||
def unavailable(*args, **kwargs):
|
||||
raise RuntimeError("private diagnostic")
|
||||
|
||||
monkeypatch.setattr(tool, "run", unavailable)
|
||||
tool.cleanup_images()
|
||||
output = capsys.readouterr().out
|
||||
assert "cleanup deferred" in output
|
||||
assert "private diagnostic" not in output
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_external_forks_do_not_execute_on_nas_runner():
|
||||
workflow = Path(".github/workflows/verify.yml").read_text()
|
||||
assert (
|
||||
"github.event.pull_request.head.repo.full_name == github.repository" in workflow
|
||||
)
|
||||
assert "needs: verify" in workflow
|
||||
|
||||
|
||||
def test_private_production_configuration_uses_secrets():
|
||||
workflow = Path(".github/workflows/verify.yml").read_text()
|
||||
for key in (
|
||||
"PROD_DATA_DIR",
|
||||
"PROD_CONFIG_DIR",
|
||||
"PROD_PHOTOS_DIR",
|
||||
"PROD_WEB_BIND",
|
||||
"SYNOLOGY_PHOTOS_URL",
|
||||
"SYNOLOGY_PHOTOS_CERT_SHA256",
|
||||
):
|
||||
assert "${{ secrets." + key + " }}" in workflow
|
||||
assert "${{ vars." + key + " }}" not in workflow
|
||||
|
||||
|
||||
def test_actions_downloads_do_not_depend_on_personal_server():
|
||||
import re
|
||||
|
||||
workflow = Path(".github/workflows/verify.yml").read_text()
|
||||
actions = re.findall(r"uses:\s*(\S+)", workflow)
|
||||
assert len(actions) == 4
|
||||
for action in actions:
|
||||
assert re.fullmatch(
|
||||
r"https://github\.com/actions/(checkout|setup-python)@[0-9a-f]{40}",
|
||||
action,
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from test_display import submit
|
||||
|
||||
from pigway_frame_flow.display.service import DisplayService
|
||||
from pigway_frame_flow.errors import ErrorCode, FlowError
|
||||
|
||||
|
||||
def unknown_upload(service, monkeypatch):
|
||||
def fail(*args):
|
||||
raise FlowError(ErrorCode.WRITE_RESULT_UNKNOWN, "Response lost", 502)
|
||||
|
||||
monkeypatch.setattr(service.registry.get("living-room"), "upload", fail)
|
||||
operation = submit(service)
|
||||
service.process_one()
|
||||
return operation
|
||||
|
||||
|
||||
def test_missing_upload_is_visible_without_unsafe_unlock(client, service, monkeypatch):
|
||||
operation = unknown_upload(service, monkeypatch)
|
||||
service.sync()
|
||||
recovery = client.get("/api/v1/operations/" + operation).json()["recovery"]
|
||||
assert recovery["reason"] == "gallery_match_missing"
|
||||
assert recovery["state"] == "checking"
|
||||
assert not recovery["requires_manual_review"]
|
||||
assert service.blocked("living-room")
|
||||
assert service.registry.get("living-room").client.uploads == 0
|
||||
|
||||
|
||||
def test_persistent_block_escalates_once_survives_restart_and_preserves_evidence(
|
||||
service, monkeypatch
|
||||
):
|
||||
operation = unknown_upload(service, monkeypatch)
|
||||
original = service.operation(operation)
|
||||
service.db.execute(
|
||||
"UPDATE operations SET created_at=? WHERE id=?",
|
||||
(
|
||||
(datetime.now(UTC) - timedelta(hours=1)).isoformat(),
|
||||
operation,
|
||||
),
|
||||
)
|
||||
service.sync()
|
||||
first = service.operation(operation)["recovery"]
|
||||
assert first["state"] == "manual_review"
|
||||
assert first["reason"] == "gallery_match_missing"
|
||||
service.unknown_checked.clear()
|
||||
service.sync()
|
||||
restarted = DisplayService(
|
||||
service.db, service.settings, service.registry, service.assets, service.renderer
|
||||
)
|
||||
restarted.sync()
|
||||
assert (
|
||||
len(service.db.all("SELECT * FROM events WHERE event_type='operation.blocked'"))
|
||||
== 1
|
||||
)
|
||||
assert (
|
||||
service.db.one(
|
||||
"SELECT COUNT(*) AS n FROM history WHERE result='MANUAL_REVIEW_REQUIRED'"
|
||||
)["n"]
|
||||
== 1
|
||||
)
|
||||
assert service.operation(operation)["recovery"]["checks"] == 3
|
||||
assert service.operation(operation)["result"] == original["result"]
|
||||
service.reconcile(operation, acknowledge=True)
|
||||
assert service.operation(operation)["recovery"]["state"] == "resolved"
|
||||
assert not service.operation(operation)["recovery"]["requires_manual_review"]
|
||||
assert not service.blocked("living-room")
|
||||
|
||||
|
||||
def test_missing_legacy_evidence_escalates_without_cloud_write(service):
|
||||
operation = submit(service)
|
||||
service._start_write(operation)
|
||||
service.update(operation, "UNKNOWN")
|
||||
service.sync()
|
||||
assert (
|
||||
service.operation(operation)["recovery"]["reason"] == "missing_upload_evidence"
|
||||
)
|
||||
assert service.blocked("living-room")
|
||||
|
||||
|
||||
def test_delete_unknown_requires_manual_review_without_replay(service):
|
||||
operation = service.submit(
|
||||
"delete", {"image_id": 1}, "living-room", "admin", "test"
|
||||
)
|
||||
service._start_write(operation)
|
||||
service.update(operation, "UNKNOWN")
|
||||
service.sync()
|
||||
assert (
|
||||
service.operation(operation)["recovery"]["reason"]
|
||||
== "manual_reconciliation_required"
|
||||
)
|
||||
assert service.blocked("living-room")
|
||||
assert service.registry.get("living-room").images()[0].image_id == 1
|
||||
|
||||
|
||||
def test_malformed_reconciliation_does_not_abort_other_background_work(
|
||||
service, monkeypatch
|
||||
):
|
||||
operation = unknown_upload(service, monkeypatch)
|
||||
|
||||
def invalid_metadata(*args):
|
||||
raise ValueError("vendor metadata with private data")
|
||||
|
||||
monkeypatch.setattr(service.upload_reconciliation, "check", invalid_metadata)
|
||||
service.sync()
|
||||
op = service.operation(operation)
|
||||
assert op["recovery"]["reason"] == "reconciliation_error"
|
||||
assert "private" not in str(op["recovery"])
|
||||
assert service.blocked("living-room")
|
||||
@@ -151,3 +151,24 @@ def test_cleanup_option_requires_random_photo_job():
|
||||
source={"type": "template", "template": "quote"},
|
||||
cleanup_unstarred=True,
|
||||
)
|
||||
|
||||
|
||||
def test_first_cleanup_runs_after_recent_host_boot(
|
||||
client, service, settings, monkeypatch
|
||||
):
|
||||
image_id = upload_random(client, service, settings)
|
||||
due(service, image_id)
|
||||
monkeypatch.setattr(
|
||||
"pigway_frame_flow.display.retention.time.monotonic", lambda: 120
|
||||
)
|
||||
service.retention.sweep()
|
||||
row = service.db.one("SELECT * FROM image_retention WHERE image_id=?", (image_id,))
|
||||
assert row["cleanup_operation"]
|
||||
assert service.retention.checked == 120
|
||||
service.retention.sweep()
|
||||
assert (
|
||||
service.db.one("SELECT COUNT(*) AS n FROM operations WHERE action='delete'")[
|
||||
"n"
|
||||
]
|
||||
== 1
|
||||
)
|
||||
|
||||
@@ -75,3 +75,76 @@ def test_playlist(app, service):
|
||||
def test_invalid_cron():
|
||||
with pytest.raises(FlowError):
|
||||
next_run(Schedule(type="cron", cron="* * * * * *"))
|
||||
|
||||
|
||||
def test_worker_checks_late_sync_before_due_tasks(app, service, monkeypatch):
|
||||
scheduler = app.state.scheduler
|
||||
calls = []
|
||||
monkeypatch.setattr(service, "sync", lambda: calls.append("sync"))
|
||||
|
||||
def tick():
|
||||
calls.append("tick")
|
||||
scheduler.stop_event.set()
|
||||
|
||||
monkeypatch.setattr(scheduler, "tick", tick)
|
||||
monkeypatch.setattr(service, "expire", lambda: None)
|
||||
monkeypatch.setattr(service.cloud_library, "sweep", lambda: None)
|
||||
monkeypatch.setattr(service.local_retention, "sweep", lambda: None)
|
||||
monkeypatch.setattr(service, "process_one", lambda: False)
|
||||
scheduler.run()
|
||||
assert calls[:2] == ["sync", "tick"]
|
||||
|
||||
|
||||
def test_blocked_job_keeps_retry_slot_without_blocking_worker(
|
||||
app, service, monkeypatch
|
||||
):
|
||||
scheduler = app.state.scheduler
|
||||
service.registry.drivers["other-room"] = service.registry.get("living-room")
|
||||
jobs = []
|
||||
for device in ("living-room", "other-room"):
|
||||
jobs.append(
|
||||
scheduler.save_job(
|
||||
JobInput(
|
||||
name=device,
|
||||
device_id=device,
|
||||
schedule={"type": "daily", "time": "07:00"},
|
||||
source={"type": "restore"},
|
||||
)
|
||||
)
|
||||
)
|
||||
current = datetime.now(UTC)
|
||||
due = (current - timedelta(seconds=2)).isoformat()
|
||||
service.db.execute("UPDATE jobs SET next_run=?", (due,))
|
||||
unknown = service.submit("display", {}, "living-room", "test", "unknown")
|
||||
service.db.execute(
|
||||
"UPDATE operations SET status='UNKNOWN',write_started=1 WHERE id=?", (unknown,)
|
||||
)
|
||||
queued = service.submit("render", {}, "living-room", "test", "render")
|
||||
calls = []
|
||||
monkeypatch.setattr(service, "sync", lambda: None)
|
||||
monkeypatch.setattr(service, "expire", lambda: calls.append("expire"))
|
||||
monkeypatch.setattr(service.cloud_library, "sweep", lambda: None)
|
||||
monkeypatch.setattr(service.local_retention, "sweep", lambda: None)
|
||||
|
||||
def process():
|
||||
calls.append("process")
|
||||
assert service.operation(queued)["status"] == "QUEUED"
|
||||
scheduler.stop_event.set()
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(service, "process_one", process)
|
||||
monkeypatch.setattr(
|
||||
scheduler.stop_event, "wait", lambda timeout: scheduler.stop_event.set()
|
||||
)
|
||||
scheduler.run()
|
||||
assert calls == ["expire", "process"]
|
||||
blocked = service.db.one("SELECT * FROM jobs WHERE id=?", (jobs[0]["id"],))
|
||||
assert blocked["next_run"] == due
|
||||
assert blocked["last_run"] is None
|
||||
assert len(service.db.all("SELECT * FROM job_runs")) == 1
|
||||
assert service.db.one("SELECT * FROM job_runs")["job_id"] == jobs[1]["id"]
|
||||
scheduler.tick(current + timedelta(minutes=6))
|
||||
assert (
|
||||
service.db.one("SELECT * FROM jobs WHERE id=?", (jobs[0]["id"],))["last_result"]
|
||||
== "SKIPPED_OVERDUE"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from test_display import submit
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode, FlowError
|
||||
|
||||
|
||||
def lose_response(service, monkeypatch, change_display=True):
|
||||
driver = service.registry.get("living-room")
|
||||
upload = driver.upload
|
||||
|
||||
def uncertain(path, name):
|
||||
upload(path, name)
|
||||
if not change_display:
|
||||
driver.client.display(1)
|
||||
raise FlowError(
|
||||
ErrorCode.WRITE_RESULT_UNKNOWN, "Cloud write outcome is unknown", 502
|
||||
)
|
||||
|
||||
monkeypatch.setattr(driver, "upload", uncertain)
|
||||
operation = submit(service)
|
||||
service.process_one()
|
||||
assert service.operation(operation)["status"] == "UNKNOWN"
|
||||
return operation, driver
|
||||
|
||||
|
||||
def test_lost_upload_response_resolves_by_exact_bytes_without_reupload(
|
||||
service, monkeypatch
|
||||
):
|
||||
operation, driver = lose_response(service, monkeypatch)
|
||||
service.sync()
|
||||
assert service.operation(operation)["status"] == "COMPLETED"
|
||||
assert service.operation(operation)["result"]["image_id"] == 2
|
||||
assert driver.client.uploads == 1
|
||||
assert not service.blocked("living-room")
|
||||
|
||||
|
||||
def test_same_name_wrong_bytes_does_not_unlock(service, monkeypatch):
|
||||
operation, driver = lose_response(service, monkeypatch)
|
||||
monkeypatch.setattr(driver, "download", lambda _: b"unrelated")
|
||||
service.sync()
|
||||
assert service.operation(operation)["status"] == "UNKNOWN"
|
||||
assert service.blocked("living-room")
|
||||
assert driver.client.uploads == 1
|
||||
|
||||
|
||||
def test_ambiguous_gallery_does_not_download_or_unlock(service, monkeypatch):
|
||||
operation, driver = lose_response(service, monkeypatch)
|
||||
driver.client.state["images"].append(dict(driver.client.state["images"][-1], id=3))
|
||||
monkeypatch.setattr(driver, "download", lambda _: pytest.fail("Ambiguous download"))
|
||||
service.sync()
|
||||
assert service.operation(operation)["status"] == "UNKNOWN"
|
||||
|
||||
|
||||
def test_confirmed_upload_not_displayed_closes_failed_display_after_stable_checks(
|
||||
service, monkeypatch
|
||||
):
|
||||
operation, driver = lose_response(service, monkeypatch, change_display=False)
|
||||
service.sync()
|
||||
result = service.operation(operation)["result"]
|
||||
assert result["upload_confirmed"]
|
||||
start = datetime.fromisoformat(result["upload_intent"]["started_at"])
|
||||
import pigway_frame_flow.display.upload_reconciliation as module
|
||||
|
||||
class Later(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return start + timedelta(seconds=service.settings.sync_timeout + 600)
|
||||
|
||||
monkeypatch.setattr(module, "datetime", Later)
|
||||
service.unknown_checked.clear()
|
||||
service.sync()
|
||||
assert service.operation(operation)["status"] == "UNKNOWN"
|
||||
result = service.operation(operation)["result"]
|
||||
result["unchanged_observed_at"] = (
|
||||
datetime.now(UTC) - timedelta(seconds=61)
|
||||
).isoformat()
|
||||
service.db.execute(
|
||||
"UPDATE operations SET result=? WHERE id=?", (json.dumps(result), operation)
|
||||
)
|
||||
service.unknown_checked.clear()
|
||||
service.sync()
|
||||
assert service.operation(operation)["status"] == "FAILED"
|
||||
assert service.operation(operation)["error"]["code"] == "DISPLAY_NOT_CONFIRMED"
|
||||
assert not service.blocked("living-room")
|
||||
assert driver.client.uploads == 1
|
||||
|
||||
|
||||
def test_legacy_unknown_without_intent_remains_locked(service):
|
||||
operation = submit(service)
|
||||
service._start_write(operation)
|
||||
service.update(operation, "UNKNOWN")
|
||||
service.sync()
|
||||
assert service.blocked("living-room")
|
||||
|
||||
|
||||
def test_recovered_upload_preserves_temporary_restore(service, monkeypatch):
|
||||
driver = service.registry.get("living-room")
|
||||
upload = driver.upload
|
||||
|
||||
def uncertain(path, name):
|
||||
upload(path, name)
|
||||
raise FlowError(ErrorCode.WRITE_RESULT_UNKNOWN, "Response lost", 502)
|
||||
|
||||
monkeypatch.setattr(driver, "upload", uncertain)
|
||||
operation = submit(service, duration=60, restore=True)
|
||||
service.process_one()
|
||||
service.sync()
|
||||
assert service.operation(operation)["status"] == "COMPLETED"
|
||||
assert service.top("living-room")["id"] == operation
|
||||
assert service.state("living-room")["managed_image_id"] == 2
|
||||
|
||||
|
||||
def test_manual_close_preserves_unknown_upload_evidence(service, monkeypatch):
|
||||
operation, driver = lose_response(service, monkeypatch, change_display=False)
|
||||
before = service.operation(operation)
|
||||
assert before["result"].get("image_id") is None
|
||||
result = service.reconcile(operation, acknowledge=True)
|
||||
after = service.operation(operation)
|
||||
assert result["resolved"]
|
||||
assert after["status"] == "FAILED"
|
||||
assert after["result"] == before["result"]
|
||||
assert after["error"]["code"] == "DISPLAY_NOT_CONFIRMED"
|
||||
assert after["error"]["details"]["previous_error"] == before["error"]
|
||||
assert after["error"]["details"]["observed_image_id"] == 1
|
||||
assert not service.blocked("living-room")
|
||||
assert driver.client.uploads == 1
|
||||
assert (
|
||||
service.db.one("SELECT * FROM history ORDER BY id DESC LIMIT 1")["result"]
|
||||
== "ACKNOWLEDGED"
|
||||
)
|
||||
|
||||
|
||||
def test_upload_transport_diagnostics_persist_on_operation(service, monkeypatch):
|
||||
driver = service.registry.get("living-room")
|
||||
|
||||
def fail(*args):
|
||||
raise FlowError(
|
||||
ErrorCode.WRITE_RESULT_UNKNOWN,
|
||||
"Cloud write outcome is unknown",
|
||||
502,
|
||||
details={"transport_error": "read_timeout"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(driver, "upload", fail)
|
||||
operation = submit(service)
|
||||
service.process_one()
|
||||
op = service.operation(operation)
|
||||
assert op["status"] == "UNKNOWN"
|
||||
assert op["error"]["details"] == {"transport_error": "read_timeout"}
|
||||
assert op["result"]["upload_intent"]
|
||||
Reference in New Issue
Block a user