feat: 新增视频床与文件床,视频支持 HLS 转码
ci / ci (22, ubuntu-latest) (push) Canceled after 0s

文件床
- 新增 files/ 存储前缀与 media_type 索引列,图片、视频、文件三类资源独立
- 新增文件上传、列表、删除、批量删除接口,禁止 html/htm/svg
- 支持公开直链与 Token 访问两种模式,后台可切换,直链强制附件下载

视频床
- 新增 videos/ 存储前缀与视频上传接口,单文件上限 500MB
- 接入 ffmpeg 转码队列,并发数与单任务核心数可配,默认串行
- 五档转码质量:直接封装 / 速度 / 平衡 / 质量 / 压缩(压缩档可调码率比例)
- 码率控制避免转码后体积反超源文件,音频跟随源音轨不放大不压缩
- 转码进度实时上报,成功后删除源文件,失败时保留源文件与可用链接
- 上传时自动抽帧生成封面,视频与封面各有独立链接
- 删除视频时中断转码并清理切片、封面与源文件

媒体库
- 图库、视频库、文件库三套独立统计,含趋势、占用与来源分布
- 统一筛选、多选、批量删除、批量标签、网格/列表切换,显示方式按类型分别记忆
- 视频详情对齐图片详情,视频库支持在线播放(hls.js,兼容回退原文件)

修复
- 清理无主的切片、封面与开发期遗留的 ffmpeg 进程
- 源文件按设计删除后,索引不再被启动孤儿清理误删
- 视频统计口径与列表一致,转码中的视频不计入统计
- 转码失败时回收已写入的部分切片,避免残留
- 设置页停用检查更新(与上游版本号已不对应)

其他
- Docker 镜像安装 ffmpeg
- git remote 指向内网仓库
This commit is contained in:
Yeomai committed 2026-10-17 03:14:57 +08:00
1 parent 05cc3895f1
commit 89ea2fb25d
61 files changed
+6637 -154

No files matched your search

+81
View File
@@ -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<BatchDeleteBody>(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
})
+77
View File
@@ -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<DeleteBody>(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)
}
})
+141
View File
@@ -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
})
+79
View File
@@ -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
})
+2 -2
View File
@@ -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)
) {
+41
View File
@@ -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
}
}
})
+11
View File
@@ -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',
@@ -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
}
})
@@ -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<TranscodeSettingsPatch>(event).catch(
(): TranscodeSettingsPatch => ({})
)
const full: Parameters<typeof setTranscodeSettings>[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)
})
+94
View File
@@ -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
})
+38 -1
View File
@@ -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)
}
+18
View File
@@ -286,3 +286,21 @@ export async function getUploadUserId(
return null
}
/** 文件床直链在 Token 模式下的校验:Auth-Token 头、?token= 查询参数或已登录会话 */
export async function isFileAccessAuthorized(event: H3Event): Promise<boolean> {
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)
}
+30
View File
@@ -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'
+29 -1
View File
@@ -1,3 +1,31 @@
/** 常见文件床类型的扩展名推断(仅在索引缺失元数据时使用) */
const EXTRA_TYPES: Record<string, string> = {
'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'
}
}
+5
View File
@@ -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'
+17
View File
@@ -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),
+178
View File
@@ -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<c>.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')
})
})
+310
View File
@@ -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
}
+228 -23
View File
@@ -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<number> {
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<number> {
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<string[]> {
const imagesRoot = join(getDataDir(), DEFAULT_FOLDER)
return collectImageKeysRecursive(imagesRoot, DEFAULT_FOLDER)
/** 扫描本地磁盘的 images/、files/ 与 videos/ 目录 */
async function listLocalStorageKeys(): Promise<string[]> {
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<number> {
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<number> {
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<string | number> = [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<number> {
ensureStorageSchema()
const params: Array<string | number> = []
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<PaginatedResult<StoredImage>> {
ensureStorageSchema()
const params: Array<string | number> = []
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<PaginatedResult<StoredImage>> {
ensureStorageSchema()
const needle = options.query.trim().toLowerCase()
const params: Array<string | number> = []
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<StoredImage[]> {
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[]
+128
View File
@@ -1,3 +1,4 @@
import { VIDEO_EXTENSIONS } from './constants'
import type { AllowedMimeType } from './constants'
const EXT_MAP: Record<AllowedMimeType, string> = {
@@ -10,11 +11,16 @@ const EXT_MAP: Record<AllowedMimeType, string> = {
}
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
+3 -1
View File
@@ -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
}
+50 -5
View File
@@ -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,
+160
View File
@@ -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 {
+30 -13
View File
@@ -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<boolean> {
export async function resolveStorageImageKey(key: string): Promise<string | null> {
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<StoredImage | null> {
}
}
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<PaginatedResult<StoredImage>> {
return listImagesFromIndex(options)
}
@@ -227,6 +242,8 @@ export async function searchImages(options: {
contentType?: string
uploadSource?: 'web' | 'api'
tagFilter?: TagFilterInput
mediaType?: MediaType
transcodeStatuses?: TranscodeStatus[]
}): Promise<PaginatedResult<StoredImage>> {
return searchImagesFromIndex(options)
}
+2 -2
View File
@@ -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<StoredImage | null> {
if (!validateImageKey(key)) return null
if (!validateStorageKey(key)) return null
let stat
try {
+2 -2
View File
@@ -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<StoredImage | null> {
if (!validateImageKey(key)) return null
if (!validateStorageKey(key)) return null
try {
const response = await this.client.send(new HeadObjectCommand({
+3 -3
View File
@@ -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<void> {
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<StoredImage | null> {
if (!validateImageKey(key)) return null
if (!validateStorageKey(key)) return null
const indexed = getImageIndexAsStored(key)
if (!indexed || indexed.backendId !== this.id) {
+17
View File
@@ -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<T> {
+86
View File
@@ -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 })
})
})
+712
View File
@@ -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<void> {
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<string | null> {
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<string, ChildProcess>()
/** 读取视频总时长(秒);失败返回 0,表示无法计算进度 */
function probeDuration(inputPath: string): Promise<number> {
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<number> {
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<number> {
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<void> {
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<void> {
await pipeline(stream, createWriteStream(filePath))
}
async function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
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<void> {
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<string>()
let activeCount = 0
async function processOne(videoKey: string): Promise<void> {
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<number> {
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<void> {
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<number> {
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<void> {
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)