// Admin uploads: real ffmpeg-made files through the real routes. import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { spawnSync } from 'node:child_process'; import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Hono } from 'hono'; const root = mkdtempSync(join(tmpdir(), 'ytp-uploads-test-')); process.env.DB_PATH = join(root, 'test.db'); const db = await import('./db.js'); const { registerUploadRoutes, describeProbe } = await import('./uploads.js'); const { parseLrc, sanitizeLyrics } = await import('./notes.js'); const ff = (...args) => { const r = spawnSync('ffmpeg', ['-v', 'error', '-y', ...args]); if (r.status !== 0) throw new Error('ffmpeg: ' + r.stderr); }; const fx = (n) => join(root, n); let app, uploads; beforeAll(async () => { await db.initDb(); ff('-f', 'lavfi', '-i', 'color=c=orange:s=240x240:d=1', '-frames:v', '1', fx('cover.png')); // Audio with cover art + embedded synced lyrics (made-up words). ff('-f', 'lavfi', '-i', 'sine=frequency=440:duration=5', '-i', fx('cover.png'), '-map', '0:a', '-map', '1:v', '-c:v', 'mjpeg', '-disposition:v', 'attached_pic', '-metadata', 'title=Test Song', '-metadata', 'artist=Test Artist', '-metadata', 'album=Test Album', '-metadata', 'lyrics=[00:00.50]first test line\n[00:02.00]second test line', '-id3v2_version', '3', fx('song.mp3')); ff('-f', 'lavfi', '-i', 'testsrc=size=320x180:rate=15:duration=3', '-f', 'lavfi', '-i', 'sine=frequency=330:duration=3', '-shortest', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', fx('clip.mp4')); app = new Hono(); uploads = registerUploadRoutes(app, { db, uploadDir: join(root, 'uploads'), parseLrc, sanitizeLyrics, requireAdminOrToken: async (c, next) => { if (c.req.header('x-admin') === 'yes') await next(); else return c.json({ ok: false, error: 'admin only' }, 401); }, rangeFileResponse: (c, path, type) => { const body = readFileSync(path); const range = c.req.header('range'); if (!range) return c.body(body, 200, { 'Content-Type': type, 'Accept-Ranges': 'bytes', 'Content-Length': String(body.length) }); const [s, e] = range.replace('bytes=', '').split('-'); const start = Number(s), end = e ? Number(e) : body.length - 1; return c.body(body.subarray(start, end + 1), 206, { 'Content-Type': type, 'Content-Range': `bytes ${start}-${end}/${body.length}` }); }, }); }); afterAll(() => rmSync(root, { recursive: true, force: true })); const upload = async (file, name, extra = {}) => { const fd = new FormData(); fd.append('file', new File([readFileSync(fx(file))], name)); for (const [k, v] of Object.entries(extra)) fd.append(k, typeof v === 'string' ? v : new File([readFileSync(fx(v.file))], v.name)); const res = await app.request('/api/admin/uploads', { method: 'POST', headers: { 'x-admin': 'yes' }, body: fd }); return { status: res.status, body: await res.json() }; }; describe('uploads', () => { let audioId, videoId; test('describeProbe separates a real video from an attached cover picture', () => { const probe = { format: { duration: '5.0', tags: { title: 'T', artist: 'A', LYRICS: '[00:01.00]x' } }, streams: [{ codec_type: 'audio', index: 0 }, { codec_type: 'video', index: 1, disposition: { attached_pic: 1 } }], }; const info = describeProbe(probe, 'file.mp3'); expect(info).toMatchObject({ kind: 'audio', hasAudio: true, coverIndex: 1, title: 'T', artist: 'A' }); expect(info.lyrics).toBe('[00:01.00]x'); const vid = describeProbe({ format: { duration: '3' }, streams: [{ codec_type: 'video', index: 0 }, { codec_type: 'audio', index: 1 }] }, 'My Clip.mp4'); expect(vid).toMatchObject({ kind: 'video', title: 'My Clip' }); }); test('refuses anything but an admin/token, and unsupported types', async () => { const fd = new FormData(); fd.append('file', new File(['x'], 'a.mp3')); expect((await app.request('/api/admin/uploads', { method: 'POST', body: fd })).status).toBe(401); const bad = await upload('cover.png', 'cover.png'); expect(bad.status).toBe(415); }); test('audio upload: tags, embedded cover art and embedded synced lyrics', async () => { const r = await upload('song.mp3', 'song.mp3'); expect(r.status).toBe(200); const u = r.body.upload; audioId = u.id; expect(u).toMatchObject({ kind: 'audio', title: 'Test Song', artist: 'Test Artist', album: 'Test Album', art: 'embedded', ext: 'mp3' }); expect(Math.round(u.duration)).toBe(5); expect(r.body.lyricLines).toBe(2); const notes = await db.getNotes(audioId); expect(notes.lyrics.data.lines[0]).toEqual({ t: 0.5, text: 'first test line', kind: 'line' }); expect(notes.lyrics.data.tags).toEqual(['from the file (synced)']); const art = await app.request(`/api/uploads/${audioId}/art`); expect(art.status).toBe(200); expect(art.headers.get('content-type')).toBe('image/jpeg'); }); test('video upload with an uploaded cover; manual title/artist win', async () => { const r = await upload('clip.mp4', 'clip.mp4', { title: 'Manual title', artist: 'Manual artist', art: { file: 'cover.png', name: 'cover.png' } }); expect(r.status).toBe(200); videoId = r.body.upload.id; expect(r.body.upload).toMatchObject({ kind: 'video', title: 'Manual title', artist: 'Manual artist', art: 'file' }); expect(r.body.lyricLines).toBe(0); }); test('listing, search and the streams/card shapes', async () => { const all = await (await app.request('/api/uploads')).json(); expect(all.uploads.length).toBe(2); const hit = await (await app.request('/api/uploads?q=test+art')).json(); expect(hit.uploads.map((u) => u.id)).toEqual([audioId]); const u = await db.getUpload(audioId); expect(uploads.streamsPayload(u)).toMatchObject({ audioUrl: `/api/uploads/${audioId}`, kind: 'audio', upload: true, art: `/api/uploads/${audioId}/art`, qualities: [], }); expect(uploads.card(u)).toMatchObject({ id: audioId, title: 'Test Song', channel: 'Test Artist', kind: 'audio' }); const v = uploads.streamsPayload(await db.getUpload(videoId)); expect(v.qualities[0]).toMatchObject({ label: 'Original', hasAudio: true, url: `/api/uploads/${videoId}` }); }); test('serving supports Range, and delete removes the files', async () => { const full = await app.request(`/api/uploads/${audioId}`); expect(full.status).toBe(200); expect(full.headers.get('content-type')).toBe('audio/mpeg'); const part = await app.request(`/api/uploads/${audioId}`, { headers: { Range: 'bytes=0-99' } }); expect(part.status).toBe(206); expect((await part.arrayBuffer()).byteLength).toBe(100); const u = await db.getUpload(audioId); const path = uploads.filePath(u); expect(existsSync(path)).toBe(true); expect((await app.request(`/api/admin/uploads/${audioId}`, { method: 'DELETE', headers: { 'x-admin': 'yes' } })).status).toBe(200); expect(existsSync(path)).toBe(false); expect(await db.getUpload(audioId)).toBeNull(); expect((await app.request(`/api/uploads/${audioId}`)).status).toBe(404); }); }); describe('streaming upload (large / long files)', () => { const put = (path, body, headers = { 'x-admin': 'yes' }) => app.request(path, { method: 'PUT', headers, body }); test('raw body is streamed to disk and registered', async () => { const buf = readFileSync(fx('clip.mp4')); const r = await put('/api/admin/uploads/stream?name=Long%20Talk.mp4&title=Long%20Talk', buf); const j = await r.json(); expect(r.status).toBe(200); expect(j.upload).toMatchObject({ kind: 'video', title: 'Long Talk', size: buf.length }); expect(existsSync(join(root, 'uploads', `${j.upload.id}.mp4`))).toBe(true); }); test('needs the admin, a known extension and a non-empty body', async () => { const buf = readFileSync(fx('clip.mp4')); expect((await put('/api/admin/uploads/stream?name=a.mp4', buf, {})).status).toBe(401); expect((await put('/api/admin/uploads/stream?name=a.exe', buf)).status).toBe(415); expect((await put('/api/admin/uploads/stream?name=a.mp4', '')).status).toBe(400); }); test('a file that is not media is rejected and leaves nothing behind', async () => { const before = (await db.listUploads({ limit: 500 })).length; const r = await put('/api/admin/uploads/stream?name=junk.mp4', new Uint8Array(5000).fill(7)); expect(r.status).toBe(500); expect((await db.listUploads({ limit: 500 })).length).toBe(before); }); test('cover image can be attached afterwards', async () => { const up = await (await put('/api/admin/uploads/stream?name=song.mp3', readFileSync(fx('song.mp3')))).json(); const r = await put(`/api/admin/uploads/${up.upload.id}/art`, readFileSync(fx('cover.png'))); expect((await r.json()).ok).toBe(true); expect((await db.getUpload(up.upload.id)).art).toBeTruthy(); }); }); describe('upload storage on a removable drive', () => { test('reads the drive copy, falls back to the backup copy, and writes to the backup while offline', async () => { const { mkdtempSync, writeFileSync, rmSync } = await import('node:fs'); const { tmpdir } = await import('node:os'); const { join } = await import('node:path'); const { registerUploadRoutes } = await import('./uploads.js'); const root = mkdtempSync(join(tmpdir(), 'ytp-up-')); const drive = join(root, 'usb'), uploadDir = join(drive, 'uploads'), backupDir = join(root, 'backup'); const marker = join(drive, '.hl-data'); (await import('node:fs')).mkdirSync(drive, { recursive: true }); writeFileSync(marker, ''); const noop = () => {}; const app = new Proxy({}, { get: () => noop }); const up = registerUploadRoutes(app, { db: {}, uploadDir, backupDir, volumeMarker: marker, rangeFileResponse: noop, requireAdminOrToken: noop }); const u = { id: 'up_test1', ext: 'mp4' }; writeFileSync(join(backupDir, 'up_test1.mp4'), 'backup'); expect(up.filePath(u)).toBe(join(backupDir, 'up_test1.mp4')); // drive copy missing → backup writeFileSync(join(uploadDir, 'up_test1.mp4'), 'drive'); expect(up.filePath(u)).toBe(join(uploadDir, 'up_test1.mp4')); // drive copy preferred rmSync(marker); expect(up.filePath(u)).toBe(join(backupDir, 'up_test1.mp4')); // drive offline → backup expect(up.filePath({ id: 'up_new', ext: 'mp4' })).toBe(join(backupDir, 'up_new.mp4')); // new writes go to the stable disk rmSync(root, { recursive: true, force: true }); }); });