Files
ytplayer/server/uploads.test.js

134 lines
7.1 KiB
JavaScript

// 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);
});
});