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])=>``).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=>``).join('')+``;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])=>``).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=>``).join('')+``;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])=>`
${t(l)}
${esc(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')}

${t('selectFile')}

${t('uploadHint')}

${buttons('make-preview','preview',true)}

${t('previewHint')}

${t('preview')}

${t('previewHint')}
`;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='Preview';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);toast(tr('已保存到服务器,可用于模板与任务','Saved on server for templates and tasks'));});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));}, + async upload(epoch){ + state.preview=null; + const sources=await api('/api/v1/source-libraries');if(epoch!==state.epoch)return; + const options=[...sources];if(!options.some(x=>x.id==='manual-uploads'))options.push({id:'manual-uploads',name:tr('手动上传','Manual uploads')}); + const selected=options.some(x=>x.id===libraryUI.source)?libraryUI.source:'manual-uploads'; + $('#main').innerHTML=notice()+`
${t('uploadNotice')}

${t('upload')}

${tr('保存无叠加的源画面,不写回 NAS 或网盘。生成尺寸、裁切与文字由图库中的生成方案决定。','Save source pixels without overlays, without writing back to NAS or cloud drives. Library generation plans control output size, crop and captions.')}

${t('selectFile')}

${t('uploadHint')}

${buttons('make-preview','preview',true)}

${t('previewHint')}

${t('preview')}

${t('previewHint')}

${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='Preview';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();}); + for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=!$('#file').files[0];$('#display-upload').disabled=true;$('#view-upload-library').hidden=true;}; + bind('save-local',async()=>{const sourceId=$('#upload-source').value,file=state.preview?.uploadFile||$('#file').files[0];let picture;if(state.preview?.sourceUrl){picture=await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/upload-url',{method:'POST',body:{url:state.preview.sourceUrl}});}else{if(!file)throw new Error(t('selectFile'));const body=new FormData();body.append('file',file);picture=await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/upload',{method:'POST',body});}libraryUI.source=picture.source_id;libraryUI.plan=null;libraryUI.offset=0;$('#view-upload-library').hidden=false;toast(tr('已保存源画面,可在来源图库按方案生成','Source pixels saved. Generate plan versions in the source library.'));}); + bind('view-upload-library',()=>navigate('gallery'));bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id})); + }, async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`
${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('helpProfile')}

${t('templateActionHelp')}

${t('templateAutomationHelp')}

${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}

${t('preview')}

${t('templatePreviewHint')}
`;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='Template preview';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}}, async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`
${t('playlistNotice')}

${t('playlists')}

${buttons('new-playlist','newPlaylist',true)}
`;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=>``).join('')}${ops.map((o,i)=>``).join('')}
${t(x)}
${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'?`
`:''}
${operationResultHTML(o)}${esc(operationClientLabel(o.client_id))}${esc(o.request_id)}
${esc(o.operation_id)}
`:`
${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')}

`);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.')}

${t('apiKeyConfigured')}: ${t(config.key_configured?'yes':'no')}

`;$('#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?``:''}

`).join('')}
${scopes.map(s=>``).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?``:''}

`).join('')}
${scopes.map(s=>``).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)}

`).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=>``).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')}

${t('urlHint')}

`;$('#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')}

${t('urlHint')}

${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='Preview';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});} +async function enhanceSettings(){await storageSettingsPanel();await weatherSettings();const config=await api('/api/v1/settings/image-url');const box=document.createElement('section');box.className='card';box.innerHTML=`

${t('urlSettings')}

${t('urlHint')}

`;$('#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')}

${t('urlHint')}

${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='Preview';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});} async function poetryControls(){ if(state.selectedTemplate!=='daily-poetry')return; const provider=await api('/api/v1/providers/poetry');if(!$('#template-fields'))return; @@ -132,7 +132,7 @@ function photosLoginDialog(){ async function photoSourcesPanel(){ const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section); - const sources=await api('/api/v1/photo-sources');if(!section.isConnected)return; + const sources=(await api('/api/v1/photo-sources')).filter(s=>s.kind!=='uploads');if(!section.isConnected)return; section.innerHTML=`

${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]):''}

`).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 ``+items.map(p=>``).join('');} +function librarySelectOptions(items,selected,label){return ``+items.map(p=>``).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.')}

`;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.')}

`;panel.querySelector('section').insertAdjacentHTML('beforeend',``);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.')}

${plan&&!plan.archived?``:''}

${tr('使用终端:','Used by: ')}${uses.map(terminalLabel).map(esc).join('、')||tr('尚未绑定','No terminals bound')}

${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.')}
`}
${tr('共','Total')} ${page.total} ${tr('张来源照片','source pictures')}
`; $('#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)=>`

${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('、'))}
`).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") == []