diff --git a/app/components/ClearStorageModal.vue b/app/components/ClearStorageModal.vue new file mode 100644 index 0000000..0d88a0f --- /dev/null +++ b/app/components/ClearStorageModal.vue @@ -0,0 +1,159 @@ + + + diff --git a/app/components/HlsDownloadModal.vue b/app/components/HlsDownloadModal.vue new file mode 100644 index 0000000..44226df --- /dev/null +++ b/app/components/HlsDownloadModal.vue @@ -0,0 +1,100 @@ + + + diff --git a/app/components/MediaSessionCard.vue b/app/components/MediaSessionCard.vue index 8a924ae..62054e4 100644 --- a/app/components/MediaSessionCard.vue +++ b/app/components/MediaSessionCard.vue @@ -17,6 +17,7 @@ const emit = defineEmits<{ const { formatFileSize } = useFileSize() const { t, locale } = useI18n() +const transcodeFailureLabel = useTranscodeFailureLabel() const { copyFormat } = useUploadPreferences(props.kind) const copyFormatItems = computed(() => [ @@ -102,22 +103,11 @@ const presetLabel = computed(() => { + + diff --git a/app/types/file.ts b/app/types/file.ts index 3a69458..1cc327d 100644 --- a/app/types/file.ts +++ b/app/types/file.ts @@ -14,6 +14,8 @@ export interface FileItem { transcodeProgress?: number /** 本视频使用的转码档位 */ transcodePreset?: string + /** 转码失败原因 */ + transcodeError?: string /** HLS 播放列表地址(转码完成后有值) */ hlsUrl?: string /** HLS 播放列表在存储中的 key */ diff --git a/app/types/image.ts b/app/types/image.ts index d3dd315..838853a 100644 --- a/app/types/image.ts +++ b/app/types/image.ts @@ -8,7 +8,10 @@ export type ErrorCode | 'UNSUPPORTED_FILE_TYPE' | 'INVALID_FILE_SIGNATURE' | 'INVALID_IMAGE_KEY' + | 'INVALID_VIDEO_KEY' | 'IMAGE_NOT_FOUND' + | 'HLS_NOT_FOUND' + | 'HLS_REMUX_FAILED' | 'UPLOAD_FAILED' | 'DELETE_FAILED' diff --git a/docs-site/guide/local-dev.md b/docs-site/guide/local-dev.md index 5ce50e8..cc37257 100644 --- a/docs-site/guide/local-dev.md +++ b/docs-site/guide/local-dev.md @@ -6,6 +6,7 @@ - Node.js **22** - npm **11**(与 `package.json` 中 `packageManager` 一致) +- FFmpeg(视频缩略图与 HLS 转码;未安装时视频仍可上传,但使用占位封面) ## 启动 diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 2472640..c78a9d7 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -8,6 +8,7 @@ "loadingSession": "Verifying session…", "save": "Save", "cancel": "Cancel", + "continue": "Continue", "copy": "Copy", "delete": "Delete", "edit": "Edit", @@ -546,7 +547,20 @@ "storageId": "Storage ID", "storageType": "Storage type", "badgeLocal": "Local", - "defaultStorageTip": "Default storage: new uploads are saved to “{name}”. Use “Set as default” on another backend to change this.", + "defaultStorageTip": "All new images, videos, and files are saved to “{name}” by default. Use “Set as default” on another backend to change this.", + "clearStorage": "Clear storage", + "clearStorageTitle": "Clear my storage", + "clearStorageDescription": "Permanently delete every image, video, file, and cache uploaded by the current account without affecting other users.", + "clearStorageWarning": "High-risk operation: this will clear all files and caches in storage and cannot be undone!", + "clearStorageScope": "Only data and files owned by the current signed-in user are removed. Other users, account settings, storage configuration, and site backups are not affected.", + "clearStorageFinalTitle": "Final confirmation", + "clearStorageInstruction": "Type “I confirm” to continue. The operation cannot be cancelled or undone once started.", + "clearStoragePlaceholder": "I confirm", + "clearStoragePhrase": "I confirm", + "clearStorageConfirm": "Permanently clear", + "clearStorageSuccess": "Cleared {files} files and {cache} temporary caches; storage space has been released", + "clearStoragePartial": "{failed}/{total} files failed to clear; please retry", + "clearStorageFailed": "Failed to clear storage; please try again", "supportedTitle": "Supported backends", "supportedSubtitle": "PicHost supports multiple storage backends for different scenarios", "supportedLocalDesc": "Ideal for small deployments and personal use", @@ -554,7 +568,7 @@ "supportedS3Title": "S3 compatible", "supportedS3Desc": "Storage services compatible with the Amazon S3 API", "supportedTelegramDesc": "Store images in a Telegram channel or group; good for light unlimited storage", - "footerTip": "The default backend receives all new uploads", + "footerTip": "The default backend receives all new images, videos, and files", "statTotal": "Total", "statUsed": "Used", "statFree": "Free", @@ -585,28 +599,28 @@ "manageStorageHint": "Configure R2 / S3 / COS backends and URL serving", "backup": { "sectionTitle": "Backup & migration", - "sectionSubtitle": "Full-site backup, restore, and cross-backend image migration", + "sectionSubtitle": "Full-site backup, restore, and cross-backend file migration", "siteTitle": "Site backup & restore", "siteSubtitle": "For migration, disaster recovery, and full-site restore", - "siteFooterHint": "Backups include the database and images, not plain-text secret_json.", + "siteFooterHint": "Backups include the database and all files, not plain-text secret_json.", "exportTitle": "Create site backup", - "exportSubtitle": "Package the database and all images as .phost.tar.gz", + "exportSubtitle": "Package the database and all files as .phost.tar.gz", "exportAction": "Start backup", "exportStarted": "Backup job started", "exportFailed": "Failed to start backup", "exportPathHint": "Exports are saved under data/backups/", "exportIncludesTitle": "Package includes", "exportIncludesDb": "Database snapshot (pichost.db)", - "exportIncludesImages": "All image files ({count})", + "exportIncludesImages": "All images, videos, and files ({count})", "exportExcludesSecrets": "Excludes plain-text secret.json for cloud storage", "exportConcurrencyHint": "Only one backup or migration job can run at a time", "estimateLoading": "Estimating size…", - "estimateImageCountLabel": "Images", + "estimateImageCountLabel": "Files", "estimatePackageSizeLabel": "Est. package", "estimateDatabaseSnapshotLabel": "Database snapshot", "estimateStoragePathLabel": "Save location", "estimateStoragePathValue": "data/backups/", - "estimateImages": "{count} images · {size}", + "estimateImages": "{count} files · {size}", "estimateDatabase": "Database ~{size}", "estimateTotal": "Total ~{size}", "restoreTitle": "Restore from backup", @@ -623,30 +637,30 @@ "restoreConfirm": "Confirm restore", "restoreConfirmTitle": "Confirm site restore", "restoreConfirmSource": "Source: {name}", - "restoreOverwriteWarning": "Overwrite mode replaces conflicting images and the database. Ensure you have a current backup.", + "restoreOverwriteWarning": "Overwrite mode replaces conflicting files and the database. Ensure you have a current backup.", "restoreSelectRequired": "Select or upload a backup file", "restoreStarted": "Restore job started", "restoreFailed": "Restore failed", "conflictMode": "Conflict policy", - "conflictSkip": "Skip existing images", - "conflictOverwrite": "Overwrite existing images and database", + "conflictSkip": "Skip existing files", + "conflictOverwrite": "Overwrite existing files and database", "syncTitle": "Cross-backend sync", "syncMigrateTitle": "Cross-backend migration", - "syncMigrateSubtitle": "Migrate images between configured backends", - "syncSubtitle": "Copy images between configured backends and update the index", + "syncMigrateSubtitle": "Migrate files between configured backends", + "syncSubtitle": "Copy files between configured backends and update the index", "syncFrom": "Source backend", "syncTo": "Target backend", - "syncPendingImages": "Images to migrate", + "syncPendingImages": "Files to migrate", "syncPendingSize": "Est. size", "syncIncludesTitle": "What sync does", - "syncIncludesCopy": "Copy image files from source to target backend", + "syncIncludesCopy": "Copy images, videos, and files from source to target backend", "syncIncludesIndex": "Update storage_backend_id in the index", "syncIncludesConfig": "Site settings and other backends are unchanged", "syncDryRun": "Dry run only", "syncSetDefaultAfter": "Set target as default after migration", "syncStartMigrate": "Start migration", "syncPreviewOnly": "Preview", - "syncFooterHint": "Only image files and index are migrated; site settings are unchanged.", + "syncFooterHint": "Only files and index are migrated; site settings are unchanged.", "syncDeleteSource": "Delete source files after sync", "syncSetDefault": "Set target as default after sync", "syncPreview": "Preview & start", @@ -658,8 +672,8 @@ "syncStarted": "Sync job started", "syncFailed": "Sync failed", "previewFailed": "Preview failed", - "previewImages": "Images", - "previewNew": "New images", + "previewImages": "Files", + "previewNew": "New files", "previewConflicts": "Conflicts", "previewCreated": "Backup created", "previewSize": "Data size", @@ -672,6 +686,8 @@ "colType": "Type", "colStatus": "Status", "colProgress": "Progress", + "progressUnitMedia": "media records", + "progressUnitObjects": "storage objects", "colMessage": "Message", "colCreated": "Created", "colActions": "Actions", @@ -691,8 +707,8 @@ "jobRunning": "Another job is already running. Try again later.", "migrateIn": "Migrate in", "migrateOut": "Migrate out", - "migrateInHint": "Copy images from the default backend to this one", - "migrateOutHint": "Move images out of this backend", + "migrateInHint": "Copy files from the default backend to this one", + "migrateOutHint": "Move files out of this backend", "syncDraftApplied": "Sync form prefilled — review and start when ready", "syncNeedTwoBackends": "At least two storage backends are required to sync", "selectBackend": "Select backend", @@ -869,7 +885,7 @@ "envOverrideTitle": "Token overridden by environment", "envOverrideDesc": "Restart after changing API_UPLOAD_TOKEN; cannot regenerate in UI.", "personalTitle": "Personal API token", - "personalDesc": "For your scripts only; uploads belong to your account.", + "personalDesc": "For your scripts only; uploaded images, videos and files belong to your account.", "notConfiguredTitle": "No token configured", "notConfiguredDesc": "A token is created automatically on first visit, or click Regenerate.", "regenerate": "Regenerate", @@ -911,6 +927,8 @@ "responseExample": "Response example", "categories": { "image": "Images", + "video": "Videos", + "file": "Files", "extension": "Plugins", "system": "System" }, @@ -935,6 +953,47 @@ "batchDelete": { "keys": "Array of image keys to delete" }, + "videoUpload": { + "video": "Primary video field; max 500 MB per file", + "file": "Compatibility field, can replace video", + "files": "Compatibility field for multiple videos", + "transcode": "Set to 1 to transcode to HLS asynchronously; otherwise keep the source and generate a thumbnail", + "transcodePreset": "Optional preset: copy, veryfast, medium, slow, or compress (target bitrate)", + "transcodeBitratePercent": "Used by compress only; target bitrate as 10–100% of the source. Uses the current server setting when omitted", + "token": "Optional; the API token can also be sent with the Auth-Token header" + }, + "fileUpload": { + "file": "Primary file field; max 100 MB per file. HTML / HTM / SVG are blocked", + "files": "Compatibility field for multiple files", + "token": "Optional; the API token can also be sent with the Auth-Token header" + }, + "mediaList": { + "kindVideo": "Set to video to list videos", + "kindFile": "Set to file to list files", + "page": "Page number, starts at 1", + "limit": "Page size, default 20, max 100", + "q": "Search by filename or path", + "processing": "Video only; set to 1 to return queued or running transcodes", + "uploadSource": "Upload source: web or api", + "tagIds": "Filter by comma-separated tag IDs", + "tagMode": "Use or or and for multiple tags; default is or", + "untagged": "Set to 1 to return only untagged items" + }, + "mediaSearch": { + "q": "Search keyword matched against filename or storage path; required" + }, + "videoDelete": { + "key": "Video storage path; also removes its thumbnail and HLS artifacts" + }, + "videoBatchDelete": { + "keys": "Array of video keys; up to 100 per request. Thumbnails and HLS artifacts are also removed" + }, + "fileDelete": { + "key": "File storage path, e.g. files/2026/10/xxx.pdf" + }, + "fileBatchDelete": { + "keys": "Array of file keys; up to 100 per request" + }, "twikoo": { "image": "Image file", "token": "API token" @@ -955,9 +1014,9 @@ "sendFailed": "Request failed", "networkError": "Network error or unreachable", "notesTitle": "Notes", - "noteSuccess": "200 means success with JSON body", + "noteSuccess": "200 means success with a JSON body", "noteError": "4xx / 5xx means failure; check error field", - "noteLimit": "Upload limit: up to 10 files, 10 MB each" + "noteLimit": "Upload limit: up to 10 files; videos up to 500 MB each, files up to 100 MB each" }, "docs": { "upload": { @@ -980,6 +1039,46 @@ "title": "Batch delete", "desc": "Delete multiple images; request body is a keys array." }, + "videoUpload": { + "title": "Upload videos", + "desc": "Upload MP4 / WebM / MOV / MKV and more, up to 10 files and 500 MB each. A thumbnail is generated independently; transcode=1 starts asynchronous HLS transcoding." + }, + "videoList": { + "title": "List videos", + "desc": "Use kind=video to list videos, including untranscoded, completed and failed items. processing=1 returns only queued or running jobs." + }, + "videoDelete": { + "title": "Delete video", + "desc": "Delete a video by key, including its thumbnail and generated HLS playlist and segments." + }, + "videoSearch": { + "title": "Search videos", + "desc": "Search by filename or storage path with pagination, backend, upload source and tag filters; q is required." + }, + "videoBatchDelete": { + "title": "Batch delete videos", + "desc": "Delete up to 100 videos at once, including each video's thumbnail, HLS playlist and segments." + }, + "fileUpload": { + "title": "Upload files", + "desc": "Upload any file, up to 10 files and 100 MB each. HTML, HTM and SVG are blocked." + }, + "fileList": { + "title": "List files", + "desc": "Use kind=file to paginate file-bed entries with search, storage, source and tag filters." + }, + "fileDelete": { + "title": "Delete file", + "desc": "Delete one file by its storage key. This action cannot be undone." + }, + "fileSearch": { + "title": "Search files", + "desc": "Search by filename or storage path with kind=file, pagination, backend, upload source and tag filters." + }, + "fileBatchDelete": { + "title": "Batch delete files", + "desc": "Delete up to 100 files at once. The response reports deleted and failed keys separately." + }, "twikoo": { "title": "Twikoo-compatible upload", "desc": "Twikoo / EasyImage 2.0 protocol; stored under images/. Fields: image + token." @@ -1058,6 +1157,14 @@ "previewKey": "Video path", "transcodeNone": "Not transcoded", "transcodeDone": "Done (m3u8)", + "transcodeErrorFfmpegMissing": "No FFmpeg", + "transcodeErrorPermissionDenied": "File permission denied", + "transcodeErrorUnknown": "Unknown reason", + "downloadMp4": "Download MP4", + "downloadMp4Title": "Download MP4 video", + "downloadMp4Confirm": "The video segments will be merged into an MP4 file. Continue?", + "downloadMp4Working": "Merging video segments, please wait…", + "downloadMp4Failed": "Video merge failed. Please try again later.", "uploadTitle": "Drop videos here to upload", "uploadHint": "or click to choose video files, up to 10 at a time", "uploadFormats": "MP4 / WebM / MOV / MKV and more, up to 100MB per file", diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index a91c3b5..7f6aefc 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -8,6 +8,7 @@ "loadingSession": "正在验证登录状态…", "save": "保存", "cancel": "取消", + "continue": "继续", "copy": "复制", "delete": "删除", "edit": "编辑", @@ -546,7 +547,20 @@ "storageId": "存储标识", "storageType": "存储类型", "badgeLocal": "本地", - "defaultStorageTip": "默认存储提示:新上传的图片将默认保存到「{name}」。可点击其他后端的「设为默认」进行修改。", + "defaultStorageTip": "新上传的所有图片、视频、文件将默认保存到「{name}」。可点击其他后端的「设为默认」进行修改。", + "clearStorage": "清空存储", + "clearStorageTitle": "清空我的存储", + "clearStorageDescription": "永久删除当前账号上传的所有图片、视频、文件和缓存,不影响其他用户的数据。", + "clearStorageWarning": "高危风险:此操作会清空存储中的所有文件和缓存,无法撤销!", + "clearStorageScope": "只会清除当前登录用户的数据和文件。其他用户的数据、账户、存储配置和全站备份不会被删除。", + "clearStorageFinalTitle": "最终确认", + "clearStorageInstruction": "请输入“我已确认”以继续。操作开始后无法取消或撤销。", + "clearStoragePlaceholder": "我已确认", + "clearStoragePhrase": "我已确认", + "clearStorageConfirm": "永久清空", + "clearStorageSuccess": "已清空 {files} 个文件和 {cache} 个临时缓存,存储空间已释放", + "clearStoragePartial": "{failed}/{total} 个文件清理失败,请重试", + "clearStorageFailed": "清空存储失败,请稍后重试", "supportedTitle": "支持的存储后端", "supportedSubtitle": "PicHost 支持多种存储后端,满足不同场景需求", "supportedLocalDesc": "适合小型部署和个人使用", @@ -554,7 +568,7 @@ "supportedS3Title": "S3 兼容", "supportedS3Desc": "兼容 Amazon S3 协议的存储服务", "supportedTelegramDesc": "用 Telegram 频道/群组存图,适合轻量与无限容量场景", - "footerTip": "默认存储后端将自动接收所有新上传的图片", + "footerTip": "默认存储后端将自动接收所有新上传的图片、视频和文件", "statTotal": "总量", "statUsed": "已用", "statFree": "剩余", @@ -585,28 +599,28 @@ "manageStorageHint": "配置 R2 / S3 / COS 等多后端与直链策略", "backup": { "sectionTitle": "备份与迁移", - "sectionSubtitle": "整站备份恢复与跨后端图片迁移", + "sectionSubtitle": "整站备份恢复与跨后端文件迁移", "siteTitle": "站点备份与恢复", "siteSubtitle": "用于换机、灾备与整站恢复", - "siteFooterHint": "备份包含数据库与图片,不包含 secret_json 明文。", + "siteFooterHint": "备份包含数据库与全部文件,不包含 secret_json 明文。", "exportTitle": "创建站点备份", - "exportSubtitle": "打包数据库与全部图片为 .phost.tar.gz", + "exportSubtitle": "打包数据库与全部文件为 .phost.tar.gz", "exportAction": "开始备份", "exportStarted": "备份任务已开始", "exportFailed": "创建备份失败", "exportPathHint": "导出文件保存在 data/backups/", "exportIncludesTitle": "备份包含", "exportIncludesDb": "数据库快照(pichost.db)", - "exportIncludesImages": "全部图片文件({count} 张)", + "exportIncludesImages": "全部图片、视频和文件({count} 个)", "exportExcludesSecrets": "不含云存储 secret.json 明文", "exportConcurrencyHint": "同时仅可运行一个备份或迁移任务", "estimateLoading": "正在估算体积…", - "estimateImageCountLabel": "图片总数", + "estimateImageCountLabel": "文件总数", "estimatePackageSizeLabel": "预计备份包", "estimateDatabaseSnapshotLabel": "数据库快照", "estimateStoragePathLabel": "保存位置", "estimateStoragePathValue": "data/backups/", - "estimateImages": "图片 {count} 张 · {size}", + "estimateImages": "文件 {count} 个 · {size}", "estimateDatabase": "数据库约 {size}", "estimateTotal": "合计约 {size}", "restoreTitle": "从备份恢复", @@ -623,44 +637,44 @@ "restoreConfirm": "确认恢复", "restoreConfirmTitle": "确认恢复站点", "restoreConfirmSource": "来源:{name}", - "restoreOverwriteWarning": "覆盖模式将替换冲突图片与数据库,请确认已做好当前站点备份", + "restoreOverwriteWarning": "覆盖模式将替换冲突文件与数据库,请确认已做好当前站点备份", "restoreSelectRequired": "请选择或上传备份文件", "restoreStarted": "恢复任务已开始", "restoreFailed": "恢复失败", "conflictMode": "冲突策略", - "conflictSkip": "跳过已存在图片", - "conflictOverwrite": "覆盖已存在图片与数据库", + "conflictSkip": "跳过已存在文件", + "conflictOverwrite": "覆盖已存在文件与数据库", "syncTitle": "跨后端同步", "syncMigrateTitle": "跨后端迁移", - "syncMigrateSubtitle": "在已配置后端之间迁移图片", - "syncSubtitle": "在已配置的后端之间拷贝图片并更新索引", + "syncMigrateSubtitle": "在已配置后端之间迁移文件", + "syncSubtitle": "在已配置的后端之间拷贝文件并更新索引", "syncFrom": "源后端", "syncTo": "目标后端", - "syncPendingImages": "待迁移图片", + "syncPendingImages": "待迁移文件", "syncPendingSize": "预计体积", "syncIncludesTitle": "同步说明", - "syncIncludesCopy": "拷贝源端图片文件到目标后端", + "syncIncludesCopy": "拷贝源端图片、视频和文件到目标后端", "syncIncludesIndex": "更新索引中的 storage_backend_id", "syncIncludesConfig": "不修改站点配置与其它后端数据", "syncDryRun": "仅预览 Dry Run", "syncSetDefaultAfter": "迁移后设为默认后端", "syncStartMigrate": "开始迁移", "syncPreviewOnly": "预览", - "syncFooterHint": "仅迁移图片文件与索引,不影响站点配置。", + "syncFooterHint": "仅迁移文件与索引,不影响站点配置。", "syncDeleteSource": "同步后删除源端文件", "syncSetDefault": "同步后将目标设为默认", "syncPreview": "预检并开始", "syncConfirm": "开始同步", "syncConfirmTitle": "确认跨后端同步", - "syncDeleteWarning": "将删除源后端上的图片文件,此操作不可撤销", + "syncDeleteWarning": "将删除源后端上的文件,此操作不可撤销", "syncSelectRequired": "请选择源与目标后端", "syncSameBackend": "源与目标不能相同", "syncStarted": "同步任务已开始", "syncFailed": "同步失败", "previewFailed": "预检失败", - "previewImages": "图片数量", - "previewNew": "新增图片", - "previewConflicts": "冲突图片", + "previewImages": "文件数量", + "previewNew": "新增文件", + "previewConflicts": "冲突文件", "previewCreated": "备份时间", "previewSize": "数据量", "jobsTitle": "最近任务", @@ -672,6 +686,8 @@ "colType": "类型", "colStatus": "状态", "colProgress": "进度", + "progressUnitMedia": "媒体记录", + "progressUnitObjects": "存储对象", "colMessage": "说明", "colCreated": "创建时间", "colActions": "操作", @@ -691,8 +707,8 @@ "jobRunning": "已有任务正在运行,请稍后再试", "migrateIn": "迁入", "migrateOut": "迁出", - "migrateInHint": "从默认后端拷贝图片到此后端", - "migrateOutHint": "将图片从此后端迁出", + "migrateInHint": "从默认后端拷贝文件到此后端", + "migrateOutHint": "将文件从此后端迁出", "syncDraftApplied": "已填入同步表单,请确认后开始", "syncNeedTwoBackends": "至少需要两个存储后端才能同步", "selectBackend": "选择后端", @@ -869,7 +885,7 @@ "envOverrideTitle": "Token 由环境变量覆盖", "envOverrideDesc": "修改 API_UPLOAD_TOKEN 后需重启服务;后台无法重新生成。", "personalTitle": "个人 API Token", - "personalDesc": "此 Token 仅用于您自己的脚本上传,上传的图片归属您的账号。", + "personalDesc": "此 Token 仅用于您自己的脚本上传,上传的图片、视频或文件归属您的账号。", "notConfiguredTitle": "尚未配置 Token", "notConfiguredDesc": "首次打开会自动创建 Token,或点击「重新生成」。", "regenerate": "重新生成", @@ -911,6 +927,8 @@ "responseExample": "响应示例", "categories": { "image": "图片接口", + "video": "视频接口", + "file": "文件接口", "extension": "插件接口", "system": "系统通用" }, @@ -938,6 +956,47 @@ "batchDelete": { "keys": "要删除的图片 key 数组" }, + "videoUpload": { + "video": "上传视频主字段,单文件最大 500 MB", + "file": "兼容字段,可代替 video", + "files": "兼容字段,可一次上传多个视频", + "transcode": "设为 1 后异步转码为 HLS;不设置则只保留原视频并生成缩略图", + "transcodePreset": "可选转码档位:copy(直接封装)、veryfast(速度)、medium(平衡)、slow(质量)、compress(目标码率压缩)", + "transcodeBitratePercent": "仅 compress 档使用,目标码率为源码率的 10–100% 整数;省略时使用服务端当前设置", + "token": "可选,API Token 也可通过 Auth-Token 请求头传递" + }, + "fileUpload": { + "file": "上传文件主字段,单文件最大 100 MB;禁止 HTML / HTM / SVG", + "files": "兼容字段,可一次上传多个文件", + "token": "可选,API Token 也可通过 Auth-Token 请求头传递" + }, + "mediaList": { + "kindVideo": "固定为 video,获取视频列表", + "kindFile": "固定为 file,获取文件列表", + "page": "页码,从 1 开始", + "limit": "每页条数,默认 20,最大 100", + "q": "按文件名或路径关键词搜索", + "processing": "视频专用;设为 1 时只返回排队中或转码中的视频", + "uploadSource": "上传来源:web 或 api", + "tagIds": "按标签筛选,逗号分隔标签 ID", + "tagMode": "多标签筛选时使用 or 或 and,默认 or", + "untagged": "设为 1 时仅返回未标记内容" + }, + "mediaSearch": { + "q": "搜索关键词,匹配文件名或存储路径;不能为空" + }, + "videoDelete": { + "key": "视频存储路径;删除时同时清理缩略图与 HLS 文件" + }, + "videoBatchDelete": { + "keys": "要删除的视频 key 数组,单次最多 100 个;同时清理缩略图和 HLS 文件" + }, + "fileDelete": { + "key": "文件存储路径,如 files/2026/10/xxx.pdf" + }, + "fileBatchDelete": { + "keys": "要删除的文件 key 数组,单次最多 100 个" + }, "twikoo": { "image": "上传图片", "token": "API Token" @@ -960,7 +1019,7 @@ "notesTitle": "响应说明", "noteSuccess": "200 表示请求成功,返回 JSON 数据", "noteError": "4xx / 5xx 表示请求失败,查看 error 字段", - "noteLimit": "上传限制:单次最多 10 张,单张最大 10 MB" + "noteLimit": "上传限制:单次最多 10 个;视频单文件最大 500 MB,文件单文件最大 100 MB" }, "docs": { "upload": { @@ -983,6 +1042,46 @@ "title": "批量删除", "desc": "一次删除多张图片,请求体为 keys 数组。" }, + "videoUpload": { + "title": "上传视频", + "desc": "上传 MP4 / WebM / MOV / MKV 等视频,单次最多 10 个、单文件最大 500 MB。上传后独立生成缩略图;transcode=1 时再异步转码为 HLS。" + }, + "videoList": { + "title": "获取视频列表", + "desc": "设置 kind=video 获取视频。默认包含未转码、已完成和失败条目;processing=1 仅查看排队中或转码中的任务。" + }, + "videoDelete": { + "title": "删除视频", + "desc": "通过视频 key 删除视频,同时清理缩略图和已生成的 HLS 播放列表与分片。" + }, + "videoSearch": { + "title": "搜索视频", + "desc": "按文件名或存储路径搜索视频,支持分页、存储后端、上传来源和标签筛选;q 必填。" + }, + "videoBatchDelete": { + "title": "批量删除视频", + "desc": "一次删除最多 100 个视频;每个视频的缩略图、HLS 播放列表与分片会一并清理。" + }, + "fileUpload": { + "title": "上传文件", + "desc": "上传任意文件,单次最多 10 个、单文件最大 100 MB;禁止上传 HTML、HTM 和 SVG。" + }, + "fileList": { + "title": "获取文件列表", + "desc": "设置 kind=file 分页获取文件床内容,支持搜索、存储后端、来源和标签筛选。" + }, + "fileDelete": { + "title": "删除文件", + "desc": "通过文件 key 删除单个文件,操作不可恢复。" + }, + "fileSearch": { + "title": "搜索文件", + "desc": "按文件名或存储路径搜索文件,设置 kind=file;支持分页、存储后端、上传来源和标签筛选。" + }, + "fileBatchDelete": { + "title": "批量删除文件", + "desc": "一次删除最多 100 个文件,响应会分别返回删除成功和失败的 key。" + }, "twikoo": { "title": "twikoo 兼容上传", "desc": "兼容 Twikoo / EasyImage 2.0 协议,图片存入 images/ 目录;字段 image + token。" @@ -1061,6 +1160,14 @@ "previewKey": "视频路径", "transcodeNone": "未转码", "transcodeDone": "已完成(m3u8)", + "transcodeErrorFfmpegMissing": "无 FFmpeg", + "transcodeErrorPermissionDenied": "无文件权限", + "transcodeErrorUnknown": "原因未知", + "downloadMp4": "下载 MP4", + "downloadMp4Title": "下载 MP4 视频", + "downloadMp4Confirm": "将会合并视频切片到 MP4,是否继续?", + "downloadMp4Working": "正在合并视频切片,请稍候…", + "downloadMp4Failed": "视频合并失败,请稍后重试", "uploadTitle": "拖拽视频到此处上传", "uploadHint": "或点击选择视频文件,单次最多 10 个", "uploadFormats": "支持 MP4 / WebM / MOV / MKV 等,单个文件最大 100MB", diff --git a/public/video-placeholder.svg b/public/video-placeholder.svg new file mode 100644 index 0000000..1645914 --- /dev/null +++ b/public/video-placeholder.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/server/api/admin/backup/estimate.get.ts b/server/api/admin/backup/estimate.get.ts index 4e8cdc2..05d2bea 100644 --- a/server/api/admin/backup/estimate.get.ts +++ b/server/api/admin/backup/estimate.get.ts @@ -1,12 +1,23 @@ import { requireAdminAuth } from '../../../utils/access' import { getBackupExportEstimate } from '../../../utils/backup/stats' import { getBackupsDir, isSafeBackupFilename } from '../../../utils/backup/paths' +import { listAllBackupJobsForUser } from '../../../utils/backup/jobs' +import { basename, join } from 'node:path' import { promises as fs } from 'node:fs' -import { join } from 'node:path' export default defineEventHandler(async (event) => { - await requireAdminAuth(event) + const user = await requireAdminAuth(event) const estimate = await getBackupExportEstimate() + const allowedArchives = new Set( + listAllBackupJobsForUser(user.id).flatMap((job) => { + const names: string[] = [] + if (job.outputPath) names.push(basename(job.outputPath)) + if (typeof job.payload.archivePath === 'string') { + names.push(basename(job.payload.archivePath)) + } + return names + }) + ) const packages = await (async () => { try { @@ -15,6 +26,7 @@ export default defineEventHandler(async (event) => { const items = await Promise.all( entries .filter(entry => entry.isFile() && isSafeBackupFilename(entry.name)) + .filter(entry => allowedArchives.has(entry.name)) .map(async (entry) => { const full = join(getBackupsDir(), entry.name) const fileStat = await fs.stat(full) diff --git a/server/api/admin/backup/export.post.ts b/server/api/admin/backup/export.post.ts index 337b827..3174818 100644 --- a/server/api/admin/backup/export.post.ts +++ b/server/api/admin/backup/export.post.ts @@ -5,14 +5,14 @@ import { getBackupExportEstimate } from '../../../utils/backup/stats' import { startFullExportJob } from '../../../utils/backup/runner' export default defineEventHandler(async (event) => { - await requireAdminAuth(event) + const user = await requireAdminAuth(event) const config = useRuntimeConfig(event) const appVersion = String(config.appVersion ?? '0.0.0') const estimate = await getBackupExportEstimate() try { - const job = startFullExportJob(appVersion) + const job = startFullExportJob(appVersion, user.id) return { job: serializeBackupJob(job), estimate diff --git a/server/api/admin/backup/jobs/[id].get.ts b/server/api/admin/backup/jobs/[id].get.ts index 5221efd..04d12db 100644 --- a/server/api/admin/backup/jobs/[id].get.ts +++ b/server/api/admin/backup/jobs/[id].get.ts @@ -4,13 +4,13 @@ import { serializeBackupJob } from '../../../../utils/backup/api' import { getBackupJob } from '../../../../utils/backup/jobs' export default defineEventHandler(async (event) => { - await requireAdminAuth(event) + const user = await requireAdminAuth(event) const id = getRouterParam(event, 'id') if (!id) { createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400) } - const job = getBackupJob(id) + const job = getBackupJob(id, user.id) if (!job) { createApiError(event, 'INVALID_REQUEST', '任务不存在', 404) } diff --git a/server/api/admin/backup/jobs/[id]/cancel.post.ts b/server/api/admin/backup/jobs/[id]/cancel.post.ts index 100aeb7..4e8f869 100644 --- a/server/api/admin/backup/jobs/[id]/cancel.post.ts +++ b/server/api/admin/backup/jobs/[id]/cancel.post.ts @@ -5,16 +5,19 @@ import { getBackupJob } from '../../../../../utils/backup/jobs' import { cancelBackupJob } from '../../../../../utils/backup/runner' export default defineEventHandler(async (event) => { - await requireAdminAuth(event) + const user = await requireAdminAuth(event) const id = getRouterParam(event, 'id') if (!id) { createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400) } + if (!getBackupJob(id, user.id)) { + createApiError(event, 'INVALID_REQUEST', '任务不存在', 404) + } if (!cancelBackupJob(id)) { createApiError(event, 'INVALID_REQUEST', '只能取消进行中的任务', 400) } - const job = getBackupJob(id) + const job = getBackupJob(id, user.id) return { job: job ? serializeBackupJob(job) : null } }) diff --git a/server/api/admin/backup/jobs/[id]/download.get.ts b/server/api/admin/backup/jobs/[id]/download.get.ts index 2b6b814..7f32b4a 100644 --- a/server/api/admin/backup/jobs/[id]/download.get.ts +++ b/server/api/admin/backup/jobs/[id]/download.get.ts @@ -6,13 +6,13 @@ import { getBackupJob } from '../../../../../utils/backup/jobs' import { BACKUP_ARCHIVE_EXT } from '../../../../../utils/backup/paths' export default defineEventHandler(async (event) => { - await requireAdminAuth(event) + const user = await requireAdminAuth(event) const id = getRouterParam(event, 'id') if (!id) { createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400) } - const job = getBackupJob(id) + const job = getBackupJob(id, user.id) if (!job || job.type !== 'full_export' || job.status !== 'done' || !job.outputPath) { createApiError(event, 'INVALID_REQUEST', '可下载的备份不存在', 404) } diff --git a/server/api/admin/backup/jobs/[id]/retry.post.ts b/server/api/admin/backup/jobs/[id]/retry.post.ts index 31f1eaa..901efc7 100644 --- a/server/api/admin/backup/jobs/[id]/retry.post.ts +++ b/server/api/admin/backup/jobs/[id]/retry.post.ts @@ -5,13 +5,13 @@ import { serializeBackupJob } from '../../../../../utils/backup/api' import { getBackupJob } from '../../../../../utils/backup/jobs' export default defineEventHandler(async (event) => { - await requireAdminAuth(event) + const user = await requireAdminAuth(event) const id = getRouterParam(event, 'id') if (!id) { createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400) } - const job = getBackupJob(id) + const job = getBackupJob(id, user.id) if (!job || job.type !== 'backend_sync') { createApiError(event, 'INVALID_REQUEST', '任务不存在', 404) } @@ -23,6 +23,6 @@ export default defineEventHandler(async (event) => { createApiError(event, 'INVALID_REQUEST', message, 400) } - const updated = getBackupJob(id) + const updated = getBackupJob(id, user.id) return { job: updated ? serializeBackupJob(updated) : null } }) diff --git a/server/api/admin/backup/jobs/index.get.ts b/server/api/admin/backup/jobs/index.get.ts index 2f939f4..0c1e8db 100644 --- a/server/api/admin/backup/jobs/index.get.ts +++ b/server/api/admin/backup/jobs/index.get.ts @@ -9,21 +9,21 @@ import { import { isBackupRunnerBusy } from '../../../../utils/backup/runner' export default defineEventHandler(async (event) => { - await requireAdminAuth(event) + const user = await requireAdminAuth(event) if (getRunningBackupJob() && !isBackupRunnerBusy()) { recoverInterruptedBackupJobs() } const query = getQuery(event) const pageSize = Math.min(50, Math.max(1, Number(query.pageSize) || 10)) const pageNum = Math.max(1, Number(query.pageNum) || Number(query.page) || 1) - const { jobs, total } = listBackupJobsPage(pageNum, pageSize) + const { jobs, total } = listBackupJobsPage(pageNum, pageSize, user.id) const totalPages = Math.max(1, Math.ceil(total / pageSize)) const page = total > 0 ? Math.min(pageNum, totalPages) : 1 const slice = page === pageNum ? jobs - : listBackupJobsPage(page, pageSize).jobs + : listBackupJobsPage(page, pageSize, user.id).jobs - const activeJob = getActiveBackupJob() + const activeJob = getActiveBackupJob(user.id) return { jobs: slice.map(serializeBackupJob), diff --git a/server/api/admin/backup/restore.post.ts b/server/api/admin/backup/restore.post.ts index a0c22dc..3445e38 100644 --- a/server/api/admin/backup/restore.post.ts +++ b/server/api/admin/backup/restore.post.ts @@ -4,13 +4,14 @@ import { createApiError } from '../../../utils/api-error' import { serializeBackupJob } from '../../../utils/backup/api' import { previewFullRestore } from '../../../utils/backup/full-restore' import { startFullRestoreJob } from '../../../utils/backup/runner' +import { listAllBackupJobsForUser } from '../../../utils/backup/jobs' import { BACKUP_ARCHIVE_EXT, getBackupsDir, getBackupStagingDir, isSafeBackupFilename } from '../../../utils/backup/paths' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { promises as fs } from 'node:fs' interface RestoreBody { @@ -19,7 +20,7 @@ interface RestoreBody { dryRun?: boolean } -async function resolveRestoreInput(event: H3Event): Promise<{ +async function resolveRestoreInput(event: H3Event, userId: number): Promise<{ archivePath: string body: RestoreBody cleanup?: () => Promise @@ -71,6 +72,19 @@ async function resolveRestoreInput(event: H3Event): Promise<{ if (!body?.archiveName || !isSafeBackupFilename(body.archiveName)) { createApiError(event, 'INVALID_REQUEST', '请选择有效的备份文件', 400) } + const allowedArchives = new Set( + listAllBackupJobsForUser(userId).flatMap((job) => { + const names: string[] = [] + if (job.outputPath) names.push(basename(job.outputPath)) + if (typeof job.payload.archivePath === 'string') { + names.push(basename(job.payload.archivePath)) + } + return names + }) + ) + if (!allowedArchives.has(body.archiveName)) { + createApiError(event, 'INVALID_REQUEST', '备份文件不存在', 404) + } const archivePath = join(getBackupsDir(), body.archiveName) try { await fs.access(archivePath) @@ -81,9 +95,9 @@ async function resolveRestoreInput(event: H3Event): Promise<{ } export default defineEventHandler(async (event) => { - await requireAdminAuth(event) + const user = await requireAdminAuth(event) - const { archivePath, body, cleanup } = await resolveRestoreInput(event) + const { archivePath, body, cleanup } = await resolveRestoreInput(event, user.id) const conflictMode = body.conflictMode === 'skip' ? 'skip' : 'overwrite' @@ -97,7 +111,7 @@ export default defineEventHandler(async (event) => { } try { - const job = startFullRestoreJob(archivePath, conflictMode) + const job = startFullRestoreJob(archivePath, conflictMode, user.id) return { job: serializeBackupJob(job) } } catch (error) { if (error instanceof Error && error.message === 'BACKUP_JOB_RUNNING') { diff --git a/server/api/admin/storage/sync.post.ts b/server/api/admin/storage/sync.post.ts index 6d2f48c..6da82a7 100644 --- a/server/api/admin/storage/sync.post.ts +++ b/server/api/admin/storage/sync.post.ts @@ -13,7 +13,7 @@ interface SyncBody { } export default defineEventHandler(async (event) => { - await requireAdminAuth(event) + const user = await requireAdminAuth(event) const body = await readBody(event) const fromBackendId = body?.fromBackendId?.trim() ?? '' @@ -36,7 +36,7 @@ export default defineEventHandler(async (event) => { return { preview } } - const job = startBackendSyncJob(options) + const job = startBackendSyncJob(options, user.id) return { job: serializeBackupJob(job) } } catch (error) { if (error instanceof Error) { diff --git a/server/api/files/hls-mp4.post.ts b/server/api/files/hls-mp4.post.ts new file mode 100644 index 0000000..2775403 --- /dev/null +++ b/server/api/files/hls-mp4.post.ts @@ -0,0 +1,81 @@ +import { createReadStream, promises as fs } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { assertImageOwnership, requireUserAuth } from '../../utils/access' +import { createApiError } from '../../utils/api-error' +import { buildAttachmentDisposition } from '../../utils/file-bed' +import { getImageIndexRow } from '../../utils/image-index' +import { validateVideoKey } from '../../utils/image-key' +import { logException, logInfo } from '../../utils/logger' +import { + cleanupExpiredHlsDownloads, + markHlsDownloadForCleanup, + remuxHlsToMp4 +} from '../../utils/transcode' + +interface DownloadBody { + key?: string +} + +export default defineEventHandler(async (event) => { + const user = await requireUserAuth(event) + + const body = await readBody(event).catch(() => null) + const key = body?.key + if (!key || !validateVideoKey(key)) { + createApiError(event, 'INVALID_VIDEO_KEY', '无效的视频路径', 400) + } + + const row = getImageIndexRow(key) + if (!row?.hls_key) { + createApiError(event, 'HLS_NOT_FOUND', 'HLS 视频不存在或尚未转码完成', 404) + } + await assertImageOwnership(event, row.user_id) + + void cleanupExpiredHlsDownloads() + const workDir = join(tmpdir(), `pichost-hls-download-${user.id}-${randomUUID()}`) + try { + await fs.mkdir(workDir, { recursive: true }) + await markHlsDownloadForCleanup(workDir) + } catch (error) { + await fs.rm(workDir, { recursive: true, force: true }).catch(() => {}) + throw error + } + + let outputPath: string + try { + outputPath = await remuxHlsToMp4(row.hls_key, workDir) + } catch (error) { + await fs.rm(workDir, { recursive: true, force: true }).catch(() => {}) + logException('HLS download remux failed', error, { key }) + createApiError(event, 'HLS_REMUX_FAILED', '视频合并失败,请稍后重试', 500) + } + + let outputSize: number + try { + const stat = await fs.stat(outputPath) + if (!stat.isFile() || stat.size === 0) { + createApiError(event, 'HLS_REMUX_FAILED', '视频合并失败,请稍后重试', 500) + } + outputSize = stat.size + await markHlsDownloadForCleanup(workDir) + } catch (error) { + await fs.rm(workDir, { recursive: true, force: true }).catch(() => {}) + throw error + } + + const baseName = row.original_name.replace(/\.[^.]+$/, '') || 'video' + setResponseHeader(event, 'Content-Type', 'video/mp4') + setResponseHeader(event, 'Content-Length', outputSize) + setResponseHeader(event, 'Cache-Control', 'no-store') + setResponseHeader( + event, + 'Content-Disposition', + buildAttachmentDisposition(`${baseName}.mp4`) + ) + + logInfo('HLS download remuxed', { key, size: outputSize }) + await sendStream(event, createReadStream(outputPath)) + await fs.rm(workDir, { recursive: true, force: true }).catch(() => {}) +}) diff --git a/server/api/files/index.get.ts b/server/api/files/index.get.ts index 38c35a0..1625391 100644 --- a/server/api/files/index.get.ts +++ b/server/api/files/index.get.ts @@ -1,3 +1,4 @@ +import type { H3Event } from 'h3' import type { FileListResponse } from '~/types/file' import type { MediaType, TranscodeStatus } from '../../utils/storage/types' import { requireApiOrAdminAuth, getImageUserFilter } from '../../utils/access' @@ -21,7 +22,10 @@ import { } from '../../utils/image-query' import { listImages, searchImages } from '../../utils/storage' -export default defineEventHandler(async (event) => { +export async function handleFileList( + event: H3Event, + forcedKind?: MediaType +): Promise { await requireApiOrAdminAuth(event) const query = getQuery(event) @@ -51,7 +55,7 @@ export default defineEventHandler(async (event) => { } // 文件床按类型分工:视频库看 media_type=video,文件库看 media_type=file - const kindRaw = typeof query.kind === 'string' ? query.kind.trim() : '' + const kindRaw = forcedKind ?? (typeof query.kind === 'string' ? query.kind.trim() : '') if (kindRaw && kindRaw !== 'file' && kindRaw !== 'video') { createApiError(event, 'INVALID_REQUEST', '无效的文件类型筛选', 400) } @@ -111,6 +115,7 @@ export default defineEventHandler(async (event) => { ...(uploadSource === 'web' || uploadSource === 'api' ? { uploadSource } : {}), transcodeStatus: stored.transcodeStatus ?? 'none', transcodeProgress: stored.transcodeProgress ?? 0, + ...(stored.transcodeError ? { transcodeError: stored.transcodeError } : {}), ...(stored.transcodePreset ? { transcodePreset: stored.transcodePreset } : {}), ...(stored.sourceDeleted ? { sourceDeleted: true } : {}), ...(stored.thumbKey @@ -138,4 +143,6 @@ export default defineEventHandler(async (event) => { } return response -}) +} + +export default defineEventHandler(handleFileList) diff --git a/server/api/files/search.get.ts b/server/api/files/search.get.ts new file mode 100644 index 0000000..2b375b9 --- /dev/null +++ b/server/api/files/search.get.ts @@ -0,0 +1,16 @@ +import { handleFileList } from './index.get' +import { createApiError } from '../../utils/api-error' + +export default defineEventHandler(async (event) => { + const query = getQuery(event) + const search = typeof query.q === 'string' ? query.q.trim() : '' + if (!search) { + createApiError(event, 'INVALID_REQUEST', '搜索关键词不能为空', 400) + } + + const kind = typeof query.kind === 'string' ? query.kind.trim() : 'file' + if (kind !== 'file' && kind !== 'video') { + createApiError(event, 'INVALID_REQUEST', '无效的文件类型筛选', 400) + } + return handleFileList(event) +}) diff --git a/server/api/user/storage/purge.post.ts b/server/api/user/storage/purge.post.ts new file mode 100644 index 0000000..11e29d7 --- /dev/null +++ b/server/api/user/storage/purge.post.ts @@ -0,0 +1,31 @@ +import { requireUserAuth } from '../../../utils/access' +import { createApiError } from '../../../utils/api-error' +import { logInfo } from '../../../utils/logger' +import { clearUserStorage } from '../../../utils/user-storage' + +export default defineEventHandler(async (event) => { + const user = await requireUserAuth(event) + const body = await readBody<{ confirmation?: string }>(event).catch(() => null) + if (!['我已确认', 'I confirm'].includes(body?.confirmation?.trim() ?? '')) { + createApiError(event, 'INVALID_REQUEST', '请输入“我已确认”以继续', 400) + } + + try { + const result = await clearUserStorage(user.id) + logInfo('user storage cleared', { + userId: user.id, + deletedMedia: result.deletedMedia, + deletedObjects: result.deletedObjects, + failed: result.failed.length + }) + return result + } catch (error) { + if (error instanceof Error && error.message === 'USER_STORAGE_CLEAR_IN_PROGRESS') { + createApiError(event, 'CONFLICT', '清空存储任务正在执行,请勿重复操作', 409) + } + if (error instanceof Error && error.message === 'USER_STORAGE_CLEAR_ACTIVE_JOB') { + createApiError(event, 'CONFLICT', '当前有备份或迁移任务正在运行,请完成后再清空存储', 409) + } + throw error + } +}) diff --git a/server/api/videos/search.get.ts b/server/api/videos/search.get.ts new file mode 100644 index 0000000..309c382 --- /dev/null +++ b/server/api/videos/search.get.ts @@ -0,0 +1,11 @@ +import { handleFileList } from '../files/index.get' +import { createApiError } from '../../utils/api-error' + +export default defineEventHandler(async (event) => { + const query = getQuery(event) + const search = typeof query.q === 'string' ? query.q.trim() : '' + if (!search) { + createApiError(event, 'INVALID_REQUEST', '搜索关键词不能为空', 400) + } + return handleFileList(event, 'video') +}) diff --git a/server/api/videos/upload.post.ts b/server/api/videos/upload.post.ts index 819c537..ee9ce43 100644 --- a/server/api/videos/upload.post.ts +++ b/server/api/videos/upload.post.ts @@ -10,7 +10,12 @@ import { MAX_FILES_PER_UPLOAD } from '../../utils/constants' import { createApiError } from '../../utils/api-error' import { checkUploadRateLimit } from '../../utils/rate-limit' import { processSingleVideoUpload } from '../../utils/file-bed' -import { enqueueTranscode } from '../../utils/transcode' +import { + enqueueTranscode, + MAX_TRANSCODE_BITRATE_PERCENT, + MIN_TRANSCODE_BITRATE_PERCENT, + parseTranscodePreset +} from '../../utils/transcode' import { getFileBedAccessMode } from '../../utils/env' export default defineEventHandler(async (event) => { @@ -37,6 +42,36 @@ export default defineEventHandler(async (event) => { ) : false + const presetField = formData.find(part => part.name === 'transcodePreset') + const presetRaw = presetField?.data?.length + ? new TextDecoder().decode(presetField.data).trim() + : '' + const transcodePreset = presetRaw ? parseTranscodePreset(presetRaw) : undefined + if (presetRaw && !transcodePreset) { + createApiError(event, 'INVALID_REQUEST', '无效的转码质量档位', 400) + } + + const bitrateField = formData.find(part => part.name === 'transcodeBitratePercent') + const bitrateRaw = bitrateField?.data?.length + ? new TextDecoder().decode(bitrateField.data).trim() + : '' + const transcodeBitratePercent = bitrateRaw ? Number(bitrateRaw) : undefined + if ( + bitrateRaw + && ( + !Number.isInteger(transcodeBitratePercent) + || transcodeBitratePercent! < MIN_TRANSCODE_BITRATE_PERCENT + || transcodeBitratePercent! > MAX_TRANSCODE_BITRATE_PERCENT + ) + ) { + createApiError( + event, + 'INVALID_REQUEST', + `转码目标码率需为 ${MIN_TRANSCODE_BITRATE_PERCENT}–${MAX_TRANSCODE_BITRATE_PERCENT} 的整数`, + 400 + ) + } + const fileParts = formData.filter( part => (part.name === 'file' || part.name === 'files' || part.name === 'video') && part.data?.length @@ -82,7 +117,10 @@ export default defineEventHandler(async (event) => { if (wantTranscode) { for (const item of items) { - enqueueTranscode(item.key) + enqueueTranscode(item.key, { + ...(transcodePreset ? { preset: transcodePreset } : {}), + ...(transcodeBitratePercent !== undefined ? { bitratePercent: transcodeBitratePercent } : {}) + }) } } diff --git a/server/cli/backup-export.mjs b/server/cli/backup-export.mjs index 899ea28..5e082ec 100644 --- a/server/cli/backup-export.mjs +++ b/server/cli/backup-export.mjs @@ -11,9 +11,12 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { loadServerModule } from './backup-cli-loader.mjs' -const { ensureBackupSchema } = loadServerModule('utils/backup/schema.ts') -const { createBackupJob, getBackupJob } = loadServerModule('utils/backup/jobs.ts') -const { runFullExportJob } = loadServerModule('utils/backup/full-export.ts') +const { + ensureBackupSchema, + createBackupJob, + getBackupJob, + runFullExportJob +} = loadServerModule('cli/backup-runtime.ts') function readAppVersion() { try { diff --git a/server/cli/backup-restore.mjs b/server/cli/backup-restore.mjs index b5fa7ed..209079c 100644 --- a/server/cli/backup-restore.mjs +++ b/server/cli/backup-restore.mjs @@ -11,10 +11,13 @@ import { access } from 'node:fs/promises' import { resolve } from 'node:path' import { loadServerModule } from './backup-cli-loader.mjs' -const { ensureBackupSchema } = loadServerModule('utils/backup/schema.ts') -const { createBackupJob, getBackupJob } = loadServerModule('utils/backup/jobs.ts') -const { previewFullRestore } = loadServerModule('utils/backup/full-restore.ts') -const { runFullRestoreJob } = loadServerModule('utils/backup/full-restore.ts') +const { + ensureBackupSchema, + createBackupJob, + getBackupJob, + previewFullRestore, + runFullRestoreJob +} = loadServerModule('cli/backup-runtime.ts') function usage() { console.error('Usage: backup-restore [--overwrite]') @@ -41,7 +44,7 @@ async function main() { ensureBackupSchema() const preview = await previewFullRestore(archivePath) - console.log(`[PicHost] Preview: ${preview.imageCount} images, ${preview.newImages} new, ${preview.conflictingImages} conflicts`) + console.log(`[PicHost] Preview: ${preview.imageCount} media items, ${preview.newImages} new files, ${preview.conflictingImages} conflicting files`) const job = createBackupJob({ type: 'full_restore', diff --git a/server/cli/backup-runtime.ts b/server/cli/backup-runtime.ts new file mode 100644 index 0000000..71e170c --- /dev/null +++ b/server/cli/backup-runtime.ts @@ -0,0 +1,5 @@ +export { ensureBackupSchema } from '../utils/backup/schema' +export { createBackupJob, getBackupJob } from '../utils/backup/jobs' +export { runFullExportJob } from '../utils/backup/full-export' +export { previewFullRestore, runFullRestoreJob } from '../utils/backup/full-restore' +export { previewBackendSync, runBackendSyncJob } from '../utils/backup/backend-sync' diff --git a/server/cli/storage-sync.mjs b/server/cli/storage-sync.mjs index 8ea7b92..ab207df 100644 --- a/server/cli/storage-sync.mjs +++ b/server/cli/storage-sync.mjs @@ -1,5 +1,5 @@ /** - * Sync images between storage backends. + * Sync media files between storage backends. * * Usage: * node server/cli/storage-sync.mjs --from --to [--delete-source] [--set-default] [--dry-run] @@ -9,9 +9,13 @@ */ import { loadServerModule } from './backup-cli-loader.mjs' -const { ensureBackupSchema } = loadServerModule('utils/backup/schema.ts') -const { createBackupJob, getBackupJob } = loadServerModule('utils/backup/jobs.ts') -const { previewBackendSync, runBackendSyncJob } = loadServerModule('utils/backup/backend-sync.ts') +const { + ensureBackupSchema, + createBackupJob, + getBackupJob, + previewBackendSync, + runBackendSyncJob +} = loadServerModule('cli/backup-runtime.ts') function usage() { console.error('Usage: storage-sync --from --to [--delete-source] [--set-default] [--dry-run]') @@ -45,7 +49,7 @@ async function main() { if (dryRun) { const preview = previewBackendSync(options) - console.log(`[PicHost] Dry run: ${preview.imageCount} images, ${preview.imageBytes} bytes`) + console.log(`[PicHost] Dry run: ${preview.imageCount} media items, ${preview.imageBytes} bytes`) console.log(`[PicHost] From ${preview.fromBackendId} → ${preview.toBackendId}`) return } diff --git a/server/plugins/hls-download-cleanup.ts b/server/plugins/hls-download-cleanup.ts new file mode 100644 index 0000000..66b0e72 --- /dev/null +++ b/server/plugins/hls-download-cleanup.ts @@ -0,0 +1,11 @@ +import { cleanupExpiredHlsDownloads } from '../utils/transcode' + +export default defineNitroPlugin(() => { + const cleanup = () => { + void cleanupExpiredHlsDownloads() + } + + cleanup() + const timer = setInterval(cleanup, 60_000) + timer.unref() +}) diff --git a/server/plugins/startup.ts b/server/plugins/startup.ts index 5f69d3f..fe1f79b 100644 --- a/server/plugins/startup.ts +++ b/server/plugins/startup.ts @@ -31,7 +31,11 @@ export default defineNitroPlugin(() => { try { const entries = await fs.readdir(tmpdir(), { withFileTypes: true }) for (const entry of entries) { - if (!entry.isDirectory() || !entry.name.startsWith('pichost-hls-')) continue + if ( + !entry.isDirectory() + || !entry.name.startsWith('pichost-hls-') + || entry.name.startsWith('pichost-hls-download-') + ) continue await fs.rm(join(tmpdir(), entry.name), { recursive: true, force: true }) .catch(() => {}) } diff --git a/server/utils/backup/backend-sync.ts b/server/utils/backup/backend-sync.ts index ed7ee1d..fc0a03f 100644 --- a/server/utils/backup/backend-sync.ts +++ b/server/utils/backup/backend-sync.ts @@ -1,18 +1,22 @@ import { promises as fs } from 'node:fs' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { createBackendInstance, getStorageBackendRow, setDefaultBackend } from '../storage-backends' import { - getImageIndexAsStored, + getImageIndexRow, listImageIndexRowsByBackend, updateImageBackendId } from '../image-index' -import { createImageStream } from '../storage' +import { contentTypeFromKey } from '../content-type' +import { mediaTypeForKey } from '../image-key' +import type { ImageIndexRow } from '../image-index' +import type { StorageBackend } from '../storage/types' import { getBackupJob, markJobDone, markJobFailed, updateBackupJob } from './jobs' import { getBackupStagingDir } from './paths' +import { collectMediaAssetKeys } from '../media-assets' export interface BackendSyncOptions { fromBackendId: string @@ -63,6 +67,44 @@ async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { return Buffer.concat(chunks) } +async function syncMediaRow( + row: ImageIndexRow, + fromBackend: StorageBackend, + toBackend: StorageBackend, + deleteSource: boolean +): Promise { + const keys = await collectMediaAssetKeys(row, fromBackend) + if (!keys.length) throw new Error('没有可同步的文件对象') + + for (const key of keys) { + const stored = await fromBackend.head(key) + if (!stored) throw new Error(`源文件不存在:${key}`) + + const bytes = await streamToBuffer(await fromBackend.createStream(key)) + const isPrimary = key === row.key + await toBackend.put(key, bytes, { + originalName: isPrimary ? row.original_name : basename(key), + uploadedAt: row.uploaded_at, + contentType: isPrimary ? row.content_type : contentTypeFromKey(key), + size: bytes.length, + userId: row.user_id, + mediaType: isPrimary ? mediaTypeForKey(key) : 'video' + }) + + if (!await toBackend.head(key)) { + throw new Error(`目标后端写入校验失败:${key}`) + } + } + + updateImageBackendId(row.key, toBackend.id) + + if (deleteSource) { + for (const key of keys) { + await fromBackend.delete(key) + } + } +} + export async function runBackendSyncJob( jobId: string, options: BackendSyncOptions @@ -88,32 +130,13 @@ export async function runBackendSyncJob( updateBackupJob(jobId, { total: rows.length, progress: 0, - message: '正在同步图片…' + message: '正在同步文件…' }) let processed = 0 for (const row of rows) { try { - const stream = await createImageStream(row.key) - const bytes = await streamToBuffer(stream) - await toBackend.put(row.key, bytes, { - originalName: row.original_name, - uploadedAt: row.uploaded_at, - contentType: row.content_type, - size: row.size, - userId: row.user_id - }) - - const head = await toBackend.head(row.key) - if (!head) { - throw new Error('目标后端写入校验失败') - } - - updateImageBackendId(row.key, options.toBackendId) - - if (options.deleteSource) { - await fromBackend.delete(row.key) - } + await syncMediaRow(row, fromBackend, toBackend, Boolean(options.deleteSource)) } catch (error) { failedKeys.push(row.key) await fs.mkdir(join(failedLogPath, '..'), { recursive: true }).catch(() => {}) @@ -136,7 +159,7 @@ export async function runBackendSyncJob( } if (failedKeys.length > 0) { - markJobFailed(jobId, `${failedKeys.length} 张图片同步失败`, `部分失败:${failedKeys.length}/${rows.length}`) + markJobFailed(jobId, `${failedKeys.length} 个文件同步失败`, `部分失败:${failedKeys.length}/${rows.length}`) updateBackupJob(jobId, { payload: { failedKeys, failedLogPath, preview } }) @@ -178,22 +201,10 @@ export async function retryFailedBackendSync(jobId: string): Promise { const stillFailed: string[] = [] for (const key of keys) { - const row = getImageIndexAsStored(key) + const row = getImageIndexRow(key) if (!row) continue try { - const stream = await createImageStream(key) - const bytes = await streamToBuffer(stream) - await toBackend.put(key, bytes, { - originalName: row.originalName, - uploadedAt: row.uploadedAt, - contentType: row.contentType, - size: row.size, - userId: row.userId - }) - updateImageBackendId(key, toBackendId) - if (deleteSource) { - await fromBackend.delete(key) - } + await syncMediaRow(row, fromBackend, toBackend, deleteSource) } catch { stillFailed.push(key) } @@ -205,7 +216,7 @@ export async function retryFailedBackendSync(jobId: string): Promise { stillFailed.map(key => `${key}\tretry failed\n`).join(''), 'utf8' ) - throw new Error(`仍有 ${stillFailed.length} 张图片同步失败`) + throw new Error(`仍有 ${stillFailed.length} 个文件同步失败`) } await fs.rm(failedLogPath, { force: true }).catch(() => {}) diff --git a/server/utils/backup/full-export.test.ts b/server/utils/backup/full-export.test.ts new file mode 100644 index 0000000..ff3c708 --- /dev/null +++ b/server/utils/backup/full-export.test.ts @@ -0,0 +1,106 @@ +import * as tar from 'tar' +import { describe, expect, it } from 'vitest' +import { ensureDefaultBackends } from '../storage-backends' +import { getActiveBackend } from '../storage/resolver' +import { putImage } from '../storage' +import { updateImageThumb, updateImageTranscode } from '../image-index' +import { createBackupJob, getBackupJob } from './jobs' +import { runFullExportJob } from './full-export' +import { runFullRestoreJob } from './full-restore' + +async function archiveEntries(path: string): Promise { + const entries: string[] = [] + await tar.t({ + file: path, + onentry: (entry) => { + if (entry.type === 'File') entries.push(entry.path.replace(/^\.\//, '')) + } + }) + return entries +} + +describe('runFullExportJob', () => { + it('includes video source, thumbnail and HLS artifacts', async () => { + ensureDefaultBackends() + const key = 'videos/2026/10/backup-test.mp4' + const thumbKey = 'videos/2026/10/backup-test.webp' + const hlsKey = 'videos/2026/10/backup-test/index.m3u8' + const segmentKey = 'videos/2026/10/backup-test/seg000.ts' + const meta = { + originalName: 'backup-test.mp4', + uploadedAt: '2026-10-17T00:00:00.000Z', + contentType: 'video/mp4', + size: 1, + userId: 1, + mediaType: 'video' as const + } + + await putImage(key, new Uint8Array([1]), meta) + const backend = await getActiveBackend() + await backend.put(thumbKey, new Uint8Array([2]), meta) + await backend.put( + hlsKey, + new TextEncoder().encode('#EXTM3U\n#EXTINF:6,\nseg000.ts\n'), + meta + ) + await backend.put(segmentKey, new Uint8Array([3]), meta) + updateImageThumb(key, thumbKey) + updateImageTranscode(key, { + status: 'done', + hlsKey, + progress: 100 + }) + + const job = createBackupJob({ type: 'full_export', message: 'test' }) + await runFullExportJob(job.id, 'test') + + const done = getBackupJob(job.id) + expect(done?.status).toBe('done') + const entries = await archiveEntries(done!.outputPath!) + expect(entries).toEqual(expect.arrayContaining([ + 'blobs/videos/2026/10/backup-test.mp4', + 'blobs/videos/2026/10/backup-test.webp', + 'blobs/videos/2026/10/backup-test/index.m3u8', + 'blobs/videos/2026/10/backup-test/seg000.ts' + ])) + }) + + it('restores missing sidecar files in skip mode', async () => { + ensureDefaultBackends() + const key = 'videos/2026/10/restore-test.mp4' + const thumbKey = 'videos/2026/10/restore-test.webp' + const hlsKey = 'videos/2026/10/restore-test/index.m3u8' + const segmentKey = 'videos/2026/10/restore-test/seg000.ts' + const meta = { + originalName: 'restore-test.mp4', + uploadedAt: '2026-10-17T00:00:00.000Z', + contentType: 'video/mp4', + size: 1, + userId: 1, + mediaType: 'video' as const + } + + await putImage(key, new Uint8Array([1]), meta) + const backend = await getActiveBackend() + await backend.put(thumbKey, new Uint8Array([2]), meta) + await backend.put( + hlsKey, + new TextEncoder().encode('#EXTM3U\n#EXTINF:6,\nseg000.ts\n'), + meta + ) + await backend.put(segmentKey, new Uint8Array([3]), meta) + updateImageThumb(key, thumbKey) + updateImageTranscode(key, { status: 'done', hlsKey, progress: 100 }) + + const exportJob = createBackupJob({ type: 'full_export', message: 'test' }) + await runFullExportJob(exportJob.id, 'test') + const archivePath = getBackupJob(exportJob.id)!.outputPath! + + await backend.delete(segmentKey) + expect(await backend.head(segmentKey)).toBeNull() + + const restoreJob = createBackupJob({ type: 'full_restore', message: 'test' }) + await runFullRestoreJob(restoreJob.id, archivePath, { conflictMode: 'skip' }) + expect(await backend.head(segmentKey)).not.toBeNull() + }) +}) diff --git a/server/utils/backup/full-export.ts b/server/utils/backup/full-export.ts index 4074e35..76df70e 100644 --- a/server/utils/backup/full-export.ts +++ b/server/utils/backup/full-export.ts @@ -1,5 +1,5 @@ import { createWriteStream, promises as fs } from 'node:fs' -import { basename, join } from 'node:path' +import { join } from 'node:path' import { pipeline } from 'node:stream/promises' import * as tar from 'tar' import { getImageTotals, listAllImageIndexRows } from '../image-index' @@ -9,7 +9,7 @@ import { } from '../storage-backends' import { parseS3Config } from '../storage/s3' import { parseTelegramConfig } from '../storage/telegram' -import { createImageStream } from '../storage' +import { getBackendForKey } from '../storage/resolver' import { copyDatabaseSnapshot } from './db-snapshot' import { markJobDone, markJobFailed, updateBackupJob } from './jobs' import { BACKUP_FORMAT_VERSION, type BackupManifest, type StorageBackendMetaExport } from './manifest' @@ -19,6 +19,7 @@ import { getBackupsDir } from './paths' import type { StorageBackendRow } from '../storage/types' +import { collectMediaAssetKeys } from '../media-assets' function formatArchiveName(jobId: string): string { return `pichost-${jobId}${BACKUP_ARCHIVE_EXT}` @@ -48,36 +49,15 @@ async function writeStreamToFile(stream: NodeJS.ReadableStream, filePath: string await pipeline(stream, createWriteStream(filePath)) } -async function streamToString(stream: NodeJS.ReadableStream): Promise { - const chunks: Buffer[] = [] - for await (const chunk of stream) { - chunks.push(Buffer.from(chunk as Uint8Array)) - } - return Buffer.concat(chunks).toString('utf8') -} - -/** - * 源文件已按设计删除(转码完成)时,导出 HLS 播放列表、分片与封面。 - * 恢复端会遍历 blobs 目录原样还原,因此这里只需把附属文件写进去。 - */ -async function exportVideoSidecars(row: ImageIndexRow, stagingRoot: string): Promise { - const keys: string[] = [] - if (row.thumb_key) keys.push(row.thumb_key) - - if (row.hls_key) { - keys.push(row.hls_key) - const dir = row.hls_key.slice(0, row.hls_key.lastIndexOf('/')) - const text = await streamToString(await createImageStream(row.hls_key)) - for (const line of text.split('\n')) { - const name = line.trim() - if (!name || name.startsWith('#')) continue - keys.push(`${dir}/${basename(name)}`) - } - } - +async function exportMediaAssets( + row: ImageIndexRow, + stagingRoot: string +): Promise { + const backend = await getBackendForKey(row.key) + const keys = await collectMediaAssetKeys(row, backend) for (const key of keys) { await writeStreamToFile( - await createImageStream(key), + await backend.createStream(key), join(stagingRoot, 'blobs', key) ) } @@ -138,17 +118,10 @@ export async function runFullExportJob(jobId: string, appVersion: string): Promi for (const row of rows) { updateBackupJob(jobId, { progress: processed, - message: `正在导出图片 ${processed}/${totals.count + 1}…` + message: `正在导出文件 ${processed}/${totals.count + 1}…` }) - if (Number(row.source_deleted ?? 0) === 1) { - // 转码完成的视频只有 HLS 产物与封面 - await exportVideoSidecars(row, stagingRoot) - } else { - const blobPath = join(stagingRoot, 'blobs', row.key) - const stream = await createImageStream(row.key) - await writeStreamToFile(stream, blobPath) - } + await exportMediaAssets(row, stagingRoot) processed += 1 updateBackupJob(jobId, { progress: processed }) } diff --git a/server/utils/backup/full-restore.ts b/server/utils/backup/full-restore.ts index 9c8fb1a..00210e5 100644 --- a/server/utils/backup/full-restore.ts +++ b/server/utils/backup/full-restore.ts @@ -4,6 +4,7 @@ import { pipeline } from 'node:stream/promises' import * as tar from 'tar' import { getImageIndexRow } from '../image-index' import { getActiveBackend } from '../storage/resolver' +import { contentTypeFromKey } from '../content-type' import { replaceDatabaseFromSnapshot } from './db-snapshot' import { markJobDone, markJobFailed, restoreBackupJobsAfterReplace, snapshotBackupJobs, updateBackupJob } from './jobs' import { @@ -60,10 +61,11 @@ export async function previewFullRestore(archivePath: string): Promise @@ -97,13 +101,15 @@ export function createBackupJob(input: { const id = generateJobId() const now = new Date().toISOString() const payloadJson = JSON.stringify(input.payload ?? {}) + const userId = input.userId === undefined ? getPrimaryAdminUserId() : input.userId getDb().prepare(` INSERT INTO backup_jobs - (id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at) - VALUES (?, ?, 'pending', 0, ?, ?, ?, NULL, NULL, ?, NULL) + (id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at) + VALUES (?, ?, ?, 'pending', 0, ?, ?, ?, NULL, NULL, ?, NULL) `).run( id, + userId, input.type, input.total ?? 0, input.message ?? '', @@ -114,43 +120,69 @@ export function createBackupJob(input: { return getBackupJob(id)! } -export function getBackupJob(id: string): BackupJob | null { +export function getBackupJob(id: string, userId?: number): BackupJob | null { ensureBackupSchema() const row = getDb().prepare(` - SELECT id, type, status, progress, total, message, payload_json, + SELECT id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at FROM backup_jobs - WHERE id = ? - `).get(id) as BackupJobRow | undefined + WHERE id = ?${userId === undefined ? '' : ' AND user_id = ?'} + `).get(...(userId === undefined ? [id] : [id, userId])) as BackupJobRow | undefined return row ? rowToJob(row) : null } -export function listBackupJobs(limit = 20): BackupJob[] { - return listBackupJobsPage(1, limit).jobs +export function listBackupJobs(limit = 20, userId?: number): BackupJob[] { + return listBackupJobsPage(1, limit, userId).jobs } -export function countBackupJobs(): number { +export function countBackupJobs(userId?: number): number { ensureBackupSchema() - const row = getDb().prepare('SELECT COUNT(*) AS count FROM backup_jobs').get() as { count: number } + const row = getDb().prepare(` + SELECT COUNT(*) AS count FROM backup_jobs + ${userId === undefined ? '' : 'WHERE user_id = ?'} + `).get(...(userId === undefined ? [] : [userId])) as { count: number } return row.count } -export function listBackupJobsPage(page = 1, pageSize = 10): { jobs: BackupJob[], total: number } { +export function listBackupJobsPage( + page = 1, + pageSize = 10, + userId?: number +): { jobs: BackupJob[], total: number } { ensureBackupSchema() const limit = Math.min(50, Math.max(1, pageSize)) const safePage = Math.max(1, page) const offset = (safePage - 1) * limit - const total = countBackupJobs() + const total = countBackupJobs(userId) const rows = getDb().prepare(` - SELECT id, type, status, progress, total, message, payload_json, + SELECT id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at FROM backup_jobs + ${userId === undefined ? '' : 'WHERE user_id = ?'} ORDER BY created_at DESC LIMIT ? OFFSET ? - `).all(limit, offset) as unknown as BackupJobRow[] + `).all(...(userId === undefined ? [limit, offset] : [userId, limit, offset])) as unknown as BackupJobRow[] return { jobs: rows.map(rowToJob), total } } +export function listAllBackupJobsForUser(userId: number): BackupJob[] { + ensureBackupSchema() + const rows = getDb().prepare(` + SELECT id, user_id, type, status, progress, total, message, payload_json, + output_path, error, created_at, finished_at + FROM backup_jobs + WHERE user_id = ? + ORDER BY created_at DESC + `).all(userId) as unknown as BackupJobRow[] + return rows.map(rowToJob) +} + +export function deleteBackupJobsForUser(userId: number): number { + ensureBackupSchema() + const result = getDb().prepare('DELETE FROM backup_jobs WHERE user_id = ?').run(userId) + return Number(result.changes ?? 0) +} + /** Capture live task history before a full restore swaps the database file. */ export function snapshotBackupJobs(limit = 50): BackupJob[] { return listBackupJobs(limit) @@ -165,12 +197,13 @@ export function restoreBackupJobsAfterReplace(preserved: BackupJob[]): void { db.prepare('DELETE FROM backup_jobs').run() const insert = db.prepare(` INSERT INTO backup_jobs - (id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) for (const job of preserved) { insert.run( job.id, + job.userId, job.type, job.status, job.progress, diff --git a/server/utils/backup/runner.ts b/server/utils/backup/runner.ts index 892b3e8..6df0ec6 100644 --- a/server/utils/backup/runner.ts +++ b/server/utils/backup/runner.ts @@ -70,10 +70,11 @@ export function scheduleBackupJob( }) } -export function startFullExportJob(appVersion: string): BackupJob { +export function startFullExportJob(appVersion: string, userId?: number | null): BackupJob { claimJobSlot() const job = createBackupJob({ type: 'full_export', + userId, message: '等待开始…' }) scheduleBackupJob(job, () => runFullExportJob(job.id, appVersion)) @@ -82,11 +83,13 @@ export function startFullExportJob(appVersion: string): BackupJob { export function startFullRestoreJob( archivePath: string, - conflictMode: 'skip' | 'overwrite' + conflictMode: 'skip' | 'overwrite', + userId?: number | null ): BackupJob { claimJobSlot() const job = createBackupJob({ type: 'full_restore', + userId, message: '等待开始…', payload: { archivePath, conflictMode } }) @@ -100,10 +103,11 @@ export function startBackendSyncJob(options: { deleteSource?: boolean setDefault?: boolean dryRun?: boolean -}): BackupJob { +}, userId?: number | null): BackupJob { claimJobSlot() const job = createBackupJob({ type: 'backend_sync', + userId, message: options.dryRun ? '预检中…' : '等待开始…', payload: options }) diff --git a/server/utils/backup/schema.ts b/server/utils/backup/schema.ts index 81fc03f..2cff10c 100644 --- a/server/utils/backup/schema.ts +++ b/server/utils/backup/schema.ts @@ -4,6 +4,7 @@ export function ensureBackupSchema(): void { getDb().exec(` CREATE TABLE IF NOT EXISTS backup_jobs ( id TEXT PRIMARY KEY, + user_id INTEGER, type TEXT NOT NULL CHECK(type IN ('full_export', 'full_restore', 'backend_sync')), status TEXT NOT NULL CHECK(status IN ('pending', 'running', 'done', 'failed')), progress INTEGER NOT NULL DEFAULT 0, @@ -18,4 +19,21 @@ export function ensureBackupSchema(): void { CREATE INDEX IF NOT EXISTS idx_backup_jobs_created_at ON backup_jobs(created_at DESC); `) + + const columns = getDb().prepare('PRAGMA table_info(backup_jobs)').all() as Array<{ + name: string + }> + if (!columns.some(column => column.name === 'user_id')) { + getDb().exec('ALTER TABLE backup_jobs ADD COLUMN user_id INTEGER') + } + getDb().exec(` + CREATE INDEX IF NOT EXISTS idx_backup_jobs_user_created + ON backup_jobs(user_id, created_at DESC); + UPDATE backup_jobs + SET user_id = ( + SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1 + ) + WHERE user_id IS NULL + AND EXISTS (SELECT 1 FROM users WHERE role = 'admin'); + `) } diff --git a/server/utils/constants.ts b/server/utils/constants.ts index 605aa7c..dbfebf8 100644 --- a/server/utils/constants.ts +++ b/server/utils/constants.ts @@ -38,9 +38,7 @@ export const VIDEO_EXTENSIONS = [ 'flv', 'wmv', 'mpg', - 'mpeg', - 'ts', - 'm3u8' + 'mpeg' ] as const /** 文件床直链缓存:公开访问可长期缓存,Token 访问不能进共享缓存 */ diff --git a/server/utils/file-bed.test.ts b/server/utils/file-bed.test.ts index 545e678..44b1072 100644 --- a/server/utils/file-bed.test.ts +++ b/server/utils/file-bed.test.ts @@ -131,6 +131,11 @@ describe('isVideoExtension', () => { expect(isVideoExtension('mkv')).toBe(true) }) + it('rejects HLS sidecars from the user upload allowlist', () => { + expect(isVideoExtension('ts')).toBe(false) + expect(isVideoExtension('m3u8')).toBe(false) + }) + it('rejects non-video extensions', () => { expect(isVideoExtension('png')).toBe(false) expect(isVideoExtension('pdf')).toBe(false) @@ -153,6 +158,8 @@ describe('validateVideoKey', () => { it('is covered by validateStorageKey', () => { expect(validateStorageKey('videos/2026/10/a.mp4')).toBe(true) + expect(validateStorageKey('videos/2026/10/a/index.m3u8')).toBe(true) + expect(validateStorageKey('videos/2026/10/a/seg000.ts')).toBe(true) }) }) diff --git a/server/utils/image-index.ts b/server/utils/image-index.ts index 7f202e1..413a3ac 100644 --- a/server/utils/image-index.ts +++ b/server/utils/image-index.ts @@ -45,6 +45,7 @@ export interface ImageIndexRow { thumb_key: string | null source_deleted: number transcode_preset: string | null + transcode_error: string | null } function keyToFilePath(key: string): string { @@ -76,6 +77,7 @@ function rowToStoredImage(row: ImageIndexRow): StoredImage { thumbKey: row.thumb_key ?? null, sourceDeleted: Number(row.source_deleted ?? 0) === 1, transcodePreset: row.transcode_preset ?? null, + transcodeError: row.transcode_error ?? null, mtimeMs: Number.isFinite(uploadedMs) ? uploadedMs : Date.now() } } @@ -230,7 +232,7 @@ export async function purgeOrphanImageIndexRows(): Promise { export function getImageIndexRow(key: string): ImageIndexRow | null { ensureStorageSchema() const row = getDb().prepare(` - SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset + SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_error FROM images WHERE key = ? `).get(key) as ImageIndexRow | undefined @@ -423,16 +425,26 @@ export function getImageTotals(): { count: number, bytes: number } { export function listAllImageIndexRows(): ImageIndexRow[] { ensureStorageSchema() return getDb().prepare(` - SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset + SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_error FROM images ORDER BY key ASC `).all() as unknown as ImageIndexRow[] } +export function listImageIndexRowsForUser(userId: number): ImageIndexRow[] { + ensureStorageSchema() + return getDb().prepare(` + SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_error + FROM images + WHERE user_id = ? + ORDER BY key ASC + `).all(userId) as unknown as ImageIndexRow[] +} + export function listImageIndexRowsByBackend(backendId: string): ImageIndexRow[] { ensureStorageSchema() return getDb().prepare(` - SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset + SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_error FROM images WHERE backend_id = ? ORDER BY key ASC @@ -456,6 +468,7 @@ export function updateImageTranscode( sourceDeleted?: boolean size?: number preset?: string | null + error?: string | null } ): void { ensureStorageSchema() @@ -466,7 +479,8 @@ export function updateImageTranscode( transcode_progress = COALESCE(?, transcode_progress), source_deleted = COALESCE(?, source_deleted), size = COALESCE(?, size), - transcode_preset = COALESCE(?, transcode_preset) + transcode_preset = COALESCE(?, transcode_preset), + transcode_error = ? WHERE key = ? `).run( input.status, @@ -475,19 +489,23 @@ export function updateImageTranscode( input.sourceDeleted === undefined ? null : (input.sourceDeleted ? 1 : 0), input.size ?? null, input.preset ?? null, + input.error ?? null, key ) } /** 启动时恢复被中断的转码任务 */ -export function listPendingTranscodeKeys(): string[] { +export function listPendingTranscodeRows(): Array<{ + key: string + preset: string | null +}> { ensureStorageSchema() const rows = getDb().prepare(` - SELECT key FROM images + SELECT key, transcode_preset FROM images WHERE media_type = 'video' AND transcode_status IN ('pending', 'running') ORDER BY uploaded_at ASC - `).all() as Array<{ key: string }> - return rows.map(row => row.key) + `).all() as Array<{ key: string, transcode_preset: string | null }> + return rows.map(row => ({ key: row.key, preset: row.transcode_preset })) } /** 转码已完成但仍保留源文件的视频(用于补删历史遗留) */ @@ -669,7 +687,7 @@ export async function listImages(options: { const backendSql = buildBackendFilterSql(options.backendId, params) const tagSql = buildTagFilterSql(options.tagFilter, params) const rows = getDb().prepare(` - SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset + SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_error FROM images WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql} ORDER BY uploaded_at DESC, key DESC @@ -715,7 +733,7 @@ export async function searchImages(options: { const backendSql = buildBackendFilterSql(options.backendId, params) const tagSql = buildTagFilterSql(options.tagFilter, params) const rows = getDb().prepare(` - SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset + SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_error FROM images WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql} ORDER BY uploaded_at DESC, key DESC @@ -849,7 +867,7 @@ export async function getUserScopedStorageStats( export async function listImageKeysForAutoDelete(): Promise { ensureStorageSchema() const rows = getDb().prepare(` - SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset + SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_error FROM images WHERE media_type = 'image' ORDER BY uploaded_at ASC diff --git a/server/utils/media-assets.test.ts b/server/utils/media-assets.test.ts new file mode 100644 index 0000000..da47c9a --- /dev/null +++ b/server/utils/media-assets.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { LocalStorageBackend } from './storage/local' +import type { ImageIndexRow } from './image-index' +import { collectMediaAssetKeys } from './media-assets' + +const row: ImageIndexRow = { + key: 'videos/2026/10/example.mp4', + backend_id: 'local', + user_id: 1, + folder: 'videos', + original_name: 'example.mp4', + content_type: 'video/mp4', + size: 100, + uploaded_at: '2026-10-17T00:00:00.000Z', + hls_key: 'videos/2026/10/example/index.m3u8', + transcode_status: 'done', + transcode_progress: 100, + thumb_key: 'videos/2026/10/example.webp', + source_deleted: 0, + transcode_preset: 'veryfast', + transcode_error: null +} + +async function createBackend() { + const backend = new LocalStorageBackend() + const meta = { + originalName: 'example', + uploadedAt: row.uploaded_at, + contentType: 'application/octet-stream', + size: 0, + userId: row.user_id, + mediaType: 'video' as const + } + await backend.put(row.key, new Uint8Array([1]), meta) + await backend.put(row.thumb_key!, new Uint8Array([2]), meta) + await backend.put( + row.hls_key!, + new TextEncoder().encode('#EXTM3U\n#EXTINF:6,\nseg000.ts\n'), + meta + ) + await backend.put('videos/2026/10/example/seg000.ts', new Uint8Array([3]), meta) + return backend +} + +describe('collectMediaAssetKeys', () => { + it('includes source, thumbnail, playlist and HLS segments', async () => { + const backend = await createBackend() + expect(new Set(await collectMediaAssetKeys(row, backend))).toEqual(new Set([ + row.key, + row.thumb_key, + row.hls_key, + 'videos/2026/10/example/seg000.ts' + ])) + }) + + it('omits the source when transcoding already removed it', async () => { + const backend = await createBackend() + expect(new Set(await collectMediaAssetKeys({ + ...row, + source_deleted: 1 + }, backend))).toEqual(new Set([ + row.thumb_key, + row.hls_key, + 'videos/2026/10/example/seg000.ts' + ])) + }) +}) diff --git a/server/utils/media-assets.ts b/server/utils/media-assets.ts new file mode 100644 index 0000000..e87d6ba --- /dev/null +++ b/server/utils/media-assets.ts @@ -0,0 +1,36 @@ +import type { ImageIndexRow } from './image-index' +import type { StorageBackend } from './storage/types' +import { hlsSegmentNames } from './transcode' + +async function streamToString(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = [] + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk as Uint8Array)) + } + return Buffer.concat(chunks).toString('utf8') +} + +/** 一个媒体记录对应的全部存储对象,包括原文件、缩略图和 HLS 附属文件。 */ +export async function collectMediaAssetKeys( + row: ImageIndexRow, + backend: Pick +): Promise { + const keys = new Set() + + if (Number(row.source_deleted ?? 0) !== 1) { + keys.add(row.key) + } + if (row.thumb_key) { + keys.add(row.thumb_key) + } + if (row.hls_key) { + keys.add(row.hls_key) + const dir = row.hls_key.slice(0, row.hls_key.lastIndexOf('/')) + const playlist = await streamToString(await backend.createStream(row.hls_key)) + for (const name of hlsSegmentNames(playlist)) { + keys.add(`${dir}/${name}`) + } + } + + return [...keys] +} diff --git a/server/utils/storage-backends.ts b/server/utils/storage-backends.ts index 7d98a54..3cbcb62 100644 --- a/server/utils/storage-backends.ts +++ b/server/utils/storage-backends.ts @@ -94,6 +94,7 @@ const STORAGE_SCHEMA_TRANSCODE_PROGRESS_KEY = 'storage_schema_transcode_progress const STORAGE_SCHEMA_THUMB_KEY = 'storage_schema_thumb' const STORAGE_SCHEMA_SOURCE_DELETED_KEY = 'storage_schema_source_deleted' const STORAGE_SCHEMA_TRANSCODE_PRESET_KEY = 'storage_schema_transcode_preset' +const STORAGE_SCHEMA_TRANSCODE_ERROR_KEY = 'storage_schema_transcode_error' /** images 表新增 media_type 列(区分图床图片与文件床文件) */ function migrateImagesMediaType(database: DatabaseSync): void { @@ -232,6 +233,27 @@ function migrateImagesTranscodePreset(database: DatabaseSync): void { `).run(STORAGE_SCHEMA_TRANSCODE_PRESET_KEY, new Date().toISOString()) } +/** images 表新增「转码失败原因」列 */ +function migrateImagesTranscodeError(database: DatabaseSync): void { + const migrated = database.prepare(` + SELECT value FROM settings WHERE key = ? + `).get(STORAGE_SCHEMA_TRANSCODE_ERROR_KEY) as { value?: string } | undefined + + if (migrated?.value === '1') return + + const columns = database.prepare('PRAGMA table_info(images)').all() as Array<{ + name: string + }> + if (!columns.some(column => column.name === 'transcode_error')) { + database.exec('ALTER TABLE images ADD COLUMN transcode_error TEXT') + } + + database.prepare(` + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, '1', ?) + `).run(STORAGE_SCHEMA_TRANSCODE_ERROR_KEY, new Date().toISOString()) +} + function migrateStorageTables(database: DatabaseSync): void { database.exec(` CREATE TABLE IF NOT EXISTS storage_backends ( @@ -263,6 +285,7 @@ function migrateStorageTables(database: DatabaseSync): void { thumb_key TEXT, source_deleted INTEGER NOT NULL DEFAULT 0, transcode_preset TEXT, + transcode_error TEXT, FOREIGN KEY (backend_id) REFERENCES storage_backends(id) ); CREATE INDEX IF NOT EXISTS idx_images_uploaded_at ON images(uploaded_at DESC); @@ -284,6 +307,7 @@ function migrateStorageTables(database: DatabaseSync): void { migrateImagesThumb(database) migrateImagesSourceDeleted(database) migrateImagesTranscodePreset(database) + migrateImagesTranscodeError(database) database.exec(` CREATE INDEX IF NOT EXISTS idx_images_media_type ON images(media_type); `) diff --git a/server/utils/storage/s3.ts b/server/utils/storage/s3.ts index bb865d9..eeb291b 100644 --- a/server/utils/storage/s3.ts +++ b/server/utils/storage/s3.ts @@ -38,6 +38,15 @@ function contentTypeFromKey(key: string): string { } } +function decodeMetadata(value: string | undefined): string | undefined { + if (!value) return value + try { + return decodeURIComponent(value) + } catch { + return value + } +} + export function buildObjectKey(prefix: string | undefined, key: string): string { const trimmed = prefix?.replace(/^\/+|\/+$/g, '') ?? '' return trimmed ? `${trimmed}/${key}` : key @@ -125,7 +134,8 @@ export class S3StorageBackend implements StorageBackend { Body: bytes, ContentType: meta.contentType, Metadata: { - originalName: meta.originalName, + // S3 metadata headers must be ASCII-safe. + originalName: encodeURIComponent(meta.originalName), uploadedAt: meta.uploadedAt, userId: meta.userId != null ? String(meta.userId) : '' } @@ -144,7 +154,7 @@ export class S3StorageBackend implements StorageBackend { const uploadedAt = response.Metadata?.uploadedat ?? response.LastModified?.toISOString() ?? new Date().toISOString() - const originalName = response.Metadata?.originalname + const originalName = decodeMetadata(response.Metadata?.originalname) ?? key.split('/').pop() ?? 'image' const userIdRaw = response.Metadata?.userid diff --git a/server/utils/storage/types.ts b/server/utils/storage/types.ts index 6a842bd..9d9c4be 100644 --- a/server/utils/storage/types.ts +++ b/server/utils/storage/types.ts @@ -25,6 +25,8 @@ export interface StoredImage extends StoredImageMeta { transcodeProgress?: number /** 本视频使用的转码档位 */ transcodePreset?: string | null + /** 转码失败原因 */ + transcodeError?: string | null /** 视频封面缩略图 key */ thumbKey?: string | null /** 转码成功后源视频是否已删除 */ diff --git a/server/utils/transcode.test.ts b/server/utils/transcode.test.ts index ebfbbf9..82dad93 100644 --- a/server/utils/transcode.test.ts +++ b/server/utils/transcode.test.ts @@ -1,13 +1,20 @@ +import { promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { + cleanupExpiredHlsDownloads, DEFAULT_TRANSCODE_CONCURRENCY, DEFAULT_TRANSCODE_PRESET, DEFAULT_TRANSCODE_THREADS, getTranscodeConcurrency, getTranscodeSettings, + hlsSegmentNames, hlsPlaylistKey, + markHlsDownloadForCleanup, parseTranscodePreset, setTranscodeSettings, + summarizeTranscodeFailure, thumbKeyFor } from './transcode' import { isVideoSidecarKey, mediaTypeForKey } from './image-key' @@ -39,6 +46,44 @@ describe('isVideoSidecarKey', () => { }) }) +describe('hlsSegmentNames', () => { + it('returns unique relative segment names and ignores tags', () => { + expect(hlsSegmentNames([ + '#EXTM3U', + '#EXTINF:6,', + 'seg000.ts', + '#EXTINF:6,', + 'seg001.ts', + 'seg000.ts' + ].join('\n'))).toEqual(['seg000.ts', 'seg001.ts']) + }) + + it('ignores absolute and traversal paths', () => { + expect(hlsSegmentNames([ + '../secret.ts', + 'https://example.com/video.ts', + 'folder/seg.ts', + 'seg.ts' + ].join('\n'))).toEqual(['seg.ts']) + }) +}) + +describe('cleanupExpiredHlsDownloads', () => { + it('keeps fresh downloads and removes files after their marker expires', async () => { + const now = Date.now() + const fresh = await fs.mkdtemp(join(tmpdir(), 'pichost-hls-download-fresh-')) + const expired = await fs.mkdtemp(join(tmpdir(), 'pichost-hls-download-expired-')) + + await markHlsDownloadForCleanup(fresh, now + 60_000) + await markHlsDownloadForCleanup(expired, now - 1) + await cleanupExpiredHlsDownloads(now) + + await expect(fs.access(fresh)).resolves.toBeUndefined() + await expect(fs.access(expired)).rejects.toThrow() + await fs.rm(fresh, { recursive: true, force: true }) + }) +}) + describe('mediaTypeForKey', () => { it('maps folders to media types', () => { expect(mediaTypeForKey('images/2026/10/a.webp')).toBe('image') @@ -47,6 +92,18 @@ describe('mediaTypeForKey', () => { }) }) +describe('summarizeTranscodeFailure', () => { + it('recognizes a missing FFmpeg binary', () => { + expect(summarizeTranscodeFailure(new Error('spawn ffmpeg ENOENT'))) + .toBe('ffmpeg-missing') + }) + + it('maps other FFmpeg errors to unknown', () => { + const error = new Error(`first line\n${'invalid data '.repeat(20)}`) + expect(summarizeTranscodeFailure(error)).toBe('unknown') + }) +}) + describe('transcode settings', () => { it('defaults to one concurrent job with one thread', () => { expect(DEFAULT_TRANSCODE_CONCURRENCY).toBe(1) diff --git a/server/utils/transcode.ts b/server/utils/transcode.ts index 8da4844..ae7800b 100644 --- a/server/utils/transcode.ts +++ b/server/utils/transcode.ts @@ -5,10 +5,11 @@ import { randomUUID } from 'node:crypto' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { pipeline } from 'node:stream/promises' +import sharp from 'sharp' import { getDataDir } from './data-dir' import { listAllImageIndexRows, - listPendingTranscodeKeys, + listPendingTranscodeRows, listTranscodedSourceRows, updateImageTranscode } from './image-index' @@ -37,6 +38,9 @@ import { /** HLS 分片时长(秒) */ const HLS_SEGMENT_TIME = 6 +const HLS_DOWNLOAD_PREFIX = 'pichost-hls-download-' +export const HLS_DOWNLOAD_RETENTION_MS = 60 * 60 * 1000 +const HLS_DOWNLOAD_MARKER = '.expires' export const DEFAULT_TRANSCODE_CONCURRENCY = 1 export const DEFAULT_TRANSCODE_THREADS = 1 @@ -56,6 +60,21 @@ export const DEFAULT_TRANSCODE_BITRATE_PERCENT = 80 export const MIN_TRANSCODE_BITRATE_PERCENT = 10 export const MAX_TRANSCODE_BITRATE_PERCENT = 100 +export interface TranscodeJobOptions { + preset: TranscodePreset + bitratePercent: number +} + +/** 将底层错误归纳成适合界面展示的简短原因 */ +export function summarizeTranscodeFailure(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + if (/spawn ffmpeg ENOENT|ffmpeg.*(not found|ENOENT)|ENOENT.*ffmpeg/i.test(message)) { + return 'ffmpeg-missing' + } + if (/permission denied/i.test(message)) return 'permission-denied' + return 'unknown' +} + export function getTranscodeBitratePercent(): number { return parseBoundedInt( getSetting(SETTINGS_TRANSCODE_BITRATE), @@ -84,6 +103,21 @@ function parseBoundedInt( return value } +function resolveTranscodeJobOptions( + options: Partial = {} +): TranscodeJobOptions { + const settings = getTranscodeSettings() + const bitratePercent = Number.isInteger(options.bitratePercent) + && options.bitratePercent! >= MIN_TRANSCODE_BITRATE_PERCENT + && options.bitratePercent! <= MAX_TRANSCODE_BITRATE_PERCENT + ? options.bitratePercent! + : settings.bitratePercent + return { + preset: options.preset ?? settings.preset, + bitratePercent + } +} + export interface TranscodeSettings { concurrency: number threads: number @@ -162,6 +196,19 @@ export function thumbKeyFor(videoKey: string): string { return `${stripExtension(videoKey)}.webp` } +/** HLS 播放列表中的相对分片文件名 */ +export function hlsSegmentNames(playlistText: string): string[] { + const names = new Set() + for (const line of playlistText.split('\n')) { + const name = line.trim() + if (!name || name.startsWith('#') || name === '.' || name === '..' || /[/\\]/.test(name)) { + continue + } + names.add(name) + } + return [...names] +} + function runThumbFfmpeg(inputPath: string, outputPath: string, seek: number): Promise { return new Promise((resolve, reject) => { const child = spawn('ffmpeg', [ @@ -172,8 +219,7 @@ function runThumbFfmpeg(inputPath: string, outputPath: string, seek: number): Pr '-i', inputPath, '-frames:v', '1', '-vf', 'scale=640:-2', - '-c:v', 'libwebp', - '-quality', '80', + '-c:v', 'png', outputPath ], { windowsHide: true }) @@ -203,6 +249,7 @@ export async function generateVideoThumbnail(videoKey: string): Promise void ): Promise { - const { preset, bitratePercent } = getTranscodeSettings() + const { preset, bitratePercent } = options // 音频不放大也不压缩:跟随源音轨码率(读不到时才用 128k) const audioBitrate = sourceAudioBitrate > 0 @@ -419,12 +468,96 @@ async function streamToString(stream: NodeJS.ReadableStream): Promise { return Buffer.concat(chunks).toString('utf8') } +function remuxPlaylistToMp4(inputPath: string, outputPath: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn('ffmpeg', [ + '-hide_banner', + '-loglevel', 'error', + '-y', + '-i', inputPath, + '-c', 'copy', + '-movflags', '+faststart', + outputPath + ], { windowsHide: true }) + + let stderr = '' + child.stderr.on('data', (chunk) => { + stderr += String(chunk) + }) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) { + resolve() + return + } + const detail = stderr.trim().split('\n').filter(Boolean).pop() + reject(new Error(detail || `ffmpeg 退出码 ${code}`)) + }) + }) +} + +/** 下载 HLS 清单和分片到临时目录,不转码,重新封装为 MP4 */ +export async function remuxHlsToMp4(playlistKey: string, workDir: string): Promise { + if (!validateStorageKey(playlistKey) || !playlistKey.endsWith('.m3u8')) { + throw new Error('无效的 HLS 播放列表') + } + + const playlistText = await streamToString(await createImageStream(playlistKey)) + const segmentNames = hlsSegmentNames(playlistText) + if (!segmentNames.length) throw new Error('HLS 播放列表没有视频分片') + + const dirKey = playlistKey.slice(0, playlistKey.lastIndexOf('/')) + await fs.mkdir(workDir, { recursive: true }) + await fs.writeFile(join(workDir, 'index.m3u8'), playlistText) + + for (const name of segmentNames) { + const segmentKey = `${dirKey}/${name}` + if (!validateStorageKey(segmentKey)) throw new Error('HLS 分片路径无效') + await streamToFile(await createImageStream(segmentKey), join(workDir, name)) + } + + const outputPath = join(workDir, 'video.mp4') + await remuxPlaylistToMp4(join(workDir, 'index.m3u8'), outputPath) + return outputPath +} + +/** 标记 HLS 下载临时文件,满一小时后允许清理 */ +export async function markHlsDownloadForCleanup( + workDir: string, + expiresAt = Date.now() + HLS_DOWNLOAD_RETENTION_MS +): Promise { + await fs.writeFile(join(workDir, HLS_DOWNLOAD_MARKER), String(expiresAt)) +} + +/** 删除已经到达过期时间的 HLS 下载临时目录 */ +export async function cleanupExpiredHlsDownloads(now = Date.now()): Promise { + let removed = 0 + try { + const entries = await fs.readdir(tmpdir(), { withFileTypes: true }) + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith(HLS_DOWNLOAD_PREFIX)) continue + const path = join(tmpdir(), entry.name) + const expiresAt = Number( + await fs.readFile(join(path, HLS_DOWNLOAD_MARKER), 'utf8').catch(() => '') + ) + if (!Number.isFinite(expiresAt) || expiresAt > now) continue + await fs.rm(path, { recursive: true, force: true }) + removed++ + } + } catch { + // 清理失败不影响新下载 + } + return removed +} + /** 把视频转成 HLS 并写入存储,返回播放列表 key 与产物总大小 */ export async function transcodeVideoToHls( - videoKey: string + videoKey: string, + jobOptions?: Partial ): Promise<{ playlistKey: string, totalBytes: number }> { const stored = await headImage(videoKey) if (!stored) throw new Error('视频不存在') + const options = resolveTranscodeJobOptions(jobOptions) const workDir = join(tmpdir(), `pichost-hls-${randomUUID()}`) const inputPath = join(workDir, `input.${extensionOf(videoKey)}`) @@ -445,17 +578,25 @@ export async function transcodeVideoToHls( const sourceAudioBitrate = await probeAudioBitrate(inputPath) let lastProgressAt = 0 - await runFfmpeg(videoKey, inputPath, outputDir, sourceBitrate, sourceAudioBitrate, (outTimeUs) => { - if (!durationSec) return - const now = Date.now() - if (now - lastProgressAt < 1000) return - lastProgressAt = now - const percent = Math.round((outTimeUs / (durationSec * 1_000_000)) * 100) - updateImageTranscode(videoKey, { - status: 'running', - progress: Math.min(99, Math.max(1, percent)) - }) - }) + await runFfmpeg( + videoKey, + inputPath, + outputDir, + sourceBitrate, + sourceAudioBitrate, + options, + (outTimeUs) => { + if (!durationSec) return + const now = Date.now() + if (now - lastProgressAt < 1000) return + lastProgressAt = now + const percent = Math.round((outTimeUs / (durationSec * 1_000_000)) * 100) + updateImageTranscode(videoKey, { + status: 'running', + progress: Math.min(99, Math.max(1, percent)) + }) + } + ) const uploadedAt = new Date().toISOString() @@ -503,10 +644,8 @@ export async function removeHlsArtifacts(videoKey: string): Promise { const dirKey = stripExtension(videoKey) const text = await streamToString(await createImageStream(playlistKey)) - for (const line of text.split('\n')) { - const name = line.trim() - if (!name || name.startsWith('#')) continue - const segmentKey = `${dirKey}/${basename(name)}` + for (const name of hlsSegmentNames(text)) { + const segmentKey = `${dirKey}/${name}` if (!validateStorageKey(segmentKey)) continue await deleteImage(segmentKey).catch(() => {}) } @@ -520,15 +659,20 @@ export async function removeHlsArtifacts(videoKey: string): Promise { } } -const queue: string[] = [] +interface QueuedTranscode extends TranscodeJobOptions { + videoKey: string +} + +const queue: QueuedTranscode[] = [] const queued = new Set() let activeCount = 0 -async function processOne(videoKey: string): Promise { +async function processOne(job: QueuedTranscode): Promise { + const { videoKey } = job updateImageTranscode(videoKey, { status: 'running' }) try { - const { playlistKey, totalBytes } = await transcodeVideoToHls(videoKey) + const { playlistKey, totalBytes } = await transcodeVideoToHls(videoKey, job) // 转码成功后源文件不再需要,直接删除以节省空间 const backend = await getBackendForKey(videoKey) @@ -543,7 +687,10 @@ async function processOne(videoKey: string): Promise { }) logInfo('transcode done', { key: videoKey, hlsKey: playlistKey }) } catch (error) { - updateImageTranscode(videoKey, { status: 'failed' }) + updateImageTranscode(videoKey, { + status: 'failed', + error: summarizeTranscodeFailure(error) + }) logException('transcode failed', error, { key: videoKey }) } } @@ -551,10 +698,11 @@ async function processOne(videoKey: string): Promise { /** 按设置的并发数消费队列(默认 1,即串行) */ function drain(): void { while (queue.length && activeCount < getTranscodeConcurrency()) { - const key = queue.shift()! + const job = queue.shift()! + const key = job.videoKey activeCount += 1 - void processOne(key).finally(() => { + void processOne(job).finally(() => { activeCount -= 1 queued.delete(key) drain() @@ -562,25 +710,28 @@ function drain(): void { } } -export function enqueueTranscode(videoKey: string): void { +export function enqueueTranscode( + videoKey: string, + options: Partial = {} +): void { if (!validateVideoKey(videoKey) || queued.has(videoKey)) return + const job = resolveTranscodeJobOptions(options) queued.add(videoKey) - queue.push(videoKey) + queue.push({ videoKey, ...job }) // 记录本视频使用的档位,便于上传页展示 - const preset = getTranscodeSettings().preset updateImageTranscode(videoKey, { status: 'pending', // 压缩档带上百分比,例如 compress:80 - preset: preset === 'compress' - ? `compress:${getTranscodeBitratePercent()}` - : preset + preset: job.preset === 'compress' + ? `compress:${job.bitratePercent}` + : job.preset }) drain() } /** 取消排队中或正在转码的任务(删除视频时调用) */ export function cancelTranscode(videoKey: string): void { - const index = queue.indexOf(videoKey) + const index = queue.findIndex(job => job.videoKey === videoKey) if (index >= 0) queue.splice(index, 1) queued.delete(videoKey) @@ -616,8 +767,14 @@ export async function cleanupTranscodedSources(): Promise { /** 启动时把上次中断的任务重新排队 */ export function resumePendingTranscodes(): void { - for (const key of listPendingTranscodeKeys()) { - enqueueTranscode(key) + for (const row of listPendingTranscodeRows()) { + const [presetName, bitrateRaw] = (row.preset ?? '').split(':') + const preset = parseTranscodePreset(presetName) + const bitratePercent = Number(bitrateRaw) + enqueueTranscode(row.key, { + ...(preset ? { preset } : {}), + ...(Number.isInteger(bitratePercent) ? { bitratePercent } : {}) + }) } } diff --git a/server/utils/user-storage.test.ts b/server/utils/user-storage.test.ts new file mode 100644 index 0000000..da63f87 --- /dev/null +++ b/server/utils/user-storage.test.ts @@ -0,0 +1,145 @@ +import { promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + getDb, + insertActivityLog, + insertUser +} from './db' +import { + listImageIndexRowsForUser, + updateImageThumb, + updateImageTranscode +} from './image-index' +import { ensureDefaultBackends } from './storage-backends' +import { getActiveBackend } from './storage/resolver' +import { putImage } from './storage' +import { createTag } from './tags' +import { clearUserStorage } from './user-storage' +import { + createBackupJob, + getBackupJob, + markJobDone +} from './backup/jobs' +import { getBackupsDir } from './backup/paths' + +describe('clearUserStorage', () => { + it('deletes only the selected user media, tags, logs and cache', async () => { + ensureDefaultBackends() + const now = new Date().toISOString() + const userA = insertUser({ + username: 'clear-user-a', + passwordHash: 'hash-a', + role: 'user', + createdAt: now + }) + const userB = insertUser({ + username: 'clear-user-b', + passwordHash: 'hash-b', + role: 'user', + createdAt: now + }) + + const keyA = 'videos/2026/10/clear-a.mp4' + const thumbA = 'videos/2026/10/clear-a.webp' + const hlsA = 'videos/2026/10/clear-a/index.m3u8' + const segmentA = 'videos/2026/10/clear-a/seg000.ts' + const keyB = 'images/2026/10/clear-b.webp' + const videoMeta = { + originalName: 'clear-a.mp4', + uploadedAt: now, + contentType: 'video/mp4', + size: 1, + userId: userA, + mediaType: 'video' as const + } + + await putImage(keyA, new Uint8Array([1]), videoMeta) + const backend = await getActiveBackend() + await backend.put(thumbA, new Uint8Array([2]), videoMeta) + await backend.put( + hlsA, + new TextEncoder().encode('#EXTM3U\n#EXTINF:6,\nseg000.ts\n'), + videoMeta + ) + await backend.put(segmentA, new Uint8Array([3]), videoMeta) + updateImageThumb(keyA, thumbA) + updateImageTranscode(keyA, { status: 'done', hlsKey: hlsA, progress: 100 }) + + await putImage(keyB, new Uint8Array([4]), { + originalName: 'clear-b.webp', + uploadedAt: now, + contentType: 'image/webp', + size: 1, + userId: userB, + mediaType: 'image' + }) + + const tagA = createTag(userA, 'A tag') + const tagB = createTag(userB, 'B tag') + getDb().prepare('INSERT INTO image_tags (image_key, tag_id) VALUES (?, ?)').run(keyA, tagA.id) + getDb().prepare('INSERT INTO image_tags (image_key, tag_id) VALUES (?, ?)').run(keyB, tagB.id) + insertActivityLog({ + action: 'upload', + key: keyA, + originalName: 'clear-a.mp4', + size: 1, + contentType: 'video/mp4', + source: 'web', + userId: userA + }) + insertActivityLog({ + action: 'upload', + key: keyB, + originalName: 'clear-b.webp', + size: 1, + contentType: 'image/webp', + source: 'web', + userId: userB + }) + const cacheDir = await fs.mkdtemp(join(tmpdir(), `pichost-hls-download-${userA}-`)) + const jobA = createBackupJob({ type: 'full_export', userId: userA }) + const jobB = createBackupJob({ type: 'full_export', userId: userB }) + const archiveA = join(getBackupsDir(), `clear-a-${jobA.id}.phost.tar.gz`) + const archiveB = join(getBackupsDir(), `clear-b-${jobB.id}.phost.tar.gz`) + await fs.mkdir(getBackupsDir(), { recursive: true }) + await fs.writeFile(archiveA, 'a') + await fs.writeFile(archiveB, 'b') + markJobDone(jobA.id, { outputPath: archiveA }) + markJobDone(jobB.id, { outputPath: archiveB }) + + const result = await clearUserStorage(userA) + + expect(result.failed).toEqual([]) + expect(result.deletedMedia).toBe(1) + expect(result.deletedObjects).toBe(4) + expect(result.deletedJobs).toBe(1) + expect(listImageIndexRowsForUser(userA)).toHaveLength(0) + expect(listImageIndexRowsForUser(userB)).toHaveLength(1) + expect(await backend.head(keyB)).not.toBeNull() + expect(await backend.head(keyA)).toBeNull() + expect(await backend.head(thumbA)).toBeNull() + expect(await backend.head(hlsA)).toBeNull() + expect(await backend.head(segmentA)).toBeNull() + await expect(fs.access(cacheDir)).rejects.toThrow() + + const db = getDb() + expect(db.prepare('SELECT COUNT(*) AS count FROM tags WHERE user_id = ?').get(userA)) + .toEqual({ count: 0 }) + expect(db.prepare('SELECT COUNT(*) AS count FROM tags WHERE user_id = ?').get(userB)) + .toEqual({ count: 1 }) + expect(db.prepare('SELECT COUNT(*) AS count FROM activity_logs WHERE user_id = ?').get(userA)) + .toEqual({ count: 0 }) + expect(db.prepare('SELECT COUNT(*) AS count FROM activity_logs WHERE user_id = ?').get(userB)) + .toEqual({ count: 1 }) + expect(db.prepare('SELECT COUNT(*) AS count FROM image_tags WHERE image_key = ?').get(keyA)) + .toEqual({ count: 0 }) + expect(db.prepare('SELECT COUNT(*) AS count FROM image_tags WHERE image_key = ?').get(keyB)) + .toEqual({ count: 1 }) + expect(getBackupJob(jobA.id, userA)).toBeNull() + expect(getBackupJob(jobB.id, userB)).not.toBeNull() + await expect(fs.access(archiveA)).rejects.toThrow() + await expect(fs.access(archiveB)).resolves.toBeUndefined() + }) +}) diff --git a/server/utils/user-storage.ts b/server/utils/user-storage.ts new file mode 100644 index 0000000..4302d62 --- /dev/null +++ b/server/utils/user-storage.ts @@ -0,0 +1,173 @@ +import { promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join, resolve } from 'node:path' +import { getDb } from './db' +import { + deleteImageIndex, + listImageIndexRowsForUser +} from './image-index' +import { getBackendForKey } from './storage/resolver' +import type { StorageBackend } from './storage/types' +import { cancelTranscode } from './transcode' +import { collectMediaAssetKeys } from './media-assets' +import { + deleteBackupJobsForUser, + getActiveBackupJob, + listAllBackupJobsForUser, + type BackupJob +} from './backup/jobs' +import { + getBackupsDir, + getBackupStagingDir, + isSafeBackupFilename +} from './backup/paths' + +const activeUsers = new Set() + +export interface ClearUserStorageResult { + deletedMedia: number + deletedObjects: number + deletedTags: number + deletedLogs: number + clearedCacheDirs: number + deletedJobs: number + failed: Array<{ key: string, message: string }> +} + +async function deleteObject( + backend: StorageBackend, + key: string +): Promise { + if (!await backend.head(key)) return false + await backend.delete(key) + return true +} + +async function clearHlsDownloadCache(userId: number): Promise { + let removed = 0 + const prefix = `pichost-hls-download-${userId}-` + try { + const entries = await fs.readdir(tmpdir(), { withFileTypes: true }) + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue + await fs.rm(join(tmpdir(), entry.name), { recursive: true, force: true }) + removed++ + } + } catch { + // Cache cleanup failure does not affect the media cleanup result. + } + return removed +} + +async function removeBackupJobArtifacts(job: BackupJob): Promise { + await fs.rm(getBackupStagingDir(job.id), { recursive: true, force: true }).catch(() => {}) + + const paths = [ + job.outputPath, + typeof job.payload.archivePath === 'string' ? job.payload.archivePath : null + ] + for (const path of paths) { + if (!path || !isSafeBackupFilename(basename(path))) continue + if (resolve(dirname(path)) !== resolve(getBackupsDir())) continue + await fs.rm(path, { force: true }).catch(() => {}) + } +} + +export async function clearUserStorage(userId: number): Promise { + if (activeUsers.has(userId)) { + throw new Error('USER_STORAGE_CLEAR_IN_PROGRESS') + } + activeUsers.add(userId) + + const result: ClearUserStorageResult = { + deletedMedia: 0, + deletedObjects: 0, + deletedTags: 0, + deletedLogs: 0, + clearedCacheDirs: 0, + deletedJobs: 0, + failed: [] + } + + try { + if (getActiveBackupJob(userId)) { + throw new Error('USER_STORAGE_CLEAR_ACTIVE_JOB') + } + + const backupJobs = listAllBackupJobsForUser(userId) + const rows = listImageIndexRowsForUser(userId) + for (const row of rows) { + try { + cancelTranscode(row.key) + const backend = await getBackendForKey(row.key) + let keys: string[] + try { + keys = await collectMediaAssetKeys(row, backend) + } catch { + keys = [ + ...(Number(row.source_deleted ?? 0) !== 1 ? [row.key] : []), + ...(row.thumb_key ? [row.thumb_key] : []), + ...(row.hls_key ? [row.hls_key] : []) + ] + } + + for (const key of new Set(keys)) { + if (await deleteObject(backend, key)) result.deletedObjects++ + } + deleteImageIndex(row.key) + result.deletedMedia++ + } catch (error) { + result.failed.push({ + key: row.key, + message: error instanceof Error ? error.message : '删除失败' + }) + } + } + + const db = getDb() + db.prepare(` + DELETE FROM image_tags + WHERE image_key NOT IN (SELECT key FROM images) + `).run() + + if (!result.failed.length) { + const tagCount = db.prepare('SELECT COUNT(*) AS count FROM tags WHERE user_id = ?') + .get(userId) as { count: number } + const logCount = db.prepare('SELECT COUNT(*) AS count FROM activity_logs WHERE user_id = ?') + .get(userId) as { count: number } + + db.exec('BEGIN') + try { + db.prepare(` + DELETE FROM image_tags + WHERE tag_id IN (SELECT id FROM tags WHERE user_id = ?) + `).run(userId) + db.prepare('DELETE FROM tags WHERE user_id = ?').run(userId) + db.prepare('DELETE FROM activity_logs WHERE user_id = ?').run(userId) + db.prepare(` + UPDATE users + SET auto_delete_days = 0, auto_delete_enabled_at = NULL + WHERE id = ? + `).run(userId) + db.exec('COMMIT') + result.deletedTags = Number(tagCount.count) + result.deletedLogs = Number(logCount.count) + } catch (error) { + db.exec('ROLLBACK') + throw error + } + } + + if (!result.failed.length) { + for (const job of backupJobs) { + await removeBackupJobArtifacts(job) + } + result.deletedJobs = deleteBackupJobsForUser(userId) + } + + result.clearedCacheDirs = await clearHlsDownloadCache(userId) + return result + } finally { + activeUsers.delete(userId) + } +}