Files
FileHost/server/utils/transcode.test.ts
T

157 lines
5.1 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,
buildThumbnailFfmpegArgs,
DEFAULT_TRANSCODE_CONCURRENCY,
DEFAULT_TRANSCODE_PRESET,
DEFAULT_TRANSCODE_THREADS,
getTranscodeConcurrency,
getTranscodeSettings,
hlsSegmentNames,
hlsPlaylistKey,
markHlsDownloadForCleanup,
parseTranscodePreset,
setTranscodeSettings,
summarizeTranscodeFailure,
thumbKeyFor,
THUMBNAIL_FFMPEG_TIMEOUT_MS
} 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('thumbnail ffmpeg args', () => {
it('limits thumbnail extraction to one worker and one filter thread', () => {
const args = buildThumbnailFfmpegArgs('/tmp/input.mp4', '/tmp/thumb.png', 1)
expect(args.slice(args.indexOf('-threads'), args.indexOf('-threads') + 2))
.toEqual(['-threads', '1'])
expect(args.slice(args.indexOf('-filter_threads'), args.indexOf('-filter_threads') + 2))
.toEqual(['-filter_threads', '1'])
expect(THUMBNAIL_FFMPEG_TIMEOUT_MS).toBe(10_000)
})
})
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 })
})
})