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

+57
View File
@@ -1,13 +1,20 @@
import { promises as fs } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
cleanupExpiredHlsDownloads,
DEFAULT_TRANSCODE_CONCURRENCY,
DEFAULT_TRANSCODE_PRESET,
DEFAULT_TRANSCODE_THREADS,
getTranscodeConcurrency,
getTranscodeSettings,
hlsSegmentNames,
hlsPlaylistKey,
markHlsDownloadForCleanup,
parseTranscodePreset,
setTranscodeSettings,
summarizeTranscodeFailure,
thumbKeyFor
} from './transcode'
import { isVideoSidecarKey, mediaTypeForKey } from './image-key'
@@ -39,6 +46,44 @@ describe('isVideoSidecarKey', () => {
})
})
describe('hlsSegmentNames', () => {
it('returns unique relative segment names and ignores tags', () => {
expect(hlsSegmentNames([
'#EXTM3U',
'#EXTINF:6,',
'seg000.ts',
'#EXTINF:6,',
'seg001.ts',
'seg000.ts'
].join('\n'))).toEqual(['seg000.ts', 'seg001.ts'])
})
it('ignores absolute and traversal paths', () => {
expect(hlsSegmentNames([
'../secret.ts',
'https://example.com/video.ts',
'folder/seg.ts',
'seg.ts'
].join('\n'))).toEqual(['seg.ts'])
})
})
describe('cleanupExpiredHlsDownloads', () => {
it('keeps fresh downloads and removes files after their marker expires', async () => {
const now = Date.now()
const fresh = await fs.mkdtemp(join(tmpdir(), 'pichost-hls-download-fresh-'))
const expired = await fs.mkdtemp(join(tmpdir(), 'pichost-hls-download-expired-'))
await markHlsDownloadForCleanup(fresh, now + 60_000)
await markHlsDownloadForCleanup(expired, now - 1)
await cleanupExpiredHlsDownloads(now)
await expect(fs.access(fresh)).resolves.toBeUndefined()
await expect(fs.access(expired)).rejects.toThrow()
await fs.rm(fresh, { recursive: true, force: true })
})
})
describe('mediaTypeForKey', () => {
it('maps folders to media types', () => {
expect(mediaTypeForKey('images/2026/10/a.webp')).toBe('image')
@@ -47,6 +92,18 @@ describe('mediaTypeForKey', () => {
})
})
describe('summarizeTranscodeFailure', () => {
it('recognizes a missing FFmpeg binary', () => {
expect(summarizeTranscodeFailure(new Error('spawn ffmpeg ENOENT')))
.toBe('ffmpeg-missing')
})
it('maps other FFmpeg errors to unknown', () => {
const error = new Error(`first line\n${'invalid data '.repeat(20)}`)
expect(summarizeTranscodeFailure(error)).toBe('unknown')
})
})
describe('transcode settings', () => {
it('defaults to one concurrent job with one thread', () => {
expect(DEFAULT_TRANSCODE_CONCURRENCY).toBe(1)