fix: close library upload flow and harden image storage
This commit is contained in:
@@ -1,3 +1,13 @@
|
|||||||
|
# 当前开发修订
|
||||||
|
|
||||||
|
- 更新图片处理、证书信任和遥测依赖;不可信图片只交给允许的格式解析器。
|
||||||
|
- 手动上传接入来源图库,保存源画面后可按方案生成并关联终端。
|
||||||
|
- 补齐图片写权限;客户端权限选项使用服务端统一目录。
|
||||||
|
- 图片文件和数据库变更加入事务、失败清理及重启恢复;缓存转成品也保留一致性。
|
||||||
|
- 统一上传及图库文案,移除图库重复设备选择器;NAS 空批次支持明确继续读取提示。
|
||||||
|
- 设置页显示磁盘容量和待清理文件;登录计算不再占用共享数据库锁。
|
||||||
|
- 保持单用户、内网 HTTP 访问,无证书要求。
|
||||||
|
|
||||||
# pigway-frame-flow v0.1.29
|
# pigway-frame-flow v0.1.29
|
||||||
|
|
||||||
完善星标与“不再选用”禁用状态的原因提示。
|
完善星标与“不再选用”禁用状态的原因提示。
|
||||||
|
|||||||
@@ -10,6 +10,14 @@
|
|||||||
|
|
||||||
优先级:P1 为对外交付前处理;P2 为下一轮产品完善;P3 为演进建议。以下“确认”指代码或本地复现已证实,“风险”表示存在可解释路径但没有执行攻击或故障压测。
|
优先级:P1 为对外交付前处理;P2 为下一轮产品完善;P3 为演进建议。以下“确认”指代码或本地复现已证实,“风险”表示存在可解释路径但没有执行攻击或故障压测。
|
||||||
|
|
||||||
|
## 2026-10-24 范围确认与本轮整改
|
||||||
|
|
||||||
|
用户确认产品为单用户、内网 IP 访问,接受 HTTP,不使用证书。A02 在此部署范围内是已接受风险,不作为交付阻塞项;未来暴露到不可信网络时重新评估。下文保留原始审计依据,不能将其当作当前全部未修复事项。
|
||||||
|
|
||||||
|
本轮对应已确认的改进:依赖升级并重新扫描、图片插件白名单、上传接入来源图库、文件/数据库事务及失败恢复、统一 `images:write` 权限目录、上传/图库术语和设备选择器去重、NAS 空批次继续读取提示、设置页磁盘容量提示、登录 scrypt 不占用数据库锁。补充故障注入及中英文、桌面/移动端流程验证。
|
||||||
|
|
||||||
|
本轮不引入 HTTPS、多用户、微服务或新数据库;原审计中的其他商业运营、外部服务条款和扩展能力建议继续作为条件性建议,不声称已完成外部认证。
|
||||||
|
|
||||||
## 一、对外交付前的问题
|
## 一、对外交付前的问题
|
||||||
|
|
||||||
### A01 · P1 · 锁定依赖存在新公开安全告警(确认,利用条件需分别判断)
|
### A01 · P1 · 锁定依赖存在新公开安全告警(确认,利用条件需分别判断)
|
||||||
|
|||||||
@@ -48,3 +48,13 @@
|
|||||||
- `PUT /api/v1/jobs/{job_id}/library-mode`:使用 `jobs:write` 切换模式,保留下次执行时间和启用状态。
|
- `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 保留兼容。
|
新增 `library_plans`、`source_images`、`library_versions`、`terminal_bindings`、`terminal_deliveries` 表,复用现有 SQLite、资源鉴权、Pillow 渲染及任务队列。`terminal_bindings.terminal_id` 是主键,保证终端仅有一个绑定;服务端同步校验来源、方案、版本归属以及屏幕尺寸。读取需要 `images:read`,管理需要 `admin`,本地删除需要 `images:delete`,终端推送需要 `display:write`。旧 API 保留兼容。
|
||||||
|
|
||||||
|
## 手动上传与本地存储
|
||||||
|
|
||||||
|
“上传图片”可将无叠加的源画面保存到所选来源图库;默认“手动上传”来源在首次成功上传时创建,不需要 NAS 连接。保存后通过“查看来源图库”找回图片,再建立方案和终端绑定。手动来源的随机选图及任务沿用同一套来源流程,不创建另一套图库。上传到已有来源图库只保存本地副本,不写回 NAS 或网盘。源画面按输入像素尺寸及 EXIF 朝向归一化,预览的相框裁切、色彩不会改变保存的源画面;实际输出由生成方案决定。相同内容在同一来源重复保存会复用图片。
|
||||||
|
|
||||||
|
本地上传使用 `POST /api/v1/source-libraries/{id}/upload`,链接导入使用 `/upload-url`,二者要求 `images:write`,保留图片大小、格式及 URL 安全限制。`manual-uploads` 是内建手动来源的保留 ID;设置页管理本地存储,照片数据源页管理远程连接。
|
||||||
|
|
||||||
|
资产写入及相关来源/版本记录共享数据库事务。文件先落临时文件并同步后再提交资产;未完成创建和已提交删除通过持久化文件记录在启动/维护时恢复清理。缓存转为持久成品时也保留可恢复的源/目标状态。删除不会在数据库事务成功前移除图片文件;清理失败会留下可重试记录,设置页显示待清理数量。清理记录不是照片备份,不会保留已删除图片供恢复。
|
||||||
|
|
||||||
|
设置页显示数据盘总容量和可用容量;可用低于 512 MiB 或总容量的 5% 时提示空间不足。容量提示不自动开启清理策略,也不自动删除来源图片或成品。
|
||||||
|
|||||||
+478
-2
@@ -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": {
|
"components": {
|
||||||
@@ -22012,7 +22456,8 @@
|
|||||||
"webdav",
|
"webdav",
|
||||||
"google-drive",
|
"google-drive",
|
||||||
"alipan",
|
"alipan",
|
||||||
"baidu"
|
"baidu",
|
||||||
|
"uploads"
|
||||||
],
|
],
|
||||||
"title": "Kind"
|
"title": "Kind"
|
||||||
},
|
},
|
||||||
@@ -22192,7 +22637,8 @@
|
|||||||
"webdav",
|
"webdav",
|
||||||
"google-drive",
|
"google-drive",
|
||||||
"alipan",
|
"alipan",
|
||||||
"baidu"
|
"baidu",
|
||||||
|
"uploads"
|
||||||
],
|
],
|
||||||
"title": "Kind"
|
"title": "Kind"
|
||||||
},
|
},
|
||||||
@@ -23757,6 +24203,36 @@
|
|||||||
"enabled"
|
"enabled"
|
||||||
],
|
],
|
||||||
"title": "LibraryModeInput"
|
"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": {
|
"securitySchemes": {
|
||||||
|
|||||||
@@ -425,7 +425,9 @@ def create_app(settings=None, start_worker=True):
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
try:
|
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:
|
if source.width * source.height > 25_000_000:
|
||||||
raise ValueError()
|
raise ValueError()
|
||||||
if source.format not in {"JPEG", "PNG", "WEBP"}:
|
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")
|
thumb = cached.with_name(cached.stem + "-thumb.jpg")
|
||||||
from PIL import Image
|
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
|
# The 430px cards need up to 860px on 2x displays. Replace the
|
||||||
# existing cache in place so old 300px previews are upgraded.
|
# existing cache in place so old 300px previews are upgraded.
|
||||||
expected_edge = min(860, max(img.size))
|
expected_edge = min(860, max(img.size))
|
||||||
regenerate = not thumb.exists()
|
regenerate = not thumb.exists()
|
||||||
if not regenerate:
|
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
|
regenerate = max(existing.size) != expected_edge
|
||||||
if regenerate:
|
if regenerate:
|
||||||
img.thumbnail((860, 860), Image.Resampling.LANCZOS)
|
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")
|
return FileResponse(thumb, media_type="image/jpeg")
|
||||||
from PIL import Image
|
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(
|
media_type = Image.MIME.get(
|
||||||
original.format or "", "application/octet-stream"
|
original.format or "", "application/octet-stream"
|
||||||
)
|
)
|
||||||
@@ -575,11 +577,11 @@ def create_app(settings=None, start_worker=True):
|
|||||||
if len(data) > settings.max_bytes:
|
if len(data) > settings.max_bytes:
|
||||||
raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB")
|
raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB")
|
||||||
name = Path(file.filename or "image").name[:120]
|
name = Path(file.filename or "image").name[:120]
|
||||||
asset = assets.put(
|
with assets.creating(
|
||||||
await run_in_threadpool(convert, data, fit, profile),
|
await run_in_threadpool(convert, data, fit, profile),
|
||||||
settings.sanitize(name),
|
settings.sanitize(name),
|
||||||
kind="cache",
|
kind="cache",
|
||||||
)
|
) as asset:
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO asset_metadata(asset_id,origin) VALUES(?,?)",
|
"INSERT INTO asset_metadata(asset_id,origin) VALUES(?,?)",
|
||||||
(asset["asset_id"], "manual"),
|
(asset["asset_id"], "manual"),
|
||||||
@@ -620,7 +622,7 @@ def create_app(settings=None, start_worker=True):
|
|||||||
dependencies=[Depends(require("images:delete"))],
|
dependencies=[Depends(require("images:delete"))],
|
||||||
)
|
)
|
||||||
def delete_asset(asset_id: str):
|
def delete_asset(asset_id: str):
|
||||||
path = assets.path(asset_id)
|
assets.path(asset_id)
|
||||||
if db.one(
|
if db.one(
|
||||||
"SELECT id FROM assets WHERE id=? AND kind IN ('source-images','renditions')",
|
"SELECT id FROM assets WHERE id=? AND kind IN ('source-images','renditions')",
|
||||||
(asset_id,),
|
(asset_id,),
|
||||||
@@ -645,14 +647,7 @@ def create_app(settings=None, start_worker=True):
|
|||||||
"Asset is referenced by a task, template or pending operation",
|
"Asset is referenced by a task, template or pending operation",
|
||||||
409,
|
409,
|
||||||
)
|
)
|
||||||
path.unlink(missing_ok=True)
|
service.libraries.remove_asset(asset_id)
|
||||||
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,))
|
|
||||||
for row in db.all(
|
for row in db.all(
|
||||||
"SELECT key,payload FROM app_config WHERE key LIKE 'wrist-thumbnail:%'"
|
"SELECT key,payload FROM app_config WHERE key LIKE 'wrist-thumbnail:%'"
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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 fastapi.responses import Response
|
||||||
from pydantic import Field
|
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 ErrorCode as E
|
||||||
from pigway_frame_flow.errors import FlowError
|
from pigway_frame_flow.errors import FlowError
|
||||||
from pigway_frame_flow.models import ImageExclusion, ImageStar, StrictModel
|
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
|
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}$")
|
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):
|
class ImportInput(StrictModel):
|
||||||
item_id: str = Field(min_length=1, max_length=2048)
|
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()
|
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(
|
@app.get(
|
||||||
"/api/v1/source-libraries/{source_id}/images",
|
"/api/v1/source-libraries/{source_id}/images",
|
||||||
dependencies=[Depends(require("images:read"))],
|
dependencies=[Depends(require("images:read"))],
|
||||||
@@ -183,7 +230,7 @@ def install_libraries(app, service):
|
|||||||
|
|
||||||
@app.put(
|
@app.put(
|
||||||
"/api/v1/source-images/{picture_id}/star",
|
"/api/v1/source-images/{picture_id}/star",
|
||||||
dependencies=[Depends(require("admin"))],
|
dependencies=[Depends(require("images:write"))],
|
||||||
)
|
)
|
||||||
def star(picture_id: str, payload: ImageStar):
|
def star(picture_id: str, payload: ImageStar):
|
||||||
picture = libraries.picture(picture_id)
|
picture = libraries.picture(picture_id)
|
||||||
@@ -193,7 +240,7 @@ def install_libraries(app, service):
|
|||||||
|
|
||||||
@app.put(
|
@app.put(
|
||||||
"/api/v1/source-images/{picture_id}/exclusion",
|
"/api/v1/source-images/{picture_id}/exclusion",
|
||||||
dependencies=[Depends(require("admin"))],
|
dependencies=[Depends(require("images:write"))],
|
||||||
)
|
)
|
||||||
def exclude(picture_id: str, payload: ImageExclusion):
|
def exclude(picture_id: str, payload: ImageExclusion):
|
||||||
picture = libraries.picture(picture_id)
|
picture = libraries.picture(picture_id)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -105,9 +106,12 @@ def session_user(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
def install_accounts(app, db):
|
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"])
|
@app.post("/api/v1/auth/login", response_model=AccountView, tags=["Accounts"])
|
||||||
def login(payload: Login, request: Request, response: Response):
|
def login(payload: Login, request: Request, response: Response):
|
||||||
with db.lock:
|
with login_lock:
|
||||||
# Reject login CSRF before checking credentials. Never trust forwarded hosts.
|
# Reject login CSRF before checking credentials. Never trust forwarded hosts.
|
||||||
origin = request.headers.get("origin")
|
origin = request.headers.get("origin")
|
||||||
if request.headers.get("sec-fetch-site") == "cross-site" or (
|
if request.headers.get("sec-fetch-site") == "cross-site" or (
|
||||||
@@ -154,12 +158,28 @@ def install_accounts(app, db):
|
|||||||
(key, failures, time.time() + 300 if failures >= 5 else 0),
|
(key, failures, time.time() + 300 if failures >= 5 else 0),
|
||||||
)
|
)
|
||||||
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
|
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
|
||||||
|
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:
|
for key in identities:
|
||||||
db.execute("DELETE FROM login_attempts WHERE identity=?", (key,))
|
db.execute("DELETE FROM login_attempts WHERE identity=?", (key,))
|
||||||
if row["password_hash"].startswith("scrypt$"):
|
if row["password_hash"].startswith("scrypt$"):
|
||||||
db.execute(
|
db.execute(
|
||||||
"UPDATE users SET password_hash=? WHERE username=?",
|
"UPDATE users SET password_hash=? WHERE username=?",
|
||||||
(password_hash(payload.password), row["username"]),
|
(upgraded, row["username"]),
|
||||||
)
|
)
|
||||||
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
|
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
|
||||||
lifetime = 30 * 86400 if payload.remember else 12 * 3600
|
lifetime = 30 * 86400 if payload.remember else 12 * 3600
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ bearer = HTTPBearer(auto_error=False)
|
|||||||
ALL_SCOPES = [
|
ALL_SCOPES = [
|
||||||
"devices:read",
|
"devices:read",
|
||||||
"images:read",
|
"images:read",
|
||||||
|
"images:write",
|
||||||
"images:delete",
|
"images:delete",
|
||||||
"display:write",
|
"display:write",
|
||||||
"templates:read",
|
"templates:read",
|
||||||
|
|||||||
@@ -164,6 +164,13 @@ class Libraries:
|
|||||||
return self.binding(terminal_id)
|
return self.binding(terminal_id)
|
||||||
|
|
||||||
def ingest(self, source_id, item_id, data, name, caption):
|
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(
|
digest = hashlib.sha256(
|
||||||
data + b"\0" + encode(caption or {}).encode()
|
data + b"\0" + encode(caption or {}).encode()
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
@@ -175,7 +182,7 @@ class Libraries:
|
|||||||
return self.picture(row["id"])
|
return self.picture(row["id"])
|
||||||
# Normalize EXIF and JPEG storage without resizing or adding decorations.
|
# Normalize EXIF and JPEG storage without resizing or adding decorations.
|
||||||
try:
|
try:
|
||||||
with Image.open(BytesIO(data)) as image:
|
with Image.open(BytesIO(data), formats=("JPEG", "PNG", "WEBP")) as image:
|
||||||
if (
|
if (
|
||||||
image.format not in {"JPEG", "PNG", "WEBP"}
|
image.format not in {"JPEG", "PNG", "WEBP"}
|
||||||
or image.width * image.height > MAX_IMAGE_PIXELS
|
or image.width * image.height > MAX_IMAGE_PIXELS
|
||||||
@@ -187,7 +194,7 @@ class Libraries:
|
|||||||
normalized = convert(data, "cover", size=size, quality=95)
|
normalized = convert(data, "cover", size=size, quality=95)
|
||||||
except (OSError, ValueError, Image.DecompressionBombError):
|
except (OSError, ValueError, Image.DecompressionBombError):
|
||||||
raise FlowError(E.INVALID_REQUEST, "Invalid source image") from None
|
raise FlowError(E.INVALID_REQUEST, "Invalid source image") from None
|
||||||
asset = self.service.assets.put(normalized, name, "source-images")
|
with self.service.assets.creating(normalized, name, "source-images") as asset:
|
||||||
self.db.execute(
|
self.db.execute(
|
||||||
"INSERT INTO source_images VALUES(?,?,?,?,?,?)",
|
"INSERT INTO source_images VALUES(?,?,?,?,?,?)",
|
||||||
(
|
(
|
||||||
@@ -208,7 +215,9 @@ class Libraries:
|
|||||||
"type": "library_source",
|
"type": "library_source",
|
||||||
"source_id": source_id,
|
"source_id": source_id,
|
||||||
"item_id": item_id,
|
"item_id": item_id,
|
||||||
"source_rendition": "preview"
|
"source_rendition": "upload"
|
||||||
|
if item_id.startswith("upload:")
|
||||||
|
else "preview"
|
||||||
if self.service.photo_sources.get(source_id).kind
|
if self.service.photo_sources.get(source_id).kind
|
||||||
== "synology-photos"
|
== "synology-photos"
|
||||||
else "source",
|
else "source",
|
||||||
@@ -342,11 +351,11 @@ class Libraries:
|
|||||||
):
|
):
|
||||||
return version
|
return version
|
||||||
data = self.service.assets.path(picture_id).read_bytes()
|
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),
|
self.render(data, picture["caption"], plan),
|
||||||
picture["name"].removesuffix(".jpg") + "-" + plan.name,
|
picture["name"].removesuffix(".jpg") + "-" + plan.name,
|
||||||
"renditions",
|
"renditions",
|
||||||
)
|
) as asset:
|
||||||
self.db.execute(
|
self.db.execute(
|
||||||
"INSERT INTO library_versions VALUES(?,?,?,?,?,?)",
|
"INSERT INTO library_versions VALUES(?,?,?,?,?,?)",
|
||||||
(
|
(
|
||||||
@@ -642,12 +651,20 @@ class Libraries:
|
|||||||
409,
|
409,
|
||||||
)
|
)
|
||||||
|
|
||||||
def remove_asset(self, asset_id):
|
def remove_assets(self, asset_ids):
|
||||||
self.service.assets.path(asset_id).unlink(missing_ok=True)
|
with self.db.transaction():
|
||||||
self.db.execute("DELETE FROM library_versions WHERE asset_id=?", (asset_id,))
|
for asset_id in asset_ids:
|
||||||
self.db.execute("DELETE FROM terminal_deliveries WHERE asset_id=?", (asset_id,))
|
row = self.db.one("SELECT kind FROM assets WHERE id=?", (asset_id,))
|
||||||
self.db.execute("DELETE FROM source_images 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 [
|
for table, key in [
|
||||||
|
("library_versions", "asset_id"),
|
||||||
|
("terminal_deliveries", "asset_id"),
|
||||||
|
("source_images", "id"),
|
||||||
("asset_sources", "asset_id"),
|
("asset_sources", "asset_id"),
|
||||||
("asset_metadata", "asset_id"),
|
("asset_metadata", "asset_id"),
|
||||||
("assets", "id"),
|
("assets", "id"),
|
||||||
@@ -657,6 +674,10 @@ class Libraries:
|
|||||||
"DELETE FROM photo_preferences WHERE source_id='@assets' AND item_id=?",
|
"DELETE FROM photo_preferences WHERE source_id='@assets' AND item_id=?",
|
||||||
(asset_id,),
|
(asset_id,),
|
||||||
)
|
)
|
||||||
|
self.service.assets.recover_files()
|
||||||
|
|
||||||
|
def remove_asset(self, asset_id):
|
||||||
|
self.remove_assets([asset_id])
|
||||||
|
|
||||||
def delete_picture(self, picture_id):
|
def delete_picture(self, picture_id):
|
||||||
with self.service.lock:
|
with self.service.lock:
|
||||||
@@ -670,8 +691,7 @@ class Libraries:
|
|||||||
] + [picture_id]
|
] + [picture_id]
|
||||||
for asset_id in ids:
|
for asset_id in ids:
|
||||||
self.check_asset_references(asset_id)
|
self.check_asset_references(asset_id)
|
||||||
for asset_id in ids:
|
self.remove_assets(ids)
|
||||||
self.remove_asset(asset_id)
|
|
||||||
|
|
||||||
def check_source_delete(self, source_id):
|
def check_source_delete(self, source_id):
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -327,9 +327,9 @@ class DisplayService:
|
|||||||
if isinstance(content, TemplateContent):
|
if isinstance(content, TemplateContent):
|
||||||
values = self.renderer.resolve(content.template, content.data)
|
values = self.renderer.resolve(content.template, content.data)
|
||||||
data = self.renderer.render(content.template, values, content.profile)
|
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"
|
data, content.template + ".jpg", "cache" if staging else "generated"
|
||||||
)
|
) as asset:
|
||||||
source = {
|
source = {
|
||||||
"type": "template",
|
"type": "template",
|
||||||
"template": content.template,
|
"template": content.template,
|
||||||
@@ -478,9 +478,9 @@ class DisplayService:
|
|||||||
"UPDATE operations SET result=? WHERE id=?",
|
"UPDATE operations SET result=? WHERE id=?",
|
||||||
(encode({"photo_attempts": attempts}), operation_id),
|
(encode({"photo_attempts": attempts}), operation_id),
|
||||||
)
|
)
|
||||||
asset = self.assets.put(
|
with self.assets.creating(
|
||||||
rendered, name, "cache" if staging else "images"
|
rendered, name, "cache" if staging else "images"
|
||||||
)
|
) as asset:
|
||||||
self.db.execute(
|
self.db.execute(
|
||||||
"INSERT OR REPLACE INTO asset_sources VALUES(?,?)",
|
"INSERT OR REPLACE INTO asset_sources VALUES(?,?)",
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -155,7 +155,9 @@ def install_ai_images(app, service):
|
|||||||
image_data = base64.b64decode(result[0]["b64_json"], validate=True)
|
image_data = base64.b64decode(result[0]["b64_json"], validate=True)
|
||||||
else:
|
else:
|
||||||
image_data = service.assets.fetch_url(result[0]["url"])
|
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:
|
if image.width * image.height > MAX_IMAGE_PIXELS:
|
||||||
raise ValueError()
|
raise ValueError()
|
||||||
rgba = ImageOps.exif_transpose(image).convert("RGBA")
|
rgba = ImageOps.exif_transpose(image).convert("RGBA")
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ def install_extensions(app, service):
|
|||||||
if len(data) > settings.max_bytes:
|
if len(data) > settings.max_bytes:
|
||||||
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
|
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
|
||||||
try:
|
try:
|
||||||
with Image.open(BytesIO(data)) as image:
|
with Image.open(
|
||||||
|
BytesIO(data), formats=("JPEG", "PNG", "WEBP")
|
||||||
|
) as image:
|
||||||
if (
|
if (
|
||||||
image.format not in {"JPEG", "PNG", "WEBP"}
|
image.format not in {"JPEG", "PNG", "WEBP"}
|
||||||
or image.width * image.height > MAX_IMAGE_PIXELS
|
or image.width * image.height > MAX_IMAGE_PIXELS
|
||||||
@@ -92,7 +94,7 @@ def install_extensions(app, service):
|
|||||||
raise FlowError(E.INVALID_REQUEST, "Invalid image") from None
|
raise FlowError(E.INVALID_REQUEST, "Invalid image") from None
|
||||||
result = []
|
result = []
|
||||||
for data, name in prepared:
|
for data, name in prepared:
|
||||||
asset = service.assets.put(data, name)
|
with service.assets.creating(data, name) as asset:
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO asset_metadata(asset_id,origin) VALUES(?,'manual')",
|
"INSERT INTO asset_metadata(asset_id,origin) VALUES(?,'manual')",
|
||||||
(asset["asset_id"],),
|
(asset["asset_id"],),
|
||||||
@@ -220,6 +222,10 @@ def install_extensions(app, service):
|
|||||||
for row in db.all("SELECT * FROM clients")
|
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(
|
@app.post(
|
||||||
"/api/v1/clients",
|
"/api/v1/clients",
|
||||||
status_code=201,
|
status_code=201,
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ def prepare(service, content, device_id=None):
|
|||||||
if source.avoid_duplicates and quality["fingerprint"] in hashes:
|
if source.avoid_duplicates and quality["fingerprint"] in hashes:
|
||||||
continue
|
continue
|
||||||
hashes.update([quality["sha256"], quality["fingerprint"]])
|
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 = ImageOps.exif_transpose(image).convert("RGB")
|
||||||
photo.thumbnail((2400, 2400))
|
photo.thumbnail((2400, 2400))
|
||||||
output = BytesIO()
|
output = BytesIO()
|
||||||
|
|||||||
@@ -126,7 +126,9 @@ class LayoutStudio:
|
|||||||
plan = local_plan(request)
|
plan = local_plan(request)
|
||||||
metadata = []
|
metadata = []
|
||||||
for asset_id in request.asset_ids:
|
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(
|
metadata.append(
|
||||||
{"asset_id": asset_id, "width": image.width, "height": image.height}
|
{"asset_id": asset_id, "width": image.width, "height": image.height}
|
||||||
)
|
)
|
||||||
@@ -203,7 +205,10 @@ class LayoutStudio:
|
|||||||
plan = LayoutPlan.model_validate(data["layout"])
|
plan = LayoutPlan.model_validate(data["layout"])
|
||||||
canvas = Image.new("RGB", (plan.width, plan.height), "white")
|
canvas = Image.new("RGB", (plan.width, plan.height), "white")
|
||||||
for region in plan.regions:
|
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.exif_transpose(source).convert("RGB")
|
||||||
image = ImageOps.fit(
|
image = ImageOps.fit(
|
||||||
image,
|
image,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from PIL import Image, ImageFilter, ImageOps, ImageStat
|
|||||||
|
|
||||||
|
|
||||||
def photo_quality(data):
|
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 = ImageOps.exif_transpose(source).convert("L")
|
||||||
image.thumbnail((256, 256))
|
image.thumbnail((256, 256))
|
||||||
brightness = ImageStat.Stat(image).mean[0]
|
brightness = ImageStat.Stat(image).mean[0]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Opt-in local retention, protecting stars, current content and durable references."""
|
"""Opt-in local retention, protecting stars, current content and durable references."""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import time
|
import time
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
@@ -80,7 +81,19 @@ class LocalRetention:
|
|||||||
def stats(self):
|
def stats(self):
|
||||||
rows = self.inspect()
|
rows = self.inspect()
|
||||||
protected = self.protected()
|
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 {
|
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(),
|
"policy": self.config().model_dump(),
|
||||||
"total_bytes": sum(x[2] for x in rows),
|
"total_bytes": sum(x[2] for x in rows),
|
||||||
"asset_count": len(rows),
|
"asset_count": len(rows),
|
||||||
@@ -92,6 +105,7 @@ class LocalRetention:
|
|||||||
return {}
|
return {}
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
self.last = time.monotonic()
|
self.last = time.monotonic()
|
||||||
|
self.service.assets.recover_files()
|
||||||
config = self.config()
|
config = self.config()
|
||||||
if not config.enabled:
|
if not config.enabled:
|
||||||
return {"deleted": 0, "bytes_freed": 0, "disabled": True}
|
return {"deleted": 0, "bytes_freed": 0, "disabled": True}
|
||||||
@@ -101,7 +115,7 @@ class LocalRetention:
|
|||||||
total = sum(x[2] for x in rows)
|
total = sum(x[2] for x in rows)
|
||||||
removed = []
|
removed = []
|
||||||
freed = 0
|
freed = 0
|
||||||
for row, path, size in rows:
|
for row, _path, size in rows:
|
||||||
# Manual uploaded photos are never removed by automatic retention.
|
# Manual uploaded photos are never removed by automatic retention.
|
||||||
if row["id"] in protected or row["kind"] not in ("generated", "cache"):
|
if row["id"] in protected or row["kind"] not in ("generated", "cache"):
|
||||||
continue
|
continue
|
||||||
@@ -119,15 +133,7 @@ class LocalRetention:
|
|||||||
total -= size
|
total -= size
|
||||||
freed += size
|
freed += size
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
path.unlink(missing_ok=True)
|
self.service.libraries.remove_asset(row["id"])
|
||||||
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"],)
|
|
||||||
)
|
|
||||||
result = {"deleted": len(removed), "bytes_freed": freed, "dry_run": dry_run}
|
result = {"deleted": len(removed), "bytes_freed": freed, "dry_run": dry_run}
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
self.service.db.execute(
|
self.service.db.execute(
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ def dimensions(root: Path, path: Path):
|
|||||||
with os.fdopen(fd, "rb") as stream:
|
with os.fdopen(fd, "rb") as stream:
|
||||||
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
||||||
return None
|
return None
|
||||||
with Image.open(stream) as image:
|
with Image.open(stream, formats=("JPEG", "PNG", "WEBP")) as image:
|
||||||
width, height = image.size
|
width, height = image.size
|
||||||
if image.getexif().get(274, 1) in (5, 6, 7, 8):
|
if image.getexif().get(274, 1) in (5, 6, 7, 8):
|
||||||
width, height = height, width
|
width, height = height, width
|
||||||
|
|||||||
@@ -30,7 +30,13 @@ from pigway_frame_flow.storage.database import encode
|
|||||||
class PhotoSourceInput(StrictModel):
|
class PhotoSourceInput(StrictModel):
|
||||||
name: str = Field(min_length=1, max_length=120)
|
name: str = Field(min_length=1, max_length=120)
|
||||||
kind: Literal[
|
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)
|
location: str = Field(min_length=1, max_length=2048)
|
||||||
nas_root_path: str | None = Field(default=None, max_length=2048, pattern=r"^/")
|
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):
|
def save(self, source_id, source):
|
||||||
self.require_api_source(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:
|
if source.minimum_brightness > source.maximum_brightness:
|
||||||
raise FlowError(E.INVALID_REQUEST, "Brightness range is reversed")
|
raise FlowError(E.INVALID_REQUEST, "Brightness range is reversed")
|
||||||
if source.kind not in ("local", "synology-photos") and (
|
if source.kind not in ("local", "synology-photos") and (
|
||||||
@@ -310,6 +322,20 @@ class PhotoSources:
|
|||||||
self.require_api_source(source)
|
self.require_api_source(source)
|
||||||
if not source.enabled:
|
if not source.enabled:
|
||||||
raise FlowError(E.CONFLICT, "Photo source is disabled", 409)
|
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 = (
|
session_identity = (
|
||||||
self.synology.sid() if source.kind == "synology-photos" else None
|
self.synology.sid() if source.kind == "synology-photos" else None
|
||||||
)
|
)
|
||||||
@@ -610,6 +636,18 @@ class PhotoSources:
|
|||||||
)
|
)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise FlowError(E.NOT_FOUND, "Photo is not in this source", 404)
|
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":
|
if source.kind == "local":
|
||||||
root = self.settings.photo_root.resolve()
|
root = self.settings.photo_root.resolve()
|
||||||
path = root / item["id"]
|
path = root / item["id"]
|
||||||
@@ -688,7 +726,7 @@ class PhotoSources:
|
|||||||
|
|
||||||
from PIL import Image
|
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
|
width, height = image.size
|
||||||
if image.getexif().get(274, 1) in (5, 6, 7, 8):
|
if image.getexif().get(274, 1) in (5, 6, 7, 8):
|
||||||
width, height = height, width
|
width, height = height, width
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class PhotosCache:
|
|||||||
os.fsync(handle.fileno())
|
os.fsync(handle.fileno())
|
||||||
if not 0 < part.stat().st_size <= self.max_bytes:
|
if not 0 < part.stat().st_size <= self.max_bytes:
|
||||||
raise ValueError()
|
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"):
|
if image.format not in ("JPEG", "PNG", "WEBP"):
|
||||||
raise ValueError()
|
raise ValueError()
|
||||||
if image.width * image.height > MAX_IMAGE_PIXELS:
|
if image.width * image.height > MAX_IMAGE_PIXELS:
|
||||||
@@ -69,7 +69,7 @@ class PhotosCache:
|
|||||||
image.verify()
|
image.verify()
|
||||||
# Decode before committing; truncated/compressed-bomb payloads
|
# Decode before committing; truncated/compressed-bomb payloads
|
||||||
# must never become a reusable cache hit.
|
# 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()
|
image.load()
|
||||||
part.replace(path)
|
part.replace(path)
|
||||||
self.downloads += 1
|
self.downloads += 1
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import base64
|
import base64
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import threading
|
||||||
|
from contextlib import contextmanager
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -27,7 +31,7 @@ def convert(
|
|||||||
quality=93,
|
quality=93,
|
||||||
):
|
):
|
||||||
try:
|
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"}:
|
if source.format not in {"JPEG", "PNG", "WEBP"}:
|
||||||
raise ValueError()
|
raise ValueError()
|
||||||
if source.width * source.height > MAX_IMAGE_PIXELS:
|
if source.width * source.height > MAX_IMAGE_PIXELS:
|
||||||
@@ -82,17 +86,99 @@ def convert(
|
|||||||
class Assets:
|
class Assets:
|
||||||
def __init__(self, db, settings):
|
def __init__(self, db, settings):
|
||||||
self.db, self.settings = 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
|
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"
|
name = Path(name).stem[:110] + ".jpg"
|
||||||
asset_id = uuid4().hex
|
asset_id = uuid4().hex
|
||||||
(self.settings.data_dir / kind / (asset_id + ".jpg")).write_bytes(data)
|
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(
|
self.db.execute(
|
||||||
"INSERT INTO assets VALUES(?,?,?,?)", (asset_id, name, kind, now_iso())
|
"INSERT INTO assets VALUES(?,?,?,?)",
|
||||||
|
(asset_id, name, kind, now_iso()),
|
||||||
)
|
)
|
||||||
return self.info(asset_id)
|
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):
|
def record_origin(self, asset_id, operation):
|
||||||
job = self.db.one(
|
job = self.db.one(
|
||||||
@@ -123,6 +209,7 @@ class Assets:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def promote(self, asset_id):
|
def promote(self, asset_id):
|
||||||
|
with self.file_lock:
|
||||||
row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
|
row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
|
||||||
if row and row["kind"] == "cache":
|
if row and row["kind"] == "cache":
|
||||||
source = self.db.one(
|
source = self.db.one(
|
||||||
@@ -130,12 +217,27 @@ class Assets:
|
|||||||
)
|
)
|
||||||
kind = (
|
kind = (
|
||||||
"generated"
|
"generated"
|
||||||
if source and json.loads(source["payload"]).get("type") == "template"
|
if source
|
||||||
|
and json.loads(source["payload"]).get("type") == "template"
|
||||||
else "images"
|
else "images"
|
||||||
)
|
)
|
||||||
target = self.settings.data_dir / kind / (asset_id + ".jpg")
|
target = self.settings.data_dir / kind / (asset_id + ".jpg")
|
||||||
self.path(asset_id).replace(target)
|
self.recover_files()
|
||||||
self.db.execute("UPDATE assets SET kind=? WHERE id=?", (kind, asset_id))
|
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)
|
return self.info(asset_id)
|
||||||
|
|
||||||
def info(self, asset_id):
|
def info(self, asset_id):
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def render_captioned_photo(
|
|||||||
quality=quality,
|
quality=quality,
|
||||||
)
|
)
|
||||||
canvas = Image.new("RGB", size, "white")
|
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))
|
canvas.paste(photo, (0, 0))
|
||||||
draw = ImageDraw.Draw(canvas)
|
draw = ImageDraw.Draw(canvas)
|
||||||
values = [
|
values = [
|
||||||
|
|||||||
@@ -177,7 +177,9 @@ class TemplateRenderer:
|
|||||||
elif block["kind"] == "photo":
|
elif block["kind"] == "photo":
|
||||||
asset_id = values.get("photo_id")
|
asset_id = values.get("photo_id")
|
||||||
if asset_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))
|
img.paste(ImageOps.fit(photo.convert("RGB"), (w, h)), (x, y))
|
||||||
else:
|
else:
|
||||||
draw.rectangle((x, y, x + w, y + h), outline="black", width=5)
|
draw.rectangle((x, y, x + w, y + h), outline="black", width=5)
|
||||||
|
|||||||
@@ -64,14 +64,7 @@ class UploadRetries:
|
|||||||
)
|
)
|
||||||
# Only disposable generated staging files are owned by this recovery.
|
# Only disposable generated staging files are owned by this recovery.
|
||||||
if row and row["kind"] == "cache" and not (meta and meta["starred"]):
|
if row and row["kind"] == "cache" and not (meta and meta["starred"]):
|
||||||
self.service.assets.path(asset_id).unlink(missing_ok=True)
|
self.service.libraries.remove_asset(asset_id)
|
||||||
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,))
|
|
||||||
|
|
||||||
def tick(self, current):
|
def tick(self, current):
|
||||||
for job in self.db.all("SELECT * FROM jobs WHERE enabled=1"):
|
for job in self.db.all("SELECT * FROM jobs WHERE enabled=1"):
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ def encode(value):
|
|||||||
class Database:
|
class Database:
|
||||||
def __init__(self, path):
|
def __init__(self, path):
|
||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
|
self._transaction_depth = 0
|
||||||
self.conn = sqlite3.connect(path, check_same_thread=False)
|
self.conn = sqlite3.connect(path, check_same_thread=False)
|
||||||
self.conn.row_factory = sqlite3.Row
|
self.conn.row_factory = sqlite3.Row
|
||||||
self.conn.executescript("""
|
self.conn.executescript("""
|
||||||
@@ -68,16 +69,31 @@ class Database:
|
|||||||
@contextmanager
|
@contextmanager
|
||||||
def transaction(self):
|
def transaction(self):
|
||||||
with self.lock:
|
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:
|
try:
|
||||||
yield self.conn
|
yield self.conn
|
||||||
|
if nested:
|
||||||
|
self.conn.execute("RELEASE SAVEPOINT " + savepoint)
|
||||||
|
else:
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
except Exception:
|
except BaseException:
|
||||||
|
if nested:
|
||||||
|
self.conn.execute("ROLLBACK TO SAVEPOINT " + savepoint)
|
||||||
|
self.conn.execute("RELEASE SAVEPOINT " + savepoint)
|
||||||
|
else:
|
||||||
self.conn.rollback()
|
self.conn.rollback()
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
self._transaction_depth -= 1
|
||||||
|
|
||||||
def execute(self, query, args=()):
|
def execute(self, query, args=()):
|
||||||
with self.lock, self.conn:
|
with self.lock:
|
||||||
|
if self._transaction_depth:
|
||||||
|
return self.conn.execute(query, args)
|
||||||
|
with self.conn:
|
||||||
return self.conn.execute(query, args)
|
return self.conn.execute(query, args)
|
||||||
|
|
||||||
def one(self, query, args=()):
|
def one(self, query, args=()):
|
||||||
|
|||||||
@@ -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;}
|
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);
|
window.addEventListener('resize',revealCurrentNavigation);
|
||||||
function deviceNameForId(id){return deviceDisplayName(state.devices.find(d=>d.id===id)||{id,name:id});}
|
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 operationClientLabel(client){return client==='admin'?tr('管理员','Administrator'):client==='scheduler'?tr('自动调度','Scheduler'):client==='library-terminal'?tr('来源图库输出','Source-library output'):client;}
|
||||||
function operationResultHTML(o){
|
function operationResultHTML(o){
|
||||||
const r=o.result||{},parts=[];
|
const r=o.result||{},parts=[];
|
||||||
@@ -48,7 +48,8 @@ function operationResultHTML(o){
|
|||||||
return parts.join('')||'—';
|
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 deviceDisplayName(device){const name=device.name||device.id;return device.id==='living-room'&&['living-room','Living room'].includes(name)?t('livingRoom'):name;}
|
||||||
function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).filter(([id])=>id!=='source-browser').map(([id,icon])=>`<button data-page="${id}" class="${(id===state.page||id==='gallery'&&state.page==='source-browser')?'active':''}" ${(id===state.page||id==='gallery'&&state.page==='source-browser')?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);const watchSelected=state.page==='terminal-gallery'&&state.tab==='watch';$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${!watchSelected&&d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('')+`<option value="@watch" ${watchSelected?'selected':''}>${tr('手表 · 410×502','Watch · 410×502')}</option>`;revealCurrentNavigation();}
|
function renderFooter(){const v=state.versionInfo;if(v)$('#footer').innerHTML=`<span>PIGWay Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`;}
|
||||||
|
function shell(){renderFooter();document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).filter(([id])=>id!=='source-browser').map(([id,icon])=>`<button data-page="${id}" class="${(id===state.page||id==='gallery'&&state.page==='source-browser')?'active':''}" ${(id===state.page||id==='gallery'&&state.page==='source-browser')?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#device').hidden=['gallery','terminal-gallery','source-browser','photo-sources','settings','connections','history'].includes(state.page);const watchSelected=state.page==='terminal-gallery'&&state.tab==='watch';$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${!watchSelected&&d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('')+`<option value="@watch" ${watchSelected?'selected':''}>${tr('手表 · 410×502','Watch · 410×502')}</option>`;revealCurrentNavigation();}
|
||||||
function imageLoading(img){
|
function imageLoading(img){
|
||||||
if(img.parentElement?.classList.contains('image-loading'))return img.parentElement;
|
if(img.parentElement?.classList.contains('image-loading'))return img.parentElement;
|
||||||
const box=document.createElement('div');box.className='image-loading';
|
const box=document.createElement('div');box.className='image-loading';
|
||||||
@@ -123,7 +124,17 @@ const views={
|
|||||||
connections:()=>connectionsPage(),
|
connections:()=>connectionsPage(),
|
||||||
'photo-sources':async()=>{ $('#main').innerHTML='';await photoSourcesPanel(); },
|
'photo-sources':async()=>{ $('#main').innerHTML='';await photoSourcesPanel(); },
|
||||||
async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;state.devices=state.devices.map(x=>x.id===d.id?{...x,name:d.name}:x);shell();const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`<div class="notice warning">${t('lowBattery')}</div>`:''}<section class="card hero"><div class="hero-info"><span class="badge ${d.status==='offline'?'bad':''}">● ${t(d.status).toUpperCase()}</span><h2>${esc(d.name)}</h2><p>${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}</p><div class="actions">${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}</div><p class="hint" style="margin-top:28px">${t('refreshInterval')}</p></div><div class="frame-demo"></div></section><div class="stats">${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['currentImageId',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`<div class="card stat"><div class="label">${t(l)}</div><div class="value">${esc(v)}</div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('device')}</h3><div class="detail-grid">${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('modelSource',t(d.model_verified?'modelVerified':'modelConfigured'))}</div></section><section class="card"><h3>${t('cloudRotation')}</h3><div class="detail-grid">${detail('playbackMode',s.mode?t(s.mode):'—')}${detail('switchInterval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}</div><p class="hint">${t('rotationNotice')}</p><a href="#playlists" id="go-playlists">${t('playlists')} →</a></section></div>`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};},
|
async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;state.devices=state.devices.map(x=>x.id===d.id?{...x,name:d.name}:x);shell();const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`<div class="notice warning">${t('lowBattery')}</div>`:''}<section class="card hero"><div class="hero-info"><span class="badge ${d.status==='offline'?'bad':''}">● ${t(d.status).toUpperCase()}</span><h2>${esc(d.name)}</h2><p>${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}</p><div class="actions">${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}</div><p class="hint" style="margin-top:28px">${t('refreshInterval')}</p></div><div class="frame-demo"></div></section><div class="stats">${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['currentImageId',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`<div class="card stat"><div class="label">${t(l)}</div><div class="value">${esc(v)}</div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('device')}</h3><div class="detail-grid">${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('modelSource',t(d.model_verified?'modelVerified':'modelConfigured'))}</div></section><section class="card"><h3>${t('cloudRotation')}</h3><div class="detail-grid">${detail('playbackMode',s.mode?t(s.mode):'—')}${detail('switchInterval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}</div><p class="hint">${t('rotationNotice')}</p><a href="#playlists" id="go-playlists">${t('playlists')} →</a></section></div>`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};},
|
||||||
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);toast(tr('已保存到服务器,可用于模板与任务','Saved on server for templates and tasks'));});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
|
async upload(epoch){
|
||||||
|
state.preview=null;
|
||||||
|
const sources=await api('/api/v1/source-libraries');if(epoch!==state.epoch)return;
|
||||||
|
const options=[...sources];if(!options.some(x=>x.id==='manual-uploads'))options.push({id:'manual-uploads',name:tr('手动上传','Manual uploads')});
|
||||||
|
const selected=options.some(x=>x.id===libraryUI.source)?libraryUI.source:'manual-uploads';
|
||||||
|
$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="field"><label for="upload-source">${tr('保存到来源图库','Save in source library')}</label><select id="upload-source">${librarySelectOptions(options,selected,'')}</select><p class="hint">${tr('保存无叠加的源画面,不写回 NAS 或网盘。生成尺寸、裁切与文字由图库中的生成方案决定。','Save source pixels without overlays, without writing back to NAS or cloud drives. Library generation plans control output size, crop and captions.')}</p></div><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['cover','contain','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button><button id="view-upload-library" hidden>${tr('查看来源图库','View source library')}</button></div><p class="hint">${tr('预览仅用于直接显示到相框;保存到图库会使用源画面。','Preview settings apply to direct frame display; saving in the library uses source pixels.')}</p></section></div>`;
|
||||||
|
bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);const preview=await api('/api/v1/preview',{method:'POST',body:form});if(!$('#file')||$('#file').files[0]!==file)return;state.preview=preview;state.preview.uploadFile=file;$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});
|
||||||
|
for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=!$('#file').files[0];$('#display-upload').disabled=true;$('#view-upload-library').hidden=true;};
|
||||||
|
bind('save-local',async()=>{const sourceId=$('#upload-source').value,file=state.preview?.uploadFile||$('#file').files[0];let picture;if(state.preview?.sourceUrl){picture=await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/upload-url',{method:'POST',body:{url:state.preview.sourceUrl}});}else{if(!file)throw new Error(t('selectFile'));const body=new FormData();body.append('file',file);picture=await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/upload',{method:'POST',body});}libraryUI.source=picture.source_id;libraryUI.plan=null;libraryUI.offset=0;$('#view-upload-library').hidden=false;toast(tr('已保存源画面,可在来源图库按方案生成','Source pixels saved. Generate plan versions in the source library.'));});
|
||||||
|
bind('view-upload-library',()=>navigate('gallery'));bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));
|
||||||
|
},
|
||||||
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
|
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
|
||||||
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
|
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
|
||||||
async history(epoch){const data=await api('/api/v1/operation-history?'+historyQuery());if(epoch!==state.epoch)return;state.historyPageData=data;state.historyPage=data.page;const ops=data.items;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr data-operation-id="${esc(o.operation_id)}"><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(o.action==='library-generate'?tr('按来源绑定终端','Source-bound terminals'):deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${operationResultHTML(o)}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
async history(epoch){const data=await api('/api/v1/operation-history?'+historyQuery());if(epoch!==state.epoch)return;state.historyPageData=data;state.historyPage=data.page;const ops=data.items;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr data-operation-id="${esc(o.operation_id)}"><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(o.action==='library-generate'?tr('按来源绑定终端','Source-bound terminals'):deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${operationResultHTML(o)}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
||||||
@@ -132,7 +143,7 @@ const views={
|
|||||||
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,library_pipeline:j.library_pipeline||false,cleanup_unstarred:j.cleanup_unstarred||false,generate_thumbnail:j.generate_thumbnail||false,thumbnail_plan_id:j.thumbnail_plan_id||null,...changes};}
|
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,library_pipeline:j.library_pipeline||false,cleanup_unstarred:j.cleanup_unstarred||false,generate_thumbnail:j.generate_thumbnail||false,thumbnail_plan_id:j.thumbnail_plan_id||null,...changes};}
|
||||||
async function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`<h2>${t('playlists')}</h2><form id="playlist-form"><div class="field"><label for="playlist-name">${t('name')}</label><input id="playlist-name" required maxlength="120" value="${esc(p.name)}"></div><div class="field"><label for="playlist-images">${t('imageIds')}</label><input id="playlist-images" required value="${esc(p.image_ids.join(','))}" placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval">${t('interval')}</label><input id="playlist-interval" type="number" min="5" max="10080" value="${p.interval_minutes}"></div><div class="field"><label for="playlist-mode">${t('playbackMode')}</label><select id="playlist-mode"><option value="queue" ${p.mode==='queue'?'selected':''}>${t('queue')}</option><option value="random" ${p.mode==='random'?'selected':''}>${t('random')}</option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});await playlistImagePicker(p);}
|
async function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`<h2>${t('playlists')}</h2><form id="playlist-form"><div class="field"><label for="playlist-name">${t('name')}</label><input id="playlist-name" required maxlength="120" value="${esc(p.name)}"></div><div class="field"><label for="playlist-images">${t('imageIds')}</label><input id="playlist-images" required value="${esc(p.image_ids.join(','))}" placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval">${t('interval')}</label><input id="playlist-interval" type="number" min="5" max="10080" value="${p.interval_minutes}"></div><div class="field"><label for="playlist-mode">${t('playbackMode')}</label><select id="playlist-mode"><option value="queue" ${p.mode==='queue'?'selected':''}>${t('queue')}</option><option value="random" ${p.mode==='random'?'selected':''}>${t('random')}</option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});await playlistImagePicker(p);}
|
||||||
$('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme);
|
$('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme);
|
||||||
applyTheme();shell();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>$('#footer').innerHTML=`<span>PIGWay Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`).catch(()=>{});
|
applyTheme();shell();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>{state.versionInfo=v;renderFooter();}).catch(()=>{});
|
||||||
setInterval(()=>{if(state.token&&state.page==='dashboard'&&!$('#modal').open)navigate();},60000);
|
setInterval(()=>{if(state.token&&state.page==='dashboard'&&!$('#modal').open)navigate();},60000);
|
||||||
|
|
||||||
// Keep Cron available for exceptional schedules, but make ordinary schedules form-based.
|
// Keep Cron available for exceptional schedules, but make ordinary schedules form-based.
|
||||||
|
|||||||
@@ -26,16 +26,16 @@ async function layoutStudioPage(){
|
|||||||
}
|
}
|
||||||
async function connectionsPage(){
|
async function connectionsPage(){
|
||||||
$('#main').innerHTML=`<div class="notice">${tr('可选连接不会影响 NAS 照片和现有设备。账号密钥保存在受限配置文件中,外部客户端密钥仅显示一次。','Optional connections do not affect NAS photos or existing devices. Provider keys use restricted config files; client tokens are shown once.')}</div>`;
|
$('#main').innerHTML=`<div class="notice">${tr('可选连接不会影响 NAS 照片和现有设备。账号密钥保存在受限配置文件中,外部客户端密钥仅显示一次。','Optional connections do not affect NAS photos or existing devices. Provider keys use restricted config files; client tokens are shown once.')}</div>`;
|
||||||
await aiSettingsPanel();await aiImagePanel();await storageSettingsPanel();await clientsPanel();await webhooksPanel();await externalProvidersPanel();await mqttPanel();
|
await aiSettingsPanel();await aiImagePanel();await clientsPanel();await webhooksPanel();await externalProvidersPanel();await mqttPanel();
|
||||||
}
|
}
|
||||||
async function aiSettingsPanel(){
|
async function aiSettingsPanel(){
|
||||||
const config=await api('/api/v1/settings/ai-layout');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('AI 排版 · OpenAI 兼容','AI layout · OpenAI compatible')}</h3><p class="hint">${tr('只发送文字、尺寸和排版要求,不发送照片。填写完整 chat/completions 地址;使用服务商的模型名称。','Only text, dimensions and instructions are sent; never photos. Enter the full chat/completions URL and provider model name.')}</p><form id="ai-settings-form"><div class="field"><label for="ai-endpoint">Endpoint</label><input id="ai-endpoint" type="url" value="${esc(config.endpoint)}" placeholder="https://…/v1/chat/completions"></div><div class="field"><label for="ai-model">Model</label><input id="ai-model" value="${esc(config.model)}"></div><div class="field"><label for="ai-key">${tr('API Key(留空保留已有密钥)','API key (blank keeps existing)')}</label><input id="ai-key" type="password" autocomplete="off"><p class="hint">${t('apiKeyConfigured')}: ${t(config.key_configured?'yes':'no')}</p></div><button type="submit">${t('save')}</button></form>`;$('#main').append(card);$('#ai-settings-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-layout',{method:'PUT',body:{endpoint:$('#ai-endpoint').value,model:$('#ai-model').value}});const key=$('#ai-key').value;$('#ai-key').value='';if(key)await api('/api/v1/settings/ai-layout/key',{method:'PUT',body:{key}});toast(t('done'));});
|
const config=await api('/api/v1/settings/ai-layout');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('AI 排版 · OpenAI 兼容','AI layout · OpenAI compatible')}</h3><p class="hint">${tr('只发送文字、尺寸和排版要求,不发送照片。填写完整 chat/completions 地址;使用服务商的模型名称。','Only text, dimensions and instructions are sent; never photos. Enter the full chat/completions URL and provider model name.')}</p><form id="ai-settings-form"><div class="field"><label for="ai-endpoint">Endpoint</label><input id="ai-endpoint" type="url" value="${esc(config.endpoint)}" placeholder="https://…/v1/chat/completions"></div><div class="field"><label for="ai-model">Model</label><input id="ai-model" value="${esc(config.model)}"></div><div class="field"><label for="ai-key">${tr('API Key(留空保留已有密钥)','API key (blank keeps existing)')}</label><input id="ai-key" type="password" autocomplete="off"><p class="hint">${t('apiKeyConfigured')}: ${t(config.key_configured?'yes':'no')}</p></div><button type="submit">${t('save')}</button></form>`;$('#main').append(card);$('#ai-settings-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-layout',{method:'PUT',body:{endpoint:$('#ai-endpoint').value,model:$('#ai-model').value}});const key=$('#ai-key').value;$('#ai-key').value='';if(key)await api('/api/v1/settings/ai-layout/key',{method:'PUT',body:{key}});toast(t('done'));});
|
||||||
}
|
}
|
||||||
async function storageSettingsPanel(){
|
async function storageSettingsPanel(){
|
||||||
const data=await api('/api/v1/storage');const p=data.policy;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('本地图库保留策略','Local storage retention')}</h3><p>${data.asset_count} ${t('count')} · ${(data.total_bytes/1024/1024).toFixed(1)} MiB · ${tr('受保护','Protected')}: ${data.protected_count}</p><p class="hint">${tr('仅清理生成图和预览缓存;手动上传、星标、当前/恢复图片以及任务/模板引用会保留。不会删除 NAS 原照片或云端图片。先查看清理预估,再开启自动清理。','Only generated images and preview cache are cleaned. Manual uploads, stars, current/restore content and task/template references are retained. NAS originals and cloud images are untouched. Preview cleanup before enabling it.')}</p><form id="storage-policy"><label class="check-row"><input id="storage-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="form-grid"><div class="field"><label for="storage-days">${tr('生成图保留天数','Generated image retention days')}</label><input id="storage-days" type="number" min="1" value="${p.generated_days}"></div><div class="field"><label for="storage-cache-days">${tr('缓存保留天数','Cache retention days')}</label><input id="storage-cache-days" type="number" min="1" value="${p.cache_days}"></div><div class="field"><label for="storage-size">${tr('容量目标 MiB','Target size MiB')}</label><input id="storage-size" type="number" min="1" value="${p.max_bytes/1024/1024}"></div></div><div class="actions"><button type="submit">${t('save')}</button><button id="storage-preview" type="button">${tr('预估清理','Preview cleanup')}</button></div><p id="storage-estimate"></p></form>`;$('#main').append(card);$('#storage-policy').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/storage/policy',{method:'PUT',body:{enabled:$('#storage-enabled').checked,generated_days:Number($('#storage-days').value),cache_days:Number($('#storage-cache-days').value),max_bytes:Number($('#storage-size').value)*1024*1024}});toast(t('done'));});bind('storage-preview',async()=>{const result=await api('/api/v1/storage/cleanup?dry_run=true',{method:'POST'});$('#storage-estimate').textContent=result.disabled?tr('策略未启用;不会清理','Policy disabled; nothing is removed'):tr('可清理 ','Eligible: ')+result.deleted+' · '+(result.bytes_freed/1024/1024).toFixed(1)+' MiB';});
|
const data=await api('/api/v1/storage');const p=data.policy;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('本地存储与清理','Local storage and cleanup')}</h3>${data.disk?`<p class="${data.disk.low_space?'notice warning':'hint'}">${tr('磁盘可用','Disk available')}: ${(data.disk.free_bytes/1024**3).toFixed(2)} GiB / ${(data.disk.total_bytes/1024**3).toFixed(2)} GiB${data.disk.low_space?tr(';空间不足,请清理无用文件或扩容,来源图片和成品不会自动删除。','; low space. Remove unused files or expand storage. Source pictures and versions are not automatically deleted.'):''}</p>`:''}${data.pending_file_cleanup?`<p class="notice warning">${tr('待重试文件清理','Pending file cleanup')}: ${data.pending_file_cleanup}</p>`:''}<p>${data.asset_count} ${t('count')} · ${(data.total_bytes/1024/1024).toFixed(1)} MiB · ${tr('受保护','Protected')}: ${data.protected_count}</p><p class="hint">${tr('仅清理生成图和预览缓存;手动上传、星标、当前/恢复图片以及任务/模板引用会保留。不会删除 NAS 原照片或云端图片。先查看清理预估,再开启自动清理。','Only generated images and preview cache are cleaned. Manual uploads, stars, current/restore content and task/template references are retained. NAS originals and cloud images are untouched. Preview cleanup before enabling it.')}</p><form id="storage-policy"><label class="check-row"><input id="storage-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="form-grid"><div class="field"><label for="storage-days">${tr('生成图保留天数','Generated image retention days')}</label><input id="storage-days" type="number" min="1" value="${p.generated_days}"></div><div class="field"><label for="storage-cache-days">${tr('缓存保留天数','Cache retention days')}</label><input id="storage-cache-days" type="number" min="1" value="${p.cache_days}"></div><div class="field"><label for="storage-size">${tr('容量目标 MiB','Target size MiB')}</label><input id="storage-size" type="number" min="1" value="${p.max_bytes/1024/1024}"></div></div><div class="actions"><button type="submit">${t('save')}</button><button id="storage-preview" type="button">${tr('预估清理','Preview cleanup')}</button></div><p id="storage-estimate"></p></form>`;$('#main').append(card);$('#storage-policy').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/storage/policy',{method:'PUT',body:{enabled:$('#storage-enabled').checked,generated_days:Number($('#storage-days').value),cache_days:Number($('#storage-cache-days').value),max_bytes:Number($('#storage-size').value)*1024*1024}});toast(t('done'));});bind('storage-preview',async()=>{const result=await api('/api/v1/storage/cleanup?dry_run=true',{method:'POST'});$('#storage-estimate').textContent=result.disabled?tr('策略未启用;不会清理','Policy disabled; nothing is removed'):tr('可清理 ','Eligible: ')+result.deleted+' · '+(result.bytes_freed/1024/1024).toFixed(1)+' MiB';});
|
||||||
}
|
}
|
||||||
async function clientsPanel(){
|
async function clientsPanel(){
|
||||||
const clients=await api('/api/v1/clients');const scopes=['devices:read','images:read','display:write','templates:read','render:write','jobs:read','jobs:write','history:read'];const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('外部客户端权限','External client permissions')}</h3><p class="hint">${tr('网页继续使用账号登录。这里只为其他程序创建独立凭据,可随时撤销。','The web UI still uses account login. These credentials are for other programs and can be revoked.')}</p>${clients.map(c=>`<p>${esc(c.name)} · ${esc(c.client_id)} · ${t(c.enabled?'enabled':'disabled')} ${c.client_id!=='admin'&&c.enabled?`<button data-revoke="${esc(c.client_id)}">${tr('撤销','Revoke')}</button>`:''}</p>`).join('')}<form id="client-form"><div class="field"><label for="client-id">Client ID</label><input id="client-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label for="client-name">${t('name')}</label><input id="client-name" required></div>${scopes.map(s=>`<label class="check-row"><input type="checkbox" name="client-scope" value="${s}" ${['devices:read','display:write','templates:read','render:write'].includes(s)?'checked':''}>${s}</label>`).join('')}<button type="submit">${tr('创建客户端','Create client')}</button><div id="client-secret" class="notice" hidden></div></form>`;$('#main').append(card);document.querySelectorAll('[data-revoke]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/clients/'+b.dataset.revoke,{method:'DELETE'});await navigate();}));$('#client-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/clients',{method:'POST',body:{client_id:$('#client-id').value,name:$('#client-name').value,permissions:[...document.querySelectorAll('[name="client-scope"]:checked')].map(x=>x.value)}});$('#client-secret').hidden=false;$('#client-secret').textContent=tr('仅显示一次,请保存:','Shown once; save it: ')+r.token;});
|
const [clients,scopes]=await Promise.all([api('/api/v1/clients'),api('/api/v1/client-scopes')]);const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('外部客户端权限','External client permissions')}</h3><p class="hint">${tr('网页继续使用账号登录。这里只为其他程序创建独立凭据,可随时撤销。','The web UI still uses account login. These credentials are for other programs and can be revoked.')}</p>${clients.map(c=>`<p>${esc(c.name)} · ${esc(c.client_id)} · ${t(c.enabled?'enabled':'disabled')} ${c.client_id!=='admin'&&c.enabled?`<button data-revoke="${esc(c.client_id)}">${tr('撤销','Revoke')}</button>`:''}</p>`).join('')}<form id="client-form"><div class="field"><label for="client-id">Client ID</label><input id="client-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label for="client-name">${t('name')}</label><input id="client-name" required></div>${scopes.map(s=>`<label class="check-row"><input type="checkbox" name="client-scope" value="${s}" ${['devices:read','display:write','templates:read','render:write'].includes(s)?'checked':''}>${s}</label>`).join('')}<button type="submit">${tr('创建客户端','Create client')}</button><div id="client-secret" class="notice" hidden></div></form>`;$('#main').append(card);document.querySelectorAll('[data-revoke]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/clients/'+b.dataset.revoke,{method:'DELETE'});await navigate();}));$('#client-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/clients',{method:'POST',body:{client_id:$('#client-id').value,name:$('#client-name').value,permissions:[...document.querySelectorAll('[name="client-scope"]:checked')].map(x=>x.value)}});$('#client-secret').hidden=false;$('#client-secret').textContent=tr('仅显示一次,请保存:','Shown once; save it: ')+r.token;});
|
||||||
}
|
}
|
||||||
async function webhooksPanel(){
|
async function webhooksPanel(){
|
||||||
const hooks=await api('/api/v1/webhooks');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>Webhook</h3><p class="hint">${tr('HTTPS 地址须先加入设置中的允许主机。事件带 HMAC 签名,最多重试三次;接收端应按事件 ID 去重。','Add the HTTPS host to the Settings allowlist first. Events are HMAC signed, with up to three attempts; deduplicate by event ID.')}</p>${hooks.map(h=>`<p>${esc(h.callback_url)} <button data-hook-delete="${h.id}">${t('delete')}</button></p>`).join('')}<form id="hook-form"><div class="field"><label for="hook-url">Callback URL</label><input id="hook-url" type="url" required></div><div class="field"><label for="hook-events">${tr('事件(逗号分隔)','Events (comma separated)')}</label><input id="hook-events" value="operation.completed,operation.failed"></div><button type="submit">${t('save')}</button><p id="hook-secret"></p></form>`;$('#main').append(card);document.querySelectorAll('[data-hook-delete]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/webhooks/'+b.dataset.hookDelete,{method:'DELETE'});await navigate();}));$('#hook-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/webhooks',{method:'POST',body:{callback_url:$('#hook-url').value,event_types:$('#hook-events').value.split(',').map(x=>x.trim()).filter(Boolean)}});$('#hook-secret').textContent=tr('签名密钥仅显示一次:','Signing key shown once: ')+r.signing_secret;});
|
const hooks=await api('/api/v1/webhooks');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>Webhook</h3><p class="hint">${tr('HTTPS 地址须先加入设置中的允许主机。事件带 HMAC 签名,最多重试三次;接收端应按事件 ID 去重。','Add the HTTPS host to the Settings allowlist first. Events are HMAC signed, with up to three attempts; deduplicate by event ID.')}</p>${hooks.map(h=>`<p>${esc(h.callback_url)} <button data-hook-delete="${h.id}">${t('delete')}</button></p>`).join('')}<form id="hook-form"><div class="field"><label for="hook-url">Callback URL</label><input id="hook-url" type="url" required></div><div class="field"><label for="hook-events">${tr('事件(逗号分隔)','Events (comma separated)')}</label><input id="hook-events" value="operation.completed,operation.failed"></div><button type="submit">${t('save')}</button><p id="hook-secret"></p></form>`;$('#main').append(card);document.querySelectorAll('[data-hook-delete]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/webhooks/'+b.dataset.hookDelete,{method:'DELETE'});await navigate();}));$('#hook-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/webhooks',{method:'POST',body:{callback_url:$('#hook-url').value,event_types:$('#hook-events').value.split(',').map(x=>x.trim()).filter(Boolean)}});$('#hook-secret').textContent=tr('签名密钥仅显示一次:','Signing key shown once: ')+r.signing_secret;});
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ async function playlistImagePicker(playlist){
|
|||||||
const raw=$('#playlist-images');const box=document.createElement('div');raw.parentElement.after(box);box.innerHTML=`<p>${t('imageChoice')}</p><div id="playlist-image-choices">${t('loading')}</div>`;const images=await api('/api/v1/devices/'+state.device+'/images');if(!box.isConnected)return;const chosen=new Set(playlist.image_ids);
|
const raw=$('#playlist-images');const box=document.createElement('div');raw.parentElement.after(box);box.innerHTML=`<p>${t('imageChoice')}</p><div id="playlist-image-choices">${t('loading')}</div>`;const images=await api('/api/v1/devices/'+state.device+'/images');if(!box.isConnected)return;const chosen=new Set(playlist.image_ids);
|
||||||
$('#playlist-image-choices').innerHTML=images.map(image=>`<label class="check-row"><input type="checkbox" data-playlist-image="${image.image_id}" ${chosen.has(image.image_id)?'checked':''}>${esc(image.name)} · #${image.image_id}${image.current?' · '+t('current'):''}</label>`).join('');box.querySelectorAll('[data-playlist-image]').forEach(el=>el.onchange=()=>{const id=Number(el.dataset.playlistImage);if(el.checked)chosen.add(id);else chosen.delete(id);raw.value=[...chosen].join(',');});raw.oninput=()=>{chosen.clear();raw.value.split(',').map(Number).filter(x=>x>0).forEach(x=>chosen.add(x));box.querySelectorAll('[data-playlist-image]').forEach(el=>el.checked=chosen.has(Number(el.dataset.playlistImage)));};
|
$('#playlist-image-choices').innerHTML=images.map(image=>`<label class="check-row"><input type="checkbox" data-playlist-image="${image.image_id}" ${chosen.has(image.image_id)?'checked':''}>${esc(image.name)} · #${image.image_id}${image.current?' · '+t('current'):''}</label>`).join('');box.querySelectorAll('[data-playlist-image]').forEach(el=>el.onchange=()=>{const id=Number(el.dataset.playlistImage);if(el.checked)chosen.add(id);else chosen.delete(id);raw.value=[...chosen].join(',');});raw.oninput=()=>{chosen.clear();raw.value.split(',').map(Number).filter(x=>x>0).forEach(x=>chosen.add(x));box.querySelectorAll('[data-playlist-image]').forEach(el=>el.checked=chosen.has(Number(el.dataset.playlistImage)));};
|
||||||
}
|
}
|
||||||
async function enhanceSettings(){await weatherSettings();const config=await api('/api/v1/settings/image-url');const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('urlSettings')}</h3><form id="image-host-form"><div class="field"><label for="image-hosts">${t('allowedHosts')}</label><textarea id="image-hosts" rows="3">${esc(config.allowed_hosts.join('\n'))}</textarea><p class="hint">${t('urlHint')}</p></div><button type="submit">${t('save')}</button></form>`;$('#main').append(box);$('#image-host-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/image-url',{method:'PUT',body:{allowed_hosts:$('#image-hosts').value.split('\n').map(x=>x.trim()).filter(Boolean)}});toast(t('done'));});}
|
async function enhanceSettings(){await storageSettingsPanel();await weatherSettings();const config=await api('/api/v1/settings/image-url');const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('urlSettings')}</h3><form id="image-host-form"><div class="field"><label for="image-hosts">${t('allowedHosts')}</label><textarea id="image-hosts" rows="3">${esc(config.allowed_hosts.join('\n'))}</textarea><p class="hint">${t('urlHint')}</p></div><button type="submit">${t('save')}</button></form>`;$('#main').append(box);$('#image-host-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/image-url',{method:'PUT',body:{allowed_hosts:$('#image-hosts').value.split('\n').map(x=>x.trim()).filter(Boolean)}});toast(t('done'));});}
|
||||||
function enhanceUpload(){const box=document.createElement('div');box.innerHTML=`<h3>${t('uploadURL')}</h3><div class="field"><label for="upload-url">${t('imageURL')}</label><input id="upload-url" type="url" placeholder="https://…"><p class="hint">${t('urlHint')}</p></div>${buttons('preview-url','uploadURL')}`;$('#make-preview').parentElement.append(box);bind('preview-url',async()=>{if(!$('#upload-url').checkValidity())return;const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image_url',url:$('#upload-url').value,fit:$('#fit').value,profile:$('#profile').value});const op=await waitOperation(id);state.preview={asset_id:op.result.asset_id,url:op.result.url};$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});}
|
function enhanceUpload(){const box=document.createElement('div');box.innerHTML=`<h3>${t('uploadURL')}</h3><div class="field"><label for="upload-url">${t('imageURL')}</label><input id="upload-url" type="url" placeholder="https://…"><p class="hint">${t('urlHint')}</p></div>${buttons('preview-url','uploadURL')}`;$('#make-preview').parentElement.append(box);$('#upload-url').oninput=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('preview-url',async()=>{if(!$('#upload-url').checkValidity())return;const sourceUrl=$('#upload-url').value;const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image_url',url:sourceUrl,fit:$('#fit').value,profile:$('#profile').value});const op=await waitOperation(id);if(!box.isConnected||$('#upload-url').value!==sourceUrl)return;state.preview={asset_id:op.result.asset_id,url:op.result.url,sourceUrl};$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});}
|
||||||
async function poetryControls(){
|
async function poetryControls(){
|
||||||
if(state.selectedTemplate!=='daily-poetry')return;
|
if(state.selectedTemplate!=='daily-poetry')return;
|
||||||
const provider=await api('/api/v1/providers/poetry');if(!$('#template-fields'))return;
|
const provider=await api('/api/v1/providers/poetry');if(!$('#template-fields'))return;
|
||||||
@@ -132,7 +132,7 @@ function photosLoginDialog(){
|
|||||||
|
|
||||||
async function photoSourcesPanel(){
|
async function photoSourcesPanel(){
|
||||||
const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section);
|
const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section);
|
||||||
const sources=await api('/api/v1/photo-sources');if(!section.isConnected)return;
|
const sources=(await api('/api/v1/photo-sources')).filter(s=>s.kind!=='uploads');if(!section.isConnected)return;
|
||||||
section.innerHTML=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><section id="photos-connection" class="photos-connection" aria-label="Synology Photos"></section><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint source-identity">${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}</p><p class="hint">${s.kind==='synology-photos'?t('sourcePhotosAPI'):t('sourceOwnCredentials')}</p><p class="hint">${esc(s.kind==='synology-photos'?'Synology Photos':s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}</p><div class="actions"><button data-source-browse="${esc(s.id)}">${t('browse')}</button><button data-source-random="${esc(s.id)}" ${s.enabled?'':'disabled'}>${t('randomPreview')}</button><button data-source-job="${esc(s.id)}">${t('sourceJob')}</button><button data-source-edit="${esc(s.id)}">${t('edit')}</button><button class="danger" data-source-delete="${esc(s.id)}">${t('remove')}</button></div></article>`).join(''):`<p class="hint">${t('noSources')}</p>`}</div>`;
|
section.innerHTML=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><section id="photos-connection" class="photos-connection" aria-label="Synology Photos"></section><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint source-identity">${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}</p><p class="hint">${s.kind==='synology-photos'?t('sourcePhotosAPI'):t('sourceOwnCredentials')}</p><p class="hint">${esc(s.kind==='synology-photos'?'Synology Photos':s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}</p><div class="actions"><button data-source-browse="${esc(s.id)}">${t('browse')}</button><button data-source-random="${esc(s.id)}" ${s.enabled?'':'disabled'}>${t('randomPreview')}</button><button data-source-job="${esc(s.id)}">${t('sourceJob')}</button><button data-source-edit="${esc(s.id)}">${t('edit')}</button><button class="danger" data-source-delete="${esc(s.id)}">${t('remove')}</button></div></article>`).join(''):`<p class="hint">${t('noSources')}</p>`}</div>`;
|
||||||
await photosConnectionPanel();
|
await photosConnectionPanel();
|
||||||
bind('add-photo-source',()=>photoSourceForm());
|
bind('add-photo-source',()=>photoSourceForm());
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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 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 terminalLabel(terminal){return terminal.id==='watch'?tr('手表','Watch'):deviceDisplayName(terminal.name?terminal:state.devices.find(d=>d.id===terminal.id)||terminal);}
|
||||||
function librarySelectOptions(items,selected,label){return `<option value="">${label}</option>`+items.map(p=>`<option value="${esc(p.id)}" ${p.id===selected?'selected':''}>${esc(p.name)}</option>`).join('');}
|
function librarySelectOptions(items,selected,label){return `<option value="">${label}</option>`+items.map(p=>`<option value="${esc(p.id)}" ${p.id===selected?'selected':''}>${esc(p.kind==='uploads'?tr('手动上传','Manual uploads'):p.name)}</option>`).join('');}
|
||||||
views.gallery=epoch=>libraryPage(epoch,'source');
|
views.gallery=epoch=>libraryPage(epoch,'source');
|
||||||
views['terminal-gallery']=epoch=>libraryPage(epoch,'terminal');
|
views['terminal-gallery']=epoch=>libraryPage(epoch,'terminal');
|
||||||
async function libraryPage(epoch,kind){
|
async function libraryPage(epoch,kind){
|
||||||
@@ -23,23 +23,23 @@ async function libraryPage(epoch,kind){
|
|||||||
|
|
||||||
async function sourceLibraryPage(epoch,sources,terminals){
|
async function sourceLibraryPage(epoch,sources,terminals){
|
||||||
const panel=$('#library-content');
|
const panel=$('#library-content');
|
||||||
if(!sources.length){panel.innerHTML=`<section class="card empty"><p>${tr('先在“照片数据源”中添加来源,再建立该来源的生成方案。','Add a photo source, then create generation plans in its library.')}</p><button id="library-add-source">${tr('添加照片来源','Add photo source')}</button></section>`;bind('library-add-source',()=>navigate('photo-sources'));return;}
|
if(!sources.length){panel.innerHTML=`<section class="card empty"><p>${tr('连接照片数据源或手动上传照片,再在来源图库建立生成方案。','Connect a photo source or upload photos, then create generation plans in its library.')}</p><button id="library-add-source">${tr('添加照片来源','Add photo source')}</button></section>`;panel.querySelector('section').insertAdjacentHTML('beforeend',`<button id="library-first-upload">${tr('上传照片到来源图库','Upload photos to source library')}</button>`);bind('library-first-upload',()=>navigate('upload'));bind('library-add-source',()=>navigate('photo-sources'));return;}
|
||||||
if(!sources.some(s=>s.id===libraryUI.source)){libraryUI.source=sources[0].id;libraryUI.plan=null;libraryUI.offset=0;}
|
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')]);
|
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(epoch!==state.epoch)return;
|
||||||
if(libraryUI.plan&&!plans.some(p=>p.id===libraryUI.plan))libraryUI.plan=null;
|
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));
|
const plan=plans.find(p=>p.id===libraryUI.plan),uses=terminals.filter(t=>t.binding?.source_id===sourceId&&(!plan||t.binding.plan_id===plan.id));
|
||||||
panel.innerHTML=`<section class="card library-source-controls"><div class="library-source-toolbar"><div class="field"><label for="library-source">${tr('来源图库','Source library')}</label><select id="library-source">${librarySelectOptions(sources,sourceId,tr('选择来源','Choose source'))}</select></div><div class="actions"><button id="library-import">${tr('从来源选入照片','Import from source')}</button><button id="library-run">${tr('随机选入并按绑定生成','Pick once and generate bound plans')}</button><button id="library-source-settings">${tr('来源设置','Source settings')}</button></div></div><p class="hint">${tr('源画面先保存到本地;同一张照片按不同方案生成版本,多个终端可共用一份版本。Photos 画面可能是源预览。','Source pixels are stored locally first. Plans create versions of the same photo; terminals may share one version. Photos may supply source previews.')}</p></section><section class="card library-output-panel"><div class="library-plan-toolbar"><div class="tabs gallery-tabs library-plan-tabs" role="tablist" aria-label="${tr('源画面与生成方案','Source pixels and generation plans')}"><button id="library-plan-tab-source" role="tab" aria-controls="library-plan-content" aria-selected="${!plan}" tabindex="${!plan?0:-1}" data-library-plan="" class="${!plan?'active':''}">${tr('源画面','Source pixels')}</button>${plans.map(p=>`<button id="library-plan-tab-${esc(p.id)}" role="tab" aria-controls="library-plan-content" aria-selected="${p.id===plan?.id}" tabindex="${p.id===plan?.id?0:-1}" data-library-plan="${esc(p.id)}" class="${p.id===plan?.id?'active':''}">${esc(p.name)}${p.archived?tr('(已归档)',' (archived)'):''}</button>`).join('')}</div><div class="actions"><button id="library-new-plan">${tr('新增生成方案','New generation plan')}</button>${plan&&!plan.archived?`<button id="library-edit-plan">${t('edit')}</button><button id="library-delete-plan" class="danger">${tr('删除方案','Delete plan')}</button>`:''}</div></div><div id="library-plan-content" role="tabpanel" aria-labelledby="library-plan-tab-${plan?esc(plan.id):'source'}"><p class="hint">${tr('使用终端:','Used by: ')}${uses.map(terminalLabel).map(esc).join('、')||tr('尚未绑定','No terminals bound')}</p><div class="gallery source-library-gallery">${page.items.map((picture,i)=>{
|
panel.innerHTML=`<section class="card library-source-controls"><div class="library-source-toolbar"><div class="field"><label for="library-source">${tr('来源图库','Source library')}</label><select id="library-source">${librarySelectOptions(sources,sourceId,tr('选择来源','Choose source'))}</select></div><div class="actions"><button id="library-import">${manual?tr('上传照片','Upload photos'):tr('从来源选入照片','Import from source')}</button><button id="library-run">${tr('随机选入并按绑定生成','Pick once and generate bound plans')}</button>${manual?'':`<button id="library-source-settings">${tr('来源设置','Source settings')}</button>`}</div></div><p class="hint">${tr('源画面先保存到本地;同一张照片按不同方案生成版本,多个终端可共用一份版本。Photos 画面可能是源预览。','Source pixels are stored locally first. Plans create versions of the same photo; terminals may share one version. Photos may supply source previews.')}</p></section><section class="card library-output-panel"><div class="library-plan-toolbar"><div class="tabs gallery-tabs library-plan-tabs" role="tablist" aria-label="${tr('源画面与生成方案','Source pixels and generation plans')}"><button id="library-plan-tab-source" role="tab" aria-controls="library-plan-content" aria-selected="${!plan}" tabindex="${!plan?0:-1}" data-library-plan="" class="${!plan?'active':''}">${tr('源画面','Source pixels')}</button>${plans.map(p=>`<button id="library-plan-tab-${esc(p.id)}" role="tab" aria-controls="library-plan-content" aria-selected="${p.id===plan?.id}" tabindex="${p.id===plan?.id?0:-1}" data-library-plan="${esc(p.id)}" class="${p.id===plan?.id?'active':''}">${esc(p.name)}${p.archived?tr('(已归档)',' (archived)'):''}</button>`).join('')}</div><div class="actions"><button id="library-new-plan">${tr('新增生成方案','New generation plan')}</button>${plan&&!plan.archived?`<button id="library-edit-plan">${t('edit')}</button><button id="library-delete-plan" class="danger">${tr('删除方案','Delete plan')}</button>`:''}</div></div><div id="library-plan-content" role="tabpanel" aria-labelledby="library-plan-tab-${plan?esc(plan.id):'source'}"><p class="hint">${tr('使用终端:','Used by: ')}${uses.map(terminalLabel).map(esc).join('、')||tr('尚未绑定','No terminals bound')}</p><div class="gallery source-library-gallery">${page.items.map((picture,i)=>{
|
||||||
const version=plan?picture.versions.find(v=>v.plan_id===plan.id):picture;
|
const version=plan?picture.versions.find(v=>v.plan_id===plan.id):picture;
|
||||||
return `<article class="card image-card">${version?`<button class="image-preview" data-library-preview="${i}"><img class="thumb" alt="${esc(picture.name)}" data-src="${esc(version.url)}" style="object-fit:contain;${plan?`aspect-ratio:${version.plan.width}/${version.plan.height}`:''}"></button>`:`<div class="empty">${tr('此方案尚未生成','Not generated for this plan')}</div>`}<h3>${esc(picture.name)}</h3><small>${plan?version?`${esc(version.plan_name)} · ${version.plan.width} × ${version.plan.height}`:tr('未生成','Not generated'):tr('无叠加的源画面','Source pixels without overlays')}</small><small>${esc(fmt(version?.created_at||picture.created_at))}</small>${plan&&version?.matches_current_plan===false?`<small class="hint">${tr('方案参数已更新,此图片为旧参数版本','Plan settings changed; this image uses earlier settings')}</small>`:''}${version?.terminal_uses?.length?`<small>${version.terminal_uses.map(u=>esc(terminalLabel(terminals.find(t=>t.id===u.terminal_id)||{id:u.terminal_id}))+' · '+esc(libraryStatus(u.status))).join(' / ')}</small>`:''}<div class="actions">${version?`<button data-library-preview="${i}">${tr('预览','Preview')}</button><button data-library-download="${i}">${tr('下载图片','Download image')}</button>${plan?`<button data-library-settings="${i}">${tr('生成参数','Generation settings')}</button>`:''}`:''}${!plan?.archived?`<button data-library-generate="${i}">${plan?tr('按此方案生成','Generate this plan'):tr('生成各方案版本','Generate plan versions')}</button>`:''}${!plan?`<button data-library-star="${i}" ${picture.excluded?'disabled':''} title="${picture.excluded?esc(t('clearExclusionFirst')):''}">${t(picture.starred?'unstar':'star')}</button><button data-library-exclude="${i}" ${picture.starred?'disabled':''} title="${picture.starred?esc(t('clearStarFirst')):''}">${t(picture.excluded?'includePhoto':'excludePhoto')}</button>`:''}<button class="danger" data-library-delete="${i}" ${plan&&!version?'disabled':''}>${t('delete')}</button></div></article>`;
|
return `<article class="card image-card">${version?`<button class="image-preview" data-library-preview="${i}"><img class="thumb" alt="${esc(picture.name)}" data-src="${esc(version.url)}" style="object-fit:contain;${plan?`aspect-ratio:${version.plan.width}/${version.plan.height}`:''}"></button>`:`<div class="empty">${tr('此方案尚未生成','Not generated for this plan')}</div>`}<h3>${esc(picture.name)}</h3><small>${plan?version?`${esc(version.plan_name)} · ${version.plan.width} × ${version.plan.height}`:tr('未生成','Not generated'):tr('无叠加的源画面','Source pixels without overlays')}</small><small>${esc(fmt(version?.created_at||picture.created_at))}</small>${!plan&&picture.starred?`<small class="photo-mark">${tr('已星标保留','Starred for retention')}</small>`:''}${!plan&&picture.excluded?`<small class="photo-mark">${tr('已标记不再选用','Excluded from automatic selection')}</small>`:''}${plan&&version?.matches_current_plan===false?`<small class="hint">${tr('方案参数已更新,此图片为旧参数版本','Plan settings changed; this image uses earlier settings')}</small>`:''}${version?.terminal_uses?.length?`<small>${version.terminal_uses.map(u=>esc(terminalLabel(terminals.find(t=>t.id===u.terminal_id)||{id:u.terminal_id}))+' · '+esc(libraryStatus(u.status))).join(' / ')}</small>`:''}<div class="actions">${version?`<button data-library-preview="${i}">${tr('预览','Preview')}</button><button data-library-download="${i}">${tr('下载图片','Download image')}</button>${plan?`<button data-library-settings="${i}">${tr('生成参数','Generation settings')}</button>`:''}`:''}${!plan?.archived?`<button data-library-generate="${i}">${plan?tr('按此方案生成','Generate this plan'):tr('生成各方案版本','Generate plan versions')}</button>`:''}${!plan?`<button data-library-star="${i}" ${picture.excluded?'disabled':''} title="${picture.excluded?esc(t('clearExclusionFirst')):''}">${t(picture.starred?'unstar':'star')}</button><button data-library-exclude="${i}" ${picture.starred?'disabled':''} title="${picture.starred?esc(t('clearStarFirst')):''}">${t(picture.excluded?'includePhoto':'excludePhoto')}</button>`:''}<button class="danger" data-library-delete="${i}" ${plan&&!version?'disabled':''}>${t('delete')}</button></div></article>`;
|
||||||
}).join('')}</div>${page.items.length?'':`<section class="card empty">${tr('尚未选入源画面。先从来源选入照片,再按方案生成。','No source pixels imported yet. Import a photo, then generate its versions.')}</section>`}<div class="actions library-pagination"><button id="library-prev" ${libraryUI.offset===0?'disabled':''}>${tr('上一页','Previous')}</button><button id="library-next" ${libraryUI.offset+30>=page.total?'disabled':''}>${tr('下一页','Next')}</button><span>${tr('共','Total')} ${page.total} ${tr('张来源照片','source pictures')}</span></div></div></section>`;
|
}).join('')}</div>${page.items.length?'':`<section class="card empty">${manual?tr('尚未上传照片。上传后可按方案生成。','No uploads yet. Upload a photo, then generate its versions.'):tr('尚未选入源画面。先从来源选入照片,再按方案生成。','No source pixels imported yet. Import a photo, then generate its versions.')}</section>`}<div class="actions library-pagination"><button id="library-prev" ${libraryUI.offset===0?'disabled':''}>${tr('上一页','Previous')}</button><button id="library-next" ${libraryUI.offset+30>=page.total?'disabled':''}>${tr('下一页','Next')}</button><span>${tr('共','Total')} ${page.total} ${tr('张来源照片','source pictures')}</span></div></div></section>`;
|
||||||
$('#library-source').onchange=()=>{libraryUI.source=$('#library-source').value;libraryUI.plan=null;libraryUI.offset=0;return navigate();};
|
$('#library-source').onchange=()=>{libraryUI.source=$('#library-source').value;libraryUI.plan=null;libraryUI.offset=0;return navigate();};
|
||||||
document.querySelectorAll('[data-library-plan]').forEach(b=>{
|
document.querySelectorAll('[data-library-plan]').forEach(b=>{
|
||||||
b.onclick=()=>{libraryUI.plan=b.dataset.libraryPlan||null;return navigate();};
|
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();};
|
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-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-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();});
|
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();});
|
||||||
|
|||||||
@@ -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-status').textContent=tr(`本批找到 ${page.items.length} 张照片${page.paged_provider?`,检查 ${page.scanned} 条记录`:''}。${!page.items.length&&page.next_offset!==null?'可继续下一页检索。':''}`,`${page.items.length} matches in this batch${page.paged_provider?`, ${page.scanned} records checked`:''}.${!page.items.length&&page.next_offset!==null?' Continue to the next batch.':''}`);
|
||||||
$('#browse-gallery').innerHTML=page.items.map((item,i)=>`<article class="card image-card"><button class="image-preview" data-browse-preview="${i}" ${item.preview_url?'':'disabled'}>${item.preview_url?`<img class="thumb" alt="${esc(item.name)}" data-src="${esc(item.preview_url)}">`:`<span class="empty">${tr('选入后预览','Preview after import')}</span>`}</button><h3 title="${esc(item.name)}">${esc(item.name)}</h3><small class="browse-folder">${esc(item.folder||item.source_path||'')}</small><small>${item.width&&item.height?`${item.width} × ${item.height} px · `:''}${item.filesize?`${(item.filesize/1024/1024).toFixed(2)} MB`:''}</small><small>${item.indexed_time?tr('入库:','Indexed: ')+esc(fmt(new Date(item.indexed_time*1000).toISOString())):''}</small><small>${item.time?tr('拍摄:','Photo: ')+esc(fmt(new Date(item.time*1000).toISOString())):''}</small><small>${esc((item.people||[]).map(p=>p.name).filter(Boolean).join('、'))}</small><div class="actions"><button data-browse-preview="${i}" ${item.preview_url?'':'disabled'}>${tr('预览','Preview')}</button><button class="primary" data-browse-import="${i}">${tr('选入本地','Import locally')}</button></div></article>`).join('');$('#browse-gallery').setAttribute('aria-busy','false');
|
$('#browse-gallery').innerHTML=page.items.map((item,i)=>`<article class="card image-card"><button class="image-preview" data-browse-preview="${i}" ${item.preview_url?'':'disabled'}>${item.preview_url?`<img class="thumb" alt="${esc(item.name)}" data-src="${esc(item.preview_url)}">`:`<span class="empty">${tr('选入后预览','Preview after import')}</span>`}</button><h3 title="${esc(item.name)}">${esc(item.name)}</h3><small class="browse-folder">${esc(item.folder||item.source_path||'')}</small><small>${item.width&&item.height?`${item.width} × ${item.height} px · `:''}${item.filesize?`${(item.filesize/1024/1024).toFixed(2)} MB`:''}</small><small>${item.indexed_time?tr('入库:','Indexed: ')+esc(fmt(new Date(item.indexed_time*1000).toISOString())):''}</small><small>${item.time?tr('拍摄:','Photo: ')+esc(fmt(new Date(item.time*1000).toISOString())):''}</small><small>${esc((item.people||[]).map(p=>p.name).filter(Boolean).join('、'))}</small><div class="actions"><button data-browse-preview="${i}" ${item.preview_url?'':'disabled'}>${tr('预览','Preview')}</button><button class="primary" data-browse-import="${i}">${tr('选入本地','Import locally')}</button></div></article>`).join('');$('#browse-gallery').setAttribute('aria-busy','false');
|
||||||
$('#browse-next').disabled=page.next_offset===null;
|
$('#browse-next').disabled=page.next_offset===null;
|
||||||
|
$('#browse-next').textContent=tr('继续读取','Read next batch');
|
||||||
|
if(!page.items.length)$('#browse-gallery').innerHTML=`<section class="card empty"><p>${page.next_offset!==null?tr('本批未找到匹配照片,不代表整个来源没有结果。请继续读取下一批。','No matches in this batch. This does not mean the entire source has no results. Read the next batch.'):tr('已读至来源末尾,当前筛选范围内没有更多结果。','End of source reached. No further results for these filters.')}</p></section>`;
|
||||||
|
|
||||||
bind('browse-prev',()=>{sourceBrowser.offset=sourceBrowser.previous.pop()||0;return navigate();});
|
bind('browse-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();});
|
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);}));
|
document.querySelectorAll('[data-browse-preview]').forEach(b=>b.onclick=busy(()=>{const item=page.items[b.dataset.browsePreview];return openImageViewer(item.preview_url,item.name);}));
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ version = "0.1.29"
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
requires-python = ">=3.14,<3.15"
|
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]
|
[project.optional-dependencies]
|
||||||
test = ["pytest==9.1.1", "httpx2==2.13.1", "mypy==2.3.1", "pip-audit==2.10.1", "types-requests==2.33.0.20260906", "types-PyYAML==6.0.12.20260906", "types-croniter==6.2.4.20260711", "ruff==0.16.9"]
|
test = ["pytest==9.1.1", "httpx2==2.13.1", "mypy==2.3.1", "pip-audit==2.10.1", "types-requests==2.33.0.20260906", "types-PyYAML==6.0.12.20260906", "types-croniter==6.2.4.20260711", "ruff==0.16.9"]
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
|
|||||||
+3
-3
@@ -1,16 +1,16 @@
|
|||||||
annotated-doc==0.0.5
|
annotated-doc==0.0.5
|
||||||
annotated-types==0.8.0
|
annotated-types==0.8.0
|
||||||
anyio==4.15.1
|
anyio==4.15.1
|
||||||
certifi==2026.7.22
|
certifi==2026.10.23
|
||||||
charset-normalizer==3.5.2
|
charset-normalizer==3.5.2
|
||||||
click==8.5.0
|
click==8.5.0
|
||||||
croniter==6.2.4
|
croniter==6.2.4
|
||||||
fastapi==0.142.2
|
fastapi==0.142.2
|
||||||
h11==0.16.0
|
h11==0.16.0
|
||||||
idna==3.20
|
idna==3.20
|
||||||
opentelemetry-api==1.45.0
|
opentelemetry-api==1.45.1
|
||||||
paho-mqtt==2.1.0
|
paho-mqtt==2.1.0
|
||||||
pillow==12.3.0
|
pillow==13.0.0
|
||||||
pydantic-core==2.46.5
|
pydantic-core==2.46.5
|
||||||
pydantic==2.13.5
|
pydantic==2.13.5
|
||||||
python-dateutil==2.9.0.post0
|
python-dateutil==2.9.0.post0
|
||||||
|
|||||||
@@ -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 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);
|
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'])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]}`);
|
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 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)});
|
(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)});
|
||||||
|
|||||||
@@ -1994,5 +1994,53 @@
|
|||||||
"502",
|
"502",
|
||||||
"503"
|
"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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -951,7 +951,8 @@
|
|||||||
"webdav",
|
"webdav",
|
||||||
"google-drive",
|
"google-drive",
|
||||||
"alipan",
|
"alipan",
|
||||||
"baidu"
|
"baidu",
|
||||||
|
"uploads"
|
||||||
],
|
],
|
||||||
"title": "Kind"
|
"title": "Kind"
|
||||||
},
|
},
|
||||||
@@ -1131,7 +1132,8 @@
|
|||||||
"webdav",
|
"webdav",
|
||||||
"google-drive",
|
"google-drive",
|
||||||
"alipan",
|
"alipan",
|
||||||
"baidu"
|
"baidu",
|
||||||
|
"uploads"
|
||||||
],
|
],
|
||||||
"title": "Kind"
|
"title": "Kind"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -242,3 +242,58 @@ def test_only_one_browser_account(app):
|
|||||||
"INSERT INTO users VALUES(?,?,1)",
|
"INSERT INTO users VALUES(?,?,1)",
|
||||||
("other", password_hash("another-password")),
|
("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") == []
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -174,3 +174,16 @@ def test_session_changes_invalidate_preview(client, browser, monkeypatch):
|
|||||||
]
|
]
|
||||||
monkeypatch.setattr(photos.synology, "sid", lambda: "new-session")
|
monkeypatch.setattr(photos.synology, "sid", lambda: "new-session")
|
||||||
assert client.get(item["preview_url"]).status_code == 404
|
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"]
|
||||||
|
|||||||
@@ -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
|
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
|
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") == []
|
||||||
|
|||||||
Reference in New Issue
Block a user