diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 8a3fa22..bcef25a 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,3 +1,13 @@
+# 当前开发修订
+
+- 更新图片处理、证书信任和遥测依赖;不可信图片只交给允许的格式解析器。
+- 手动上传接入来源图库,保存源画面后可按方案生成并关联终端。
+- 补齐图片写权限;客户端权限选项使用服务端统一目录。
+- 图片文件和数据库变更加入事务、失败清理及重启恢复;缓存转成品也保留一致性。
+- 统一上传及图库文案,移除图库重复设备选择器;NAS 空批次支持明确继续读取提示。
+- 设置页显示磁盘容量和待清理文件;登录计算不再占用共享数据库锁。
+- 保持单用户、内网 HTTP 访问,无证书要求。
+
# pigway-frame-flow v0.1.29
完善星标与“不再选用”禁用状态的原因提示。
diff --git a/docs/COMMERCIAL_AUDIT.md b/docs/COMMERCIAL_AUDIT.md
index 21b9592..b8363ef 100644
--- a/docs/COMMERCIAL_AUDIT.md
+++ b/docs/COMMERCIAL_AUDIT.md
@@ -10,6 +10,14 @@
优先级:P1 为对外交付前处理;P2 为下一轮产品完善;P3 为演进建议。以下“确认”指代码或本地复现已证实,“风险”表示存在可解释路径但没有执行攻击或故障压测。
+## 2026-10-24 范围确认与本轮整改
+
+用户确认产品为单用户、内网 IP 访问,接受 HTTP,不使用证书。A02 在此部署范围内是已接受风险,不作为交付阻塞项;未来暴露到不可信网络时重新评估。下文保留原始审计依据,不能将其当作当前全部未修复事项。
+
+本轮对应已确认的改进:依赖升级并重新扫描、图片插件白名单、上传接入来源图库、文件/数据库事务及失败恢复、统一 `images:write` 权限目录、上传/图库术语和设备选择器去重、NAS 空批次继续读取提示、设置页磁盘容量提示、登录 scrypt 不占用数据库锁。补充故障注入及中英文、桌面/移动端流程验证。
+
+本轮不引入 HTTPS、多用户、微服务或新数据库;原审计中的其他商业运营、外部服务条款和扩展能力建议继续作为条件性建议,不声称已完成外部认证。
+
## 一、对外交付前的问题
### A01 · P1 · 锁定依赖存在新公开安全告警(确认,利用条件需分别判断)
diff --git a/docs/SOURCE_LIBRARIES.md b/docs/SOURCE_LIBRARIES.md
index ad969a1..83e287a 100644
--- a/docs/SOURCE_LIBRARIES.md
+++ b/docs/SOURCE_LIBRARIES.md
@@ -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% 时提示空间不足。容量提示不自动开启清理策略,也不自动删除来源图片或成品。
diff --git a/docs/openapi.json b/docs/openapi.json
index f5a5b83..1bf2377 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -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": {
diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py
index 92dd6e4..17c2700 100644
--- a/pigway_frame_flow/api/app.py
+++ b/pigway_frame_flow/api/app.py
@@ -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:%'"
):
diff --git a/pigway_frame_flow/api/libraries.py b/pigway_frame_flow/api/libraries.py
index f23b842..eb709c9 100644
--- a/pigway_frame_flow/api/libraries.py
+++ b/pigway_frame_flow/api/libraries.py
@@ -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)
diff --git a/pigway_frame_flow/core/accounts.py b/pigway_frame_flow/core/accounts.py
index a166932..80997bc 100644
--- a/pigway_frame_flow/core/accounts.py
+++ b/pigway_frame_flow/core/accounts.py
@@ -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,
diff --git a/pigway_frame_flow/core/auth.py b/pigway_frame_flow/core/auth.py
index 9500c9f..79cc3a9 100644
--- a/pigway_frame_flow/core/auth.py
+++ b/pigway_frame_flow/core/auth.py
@@ -14,6 +14,7 @@ bearer = HTTPBearer(auto_error=False)
ALL_SCOPES = [
"devices:read",
"images:read",
+ "images:write",
"images:delete",
"display:write",
"templates:read",
diff --git a/pigway_frame_flow/display/libraries.py b/pigway_frame_flow/display/libraries.py
index 1a38624..e5e81fe 100644
--- a/pigway_frame_flow/display/libraries.py
+++ b/pigway_frame_flow/display/libraries.py
@@ -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 (
diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py
index 22ec9cc..235a37e 100644
--- a/pigway_frame_flow/display/service.py
+++ b/pigway_frame_flow/display/service.py
@@ -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"
diff --git a/pigway_frame_flow/extensions/ai_images.py b/pigway_frame_flow/extensions/ai_images.py
index 4864689..0b7f166 100644
--- a/pigway_frame_flow/extensions/ai_images.py
+++ b/pigway_frame_flow/extensions/ai_images.py
@@ -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")
diff --git a/pigway_frame_flow/extensions/api.py b/pigway_frame_flow/extensions/api.py
index 4148d75..6af435d 100644
--- a/pigway_frame_flow/extensions/api.py
+++ b/pigway_frame_flow/extensions/api.py
@@ -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,
diff --git a/pigway_frame_flow/extensions/auto_collage.py b/pigway_frame_flow/extensions/auto_collage.py
index 7c61f20..7ac2663 100644
--- a/pigway_frame_flow/extensions/auto_collage.py
+++ b/pigway_frame_flow/extensions/auto_collage.py
@@ -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()
diff --git a/pigway_frame_flow/extensions/layout.py b/pigway_frame_flow/extensions/layout.py
index e16820e..f06c2c1 100644
--- a/pigway_frame_flow/extensions/layout.py
+++ b/pigway_frame_flow/extensions/layout.py
@@ -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,
diff --git a/pigway_frame_flow/extensions/quality.py b/pigway_frame_flow/extensions/quality.py
index 1a2b5df..28623ec 100644
--- a/pigway_frame_flow/extensions/quality.py
+++ b/pigway_frame_flow/extensions/quality.py
@@ -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]
diff --git a/pigway_frame_flow/extensions/storage.py b/pigway_frame_flow/extensions/storage.py
index 4aae350..613be2b 100644
--- a/pigway_frame_flow/extensions/storage.py
+++ b/pigway_frame_flow/extensions/storage.py
@@ -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(
diff --git a/pigway_frame_flow/providers/photo_orientation.py b/pigway_frame_flow/providers/photo_orientation.py
index 55175b1..85e76e1 100644
--- a/pigway_frame_flow/providers/photo_orientation.py
+++ b/pigway_frame_flow/providers/photo_orientation.py
@@ -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
diff --git a/pigway_frame_flow/providers/photo_sources.py b/pigway_frame_flow/providers/photo_sources.py
index 874d2d8..c1cc9c3 100644
--- a/pigway_frame_flow/providers/photo_sources.py
+++ b/pigway_frame_flow/providers/photo_sources.py
@@ -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
diff --git a/pigway_frame_flow/providers/photos_cache.py b/pigway_frame_flow/providers/photos_cache.py
index 4236ce8..c4dc134 100644
--- a/pigway_frame_flow/providers/photos_cache.py
+++ b/pigway_frame_flow/providers/photos_cache.py
@@ -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
diff --git a/pigway_frame_flow/renderers/images.py b/pigway_frame_flow/renderers/images.py
index 3883daa..ed0f4b2 100644
--- a/pigway_frame_flow/renderers/images.py
+++ b/pigway_frame_flow/renderers/images.py
@@ -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,))
diff --git a/pigway_frame_flow/renderers/photo_caption.py b/pigway_frame_flow/renderers/photo_caption.py
index 0f78df0..54d08a7 100644
--- a/pigway_frame_flow/renderers/photo_caption.py
+++ b/pigway_frame_flow/renderers/photo_caption.py
@@ -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 = [
diff --git a/pigway_frame_flow/renderers/templates.py b/pigway_frame_flow/renderers/templates.py
index 2a7c426..600dd41 100644
--- a/pigway_frame_flow/renderers/templates.py
+++ b/pigway_frame_flow/renderers/templates.py
@@ -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)
diff --git a/pigway_frame_flow/scheduler/upload_retries.py b/pigway_frame_flow/scheduler/upload_retries.py
index 2fbfc3b..133af94 100644
--- a/pigway_frame_flow/scheduler/upload_retries.py
+++ b/pigway_frame_flow/scheduler/upload_retries.py
@@ -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"):
diff --git a/pigway_frame_flow/storage/database.py b/pigway_frame_flow/storage/database.py
index 56fe8e0..1aeabfe 100644
--- a/pigway_frame_flow/storage/database.py
+++ b/pigway_frame_flow/storage/database.py
@@ -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:
diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js
index b87fe74..057cd9e 100644
--- a/pigway_frame_flow/web/app.js
+++ b/pigway_frame_flow/web/app.js
@@ -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])=>`${icon} ${t(id)} `).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=>`${esc(deviceDisplayName(d))} `).join('')+`${tr('手表 · 410×502','Watch · 410×502')} `;revealCurrentNavigation();}
+function renderFooter(){const v=state.versionInfo;if(v)$('#footer').innerHTML=`PIGWay Frame Flow · ${t('footer')} v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)} `;}
+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])=>`${icon} ${t(id)} `).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=>`${esc(deviceDisplayName(d))} `).join('')+`${tr('手表 · 410×502','Watch · 410×502')} `;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?`
${t('lowBattery')}
`:''}● ${t(d.status).toUpperCase()} ${esc(d.name)} ${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}
${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}
${t('refreshInterval')}
${[['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])=>`
`).join('')}
${t('device')} ${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'))}
${t('cloudRotation')} ${detail('playbackMode',s.mode?t(s.mode):'—')}${detail('switchInterval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}
${t('rotationNotice')}
${t('playlists')} → `;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()+`${t('uploadNotice')}
${t('upload')} ${buttons('make-preview','preview',true)}${t('previewHint')}
${t('preview')} ${t('previewHint')}
${t('staging')} ${t('display')}
`;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=' ';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()+`${t('uploadNotice')}
${t('upload')} ${tr('保存到来源图库','Save in source library')} ${librarySelectOptions(options,selected,'')} ${tr('保存无叠加的源画面,不写回 NAS 或网盘。生成尺寸、裁切与文字由图库中的生成方案决定。','Save source pixels without overlays, without writing back to NAS or cloud drives. Library generation plans control output size, crop and captions.')}
${buttons('make-preview','preview',true)}${t('previewHint')}
${t('preview')} ${t('previewHint')}
${t('staging')} ${t('display')} ${tr('查看来源图库','View source library')}
${tr('预览仅用于直接显示到相框;保存到图库会使用源画面。','Preview settings apply to direct frame display; saving in the library uses source pixels.')}
`;
+ 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=' ';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()+`${t('templateHint')}
${t('templateSteps')}
${state.templates.map((x,i)=>`
${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}
${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)} 1200 × 1600 · ${esc(x.id)} `).join('')}
${t('templateData')} ${t('templateGuideTitle')}
${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}
${['weather','daily-dashboard'].includes(selected.id)?`${t('templateWeatherHelp')}
`:''}${t('profile')} ${t('color')} ${t('grayscale')} ${t('helpProfile')}
${t('templateActionHelp')}
${t('templateAutomationHelp')}
${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}
${t('preview')} ${t('templatePreviewHint')}
${t('display')} `;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=' ';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()+`${t('playlistNotice')}
${t('playlists')} ${buttons('new-playlist','newPlaylist',true)}${playlists.map((p,i)=>`
${t(p.enabled?'enabled':'disabled')} ${esc(p.name)} ${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}
${esc(p.image_ids.join(' → '))}
${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'} ${t('edit')} ${t('remove')}
`).join('')||`
${t('noTasks')}
`}
`;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=`
${t('history')} ${buttons('history-refresh','refresh')}${ops.some(x=>x.status==='UNKNOWN')?`${t('unknown')}
`:''}${ops.length?`
${['created','action','status','result','client','requestId'].map(x=>`${t(x)} `).join('')} ${ops.map((o,i)=>`${fmt(o.created_at)} ${esc(operationActionLabel(o.action))}${esc(o.action==='library-generate'?tr('按来源绑定终端','Source-bound terminals'):deviceNameForId(o.device_id))} ${t(o.status)} ${o.status==='UNKNOWN'?`${t('reconcile')}
`:''}${operationResultHTML(o)} ${esc(operationClientLabel(o.client_id))} ${esc(o.request_id)} ${esc(o.operation_id)} `).join('')}
`:`
${t('noHistory')}
`}
`;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(`${t('reconcile')} ${t('current')}: ${esc(r.current_image_id)}
${t('result')}: ${esc(r.expected_image_id)}
${t('unknown')}
${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}
`);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(`${t('playlists')} `);$('#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=`PIGWay Frame Flow · ${t('footer')} v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)} `).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.
diff --git a/pigway_frame_flow/web/extensions.js b/pigway_frame_flow/web/extensions.js
index 33694d4..eece5e4 100644
--- a/pigway_frame_flow/web/extensions.js
+++ b/pigway_frame_flow/web/extensions.js
@@ -26,16 +26,16 @@ async function layoutStudioPage(){
}
async function connectionsPage(){
$('#main').innerHTML=`${tr('可选连接不会影响 NAS 照片和现有设备。账号密钥保存在受限配置文件中,外部客户端密钥仅显示一次。','Optional connections do not affect NAS photos or existing devices. Provider keys use restricted config files; client tokens are shown once.')}
`;
- 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=`${tr('AI 排版 · OpenAI 兼容','AI layout · OpenAI compatible')} ${tr('只发送文字、尺寸和排版要求,不发送照片。填写完整 chat/completions 地址;使用服务商的模型名称。','Only text, dimensions and instructions are sent; never photos. Enter the full chat/completions URL and provider model name.')}
`;$('#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=`${tr('本地图库保留策略','Local storage retention')} ${data.asset_count} ${t('count')} · ${(data.total_bytes/1024/1024).toFixed(1)} MiB · ${tr('受保护','Protected')}: ${data.protected_count}
${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.')}
`;$('#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=`${tr('本地存储与清理','Local storage and cleanup')} ${data.disk?`${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.'):''}
`:''}${data.pending_file_cleanup?`${tr('待重试文件清理','Pending file cleanup')}: ${data.pending_file_cleanup}
`:''}${data.asset_count} ${t('count')} · ${(data.total_bytes/1024/1024).toFixed(1)} MiB · ${tr('受保护','Protected')}: ${data.protected_count}
${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.')}
`;$('#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=`${tr('外部客户端权限','External client permissions')} ${tr('网页继续使用账号登录。这里只为其他程序创建独立凭据,可随时撤销。','The web UI still uses account login. These credentials are for other programs and can be revoked.')}
${clients.map(c=>`${esc(c.name)} · ${esc(c.client_id)} · ${t(c.enabled?'enabled':'disabled')} ${c.client_id!=='admin'&&c.enabled?`${tr('撤销','Revoke')} `:''}
`).join('')}`;$('#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=`${tr('外部客户端权限','External client permissions')} ${tr('网页继续使用账号登录。这里只为其他程序创建独立凭据,可随时撤销。','The web UI still uses account login. These credentials are for other programs and can be revoked.')}
${clients.map(c=>`${esc(c.name)} · ${esc(c.client_id)} · ${t(c.enabled?'enabled':'disabled')} ${c.client_id!=='admin'&&c.enabled?`${tr('撤销','Revoke')} `:''}
`).join('')}`;$('#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=`Webhook ${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.')}
${hooks.map(h=>`${esc(h.callback_url)} ${t('delete')}
`).join('')}`;$('#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;});
diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js
index c4b9403..53d822f 100644
--- a/pigway_frame_flow/web/features.js
+++ b/pigway_frame_flow/web/features.js
@@ -60,8 +60,8 @@ async function playlistImagePicker(playlist){
const raw=$('#playlist-images');const box=document.createElement('div');raw.parentElement.after(box);box.innerHTML=`${t('imageChoice')}
${t('loading')}
`;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=>` ${esc(image.name)} · #${image.image_id}${image.current?' · '+t('current'):''} `).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=`${t('urlSettings')} `;$('#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=`${t('uploadURL')} ${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=' ';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=`${t('urlSettings')} ${t('allowedHosts')} ${esc(config.allowed_hosts.join('\n'))} ${t('urlHint')}
${t('save')} `;$('#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=`${t('uploadURL')} ${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=' ';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=`${t('photoSources')} ${t('sourceHint')}
${buttons('add-photo-source','addSource')}
${sources.length?sources.map(s=>`
${esc(s.name)} ${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}
${s.kind==='synology-photos'?t('sourcePhotosAPI'):t('sourceOwnCredentials')}
${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]):''}
${t('browse')} ${t('randomPreview')} ${t('sourceJob')} ${t('edit')} ${t('remove')}
`).join(''):`
${t('noSources')}
`}
`;
await photosConnectionPanel();
bind('add-photo-source',()=>photoSourceForm());
diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js
index 9369535..f272fa6 100644
--- a/pigway_frame_flow/web/i18n.js
+++ b/pigway_frame_flow/web/i18n.js
@@ -1,6 +1,6 @@
window.MESSAGES={
-zh:{cloudImageId:"云端图片 ID",localAssetId:"本地图片 ID",DISPLAY_NOT_CONFIRMED:'图片已上传,但画框未确认显示;后续任务已恢复',OFFICIAL_AUTH_REQUIRED:'官方账号登录已失效,请重新连接',INVALID_REQUEST:'请求无效,请检查输入',UNAUTHORIZED:'API Token 无效或未填写',FORBIDDEN:'没有执行此操作的权限',DEVICE_NOT_FOUND:'找不到目标设备',DEVICE_OFFLINE:'画框离线',UNSUPPORTED_CAPABILITY:'设备或内容类型不支持此功能',WRITE_RESULT_UNKNOWN:'写入结果未知,请先核对设备状态',RATE_LIMITED:'请求过于频繁,请稍后再试',WRITE_DISABLED:'设备写入已关闭',CONFIG_REQUIRED:'请先配置设备',CLOUD_UNAVAILABLE:'设备云端暂时不可用',NOT_FOUND:'找不到请求的内容',CONFLICT:'配置冲突或内容仍被恢复任务引用,请先检查状态',INTERNAL_ERROR:'服务内部错误',lastScheduled:'上次调度',displayByName:'按文件名显示',QUEUED:'排队中',RENDERING:'生成中',UPLOADING:'发送中',SYNCING:'等待同步',COMPLETED:'已完成',FAILED:'失败',UNKNOWN:'结果未知',modelVerified:'API 已验证',modelConfigured:'设备配置值',dashboard:'总览',gallery:'图库',upload:'上传图片',templates:'模板',automation:'自动化',playlists:'播放列表',history:'历史记录',settings:'设置',refresh:'刷新',restore:'恢复上一张',online:'在线',offline:'离线',battery:'电量',firmware:'固件',current:'当前图片',heartbeat:'最近心跳',device:'设备信息',rotation:'旋转角度',timezone:'时区',sync:'同步状态',synced:'已同步',syncing:'等待同步',cloud:'KoKonna 云端图库',cloudGallery:'云端图库',galleryListLoading:'正在读取图片列表…',local:'本地图库',preview:'预览',display:'显示到画框',delete:'删除',original:'查看图片',name:'名称',created:'创建时间',save:'保存',cancel:'取消',confirm:'确认',loading:'正在加载…',imageLoadError:'图片加载失败,请刷新重试',mode:'适配方式',profile:'渲染模式',contain:'完整显示 · 留白',cover:'铺满屏幕 · 中心裁切',smart:'智能裁切 · 检查预览',color:'彩色',grayscale:'灰度',staging:'仅保存到本地图库',uploadHint:'JPG、PNG、WebP · 最大 20 MiB · 输出尺寸以生成预览为准',uploadNotice:'KoKonna 云端上传会立即切换画框。仅保存图库时使用本地暂存,不会改变设备。',selectFile:'选择图片',previewHint:'先选择图片并预览,再决定保存或显示。smart 使用构图启发式,不保证识别人脸。',mock:'演示模式',readOnly:'只读',writable:'允许写入',cloudRotation:'设备轮播',queue:'顺序',random:'随机',minutes:'分钟',rotationNotice:'可读取设备轮播模式与间隔;官方接口不提供具体轮播成员,也不支持修改规则。云端图库不是已确认的轮播队列。',playlistNotice:'以下是 Frame Flow 管理的播放列表,与 KoKonna 内置轮播独立。临时告警期间暂停,结束后继续。建议在厂商 App 中关闭内置自动轮播以避免竞争。',newPlaylist:'新建播放列表',newJob:'新建任务',interval:'切换间隔(分钟)',imageIds:'图片 ID(逗号分隔)',enabled:'启用',disabled:'暂停',edit:'编辑',remove:'移除',schedule:'时间安排',source:'内容来源',duration:'显示时长(秒)',restoreAfter:'到期恢复之前图片',priority:'优先级(0–100)',cron:'Cron',daily:'每天',weekdays:'工作日',weekends:'周末',date:'指定日期',temporary:'临时任务',time:'时间(上海时区)',at:'执行日期',lastRun:'上次运行',nextRun:'下次运行',result:'结果',templateData:'模板数据(JSON)',render:'生成预览',templateHint:'模板与数据、设备驱动分离。预览成功后再推送。天气为明确标记的 Mock。',template:'模板',operation:'操作',action:'动作',requestId:'请求 ID',client:'调用方',noImages:'图库为空',emptyHint:'上传图片或生成模板,开始布置你的画框。',noTasks:'还没有任务',noHistory:'暂无历史记录',connect:'连接 Frame Flow',token:'Frame Flow API Token',rememberLogin:'在此浏览器记住登录 30 天',tokenHint:'输入 Frame Flow 登录 Token,不要输入 KoKonna API Key。勾选后将登录 Token 保存在此浏览器 30 天;共享设备请取消勾选。断开连接会清除保存。',logout:'断开连接',auth:'鉴权已启用',apiDocs:'API 文档',version:'版本',writeProtection:'真实写入保护',keyConfigured:'设备密钥已配置',yes:'是',no:'否',lowBattery:'电量较低,建议充电',unknown:'写入结果未知,需要核对设备状态',reconcile:'只读核对',acknowledge:'已人工核对,解除阻塞',accepted:'操作已受理;请在历史记录查看同步结果。',deleteCurrent:'确认删除当前显示图片的云端副本?可能影响画框当前显示,且无法撤销。',deleteConfirm:'确认删除此图片?此操作不可撤销。',removeConfirm:'确认移除此配置?',error:'操作失败',done:'已完成',apiError:'页面加载失败,请查看原因后重试',noToken:'输入 API Token 后可管理画框',status:'状态',systemTheme:'跟随系统',theme:'外观',language:'语言',dark:'深色',light:'浅色',refreshInterval:'设备信息缓存 60 秒;图库仅按需读取',model:'型号',coverId:'封面 ID',saveTemplate:'保存模板默认数据',previewLoading:'正在生成预览…',storage:'设备存储占用',readOnlyWarning:'真实写入已关闭。可以预览和配置任务,但不会上传、切换或删除设备图片。',footer:'独立智能画框服务',photos:'画框内容',count:'张图片',testLabel:'示意画面 · 不代表当前真实内容'},
-en:{cloudImageId:"Cloud image ID",localAssetId:"Local image ID",DISPLAY_NOT_CONFIRMED:'Image uploaded but display was not confirmed; future jobs unblocked',OFFICIAL_AUTH_REQUIRED:'Official account authorization expired; reconnect',INVALID_REQUEST:'Invalid request; check your input',UNAUTHORIZED:'Missing or invalid API Token',FORBIDDEN:'This token does not allow this action',DEVICE_NOT_FOUND:'Device not found',DEVICE_OFFLINE:'Target frame is offline',UNSUPPORTED_CAPABILITY:'Unsupported device capability or content type',WRITE_RESULT_UNKNOWN:'Write outcome unknown; reconcile device state',RATE_LIMITED:'Request budget exceeded; try later',WRITE_DISABLED:'Real device writes are disabled',CONFIG_REQUIRED:'Device configuration required',CLOUD_UNAVAILABLE:'Device cloud is unavailable',NOT_FOUND:'Requested content not found',CONFLICT:'Conflicting configuration or restore reference; check current state',INTERNAL_ERROR:'Internal service error',lastScheduled:'Last scheduled',displayByName:'Display by filename',QUEUED:'Queued',RENDERING:'Rendering',UPLOADING:'Sending',SYNCING:'Awaiting sync',COMPLETED:'Completed',FAILED:'Failed',UNKNOWN:'Unknown outcome',modelVerified:'API verified',modelConfigured:'Configured model',dashboard:'Dashboard',gallery:'Gallery',upload:'Upload',templates:'Templates',automation:'Automation',playlists:'Playlists',history:'History',settings:'Settings',refresh:'Refresh',restore:'Restore previous',online:'Online',offline:'Offline',battery:'Battery',firmware:'Firmware',current:'Current image',heartbeat:'Last heartbeat',device:'Device details',rotation:'Rotation',timezone:'Timezone',sync:'Sync state',synced:'Synced',syncing:'Awaiting sync',cloud:'KoKonna cloud gallery',cloudGallery:'cloud gallery',galleryListLoading:'Reading image list…',local:'Local gallery',preview:'Preview',display:'Display on frame',delete:'Delete',original:'View image',name:'Name',created:'Created',save:'Save',cancel:'Cancel',confirm:'Confirm',loading:'Loading…',imageLoadError:'Image failed to load. Refresh to try again.',mode:'Image fit',profile:'Renderer profile',contain:'Contain · white margins',cover:'Cover · center crop',smart:'Smart crop · inspect preview',color:'Color',grayscale:'Grayscale',staging:'Save to local gallery only',uploadHint:'JPG, PNG, WebP · up to 20 MiB · output dimensions are shown in the rendered preview',uploadNotice:'KoKonna cloud uploads immediately activate the image. Gallery-only uploads are staged locally without changing the device.',selectFile:'Choose an image',previewHint:'Preview your image before saving or displaying it. Smart crop uses saliency heuristics and does not guarantee face detection.',mock:'Demo mode',readOnly:'Live device · read only',writable:'Live device · writes authorized',cloudRotation:'Device slideshow',queue:'Sequential',random:'Random',minutes:'minutes',rotationNotice:'Device slideshow mode and interval are readable. The official API does not expose slideshow membership or rule editing. Cloud gallery is not a verified playback queue.',playlistNotice:'These playlists are managed by Frame Flow independently of KoKonna’s slideshow. Playback pauses during temporary overrides. Disable native slideshow in the vendor app to avoid competing switches.',newPlaylist:'New playlist',newJob:'New job',interval:'Interval (minutes)',imageIds:'Image IDs (comma-separated)',enabled:'Enabled',disabled:'Paused',edit:'Edit',remove:'Remove',schedule:'Schedule',source:'Content source',duration:'Display duration (seconds)',restoreAfter:'Restore previous when expired',priority:'Priority (0–100)',cron:'Cron',daily:'Daily',weekdays:'Weekdays',weekends:'Weekends',date:'Specific date',temporary:'One-time task',time:'Time (Shanghai)',at:'Run at',lastRun:'Last run',nextRun:'Next run',result:'Result',templateData:'Template data (JSON)',render:'Generate preview',templateHint:'Templates are independent of providers and device drivers. Preview before displaying. Weather uses explicitly labeled mock data.',template:'Template',operation:'Operation',action:'Action',requestId:'Request ID',client:'Client',noImages:'Your gallery is empty',emptyHint:'Upload an image or render a template to make this frame yours.',noTasks:'No jobs yet',noHistory:'No history yet',connect:'Connect to Frame Flow',token:'Frame Flow API Token',rememberLogin:'Remember this browser for 30 days',tokenHint:'Use the Frame Flow login Token, not the KoKonna API Key. Remembering saves the login Token in this browser for 30 days. Uncheck on shared devices; Disconnect removes it.',logout:'Disconnect',auth:'Authentication enabled',apiDocs:'API documentation',version:'Version',writeProtection:'Real write protection',keyConfigured:'Device key configured',yes:'Yes',no:'No',lowBattery:'Battery is low. Charge your frame.',unknown:'Write outcome unknown. Reconcile device state.',reconcile:'Read-only reconcile',acknowledge:'Manually verified; release block',accepted:'Operation accepted. Check History for synchronization results.',deleteCurrent:'Delete the cloud copy of the currently displayed image? This may affect the frame display and cannot be undone.',deleteConfirm:'Delete this image? This cannot be undone.',removeConfirm:'Remove this configuration?',error:'Action failed',done:'Completed',apiError:'Page could not load. Check the details and try again.',noToken:'Enter an API Token to manage your frames',status:'Status',systemTheme:'System',theme:'Appearance',language:'Language',dark:'Dark',light:'Light',refreshInterval:'Device state cached for 60 seconds; gallery loaded on demand',model:'Model',coverId:'Cover ID',saveTemplate:'Save template defaults',previewLoading:'Rendering preview…',storage:'Device storage used',readOnlyWarning:'Real writes are disabled. Preview and configure jobs safely; live device uploads, switches and deletes are blocked.',footer:'Independent smart frame service',photos:'Frame content',count:'images',testLabel:'Illustration · not the device’s current image'}
+zh:{cloudImageId:"云端图片 ID",localAssetId:"本地图片 ID",DISPLAY_NOT_CONFIRMED:'图片已上传,但画框未确认显示;后续任务已恢复',OFFICIAL_AUTH_REQUIRED:'官方账号登录已失效,请重新连接',INVALID_REQUEST:'请求无效,请检查输入',UNAUTHORIZED:'API Token 无效或未填写',FORBIDDEN:'没有执行此操作的权限',DEVICE_NOT_FOUND:'找不到目标设备',DEVICE_OFFLINE:'画框离线',UNSUPPORTED_CAPABILITY:'设备或内容类型不支持此功能',WRITE_RESULT_UNKNOWN:'写入结果未知,请先核对设备状态',RATE_LIMITED:'请求过于频繁,请稍后再试',WRITE_DISABLED:'设备写入已关闭',CONFIG_REQUIRED:'请先配置设备',CLOUD_UNAVAILABLE:'设备云端暂时不可用',NOT_FOUND:'找不到请求的内容',CONFLICT:'配置冲突或内容仍被恢复任务引用,请先检查状态',INTERNAL_ERROR:'服务内部错误',lastScheduled:'上次调度',displayByName:'按文件名显示',QUEUED:'排队中',RENDERING:'生成中',UPLOADING:'发送中',SYNCING:'等待同步',COMPLETED:'已完成',FAILED:'失败',UNKNOWN:'结果未知',modelVerified:'API 已验证',modelConfigured:'设备配置值',dashboard:'总览',gallery:'图库',upload:'上传图片',templates:'模板',automation:'自动化',playlists:'播放列表',history:'历史记录',settings:'设置',refresh:'刷新',restore:'恢复上一张',online:'在线',offline:'离线',battery:'电量',firmware:'固件',current:'当前图片',heartbeat:'最近心跳',device:'设备信息',rotation:'旋转角度',timezone:'时区',sync:'同步状态',synced:'已同步',syncing:'等待同步',cloud:'KoKonna 云端图库',cloudGallery:'云端图库',galleryListLoading:'正在读取图片列表…',local:'本地图库',preview:'预览',display:'显示到画框',delete:'删除',original:'查看图片',name:'名称',created:'创建时间',save:'保存',cancel:'取消',confirm:'确认',loading:'正在加载…',imageLoadError:'图片加载失败,请刷新重试',mode:'适配方式',profile:'渲染模式',contain:'完整显示 · 留白',cover:'铺满屏幕 · 中心裁切',smart:'智能裁切 · 检查预览',color:'彩色',grayscale:'灰度',staging:'保存源画面到图库',uploadHint:'JPG、PNG、WebP · 最大 20 MiB · 输出尺寸以生成预览为准',uploadNotice:'KoKonna 云端上传会立即切换画框。保存源画面到来源图库不会改变设备。',selectFile:'选择图片',previewHint:'先选择图片并预览,再决定保存或显示。smart 使用构图启发式,不保证识别人脸。',mock:'演示模式',readOnly:'只读',writable:'允许写入',cloudRotation:'设备轮播',queue:'顺序',random:'随机',minutes:'分钟',rotationNotice:'可读取设备轮播模式与间隔;官方接口不提供具体轮播成员,也不支持修改规则。云端图库不是已确认的轮播队列。',playlistNotice:'以下是 Frame Flow 管理的播放列表,与 KoKonna 内置轮播独立。临时告警期间暂停,结束后继续。建议在厂商 App 中关闭内置自动轮播以避免竞争。',newPlaylist:'新建播放列表',newJob:'新建任务',interval:'切换间隔(分钟)',imageIds:'图片 ID(逗号分隔)',enabled:'启用',disabled:'暂停',edit:'编辑',remove:'移除',schedule:'时间安排',source:'内容来源',duration:'显示时长(秒)',restoreAfter:'到期恢复之前图片',priority:'优先级(0–100)',cron:'Cron',daily:'每天',weekdays:'工作日',weekends:'周末',date:'指定日期',temporary:'临时任务',time:'时间(上海时区)',at:'执行日期',lastRun:'上次运行',nextRun:'下次运行',result:'结果',templateData:'模板数据(JSON)',render:'生成预览',templateHint:'模板与数据、设备驱动分离。预览成功后再推送。天气为明确标记的 Mock。',template:'模板',operation:'操作',action:'动作',requestId:'请求 ID',client:'调用方',noImages:'图库为空',emptyHint:'上传图片或生成模板,开始布置你的画框。',noTasks:'还没有任务',noHistory:'暂无历史记录',connect:'连接 Frame Flow',token:'Frame Flow API Token',rememberLogin:'在此浏览器记住登录 30 天',tokenHint:'输入 Frame Flow 登录 Token,不要输入 KoKonna API Key。勾选后将登录 Token 保存在此浏览器 30 天;共享设备请取消勾选。断开连接会清除保存。',logout:'断开连接',auth:'鉴权已启用',apiDocs:'API 文档',version:'版本',writeProtection:'真实写入保护',keyConfigured:'设备密钥已配置',yes:'是',no:'否',lowBattery:'电量较低,建议充电',unknown:'写入结果未知,需要核对设备状态',reconcile:'只读核对',acknowledge:'已人工核对,解除阻塞',accepted:'操作已受理;请在历史记录查看同步结果。',deleteCurrent:'确认删除当前显示图片的云端副本?可能影响画框当前显示,且无法撤销。',deleteConfirm:'确认删除此图片?此操作不可撤销。',removeConfirm:'确认移除此配置?',error:'操作失败',done:'已完成',apiError:'页面加载失败,请查看原因后重试',noToken:'输入 API Token 后可管理画框',status:'状态',systemTheme:'跟随系统',theme:'外观',language:'语言',dark:'深色',light:'浅色',refreshInterval:'设备信息缓存 60 秒;图库仅按需读取',model:'型号',coverId:'封面 ID',saveTemplate:'保存模板默认数据',previewLoading:'正在生成预览…',storage:'设备存储占用',readOnlyWarning:'真实写入已关闭。可以预览和配置任务,但不会上传、切换或删除设备图片。',footer:'独立智能画框服务',photos:'画框内容',count:'张图片',testLabel:'示意画面 · 不代表当前真实内容'},
+en:{cloudImageId:"Cloud image ID",localAssetId:"Local image ID",DISPLAY_NOT_CONFIRMED:'Image uploaded but display was not confirmed; future jobs unblocked',OFFICIAL_AUTH_REQUIRED:'Official account authorization expired; reconnect',INVALID_REQUEST:'Invalid request; check your input',UNAUTHORIZED:'Missing or invalid API Token',FORBIDDEN:'This token does not allow this action',DEVICE_NOT_FOUND:'Device not found',DEVICE_OFFLINE:'Target frame is offline',UNSUPPORTED_CAPABILITY:'Unsupported device capability or content type',WRITE_RESULT_UNKNOWN:'Write outcome unknown; reconcile device state',RATE_LIMITED:'Request budget exceeded; try later',WRITE_DISABLED:'Real device writes are disabled',CONFIG_REQUIRED:'Device configuration required',CLOUD_UNAVAILABLE:'Device cloud is unavailable',NOT_FOUND:'Requested content not found',CONFLICT:'Conflicting configuration or restore reference; check current state',INTERNAL_ERROR:'Internal service error',lastScheduled:'Last scheduled',displayByName:'Display by filename',QUEUED:'Queued',RENDERING:'Rendering',UPLOADING:'Sending',SYNCING:'Awaiting sync',COMPLETED:'Completed',FAILED:'Failed',UNKNOWN:'Unknown outcome',modelVerified:'API verified',modelConfigured:'Configured model',dashboard:'Dashboard',gallery:'Gallery',upload:'Upload',templates:'Templates',automation:'Automation',playlists:'Playlists',history:'History',settings:'Settings',refresh:'Refresh',restore:'Restore previous',online:'Online',offline:'Offline',battery:'Battery',firmware:'Firmware',current:'Current image',heartbeat:'Last heartbeat',device:'Device details',rotation:'Rotation',timezone:'Timezone',sync:'Sync state',synced:'Synced',syncing:'Awaiting sync',cloud:'KoKonna cloud gallery',cloudGallery:'cloud gallery',galleryListLoading:'Reading image list…',local:'Local gallery',preview:'Preview',display:'Display on frame',delete:'Delete',original:'View image',name:'Name',created:'Created',save:'Save',cancel:'Cancel',confirm:'Confirm',loading:'Loading…',imageLoadError:'Image failed to load. Refresh to try again.',mode:'Image fit',profile:'Renderer profile',contain:'Contain · white margins',cover:'Cover · center crop',smart:'Smart crop · inspect preview',color:'Color',grayscale:'Grayscale',staging:'Save source pixels to library',uploadHint:'JPG, PNG, WebP · up to 20 MiB · output dimensions are shown in the rendered preview',uploadNotice:'KoKonna cloud uploads immediately activate the image. Saving source pixels in a source library does not change the device.',selectFile:'Choose an image',previewHint:'Preview your image before saving or displaying it. Smart crop uses saliency heuristics and does not guarantee face detection.',mock:'Demo mode',readOnly:'Live device · read only',writable:'Live device · writes authorized',cloudRotation:'Device slideshow',queue:'Sequential',random:'Random',minutes:'minutes',rotationNotice:'Device slideshow mode and interval are readable. The official API does not expose slideshow membership or rule editing. Cloud gallery is not a verified playback queue.',playlistNotice:'These playlists are managed by Frame Flow independently of KoKonna’s slideshow. Playback pauses during temporary overrides. Disable native slideshow in the vendor app to avoid competing switches.',newPlaylist:'New playlist',newJob:'New job',interval:'Interval (minutes)',imageIds:'Image IDs (comma-separated)',enabled:'Enabled',disabled:'Paused',edit:'Edit',remove:'Remove',schedule:'Schedule',source:'Content source',duration:'Display duration (seconds)',restoreAfter:'Restore previous when expired',priority:'Priority (0–100)',cron:'Cron',daily:'Daily',weekdays:'Weekdays',weekends:'Weekends',date:'Specific date',temporary:'One-time task',time:'Time (Shanghai)',at:'Run at',lastRun:'Last run',nextRun:'Next run',result:'Result',templateData:'Template data (JSON)',render:'Generate preview',templateHint:'Templates are independent of providers and device drivers. Preview before displaying. Weather uses explicitly labeled mock data.',template:'Template',operation:'Operation',action:'Action',requestId:'Request ID',client:'Client',noImages:'Your gallery is empty',emptyHint:'Upload an image or render a template to make this frame yours.',noTasks:'No jobs yet',noHistory:'No history yet',connect:'Connect to Frame Flow',token:'Frame Flow API Token',rememberLogin:'Remember this browser for 30 days',tokenHint:'Use the Frame Flow login Token, not the KoKonna API Key. Remembering saves the login Token in this browser for 30 days. Uncheck on shared devices; Disconnect removes it.',logout:'Disconnect',auth:'Authentication enabled',apiDocs:'API documentation',version:'Version',writeProtection:'Real write protection',keyConfigured:'Device key configured',yes:'Yes',no:'No',lowBattery:'Battery is low. Charge your frame.',unknown:'Write outcome unknown. Reconcile device state.',reconcile:'Read-only reconcile',acknowledge:'Manually verified; release block',accepted:'Operation accepted. Check History for synchronization results.',deleteCurrent:'Delete the cloud copy of the currently displayed image? This may affect the frame display and cannot be undone.',deleteConfirm:'Delete this image? This cannot be undone.',removeConfirm:'Remove this configuration?',error:'Action failed',done:'Completed',apiError:'Page could not load. Check the details and try again.',noToken:'Enter an API Token to manage your frames',status:'Status',systemTheme:'System',theme:'Appearance',language:'Language',dark:'Dark',light:'Light',refreshInterval:'Device state cached for 60 seconds; gallery loaded on demand',model:'Model',coverId:'Cover ID',saveTemplate:'Save template defaults',previewLoading:'Rendering preview…',storage:'Device storage used',readOnlyWarning:'Real writes are disabled. Preview and configure jobs safely; live device uploads, switches and deletes are blocked.',footer:'Independent smart frame service',photos:'Frame content',count:'images',testLabel:'Illustration · not the device’s current image'}
};
Object.assign(window.MESSAGES.zh,{username:'账号',password:'密码',connect:'登录 Frame Flow',UNAUTHORIZED:'账号或密码错误,或登录已过期',tokenHint:'使用账号和密码登录。勾选记住登录后可保持 30 天;共享设备请退出登录。',logout:'退出登录',noToken:'请登录管理画框',formEditor:'表单编辑',advanced:'高级 JSON',resetTemplate:'恢复默认模板',resetConfirm:'清除已保存的模板配置,恢复内置默认值?',eventsHelp:'每行一个事项',photo:'家庭照片',noPhoto:'使用照片占位区域',changePassword:'修改密码',confirmPassword:'确认新密码',passwordMismatch:'两次输入的新密码不一致',currentPassword:'当前密码',newPassword:'新密码(至少 8 位)',passwordChanged:'密码已修改,请重新登录',saveTemplate:'保存模板设置'});
@@ -159,3 +159,6 @@ Object.assign(MESSAGES.en,{excludePhoto:'Exclude from selection',includePhoto:'A
Object.assign(MESSAGES.zh,{downloadShort:'下载图片'});Object.assign(MESSAGES.en,{downloadShort:'Download'});
Object.assign(MESSAGES.zh,{photoStarred:'已星标保留',clearExclusionFirst:'此图片已标记“不再选用”,不能同时星标保留。请先点击“恢复选用”。',clearStarFirst:'此图片已星标保留,不能同时标记“不再选用”。请先点击“取消星标”。'});Object.assign(MESSAGES.en,{photoStarred:'Starred for retention',clearExclusionFirst:'This photo is excluded and cannot also be starred. Choose Allow selection first.',clearStarFirst:'This photo is starred and cannot also be excluded. Remove the star first.'});
+
+Object.assign(MESSAGES.zh,{staging:'保存源画面到图库',uploadNotice:'保存源画面到来源图库后,可按方案生成;直接显示到相框会立即切换设备。',local:'来源图库',localAssetId:'本地资产 ID',localGallery:'来源图库'});
+Object.assign(MESSAGES.en,{staging:'Save source pixels to library',uploadNotice:'Save source pixels in a source library to generate plan versions. Direct frame display immediately changes the device.',local:'Source library',localAssetId:'Local asset ID',localGallery:'Source library'});
diff --git a/pigway_frame_flow/web/libraries.js b/pigway_frame_flow/web/libraries.js
index b0deb6c..3e4d63f 100644
--- a/pigway_frame_flow/web/libraries.js
+++ b/pigway_frame_flow/web/libraries.js
@@ -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 `${label} `+items.map(p=>`${esc(p.name)} `).join('');}
+function librarySelectOptions(items,selected,label){return `${label} `+items.map(p=>`${esc(p.kind==='uploads'?tr('手动上传','Manual uploads'):p.name)} `).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=`${tr('先在“照片数据源”中添加来源,再建立该来源的生成方案。','Add a photo source, then create generation plans in its library.')}
${tr('添加照片来源','Add photo source')} `;bind('library-add-source',()=>navigate('photo-sources'));return;}
+ if(!sources.length){panel.innerHTML=`${tr('连接照片数据源或手动上传照片,再在来源图库建立生成方案。','Connect a photo source or upload photos, then create generation plans in its library.')}
${tr('添加照片来源','Add photo source')} `;panel.querySelector('section').insertAdjacentHTML('beforeend',`${tr('上传照片到来源图库','Upload photos to source library')} `);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=`${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.')}
${tr('使用终端:','Used by: ')}${uses.map(terminalLabel).map(esc).join('、')||tr('尚未绑定','No terminals bound')}
${page.items.map((picture,i)=>{
+ panel.innerHTML=`
${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.')}
${tr('使用终端:','Used by: ')}${uses.map(terminalLabel).map(esc).join('、')||tr('尚未绑定','No terminals bound')}
${page.items.map((picture,i)=>{
const version=plan?picture.versions.find(v=>v.plan_id===plan.id):picture;
- return `
${version?` `:`${tr('此方案尚未生成','Not generated for this plan')}
`}${esc(picture.name)} ${plan?version?`${esc(version.plan_name)} · ${version.plan.width} × ${version.plan.height}`:tr('未生成','Not generated'):tr('无叠加的源画面','Source pixels without overlays')} ${esc(fmt(version?.created_at||picture.created_at))} ${plan&&version?.matches_current_plan===false?`${tr('方案参数已更新,此图片为旧参数版本','Plan settings changed; this image uses earlier settings')} `:''}${version?.terminal_uses?.length?`${version.terminal_uses.map(u=>esc(terminalLabel(terminals.find(t=>t.id===u.terminal_id)||{id:u.terminal_id}))+' · '+esc(libraryStatus(u.status))).join(' / ')} `:''}${version?`${tr('预览','Preview')} ${tr('下载图片','Download image')} ${plan?`${tr('生成参数','Generation settings')} `:''}`:''}${!plan?.archived?`${plan?tr('按此方案生成','Generate this plan'):tr('生成各方案版本','Generate plan versions')} `:''}${!plan?`${t(picture.starred?'unstar':'star')} ${t(picture.excluded?'includePhoto':'excludePhoto')} `:''}${t('delete')}
`;
- }).join('')}
${page.items.length?'':`
${tr('尚未选入源画面。先从来源选入照片,再按方案生成。','No source pixels imported yet. Import a photo, then generate its versions.')} `}
`;
+ return `
${version?` `:`${tr('此方案尚未生成','Not generated for this plan')}
`}${esc(picture.name)} ${plan?version?`${esc(version.plan_name)} · ${version.plan.width} × ${version.plan.height}`:tr('未生成','Not generated'):tr('无叠加的源画面','Source pixels without overlays')} ${esc(fmt(version?.created_at||picture.created_at))} ${!plan&&picture.starred?`${tr('已星标保留','Starred for retention')} `:''}${!plan&&picture.excluded?`${tr('已标记不再选用','Excluded from automatic selection')} `:''}${plan&&version?.matches_current_plan===false?`${tr('方案参数已更新,此图片为旧参数版本','Plan settings changed; this image uses earlier settings')} `:''}${version?.terminal_uses?.length?`${version.terminal_uses.map(u=>esc(terminalLabel(terminals.find(t=>t.id===u.terminal_id)||{id:u.terminal_id}))+' · '+esc(libraryStatus(u.status))).join(' / ')} `:''}${version?`${tr('预览','Preview')} ${tr('下载图片','Download image')} ${plan?`${tr('生成参数','Generation settings')} `:''}`:''}${!plan?.archived?`${plan?tr('按此方案生成','Generate this plan'):tr('生成各方案版本','Generate plan versions')} `:''}${!plan?`${t(picture.starred?'unstar':'star')} ${t(picture.excluded?'includePhoto':'excludePhoto')} `:''}${t('delete')}
`;
+ }).join('')}
${page.items.length?'':`
${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.')} `}
`;
$('#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();});
diff --git a/pigway_frame_flow/web/source-browser.js b/pigway_frame_flow/web/source-browser.js
index 5bb39da..fc2f14f 100644
--- a/pigway_frame_flow/web/source-browser.js
+++ b/pigway_frame_flow/web/source-browser.js
@@ -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)=>`${item.preview_url?` `:`${tr('选入后预览','Preview after import')} `} ${esc(item.name)} ${esc(item.folder||item.source_path||'')} ${item.width&&item.height?`${item.width} × ${item.height} px · `:''}${item.filesize?`${(item.filesize/1024/1024).toFixed(2)} MB`:''} ${item.indexed_time?tr('入库:','Indexed: ')+esc(fmt(new Date(item.indexed_time*1000).toISOString())):''} ${item.time?tr('拍摄:','Photo: ')+esc(fmt(new Date(item.time*1000).toISOString())):''} ${esc((item.people||[]).map(p=>p.name).filter(Boolean).join('、'))} ${tr('预览','Preview')} ${tr('选入本地','Import locally')}
`).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=`${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.')}
`;
+
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);}));
diff --git a/pyproject.toml b/pyproject.toml
index 448cf0f..4a0f82b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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]
diff --git a/requirements.lock b/requirements.lock
index 76df963..8190844 100644
--- a/requirements.lock
+++ b/requirements.lock
@@ -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
diff --git a/scripts/ui-copy-audit.cjs b/scripts/ui-copy-audit.cjs
index 84069fd..e2a3d40 100644
--- a/scripts/ui-copy-audit.cjs
+++ b/scripts/ui-copy-audit.cjs
@@ -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)});
diff --git a/tests/contract.json b/tests/contract.json
index ef5d338..bfc05dd 100644
--- a/tests/contract.json
+++ b/tests/contract.json
@@ -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"
+ ]
}
}
diff --git a/tests/schema-contract.json b/tests/schema-contract.json
index 1abcda4..9d2630c 100644
--- a/tests/schema-contract.json
+++ b/tests/schema-contract.json
@@ -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"
},
diff --git a/tests/test_accounts.py b/tests/test_accounts.py
index 2fc63a5..3602e9c 100644
--- a/tests/test_accounts.py
+++ b/tests/test_accounts.py
@@ -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") == []
diff --git a/tests/test_audit_fixes.py b/tests/test_audit_fixes.py
new file mode 100644
index 0000000..6dec459
--- /dev/null
+++ b/tests/test_audit_fixes.py
@@ -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")
diff --git a/tests/test_source_browser.py b/tests/test_source_browser.py
index dde757c..1b4ba0d 100644
--- a/tests/test_source_browser.py
+++ b/tests/test_source_browser.py
@@ -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"]
diff --git a/tests/test_source_libraries.py b/tests/test_source_libraries.py
index b864779..21eb752 100644
--- a/tests/test_source_libraries.py
+++ b/tests/test_source_libraries.py
@@ -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") == []