Files
video-downloader/worker/__tests__/processor.test.ts
T
anthonyandClaude Sonnet 5 27ba48d181 feat: gate download options on real-time yt-dlp video analysis
Options (quality, subtitles) are now derived from a yt-dlp -J probe of
the submitted URL instead of a static list, so users can't pick
combinations the source video doesn't actually support. Submission is
blocked until the probe succeeds.

- New /api/probe endpoint + src/lib/ytdlp-probe.ts, with its own rate
  limiter (rate-limit.ts refactored into a createRateLimiter factory).
- New options: subtitle language selection, clip start/end trim, MP3
  audio quality.
- Fixed buildYtdlpArgs: format=mp3 never triggered audio extraction
  (-x/--audio-format), and quality=best never applied --merge-output-format,
  so the chosen container had no real effect.
- Cross-platform yt-dlp invocation: the bundled bin/yt-dlp is a Python
  zipapp relying on a shebang, which Windows' spawn() can't run
  directly. buildYtdlpCommand() runs it through `python` on Windows and
  execs it directly on Linux/o2switch, where the shebang works natively.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 14:13:38 +02:00

94 lines
2.7 KiB
TypeScript

import { EventEmitter } from 'events'
import { processDownload } from '../processor'
import { prisma } from '@/lib/prisma'
import * as tokenLib from '@/lib/token'
import * as cp from 'child_process'
jest.mock('@/lib/prisma', () => ({
prisma: {
download: { findUnique: jest.fn(), update: jest.fn() },
},
}))
jest.mock('@/lib/token')
jest.mock('child_process')
jest.mock('fs', () => ({
existsSync: jest.fn(() => false),
readdirSync: jest.fn(() => ['uuid-abc.mp4']),
statSync: jest.fn(() => ({ size: 1024 })),
}))
const mockFindUnique = prisma.download.findUnique as jest.Mock
const mockUpdate = prisma.download.update as jest.Mock
const mockCreateToken = tokenLib.createToken as jest.Mock
const mockSpawn = cp.spawn as jest.Mock
const baseDownload = {
id: 'dl-1',
uuid: 'uuid-abc',
url: 'https://youtube.com/watch?v=abc',
format: 'mp4',
quality: 'best',
subtitles: false,
subtitleLangs: null,
clipStart: null,
clipEnd: null,
audioQuality: null,
extraArgs: null,
}
function makeChildProcess(exitCode: number, stderrMsg = '') {
const proc = new EventEmitter() as any
proc.stdout = new EventEmitter()
proc.stderr = new EventEmitter()
setImmediate(() => {
if (stderrMsg) proc.stderr.emit('data', Buffer.from(stderrMsg))
proc.emit('close', exitCode)
})
return proc
}
describe('processDownload', () => {
beforeEach(() => {
jest.clearAllMocks()
mockFindUnique.mockResolvedValue(baseDownload)
mockUpdate.mockResolvedValue({})
mockCreateToken.mockResolvedValue('new-token')
})
it('marks download as DONE on yt-dlp exit code 0', async () => {
mockSpawn.mockReturnValue(makeChildProcess(0))
await processDownload('dl-1')
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'dl-1' },
data: expect.objectContaining({ status: 'DONE' }),
})
)
})
it('creates a token on success', async () => {
mockSpawn.mockReturnValue(makeChildProcess(0))
await processDownload('dl-1')
expect(mockCreateToken).toHaveBeenCalledWith('dl-1')
})
it('marks download as FAILED on non-zero exit code', async () => {
mockSpawn.mockReturnValue(makeChildProcess(1, 'unsupported URL'))
await processDownload('dl-1')
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'FAILED',
errorMsg: expect.stringContaining('unsupported URL'),
}),
})
)
})
it('does nothing when download not found', async () => {
mockFindUnique.mockResolvedValue(null)
await expect(processDownload('nonexistent')).resolves.toBeUndefined()
expect(mockSpawn).not.toHaveBeenCalled()
})
})