Files
ytplayer/server/uploads.test.js
Jonathan Sykes 11ddb8a970 Uploads library on the USB drive with a nightly stable-disk copy; Topic channels resolve by name
- UPLOAD_DIR moves to the USB drive; reads fall back to UPLOAD_BACKUP_DIR on the
  ytplayer-data volume, and while the drive is offline new uploads land there
- scripts/ops/uploads-backup.sh (homelab cron, 03:30) copies both ways, never
  deletes; the server removes a deleted upload from both places
- /api/channel resolves a bare channel name ('Artist - Topic') to its id via a
  search, and the client falls back to the channel name for old saved entries
2026-10-02 22:38:05 +08:00

195 lines
10 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);
});
});
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 });
});
});