fix: close library upload flow and harden image storage
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 8m9s

This commit is contained in:
渡口浪人
2026-10-24 00:38:00 +08:00
parent 0859871363
commit 1494904b12
39 changed files with 1471 additions and 240 deletions
+10
View File
@@ -1,3 +1,13 @@
# 当前开发修订
- 更新图片处理、证书信任和遥测依赖;不可信图片只交给允许的格式解析器。
- 手动上传接入来源图库,保存源画面后可按方案生成并关联终端。
- 补齐图片写权限;客户端权限选项使用服务端统一目录。
- 图片文件和数据库变更加入事务、失败清理及重启恢复;缓存转成品也保留一致性。
- 统一上传及图库文案,移除图库重复设备选择器;NAS 空批次支持明确继续读取提示。
- 设置页显示磁盘容量和待清理文件;登录计算不再占用共享数据库锁。
- 保持单用户、内网 HTTP 访问,无证书要求。
# pigway-frame-flow v0.1.29
完善星标与“不再选用”禁用状态的原因提示。
+8
View File
@@ -10,6 +10,14 @@
优先级:P1 为对外交付前处理;P2 为下一轮产品完善;P3 为演进建议。以下“确认”指代码或本地复现已证实,“风险”表示存在可解释路径但没有执行攻击或故障压测。
## 2026-10-24 范围确认与本轮整改
用户确认产品为单用户、内网 IP 访问,接受 HTTP,不使用证书。A02 在此部署范围内是已接受风险,不作为交付阻塞项;未来暴露到不可信网络时重新评估。下文保留原始审计依据,不能将其当作当前全部未修复事项。
本轮对应已确认的改进:依赖升级并重新扫描、图片插件白名单、上传接入来源图库、文件/数据库事务及失败恢复、统一 `images:write` 权限目录、上传/图库术语和设备选择器去重、NAS 空批次继续读取提示、设置页磁盘容量提示、登录 scrypt 不占用数据库锁。补充故障注入及中英文、桌面/移动端流程验证。
本轮不引入 HTTPS、多用户、微服务或新数据库;原审计中的其他商业运营、外部服务条款和扩展能力建议继续作为条件性建议,不声称已完成外部认证。
## 一、对外交付前的问题
### A01 · P1 · 锁定依赖存在新公开安全告警(确认,利用条件需分别判断)
+10
View File
@@ -48,3 +48,13 @@
- `PUT /api/v1/jobs/{job_id}/library-mode`:使用 `jobs:write` 切换模式,保留下次执行时间和启用状态。
新增 `library_plans`、`source_images`、`library_versions`、`terminal_bindings`、`terminal_deliveries` 表,复用现有 SQLite、资源鉴权、Pillow 渲染及任务队列。`terminal_bindings.terminal_id` 是主键,保证终端仅有一个绑定;服务端同步校验来源、方案、版本归属以及屏幕尺寸。读取需要 `images:read`,管理需要 `admin`,本地删除需要 `images:delete`,终端推送需要 `display:write`。旧 API 保留兼容。
## 手动上传与本地存储
“上传图片”可将无叠加的源画面保存到所选来源图库;默认“手动上传”来源在首次成功上传时创建,不需要 NAS 连接。保存后通过“查看来源图库”找回图片,再建立方案和终端绑定。手动来源的随机选图及任务沿用同一套来源流程,不创建另一套图库。上传到已有来源图库只保存本地副本,不写回 NAS 或网盘。源画面按输入像素尺寸及 EXIF 朝向归一化,预览的相框裁切、色彩不会改变保存的源画面;实际输出由生成方案决定。相同内容在同一来源重复保存会复用图片。
本地上传使用 `POST /api/v1/source-libraries/{id}/upload`,链接导入使用 `/upload-url`,二者要求 `images:write`,保留图片大小、格式及 URL 安全限制。`manual-uploads` 是内建手动来源的保留 ID;设置页管理本地存储,照片数据源页管理远程连接。
资产写入及相关来源/版本记录共享数据库事务。文件先落临时文件并同步后再提交资产;未完成创建和已提交删除通过持久化文件记录在启动/维护时恢复清理。缓存转为持久成品时也保留可恢复的源/目标状态。删除不会在数据库事务成功前移除图片文件;清理失败会留下可重试记录,设置页显示待清理数量。清理记录不是照片备份,不会保留已删除图片供恢复。
设置页显示数据盘总容量和可用容量;可用低于 512 MiB 或总容量的 5% 时提示空间不足。容量提示不自动开启清理策略,也不自动删除来源图片或成品。
+478 -2
View File
@@ -19633,6 +19633,450 @@
}
}
}
},
"/api/v1/client-scopes": {
"get": {
"summary": "Client Scopes",
"operationId": "client_scopes_api_v1_client_scopes_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"409": {
"description": "Conflict",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"413": {
"description": "Content Too Large",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"429": {
"description": "Too Many Requests",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"502": {
"description": "Bad Gateway",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"503": {
"description": "Service Unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
},
"security": [
{
"HTTPBearer": []
},
{
"AccountSession": []
}
]
}
},
"/api/v1/source-libraries/{source_id}/upload": {
"post": {
"summary": "Upload",
"operationId": "upload_api_v1_source_libraries__source_id__upload_post",
"security": [
{
"HTTPBearer": []
},
{
"AccountSession": []
}
],
"parameters": [
{
"name": "source_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Source Id"
}
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_upload_api_v1_source_libraries__source_id__upload_post"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Bad Request"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Forbidden"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Not Found"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Conflict"
},
"413": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Content Too Large"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Unprocessable Content"
},
"429": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Too Many Requests"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Internal Server Error"
},
"502": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Bad Gateway"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Service Unavailable"
}
}
}
},
"/api/v1/source-libraries/{source_id}/upload-url": {
"post": {
"summary": "Upload Url",
"operationId": "upload_url_api_v1_source_libraries__source_id__upload_url_post",
"security": [
{
"HTTPBearer": []
},
{
"AccountSession": []
}
],
"parameters": [
{
"name": "source_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Source Id"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UploadURLInput"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Bad Request"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Forbidden"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Not Found"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Conflict"
},
"413": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Content Too Large"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Unprocessable Content"
},
"429": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Too Many Requests"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Internal Server Error"
},
"502": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Bad Gateway"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Service Unavailable"
}
}
}
}
},
"components": {
@@ -22012,7 +22456,8 @@
"webdav",
"google-drive",
"alipan",
"baidu"
"baidu",
"uploads"
],
"title": "Kind"
},
@@ -22192,7 +22637,8 @@
"webdav",
"google-drive",
"alipan",
"baidu"
"baidu",
"uploads"
],
"title": "Kind"
},
@@ -23757,6 +24203,36 @@
"enabled"
],
"title": "LibraryModeInput"
},
"Body_upload_api_v1_source_libraries__source_id__upload_post": {
"properties": {
"file": {
"type": "string",
"contentMediaType": "application/octet-stream",
"title": "File"
}
},
"type": "object",
"required": [
"file"
],
"title": "Body_upload_api_v1_source_libraries__source_id__upload_post"
},
"UploadURLInput": {
"properties": {
"url": {
"type": "string",
"maxLength": 2048,
"minLength": 1,
"title": "Url"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"url"
],
"title": "UploadURLInput"
}
},
"securitySchemes": {
+14 -19
View File
@@ -425,7 +425,9 @@ def create_app(settings=None, start_worker=True):
from PIL import Image
try:
with Image.open(BytesIO(data)) as source:
with Image.open(
BytesIO(data), formats=("JPEG", "PNG", "WEBP")
) as source:
if source.width * source.height > 25_000_000:
raise ValueError()
if source.format not in {"JPEG", "PNG", "WEBP"}:
@@ -438,13 +440,13 @@ def create_app(settings=None, start_worker=True):
thumb = cached.with_name(cached.stem + "-thumb.jpg")
from PIL import Image
with Image.open(cached) as img:
with Image.open(cached, formats=("JPEG", "PNG", "WEBP")) as img:
# The 430px cards need up to 860px on 2x displays. Replace the
# existing cache in place so old 300px previews are upgraded.
expected_edge = min(860, max(img.size))
regenerate = not thumb.exists()
if not regenerate:
with Image.open(thumb) as existing:
with Image.open(thumb, formats=("JPEG", "PNG", "WEBP")) as existing:
regenerate = max(existing.size) != expected_edge
if regenerate:
img.thumbnail((860, 860), Image.Resampling.LANCZOS)
@@ -452,7 +454,7 @@ def create_app(settings=None, start_worker=True):
return FileResponse(thumb, media_type="image/jpeg")
from PIL import Image
with Image.open(cached) as original:
with Image.open(cached, formats=("JPEG", "PNG", "WEBP")) as original:
media_type = Image.MIME.get(
original.format or "", "application/octet-stream"
)
@@ -575,15 +577,15 @@ def create_app(settings=None, start_worker=True):
if len(data) > settings.max_bytes:
raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB")
name = Path(file.filename or "image").name[:120]
asset = assets.put(
with assets.creating(
await run_in_threadpool(convert, data, fit, profile),
settings.sanitize(name),
kind="cache",
)
db.execute(
"INSERT INTO asset_metadata(asset_id,origin) VALUES(?,?)",
(asset["asset_id"], "manual"),
)
) as asset:
db.execute(
"INSERT INTO asset_metadata(asset_id,origin) VALUES(?,?)",
(asset["asset_id"], "manual"),
)
return assets.info(asset["asset_id"])
@app.get(
@@ -620,7 +622,7 @@ def create_app(settings=None, start_worker=True):
dependencies=[Depends(require("images:delete"))],
)
def delete_asset(asset_id: str):
path = assets.path(asset_id)
assets.path(asset_id)
if db.one(
"SELECT id FROM assets WHERE id=? AND kind IN ('source-images','renditions')",
(asset_id,),
@@ -645,14 +647,7 @@ def create_app(settings=None, start_worker=True):
"Asset is referenced by a task, template or pending operation",
409,
)
path.unlink(missing_ok=True)
db.execute(
"DELETE FROM photo_preferences WHERE source_id='@assets' AND item_id=?",
(asset_id,),
)
db.execute("DELETE FROM asset_metadata WHERE asset_id=?", (asset_id,))
db.execute("DELETE FROM asset_sources WHERE asset_id=?", (asset_id,))
db.execute("DELETE FROM assets WHERE id=?", (asset_id,))
service.libraries.remove_asset(asset_id)
for row in db.all(
"SELECT key,payload FROM app_config WHERE key LIKE 'wrist-thumbnail:%'"
):
+50 -3
View File
@@ -1,4 +1,6 @@
from fastapi import Depends, Query, Request
import hashlib
from fastapi import Depends, File, Query, Request, UploadFile
from fastapi.responses import Response
from pydantic import Field
@@ -8,6 +10,7 @@ from pigway_frame_flow.display.libraries import GenerationPlan
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import ImageExclusion, ImageStar, StrictModel
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
from pigway_frame_flow.providers.source_browser import BrowseFilters
@@ -16,6 +19,10 @@ class BindingInput(StrictModel):
plan_id: str = Field(pattern=r"^[a-f0-9]{32}$")
class UploadURLInput(StrictModel):
url: str = Field(min_length=1, max_length=2048)
class ImportInput(StrictModel):
item_id: str = Field(min_length=1, max_length=2048)
@@ -56,6 +63,46 @@ def install_libraries(app, service):
for source in service.photo_sources.list()
]
def save_upload(source_id, data, name):
if len(data) > service.settings.max_bytes:
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
with service.lock:
if source_id == "manual-uploads" and not db.one(
"SELECT key FROM app_config WHERE key='photo_source:manual-uploads'"
):
from pigway_frame_flow.renderers.images import convert
convert(data, size=(1, 1))
service.photo_sources.save(
source_id,
PhotoSourceInput(name="手动上传", kind="uploads", location="."),
)
if not service.photo_sources.get(source_id).enabled:
raise FlowError(E.CONFLICT, "Photo source is disabled", 409)
return libraries.ingest(
source_id, "upload:" + hashlib.sha256(data).hexdigest(), data, name, {}
)
@app.post(
"/api/v1/source-libraries/{source_id}/upload",
dependencies=[Depends(require("images:write"))],
)
def upload(source_id: str, file: UploadFile = File(...)):
return save_upload(
source_id,
file.file.read(service.settings.max_bytes + 1),
file.filename or "upload.jpg",
)
@app.post(
"/api/v1/source-libraries/{source_id}/upload-url",
dependencies=[Depends(require("images:write"))],
)
def upload_url(source_id: str, payload: UploadURLInput):
return save_upload(
source_id, service.assets.fetch_url(payload.url), "url-upload.jpg"
)
@app.get(
"/api/v1/source-libraries/{source_id}/images",
dependencies=[Depends(require("images:read"))],
@@ -183,7 +230,7 @@ def install_libraries(app, service):
@app.put(
"/api/v1/source-images/{picture_id}/star",
dependencies=[Depends(require("admin"))],
dependencies=[Depends(require("images:write"))],
)
def star(picture_id: str, payload: ImageStar):
picture = libraries.picture(picture_id)
@@ -193,7 +240,7 @@ def install_libraries(app, service):
@app.put(
"/api/v1/source-images/{picture_id}/exclusion",
dependencies=[Depends(require("admin"))],
dependencies=[Depends(require("images:write"))],
)
def exclude(picture_id: str, payload: ImageExclusion):
picture = libraries.picture(picture_id)
+45 -25
View File
@@ -4,6 +4,7 @@ import hashlib
import hmac
import json
import secrets
import threading
import time
from pathlib import Path
@@ -105,9 +106,12 @@ def session_user(request: Request):
def install_accounts(app, db):
# Serialize authentication work without holding the database lock during scrypt.
login_lock = threading.Lock()
@app.post("/api/v1/auth/login", response_model=AccountView, tags=["Accounts"])
def login(payload: Login, request: Request, response: Response):
with db.lock:
with login_lock:
# Reject login CSRF before checking credentials. Never trust forwarded hosts.
origin = request.headers.get("origin")
if request.headers.get("sec-fetch-site") == "cross-site" or (
@@ -154,31 +158,47 @@ def install_accounts(app, db):
(key, failures, time.time() + 300 if failures >= 5 else 0),
)
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
for key in identities:
db.execute("DELETE FROM login_attempts WHERE identity=?", (key,))
if row["password_hash"].startswith("scrypt$"):
db.execute(
"UPDATE users SET password_hash=? WHERE username=?",
(password_hash(payload.password), row["username"]),
)
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
lifetime = 30 * 86400 if payload.remember else 12 * 3600
db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),))
old = request.cookies.get(COOKIE)
if old:
db.execute(
"DELETE FROM sessions WHERE token_hash=?",
(hashlib.sha256(old.encode()).hexdigest(),),
)
db.execute(
"INSERT INTO sessions VALUES(?,?,?,?)",
(
hashlib.sha256(raw.encode()).hexdigest(),
row["username"],
csrf,
time.time() + lifetime,
),
upgraded = (
password_hash(payload.password)
if row["password_hash"].startswith("scrypt$")
else None
)
with db.lock:
# A concurrent password change must not create a session from old credentials.
current_row = db.one(
"SELECT * FROM users WHERE username=? AND enabled=1",
(payload.username,),
)
if (
not current_row
or current_row["password_hash"] != row["password_hash"]
):
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
for key in identities:
db.execute("DELETE FROM login_attempts WHERE identity=?", (key,))
if row["password_hash"].startswith("scrypt$"):
db.execute(
"UPDATE users SET password_hash=? WHERE username=?",
(upgraded, row["username"]),
)
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
lifetime = 30 * 86400 if payload.remember else 12 * 3600
db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),))
old = request.cookies.get(COOKIE)
if old:
db.execute(
"DELETE FROM sessions WHERE token_hash=?",
(hashlib.sha256(old.encode()).hexdigest(),),
)
db.execute(
"INSERT INTO sessions VALUES(?,?,?,?)",
(
hashlib.sha256(raw.encode()).hexdigest(),
row["username"],
csrf,
time.time() + lifetime,
),
)
response.set_cookie(
COOKIE,
raw,
+1
View File
@@ -14,6 +14,7 @@ bearer = HTTPBearer(auto_error=False)
ALL_SCOPES = [
"devices:read",
"images:read",
"images:write",
"images:delete",
"display:write",
"templates:read",
+92 -72
View File
@@ -164,6 +164,13 @@ class Libraries:
return self.binding(terminal_id)
def ingest(self, source_id, item_id, data, name, caption):
if self.service.photo_sources.get(source_id).kind == "uploads":
existing = self.db.one(
"SELECT id FROM source_images WHERE source_id=? AND item_id=?",
(source_id, item_id),
)
if existing:
return self.picture(existing["id"])
digest = hashlib.sha256(
data + b"\0" + encode(caption or {}).encode()
).hexdigest()
@@ -175,7 +182,7 @@ class Libraries:
return self.picture(row["id"])
# Normalize EXIF and JPEG storage without resizing or adding decorations.
try:
with Image.open(BytesIO(data)) as image:
with Image.open(BytesIO(data), formats=("JPEG", "PNG", "WEBP")) as image:
if (
image.format not in {"JPEG", "PNG", "WEBP"}
or image.width * image.height > MAX_IMAGE_PIXELS
@@ -187,35 +194,37 @@ class Libraries:
normalized = convert(data, "cover", size=size, quality=95)
except (OSError, ValueError, Image.DecompressionBombError):
raise FlowError(E.INVALID_REQUEST, "Invalid source image") from None
asset = self.service.assets.put(normalized, name, "source-images")
self.db.execute(
"INSERT INTO source_images VALUES(?,?,?,?,?,?)",
(
asset["asset_id"],
source_id,
item_id,
digest,
encode(caption or {}),
now_iso(),
),
)
self.db.execute(
"INSERT INTO asset_sources VALUES(?,?)",
(
asset["asset_id"],
encode(
{
"type": "library_source",
"source_id": source_id,
"item_id": item_id,
"source_rendition": "preview"
if self.service.photo_sources.get(source_id).kind
== "synology-photos"
else "source",
}
with self.service.assets.creating(normalized, name, "source-images") as asset:
self.db.execute(
"INSERT INTO source_images VALUES(?,?,?,?,?,?)",
(
asset["asset_id"],
source_id,
item_id,
digest,
encode(caption or {}),
now_iso(),
),
),
)
)
self.db.execute(
"INSERT INTO asset_sources VALUES(?,?)",
(
asset["asset_id"],
encode(
{
"type": "library_source",
"source_id": source_id,
"item_id": item_id,
"source_rendition": "upload"
if item_id.startswith("upload:")
else "preview"
if self.service.photo_sources.get(source_id).kind
== "synology-photos"
else "source",
}
),
),
)
return self.picture(asset["asset_id"])
def import_picture(self, source_id, item_id):
@@ -342,37 +351,37 @@ class Libraries:
):
return version
data = self.service.assets.path(picture_id).read_bytes()
asset = self.service.assets.put(
with self.service.assets.creating(
self.render(data, picture["caption"], plan),
picture["name"].removesuffix(".jpg") + "-" + plan.name,
"renditions",
)
self.db.execute(
"INSERT INTO library_versions VALUES(?,?,?,?,?,?)",
(
asset["asset_id"],
picture_id,
plan_id,
revision,
encode(plan.model_dump()),
now_iso(),
),
)
self.db.execute(
"INSERT INTO asset_sources VALUES(?,?)",
(
asset["asset_id"],
encode(
{
"type": "library_version",
"source_image_id": picture_id,
"source_id": plan.source_id,
"plan_id": plan_id,
"revision": revision,
}
) as asset:
self.db.execute(
"INSERT INTO library_versions VALUES(?,?,?,?,?,?)",
(
asset["asset_id"],
picture_id,
plan_id,
revision,
encode(plan.model_dump()),
now_iso(),
),
),
)
)
self.db.execute(
"INSERT INTO asset_sources VALUES(?,?)",
(
asset["asset_id"],
encode(
{
"type": "library_version",
"source_image_id": picture_id,
"source_id": plan.source_id,
"plan_id": plan_id,
"revision": revision,
}
),
),
)
return next(
v
for v in self.versions(picture_id)
@@ -642,21 +651,33 @@ class Libraries:
409,
)
def remove_assets(self, asset_ids):
with self.db.transaction():
for asset_id in asset_ids:
row = self.db.one("SELECT kind FROM assets WHERE id=?", (asset_id,))
if not row:
continue
self.db.execute(
"INSERT OR REPLACE INTO asset_file_journal VALUES(?,?)",
(asset_id, row["kind"]),
)
for table, key in [
("library_versions", "asset_id"),
("terminal_deliveries", "asset_id"),
("source_images", "id"),
("asset_sources", "asset_id"),
("asset_metadata", "asset_id"),
("assets", "id"),
]:
self.db.execute(f"DELETE FROM {table} WHERE {key}=?", (asset_id,))
self.db.execute(
"DELETE FROM photo_preferences WHERE source_id='@assets' AND item_id=?",
(asset_id,),
)
self.service.assets.recover_files()
def remove_asset(self, asset_id):
self.service.assets.path(asset_id).unlink(missing_ok=True)
self.db.execute("DELETE FROM library_versions WHERE asset_id=?", (asset_id,))
self.db.execute("DELETE FROM terminal_deliveries WHERE asset_id=?", (asset_id,))
self.db.execute("DELETE FROM source_images WHERE id=?", (asset_id,))
for table, key in [
("asset_sources", "asset_id"),
("asset_metadata", "asset_id"),
("assets", "id"),
]:
self.db.execute(f"DELETE FROM {table} WHERE {key}=?", (asset_id,))
self.db.execute(
"DELETE FROM photo_preferences WHERE source_id='@assets' AND item_id=?",
(asset_id,),
)
self.remove_assets([asset_id])
def delete_picture(self, picture_id):
with self.service.lock:
@@ -670,8 +691,7 @@ class Libraries:
] + [picture_id]
for asset_id in ids:
self.check_asset_references(asset_id)
for asset_id in ids:
self.remove_asset(asset_id)
self.remove_assets(ids)
def check_source_delete(self, source_id):
if (
+27 -27
View File
@@ -327,19 +327,19 @@ class DisplayService:
if isinstance(content, TemplateContent):
values = self.renderer.resolve(content.template, content.data)
data = self.renderer.render(content.template, values, content.profile)
asset = self.assets.put(
with self.assets.creating(
data, content.template + ".jpg", "cache" if staging else "generated"
)
source = {
"type": "template",
"template": content.template,
"data": self.settings.sanitize(values),
"profile": content.profile,
}
self.db.execute(
"INSERT OR REPLACE INTO asset_sources VALUES(?,?)",
(asset["asset_id"], encode(source)),
)
) as asset:
source = {
"type": "template",
"template": content.template,
"data": self.settings.sanitize(values),
"profile": content.profile,
}
self.db.execute(
"INSERT OR REPLACE INTO asset_sources VALUES(?,?)",
(asset["asset_id"], encode(source)),
)
return asset
if isinstance(content, PhotoSourceContent):
source = self.photo_sources.get(content.source_id)
@@ -478,23 +478,23 @@ class DisplayService:
"UPDATE operations SET result=? WHERE id=?",
(encode({"photo_attempts": attempts}), operation_id),
)
asset = self.assets.put(
with self.assets.creating(
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"),
}
) as asset:
self.db.execute(
"INSERT OR REPLACE INTO asset_sources VALUES(?,?)",
(
asset["asset_id"],
encode(
{
"type": "photo_source",
"source_id": content.source_id,
"item_id": item["id"],
"source_path": item.get("source_path"),
}
),
),
),
)
)
return self.assets.info(asset["asset_id"])
raise FlowError(
E.INVALID_REQUEST, "No decodable photo found within 5 candidates"
+3 -1
View File
@@ -155,7 +155,9 @@ def install_ai_images(app, service):
image_data = base64.b64decode(result[0]["b64_json"], validate=True)
else:
image_data = service.assets.fetch_url(result[0]["url"])
with Image.open(BytesIO(image_data)) as image:
with Image.open(
BytesIO(image_data), formats=("JPEG", "PNG", "WEBP")
) as image:
if image.width * image.height > MAX_IMAGE_PIXELS:
raise ValueError()
rgba = ImageOps.exif_transpose(image).convert("RGBA")
+12 -6
View File
@@ -76,7 +76,9 @@ def install_extensions(app, service):
if len(data) > settings.max_bytes:
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
try:
with Image.open(BytesIO(data)) as image:
with Image.open(
BytesIO(data), formats=("JPEG", "PNG", "WEBP")
) as image:
if (
image.format not in {"JPEG", "PNG", "WEBP"}
or image.width * image.height > MAX_IMAGE_PIXELS
@@ -92,11 +94,11 @@ def install_extensions(app, service):
raise FlowError(E.INVALID_REQUEST, "Invalid image") from None
result = []
for data, name in prepared:
asset = service.assets.put(data, name)
db.execute(
"INSERT INTO asset_metadata(asset_id,origin) VALUES(?,'manual')",
(asset["asset_id"],),
)
with service.assets.creating(data, name) as asset:
db.execute(
"INSERT INTO asset_metadata(asset_id,origin) VALUES(?,'manual')",
(asset["asset_id"],),
)
result.append(asset)
return result
@@ -220,6 +222,10 @@ def install_extensions(app, service):
for row in db.all("SELECT * FROM clients")
]
@app.get("/api/v1/client-scopes", dependencies=[Depends(require("admin"))])
def client_scopes():
return ALL_SCOPES
@app.post(
"/api/v1/clients",
status_code=201,
+3 -1
View File
@@ -37,7 +37,9 @@ def prepare(service, content, device_id=None):
if source.avoid_duplicates and quality["fingerprint"] in hashes:
continue
hashes.update([quality["sha256"], quality["fingerprint"]])
with Image.open(BytesIO(data)) as image:
with Image.open(
BytesIO(data), formats=("JPEG", "PNG", "WEBP")
) as image:
photo = ImageOps.exif_transpose(image).convert("RGB")
photo.thumbnail((2400, 2400))
output = BytesIO()
+7 -2
View File
@@ -126,7 +126,9 @@ class LayoutStudio:
plan = local_plan(request)
metadata = []
for asset_id in request.asset_ids:
with Image.open(self.renderer.assets.path(asset_id)) as image:
with Image.open(
self.renderer.assets.path(asset_id), formats=("JPEG", "PNG", "WEBP")
) as image:
metadata.append(
{"asset_id": asset_id, "width": image.width, "height": image.height}
)
@@ -203,7 +205,10 @@ class LayoutStudio:
plan = LayoutPlan.model_validate(data["layout"])
canvas = Image.new("RGB", (plan.width, plan.height), "white")
for region in plan.regions:
with Image.open(self.renderer.assets.path(region.asset_id)) as source:
with Image.open(
self.renderer.assets.path(region.asset_id),
formats=("JPEG", "PNG", "WEBP"),
) as source:
image = ImageOps.exif_transpose(source).convert("RGB")
image = ImageOps.fit(
image,
+1 -1
View File
@@ -7,7 +7,7 @@ from PIL import Image, ImageFilter, ImageOps, ImageStat
def photo_quality(data):
with Image.open(BytesIO(data)) as source:
with Image.open(BytesIO(data), formats=("JPEG", "PNG", "WEBP")) as source:
image = ImageOps.exif_transpose(source).convert("L")
image.thumbnail((256, 256))
brightness = ImageStat.Stat(image).mean[0]
+16 -10
View File
@@ -1,6 +1,7 @@
"""Opt-in local retention, protecting stars, current content and durable references."""
import re
import shutil
import time
from datetime import UTC, datetime, timedelta
@@ -80,7 +81,19 @@ class LocalRetention:
def stats(self):
rows = self.inspect()
protected = self.protected()
disk = shutil.disk_usage(self.service.settings.data_dir)
low_space = disk.free < max(512 * 1024 * 1024, int(disk.total * 0.05))
return {
"disk": {
"total_bytes": disk.total,
"used_bytes": disk.used,
"free_bytes": disk.free,
"low_space": low_space,
},
"pending_file_cleanup": len(
self.service.db.all("SELECT id FROM asset_file_journal")
)
+ len(self.service.db.all("SELECT id FROM asset_file_moves")),
"policy": self.config().model_dump(),
"total_bytes": sum(x[2] for x in rows),
"asset_count": len(rows),
@@ -92,6 +105,7 @@ class LocalRetention:
return {}
if not dry_run:
self.last = time.monotonic()
self.service.assets.recover_files()
config = self.config()
if not config.enabled:
return {"deleted": 0, "bytes_freed": 0, "disabled": True}
@@ -101,7 +115,7 @@ class LocalRetention:
total = sum(x[2] for x in rows)
removed = []
freed = 0
for row, path, size in rows:
for row, _path, size in rows:
# Manual uploaded photos are never removed by automatic retention.
if row["id"] in protected or row["kind"] not in ("generated", "cache"):
continue
@@ -119,15 +133,7 @@ class LocalRetention:
total -= size
freed += size
if not dry_run:
path.unlink(missing_ok=True)
for table, key in [
("asset_metadata", "asset_id"),
("asset_sources", "asset_id"),
("assets", "id"),
]:
self.service.db.execute(
f"DELETE FROM {table} WHERE {key}=?", (row["id"],)
)
self.service.libraries.remove_asset(row["id"])
result = {"deleted": len(removed), "bytes_freed": freed, "dry_run": dry_run}
if not dry_run:
self.service.db.execute(
@@ -23,7 +23,7 @@ def dimensions(root: Path, path: Path):
with os.fdopen(fd, "rb") as stream:
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
return None
with Image.open(stream) as image:
with Image.open(stream, formats=("JPEG", "PNG", "WEBP")) as image:
width, height = image.size
if image.getexif().get(274, 1) in (5, 6, 7, 8):
width, height = height, width
+40 -2
View File
@@ -30,7 +30,13 @@ from pigway_frame_flow.storage.database import encode
class PhotoSourceInput(StrictModel):
name: str = Field(min_length=1, max_length=120)
kind: Literal[
"local", "synology-photos", "webdav", "google-drive", "alipan", "baidu"
"local",
"synology-photos",
"webdav",
"google-drive",
"alipan",
"baidu",
"uploads",
]
location: str = Field(min_length=1, max_length=2048)
nas_root_path: str | None = Field(default=None, max_length=2048, pattern=r"^/")
@@ -132,6 +138,12 @@ class PhotoSources:
def save(self, source_id, source):
self.require_api_source(source)
if source.kind == "uploads" and (
source_id != "manual-uploads" or source.location != "."
):
raise FlowError(E.INVALID_REQUEST, "Invalid manual upload source")
if source_id == "manual-uploads" and source.kind != "uploads":
raise FlowError(E.CONFLICT, "Reserved manual upload source")
if source.minimum_brightness > source.maximum_brightness:
raise FlowError(E.INVALID_REQUEST, "Brightness range is reversed")
if source.kind not in ("local", "synology-photos") and (
@@ -310,6 +322,20 @@ class PhotoSources:
self.require_api_source(source)
if not source.enabled:
raise FlowError(E.CONFLICT, "Photo source is disabled", 409)
if source.kind == "uploads":
return [
{
"id": row["item_id"],
"name": row["name"],
"caption": json.loads(row["caption"]),
"starred": bool(row["starred"]),
"excluded": bool(row["excluded"]),
}
for row in self.db.all(
"SELECT s.item_id,s.caption,a.name,COALESCE(p.starred,0) AS starred,COALESCE(p.excluded,0) AS excluded FROM source_images s JOIN assets a ON a.id=s.id LEFT JOIN photo_preferences p ON p.source_id=s.source_id AND p.item_id=s.item_id WHERE s.source_id=? ORDER BY s.created_at DESC,s.id DESC",
(source_id,),
)
]
session_identity = (
self.synology.sid() if source.kind == "synology-photos" else None
)
@@ -610,6 +636,18 @@ class PhotoSources:
)
if item is None:
raise FlowError(E.NOT_FOUND, "Photo is not in this source", 404)
if source.kind == "uploads":
row = self.db.one(
"SELECT id,caption FROM source_images WHERE source_id=? AND item_id=?",
(source_id, item["id"]),
)
if not row:
raise FlowError(E.NOT_FOUND, "Uploaded source picture not found", 404)
return (
self.assets.path(row["id"]).read_bytes(),
item["name"],
json.loads(row["caption"]),
)
if source.kind == "local":
root = self.settings.photo_root.resolve()
path = root / item["id"]
@@ -688,7 +726,7 @@ class PhotoSources:
from PIL import Image
with Image.open(BytesIO(data)) as image:
with Image.open(BytesIO(data), formats=("JPEG", "PNG", "WEBP")) as image:
width, height = image.size
if image.getexif().get(274, 1) in (5, 6, 7, 8):
width, height = height, width
+2 -2
View File
@@ -61,7 +61,7 @@ class PhotosCache:
os.fsync(handle.fileno())
if not 0 < part.stat().st_size <= self.max_bytes:
raise ValueError()
with Image.open(part) as image:
with Image.open(part, formats=("JPEG", "PNG", "WEBP")) as image:
if image.format not in ("JPEG", "PNG", "WEBP"):
raise ValueError()
if image.width * image.height > MAX_IMAGE_PIXELS:
@@ -69,7 +69,7 @@ class PhotosCache:
image.verify()
# Decode before committing; truncated/compressed-bomb payloads
# must never become a reusable cache hit.
with Image.open(part) as image:
with Image.open(part, formats=("JPEG", "PNG", "WEBP")) as image:
image.load()
part.replace(path)
self.downloads += 1
+123 -21
View File
@@ -1,7 +1,11 @@
import base64
import ipaddress
import json
import logging
import os
import socket
import threading
from contextlib import contextmanager
from io import BytesIO
from urllib.parse import urlsplit
from uuid import uuid4
@@ -27,7 +31,7 @@ def convert(
quality=93,
):
try:
with Image.open(BytesIO(data)) as source:
with Image.open(BytesIO(data), formats=("JPEG", "PNG", "WEBP")) as source:
if source.format not in {"JPEG", "PNG", "WEBP"}:
raise ValueError()
if source.width * source.height > MAX_IMAGE_PIXELS:
@@ -82,17 +86,99 @@ def convert(
class Assets:
def __init__(self, db, settings):
self.db, self.settings = db, settings
self.file_lock = threading.RLock()
db.execute(
"CREATE TABLE IF NOT EXISTS asset_file_journal(id TEXT PRIMARY KEY,kind TEXT NOT NULL)"
)
db.execute(
"CREATE TABLE IF NOT EXISTS asset_file_moves(id TEXT PRIMARY KEY,source_kind TEXT NOT NULL,target_kind TEXT NOT NULL)"
)
self.recover_files()
def put(self, data, name="image.jpg", kind="images"):
@staticmethod
def sync_directory(directory):
fd = os.open(directory, os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
def recover_files(self):
with self.file_lock:
self._recover_files()
def _recover_files(self):
for row in self.db.all("SELECT * FROM asset_file_journal"):
path = self.settings.data_dir / row["kind"] / (row["id"] + ".jpg")
try:
if not self.db.one("SELECT id FROM assets WHERE id=?", (row["id"],)):
path.unlink(missing_ok=True)
path.with_suffix(".part").unlink(missing_ok=True)
if path.parent.exists():
self.sync_directory(path.parent)
self.db.execute(
"DELETE FROM asset_file_journal WHERE id=?", (row["id"],)
)
except OSError:
logging.getLogger(__name__).warning("Asset file cleanup pending")
for row in self.db.all("SELECT * FROM asset_file_moves"):
asset = self.db.one("SELECT kind FROM assets WHERE id=?", (row["id"],))
obsolete = (
row["source_kind"]
if asset and asset["kind"] == row["target_kind"]
else row["target_kind"]
)
path = self.settings.data_dir / obsolete / (row["id"] + ".jpg")
try:
path.unlink(missing_ok=True)
self.sync_directory(path.parent)
self.db.execute("DELETE FROM asset_file_moves WHERE id=?", (row["id"],))
except OSError:
logging.getLogger(__name__).warning("Asset promotion cleanup pending")
@contextmanager
def creating(self, data, name="image.jpg", kind="images"):
with self.file_lock:
with self._creating(data, name, kind) as asset:
yield asset
@contextmanager
def _creating(self, data, name="image.jpg", kind="images"):
from pathlib import Path
if kind not in {"images", "generated", "cache", "source-images", "renditions"}:
raise ValueError("Invalid asset kind")
name = Path(name).stem[:110] + ".jpg"
asset_id = uuid4().hex
(self.settings.data_dir / kind / (asset_id + ".jpg")).write_bytes(data)
self.db.execute(
"INSERT INTO assets VALUES(?,?,?,?)", (asset_id, name, kind, now_iso())
)
return self.info(asset_id)
path = self.settings.data_dir / kind / (asset_id + ".jpg")
path.parent.mkdir(exist_ok=True)
self.db.execute("INSERT INTO asset_file_journal VALUES(?,?)", (asset_id, kind))
temporary = path.with_suffix(".part")
try:
with temporary.open("xb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
temporary.replace(path)
self.sync_directory(path.parent)
with self.db.transaction():
self.db.execute(
"INSERT INTO assets VALUES(?,?,?,?)",
(asset_id, name, kind, now_iso()),
)
yield self.info(asset_id)
self.db.execute(
"DELETE FROM asset_file_journal WHERE id=?", (asset_id,)
)
except BaseException:
# The journal remains durable if filesystem cleanup also fails.
self.recover_files()
raise
def put(self, data, name="image.jpg", kind="images"):
with self.creating(data, name, kind) as asset:
return asset
def record_origin(self, asset_id, operation):
job = self.db.one(
@@ -123,20 +209,36 @@ class Assets:
)
def promote(self, asset_id):
row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
if row and row["kind"] == "cache":
source = self.db.one(
"SELECT payload FROM asset_sources WHERE asset_id=?", (asset_id,)
)
kind = (
"generated"
if source and json.loads(source["payload"]).get("type") == "template"
else "images"
)
target = self.settings.data_dir / kind / (asset_id + ".jpg")
self.path(asset_id).replace(target)
self.db.execute("UPDATE assets SET kind=? WHERE id=?", (kind, asset_id))
return self.info(asset_id)
with self.file_lock:
row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
if row and row["kind"] == "cache":
source = self.db.one(
"SELECT payload FROM asset_sources WHERE asset_id=?", (asset_id,)
)
kind = (
"generated"
if source
and json.loads(source["payload"]).get("type") == "template"
else "images"
)
target = self.settings.data_dir / kind / (asset_id + ".jpg")
self.recover_files()
self.db.execute(
"INSERT INTO asset_file_moves VALUES(?,?,?)",
(asset_id, "cache", kind),
)
try:
# Keep the source valid until the database points to the durable target.
os.link(self.path(asset_id), target)
self.sync_directory(target.parent)
self.db.execute(
"UPDATE assets SET kind=? WHERE id=?", (kind, asset_id)
)
except BaseException:
self.recover_files()
raise
self.recover_files()
return self.info(asset_id)
def info(self, asset_id):
row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
+1 -1
View File
@@ -41,7 +41,7 @@ def render_captioned_photo(
quality=quality,
)
canvas = Image.new("RGB", size, "white")
with Image.open(BytesIO(photo_data)) as photo:
with Image.open(BytesIO(photo_data), formats=("JPEG", "PNG", "WEBP")) as photo:
canvas.paste(photo, (0, 0))
draw = ImageDraw.Draw(canvas)
values = [
+3 -1
View File
@@ -177,7 +177,9 @@ class TemplateRenderer:
elif block["kind"] == "photo":
asset_id = values.get("photo_id")
if asset_id:
with Image.open(self.assets.path(asset_id)) as photo:
with Image.open(
self.assets.path(asset_id), formats=("JPEG", "PNG", "WEBP")
) as photo:
img.paste(ImageOps.fit(photo.convert("RGB"), (w, h)), (x, y))
else:
draw.rectangle((x, y, x + w, y + h), outline="black", width=5)
@@ -64,14 +64,7 @@ class UploadRetries:
)
# Only disposable generated staging files are owned by this recovery.
if row and row["kind"] == "cache" and not (meta and meta["starred"]):
self.service.assets.path(asset_id).unlink(missing_ok=True)
with self.db.transaction() as tx:
for table, field in (
("assets", "id"),
("asset_sources", "asset_id"),
("asset_metadata", "asset_id"),
):
tx.execute(f"DELETE FROM {table} WHERE {field}=?", (asset_id,))
self.service.libraries.remove_asset(asset_id)
def tick(self, current):
for job in self.db.all("SELECT * FROM jobs WHERE enabled=1"):
+22 -6
View File
@@ -17,6 +17,7 @@ def encode(value):
class Database:
def __init__(self, path):
self.lock = threading.RLock()
self._transaction_depth = 0
self.conn = sqlite3.connect(path, check_same_thread=False)
self.conn.row_factory = sqlite3.Row
self.conn.executescript("""
@@ -68,17 +69,32 @@ class Database:
@contextmanager
def transaction(self):
with self.lock:
self.conn.execute("BEGIN IMMEDIATE")
nested = self._transaction_depth > 0
savepoint = "tx_" + str(self._transaction_depth)
self.conn.execute("SAVEPOINT " + savepoint if nested else "BEGIN IMMEDIATE")
self._transaction_depth += 1
try:
yield self.conn
self.conn.commit()
except Exception:
self.conn.rollback()
if nested:
self.conn.execute("RELEASE SAVEPOINT " + savepoint)
else:
self.conn.commit()
except BaseException:
if nested:
self.conn.execute("ROLLBACK TO SAVEPOINT " + savepoint)
self.conn.execute("RELEASE SAVEPOINT " + savepoint)
else:
self.conn.rollback()
raise
finally:
self._transaction_depth -= 1
def execute(self, query, args=()):
with self.lock, self.conn:
return self.conn.execute(query, args)
with self.lock:
if self._transaction_depth:
return self.conn.execute(query, args)
with self.conn:
return self.conn.execute(query, args)
def one(self, query, args=()):
with self.lock:
+15 -4
View File
@@ -36,7 +36,7 @@ function applyTheme(){const dark=state.theme==='dark'||(state.theme==='system'&&
function revealCurrentNavigation(){const active=$('#nav .active'),sidebar=$('.sidebar');if(active&&matchMedia('(max-width:720px)').matches)sidebar.scrollLeft=active.offsetLeft-(sidebar.clientWidth-active.offsetWidth)/2;}
window.addEventListener('resize',revealCurrentNavigation);
function deviceNameForId(id){return deviceDisplayName(state.devices.find(d=>d.id===id)||{id,name:id});}
function operationActionLabel(action){const labels={'render':['生成预览','Generate preview'],'render-ai':['AI 生成图片','Generate AI image'],'official-widget':['更新官方订阅','Update official widget'],'delete':['删除图片','Delete image'],'display':['显示图片','Display image'],'restore':['恢复图片','Restore image'],'library-generate':['来源图库统一输出','Source-library outputs'],'local-upload':['保存到图库','Save to gallery'],'reconcile':['核对状态','Reconcile']};return labels[action]?.[state.lang==='zh'?0:1]||t('operation');}
function operationActionLabel(action){const labels={'render':['生成预览','Generate preview'],'render-ai':['AI 生成图片','Generate AI image'],'official-widget':['更新官方订阅','Update official widget'],'delete':['删除图片','Delete image'],'display':['显示图片','Display image'],'restore':['恢复图片','Restore image'],'library-generate':['来源图库统一输出','Source-library outputs'],'local-upload':['准备本地成品','Prepare local output'],'reconcile':['核对状态','Reconcile']};return labels[action]?.[state.lang==='zh'?0:1]||t('operation');}
function operationClientLabel(client){return client==='admin'?tr('管理员','Administrator'):client==='scheduler'?tr('自动调度','Scheduler'):client==='library-terminal'?tr('来源图库输出','Source-library output'):client;}
function operationResultHTML(o){
const r=o.result||{},parts=[];
@@ -48,7 +48,8 @@ function operationResultHTML(o){
return parts.join('')||'—';
}
function deviceDisplayName(device){const name=device.name||device.id;return device.id==='living-room'&&['living-room','Living room'].includes(name)?t('livingRoom'):name;}
function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).filter(([id])=>id!=='source-browser').map(([id,icon])=>`<button data-page="${id}" class="${(id===state.page||id==='gallery'&&state.page==='source-browser')?'active':''}" ${(id===state.page||id==='gallery'&&state.page==='source-browser')?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);const watchSelected=state.page==='terminal-gallery'&&state.tab==='watch';$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${!watchSelected&&d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('')+`<option value="@watch" ${watchSelected?'selected':''}>${tr('手表 · 410×502','Watch · 410×502')}</option>`;revealCurrentNavigation();}
function renderFooter(){const v=state.versionInfo;if(v)$('#footer').innerHTML=`<span>PIGWay Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`;}
function shell(){renderFooter();document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).filter(([id])=>id!=='source-browser').map(([id,icon])=>`<button data-page="${id}" class="${(id===state.page||id==='gallery'&&state.page==='source-browser')?'active':''}" ${(id===state.page||id==='gallery'&&state.page==='source-browser')?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#device').hidden=['gallery','terminal-gallery','source-browser','photo-sources','settings','connections','history'].includes(state.page);const watchSelected=state.page==='terminal-gallery'&&state.tab==='watch';$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${!watchSelected&&d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('')+`<option value="@watch" ${watchSelected?'selected':''}>${tr('手表 · 410×502','Watch · 410×502')}</option>`;revealCurrentNavigation();}
function imageLoading(img){
if(img.parentElement?.classList.contains('image-loading'))return img.parentElement;
const box=document.createElement('div');box.className='image-loading';
@@ -123,7 +124,17 @@ const views={
connections:()=>connectionsPage(),
'photo-sources':async()=>{ $('#main').innerHTML='';await photoSourcesPanel(); },
async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;state.devices=state.devices.map(x=>x.id===d.id?{...x,name:d.name}:x);shell();const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`<div class="notice warning">${t('lowBattery')}</div>`:''}<section class="card hero"><div class="hero-info"><span class="badge ${d.status==='offline'?'bad':''}">● ${t(d.status).toUpperCase()}</span><h2>${esc(d.name)}</h2><p>${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}</p><div class="actions">${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}</div><p class="hint" style="margin-top:28px">${t('refreshInterval')}</p></div><div class="frame-demo"></div></section><div class="stats">${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['currentImageId',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`<div class="card stat"><div class="label">${t(l)}</div><div class="value">${esc(v)}</div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('device')}</h3><div class="detail-grid">${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('modelSource',t(d.model_verified?'modelVerified':'modelConfigured'))}</div></section><section class="card"><h3>${t('cloudRotation')}</h3><div class="detail-grid">${detail('playbackMode',s.mode?t(s.mode):'—')}${detail('switchInterval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}</div><p class="hint">${t('rotationNotice')}</p><a href="#playlists" id="go-playlists">${t('playlists')} →</a></section></div>`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};},
async upload(){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);toast(tr('已保存到服务器,可用于模板与任务','Saved on server for templates and tasks'));});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
async upload(epoch){
state.preview=null;
const sources=await api('/api/v1/source-libraries');if(epoch!==state.epoch)return;
const options=[...sources];if(!options.some(x=>x.id==='manual-uploads'))options.push({id:'manual-uploads',name:tr('手动上传','Manual uploads')});
const selected=options.some(x=>x.id===libraryUI.source)?libraryUI.source:'manual-uploads';
$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="field"><label for="upload-source">${tr('保存到来源图库','Save in source library')}</label><select id="upload-source">${librarySelectOptions(options,selected,'')}</select><p class="hint">${tr('保存无叠加的源画面,不写回 NAS 或网盘。生成尺寸、裁切与文字由图库中的生成方案决定。','Save source pixels without overlays, without writing back to NAS or cloud drives. Library generation plans control output size, crop and captions.')}</p></div><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['cover','contain','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button><button id="view-upload-library" hidden>${tr('查看来源图库','View source library')}</button></div><p class="hint">${tr('预览仅用于直接显示到相框;保存到图库会使用源画面。','Preview settings apply to direct frame display; saving in the library uses source pixels.')}</p></section></div>`;
bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);const preview=await api('/api/v1/preview',{method:'POST',body:form});if(!$('#file')||$('#file').files[0]!==file)return;state.preview=preview;state.preview.uploadFile=file;$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});
for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=!$('#file').files[0];$('#display-upload').disabled=true;$('#view-upload-library').hidden=true;};
bind('save-local',async()=>{const sourceId=$('#upload-source').value,file=state.preview?.uploadFile||$('#file').files[0];let picture;if(state.preview?.sourceUrl){picture=await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/upload-url',{method:'POST',body:{url:state.preview.sourceUrl}});}else{if(!file)throw new Error(t('selectFile'));const body=new FormData();body.append('file',file);picture=await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/upload',{method:'POST',body});}libraryUI.source=picture.source_id;libraryUI.plan=null;libraryUI.offset=0;$('#view-upload-library').hidden=false;toast(tr('已保存源画面,可在来源图库按方案生成','Source pixels saved. Generate plan versions in the source library.'));});
bind('view-upload-library',()=>navigate('gallery'));bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));
},
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
async 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 data=await api('/api/v1/operation-history?'+historyQuery());if(epoch!==state.epoch)return;state.historyPageData=data;state.historyPage=data.page;const ops=data.items;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr data-operation-id="${esc(o.operation_id)}"><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(o.action==='library-generate'?tr('按来源绑定终端','Source-bound terminals'):deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${operationResultHTML(o)}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
@@ -132,7 +143,7 @@ const views={
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,library_pipeline:j.library_pipeline||false,cleanup_unstarred:j.cleanup_unstarred||false,generate_thumbnail:j.generate_thumbnail||false,thumbnail_plan_id:j.thumbnail_plan_id||null,...changes};}
async function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`<h2>${t('playlists')}</h2><form id="playlist-form"><div class="field"><label for="playlist-name">${t('name')}</label><input id="playlist-name" required maxlength="120" value="${esc(p.name)}"></div><div class="field"><label for="playlist-images">${t('imageIds')}</label><input id="playlist-images" required value="${esc(p.image_ids.join(','))}" placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval">${t('interval')}</label><input id="playlist-interval" type="number" min="5" max="10080" value="${p.interval_minutes}"></div><div class="field"><label for="playlist-mode">${t('playbackMode')}</label><select id="playlist-mode"><option value="queue" ${p.mode==='queue'?'selected':''}>${t('queue')}</option><option value="random" ${p.mode==='random'?'selected':''}>${t('random')}</option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});await playlistImagePicker(p);}
$('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme);
applyTheme();shell();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>$('#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=>{state.versionInfo=v;renderFooter();}).catch(()=>{});
setInterval(()=>{if(state.token&&state.page==='dashboard'&&!$('#modal').open)navigate();},60000);
// Keep Cron available for exceptional schedules, but make ordinary schedules form-based.
+3 -3
View File
@@ -26,16 +26,16 @@ async function layoutStudioPage(){
}
async function connectionsPage(){
$('#main').innerHTML=`<div class="notice">${tr('可选连接不会影响 NAS 照片和现有设备。账号密钥保存在受限配置文件中,外部客户端密钥仅显示一次。','Optional connections do not affect NAS photos or existing devices. Provider keys use restricted config files; client tokens are shown once.')}</div>`;
await aiSettingsPanel();await aiImagePanel();await storageSettingsPanel();await clientsPanel();await webhooksPanel();await externalProvidersPanel();await mqttPanel();
await aiSettingsPanel();await aiImagePanel();await clientsPanel();await webhooksPanel();await externalProvidersPanel();await mqttPanel();
}
async function aiSettingsPanel(){
const config=await api('/api/v1/settings/ai-layout');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('AI 排版 · OpenAI 兼容','AI layout · OpenAI compatible')}</h3><p class="hint">${tr('只发送文字、尺寸和排版要求,不发送照片。填写完整 chat/completions 地址;使用服务商的模型名称。','Only text, dimensions and instructions are sent; never photos. Enter the full chat/completions URL and provider model name.')}</p><form id="ai-settings-form"><div class="field"><label for="ai-endpoint">Endpoint</label><input id="ai-endpoint" type="url" value="${esc(config.endpoint)}" placeholder="https://…/v1/chat/completions"></div><div class="field"><label for="ai-model">Model</label><input id="ai-model" value="${esc(config.model)}"></div><div class="field"><label for="ai-key">${tr('API Key(留空保留已有密钥)','API key (blank keeps existing)')}</label><input id="ai-key" type="password" autocomplete="off"><p class="hint">${t('apiKeyConfigured')}: ${t(config.key_configured?'yes':'no')}</p></div><button type="submit">${t('save')}</button></form>`;$('#main').append(card);$('#ai-settings-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-layout',{method:'PUT',body:{endpoint:$('#ai-endpoint').value,model:$('#ai-model').value}});const key=$('#ai-key').value;$('#ai-key').value='';if(key)await api('/api/v1/settings/ai-layout/key',{method:'PUT',body:{key}});toast(t('done'));});
}
async function storageSettingsPanel(){
const data=await api('/api/v1/storage');const p=data.policy;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('本地图库保留策略','Local storage retention')}</h3><p>${data.asset_count} ${t('count')} · ${(data.total_bytes/1024/1024).toFixed(1)} MiB · ${tr('受保护','Protected')}: ${data.protected_count}</p><p class="hint">${tr('仅清理生成图和预览缓存;手动上传、星标、当前/恢复图片以及任务/模板引用会保留。不会删除 NAS 原照片或云端图片。先查看清理预估,再开启自动清理。','Only generated images and preview cache are cleaned. Manual uploads, stars, current/restore content and task/template references are retained. NAS originals and cloud images are untouched. Preview cleanup before enabling it.')}</p><form id="storage-policy"><label class="check-row"><input id="storage-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="form-grid"><div class="field"><label for="storage-days">${tr('生成图保留天数','Generated image retention days')}</label><input id="storage-days" type="number" min="1" value="${p.generated_days}"></div><div class="field"><label for="storage-cache-days">${tr('缓存保留天数','Cache retention days')}</label><input id="storage-cache-days" type="number" min="1" value="${p.cache_days}"></div><div class="field"><label for="storage-size">${tr('容量目标 MiB','Target size MiB')}</label><input id="storage-size" type="number" min="1" value="${p.max_bytes/1024/1024}"></div></div><div class="actions"><button type="submit">${t('save')}</button><button id="storage-preview" type="button">${tr('预估清理','Preview cleanup')}</button></div><p id="storage-estimate"></p></form>`;$('#main').append(card);$('#storage-policy').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/storage/policy',{method:'PUT',body:{enabled:$('#storage-enabled').checked,generated_days:Number($('#storage-days').value),cache_days:Number($('#storage-cache-days').value),max_bytes:Number($('#storage-size').value)*1024*1024}});toast(t('done'));});bind('storage-preview',async()=>{const result=await api('/api/v1/storage/cleanup?dry_run=true',{method:'POST'});$('#storage-estimate').textContent=result.disabled?tr('策略未启用;不会清理','Policy disabled; nothing is removed'):tr('可清理 ','Eligible: ')+result.deleted+' · '+(result.bytes_freed/1024/1024).toFixed(1)+' MiB';});
const data=await api('/api/v1/storage');const p=data.policy;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('本地存储与清理','Local storage and cleanup')}</h3>${data.disk?`<p class="${data.disk.low_space?'notice warning':'hint'}">${tr('磁盘可用','Disk available')}: ${(data.disk.free_bytes/1024**3).toFixed(2)} GiB / ${(data.disk.total_bytes/1024**3).toFixed(2)} GiB${data.disk.low_space?tr(';空间不足,请清理无用文件或扩容,来源图片和成品不会自动删除。','; low space. Remove unused files or expand storage. Source pictures and versions are not automatically deleted.'):''}</p>`:''}${data.pending_file_cleanup?`<p class="notice warning">${tr('待重试文件清理','Pending file cleanup')}: ${data.pending_file_cleanup}</p>`:''}<p>${data.asset_count} ${t('count')} · ${(data.total_bytes/1024/1024).toFixed(1)} MiB · ${tr('受保护','Protected')}: ${data.protected_count}</p><p class="hint">${tr('仅清理生成图和预览缓存;手动上传、星标、当前/恢复图片以及任务/模板引用会保留。不会删除 NAS 原照片或云端图片。先查看清理预估,再开启自动清理。','Only generated images and preview cache are cleaned. Manual uploads, stars, current/restore content and task/template references are retained. NAS originals and cloud images are untouched. Preview cleanup before enabling it.')}</p><form id="storage-policy"><label class="check-row"><input id="storage-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="form-grid"><div class="field"><label for="storage-days">${tr('生成图保留天数','Generated image retention days')}</label><input id="storage-days" type="number" min="1" value="${p.generated_days}"></div><div class="field"><label for="storage-cache-days">${tr('缓存保留天数','Cache retention days')}</label><input id="storage-cache-days" type="number" min="1" value="${p.cache_days}"></div><div class="field"><label for="storage-size">${tr('容量目标 MiB','Target size MiB')}</label><input id="storage-size" type="number" min="1" value="${p.max_bytes/1024/1024}"></div></div><div class="actions"><button type="submit">${t('save')}</button><button id="storage-preview" type="button">${tr('预估清理','Preview cleanup')}</button></div><p id="storage-estimate"></p></form>`;$('#main').append(card);$('#storage-policy').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/storage/policy',{method:'PUT',body:{enabled:$('#storage-enabled').checked,generated_days:Number($('#storage-days').value),cache_days:Number($('#storage-cache-days').value),max_bytes:Number($('#storage-size').value)*1024*1024}});toast(t('done'));});bind('storage-preview',async()=>{const result=await api('/api/v1/storage/cleanup?dry_run=true',{method:'POST'});$('#storage-estimate').textContent=result.disabled?tr('策略未启用;不会清理','Policy disabled; nothing is removed'):tr('可清理 ','Eligible: ')+result.deleted+' · '+(result.bytes_freed/1024/1024).toFixed(1)+' MiB';});
}
async function clientsPanel(){
const clients=await api('/api/v1/clients');const scopes=['devices:read','images:read','display:write','templates:read','render:write','jobs:read','jobs:write','history:read'];const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('外部客户端权限','External client permissions')}</h3><p class="hint">${tr('网页继续使用账号登录。这里只为其他程序创建独立凭据,可随时撤销。','The web UI still uses account login. These credentials are for other programs and can be revoked.')}</p>${clients.map(c=>`<p>${esc(c.name)} · ${esc(c.client_id)} · ${t(c.enabled?'enabled':'disabled')} ${c.client_id!=='admin'&&c.enabled?`<button data-revoke="${esc(c.client_id)}">${tr('撤销','Revoke')}</button>`:''}</p>`).join('')}<form id="client-form"><div class="field"><label for="client-id">Client ID</label><input id="client-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label for="client-name">${t('name')}</label><input id="client-name" required></div>${scopes.map(s=>`<label class="check-row"><input type="checkbox" name="client-scope" value="${s}" ${['devices:read','display:write','templates:read','render:write'].includes(s)?'checked':''}>${s}</label>`).join('')}<button type="submit">${tr('创建客户端','Create client')}</button><div id="client-secret" class="notice" hidden></div></form>`;$('#main').append(card);document.querySelectorAll('[data-revoke]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/clients/'+b.dataset.revoke,{method:'DELETE'});await navigate();}));$('#client-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/clients',{method:'POST',body:{client_id:$('#client-id').value,name:$('#client-name').value,permissions:[...document.querySelectorAll('[name="client-scope"]:checked')].map(x=>x.value)}});$('#client-secret').hidden=false;$('#client-secret').textContent=tr('仅显示一次,请保存:','Shown once; save it: ')+r.token;});
const [clients,scopes]=await Promise.all([api('/api/v1/clients'),api('/api/v1/client-scopes')]);const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('外部客户端权限','External client permissions')}</h3><p class="hint">${tr('网页继续使用账号登录。这里只为其他程序创建独立凭据,可随时撤销。','The web UI still uses account login. These credentials are for other programs and can be revoked.')}</p>${clients.map(c=>`<p>${esc(c.name)} · ${esc(c.client_id)} · ${t(c.enabled?'enabled':'disabled')} ${c.client_id!=='admin'&&c.enabled?`<button data-revoke="${esc(c.client_id)}">${tr('撤销','Revoke')}</button>`:''}</p>`).join('')}<form id="client-form"><div class="field"><label for="client-id">Client ID</label><input id="client-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label for="client-name">${t('name')}</label><input id="client-name" required></div>${scopes.map(s=>`<label class="check-row"><input type="checkbox" name="client-scope" value="${s}" ${['devices:read','display:write','templates:read','render:write'].includes(s)?'checked':''}>${s}</label>`).join('')}<button type="submit">${tr('创建客户端','Create client')}</button><div id="client-secret" class="notice" hidden></div></form>`;$('#main').append(card);document.querySelectorAll('[data-revoke]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/clients/'+b.dataset.revoke,{method:'DELETE'});await navigate();}));$('#client-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/clients',{method:'POST',body:{client_id:$('#client-id').value,name:$('#client-name').value,permissions:[...document.querySelectorAll('[name="client-scope"]:checked')].map(x=>x.value)}});$('#client-secret').hidden=false;$('#client-secret').textContent=tr('仅显示一次,请保存:','Shown once; save it: ')+r.token;});
}
async function webhooksPanel(){
const hooks=await api('/api/v1/webhooks');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>Webhook</h3><p class="hint">${tr('HTTPS 地址须先加入设置中的允许主机。事件带 HMAC 签名,最多重试三次;接收端应按事件 ID 去重。','Add the HTTPS host to the Settings allowlist first. Events are HMAC signed, with up to three attempts; deduplicate by event ID.')}</p>${hooks.map(h=>`<p>${esc(h.callback_url)} <button data-hook-delete="${h.id}">${t('delete')}</button></p>`).join('')}<form id="hook-form"><div class="field"><label for="hook-url">Callback URL</label><input id="hook-url" type="url" required></div><div class="field"><label for="hook-events">${tr('事件(逗号分隔)','Events (comma separated)')}</label><input id="hook-events" value="operation.completed,operation.failed"></div><button type="submit">${t('save')}</button><p id="hook-secret"></p></form>`;$('#main').append(card);document.querySelectorAll('[data-hook-delete]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/webhooks/'+b.dataset.hookDelete,{method:'DELETE'});await navigate();}));$('#hook-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/webhooks',{method:'POST',body:{callback_url:$('#hook-url').value,event_types:$('#hook-events').value.split(',').map(x=>x.trim()).filter(Boolean)}});$('#hook-secret').textContent=tr('签名密钥仅显示一次:','Signing key shown once: ')+r.signing_secret;});
+3 -3
View File
@@ -60,8 +60,8 @@ async function playlistImagePicker(playlist){
const raw=$('#playlist-images');const box=document.createElement('div');raw.parentElement.after(box);box.innerHTML=`<p>${t('imageChoice')}</p><div id="playlist-image-choices">${t('loading')}</div>`;const images=await api('/api/v1/devices/'+state.device+'/images');if(!box.isConnected)return;const chosen=new Set(playlist.image_ids);
$('#playlist-image-choices').innerHTML=images.map(image=>`<label class="check-row"><input type="checkbox" data-playlist-image="${image.image_id}" ${chosen.has(image.image_id)?'checked':''}>${esc(image.name)} · #${image.image_id}${image.current?' · '+t('current'):''}</label>`).join('');box.querySelectorAll('[data-playlist-image]').forEach(el=>el.onchange=()=>{const id=Number(el.dataset.playlistImage);if(el.checked)chosen.add(id);else chosen.delete(id);raw.value=[...chosen].join(',');});raw.oninput=()=>{chosen.clear();raw.value.split(',').map(Number).filter(x=>x>0).forEach(x=>chosen.add(x));box.querySelectorAll('[data-playlist-image]').forEach(el=>el.checked=chosen.has(Number(el.dataset.playlistImage)));};
}
async function enhanceSettings(){await weatherSettings();const config=await api('/api/v1/settings/image-url');const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('urlSettings')}</h3><form id="image-host-form"><div class="field"><label for="image-hosts">${t('allowedHosts')}</label><textarea id="image-hosts" rows="3">${esc(config.allowed_hosts.join('\n'))}</textarea><p class="hint">${t('urlHint')}</p></div><button type="submit">${t('save')}</button></form>`;$('#main').append(box);$('#image-host-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/image-url',{method:'PUT',body:{allowed_hosts:$('#image-hosts').value.split('\n').map(x=>x.trim()).filter(Boolean)}});toast(t('done'));});}
function enhanceUpload(){const box=document.createElement('div');box.innerHTML=`<h3>${t('uploadURL')}</h3><div class="field"><label for="upload-url">${t('imageURL')}</label><input id="upload-url" type="url" placeholder="https://…"><p class="hint">${t('urlHint')}</p></div>${buttons('preview-url','uploadURL')}`;$('#make-preview').parentElement.append(box);bind('preview-url',async()=>{if(!$('#upload-url').checkValidity())return;const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image_url',url:$('#upload-url').value,fit:$('#fit').value,profile:$('#profile').value});const op=await waitOperation(id);state.preview={asset_id:op.result.asset_id,url:op.result.url};$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});}
async function enhanceSettings(){await storageSettingsPanel();await weatherSettings();const config=await api('/api/v1/settings/image-url');const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('urlSettings')}</h3><form id="image-host-form"><div class="field"><label for="image-hosts">${t('allowedHosts')}</label><textarea id="image-hosts" rows="3">${esc(config.allowed_hosts.join('\n'))}</textarea><p class="hint">${t('urlHint')}</p></div><button type="submit">${t('save')}</button></form>`;$('#main').append(box);$('#image-host-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/image-url',{method:'PUT',body:{allowed_hosts:$('#image-hosts').value.split('\n').map(x=>x.trim()).filter(Boolean)}});toast(t('done'));});}
function enhanceUpload(){const box=document.createElement('div');box.innerHTML=`<h3>${t('uploadURL')}</h3><div class="field"><label for="upload-url">${t('imageURL')}</label><input id="upload-url" type="url" placeholder="https://…"><p class="hint">${t('urlHint')}</p></div>${buttons('preview-url','uploadURL')}`;$('#make-preview').parentElement.append(box);$('#upload-url').oninput=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('preview-url',async()=>{if(!$('#upload-url').checkValidity())return;const sourceUrl=$('#upload-url').value;const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image_url',url:sourceUrl,fit:$('#fit').value,profile:$('#profile').value});const op=await waitOperation(id);if(!box.isConnected||$('#upload-url').value!==sourceUrl)return;state.preview={asset_id:op.result.asset_id,url:op.result.url,sourceUrl};$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});}
async function poetryControls(){
if(state.selectedTemplate!=='daily-poetry')return;
const provider=await api('/api/v1/providers/poetry');if(!$('#template-fields'))return;
@@ -132,7 +132,7 @@ function photosLoginDialog(){
async function photoSourcesPanel(){
const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section);
const sources=await api('/api/v1/photo-sources');if(!section.isConnected)return;
const sources=(await api('/api/v1/photo-sources')).filter(s=>s.kind!=='uploads');if(!section.isConnected)return;
section.innerHTML=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><section id="photos-connection" class="photos-connection" aria-label="Synology Photos"></section><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint source-identity">${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}</p><p class="hint">${s.kind==='synology-photos'?t('sourcePhotosAPI'):t('sourceOwnCredentials')}</p><p class="hint">${esc(s.kind==='synology-photos'?'Synology Photos':s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}</p><div class="actions"><button data-source-browse="${esc(s.id)}">${t('browse')}</button><button data-source-random="${esc(s.id)}" ${s.enabled?'':'disabled'}>${t('randomPreview')}</button><button data-source-job="${esc(s.id)}">${t('sourceJob')}</button><button data-source-edit="${esc(s.id)}">${t('edit')}</button><button class="danger" data-source-delete="${esc(s.id)}">${t('remove')}</button></div></article>`).join(''):`<p class="hint">${t('noSources')}</p>`}</div>`;
await photosConnectionPanel();
bind('add-photo-source',()=>photoSourceForm());
File diff suppressed because one or more lines are too long
+7 -7
View File
@@ -7,7 +7,7 @@ const libraryUI={kind:'source',source:null,plan:null,terminal:null,offset:0};
function libraryStatus(status){return ({GENERATED:tr('已生成','Generated'),QUEUED:tr('待推送','Queued'),RENDERING:tr('准备推送','Preparing'),UPLOADING:tr('上传中','Uploading'),SYNCING:tr('等待设备确认','Awaiting confirmation'),COMPLETED:tr('已确认显示','Display confirmed'),FAILED:tr('推送失败','Failed'),UNKNOWN:tr('结果待核对','Outcome unknown')})[status]||status;}
function terminalLabel(terminal){return terminal.id==='watch'?tr('手表','Watch'):deviceDisplayName(terminal.name?terminal:state.devices.find(d=>d.id===terminal.id)||terminal);}
function librarySelectOptions(items,selected,label){return `<option value="">${label}</option>`+items.map(p=>`<option value="${esc(p.id)}" ${p.id===selected?'selected':''}>${esc(p.name)}</option>`).join('');}
function librarySelectOptions(items,selected,label){return `<option value="">${label}</option>`+items.map(p=>`<option value="${esc(p.id)}" ${p.id===selected?'selected':''}>${esc(p.kind==='uploads'?tr('手动上传','Manual uploads'):p.name)}</option>`).join('');}
views.gallery=epoch=>libraryPage(epoch,'source');
views['terminal-gallery']=epoch=>libraryPage(epoch,'terminal');
async function libraryPage(epoch,kind){
@@ -23,23 +23,23 @@ async function libraryPage(epoch,kind){
async function sourceLibraryPage(epoch,sources,terminals){
const panel=$('#library-content');
if(!sources.length){panel.innerHTML=`<section class="card empty"><p>${tr('先在“照片数据源”中添加来源,再建立该来源的生成方案。','Add a photo source, then create generation plans in its library.')}</p><button id="library-add-source">${tr('添加照片来源','Add photo source')}</button></section>`;bind('library-add-source',()=>navigate('photo-sources'));return;}
if(!sources.length){panel.innerHTML=`<section class="card empty"><p>${tr('连接照片数据源或手动上传照片,再在来源图库建立生成方案。','Connect a photo source or upload photos, then create generation plans in its library.')}</p><button id="library-add-source">${tr('添加照片来源','Add photo source')}</button></section>`;panel.querySelector('section').insertAdjacentHTML('beforeend',`<button id="library-first-upload">${tr('上传照片到来源图库','Upload photos to source library')}</button>`);bind('library-first-upload',()=>navigate('upload'));bind('library-add-source',()=>navigate('photo-sources'));return;}
if(!sources.some(s=>s.id===libraryUI.source)){libraryUI.source=sources[0].id;libraryUI.plan=null;libraryUI.offset=0;}
const sourceId=libraryUI.source;
const sourceId=libraryUI.source,manual=sources.find(s=>s.id===sourceId)?.kind==='uploads';
const [plans,page]=await Promise.all([api('/api/v1/generation-plans?source_id='+encodeURIComponent(sourceId)),api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/images?offset='+libraryUI.offset+'&limit=30')]);
if(epoch!==state.epoch)return;
if(libraryUI.plan&&!plans.some(p=>p.id===libraryUI.plan))libraryUI.plan=null;
const plan=plans.find(p=>p.id===libraryUI.plan),uses=terminals.filter(t=>t.binding?.source_id===sourceId&&(!plan||t.binding.plan_id===plan.id));
panel.innerHTML=`<section class="card library-source-controls"><div class="library-source-toolbar"><div class="field"><label for="library-source">${tr('来源图库','Source library')}</label><select id="library-source">${librarySelectOptions(sources,sourceId,tr('选择来源','Choose source'))}</select></div><div class="actions"><button id="library-import">${tr('从来源选入照片','Import from source')}</button><button id="library-run">${tr('随机选入并按绑定生成','Pick once and generate bound plans')}</button><button id="library-source-settings">${tr('来源设置','Source settings')}</button></div></div><p class="hint">${tr('源画面先保存到本地;同一张照片按不同方案生成版本,多个终端可共用一份版本。Photos 画面可能是源预览。','Source pixels are stored locally first. Plans create versions of the same photo; terminals may share one version. Photos may supply source previews.')}</p></section><section class="card library-output-panel"><div class="library-plan-toolbar"><div class="tabs gallery-tabs library-plan-tabs" role="tablist" aria-label="${tr('源画面与生成方案','Source pixels and generation plans')}"><button id="library-plan-tab-source" role="tab" aria-controls="library-plan-content" aria-selected="${!plan}" tabindex="${!plan?0:-1}" data-library-plan="" class="${!plan?'active':''}">${tr('源画面','Source pixels')}</button>${plans.map(p=>`<button id="library-plan-tab-${esc(p.id)}" role="tab" aria-controls="library-plan-content" aria-selected="${p.id===plan?.id}" tabindex="${p.id===plan?.id?0:-1}" data-library-plan="${esc(p.id)}" class="${p.id===plan?.id?'active':''}">${esc(p.name)}${p.archived?tr('(已归档)',' (archived)'):''}</button>`).join('')}</div><div class="actions"><button id="library-new-plan">${tr('新增生成方案','New generation plan')}</button>${plan&&!plan.archived?`<button id="library-edit-plan">${t('edit')}</button><button id="library-delete-plan" class="danger">${tr('删除方案','Delete plan')}</button>`:''}</div></div><div id="library-plan-content" role="tabpanel" aria-labelledby="library-plan-tab-${plan?esc(plan.id):'source'}"><p class="hint">${tr('使用终端:','Used by: ')}${uses.map(terminalLabel).map(esc).join('、')||tr('尚未绑定','No terminals bound')}</p><div class="gallery source-library-gallery">${page.items.map((picture,i)=>{
panel.innerHTML=`<section class="card library-source-controls"><div class="library-source-toolbar"><div class="field"><label for="library-source">${tr('来源图库','Source library')}</label><select id="library-source">${librarySelectOptions(sources,sourceId,tr('选择来源','Choose source'))}</select></div><div class="actions"><button id="library-import">${manual?tr('上传照片','Upload photos'):tr('从来源选入照片','Import from source')}</button><button id="library-run">${tr('随机选入并按绑定生成','Pick once and generate bound plans')}</button>${manual?'':`<button id="library-source-settings">${tr('来源设置','Source settings')}</button>`}</div></div><p class="hint">${tr('源画面先保存到本地;同一张照片按不同方案生成版本,多个终端可共用一份版本。Photos 画面可能是源预览。','Source pixels are stored locally first. Plans create versions of the same photo; terminals may share one version. Photos may supply source previews.')}</p></section><section class="card library-output-panel"><div class="library-plan-toolbar"><div class="tabs gallery-tabs library-plan-tabs" role="tablist" aria-label="${tr('源画面与生成方案','Source pixels and generation plans')}"><button id="library-plan-tab-source" role="tab" aria-controls="library-plan-content" aria-selected="${!plan}" tabindex="${!plan?0:-1}" data-library-plan="" class="${!plan?'active':''}">${tr('源画面','Source pixels')}</button>${plans.map(p=>`<button id="library-plan-tab-${esc(p.id)}" role="tab" aria-controls="library-plan-content" aria-selected="${p.id===plan?.id}" tabindex="${p.id===plan?.id?0:-1}" data-library-plan="${esc(p.id)}" class="${p.id===plan?.id?'active':''}">${esc(p.name)}${p.archived?tr('(已归档)',' (archived)'):''}</button>`).join('')}</div><div class="actions"><button id="library-new-plan">${tr('新增生成方案','New generation plan')}</button>${plan&&!plan.archived?`<button id="library-edit-plan">${t('edit')}</button><button id="library-delete-plan" class="danger">${tr('删除方案','Delete plan')}</button>`:''}</div></div><div id="library-plan-content" role="tabpanel" aria-labelledby="library-plan-tab-${plan?esc(plan.id):'source'}"><p class="hint">${tr('使用终端:','Used by: ')}${uses.map(terminalLabel).map(esc).join('、')||tr('尚未绑定','No terminals bound')}</p><div class="gallery source-library-gallery">${page.items.map((picture,i)=>{
const version=plan?picture.versions.find(v=>v.plan_id===plan.id):picture;
return `<article class="card image-card">${version?`<button class="image-preview" data-library-preview="${i}"><img class="thumb" alt="${esc(picture.name)}" data-src="${esc(version.url)}" style="object-fit:contain;${plan?`aspect-ratio:${version.plan.width}/${version.plan.height}`:''}"></button>`:`<div class="empty">${tr('此方案尚未生成','Not generated for this plan')}</div>`}<h3>${esc(picture.name)}</h3><small>${plan?version?`${esc(version.plan_name)} · ${version.plan.width} × ${version.plan.height}`:tr('未生成','Not generated'):tr('无叠加的源画面','Source pixels without overlays')}</small><small>${esc(fmt(version?.created_at||picture.created_at))}</small>${plan&&version?.matches_current_plan===false?`<small class="hint">${tr('方案参数已更新,此图片为旧参数版本','Plan settings changed; this image uses earlier settings')}</small>`:''}${version?.terminal_uses?.length?`<small>${version.terminal_uses.map(u=>esc(terminalLabel(terminals.find(t=>t.id===u.terminal_id)||{id:u.terminal_id}))+' · '+esc(libraryStatus(u.status))).join(' / ')}</small>`:''}<div class="actions">${version?`<button data-library-preview="${i}">${tr('预览','Preview')}</button><button data-library-download="${i}">${tr('下载图片','Download image')}</button>${plan?`<button data-library-settings="${i}">${tr('生成参数','Generation settings')}</button>`:''}`:''}${!plan?.archived?`<button data-library-generate="${i}">${plan?tr('按此方案生成','Generate this plan'):tr('生成各方案版本','Generate plan versions')}</button>`:''}${!plan?`<button data-library-star="${i}" ${picture.excluded?'disabled':''} title="${picture.excluded?esc(t('clearExclusionFirst')):''}">${t(picture.starred?'unstar':'star')}</button><button data-library-exclude="${i}" ${picture.starred?'disabled':''} title="${picture.starred?esc(t('clearStarFirst')):''}">${t(picture.excluded?'includePhoto':'excludePhoto')}</button>`:''}<button class="danger" data-library-delete="${i}" ${plan&&!version?'disabled':''}>${t('delete')}</button></div></article>`;
}).join('')}</div>${page.items.length?'':`<section class="card empty">${tr('尚未选入源画面。先从来源选入照片,再按方案生成。','No source pixels imported yet. Import a photo, then generate its versions.')}</section>`}<div class="actions library-pagination"><button id="library-prev" ${libraryUI.offset===0?'disabled':''}>${tr('上一页','Previous')}</button><button id="library-next" ${libraryUI.offset+30>=page.total?'disabled':''}>${tr('下一页','Next')}</button><span>${tr('共','Total')} ${page.total} ${tr('张来源照片','source pictures')}</span></div></div></section>`;
return `<article class="card image-card">${version?`<button class="image-preview" data-library-preview="${i}"><img class="thumb" alt="${esc(picture.name)}" data-src="${esc(version.url)}" style="object-fit:contain;${plan?`aspect-ratio:${version.plan.width}/${version.plan.height}`:''}"></button>`:`<div class="empty">${tr('此方案尚未生成','Not generated for this plan')}</div>`}<h3>${esc(picture.name)}</h3><small>${plan?version?`${esc(version.plan_name)} · ${version.plan.width} × ${version.plan.height}`:tr('未生成','Not generated'):tr('无叠加的源画面','Source pixels without overlays')}</small><small>${esc(fmt(version?.created_at||picture.created_at))}</small>${!plan&&picture.starred?`<small class="photo-mark">${tr('已星标保留','Starred for retention')}</small>`:''}${!plan&&picture.excluded?`<small class="photo-mark">${tr('已标记不再选用','Excluded from automatic selection')}</small>`:''}${plan&&version?.matches_current_plan===false?`<small class="hint">${tr('方案参数已更新,此图片为旧参数版本','Plan settings changed; this image uses earlier settings')}</small>`:''}${version?.terminal_uses?.length?`<small>${version.terminal_uses.map(u=>esc(terminalLabel(terminals.find(t=>t.id===u.terminal_id)||{id:u.terminal_id}))+' · '+esc(libraryStatus(u.status))).join(' / ')}</small>`:''}<div class="actions">${version?`<button data-library-preview="${i}">${tr('预览','Preview')}</button><button data-library-download="${i}">${tr('下载图片','Download image')}</button>${plan?`<button data-library-settings="${i}">${tr('生成参数','Generation settings')}</button>`:''}`:''}${!plan?.archived?`<button data-library-generate="${i}">${plan?tr('按此方案生成','Generate this plan'):tr('生成各方案版本','Generate plan versions')}</button>`:''}${!plan?`<button data-library-star="${i}" ${picture.excluded?'disabled':''} title="${picture.excluded?esc(t('clearExclusionFirst')):''}">${t(picture.starred?'unstar':'star')}</button><button data-library-exclude="${i}" ${picture.starred?'disabled':''} title="${picture.starred?esc(t('clearStarFirst')):''}">${t(picture.excluded?'includePhoto':'excludePhoto')}</button>`:''}<button class="danger" data-library-delete="${i}" ${plan&&!version?'disabled':''}>${t('delete')}</button></div></article>`;
}).join('')}</div>${page.items.length?'':`<section class="card empty">${manual?tr('尚未上传照片。上传后可按方案生成。','No uploads yet. Upload a photo, then generate its versions.'):tr('尚未选入源画面。先从来源选入照片,再按方案生成。','No source pixels imported yet. Import a photo, then generate its versions.')}</section>`}<div class="actions library-pagination"><button id="library-prev" ${libraryUI.offset===0?'disabled':''}>${tr('上一页','Previous')}</button><button id="library-next" ${libraryUI.offset+30>=page.total?'disabled':''}>${tr('下一页','Next')}</button><span>${tr('共','Total')} ${page.total} ${tr('张来源照片','source pictures')}</span></div></div></section>`;
$('#library-source').onchange=()=>{libraryUI.source=$('#library-source').value;libraryUI.plan=null;libraryUI.offset=0;return navigate();};
document.querySelectorAll('[data-library-plan]').forEach(b=>{
b.onclick=()=>{libraryUI.plan=b.dataset.libraryPlan||null;return navigate();};
b.onkeydown=async e=>{const tabs=[...document.querySelectorAll('[data-library-plan]')],index=tabs.indexOf(b);let next;if(e.key==='ArrowRight')next=(index+1)%tabs.length;else if(e.key==='ArrowLeft')next=(index+tabs.length-1)%tabs.length;else if(e.key==='Home')next=0;else if(e.key==='End')next=tabs.length-1;else return;e.preventDefault();libraryUI.plan=tabs[next].dataset.libraryPlan||null;await navigate();document.querySelector('[data-library-plan][aria-selected="true"]')?.focus();};
});
bind('library-import',()=>libraryImportDialog(sourceId));bind('library-source-settings',()=>navigate('photo-sources'));
bind('library-import',()=>manual?navigate('upload'):libraryImportDialog(sourceId));bind('library-source-settings',()=>navigate('photo-sources'));
bind('library-new-plan',()=>generationPlanEditor(sourceId,null,page.items));bind('library-edit-plan',()=>generationPlanEditor(sourceId,plan,page.items));
bind('library-delete-plan',()=>confirmDialog(tr(`确认删除生成方案“${plan.name}”?已有生成版本保留参数快照;正在被终端使用的方案不能删除。`,`Delete generation plan “${plan.name}”? Existing versions keep their snapshots. Bound plans cannot be deleted.`),async()=>{await api('/api/v1/generation-plans/'+plan.id,{method:'DELETE'});await navigate();}));
bind('library-run',async()=>{const result=await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/run',{method:'POST',body:{deliver:false}});toast(result.errors.length?result.errors.map(e=>e.message).join('; '):tr('已从同一来源画面生成各终端版本,尚未推送','Generated terminal versions from one source picture; not sent'));libraryUI.offset=0;await navigate();});
+3
View File
@@ -22,6 +22,9 @@ views['source-browser']=async function(epoch){
$('#browse-status').textContent=tr(`本批找到 ${page.items.length} 张照片${page.paged_provider?`,检查 ${page.scanned} 条记录`:''}。${!page.items.length&&page.next_offset!==null?'可继续下一页检索。':''}`,`${page.items.length} matches in this batch${page.paged_provider?`, ${page.scanned} records checked`:''}.${!page.items.length&&page.next_offset!==null?' Continue to the next batch.':''}`);
$('#browse-gallery').innerHTML=page.items.map((item,i)=>`<article class="card image-card"><button class="image-preview" data-browse-preview="${i}" ${item.preview_url?'':'disabled'}>${item.preview_url?`<img class="thumb" alt="${esc(item.name)}" data-src="${esc(item.preview_url)}">`:`<span class="empty">${tr('选入后预览','Preview after import')}</span>`}</button><h3 title="${esc(item.name)}">${esc(item.name)}</h3><small class="browse-folder">${esc(item.folder||item.source_path||'')}</small><small>${item.width&&item.height?`${item.width} × ${item.height} px · `:''}${item.filesize?`${(item.filesize/1024/1024).toFixed(2)} MB`:''}</small><small>${item.indexed_time?tr('入库:','Indexed: ')+esc(fmt(new Date(item.indexed_time*1000).toISOString())):''}</small><small>${item.time?tr('拍摄:','Photo: ')+esc(fmt(new Date(item.time*1000).toISOString())):''}</small><small>${esc((item.people||[]).map(p=>p.name).filter(Boolean).join('、'))}</small><div class="actions"><button data-browse-preview="${i}" ${item.preview_url?'':'disabled'}>${tr('预览','Preview')}</button><button class="primary" data-browse-import="${i}">${tr('选入本地','Import locally')}</button></div></article>`).join('');$('#browse-gallery').setAttribute('aria-busy','false');
$('#browse-next').disabled=page.next_offset===null;
$('#browse-next').textContent=tr('继续读取','Read next batch');
if(!page.items.length)$('#browse-gallery').innerHTML=`<section class="card empty"><p>${page.next_offset!==null?tr('本批未找到匹配照片,不代表整个来源没有结果。请继续读取下一批。','No matches in this batch. This does not mean the entire source has no results. Read the next batch.'):tr('已读至来源末尾,当前筛选范围内没有更多结果。','End of source reached. No further results for these filters.')}</p></section>`;
bind('browse-prev',()=>{sourceBrowser.offset=sourceBrowser.previous.pop()||0;return navigate();});
bind('browse-next',()=>{if(page.next_offset===null)return;sourceBrowser.previous.push(sourceBrowser.offset);sourceBrowser.offset=page.next_offset;return navigate();});
document.querySelectorAll('[data-browse-preview]').forEach(b=>b.onclick=busy(()=>{const item=page.items[b.dataset.browsePreview];return openImageViewer(item.preview_url,item.name);}));
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.29"
readme = "README.md"
license = {file = "LICENSE"}
requires-python = ">=3.14,<3.15"
dependencies = ["paho-mqtt==2.1.0", "python-dateutil==2.9.0.post0", "fastapi==0.142.2", "uvicorn==0.54.0", "pillow==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==13.0.0", "requests==2.34.2", "croniter==6.2.4", "python-multipart==0.0.32", "pyyaml==6.0.3"]
[project.optional-dependencies]
test = ["pytest==9.1.1", "httpx2==2.13.1", "mypy==2.3.1", "pip-audit==2.10.1", "types-requests==2.33.0.20260906", "types-PyYAML==6.0.12.20260906", "types-croniter==6.2.4.20260711", "ruff==0.16.9"]
[tool.pytest.ini_options]
+3 -3
View File
@@ -1,16 +1,16 @@
annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.15.1
certifi==2026.7.22
certifi==2026.10.23
charset-normalizer==3.5.2
click==8.5.0
croniter==6.2.4
fastapi==0.142.2
h11==0.16.0
idna==3.20
opentelemetry-api==1.45.0
opentelemetry-api==1.45.1
paho-mqtt==2.1.0
pillow==12.3.0
pillow==13.0.0
pydantic-core==2.46.5
pydantic==2.13.5
python-dateutil==2.9.0.post0
+40 -4
View File
@@ -1,5 +1,41 @@
/* Copy must describe actual storage targets and actions; run against isolated Mock only. */
/* Current gallery terminology and upload flow; isolated Mock only. */
const assert=require('assert'),fs=require('fs'),vm=require('vm'),{chromium}=require('playwright');
const context={};context.window=context;vm.createContext(context);vm.runInContext(fs.readFileSync('pigway_frame_flow/web/i18n.js','utf8'),context);
for(const file of ['app.js','features.js','extensions.js'])for(const match of fs.readFileSync('pigway_frame_flow/web/'+file,'utf8').matchAll(/\bt\('([^']+)'\)/g))for(const lang of ['zh','en'])assert(context.MESSAGES[lang][match[1]],`${file}: missing ${lang}.${match[1]}`);
(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});try{const p=await b.newPage();let deletes=0;await p.route('**/api/v1/assets?*',r=>r.fulfill({json:[{asset_id:'fixture-local',name:'Local fixture',origin:'manual',starred:false,current:true,url:'/fixture.jpg'}]}));p.on('request',r=>{if(r.method()==='DELETE')deletes++;});await p.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await p.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await p.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await p.locator('#login-form button').click();await p.locator('.hero').waitFor();assert(await p.evaluate(()=>state.settings.mock));for(const lang of ['zh','en']){await p.evaluate(async lang=>{state.lang=lang;state.tab='local';await navigate('gallery')},lang);await p.locator('[data-delete="0"]').click();assert(await p.locator('#modal-content').textContent().then(x=>x.includes(lang==='zh'?'不会切换画框':'frame display is unchanged')));await p.locator('#modal-cancel').click();assert.equal(await p.locator('label[for="gallery-sort"]').textContent(),lang==='zh'?'排序方式':'Sort order');assert(await p.locator('#gallery-selection-help').textContent().then(x=>x.includes(lang==='zh'?'手动删除仍有效':'manual deletion still applies')));await p.evaluate(()=>navigate('playlists'));await p.locator('#new-playlist').click();assert.equal(await p.locator('label[for="playlist-mode"]').textContent(),lang==='zh'?'播放顺序':'Playback order');await p.locator('#modal-cancel').click();await p.evaluate(()=>navigate('dashboard'));await p.waitForFunction(()=>document.querySelector('#main').textContent.includes(t('weather')));assert(await p.locator('#main').textContent().then(x=>x.includes(lang==='zh'?'型号信息来源':'Model information source')));}assert.equal(deletes,0);console.log('Copy audit PASS: all literal translations present; bilingual sort/playback/model labels, local deletion and star scope verified; no deletions');}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
const translations={};translations.window=translations;vm.createContext(translations);vm.runInContext(fs.readFileSync('pigway_frame_flow/web/i18n.js','utf8'),translations);
for(const file of ['app.js','features.js','extensions.js','libraries.js','source-browser.js'])for(const match of fs.readFileSync('pigway_frame_flow/web/'+file,'utf8').matchAll(/\bt\('([^']+)'\)/g))for(const lang of ['zh','en'])assert(translations.MESSAGES[lang][match[1]],`${file}: missing ${lang}.${match[1]}`);
(async()=>{
const base=process.env.FRAME_FLOW_URL;assert(base);
const token=process.env.FRAME_FLOW_TOKEN||fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);
const headers={Authorization:'Bearer '+token};const request=async(path,method='GET',body)=>{const response=await fetch(base+path,{method,headers:{...headers,...(body?{'Content-Type':'application/json'}:{})},body:body?JSON.stringify(body):undefined});assert(response.ok,`${path}: ${response.status}`);return response.status===204?null:response.json();};
assert((await request('/api/v1/settings')).mock===true);
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
try{
const page=await browser.newPage({extraHTTPHeaders:headers}),errors=[];
page.on('pageerror',e=>errors.push(e.message));await page.route('**/api/v1/auth/me',r=>r.fulfill({json:{username:'fixture',csrf_token:''}}));
await page.goto(base+'/#upload');await page.locator('#upload-source').waitFor();
const png=Buffer.from(process.env.FRAME_FLOW_FIXTURE_BASE64,'base64');assert(png.length);
await page.locator('#upload-source').selectOption('manual-uploads');
await page.locator('#file').setInputFiles({name:'Audit fixture.png',mimeType:'image/png',buffer:png});
assert(await page.locator('#save-local').isEnabled());await page.locator('#save-local').click();await page.locator('#view-upload-library').waitFor();
await page.locator('#view-upload-library').click();await page.locator('[data-library-preview="0"]').first().waitFor();
assert.equal(await page.locator('#library-source').inputValue(),'manual-uploads');assert(await page.locator('#library-import').textContent().then(x=>x.includes('上传')));assert(await page.locator('#device').isHidden());
const pictures=await request('/api/v1/source-libraries/manual-uploads/images');assert.equal(pictures.total,1);
const plan=await request('/api/v1/generation-plans','POST',{source_id:'manual-uploads',name:'UI fixture watch',width:410,height:502});
await request('/api/v1/source-images/'+pictures.items[0].id+'/generate','POST',{plan_id:plan.id});
await request('/api/v1/library-terminals/watch/binding','PUT',{source_id:'manual-uploads',plan_id:plan.id});
for(const lang of ['zh','en'])for(const width of [1440,390]){
await page.setViewportSize({width,height:1000});await page.evaluate(async lang=>{state.lang=lang;await navigate('gallery');},lang);await page.locator('#library-source').waitFor();
const tabs=page.locator('[data-library-plan]');assert(await tabs.count()>=2);await page.locator('[data-library-plan="'+plan.id+'"]').click();await page.locator('[data-library-settings="0"]').waitFor();
assert(!(await page.locator('#main').innerText()).includes('本地图库'));assert(await page.locator('#device').isHidden());assert((await page.locator('#footer').innerText()).includes(lang==='zh'?'独立智能':'Independent smart'));
await page.evaluate(()=>navigate('terminal-gallery'));await page.locator('[data-library-terminal="watch"]').click();await page.locator('[data-terminal-send="0"]').waitFor();assert(await page.locator('[data-terminal-send="0"]').isDisabled());
await page.evaluate(()=>navigate('upload'));await page.locator('#upload-source').waitFor();assert.equal(await page.locator('#save-local').innerText(),lang==='zh'?'保存源画面到图库':'Save source pixels to library');
await page.evaluate(()=>navigate('settings'));await page.locator('#storage-policy').waitFor();assert(await page.locator('#main').innerText().then(x=>x.includes(lang==='zh'?'磁盘可用':'Disk available')));
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),`${lang} ${width}: overflow`);
}
await page.evaluate(()=>navigate('connections'));await page.locator('#client-form').waitFor();assert.equal(await page.locator('input[name="client-scope"][value="images:write"]').count(),1);
const sources=await request('/api/v1/source-libraries'),nas=sources.find(s=>s.kind==='synology-photos');assert(nas);
await page.route('**/api/v1/source-libraries/'+nas.id+'/browse?*',r=>r.fulfill({json:{items:[],next_offset:new URL(r.request().url()).searchParams.get('offset')==='0'?40:null,scanned:40,paged_provider:true}}));
await page.evaluate(id=>openSourceBrowser(id),nas.id);await page.locator('#browse-next').waitFor({state:'visible'});await page.waitForFunction(()=>!document.querySelector('#browse-next').disabled);assert((await page.locator('#browse-gallery').innerText()).includes('does not mean'));await page.locator('#browse-next').click();await page.waitForFunction(()=>document.querySelector('#browse-gallery').textContent.includes('End of source'));assert(await page.locator('#browse-next').isDisabled());
if(process.env.FRAME_FLOW_UI_SCREENSHOT_DIR){fs.mkdirSync(process.env.FRAME_FLOW_UI_SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.FRAME_FLOW_UI_SCREENSHOT_DIR+'/browser-empty-mobile.png',fullPage:true});await page.evaluate(()=>navigate('gallery'));await page.locator('#library-source').waitFor();await page.screenshot({path:process.env.FRAME_FLOW_UI_SCREENSHOT_DIR+'/source-upload-mobile.png',fullPage:true});}
assert.deepEqual(errors,[]);console.log('PASS: upload → source library → plan generation → terminal, bilingual desktop/mobile copy, disk capacity, client scopes and sparse-search continuation');
}finally{await browser.close();}
})().catch(e=>{console.error(e.message);process.exit(1)});
+48
View File
@@ -1994,5 +1994,53 @@
"502",
"503"
]
},
"/api/v1/client-scopes": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-libraries/{source_id}/upload": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-libraries/{source_id}/upload-url": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
}
}
+4 -2
View File
@@ -951,7 +951,8 @@
"webdav",
"google-drive",
"alipan",
"baidu"
"baidu",
"uploads"
],
"title": "Kind"
},
@@ -1131,7 +1132,8 @@
"webdav",
"google-drive",
"alipan",
"baidu"
"baidu",
"uploads"
],
"title": "Kind"
},
+55
View File
@@ -242,3 +242,58 @@ def test_only_one_browser_account(app):
"INSERT INTO users VALUES(?,?,1)",
("other", password_hash("another-password")),
)
def test_password_hash_does_not_hold_database_lock(client, app, monkeypatch):
import threading
from concurrent.futures import ThreadPoolExecutor
from pigway_frame_flow.core import accounts
account(app)
started, release = threading.Event(), threading.Event()
verify = accounts.verify_password
def delayed(*args):
started.set()
assert release.wait(5)
return verify(*args)
monkeypatch.setattr(accounts, "verify_password", delayed)
with ThreadPoolExecutor(max_workers=2) as executor:
request = executor.submit(
client.post,
"/api/v1/auth/login",
json={"username": "test-user", "password": "fixture-password"},
)
try:
assert started.wait(5)
query = executor.submit(app.state.service.db.one, "SELECT 1 AS ok")
assert query.result(timeout=2)["ok"] == 1
finally:
release.set()
assert request.result(timeout=5).status_code == 200
def test_password_changed_during_login_does_not_create_session(
client, app, monkeypatch
):
from pigway_frame_flow.core import accounts
account(app)
verify = accounts.verify_password
def changed(*args):
result = verify(*args)
app.state.service.db.execute(
"UPDATE users SET password_hash=?", (password_hash("replacement-password"),)
)
return result
monkeypatch.setattr(accounts, "verify_password", changed)
response = client.post(
"/api/v1/auth/login",
json={"username": "test-user", "password": "fixture-password"},
)
assert response.status_code == 401
assert app.state.service.db.all("SELECT * FROM sessions") == []
+129
View File
@@ -0,0 +1,129 @@
import shutil
import pytest
def test_image_write_permission_can_be_granted_without_admin(client):
from io import BytesIO
from PIL import Image
data = BytesIO()
Image.new("RGB", (30, 20), "green").save(data, "PNG")
picture = client.post(
"/api/v1/source-libraries/manual-uploads/upload",
files={"file": ("fixture.png", data.getvalue())},
).json()
scopes = client.get("/api/v1/client-scopes").json()
assert "images:write" in scopes
response = client.post(
"/api/v1/clients",
json={
"client_id": "image-writer",
"name": "Writer",
"permissions": ["images:write"],
},
)
assert response.status_code == 201
client.headers["Authorization"] = "Bearer " + response.json()["token"]
# Authorization succeeds and resource lookup fails; administrative routes stay forbidden.
assert (
client.put("/api/v1/assets/missing/star", json={"starred": True}).status_code
== 404
)
assert (
client.put(
"/api/v1/source-images/" + picture["id"] + "/star", json={"starred": True}
).status_code
== 200
)
assert (
client.put(
"/api/v1/source-images/" + picture["id"] + "/exclusion",
json={"excluded": True},
).status_code
== 409
)
assert client.get("/api/v1/clients").status_code == 403
def test_nested_transaction_rollback_includes_execute(service):
with pytest.raises(RuntimeError):
with service.db.transaction():
service.db.execute("INSERT INTO app_config VALUES('outer','{}')")
with service.db.transaction():
service.db.execute("INSERT INTO app_config VALUES('inner','{}')")
raise RuntimeError("injected failure")
assert (
service.db.one("SELECT key FROM app_config WHERE key IN ('outer','inner')")
is None
)
def test_low_disk_space_is_reported_without_enabling_cleanup(client, monkeypatch):
monkeypatch.setattr(
shutil,
"disk_usage",
lambda _path: shutil._ntuple_diskusage(100 * 1024**3, 99 * 1024**3, 1024**3),
)
data = client.get("/api/v1/storage").json()
assert data["disk"]["low_space"] is True
assert data["disk"]["free_bytes"] == 1024**3
assert data["policy"]["enabled"] is False
def test_unsupported_image_plugin_is_never_invoked(monkeypatch):
from PIL import Image
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.renderers.images import convert
Image.init()
invoked = []
def vulnerable_plugin(*_args):
invoked.append(True)
raise RuntimeError("Unsupported plugin must not parse input")
monkeypatch.setitem(Image.OPEN, "XBM", (vulnerable_plugin, lambda _prefix: True))
with pytest.raises(FlowError):
convert(b"#define image_width 1\n#define image_height 1\n")
assert not invoked
def test_promotion_sql_failure_keeps_original_cache(service, monkeypatch):
asset = service.assets.put(b"fixture", "cached.jpg", "cache")
original = service.db.execute
def fail(query, args=()):
if query.startswith("UPDATE assets SET kind"):
raise RuntimeError("injected promotion SQL failure")
return original(query, args)
monkeypatch.setattr(service.db, "execute", fail)
with pytest.raises(RuntimeError):
service.assets.promote(asset["asset_id"])
assert service.assets.path(asset["asset_id"]).parent.name == "cache"
assert service.assets.path(asset["asset_id"]).read_bytes() == b"fixture"
assert not list((service.settings.data_dir / "images").iterdir())
assert not service.db.all("SELECT * FROM asset_file_moves")
def test_promotion_restart_finishes_committed_move(service):
from pigway_frame_flow.renderers.images import Assets
asset = service.assets.put(b"fixture", "cached.jpg", "cache")
source = service.assets.path(asset["asset_id"])
target = service.settings.data_dir / "images" / source.name
target.write_bytes(source.read_bytes())
service.db.execute(
"INSERT INTO asset_file_moves VALUES(?,?,?)",
(asset["asset_id"], "cache", "images"),
)
service.db.execute(
"UPDATE assets SET kind='images' WHERE id=?", (asset["asset_id"],)
)
Assets(service.db, service.settings)
assert not source.exists()
assert service.assets.path(asset["asset_id"]).read_bytes() == b"fixture"
assert not service.db.all("SELECT * FROM asset_file_moves")
+13
View File
@@ -174,3 +174,16 @@ def test_session_changes_invalidate_preview(client, browser, monkeypatch):
]
monkeypatch.setattr(photos.synology, "sid", lambda: "new-session")
assert client.get(item["preview_url"]).status_code == 404
def test_sparse_search_finds_match_after_an_empty_batch(client, browser):
_, rows, _ = browser
rows[45]["filename"] = "later-match.jpg"
first = client.get(
"/api/v1/source-libraries/family/browse?filename=later-match&limit=10"
).json()
assert first["items"] == [] and first["next_offset"] == 40
second = client.get(
"/api/v1/source-libraries/family/browse?filename=later-match&limit=10&offset=40"
).json()
assert [item["name"] for item in second["items"]] == ["later-match.jpg"]
+172
View File
@@ -781,3 +781,175 @@ def test_source_deletion_removes_unreferenced_archived_plans(
service.db.one("SELECT id FROM library_plans WHERE source_id='family'") is None
)
assert client.get("/api/v1/generation-plans?source_id=family").status_code == 404
def test_manual_upload_survives_reload_and_generates_bound_versions(client, service):
data = BytesIO()
Image.new("RGB", (700, 500), "blue").save(data, "PNG")
path = "/api/v1/source-libraries/manual-uploads/upload"
result = client.post(
path, files={"file": ("original.png", data.getvalue(), "image/png")}
)
assert result.status_code == 200, result.text
picture = result.json()
with Image.open(service.assets.path(picture["id"])) as image:
assert image.size == (700, 500)
assert (
client.post(path, files={"file": ("same.png", data.getvalue())}).json()["id"]
== picture["id"]
)
listed = client.get("/api/v1/source-libraries/manual-uploads/images").json()
assert listed["total"] == 1 and listed["items"][0]["id"] == picture["id"]
plan = create_plan(client, "manual-uploads")
bind(client, "watch", "manual-uploads", plan)
generated = client.post(
"/api/v1/source-images/" + picture["id"] + "/generate",
json={"plan_id": plan["id"]},
)
assert generated.status_code == 200, generated.text
assert generated.json()[0]["plan"]["width"] == 410
run = client.post(
"/api/v1/source-libraries/manual-uploads/run", json={"deliver": False}
)
assert run.status_code == 200, run.text
assert (
client.get("/api/v1/source-libraries/manual-uploads/images").json()["total"]
== 1
)
assert client.get("/api/v1/library-terminals/watch/images").json()["total"] == 1
def test_invalid_upload_does_not_create_source(client, service):
response = client.post(
"/api/v1/source-libraries/manual-uploads/upload",
files={"file": ("bad.jpg", b"not an image")},
)
assert response.status_code == 400
assert service.photo_sources.list() == []
def test_ingest_sql_failure_rolls_back_asset_and_file(
client, service, library_source, monkeypatch
):
original = service.db.execute
def fail(query, args=()):
if "INSERT INTO source_images" in query:
raise RuntimeError("injected SQL failure")
return original(query, args)
monkeypatch.setattr(service.db, "execute", fail)
with pytest.raises(RuntimeError):
service.libraries.import_picture("family", "photo.jpg")
assert service.db.all("SELECT * FROM assets") == []
assert service.db.all("SELECT * FROM source_images") == []
assert list((service.settings.data_dir / "source-images").iterdir()) == []
assert service.db.all("SELECT * FROM asset_file_journal") == []
def test_generation_sql_failure_keeps_source_without_orphan(
client, service, library_source, monkeypatch
):
picture = import_picture(client)
plan = create_plan(client)
original = service.db.execute
def fail(query, args=()):
if "INSERT INTO library_versions" in query:
raise RuntimeError("injected SQL failure")
return original(query, args)
monkeypatch.setattr(service.db, "execute", fail)
with pytest.raises(RuntimeError):
service.libraries.generate(picture["id"], plan["id"])
assert len(service.db.all("SELECT * FROM assets")) == 1
assert list((service.settings.data_dir / "renditions").iterdir()) == []
assert service.assets.path(picture["id"]).exists()
def test_delete_sql_failure_preserves_files_and_relations(
client, service, library_source, monkeypatch
):
picture = import_picture(client)
plan = create_plan(client)
version = service.libraries.generate(picture["id"], plan["id"])
original = service.db.execute
def fail(query, args=()):
if query == "DELETE FROM source_images WHERE id=?":
raise RuntimeError("injected SQL failure")
return original(query, args)
monkeypatch.setattr(service.db, "execute", fail)
with pytest.raises(RuntimeError):
service.libraries.delete_picture(picture["id"])
assert service.assets.path(version["asset_id"]).exists()
assert (
service.libraries.picture(picture["id"])["versions"][0]["asset_id"]
== version["asset_id"]
)
assert service.db.all("SELECT * FROM asset_file_journal") == []
def test_delete_file_failure_retries_on_restart(
client, service, library_source, monkeypatch
):
from pathlib import Path
from pigway_frame_flow.renderers.images import Assets
picture = import_picture(client)
path = service.assets.path(picture["id"])
unlink = Path.unlink
def fail(file, *args, **kwargs):
if file == path:
raise OSError("injected unlink failure")
return unlink(file, *args, **kwargs)
with monkeypatch.context() as patch:
patch.setattr(Path, "unlink", fail)
service.libraries.delete_picture(picture["id"])
assert service.db.all("SELECT * FROM source_images") == []
assert (
service.db.one("SELECT id FROM asset_file_journal")["id"] == picture["id"]
)
Assets(service.db, service.settings)
assert not path.exists()
assert service.db.all("SELECT * FROM asset_file_journal") == []
def test_asset_disk_write_failure_leaves_no_partial_file(service, monkeypatch):
import os
fsync = os.fsync
calls = 0
def fail(fd):
nonlocal calls
calls += 1
if calls == 1:
raise OSError("injected disk write failure")
return fsync(fd)
monkeypatch.setattr(os, "fsync", fail)
with pytest.raises(OSError):
service.assets.put(b"data", "failed.jpg")
assert service.db.all("SELECT * FROM assets") == []
assert service.db.all("SELECT * FROM asset_file_journal") == []
assert list((service.settings.data_dir / "images").iterdir()) == []
def test_asset_startup_removes_interrupted_creation(service):
from pigway_frame_flow.renderers.images import Assets
asset_id = "c" * 32
root = service.settings.data_dir / "images"
(root / (asset_id + ".jpg")).write_bytes(b"incomplete")
(root / (asset_id + ".part")).write_bytes(b"partial")
service.db.execute(
"INSERT INTO asset_file_journal VALUES(?,?)", (asset_id, "images")
)
Assets(service.db, service.settings)
assert not list(root.iterdir())
assert service.db.all("SELECT * FROM asset_file_journal") == []