- 视频上传生成缩略图并提供占位封面和失败原因展示 - 支持 HLS 按需重新封装 MP4,并限制临时文件生命周期与访问权限 - 完善视频、文件 API 文档、搜索、批量删除和单次转码质量参数 - 修复备份恢复与跨后端迁移遗漏缩略图、HLS 清单和分片的问题 - 将备份任务、归档和下载权限按用户隔离 - 增加仅清理当前用户数据的高危存储清空流程和二次文字确认 - 修复 S3 中文文件名元数据及 CLI 数据库连接问题 - 补充备份恢复、用户隔离、媒体资源和转码相关测试
144 lines
4.6 KiB
TypeScript
144 lines
4.6 KiB
TypeScript
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'
|
|
|
|
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('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')
|
|
expect(mediaTypeForKey('videos/2026/10/a.mp4')).toBe('video')
|
|
expect(mediaTypeForKey('files/2026/10/a.pdf')).toBe('file')
|
|
})
|
|
})
|
|
|
|
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)
|
|
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 })
|
|
})
|
|
})
|