diff --git a/src/app.js b/src/app.js index 40890cf..5e660c2 100644 --- a/src/app.js +++ b/src/app.js @@ -15,6 +15,7 @@ import { registerDfontConverter } from './converters/dfont.js'; import { registerEbookConverter } from './converters/ebook.js'; import { registerArchiveConverters } from './converters/archive.js'; import { registerAudioConverters } from './converters/audio.js'; +import { registerVideoConverters } from './converters/video.js'; import { extractExtension, stripExtension } from './archiveExtensions.js'; import { resolveInputFormat } from './mime.js'; import { deleteIfExists } from './storage.js'; @@ -31,6 +32,7 @@ function isValidQuality(targetFormat, quality) { if (['zip', 'tar.gz', 'tar.bz2', '7z', 'tar.7z'].includes(targetFormat)) return quality >= 0 && quality <= 9; if (['wav', 'flac'].includes(targetFormat)) return false; if (['mp3', 'ogg', 'aac', 'm4a'].includes(targetFormat)) return [128, 192, 256, 320].includes(quality); + if (['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(targetFormat)) return [480, 720, 1080].includes(quality); return quality >= 1 && quality <= 100; } @@ -54,6 +56,7 @@ function registerAllConverters() { registerEbookConverter(); registerArchiveConverters(); registerAudioConverters(); + registerVideoConverters(); convertersRegistered = true; } diff --git a/src/worker.js b/src/worker.js index d3eb3af..63617e6 100644 --- a/src/worker.js +++ b/src/worker.js @@ -15,6 +15,7 @@ import { registerDfontConverter } from './converters/dfont.js'; import { registerEbookConverter } from './converters/ebook.js'; import { registerArchiveConverters } from './converters/archive.js'; import { registerAudioConverters } from './converters/audio.js'; +import { registerVideoConverters } from './converters/video.js'; import { findPendingJobs, markProcessing, markDone, markFailed } from './jobs/jobRepository.js'; const JOB_TIMEOUT_MS = 60000; @@ -39,7 +40,10 @@ async function processJob(prisma, config, job) { throw new Error(`No converter registered for ${job.sourceFormat} -> ${job.targetFormat}`); } - const timeoutMs = entry.family === 'ebook' ? config.ebookJobTimeoutMs : JOB_TIMEOUT_MS; + const timeoutMs = + entry.family === 'ebook' ? config.ebookJobTimeoutMs : + entry.family === 'video' ? config.videoJobTimeoutMs : + JOB_TIMEOUT_MS; const startedAt = Date.now(); await withTimeout( entry.convert(inputFilePath, outputFilePath, { quality: job.quality, iconSize: job.iconSize, timeoutMs }), @@ -93,6 +97,7 @@ async function main() { registerEbookConverter(); registerArchiveConverters(); registerAudioConverters(); + registerVideoConverters(); startWorker(prisma, config); console.log(`Worker started, polling every ${config.workerPollIntervalMs}ms`); diff --git a/test/api/jobs.test.js b/test/api/jobs.test.js index 5343d2c..0891c96 100644 --- a/test/api/jobs.test.js +++ b/test/api/jobs.test.js @@ -527,3 +527,75 @@ describe('POST /api/jobs — audio', () => { expect(response.body.targets).not.toContain('mp3'); }); }); + +describe('POST /api/jobs — video', () => { + it('creates a pending job converting an mp4 upload to webm with a valid resolution', async () => { + const fixturePath = path.join(config.storageDir, 'clip.mp4'); + await fs.writeFile( + fixturePath, + Buffer.concat([Buffer.from([0, 0, 0, 0x18]), Buffer.from('ftyp'), Buffer.from('isom')]) + ); + + const response = await request(app) + .post('/api/jobs') + .field('targetFormats', JSON.stringify(['webm'])) + .field('qualities', JSON.stringify([720])) + .attach('files', fixturePath, 'clip.mp4'); + + expect(response.status).toBe(201); + expect(response.body.jobs[0].status).toBe('pending'); + + const job = await getJobByUuid(prisma, response.body.jobs[0].id); + expect(job.sourceFormat).toBe('mp4'); + expect(job.targetFormat).toBe('webm'); + expect(job.family).toBe('video'); + expect(job.quality).toBe(720); + + await fs.unlink(fixturePath); + }); + + it('creates a pending job with no quality value (original resolution)', async () => { + const fixturePath = path.join(config.storageDir, 'clip-original.mp4'); + await fs.writeFile( + fixturePath, + Buffer.concat([Buffer.from([0, 0, 0, 0x18]), Buffer.from('ftyp'), Buffer.from('isom')]) + ); + + const response = await request(app) + .post('/api/jobs') + .field('targetFormats', JSON.stringify(['mkv'])) + .attach('files', fixturePath, 'clip-original.mp4'); + + expect(response.status).toBe(201); + const job = await getJobByUuid(prisma, response.body.jobs[0].id); + expect(job.quality).toBeNull(); + + await fs.unlink(fixturePath); + }); + + it('rejects a resolution outside the fixed 480/720/1080 set', async () => { + const fixturePath = path.join(config.storageDir, 'bad-resolution.mp4'); + await fs.writeFile( + fixturePath, + Buffer.concat([Buffer.from([0, 0, 0, 0x18]), Buffer.from('ftyp'), Buffer.from('isom')]) + ); + + const response = await request(app) + .post('/api/jobs') + .field('targetFormats', JSON.stringify(['webm'])) + .field('qualities', JSON.stringify([360])) + .attach('files', fixturePath, 'bad-resolution.mp4'); + + expect(response.status).toBe(201); + expect(response.body.jobs[0].error).toMatch(/Invalid quality/); + + await fs.unlink(fixturePath); + }); + + it('lists the other 4 video formats as targets for mp4, and never lists mp4 as its own target', async () => { + const response = await request(app).get('/api/formats').query({ source: 'mp4' }); + + expect(response.body.targets).toEqual(expect.arrayContaining(['webm', 'mov', 'avi', 'mkv'])); + expect(response.body.targets).not.toContain('mp4'); + }); +}); diff --git a/test/worker.test.js b/test/worker.test.js index 98f5d11..28e9fad 100644 --- a/test/worker.test.js +++ b/test/worker.test.js @@ -11,6 +11,7 @@ import { createJob, getJobByUuid, getJobErrorLog } from '../src/jobs/jobReposito import { registerImageConverters } from '../src/converters/image.js'; import { registerIcoConverter } from '../src/converters/ico.js'; import { registerEbookConverter } from '../src/converters/ebook.js'; +import { registerVideoConverters } from '../src/converters/video.js'; import { processPendingJobs } from '../src/worker.js'; const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })); @@ -23,6 +24,7 @@ beforeAll(async () => { registerImageConverters(); registerIcoConverter(); registerEbookConverter(); + registerVideoConverters(); config = { ...loadConfig(), storageDir: await fs.mkdtemp(path.join(os.tmpdir(), 'converter-worker-')) }; await ensureStorageDirs(config); prisma = getPrismaClient(config); @@ -227,4 +229,42 @@ describe('processPendingJobs', () => { expect(options).toEqual({ timeout: config.ebookJobTimeoutMs }); expect(config.ebookJobTimeoutMs).not.toBe(60000); }); + + it('converts a pending video job to done using config.videoJobTimeoutMs, not the default 60s timeout', async () => { + execFileMock.mockReset(); + execFileMock.mockImplementation((file, args, options, callback) => { + writeFileSync(args[args.length - 1], 'fake converted video output'); + callback(null, '', ''); + }); + + const uuid = '22222222-2222-4222-8222-222222222222'; + const inputFilePath = uploadPath(config, uuid, 'mp4'); + await fs.writeFile(inputFilePath, 'fake mp4 content'); + + await createJob(prisma, { + uuid, + family: 'video', + sourceFormat: 'mp4', + targetFormat: 'webm', + originalFilename: 'clip.mp4', + inputPath: `${uuid}.mp4`, + inputMimeType: 'video/mp4', + inputSizeBytes: 17, + expiresAt: new Date(Date.now() + 3600 * 1000), + quality: 720, + }); + + await processPendingJobs(prisma, config); + + const job = await getJobByUuid(prisma, uuid); + expect(job.status).toBe('done'); + const [file, args, options] = execFileMock.mock.calls[0]; + expect(file).toBe('ffmpeg'); + expect(args).toEqual([ + '-y', '-i', inputFilePath, '-c:v', 'libvpx-vp9', '-c:a', 'libopus', '-vf', 'scale=-2:720', + outputPath(config, uuid, 'webm'), + ]); + expect(options).toEqual({ timeout: config.videoJobTimeoutMs }); + expect(config.videoJobTimeoutMs).not.toBe(60000); + }); });