feat: add yt-dlp download processor
This commit is contained in:
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<void> {
|
||||||
|
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<void>((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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user