Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d691826b6 | ||
|
|
5b2763910e | ||
|
|
e92a7a51fa | ||
|
|
b1c41e91ea | ||
|
|
f39c7e535b | ||
|
|
51d76a196e | ||
|
|
038b858eb7 | ||
|
|
4e9eccd809 | ||
|
|
8aaff83b8d | ||
|
|
7393325b6e | ||
|
|
db0b408680 | ||
|
|
2af0aea2f6 | ||
|
|
a05260712e | ||
|
|
c0291050be | ||
|
|
3cff5045b2 | ||
|
|
9f1c15a4c4 | ||
|
|
443a60ec54 | ||
|
|
9b31c1f425 | ||
|
|
743c6afe0c | ||
|
|
7bf23e5687 | ||
|
|
8cd005fd89 | ||
|
|
ec14ca2f1d | ||
|
|
f0a42caf45 | ||
|
|
7579ee13d1 | ||
|
|
c153822479 | ||
|
|
7cb940cf36 | ||
|
|
54a8d37f5e | ||
|
|
b101bb2f2d | ||
|
|
f1ce7e2cb3 | ||
|
|
10534fe8b0 | ||
|
|
32e8d4066e | ||
|
|
83837a4511 | ||
|
|
2b5f36019e | ||
|
|
f7b6c78a10 | ||
|
|
ba91dde2e7 |
+1
-6
@@ -17,15 +17,10 @@ IMAGE_URL_ALLOWED_HOSTS=
|
|||||||
BUILD_COMMIT=development
|
BUILD_COMMIT=development
|
||||||
BUILD_TIME=unknown
|
BUILD_TIME=unknown
|
||||||
|
|
||||||
# Optional read-only NAS photo mount; use deploy/docker-compose.photos.yml.
|
# Filesystem/NFS photo sources are retired. Use Synology Photos API or WebDAV.
|
||||||
# NAS_PHOTOS_DIR=/volume2/photo
|
|
||||||
# PHOTO_ROOT=/photos
|
|
||||||
# Optional WebDAV credential reference; prefer a secret file under /config.
|
# Optional WebDAV credential reference; prefer a secret file under /config.
|
||||||
# FRAME_FLOW_SOURCE_CLOUD_FILE=/config/webdav-cloud.secret
|
# FRAME_FLOW_SOURCE_CLOUD_FILE=/config/webdav-cloud.secret
|
||||||
|
|
||||||
# Optional existing group with photo read permission; does not change NAS ACLs.
|
|
||||||
# NAS_PHOTOS_GID=101
|
|
||||||
|
|
||||||
# Optional Synology Photos shared-space people filter. Configure a pinned NAS endpoint.
|
# Optional Synology Photos shared-space people filter. Configure a pinned NAS endpoint.
|
||||||
# Login in the photo-source editor; only the session is saved in /config (mode 0600).
|
# Login in the photo-source editor; only the session is saved in /config (mode 0600).
|
||||||
SYNOLOGY_PHOTOS_URL=
|
SYNOLOGY_PHOTOS_URL=
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: Community checks
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
# GitHub-hosted disposable runner only; never run forks on the NAS runner.
|
||||||
|
if: github.server_url == 'https://github.com'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
|
with:
|
||||||
|
python-version: '3.14'
|
||||||
|
- run: python -m pip install -e '.[test]'
|
||||||
|
- run: python -m ruff check pigway_frame_flow tests scripts
|
||||||
|
- run: python -m ruff format --check pigway_frame_flow tests scripts
|
||||||
|
- run: python -m mypy pigway_frame_flow
|
||||||
|
- run: python -m pytest -q
|
||||||
|
- run: python scripts/ci-audit.py
|
||||||
@@ -8,7 +8,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
verify:
|
verify:
|
||||||
# The NAS Docker runner must never execute code from external forks.
|
# The NAS Docker runner must never execute code from external forks.
|
||||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
if: github.server_url != 'https://github.com' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
DOCKER_PIP_INDEX_URL: ${{ vars.DOCKER_PIP_INDEX_URL }}
|
DOCKER_PIP_INDEX_URL: ${{ vars.DOCKER_PIP_INDEX_URL }}
|
||||||
|
|||||||
@@ -24,3 +24,6 @@ build-cache/wheels/*
|
|||||||
# Private browser captures and platform metadata
|
# Private browser captures and platform metadata
|
||||||
docs/screenshots/
|
docs/screenshots/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Private deployment receipts belong outside public source control.
|
||||||
|
docs/verification/
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Use Python 3.14.x and follow the development commands in README.md. Run Ruff
|
||||||
|
lint/format checks, mypy and pytest before submitting a pull request. Keep changes
|
||||||
|
focused and include a regression test for behavior fixes where practical.
|
||||||
|
|
||||||
|
Use mock mode with device writes disabled. Do not commit `.env`, credentials,
|
||||||
|
private configuration, databases, photos, or production receipts. Tests must not
|
||||||
|
contact real frames or mutate production services.
|
||||||
|
|
||||||
|
GitHub mirrors run community checks on disposable GitHub-hosted runners, including
|
||||||
|
fork PRs. Gitea uses the existing trusted NAS runner and rejects fork PRs; maintainers
|
||||||
|
review contributions before moving them to a trusted branch. Docker build/smoke
|
||||||
|
verification remains in Gitea CI. Production releases are maintainer-controlled;
|
||||||
|
contributors do not need access to production infrastructure.
|
||||||
|
|
||||||
|
For security issues, follow SECURITY.md instead of posting exploit details publicly.
|
||||||
|
Contributions are provided under the project's MIT license; preserve third-party
|
||||||
|
licenses and provenance when introducing external code or assets.
|
||||||
@@ -14,6 +14,7 @@ RUN --mount=type=cache,id=frameflow-bookworm-apt-${TARGETARCH},target=/var/cache
|
|||||||
&& printf 'Binary::apt::APT::Keep-Downloaded-Packages "true";\n' > /etc/apt/apt.conf.d/keep-cache \
|
&& printf 'Binary::apt::APT::Keep-Downloaded-Packages "true";\n' > /etc/apt/apt.conf.d/keep-cache \
|
||||||
&& sed -i "s|http://deb.debian.org/debian-security|https://${DEBIAN_SECURITY_MIRROR}/debian-security|g; s|http://deb.debian.org/debian|https://${DEBIAN_MIRROR}/debian|g" /etc/apt/sources.list.d/debian.sources \
|
&& sed -i "s|http://deb.debian.org/debian-security|https://${DEBIAN_SECURITY_MIRROR}/debian-security|g; s|http://deb.debian.org/debian|https://${DEBIAN_MIRROR}/debian|g" /etc/apt/sources.list.d/debian.sources \
|
||||||
&& apt-get -o Acquire::Retries=3 -o Acquire::https::Timeout=60 update \
|
&& apt-get -o Acquire::Retries=3 -o Acquire::https::Timeout=60 update \
|
||||||
|
&& apt-get -y --no-install-recommends upgrade \
|
||||||
&& if [ -f /build-debs/fonts-noto-cjk.deb ]; then \
|
&& if [ -f /build-debs/fonts-noto-cjk.deb ]; then \
|
||||||
font_version=$(apt-cache policy fonts-noto-cjk | awk '$1=="Candidate:" {print $2}'); \
|
font_version=$(apt-cache policy fonts-noto-cjk | awk '$1=="Candidate:" {print $2}'); \
|
||||||
font_sha=$(apt-cache show "fonts-noto-cjk=$font_version" | awk '$1=="SHA256:" {print $2;exit}'); \
|
font_sha=$(apt-cache show "fonts-noto-cjk=$font_version" | awk '$1=="SHA256:" {print $2;exit}'); \
|
||||||
@@ -37,6 +38,7 @@ RUN --mount=type=cache,id=frameflow-pip-py314-${TARGETARCH},target=/root/.cache/
|
|||||||
else python /build-tools/install.py --locked; fi
|
else python /build-tools/install.py --locked; fi
|
||||||
COPY pigway_frame_flow ./pigway_frame_flow
|
COPY pigway_frame_flow ./pigway_frame_flow
|
||||||
COPY vendor ./vendor
|
COPY vendor ./vendor
|
||||||
|
COPY LICENSE NOTICE.md ./
|
||||||
# Publish only the cleaned filesystem, without retaining build-stage layers.
|
# Publish only the cleaned filesystem, without retaining build-stage layers.
|
||||||
RUN rm -rf /usr/local/lib/python*/site-packages/pip \
|
RUN rm -rf /usr/local/lib/python*/site-packages/pip \
|
||||||
/usr/local/lib/python*/site-packages/pip-*.dist-info \
|
/usr/local/lib/python*/site-packages/pip-*.dist-info \
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 Pigway
|
Copyright (c) 2026 PIGWay
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Notices
|
# Notices
|
||||||
|
|
||||||
Pigway Frame Flow is an independent project. KoKonna is a supported device
|
PIGWay Frame Flow is an independent project. KoKonna is a supported device
|
||||||
integration and is not affiliated with this project unless otherwise stated.
|
integration and is not affiliated with this project unless otherwise stated.
|
||||||
KoKonna names and marks belong to their respective owners.
|
KoKonna names and marks belong to their respective owners.
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# Pigway Frame Flow
|
# PIGWay Frame Flow
|
||||||
|
|
||||||
|
Development currently supports Python 3.14.x. See [contributing](CONTRIBUTING.md) and [security reporting](SECURITY.md).
|
||||||
|
|
||||||
[简体中文](README.zh-CN.md) | English
|
[简体中文](README.zh-CN.md) | English
|
||||||
|
|
||||||
Pigway Frame Flow is a lightweight, self-hosted platform for programmable smart-frame automation. It runs independently on a NAS or server, with its own Web UI, image library, rendering, scheduler and REST API. KoKonna is a supported device integration; external applications do not need its credentials or SDK.
|
PIGWay Frame Flow is a lightweight, self-hosted platform for programmable smart-frame automation. It runs independently on a NAS or server, with its own Web UI, image library, rendering, scheduler and REST API. KoKonna is a supported device integration; external applications do not need its credentials or SDK.
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
@@ -24,7 +26,7 @@ docker compose up -d
|
|||||||
|
|
||||||
Open `http://127.0.0.1:6005` and sign in with the account you created. The browser uses an HttpOnly session cookie; it does not require an API token. Remembered sessions last 30 days. Change the password or sign out in Settings. `FRAME_FLOW_TOKEN` authenticates external API clients and is separate from the KoKonna API key.
|
Open `http://127.0.0.1:6005` and sign in with the account you created. The browser uses an HttpOnly session cookie; it does not require an API token. Remembered sessions last 30 days. Change the password or sign out in Settings. `FRAME_FLOW_TOKEN` authenticates external API clients and is separate from the KoKonna API key.
|
||||||
|
|
||||||
The published interface defaults to loopback. For LAN access, explicitly set `WEB_BIND` to your NAS LAN address and choose an available `WEB_PORT`. See [Synology deployment](docs/NAS.md), [build mirrors and caching](docs/BUILDING.md), and [Gitea deployment/rollback](docs/deployment/GITEA_CD.md). No firewall, router or reverse-proxy settings are changed automatically.
|
The published interface defaults to loopback. For LAN access, explicitly set `WEB_BIND` to your NAS LAN address and choose an available `WEB_PORT`. See [Synology deployment](docs/NAS.md), [build mirrors and caching](docs/BUILDING.md). No firewall, router or reverse-proxy settings are changed automatically.
|
||||||
|
|
||||||
For a local mock demo, use Python 3.14:
|
For a local mock demo, use Python 3.14:
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ Synology Photos API sources work without `/photos` or NFS mounts, with shared/cu
|
|||||||
- Seven templates: Daily Dashboard, Weather, Calendar, Quote, Photo + Text, Daily Poetry and System Alert.
|
- 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.
|
- 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.
|
- 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 folder and filter settings. People filtering uses one shared Synology Photos sign-in; WebDAV and cloud-drive credentials belong to their respective sources.
|
- Synology Photos person filtering, WebDAV and optional cloud-drive connectors. Each source has its own folder and filter settings. People filtering uses one shared Synology Photos sign-in; WebDAV and cloud-drive credentials belong to their respective sources.
|
||||||
- Optional weather, calendar, monitoring, AI, MQTT and webhook integrations. Provider configuration is required; an unconnected provider is not a working connection.
|
- Optional weather, calendar, monitoring, AI, MQTT and webhook integrations. Provider configuration is required; an unconnected provider is not a working connection.
|
||||||
|
|
||||||
Templates **render images**, including text-only templates. Choose a template, edit its data, generate a preview, then display it. Preview generation and saving template settings do not update the frame. Photo + Text and Daily Dashboard can include a selected local photo; other templates do not automatically choose one. Scheduled template jobs render afresh on each run. See [photo sources](docs/PHOTO_SOURCES.md), [automatic collages](docs/AUTOMATIC_COLLAGE.md) and [integration setup/limits](docs/EXTENSIONS.md).
|
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).
|
||||||
@@ -93,3 +95,5 @@ Tests mock cloud/device calls. CI also verifies an isolated Docker build, readin
|
|||||||
Cloud synchronization does not prove the physical pixels refreshed. KoKonna cloud uploads activate immediately, so upload-only uses local staging. Native slideshow membership editing is unavailable; avoid running native and Frame Flow playback simultaneously. Smart cropping is a saliency heuristic, not guaranteed face detection. The service uses one worker and is not designed for multiple replicas. Browser OAuth, AI daily-art scheduling, additional calendar recurrence features and more device drivers remain roadmap items.
|
Cloud synchronization does not prove the physical pixels refreshed. KoKonna cloud uploads activate immediately, so upload-only uses local staging. Native slideshow membership editing is unavailable; avoid running native and Frame Flow playback simultaneously. Smart cropping is a saliency heuristic, not guaranteed face detection. The service uses one worker and is not designed for multiple replicas. Browser OAuth, AI daily-art scheduling, additional calendar recurrence features and more device drivers remain roadmap items.
|
||||||
|
|
||||||
Related projects: [PIGWay Raspberry Pi Agent](https://tea.pigway.com/way/pigway-raspberry-pi-agent) · [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) · [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat)
|
Related projects: [PIGWay Raspberry Pi Agent](https://tea.pigway.com/way/pigway-raspberry-pi-agent) · [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) · [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat)
|
||||||
|
|
||||||
|
[Documentation](docs/README.md)
|
||||||
|
|||||||
+9
-3
@@ -1,4 +1,6 @@
|
|||||||
# Pigway Frame Flow
|
# PIGWay Frame Flow
|
||||||
|
|
||||||
|
开发环境目前支持 Python 3.14.x。参见[贡献指南](CONTRIBUTING.md)和[安全问题报告](SECURITY.md)。
|
||||||
|
|
||||||
简体中文 | [English](README.md)
|
简体中文 | [English](README.md)
|
||||||
|
|
||||||
@@ -24,7 +26,7 @@ docker compose up -d
|
|||||||
|
|
||||||
打开 `http://127.0.0.1:6005`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。
|
打开 `http://127.0.0.1:6005`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。
|
||||||
|
|
||||||
默认端口只绑定本机回环地址。需要局域网访问时,显式将 `WEB_BIND` 设置为 NAS 的局域网地址,并通过 `WEB_PORT` 选择空闲端口。参见[群晖部署](docs/NAS.md)、[构建镜像源与缓存](docs/BUILDING.md)和 [Gitea 部署与回滚](docs/deployment/GITEA_CD.md)。不会自动修改防火墙、路由器或反向代理。
|
默认端口只绑定本机回环地址。需要局域网访问时,显式将 `WEB_BIND` 设置为 NAS 的局域网地址,并通过 `WEB_PORT` 选择空闲端口。参见[群晖部署](docs/NAS.md)、[构建镜像源与缓存](docs/BUILDING.md)。不会自动修改防火墙、路由器或反向代理。
|
||||||
|
|
||||||
使用 Python 3.14 运行本地 Mock 演示:
|
使用 Python 3.14 运行本地 Mock 演示:
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ Synology Photos 可使用独立的 API 来源,无需 `/photos` 或 NFS 挂载
|
|||||||
- 七种模板:家庭早报、天气、日历、一句话、照片与文字、每日诗词、系统告警。
|
- 七种模板:家庭早报、天气、日历、一句话、照片与文字、每日诗词、系统告警。
|
||||||
- 拼图工作室支持 1–12 张图片的拼图与海报;可选 OpenAI 兼容接口排版,以及从数据源自动选图拼接。
|
- 拼图工作室支持 1–12 张图片的拼图与海报;可选 OpenAI 兼容接口排版,以及从数据源自动选图拼接。
|
||||||
- 绑定设备的定时任务、顺序/随机播放列表、带优先级的临时覆盖、恢复与持久化操作历史。
|
- 绑定设备的定时任务、顺序/随机播放列表、带优先级的临时覆盖、恢复与持久化操作历史。
|
||||||
- 只读 NAS 文件夹、Synology Photos 人物筛选、WebDAV 和可选网盘连接器;每个数据源独立配置、独立授权。
|
- Synology Photos 人物筛选、WebDAV 和可选网盘连接器;每个数据源独立配置、独立授权。
|
||||||
- 可选天气、日历、监控、AI、MQTT 与 Webhook 集成。需要配置对应 Provider;尚未授权的数据源不会被当作已连接。
|
- 可选天气、日历、监控、AI、MQTT 与 Webhook 集成。需要配置对应 Provider;尚未授权的数据源不会被当作已连接。
|
||||||
|
|
||||||
模板会**生成图片**,纯文字模板也是如此。使用顺序是:选择模板 → 编辑内容 → 生成预览 → 显示到画框。生成预览和保存模板设置都不会更新画框。“照片与文字”和“家庭早报”可以包含指定的本地照片,其他模板不会自动选图;定时模板任务每次运行会重新渲染。参见[照片数据源](docs/PHOTO_SOURCES.md)、[自动拼图](docs/AUTOMATIC_COLLAGE.md)和[集成配置及限制](docs/EXTENSIONS.md)。
|
模板会**生成图片**,纯文字模板也是如此。使用顺序是:选择模板 → 编辑内容 → 生成预览 → 显示到画框。生成预览和保存模板设置都不会更新画框。“照片与文字”和“家庭早报”可以包含指定的本地照片,其他模板不会自动选图;定时模板任务每次运行会重新渲染。参见[照片数据源](docs/PHOTO_SOURCES.md)、[自动拼图](docs/AUTOMATIC_COLLAGE.md)和[集成配置及限制](docs/EXTENSIONS.md)。
|
||||||
@@ -69,6 +71,8 @@ Web UI / Scheduler / REST API → DisplayService → FrameDeviceDriver → KoKon
|
|||||||
|
|
||||||
Ticktar 集成为可选项,Frame Flow 不依赖 Ticktar 启动,也不访问其数据库。参见[外部集成契约](docs/integrations/TICKTAR.md)。
|
Ticktar 集成为可选项,Frame Flow 不依赖 Ticktar 启动,也不访问其数据库。参见[外部集成契约](docs/integrations/TICKTAR.md)。
|
||||||
|
|
||||||
|
Console 监控接入提供独立只读 ZIP 插件,支持服务就绪、画框在线/电量及任务最近结果,见 [Console 插件安装说明](https://tea.pigway.com/way/pigway-console-adapter-frame-flow/src/branch/main/README.zh-CN.md)。
|
||||||
|
|
||||||
### 存储与安全
|
### 存储与安全
|
||||||
|
|
||||||
Docker 持久化 `/data/frameflow.db`、`/data` 下的图片/生成文件/缓存,以及 `/config` 下的私有配置和会话文件。容器以 UID/GID 10333 运行,根文件系统只读,使用独立桥接网络、`unless-stopped` 重启策略和有大小限制的标准输出日志;不需要 privileged、host network 或 Docker socket。
|
Docker 持久化 `/data/frameflow.db`、`/data` 下的图片/生成文件/缓存,以及 `/config` 下的私有配置和会话文件。容器以 UID/GID 10333 运行,根文件系统只读,使用独立桥接网络、`unless-stopped` 重启策略和有大小限制的标准输出日志;不需要 privileged、host network 或 Docker socket。
|
||||||
@@ -93,3 +97,5 @@ KoKonna 是支持的设备集成。本项目与 KoKonna 无隶属关系,也不
|
|||||||
云端同步不等于已确认物理屏幕刷新。KoKonna 云端上传会立即切换,因此“仅上传”使用本地暂存。官方接口不支持编辑原生轮播成员,应避免原生轮播与 Frame Flow 播放同时运行。smart 裁切采用显著性启发式,不保证识别人脸。当前服务使用单 Worker,不面向多副本部署。浏览器 OAuth、AI 每日艺术调度、更多日历重复规则及设备驱动仍属于后续计划。
|
云端同步不等于已确认物理屏幕刷新。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)
|
相关项目:[PIGWay Raspberry Pi Agent](https://tea.pigway.com/way/pigway-raspberry-pi-agent) · [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) · [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat)
|
||||||
|
|
||||||
|
[文档目录](docs/README.md)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# pigway-frame-flow v0.1.20
|
||||||
|
|
||||||
|
统一开源许可证、贡献和安全反馈入口;完善当前版本的文档导航、源码分发和校验和。插件 ZIP 携带许可证。运行功能边界保持项目首页所述。
|
||||||
|
|
||||||
|
本轮 585 项 Python 测试通过。源码语法、文档入口、打包和校验和检查通过;生产运行状态以实际部署验收为准。
|
||||||
|
|
||||||
|
Ruff、格式、mypy、25 项锁定依赖的漏洞检查通过;AMD64 Docker 构建和两轮隔离启动/重启只读冒烟验证通过。
|
||||||
|
|
||||||
|
Docker 构建增加发行版安全更新,清除可修复系统包告警;仍有上游未修复包级告警,分析及默认部署边界见 [安全说明](SECURITY.md),不宣称映像零漏洞。
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# Security policy
|
||||||
|
|
||||||
|
Security fixes target the latest release. Older releases have no guaranteed
|
||||||
|
backport support. Upgrade before verifying whether an issue is still present.
|
||||||
|
|
||||||
|
Do not publish credentials, private photos, production logs, or working exploit
|
||||||
|
payloads in public issues. Contact the repository owner privately through the
|
||||||
|
hosting platform when available. If no private contact channel is available,
|
||||||
|
open an issue asking for a confidential reporting channel without technical
|
||||||
|
vulnerability details. There is currently no dedicated security mailbox or
|
||||||
|
promised response SLA.
|
||||||
|
|
||||||
|
A useful private report includes the affected version, deployment mode, required
|
||||||
|
permissions, impact, and minimal steps using mock data. Do not test on another
|
||||||
|
person's deployment or write to real frames without explicit authorization.
|
||||||
|
|
||||||
|
## 当前容器基础系统扫描
|
||||||
|
|
||||||
|
2026-10-18 使用 Trivy 0.76.0 扫描当前映像。在构建阶段应用 Debian 安全更新后,可修复的 HIGH/CRITICAL 项已清除;仍有 63 条发行版包级告警,合并为 16 个 CVE,当前 Bookworm 无可用修复版本。不能将 Python 依赖检查通过描述为整个映像零漏洞。
|
||||||
|
|
||||||
|
- [CVE-2023-45853](https://security-tracker.debian.org/tracker/CVE-2023-45853) 针对 MiniZip;Debian 明确 Bookworm 的 zlib 未构建相应代码,本映像也未安装 MiniZip。
|
||||||
|
- [CVE-2025-7458](https://security-tracker.debian.org/tracker/CVE-2025-7458) 需要攻击者执行特制 SQL;Frame Flow 不接受任意 SQL 或上传外部 SQLite 数据库。FTS5 相关 [CVE-2026-11822](https://security-tracker.debian.org/tracker/CVE-2026-11822)、[CVE-2026-11824](https://security-tracker.debian.org/tracker/CVE-2026-11824) 的匹配查询也未被应用使用。此结论不适用于自行替换数据库或修改服务 SQL 的部署。
|
||||||
|
- 其他告警涉及挂载/命名空间工具、systemd-homed、ACL、ncurses、Perl/LZH CLI 及 DTLS;应用未调用这些路径。默认 UID/GID 10333、只读根目录、清空 capabilities、禁提权和独立数据卷仍是必要边界。
|
||||||
|
|
||||||
|
本轮未发现这些遗留项在默认应用路径中的可达利用入口,不将该判断扩展为基础系统已修复。后续基础映像更新仍须复扫;不得为消除扫描数字而混装其他 Debian 发行版包或删除许可声明。
|
||||||
+228
@@ -0,0 +1,228 @@
|
|||||||
|
c2792b2c75880ff09822804632b1b3b6dd6190b38841b8c3567bb1b7e7b59a8c .dockerignore
|
||||||
|
fe4b8e57c14d6f4df261de0ec274f33153def558227a47f38c3f91d5cfc77f80 .env.example
|
||||||
|
0c7f4585d62a8d35bfc22e0bebdf7a479a179122b2b092bfe434e3038cddd3d5 .gitattributes
|
||||||
|
b45d35d737f119a70bf40cb1f9626ff80772ed7913cb35f6e9b870d6ada01a15 .github/workflows/community.yml
|
||||||
|
c93afa220096b0a3a27115e74617db9dbe97e98c6a3735a0abfb528cdca7e668 .github/workflows/verify.yml
|
||||||
|
342242379194ca16021542722407a42858f22685293d16162d0e6fe004cbd62c .gitignore
|
||||||
|
4b4ed4926a350cbeb71436541b4566e4ba7d2494ac95236b88827a9f5dd171c2 CONTRIBUTING.md
|
||||||
|
6588694aee24d5e662061be78027a049f6726d50dd99ef00a60fba05051ef299 Dockerfile
|
||||||
|
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
|
||||||
|
ef0d1ecd6427325fc92324032fbf7b880624a68629acbaf430cd8be80c73b698 NOTICE.md
|
||||||
|
26ad0d9063c0ee843f0f532604b8fd66e8b39fd72d129595ed078677c0d6d13c README.md
|
||||||
|
ee00cb267b38485f1e574d610f0a57b51d4ae575731a18e57d06a7c43dc0a8b0 README.zh-CN.md
|
||||||
|
3c0ab38958a3bdb17ee95b5642c2ee691affc3da44252a5c4a3048ebf7860fe4 RELEASE_NOTES.md
|
||||||
|
d1233db9c8da6d391028e1e515813cd45ce8d8f97e48087daa325a61789987bc SECURITY.md
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/debs/.gitkeep
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 config/.gitkeep
|
||||||
|
a897bf4a9f95f3e7b96b286a4bf7d124bb4a50fd47a16c6a3d9fbb70bce47f15 deploy/Dockerfile.runtime-update
|
||||||
|
4cc0b0813f527ac0ca311993f791005de5c8e89e6810ecbf5cf058e58e2fe572 deploy/build-cn.env
|
||||||
|
3683a3cfb63f08cc9e6144162117ab68c607d38bdeaae0e82cb418e812a450f2 deploy/compose.ubuntu-photos-api.yml
|
||||||
|
d68d727e01c592c098fde71a900bcdd234a9ca6610d0799019a6f585983b4494 deploy/devices.yml.example
|
||||||
|
fd53125a5359dcae1d81e95b8447aac755270f9aeb354d3e1140d7fccb57c42e deploy/docker-compose.photos.yml
|
||||||
|
4464545a9d91bbc9fff9cd9941db63f44fa61120caca426687d1ed06635cc3e6 deploy/organized-photos-source.json
|
||||||
|
582dae3db4a08192d714e81e5e8234b170b4ac4231faf251b2c26eeec54f0942 docker-compose.yml
|
||||||
|
30d3e705870ebb012ce122bd7d8f95192d86664c4d2bb45a3169c86988fbaf72 docs/AUTOMATIC_COLLAGE.md
|
||||||
|
6b602b686db5b25060f91cfecf05a840a09ee5b2a6d5099636cb3f47880a4a4d docs/BUILDING.md
|
||||||
|
c14983d5c65775ad80244fcbb77fe5c0c4a413cc27945b7184bbc923a53d38a8 docs/EXTENSIONS.md
|
||||||
|
1d8c5f79eff3115b1cd13d8f63fff1332b80d1fe1bec6b0f2713b5f80b0cbf67 docs/KOKONNA_RECOVERY.md
|
||||||
|
3b4d24d633dea8474a6a7b8868c5afdd37a5205df32356d3967cf838bcafdba9 docs/NAS.md
|
||||||
|
3280bddcbe2d2721bb7efeb0eb2ec03d9d5ef867c69f3e70066b69f1ad5926e2 docs/PHOTO_SOURCES.md
|
||||||
|
edf44436fd044561539c48480c481e66c1a38164f51ba5226cde04c756f7bacb docs/PHOTO_SOURCE_CONNECTIONS.md
|
||||||
|
4b00d08eb5a2425b205af13b87f9be04dd6870a734b42463a3e075658039dac9 docs/PUBLICATION.md
|
||||||
|
5fe4641e75e0746929f71256c6ff11116c777cca8d4288ccc2d7aee91bd7a6ac docs/README.md
|
||||||
|
5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md
|
||||||
|
bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md
|
||||||
|
6772ddfd79b28bb52ce469e40188f8781f66275bdf4b84861f81c32a87aea6eb docs/SYNOLOGY_PHOTOS_API.md
|
||||||
|
ac0481196e593ff9cca2e5133ff41fb3614268e6a10728a0deca3ad3bfb80b34 docs/integrations/TICKTAR.md
|
||||||
|
757a4d87aec789054dc5268d314cd218f6d7cab49082cfacef55f9860d2362b0 docs/openapi.json
|
||||||
|
54bf7061e0626900ce35e19e81efc5c32c8fd6a0438bdad68f494e1ce81616d4 docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md
|
||||||
|
a07cbe71571c6e2d5a1b94897bd63321c74675c05d15438c4964eef39db0fd76 docs/research/KOKONNA_CONTENT_SUBSCRIPTIONS.md
|
||||||
|
ec53d587b855be84283c50f8fb45be62a4c1caba78b28d436a8260fd171943a2 docs/research/KOKONNA_LOCAL_CONTROL.md
|
||||||
|
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__init__.py
|
||||||
|
632e8be140d1c206388b84e6e08039fc42d6449e83ea23b58d7a57c3404a8747 pigway_frame_flow/api/app.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py
|
||||||
|
fbadf6beaf25b9670dcbf125ec9d192188cf9e076ee4d4a0b5decdaf3834417c pigway_frame_flow/core/accounts.py
|
||||||
|
818e8fb29d22c13159a6cb22bcbc6b9e211a8a1301588126eb192ac4ed71441b pigway_frame_flow/core/auth.py
|
||||||
|
8f2220fa90b0dc2e45df2023dc5ac695efe55361644ef266829553c99151949b pigway_frame_flow/core/registry.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/display/__init__.py
|
||||||
|
81effa34d6c452579cfa65781909f7c5a9e8ddad136e4c47ee0704939851879a pigway_frame_flow/display/cloud_library.py
|
||||||
|
558d2e2111e3dea1917eaa4f81d7c2dac3b47af3eda1cdc9426af4bf9bc570dd pigway_frame_flow/display/recovery.py
|
||||||
|
e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py
|
||||||
|
1ee9ee8745dc497dadcc20e5e238c78f6c228f7f8a4ed310ced32f9be55b512e pigway_frame_flow/display/service.py
|
||||||
|
0975b24488be19f24e768c4c76d9f3c0348417c1351c4e410897da00f7be6656 pigway_frame_flow/display/upload_reconciliation.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/drivers/__init__.py
|
||||||
|
9a8386ef368a6df7fd8cb23bcc442c28e784729a5ce4a78911ad86245d5c5794 pigway_frame_flow/drivers/base.py
|
||||||
|
54cff8782e21e2ee4b8540259a4868380c58a0aa656c9d23796a049ab96a1b4f pigway_frame_flow/drivers/generic_http.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/drivers/kokonna/__init__.py
|
||||||
|
32d221f49ad28a309941856a8b1e2ba1806ec90b80e48ff51aefd2b5466cb8b5 pigway_frame_flow/drivers/kokonna/client.py
|
||||||
|
74306c44ef49223d5b2011fb667d4e19a5c8974742076df75d8a6196a96ac7f4 pigway_frame_flow/drivers/kokonna/driver.py
|
||||||
|
58c7daeaea8eb942d9e5d4f4d484e624b99c6c986a530f6af22cf05cf40cceb7 pigway_frame_flow/drivers/kokonna/mock.py
|
||||||
|
1a139e6f5169c23ce8339fe35fb39bb104a8f0a45f8c1222eda9996df459dd5d pigway_frame_flow/drivers/kokonna/widgets.py
|
||||||
|
f548a5a2b01d298eb3543cf6a1ac41177357bc8eb81b5578293347a15310b700 pigway_frame_flow/errors.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/extensions/__init__.py
|
||||||
|
4655fb6cdf9550efd90d33b8de64511d8d6ff5a96d19782fc52d99498fe2a6c0 pigway_frame_flow/extensions/ai_images.py
|
||||||
|
394459895a060f43d0ace27a4be0ea06ba1590df5de026b5661b06d42f2b31f9 pigway_frame_flow/extensions/api.py
|
||||||
|
cf05d8bf20ad1ddb399ba0fc244fe1f36c895133579660f5ab7fc6716f9e66fc pigway_frame_flow/extensions/auto_collage.py
|
||||||
|
46dab9596ba605da4d306ea941b7f19c4f3cde1f714fe1889c9f15bd8c087e42 pigway_frame_flow/extensions/device_events.py
|
||||||
|
d463465ca3aea449e67386bc84a5f25b58535f74d89d7858c5b9bca0d93189d3 pigway_frame_flow/extensions/layout.py
|
||||||
|
13bea510a6b5ae1958b3e095936429516c3e3bee6b06c3676a7eb31f311766e0 pigway_frame_flow/extensions/mqtt.py
|
||||||
|
95da4664d2ceb47ce8f45184f9924d8e61c63bb54b09e1844c55dfe35beb83db pigway_frame_flow/extensions/official.py
|
||||||
|
6d70e410d3559f370e4489aa92b54eea91d11407e8039c9adf31612de0456df1 pigway_frame_flow/extensions/providers.py
|
||||||
|
ab3081bd1afd2a4cd501355f10771cd92f3432439d9bc240d0790c2e76c2a508 pigway_frame_flow/extensions/quality.py
|
||||||
|
8e7d7da457d3b82811dc98cf6c3a93fcd7cbe8a4addb5682f48b1acd4983de82 pigway_frame_flow/extensions/secrets.py
|
||||||
|
49f2d6f7a35675fedaf03ca648f046bde6e49aebb21d6e7afb39f1b8a523fbb2 pigway_frame_flow/extensions/storage.py
|
||||||
|
1f2db3a58e7ebe5a4531f4550356fc9bcb331a2701f0bc25edf0ea8b13a57d97 pigway_frame_flow/extensions/webhooks.py
|
||||||
|
b0855f8d22665fb208dfe47856f374b76a3861c15efb312d4ada81e84fee64e5 pigway_frame_flow/integrations/__init__.py
|
||||||
|
d3025e294466d72c9332a7c577c3262816c6a12d12c7c2f0c241a7e423034eed pigway_frame_flow/integrations/kokonna_official/README.md
|
||||||
|
21536166e5fe9e15bfd4ea4865c85bcbdf79838aedc002e6c3148b1667bc8519 pigway_frame_flow/integrations/kokonna_official/__init__.py
|
||||||
|
a22723f461a9b420d1dc11ea0b794a41bb4621771b52465db21cdc0f199d51e8 pigway_frame_flow/integrations/kokonna_official/capabilities.py
|
||||||
|
c2ac7edcc5112131f093a9a33b31d632186d5ca7517ca6b26407c8099bdcba03 pigway_frame_flow/integrations/kokonna_official/connection.py
|
||||||
|
a518965aa4db9abeb60865c315229c3053c8e9edd26d80f24dc0af0528bad9e6 pigway_frame_flow/integrations/kokonna_official/rpc.py
|
||||||
|
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 pigway_frame_flow/integrations/kokonna_official/widget_catalog.json
|
||||||
|
aea594fb4e4c9a6e2094d4d7973eb627ac02fb632811b2d1a558a0ee1cd12e77 pigway_frame_flow/integrations/kokonna_official/widgets.py
|
||||||
|
1fe53f4bfb407b8db774fe730131ecf1213e16d752a3506c4c56b46365ea14e8 pigway_frame_flow/main.py
|
||||||
|
fb41e20b181815a62e213a1d179f6479b155586fc7000c0a103737ddc16b1c83 pigway_frame_flow/models.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/providers/__init__.py
|
||||||
|
b7a6cb8eb40d8dedfbce18777f8fbd433a174f14bcdadf498bd834132aa568fd pigway_frame_flow/providers/cloud_drives.py
|
||||||
|
58b070f0c814d639a4a912d079479d751d459816d59f2199d0be03e4d573ec3b pigway_frame_flow/providers/external.py
|
||||||
|
e90b6becc3e19baa83fab763cb156eb8882a96594c04040305dcb315f68e85d0 pigway_frame_flow/providers/ical.py
|
||||||
|
69e7419e67e8416bf73f37e257a2586d2a1aa4d2706da8f7b42c5cb8ca7f1eb3 pigway_frame_flow/providers/oauth.py
|
||||||
|
abff8ee4b4eb1575451bcd5addf54451056af665d7aaa90173b5ed69a75a4d8e pigway_frame_flow/providers/photo_names.py
|
||||||
|
7d6fd8ff3ae99798b26d7a82644353e2c9ce5439e18c64393dc261c395ac6f4c pigway_frame_flow/providers/photo_orientation.py
|
||||||
|
322cf299b1811b61636771b925aacfd57aa1cab0b0d84a245b70a2989eda66c0 pigway_frame_flow/providers/photo_sources.py
|
||||||
|
114adfb3fcd1ab050c1dd2f1fff9d91fdde192b8eced907dee3bc8086037e13d pigway_frame_flow/providers/photos_cache.py
|
||||||
|
119427bc435ec8d4c91114713f37d07f22067eaeec391261d1441a1436baf5e0 pigway_frame_flow/providers/poetry.py
|
||||||
|
c44bf2b4a3776bebe3689b251ae3e8c352a43088829ae8c3f6fd379b64dea05e pigway_frame_flow/providers/synology_photos.py
|
||||||
|
7dfc5eab241122c4758d8b0c027369049231451dc8e0e1b49f7da56532a8e87d pigway_frame_flow/providers/weather.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/renderers/__init__.py
|
||||||
|
ce1e3d9dd7d8dc8d76d6206ab9cd05974bd2e81e1bbf28711f4b461332bf9236 pigway_frame_flow/renderers/frame_geometry.py
|
||||||
|
34a22709b300e7e36813d37ce449cebe5a5051bf6711c2c28f59a649b5e0bdc7 pigway_frame_flow/renderers/images.py
|
||||||
|
b70197edc25101b7ee73434b253ae947cddc8a9d3e55545369ac7189e117b2a2 pigway_frame_flow/renderers/photo_caption.py
|
||||||
|
241f7d80997a6b6c7d421723d5375d4890f64eea8c70680abefb0fa5d15b9502 pigway_frame_flow/renderers/photo_layout.py
|
||||||
|
d36258296d04b6575d78dd9831f8d3f66cf8b1e9182e2e90a589d10fa0067a04 pigway_frame_flow/renderers/templates.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/scheduler/__init__.py
|
||||||
|
ee80352e10eb871293bd8700837fab8d5f2e0da777d22e32811e935cf7572edc pigway_frame_flow/scheduler/engine.py
|
||||||
|
d54facc006da1345a150780c414830efaa2e0ab75fa0c9673cf5d39e912472b1 pigway_frame_flow/scheduler/upload_retries.py
|
||||||
|
77464a08816507a2423f08327c7767a9c019f55ea8f232856fa647e72e1e0246 pigway_frame_flow/settings.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/storage/__init__.py
|
||||||
|
36cfc19b9583809f59a2276da3ba0f023a747013b6903d3a11fbb5941931681a pigway_frame_flow/storage/database.py
|
||||||
|
44c0c47d852ffd80cb05969965ee216479c1047daffabf9c3bdc8fe2b036db12 pigway_frame_flow/templates/calendar.json
|
||||||
|
1b01fcda4b1cba11c3b152dfc2223d4119f1879ddd0b7eaf6a2e2731a6f6bf6d pigway_frame_flow/templates/collage.json
|
||||||
|
7bfe623e0569beae25b44261d949679c95d42c8bd095ef6b5cca25cf1b0d4c40 pigway_frame_flow/templates/daily-dashboard.json
|
||||||
|
f9e777980ea5bb3ea8fb2357f23530802811ea2674d25100af9b728cbc9f83a9 pigway_frame_flow/templates/daily-poetry.json
|
||||||
|
990181518d89d8a7c5021e477849eea387906b45fe2e2db3735b85349974d0c8 pigway_frame_flow/templates/photo-text.json
|
||||||
|
2fcc77fce9d50475b822ea27345f350525adae9bce76613f11fc097797eaf7d6 pigway_frame_flow/templates/poster.json
|
||||||
|
8f37296f52a88c11a000a5a0767310354550da34e1d4c0c7260393ec00cd0d9b pigway_frame_flow/templates/quote.json
|
||||||
|
82c17f4e7eaac95483592add181f549186dea9d7c0375fa9cad3554e3ccd1794 pigway_frame_flow/templates/system-alert.json
|
||||||
|
d6f705b9d183b57d50759e283db2161f8493c4e3969459e74da635252014c925 pigway_frame_flow/templates/weather.json
|
||||||
|
c43faa235848bec6370d7a3d8a7b3475cbd8eb4a6e494518dfaf160ca8f77bfa pigway_frame_flow/web/app.js
|
||||||
|
d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js
|
||||||
|
fe19e508ad60fab0ab4ca61f5d966f8340143e89f1572de00e78b3f1fed8384b pigway_frame_flow/web/features.js
|
||||||
|
3c4e3be6321d40547d45724fc3c54af090ebbbc2a41dec717966596577c72c5d pigway_frame_flow/web/i18n.js
|
||||||
|
0ff0e5c80f6cfb3dc71a63813e01fd8cde5355b0f135a4fbb2781d43383a569f pigway_frame_flow/web/index.html
|
||||||
|
0609e796b5ca8346405b1bb6e65ac9f88ee163f7a93024d96d54d4e6ab96ba41 pigway_frame_flow/web/style.css
|
||||||
|
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE
|
||||||
|
050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js
|
||||||
|
1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css
|
||||||
|
a363adb54463d0345cd4e8d0ebe6cac758856e8c0e1d4da49468ddee1ca1aec6 pyproject.toml
|
||||||
|
d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock
|
||||||
|
ae264ad19391bb2515def85963f39458cfa75a8611ff54e5329106c35bd8cc6e scripts/build-verify.sh
|
||||||
|
43e8345ac5f29c2a5d1fa751c0e7c963b00b5391817393541e72c52919f0f2b4 scripts/check-compose-remote.py
|
||||||
|
8186d4d9c2542d42d786404b096fbbdee22e4c74edae78ddebab5bd3f632764c scripts/ci-audit.py
|
||||||
|
b0ec44fba0adfceca3655628021683030469be77c9e303c3f5cf830e8fd0cee3 scripts/ci-compose-verify.py
|
||||||
|
6ebe82488d7eb2f10f69cf8bf4ed4e1ee272a216413fc5be1329f05889ed49a7 scripts/ci-install.py
|
||||||
|
a11e660a0343f2631c1a3dd0aa28471db827fb1f43d1af4217f51209aa3afe32 scripts/create-account.py
|
||||||
|
cdabf30144ea089ae8e64228f6762951f53d4fe8c191673fb5676331d249473d scripts/migrate-photos-api.py
|
||||||
|
80813607835c19d321085fac4cabb17d865d98ed62a09b7f45195ac48bec94a8 scripts/package-nas.py
|
||||||
|
b0b877cfbb2bdf830e2e41097c2057538bb9d0b61a3c39952fe505d910cd5c29 scripts/persistence-check.py
|
||||||
|
3213ef2c17dbcf7423b04440794b842c691dc65616d56c1e1a85dd286ee34ada scripts/production-deploy.py
|
||||||
|
11627ecbd8a3be01aa079bdf6166b4368e670f5ad4a165b5971754b8187bbc2c scripts/run_local.py
|
||||||
|
0fe19e94ffa4e9ff56d4f4a079b19f74991b35f0e18679ac793e8de38ecc3833 scripts/server-release.py
|
||||||
|
1fc16c43485a60a05012322cb1e4a219309dbd7e11f002f510602beea743ce30 scripts/smoke-test.py
|
||||||
|
1236d3b2d27559cbe73a27dcf56248ef3c60404c6fb21453caa7225570d4ac45 scripts/ui-audit-regressions.cjs
|
||||||
|
57a79a534a71442f5e3a70ed9ff96f426007d9f6dd0bb9b1f5d79eac773d2254 scripts/ui-auto-collage.cjs
|
||||||
|
5b61836a89db45cbd22274b9e0b035283d29d4b4926c05e203bd5da85a3bb270 scripts/ui-copy-audit.cjs
|
||||||
|
6b602f617ba845fb64335ececc90ca3fac46073f13555707652f2f080be73b2e scripts/ui-dashboard-preview.cjs
|
||||||
|
67b87d9155b74e2591ab459cc785aa4f2ff6e30c0ad6acef297593261f34ed00 scripts/ui-gallery-bootstrap.cjs
|
||||||
|
f981da8a8b4a043b4d39686feadd0e107e008c4e9e6804c45b92607777d2dbbc scripts/ui-gallery-current.cjs
|
||||||
|
01991b94630c9b4af9f52d304b3b7e7b121c59e32d5765f778b53d238cbc6c06 scripts/ui-gallery-destinations.cjs
|
||||||
|
bc47ba7a196d84a155d9df5f0e2e23e892256979faa435e01b0a63816562fa01 scripts/ui-gallery-stars.cjs
|
||||||
|
02bae6aea8e9b6aef99afc690ad6ddf1fe796501fd83667109cedfe67d5c2586 scripts/ui-history-layout.cjs
|
||||||
|
cb0ccb04e087e2117f2a2e7ebe7f1a6b116e39e2bfc197ea996675ef628c98ae scripts/ui-history-localization.cjs
|
||||||
|
1f87c8b0c6d403c96bf91ca62b6679d1ccdfa3d8cca3f13a006d73f669fe7b09 scripts/ui-image-loading.cjs
|
||||||
|
742197504264a547bcf5f121ed7fae447d8d31f51672d41278b4b984e94b5129 scripts/ui-integrations.cjs
|
||||||
|
66a12251ea36f372072923ee0b8c7bfebac222fe1dbb702b22242a3859512a9a scripts/ui-interval-schedule.cjs
|
||||||
|
70837acf16586f2c97e4e0ba30c1de00484b12113caeedda54ddb226ccaad510 scripts/ui-layout-audit.cjs
|
||||||
|
39969a70bc473ff3f7496490a2096a0de4aaf98e389953b2be4a9bd1fef3f006 scripts/ui-layout.cjs
|
||||||
|
b8b6c94cce58efbcb97dd33fc9ce3f3525be1349679f0cf543a6c61af4bd7e6f scripts/ui-login-layout.cjs
|
||||||
|
8574025d0b22f653c58846bdbb81cc10b4a7a7ca515d8e6b462083f6ca6aa39a scripts/ui-official-connection.cjs
|
||||||
|
98a03f5fb7aeead34a50ad6c17ee4cd5f2cc61eeb6ca7460652835dc31255447 scripts/ui-photo-people.cjs
|
||||||
|
19ec17efbcef3dd162d3698e0b30d55f4182ae44d2058eb94552d2ef44562e04 scripts/ui-photo-retention.cjs
|
||||||
|
f721682b12291d232bf727b1c999a7231fb1f4fc5030303baea04ad3b3bfdf2a scripts/ui-photo-sources.cjs
|
||||||
|
a4ba9a6d5fbfff7aa05d77ce7eab74a6468c1e929a90305a4afa8abbc6c891a0 scripts/ui-photos-api-form.cjs
|
||||||
|
7047f9f49886abe3c2f08b6b2bfaea35bf9682d4e3aaee773e90c5f3885375fc scripts/ui-photos-api.cjs
|
||||||
|
d390c1c9a76ddc167a17bfd7afb0bd1a7169809c8a0b30a26a5be4d18c3032de scripts/ui-progressive-loading.cjs
|
||||||
|
8eee6d087ae3d65208c423d40623052a9bfbf3db4907299b832e81fbe835fb71 scripts/ui-smoke.cjs
|
||||||
|
4d1c13a81d28f681aa1401953636d702cecfaed81274b347876c39e09931bb53 scripts/ui-studio-image-loading.cjs
|
||||||
|
8a1a3ea2b4deb25ac656a0089e085e9837330ce9a386672f302326ac0cb71306 scripts/ui-widgets.cjs
|
||||||
|
e474740bdb7dbe0617f7c6b170ada918e51f5eed8edbb9c2c47c4590a5dc2359 scripts/verify-photos-api-container.py
|
||||||
|
b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify-photos-api.py
|
||||||
|
d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py
|
||||||
|
41f128b9bf09fd160b8e99f3c811b73443b765b019c71306ada3dc18b83a31ca tests/contract.json
|
||||||
|
c44080785bb2cca54bb17ac9e25f2e91a36839688bc70e40faecaea42d75e9f8 tests/schema-contract.json
|
||||||
|
14a7c56c40ea4b520585c20341b3ec70fa3ba52518144a10ba3494ceceb30758 tests/test_accounts.py
|
||||||
|
e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py
|
||||||
|
94c01e6d8bdbfffbff72edb91823eaf39a325ad8782aba1d8e2804402e256aec tests/test_api.py
|
||||||
|
24fca799b0f5e33c800c6310764b18f6b39fc210be769684171388cf14b95746 tests/test_audit_regressions.py
|
||||||
|
7001a2209473e6f6bf1050d33212985f63f4844defeb5df8e75d9f2a96c0d83c tests/test_auto_collage.py
|
||||||
|
f267ebcdb3cf1fcb01ef9fd9ef7c5c8c4daca11d02543f84bfbe56359f35b78a tests/test_ci_audit.py
|
||||||
|
ad0f05ee4b1a9bb384869cc7f95294b23fc2fb1968ceb125ca306a87d9930207 tests/test_ci_compose.py
|
||||||
|
f256706ccd7ded02fd2c1cc4460600080eef4263b13ef986d46484e7d6dc3c03 tests/test_ci_install.py
|
||||||
|
d4469052fc3a6213c5353312e9826655491ff9c1cbd8cd2151e3a1f4ef67b7af tests/test_client.py
|
||||||
|
fc7a3b37e43ae6716c3afeeeddc0631c932175d527bd8adfea6e5e0acb166311 tests/test_content_provenance.py
|
||||||
|
40d0962edd395f57342e88e6da5467bce6cad0661ba11a5aeae51351cee72ab4 tests/test_device_events.py
|
||||||
|
764a1fcb0cc024c69416d170fcc62b3f01ef54c573981be5b9a611b3137bc3bf tests/test_display.py
|
||||||
|
f5d737520934371fa8291727458285ccbc6285d8612f286e424c0f0ea6c34666 tests/test_extensions.py
|
||||||
|
f695398aabf0ef7a5c86a23bef44421a09eed8b24c655987d87cd793df6a8a32 tests/test_external_photo_sources.py
|
||||||
|
2ab265986f89df625b8e9c8e057ffa2e421e1c6e9bb02884b355b1b71cb918f7 tests/test_gallery_thumbnail.py
|
||||||
|
cbc44ca3bd0eab93b39f0aabfe5c9d923105f4556df7fd7b86593c06daddcae5 tests/test_ical.py
|
||||||
|
787bb5ca8a03b8c94496b00e5a21957dea756b9ba1a4daf354b9e27a5f56d7ef tests/test_images.py
|
||||||
|
95892c597f80aed1e5217c81ce187c3eae09a18542093c02c97fd2d950cc76c6 tests/test_interval_schedule.py
|
||||||
|
1f2a28f4f9fc336fe843ced3ac14e1c4442bd6b4dcdb6460cd20451e6cf7fa57 tests/test_local_cloud_library.py
|
||||||
|
15fd0eab1474aae281729b66b703236625d832adc66e8bbfa791febb7d700eb8 tests/test_mqtt.py
|
||||||
|
9f005ec78c2309098cdb10c03d4167aa0e020afcc28af17a23b7afa056ad2d1e tests/test_oauth.py
|
||||||
|
69de025192b4d481d29c38271736293e8b3c4de8bd061f80521548e299cfba10 tests/test_official_connection.py
|
||||||
|
5f308c16e716cd47364b031e62509ad6bc430855f2d2d4d7f46db5f743f4e0aa tests/test_official_module.py
|
||||||
|
239444c6d6fae4cf5212d077cfc064832dde8223222b10d3679e8df2e4b30979 tests/test_photo_canvas.py
|
||||||
|
5e45cfda1fdc9f1580a1082721e64c27cc6b4e28f4e73e0afb7d875c96b46391 tests/test_photo_captions.py
|
||||||
|
cad91937e8429647319cd01fd1925132b7afe057d5420be9586c5fe63f79e498 tests/test_photo_failures.py
|
||||||
|
2bfc64fe11c882dd7de0ad0d9cc5d73a3f5a3394771adc84e7b010243a452a9c tests/test_photo_orientation.py
|
||||||
|
f14bad45534041ff0b7dead21d2952ec9d6c60e9f718ab5393bdf5a47b2a6d3e tests/test_photo_paths.py
|
||||||
|
64117766bf85dbd2a5fac77a55ba24303c1067d5e7d92571385210816dd3e85c tests/test_photo_resolution.py
|
||||||
|
8226d48f02aadcc60b7d951dc60808d559891d64bf7de7f434731dcf0a90cd44 tests/test_photo_sources.py
|
||||||
|
1e5f544dd4abb4e360efa7d7057f9ccafef7c2e15d19d1eb68db059f5bf55a90 tests/test_photo_watermark.py
|
||||||
|
530050aa636b6ddb6c2ed989bf84bbe6c9c5849a1efb3da68c4b8e8e9aa8cc3b tests/test_photos_api_boundary.py
|
||||||
|
9ab51167f18ae5c75efbf8bec2c479557e38a0e7d55cf43665d2e01d4233d816 tests/test_poetry.py
|
||||||
|
ab773ca6868ec007786e7142d5425663a5c8da35b9aa90837ac8e46b51e51507 tests/test_production_bootstrap.py
|
||||||
|
fee152c5f6eaeda712d38cf88a79213f5e885ba323eb15dfa5a12cf4c88028c3 tests/test_production_deploy.py
|
||||||
|
f0601f5ed89e8a14a4584a9683611aa7510a964f50a642348b8ab6c62df72278 tests/test_publication_security.py
|
||||||
|
0ed189d3469097aa7538325d418425e0c7a3ccc8a285bcf4c2ac10658af4b0df tests/test_recovery.py
|
||||||
|
072e8ca2fa68ca73fb078b107ced96ea683652410b286017db883c97608d78f6 tests/test_retention.py
|
||||||
|
ab2cd30163453fe571b55c30cb26dc047a104a536ec01d2c64aa2bc91996ba11 tests/test_scheduler.py
|
||||||
|
5f25f608f13f96b245d6963c02a98add679c6e7d70d398ebb5c3c49b38fbeda8 tests/test_server_release.py
|
||||||
|
a7ae1b4a64bfcbcf2b7ff795b30ba91db24ee2ad7544073dca2bf43d021cd266 tests/test_smoke_dependencies.py
|
||||||
|
2624fb6ca01f32d93499d7d771144dcaa2850e5f77b2ac3b7b566891b5e9d283 tests/test_synology_photos.py
|
||||||
|
ca98469f6a6b8b49c36a00657ea1a81775c3496e1fbdd5e38eb4d5298b5936b4 tests/test_synology_photos_api.py
|
||||||
|
33f05bc6279cf3573493335ae5bb1c91130754b927b52aa5f50e47f2d951d2ae tests/test_upload_reconciliation.py
|
||||||
|
bdc3142f9c2291a91a5091b4b98b1addf9569cfb141810024ce6f85309704b9b tests/test_upload_retries.py
|
||||||
|
8ec55ce5706d791a155f13445fe7e0828278e8ee1fa1b527591fdf24cc5b2e86 tests/test_weather.py
|
||||||
|
e95eaa1f8a7416c5035ae6deae640b8d5defb2db36c0b8dac6d65bcceedc6750 tests/test_widgets.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 vendor/__init__.py
|
||||||
|
4cae8606769fdb590c427bc1716a871017bf22d91aabf8a52513bf126cfb847f vendor/guooscar_kokonna.py
|
||||||
|
63c7a2f1a6adf98e7fb83a83ae2d6a2dddcecd7bce4d7de25dd6f26bd496019f vendor/guooscar_protocol.py
|
||||||
|
e5f5b7a5955f2438da131859a08c9d78cda3718ada8a9365a74cbbb9ac5e7853 vendor/metadata.json
|
||||||
|
e8cf7d54ea46c19aba793983889b7f7425e1ebfcaaccec764a7db091646e203c vendor/paho-mqtt-EDL-1.0.txt
|
||||||
|
57938f47d5f05f7de119b17fcecd4509c5d13644c8d5f43db30e45684d09d07e vendor/swagger-metadata.json
|
||||||
@@ -8,6 +8,7 @@ COPY requirements.lock /app/requirements.expected.lock
|
|||||||
RUN python -c "from pathlib import Path; assert Path('/app/requirements.lock').read_bytes() == Path('/app/requirements.expected.lock').read_bytes(), 'Dependency lock changed; use the full Dockerfile'"
|
RUN python -c "from pathlib import Path; assert Path('/app/requirements.lock').read_bytes() == Path('/app/requirements.expected.lock').read_bytes(), 'Dependency lock changed; use the full Dockerfile'"
|
||||||
COPY pigway_frame_flow /app/pigway_frame_flow
|
COPY pigway_frame_flow /app/pigway_frame_flow
|
||||||
COPY vendor /app/vendor
|
COPY vendor /app/vendor
|
||||||
|
COPY LICENSE NOTICE.md /app/
|
||||||
# Publish only the cleaned filesystem, without retaining build-stage layers.
|
# Publish only the cleaned filesystem, without retaining build-stage layers.
|
||||||
RUN rm -rf /usr/local/lib/python*/site-packages/pip \
|
RUN rm -rf /usr/local/lib/python*/site-packages/pip \
|
||||||
/usr/local/lib/python*/site-packages/pip-*.dist-info \
|
/usr/local/lib/python*/site-packages/pip-*.dist-info \
|
||||||
@@ -29,4 +30,4 @@ CMD ["python", "-m", "uvicorn", "pigway_frame_flow.main:app", "--host", "0.0.0.0
|
|||||||
ARG BUILD_COMMIT
|
ARG BUILD_COMMIT
|
||||||
ARG BUILD_TIME
|
ARG BUILD_TIME
|
||||||
ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME
|
ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME
|
||||||
LABEL org.opencontainers.image.title="Pigway Frame Flow" org.opencontainers.image.version="0.1.15" org.opencontainers.image.revision=$BUILD_COMMIT
|
LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.19" org.opencontainers.image.revision=$BUILD_COMMIT
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ services:
|
|||||||
init: true
|
init: true
|
||||||
user: "10333:10333"
|
user: "10333:10333"
|
||||||
ports:
|
ports:
|
||||||
- "${FRAME_FLOW_BIND:-192.0.2.10}:81:18787"
|
- "${FRAME_FLOW_BIND:-127.0.0.1}:81:18787"
|
||||||
env_file: ${FRAME_FLOW_ENV_FILE:-/srv/pigway/apps/pigway-frame-flow/.env}
|
env_file: ${FRAME_FLOW_ENV_FILE:?Set the private environment file path}
|
||||||
environment:
|
environment:
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
DATA_DIR: /data
|
DATA_DIR: /data
|
||||||
@@ -23,12 +23,12 @@ services:
|
|||||||
TMPDIR: /data/tmp
|
TMPDIR: /data/tmp
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /srv/pigway/apps/pigway-frame-flow/data
|
source: ${FRAME_FLOW_APP_DIR:?Set the existing application directory}/data
|
||||||
target: /data
|
target: /data
|
||||||
bind:
|
bind:
|
||||||
create_host_path: false
|
create_host_path: false
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /srv/pigway/apps/pigway-frame-flow/config
|
source: ${FRAME_FLOW_APP_DIR:?Set the existing application directory}/config
|
||||||
target: /config
|
target: /config
|
||||||
bind:
|
bind:
|
||||||
create_host_path: false
|
create_host_path: false
|
||||||
|
|||||||
+23
-10
@@ -4,17 +4,30 @@ Runtime keys, passwords, sessions, private photos, databases and local `.env` fi
|
|||||||
must remain outside Git. Examples use documentation-only addresses and identities.
|
must remain outside Git. Examples use documentation-only addresses and identities.
|
||||||
Personal deployment reports and screenshots are excluded from public history.
|
Personal deployment reports and screenshots are excluded from public history.
|
||||||
|
|
||||||
The NAS Docker runner accepts trusted repository events only. External fork PRs
|
The Gitea NAS Docker runner accepts trusted repository events only. GitHub mirrors
|
||||||
|
use the separate community workflow on GitHub-hosted disposable runners, with
|
||||||
|
read-only repository permissions and no deployment configuration. External fork PRs
|
||||||
are not executed on this runner; maintainers must review them before copying changes
|
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.
|
to a trusted branch. All lint, type, test, audit and isolated Docker checks remain.
|
||||||
|
|
||||||
Private deployment configuration is stored in Gitea Actions Secrets:
|
Public CI verifies source only. Runtime configuration belongs in private environment
|
||||||
`PROD_DATA_DIR`, `PROD_CONFIG_DIR`, `PROD_PHOTOS_DIR`, `PROD_WEB_BIND`,
|
files or the deployment system's secret store. Never print complete Docker inspect
|
||||||
`SYNOLOGY_PHOTOS_URL`, `SYNOLOGY_PHOTOS_CERT_SHA256`, and `PRODUCTION_ENV`.
|
environment or secret values.
|
||||||
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
|
Before publishing, scan the current source and distribution files for secrets and
|
||||||
unreachable remote Git objects and archiving/removing historical Actions logs.
|
private receipts. Keep Git history and existing remote releases. If a real historic
|
||||||
A private original mirror is retained outside the repository. Existing clones must
|
secret is found, revoke it and handle history remediation as a separate operation.
|
||||||
reclone after the privacy history rewrite; do not merge the original history back.
|
|
||||||
|
## Private deployment inputs
|
||||||
|
|
||||||
|
Do not publish host addresses, workstation paths, disk UUIDs, photo-library counts,
|
||||||
|
or deployment receipts. Do not include deployment receipts or recovery bundles in published archives. Local
|
||||||
|
obsolete artifacts may be removed after verification; active runtime configuration,
|
||||||
|
databases and images must be preserved.
|
||||||
|
|
||||||
|
`server-release.py` reads `FRAME_FLOW_ROOT` (default `/srv/frameflow`), requires
|
||||||
|
`FRAME_FLOW_MOUNT_UUID` and `FRAME_FLOW_ORIGIN`, and retains the mount-identity
|
||||||
|
check before release actions. Supply verified values privately to the sudo process;
|
||||||
|
existing installations must set their existing root explicitly. Do not use the
|
||||||
|
generic default to migrate a deployment. The script still requires the existing
|
||||||
|
mount guard and does not change host mounts, services, or storage automatically.
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 文档目录
|
||||||
|
|
||||||
|
安装、连接及日常使用从[中文首页](../README.zh-CN.md)或[英文首页](../README.md)开始。本目录收录仍适用的功能、配置与开发说明;版本变化见[版本记录](../RELEASE_NOTES.md)。
|
||||||
|
|
||||||
|
- [自动拼图](AUTOMATIC_COLLAGE.md)
|
||||||
|
- [构建、缓存与 CI](BUILDING.md)
|
||||||
|
- [可选连接与扩展](EXTENSIONS.md)
|
||||||
|
- [厂商请求失败与未知写入处理](KOKONNA_RECOVERY.md)
|
||||||
|
- [Docker 与群晖安装](NAS.md)
|
||||||
|
- [照片来源配置](PHOTO_SOURCES.md)
|
||||||
|
- [照片来源与共享连接](PHOTO_SOURCE_CONNECTIONS.md)
|
||||||
|
- [公开源码与隐私边界](PUBLICATION.md)
|
||||||
|
- [真实画框测试步骤](REAL_DEVICE_TEST.md)
|
||||||
|
- [厂商接口研究概览](RESEARCH.md)
|
||||||
|
- [Synology Photos API 与来源迁移](SYNOLOGY_PHOTOS_API.md)
|
||||||
|
- [Ticktar 外部集成契约](integrations/TICKTAR.md)
|
||||||
|
- [厂商账号能力研究](research/KOKONNA_ACCOUNT_CAPABILITIES.md)
|
||||||
|
- [厂商内容订阅研究](research/KOKONNA_CONTENT_SUBSCRIPTIONS.md)
|
||||||
|
- [厂商本地控制研究](research/KOKONNA_LOCAL_CONTROL.md)
|
||||||
|
- [开发与贡献](../CONTRIBUTING.md)
|
||||||
|
- [安全问题反馈](../SECURITY.md)
|
||||||
|
- [第三方声明](../NOTICE.md)
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
duplicates, name/date/dimensions anomalies. No download-all action.
|
duplicates, name/date/dimensions anomalies. No download-all action.
|
||||||
3. Stop on discrepancy. Missing model fields must be reported as unavailable.
|
3. Stop on discrepancy. Missing model fields must be reported as unavailable.
|
||||||
4. Once explicitly authorized, locally render **one** 1200×1600 test image containing
|
4. Once explicitly authorized, locally render **one** 1200×1600 test image containing
|
||||||
PIGWAY FRAME FLOW TEST and Shanghai timestamp, no private content.
|
PIGWay Frame Flow TEST and Shanghai timestamp, no private content.
|
||||||
5. Record an operation ID and previous image; one upload only, no write retries.
|
5. Record an operation ID and previous image; one upload only, no write retries.
|
||||||
6. Poll device read-only no more often than 60 seconds, respecting the 20/min budget.
|
6. Poll device read-only no more often than 60 seconds, respecting the 20/min budget.
|
||||||
Confirm imageId and synced. If sent but response lost, mark UNKNOWN, block writes,
|
Confirm imageId and synced. If sent but response lost, mark UNKNOWN, block writes,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
- 支持目录相对路径、递归开关、日期文件夹 / 标题、方向及现有质量筛选。`.` 表示所选空间根目录;路径以 Photos 返回的目录名为准,不是 Ubuntu 绝对路径。
|
- 支持目录相对路径、递归开关、日期文件夹 / 标题、方向及现有质量筛选。`.` 表示所选空间根目录;路径以 Photos 返回的目录名为准,不是 Ubuntu 绝对路径。
|
||||||
- 图片使用经过实测的 `Thumbnail.get` v2、`size=xl`。原始文件名不代表下载的是原图。对 NAS 当前原图下载接口的探测被拒绝,原图下载**未宣称支持**。
|
- 图片使用经过实测的 `Thumbnail.get` v2、`size=xl`。原始文件名不代表下载的是原图。对 NAS 当前原图下载接口的探测被拒绝,原图下载**未宣称支持**。
|
||||||
- 照片枚举 `Browse.Item.list` v7,人物 `Browse.Person.list` v3;每批 500 条,最多 100000 条,超过明确失败,界面目录上限仍由 `catalog_limit` 控制。不支持 NAS 未暴露或账号无权访问的相册。
|
- 照片枚举 `Browse.Item.list` v7,人物 `Browse.Person.list` v3;每批 500 条,最多 100000 条,超过明确失败,界面目录上限仍由 `catalog_limit` 控制。不支持 NAS 未暴露或账号无权访问的相册。
|
||||||
- 旧 `local` 来源保留挂载读取方式,不自动变成 API 来源。新建 / 编辑来源时可明确选择 API 类型。
|
- 文件系统 `local` 来源已停用;旧配置需显式转换为 Photos API 来源。
|
||||||
|
|
||||||
## 安全与缓存
|
## 安全与缓存
|
||||||
|
|
||||||
@@ -22,14 +22,11 @@
|
|||||||
|
|
||||||
## Ubuntu 候选配置
|
## Ubuntu 候选配置
|
||||||
|
|
||||||
正式代码在 `/home/developer/Documents/Projects/pigway-frame-flow` 的 `codex/synology-photos-api` 分支开发,不能以迁移临时解包目录作为开发仓库。
|
在正式 Git 仓库开发;不要把迁移临时解包目录作为开发仓库。
|
||||||
|
|
||||||
候选文件:`deploy/compose.ubuntu-photos-api.yml`。外部 `192.0.2.10:81`,内部 `18787`,UID/GID `10333:10333`,数据和配置固定为:
|
候选文件:`deploy/compose.ubuntu-photos-api.yml`。内部端口 `18787`,UID/GID `10333:10333`。通过私有环境变量设置 `FRAME_FLOW_APP_DIR`(已有应用目录)、`FRAME_FLOW_ENV_FILE`(已有私有配置文件)和可选 `FRAME_FLOW_BIND`(默认回环地址),不在仓库保存真实主机地址或部署目录。
|
||||||
|
|
||||||
- `/srv/pigway/apps/pigway-frame-flow/data:/data`
|
无 `/photos` 绑定。`create_host_path: false` 防止在 T7 未挂载时悄悄创建目录;但仍需在启动前检查 `findmnt -T "$FRAME_FLOW_APP_DIR"` 的实际源设备。
|
||||||
- `/srv/pigway/apps/pigway-frame-flow/config:/config`
|
|
||||||
|
|
||||||
无 `/photos` 绑定。`create_host_path: false` 防止在 T7 未挂载时悄悄创建目录;但仍需在启动前检查 `findmnt -T /srv/pigway` 的实际源设备。
|
|
||||||
|
|
||||||
`.env` 需从已确认的配置安全迁移,保留 Photos URL、证书 pin 与已有 Frame Flow / 相框鉴权;不要把密钥写入仓库。模板默认 `KOKONNA_WRITE_ENABLED=false`。创建 `data/tmp`,属主保持 10333:10333,`TMPDIR=/data/tmp`。Docker data-root 和 containerd root 也必须在 T7,不能仅检查 bind mount。
|
`.env` 需从已确认的配置安全迁移,保留 Photos URL、证书 pin 与已有 Frame Flow / 相框鉴权;不要把密钥写入仓库。模板默认 `KOKONNA_WRITE_ENABLED=false`。创建 `data/tmp`,属主保持 10333:10333,`TMPDIR=/data/tmp`。Docker data-root 和 containerd root 也必须在 T7,不能仅检查 bind mount。
|
||||||
|
|
||||||
@@ -60,6 +57,6 @@ FRAME_FLOW_IMAGE='<已验证的镜像标签>' docker compose -f deploy/compose.u
|
|||||||
|
|
||||||
## NFS 授权判断
|
## NFS 授权判断
|
||||||
|
|
||||||
API 来源的共享 / 个人空间取图已经可以完全不依赖 NFS,因此这条新取图链路不需要为 `192.0.2.10` 保留 DSM `photo` NFS 授权。但目前旧来源和待迁移配置没有全部切换验收,不能据此宣称迁移完成或旧本地来源已可脱离挂载。确认没有其他已批准用途后,可以单独授权删除该特定 NFS 规则;本次没有删除。
|
API 来源的共享 / 个人空间取图已经可以完全不依赖 NFS,因此这条新取图链路不需要为旧客户端保留 DSM `photo` NFS 授权。但目前旧来源和待迁移配置没有全部切换验收,不能据此宣称迁移完成或旧本地来源已可脱离挂载。确认没有其他已批准用途后,可以单独授权删除该特定 NFS 规则;本次没有删除。
|
||||||
|
|
||||||
实际版本、镜像标识、通过 / 失败 / 未执行项见本任务验证记录。
|
实际部署回执和照片库统计应私下保管,不提交公共仓库。
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
# UI 一致性与图片加载检查
|
|
||||||
|
|
||||||
页面使用既有真实元数据创建控件和图片区域,不使用页面骨架或虚构图片卡片。
|
|
||||||
搜索、排序、卡片操作与写入保护在图片请求前初始化;已知图库数据可先显示,
|
|
||||||
后台刷新元数据。首页图片独立加载,不阻塞后续页面内容。
|
|
||||||
|
|
||||||
图片共用局部加载函数:旋转指示器仅居中显示于所属图片容器,网络请求与解码完成后
|
|
||||||
立即显示图片,失败后显示中英文提示。没有固定等待或动画最短展示时长。
|
|
||||||
图库仅请求可见区域及附近图片。浏览器禁缓存不会清除服务端缩略图缓存,
|
|
||||||
快速图片可能在第一帧绘制前完成,动画不一定肉眼可见。
|
|
||||||
|
|
||||||
手动拼版先创建全部真实区域与坐标/拖动交互,再并行请求图片。
|
|
||||||
每张图片独立完成或失败,持久的区域容器保持加载前后几何位置与坐标索引稳定。
|
|
||||||
过期布局事件不修改新的规划。
|
|
||||||
|
|
||||||
移动端导航单行横向滚动,当前入口自动进入可见区域;低高度横屏侧栏可滚动。
|
|
||||||
页头在窄屏分行,并为底部安全区域留白。可见控件保留 44 像素触控高度,
|
|
||||||
当前导航使用 aria-current,扩展表单标签关联相应控件。
|
|
||||||
表格与手机导航允许容器内滚动,不应导致整页水平溢出。
|
|
||||||
|
|
||||||
## 自动检查
|
|
||||||
|
|
||||||
使用隔离 Mock 服务、临时数据与测试账号,禁止对生产执行可写的 Mock 脚本。
|
|
||||||
Photos 授权使用拦截测试响应;不需要真实 NAS 凭据。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
FRAME_FLOW_URL=http://127.0.0.1:6005 \
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
需要已安装的 Playwright;可通过 NODE_PATH 指定运行时。
|
|
||||||
FRAME_FLOW_VIEWPORTS 接受包含 width/height 的 JSON 数组;
|
|
||||||
FRAME_FLOW_SCREENSHOT_DIR 可选,用于保存仅含测试数据的截图。
|
|
||||||
|
|
||||||
| 脚本 | 检查范围 | 本次结果 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| ui-layout-audit.cjs | 11 页面、本地/云端图库、6 类弹窗、展开手动拼版;溢出、控件间距、触控高度、弹窗边界 | 16 组尺寸/语言/主题组合,305 项通过 |
|
|
||||||
| ui-image-loading.cjs | 三种宽度、明暗主题;局部居中、成功解码、错误提示与无障碍状态 | 45 项通过 |
|
|
||||||
| ui-progressive-loading.cjs | 阻塞初始化/元数据/图片/任务筛选;首次无缓存时控件先就绪、输入保留、慢请求切换、列表失败、无骨架 | 18 项通过 |
|
|
||||||
| ui-studio-image-loading.cjs | 桌面/手机、明暗主题;并行请求、独立完成/失败、坐标与拖动、稳定区域 | 31 项通过 |
|
|
||||||
|
|
||||||
上述检查无页面脚本异常。生产静态文件与构建文件哈希比对、health/ready 检查通过;
|
|
||||||
生产浏览器交互验证使用拦截的账号、规划及素材响应,对真实服务仅 GET,
|
|
||||||
不代表真实账号密码登录、AI 生成、iOS/Android 实机或软键盘测试。
|
|
||||||
|
|
||||||
连接中心内容较长,后续可按用户任务分组;模板与排版导航图标仍有重复。
|
|
||||||
|
|
||||||
|
|
||||||
## 首次未缓存图库的初始化
|
|
||||||
|
|
||||||
目的地标签、搜索、排序、刷新和上传控件必须先于列表接口完成。
|
|
||||||
仅已有列表的缓存分支先渲染还不够;无缓存时也应创建真实控件与空的列表容器。
|
|
||||||
元数据返回前不推测图片数量,不显示真实空图库提示,不生成骨架卡片。
|
|
||||||
列表失败保留页面控件;慢云端请求期间切换 NAS,迟到响应不得覆盖新页面。
|
|
||||||
列表返回后保留之前输入的搜索词和排序。任务筛选在同一导航内复用请求,仍不阻塞图库。
|
|
||||||
|
|
||||||
启动使用 Settings API 中不调用厂商接口的 device_catalog;完整设备状态作为后台更新,
|
|
||||||
迟到响应在退出账号后不应用。ui-gallery-bootstrap.cjs 同时阻塞设备状态与图片列表,
|
|
||||||
验证真实控件仍先显示,列表返回后保留查询和排序,2 项通过。
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
# Frame Flow 项目审计 — 2026-10-05
|
|
||||||
|
|
||||||
审计基线:794a7e1485cc9046be3e77f6557c75740305ac47。
|
|
||||||
范围:本地源码、配置、Mock 测试与依赖审计;不包含本次 NAS 状态验证、真实账号登录或真实设备写入。未修改业务代码。
|
|
||||||
|
|
||||||
## 发现
|
|
||||||
|
|
||||||
### P1:队列扫描上限导致后续高优先级告警饥饿
|
|
||||||
|
|
||||||
`display/service.py:222` 只取按创建时间排序的前 50 个 QUEUED 操作,再跳过
|
|
||||||
被 override 阻挡的低优先级请求。如果这 50 项全被阻挡,第 51 项更高优先级
|
|
||||||
告警每一轮都无法被扫描到;其他设备请求与本地渲染也可能受影响。
|
|
||||||
|
|
||||||
Mock 复现:激活 priority=50 的一小时 override,排入 50 个 priority=10
|
|
||||||
请求,再排 priority=90 请求,调用 process_one 后后者仍为 QUEUED。
|
|
||||||
建议先筛选可执行请求,或分页扫描直到找到可执行操作;增加跨设备与优先级回归测试。
|
|
||||||
|
|
||||||
### P2:会话失效未更新连接状态,并误清第一方登录
|
|
||||||
|
|
||||||
`integrations/kokonna_official/rpc.py:44-51` 在官方返回“请重新登陆”时抛出
|
|
||||||
UNAUTHORIZED,但没有使官方 session 失效。`connection.py:104-113` 只依据
|
|
||||||
本地 token 非空,继续显示 authorized=true。`web/app.js:17` 将任意 API 401
|
|
||||||
都当作 Frame Flow 登录失效并执行 forgetLogin,包括外部官方会话过期。
|
|
||||||
|
|
||||||
Mock 复现:RPC 返回 HTTP 500 / error -1 / 请重新登陆后,authorized 仍 true。
|
|
||||||
建议区分第一方认证与外部账号过期,持久化失效状态,保留 Frame Flow 登录,
|
|
||||||
在官方区域提供重新连接提示。
|
|
||||||
|
|
||||||
### P2:损坏的可选官方会话文件阻止整个服务启动
|
|
||||||
|
|
||||||
`connection.py:44-51` 在构造函数中直接解析 JSON 并索引字段,没有异常隔离。
|
|
||||||
DeviceRegistry 在应用启动时构建该对象。损坏 JSON、缺字段或文件不可读会
|
|
||||||
使可选功能故障升级为全服务启动失败,影响 API Key、UI 和 Scheduler。
|
|
||||||
|
|
||||||
Mock 复现:官方 secret 文件内容为 `{broken`,create_app 抛 JSONDecodeError。
|
|
||||||
建议隔离并禁用该连接,记录不含文件内容的诊断;其他核心功能继续启动。
|
|
||||||
|
|
||||||
### P2:持久化失败导致断开状态与重启状态不同
|
|
||||||
|
|
||||||
`connection.py:122-127,146-157` 先修改内存,再保存。磁盘满、权限错误等
|
|
||||||
导致保存失败时没有回滚。尤其 disconnect 已清内存,而磁盘仍保留旧 token,
|
|
||||||
重启后又恢复旧连接;虽然原请求返回错误,随后状态查询却会显示未连接。
|
|
||||||
|
|
||||||
Mock 复现:让 _save 抛 OSError;当前 authorized=false,重新构造连接后 true。
|
|
||||||
建议先原子持久化候选状态,再提交内存状态;失败时保持一致并提供明确错误。
|
|
||||||
|
|
||||||
### P2:Request ID 脱敏遗漏官方会话
|
|
||||||
|
|
||||||
`api/app.py:148-150` 仅检查 API Key、Frame Flow Token、registry.keys,
|
|
||||||
没有检查用于官方会话的 settings.additional_secrets。符合 Request ID 长度和
|
|
||||||
字符限制的会话值若被客户端误用为 X-Request-ID,可被响应头和错误模型原样回显。
|
|
||||||
本问题不表示未授权者能凭空取到 token,而是违反已有 secret 不回显约束。
|
|
||||||
|
|
||||||
Mock 复现:完成模拟官方连接后,将 synthetic session `mock-session` 作为
|
|
||||||
X-Request-ID 发给 /health,响应原样返回该值。建议统一使用完整 secret 集合。
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
- pytest:154 passed,0 failed,0 warning。
|
|
||||||
- Ruff:通过(应用和 tests)。
|
|
||||||
- mypy:36 个源码文件通过。
|
|
||||||
- pip-audit(requirements.lock):No known vulnerabilities found。
|
|
||||||
- 五项专项 Mock 复现均确认上述行为,不调用真实 KoKonna。
|
|
||||||
- 没有因发现问题而启用官方写方法;严格 RPC allowlist 仍阻止未启用方法。
|
|
||||||
|
|
||||||
## 边界与下一步
|
|
||||||
|
|
||||||
测试全绿不代表无缺陷:现有套件没有覆盖以上失败场景。
|
|
||||||
本轮没有修复或部署;建议先修复队列饥饿,再统一外部账号状态与第一方认证,
|
|
||||||
然后补齐会话文件容错、持久化事务一致性和 Request ID 脱敏测试。
|
|
||||||
依赖审计仅表示本次查询未发现已知漏洞,不是全部依赖安全的证明。
|
|
||||||
|
|
||||||
## 修复与复验
|
|
||||||
|
|
||||||
上述五项已修复:队列使用 keyset 分页查找可执行操作;官方账号失效使用
|
|
||||||
独立 `OFFICIAL_AUTH_REQUIRED` 错误码并清除会话,前端保留第一方登录且提供
|
|
||||||
重新连接入口;可选 secret 解析失败不再阻止核心启动;持久化候选状态成功后
|
|
||||||
才提交内存变更;Request ID 检查涵盖官方会话凭据。
|
|
||||||
|
|
||||||
复验:164 passed、0 warning(新增 10 项回归测试);Ruff 通过;mypy 36 文件
|
|
||||||
通过;113 项布局检查通过;官方过期/第一方登录隔离浏览器检查通过。
|
|
||||||
已知本地凭据对照历史扫描通过(278 个 blob);该扫描不等同于发现所有未知
|
|
||||||
secret 的通用审计。本轮不部署 NAS,不执行真实设备写接口。
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# 界面表达审计(2026-10-16)
|
|
||||||
|
|
||||||
范围:11 个主页面、主要弹窗、中英文文案、README 和照片数据源说明;对照当前 API、清理与来源记录逻辑。不是厂商接口能力或线上服务状态的全面审计。
|
|
||||||
|
|
||||||
| 位置 | 原问题 | 修正 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 图库 | 排序使用“适配方式” | 独立“排序方式”标签 |
|
|
||||||
| 轮播 / 播放列表 | 播放顺序使用图片适配标签;间隔重复单位 | 使用“播放顺序”“切换间隔” |
|
|
||||||
| 总览 | 天气缺译,显示原始 weather | 补齐中英文 |
|
|
||||||
| 总览 | 图片 ID 标成图片;型号来源标成型号 | 独立标注 ID 与信息来源 |
|
|
||||||
| 同步状态 | “已同步”容易被视为实物刷新确认 | 标明云端确认 |
|
|
||||||
| 图片来源 | 无记录却断言非本系统生成 | 只描述未找到可用记录 |
|
|
||||||
| 模板编辑 | 非模板图片复用不准确的来源错误 | 明确无可编辑的模板来源 |
|
|
||||||
| 删除 | 本地当前图也提示画框切图 | 按本地、云端、当前云端副本区分确认文案 |
|
|
||||||
| 星标 / 多选 | 自动清理保护及隐藏勾选项范围不明确 | 加入服务端保留、手动删除、当前页选择范围说明 |
|
|
||||||
| 上传 | 固定尺寸提示可能与输出不一致 | 提示以生成预览为准 |
|
|
||||||
| 显示选项 | 不勾选恢复时仍显示时长,容易误解 | 说明时长与优先级的临时显示用途 |
|
|
||||||
| 数据源 | 未配置与无匹配照片共用空态 | 拆分两种空态 |
|
|
||||||
| 数据源 | 位置标签、凭据提示仅描述 WebDAV | 补充网盘目录 ID、百度路径、OAuth 凭据 |
|
|
||||||
| 人物筛选 | 仅描述共享空间,但实际可选个人空间 | 按所选空间说明访问权限 |
|
|
||||||
| AI / MQTT | AI 密钥称为设备密钥;连接状态缺译 | 独立 API 密钥标签,补齐连接状态 |
|
|
||||||
| 错误页 | 所有失败都归因设备云端 | 改成通用页面加载失败,保留具体原因 |
|
|
||||||
| 文档 | 照片数据源旧入口及账号独立性描述过时 | 对齐独立导航与共享 Photos 登录 |
|
|
||||||
|
|
||||||
验证:305 项布局检查覆盖 320、390、844 横屏、1440 宽度,中英文与明暗主题,11 页及主要弹窗;8 组星标 / 反选回归;新增文案检查覆盖静态翻译完整性、排序 / 播放 / 型号标签、本地删除和星标作用范围。测试使用隔离 Mock,无真实设备写入、照片删除、AI 调用或部署。
|
|
||||||
|
|
||||||
此前未提交的反选、总览图片放大及共享图片查看器改动保留。此报告不代表已提交或已部署。
|
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
# Gitea Actions production CD v1
|
|
||||||
|
|
||||||
## Audit and adoption boundary
|
|
||||||
|
|
||||||
Production `docker-compose.yml` is unchanged: AMD64 image, UID/GID 10333,
|
|
||||||
`unless-stopped`, read-only rootfs, `/tmp` tmpfs, dropped capabilities, limited
|
|
||||||
logs, `/data` and `/config` bind mounts, default private bridge, and configurable
|
|
||||||
host bind/port. SQLite/images/generated/cache live under `/data`; accounts,
|
|
||||||
vendor sessions and configuration live under `/config`. Neither is disposable.
|
|
||||||
The optional photos mount is read-only and has supplementary group access.
|
|
||||||
|
|
||||||
Historical repository receipts mention `/volume1/docker/pigway-frame-flow/data`,
|
|
||||||
`/volume1/docker/pigway-frame-flow/config`, `/volume1/photo`, port 18787, and
|
|
||||||
container/project `pigway-frame-flow`. These are **confirmation candidates**, not
|
|
||||||
script defaults. The deploy action accepts only explicitly configured paths
|
|
||||||
that match the existing running container on the Runner's Docker daemon.
|
|
||||||
It rejects different projects, ports, unknown mounts, host/custom networking,
|
|
||||||
missing photos confirmation, or different user/restart policy. The deploy action does not create NAS host directories or migrate/adopt an unrelated
|
|
||||||
container. First-time directory creation is limited to the manual bootstrap below.
|
|
||||||
|
|
||||||
CI uses `ffci-*` projects, per-run image tags and named volumes, no host ports.
|
|
||||||
Its cleanup does not refer to any production resource. Production uses fixed
|
|
||||||
configured names and existing bind mounts, with `create_host_path: false`.
|
|
||||||
|
|
||||||
## Gitea configuration
|
|
||||||
|
|
||||||
Repository Settings → Actions configuration (private paths/addresses use Secrets):
|
|
||||||
|
|
||||||
| Variable or Secret | Value to explicitly confirm |
|
|
||||||
| --- | --- |
|
|
||||||
| `PROD_DEPLOY_ENABLED` | Initially `false`; change to `true` only after plan acceptance |
|
|
||||||
| `PROD_PROJECT` | Existing Compose project label; candidate `pigway-frame-flow` |
|
|
||||||
| `PROD_CONTAINER` | Existing container name; candidate `pigway-frame-flow` |
|
|
||||||
| `PROD_IMAGE_REPO` | Fixed local image repository; suggested `pigway-frame-flow` |
|
|
||||||
| `PROD_DATA_DIR` (Secret) | Existing absolute NAS host data path; no default |
|
|
||||||
| `PROD_CONFIG_DIR` (Secret) | Existing absolute NAS host config path; no default |
|
|
||||||
| `PROD_PHOTOS_DIR` (Secret) | Existing read-only photos source if present; otherwise empty |
|
|
||||||
| `PROD_WEB_BIND` (Secret) | Exact existing Docker HostIp (`0.0.0.0` or NAS LAN IP etc.) |
|
|
||||||
| `PROD_WEB_PORT` | Exact existing mapped port |
|
|
||||||
| `FRAME_FLOW_STORAGE_NAME` | Optional NAS display name for the local gallery; defaults to NAS |
|
|
||||||
| `PROD_STATE_VOLUME` | Dedicated new deployment journal volume, e.g. `pigway-frame-flow-cd-state` |
|
|
||||||
| `PROD_MOCK` | Explicit `true`/`false`, consistent with intended production mode |
|
|
||||||
| `PROD_WRITE_ENABLED` | Explicit `true`/`false`; preserve owner's existing write policy |
|
|
||||||
|
|
||||||
The state volume must be separate from application/CI storage. An existing
|
|
||||||
volume without the matching `frameflow.deploy.project` label is rejected.
|
|
||||||
Supplementary groups are read from the existing container; no host ACL changes.
|
|
||||||
Keep existing `DEBIAN_MIRROR`, `DEBIAN_SECURITY_MIRROR`, `CI_PIP_INDEX_URL`, and
|
|
||||||
`CI_PIP_FALLBACK_INDEX_URL` settings from Verify; CI/production builds share the
|
|
||||||
Dockerfile, pinned base, lockfile, and BuildKit caches.
|
|
||||||
|
|
||||||
Repository Settings → Actions → Secrets:
|
|
||||||
|
|
||||||
`PRODUCTION_ENV`: multiline **unquoted** `KEY=value` entries, without shell
|
|
||||||
expansion. At least:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
FRAME_FLOW_TOKEN=<existing Frame Flow administrator token>
|
|
||||||
KOKONNA_API_KEY=<existing KoKonna key, or empty when mock>
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional additional runtime secret variables can be included. No key values are
|
|
||||||
committed, passed to image builds, or printed. Nonsecret mock/write flags use
|
|
||||||
Variables. Other existing runtime environment values are retained. The script
|
|
||||||
sets commit/build time and internal data/config paths explicitly. Dollar signs
|
|
||||||
in secret values are escaped before Compose interpolation. Snapshots containing
|
|
||||||
old runtime secrets are stored only in a private journal file (0600, directory
|
|
||||||
0700) on the dedicated Docker volume; Docker administrators can access it.
|
|
||||||
Never upload that journal or a full Docker inspect as a public CI artifact.
|
|
||||||
|
|
||||||
## First safe trial
|
|
||||||
|
|
||||||
1. On NAS, inspect only the necessary fields (use existing Docker/sudo access):
|
|
||||||
```sh
|
|
||||||
docker inspect pigway-frame-flow --format '{{index .Config.Labels "com.docker.compose.project"}}'
|
|
||||||
docker inspect pigway-frame-flow --format '{{json .Mounts}}'
|
|
||||||
docker inspect pigway-frame-flow --format '{{json .HostConfig.PortBindings}} {{.HostConfig.NetworkMode}}'
|
|
||||||
```
|
|
||||||
Confirm the Variables/Secrets against these actual values, including photos.
|
|
||||||
2. Configure the Secret privately. Keep `PROD_DEPLOY_ENABLED=false`.
|
|
||||||
3. Manually run **Verify Frame Flow** on `main`, `deploy_action=plan`.
|
|
||||||
Verify must pass first. Plan checks resource identity, paths/port, required
|
|
||||||
Secret keys, current `/ready`, smoke endpoints and commit. It performs no
|
|
||||||
build, stop, restart, tagging, lock creation, volume creation, or data writes.
|
|
||||||
If no production Variables exist, it reports configuration missing and does
|
|
||||||
nothing; that is an unconfigured plan, not production acceptance.
|
|
||||||
4. Review `PLAN ONLY` output and arrange a maintenance window for the first real
|
|
||||||
deploy/restart. Set `PROD_DEPLOY_ENABLED=true`, manually run `deploy_action=deploy`.
|
|
||||||
5. After `DEPLOY VERIFIED`, subsequent pushes to `main` run Verify → deploy.
|
|
||||||
Pushes to other branches and pull requests never deploy. A manual run defaults
|
|
||||||
to plan, even when automatic deployment is enabled.
|
|
||||||
|
|
||||||
The deploy action supports updates to an existing healthy deployment. For first-time
|
|
||||||
creation use the explicit manual bootstrap flow below. No real production deployment or rollback drill has
|
|
||||||
been performed during implementation; those are first-run acceptance steps.
|
|
||||||
|
|
||||||
## Deployment transaction and rollback
|
|
||||||
|
|
||||||
1. Verify job must succeed completely, including isolated Docker tests/cleanup.
|
|
||||||
2. Deploy job checks out the same revision and prepares tools. An atomic
|
|
||||||
`docker create` reservation named `<PROD_CONTAINER>-deploy-lock` serializes
|
|
||||||
production changes on the daemon. Job `concurrency` is also declared, but
|
|
||||||
correctness does not depend on Gitea implementing it. A competing deploy
|
|
||||||
fails safely instead of changing production. No other job may remove a lock
|
|
||||||
whose owner label differs from its own.
|
|
||||||
3. Re-read the current baseline under the lock; confirm ready/smoke. Record exact
|
|
||||||
image ID, runtime Compose/environment, version/commit. Pin the old image with
|
|
||||||
an immutable rollback tag so normal dangling-image cleanup cannot remove it.
|
|
||||||
4. Refuse a stale checked-out `main` revision that no longer equals remote main.
|
|
||||||
Build an immutable `release-<commit>-<owner>` tag locally, without a registry
|
|
||||||
then point `latest` at the candidate; CI image tags are never changed.
|
|
||||||
5. Persist a `prepared` journal with old/new runtime configs. Create an additive
|
|
||||||
SQLite online backup under `/data/deploy-backups/<owner>.db`. No data/config
|
|
||||||
directory or file is removed, replaced or recursively chowned.
|
|
||||||
6. `compose up -d --no-build --pull never` replaces only the configured service.
|
|
||||||
Check `/ready`, the unchanged nine-endpoint read-only smoke test, and exact
|
|
||||||
`/api/v1/version` commit. Only all checks passing produce `DEPLOY VERIFIED`.
|
|
||||||
7. Any activation/verification failure runs `up` again with the saved exact old
|
|
||||||
image ID and old runtime environment/mounts, then repeats ready/smoke/commit
|
|
||||||
verification. A failed deploy remains a failed workflow even if rollback
|
|
||||||
succeeds. If rollback fails, the journal/lock are retained, failure is explicit,
|
|
||||||
and redacted scoped diagnostics are emitted.
|
|
||||||
8. An `always()` step uses the recorded owner to recover an interrupted prepared
|
|
||||||
transaction and release only its own lock. Abrupt host loss or termination of
|
|
||||||
the entire runner can prevent both handlers: inspect the journal/lock before
|
|
||||||
manually recovering; never automatically force-unlock an unknown owner.
|
|
||||||
|
|
||||||
Rollback is an **image/config rollback**, not an automatic database rewind.
|
|
||||||
User data accumulated during deployment remains. The SQLite backup is for
|
|
||||||
operator-reviewed recovery; destructive or backward-incompatible future schema
|
|
||||||
changes must not be auto-deployed without a separate migration/backup plan.
|
|
||||||
Baseline artifacts and additive backups are retained for the operator; there is
|
|
||||||
no automated pruning of production rollback history or data.
|
|
||||||
|
|
||||||
After enabling CD, use its workflow/script as the production lifecycle owner.
|
|
||||||
The old NAS checkout's Compose/.env files are not rewritten remotely by CD and
|
|
||||||
may describe an older deployment. Do not concurrently recreate production from
|
|
||||||
that old checkout. The repository's standalone Compose remains usable for an
|
|
||||||
independent deployment; the private journal records the CD runtime configuration.
|
|
||||||
|
|
||||||
## Actual commit and manual rollback
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl -fsS http://NAS_LAN_IP:PORT/api/v1/version
|
|
||||||
# Check `commit`, `version`, and `build_time` against successful deployment output.
|
|
||||||
docker inspect PRODUCTION_CONTAINER --format '{{.Image}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
For a manual rollback: keep CD enabled and run **Verify Frame Flow** on `main`
|
|
||||||
with `deploy_action=rollback`. After Verify passes, the script locks production,
|
|
||||||
loads the last journal's `previous` image/config and verifies recovery. This is
|
|
||||||
last-baseline rollback, not an arbitrary image selector. Never use `down -v`,
|
|
||||||
volume pruning, or delete data/config to roll back.
|
|
||||||
|
|
||||||
If a failed run retained a lock, inspect only its owner label:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker inspect PRODUCTION_CONTAINER-deploy-lock --format '{{index .Config.Labels "frameflow.deploy.owner"}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
With the documented Variables configured in a trusted Docker-enabled shell:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
FRAME_FLOW_DEPLOY_OWNER=<confirmed failed owner> CD_ACTION=recover python scripts/production-deploy.py
|
|
||||||
```
|
|
||||||
|
|
||||||
`recover` retries that owner's journaled rollback and retains the lock again if
|
|
||||||
recovery fails. If the very first attempt stopped before a journal was written,
|
|
||||||
confirm that the original image is still running and healthy before an operator
|
|
||||||
removes the stale lock. Do not delete a live lock or any persistence volume.
|
|
||||||
|
|
||||||
## First-time production bootstrap (explicit manual action)
|
|
||||||
|
|
||||||
The confirmed NAS residue is a `created`, never-started container with dead
|
|
||||||
`/workspace/way/...` bind paths. It is not an adoption/rollback baseline.
|
|
||||||
No migration from those paths is performed. Production Compose remains independently usable.
|
|
||||||
|
|
||||||
Set the ordinary repository Actions Variables below; set paths and addresses as Secrets (see docs/PUBLICATION.md):
|
|
||||||
|
|
||||||
```text
|
|
||||||
PROD_DEPLOY_ENABLED=true
|
|
||||||
PROD_PROJECT=pigway-frame-flow
|
|
||||||
PROD_CONTAINER=pigway-frame-flow
|
|
||||||
PROD_IMAGE_REPO=pigway-frame-flow
|
|
||||||
PROD_DATA_DIR=/volume1/docker/pigway-frame-flow/data
|
|
||||||
PROD_CONFIG_DIR=/volume1/docker/pigway-frame-flow/config
|
|
||||||
PROD_WEB_BIND=192.0.2.10
|
|
||||||
PROD_WEB_PORT=6005
|
|
||||||
PROD_STATE_VOLUME=pigway-frame-flow-cd-state
|
|
||||||
PROD_MOCK=true
|
|
||||||
PROD_WRITE_ENABLED=false
|
|
||||||
```
|
|
||||||
|
|
||||||
Leave `PROD_PHOTOS_DIR` unset/empty. Existing mirror Variables still apply.
|
|
||||||
Enabling CD does not enable real frame writes. Before the first successful bootstrap,
|
|
||||||
ordinary main pushes report `production not bootstrapped / bootstrap required`.
|
|
||||||
They cannot create production. `bootstrap` additionally requires the Actions event
|
|
||||||
`workflow_dispatch`; the default action remains `plan`. Every action is gated by
|
|
||||||
successful complete Verify on main.
|
|
||||||
|
|
||||||
Create repository Actions **Secret** `PRODUCTION_ENV` as multiline bare `KEY=value`:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
FRAME_FLOW_TOKEN=replace-with-a-long-random-private-token
|
|
||||||
KOKONNA_API_KEY=
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not add shell quotes, `export`, interpolation or spaces around `=`. An existing
|
|
||||||
KoKonna key may be supplied but first bootstrap forcibly uses Mock with writes off.
|
|
||||||
Optional application secrets/settings may be added in the same format; bootstrap
|
|
||||||
always overrides Mock/write flags, data/config paths, timezone and build identity.
|
|
||||||
This token remains useful for API/smoke checks even if Web UI uses account login.
|
|
||||||
Do not paste the real Secret into logs or repository files.
|
|
||||||
|
|
||||||
### Safety and cleanup
|
|
||||||
|
|
||||||
Bootstrap builds the verified main revision first, then acquires the same atomic
|
|
||||||
Docker daemon deployment lock used by deploy. Host path checks run in a short-lived,
|
|
||||||
network-disabled helper with **read-only** NAS root mounted, not CI workspace paths.
|
|
||||||
Synology does not automatically create missing bind sources with `-v`. A temporary
|
|
||||||
network-disabled helper mounts the explicitly configured existing parent directories
|
|
||||||
and creates only their data/config leaves, owned `10333:10333`, mode `0750`. It never
|
|
||||||
mounts NAS root writable, recursively changes parent permissions, or deletes contents.
|
|
||||||
Parents must already exist; missing parents fail closed. Nonempty directories, symlinks
|
|
||||||
(including ancestor symlinks), overlapping paths and unknown journal resources fail closed.
|
|
||||||
|
|
||||||
A same-name residue can be removed **without force** only after checking all of:
|
|
||||||
Compose project/service labels; `Status=created`; `Running=false`;
|
|
||||||
`StartedAt=0001-01-01T00:00:00Z`; exactly two bind mounts `/data` and `/config`;
|
|
||||||
and both old host sources absent. A running, previously started, unknown or data-bearing
|
|
||||||
container is refused. An existing default network must be owned by this Compose
|
|
||||||
project and contain no endpoints other than the validated residue.
|
|
||||||
|
|
||||||
The production service uses `create_host_path=false`, the explicit NAS LAN bind,
|
|
||||||
non-root UID, `unless-stopped`, normal bridge network and existing resource/security
|
|
||||||
limits. Acceptance runs ready, all read-only smoke endpoints, version/commit,
|
|
||||||
preflight mount/port/project/user/restart checks, one restart, repeated checks,
|
|
||||||
Docker health and exact built-image ID before `BOOTSTRAP VERIFIED`.
|
|
||||||
|
|
||||||
On failure after transaction preparation, cleanup removes only this transaction's
|
|
||||||
owner-labelled new container and its newly created, empty, project-labelled network.
|
|
||||||
The previously validated never-started residue has no rollback value and is not recreated.
|
|
||||||
**Data/config files, images/build cache and the private CD journal volume are retained.**
|
|
||||||
No `down -v`, volume prune or removal of unrelated/CI resources is used.
|
|
||||||
Cleanup failure retains the lock so the always recovery step can retry using the journal.
|
|
||||||
Only an explicitly dispatched bootstrap may retry a failed first-install journal, and
|
|
||||||
only when its project/container/mounts/port match, its volume ownership matches, and
|
|
||||||
target directories remain empty or absent. Prepared/success/foreign journals are refused.
|
|
||||||
On a new transaction, the prior journal is archived as `deploy.<owner>.json` inside
|
|
||||||
the private state volume before replacing the active journal; generated data is never
|
|
||||||
automatically emptied and journals are never deleted.
|
|
||||||
Abrupt host/runner loss still requires operator review of the lock/journal.
|
|
||||||
The journal is private (0700/0600) and may contain prior/candidate secrets; never export it
|
|
||||||
as an Actions artifact. First bootstrap has no prior healthy version to roll back to.
|
|
||||||
|
|
||||||
### First run and acceptance
|
|
||||||
|
|
||||||
1. In Gitea repository **Actions → Verify Frame Flow → Run workflow**, choose branch
|
|
||||||
`main`, action **plan**. This is read-only and reports bootstrap required for the
|
|
||||||
absent/created instance; it is not a bootstrap safety acceptance. Detailed host
|
|
||||||
residue/directory checks happen under the lock in bootstrap.
|
|
||||||
2. Run the same workflow on `main` with action **bootstrap**. Verify runs in full first.
|
|
||||||
No NAS bootstrap is executed merely by committing this implementation.
|
|
||||||
3. Require `BOOTSTRAP VERIFIED` in the deploy log. In Container Manager confirm
|
|
||||||
container `pigway-frame-flow` is running/healthy, user 10333, restart unless-stopped,
|
|
||||||
exact `/volume1/docker/pigway-frame-flow/{data,config}` mounts and
|
|
||||||
`192.0.2.10:6005` binding (not loopback).
|
|
||||||
4. Open `http://192.0.2.10:6005/api/v1/version`; its `commit` must equal the full
|
|
||||||
workflow checkout commit. Check `/ready` returns HTTP 200. The immutable image tag
|
|
||||||
is `pigway-frame-flow:latest`; compare its image ID in
|
|
||||||
Container Manager with the running container.
|
|
||||||
5. Keep `PROD_DEPLOY_ENABLED=true`. Subsequent main pushes Verify then deploy using
|
|
||||||
this same runtime and journal mechanism. The first update records bootstrap as
|
|
||||||
its rollback baseline. Repeating bootstrap against this instance is refused.
|
|
||||||
Enable real-device read-only access and write access only through separate explicit
|
|
||||||
operator decisions; no automatic change is made by bootstrap.
|
|
||||||
|
|
||||||
Docker tests are mocked locally. The first manual NAS Runner bootstrap, including
|
|
||||||
real host mounts and restart, remains the required live acceptance.
|
|
||||||
|
|
||||||
## Actions download transport
|
|
||||||
|
|
||||||
Verify and deploy reference official GitHub Actions directly, pinned to immutable
|
|
||||||
commits rather than moving tags:
|
|
||||||
|
|
||||||
| Action | Official upstream commit |
|
|
||||||
| --- | --- |
|
|
||||||
| actions/checkout v4 | 11d5960a326750d5838078e36cf38b85af677262 |
|
|
||||||
| actions/setup-python v5 | a26af69be951a213d495a4c3e4e4022e16d87065 |
|
|
||||||
|
|
||||||
The workflow uses explicit `https://github.com/actions/...` URLs so Gitea's
|
|
||||||
configured default Actions host cannot redirect downloads to the project owner's
|
|
||||||
server. Forks download Actions through their own Runner/network; no public action
|
|
||||||
mirror is served by this project. The former Tea auxiliary repository is private
|
|
||||||
and is no longer required by current workflows.
|
|
||||||
|
|
||||||
Keep the Runner's action cache and Python toolcache persistent. For Gitea Runner
|
|
||||||
versions supporting `cache.offline_mode`, use a private Runner configuration:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
runner:
|
|
||||||
file: /data/.runner
|
|
||||||
cache:
|
|
||||||
offline_mode: true
|
|
||||||
```
|
|
||||||
|
|
||||||
In the Runner's own Compose service (not the application), set `HOME=/data` and
|
|
||||||
`CONFIG_FILE=/data/config.yaml`, with `/data` already persisted. Before recreating
|
|
||||||
an idle Runner, back up its configuration and copy the existing action cache from
|
|
||||||
`/root/.cache/act` to `/data/.cache/act`. Preserve registration, labels, image,
|
|
||||||
mounts and networks; do not interrupt active jobs or recreate other services.
|
|
||||||
Verify cached actions against the pinned upstream commit and tree before reuse.
|
|
||||||
This is administrator configuration, not a public workflow dependency.
|
|
||||||
|
|
||||||
Offline reuse avoids refreshing cached actions from GitHub on each job. Moving
|
|
||||||
tags/branches may remain at the cached version; this workflow deliberately pins
|
|
||||||
immutable SHAs. A new action URL or commit requires an initial download or verified
|
|
||||||
administrator prewarming; an old mirror subtree is not automatically equivalent
|
|
||||||
to an upstream Git repository. Do not prune Runner caches between jobs. Python
|
|
||||||
toolcache misses still download Python from its upstream release source. Network
|
|
||||||
failures remain failures, never reasons to skip validation or silently fall back
|
|
||||||
to the owner's Tea server.
|
|
||||||
|
|
||||||
Administrators needing a mainland-specific download route can configure their
|
|
||||||
own Runner/network cache privately. Such settings must not introduce public
|
|
||||||
workflow references to a personal mirror. This change does not modify NAS VPN,
|
|
||||||
proxy, firewall or other applications. All validation and deploy gates remain.
|
|
||||||
|
|
||||||
### Synology Photos dependencies
|
|
||||||
|
|
||||||
For a production photo source using Synology Photos person filtering, configure
|
|
||||||
`SYNOLOGY_PHOTOS_URL` and `SYNOLOGY_PHOTOS_CERT_SHA256` as Gitea Secrets. The
|
|
||||||
certificate fingerprint must match the configured HTTPS endpoint. Keep the Photos
|
|
||||||
session in the persistent `/config` directory; never put session credentials in
|
|
||||||
Variables or repository files.
|
|
||||||
|
|
||||||
`PROD_PHOTOS_DIR` must identify the actual NAS photo directory mounted read-only
|
|
||||||
at `/photos`. Adoption requires that mount to exist on the current container;
|
|
||||||
changing this Variable alone does not create a new mount. Existing supplementary
|
|
||||||
permission groups are preserved by deployment.
|
|
||||||
|
|
||||||
Restoring SQLite and session files does not restore these runtime dependencies.
|
|
||||||
Production smoke checks now reject an enabled people-filtered source if its
|
|
||||||
Photos configuration or stored connection is missing. These checks do not prove
|
|
||||||
session validity or scan the complete catalog: after restoring a source, also
|
|
||||||
verify its read-only `/api/v1/photo-sources/{id}/images?limit=1` endpoint before
|
|
||||||
running an automation task.
|
|
||||||
|
|
||||||
### Advisory-service network resilience
|
|
||||||
|
|
||||||
CI dependency installation uses the domestic `CI_PIP_INDEX_URL` first and the
|
|
||||||
`CI_PIP_FALLBACK_INDEX_URL` sequentially on failure. These package indexes do not
|
|
||||||
replace PyPI's vulnerability JSON API.
|
|
||||||
|
|
||||||
`python scripts/ci-audit.py` retains the pinned `requirements.lock`, PyPI advisory
|
|
||||||
service and `--no-deps --disable-pip` checks. It uses a 60-second socket timeout,
|
|
||||||
at most three attempts, and 5/10-second backoff only for network failures. Known
|
|
||||||
vulnerabilities, malformed/incomplete reports and skipped dependencies fail
|
|
||||||
immediately. Every attempt has a five-minute process limit. A timeout or failed
|
|
||||||
final attempt still blocks deployment.
|
|
||||||
|
|
||||||
The advisory HTTP cache is reused within the job (including retries) and follows
|
|
||||||
the audit client's normal HTTP freshness rules. It is not a permanent offline
|
|
||||||
snapshot or a cross-job cache. Raw network exceptions are omitted because proxy
|
|
||||||
URLs may contain credentials. Logs identify the advisory host and whether the
|
|
||||||
complete audit succeeded.
|
|
||||||
|
|
||||||
### Production image retention
|
|
||||||
|
|
||||||
Production uses `pigway-frame-flow:latest`. Builds first create a unique release
|
|
||||||
image, then tag it as latest before activation. The old image is pinned during
|
|
||||||
an update so a failed deployment can restore it. Only after the new version
|
|
||||||
passes verification are old release/rollback images and duplicate tags removed.
|
|
||||||
After successful deployment, only the current production image remains; there
|
|
||||||
is no retained historical image for manual rollback. Rebuild a historical
|
|
||||||
revision before manually reverting a successful deployment.
|
|
||||||
|
|
||||||
Images referenced by other containers (including stopped containers), images
|
|
||||||
with unrelated tags, and CI resources remain protected. Cleanup never prunes
|
|
||||||
volumes, data, or shared build cache. Cleanup failure is a warning and does not
|
|
||||||
roll back a verified deployment. Failed deployments do not run cleanup.
|
|
||||||
+81
-5
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Pigway Frame Flow API",
|
"title": "PIGWay Frame Flow API",
|
||||||
"version": "0.1.9"
|
"version": "0.1.19"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/devices/{device_id}/widgets/selection": {
|
"/api/v1/devices/{device_id}/widgets/selection": {
|
||||||
@@ -14203,6 +14203,17 @@
|
|||||||
},
|
},
|
||||||
"Asset": {
|
"Asset": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"source_path": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Source Path"
|
||||||
|
},
|
||||||
"starred": {
|
"starred": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"title": "Starred",
|
"title": "Starred",
|
||||||
@@ -15216,7 +15227,7 @@
|
|||||||
"version": {
|
"version": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Version",
|
"title": "Version",
|
||||||
"default": "0.1.9"
|
"default": "0.1.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -16227,6 +16238,17 @@
|
|||||||
],
|
],
|
||||||
"title": "Caption"
|
"title": "Caption"
|
||||||
},
|
},
|
||||||
|
"source_path": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Source Path"
|
||||||
|
},
|
||||||
"provider_preview": {
|
"provider_preview": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"title": "Provider Preview",
|
"title": "Provider Preview",
|
||||||
@@ -16346,6 +16368,19 @@
|
|||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Location"
|
"title": "Location"
|
||||||
},
|
},
|
||||||
|
"nas_root_path": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 2048,
|
||||||
|
"pattern": "^/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Nas Root Path"
|
||||||
|
},
|
||||||
"recursive": {
|
"recursive": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"title": "Recursive",
|
"title": "Recursive",
|
||||||
@@ -16394,6 +16429,20 @@
|
|||||||
],
|
],
|
||||||
"title": "Orientation"
|
"title": "Orientation"
|
||||||
},
|
},
|
||||||
|
"minimum_short_edge": {
|
||||||
|
"type": "integer",
|
||||||
|
"maximum": 50000.0,
|
||||||
|
"minimum": 0.0,
|
||||||
|
"title": "Minimum Short Edge",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"minimum_long_edge": {
|
||||||
|
"type": "integer",
|
||||||
|
"maximum": 50000.0,
|
||||||
|
"minimum": 0.0,
|
||||||
|
"title": "Minimum Long Edge",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
"minimum_sharpness": {
|
"minimum_sharpness": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"maximum": 10000.0,
|
"maximum": 10000.0,
|
||||||
@@ -16499,6 +16548,19 @@
|
|||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Location"
|
"title": "Location"
|
||||||
},
|
},
|
||||||
|
"nas_root_path": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 2048,
|
||||||
|
"pattern": "^/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Nas Root Path"
|
||||||
|
},
|
||||||
"recursive": {
|
"recursive": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"title": "Recursive",
|
"title": "Recursive",
|
||||||
@@ -16547,6 +16609,20 @@
|
|||||||
],
|
],
|
||||||
"title": "Orientation"
|
"title": "Orientation"
|
||||||
},
|
},
|
||||||
|
"minimum_short_edge": {
|
||||||
|
"type": "integer",
|
||||||
|
"maximum": 50000.0,
|
||||||
|
"minimum": 0.0,
|
||||||
|
"title": "Minimum Short Edge",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"minimum_long_edge": {
|
||||||
|
"type": "integer",
|
||||||
|
"maximum": 50000.0,
|
||||||
|
"minimum": 0.0,
|
||||||
|
"title": "Minimum Long Edge",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
"minimum_sharpness": {
|
"minimum_sharpness": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"maximum": 10000.0,
|
"maximum": 10000.0,
|
||||||
@@ -17409,12 +17485,12 @@
|
|||||||
"product": {
|
"product": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Product",
|
"title": "Product",
|
||||||
"default": "Pigway Frame Flow"
|
"default": "PIGWay Frame Flow"
|
||||||
},
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Version",
|
"title": "Version",
|
||||||
"default": "0.1.9"
|
"default": "0.1.19"
|
||||||
},
|
},
|
||||||
"api_version": {
|
"api_version": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
# v0.1.11
|
|
||||||
|
|
||||||
照片底部单行按“日期 · 已命名人物 · 主题 · 地点 · 文件名”排列,空项省略,人物之间也使用 ` · `。主题、日期和地点沿用最近的日期文件夹解析;没有结构化日期目录时仍保留文件名。人物来自 Synology Photos 已命名人物相册的实际图片归属,按账号会话和空间缓存 15 分钟,不显示未命名人物编号。人物查询失败时保留图片及其他描述。
|
|
||||||
|
|
||||||
新建照片源默认开启底部文字,既有来源保留明确设置。横向来源继续同时约束选图和生成画布,不拉伸图片。更新来源表单说明与浏览描述顺序。
|
|
||||||
|
|
||||||
使用既有 Ubuntu Server 发布流程,备份配置、数据库与 Compose,从提交源码构建并核验部署哈希;失败恢复旧 Compose。发布过程不主动切换真实画框,不重绘历史图库图片。
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# v0.1.12
|
|
||||||
|
|
||||||
修复临时展示到期或手动恢复后,“上一张图片”仍指向当前图片,导致已确认删除仍被恢复保护拦截的问题。只有设备已确认同步到受管理的当前图片、且没有基础恢复目标、临时展示或待确认写入时,才清除这条自身引用;不同图片的有效恢复引用继续受保护。既有残留引用在读取设备状态或删除校验时自动修正,不需要强制删除图片或重置数据库。
|
|
||||||
|
|
||||||
有效恢复引用仍拒绝删除,并通过结构化原因显示中英文提示。保留当前图片删除确认,不主动删除照片或切换画框。
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# v0.1.13
|
|
||||||
|
|
||||||
照片底部同时显示所有已识别且已命名的人物,人物之间使用 ` & `;日期、人物、主题、地点及文件名之间继续使用 ` · `。例如 `2026-10-18 · Alice & Bob · Family · one.jpg`。人物仍来自 Photos 实际相册归属,未命名人物不显示;历史生成图片不自动重绘。
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# v0.1.14
|
|
||||||
|
|
||||||
底部文字仅显示日期、已命名人物(使用 & 连接)、文件夹主题及地点,不显示原始文件名。原始文件名仍用于图库和诊断记录。
|
|
||||||
|
|
||||||
上传传输区分明确的 DNS/连接建立失败与已建立连接中断:前者尚未发送 HTTP 写入,最多重试三次,失败后结束该次操作,不进入阻塞后续写入的 UNKNOWN;读超时或连接中断等不确定结果继续保留核对保护,不盲目重放上传。既有未知操作通过只读设备/图库核验后使用原有 acknowledge 流程关闭,保留原始错误和审计记录。
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# v0.1.15
|
|
||||||
|
|
||||||
自动照片、拼版、模板及数据模板任务在上传失败后,持久保存同一张暂存图片,并在下一次计划之前安排最多 5 次额外重试。间隔按 1、2、4、8、16 权重递增,随剩余窗口缩放,计划时间集中在窗口前半段,为上传与设备同步留出时间。重试不移动原定计划,也不复用旧操作 ID;每次独立记录操作及事件。成功后取消剩余重试,重启后沿用已有计数与截止时间;停用任务不再追加重试,删除任务取消其重试计划。
|
|
||||||
|
|
||||||
若额外重试均失败,下一次计划仍先上传同一张图;若这一次仍失败,只删除该任务未入库、未星标的生成缓存,再按照片源生成并上传新图。NAS 原照片、用户上传文件和成功图库图片不由此流程删除。新图仍失败时,进入它自己的下一轮恢复窗口。
|
|
||||||
|
|
||||||
上传结果未知不再阻塞后续独立上传。每次重试前先只读核对设备及云图库,精确确认已成功的上传不再重传;需继续尝试时关闭旧尝试并保留错误和核对审计。删除、恢复及自动清理仍保留各自的保护条件。重试窗口到期后不再启动额外上传,待同步尝试经核对结束后交给原计划执行。
|
|
||||||
|
|
||||||
任务生成图片先保存在缓存,确认任务成功后才进入本地图库;失败不会产生看似成功的本地图库记录。额外重试仍归属原任务的云端保留规则,任务列表显示最近尝试结果。
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# Pigway Frame Flow v0.1.5
|
|
||||||
|
|
||||||
## 简体中文
|
|
||||||
|
|
||||||
- 图片仅在真实图片区域内居中显示加载动画,无页面骨架、虚构图片卡片或人为展示延时;请求及解码完成后立即显示,失败时显示局部提示。
|
|
||||||
- 图库搜索、排序、卡片操作及写入保护先于图片请求初始化,复用真实列表元数据;本地任务筛选异步填充,不阻塞其他控件。
|
|
||||||
- 手动拼版改为并行加载,各图独立完成或失败;固定区域容器保留坐标和拖动交互,加载前后位置一致。
|
|
||||||
- 修复手机底栏文字裁切、窄屏页头拥挤和横屏导航可达性,支持底部安全区域,补齐表单标签和当前导航无障碍状态。
|
|
||||||
- 默认对外访问端口改为 **6005**。本地启动、Compose、测试地址及文档同步;容器内部仍监听 18787。
|
|
||||||
|
|
||||||
### 验证
|
|
||||||
|
|
||||||
本地 505 项 Python 测试、Ruff、格式检查、mypy、JavaScript 语法和差异检查通过。
|
|
||||||
浏览器回归:布局 305 项、图片加载 45 项、图库加载顺序 14 项、手动拼版 31 项通过。
|
|
||||||
先行 UI 部署的生产静态文件哈希、health/ready 和拦截测试数据下的浏览器交互已验证;
|
|
||||||
不代表真实 AI 生成、画框写入、手机实机或软键盘测试。
|
|
||||||
|
|
||||||
### 升级说明
|
|
||||||
|
|
||||||
已有安装须更新私有环境配置中的 `WEB_PORT=6005`,并重建服务后生效。
|
|
||||||
自动部署的 `PROD_WEB_PORT` 必须与现有实例的映射一致;仅修改该变量不能迁移端口。
|
|
||||||
NAS 附件是源码部署包,需自行构建 Docker 镜像,不含密钥、运行配置、数据库或私人照片。
|
|
||||||
|
|
||||||
## English
|
|
||||||
|
|
||||||
Image loading indicators stay inside real image regions, without page skeletons or artificial delays.
|
|
||||||
Gallery controls initialize before image requests. Manual collage images load independently in parallel,
|
|
||||||
with stable regions and working coordinate/drag controls. Mobile navigation, safe-area spacing and form
|
|
||||||
accessibility are improved. The default published port is now **6005**, mapped to container port 18787.
|
|
||||||
Existing installations must update their private port configuration and recreate the service.
|
|
||||||
NAS source package and SHA256 checksum are included; this is not a prebuilt Docker image.
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# Pigway Frame Flow v0.1.6
|
|
||||||
|
|
||||||
## 简体中文
|
|
||||||
|
|
||||||
- 图库使用具体目的地标签:`KoKonna 云端图库` 与 `<NAS 名称> 本地图库`,NAS 名称通过 `FRAME_FLOW_STORAGE_NAME` 配置。
|
|
||||||
- Settings API 增加目的地元数据,标签仅展示连接已配置、明确支持保存且存在适配器的目的地;全局写入保护保持独立。
|
|
||||||
- 后续网盘须实现读取/保存适配器并完成连接后才能加入标签栏,显示具体服务商/实例名称。当前只读照片来源不会作为保存目的地展示。
|
|
||||||
- 长标签在手机上换行,避免整页横向溢出;中英文保持相同目的地身份。
|
|
||||||
- Compose 和自动部署透传 NAS 展示名称;分支 CI 完整验证后发布标签,避免相同提交的标签推送重复占用构建队列。
|
|
||||||
|
|
||||||
### 验证
|
|
||||||
|
|
||||||
本地 507 项 Python 测试、Ruff、格式检查、mypy、JavaScript 语法检查通过。
|
|
||||||
图库目的地标签与能力过滤 18 项浏览器检查通过;覆盖 320–1440 像素、中英文、明暗主题和长 NAS 名称。
|
|
||||||
本次不增加任何网盘写入功能,不改变画框写入策略。
|
|
||||||
|
|
||||||
### 升级
|
|
||||||
|
|
||||||
Compose 设置 `FRAME_FLOW_STORAGE_NAME`;Actions 设置同名仓库 Variable。
|
|
||||||
名称只描述实例,不作为路径或授权凭据。未设置时回退为 NAS。
|
|
||||||
默认访问端口仍为 6005,容器内部端口仍为 18787。
|
|
||||||
NAS 源码包与 SHA256 附件不含运行配置、密钥、数据库或私人照片,需自行构建镜像。
|
|
||||||
|
|
||||||
## English
|
|
||||||
|
|
||||||
Gallery tabs identify the concrete destination: KoKonna cloud gallery and the configured NAS name plus Local gallery.
|
|
||||||
Destination metadata includes connection and upload capabilities; only implemented writable adapters are shown.
|
|
||||||
Existing read-only cloud photo sources are not presented as save destinations. Long labels wrap on mobile.
|
|
||||||
Set FRAME_FLOW_STORAGE_NAME in Compose or the Actions repository Variables. No cloud-drive write support or device write policy is changed.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Pigway Frame Flow v0.1.7
|
|
||||||
|
|
||||||
## 简体中文
|
|
||||||
|
|
||||||
修复首次打开图库时内容区空白、必须等待云端图片列表返回才出现控件的问题。
|
|
||||||
|
|
||||||
- 目的地标签、搜索、排序、刷新和上传按钮立即初始化,图片列表独立请求。
|
|
||||||
- 仅在工具栏提示正在读取列表;未知数量时不生成图片卡片,不提前显示“图库为空”。实际图片仍在自己的区域内独立加载。
|
|
||||||
- 启动使用轻量设备身份目录,完整设备状态在后台更新,避免厂商状态请求阻塞首屏。
|
|
||||||
- 慢请求期间可以切换到 NAS 图库;过期响应不会覆盖当前页面。
|
|
||||||
- 列表失败时保留控件并在工具栏显示错误,仍可刷新或切换目的地。
|
|
||||||
- 保留等待期间输入的搜索词和排序选择;本地任务筛选请求在同一次导航内复用。
|
|
||||||
|
|
||||||
### 验证
|
|
||||||
|
|
||||||
本地 508 项 Python 测试、Ruff、格式检查、mypy 和 JavaScript 语法检查通过。
|
|
||||||
新增首次未缓存列表的控件就绪、输入保留、慢云端切换和列表失败回归。
|
|
||||||
不增加图库列表的服务端缓存,不修改厂商请求重试、照片、生成内容或设备写入策略。
|
|
||||||
|
|
||||||
## English
|
|
||||||
|
|
||||||
Fix the blank gallery on its first uncached visit. Destination tabs and gallery controls initialize before metadata requests complete.
|
|
||||||
Slow or failed cloud listing does not prevent switching to NAS storage. Search and sort choices survive list arrival, and stale responses cannot replace the active destination.
|
|
||||||
Only a small toolbar status indicates metadata loading; real images keep independent loading indicators.
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Pigway Frame Flow v0.1.8
|
|
||||||
|
|
||||||
增加 NAS 本地图库反选、放大总览图片,并统一图片查看器与界面表达。
|
|
||||||
|
|
||||||
- 反选只切换当前页可见图片的勾选状态;隐藏勾选项的批量操作范围明确提示。
|
|
||||||
- 总览当前图片预览增大,点击或键盘打开与图库相同的查看器,读取原图并支持适应屏幕、原始尺寸和 Esc 关闭。
|
|
||||||
- 修正图库排序、播放顺序、型号信息来源、云端确认状态等标签,补齐天气与连接状态翻译。
|
|
||||||
- 区分本地与云端删除影响,解释星标保留规则,避免对未知来源作无依据判断。
|
|
||||||
- 修正数据源空态、路径与凭据说明;同步 README 和操作文档中的共享 Photos 登录机制与入口。
|
|
||||||
|
|
||||||
验证:305 项布局检查、8 组星标与反选检查、12 组总览查看器检查、45 项图片加载检查及新增中英文文案检查通过。全套后端与静态检查由本次发布流水线再次验证。检查不涉及真实照片删除、设备写入或收费 AI 调用。
|
|
||||||
|
|
||||||
Improve NAS gallery selection, dashboard image viewing and UI wording. Invert selection affects visible items on the current page. Dashboard previews open the same full-image viewer as the gallery. Storage destinations, deletion scope, server-side stars, cloud confirmation and shared Photos credentials are clarified in both languages.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# v0.1.9
|
|
||||||
|
|
||||||
照片来源通过 Synology Photos API 按账号权限读取,共享/个人空间、人物 any/all 筛选和有界 XL 预览缓存可用。生产拒绝文件系统照片来源及 /photos 挂载;本地上传、生成图片和 Server 本地图库继续保留。NAS 原图下载接口曾被拒绝,本版不宣称支持原图下载。
|
|
||||||
|
|
||||||
自动化新增 1~10080 分钟的真实间隔,跨整点与跨天不按 Cron 字段重置,既有 Cron 任务保持原样。显示时长明确为到期恢复期限。统一来源快捷创建任务编辑器,改善登录及来源表单响应式布局、设备显示名与历史记录中英文;诊断详情保留原始标识。
|
|
||||||
|
|
||||||
## 生产部署
|
|
||||||
|
|
||||||
生产已迁至 Ubuntu Server,由 systemd 管理 Docker;业务、Docker 数据和恢复资料位于 T7,并有 UUID 与挂载依赖保护。NAS 旧 Frame 专属资源已清理且独立复核 PASS。CI 保留隔离验证,移除 NAS 自动生产部署任务,不再向 NAS 发布。
|
|
||||||
|
|
||||||
正式发布使用 scripts/server-release.py:仅在既有 Ubuntu Server 环境运行,从已提交源码包构建,校验依赖锁、源码哈希、T7 UUID、容器安全约束和真实写入设置;备份 SQLite、配置及 Compose 后切换镜像,失败恢复旧 Compose,不自动回写旧数据库。它不执行上传、显示、删除或恢复图片等设备操作。成功后记录正式提交号、保存源码包及审计回执,删除本次暂存目录;白名单内的 Frame 旧迁移输入归档到本次 recovery,清除无恢复引用的临时镜像别名,共享 Console 资料不动。旧镜像及历史恢复资料保留供回滚,不执行全局 prune。
|
|
||||||
|
|
||||||
脚本要求由 git archive 生成的源码放在 /srv/pigway/migration-inbox 的独立子目录;目录内同时提供 RELEASE_COMMIT、RELEASE_HASHES.json 和原始 source.tar.gz。哈希清单涵盖源码与源码包,不含自身。执行 sudo python3 <暂存目录>/scripts/server-release.py;成功后读取 /srv/pigway/apps/pigway-frame-flow/release-verification.json 并独立检查线上接口。
|
|
||||||
|
|
||||||
## 验收范围
|
|
||||||
|
|
||||||
完整后端测试、静态检查与桌面/平板/手机的中英文浏览器回归纳入发布检查。线上历史补丁已核验,真实写入保持开启;本轮正式发布不重复真实设备写入测试。隔离浏览器验证与真实设备写入证据分别记录于 docs/verification/2026-10-17-final-audit.md。
|
|
||||||
|
|
||||||
局域网 HTTPS、独立加密备份及恢复演练仍是后续工作;T7 recovery 不属于独立备份。没有执行 USB 断连、服务器重启或压力测试。
|
|
||||||
@@ -124,9 +124,9 @@ def create_app(settings=None, start_worker=True):
|
|||||||
for code in [400, 401, 403, 404, 409, 413, 422, 429, 500, 502, 503]
|
for code in [400, 401, 403, 404, 409, 413, 422, 429, 500, 502, 503]
|
||||||
}
|
}
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Pigway Frame Flow API",
|
title="PIGWay Frame Flow API",
|
||||||
docs_url=None,
|
docs_url=None,
|
||||||
version="0.1.15",
|
version="0.1.20",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
responses=error_responses,
|
responses=error_responses,
|
||||||
)
|
)
|
||||||
@@ -421,12 +421,19 @@ def create_app(settings=None, start_worker=True):
|
|||||||
raise FlowError(E.INVALID_REQUEST, "Invalid cloud image") from None
|
raise FlowError(E.INVALID_REQUEST, "Invalid cloud image") from None
|
||||||
if thumbnail:
|
if thumbnail:
|
||||||
thumb = cached.with_name(cached.stem + "-thumb.jpg")
|
thumb = cached.with_name(cached.stem + "-thumb.jpg")
|
||||||
if not thumb.exists():
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
with Image.open(cached) as img:
|
with Image.open(cached) as img:
|
||||||
img.thumbnail((300, 400))
|
# The 430px cards need up to 860px on 2x displays. Replace the
|
||||||
img.save(thumb, "JPEG")
|
# existing cache in place so old 300px previews are upgraded.
|
||||||
|
expected_edge = min(860, max(img.size))
|
||||||
|
regenerate = not thumb.exists()
|
||||||
|
if not regenerate:
|
||||||
|
with Image.open(thumb) as existing:
|
||||||
|
regenerate = max(existing.size) != expected_edge
|
||||||
|
if regenerate:
|
||||||
|
img.thumbnail((860, 860), Image.Resampling.LANCZOS)
|
||||||
|
img.convert("RGB").save(thumb, "JPEG", quality=88, optimize=True)
|
||||||
return FileResponse(thumb, media_type="image/jpeg")
|
return FileResponse(thumb, media_type="image/jpeg")
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
@@ -1033,7 +1040,7 @@ def create_app(settings=None, start_worker=True):
|
|||||||
supports_upload=True,
|
supports_upload=True,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
version="0.1.15",
|
version="0.1.20",
|
||||||
mock=settings.mock,
|
mock=settings.mock,
|
||||||
write_enabled=settings.write_enabled,
|
write_enabled=settings.write_enabled,
|
||||||
api_key_configured=bool(settings.api_key)
|
api_key_configured=bool(settings.api_key)
|
||||||
@@ -1262,7 +1269,7 @@ def create_app(settings=None, start_worker=True):
|
|||||||
def api_docs():
|
def api_docs():
|
||||||
response = get_swagger_ui_html(
|
response = get_swagger_ui_html(
|
||||||
openapi_url="/openapi.json",
|
openapi_url="/openapi.json",
|
||||||
title="Pigway Frame Flow · API Docs",
|
title="PIGWay Frame Flow · API Docs",
|
||||||
swagger_js_url="/ui/swagger-ui-bundle.js",
|
swagger_js_url="/ui/swagger-ui-bundle.js",
|
||||||
swagger_css_url="/ui/swagger-ui.css",
|
swagger_css_url="/ui/swagger-ui.css",
|
||||||
swagger_favicon_url='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>',
|
swagger_favicon_url='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||||
|
|||||||
@@ -392,7 +392,24 @@ class DisplayService:
|
|||||||
"UPDATE operations SET result=? WHERE id=?",
|
"UPDATE operations SET result=? WHERE id=?",
|
||||||
(encode({"photo_attempts": attempts}), operation_id),
|
(encode({"photo_attempts": attempts}), operation_id),
|
||||||
)
|
)
|
||||||
return self.assets.put(rendered, name, "cache" if staging else "images")
|
asset = self.assets.put(
|
||||||
|
rendered, name, "cache" if staging else "images"
|
||||||
|
)
|
||||||
|
self.db.execute(
|
||||||
|
"INSERT OR REPLACE INTO asset_sources VALUES(?,?)",
|
||||||
|
(
|
||||||
|
asset["asset_id"],
|
||||||
|
encode(
|
||||||
|
{
|
||||||
|
"type": "photo_source",
|
||||||
|
"source_id": content.source_id,
|
||||||
|
"item_id": item["id"],
|
||||||
|
"source_path": item.get("source_path"),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return self.assets.info(asset["asset_id"])
|
||||||
raise FlowError(
|
raise FlowError(
|
||||||
E.INVALID_REQUEST, "No decodable photo found within 5 candidates"
|
E.INVALID_REQUEST, "No decodable photo found within 5 candidates"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -366,7 +366,7 @@ class SettingsView(BaseModel):
|
|||||||
|
|
||||||
class Health(BaseModel):
|
class Health(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
version: str = "0.1.15"
|
version: str = "0.1.20"
|
||||||
|
|
||||||
|
|
||||||
class Readiness(BaseModel):
|
class Readiness(BaseModel):
|
||||||
@@ -375,6 +375,7 @@ class Readiness(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class Asset(BaseModel):
|
class Asset(BaseModel):
|
||||||
|
source_path: str | None = None
|
||||||
starred: bool = False
|
starred: bool = False
|
||||||
origin: Literal["manual", "task", "external", "template", "unknown"] = "unknown"
|
origin: Literal["manual", "task", "external", "template", "unknown"] = "unknown"
|
||||||
job_id: str | None = None
|
job_id: str | None = None
|
||||||
@@ -395,8 +396,8 @@ class RestoreRequest(StrictModel):
|
|||||||
|
|
||||||
|
|
||||||
class Version(BaseModel):
|
class Version(BaseModel):
|
||||||
product: str = "Pigway Frame Flow"
|
product: str = "PIGWay Frame Flow"
|
||||||
version: str = "0.1.15"
|
version: str = "0.1.20"
|
||||||
api_version: str = "v1"
|
api_version: str = "v1"
|
||||||
commit: str = "development"
|
commit: str = "development"
|
||||||
build_time: str = "unknown"
|
build_time: str = "unknown"
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class PhotoSourceInput(StrictModel):
|
|||||||
"local", "synology-photos", "webdav", "google-drive", "alipan", "baidu"
|
"local", "synology-photos", "webdav", "google-drive", "alipan", "baidu"
|
||||||
]
|
]
|
||||||
location: str = Field(min_length=1, max_length=2048)
|
location: str = Field(min_length=1, max_length=2048)
|
||||||
|
nas_root_path: str | None = Field(default=None, max_length=2048, pattern=r"^/")
|
||||||
recursive: bool = True
|
recursive: bool = True
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
catalog_limit: int = Field(default=10000, ge=1, le=100000)
|
catalog_limit: int = Field(default=10000, ge=1, le=100000)
|
||||||
@@ -39,6 +40,8 @@ class PhotoSourceInput(StrictModel):
|
|||||||
organized_only: bool = False
|
organized_only: bool = False
|
||||||
folder_caption: bool = False
|
folder_caption: bool = False
|
||||||
orientation: Literal["auto", "landscape", "portrait"] | None = None
|
orientation: Literal["auto", "landscape", "portrait"] | None = None
|
||||||
|
minimum_short_edge: int = Field(default=0, ge=0, le=50000)
|
||||||
|
minimum_long_edge: int = Field(default=0, ge=0, le=50000)
|
||||||
minimum_sharpness: float = Field(default=0, ge=0, le=10000)
|
minimum_sharpness: float = Field(default=0, ge=0, le=10000)
|
||||||
minimum_brightness: float = Field(default=0, ge=0, le=255)
|
minimum_brightness: float = Field(default=0, ge=0, le=255)
|
||||||
maximum_brightness: float = Field(default=255, ge=0, le=255)
|
maximum_brightness: float = Field(default=255, ge=0, le=255)
|
||||||
@@ -60,6 +63,7 @@ class PhotoItem(StrictModel):
|
|||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
caption: dict[str, str] | None = None
|
caption: dict[str, str] | None = None
|
||||||
|
source_path: str | None = None
|
||||||
provider_preview: bool = False
|
provider_preview: bool = False
|
||||||
orientation: Literal["landscape", "portrait", "square"] | None = None
|
orientation: Literal["landscape", "portrait", "square"] | None = None
|
||||||
width: int | None = None
|
width: int | None = None
|
||||||
@@ -412,6 +416,8 @@ class PhotoSources:
|
|||||||
height,
|
height,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if not self.resolution_matches(source, width, height):
|
||||||
|
continue
|
||||||
direction = orientation(width, height)
|
direction = orientation(width, height)
|
||||||
if wanted and direction != wanted:
|
if wanted and direction != wanted:
|
||||||
continue
|
continue
|
||||||
@@ -475,9 +481,9 @@ class PhotoSources:
|
|||||||
continue
|
continue
|
||||||
size = row.get("additional", {}).get("resolution", {})
|
size = row.get("additional", {}).get("resolution", {})
|
||||||
width, height = size.get("width", 0), size.get("height", 0)
|
width, height = size.get("width", 0), size.get("height", 0)
|
||||||
rotation = row.get("additional", {}).get("orientation", 1)
|
# Photos resolution is already oriented; EXIF must not swap it twice.
|
||||||
if rotation in (5, 6, 7, 8):
|
if not self.resolution_matches(source, width, height):
|
||||||
width, height = height, width
|
continue
|
||||||
direction = orientation(width, height)
|
direction = orientation(width, height)
|
||||||
if wanted and (not width or not height or direction != wanted):
|
if wanted and (not width or not height or direction != wanted):
|
||||||
continue
|
continue
|
||||||
@@ -486,6 +492,13 @@ class PhotoSources:
|
|||||||
"id": str(row["id"]),
|
"id": str(row["id"]),
|
||||||
"name": row["filename"],
|
"name": row["filename"],
|
||||||
"provider_preview": True,
|
"provider_preview": True,
|
||||||
|
"source_path": (
|
||||||
|
source.nas_root_path.rstrip("/")
|
||||||
|
+ "/"
|
||||||
|
+ relative.lstrip("/")
|
||||||
|
if source.nas_root_path
|
||||||
|
else "/" + relative.lstrip("/")
|
||||||
|
),
|
||||||
"caption": caption if source.folder_caption else None,
|
"caption": caption if source.folder_caption else None,
|
||||||
"width": width,
|
"width": width,
|
||||||
"height": height,
|
"height": height,
|
||||||
@@ -560,6 +573,11 @@ class PhotoSources:
|
|||||||
self.cache[source_id] = (time.monotonic(), items, wanted, session_identity)
|
self.cache[source_id] = (time.monotonic(), items, wanted, session_identity)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolution_matches(source, width, height):
|
||||||
|
short, long = sorted((width, height))
|
||||||
|
return short >= source.minimum_short_edge and long >= source.minimum_long_edge
|
||||||
|
|
||||||
def read(self, source_id, item_id=None, *, target_size=(1200, 1600)):
|
def read(self, source_id, item_id=None, *, target_size=(1200, 1600)):
|
||||||
source = self.get(source_id)
|
source = self.get(source_id)
|
||||||
items = self.catalog(source_id, target_size=target_size)
|
items = self.catalog(source_id, target_size=target_size)
|
||||||
@@ -642,4 +660,29 @@ class PhotoSources:
|
|||||||
data = self.assets.fetch_url(item["id"], headers=self.headers(source))
|
data = self.assets.fetch_url(item["id"], headers=self.headers(source))
|
||||||
if len(data) > self.settings.max_bytes:
|
if len(data) > self.settings.max_bytes:
|
||||||
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
|
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
|
||||||
|
if source.orientation or source.minimum_short_edge or source.minimum_long_edge:
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
with Image.open(BytesIO(data)) as image:
|
||||||
|
width, height = image.size
|
||||||
|
if image.getexif().get(274, 1) in (5, 6, 7, 8):
|
||||||
|
width, height = height, width
|
||||||
|
wanted = (
|
||||||
|
orientation(*target_size)
|
||||||
|
if source.orientation == "auto"
|
||||||
|
else source.orientation
|
||||||
|
)
|
||||||
|
# Validate the fetched rendition before a crop can hide a mismatch.
|
||||||
|
if wanted and orientation(width, height) != wanted:
|
||||||
|
raise FlowError(
|
||||||
|
E.INVALID_REQUEST,
|
||||||
|
"Photo direction does not match the source orientation",
|
||||||
|
)
|
||||||
|
if not self.resolution_matches(source, width, height):
|
||||||
|
raise FlowError(
|
||||||
|
E.INVALID_REQUEST,
|
||||||
|
"Photo is below the configured minimum resolution",
|
||||||
|
)
|
||||||
return data, item["name"], item.get("caption")
|
return data, item["name"], item.get("caption")
|
||||||
|
|||||||
@@ -299,7 +299,9 @@ class SynologyPhotos:
|
|||||||
rows = self.pages(
|
rows = self.pages(
|
||||||
prefix + ".Browse.Item",
|
prefix + ".Browse.Item",
|
||||||
7,
|
7,
|
||||||
additional=json.dumps(["folder", "thumbnail", "resolution"]),
|
additional=json.dumps(
|
||||||
|
["folder", "thumbnail", "resolution", "orientation"]
|
||||||
|
),
|
||||||
**({"person_id": person} if person is not None else {}),
|
**({"person_id": person} if person is not None else {}),
|
||||||
)
|
)
|
||||||
groups.append(
|
groups.append(
|
||||||
|
|||||||
@@ -134,7 +134,13 @@ class Assets:
|
|||||||
if not row:
|
if not row:
|
||||||
raise FlowError(E.NOT_FOUND, "Asset not found", 404)
|
raise FlowError(E.NOT_FOUND, "Asset not found", 404)
|
||||||
meta = self.db.one("SELECT * FROM asset_metadata WHERE asset_id=?", (asset_id,))
|
meta = self.db.one("SELECT * FROM asset_metadata WHERE asset_id=?", (asset_id,))
|
||||||
|
provenance = self.db.one(
|
||||||
|
"SELECT payload FROM asset_sources WHERE asset_id=?", (asset_id,)
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
|
"source_path": json.loads(provenance["payload"]).get("source_path")
|
||||||
|
if provenance
|
||||||
|
else None,
|
||||||
"starred": bool(meta and meta["starred"]),
|
"starred": bool(meta and meta["starred"]),
|
||||||
"origin": meta["origin"] if meta else "unknown",
|
"origin": meta["origin"] if meta else "unknown",
|
||||||
"job_id": meta["job_id"] if meta else None,
|
"job_id": meta["job_id"] if meta else None,
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ async function templateEditor(selected){
|
|||||||
const reset=document.createElement('button');reset.id='reset-template';reset.textContent=t('resetTemplate');$('#save-template').after(reset);bind('reset-template',()=>confirmDialog(t('resetConfirm'),async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'DELETE'});await navigate();}));
|
const reset=document.createElement('button');reset.id='reset-template';reset.textContent=t('resetTemplate');$('#save-template').after(reset);bind('reset-template',()=>confirmDialog(t('resetConfirm'),async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'DELETE'});await navigate();}));
|
||||||
const save=$('#save-template');const oldSave=save.onclick;save.onclick=async e=>{if([...panel.querySelectorAll('textarea'),raw].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}await oldSave(e);};
|
const save=$('#save-template');const oldSave=save.onclick;save.onclick=async e=>{if([...panel.querySelectorAll('textarea'),raw].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}await oldSave(e);};
|
||||||
}
|
}
|
||||||
function accountSettings(){const section=$('#logout').parentElement;const form=document.createElement('form');form.id='password-form';form.innerHTML=`<h3>${t('changePassword')}</h3><div class="field"><label for="current-password">${t('currentPassword')}</label><input id="current-password" type="password" required autocomplete="current-password"></div><div class="field"><label for="new-password">${t('newPassword')}</label><input id="new-password" type="password" minlength="8" required autocomplete="new-password"></div><button type="submit">${t('changePassword')}</button>`;section.append(form);form.onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/auth/password',{method:'PUT',body:{current_password:$('#current-password').value,new_password:$('#new-password').value}});forgetLogin();shell();login();toast(t('passwordChanged'));});}
|
function accountSettings(){const section=$('#logout').parentElement;const form=document.createElement('form');form.id='password-form';form.innerHTML=`<h3>${t('changePassword')}</h3><div class="form-grid password-fields"><div class="field"><label for="current-password">${t('currentPassword')}</label><input id="current-password" type="password" required autocomplete="current-password"></div><div class="field"><label for="new-password">${t('newPassword')}</label><input id="new-password" type="password" minlength="8" required autocomplete="new-password"></div></div><button type="submit">${t('changePassword')}</button>`;section.append(form);form.onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/auth/password',{method:'PUT',body:{current_password:$('#current-password').value,new_password:$('#new-password').value}});forgetLogin();shell();login();toast(t('passwordChanged'));});}
|
||||||
const views={
|
const views={
|
||||||
studio:()=>layoutStudioPage(),
|
studio:()=>layoutStudioPage(),
|
||||||
connections:()=>connectionsPage(),
|
connections:()=>connectionsPage(),
|
||||||
@@ -128,7 +128,7 @@ const views={
|
|||||||
const destinations=galleryDestinations();if(!destinations.some(d=>d.id===state.tab))state.tab=destinations[0]?.id||'local';
|
const destinations=galleryDestinations();if(!destinations.some(d=>d.id===state.tab))state.tab=destinations[0]?.id||'local';
|
||||||
const key=JSON.stringify([state.device,state.tab,state.assetOffset||0,state.assetOrigin||'all',state.assetJob||'']);
|
const key=JSON.stringify([state.device,state.tab,state.assetOffset||0,state.assetOrigin||'all',state.assetJob||'']);
|
||||||
const cache=state.galleryCache||(state.galleryCache=new Map());const cached=cache.get(key);
|
const cache=state.galleryCache||(state.galleryCache=new Map());const cached=cache.get(key);
|
||||||
const render=(items,pending=false)=>{if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`<div class="section-heading"><div class="tabs gallery-tabs">${destinations.map(d=>`<button id="${esc(d.id)}-tab" data-gallery-destination="${esc(d.id)}" class="${state.tab===d.id?'active':''}">${esc(galleryDestinationName(d))}</button>`).join('')}</div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}<span id="gallery-list-status" class="hint" role="status" ${pending?'':'hidden'}>${pending?t('galleryListLoading'):''}</span></div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${`<div class="gallery" aria-busy="${pending}">${items.map((x,i)=>`<article class="card image-card"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"><h3 title="${esc(x.name)}">${esc(x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:t('local')} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button data-original="${i}">${t('original')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`}${!pending&&!items.length?`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`:''}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});enhanceGallery();guardDeviceButtons();hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];await openImageViewer(x.url,x.name);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(state.tab==='local'?t('localDeleteConfirm'):x.current?t('deleteCurrent'):t('cloudDeleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});};
|
const render=(items,pending=false)=>{if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`<div class="section-heading"><div class="tabs gallery-tabs">${destinations.map(d=>`<button id="${esc(d.id)}-tab" data-gallery-destination="${esc(d.id)}" class="${state.tab===d.id?'active':''}">${esc(galleryDestinationName(d))}</button>`).join('')}</div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}<span id="gallery-list-status" class="hint" role="status" ${pending?'':'hidden'}>${pending?t('galleryListLoading'):''}</span></div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${`<div class="gallery" aria-busy="${pending}">${items.map((x,i)=>`<article class="card image-card"><button class="image-preview" data-original="${i}" aria-label="${t('original')} · ${esc(x.name)}"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"></button><h3 class="${state.tab==='local'?'source-path':''}" title="${esc(x.source_path||x.name)}">${esc(state.tab==='local'?(x.source_path||x.name):x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:t('local')} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`}${!pending&&!items.length?`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`:''}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});enhanceGallery();guardDeviceButtons();hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];await openImageViewer(x.url,x.name);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(state.tab==='local'?t('localDeleteConfirm'):x.current?t('deleteCurrent'):t('cloudDeleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});};
|
||||||
render(cached||[],!cached);
|
render(cached||[],!cached);
|
||||||
try{
|
try{
|
||||||
const items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets?limit=200&offset='+(state.assetOffset||0)+'&origin='+encodeURIComponent(state.assetOrigin||'all')+(state.assetJob?'&job_id='+encodeURIComponent(state.assetJob):''));
|
const items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets?limit=200&offset='+(state.assetOffset||0)+'&origin='+encodeURIComponent(state.assetOrigin||'all')+(state.assetJob?'&job_id='+encodeURIComponent(state.assetJob):''));
|
||||||
@@ -143,13 +143,13 @@ const views={
|
|||||||
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
|
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
|
||||||
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
|
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
|
||||||
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
|
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
|
||||||
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="badge ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||operationErrorLabel(o.error)||'—')}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||operationErrorLabel(o.error)||'—')}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
||||||
async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns"><section class="card"><h3>${t('settings')}</h3><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();}
|
async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns settings-columns"><section class="card"><h3>${t('settings')}</h3><div class="form-grid settings-preferences"><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();}
|
||||||
};
|
};
|
||||||
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...changes};}
|
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...changes};}
|
||||||
async function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`<h2>${t('playlists')}</h2><form id="playlist-form"><div class="field"><label for="playlist-name">${t('name')}</label><input id="playlist-name" required maxlength="120" value="${esc(p.name)}"></div><div class="field"><label for="playlist-images">${t('imageIds')}</label><input id="playlist-images" required value="${esc(p.image_ids.join(','))}" placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval">${t('interval')}</label><input id="playlist-interval" type="number" min="5" max="10080" value="${p.interval_minutes}"></div><div class="field"><label for="playlist-mode">${t('playbackMode')}</label><select id="playlist-mode"><option value="queue" ${p.mode==='queue'?'selected':''}>${t('queue')}</option><option value="random" ${p.mode==='random'?'selected':''}>${t('random')}</option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});await playlistImagePicker(p);}
|
async function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`<h2>${t('playlists')}</h2><form id="playlist-form"><div class="field"><label for="playlist-name">${t('name')}</label><input id="playlist-name" required maxlength="120" value="${esc(p.name)}"></div><div class="field"><label for="playlist-images">${t('imageIds')}</label><input id="playlist-images" required value="${esc(p.image_ids.join(','))}" placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval">${t('interval')}</label><input id="playlist-interval" type="number" min="5" max="10080" value="${p.interval_minutes}"></div><div class="field"><label for="playlist-mode">${t('playbackMode')}</label><select id="playlist-mode"><option value="queue" ${p.mode==='queue'?'selected':''}>${t('queue')}</option><option value="random" ${p.mode==='random'?'selected':''}>${t('random')}</option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});await playlistImagePicker(p);}
|
||||||
$('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#device').onchange=()=>{state.device=$('#device').value;navigate();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme);
|
$('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#device').onchange=()=>{state.device=$('#device').value;navigate();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme);
|
||||||
applyTheme();shell();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>$('#footer').innerHTML=`<span>Pigway Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`).catch(()=>{});
|
applyTheme();shell();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>$('#footer').innerHTML=`<span>PIGWay Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`).catch(()=>{});
|
||||||
setInterval(()=>{if(state.token&&state.page==='dashboard'&&!$('#modal').open)navigate();},60000);
|
setInterval(()=>{if(state.token&&state.page==='dashboard'&&!$('#modal').open)navigate();},60000);
|
||||||
|
|
||||||
// Keep Cron available for exceptional schedules, but make ordinary schedules form-based.
|
// Keep Cron available for exceptional schedules, but make ordinary schedules form-based.
|
||||||
@@ -175,7 +175,7 @@ async function jobEditor(job){
|
|||||||
views.automation=async epoch=>{
|
views.automation=async epoch=>{
|
||||||
const jobs=await api('/api/v1/jobs');
|
const jobs=await api('/api/v1/jobs');
|
||||||
if(epoch!==state.epoch)return;
|
if(epoch!==state.epoch)return;
|
||||||
$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">${state.lang==='zh'?'上海时间':'Shanghai time'} · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table><thead><tr>${['name','schedule','status','nextRun','lastRun','result','action'].map(key=>`<th>${t(key)}</th>`).join('')}</tr></thead><tbody>${jobs.map((job,index)=>`<tr><td><strong>${esc(job.name)}</strong><br><small>${esc(deviceDisplayName(state.devices.find(d=>d.id===job.device_id)||{id:job.device_id,name:job.device_id}))}</small></td><td>${esc(scheduleSummary(job.schedule))}</td><td><span class="badge">${t(job.enabled?'enabled':'disabled')}</span></td><td>${fmt(job.next_run)}</td><td>${fmt(job.last_run)}</td><td><code>${esc(job.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${index}">${t('edit')}</button><button data-job-toggle="${index}">${t(job.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${index}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;
|
$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">${state.lang==='zh'?'上海时间':'Shanghai time'} · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table class="automation-table"><thead><tr>${['name','targetFrame','schedule','status','nextRun','lastRun','result','action'].map(key=>`<th>${key==='targetFrame'?tr('目标相框','Target frame'):t(key)}</th>`).join('')}</tr></thead><tbody>${jobs.map((job,index)=>`<tr><td><strong>${esc(job.name)}</strong></td><td>${esc(deviceDisplayName(state.devices.find(d=>d.id===job.device_id)||{id:job.device_id,name:job.device_id}))}</td><td>${esc(scheduleSummary(job.schedule))}</td><td><span class="automation-status">${t(job.enabled?'enabled':'disabled')}</span></td><td>${fmt(job.next_run)}</td><td>${fmt(job.last_run)}</td><td><code>${esc(job.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${index}">${t('edit')}</button><button data-job-toggle="${index}">${t(job.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${index}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;
|
||||||
bind('new-job',()=>jobEditor());
|
bind('new-job',()=>jobEditor());
|
||||||
document.querySelectorAll('[data-job-edit]').forEach(button=>button.onclick=()=>jobEditor(jobs[button.dataset.jobEdit]));
|
document.querySelectorAll('[data-job-edit]').forEach(button=>button.onclick=()=>jobEditor(jobs[button.dataset.jobEdit]));
|
||||||
document.querySelectorAll('[data-job-toggle]').forEach(button=>button.onclick=busy(async()=>{const job=jobs[button.dataset.jobToggle];await api('/api/v1/jobs/'+job.id,{method:'PUT',body:jobPayload(job,{enabled:!job.enabled})});await navigate();}));
|
document.querySelectorAll('[data-job-toggle]').forEach(button=>button.onclick=busy(async()=>{const job=jobs[button.dataset.jobToggle];await api('/api/v1/jobs/'+job.id,{method:'PUT',body:jobPayload(job,{enabled:!job.enabled})});await navigate();}));
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ async function officialWidgetControls(info,data){
|
|||||||
const send=cover_id=>confirmDialog(tr('此操作会改变官方订阅和画框内容,是否继续?','This changes the official subscription and frame content. Continue?'),async()=>{const args=cover_id?Object.fromEntries([...document.querySelectorAll('[data-widget-arg]')].filter(x=>x.value).map(x=>[x.dataset.widgetArg,x.value])):{};const r=await api('/api/v1/devices/'+state.device+'/widgets/selection',{method:'POST',body:{cover_id,args},headers:{'Idempotency-Key':'widget-'+clientUUID()}});await waitOperation(r.operation_id);await navigate('templates');});if(!disabled){$('#official-widget-use').onclick=busy(()=>send(Number($('#official-widget-choice').value)));$('#official-widget-cancel').onclick=busy(()=>send(null));}
|
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){
|
function addPhotoQualityFields(source){
|
||||||
const box=document.createElement('section');box.innerHTML=`<div id="source-local-quality"><h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${tr('对 API 获取的预览图按清晰度、亮度和相似度筛选,不修改或删除 NAS 原照片;无法保证识别闭眼。建议先使用较低阈值。','Filters API previews by sharpness, brightness and similarity without modifying or deleting NAS originals. Closed-eye detection is not guaranteed. Start with low thresholds.')}</p><div class="field"><label for="source-people-space">${tr('照片空间','Photo space')}</label><select id="source-people-space"><option value="shared">${tr('共享空间','Shared space')}</option><option value="personal">${tr('个人空间','Personal space')}</option></select></div><div class="form-grid"><div class="field"><label for="source-sharpness">${tr('最低清晰度(0 不筛选)','Minimum sharpness (0 disables)')}</label><input id="source-sharpness" type="number" min="0" max="10000" value="${source?.minimum_sharpness||0}"></div><div class="field"><label for="source-bright-min">${tr('最低亮度','Minimum brightness')}</label><input id="source-bright-min" type="number" min="0" max="255" value="${source?.minimum_brightness||0}"></div><div class="field"><label for="source-bright-max">${tr('最高亮度','Maximum brightness')}</label><input id="source-bright-max" type="number" min="0" max="255" value="${source?.maximum_brightness??255}"></div></div><label class="check-row"><input id="source-dedup" type="checkbox" ${source?.avoid_duplicates?'checked':''}>${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}</label></div><div class="field"><label for="source-oauth">${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
|
const box=document.createElement('section');box.innerHTML=`<div id="source-local-quality"><h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${tr('对 API 获取的预览图按清晰度、亮度和相似度筛选,不修改或删除 NAS 原照片;无法保证识别闭眼。建议先使用较低阈值。','Filters API previews by sharpness, brightness and similarity without modifying or deleting NAS originals. Closed-eye detection is not guaranteed. Start with low thresholds.')}</p><div class="field"><label for="source-people-space">${tr('照片空间','Photo space')}</label><select id="source-people-space"><option value="shared">${tr('共享空间','Shared space')}</option><option value="personal">${tr('个人空间','Personal space')}</option></select></div><div class="form-grid">${[['short','minimum_short_edge',tr('最小短边(像素)','Minimum short edge (px)')],['long','minimum_long_edge',tr('最小长边(像素)','Minimum long edge (px)')]].map(([id,key,label])=>`<div class="field"><label for="source-min-${id}">${label}</label><input id="source-min-${id}" type="number" min="0" max="50000" step="1" value="${source?.[key]||0}"></div>`).join('')}</div><p class="hint">${tr('0 表示不限制;按源照片和实际取得的预览图像素筛选,横竖图通用。可从短边 1200、长边 1600 开始设置;分辨率不代表对焦清晰度。','0 disables. Checks source and downloaded preview dimensions regardless of orientation. Start with short edge 1200 and long edge 1600; pixel count does not measure focus.')}</p><div class="form-grid"><div class="field"><label for="source-sharpness">${tr('最低清晰度(0 不筛选)','Minimum sharpness (0 disables)')}</label><input id="source-sharpness" type="number" min="0" max="10000" value="${source?.minimum_sharpness||0}"></div><div class="field"><label for="source-bright-min">${tr('最低亮度','Minimum brightness')}</label><input id="source-bright-min" type="number" min="0" max="255" value="${source?.minimum_brightness||0}"></div><div class="field"><label for="source-bright-max">${tr('最高亮度','Maximum brightness')}</label><input id="source-bright-max" type="number" min="0" max="255" value="${source?.maximum_brightness??255}"></div></div><label class="check-row"><input id="source-dedup" type="checkbox" ${source?.avoid_duplicates?'checked':''}>${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}</label></div><div class="field"><label for="source-oauth">${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
|
||||||
|
|
||||||
}
|
}
|
||||||
async function aiImagePanel(){
|
async function aiImagePanel(){
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function editTemplateSource(source){state.selectedTemplate=source.template;state
|
|||||||
async function enhanceDashboard(){
|
async function enhanceDashboard(){
|
||||||
const epoch=state.epoch;const frame=$('.frame-demo');const panel=document.createElement('section');panel.className='card';$('#main').append(panel);
|
const epoch=state.epoch;const frame=$('.frame-demo');const panel=document.createElement('section');panel.className='card';$('#main').append(panel);
|
||||||
const current=await currentPanel(panel);if(epoch!==state.epoch)return;
|
const current=await currentPanel(panel);if(epoch!==state.epoch)return;
|
||||||
if(current.preview_url){frame.innerHTML=`<button id="current-frame-preview" class="current-frame-preview" aria-label="${t('original')} · ${t('currentContent')} #${esc(current.image_id)}"><img id="current-frame-image" alt="${t('currentContent')}"></button><span class="frame-label">${t('currentContent')} #${esc(current.image_id)}</span>`;const original=new URL(current.preview_url,location.origin);original.searchParams.delete('thumbnail');bind('current-frame-preview',()=>openImageViewer(original.pathname+original.search,`${t('currentContent')} #${current.image_id}`));void blobImage($('#current-frame-image'),current.preview_url);}else{frame.innerHTML=`<div class="mini-art"><div class="sun"></div></div><span class="frame-label">${t('testLabel')}</span>`;}
|
if(current.preview_url){frame.innerHTML=`<button id="current-frame-preview" class="current-frame-preview" aria-label="${t('original')} · ${t('currentContent')} #${esc(current.image_id)}"><img id="current-frame-image" alt="${t('currentContent')}"></button><span class="frame-label">${t('currentContent')} #${esc(current.image_id)}</span>`;const original=new URL(current.preview_url,location.origin);original.searchParams.delete('thumbnail');bind('current-frame-preview',()=>openImageViewer(original.pathname+original.search,`${t('currentContent')} #${current.image_id}`));void blobImage($('#current-frame-image'),original.pathname+original.search);}else{frame.innerHTML=`<div class="mini-art"><div class="sun"></div></div><span class="frame-label">${t('testLabel')}</span>`;}
|
||||||
const weather=await api('/api/v1/providers/weather');if(epoch!==state.epoch)return;const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('weather')}</h3><p>${esc(weather.current.location)} · ${esc(weather.current.temperature)} · ${esc(weather.current.summary)}</p><p class="hint">${weather.current.mock?t('weatherMock'):t('weatherAttribution')}${weather.current.stale?' · '+t('stale'):''}</p>`;$('#main').append(box);
|
const weather=await api('/api/v1/providers/weather');if(epoch!==state.epoch)return;const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('weather')}</h3><p>${esc(weather.current.location)} · ${esc(weather.current.temperature)} · ${esc(weather.current.summary)}</p><p class="hint">${weather.current.mock?t('weatherMock'):t('weatherAttribution')}${weather.current.stale?' · '+t('stale'):''}</p>`;$('#main').append(box);
|
||||||
}
|
}
|
||||||
async function enhanceTemplates(){
|
async function enhanceTemplates(){
|
||||||
@@ -33,14 +33,14 @@ function displayDialog(content){
|
|||||||
}
|
}
|
||||||
function enhanceGallery(){
|
function enhanceGallery(){
|
||||||
const grid=$('.gallery');if(!grid)return;
|
const grid=$('.gallery');if(!grid)return;
|
||||||
const controls=document.createElement('div');controls.className='form-grid';controls.innerHTML=`<div class="field"><label for="gallery-search">${t('searchImages')}</label><input id="gallery-search" type="search"></div><div class="field"><label for="gallery-sort">${t('sortOrder')}</label><select id="gallery-sort"><option value="newest">${t('sortNewest')}</option><option value="oldest">${t('sortOldest')}</option><option value="name">${t('sortName')}</option></select></div>`;grid.before(controls);
|
const controls=document.createElement('div');controls.className='form-grid gallery-filters';controls.innerHTML=`<div class="field"><label for="gallery-search">${t('searchImages')}</label><input id="gallery-search" type="search"></div><div class="field"><label for="gallery-sort">${t('sortOrder')}</label><select id="gallery-sort"><option value="newest">${t('sortNewest')}</option><option value="oldest">${t('sortOldest')}</option><option value="name">${t('sortName')}</option></select></div>`;grid.before(controls);
|
||||||
if(state.tab==='local'){const bar=document.createElement('div');bar.className='actions';bar.innerHTML=`<select id="asset-origin" aria-label="${t('originFilter')}">${['all','manual','task','external','template','unknown'].map(x=>`<option value="${x}">${t('origin_'+x)}</option>`).join('')}</select><select id="asset-job" aria-label="${t('taskOrigin')}"><option value="">${t('allTasks')}</option></select><button id="asset-prev">${t('previousPage')}</button><button id="asset-next">${t('nextPage')}</button><button id="asset-all">${t('selectAll')}</button><button id="asset-invert">${t('invertSelection')}</button><button id="asset-batch-star">${t('star')}</button><button id="asset-batch-unstar">${t('unstar')}</button><button id="asset-batch-delete" class="danger">${t('deleteSelected')}</button>`;grid.before(bar);const help=document.createElement('p');help.className='hint';help.id='gallery-selection-help';help.textContent=t('gallerySelectionHelp')+' '+t('starHelp');grid.before(help);bar.setAttribute('aria-describedby',help.id);$('#asset-origin').value=state.assetOrigin||'all';if(state.galleryJobsEpoch!==state.epoch){state.galleryJobsEpoch=state.epoch;state.galleryJobsRequest=api('/api/v1/jobs');}void state.galleryJobsRequest.then(jobs=>{if(!bar.isConnected)return;const picker=bar.querySelector('#asset-job');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;picker.append(option);}picker.value=state.assetJob||'';}).catch(error=>{if(bar.isConnected)toast(error.message);});$('#asset-origin').onchange=()=>{state.assetOffset=0;state.assetOrigin=$('#asset-origin').value;return navigate();};$('#asset-job').onchange=()=>{state.assetOffset=0;state.assetJob=$('#asset-job').value;state.assetOrigin=state.assetJob?'task':state.assetOrigin;return navigate();};$('#asset-prev').disabled=!(state.assetOffset||0);$('#asset-next').disabled=state.gallery.length<200;bind('asset-prev',()=>{state.assetOffset=Math.max(0,(state.assetOffset||0)-200);return navigate();});bind('asset-next',()=>{state.assetOffset=(state.assetOffset||0)+200;return navigate();});const visibleSelection=()=>[...grid.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);$('#asset-all').onclick=()=>{const boxes=visibleSelection();const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};$('#asset-invert').onclick=()=>visibleSelection().forEach(x=>x.checked=!x.checked);for(const action of ['star','unstar','delete']){bind('asset-batch-'+action,async()=>{const ids=[...document.querySelectorAll('.asset-select:checked')].map(x=>x.dataset.asset);if(!ids.length){toast(t('selectImagesFirst'));return;}if(action==='delete'&&!confirm(t('deleteSelectedConfirm')))return;const results=[];for(let i=0;i<ids.length;i+=100){const r=await api('/api/v1/assets/batch',{method:'POST',body:{asset_ids:ids.slice(i,i+100),action}});results.push(...r.results);}const failed=results.filter(x=>x.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}}
|
if(state.tab==='local'){controls.classList.add('gallery-filters-local');const bar=document.createElement('div');bar.className='actions';bar.innerHTML=`<select id="asset-origin" aria-label="${t('originFilter')}">${['all','manual','task','external','template','unknown'].map(x=>`<option value="${x}">${t('origin_'+x)}</option>`).join('')}</select><select id="asset-job" aria-label="${t('taskOrigin')}"><option value="">${t('allTasks')}</option></select><button id="asset-prev">${t('previousPage')}</button><button id="asset-next">${t('nextPage')}</button><button id="asset-all">${t('selectAll')}</button><button id="asset-invert">${t('invertSelection')}</button><button id="asset-batch-star">${t('star')}</button><button id="asset-batch-unstar">${t('unstar')}</button><button id="asset-batch-delete" class="danger">${t('deleteSelected')}</button>`;grid.before(bar);for(const [id,key] of [['asset-origin','originFilter'],['asset-job','taskOrigin']]){const field=document.createElement('div');field.className='field';const label=document.createElement('label');label.htmlFor=id;label.textContent=t(key);field.append(label,bar.querySelector('#'+id));controls.append(field);}const help=document.createElement('p');help.className='hint';help.id='gallery-selection-help';help.textContent=t('gallerySelectionHelp')+' '+t('starHelp');grid.before(help);bar.setAttribute('aria-describedby',help.id);$('#asset-origin').value=state.assetOrigin||'all';if(state.galleryJobsEpoch!==state.epoch){state.galleryJobsEpoch=state.epoch;state.galleryJobsRequest=api('/api/v1/jobs');}void state.galleryJobsRequest.then(jobs=>{if(!bar.isConnected)return;const picker=bar.querySelector('#asset-job');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;picker.append(option);}picker.value=state.assetJob||'';}).catch(error=>{if(bar.isConnected)toast(error.message);});$('#asset-origin').onchange=()=>{state.assetOffset=0;state.assetOrigin=$('#asset-origin').value;return navigate();};$('#asset-job').onchange=()=>{state.assetOffset=0;state.assetJob=$('#asset-job').value;state.assetOrigin=state.assetJob?'task':state.assetOrigin;return navigate();};$('#asset-prev').disabled=!(state.assetOffset||0);$('#asset-next').disabled=state.gallery.length<200;bind('asset-prev',()=>{state.assetOffset=Math.max(0,(state.assetOffset||0)-200);return navigate();});bind('asset-next',()=>{state.assetOffset=(state.assetOffset||0)+200;return navigate();});const visibleSelection=()=>[...grid.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);$('#asset-all').onclick=()=>{const boxes=visibleSelection();const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};$('#asset-invert').onclick=()=>visibleSelection().forEach(x=>x.checked=!x.checked);for(const action of ['star','unstar','delete']){bind('asset-batch-'+action,async()=>{const ids=[...document.querySelectorAll('.asset-select:checked')].map(x=>x.dataset.asset);if(!ids.length){toast(t('selectImagesFirst'));return;}if(action==='delete'&&!confirm(t('deleteSelectedConfirm')))return;const results=[];for(let i=0;i<ids.length;i+=100){const r=await api('/api/v1/assets/batch',{method:'POST',body:{asset_ids:ids.slice(i,i+100),action}});results.push(...r.results);}const failed=results.filter(x=>x.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}}
|
||||||
const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{state.galleryQuery=$('#gallery-search').value;const query=state.galleryQuery.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;state.galleryOrder=order;const indexes=cards.map((card,index)=>({card,item:state.gallery[index]}));indexes.sort((a,b)=>order==='name'?a.item.name.localeCompare(b.item.name):order==='oldest'?String(a.item.created_at).localeCompare(String(b.item.created_at)):String(b.item.created_at).localeCompare(String(a.item.created_at)));indexes.forEach(x=>grid.append(x.card));};
|
const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{state.galleryQuery=$('#gallery-search').value;const query=state.galleryQuery.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;state.galleryOrder=order;const indexes=cards.map((card,index)=>({card,item:state.gallery[index]}));indexes.sort((a,b)=>order==='name'?a.item.name.localeCompare(b.item.name):order==='oldest'?String(a.item.created_at).localeCompare(String(b.item.created_at)):String(b.item.created_at).localeCompare(String(a.item.created_at)));indexes.forEach(x=>grid.append(x.card));};
|
||||||
cards.forEach((card,i)=>{const item=state.gallery[i];if(state.tab==='local'){const star=document.createElement('button');star.textContent=t(item.starred?'unstar':'star');star.setAttribute('aria-pressed',String(item.starred));card.querySelector('.actions').append(star);star.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/star',{method:'PUT',body:{starred:!item.starred}});await navigate();});const label=document.createElement('small');label.textContent=item.origin==='task'?t('taskOrigin')+' · '+(item.job_name||item.job_id):t('origin_'+item.origin);card.querySelector('h3').after(label);const select=document.createElement('input');select.type='checkbox';select.className='asset-select';select.dataset.asset=item.asset_id;select.setAttribute('aria-label',t('selectImage'));card.prepend(select);}if(item.width&&item.height){const dimensions=document.createElement('small');dimensions.textContent=item.width+' × '+item.height;card.querySelector('h3').after(dimensions);}const download=document.createElement('button');download.textContent=t('download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local'){const edit=document.createElement('button');edit.textContent=t('continueEdit');card.querySelector('.actions').append(edit);edit.onclick=busy(async()=>{const meta=await api('/api/v1/assets/'+item.asset_id+'/source');if(meta.source?.type==='template')await editTemplateSource(meta.source);else toast(t('templateSourceUnavailable'));});}});
|
cards.forEach((card,i)=>{const item=state.gallery[i];if(state.tab==='local'){const star=document.createElement('button');star.textContent=t(item.starred?'unstar':'star');star.setAttribute('aria-pressed',String(item.starred));card.querySelector('.actions').append(star);star.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/star',{method:'PUT',body:{starred:!item.starred}});await navigate();});const label=document.createElement('small');label.textContent=item.origin==='task'?t('taskOrigin')+' · '+(item.job_name||item.job_id):t('origin_'+item.origin);card.querySelector('h3').after(label);const select=document.createElement('input');select.type='checkbox';select.className='asset-select';select.dataset.asset=item.asset_id;select.setAttribute('aria-label',t('selectImage'));card.prepend(select);}if(item.width&&item.height){const dimensions=document.createElement('small');dimensions.textContent=item.width+' × '+item.height;card.querySelector('h3').after(dimensions);}const download=document.createElement('button');download.textContent=t('download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local'){const edit=document.createElement('button');edit.textContent=t('continueEdit');card.querySelector('.actions').append(edit);edit.onclick=busy(async()=>{const meta=await api('/api/v1/assets/'+item.asset_id+'/source');if(meta.source?.type==='template')await editTemplateSource(meta.source);else toast(t('templateSourceUnavailable'));});}});
|
||||||
$('#gallery-search').value=state.galleryQuery||'';$('#gallery-sort').value=state.galleryOrder||'newest';$('#gallery-search').oninput();$('#gallery-sort').onchange();
|
$('#gallery-search').value=state.galleryQuery||'';$('#gallery-sort').value=state.galleryOrder||'newest';$('#gallery-search').oninput();$('#gallery-sort').onchange();
|
||||||
}
|
}
|
||||||
async function weatherSettings(){
|
async function weatherSettings(){
|
||||||
const weather=await api('/api/v1/providers/weather');const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('weatherSettings')}</h3><form id="weather-form"><div class="field"><label for="weather-provider">${t('weatherSettings')}</label><select id="weather-provider"><option value="mock">${t('weatherMock')}</option><option value="open-meteo">Open-Meteo</option></select></div><div class="field"><label for="weather-location">${t('location')}</label><input id="weather-location" required maxlength="120" value="${esc(weather.config.location)}"></div><div class="form-grid">${['latitude','longitude'].map(k=>`<div class="field"><label for="weather-${k}">${t(k)}</label><input id="weather-${k}" required type="number" step="any" min="${k==='latitude'?-90:-180}" max="${k==='latitude'?90:180}" value="${weather.config[k]}"></div>`).join('')}</div><button type="submit" class="primary">${t('save')}</button></form><div id="weather-result"></div>`;$('#main').append(box);$('#weather-provider').value=weather.config.provider;
|
const weather=await api('/api/v1/providers/weather');const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('weatherSettings')}</h3><form id="weather-form"><div class="field"><label for="weather-provider">${t('weatherSettings')}</label><select id="weather-provider"><option value="mock">${t('weatherMock')}</option><option value="open-meteo">Open-Meteo</option></select></div><div class="field"><label for="weather-location">${t('location')}</label><input id="weather-location" required maxlength="120" value="${esc(weather.config.location)}"></div><div class="form-grid weather-coordinates">${['latitude','longitude'].map(k=>`<div class="field"><label for="weather-${k}">${t(k)}</label><input id="weather-${k}" required type="number" step="any" min="${k==='latitude'?-90:-180}" max="${k==='latitude'?90:180}" value="${weather.config[k]}"></div>`).join('')}</div><button type="submit" class="primary">${t('save')}</button></form><div id="weather-result"></div>`;$('#main').append(box);$('#weather-provider').value=weather.config.provider;
|
||||||
function result(w){$('#weather-result').innerHTML=`<p>${esc(w.current.temperature)} · ${esc(w.current.summary)}</p><p class="hint">${w.current.mock?t('weatherMock'):t('weatherAttribution')}${w.current.stale?' · '+t('stale'):''}</p><h4>${t('forecast')}</h4>${w.forecast.map(x=>`<p>${esc(x.date)} · ${esc(x.summary)} · ${x.minimum_c}–${x.maximum_c}°C · ${x.rain_probability}%</p>`).join('')}`;}
|
function result(w){$('#weather-result').innerHTML=`<p>${esc(w.current.temperature)} · ${esc(w.current.summary)}</p><p class="hint">${w.current.mock?t('weatherMock'):t('weatherAttribution')}${w.current.stale?' · '+t('stale'):''}</p><h4>${t('forecast')}</h4>${w.forecast.map(x=>`<p>${esc(x.date)} · ${esc(x.summary)} · ${x.minimum_c}–${x.maximum_c}°C · ${x.rain_probability}%</p>`).join('')}`;}
|
||||||
result(weather);$('#weather-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/weather',{method:'PUT',body:{provider:$('#weather-provider').value,location:$('#weather-location').value,latitude:Number($('#weather-latitude').value),longitude:Number($('#weather-longitude').value)}});result(await api('/api/v1/providers/weather'));toast(t('done'));});
|
result(weather);$('#weather-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/weather',{method:'PUT',body:{provider:$('#weather-provider').value,location:$('#weather-location').value,latitude:Number($('#weather-latitude').value),longitude:Number($('#weather-longitude').value)}});result(await api('/api/v1/providers/weather'));toast(t('done'));});
|
||||||
}
|
}
|
||||||
@@ -150,9 +150,9 @@ async function photoSourcesPanel(){
|
|||||||
section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob));
|
section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob));
|
||||||
}
|
}
|
||||||
async function photoSourceForm(source=null){
|
async function photoSourceForm(source=null){
|
||||||
showModal(`<h2>${t(source?'edit':'addSource')}</h2><p class="notice">${t('sourceIndependentHelp')}</p><form id="photo-source-form"><input id="source-id" type="hidden" value="${esc(source?.id||'photos-'+clientUUID())}"><div class="field"><label for="source-name">${t('name')}</label><input id="source-name" required maxlength="120" value="${esc(source?.name||'')}"></div><div class="field" ${!source||source.kind==='synology-photos'?'hidden':''}><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="synology-photos">${t('synologyAPISource')}</option><option value="webdav">WebDAV</option><option value="google-drive">Google Drive</option><option value="alipan">阿里云盘 / AliPan</option><option value="baidu">百度网盘 / Baidu</option></select></div><div class="field"><label for="source-location">${t('sourceLocation')}</label><input id="source-location" required maxlength="2048" value="${esc(source?.location||'.')}"><p class="hint">${t('sourceLocationHint')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${!source||source.folder_caption?'checked':''}>${t('folderCaption')}</label><label hidden><input id="source-synology-previews" type="checkbox" disabled></label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
|
showModal(`<h2>${t(source?'edit':'addSource')}</h2><p class="notice">${t('sourceIndependentHelp')}</p><form id="photo-source-form"><input id="source-id" type="hidden" value="${esc(source?.id||'photos-'+clientUUID())}"><div class="field"><label for="source-name">${t('name')}</label><input id="source-name" required maxlength="120" value="${esc(source?.name||'')}"></div><div class="field" ${!source||source.kind==='synology-photos'?'hidden':''}><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="synology-photos">${t('synologyAPISource')}</option><option value="webdav">WebDAV</option><option value="google-drive">Google Drive</option><option value="alipan">阿里云盘 / AliPan</option><option value="baidu">百度网盘 / Baidu</option></select></div><div class="field"><label for="source-location">${t('sourceLocation')}</label><input id="source-location" required maxlength="2048" value="${esc(source?.location||'.')}"><p class="hint">${t('sourceLocationHint')}</p></div><div class="field" id="source-nas-root-field"><label for="source-nas-root">${tr('NAS 原照片根目录(仅用于显示路径)','NAS original root (display only)')}</label><input id="source-nas-root" pattern="/.*" maxlength="2048" placeholder="/volume1/photo" value="${esc(source?.nas_root_path||'')}"><p class="hint">${tr('共享空间通常为 /volume1/photo,请填写实际 NAS 根目录;留空时显示 Photos 空间内路径。','Enter the actual NAS root; shared space is commonly /volume1/photo. Blank shows the path within Photos.')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${!source||source.folder_caption?'checked':''}>${t('folderCaption')}</label><label hidden><input id="source-synology-previews" type="checkbox" disabled></label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
|
||||||
$('#source-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'synology-photos';const update=()=>{const photosAPI=$('#source-kind').value==='synology-photos';$('#source-location').labels[0].textContent=photosAPI?tr('照片目录','Photo folder'):t('sourceLocation');$('#source-location').nextElementSibling.textContent=photosAPI?tr('填写所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。','Folder path within the selected space; . means the whole space. Downloads XL previews on demand, not originals.'):t('sourceLocationHint');const dav=!['local','synology-photos'].includes($('#source-kind').value);$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-credential').closest('.field').hidden=$('#source-kind').value!=='webdav';const oauth=$('#source-oauth');if(oauth)oauth.closest('.field').hidden=!['google-drive','alipan','baidu'].includes($('#source-kind').value);const quality=$('#source-local-quality');if(quality)quality.hidden=dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=$('#source-kind').value!=='local';$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};addPhotoQualityFields(source);$('#source-location').closest('.field').before($('#source-people-space').closest('.field'));update();$('#source-kind').onchange=update;$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';};
|
$('#source-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'synology-photos';const update=()=>{const photosAPI=$('#source-kind').value==='synology-photos';$('#source-nas-root-field').hidden=!photosAPI;$('#source-location').labels[0].textContent=photosAPI?tr('照片目录','Photo folder'):t('sourceLocation');$('#source-location').nextElementSibling.textContent=photosAPI?tr('填写所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。','Folder path within the selected space; . means the whole space. Downloads XL previews on demand, not originals.'):t('sourceLocationHint');const dav=!['local','synology-photos'].includes($('#source-kind').value);$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-credential').closest('.field').hidden=$('#source-kind').value!=='webdav';const oauth=$('#source-oauth');if(oauth)oauth.closest('.field').hidden=!['google-drive','alipan','baidu'].includes($('#source-kind').value);const quality=$('#source-local-quality');if(quality)quality.hidden=dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=$('#source-kind').value!=='local';$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};addPhotoQualityFields(source);$('#source-location').closest('.field').before($('#source-people-space').closest('.field'));update();$('#source-kind').onchange=update;$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';};
|
||||||
$('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=!['local','synology-photos'].includes($('#source-kind').value);await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,recursive:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:false,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');});
|
$('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=!['local','synology-photos'].includes($('#source-kind').value);await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,nas_root_path:$('#source-nas-root').value||null,recursive:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:false,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_short_edge:Number($('#source-min-short').value),minimum_long_edge:Number($('#source-min-long').value),minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');});
|
||||||
}
|
}
|
||||||
async function browseSource(id,offset=0,refresh=false){
|
async function browseSource(id,offset=0,refresh=false){
|
||||||
const result=await api('/api/v1/photo-sources/'+id+'/images?device_id='+encodeURIComponent(state.device)+'&offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return;
|
const result=await api('/api/v1/photo-sources/'+id+'/images?device_id='+encodeURIComponent(state.device)+'&offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return;
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>Pigway Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
|
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
|
||||||
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.15</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=upload-retries-v0115"></script><script src="/ui/features.js?v=upload-retries-v0115"></script><script src="/ui/extensions.js?v=upload-retries-v0115"></script><script src="/ui/app.js?v=upload-retries-v0115"></script></body></html>
|
<body><aside class="sidebar"><a class="brand" href="/"><span>PIGWay Frame Flow<small>Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.19</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=photo-quality-v0117"></script><script src="/ui/features.js?v=photo-quality-v0117"></script><script src="/ui/extensions.js?v=photo-quality-v0117"></script><script src="/ui/app.js?v=photo-quality-v0117"></script></body></html>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+6
-2
@@ -1,7 +1,9 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "pigway-frame-flow"
|
name = "pigway-frame-flow"
|
||||||
version = "0.1.15"
|
version = "0.1.20"
|
||||||
requires-python = ">=3.12"
|
readme = "README.md"
|
||||||
|
license = {file = "LICENSE"}
|
||||||
|
requires-python = ">=3.14,<3.15"
|
||||||
dependencies = ["paho-mqtt==2.1.0", "python-dateutil==2.9.0.post0", "fastapi==0.142.2", "uvicorn==0.54.0", "pillow==12.3.0", "requests==2.34.2", "croniter==6.2.4", "python-multipart==0.0.32", "pyyaml==6.0.3"]
|
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]
|
[project.optional-dependencies]
|
||||||
test = ["pytest==9.1.1", "httpx2==2.13.1", "mypy==2.3.1", "pip-audit==2.10.1", "types-requests==2.33.0.20260906", "types-PyYAML==6.0.12.20260906", "types-croniter==6.2.4.20260711", "ruff==0.16.9"]
|
test = ["pytest==9.1.1", "httpx2==2.13.1", "mypy==2.3.1", "pip-audit==2.10.1", "types-requests==2.33.0.20260906", "types-PyYAML==6.0.12.20260906", "types-croniter==6.2.4.20260711", "ruff==0.16.9"]
|
||||||
@@ -9,6 +11,8 @@ test = ["pytest==9.1.1", "httpx2==2.13.1", "mypy==2.3.1", "pip-audit==2.10.1", "
|
|||||||
pythonpath = ["."]
|
pythonpath = ["."]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
|
# Preserve existing syntax/formatting independently of runtime support.
|
||||||
|
target-version = "py312"
|
||||||
exclude = ["vendor", ".venv"]
|
exclude = ["vendor", ".venv"]
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=75"]
|
requires = ["setuptools>=75"]
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ import time
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
APP = Path("/srv/pigway/apps/pigway-frame-flow")
|
ROOT = Path(os.getenv("FRAME_FLOW_ROOT", "/srv/frameflow"))
|
||||||
RECOVERY = Path("/srv/pigway/recovery/frame-flow")
|
APP = ROOT / "apps/pigway-frame-flow"
|
||||||
|
RECOVERY = ROOT / "recovery/frame-flow"
|
||||||
CONTAINER = "pigway-frame-flow"
|
CONTAINER = "pigway-frame-flow"
|
||||||
ORIGIN = "http://192.0.2.10:81"
|
ORIGIN = os.getenv("FRAME_FLOW_ORIGIN", "http://127.0.0.1:6005").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
def run(*args):
|
def run(*args):
|
||||||
@@ -112,11 +113,14 @@ def verify_runtime(commit):
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
assert os.geteuid() == 0, "Run with sudo on the Ubuntu Server"
|
assert os.geteuid() == 0, "Run with sudo on the Ubuntu Server"
|
||||||
|
expected_uuid = os.environ.get("FRAME_FLOW_MOUNT_UUID", "")
|
||||||
|
assert expected_uuid, "Set FRAME_FLOW_MOUNT_UUID to the verified storage UUID"
|
||||||
|
assert os.environ.get("FRAME_FLOW_ORIGIN"), (
|
||||||
|
"Set FRAME_FLOW_ORIGIN to the verified listener"
|
||||||
|
)
|
||||||
os.umask(0o077)
|
os.umask(0o077)
|
||||||
run("/usr/local/libexec/pigway-guard-mount")
|
run("/usr/local/libexec/pigway-guard-mount")
|
||||||
assert run("findmnt", "-n", "-o", "UUID", "/srv/pigway") == (
|
assert run("findmnt", "-n", "-o", "UUID", str(ROOT)) == expected_uuid
|
||||||
"00000000-0000-4000-8000-000000000001"
|
|
||||||
)
|
|
||||||
with (RECOVERY / ".release.lock").open("a") as lock:
|
with (RECOVERY / ".release.lock").open("a") as lock:
|
||||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
release(lock)
|
release(lock)
|
||||||
@@ -124,7 +128,7 @@ def main():
|
|||||||
|
|
||||||
def release(lock):
|
def release(lock):
|
||||||
ctx = Path(__file__).resolve().parent.parent
|
ctx = Path(__file__).resolve().parent.parent
|
||||||
assert ctx.parent == Path("/srv/pigway/migration-inbox")
|
assert ctx.parent == ROOT / "migration-inbox"
|
||||||
commit = (ctx / "RELEASE_COMMIT").read_text().strip()
|
commit = (ctx / "RELEASE_COMMIT").read_text().strip()
|
||||||
assert re.fullmatch("[0-9a-f]{40}", commit)
|
assert re.fullmatch("[0-9a-f]{40}", commit)
|
||||||
hashes = json.loads((ctx / "RELEASE_HASHES.json").read_text())
|
hashes = json.loads((ctx / "RELEASE_HASHES.json").read_text())
|
||||||
@@ -151,7 +155,7 @@ def release(lock):
|
|||||||
assert dst.execute("pragma quick_check").fetchone()[0] == "ok"
|
assert dst.execute("pragma quick_check").fetchone()[0] == "ok"
|
||||||
base = "pigway-frame-flow:release-base-" + commit[:12]
|
base = "pigway-frame-flow:release-base-" + commit[:12]
|
||||||
run("docker", "image", "tag", before["Image"], base)
|
run("docker", "image", "tag", before["Image"], base)
|
||||||
image = "pigway-frame-flow:0.1.15-" + commit[:12]
|
image = "pigway-frame-flow:0.1.19-" + commit[:12]
|
||||||
try:
|
try:
|
||||||
run(
|
run(
|
||||||
"docker",
|
"docker",
|
||||||
@@ -193,7 +197,7 @@ def release(lock):
|
|||||||
n: h
|
n: h
|
||||||
for n, h in hashes.items()
|
for n, h in hashes.items()
|
||||||
if n.startswith(("pigway_frame_flow/", "vendor/"))
|
if n.startswith(("pigway_frame_flow/", "vendor/"))
|
||||||
or n == "requirements.lock"
|
or n in {"requirements.lock", "LICENSE", "NOTICE.md"}
|
||||||
}
|
}
|
||||||
code = 'import hashlib,json,pathlib,sys; h=json.load(sys.stdin); assert all(hashlib.sha256((pathlib.Path("/app")/n).read_bytes()).hexdigest()==v for n,v in h.items())'
|
code = 'import hashlib,json,pathlib,sys; h=json.load(sys.stdin); assert all(hashlib.sha256((pathlib.Path("/app")/n).read_bytes()).hexdigest()==v for n,v in h.items())'
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* Isolated Mock only: dashboard and gallery share the same read-only image viewer. */
|
/* Isolated Mock only: dashboard and gallery share the same read-only image viewer. */
|
||||||
const assert=require('assert'),{chromium}=require('playwright');
|
const assert=require('assert'),{chromium}=require('playwright');
|
||||||
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});let checks=0;
|
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});let checks=0;
|
||||||
try{for(const width of [1440,768,390]){const p=await browser.newPage({viewport:{width,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.route('**/api/v1/devices/*/current-content',r=>r.fulfill({json:{image_id:42,preview_url:'/fixture-preview.svg?thumbnail=true',synced:true,source_known:false,overrides:[]}}));await p.route('**/fixture-preview.svg*',r=>r.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900"><rect width="1200" height="900" fill="#6c9c83"/><circle cx="600" cy="450" r="220" fill="#e9d9af"/></svg>'}));await p.route('**/api/v1/devices/*/images',r=>r.fulfill({json:[{image_id:42,name:'Fixture gallery',current:true,url:'/fixture-preview.svg'}]}));
|
try{for(const width of [1440,768,390]){const p=await browser.newPage({viewport:{width,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.route('**/api/v1/devices/*/current-content',r=>r.fulfill({json:{image_id:42,preview_url:'/fixture-preview.svg?thumbnail=true',synced:true,source_known:false,overrides:[]}}));await p.route('**/fixture-preview.svg*',r=>{return r.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="WIDTH" height="900"><rect width="1200" height="900" fill="#6c9c83"/><circle cx="600" cy="450" r="220" fill="#e9d9af"/></svg>'.replace('WIDTH',new URL(r.request().url()).searchParams.has('thumbnail')?'300':'1200')});});await p.route('**/api/v1/devices/*/images',r=>r.fulfill({json:[{image_id:42,name:'Fixture gallery',current:true,url:'/fixture-preview.svg'}]}));
|
||||||
await p.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await p.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await p.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await p.locator('#login-form button').click();await p.locator('#current-frame-preview').waitFor();assert(await p.evaluate(()=>state.settings.mock));
|
await p.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await p.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await p.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await p.locator('#login-form button').click();await p.locator('#current-frame-preview').waitFor();assert(await p.evaluate(()=>state.settings.mock));
|
||||||
for(const lang of ['zh','en'])for(const theme of ['dark','light']){await p.evaluate(async({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;await navigate('dashboard')},{lang,theme});await p.waitForFunction(()=>document.querySelector('#current-frame-image')?.naturalWidth===1200);assert(await p.locator('#current-frame-preview').evaluate(e=>e.getBoundingClientRect().height)>=(width<=720?320:400));assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await p.locator('#current-frame-preview').focus();const fullImage=p.waitForRequest(r=>new URL(r.url()).pathname==='/fixture-preview.svg'&&!new URL(r.url()).searchParams.has('thumbnail'));await p.keyboard.press('Enter');await fullImage;await p.locator('dialog.image-viewer[open]').waitFor();await p.waitForFunction(()=>document.querySelector('#original-image')?.naturalWidth===1200);assert.equal(await p.locator('.image-viewer-name').textContent(),(lang==='zh'?'当前显示内容':'Currently displayed content')+' #42');assert.equal(await p.locator('.image-viewer-actions button').count(),3);const sizing=p.locator('.image-viewer-actions button[aria-pressed]');await sizing.nth(1).click();assert(await p.locator('.image-viewer-viewport').evaluate(e=>e.classList.contains('actual-size')));await sizing.nth(0).click();assert(!await p.locator('.image-viewer-viewport').evaluate(e=>e.classList.contains('actual-size')));await p.keyboard.press('Escape');assert(!await p.locator('#modal').evaluate(e=>e.open));await p.waitForFunction(()=>!document.body.classList.contains('image-viewer-open'));if(process.env.FRAME_FLOW_SCREENSHOT_DIR&&lang==='zh'&&theme==='dark'){require('fs').mkdirSync(process.env.FRAME_FLOW_SCREENSHOT_DIR,{recursive:true});await p.screenshot({path:process.env.FRAME_FLOW_SCREENSHOT_DIR+'/dashboard-'+width+'.png',fullPage:true});}await p.evaluate(()=>navigate('gallery'));await p.locator('[data-original="0"]').click();await p.locator('dialog.image-viewer[open]').waitFor();assert.equal(await p.locator('.image-viewer-actions button').count(),3);await p.locator('#modal-cancel').click();checks++;}assert.deepEqual(errors,[]);await p.close();}console.log('Dashboard preview: '+checks+' profiles PASS; larger preview, keyboard opening, shared gallery viewer, fit/actual size, Escape, no overflow');}finally{await browser.close()}})().catch(e=>{console.error(e);process.exit(1)});
|
for(const lang of ['zh','en'])for(const theme of ['dark','light']){await p.evaluate(async({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;await navigate('dashboard')},{lang,theme});await p.waitForFunction(()=>document.querySelector('#current-frame-image')?.naturalWidth===1200);assert(await p.locator('#current-frame-preview').evaluate(e=>e.getBoundingClientRect().height)>=(width<=720?320:400));assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await p.locator('#current-frame-preview').focus();const fullImage=p.waitForRequest(r=>new URL(r.url()).pathname==='/fixture-preview.svg'&&!new URL(r.url()).searchParams.has('thumbnail'));await p.keyboard.press('Enter');await fullImage;await p.locator('dialog.image-viewer[open]').waitFor();await p.waitForFunction(()=>document.querySelector('#original-image')?.naturalWidth===1200);assert.equal(await p.locator('.image-viewer-name').textContent(),(lang==='zh'?'当前显示内容':'Currently displayed content')+' #42');assert.equal(await p.locator('.image-viewer-actions button').count(),3);const sizing=p.locator('.image-viewer-actions button[aria-pressed]');await sizing.nth(1).click();assert(await p.locator('.image-viewer-viewport').evaluate(e=>e.classList.contains('actual-size')));await sizing.nth(0).click();assert(!await p.locator('.image-viewer-viewport').evaluate(e=>e.classList.contains('actual-size')));await p.keyboard.press('Escape');assert(!await p.locator('#modal').evaluate(e=>e.open));await p.waitForFunction(()=>!document.body.classList.contains('image-viewer-open'));if(process.env.FRAME_FLOW_SCREENSHOT_DIR&&lang==='zh'&&theme==='dark'){require('fs').mkdirSync(process.env.FRAME_FLOW_SCREENSHOT_DIR,{recursive:true});await p.screenshot({path:process.env.FRAME_FLOW_SCREENSHOT_DIR+'/dashboard-'+width+'.png',fullPage:true});}await p.evaluate(()=>navigate('gallery'));await p.locator('[data-original="0"]').click();await p.locator('dialog.image-viewer[open]').waitFor();assert.equal(await p.locator('.image-viewer-actions button').count(),3);await p.locator('#modal-cancel').click();checks++;}assert.deepEqual(errors,[]);await p.close();}console.log('Dashboard preview: '+checks+' profiles PASS; larger preview, keyboard opening, shared gallery viewer, fit/actual size, Escape, no overflow');}finally{await browser.close()}})().catch(e=>{console.error(e);process.exit(1)});
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ const assert=require('assert');const fs=require('fs');const {chromium}=require('
|
|||||||
await page.route('**/api/v1/integrations/synology-photos',async route=>{if(route.request().method()==='POST'){assert.deepStrictEqual(route.request().postDataJSON(),{username:'fixture-nas',password:'fixture-only'});connected=true;logins++;}await route.fulfill({json:{configured:true,connected}});});
|
await page.route('**/api/v1/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.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();
|
await page.goto(base);await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();
|
||||||
const viewports=process.env.FRAME_FLOW_VIEWPORTS?JSON.parse(process.env.FRAME_FLOW_VIEWPORTS):[1440,768,390].map(width=>({width,height:900}));
|
const viewports=process.env.FRAME_FLOW_VIEWPORTS?JSON.parse(process.env.FRAME_FLOW_VIEWPORTS):[1920,1440,768,390].map(width=>({width,height:900}));
|
||||||
let checks=0;const failures=[];const out=process.env.FRAME_FLOW_SCREENSHOT_DIR;if(out)fs.mkdirSync(out,{recursive:true});
|
let checks=0;const failures=[];const out=process.env.FRAME_FLOW_SCREENSHOT_DIR;if(out)fs.mkdirSync(out,{recursive:true});
|
||||||
async function inspect(label){
|
async function inspect(label){
|
||||||
const result=await page.evaluate(()=>{
|
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=[];
|
const root=document.querySelector('dialog[open]')||document.querySelector('#main');const controls=[...root.querySelectorAll('button,select,input:not([type=checkbox]):not([type=radio]):not([type=file]),textarea')].filter(e=>e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})&&!e.closest('[hidden]')).map(e=>({id:e.id||e.textContent.trim().slice(0,24),compact:!!e.closest('.image-card .actions'),r:e.getBoundingClientRect()}));const close=[];
|
||||||
for(let i=0;i<controls.length;i++)for(let j=i+1;j<controls.length;j++){const a=controls[i].r,b=controls[j].r;const x=Math.min(a.right,b.right)-Math.max(a.left,b.left),y=Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top);const dx=Math.max(a.left,b.left)-Math.min(a.right,b.right),dy=Math.max(a.top,b.top)-Math.min(a.bottom,b.bottom);if((x>1&&dy<7.5)||(y>1&&dx<7.5))close.push([controls[i].id,controls[j].id]);}
|
for(let i=0;i<controls.length;i++)for(let j=i+1;j<controls.length;j++){const a=controls[i].r,b=controls[j].r;const x=Math.min(a.right,b.right)-Math.max(a.left,b.left),y=Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top);const dx=Math.max(a.left,b.left)-Math.min(a.right,b.right),dy=Math.max(a.top,b.top)-Math.min(a.bottom,b.bottom);const gap=controls[i].compact&&controls[j].compact?5.5:7.5;if((x>1&&dy<gap)||(y>1&&dx<gap))close.push([controls[i].id,controls[j].id]);}
|
||||||
const dialog=document.querySelector('dialog[open]');const rect=dialog?.getBoundingClientRect();
|
const dialog=document.querySelector('dialog[open]');const rect=dialog?.getBoundingClientRect();
|
||||||
const clippedDialog=!!rect&&(rect.left<0||rect.right>innerWidth||rect.top<0||rect.bottom>innerHeight);
|
const clippedDialog=!!rect&&(rect.left<0||rect.right>innerWidth||rect.top<0||rect.bottom>innerHeight);
|
||||||
const clippedNav=innerWidth<=720&&[...document.querySelectorAll('nav button')].some(button=>{const r=button.getBoundingClientRect();return button.scrollHeight>button.clientHeight||r.height<44;});
|
const clippedNav=innerWidth<=720&&[...document.querySelectorAll('nav button')].some(button=>{const r=button.getBoundingClientRect();return button.scrollHeight>button.clientHeight||r.height<44;});
|
||||||
@@ -25,7 +25,7 @@ const assert=require('assert');const fs=require('fs');const {chromium}=require('
|
|||||||
await page.setViewportSize({width,height});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});
|
await page.setViewportSize({width,height});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});
|
||||||
for(const view of ['dashboard','gallery','photo-sources','upload','templates','automation','playlists','history','settings','studio','connections']){await page.evaluate(view=>navigate(view),view);await inspect(`${view}/${width}/${lang}/${theme}`);}
|
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.evaluate(()=>navigate('studio'));await page.locator('.manual-collage summary').click();await inspect(`Manual collage/${width}/${lang}/${theme}`);await page.evaluate(()=>navigate('photo-sources'));await page.locator('#photos-login').click();await inspect(`Photos login/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
|
||||||
await page.locator('#add-photo-source').click();assert(await page.locator('#source-credential').isHidden());assert(await page.locator('#source-oauth').isHidden());await inspect(`NAS source/${width}/${lang}/${theme}`);await page.locator('#source-kind').selectOption('google-drive');assert(await page.locator('#source-oauth').isVisible());assert(await page.locator('#source-local-quality').isHidden());await inspect(`Drive source/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
|
await page.locator('#add-photo-source').click();assert(await page.locator('#source-credential').isHidden());assert(await page.locator('#source-oauth').isHidden());await inspect(`NAS source/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
|
||||||
await page.evaluate(()=>navigate('automation'));await page.locator('#new-job').click();await inspect(`Job/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
|
await page.evaluate(()=>navigate('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(()=>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(()=>displayDialog({type:'image',image_id:1}));await inspect(`Display options/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
|
||||||
@@ -33,6 +33,6 @@ const assert=require('assert');const fs=require('fs');const {chromium}=require('
|
|||||||
if(out&&lang==='zh'&&theme==='dark'){await page.evaluate(()=>navigate('photo-sources'));await page.screenshot({path:out+`/photos-${width}.png`,fullPage:true});}
|
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++;
|
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, '+(viewports.length*4)+' viewport/language/theme combinations; console errors:',errors.length);
|
assert.deepStrictEqual(errors,[]);assert.deepStrictEqual(failures,[],JSON.stringify(failures));console.log('Layout audit:',checks,'passed; 11 pages plus local gallery, 5 modal states, '+(viewports.length*4)+' viewport/language/theme combinations; console errors:',errors.length);
|
||||||
}finally{await browser.close();}
|
}finally{await browser.close();}
|
||||||
})().catch(e=>{console.error(e.message);process.exit(1)});
|
})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||||
|
|||||||
@@ -883,6 +883,19 @@
|
|||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Location"
|
"title": "Location"
|
||||||
},
|
},
|
||||||
|
"nas_root_path": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 2048,
|
||||||
|
"pattern": "^/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Nas Root Path"
|
||||||
|
},
|
||||||
"recursive": {
|
"recursive": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"title": "Recursive",
|
"title": "Recursive",
|
||||||
@@ -931,6 +944,20 @@
|
|||||||
],
|
],
|
||||||
"title": "Orientation"
|
"title": "Orientation"
|
||||||
},
|
},
|
||||||
|
"minimum_short_edge": {
|
||||||
|
"type": "integer",
|
||||||
|
"maximum": 50000.0,
|
||||||
|
"minimum": 0.0,
|
||||||
|
"title": "Minimum Short Edge",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"minimum_long_edge": {
|
||||||
|
"type": "integer",
|
||||||
|
"maximum": 50000.0,
|
||||||
|
"minimum": 0.0,
|
||||||
|
"title": "Minimum Long Edge",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
"minimum_sharpness": {
|
"minimum_sharpness": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"maximum": 10000.0,
|
"maximum": 10000.0,
|
||||||
@@ -1036,6 +1063,19 @@
|
|||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Location"
|
"title": "Location"
|
||||||
},
|
},
|
||||||
|
"nas_root_path": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 2048,
|
||||||
|
"pattern": "^/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Nas Root Path"
|
||||||
|
},
|
||||||
"recursive": {
|
"recursive": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"title": "Recursive",
|
"title": "Recursive",
|
||||||
@@ -1084,6 +1124,20 @@
|
|||||||
],
|
],
|
||||||
"title": "Orientation"
|
"title": "Orientation"
|
||||||
},
|
},
|
||||||
|
"minimum_short_edge": {
|
||||||
|
"type": "integer",
|
||||||
|
"maximum": 50000.0,
|
||||||
|
"minimum": 0.0,
|
||||||
|
"title": "Minimum Short Edge",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"minimum_long_edge": {
|
||||||
|
"type": "integer",
|
||||||
|
"maximum": 50000.0,
|
||||||
|
"minimum": 0.0,
|
||||||
|
"title": "Minimum Long Edge",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
"minimum_sharpness": {
|
"minimum_sharpness": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"maximum": 10000.0,
|
"maximum": 10000.0,
|
||||||
@@ -1192,6 +1246,17 @@
|
|||||||
],
|
],
|
||||||
"title": "Caption"
|
"title": "Caption"
|
||||||
},
|
},
|
||||||
|
"source_path": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Source Path"
|
||||||
|
},
|
||||||
"provider_preview": {
|
"provider_preview": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"title": "Provider Preview",
|
"title": "Provider Preview",
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import io
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def test_gallery_thumbnail_upgrades_existing_cache_and_reuses_it(client, settings):
|
||||||
|
cache = settings.data_dir / "cache"
|
||||||
|
original = cache / "living-room-1.original"
|
||||||
|
thumb = cache / "living-room-1-thumb.jpg"
|
||||||
|
Image.new("RGB", (1600, 1200), "blue").save(original, "JPEG")
|
||||||
|
Image.new("RGB", (300, 225), "blue").save(thumb, "JPEG")
|
||||||
|
url = "/api/v1/devices/living-room/images/1/file?thumbnail=true"
|
||||||
|
result = client.get(url)
|
||||||
|
assert result.status_code == 200
|
||||||
|
assert Image.open(io.BytesIO(result.content)).size == (860, 645)
|
||||||
|
modified = thumb.stat().st_mtime_ns
|
||||||
|
assert client.get(url).content == result.content
|
||||||
|
assert thumb.stat().st_mtime_ns == modified
|
||||||
|
assert len(list(cache.glob("living-room-1-thumb*"))) == 1
|
||||||
|
assert Image.open(original).size == (1600, 1200)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gallery_thumbnail_does_not_upscale_small_source(client, settings):
|
||||||
|
original = settings.data_dir / "cache" / "living-room-1.original"
|
||||||
|
Image.new("RGBA", (200, 400), (255, 0, 0, 255)).save(original, "PNG")
|
||||||
|
result = client.get("/api/v1/devices/living-room/images/1/file?thumbnail=true")
|
||||||
|
assert result.status_code == 200
|
||||||
|
assert Image.open(io.BytesIO(result.content)).size == (200, 400)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import io
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from pigway_frame_flow.models import PhotoSourceContent
|
||||||
|
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
||||||
|
|
||||||
|
|
||||||
|
def test_photo_path_survives_render_and_asset_listing(client, service, monkeypatch):
|
||||||
|
photos = service.photo_sources
|
||||||
|
photos.save(
|
||||||
|
"family",
|
||||||
|
PhotoSourceInput(
|
||||||
|
name="Family",
|
||||||
|
kind="synology-photos",
|
||||||
|
location=".",
|
||||||
|
nas_root_path="/volume1/photo",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(photos.synology, "sid", lambda: "fixture")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
photos.synology,
|
||||||
|
"items",
|
||||||
|
lambda *a, **kw: [
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"filename": "original.PNG",
|
||||||
|
"additional": {
|
||||||
|
"folder": "/家庭/20190130",
|
||||||
|
"resolution": {"width": 1600, "height": 1200},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
out = io.BytesIO()
|
||||||
|
Image.new("RGB", (1600, 1200)).save(out, "PNG")
|
||||||
|
monkeypatch.setattr(photos.synology, "download", lambda *a, **kw: out.getvalue())
|
||||||
|
assert (
|
||||||
|
photos.catalog("family")[0]["source_path"]
|
||||||
|
== "/volume1/photo/家庭/20190130/original.PNG"
|
||||||
|
)
|
||||||
|
asset = service.content_asset(
|
||||||
|
PhotoSourceContent(type="photo_source", source_id="family", item_id="42")
|
||||||
|
)
|
||||||
|
assert asset["source_path"] == "/volume1/photo/家庭/20190130/original.PNG"
|
||||||
|
assert client.get("/api/v1/assets").json()[0]["source_path"] == asset["source_path"]
|
||||||
|
assert (
|
||||||
|
client.get("/api/v1/assets/" + asset["asset_id"] + "/source").json()["source"][
|
||||||
|
"item_id"
|
||||||
|
]
|
||||||
|
== "42"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_photo_path_is_not_invented(service):
|
||||||
|
out = io.BytesIO()
|
||||||
|
Image.new("RGB", (20, 20)).save(out, "JPEG")
|
||||||
|
asset = service.assets.put(out.getvalue(), "manual.jpg")
|
||||||
|
assert asset["source_path"] is None
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import io
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from pigway_frame_flow.errors import FlowError
|
||||||
|
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
||||||
|
|
||||||
|
|
||||||
|
def test_minimum_resolution_filters_catalog_and_persists(
|
||||||
|
client, service, settings, legacy_photo_engine
|
||||||
|
):
|
||||||
|
root = settings.data_dir.parent / "photos"
|
||||||
|
root.mkdir()
|
||||||
|
settings.photo_root = root
|
||||||
|
for name, size in [
|
||||||
|
("small.jpg", (160, 120)),
|
||||||
|
("wide.jpg", (800, 600)),
|
||||||
|
("tall.jpg", (600, 800)),
|
||||||
|
]:
|
||||||
|
Image.new("RGB", size).save(root / name)
|
||||||
|
response = client.put(
|
||||||
|
"/api/v1/photo-sources/family",
|
||||||
|
json={
|
||||||
|
"name": "Family",
|
||||||
|
"kind": "local",
|
||||||
|
"location": ".",
|
||||||
|
"minimum_short_edge": 600,
|
||||||
|
"minimum_long_edge": 800,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["minimum_short_edge"] == 600
|
||||||
|
assert service.photo_sources.get("family").minimum_long_edge == 800
|
||||||
|
assert {x["name"] for x in service.photo_sources.catalog("family")} == {
|
||||||
|
"wide.jpg",
|
||||||
|
"tall.jpg",
|
||||||
|
}
|
||||||
|
response = client.put(
|
||||||
|
"/api/v1/photo-sources/family",
|
||||||
|
json={
|
||||||
|
"name": "Family",
|
||||||
|
"kind": "local",
|
||||||
|
"location": ".",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(service.photo_sources.catalog("family")) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolution_rejects_small_synology_preview(service, monkeypatch):
|
||||||
|
photos = service.photo_sources
|
||||||
|
photos.save(
|
||||||
|
"family",
|
||||||
|
PhotoSourceInput(
|
||||||
|
name="Family",
|
||||||
|
kind="synology-photos",
|
||||||
|
location=".",
|
||||||
|
minimum_short_edge=600,
|
||||||
|
minimum_long_edge=800,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = {"id": "1", "name": "photo.jpg"}
|
||||||
|
monkeypatch.setattr(photos, "catalog", lambda *args, **kwargs: [row])
|
||||||
|
photos.api_items["family"] = {"1": {}}
|
||||||
|
monkeypatch.setattr(photos.synology, "item_people", lambda *a, **kw: [])
|
||||||
|
|
||||||
|
def image(size):
|
||||||
|
out = io.BytesIO()
|
||||||
|
Image.new("RGB", size).save(out, "JPEG")
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
monkeypatch.setattr(photos.synology, "download", lambda *a, **kw: image((300, 400)))
|
||||||
|
with pytest.raises(FlowError, match="minimum resolution"):
|
||||||
|
photos.read("family", "1")
|
||||||
|
monkeypatch.setattr(photos.synology, "download", lambda *a, **kw: image((800, 600)))
|
||||||
|
assert photos.read("family", "1")[1] == "photo.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_minimum_resolution_rejects_negative_value(client):
|
||||||
|
response = client.put(
|
||||||
|
"/api/v1/photo-sources/family",
|
||||||
|
json={
|
||||||
|
"name": "Family",
|
||||||
|
"kind": "synology-photos",
|
||||||
|
"location": ".",
|
||||||
|
"minimum_short_edge": -1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_synology_metadata_resolution_filters_before_catalog_limit(
|
||||||
|
client, service, monkeypatch
|
||||||
|
):
|
||||||
|
photos = service.photo_sources
|
||||||
|
monkeypatch.setattr(photos.synology, "sid", lambda: "fixture")
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"id": i,
|
||||||
|
"filename": f"{i}.jpg",
|
||||||
|
"additional": {
|
||||||
|
"folder": "/family",
|
||||||
|
"resolution": {"width": w, "height": h},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for i, w, h in [(1, 300, 400), (2, 800, 600), (3, 0, 0)]
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(photos.synology, "items", lambda *a, **kw: rows)
|
||||||
|
response = client.put(
|
||||||
|
"/api/v1/photo-sources/family",
|
||||||
|
json={
|
||||||
|
"name": "Family",
|
||||||
|
"kind": "synology-photos",
|
||||||
|
"location": ".",
|
||||||
|
"minimum_short_edge": 600,
|
||||||
|
"minimum_long_edge": 800,
|
||||||
|
"catalog_limit": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["minimum_long_edge"] == 800
|
||||||
|
assert [x["id"] for x in photos.catalog("family")] == ["2"]
|
||||||
@@ -34,3 +34,22 @@ def test_actions_downloads_do_not_depend_on_personal_server():
|
|||||||
r"https://github\.com/actions/(checkout|setup-python)@[0-9a-f]{40}",
|
r"https://github\.com/actions/(checkout|setup-python)@[0-9a-f]{40}",
|
||||||
action,
|
action,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_community_checks_are_isolated_from_private_runners():
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
private = yaml.safe_load(Path(".github/workflows/verify.yml").read_text())
|
||||||
|
public = yaml.safe_load(Path(".github/workflows/community.yml").read_text())
|
||||||
|
assert (
|
||||||
|
"github.server_url != 'https://github.com'" in private["jobs"]["verify"]["if"]
|
||||||
|
)
|
||||||
|
job = public["jobs"]["verify"]
|
||||||
|
assert job["if"] == "github.server_url == 'https://github.com'"
|
||||||
|
assert job["runs-on"] == "ubuntu-latest"
|
||||||
|
assert public["permissions"] == {"contents": "read"}
|
||||||
|
workflow = Path(".github/workflows/community.yml").read_text()
|
||||||
|
assert "secrets." not in workflow
|
||||||
|
assert "production-deploy" not in workflow
|
||||||
|
assert "pull_request_target" not in workflow
|
||||||
|
assert "persist-credentials: false" in workflow
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ def test_release_readiness_retries_on_actual_listener(release_tool, monkeypatch)
|
|||||||
|
|
||||||
def listener(url, timeout):
|
def listener(url, timeout):
|
||||||
calls.append(url)
|
calls.append(url)
|
||||||
if url != "http://192.0.2.10:81/ready" or len(calls) == 1:
|
if url != release_tool.ORIGIN + "/ready" or len(calls) == 1:
|
||||||
raise urllib.error.URLError("Connection refused during restart")
|
raise urllib.error.URLError("Connection refused during restart")
|
||||||
return io.BytesIO(b'{"status":"ready"}')
|
return io.BytesIO(b'{"status":"ready"}')
|
||||||
|
|
||||||
@@ -71,3 +71,24 @@ def test_release_readiness_retries_on_actual_listener(release_tool, monkeypatch)
|
|||||||
monkeypatch.setattr(release_tool.time, "sleep", lambda _: None)
|
monkeypatch.setattr(release_tool.time, "sleep", lambda _: None)
|
||||||
release_tool.wait_ready()
|
release_tool.wait_ready()
|
||||||
assert len(calls) == 2
|
assert len(calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_requires_private_storage_identity_before_host_actions(
|
||||||
|
release_tool, monkeypatch
|
||||||
|
):
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(release_tool.os, "geteuid", lambda: 0)
|
||||||
|
monkeypatch.setattr(release_tool, "run", lambda *args: calls.append(args))
|
||||||
|
monkeypatch.delenv("FRAME_FLOW_MOUNT_UUID", raising=False)
|
||||||
|
with pytest.raises(AssertionError, match="FRAME_FLOW_MOUNT_UUID"):
|
||||||
|
release_tool.main()
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_rejects_wrong_storage_uuid(release_tool, monkeypatch):
|
||||||
|
monkeypatch.setattr(release_tool.os, "geteuid", lambda: 0)
|
||||||
|
monkeypatch.setenv("FRAME_FLOW_MOUNT_UUID", "expected-storage-uuid")
|
||||||
|
monkeypatch.setenv("FRAME_FLOW_ORIGIN", release_tool.ORIGIN)
|
||||||
|
monkeypatch.setattr(release_tool, "run", lambda *args: "different-storage-uuid")
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
release_tool.main()
|
||||||
|
|||||||
@@ -69,12 +69,12 @@ def test_people_pagination_does_not_stop_at_500(settings, monkeypatch):
|
|||||||
return {
|
return {
|
||||||
"list": [
|
"list": [
|
||||||
{"id": i}
|
{"id": i}
|
||||||
for i in range(params["offset"], min(1334, params["offset"] + 500))
|
for i in range(params["offset"], min(1203, params["offset"] + 500))
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
monkeypatch.setattr(p, "request", request)
|
monkeypatch.setattr(p, "request", request)
|
||||||
assert len(p.people()) == 1334
|
assert len(p.people()) == 1203
|
||||||
assert calls == [0, 500, 1000]
|
assert calls == [0, 500, 1000]
|
||||||
|
|
||||||
|
|
||||||
@@ -401,3 +401,79 @@ def test_explicit_offline_conversion_preserves_task_reference(service, settings)
|
|||||||
and after.person_match == "all"
|
and after.person_match == "all"
|
||||||
and after.location == "family"
|
and after.location == "family"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("space", ["shared", "personal"])
|
||||||
|
@pytest.mark.parametrize("exif", [5, 6, 7, 8])
|
||||||
|
def test_api_filters_already_oriented_resolution_without_rotating_twice(
|
||||||
|
service, settings, monkeypatch, space, exif
|
||||||
|
):
|
||||||
|
session(settings)
|
||||||
|
photos = service.photo_sources
|
||||||
|
requested = []
|
||||||
|
|
||||||
|
def pages(api, version, **params):
|
||||||
|
requested[:] = json.loads(params["additional"])
|
||||||
|
item = row(1)
|
||||||
|
# Photos returns display dimensions even when EXIF retains a rotation.
|
||||||
|
item["additional"]["resolution"] = {"width": 16, "height": 24}
|
||||||
|
if "orientation" in requested:
|
||||||
|
item["additional"]["orientation"] = exif
|
||||||
|
return [item]
|
||||||
|
|
||||||
|
monkeypatch.setattr(photos.synology, "pages", pages)
|
||||||
|
photos.save(
|
||||||
|
"direction",
|
||||||
|
PhotoSourceInput(
|
||||||
|
name="Fixture",
|
||||||
|
kind="synology-photos",
|
||||||
|
location=".",
|
||||||
|
people_space=space,
|
||||||
|
orientation="landscape",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert photos.catalog("direction") == []
|
||||||
|
photos.save(
|
||||||
|
"direction",
|
||||||
|
photos.get("direction").model_copy(update={"orientation": "portrait"}),
|
||||||
|
)
|
||||||
|
items = photos.catalog("direction")
|
||||||
|
assert "orientation" in requested
|
||||||
|
assert [(x["width"], x["height"]) for x in items] == [(16, 24)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rotation", [1, 8])
|
||||||
|
def test_fetched_photo_direction_is_checked_before_cropping(
|
||||||
|
service, settings, monkeypatch, rotation
|
||||||
|
):
|
||||||
|
session(settings)
|
||||||
|
photos = service.photo_sources
|
||||||
|
monkeypatch.setattr(photos.synology, "items", lambda *a, **k: [row(1)])
|
||||||
|
out = io.BytesIO()
|
||||||
|
exif = Image.Exif()
|
||||||
|
exif[274] = rotation
|
||||||
|
size = (16, 24) if rotation == 1 else (24, 16)
|
||||||
|
Image.new("RGB", size, "blue").save(out, "JPEG", exif=exif)
|
||||||
|
monkeypatch.setattr(photos.synology, "download", lambda *a, **k: out.getvalue())
|
||||||
|
photos.save(
|
||||||
|
"direction",
|
||||||
|
PhotoSourceInput(
|
||||||
|
name="Fixture",
|
||||||
|
kind="synology-photos",
|
||||||
|
location=".",
|
||||||
|
orientation="landscape",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with pytest.raises(FlowError, match="direction"):
|
||||||
|
photos.read("direction", "1")
|
||||||
|
|
||||||
|
# A correctly selected portrait remains readable, including EXIF-only rotation.
|
||||||
|
item = row(1)
|
||||||
|
item["additional"]["orientation"] = 8
|
||||||
|
item["additional"]["resolution"] = {"width": 16, "height": 24}
|
||||||
|
monkeypatch.setattr(photos.synology, "items", lambda *a, **k: [item])
|
||||||
|
photos.save(
|
||||||
|
"direction",
|
||||||
|
photos.get("direction").model_copy(update={"orientation": "portrait"}),
|
||||||
|
)
|
||||||
|
assert photos.read("direction", "1")[0] == out.getvalue()
|
||||||
|
|||||||
Reference in New Issue
Block a user