From 443657ceefaecc2313495dccc0ed956a1fedb96a Mon Sep 17 00:00:00 2001 From: Anthony G <1@anthony.sh> Date: Mon, 10 Aug 2026 14:42:29 +0200 Subject: [PATCH] feat: add yt-dlp download processor --- worker/__tests__/processor.test.ts | 88 ++++++++++++++++++++++++++++++ worker/processor.ts | 61 +++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 worker/__tests__/processor.test.ts create mode 100644 worker/processor.ts diff --git a/worker/__tests__/processor.test.ts b/worker/__tests__/processor.test.ts new file mode 100644 index 0000000..968889c --- /dev/null +++ b/worker/__tests__/processor.test.ts @@ -0,0 +1,88 @@ +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', () => ({ + 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, + 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() + }) +}) diff --git a/worker/processor.ts b/worker/processor.ts new file mode 100644 index 0000000..8d2ad78 --- /dev/null +++ b/worker/processor.ts @@ -0,0 +1,61 @@ +import { spawn } from 'child_process' +import { readdirSync, statSync } from 'fs' +import path from 'path' +import { prisma } from '@/lib/prisma' +import { buildYtdlpArgs } from '@/lib/ytdlp' +import { createToken } from '@/lib/token' +import { config } from '../config/app.config' + +export async function processDownload(downloadId: string): Promise { + const download = await prisma.download.findUnique({ where: { id: downloadId } }) + if (!download) return + + const args = buildYtdlpArgs({ + url: download.url, + uuid: download.uuid, + format: download.format, + quality: download.quality, + subtitles: download.subtitles, + extraArgs: download.extraArgs, + }) + + let stderr = '' + + await new Promise((resolve) => { + const proc = spawn('yt-dlp', args) + proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) + + proc.on('close', async (code) => { + if (code === 0) { + const files = readdirSync(config.STORAGE_PATH).filter((f) => + f.startsWith(download.uuid) + ) + const relFile = files[0] ?? null + const filePath = relFile ? path.join(config.STORAGE_PATH, relFile) : null + const fileSize = filePath ? BigInt(statSync(filePath).size) : null + + await prisma.download.update({ + where: { id: downloadId }, + data: { + status: 'DONE', + filePath, + fileName: relFile, + fileSize, + completedAt: new Date(), + }, + }) + await createToken(downloadId) + } else { + await prisma.download.update({ + where: { id: downloadId }, + data: { + status: 'FAILED', + errorMsg: stderr || `yt-dlp exited with code ${code}`, + completedAt: new Date(), + }, + }) + } + resolve() + }) + }) +}