From 89ea2fb25d2ce3683b7a57d0342c5813d55d9dbd Mon Sep 17 00:00:00 2001 From: YeomaiPC Date: Sat, 17 Oct 2026 03:14:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E5=BA=8A=E4=B8=8E=E6=96=87=E4=BB=B6=E5=BA=8A=EF=BC=8C=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E6=94=AF=E6=8C=81=20HLS=20=E8=BD=AC=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 文件床 - 新增 files/ 存储前缀与 media_type 索引列,图片、视频、文件三类资源独立 - 新增文件上传、列表、删除、批量删除接口,禁止 html/htm/svg - 支持公开直链与 Token 访问两种模式,后台可切换,直链强制附件下载 视频床 - 新增 videos/ 存储前缀与视频上传接口,单文件上限 500MB - 接入 ffmpeg 转码队列,并发数与单任务核心数可配,默认串行 - 五档转码质量:直接封装 / 速度 / 平衡 / 质量 / 压缩(压缩档可调码率比例) - 码率控制避免转码后体积反超源文件,音频跟随源音轨不放大不压缩 - 转码进度实时上报,成功后删除源文件,失败时保留源文件与可用链接 - 上传时自动抽帧生成封面,视频与封面各有独立链接 - 删除视频时中断转码并清理切片、封面与源文件 媒体库 - 图库、视频库、文件库三套独立统计,含趋势、占用与来源分布 - 统一筛选、多选、批量删除、批量标签、网格/列表切换,显示方式按类型分别记忆 - 视频详情对齐图片详情,视频库支持在线播放(hls.js,兼容回退原文件) 修复 - 清理无主的切片、封面与开发期遗留的 ffmpeg 进程 - 源文件按设计删除后,索引不再被启动孤儿清理误删 - 视频统计口径与列表一致,转码中的视频不计入统计 - 转码失败时回收已写入的部分切片,避免残留 - 设置页停用检查更新(与上游版本号已不对应) 其他 - Docker 镜像安装 ffmpeg - git remote 指向内网仓库 --- Dockerfile | 3 + app/components/AppShell.vue | 98 ++- app/components/DeleteConfirmModal.vue | 17 +- app/components/FileCard.vue | 116 +++ app/components/FileLibraryPanel.vue | 713 +++++++++++++++++ ...tatsOverview.vue => LibraryStatsPanel.vue} | 42 +- app/components/MediaSessionCard.vue | 249 ++++++ app/components/MediaUploadPanel.vue | 100 +++ .../{ImageUploader.vue => UploadCard.vue} | 54 +- app/components/UploadFlipCard.vue | 41 +- app/components/UploadPreferencesPanel.vue | 160 +++- app/components/VideoCard.vue | 175 ++++ app/components/VideoLibraryPanel.vue | 752 ++++++++++++++++++ app/components/VideoPreviewModal.vue | 522 ++++++++++++ .../settings/SettingsSystemInfo.vue | 5 +- app/composables/useLibraryView.ts | 40 + app/composables/useMediaUpload.ts | 286 +++++++ app/composables/useTranscodeSettings.ts | 114 +++ app/composables/useUploadPreferences.ts | 59 +- app/pages/file-library.vue | 59 ++ app/pages/files/index.vue | 38 + app/pages/gallery.vue | 12 +- app/pages/settings/index.vue | 32 +- app/pages/video-library.vue | 56 ++ app/pages/videos/index.vue | 38 + app/types/file.ts | 58 ++ app/types/media.ts | 21 + i18n/locales/en.json | 97 +++ i18n/locales/zh-CN.json | 99 ++- package-lock.json | 7 + package.json | 1 + server/api/files/batch-delete.post.ts | 81 ++ server/api/files/index.delete.ts | 77 ++ server/api/files/index.get.ts | 141 ++++ server/api/files/upload.post.ts | 79 ++ server/api/images/batch-tags.post.ts | 4 +- server/api/library/stats.get.ts | 41 + server/api/settings/index.patch.ts | 11 + server/api/videos/transcode-settings.get.ts | 22 + server/api/videos/transcode-settings.patch.ts | 80 ++ server/api/videos/upload.post.ts | 94 +++ server/plugins/startup.ts | 39 +- server/utils/access.ts | 18 + server/utils/constants.ts | 30 + server/utils/content-type.ts | 30 +- server/utils/db.ts | 5 + server/utils/env.ts | 17 + server/utils/file-bed.test.ts | 178 +++++ server/utils/file-bed.ts | 310 ++++++++ server/utils/image-index.ts | 251 +++++- server/utils/image-key.ts | 128 +++ server/utils/image-public-path.ts | 4 +- server/utils/serve-image.ts | 55 +- server/utils/storage-backends.ts | 160 ++++ server/utils/storage.ts | 43 +- server/utils/storage/local.ts | 4 +- server/utils/storage/s3.ts | 4 +- server/utils/storage/telegram.ts | 6 +- server/utils/storage/types.ts | 17 + server/utils/transcode.test.ts | 86 ++ server/utils/transcode.ts | 712 +++++++++++++++++ 61 files changed, 6637 insertions(+), 154 deletions(-) create mode 100644 app/components/FileCard.vue create mode 100644 app/components/FileLibraryPanel.vue rename app/components/{gallery/GalleryStatsOverview.vue => LibraryStatsPanel.vue} (92%) create mode 100644 app/components/MediaSessionCard.vue create mode 100644 app/components/MediaUploadPanel.vue rename app/components/{ImageUploader.vue => UploadCard.vue} (70%) create mode 100644 app/components/VideoCard.vue create mode 100644 app/components/VideoLibraryPanel.vue create mode 100644 app/components/VideoPreviewModal.vue create mode 100644 app/composables/useLibraryView.ts create mode 100644 app/composables/useMediaUpload.ts create mode 100644 app/composables/useTranscodeSettings.ts create mode 100644 app/pages/file-library.vue create mode 100644 app/pages/files/index.vue create mode 100644 app/pages/video-library.vue create mode 100644 app/pages/videos/index.vue create mode 100644 app/types/file.ts create mode 100644 app/types/media.ts create mode 100644 server/api/files/batch-delete.post.ts create mode 100644 server/api/files/index.delete.ts create mode 100644 server/api/files/index.get.ts create mode 100644 server/api/files/upload.post.ts create mode 100644 server/api/library/stats.get.ts create mode 100644 server/api/videos/transcode-settings.get.ts create mode 100644 server/api/videos/transcode-settings.patch.ts create mode 100644 server/api/videos/upload.post.ts create mode 100644 server/utils/file-bed.test.ts create mode 100644 server/utils/file-bed.ts create mode 100644 server/utils/transcode.test.ts create mode 100644 server/utils/transcode.ts diff --git a/Dockerfile b/Dockerfile index c44922d..f472f37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,9 @@ FROM node:22-alpine WORKDIR /app +# 视频床转码依赖 ffmpeg(单码率 HLS 切片) +RUN apk add --no-cache ffmpeg + ENV NODE_ENV=production \ NITRO_PORT=6892 \ DATA_DIR=/data diff --git a/app/components/AppShell.vue b/app/components/AppShell.vue index c251183..849440f 100644 --- a/app/components/AppShell.vue +++ b/app/components/AppShell.vue @@ -13,7 +13,9 @@ const mobileUserOpen = ref(false) const navItems = computed(() => { const items = [ { label: t('nav.api'), to: '/api', icon: 'i-lucide-code-2' }, - { label: t('nav.gallery'), to: '/gallery', icon: 'i-lucide-images' } + { label: t('nav.gallery'), to: '/gallery', icon: 'i-lucide-images' }, + { label: t('nav.videoLibrary'), to: '/video-library', icon: 'i-lucide-film' }, + { label: t('nav.fileLibrary'), to: '/file-library', icon: 'i-lucide-folder-open' } ] if (isAdmin.value) { items.push({ label: t('nav.storage'), to: '/storage', icon: 'i-lucide-hard-drive' }) @@ -22,6 +24,28 @@ const navItems = computed(() => { return items }) +/** 三个上传入口,与顶部 Logo 并列 */ +const hostItems = computed(() => [ + { + label: t('host.video'), + to: '/videos', + icon: 'i-lucide-film', + title: t('host.videoFull') + }, + { + label: t('host.file'), + to: '/files', + icon: 'i-lucide-folder-open', + title: t('host.fileFull') + } +]) + +const mobileNavItems = computed(() => [...hostItems.value, ...navItems.value]) + +function isHostActive(to: string) { + return route.path === to || route.path.startsWith(`${to}/`) +} + const accountMenuItems = computed(() => [ { label: t('nav.changePassword'), @@ -66,34 +90,50 @@ async function handleLogout() {
- - PicHost + - -
-

- PicHost -

- -
-
+ PicHost + +
+

+ PicHost +

+ +
+
+ + +
@@ -124,7 +164,7 @@ async function handleLogout() { />
diff --git a/app/components/DeleteConfirmModal.vue b/app/components/DeleteConfirmModal.vue index 09aae7b..40c70ca 100644 --- a/app/components/DeleteConfirmModal.vue +++ b/app/components/DeleteConfirmModal.vue @@ -1,10 +1,16 @@ + + diff --git a/app/components/FileLibraryPanel.vue b/app/components/FileLibraryPanel.vue new file mode 100644 index 0000000..eabc5f5 --- /dev/null +++ b/app/components/FileLibraryPanel.vue @@ -0,0 +1,713 @@ + + + diff --git a/app/components/gallery/GalleryStatsOverview.vue b/app/components/LibraryStatsPanel.vue similarity index 92% rename from app/components/gallery/GalleryStatsOverview.vue rename to app/components/LibraryStatsPanel.vue index eed8451..9af576c 100644 --- a/app/components/gallery/GalleryStatsOverview.vue +++ b/app/components/LibraryStatsPanel.vue @@ -1,33 +1,31 @@ + + diff --git a/app/components/MediaUploadPanel.vue b/app/components/MediaUploadPanel.vue new file mode 100644 index 0000000..07a908f --- /dev/null +++ b/app/components/MediaUploadPanel.vue @@ -0,0 +1,100 @@ + + + diff --git a/app/components/ImageUploader.vue b/app/components/UploadCard.vue similarity index 70% rename from app/components/ImageUploader.vue rename to app/components/UploadCard.vue index 999fa1b..b32a2f4 100644 --- a/app/components/ImageUploader.vue +++ b/app/components/UploadCard.vue @@ -2,9 +2,24 @@ import uploadCardBg from '~/assets/image/upload-card-bg.webp' import uploadCardHero from '~/assets/image/upload-card-hero.webp' -const props = defineProps<{ +const props = withDefaults(defineProps<{ disabled?: boolean -}>() + title?: string + hint?: string + formats?: string + accept?: string + heroImage?: string + backgroundImage?: string + showSettings?: boolean + pasteEnabled?: boolean + /** 拖拽/选择时的类型限制;图片页与视频页各自限定,文件页放开 */ + mediaKind?: 'image' | 'video' | 'any' +}>(), { + accept: 'image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon,.svg,.ico', + showSettings: true, + pasteEnabled: true, + mediaKind: 'image' +}) const emit = defineEmits<{ 'upload': [files: File[]] @@ -18,9 +33,22 @@ const { t } = useI18n() const { bindPaste } = useClipboardImage(ref(null)) bindPaste((files) => { - if (!props.disabled) emit('upload', files) + if (props.disabled || !props.pasteEnabled) return + emit('upload', files) }) +const heroSource = computed(() => props.heroImage ?? uploadCardHero) +const backgroundSource = computed(() => props.backgroundImage ?? uploadCardBg) +const titleText = computed(() => props.title ?? t('upload.title')) +const hintText = computed(() => props.hint ?? t('upload.hint', { ctrl: 'Ctrl', v: 'V' })) +const formatsText = computed(() => props.formats ?? t('upload.formats')) + +function matchesMediaKind(file: File): boolean { + if (props.mediaKind === 'any') return true + if (!file.type) return true + return file.type.startsWith(`${props.mediaKind}/`) +} + function openFilePicker() { if (props.disabled) return fileInput.value?.click() @@ -38,8 +66,7 @@ function onDrop(event: DragEvent) { dragging.value = false if (props.disabled) return - const files = Array.from(event.dataTransfer?.files ?? []) - .filter(file => file.type.startsWith('image/')) + const files = Array.from(event.dataTransfer?.files ?? []).filter(matchesMediaKind) if (files.length) emit('upload', files) } @@ -83,7 +110,7 @@ function onKeydown(event: KeyboardEvent) { -
+

- {{ t('upload.formats') }} + {{ formatsText }}

diff --git a/app/components/UploadFlipCard.vue b/app/components/UploadFlipCard.vue index 7a4ffdc..9bb0074 100644 --- a/app/components/UploadFlipCard.vue +++ b/app/components/UploadFlipCard.vue @@ -1,7 +1,20 @@ @@ -78,8 +128,14 @@ onMounted(() => {
-
-
+
+

{{ t('preferences.processing') }}

@@ -137,7 +193,103 @@ onMounted(() => {
-
+
+

+ {{ t('preferences.videoProcessing') }} +

+ + + +
+
+ {{ t('preferences.transcodeConcurrency') }} + + {{ t('preferences.unitCount') }} + {{ t('preferences.concurrentHint') }} +
+ +
+ {{ t('preferences.transcodeThreads') }} + + {{ t('preferences.unitCount') }} + {{ t('preferences.threadsHint') }} +
+ +
+ {{ t('preferences.transcodeQuality') }} +
+ +
+
+ +
+ {{ t('preferences.compressBitrate') }} + + + {{ transcodeBitratePercent }}% + +
+
+
+ +

{{ t('preferences.webpQuality') }}

diff --git a/app/components/VideoCard.vue b/app/components/VideoCard.vue new file mode 100644 index 0000000..7fe07f2 --- /dev/null +++ b/app/components/VideoCard.vue @@ -0,0 +1,175 @@ + + + diff --git a/app/components/VideoLibraryPanel.vue b/app/components/VideoLibraryPanel.vue new file mode 100644 index 0000000..5354410 --- /dev/null +++ b/app/components/VideoLibraryPanel.vue @@ -0,0 +1,752 @@ + + + diff --git a/app/components/VideoPreviewModal.vue b/app/components/VideoPreviewModal.vue new file mode 100644 index 0000000..bcd7f30 --- /dev/null +++ b/app/components/VideoPreviewModal.vue @@ -0,0 +1,522 @@ + + + diff --git a/app/components/settings/SettingsSystemInfo.vue b/app/components/settings/SettingsSystemInfo.vue index 3d601e2..9c11168 100644 --- a/app/components/settings/SettingsSystemInfo.vue +++ b/app/components/settings/SettingsSystemInfo.vue @@ -7,7 +7,8 @@ defineProps<{ checking: boolean }>() -const emit = defineEmits<{ +// 「检查更新」已停用(改动较大,与上游版本号已不对应),暂时保留事件定义 +defineEmits<{ checkUpdate: [] }>() @@ -28,6 +29,7 @@ const { t } = useI18n() v{{ appVersion }} +
diff --git a/app/composables/useLibraryView.ts b/app/composables/useLibraryView.ts new file mode 100644 index 0000000..01c94b2 --- /dev/null +++ b/app/composables/useLibraryView.ts @@ -0,0 +1,40 @@ +const STORAGE_KEY = 'pichost.library-view' +export type LibraryViewKind = 'image' | 'video' | 'file' + +export type LibraryViewMode = 'grid' | 'list' + +function storageKeyFor(kind: LibraryViewKind): string { + return `${STORAGE_KEY}.${kind}` +} + +/** + * 图库 / 视频库 / 文件库各自的显示方式。 + * 三个库分别记住(互不影响),且页面切换后保持,不用重新点。 + */ +export function useLibraryView(kind: LibraryViewKind = 'image') { + const viewMode = useState(`library-view-mode-${kind}`, () => 'grid') + const loaded = useState(`library-view-loaded-${kind}`, () => false) + + if (import.meta.client && !loaded.value) { + try { + const stored = localStorage.getItem(storageKeyFor(kind)) + if (stored === 'grid' || stored === 'list') { + viewMode.value = stored + } + } catch { + // 读取失败保持默认 + } + loaded.value = true + } + + watch(viewMode, (value) => { + if (!import.meta.client) return + try { + localStorage.setItem(storageKeyFor(kind), value) + } catch { + // 写入失败不影响使用 + } + }) + + return { viewMode } +} diff --git a/app/composables/useMediaUpload.ts b/app/composables/useMediaUpload.ts new file mode 100644 index 0000000..75cdc95 --- /dev/null +++ b/app/composables/useMediaUpload.ts @@ -0,0 +1,286 @@ +import type { UploadProgressItem } from '~/types/image' +import type { FileItem, FileListResponse, FileUploadResponse } from '~/types/file' +import type { CopyFormat, UploadMediaKind } from './useUploadPreferences' +import { isUnauthorizedError } from './useAuth' + +const MAX_FILES = 10 + +function buildCopyText(item: FileItem, format: CopyFormat): string { + switch (format) { + case 'url': + return item.url + case 'html': + return `${item.originalName}` + case 'bbcode': + return `[url=${item.url}]${item.originalName}[/url]` + default: + return `[${item.originalName}](${item.url})` + } +} + +async function copyToClipboard(text: string): Promise { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + return true + } + } catch { + // 内网 http:// 等非安全上下文继续走回退方案 + } + + try { + const textarea = document.createElement('textarea') + textarea.value = text + textarea.style.position = 'fixed' + textarea.style.left = '-9999px' + document.body.appendChild(textarea) + textarea.select() + const ok = document.execCommand('copy') + document.body.removeChild(textarea) + return ok + } catch { + return false + } +} + +/** 视频床 / 文件床的上传:走文件床接口,偏好按类型独立 */ +export function useMediaUpload(kind: UploadMediaKind) { + const toast = useToast() + const { t } = useI18n() + const { + autoCopyMarkdown, + copyFormat, + transcodeToHls, + loadPreferences + } = useUploadPreferences(kind) + + const uploading = ref(false) + const progressItems = ref([]) + const lastResult = ref(null) + /** 本次会话内上传的条目,页面刷新后清空 */ + const sessionItems = ref([]) + const accessMode = ref<'public' | 'token'>('public') + const apiToken = ref('') + + const endpoint = kind === 'video' ? '/api/videos/upload' : '/api/files/upload' + + loadPreferences() + + async function loadApiToken() { + try { + const data = await $fetch<{ apiUploadToken: string }>('/api/user/api-token', { + credentials: 'include' + }) + apiToken.value = data.apiUploadToken ?? '' + } catch { + apiToken.value = '' + } + } + + /** Token 模式下复制出的链接需要带上令牌 */ + function linkUrl(item: FileItem): string { + // 转码完成后优先给出 m3u8 播放地址 + const base = item.hlsUrl ?? item.url + if (accessMode.value !== 'token' || !apiToken.value) return base + const separator = base.includes('?') ? '&' : '?' + return `${base}${separator}token=${encodeURIComponent(apiToken.value)}` + } + + /** 封面缩略图地址(同样按访问模式拼接) */ + function thumbLink(item: FileItem): string { + const base = item.thumbUrl + if (!base) return '' + if (accessMode.value !== 'token' || !apiToken.value) return base + const separator = base.includes('?') ? '&' : '?' + return `${base}${separator}token=${encodeURIComponent(apiToken.value)}` + } + + let statusTimer: ReturnType | null = null + + function stopStatusPolling() { + if (statusTimer) { + clearInterval(statusTimer) + statusTimer = null + } + } + + function hasPendingTranscode(): boolean { + return sessionItems.value.some( + item => item.transcodeStatus === 'pending' || item.transcodeStatus === 'running' + ) + } + + /** 转码是异步的,轮询列表把状态与 m3u8 地址同步回来 */ + async function refreshTranscodeStatus() { + if (!hasPendingTranscode()) { + stopStatusPolling() + return + } + + try { + const [active, finished] = await Promise.all([ + $fetch('/api/files', { + credentials: 'include', + query: { kind: 'video', limit: 100, processing: '1' } + }), + $fetch('/api/files', { + credentials: 'include', + query: { kind: 'video', limit: 100 } + }) + ]) + const latest = new Map( + [...active.items, ...finished.items].map(item => [item.key, item]) + ) + sessionItems.value = sessionItems.value.map(item => latest.get(item.key) ?? item) + } catch { + // 轮询失败保持现状,下个周期重试 + } + + if (!hasPendingTranscode()) stopStatusPolling() + } + + function startStatusPolling() { + if (kind !== 'video' || statusTimer) return + statusTimer = setInterval(() => { + void refreshTranscodeStatus() + }, 1000) + } + + onUnmounted(stopStatusPolling) + + /** 刷新或换页后仍要显示还在排队/转码中的视频 */ + async function loadActiveTranscodes() { + if (kind !== 'video') return + + try { + const response = await $fetch('/api/files', { + credentials: 'include', + query: { kind: 'video', limit: 100, processing: '1' } + }) + if (!response.items.length) return + + const known = new Set(sessionItems.value.map(item => item.key)) + const missing = response.items.filter(item => !known.has(item.key)) + const latest = new Map(response.items.map(item => [item.key, item])) + + sessionItems.value = [ + ...missing, + ...sessionItems.value.map(item => latest.get(item.key) ?? item) + ] + startStatusPolling() + } catch { + // 拉取失败时保持现状 + } + } + + async function uploadFiles(files: File[]) { + if (!files.length || uploading.value) return + + if (files.length > MAX_FILES) { + toast.add({ title: t('files.maxFiles', { n: MAX_FILES }), color: 'warning' }) + return + } + + uploading.value = true + progressItems.value = files.map(file => ({ + name: file.name, + status: 'pending', + progress: 0 + })) + + try { + const formData = new FormData() + for (const file of files) { + formData.append('files', file, file.name) + } + + if (kind === 'video' && transcodeToHls.value) { + formData.append('transcode', '1') + } + + progressItems.value = progressItems.value.map(item => ({ + ...item, + status: 'uploading', + progress: 50 + })) + + const response = await $fetch(endpoint, { + method: 'POST', + body: formData + }) + + lastResult.value = response + accessMode.value = response.accessMode + if (response.accessMode === 'token' && !apiToken.value) { + await loadApiToken() + } + + progressItems.value = files.map((file) => { + const failed = response.errors.find(error => error.name === file.name) + return { + name: file.name, + status: failed ? 'error' : 'success', + progress: 100, + error: failed?.error.message + } + }) + + if (response.items.length) { + const wantsTranscode = kind === 'video' && transcodeToHls.value + const uploaded = wantsTranscode + ? response.items.map(item => ({ ...item, transcodeStatus: 'pending' as const })) + : response.items + + sessionItems.value = [...uploaded, ...sessionItems.value] + if (wantsTranscode) startStatusPolling() + + toast.add({ + title: t('files.uploadSuccess', { n: response.items.length }), + color: 'success' + }) + + const first = response.items[0] + if (autoCopyMarkdown.value && first) { + const text = buildCopyText(first, copyFormat.value) + if (text && await copyToClipboard(text)) { + toast.add({ title: t('copy.copied'), color: 'success' }) + } + } + } + + for (const failure of response.errors.slice(0, 3)) { + toast.add({ + title: `${failure.name}:${failure.error.message}`, + color: 'error' + }) + } + + return response + } catch (error: unknown) { + if (!isUnauthorizedError(error)) { + progressItems.value = progressItems.value.map(item => ({ + ...item, + status: 'error', + progress: 100, + error: t('files.uploadFailed') + })) + toast.add({ title: t('files.uploadFailed'), color: 'error' }) + } + throw error + } finally { + uploading.value = false + } + } + + return { + uploading, + progressItems, + lastResult, + sessionItems, + accessMode, + linkUrl, + thumbLink, + loadActiveTranscodes, + uploadFiles + } +} diff --git a/app/composables/useTranscodeSettings.ts b/app/composables/useTranscodeSettings.ts new file mode 100644 index 0000000..23fae69 --- /dev/null +++ b/app/composables/useTranscodeSettings.ts @@ -0,0 +1,114 @@ +interface TranscodeSettingsResponse { + concurrency: number + threads: number + preset: string + bitratePercent: number + maxConcurrency: number + maxThreads: number + minBitratePercent: number + maxBitratePercent: number + presets: string[] +} + +/** 服务端转码参数:并发任务数与单任务 CPU 核心数(仅管理员可改) */ +export function useTranscodeSettings() { + const { isAuthenticated, isAdmin, handleAuthError } = useAuth() + const toast = useToast() + const { t } = useI18n() + + const concurrency = ref(1) + const threads = ref(1) + const maxConcurrency = ref(8) + const maxThreads = ref(64) + const preset = ref('veryfast') + const presets = ref(['veryfast', 'medium', 'slow']) + const bitratePercent = ref(80) + const minBitratePercent = ref(10) + const maxBitratePercent = ref(100) + const loaded = ref(false) + const hydrated = ref(false) + + let saveTimer: ReturnType | null = null + + function apply(data: TranscodeSettingsResponse) { + hydrated.value = false + concurrency.value = data.concurrency + threads.value = data.threads + preset.value = data.preset + bitratePercent.value = data.bitratePercent + minBitratePercent.value = data.minBitratePercent ?? 10 + maxBitratePercent.value = data.maxBitratePercent ?? 100 + if (data.presets?.length) presets.value = data.presets + maxConcurrency.value = data.maxConcurrency + maxThreads.value = data.maxThreads + loaded.value = true + nextTick(() => { + hydrated.value = true + }) + } + + async function load() { + if (!isAuthenticated.value) return + try { + apply(await $fetch('/api/videos/transcode-settings', { + credentials: 'include' + })) + } catch (error: unknown) { + handleAuthError(error) + if (isAuthenticated.value) { + toast.add({ title: t('preferences.transcodeLoadFailed'), color: 'error' }) + } + } + } + + async function save() { + if (!hydrated.value || !isAdmin.value) return + try { + const data = await $fetch('/api/videos/transcode-settings', { + method: 'PATCH', + credentials: 'include', + body: { + concurrency: concurrency.value, + threads: threads.value, + preset: preset.value, + bitratePercent: bitratePercent.value + } + }) + hydrated.value = false + concurrency.value = data.concurrency + threads.value = data.threads + preset.value = data.preset + bitratePercent.value = data.bitratePercent + nextTick(() => { + hydrated.value = true + }) + } catch (error: unknown) { + handleAuthError(error) + if (isAuthenticated.value) { + toast.add({ title: t('preferences.transcodeSaveFailed'), color: 'error' }) + } + } + } + + watch([concurrency, threads, preset, bitratePercent], () => { + if (!hydrated.value || !isAdmin.value) return + if (saveTimer) clearTimeout(saveTimer) + saveTimer = setTimeout(() => { + void save() + }, 600) + }) + + return { + concurrency, + threads, + preset, + bitratePercent, + minBitratePercent, + maxBitratePercent, + presets, + maxConcurrency, + maxThreads, + loaded, + load + } +} diff --git a/app/composables/useUploadPreferences.ts b/app/composables/useUploadPreferences.ts index f9bc6be..ab66516 100644 --- a/app/composables/useUploadPreferences.ts +++ b/app/composables/useUploadPreferences.ts @@ -1,6 +1,12 @@ const STORAGE_KEY = 'pichost.upload-preferences' export type CopyFormat = 'url' | 'markdown' | 'html' | 'bbcode' +export type UploadMediaKind = 'image' | 'video' | 'file' + +/** 每种上传页各存一份偏好,互不影响 */ +function storageKeyFor(kind: UploadMediaKind): string { + return kind === 'image' ? STORAGE_KEY : `${STORAGE_KEY}.${kind}` +} export interface UploadPreferences { compressEnabled: boolean @@ -8,6 +14,8 @@ export interface UploadPreferences { copyFormat: CopyFormat clientWebpQuality: number lastTagIds: number[] + /** 视频床专属:上传后转码为 HLS(m3u8 + 分片) */ + transcodeToHls: boolean } const DEFAULTS: UploadPreferences = { @@ -15,7 +23,8 @@ const DEFAULTS: UploadPreferences = { autoCopyMarkdown: false, copyFormat: 'markdown', clientWebpQuality: 80, - lastTagIds: [] + lastTagIds: [], + transcodeToHls: false } function clampQuality(value: unknown): number { @@ -28,11 +37,11 @@ function isCopyFormat(value: unknown): value is CopyFormat { return value === 'url' || value === 'markdown' || value === 'html' || value === 'bbcode' } -function readStored(): UploadPreferences { +function readStored(kind: UploadMediaKind): UploadPreferences { if (import.meta.server) return { ...DEFAULTS } try { - const raw = localStorage.getItem(STORAGE_KEY) + const raw = localStorage.getItem(storageKeyFor(kind)) if (!raw) return { ...DEFAULTS } const parsed = JSON.parse(raw) as Partial return { @@ -42,33 +51,36 @@ function readStored(): UploadPreferences { clientWebpQuality: clampQuality(parsed.clientWebpQuality), lastTagIds: Array.isArray(parsed.lastTagIds) ? parsed.lastTagIds.filter(id => Number.isInteger(id) && id > 0) - : DEFAULTS.lastTagIds + : DEFAULTS.lastTagIds, + transcodeToHls: parsed.transcodeToHls === true } } catch { return { ...DEFAULTS } } } -function writeStored(prefs: UploadPreferences) { +function writeStored(kind: UploadMediaKind, prefs: UploadPreferences) { if (import.meta.server) return - localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)) + localStorage.setItem(storageKeyFor(kind), JSON.stringify(prefs)) } -export function useUploadPreferences() { - const compressEnabled = useState('upload-pref-compress', () => DEFAULTS.compressEnabled) - const autoCopyMarkdown = useState('upload-pref-auto-md', () => DEFAULTS.autoCopyMarkdown) - const copyFormat = useState('upload-pref-copy-format', () => DEFAULTS.copyFormat) - const clientWebpQuality = useState('upload-pref-webp-quality', () => DEFAULTS.clientWebpQuality) - const lastTagIds = useState('upload-pref-last-tag-ids', () => DEFAULTS.lastTagIds) - const loaded = useState('upload-pref-loaded', () => false) +export function useUploadPreferences(kind: UploadMediaKind = 'image') { + const compressEnabled = useState(`upload-pref-compress-${kind}`, () => DEFAULTS.compressEnabled) + const autoCopyMarkdown = useState(`upload-pref-auto-md-${kind}`, () => DEFAULTS.autoCopyMarkdown) + const copyFormat = useState(`upload-pref-copy-format-${kind}`, () => DEFAULTS.copyFormat) + const clientWebpQuality = useState(`upload-pref-webp-quality-${kind}`, () => DEFAULTS.clientWebpQuality) + const lastTagIds = useState(`upload-pref-last-tag-ids-${kind}`, () => DEFAULTS.lastTagIds) + const transcodeToHls = useState(`upload-pref-transcode-${kind}`, () => DEFAULTS.transcodeToHls) + const loaded = useState(`upload-pref-loaded-${kind}`, () => false) function loadPreferences() { - const stored = readStored() + const stored = readStored(kind) compressEnabled.value = stored.compressEnabled autoCopyMarkdown.value = stored.autoCopyMarkdown copyFormat.value = stored.copyFormat clientWebpQuality.value = stored.clientWebpQuality lastTagIds.value = stored.lastTagIds + transcodeToHls.value = stored.transcodeToHls loaded.value = true } @@ -77,12 +89,13 @@ export function useUploadPreferences() { } function savePreferences() { - writeStored({ + writeStored(kind, { compressEnabled: compressEnabled.value, autoCopyMarkdown: autoCopyMarkdown.value, copyFormat: copyFormat.value, clientWebpQuality: clampQuality(clientWebpQuality.value), - lastTagIds: lastTagIds.value + lastTagIds: lastTagIds.value, + transcodeToHls: transcodeToHls.value }) } @@ -91,21 +104,27 @@ export function useUploadPreferences() { autoCopyMarkdown.value = DEFAULTS.autoCopyMarkdown copyFormat.value = DEFAULTS.copyFormat clientWebpQuality.value = DEFAULTS.clientWebpQuality + transcodeToHls.value = DEFAULTS.transcodeToHls savePreferences() } - watch([compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality, lastTagIds], () => { - if (loaded.value) { - savePreferences() + watch( + [compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality, lastTagIds, transcodeToHls], + () => { + if (loaded.value) { + savePreferences() + } } - }) + ) return { + kind, compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality, lastTagIds, + transcodeToHls, loaded, loadPreferences, savePreferences, diff --git a/app/pages/file-library.vue b/app/pages/file-library.vue new file mode 100644 index 0000000..800803e --- /dev/null +++ b/app/pages/file-library.vue @@ -0,0 +1,59 @@ + + + diff --git a/app/pages/files/index.vue b/app/pages/files/index.vue new file mode 100644 index 0000000..4aaf070 --- /dev/null +++ b/app/pages/files/index.vue @@ -0,0 +1,38 @@ + + + diff --git a/app/pages/gallery.vue b/app/pages/gallery.vue index b0e0f5c..b124d97 100644 --- a/app/pages/gallery.vue +++ b/app/pages/gallery.vue @@ -51,8 +51,7 @@ const { activeTagMode, activeUntaggedOnly, setActiveTagFilter, - toggleTagFilter, - listQueryParams + toggleTagFilter } = useImageList() const { items: tagItems, fetchTags, batchUpdateTags } = useTags() @@ -73,7 +72,7 @@ const batchDeleting = ref(false) const showScrollTop = ref(false) const previewOpen = ref(false) const previewImage = ref(null) -const viewMode = ref<'grid' | 'list'>('grid') +const { viewMode } = useLibraryView('image') const batchTagOpen = ref(false) const currentUploadSource = ref('all') @@ -104,9 +103,9 @@ function scrollToTop() { } async function fetchStats() { - stats.value = await $fetch('/api/stats', { + stats.value = await $fetch('/api/library/stats', { credentials: 'include', - query: listQueryParams() + query: { kind: 'image' } }) } @@ -466,7 +465,8 @@ watch(isAuthenticated, async (authed, prev) => { />
- diff --git a/app/pages/settings/index.vue b/app/pages/settings/index.vue index 2e44899..1689782 100644 --- a/app/pages/settings/index.vue +++ b/app/pages/settings/index.vue @@ -61,6 +61,7 @@ interface SettingsResponse { loginRateWindowMinutesSource: PolicySource preserveOriginalUpload: boolean preserveOriginalUploadSource: PolicySource + fileBedAccess: 'public' | 'token' mimeTypeOptions: ReadonlyArray<{ mime: MimeType, label: string }> } @@ -171,6 +172,20 @@ const uploadRateWindowMinutesDraft = ref(15) const loginRateMaxDraft = ref(10) const loginRateWindowMinutesDraft = ref(15) const preserveOriginalUploadDraft = ref(false) +const fileBedAccessDraft = ref<'public' | 'token'>('public') + +const fileBedAccessOptions = computed(() => [ + { + value: 'public', + label: t('settings.fileBedPublic'), + description: t('settings.fileBedPublicHint') + }, + { + value: 'token', + label: t('settings.fileBedToken'), + description: t('settings.fileBedTokenHint') + } +]) interface ReleaseCheckResponse { currentVersion: string @@ -207,6 +222,7 @@ const hasServerChanges = computed(() => { || loginRateMaxDraft.value !== settings.value.loginRateMax || loginRateWindowMinutesDraft.value !== settings.value.loginRateWindowMinutes || preserveOriginalUploadDraft.value !== settings.value.preserveOriginalUpload + || fileBedAccessDraft.value !== settings.value.fileBedAccess }) const domainSeparationWouldActivate = computed(() => @@ -270,6 +286,7 @@ function applySettings(data: SettingsResponse) { loginRateMaxDraft.value = data.loginRateMax loginRateWindowMinutesDraft.value = data.loginRateWindowMinutes preserveOriginalUploadDraft.value = data.preserveOriginalUpload + fileBedAccessDraft.value = data.fileBedAccess } async function patchSettings(body: Record) { @@ -370,7 +387,8 @@ async function performSaveServerSettings() { uploadRateWindowMinutes: uploadRateWindowMinutesDraft.value, loginRateMax: loginRateMaxDraft.value, loginRateWindowMinutes: loginRateWindowMinutesDraft.value, - preserveOriginalUpload: preserveOriginalUploadDraft.value + preserveOriginalUpload: preserveOriginalUploadDraft.value, + fileBedAccess: fileBedAccessDraft.value }) toast.add({ title: t('settings.saved'), color: 'success' }) } catch (error: unknown) { @@ -923,6 +941,18 @@ watch(isAdmin, () => {
+ + + + + + +import VideoLibraryPanel from '~/components/VideoLibraryPanel.vue' + +const { isChecking, isAuthenticated, checkSession, fetchStatus } = useAuth() +const { t } = useI18n() + +const panelRef = ref<{ refresh: () => Promise } | null>(null) + +async function loadPanel() { + if (isAuthenticated.value) { + await panelRef.value?.refresh() + } +} + +onMounted(async () => { + const status = await fetchStatus() + if (!status.initialized && !status.legacyMode) { + await navigateTo('/setup') + return + } + await checkSession() + await loadPanel() +}) + +watch(isAuthenticated, async (authed, prev) => { + if (authed && prev === false) { + await nextTick() + await loadPanel() + } +}) + + + diff --git a/app/pages/videos/index.vue b/app/pages/videos/index.vue new file mode 100644 index 0000000..01b7146 --- /dev/null +++ b/app/pages/videos/index.vue @@ -0,0 +1,38 @@ + + + diff --git a/app/types/file.ts b/app/types/file.ts new file mode 100644 index 0000000..3a69458 --- /dev/null +++ b/app/types/file.ts @@ -0,0 +1,58 @@ +import type { ImageTag, UploadErrorItem } from './image' + +export interface FileItem { + key: string + url: string + originalName: string + contentType: string + size: number + uploadedAt: string + markdown: string + /** 视频转码状态(仅视频有值) */ + transcodeStatus?: 'none' | 'pending' | 'running' | 'done' | 'failed' + /** 转码进度 0–100 */ + transcodeProgress?: number + /** 本视频使用的转码档位 */ + transcodePreset?: string + /** HLS 播放列表地址(转码完成后有值) */ + hlsUrl?: string + /** HLS 播放列表在存储中的 key */ + hlsKey?: string + /** 视频封面缩略图地址 */ + thumbUrl?: string + /** 上传来源(来自活动日志) */ + uploadSource?: 'web' | 'api' + /** 转码成功后源视频已删除,不再提供原文件下载 */ + sourceDeleted?: boolean + /** 视频标签 */ + tags?: ImageTag[] + /** 管理员列表可见:上传者信息 */ + owner?: { + userId: number | null + username: string + } + /** 文件所在存储后端 */ + storage?: { + id: string + name: string + type: 'local' | 's3' | 'telegram' + } +} + +export interface FileListResponse { + items: FileItem[] + total: number + page: number + pageSize: number + totalPages: number + /** 文件床直链访问方式(后台可切换) */ + accessMode: 'public' | 'token' +} + +export interface FileUploadResponse { + success: boolean + items: FileItem[] + errors: UploadErrorItem[] + /** 直链访问方式,token 模式下复制链接需要附带令牌 */ + accessMode: 'public' | 'token' +} diff --git a/app/types/media.ts b/app/types/media.ts new file mode 100644 index 0000000..7e83e19 --- /dev/null +++ b/app/types/media.ts @@ -0,0 +1,21 @@ +export type MediaType = 'image' | 'video' | 'file' + +/** 图库 / 视频库 / 文件库共用的统计数据结构 */ +export interface LibraryStatsData { + uploadToday: number + uploadYesterday: number + uploadMonth: number + uploadLastMonth: number + uploadTotal: number + storedCount: number + uploadBytesTotal: number + bySource: { + web: number + api: number + } + storageUsage: { + usedBytes: number + totalBytes: number | null + percent: number | null + } +} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 419c875..2472640 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -24,6 +24,8 @@ "nav": { "api": "API", "gallery": "Gallery", + "videoLibrary": "Video library", + "fileLibrary": "File library", "stats": "Stats", "logs": "Activity log", "settings": "Settings", @@ -298,6 +300,23 @@ "qualityMedium": "Medium quality", "qualityLow": "Lossy", "convenience": "Convenience", + "videoProcessing": "Video processing", + "transcodeToHls": "Transcode to m3u8", + "transcodeToHlsHint": "Segment into HLS, uses extra CPU.", + "concurrentHint": "at a time", + "threadsHint": "per job", + "transcodeConcurrency": "Concurrent transcodes", + "transcodeThreads": "CPU threads", + "unitCount": "", + "transcodeQuality": "Quality", + "presetCopy": "Remux", + "presetSpeed": "Speed", + "presetBalanced": "Balanced", + "presetQuality": "Quality", + "presetCompress": "Compress", + "compressBitrate": "Target bitrate", + "transcodeLoadFailed": "Failed to load transcode settings", + "transcodeSaveFailed": "Failed to save transcode settings", "autoCopy": "Copy link after upload", "autoCopyHint": "Copy to clipboard in the selected format" }, @@ -356,6 +375,12 @@ "refererUnrestricted": "Unrestricted", "loginVerification": "Login verification", "loginVerificationHint": "Human verification for login and registration (choose one)", + "fileBed": "File bed", + "fileBedHint": "Access mode for file download links. Takes effect immediately.", + "fileBedPublic": "Public", + "fileBedPublicHint": "Anyone with the link can download. Best for sharing.", + "fileBedToken": "Token required", + "fileBedTokenHint": "Downloads must carry a token (append ?token= to the link). Best for private files.", "loginVerificationSlider": "Local slider", "loginVerificationSliderHint": "Built-in slider puzzle; no third-party service", "loginVerificationTurnstile": "Cloudflare Turnstile", @@ -816,6 +841,8 @@ "storageBackend": "Storage: {name}" }, "delete": { + "confirmDescVideo": "Delete the selected {count} video(s)? This cannot be undone.", + "confirmDescFile": "Delete the selected {count} file(s)? This cannot be undone.", "confirmTitle": "Confirm delete", "confirmDesc": "Delete {count} selected image(s)? This cannot be undone.", "confirm": "Delete" @@ -967,6 +994,76 @@ } } }, + "files": { + "maxFiles": "Up to {n} files per upload", + "uploadSuccess": "Uploaded {n} file(s)", + "uploadFailed": "Upload failed, please try again", + "loadFailed": "Failed to load files", + "copyLink": "Copy link", + "download": "Download", + "deleted": "Deleted", + "deleteFailed": "Delete failed" + }, + "host": { + "video": "Video", + "file": "File", + "imageFull": "Personal lightweight image host", + "videoFull": "Personal lightweight video host", + "fileFull": "Personal lightweight file host" + }, + "library": { + "play": "Play", + "copyVideoLink": "Copy video link", + "copyThumbLink": "Copy thumbnail link", + "selectedCount": "{n} selected", + "selectFirst": "Select videos first", + "clearSelection": "Clear", + "reset": "Reset", + "batchDelete": "Delete", + "deleted": "Deleted {n} video(s)", + "fileDeleted": "Deleted {n} file(s)", + "deletePartialFail": "{n} video(s) failed to delete", + "statsToday": "Uploaded today", + "statsMonth": "This month", + "statsTotal": "Videos", + "statsBytes": "Total size", + "statsUsage": "Storage usage", + "statsSource": "Upload source", + "statsTotalHintVideo": "Total videos", + "statsTotalHintFile": "Total files", + "statsTotalHintImage": "Total images", + "statsVsYesterday": "vs yesterday {value}", + "statsVsLastMonth": "vs last month {value}", + "transcodePending": "Queued", + "transcoding": "Transcoding", + "transcodeFailed": "Transcode failed", + "searchPlaceholder": "Search file name", + "searchEmpty": "Nothing matched", + "clearSearch": "Clear search", + "file": { + "title": "File bed", + "subtitle": "Manage uploaded files, copy links or download", + "uploadTitle": "Drop files here to upload", + "uploadHint": "or click to choose files, up to 10 at a time", + "uploadFormats": "Any type allowed (HTML / HTM / SVG blocked), up to 100MB per file", + "empty": "No files yet — upload one to get started" + }, + "video": { + "title": "Videos", + "subtitle": "Manage uploaded videos, copy links or download", + "previewTitle": "Video details", + "previewSubtitle": "View video info and links", + "previewTranscode": "Transcode", + "previewThumbLink": "Thumbnail link", + "previewKey": "Video path", + "transcodeNone": "Not transcoded", + "transcodeDone": "Done (m3u8)", + "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", + "empty": "No videos yet — upload one to get started" + } + }, "userSettings": { "loadFailed": "Failed to load settings", "saveFailed": "Failed to save settings" diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index 401f296..a91c3b5 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -24,6 +24,8 @@ "nav": { "api": "API", "gallery": "图库", + "videoLibrary": "视频库", + "fileLibrary": "文件库", "stats": "统计", "logs": "操作日志", "settings": "设置", @@ -298,8 +300,25 @@ "qualityMedium": "中等质量", "qualityLow": "有损压缩", "convenience": "便捷功能", + "videoProcessing": "视频处理", + "transcodeToHls": "转码成 m3u8", + "transcodeToHlsHint": "自动切片为 HLS,会占用 CPU。", + "concurrentHint": "同时转码数", + "threadsHint": "单任务 CPU 上限", + "transcodeConcurrency": "并发转码数量", + "transcodeThreads": "限制核心数", + "unitCount": "个", + "transcodeQuality": "转码质量", + "presetCopy": "直接封装", + "presetSpeed": "速度", + "presetBalanced": "平衡", + "presetQuality": "质量", + "presetCompress": "压缩", + "compressBitrate": "目标码率", + "transcodeLoadFailed": "读取转码设置失败", + "transcodeSaveFailed": "保存转码设置失败", "autoCopy": "上传成功自动复制链接", - "autoCopyHint": "图片上传成功后,自动复制图片链接到剪贴板。" + "autoCopyHint": "上传成功后,自动复制链接到剪贴板。" }, "settings": { "title": "系统设置", @@ -356,6 +375,12 @@ "refererUnrestricted": "未限制", "loginVerification": "登录验证", "loginVerificationHint": "登录与注册时使用的人机验证方式,三选一", + "fileBed": "文件床", + "fileBedHint": "文件直链的访问方式,切换后立即生效", + "fileBedPublic": "公开访问", + "fileBedPublicHint": "任何人拿到链接都能下载,适合分享。", + "fileBedToken": "Token 访问", + "fileBedTokenHint": "下载需要携带 Token(链接后追加 ?token=),适合私密文件。", "loginVerificationSlider": "本地滑块", "loginVerificationSliderHint": "内置滑块验证,无需第三方服务。", "loginVerificationTurnstile": "Cloudflare Turnstile", @@ -816,6 +841,8 @@ "storageBackend": "存储:{name}" }, "delete": { + "confirmDescVideo": "确定要删除选中的 {count} 个视频吗?此操作不可恢复。", + "confirmDescFile": "确定要删除选中的 {count} 个文件吗?此操作不可恢复。", "confirmTitle": "确认删除", "confirmDesc": "确定要删除选中的 {count} 张图片吗?此操作不可恢复。", "confirm": "确认删除" @@ -970,6 +997,76 @@ } } }, + "files": { + "maxFiles": "每次最多上传 {n} 个文件", + "uploadSuccess": "已上传 {n} 个文件", + "uploadFailed": "上传失败,请稍后重试", + "loadFailed": "读取文件列表失败", + "copyLink": "复制直链", + "download": "下载", + "deleted": "已删除", + "deleteFailed": "删除失败" + }, + "host": { + "video": "视频", + "file": "文件", + "imageFull": "个人轻量图床", + "videoFull": "个人轻量视频床", + "fileFull": "个人轻量文件床" + }, + "library": { + "play": "播放", + "copyVideoLink": "复制视频链接", + "copyThumbLink": "复制缩略图链接", + "selectedCount": "已选择 {n} 项", + "selectFirst": "请先选择视频", + "clearSelection": "取消选择", + "reset": "重置", + "batchDelete": "批量删除", + "deleted": "已删除 {n} 个视频", + "fileDeleted": "已删除 {n} 个文件", + "deletePartialFail": "{n} 个视频删除失败", + "statsToday": "今日上传", + "statsMonth": "本月上传", + "statsTotal": "累计视频", + "statsBytes": "总占用", + "statsUsage": "存储使用率", + "statsSource": "上传来源分布", + "statsTotalHintVideo": "总视频数", + "statsTotalHintFile": "总文件数", + "statsTotalHintImage": "总图片数", + "statsVsYesterday": "较昨日 {value}", + "statsVsLastMonth": "较上月 {value}", + "transcodePending": "等待转码中", + "transcoding": "转码中", + "transcodeFailed": "转码失败", + "searchPlaceholder": "搜索文件名", + "searchEmpty": "没有匹配的内容", + "clearSearch": "清除搜索", + "file": { + "title": "文件床", + "subtitle": "管理已上传的文件,复制直链或下载", + "uploadTitle": "拖拽文件到此处上传", + "uploadHint": "或点击选择文件,单次最多 10 个", + "uploadFormats": "支持任意类型(禁止 HTML / HTM / SVG),单个文件最大 100MB", + "empty": "还没有文件,上传一个试试" + }, + "video": { + "title": "视频", + "subtitle": "管理已上传的视频,复制直链或下载", + "previewTitle": "视频详情", + "previewSubtitle": "查看视频信息与链接", + "previewTranscode": "转码状态", + "previewThumbLink": "缩略图链接", + "previewKey": "视频路径", + "transcodeNone": "未转码", + "transcodeDone": "已完成(m3u8)", + "uploadTitle": "拖拽视频到此处上传", + "uploadHint": "或点击选择视频文件,单次最多 10 个", + "uploadFormats": "支持 MP4 / WebM / MOV / MKV 等,单个文件最大 100MB", + "empty": "还没有视频,上传一个试试" + } + }, "userSettings": { "loadFailed": "加载设置失败", "saveFailed": "保存设置失败" diff --git a/package-lock.json b/package-lock.json index 8074995..62f61cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@nuxt/ui": "^4.9.0", "@nuxtjs/i18n": "^10.6.0", "cap-widget": "^0.1.58", + "hls.js": "^1.7.3", "nuxt": "^4.4.8", "sharp": "^0.35.3", "tailwindcss": "^4.3.1", @@ -13843,6 +13844,12 @@ "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==", "license": "MIT" }, + "node_modules/hls.js": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz", + "integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==", + "license": "Apache-2.0" + }, "node_modules/hookable": { "version": "6.1.1", "resolved": "https://registry.npmmirror.com/hookable/-/hookable-6.1.1.tgz", diff --git a/package.json b/package.json index 888d2f7..134248c 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@nuxt/ui": "^4.9.0", "@nuxtjs/i18n": "^10.6.0", "cap-widget": "^0.1.58", + "hls.js": "^1.7.3", "nuxt": "^4.4.8", "sharp": "^0.35.3", "tailwindcss": "^4.3.1", diff --git a/server/api/files/batch-delete.post.ts b/server/api/files/batch-delete.post.ts new file mode 100644 index 0000000..e430719 --- /dev/null +++ b/server/api/files/batch-delete.post.ts @@ -0,0 +1,81 @@ +import type { BatchDeleteResponse } from '~/types/image' +import { assertImageOwnership, requireApiOrAdminAuth } from '../../utils/access' +import { createApiError } from '../../utils/api-error' +import { logActivity } from '../../utils/activity-log' +import { deleteImageIndex, getImageIndexRow } from '../../utils/image-index' +import { validateFileKey, validateVideoKey } from '../../utils/image-key' +import { deleteImage, headImage } from '../../utils/storage' +import { cancelTranscode, removeHlsArtifacts } from '../../utils/transcode' +import { MAX_DELETE_BATCH } from '../../utils/constants' + +interface BatchDeleteBody { + keys?: unknown +} + +export default defineEventHandler(async (event) => { + await requireApiOrAdminAuth(event) + + const body = await readBody(event).catch((): BatchDeleteBody => ({})) + const keys = Array.isArray(body.keys) + ? body.keys.map(key => String(key)).filter(Boolean) + : [] + + if (!keys.length) { + createApiError(event, 'INVALID_REQUEST', '未提供要删除的文件', 400) + } + if (keys.length > MAX_DELETE_BATCH) { + createApiError(event, 'INVALID_REQUEST', `每次最多删除 ${MAX_DELETE_BATCH} 个文件`, 400) + } + + const deleted: string[] = [] + const failed: BatchDeleteResponse['failed'] = [] + + for (const key of keys) { + if (!validateFileKey(key) && !validateVideoKey(key)) { + failed.push({ key, error: { code: 'INVALID_IMAGE_KEY', message: '无效的文件路径' } }) + continue + } + + try { + const existing = await headImage(key) + const indexed = getImageIndexRow(key) + + if (!existing && !indexed) { + failed.push({ key, error: { code: 'IMAGE_NOT_FOUND', message: '文件不存在' } }) + continue + } + + const ownerId = existing?.userId ?? indexed?.user_id ?? null + await assertImageOwnership(event, ownerId) + + if (validateVideoKey(key)) { + cancelTranscode(key) + await removeHlsArtifacts(key).catch(() => {}) + } + + if (existing) { + await deleteImage(key, key) + } else { + deleteImageIndex(key) + } + + logActivity(event, { + action: 'delete', + key, + originalName: existing?.originalName ?? indexed?.original_name ?? key, + size: existing?.size ?? indexed?.size ?? 0, + contentType: existing?.contentType ?? indexed?.content_type ?? 'application/octet-stream', + userId: ownerId, + backendId: existing?.backendId ?? indexed?.backend_id ?? null + }) + + deleted.push(key) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : '删除失败' + failed.push({ key, error: { code: 'DELETE_FAILED', message } }) + } + } + + const response: BatchDeleteResponse = { success: failed.length === 0, deleted, failed } + return response +}) diff --git a/server/api/files/index.delete.ts b/server/api/files/index.delete.ts new file mode 100644 index 0000000..96c5bee --- /dev/null +++ b/server/api/files/index.delete.ts @@ -0,0 +1,77 @@ +import type { DeleteResponse } from '~/types/image' +import { assertImageOwnership, requireApiOrAdminAuth } from '../../utils/access' +import { createApiError } from '../../utils/api-error' +import { logActivity } from '../../utils/activity-log' +import { deleteImageIndex, getImageIndexRow } from '../../utils/image-index' +import { validateFileKey, validateVideoKey } from '../../utils/image-key' +import { deleteImage, headImage } from '../../utils/storage' +import { cancelTranscode, removeHlsArtifacts } from '../../utils/transcode' +import { logInfo } from '../../utils/logger' + +interface DeleteBody { + key?: string +} + +export default defineEventHandler(async (event) => { + try { + await requireApiOrAdminAuth(event) + + const query = getQuery(event) + const body = await readBody(event).catch(() => null) + const key = (typeof query.key === 'string' ? query.key : undefined) ?? body?.key + + if (!key || (!validateFileKey(key) && !validateVideoKey(key))) { + createApiError(event, 'INVALID_IMAGE_KEY', '无效的文件路径', 400) + } + + const existing = await headImage(key) + const indexed = getImageIndexRow(key) + + if (!existing && !indexed) { + createApiError(event, 'IMAGE_NOT_FOUND', '文件不存在', 404) + } + + const originalName = existing?.originalName ?? indexed?.original_name ?? key + const size = existing?.size ?? indexed?.size ?? 0 + const contentType = existing?.contentType ?? indexed?.content_type ?? 'application/octet-stream' + const ownerId = existing?.userId ?? indexed?.user_id ?? null + const backendId = existing?.backendId ?? indexed?.backend_id ?? null + + await assertImageOwnership(event, ownerId) + + try { + if (validateVideoKey(key)) { + // 立即中断正在进行的转码,再清理产物与原文件 + cancelTranscode(key) + await removeHlsArtifacts(key).catch(() => {}) + } + if (existing) { + await deleteImage(key, key) + } else { + deleteImageIndex(key) + } + } catch { + createApiError(event, 'DELETE_FAILED', '删除失败', 500) + } + + logActivity(event, { + action: 'delete', + key, + originalName, + size, + contentType, + userId: ownerId, + backendId + }) + + logInfo('file delete success', { key, size }) + + const response: DeleteResponse = { success: true } + return response + } catch (error: unknown) { + if (error && typeof error === 'object' && 'statusCode' in error) { + throw error + } + createApiError(event, 'DELETE_FAILED', '删除失败', 500) + } +}) diff --git a/server/api/files/index.get.ts b/server/api/files/index.get.ts new file mode 100644 index 0000000..38c35a0 --- /dev/null +++ b/server/api/files/index.get.ts @@ -0,0 +1,141 @@ +import type { FileListResponse } from '~/types/file' +import type { MediaType, TranscodeStatus } from '../../utils/storage/types' +import { requireApiOrAdminAuth, getImageUserFilter } from '../../utils/access' +import { getCurrentUser } from '../../utils/auth' +import { DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT } from '../../utils/constants' +import { getUploadSourcesForKeys, listUserIdUsernameMap } from '../../utils/db' +import { getFileBedAccessMode } from '../../utils/env' +import { buildFileItem } from '../../utils/file-bed' +import { + resolveImageOwner, + resolveImageStorage, + resolveImageUrl +} from '../../utils/image-response' +import { getTagsForImageKeys } from '../../utils/tags' +import { listStorageBackendNameMap } from '../../utils/storage-backends' +import { createApiError } from '../../utils/api-error' +import { + readBackendIdQuery, + readTagFilterQuery, + readUploadSourceQuery +} from '../../utils/image-query' +import { listImages, searchImages } from '../../utils/storage' + +export default defineEventHandler(async (event) => { + await requireApiOrAdminAuth(event) + + const query = getQuery(event) + + const limitRaw = Number(query.limit ?? DEFAULT_LIST_LIMIT) + const limit = Number.isFinite(limitRaw) + ? Math.min(Math.max(1, Math.floor(limitRaw)), MAX_LIST_LIMIT) + : DEFAULT_LIST_LIMIT + + const pageRaw = Number(query.page ?? 1) + const page = Number.isFinite(pageRaw) && pageRaw > 0 ? Math.floor(pageRaw) : 1 + + const search = typeof query.q === 'string' ? query.q.trim() : '' + const userFilter = await getImageUserFilter(event) + + const backendId = readBackendIdQuery(query) + if (backendId === null) { + createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400) + } + const uploadSource = readUploadSourceQuery(query) + if (uploadSource === null) { + createApiError(event, 'INVALID_REQUEST', '无效的上传来源', 400) + } + const tagFilter = readTagFilterQuery(query) + if (tagFilter === null) { + createApiError(event, 'INVALID_REQUEST', '无效的标签筛选参数', 400) + } + + // 文件床按类型分工:视频库看 media_type=video,文件库看 media_type=file + const kindRaw = typeof query.kind === 'string' ? query.kind.trim() : '' + if (kindRaw && kindRaw !== 'file' && kindRaw !== 'video') { + createApiError(event, 'INVALID_REQUEST', '无效的文件类型筛选', 400) + } + const mediaType: MediaType = kindRaw === 'video' ? 'video' : 'file' + // 视频库只收录转码已结束的条目;processing=1 用于上传页查仍在转码中的视频 + const processingRaw = query.processing + const processingOnly = processingRaw === '1' || processingRaw === 'true' + const transcodeStatuses: TranscodeStatus[] | undefined = mediaType === 'video' + ? (processingOnly ? ['pending', 'running'] : ['none', 'done', 'failed']) + : undefined + + const listing = search + ? await searchImages({ + query: search, + limit, + page, + userFilter, + mediaType, + transcodeStatuses, + backendId, + uploadSource, + tagFilter + }) + : await listImages({ + limit, + page, + userFilter, + mediaType, + transcodeStatuses, + backendId, + uploadSource, + tagFilter + }) + + const user = await getCurrentUser(event) + const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined + const backendMap = listStorageBackendNameMap() + const sourceMap = getUploadSourcesForKeys(listing.items.map(item => item.key)) + + const mapped = listing.items.map((stored) => { + const owner = resolveImageOwner(stored.userId, ownerMap) + const storage = resolveImageStorage(stored.backendId, backendMap) + const uploadSource = sourceMap.get(stored.key) + + return { + ...buildFileItem({ + event, + key: stored.key, + backendId: stored.backendId, + originalName: stored.originalName, + contentType: stored.contentType, + size: stored.size, + uploadedAt: stored.uploadedAt + }), + ...(owner ? { owner } : {}), + ...(storage ? { storage } : {}), + ...(uploadSource === 'web' || uploadSource === 'api' ? { uploadSource } : {}), + transcodeStatus: stored.transcodeStatus ?? 'none', + transcodeProgress: stored.transcodeProgress ?? 0, + ...(stored.transcodePreset ? { transcodePreset: stored.transcodePreset } : {}), + ...(stored.sourceDeleted ? { sourceDeleted: true } : {}), + ...(stored.thumbKey + ? { thumbUrl: resolveImageUrl(event, stored.thumbKey, stored.backendId) } + : {}), + ...(stored.hlsKey + ? { + hlsUrl: resolveImageUrl(event, stored.hlsKey, stored.backendId), + hlsKey: stored.hlsKey + } + : {}) + } + }) + + const tagMap = getTagsForImageKeys(mapped.map(item => item.key)) + const items = mapped.map(item => ({ ...item, tags: tagMap.get(item.key) ?? [] })) + + const response: FileListResponse = { + items, + total: listing.total, + page: listing.page, + pageSize: listing.pageSize, + totalPages: listing.totalPages, + accessMode: getFileBedAccessMode() + } + + return response +}) diff --git a/server/api/files/upload.post.ts b/server/api/files/upload.post.ts new file mode 100644 index 0000000..f97a2da --- /dev/null +++ b/server/api/files/upload.post.ts @@ -0,0 +1,79 @@ +import { readMultipartFormData } from 'h3' +import type { FileItem, FileUploadResponse } from '~/types/file' +import type { UploadErrorItem } from '~/types/image' +import { + getUploadUserId, + requireUploadAuth, + verifyApiUploadToken +} from '../../utils/access' +import { MAX_FILES_PER_UPLOAD } from '../../utils/constants' +import { createApiError } from '../../utils/api-error' +import { checkUploadRateLimit } from '../../utils/rate-limit' +import { processSingleFileUpload } from '../../utils/file-bed' +import { getFileBedAccessMode } from '../../utils/env' + +export default defineEventHandler(async (event) => { + await requireUploadAuth(event) + + const formData = await readMultipartFormData(event) + if (!formData?.length) { + createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400) + } + + const tokenField = formData.find(part => part.name === 'token') + const formToken = tokenField?.data?.length + ? new TextDecoder().decode(tokenField.data).trim() + : '' + checkUploadRateLimit(event, formToken) + + const source = verifyApiUploadToken(event) ? 'api' : 'web' + const uploadUserId = await getUploadUserId(event, formToken) + + const fileParts = formData.filter( + part => (part.name === 'file' || part.name === 'files') && part.data?.length + ) + if (!fileParts.length) { + createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400) + } + + if (fileParts.length > MAX_FILES_PER_UPLOAD) { + createApiError( + event, + 'TOO_MANY_FILES', + `每次最多上传 ${MAX_FILES_PER_UPLOAD} 个文件`, + 400 + ) + } + + const items: FileItem[] = [] + const errors: UploadErrorItem[] = [] + + for (const part of fileParts) { + const result = await processSingleFileUpload(event, { + bytes: new Uint8Array(part.data), + filename: part.filename, + contentType: part.type, + source, + userId: uploadUserId + }) + + if ('error' in result) { + errors.push(result.error) + } else { + items.push(result.item) + } + } + + const response: FileUploadResponse = { + success: items.length > 0, + items, + errors, + accessMode: getFileBedAccessMode() + } + + if (!items.length && errors.length) { + setResponseStatus(event, 400) + } + + return response +}) diff --git a/server/api/images/batch-tags.post.ts b/server/api/images/batch-tags.post.ts index 3a0c973..b99748c 100644 --- a/server/api/images/batch-tags.post.ts +++ b/server/api/images/batch-tags.post.ts @@ -4,7 +4,7 @@ import { getCurrentUser } from '../../utils/auth' import { createApiError } from '../../utils/api-error' import { MAX_DELETE_BATCH } from '../../utils/constants' import { getImageIndexRow } from '../../utils/image-index' -import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key' +import { toCanonicalImageKey, validateStorageKey } from '../../utils/image-key' import { addTagsToImage, removeTagFromImage @@ -20,7 +20,7 @@ interface BatchTagsBody { function validateKeys(keys: string[]): void { for (const key of keys) { if ( - !validateImageKey(key) + !validateStorageKey(key) && !toCanonicalImageKey(key) && !getImageIndexRow(key) ) { diff --git a/server/api/library/stats.get.ts b/server/api/library/stats.get.ts new file mode 100644 index 0000000..1ef1a23 --- /dev/null +++ b/server/api/library/stats.get.ts @@ -0,0 +1,41 @@ +import { getImageUserFilter, requireApiOrAdminAuth } from '../../utils/access' +import { createApiError } from '../../utils/api-error' +import { getMediaStats } from '../../utils/image-index' +import { buildBackendCapacity } from '../../utils/storage-capacity' +import { getActiveBackendRow } from '../../utils/storage/resolver' +import type { MediaType } from '../../utils/storage/types' + +const ALLOWED_KINDS: MediaType[] = ['image', 'video', 'file'] + +export default defineEventHandler(async (event) => { + await requireApiOrAdminAuth(event) + + const query = getQuery(event) + const kindRaw = typeof query.kind === 'string' ? query.kind.trim() : 'image' + const kind = ALLOWED_KINDS.find(item => item === kindRaw) + if (!kind) { + createApiError(event, 'INVALID_REQUEST', '无效的统计类型', 400) + } + + const userFilter = await getImageUserFilter(event) + const stats = getMediaStats(kind, userFilter) + + const backend = getActiveBackendRow() + const capacity = backend + ? await buildBackendCapacity( + backend.id, + backend.type, + stats.uploadBytesTotal, + backend.quota_bytes + ) + : { totalBytes: null, percent: null } + + return { + ...stats, + storageUsage: { + usedBytes: stats.uploadBytesTotal, + totalBytes: capacity.totalBytes, + percent: capacity.percent + } + } +}) diff --git a/server/api/settings/index.patch.ts b/server/api/settings/index.patch.ts index cc275ed..9997e06 100644 --- a/server/api/settings/index.patch.ts +++ b/server/api/settings/index.patch.ts @@ -15,9 +15,11 @@ import { SETTINGS_TURNSTILE_SECRET_KEY, SETTINGS_CAP_API_ENDPOINT, SETTINGS_CAP_SECRET, + SETTINGS_FILE_BED_ACCESS, setAllowRegistration } from '../../utils/db' import { + parseFileBedAccessMode, getSettingsPayload, isValidImageBaseUrl, isValidRefererHost, @@ -68,6 +70,7 @@ interface SettingsPatchBody { loginRateMax?: unknown loginRateWindowMinutes?: unknown preserveOriginalUpload?: unknown + fileBedAccess?: unknown } export default defineEventHandler(async (event) => { @@ -333,6 +336,14 @@ export default defineEventHandler(async (event) => { setUploadPolicySettings({ preserveOriginalUpload: body.preserveOriginalUpload }) } + if (body.fileBedAccess !== undefined) { + const mode = parseFileBedAccessMode(String(body.fileBedAccess)) + if (!mode) { + createApiError(event, 'INVALID_REQUEST', '文件床访问方式无效', 400) + } + setSetting(SETTINGS_FILE_BED_ACCESS, mode) + } + logActivity(event, { action: 'settings', key: 'settings/system', diff --git a/server/api/videos/transcode-settings.get.ts b/server/api/videos/transcode-settings.get.ts new file mode 100644 index 0000000..215a171 --- /dev/null +++ b/server/api/videos/transcode-settings.get.ts @@ -0,0 +1,22 @@ +import { requireUserAuth } from '../../utils/access' +import { + getTranscodeSettings, + MAX_TRANSCODE_CONCURRENCY, + MAX_TRANSCODE_THREADS, + MAX_TRANSCODE_BITRATE_PERCENT, + MIN_TRANSCODE_BITRATE_PERCENT, + TRANSCODE_PRESETS +} from '../../utils/transcode' + +export default defineEventHandler(async (event) => { + await requireUserAuth(event) + + return { + ...getTranscodeSettings(), + maxConcurrency: MAX_TRANSCODE_CONCURRENCY, + maxThreads: MAX_TRANSCODE_THREADS, + minBitratePercent: MIN_TRANSCODE_BITRATE_PERCENT, + maxBitratePercent: MAX_TRANSCODE_BITRATE_PERCENT, + presets: TRANSCODE_PRESETS + } +}) diff --git a/server/api/videos/transcode-settings.patch.ts b/server/api/videos/transcode-settings.patch.ts new file mode 100644 index 0000000..0e12a0f --- /dev/null +++ b/server/api/videos/transcode-settings.patch.ts @@ -0,0 +1,80 @@ +import { requireAdminAuth } from '../../utils/access' +import { createApiError } from '../../utils/api-error' +import { + MAX_TRANSCODE_CONCURRENCY, + MAX_TRANSCODE_THREADS, + MAX_TRANSCODE_BITRATE_PERCENT, + MIN_TRANSCODE_BITRATE_PERCENT, + parseTranscodePreset, + setTranscodeSettings +} from '../../utils/transcode' + +interface TranscodeSettingsPatch { + concurrency?: unknown + threads?: unknown + preset?: unknown + bitratePercent?: unknown +} + +export default defineEventHandler(async (event) => { + await requireAdminAuth(event) + + const body = await readBody(event).catch( + (): TranscodeSettingsPatch => ({}) + ) + + const full: Parameters[0] = {} + + if (body.concurrency !== undefined) { + const value = Number(body.concurrency) + if (!Number.isInteger(value) || value < 1 || value > MAX_TRANSCODE_CONCURRENCY) { + createApiError( + event, + 'INVALID_REQUEST', + `并发转码数量需为 1–${MAX_TRANSCODE_CONCURRENCY} 的整数`, + 400 + ) + } + full.concurrency = value + } + + if (body.threads !== undefined) { + const value = Number(body.threads) + if (!Number.isInteger(value) || value < 1 || value > MAX_TRANSCODE_THREADS) { + createApiError( + event, + 'INVALID_REQUEST', + `限制核心数需为 1–${MAX_TRANSCODE_THREADS} 的整数`, + 400 + ) + } + full.threads = value + } + + if (body.preset !== undefined) { + const preset = parseTranscodePreset(String(body.preset)) + if (!preset) { + createApiError(event, 'INVALID_REQUEST', '无效的转码质量档位', 400) + } + full.preset = preset + } + + if (body.bitratePercent !== undefined) { + const value = Number(body.bitratePercent) + if ( + !Number.isInteger(value) + || value < MIN_TRANSCODE_BITRATE_PERCENT + || value > MAX_TRANSCODE_BITRATE_PERCENT + ) { + createApiError( + event, + 'INVALID_REQUEST', + `码率需为 ${MIN_TRANSCODE_BITRATE_PERCENT}–${MAX_TRANSCODE_BITRATE_PERCENT} 的整数`, + 400 + ) + } + full.bitratePercent = value + } + + return setTranscodeSettings(full) +}) diff --git a/server/api/videos/upload.post.ts b/server/api/videos/upload.post.ts new file mode 100644 index 0000000..819c537 --- /dev/null +++ b/server/api/videos/upload.post.ts @@ -0,0 +1,94 @@ +import { readMultipartFormData } from 'h3' +import type { FileItem, FileUploadResponse } from '~/types/file' +import type { UploadErrorItem } from '~/types/image' +import { + getUploadUserId, + requireUploadAuth, + verifyApiUploadToken +} from '../../utils/access' +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 { getFileBedAccessMode } from '../../utils/env' + +export default defineEventHandler(async (event) => { + await requireUploadAuth(event) + + const formData = await readMultipartFormData(event) + if (!formData?.length) { + createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400) + } + + const tokenField = formData.find(part => part.name === 'token') + const formToken = tokenField?.data?.length + ? new TextDecoder().decode(tokenField.data).trim() + : '' + checkUploadRateLimit(event, formToken) + + const source = verifyApiUploadToken(event) ? 'api' : 'web' + const uploadUserId = await getUploadUserId(event, formToken) + + const transcodeField = formData.find(part => part.name === 'transcode') + const wantTranscode = transcodeField?.data?.length + ? ['1', 'true', 'yes'].includes( + new TextDecoder().decode(transcodeField.data).trim().toLowerCase() + ) + : false + + const fileParts = formData.filter( + part => (part.name === 'file' || part.name === 'files' || part.name === 'video') + && part.data?.length + ) + if (!fileParts.length) { + createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400) + } + + if (fileParts.length > MAX_FILES_PER_UPLOAD) { + createApiError( + event, + 'TOO_MANY_FILES', + `每次最多上传 ${MAX_FILES_PER_UPLOAD} 个视频`, + 400 + ) + } + + const items: FileItem[] = [] + const errors: UploadErrorItem[] = [] + + for (const part of fileParts) { + const result = await processSingleVideoUpload(event, { + bytes: new Uint8Array(part.data), + filename: part.filename, + contentType: part.type, + source, + userId: uploadUserId + }) + + if ('error' in result) { + errors.push(result.error) + } else { + items.push(result.item) + } + } + + const response: FileUploadResponse = { + success: items.length > 0, + items, + errors, + accessMode: getFileBedAccessMode() + } + + if (wantTranscode) { + for (const item of items) { + enqueueTranscode(item.key) + } + } + + if (!items.length && errors.length) { + setResponseStatus(event, 400) + } + + return response +}) diff --git a/server/plugins/startup.ts b/server/plugins/startup.ts index be2e1df..5f69d3f 100644 --- a/server/plugins/startup.ts +++ b/server/plugins/startup.ts @@ -1,7 +1,16 @@ import { getDataDir } from '../utils/data-dir' +import { promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { runAutoDeleteCleanup } from '../utils/auto-delete' import { migrateLocalImagesToIndex, purgeOrphanImageIndexRows, reconcileLegacyImageKeys, repairLocalImageIndexMetadata } from '../utils/image-index' import { ensureDefaultBackends } from '../utils/storage-backends' +import { + cleanupTranscodedSources, + killOrphanFfmpeg, + purgeOrphanVideoAssets, + resumePendingTranscodes +} from '../utils/transcode' import { logException, logInfo } from '../utils/logger' export default defineNitroPlugin(() => { @@ -17,10 +26,26 @@ export default defineNitroPlugin(() => { ensureDefaultBackends() + /** 清理上次异常退出留下的转码临时目录 */ + void (async () => { + try { + const entries = await fs.readdir(tmpdir(), { withFileTypes: true }) + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith('pichost-hls-')) continue + await fs.rm(join(tmpdir(), entry.name), { recursive: true, force: true }) + .catch(() => {}) + } + } catch { + // 清理失败不影响启动 + } + })() + void (async () => { const dataDir = getDataDir() try { - logInfo('开始同步图片索引:扫描本地磁盘', { path: `${dataDir}/images/` }) + logInfo('开始同步存储索引:扫描本地磁盘', { + path: `${dataDir}/images/, ${dataDir}/files/, ${dataDir}/videos/` + }) const inserted = await migrateLocalImagesToIndex() if (inserted > 0) { @@ -54,6 +79,18 @@ export default defineNitroPlugin(() => { } logInfo('图片索引同步结束') + + logInfo('开始恢复未完成的视频转码任务') + await killOrphanFfmpeg() + const purgedAssets = await purgeOrphanVideoAssets() + if (purgedAssets > 0) { + logInfo('清理无主的视频附属文件', { removed: purgedAssets }) + } + const removedSources = await cleanupTranscodedSources() + if (removedSources > 0) { + logInfo('清理转码完成后的源视频', { removed: removedSources }) + } + resumePendingTranscodes() } catch (error) { logException('图片索引同步失败', error) } diff --git a/server/utils/access.ts b/server/utils/access.ts index 6f1382f..b5ec016 100644 --- a/server/utils/access.ts +++ b/server/utils/access.ts @@ -286,3 +286,21 @@ export async function getUploadUserId( return null } + +/** 文件床直链在 Token 模式下的校验:Auth-Token 头、?token= 查询参数或已登录会话 */ +export async function isFileAccessAuthorized(event: H3Event): Promise { + if (devBypass(event)) return true + if (verifyApiUploadToken(event)) return true + + const queryToken = getQuery(event).token + if ( + typeof queryToken === 'string' + && queryToken + && verifyUploadTokenValue(event, queryToken) + ) { + return true + } + + const user = await getCurrentUser(event) + return Boolean(user) +} diff --git a/server/utils/constants.ts b/server/utils/constants.ts index 2f595aa..605aa7c 100644 --- a/server/utils/constants.ts +++ b/server/utils/constants.ts @@ -17,3 +17,33 @@ export const MAX_LIST_LIMIT = 100 export const IMAGE_CACHE_CONTROL = 'public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400' + +/** 文件床单文件上限(当前上传全量读入内存,限制在可控范围) */ +export const MAX_FILE_BED_FILE_SIZE = 100 * 1024 * 1024 + +/** 视频床单文件上限 */ +export const MAX_VIDEO_FILE_SIZE = 500 * 1024 * 1024 + +/** 文件床禁止的扩展名:避免上传的文件被浏览器当作脚本或页面执行 */ +export const FORBIDDEN_FILE_EXTENSIONS = ['html', 'htm', 'svg'] as const + +/** 视频床允许的扩展名 */ +export const VIDEO_EXTENSIONS = [ + 'mp4', + 'm4v', + 'webm', + 'mov', + 'mkv', + 'avi', + 'flv', + 'wmv', + 'mpg', + 'mpeg', + 'ts', + 'm3u8' +] as const + +/** 文件床直链缓存:公开访问可长期缓存,Token 访问不能进共享缓存 */ +export const FILE_CACHE_CONTROL + = 'public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400' +export const FILE_PRIVATE_CACHE_CONTROL = 'private, no-store' diff --git a/server/utils/content-type.ts b/server/utils/content-type.ts index 8fc6684..f60f977 100644 --- a/server/utils/content-type.ts +++ b/server/utils/content-type.ts @@ -1,3 +1,31 @@ +/** 常见文件床类型的扩展名推断(仅在索引缺失元数据时使用) */ +const EXTRA_TYPES: Record = { + 'pdf': 'application/pdf', + 'zip': 'application/zip', + '7z': 'application/x-7z-compressed', + 'rar': 'application/vnd.rar', + 'gz': 'application/gzip', + 'tar': 'application/x-tar', + 'txt': 'text/plain; charset=utf-8', + 'md': 'text/markdown; charset=utf-8', + 'json': 'application/json', + 'csv': 'text/csv; charset=utf-8', + 'mp3': 'audio/mpeg', + 'mp4': 'video/mp4', + 'm4v': 'video/x-m4v', + 'webm': 'video/webm', + 'mov': 'video/quicktime', + 'mkv': 'video/x-matroska', + 'avi': 'video/x-msvideo', + 'flv': 'video/x-flv', + 'wmv': 'video/x-ms-wmv', + 'mpg': 'video/mpeg', + 'mpeg': 'video/mpeg', + 'ts': 'video/mp2t', + 'm3u8': 'application/vnd.apple.mpegurl', + 'apk': 'application/vnd.android.package-archive' +} + export function contentTypeFromKey(key: string): string { const ext = key.split('.').pop()?.toLowerCase() switch (ext) { @@ -15,6 +43,6 @@ export function contentTypeFromKey(key: string): string { case 'ico': return 'image/x-icon' default: - return 'application/octet-stream' + return (ext && EXTRA_TYPES[ext]) || 'application/octet-stream' } } diff --git a/server/utils/db.ts b/server/utils/db.ts index f824b28..4e42b11 100644 --- a/server/utils/db.ts +++ b/server/utils/db.ts @@ -500,6 +500,11 @@ export function deleteSessionsForUser(userId: number): void { export const SETTINGS_API_UPLOAD_TOKEN = 'api_upload_token' export const SETTINGS_WEBP_QUALITY = 'webp_quality' export const SETTINGS_ALLOWED_REFERER_HOSTS = 'allowed_referer_hosts' +export const SETTINGS_FILE_BED_ACCESS = 'file_bed_access' +export const SETTINGS_TRANSCODE_CONCURRENCY = 'transcode_concurrency' +export const SETTINGS_TRANSCODE_THREADS = 'transcode_threads' +export const SETTINGS_TRANSCODE_PRESET = 'transcode_preset' +export const SETTINGS_TRANSCODE_BITRATE = 'transcode_bitrate_percent' export const SETTINGS_HIDE_FOLDER_IN_URL = 'hide_folder_in_url' export const SETTINGS_STORAGE_USE_DATE_PATH = 'storage_use_date_path' export const SETTINGS_SITE_BASE_URL = 'site_base_url' diff --git a/server/utils/env.ts b/server/utils/env.ts index 6335549..9523104 100644 --- a/server/utils/env.ts +++ b/server/utils/env.ts @@ -10,6 +10,7 @@ import { SETTINGS_STORAGE_USE_DATE_PATH, SETTINGS_IMAGE_BASE_URL, SETTINGS_SITE_BASE_URL, + SETTINGS_FILE_BED_ACCESS, SETTINGS_WEBP_QUALITY, SETTINGS_LOGIN_VERIFICATION_METHOD, SETTINGS_TURNSTILE_SITE_KEY, @@ -20,6 +21,21 @@ import { } from './db' import { getUploadPolicySettingsPayload } from './upload-policy' +export type FileBedAccessMode = 'public' | 'token' + +export function parseFileBedAccessMode( + raw: string | null | undefined +): FileBedAccessMode | null { + const value = raw?.trim().toLowerCase() + if (value === 'public' || value === 'token') return value + return null +} + +/** 文件床直链访问方式:公开直链或需要 Token(默认公开) */ +export function getFileBedAccessMode(): FileBedAccessMode { + return parseFileBedAccessMode(getSetting(SETTINGS_FILE_BED_ACCESS)) ?? 'public' +} + export function getRuntimeEnv( event: H3Event, envKey: string, @@ -688,6 +704,7 @@ export function getSettingsPayload(event: H3Event) { autoDeleteDays, autoDeleteDaysSource: getAutoDeleteDaysSource(), autoDeleteEnvFallback: parseAutoDeleteDays(getAutoDeleteDaysFromEnv()) ?? 0, + fileBedAccess: getFileBedAccessMode(), allowRegistration: isAllowRegistration(), loginVerificationMethod: getLoginVerificationMethod(event), turnstileSiteKey: getTurnstileSiteKeyConfigured(event), diff --git a/server/utils/file-bed.test.ts b/server/utils/file-bed.test.ts new file mode 100644 index 0000000..545e678 --- /dev/null +++ b/server/utils/file-bed.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest' +import { + buildAttachmentDisposition, + isForbiddenFileExtension, + normalizeFileContentType, + normalizeVideoContentType, + sanitizeDownloadName +} from './file-bed' +import { + extensionFromFilename, + generateFileKey, + generateVideoKey, + isVideoExtension, + validateFileKey, + validateStorageKey, + validateVideoKey +} from './image-key' +import { parseFileBedAccessMode } from './env' + +describe('isForbiddenFileExtension', () => { + it('blocks html, htm and svg regardless of case', () => { + expect(isForbiddenFileExtension('html')).toBe(true) + expect(isForbiddenFileExtension('HTM')).toBe(true) + expect(isForbiddenFileExtension('svg')).toBe(true) + }) + + it('allows other extensions', () => { + expect(isForbiddenFileExtension('pdf')).toBe(false) + expect(isForbiddenFileExtension('')).toBe(false) + }) +}) + +describe('sanitizeDownloadName', () => { + it('keeps unicode names and strips path segments', () => { + expect(sanitizeDownloadName('C:\\path/设计稿.pdf')).toBe('设计稿.pdf') + }) + + it('replaces reserved characters', () => { + expect(sanitizeDownloadName('a"b.txt')).toBe('a_b_c_.txt') + }) + + it('falls back when the name is empty', () => { + expect(sanitizeDownloadName('')).toBe('file') + }) +}) + +describe('buildAttachmentDisposition', () => { + it('includes an ascii fallback and a utf-8 filename', () => { + const value = buildAttachmentDisposition('报告.pdf') + expect(value.startsWith('attachment; ')).toBe(true) + expect(value).toContain('filename="__.pdf"') + expect(value).toContain(`filename*=UTF-8''${encodeURIComponent('报告.pdf')}`) + }) +}) + +describe('normalizeFileContentType', () => { + it('keeps valid mime types', () => { + expect(normalizeFileContentType('application/pdf; charset=binary', 'pdf')) + .toBe('application/pdf') + }) + + it('infers from the extension when the declared type is invalid', () => { + expect(normalizeFileContentType('not a mime', 'pdf')).toBe('application/pdf') + expect(normalizeFileContentType(undefined, 'mp4')).toBe('video/mp4') + }) + + it('falls back to octet-stream without an extension', () => { + expect(normalizeFileContentType('', '')).toBe('application/octet-stream') + }) +}) + +describe('extensionFromFilename', () => { + it('lowercases and sanitizes the extension', () => { + expect(extensionFromFilename('a/b/报告.PDF')).toBe('pdf') + expect(extensionFromFilename('archive.tar.gz')).toBe('gz') + }) + + it('returns empty for files without a usable extension', () => { + expect(extensionFromFilename('README')).toBe('') + expect(extensionFromFilename('.env')).toBe('') + }) +}) + +describe('validateFileKey', () => { + it('accepts files/ keys with or without extension', () => { + expect(validateFileKey('files/2026/10/abc123.pdf')).toBe(true) + expect(validateFileKey('files/abc123')).toBe(true) + }) + + it('rejects traversal, absolute and image keys', () => { + expect(validateFileKey('files/../../etc/passwd')).toBe(false) + expect(validateFileKey('/files/a.pdf')).toBe(false) + expect(validateFileKey('images/2026/10/a.webp')).toBe(false) + expect(validateFileKey('../files/a.pdf')).toBe(false) + }) + + it('keeps image keys working through validateStorageKey', () => { + expect(validateStorageKey('images/2026/10/a.webp')).toBe(true) + expect(validateStorageKey('files/2026/10/a.pdf')).toBe(true) + expect(validateStorageKey('other/2026/10/a.pdf')).toBe(false) + }) +}) + +describe('generateFileKey', () => { + const date = new Date('2026-10-16T00:00:00Z') + + it('builds a dated key with the sanitized extension', () => { + expect(generateFileKey('pdf', date)).toMatch(/^files\/2026\/10\/[A-Za-z0-9]{12}\.pdf$/) + }) + + it('drops unknown extensions and supports flat layout', () => { + expect(generateFileKey('../../etc', date)).toMatch(/^files\/2026\/10\/[A-Za-z0-9]{12}$/) + expect(generateFileKey('', date, 'flat')).toMatch(/^files\/[A-Za-z0-9]{12}$/) + }) +}) + +describe('parseFileBedAccessMode', () => { + it('parses known modes and rejects others', () => { + expect(parseFileBedAccessMode('public')).toBe('public') + expect(parseFileBedAccessMode(' TOKEN ')).toBe('token') + expect(parseFileBedAccessMode('other')).toBeNull() + expect(parseFileBedAccessMode(null)).toBeNull() + }) +}) + +describe('isVideoExtension', () => { + it('accepts common video containers', () => { + expect(isVideoExtension('mp4')).toBe(true) + expect(isVideoExtension('MP4')).toBe(true) + expect(isVideoExtension('webm')).toBe(true) + expect(isVideoExtension('mkv')).toBe(true) + }) + + it('rejects non-video extensions', () => { + expect(isVideoExtension('png')).toBe(false) + expect(isVideoExtension('pdf')).toBe(false) + expect(isVideoExtension('')).toBe(false) + }) +}) + +describe('validateVideoKey', () => { + it('accepts videos/ keys with a video extension', () => { + expect(validateVideoKey('videos/2026/10/abc123.mp4')).toBe(true) + expect(validateVideoKey('videos/2026/10/abc123.MKV')).toBe(true) + }) + + it('rejects other folders, non-video extensions and traversal', () => { + expect(validateVideoKey('files/2026/10/abc123.mp4')).toBe(false) + expect(validateVideoKey('videos/2026/10/abc123.txt')).toBe(false) + expect(validateVideoKey('videos/../../etc/passwd')).toBe(false) + expect(validateVideoKey('/videos/a.mp4')).toBe(false) + }) + + it('is covered by validateStorageKey', () => { + expect(validateStorageKey('videos/2026/10/a.mp4')).toBe(true) + }) +}) + +describe('generateVideoKey', () => { + const date = new Date('2026-10-16T00:00:00Z') + + it('builds a dated key and keeps the extension', () => { + expect(generateVideoKey('mp4', date)).toMatch(/^videos\/2026\/10\/[A-Za-z0-9]{12}\.mp4$/) + expect(generateVideoKey('', date)).toMatch(/^videos\/2026\/10\/[A-Za-z0-9]{12}\.mp4$/) + }) +}) + +describe('normalizeVideoContentType', () => { + it('keeps declared video mime types', () => { + expect(normalizeVideoContentType('video/mp4', 'mp4')).toBe('video/mp4') + }) + + it('falls back to the extension when the declared type is not a video', () => { + expect(normalizeVideoContentType('application/octet-stream', 'mkv')) + .toBe('video/x-matroska') + expect(normalizeVideoContentType(undefined, 'm4v')).toBe('video/x-m4v') + }) +}) diff --git a/server/utils/file-bed.ts b/server/utils/file-bed.ts new file mode 100644 index 0000000..b29609d --- /dev/null +++ b/server/utils/file-bed.ts @@ -0,0 +1,310 @@ +import type { H3Event } from 'h3' +import type { UploadErrorItem } from '~/types/image' +import type { FileItem } from '~/types/file' +import { + FORBIDDEN_FILE_EXTENSIONS, + MAX_FILE_BED_FILE_SIZE, + MAX_VIDEO_FILE_SIZE +} from './constants' +import { contentTypeFromKey } from './content-type' +import { + extensionFromFilename, + generateFileKey, + generateVideoKey, + isVideoExtension +} from './image-key' +import { getStorageLayout } from './env' +import { resolveImageUrl } from './image-response' +import type { LogSource } from './db' +import type { MediaType } from './storage/types' +import { logActivity } from './activity-log' +import { logException, logInfo } from './logger' +import { putImage } from './storage' +import { generateVideoThumbnail } from './transcode' +import { updateImageThumb } from './image-index' + +/** 文件床禁止上传的扩展名(html / htm / svg) */ +export function isForbiddenFileExtension(extension: string): boolean { + if (!extension) return false + return (FORBIDDEN_FILE_EXTENSIONS as readonly string[]).includes(extension.toLowerCase()) +} + +/** 下载文件名清洗:保留中文等字符,去掉路径与控制字符 */ +export function sanitizeDownloadName(name: string): string { + const base = name.split(/[/\\]/).pop() ?? 'file' + const withoutControl = Array.from(base) + .filter(char => char.charCodeAt(0) > 0x1f && char.charCodeAt(0) !== 0x7f) + .join('') + const cleaned = withoutControl + .replace(/["<>|:*?]/g, '_') + .trim() + return cleaned.slice(0, 200) || 'file' +} + +/** RFC 5987 兼容的附件下载响应头值 */ +export function buildAttachmentDisposition(name: string): string { + const safe = sanitizeDownloadName(name) + const ascii = safe.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_') + return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(safe)}` +} + +export function generateFileStorageKey(originalName: string, date = new Date()): string { + return generateFileKey(extensionFromFilename(originalName), date, getStorageLayout()) +} + +export function generateVideoStorageKey(originalName: string, date = new Date()): string { + return generateVideoKey(extensionFromFilename(originalName), date, getStorageLayout()) +} + +/** 声明 MIME 清洗;不合法时按扩展名推断 */ +export function normalizeFileContentType( + declared: string | undefined, + extension: string +): string { + const value = (declared ?? '').split(';')[0]!.trim().toLowerCase() + const valid = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(value) + if (valid) return value + return extension ? contentTypeFromKey(`file.${extension}`) : 'application/octet-stream' +} + +/** 视频必须落到 video/* 上,否则按扩展名推断 */ +export function normalizeVideoContentType( + declared: string | undefined, + extension: string +): string { + const normalized = normalizeFileContentType(declared, extension) + if (normalized.startsWith('video/')) return normalized + return contentTypeFromKey(`video.${extension}`) +} + +export function buildFileItem(input: { + event: H3Event + key: string + backendId?: string + originalName: string + contentType: string + size: number + uploadedAt: string +}): FileItem { + const url = resolveImageUrl(input.event, input.key, input.backendId) + const name = sanitizeDownloadName(input.originalName) + + return { + key: input.key, + url, + originalName: name, + contentType: input.contentType, + size: input.size, + uploadedAt: input.uploadedAt, + markdown: `[${name}](${url})` + } +} + +export async function processSingleFileUpload( + event: H3Event, + input: { + bytes: Uint8Array + filename?: string + contentType?: string + source?: LogSource + userId?: number | null + } +): Promise<{ item: FileItem } | { error: UploadErrorItem }> { + const originalName = sanitizeDownloadName(input.filename ?? 'file') + const bytes = input.bytes + + if (!bytes.byteLength) { + return { + error: { + name: originalName, + error: { code: 'INVALID_REQUEST', message: '文件内容为空' } + } + } + } + + if (bytes.byteLength > MAX_FILE_BED_FILE_SIZE) { + return { + error: { + name: originalName, + error: { + code: 'FILE_TOO_LARGE', + message: `单个文件不能超过 ${Math.round(MAX_FILE_BED_FILE_SIZE / 1024 / 1024)}MB` + } + } + } + } + + const extension = extensionFromFilename(originalName) + if (isForbiddenFileExtension(extension)) { + return { + error: { + name: originalName, + error: { + code: 'UNSUPPORTED_FILE_TYPE', + message: '不允许上传 html / htm / svg 文件' + } + } + } + } + + const uploadedAt = new Date().toISOString() + const key = generateFileStorageKey(originalName, new Date(uploadedAt)) + + return persistMediaUpload(event, { + key, + bytes, + originalName, + uploadedAt, + contentType: normalizeFileContentType(input.contentType, extension), + source: input.source, + userId: input.userId, + mediaType: 'file' + }) +} + +/** 写存储 + 建索引 + 记日志 + 组装返回项(文件床与视频床共用) */ +async function persistMediaUpload( + event: H3Event, + input: { + key: string + bytes: Uint8Array + originalName: string + uploadedAt: string + contentType: string + source?: LogSource + userId?: number | null + mediaType: MediaType + } +): Promise<{ item: FileItem } | { error: UploadErrorItem }> { + let backendId: string + try { + backendId = await putImage(input.key, input.bytes, { + originalName: input.originalName, + uploadedAt: input.uploadedAt, + contentType: input.contentType, + size: input.bytes.byteLength, + userId: input.userId ?? null, + mediaType: input.mediaType + }) + } catch (error) { + logException('upload failed: storage error', error, { + name: input.originalName, + source: input.source ?? 'web' + }) + const detail = error instanceof Error ? error.message.trim() : '' + return { + error: { + name: input.originalName, + error: { + code: 'UPLOAD_FAILED', + message: detail.startsWith('Telegram') || detail.includes('群组') + ? detail + : '上传失败,请稍后重试' + } + } + } + } + + logActivity(event, { + action: 'upload', + key: input.key, + originalName: input.originalName, + size: input.bytes.byteLength, + contentType: input.contentType, + source: input.source, + userId: input.userId ?? null, + backendId + }) + + logInfo(`${input.mediaType} upload success`, { + key: input.key, + size: input.bytes.byteLength, + contentType: input.contentType, + source: input.source ?? 'web' + }) + + return { + item: buildFileItem({ + event, + key: input.key, + backendId, + originalName: input.originalName, + contentType: input.contentType, + size: input.bytes.byteLength, + uploadedAt: input.uploadedAt + }) + } +} + +export async function processSingleVideoUpload( + event: H3Event, + input: { + bytes: Uint8Array + filename?: string + contentType?: string + source?: LogSource + userId?: number | null + } +): Promise<{ item: FileItem } | { error: UploadErrorItem }> { + const originalName = sanitizeDownloadName(input.filename ?? 'video') + const bytes = input.bytes + + if (!bytes.byteLength) { + return { + error: { + name: originalName, + error: { code: 'INVALID_REQUEST', message: '文件内容为空' } + } + } + } + + if (bytes.byteLength > MAX_VIDEO_FILE_SIZE) { + return { + error: { + name: originalName, + error: { + code: 'FILE_TOO_LARGE', + message: `单个视频不能超过 ${Math.round(MAX_VIDEO_FILE_SIZE / 1024 / 1024)}MB` + } + } + } + } + + const extension = extensionFromFilename(originalName) + if (!isVideoExtension(extension)) { + return { + error: { + name: originalName, + error: { + code: 'UNSUPPORTED_FILE_TYPE', + message: '只支持常见视频格式(MP4 / WebM / MOV / MKV 等)' + } + } + } + } + + const uploadedAt = new Date().toISOString() + const key = generateVideoStorageKey(originalName, new Date(uploadedAt)) + + const result = await persistMediaUpload(event, { + key, + bytes, + originalName, + uploadedAt, + contentType: normalizeVideoContentType(input.contentType, extension), + source: input.source, + userId: input.userId, + mediaType: 'video' + }) + + // 生成封面缩略图;失败不影响上传结果 + if ('item' in result) { + const thumbKey = await generateVideoThumbnail(key).catch(() => null) + if (thumbKey) { + updateImageThumb(key, thumbKey) + result.item.thumbUrl = resolveImageUrl(event, thumbKey) + } + } + + return result +} diff --git a/server/utils/image-index.ts b/server/utils/image-index.ts index d4a4cc6..7f202e1 100644 --- a/server/utils/image-index.ts +++ b/server/utils/image-index.ts @@ -3,7 +3,16 @@ import { dirname, join } from 'node:path' import { getDb, getUploadSourcesForKeys } from './db' import { getDataDir } from './data-dir' import { contentTypeFromKey } from './content-type' -import { DEFAULT_FOLDER, toCanonicalImageKey, validateImageKey } from './image-key' +import { + DEFAULT_FOLDER, + FILE_FOLDER, + VIDEO_FOLDER, + isVideoSidecarKey, + mediaTypeForKey, + toCanonicalImageKey, + validateImageKey, + validateStorageKey +} from './image-key' import { buildTagFilterSql, type TagFilterInput } from './tags' import { isBareImageFilename, isHiddenImageDatePath } from './image-public-path' import { @@ -11,7 +20,13 @@ import { ensureStorageSchema, LOCAL_BACKEND_ID } from './storage-backends' -import type { PaginatedResult, StoredImage, StoredImageMeta } from './storage/types' +import type { + MediaType, + PaginatedResult, + StoredImage, + StoredImageMeta, + TranscodeStatus +} from './storage/types' const META_SUFFIX = '.meta.json' @@ -24,6 +39,12 @@ export interface ImageIndexRow { content_type: string size: number uploaded_at: string + hls_key: string | null + transcode_status: string + transcode_progress: number + thumb_key: string | null + source_deleted: number + transcode_preset: string | null } function keyToFilePath(key: string): string { @@ -49,6 +70,12 @@ function rowToStoredImage(row: ImageIndexRow): StoredImage { contentType: row.content_type, size: row.size, userId: row.user_id, + hlsKey: row.hls_key ?? null, + transcodeStatus: (row.transcode_status ?? 'none') as TranscodeStatus, + transcodeProgress: Number(row.transcode_progress ?? 0), + thumbKey: row.thumb_key ?? null, + sourceDeleted: Number(row.source_deleted ?? 0) === 1, + transcodePreset: row.transcode_preset ?? null, mtimeMs: Number.isFinite(uploadedMs) ? uploadedMs : Date.now() } } @@ -70,13 +97,14 @@ export function insertImageIndex(input: { contentType: string size: number uploadedAt: string + mediaType?: MediaType }): void { ensureStorageSchema() const folder = input.key.split('/')[0] ?? DEFAULT_FOLDER getDb().prepare(` INSERT INTO images - (key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + (key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, media_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET backend_id = excluded.backend_id, user_id = excluded.user_id, @@ -84,7 +112,8 @@ export function insertImageIndex(input: { original_name = excluded.original_name, content_type = excluded.content_type, size = excluded.size, - uploaded_at = excluded.uploaded_at + uploaded_at = excluded.uploaded_at, + media_type = excluded.media_type `).run( input.key, input.backendId, @@ -93,7 +122,8 @@ export function insertImageIndex(input: { input.originalName, input.contentType, input.size, - input.uploadedAt + input.uploadedAt, + input.mediaType ?? 'image' ) } @@ -180,11 +210,13 @@ export async function reconcileLegacyImageKeys(): Promise<{ export async function purgeOrphanImageIndexRows(): Promise { ensureStorageSchema() const rows = getDb().prepare(` - SELECT key, backend_id FROM images - `).all() as Array<{ key: string, backend_id: string }> + SELECT key, backend_id, source_deleted FROM images + `).all() as Array<{ key: string, backend_id: string, source_deleted: number }> let removed = 0 for (const row of rows) { + // 转码成功后源文件是主动删除的,索引要保留(播放走 HLS) + if (Number(row.source_deleted ?? 0) === 1) continue if (row.backend_id !== LOCAL_BACKEND_ID) continue if (await fileExistsAtKey(row.key)) continue const canonical = toCanonicalImageKey(row.key) @@ -198,7 +230,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 + 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 FROM images WHERE key = ? `).get(key) as ImageIndexRow | undefined @@ -217,7 +249,7 @@ export function findImageKeyByDatePath(datePath: string): string | null { ensureStorageSchema() const suffix = `/${datePath}` const rows = getDb().prepare(` - SELECT key FROM images WHERE key LIKE ? + SELECT key FROM images WHERE media_type = 'image' AND key LIKE ? `).all(`%${suffix}`) as Array<{ key: string }> if (rows.length === 0) { @@ -240,7 +272,7 @@ export function findImageKeyByFilename(filename: string): string | null { ensureStorageSchema() const suffix = `/${filename}` const rows = getDb().prepare(` - SELECT key FROM images WHERE key LIKE ? + SELECT key FROM images WHERE media_type = 'image' AND key LIKE ? `).all(`%${suffix}`) as Array<{ key: string }> if (rows.length === 0) { @@ -274,7 +306,7 @@ async function collectImageKeysRecursive( } else if (entry.isFile()) { if (name.endsWith(META_SUFFIX)) continue const key = `${keyPrefix}/${name}` - if (validateImageKey(key)) keys.push(key) + if (validateStorageKey(key)) keys.push(key) } } } catch { @@ -284,17 +316,25 @@ async function collectImageKeysRecursive( return keys } -async function listLocalImageKeys(): Promise { - const imagesRoot = join(getDataDir(), DEFAULT_FOLDER) - return collectImageKeysRecursive(imagesRoot, DEFAULT_FOLDER) +/** 扫描本地磁盘的 images/、files/ 与 videos/ 目录 */ +async function listLocalStorageKeys(): Promise { + const keys: string[] = [] + for (const root of [DEFAULT_FOLDER, FILE_FOLDER, VIDEO_FOLDER]) { + const found = await collectImageKeysRecursive(join(getDataDir(), root), root) + keys.push(...found) + } + return keys } export async function migrateLocalImagesToIndex(): Promise { ensureDefaultBackends() - const keys = await listLocalImageKeys() + const keys = await listLocalStorageKeys() let inserted = 0 for (const key of keys) { + // HLS 播放列表与分片属于视频的附属产物,不进索引 + if (isVideoSidecarKey(key)) continue + const existing = getImageIndexRow(key) if (existing) continue @@ -320,7 +360,8 @@ export async function migrateLocalImagesToIndex(): Promise { originalName: meta.originalName ?? key.split('/').pop() ?? 'image', contentType, size: stat.size, - uploadedAt + uploadedAt, + mediaType: mediaTypeForKey(key) as MediaType }) inserted++ } @@ -382,7 +423,7 @@ 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 + 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 FROM images ORDER BY key ASC `).all() as unknown as ImageIndexRow[] @@ -391,7 +432,7 @@ export function listAllImageIndexRows(): 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 + 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 FROM images WHERE backend_id = ? ORDER BY key ASC @@ -405,15 +446,157 @@ export function updateImageBackendId(key: string, backendId: string): void { `).run(backendId, key) } +/** 更新 HLS 转码状态;hlsKey / progress 传 null 时保留原值 */ +export function updateImageTranscode( + key: string, + input: { + status: TranscodeStatus + hlsKey?: string | null + progress?: number + sourceDeleted?: boolean + size?: number + preset?: string | null + } +): void { + ensureStorageSchema() + getDb().prepare(` + UPDATE images + SET transcode_status = ?, + hls_key = COALESCE(?, hls_key), + transcode_progress = COALESCE(?, transcode_progress), + source_deleted = COALESCE(?, source_deleted), + size = COALESCE(?, size), + transcode_preset = COALESCE(?, transcode_preset) + WHERE key = ? + `).run( + input.status, + input.hlsKey ?? null, + input.progress ?? null, + input.sourceDeleted === undefined ? null : (input.sourceDeleted ? 1 : 0), + input.size ?? null, + input.preset ?? null, + key + ) +} + +/** 启动时恢复被中断的转码任务 */ +export function listPendingTranscodeKeys(): string[] { + ensureStorageSchema() + const rows = getDb().prepare(` + SELECT key 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) +} + +/** 转码已完成但仍保留源文件的视频(用于补删历史遗留) */ +export function listTranscodedSourceRows(): Array<{ key: string, hlsKey: string }> { + ensureStorageSchema() + const rows = getDb().prepare(` + SELECT key, hls_key FROM images + WHERE media_type = 'video' + AND transcode_status = 'done' + AND source_deleted = 0 + AND hls_key IS NOT NULL + `).all() as Array<{ key: string, hls_key: string }> + return rows.map(row => ({ key: row.key, hlsKey: row.hls_key })) +} + +/** 记录视频封面缩略图 key */ +export function updateImageThumb(key: string, thumbKey: string): void { + ensureStorageSchema() + getDb().prepare('UPDATE images SET thumb_key = ? WHERE key = ?').run(thumbKey, key) +} + +export interface MediaStats { + uploadToday: number + uploadYesterday: number + uploadMonth: number + uploadLastMonth: number + uploadTotal: number + storedCount: number + uploadBytesTotal: number + bySource: { web: number, api: number } +} + +/** 按媒体类型统计(图片 / 视频 / 文件各自独立) */ +export function getMediaStats( + mediaType: MediaType, + userFilter?: number | 'admin' +): MediaStats { + ensureStorageSchema() + const params: Array = [mediaType] + const userSql = buildUserFilterSql(userFilter, params) + // 转码中的视频还没入库,与列表口径保持一致:统计不计入 + const transcodeSql = mediaType === 'video' + ? ` AND transcode_status IN ('none', 'done', 'failed')` + : '' + const rows = getDb().prepare(` + SELECT size, uploaded_at FROM images + WHERE media_type = ?${transcodeSql}${userSql} + `).all(...params) as Array<{ size: number, uploaded_at: string }> + + const startOfDay = startOfDayIsoInShanghai() + const startOfYesterday = startOfYesterdayIsoInShanghai() + const startOfMonth = startOfMonthIsoInShanghai() + const startOfLastMonth = startOfLastMonthIsoInShanghai() + + let bytes = 0 + let today = 0 + let yesterday = 0 + let month = 0 + let lastMonth = 0 + + for (const row of rows) { + bytes += row.size + if (row.uploaded_at >= startOfDay) today++ + if (row.uploaded_at >= startOfYesterday && row.uploaded_at < startOfDay) yesterday++ + if (row.uploaded_at >= startOfMonth) month++ + if (row.uploaded_at >= startOfLastMonth && row.uploaded_at < startOfMonth) lastMonth++ + } + + // 来源分布来自上传日志,按目录前缀区分类型 + const prefix = mediaType === 'video' ? 'videos/' : mediaType === 'file' ? 'files/' : 'images/' + const sourceRows = getDb().prepare(` + SELECT source, COUNT(*) AS count FROM activity_logs + WHERE action = 'upload' AND key LIKE ? + GROUP BY source + `).all(`${prefix}%`) as Array<{ source: string, count: number }> + + const bySource = { web: 0, api: 0 } + for (const row of sourceRows) { + if (row.source === 'web') bySource.web = row.count + else if (row.source === 'api') bySource.api = row.count + } + + return { + uploadToday: today, + uploadYesterday: yesterday, + uploadMonth: month, + uploadLastMonth: lastMonth, + uploadTotal: rows.length, + storedCount: rows.length, + uploadBytesTotal: bytes, + bySource + } +} + export async function countImages( userFilter?: number | 'admin', backendId?: string, - tagFilter?: TagFilterInput + tagFilter?: TagFilterInput, + mediaType: MediaType | 'all' = 'image' ): Promise { ensureStorageSchema() const params: Array = [] const clauses: string[] = ['1=1'] + if (mediaType !== 'all') { + clauses.push('media_type = ?') + params.push(mediaType) + } + const userSql = buildUserFilterSql(userFilter, params) const backendSql = buildBackendFilterSql(backendId, params) const tagSql = buildTagFilterSql(tagFilter, params) @@ -466,16 +649,27 @@ export async function listImages(options: { contentType?: string uploadSource?: 'web' | 'api' tagFilter?: TagFilterInput + mediaType?: MediaType + /** 只返回这些转码状态的条目(视频库用来隐藏转码中的视频) */ + transcodeStatuses?: TranscodeStatus[] }): Promise> { ensureStorageSchema() const params: Array = [] const clauses: string[] = ['1=1'] + clauses.push('media_type = ?') + params.push(options.mediaType ?? 'image') + + if (options.transcodeStatuses?.length) { + clauses.push(`transcode_status IN (${options.transcodeStatuses.map(() => '?').join(', ')})`) + params.push(...options.transcodeStatuses) + } + const userSql = buildUserFilterSql(options.userFilter, params) 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 + 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 FROM images WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql} ORDER BY uploaded_at DESC, key DESC @@ -501,17 +695,27 @@ export async function searchImages(options: { contentType?: string uploadSource?: 'web' | 'api' tagFilter?: TagFilterInput + mediaType?: MediaType + transcodeStatuses?: TranscodeStatus[] }): Promise> { ensureStorageSchema() const needle = options.query.trim().toLowerCase() const params: Array = [] const clauses: string[] = ['1=1'] + clauses.push('media_type = ?') + params.push(options.mediaType ?? 'image') + + if (options.transcodeStatuses?.length) { + clauses.push(`transcode_status IN (${options.transcodeStatuses.map(() => '?').join(', ')})`) + params.push(...options.transcodeStatuses) + } + const userSql = buildUserFilterSql(options.userFilter, params) 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 + 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 FROM images WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql} ORDER BY uploaded_at DESC, key DESC @@ -645,8 +849,9 @@ 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 + 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 FROM images + WHERE media_type = 'image' ORDER BY uploaded_at ASC `).all() as unknown as ImageIndexRow[] diff --git a/server/utils/image-key.ts b/server/utils/image-key.ts index 41e1dcb..339e9c3 100644 --- a/server/utils/image-key.ts +++ b/server/utils/image-key.ts @@ -1,3 +1,4 @@ +import { VIDEO_EXTENSIONS } from './constants' import type { AllowedMimeType } from './constants' const EXT_MAP: Record = { @@ -10,11 +11,16 @@ const EXT_MAP: Record = { } export const DEFAULT_FOLDER = 'images' +export const FILE_FOLDER = 'files' +export const VIDEO_FOLDER = 'videos' export type StorageLayout = 'date' | 'flat' const IMAGE_EXT_PATTERN = /\.(jpg|jpeg|png|webp|gif|svg|ico)$/i +/** 文件床文件名:字母数字开头,允许 - _ .,最长 64(含扩展名) */ +const FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/ + function generateRandomId(length = 12): string { const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' const bytes = new Uint8Array(length) @@ -69,6 +75,128 @@ export function validateImageKey(key: string): boolean { return true } +/** 扩展名清洗:只保留 1–10 位字母数字 */ +export function sanitizeFileExtension(raw: string): string { + const ext = raw.trim().toLowerCase().replace(/^\.+/, '') + return /^[a-z0-9]{1,10}$/.test(ext) ? ext : '' +} + +/** 从原始文件名取安全扩展名;无扩展名时返回空串 */ +export function extensionFromFilename(filename: string): string { + const base = filename.split(/[/\\]/).pop() ?? '' + const dot = base.lastIndexOf('.') + if (dot <= 0 || dot === base.length - 1) return '' + return sanitizeFileExtension(base.slice(dot + 1)) +} + +export function generateFileKey( + extension: string, + date = new Date(), + layout: StorageLayout = 'date' +): string { + const id = generateRandomId(12) + const ext = sanitizeFileExtension(extension) + const suffix = ext ? `.${ext}` : '' + if (layout === 'flat') { + return `${FILE_FOLDER}/${id}${suffix}` + } + const year = date.getUTCFullYear() + const month = String(date.getUTCMonth() + 1).padStart(2, '0') + return `${FILE_FOLDER}/${year}/${month}/${id}${suffix}` +} + +/** 文件床 key(files/…);图片 key 走 validateImageKey */ +export function validateFileKey(key: string): boolean { + if (!key || typeof key !== 'string') return false + if (key.startsWith('/')) return false + if (key.includes('..')) return false + if (key.includes('//')) return false + + const parts = key.split('/') + if (parts.length < 2 || parts.some(part => !part)) return false + + if (parts[0] !== FILE_FOLDER) return false + + const filename = parts[parts.length - 1]! + if (!FILE_NAME_PATTERN.test(filename)) return false + + for (let i = 1; i < parts.length - 1; i++) { + if (!isValidPathSegment(parts[i]!)) return false + } + + return true +} + +/** 图片或文件床的存储 key */ +export function validateStorageKey(key: string): boolean { + return validateImageKey(key) || validateFileKey(key) || validateVideoKey(key) +} + +export function mediaTypeForKey(key: string): 'image' | 'video' | 'file' { + if (key.startsWith(`${FILE_FOLDER}/`)) return 'file' + if (key.startsWith(`${VIDEO_FOLDER}/`)) return 'video' + return 'image' +} + +/** videos/ 下除视频本体外的附属资源:播放列表、分片、封面缩略图 */ +const VIDEO_SIDECAR_EXTENSIONS = ['m3u8', 'ts', 'webp', 'jpg', 'jpeg', 'png'] + +function isVideoKeyExtension(extension: string): boolean { + if (!extension) return false + const value = extension.toLowerCase() + return (VIDEO_EXTENSIONS as readonly string[]).includes(value) + || VIDEO_SIDECAR_EXTENSIONS.includes(value) +} + +/** 附属资源不进索引,也不出现在列表里 */ +export function isVideoSidecarKey(key: string): boolean { + return validateVideoKey(key) + && VIDEO_SIDECAR_EXTENSIONS.includes(extensionFromFilename(key)) +} + +export function isVideoExtension(extension: string): boolean { + if (!extension) return false + return (VIDEO_EXTENSIONS as readonly string[]).includes(extension.toLowerCase()) +} + +export function generateVideoKey( + extension: string, + date = new Date(), + layout: StorageLayout = 'date' +): string { + const id = generateRandomId(12) + const ext = sanitizeFileExtension(extension) || 'mp4' + if (layout === 'flat') { + return `${VIDEO_FOLDER}/${id}.${ext}` + } + const year = date.getUTCFullYear() + const month = String(date.getUTCMonth() + 1).padStart(2, '0') + return `${VIDEO_FOLDER}/${year}/${month}/${id}.${ext}` +} + +/** 视频床 key(videos/…),扩展名必须在视频白名单内 */ +export function validateVideoKey(key: string): boolean { + if (!key || typeof key !== 'string') return false + if (key.startsWith('/')) return false + if (key.includes('..')) return false + if (key.includes('//')) return false + + const parts = key.split('/') + if (parts.length < 2 || parts.some(part => !part)) return false + + if (parts[0] !== VIDEO_FOLDER) return false + + const filename = parts[parts.length - 1]! + if (!FILE_NAME_PATTERN.test(filename)) return false + if (!isVideoKeyExtension(extensionFromFilename(filename))) return false + + for (let i = 1; i < parts.length - 1; i++) { + if (!isValidPathSegment(parts[i]!)) return false + } + + return true +} + /** 将遗留顶层目录 key(如 blog/foo.webp)映射为 images/blog/foo.webp */ export function toCanonicalImageKey(key: string): string | null { if (validateImageKey(key)) return key diff --git a/server/utils/image-public-path.ts b/server/utils/image-public-path.ts index 180d24e..78daafb 100644 --- a/server/utils/image-public-path.ts +++ b/server/utils/image-public-path.ts @@ -1,5 +1,5 @@ import { basename } from 'node:path' -import { DEFAULT_FOLDER, validateImageKey } from './image-key' +import { DEFAULT_FOLDER, validateFileKey, validateImageKey } from './image-key' /** 存储 key → 对外 URL 路径(不含域名) */ export function storageKeyToPublicPath(key: string): string { @@ -23,6 +23,8 @@ export function toStorageKeyFromPublicPath(publicPath: string): string | null { const raw = publicPath.replace(/^\/+/, '') if (!raw) return null if (validateImageKey(raw)) return raw + // 文件床直链保留 files/ 前缀,避免与图片路径混用 + if (validateFileKey(raw)) return raw const candidate = `${DEFAULT_FOLDER}/${raw}` return validateImageKey(candidate) ? candidate : null } diff --git a/server/utils/serve-image.ts b/server/utils/serve-image.ts index 9d4a411..30a99b1 100644 --- a/server/utils/serve-image.ts +++ b/server/utils/serve-image.ts @@ -1,14 +1,26 @@ import type { H3Event } from 'h3' -import { IMAGE_CACHE_CONTROL } from './constants' +import { + FILE_CACHE_CONTROL, + FILE_PRIVATE_CACHE_CONTROL, + IMAGE_CACHE_CONTROL +} from './constants' import { getAllowedRefererHostsRaw, + getFileBedAccessMode, getImageBaseUrlConfigured, getSiteBaseUrlConfigured, hostnameFromBaseUrl, isRefererConfigured } from './env' +import { isFileAccessAuthorized } from './access' +import { buildAttachmentDisposition } from './file-bed' import { findImageKeyByDatePath, findImageKeyByFilename } from './image-index' -import { validateImageKey } from './image-key' +import { + validateFileKey, + validateImageKey, + validateStorageKey, + validateVideoKey +} from './image-key' import { isBareImageFilename, toStorageKeyFromPublicPath } from './image-public-path' import { buildPublicImageUrl } from './image-response' import { createImageStream, headImage } from './storage' @@ -90,7 +102,7 @@ export function requestPathToImageKey( const raw = decodeURIComponent(pathname).replace(/^\/+/, '') if (!raw) return null - if (validateImageKey(raw)) return raw + if (validateStorageKey(raw)) return raw // 简短链接:纯文件名须先走索引反查,不能猜成 images/文件名(与按年/月存储冲突) if (options?.hideFolder && isBareImageFilename(raw)) { @@ -114,6 +126,12 @@ export function requestPathToImageKey( } export async function serveImageByKey(event: H3Event, key: string) { + const isFile = validateFileKey(key) + const isVideo = validateVideoKey(key) + // 文件与视频共用「文件床访问方式」开关 + const isMedia = isFile || isVideo + const fileAccessMode = isMedia ? getFileBedAccessMode() : 'public' + const referer = getHeader(event, 'referer') ?? null if (isHotlinkBlocked(event, referer)) { setHeader(event, 'Cache-Control', ERROR_CACHE_CONTROL) @@ -123,6 +141,12 @@ export async function serveImageByKey(event: H3Event, key: string) { }) } + // Token 模式:文件直链需要 Auth-Token / ?token= / 已登录会话 + if (isMedia && fileAccessMode === 'token' && !(await isFileAccessAuthorized(event))) { + setHeader(event, 'Cache-Control', ERROR_CACHE_CONTROL) + throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) + } + const stored = await headImage(key) if (!stored) { setHeader(event, 'Cache-Control', ERROR_CACHE_CONTROL) @@ -130,7 +154,12 @@ export async function serveImageByKey(event: H3Event, key: string) { } const backendRow = await getBackendRowForKey(key) - if (backendRow.serving_mode === 'public' && backendRow.public_url) { + // Token 模式不做对象存储直链跳转,避免绕过鉴权 + if ( + fileAccessMode === 'public' + && backendRow.serving_mode === 'public' + && backendRow.public_url + ) { const publicUrl = buildPublicImageUrl(backendRow, key) return sendRedirect(event, publicUrl, 302) } @@ -138,11 +167,27 @@ export async function serveImageByKey(event: H3Event, key: string) { const etag = `"${stored.size}-${Math.round(stored.mtimeMs)}"` setHeader(event, 'ETag', etag) - setHeader(event, 'Cache-Control', IMAGE_CACHE_CONTROL) + setHeader( + event, + 'Cache-Control', + isMedia + ? (fileAccessMode === 'token' ? FILE_PRIVATE_CACHE_CONTROL : FILE_CACHE_CONTROL) + : IMAGE_CACHE_CONTROL + ) setHeader(event, 'Accept-Ranges', 'bytes') setHeader(event, 'X-Content-Type-Options', 'nosniff') setHeader(event, 'Content-Type', stored.contentType) + // 文件床统一以附件形式下发,避免上传的文档在站点域名下被渲染执行 + if (isFile) { + setHeader(event, 'Content-Disposition', buildAttachmentDisposition(stored.originalName)) + } + + // 视频走 inline 播放,跨域(图片域名)访问需要放行 CORS + if (isVideo) { + setHeader(event, 'Access-Control-Allow-Origin', '*') + } + if (stored.contentType === 'image/svg+xml') { setHeader( event, diff --git a/server/utils/storage-backends.ts b/server/utils/storage-backends.ts index fa6acf1..7d98a54 100644 --- a/server/utils/storage-backends.ts +++ b/server/utils/storage-backends.ts @@ -88,6 +88,150 @@ function migrateStorageBackendTelegramType(database: DatabaseSync): void { } } +const STORAGE_SCHEMA_MEDIA_TYPE_KEY = 'storage_schema_media_type' +const STORAGE_SCHEMA_TRANSCODE_KEY = 'storage_schema_transcode' +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' + +/** images 表新增 media_type 列(区分图床图片与文件床文件) */ +function migrateImagesMediaType(database: DatabaseSync): void { + const migrated = database.prepare(` + SELECT value FROM settings WHERE key = ? + `).get(STORAGE_SCHEMA_MEDIA_TYPE_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 === 'media_type')) { + database.exec(` + ALTER TABLE images ADD COLUMN media_type TEXT NOT NULL DEFAULT 'image' + `) + } + + database.prepare(` + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, '1', ?) + `).run(STORAGE_SCHEMA_MEDIA_TYPE_KEY, new Date().toISOString()) +} + +/** images 表新增 HLS 转码相关列(hls_key / transcode_status) */ +function migrateImagesTranscode(database: DatabaseSync): void { + const migrated = database.prepare(` + SELECT value FROM settings WHERE key = ? + `).get(STORAGE_SCHEMA_TRANSCODE_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 === 'hls_key')) { + database.exec('ALTER TABLE images ADD COLUMN hls_key TEXT') + } + if (!columns.some(column => column.name === 'transcode_status')) { + database.exec(` + ALTER TABLE images ADD COLUMN transcode_status TEXT NOT NULL DEFAULT 'none' + `) + } + + database.prepare(` + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, '1', ?) + `).run(STORAGE_SCHEMA_TRANSCODE_KEY, new Date().toISOString()) +} + +/** images 表新增转码进度列(0–100) */ +function migrateImagesTranscodeProgress(database: DatabaseSync): void { + const migrated = database.prepare(` + SELECT value FROM settings WHERE key = ? + `).get(STORAGE_SCHEMA_TRANSCODE_PROGRESS_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_progress')) { + database.exec(` + ALTER TABLE images ADD COLUMN transcode_progress INTEGER NOT NULL DEFAULT 0 + `) + } + + database.prepare(` + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, '1', ?) + `).run(STORAGE_SCHEMA_TRANSCODE_PROGRESS_KEY, new Date().toISOString()) +} + +/** images 表新增视频缩略图列 */ +function migrateImagesThumb(database: DatabaseSync): void { + const migrated = database.prepare(` + SELECT value FROM settings WHERE key = ? + `).get(STORAGE_SCHEMA_THUMB_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 === 'thumb_key')) { + database.exec('ALTER TABLE images ADD COLUMN thumb_key TEXT') + } + + database.prepare(` + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, '1', ?) + `).run(STORAGE_SCHEMA_THUMB_KEY, new Date().toISOString()) +} + +/** images 表新增「源视频已删除」标记 */ +function migrateImagesSourceDeleted(database: DatabaseSync): void { + const migrated = database.prepare(` + SELECT value FROM settings WHERE key = ? + `).get(STORAGE_SCHEMA_SOURCE_DELETED_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 === 'source_deleted')) { + database.exec(` + ALTER TABLE images ADD COLUMN source_deleted INTEGER NOT NULL DEFAULT 0 + `) + } + + database.prepare(` + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, '1', ?) + `).run(STORAGE_SCHEMA_SOURCE_DELETED_KEY, new Date().toISOString()) +} + +/** images 表新增「本视频使用的转码档位」列 */ +function migrateImagesTranscodePreset(database: DatabaseSync): void { + const migrated = database.prepare(` + SELECT value FROM settings WHERE key = ? + `).get(STORAGE_SCHEMA_TRANSCODE_PRESET_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_preset')) { + database.exec('ALTER TABLE images ADD COLUMN transcode_preset TEXT') + } + + database.prepare(` + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, '1', ?) + `).run(STORAGE_SCHEMA_TRANSCODE_PRESET_KEY, new Date().toISOString()) +} + function migrateStorageTables(database: DatabaseSync): void { database.exec(` CREATE TABLE IF NOT EXISTS storage_backends ( @@ -112,6 +256,13 @@ function migrateStorageTables(database: DatabaseSync): void { content_type TEXT NOT NULL, size INTEGER NOT NULL, uploaded_at TEXT NOT NULL, + media_type TEXT NOT NULL DEFAULT 'image', + hls_key TEXT, + transcode_status TEXT NOT NULL DEFAULT 'none', + transcode_progress INTEGER NOT NULL DEFAULT 0, + thumb_key TEXT, + source_deleted INTEGER NOT NULL DEFAULT 0, + transcode_preset TEXT, FOREIGN KEY (backend_id) REFERENCES storage_backends(id) ); CREATE INDEX IF NOT EXISTS idx_images_uploaded_at ON images(uploaded_at DESC); @@ -127,6 +278,15 @@ function migrateStorageTables(database: DatabaseSync): void { ); `) migrateStorageBackendTelegramType(database) + migrateImagesMediaType(database) + migrateImagesTranscode(database) + migrateImagesTranscodeProgress(database) + migrateImagesThumb(database) + migrateImagesSourceDeleted(database) + migrateImagesTranscodePreset(database) + database.exec(` + CREATE INDEX IF NOT EXISTS idx_images_media_type ON images(media_type); + `) } export function ensureStorageSchema(): void { diff --git a/server/utils/storage.ts b/server/utils/storage.ts index b51d535..cfcf3e4 100644 --- a/server/utils/storage.ts +++ b/server/utils/storage.ts @@ -18,7 +18,13 @@ import { listImages as listImagesFromIndex, searchImages as searchImagesFromIndex } from './image-index' -import { DEFAULT_FOLDER, toCanonicalImageKey, validateImageKey } from './image-key' +import { + DEFAULT_FOLDER, + isVideoSidecarKey, + mediaTypeForKey, + toCanonicalImageKey, + validateStorageKey +} from './image-key' import type { TagFilterInput } from './tags' import { getActiveBackend, @@ -26,9 +32,11 @@ import { } from './storage/resolver' import type { FolderStorageStat, + MediaType, PaginatedResult, StoredImage, - StoredImageMeta + StoredImageMeta, + TranscodeStatus } from './storage/types' export type { @@ -57,7 +65,7 @@ async function fileExistsAtKey(key: string): Promise { export async function resolveStorageImageKey(key: string): Promise { if (!key || typeof key !== 'string') return null - if (validateImageKey(key)) { + if (validateStorageKey(key)) { if (await fileExistsAtKey(key) || getImageIndexRow(key)) return key return null } @@ -106,7 +114,8 @@ export async function putImage( originalName: meta.originalName, contentType: meta.contentType, size: meta.size, - uploadedAt: meta.uploadedAt + uploadedAt: meta.uploadedAt, + mediaType: meta.mediaType }) return backend.id } @@ -134,15 +143,19 @@ export async function headImage(key: string): Promise { } } - insertImageIndex({ - key: stored.key, - backendId: backend.id, - userId: stored.userId, - originalName: stored.originalName, - contentType: stored.contentType, - size: stored.size, - uploadedAt: stored.uploadedAt - }) + // HLS 播放列表与分片不建索引,避免污染视频库 + if (!isVideoSidecarKey(stored.key)) { + insertImageIndex({ + key: stored.key, + backendId: backend.id, + userId: stored.userId, + originalName: stored.originalName, + contentType: stored.contentType, + size: stored.size, + uploadedAt: stored.uploadedAt, + mediaType: mediaTypeForKey(stored.key) + }) + } return { ...stored, backendId: backend.id } } @@ -214,6 +227,8 @@ export async function listImages(options: { contentType?: string uploadSource?: 'web' | 'api' tagFilter?: TagFilterInput + mediaType?: MediaType + transcodeStatuses?: TranscodeStatus[] }): Promise> { return listImagesFromIndex(options) } @@ -227,6 +242,8 @@ export async function searchImages(options: { contentType?: string uploadSource?: 'web' | 'api' tagFilter?: TagFilterInput + mediaType?: MediaType + transcodeStatuses?: TranscodeStatus[] }): Promise> { return searchImagesFromIndex(options) } diff --git a/server/utils/storage/local.ts b/server/utils/storage/local.ts index 2994a45..e92fbe8 100644 --- a/server/utils/storage/local.ts +++ b/server/utils/storage/local.ts @@ -3,7 +3,7 @@ import type { ReadStream } from 'node:fs' import { dirname, join } from 'node:path' import { contentTypeFromKey } from '../content-type' import { getDataDir } from '../data-dir' -import { validateImageKey } from '../image-key' +import { validateStorageKey } from '../image-key' import type { StoredImage, StoredImageMeta, StorageBackend } from './types' const META_SUFFIX = '.meta.json' @@ -44,7 +44,7 @@ export class LocalStorageBackend implements StorageBackend { } async head(key: string): Promise { - if (!validateImageKey(key)) return null + if (!validateStorageKey(key)) return null let stat try { diff --git a/server/utils/storage/s3.ts b/server/utils/storage/s3.ts index f72263d..bb865d9 100644 --- a/server/utils/storage/s3.ts +++ b/server/utils/storage/s3.ts @@ -7,7 +7,7 @@ import { S3Client } from '@aws-sdk/client-s3' import type { Readable } from 'node:stream' -import { validateImageKey } from '../image-key' +import { validateStorageKey } from '../image-key' import type { S3BackendConfig, S3BackendSecrets, @@ -133,7 +133,7 @@ export class S3StorageBackend implements StorageBackend { } async head(key: string): Promise { - if (!validateImageKey(key)) return null + if (!validateStorageKey(key)) return null try { const response = await this.client.send(new HeadObjectCommand({ diff --git a/server/utils/storage/telegram.ts b/server/utils/storage/telegram.ts index 0950aed..2dcd153 100644 --- a/server/utils/storage/telegram.ts +++ b/server/utils/storage/telegram.ts @@ -1,6 +1,6 @@ import { Readable } from 'node:stream' import { getImageIndexAsStored } from '../image-index' -import { validateImageKey } from '../image-key' +import { validateStorageKey } from '../image-key' import { logException } from '../logger' import { deleteStorageObjectRef, @@ -338,7 +338,7 @@ export class TelegramStorageBackend implements StorageBackend { } async put(key: string, bytes: Uint8Array, _meta: StoredImageMeta): Promise { - if (!validateImageKey(key)) { + if (!validateStorageKey(key)) { throw new Error('Invalid image key') } if (bytes.byteLength > TELEGRAM_MAX_FILE_BYTES) { @@ -393,7 +393,7 @@ export class TelegramStorageBackend implements StorageBackend { } async head(key: string): Promise { - if (!validateImageKey(key)) return null + if (!validateStorageKey(key)) return null const indexed = getImageIndexAsStored(key) if (!indexed || indexed.backendId !== this.id) { diff --git a/server/utils/storage/types.ts b/server/utils/storage/types.ts index a0ef49b..6a842bd 100644 --- a/server/utils/storage/types.ts +++ b/server/utils/storage/types.ts @@ -6,12 +6,29 @@ export interface StoredImageMeta { contentType: string size: number userId?: number | null + /** 索引表中的资源类型:图片或文件床文件 */ + mediaType?: MediaType } +export type MediaType = 'image' | 'video' | 'file' + +export type TranscodeStatus = 'none' | 'pending' | 'running' | 'done' | 'failed' + export interface StoredImage extends StoredImageMeta { key: string mtimeMs: number backendId?: string + /** HLS 播放列表 key(转码完成后有值) */ + hlsKey?: string | null + transcodeStatus?: TranscodeStatus + /** 转码进度 0–100 */ + transcodeProgress?: number + /** 本视频使用的转码档位 */ + transcodePreset?: string | null + /** 视频封面缩略图 key */ + thumbKey?: string | null + /** 转码成功后源视频是否已删除 */ + sourceDeleted?: boolean } export interface PaginatedResult { diff --git a/server/utils/transcode.test.ts b/server/utils/transcode.test.ts new file mode 100644 index 0000000..ebfbbf9 --- /dev/null +++ b/server/utils/transcode.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_TRANSCODE_CONCURRENCY, + DEFAULT_TRANSCODE_PRESET, + DEFAULT_TRANSCODE_THREADS, + getTranscodeConcurrency, + getTranscodeSettings, + hlsPlaylistKey, + parseTranscodePreset, + setTranscodeSettings, + thumbKeyFor +} from './transcode' +import { isVideoSidecarKey, mediaTypeForKey } from './image-key' + +describe('hlsPlaylistKey', () => { + it('uses the video key without extension as the hls directory', () => { + expect(hlsPlaylistKey('videos/2026/10/abc123.mp4')) + .toBe('videos/2026/10/abc123/index.m3u8') + expect(hlsPlaylistKey('videos/abc123.mkv')) + .toBe('videos/abc123/index.m3u8') + }) +}) + +describe('isVideoSidecarKey', () => { + it('detects playlists, segments and thumbnails', () => { + expect(isVideoSidecarKey('videos/2026/10/abc/index.m3u8')).toBe(true) + expect(isVideoSidecarKey('videos/2026/10/abc/seg000.ts')).toBe(true) + expect(isVideoSidecarKey('videos/2026/10/abc.webp')).toBe(true) + }) + + it('leaves the original video and other media out', () => { + expect(isVideoSidecarKey('videos/2026/10/abc.mp4')).toBe(false) + expect(isVideoSidecarKey('files/2026/10/abc.pdf')).toBe(false) + expect(isVideoSidecarKey('images/2026/10/abc.webp')).toBe(false) + }) + + it('builds thumbnail keys next to the video', () => { + expect(thumbKeyFor('videos/2026/10/abc.mp4')).toBe('videos/2026/10/abc.webp') + }) +}) + +describe('mediaTypeForKey', () => { + it('maps folders to media types', () => { + expect(mediaTypeForKey('images/2026/10/a.webp')).toBe('image') + expect(mediaTypeForKey('videos/2026/10/a.mp4')).toBe('video') + expect(mediaTypeForKey('files/2026/10/a.pdf')).toBe('file') + }) +}) + +describe('transcode settings', () => { + it('defaults to one concurrent job with one thread', () => { + expect(DEFAULT_TRANSCODE_CONCURRENCY).toBe(1) + expect(DEFAULT_TRANSCODE_THREADS).toBe(1) + expect(getTranscodeSettings()).toEqual({ + concurrency: 1, + threads: 1, + preset: 'veryfast', + bitratePercent: 80 + }) + }) + + it('round-trips stored values', () => { + setTranscodeSettings({ concurrency: 3, threads: 2 }) + expect(getTranscodeSettings()).toEqual({ + concurrency: 3, + threads: 2, + preset: 'veryfast', + bitratePercent: 80 + }) + }) + + it('falls back to the default when the stored value is out of range', () => { + setTranscodeSettings({ concurrency: 99 }) + expect(getTranscodeConcurrency()).toBe(DEFAULT_TRANSCODE_CONCURRENCY) + + setTranscodeSettings({ concurrency: 1, threads: 1 }) + }) + + it('stores the quality preset and rejects unknown ones', () => { + setTranscodeSettings({ preset: 'slow' }) + expect(getTranscodeSettings().preset).toBe('slow') + + expect(parseTranscodePreset('nope')).toBeNull() + setTranscodeSettings({ preset: DEFAULT_TRANSCODE_PRESET }) + }) +}) diff --git a/server/utils/transcode.ts b/server/utils/transcode.ts new file mode 100644 index 0000000..8da4844 --- /dev/null +++ b/server/utils/transcode.ts @@ -0,0 +1,712 @@ +import { spawn } from 'node:child_process' +import type { ChildProcess } from 'node:child_process' +import { createWriteStream, promises as fs } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { pipeline } from 'node:stream/promises' +import { getDataDir } from './data-dir' +import { + listAllImageIndexRows, + listPendingTranscodeKeys, + listTranscodedSourceRows, + updateImageTranscode +} from './image-index' +import { + VIDEO_FOLDER, + extensionFromFilename, + isVideoSidecarKey, + validateStorageKey, + validateVideoKey +} from './image-key' +import { + createImageStream, + deleteImage, + headImage +} from './storage' +import { getBackendForKey } from './storage/resolver' +import { logException, logInfo } from './logger' +import { + getSetting, + SETTINGS_TRANSCODE_CONCURRENCY, + SETTINGS_TRANSCODE_BITRATE, + SETTINGS_TRANSCODE_PRESET, + SETTINGS_TRANSCODE_THREADS, + setSetting +} from './db' + +/** HLS 分片时长(秒) */ +const HLS_SEGMENT_TIME = 6 + +export const DEFAULT_TRANSCODE_CONCURRENCY = 1 +export const DEFAULT_TRANSCODE_THREADS = 1 +export const MAX_TRANSCODE_CONCURRENCY = 8 +export const MAX_TRANSCODE_THREADS = 64 + +/** + * 转码档位:直接封装(不重编码)/ 速度 / 平衡 / 质量。 + * 注意 CRF 模式下 preset 越慢画质越好、体积也越大。 + */ +export const TRANSCODE_PRESETS = ['copy', 'veryfast', 'medium', 'slow', 'compress'] as const +export type TranscodePreset = (typeof TRANSCODE_PRESETS)[number] +export const DEFAULT_TRANSCODE_PRESET: TranscodePreset = 'veryfast' + +/** 「压缩」档的目标码率占源码率的百分比 */ +export const DEFAULT_TRANSCODE_BITRATE_PERCENT = 80 +export const MIN_TRANSCODE_BITRATE_PERCENT = 10 +export const MAX_TRANSCODE_BITRATE_PERCENT = 100 + +export function getTranscodeBitratePercent(): number { + return parseBoundedInt( + getSetting(SETTINGS_TRANSCODE_BITRATE), + DEFAULT_TRANSCODE_BITRATE_PERCENT, + MIN_TRANSCODE_BITRATE_PERCENT, + MAX_TRANSCODE_BITRATE_PERCENT + ) +} + +export function parseTranscodePreset(raw: string | null | undefined): TranscodePreset | null { + const value = raw?.trim() + if (!value) return null + return (TRANSCODE_PRESETS as readonly string[]).includes(value) + ? value as TranscodePreset + : null +} + +function parseBoundedInt( + raw: string | null, + fallback: number, + min: number, + max: number +): number { + const value = Number(raw) + if (!Number.isInteger(value) || value < min || value > max) return fallback + return value +} + +export interface TranscodeSettings { + concurrency: number + threads: number + preset: TranscodePreset + bitratePercent: number +} + +/** 同时进行的转码任务数,默认 1(串行) */ +export function getTranscodeConcurrency(): number { + return parseBoundedInt( + getSetting(SETTINGS_TRANSCODE_CONCURRENCY), + DEFAULT_TRANSCODE_CONCURRENCY, + 1, + MAX_TRANSCODE_CONCURRENCY + ) +} + +/** 每个转码任务使用的 CPU 核心数,默认 1 */ +export function getTranscodeThreads(): number { + return parseBoundedInt( + getSetting(SETTINGS_TRANSCODE_THREADS), + DEFAULT_TRANSCODE_THREADS, + 1, + MAX_TRANSCODE_THREADS + ) +} + +export function getTranscodeSettings(): TranscodeSettings { + return { + concurrency: getTranscodeConcurrency(), + threads: getTranscodeThreads(), + preset: parseTranscodePreset(getSetting(SETTINGS_TRANSCODE_PRESET)) ?? DEFAULT_TRANSCODE_PRESET, + bitratePercent: getTranscodeBitratePercent() + } +} + +export function setTranscodeSettings(patch: { + concurrency?: number + threads?: number + preset?: TranscodePreset + bitratePercent?: number +}): TranscodeSettings { + if (patch.concurrency !== undefined) { + setSetting(SETTINGS_TRANSCODE_CONCURRENCY, String(patch.concurrency)) + } + if (patch.threads !== undefined) { + setSetting(SETTINGS_TRANSCODE_THREADS, String(patch.threads)) + } + if (patch.preset !== undefined) { + setSetting(SETTINGS_TRANSCODE_PRESET, patch.preset) + } + if (patch.bitratePercent !== undefined) { + setSetting(SETTINGS_TRANSCODE_BITRATE, String(patch.bitratePercent)) + } + return getTranscodeSettings() +} + +function stripExtension(key: string): string { + const slash = key.lastIndexOf('/') + const dot = key.lastIndexOf('.') + return dot > slash ? key.slice(0, dot) : key +} + +function extensionOf(key: string): string { + const dot = key.lastIndexOf('.') + return dot > 0 ? key.slice(dot + 1) : 'mp4' +} + +/** 视频 key 对应的 HLS 播放列表 key(去扩展名后作为目录) */ +export function hlsPlaylistKey(videoKey: string): string { + return `${stripExtension(videoKey)}/index.m3u8` +} + +/** 视频 key 对应的封面缩略图 key(同目录同名,扩展名换成 webp) */ +export function thumbKeyFor(videoKey: string): string { + return `${stripExtension(videoKey)}.webp` +} + +function runThumbFfmpeg(inputPath: string, outputPath: string, seek: number): Promise { + return new Promise((resolve, reject) => { + const child = spawn('ffmpeg', [ + '-hide_banner', + '-loglevel', 'error', + '-y', + '-ss', String(seek), + '-i', inputPath, + '-frames:v', '1', + '-vf', 'scale=640:-2', + '-c:v', 'libwebp', + '-quality', '80', + 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}`)) + }) + }) +} + +/** + * 抽取视频封面并写入存储,返回缩略图 key。 + * 优先取第 1 秒的画面,短视频或抽取失败时退回第一帧。 + */ +export async function generateVideoThumbnail(videoKey: string): Promise { + const stored = await headImage(videoKey) + if (!stored) return null + + const workDir = join(tmpdir(), `pichost-thumb-${randomUUID()}`) + const inputPath = join(workDir, `input.${extensionOf(videoKey)}`) + const outputPath = join(workDir, 'thumb.webp') + + await fs.mkdir(workDir, { recursive: true }) + + try { + await streamToFile(await createImageStream(videoKey), inputPath) + + try { + await runThumbFfmpeg(inputPath, outputPath, 1) + } catch { + await runThumbFfmpeg(inputPath, outputPath, 0) + } + + const bytes = await fs.readFile(outputPath) + const thumbKey = thumbKeyFor(videoKey) + const backend = await getBackendForKey(videoKey) + + await backend.put(thumbKey, new Uint8Array(bytes), { + originalName: `${basename(thumbKey)}`, + uploadedAt: new Date().toISOString(), + contentType: 'image/webp', + size: bytes.byteLength, + userId: stored.userId ?? null, + mediaType: 'video' + }) + + return thumbKey + } catch (error) { + logException('video thumbnail failed', error, { key: videoKey }) + return null + } finally { + await fs.rm(workDir, { recursive: true, force: true }).catch(() => {}) + } +} + +/** 正在转码的视频 → ffmpeg 进程,用于删除时中断 */ +const runningProcesses = new Map() + +/** 读取视频总时长(秒);失败返回 0,表示无法计算进度 */ +function probeDuration(inputPath: string): Promise { + return new Promise((resolve) => { + const child = spawn('ffprobe', [ + '-v', 'error', + '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', + inputPath + ], { windowsHide: true }) + + let output = '' + child.stdout.on('data', (chunk) => { + output += String(chunk) + }) + child.on('error', () => resolve(0)) + child.on('close', () => { + const value = Number.parseFloat(output.trim()) + resolve(Number.isFinite(value) && value > 0 ? value : 0) + }) + }) +} + +/** 读取视频总码率(bps);失败返回 0 */ +function probeBitrate(inputPath: string): Promise { + return new Promise((resolve) => { + const child = spawn('ffprobe', [ + '-v', 'error', + '-show_entries', 'format=bit_rate', + '-of', 'default=noprint_wrappers=1:nokey=1', + inputPath + ], { windowsHide: true }) + + let output = '' + child.stdout.on('data', (chunk) => { + output += String(chunk) + }) + child.on('error', () => resolve(0)) + child.on('close', () => { + const value = Number.parseInt(output.trim(), 10) + resolve(Number.isFinite(value) && value > 0 ? value : 0) + }) + }) +} + +/** + * 读取音轨码率(bps);没有音轨或读不到时返回 0。 + * 音频目标码率会取「128k 与源音频码率」中的较小值,避免低码率音频被放大。 + */ +function probeAudioBitrate(inputPath: string): Promise { + return new Promise((resolve) => { + const child = spawn('ffprobe', [ + '-v', 'error', + '-select_streams', 'a:0', + '-show_entries', 'stream=bit_rate', + '-of', 'default=noprint_wrappers=1:nokey=1', + inputPath + ], { windowsHide: true }) + + let output = '' + child.stdout.on('data', (chunk) => { + output += String(chunk) + }) + child.on('error', () => resolve(0)) + child.on('close', () => { + const value = Number.parseInt(output.trim(), 10) + resolve(Number.isFinite(value) && value > 0 ? value : 0) + }) + }) +} + +/** 单码率 H.264 + AAC 切片,产出 index.m3u8 与 segNNN.ts */ +function runFfmpeg( + videoKey: string, + inputPath: string, + outputDir: string, + sourceBitrate: number, + sourceAudioBitrate: number, + onProgressUs: (outTimeUs: number) => void +): Promise { + const { preset, bitratePercent } = getTranscodeSettings() + + // 音频不放大也不压缩:跟随源音轨码率(读不到时才用 128k) + const audioBitrate = sourceAudioBitrate > 0 + ? sourceAudioBitrate + : 128_000 + const audioKbps = `${Math.round(audioBitrate / 1000)}k` + + let codecArgs: string[] + if (preset === 'copy') { + // 不重新编码:只把音视频流重新封装成 HLS 切片 + codecArgs = ['-c', 'copy', '-bsf:v', 'h264_mp4toannexb'] + } else if (preset === 'compress') { + // 目标「总码率」= 源码率 × 比例,再扣掉音频预算得到视频码率,避免总量反超 + const targetTotal = Math.round(sourceBitrate * bitratePercent / 100) + const targetVideo = Math.max(80_000, targetTotal - audioBitrate) + codecArgs = [ + '-c:v', 'libx264', + '-preset', 'medium', + '-b:v', String(targetVideo), + '-maxrate', String(Math.round(targetVideo * 1.2)), + '-bufsize', String(targetVideo * 2), + '-c:a', 'aac', + '-b:a', audioKbps + ] + } else { + // 恒定质量优先,但用「源码率 - 音频预算」卡住视频峰值,避免体积反超源文件 + const ceiling = sourceBitrate > audioBitrate ? sourceBitrate - audioBitrate : 0 + codecArgs = [ + '-c:v', 'libx264', + '-preset', preset, + '-crf', '23', + ...(ceiling + ? ['-maxrate', String(ceiling), '-bufsize', String(ceiling * 2)] + : []), + '-c:a', 'aac', + '-b:a', audioKbps + ] + } + + return new Promise((resolve, reject) => { + const child = spawn('ffmpeg', [ + '-hide_banner', + '-loglevel', 'error', + '-nostats', + '-progress', 'pipe:1', + '-y', + '-i', inputPath, + '-threads', String(getTranscodeThreads()), + ...codecArgs, + '-f', 'hls', + '-hls_time', String(HLS_SEGMENT_TIME), + '-hls_playlist_type', 'vod', + // 分片写相对名,避免播放列表里出现本机绝对路径 + '-hls_segment_filename', 'seg%03d.ts', + 'index.m3u8' + ], { cwd: outputDir, windowsHide: true }) + + runningProcesses.set(videoKey, child) + const release = () => { + runningProcesses.delete(videoKey) + } + + let stderr = '' + child.stderr.on('data', (chunk) => { + stderr += String(chunk) + }) + child.stdout.on('data', (chunk) => { + const match = /out_time_us=(\d+)/.exec(String(chunk)) + if (match?.[1]) onProgressUs(Number(match[1])) + }) + child.on('error', (error) => { + release() + reject(error) + }) + child.on('close', (code) => { + release() + if (code === 0) { + resolve() + return + } + const detail = stderr.trim().split('\n').filter(Boolean).pop() + reject(new Error(detail || `ffmpeg 退出码 ${code}`)) + }) + }) +} + +async function streamToFile(stream: NodeJS.ReadableStream, filePath: string): Promise { + 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 并写入存储,返回播放列表 key 与产物总大小 */ +export async function transcodeVideoToHls( + videoKey: string +): Promise<{ playlistKey: string, totalBytes: number }> { + const stored = await headImage(videoKey) + if (!stored) throw new Error('视频不存在') + + const workDir = join(tmpdir(), `pichost-hls-${randomUUID()}`) + const inputPath = join(workDir, `input.${extensionOf(videoKey)}`) + const outputDir = join(workDir, 'out') + const dirKey = stripExtension(videoKey) + const playlistKey = hlsPlaylistKey(videoKey) + + await fs.mkdir(outputDir, { recursive: true }) + + // 记录已写入存储的产物,中途失败时要逐个回收,避免留下残缺切片 + const writtenKeys: string[] = [] + const backend = await getBackendForKey(videoKey) + + try { + await streamToFile(await createImageStream(videoKey), inputPath) + const durationSec = await probeDuration(inputPath) + const sourceBitrate = await probeBitrate(inputPath) + 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)) + }) + }) + + const uploadedAt = new Date().toISOString() + + let totalBytes = 0 + for (const name of await fs.readdir(outputDir)) { + const bytes = await fs.readFile(join(outputDir, name)) + const objectKey = `${dirKey}/${name}` + await backend.put(objectKey, new Uint8Array(bytes), { + originalName: name, + uploadedAt, + contentType: name.endsWith('.m3u8') + ? 'application/vnd.apple.mpegurl' + : 'video/mp2t', + size: bytes.byteLength, + userId: stored.userId ?? null, + mediaType: 'video' + }) + writtenKeys.push(objectKey) + totalBytes += bytes.byteLength + } + + return { playlistKey, totalBytes } + } catch (error) { + for (const key of writtenKeys) { + await backend.delete(key).catch(() => {}) + } + if (backend.type === 'local') { + await fs.rmdir(join(getDataDir(), ...dirKey.split('/'))).catch(() => {}) + } + throw error + } finally { + await fs.rm(workDir, { recursive: true, force: true }).catch(() => {}) + } +} + +/** 删除视频对应的 HLS 播放列表与分片 */ +export async function removeHlsArtifacts(videoKey: string): Promise { + const playlistKey = hlsPlaylistKey(videoKey) + + // 封面缩略图与播放列表无关,必须先于提前返回处理 + await deleteImage(thumbKeyFor(videoKey)).catch(() => {}) + + if (!await headImage(playlistKey)) return + + 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)}` + if (!validateStorageKey(segmentKey)) continue + await deleteImage(segmentKey).catch(() => {}) + } + + await deleteImage(playlistKey).catch(() => {}) + + // 本地磁盘上把清空后的分片目录一并移除 + const backend = await getBackendForKey(videoKey) + if (backend.type === 'local') { + await fs.rmdir(join(getDataDir(), ...dirKey.split('/'))).catch(() => {}) + } +} + +const queue: string[] = [] +const queued = new Set() +let activeCount = 0 + +async function processOne(videoKey: string): Promise { + updateImageTranscode(videoKey, { status: 'running' }) + + try { + const { playlistKey, totalBytes } = await transcodeVideoToHls(videoKey) + + // 转码成功后源文件不再需要,直接删除以节省空间 + const backend = await getBackendForKey(videoKey) + await backend.delete(videoKey).catch(() => {}) + + updateImageTranscode(videoKey, { + status: 'done', + hlsKey: playlistKey, + progress: 100, + sourceDeleted: true, + size: totalBytes + }) + logInfo('transcode done', { key: videoKey, hlsKey: playlistKey }) + } catch (error) { + updateImageTranscode(videoKey, { status: 'failed' }) + logException('transcode failed', error, { key: videoKey }) + } +} + +/** 按设置的并发数消费队列(默认 1,即串行) */ +function drain(): void { + while (queue.length && activeCount < getTranscodeConcurrency()) { + const key = queue.shift()! + activeCount += 1 + + void processOne(key).finally(() => { + activeCount -= 1 + queued.delete(key) + drain() + }) + } +} + +export function enqueueTranscode(videoKey: string): void { + if (!validateVideoKey(videoKey) || queued.has(videoKey)) return + queued.add(videoKey) + queue.push(videoKey) + // 记录本视频使用的档位,便于上传页展示 + const preset = getTranscodeSettings().preset + updateImageTranscode(videoKey, { + status: 'pending', + // 压缩档带上百分比,例如 compress:80 + preset: preset === 'compress' + ? `compress:${getTranscodeBitratePercent()}` + : preset + }) + drain() +} + +/** 取消排队中或正在转码的任务(删除视频时调用) */ +export function cancelTranscode(videoKey: string): void { + const index = queue.indexOf(videoKey) + if (index >= 0) queue.splice(index, 1) + queued.delete(videoKey) + + const child = runningProcesses.get(videoKey) + if (child) { + runningProcesses.delete(videoKey) + child.kill() + } +} + +/** + * 补删转码完成后仍保留的源文件。 + * 仅当 HLS 播放列表确实存在时才删除,避免产物缺失时把视频删没。 + */ +export async function cleanupTranscodedSources(): Promise { + let removed = 0 + + for (const row of listTranscodedSourceRows()) { + try { + if (!await headImage(row.hlsKey)) continue + + const backend = await getBackendForKey(row.key) + await backend.delete(row.key).catch(() => {}) + updateImageTranscode(row.key, { status: 'done', sourceDeleted: true }) + removed += 1 + } catch (error) { + logException('cleanup transcoded source failed', error, { key: row.key }) + } + } + + return removed +} + +/** 启动时把上次中断的任务重新排队 */ +export function resumePendingTranscodes(): void { + for (const key of listPendingTranscodeKeys()) { + enqueueTranscode(key) + } +} + +/** + * 清理遗留的转码进程。 + * 开发时热重载会重建模块,队列里的旧 ffmpeg 仍在跑,必须按命令行特征回收。 + */ +export function killOrphanFfmpeg(): Promise { + return new Promise((resolve) => { + try { + const psScript = 'Get-CimInstance Win32_Process -Filter "Name=\'ffmpeg.exe\'" ' + + '| Where-Object { $_.CommandLine -like \'*pichost-hls-*\' } ' + + '| ForEach-Object { Stop-Process -Id $_.ProcessId -Force }' + + const child = process.platform === 'win32' + ? spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', psScript], { + windowsHide: true, + stdio: 'ignore' + }) + : spawn('pkill', ['-f', 'pichost-hls-'], { stdio: 'ignore' }) + + child.on('error', () => resolve()) + child.on('close', () => resolve()) + } catch { + resolve() + } + }) +} + +/** + * 清理没有对应主视频记录的附属文件(缩略图、HLS 播放列表与分片)。 + * 正常删除流程已经清理,这里兜底历史残留。 + */ +export async function purgeOrphanVideoAssets(): Promise { + const root = join(getDataDir(), VIDEO_FOLDER) + const owners = new Set( + listAllImageIndexRows() + .filter(row => row.key.startsWith(`${VIDEO_FOLDER}/`)) + .map(row => stripExtension(row.key)) + ) + + let removed = 0 + + async function walk(dir: string, keyPrefix: string): Promise { + let entries + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch { + return + } + + for (const entry of entries) { + const name = String(entry.name) + const key = `${keyPrefix}/${name}` + + if (entry.isDirectory()) { + await walk(join(dir, name), key) + // 附属文件清空后的空目录一并移除 + await fs.rmdir(join(dir, name)).catch(() => {}) + continue + } + + if (!isVideoSidecarKey(key)) continue + + const extension = extensionFromFilename(name) + // 缩略图与视频同名前缀;播放列表和分片归属所在目录 + const owner = ['webp', 'jpg', 'jpeg', 'png'].includes(extension) + ? stripExtension(key) + : keyPrefix + + if (owners.has(owner)) continue + + await fs.rm(join(dir, name), { force: true }).catch(() => {}) + removed += 1 + } + } + + await walk(root, VIDEO_FOLDER) + return removed +} + +/** 进程退出时尽量带走正在运行的 ffmpeg,避免留下孤儿进程 */ +function killRunningProcesses(): void { + for (const child of runningProcesses.values()) { + child.kill() + } + runningProcesses.clear() +} + +process.on('exit', killRunningProcesses) +process.on('SIGINT', killRunningProcesses) +process.on('SIGTERM', killRunningProcesses)