feat: 完善媒体管理与备份迁移流程

- 视频上传生成缩略图并提供占位封面和失败原因展示
- 支持 HLS 按需重新封装 MP4,并限制临时文件生命周期与访问权限
- 完善视频、文件 API 文档、搜索、批量删除和单次转码质量参数
- 修复备份恢复与跨后端迁移遗漏缩略图、HLS 清单和分片的问题
- 将备份任务、归档和下载权限按用户隔离
- 增加仅清理当前用户数据的高危存储清空流程和二次文字确认
- 修复 S3 中文文件名元数据及 CLI 数据库连接问题
- 补充备份恢复、用户隔离、媒体资源和转码相关测试
This commit is contained in:
tianmo committed 2026-10-17 13:46:02 +08:00
1 parent 9ebab8270c
commit a64be50b73
60 files changed
+2703 -325

No files matched your search

+194 -37
View File
@@ -5,10 +5,11 @@ import { randomUUID } from 'node:crypto'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { pipeline } from 'node:stream/promises'
import sharp from 'sharp'
import { getDataDir } from './data-dir'
import {
listAllImageIndexRows,
listPendingTranscodeKeys,
listPendingTranscodeRows,
listTranscodedSourceRows,
updateImageTranscode
} from './image-index'
@@ -37,6 +38,9 @@ import {
/** HLS 分片时长(秒) */
const HLS_SEGMENT_TIME = 6
const HLS_DOWNLOAD_PREFIX = 'pichost-hls-download-'
export const HLS_DOWNLOAD_RETENTION_MS = 60 * 60 * 1000
const HLS_DOWNLOAD_MARKER = '.expires'
export const DEFAULT_TRANSCODE_CONCURRENCY = 1
export const DEFAULT_TRANSCODE_THREADS = 1
@@ -56,6 +60,21 @@ export const DEFAULT_TRANSCODE_BITRATE_PERCENT = 80
export const MIN_TRANSCODE_BITRATE_PERCENT = 10
export const MAX_TRANSCODE_BITRATE_PERCENT = 100
export interface TranscodeJobOptions {
preset: TranscodePreset
bitratePercent: number
}
/** 将底层错误归纳成适合界面展示的简短原因 */
export function summarizeTranscodeFailure(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
if (/spawn ffmpeg ENOENT|ffmpeg.*(not found|ENOENT)|ENOENT.*ffmpeg/i.test(message)) {
return 'ffmpeg-missing'
}
if (/permission denied/i.test(message)) return 'permission-denied'
return 'unknown'
}
export function getTranscodeBitratePercent(): number {
return parseBoundedInt(
getSetting(SETTINGS_TRANSCODE_BITRATE),
@@ -84,6 +103,21 @@ function parseBoundedInt(
return value
}
function resolveTranscodeJobOptions(
options: Partial<TranscodeJobOptions> = {}
): TranscodeJobOptions {
const settings = getTranscodeSettings()
const bitratePercent = Number.isInteger(options.bitratePercent)
&& options.bitratePercent! >= MIN_TRANSCODE_BITRATE_PERCENT
&& options.bitratePercent! <= MAX_TRANSCODE_BITRATE_PERCENT
? options.bitratePercent!
: settings.bitratePercent
return {
preset: options.preset ?? settings.preset,
bitratePercent
}
}
export interface TranscodeSettings {
concurrency: number
threads: number
@@ -162,6 +196,19 @@ export function thumbKeyFor(videoKey: string): string {
return `${stripExtension(videoKey)}.webp`
}
/** HLS 播放列表中的相对分片文件名 */
export function hlsSegmentNames(playlistText: string): string[] {
const names = new Set<string>()
for (const line of playlistText.split('\n')) {
const name = line.trim()
if (!name || name.startsWith('#') || name === '.' || name === '..' || /[/\\]/.test(name)) {
continue
}
names.add(name)
}
return [...names]
}
function runThumbFfmpeg(inputPath: string, outputPath: string, seek: number): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn('ffmpeg', [
@@ -172,8 +219,7 @@ function runThumbFfmpeg(inputPath: string, outputPath: string, seek: number): Pr
'-i', inputPath,
'-frames:v', '1',
'-vf', 'scale=640:-2',
'-c:v', 'libwebp',
'-quality', '80',
'-c:v', 'png',
outputPath
], { windowsHide: true })
@@ -203,6 +249,7 @@ export async function generateVideoThumbnail(videoKey: string): Promise<string |
const workDir = join(tmpdir(), `pichost-thumb-${randomUUID()}`)
const inputPath = join(workDir, `input.${extensionOf(videoKey)}`)
const framePath = join(workDir, 'thumb.png')
const outputPath = join(workDir, 'thumb.webp')
await fs.mkdir(workDir, { recursive: true })
@@ -211,11 +258,12 @@ export async function generateVideoThumbnail(videoKey: string): Promise<string |
await streamToFile(await createImageStream(videoKey), inputPath)
try {
await runThumbFfmpeg(inputPath, outputPath, 1)
await runThumbFfmpeg(inputPath, framePath, 1)
} catch {
await runThumbFfmpeg(inputPath, outputPath, 0)
await runThumbFfmpeg(inputPath, framePath, 0)
}
await sharp(framePath).webp({ quality: 80 }).toFile(outputPath)
const bytes = await fs.readFile(outputPath)
const thumbKey = thumbKeyFor(videoKey)
const backend = await getBackendForKey(videoKey)
@@ -318,9 +366,10 @@ function runFfmpeg(
outputDir: string,
sourceBitrate: number,
sourceAudioBitrate: number,
options: TranscodeJobOptions,
onProgressUs: (outTimeUs: number) => void
): Promise<void> {
const { preset, bitratePercent } = getTranscodeSettings()
const { preset, bitratePercent } = options
// 音频不放大也不压缩:跟随源音轨码率(读不到时才用 128k)
const audioBitrate = sourceAudioBitrate > 0
@@ -419,12 +468,96 @@ async function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
return Buffer.concat(chunks).toString('utf8')
}
function remuxPlaylistToMp4(inputPath: string, outputPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn('ffmpeg', [
'-hide_banner',
'-loglevel', 'error',
'-y',
'-i', inputPath,
'-c', 'copy',
'-movflags', '+faststart',
outputPath
], { windowsHide: true })
let stderr = ''
child.stderr.on('data', (chunk) => {
stderr += String(chunk)
})
child.on('error', reject)
child.on('close', (code) => {
if (code === 0) {
resolve()
return
}
const detail = stderr.trim().split('\n').filter(Boolean).pop()
reject(new Error(detail || `ffmpeg 退出码 ${code}`))
})
})
}
/** 下载 HLS 清单和分片到临时目录,不转码,重新封装为 MP4 */
export async function remuxHlsToMp4(playlistKey: string, workDir: string): Promise<string> {
if (!validateStorageKey(playlistKey) || !playlistKey.endsWith('.m3u8')) {
throw new Error('无效的 HLS 播放列表')
}
const playlistText = await streamToString(await createImageStream(playlistKey))
const segmentNames = hlsSegmentNames(playlistText)
if (!segmentNames.length) throw new Error('HLS 播放列表没有视频分片')
const dirKey = playlistKey.slice(0, playlistKey.lastIndexOf('/'))
await fs.mkdir(workDir, { recursive: true })
await fs.writeFile(join(workDir, 'index.m3u8'), playlistText)
for (const name of segmentNames) {
const segmentKey = `${dirKey}/${name}`
if (!validateStorageKey(segmentKey)) throw new Error('HLS 分片路径无效')
await streamToFile(await createImageStream(segmentKey), join(workDir, name))
}
const outputPath = join(workDir, 'video.mp4')
await remuxPlaylistToMp4(join(workDir, 'index.m3u8'), outputPath)
return outputPath
}
/** 标记 HLS 下载临时文件,满一小时后允许清理 */
export async function markHlsDownloadForCleanup(
workDir: string,
expiresAt = Date.now() + HLS_DOWNLOAD_RETENTION_MS
): Promise<void> {
await fs.writeFile(join(workDir, HLS_DOWNLOAD_MARKER), String(expiresAt))
}
/** 删除已经到达过期时间的 HLS 下载临时目录 */
export async function cleanupExpiredHlsDownloads(now = Date.now()): Promise<number> {
let removed = 0
try {
const entries = await fs.readdir(tmpdir(), { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith(HLS_DOWNLOAD_PREFIX)) continue
const path = join(tmpdir(), entry.name)
const expiresAt = Number(
await fs.readFile(join(path, HLS_DOWNLOAD_MARKER), 'utf8').catch(() => '')
)
if (!Number.isFinite(expiresAt) || expiresAt > now) continue
await fs.rm(path, { recursive: true, force: true })
removed++
}
} catch {
// 清理失败不影响新下载
}
return removed
}
/** 把视频转成 HLS 并写入存储,返回播放列表 key 与产物总大小 */
export async function transcodeVideoToHls(
videoKey: string
videoKey: string,
jobOptions?: Partial<TranscodeJobOptions>
): Promise<{ playlistKey: string, totalBytes: number }> {
const stored = await headImage(videoKey)
if (!stored) throw new Error('视频不存在')
const options = resolveTranscodeJobOptions(jobOptions)
const workDir = join(tmpdir(), `pichost-hls-${randomUUID()}`)
const inputPath = join(workDir, `input.${extensionOf(videoKey)}`)
@@ -445,17 +578,25 @@ export async function transcodeVideoToHls(
const sourceAudioBitrate = await probeAudioBitrate(inputPath)
let lastProgressAt = 0
await runFfmpeg(videoKey, inputPath, outputDir, sourceBitrate, sourceAudioBitrate, (outTimeUs) => {
if (!durationSec) return
const now = Date.now()
if (now - lastProgressAt < 1000) return
lastProgressAt = now
const percent = Math.round((outTimeUs / (durationSec * 1_000_000)) * 100)
updateImageTranscode(videoKey, {
status: 'running',
progress: Math.min(99, Math.max(1, percent))
})
})
await runFfmpeg(
videoKey,
inputPath,
outputDir,
sourceBitrate,
sourceAudioBitrate,
options,
(outTimeUs) => {
if (!durationSec) return
const now = Date.now()
if (now - lastProgressAt < 1000) return
lastProgressAt = now
const percent = Math.round((outTimeUs / (durationSec * 1_000_000)) * 100)
updateImageTranscode(videoKey, {
status: 'running',
progress: Math.min(99, Math.max(1, percent))
})
}
)
const uploadedAt = new Date().toISOString()
@@ -503,10 +644,8 @@ export async function removeHlsArtifacts(videoKey: string): Promise<void> {
const dirKey = stripExtension(videoKey)
const text = await streamToString(await createImageStream(playlistKey))
for (const line of text.split('\n')) {
const name = line.trim()
if (!name || name.startsWith('#')) continue
const segmentKey = `${dirKey}/${basename(name)}`
for (const name of hlsSegmentNames(text)) {
const segmentKey = `${dirKey}/${name}`
if (!validateStorageKey(segmentKey)) continue
await deleteImage(segmentKey).catch(() => {})
}
@@ -520,15 +659,20 @@ export async function removeHlsArtifacts(videoKey: string): Promise<void> {
}
}
const queue: string[] = []
interface QueuedTranscode extends TranscodeJobOptions {
videoKey: string
}
const queue: QueuedTranscode[] = []
const queued = new Set<string>()
let activeCount = 0
async function processOne(videoKey: string): Promise<void> {
async function processOne(job: QueuedTranscode): Promise<void> {
const { videoKey } = job
updateImageTranscode(videoKey, { status: 'running' })
try {
const { playlistKey, totalBytes } = await transcodeVideoToHls(videoKey)
const { playlistKey, totalBytes } = await transcodeVideoToHls(videoKey, job)
// 转码成功后源文件不再需要,直接删除以节省空间
const backend = await getBackendForKey(videoKey)
@@ -543,7 +687,10 @@ async function processOne(videoKey: string): Promise<void> {
})
logInfo('transcode done', { key: videoKey, hlsKey: playlistKey })
} catch (error) {
updateImageTranscode(videoKey, { status: 'failed' })
updateImageTranscode(videoKey, {
status: 'failed',
error: summarizeTranscodeFailure(error)
})
logException('transcode failed', error, { key: videoKey })
}
}
@@ -551,10 +698,11 @@ async function processOne(videoKey: string): Promise<void> {
/** 按设置的并发数消费队列(默认 1,即串行) */
function drain(): void {
while (queue.length && activeCount < getTranscodeConcurrency()) {
const key = queue.shift()!
const job = queue.shift()!
const key = job.videoKey
activeCount += 1
void processOne(key).finally(() => {
void processOne(job).finally(() => {
activeCount -= 1
queued.delete(key)
drain()
@@ -562,25 +710,28 @@ function drain(): void {
}
}
export function enqueueTranscode(videoKey: string): void {
export function enqueueTranscode(
videoKey: string,
options: Partial<TranscodeJobOptions> = {}
): void {
if (!validateVideoKey(videoKey) || queued.has(videoKey)) return
const job = resolveTranscodeJobOptions(options)
queued.add(videoKey)
queue.push(videoKey)
queue.push({ videoKey, ...job })
// 记录本视频使用的档位,便于上传页展示
const preset = getTranscodeSettings().preset
updateImageTranscode(videoKey, {
status: 'pending',
// 压缩档带上百分比,例如 compress:80
preset: preset === 'compress'
? `compress:${getTranscodeBitratePercent()}`
: preset
preset: job.preset === 'compress'
? `compress:${job.bitratePercent}`
: job.preset
})
drain()
}
/** 取消排队中或正在转码的任务(删除视频时调用) */
export function cancelTranscode(videoKey: string): void {
const index = queue.indexOf(videoKey)
const index = queue.findIndex(job => job.videoKey === videoKey)
if (index >= 0) queue.splice(index, 1)
queued.delete(videoKey)
@@ -616,8 +767,14 @@ export async function cleanupTranscodedSources(): Promise<number> {
/** 启动时把上次中断的任务重新排队 */
export function resumePendingTranscodes(): void {
for (const key of listPendingTranscodeKeys()) {
enqueueTranscode(key)
for (const row of listPendingTranscodeRows()) {
const [presetName, bitrateRaw] = (row.preset ?? '').split(':')
const preset = parseTranscodePreset(presetName)
const bitratePercent = Number(bitrateRaw)
enqueueTranscode(row.key, {
...(preset ? { preset } : {}),
...(Number.isInteger(bitratePercent) ? { bitratePercent } : {})
})
}
}