Fetch lyrics from LRCLIB (synced when available) and serve an admin media library of uploaded video and audio with cover art and embedded lyrics

This commit is contained in:
Jonathan Sykes
2026-09-20 05:59:50 +08:00
parent 9c1ec4ac51
commit b983c6ca3c
14 changed files with 963 additions and 25 deletions

View File

@@ -142,6 +142,23 @@ export async function initDb() {
CREATE INDEX IF NOT EXISTS idx_note_revs_time
ON video_note_revs (created_at DESC);
-- Admin uploads: the server's own video/audio library, searched next to
-- YouTube. Files live in UPLOAD_DIR as <id>.<ext> (+ <id>.art.jpg).
CREATE TABLE IF NOT EXISTS uploads (
id TEXT PRIMARY KEY, -- upl_<hex>
kind TEXT NOT NULL, -- video | audio
title TEXT NOT NULL,
artist TEXT,
album TEXT,
duration REAL NOT NULL DEFAULT 0,
ext TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
art TEXT, -- 'embedded' | 'file' | NULL
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
plays INTEGER NOT NULL DEFAULT 0
);
-- API tokens for scripts (lyrics injection etc.). Only a SHA-256 of the
-- token is stored; the plaintext is shown once when it is created.
CREATE TABLE IF NOT EXISTS api_tokens (
@@ -561,3 +578,47 @@ export async function getUserData(fingerprint) {
return { lastVersion, playlists, history };
}
// ---- Uploads (the server's own media library) ---------------------------------
const uploadRow = (r) => ({
id: r.id, kind: r.kind, title: r.title, artist: r.artist || '', album: r.album || '',
duration: Number(r.duration) || 0, ext: r.ext, mime: r.mime, size: Number(r.size) || 0,
art: r.art || null, createdAt: Number(r.created_at), plays: Number(r.plays) || 0,
});
export async function createUpload(u) {
await db.execute({
sql: `INSERT INTO uploads (id, kind, title, artist, album, duration, ext, mime, size, art, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, unixepoch())`,
args: [u.id, u.kind, u.title, u.artist || null, u.album || null, u.duration || 0, u.ext, u.mime, u.size || 0, u.art || null],
});
}
export async function getUpload(id) {
const r = await db.execute({ sql: 'SELECT * FROM uploads WHERE id = ?', args: [id] });
return r.rows[0] ? uploadRow(r.rows[0]) : null;
}
// Newest first; `q` matches title/artist/album (case-insensitive).
export async function listUploads({ q = '', limit = 100 } = {}) {
const like = `%${String(q).toLowerCase()}%`;
const r = q
? await db.execute({
sql: `SELECT * FROM uploads
WHERE lower(title) LIKE ? OR lower(ifnull(artist, '')) LIKE ? OR lower(ifnull(album, '')) LIKE ?
ORDER BY created_at DESC LIMIT ?`,
args: [like, like, like, limit],
})
: await db.execute({ sql: 'SELECT * FROM uploads ORDER BY created_at DESC LIMIT ?', args: [limit] });
return r.rows.map(uploadRow);
}
export async function deleteUpload(id) {
const r = await db.execute({ sql: 'DELETE FROM uploads WHERE id = ?', args: [id] });
return (r.rowsAffected || 0) > 0;
}
export async function touchUpload(id) {
db.execute({ sql: 'UPDATE uploads SET plays = plays + 1 WHERE id = ?', args: [id] }).catch(() => {});
}

View File

@@ -24,6 +24,7 @@
* GET /api/notes/:id/:kind/revs/:rev → one revision's data
* GET /api/notes/:id/captions → YouTube captions as lyric lines (preview, no save)
* POST /api/notes/:id/lyrics/auto { overwrite? } token/admin: captions → saved lyrics
* POST /api/notes/:id/lyrics/web { overwrite? } token/admin: LRCLIB (synced when available)
* POST /api/admin/login | /api/admin/logout, GET /api/admin/me
* GET|POST /api/admin/tokens, DELETE /api/admin/tokens/:id
* GET /api/admin/notes/recent, GET /api/admin/notes/export
@@ -38,7 +39,8 @@ import { join } from 'node:path';
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
export const NOTE_KINDS = new Set(['lyrics', 'chapters']);
const VIDEO_ID_RE = /^[A-Za-z0-9_-]{11}$/;
// A YouTube id or one of the server's own uploads (see uploads.js).
const VIDEO_ID_RE = /^([A-Za-z0-9_-]{11}|upl_[a-f0-9]{12})$/;
const MAX_LINES = 1000;
const MAX_LINE_CHARS = 300;
@@ -179,6 +181,91 @@ export function pickCaptionTrack(info) {
return null;
}
// ---- Lyrics from the web (LRCLIB) ---------------------------------------------
// LRCLIB (lrclib.net) is a free, key-less, crowd-sourced lyrics database made
// for music players; it often has SYNCED lyrics, which is what this app wants.
// Titles from YouTube carry a lot of noise ("(Official Video)", "| Lyrics",
// "Karaoke | Minus-One"), so they are cleaned before the lookup.
const NOISE = /\b(official|video|audio|lyrics?|lyric|hd|hq|4k|live|mv|karaoke|minus[\s-]?one|instrumental|cover|remaster(ed)?|visualizer|performance|version)\b/gi;
// NOISE is global (used with .replace), so testing needs its own stateless
// copy — a /g regex remembers lastIndex between .test() calls.
const hasNoise = (s) => new RegExp(NOISE.source, 'i').test(s);
export function cleanTitle(raw) {
let t = String(raw || '');
t = t.split('|')[0]; // "Song | Channel extras"
t = t.replace(/\([^)]*\)|\[[^\]]*\]/g, (m) => (hasNoise(m) ? ' ' : m)); // drop noisy brackets only
t = t.replace(NOISE, ' ');
t = t.replace(/\(\s*\)|\[\s*\]/g, ' ').replace(/[-–—]\s*$/, ''); // leftovers like "[ ]"
return t.replace(/\s{2,}/g, ' ').replace(/^[\s\-–—,]+|[\s\-–—,]+$/g, '').trim();
}
export function cleanArtist(raw) {
return String(raw || '').replace(/\s*-\s*Topic$/i, '').replace(/VEVO$/i, '').replace(NOISE, ' ').replace(/\s{2,}/g, ' ').trim();
}
// "[mm:ss.xx] words" → timed lines; plain text → untimed lines.
export function parseLrc(text) {
const out = [];
for (const raw of String(text || '').replace(/\r/g, '').split('\n')) {
let rest = raw.trim();
if (!rest) continue;
if (/^\[[a-z]+:[^\]]*\]$/i.test(rest)) continue; // [ar:…] [length:…] headers
const stamps = [];
let m;
while ((m = rest.match(/^\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/))) {
stamps.push(Number(m[1]) * 60 + Number(m[2]) + (m[3] ? Number('0.' + m[3]) : 0));
rest = rest.slice(m[0].length).trim();
}
if (!rest) continue;
if (!stamps.length) out.push({ t: null, text: rest, kind: 'line' });
for (const t of stamps) out.push({ t: Math.round(t * 100) / 100, text: rest, kind: 'line' });
}
if (out.length && out.every((l) => l.t !== null)) out.sort((a, b) => a.t - b.t);
return out;
}
const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
// Pick the entry whose title matches and whose duration is closest (± 6 s).
export function pickLrclib(list, { title, duration }) {
const want = norm(title);
const scored = (Array.isArray(list) ? list : [])
.filter((x) => x && (x.syncedLyrics || x.plainLyrics) && !x.instrumental)
.map((x) => {
const t = norm(x.trackName);
const dd = duration && x.duration ? Math.abs(x.duration - duration) : 99;
const titleHit = t === want ? 2 : t.includes(want) || want.includes(t) ? 1 : 0;
return { x, score: titleHit * 10 + (x.syncedLyrics ? 3 : 0) - Math.min(9, dd), dd, titleHit };
})
.filter((c) => c.titleHit > 0 && (!duration || c.dd <= 6))
.sort((a, b) => b.score - a.score);
return scored.length ? scored[0].x : null;
}
async function lrclibLookup({ title, artist, album, duration }) {
const get = async (url) => {
const res = await fetch(url, { headers: { 'User-Agent': 'ytplayer (https://worship.hesed.sbs)' } });
if (res.status === 404) return null;
if (!res.ok) throw new Error(`LRCLIB HTTP ${res.status}`);
return res.json();
};
const q = new URLSearchParams({ track_name: title, artist_name: artist || '' });
if (album) q.set('album_name', album);
if (duration) q.set('duration', String(Math.round(duration)));
let hit = null;
try { hit = await get(`https://lrclib.net/api/get?${q}`); } catch { /* fall through to search */ }
if (!hit) {
const list = await get(`https://lrclib.net/api/search?q=${encodeURIComponent(`${title} ${artist || ''}`.trim())}`);
hit = pickLrclib(list, { title, duration });
}
if (!hit) return null;
const synced = hit.syncedLyrics && parseLrc(hit.syncedLyrics);
if (synced && synced.length) return { lines: synced, synced: true, meta: hit };
const plain = hit.plainLyrics && parseLrc(hit.plainLyrics);
return plain && plain.length ? { lines: plain, synced: false, meta: hit } : null;
}
// ---- Tokens / admin cookie ------------------------------------------------------
export function hashToken(token) {
@@ -331,6 +418,50 @@ export function registerNoteRoutes(app, deps) {
}
});
// POST /api/notes/:id/lyrics/web — look the song up on LRCLIB (timed lyrics
// when they exist) and save them. Never overwrites existing lyrics unless
// asked. Token/admin only; title & artist come from the cached media info
// unless the caller passes them.
app.post('/api/notes/:id/lyrics/web', async (c) => {
const id = c.req.param('id');
if (badId(id)) return c.json({ ok: false, error: 'invalid video id' }, 400);
let body = {};
try { body = await c.req.json(); } catch { /* optional */ }
const who = await resolveWriter(c, body);
if (!who || who.invalid) return c.json({ ok: false, error: 'link an online profile (or use an API token) to fetch lyrics' }, 401);
try {
const existing = (await db.getNotes(id)).lyrics;
if (existing && existing.data.lines.length && !body.overwrite) {
return c.json({ ok: false, error: 'lyrics already exist — pass {"overwrite":true} to replace them', rev: existing.rev }, 409);
}
let meta = {};
let row = null;
if (id.startsWith('upl_')) {
const u = await db.getUpload(id);
if (u) meta = { title: u.title, channel: u.artist, album: u.album, duration: u.duration };
} else {
try { meta = JSON.parse((await db.getMedia(id) || {}).meta || '{}'); } catch { /* none */ }
row = await db.getMedia(id);
}
const title = cleanTitle(body.title || meta.title || '');
const artist = cleanArtist(body.artist || meta.channel || meta.uploader || '');
const duration = Number(body.duration || (row && row.duration) || meta.duration || 0) || 0;
if (!title) return c.json({ ok: false, error: 'no title known for this video — pass {"title":"…","artist":"…"}' }, 400);
const hit = await lrclibLookup({ title, artist, album: body.album || meta.album, duration });
if (!hit) return c.json({ ok: false, error: `no lyrics on LRCLIB for “${title}”${artist ? ` by ${artist}` : ''}` }, 404);
const data = sanitizeLyrics({
lines: hit.lines,
tags: [hit.synced ? 'from LRCLIB (synced)' : 'from LRCLIB (plain text)'],
offset: 0,
});
const saved = await db.saveNote({ videoId: id, kind: 'lyrics', data, source: 'auto', updatedBy: who.by, force: true });
return c.json({ ok: true, rev: saved.rev, lines: data.lines.length, synced: hit.synced, match: { track: hit.meta.trackName, artist: hit.meta.artistName, duration: hit.meta.duration } });
} catch (err) {
return c.json({ ok: false, error: err.message }, 502);
}
});
app.put('/api/notes/:id/:kind', async (c) => {
const id = c.req.param('id');
const kind = c.req.param('kind');
@@ -511,6 +642,7 @@ export function registerNoteRoutes(app, deps) {
}
}
return {
requireAdminOrToken,
startBackups() {
setTimeout(writeBackup, 60_000);
setInterval(writeBackup, 24 * 3600_000).unref?.();

View File

@@ -106,6 +106,36 @@ describe('pure helpers', () => {
expect(N.pickCaptionTrack({ automatic_captions: { es: [{ ext: 'vtt', url: 's' }] } })).toBeNull();
});
test('cleanTitle / cleanArtist strip YouTube noise', () => {
expect(N.cleanTitle('Hosanna - Hillsong Worship (Official Live Video) [HD]')).toBe('Hosanna - Hillsong Worship');
expect(N.cleanTitle('Kay Buti-Buti Mo Panginoon Karaoke | Minus-One | Instrumental')).toBe('Kay Buti-Buti Mo Panginoon');
expect(N.cleanTitle('Still (Lyrics)')).toBe('Still');
expect(N.cleanArtist('Hillsong Worship - Topic')).toBe('Hillsong Worship');
expect(N.cleanArtist('BethelVEVO')).toBe('Bethel');
});
test('parseLrc reads synced and plain lyrics', () => {
const synced = N.parseLrc('[ar:Someone]\n[00:12.50]first line here\n[00:20.00][01:05.25]repeated line\n');
expect(synced).toEqual([
{ t: 12.5, text: 'first line here', kind: 'line' },
{ t: 20, text: 'repeated line', kind: 'line' },
{ t: 65.25, text: 'repeated line', kind: 'line' },
]);
const plain = N.parseLrc('one line\n\nanother line');
expect(plain).toEqual([{ t: null, text: 'one line', kind: 'line' }, { t: null, text: 'another line', kind: 'line' }]);
});
test('pickLrclib prefers a title match, synced lyrics and the closest duration', () => {
const list = [
{ trackName: 'As The Deer', duration: 120, plainLyrics: 'x' },
{ trackName: 'As The Deer', duration: 249, syncedLyrics: '[00:01.00]x', plainLyrics: 'x' },
{ trackName: 'As The Deer (Instrumental)', duration: 248, instrumental: true, plainLyrics: 'x' },
{ trackName: 'Something else', duration: 250, syncedLyrics: '[00:01.00]y' },
];
expect(N.pickLrclib(list, { title: 'As the Deer', duration: 249 }).duration).toBe(249);
expect(N.pickLrclib(list, { title: 'No such song', duration: 249 })).toBeNull();
});
test('admin cookie signature and expiry', () => {
const v = N.signAdminCookie('k', 2000);
expect(N.verifyAdminCookie('k', v, 1000)).toBe(true);

View File

@@ -6,7 +6,7 @@
"scripts": {
"start": "bun server.js",
"dev": "bun --hot server.js",
"test": "bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./remote.test.js && bun test ./party.test.js"
"test": "bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js"
},
"dependencies": {
"@hono/node-server": "^1.14.0",

View File

@@ -43,9 +43,10 @@ import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getP
getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats } from './db.js';
import { createMediaCache, HIGH, LOW } from './media-cache.js';
import * as notesDb from './db.js';
import { registerNoteRoutes } from './notes.js';
import { registerNoteRoutes, parseLrc, sanitizeLyrics } from './notes.js';
import { createRemoteHub } from './remote.js';
import { createPartyHub } from './party.js';
import { registerUploadRoutes } from './uploads.js';
import QRCode from 'qrcode';
import { dirname, join as pathJoin } from 'node:path';
@@ -58,6 +59,7 @@ const PORT = parseInt(process.env.PORT || '3000', 10);
const APP_VERSION = process.env.APP_VERSION || '1.0.0';
const YTDLP = process.env.YTDLP_PATH || 'yt-dlp';
const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg';
const FFPROBE = process.env.FFPROBE_PATH || 'ffprobe';
// Cap for server-side SAVE downloads. yt-dlp with -N 4 pinned the homelab's
// whole downlink (~7.6 MB/s measured), and since /api/play fetches its own
// googlevideo slices over the same link, one long save starved every
@@ -356,14 +358,19 @@ app.get('/api/search', async (c) => {
const q = (c.req.query('q') || '').trim();
if (!q) return c.json({ ok: false, error: 'empty query' }, 400);
// This server's own library first — and it still answers when YouTube
// (yt-dlp) is unreachable or rate-limited.
let mine = [];
try { mine = (await notesDb.listUploads({ q, limit: 20 })).map(uploads.card); } catch { /* library optional */ }
try {
const out = await runYtdlpResilient([
`ytsearch${SEARCH_LIMIT}:${q}`,
'--dump-json', '--flat-playlist',
'--no-warnings', '--ignore-errors',
]);
return c.json({ ok: true, results: parseCards(out) });
return c.json({ ok: true, results: [...mine, ...parseCards(out)] });
} catch (err) {
if (mine.length) return c.json({ ok: true, results: mine, youtubeError: err.message });
return c.json({ ok: false, error: err.message }, 500);
}
});
@@ -509,6 +516,13 @@ app.get('/api/streams', async (c) => {
const videoId = (c.req.query('v') || '').replace(/[/\\:?<>|*"]/g, '').trim();
if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400);
// An upload from the server's own library — no yt-dlp, no media cache.
if (isUpload(videoId)) {
const u = await notesDb.getUpload(videoId);
if (!u) return c.json({ ok: false, error: 'upload not found' }, 404);
return c.json({ ok: true, data: uploads.streamsPayload(u) });
}
// Server already holds a validated copy → answer from the DB alone, no
// yt-dlp round trip. ?nocache=1 (the client's fallback when a cached copy
// won't play on its device) forces the YouTube path below.
@@ -1070,6 +1084,23 @@ app.get('/api/media/:id', async (c) => {
});
// GET /api/media/:id/status
// Where the bytes for an id live: the validated YouTube copy in the media
// cache, or one of the server's own uploads.
async function audioSourcePath(id) {
if (isUpload(id)) {
const u = await notesDb.getUpload(id);
return u ? uploads.filePath(u) : null;
}
return (await media.filePath(id, null, 'm4a')) || (await media.filePath(id, null, 'mp4'));
}
async function videoSourcePath(id) {
if (isUpload(id)) {
const u = await notesDb.getUpload(id);
return u && u.kind === 'video' ? uploads.filePath(u) : null;
}
return media.filePath(id, null, 'mp4');
}
// GET /api/media/:id/peaks — loudness envelope of a server-cached copy for
// the waveform seek bar: PEAKS_N RMS buckets scaled 0..100. Computed once per
// file with ffmpeg (mono, 2 kHz is plenty for an envelope) and kept in memory;
@@ -1108,8 +1139,8 @@ function computePeaks(path) {
app.get('/api/media/:id/peaks', async (c) => {
const id = c.req.param('id');
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
const path = (await media.filePath(id, null, 'm4a')) || (await media.filePath(id, null, 'mp4'));
if (!/^([A-Za-z0-9_-]{11}|upl_[a-f0-9]{12})$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
const path = await audioSourcePath(id);
if (!path) return c.json({ ok: false, error: 'not cached on the server' }, 404);
let hit = peaksCache.get(path);
if (!hit) {
@@ -1130,11 +1161,11 @@ const gifCache = new Map();
let gifRunning = 0;
app.get('/api/media/:id/gif', async (c) => {
const id = c.req.param('id');
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
if (!/^([A-Za-z0-9_-]{11}|upl_[a-f0-9]{12})$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
const t = Math.max(0, Number(c.req.query('t')) || 0);
const d = Math.min(6, Math.max(1, Number(c.req.query('d')) || 3));
const w = Math.min(640, Math.max(240, Math.round((Number(c.req.query('w')) || 480) / 2) * 2));
const path = await media.filePath(id, null, 'mp4');
const path = await videoSourcePath(id);
if (!path) return c.json({ ok: false, error: 'this video is not cached on the server yet — play it once, then try again' }, 404);
const key = `${path}|${t.toFixed(1)}|${d}|${w}`;
let gif = gifCache.get(key);
@@ -1177,12 +1208,12 @@ app.get('/api/media/:id/gif', async (c) => {
// (.m4r = AAC in an iPod MP4 container, ≤ 40 s — Apple's ringtone limit).
app.get('/api/media/:id/clip', async (c) => {
const id = c.req.param('id');
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
if (!/^([A-Za-z0-9_-]{11}|upl_[a-f0-9]{12})$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
const fmt = c.req.query('fmt') === 'm4r' ? 'm4r' : 'mp3';
const start = Math.max(0, Number(c.req.query('start')) || 0);
const maxLen = fmt === 'm4r' ? 40 : 60;
const len = Math.min(maxLen, Math.max(1, (Number(c.req.query('end')) || start + 20) - start));
const path = (await media.filePath(id, null, 'm4a')) || (await media.filePath(id, null, 'mp4'));
const path = await audioSourcePath(id);
if (!path) return c.json({ ok: false, error: 'this video is not cached on the server yet — play it once, then try again' }, 404);
const fade = Math.min(0.5, len / 4);
const af = `afade=t=in:st=0:d=${fade},afade=t=out:st=${(len - fade).toFixed(2)}:d=${fade}`;
@@ -1263,6 +1294,17 @@ function cachedDownloadResponse(videoId, fp, row) {
app.get('/api/download/:videoId', async (c) => {
const videoId = (c.req.param('videoId') || '').replace(/[/\\:?<>|*"]/g, '').trim();
if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400);
// Uploads are already a single file on disk — hand it over as-is.
if (isUpload(videoId)) {
const u = await notesDb.getUpload(videoId);
if (!u) return c.json({ ok: false, error: 'upload not found' }, 404);
const f = Bun.file(uploads.filePath(u));
return new Response(f, { status: 200, headers: {
'Content-Type': u.mime, 'Content-Length': String(f.size),
'Content-Disposition': `attachment; filename="${u.id}.${u.ext}"`, 'Cache-Control': 'no-store',
} });
}
const fp = c.req.query('fp');
// ?edit=1&keep=s-e,s-e — "Edit & download" path: download the source, then
@@ -1772,6 +1814,22 @@ const notes = registerNoteRoutes(app, {
workerToken: process.env.LYRICS_WORKER_TOKEN || '',
});
// ============================================================================
// Uploads — the server's own searchable media library (see uploads.js)
// ============================================================================
const UPLOAD_DIR = process.env.UPLOAD_DIR || pathJoin(dirname(process.env.DB_PATH || './data/ytplayer.db'), 'uploads');
const uploads = registerUploadRoutes(app, {
db: notesDb,
ffmpeg: FFMPEG,
ffprobe: FFPROBE,
uploadDir: UPLOAD_DIR,
rangeFileResponse,
requireAdminOrToken: notes.requireAdminOrToken,
parseLrc,
sanitizeLyrics,
});
const isUpload = (id) => uploads.isUploadId(id);
// ============================================================================
// GET /sw.js — serve the service worker with BUILD_TAG injected
//

215
server/uploads.js Normal file
View File

@@ -0,0 +1,215 @@
/* ============================================================================
* uploads.js — the server's own media library (admin uploads)
*
* An admin uploads a video or an audio file; it is then searchable and
* playable next to YouTube results. Files live in UPLOAD_DIR as
* <id>.<ext> (+ <id>.art.jpg for cover art), the row in the `uploads` table.
*
* On upload the file is probed once with ffprobe and everything useful is
* taken from it:
* - title / artist / album tags (falling back to the file name),
* - duration, and whether there is a REAL video stream (an attached cover
* picture also shows up as a video stream — `disposition.attached_pic`),
* - embedded cover art → extracted to <id>.art.jpg (or an uploaded image),
* - embedded lyrics tags (LYRICS / lyrics-eng / UNSYNCEDLYRICS / ©lyr) →
* saved as the song's shared lyrics, synced when they are in LRC form.
*
* Endpoints:
* POST /api/admin/uploads (admin or token) multipart: file, art?, title?, artist?
* DELETE /api/admin/uploads/:id (admin or token)
* GET /api/uploads?q=&limit= public list / search
* GET /api/uploads/:id the media file (Range-aware)
* GET /api/uploads/:id/art cover image
* ========================================================================== */
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
export const UPLOAD_ID_RE = /^upl_[a-f0-9]{12}$/;
const MAX_BYTES = 4 * 1024 * 1024 * 1024; // 4 GB
const VIDEO_EXT = new Set(['mp4', 'webm', 'mkv', 'mov', 'm4v', 'avi']);
const AUDIO_EXT = new Set(['mp3', 'm4a', 'aac', 'flac', 'ogg', 'opus', 'wav', 'wma', 'mp4a']);
const MIME = {
mp4: 'video/mp4', m4v: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska', mov: 'video/quicktime', avi: 'video/x-msvideo',
mp3: 'audio/mpeg', m4a: 'audio/mp4', aac: 'audio/aac', flac: 'audio/flac', ogg: 'audio/ogg', opus: 'audio/ogg', wav: 'audio/wav', wma: 'audio/x-ms-wma',
};
const extOf = (name) => (String(name).toLowerCase().match(/\.([a-z0-9]{1,5})$/) || [, ''])[1];
const baseName = (name) => String(name).replace(/\.[^.]+$/, '').replace(/[_]+/g, ' ').trim();
function run(bin, args) {
return new Promise((resolve, reject) => {
const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let out = '', err = '';
child.stdout.on('data', (d) => { out += d; });
child.stderr.on('data', (d) => { err = (err + d).slice(-2000); });
child.on('error', reject);
child.on('close', (code) => (code === 0 ? resolve(out) : reject(new Error(err.trim() || `${bin} exited ${code}`))));
});
}
// Lyrics can be tagged in many ways depending on the container/tagger.
function lyricsFromTags(probe) {
const pools = [probe.format && probe.format.tags, ...(probe.streams || []).map((s) => s.tags)].filter(Boolean);
for (const tags of pools) {
for (const [k, v] of Object.entries(tags)) {
if (/^(lyrics|unsyncedlyrics|usltext|©lyr|syncedlyrics)/i.test(k) && typeof v === 'string' && v.trim().length > 3) return v;
}
}
return '';
}
export function describeProbe(probe, fallbackName) {
const tags = (probe.format && probe.format.tags) || {};
const pick = (...keys) => { for (const k of keys) { const hit = Object.keys(tags).find((t) => t.toLowerCase() === k); if (hit && String(tags[hit]).trim()) return String(tags[hit]).trim(); } return ''; };
const streams = probe.streams || [];
const realVideo = streams.find((s) => s.codec_type === 'video' && !(s.disposition && s.disposition.attached_pic));
const cover = streams.find((s) => s.codec_type === 'video' && s.disposition && s.disposition.attached_pic);
const audio = streams.find((s) => s.codec_type === 'audio');
return {
kind: realVideo ? 'video' : 'audio',
hasAudio: !!audio,
coverIndex: cover ? cover.index : null,
title: pick('title') || baseName(fallbackName),
artist: pick('artist', 'album_artist', 'composer'),
album: pick('album'),
duration: Math.max(0, Number(probe.format && probe.format.duration) || 0),
lyrics: lyricsFromTags(probe),
};
}
export function registerUploadRoutes(app, deps) {
const { db, ffmpeg = 'ffmpeg', ffprobe = 'ffprobe', uploadDir, rangeFileResponse, requireAdminOrToken, parseLrc, sanitizeLyrics } = deps;
mkdirSync(uploadDir, { recursive: true });
const filePath = (u) => join(uploadDir, `${u.id}.${u.ext}`);
const artPath = (id) => join(uploadDir, `${id}.art.jpg`);
async function probeFile(path) {
const out = await run(ffprobe, ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', path]);
return JSON.parse(out);
}
app.post('/api/admin/uploads', requireAdminOrToken, async (c) => {
let body;
try { body = await c.req.parseBody(); } catch { return c.json({ ok: false, error: 'send the file as multipart/form-data' }, 400); }
const file = body.file;
if (!file || typeof file === 'string' || !file.name) return c.json({ ok: false, error: 'no file' }, 400);
if (file.size > MAX_BYTES) return c.json({ ok: false, error: 'file is larger than 4 GB' }, 413);
const ext = extOf(file.name);
if (!VIDEO_EXT.has(ext) && !AUDIO_EXT.has(ext)) {
return c.json({ ok: false, error: `unsupported file type ".${ext}" — video: ${[...VIDEO_EXT].join(', ')}; audio: ${[...AUDIO_EXT].join(', ')}` }, 415);
}
const id = 'upl_' + randomBytes(6).toString('hex');
const target = join(uploadDir, `${id}.${ext}`);
try {
await Bun.write(target, file);
const probe = await probeFile(target);
const info = describeProbe(probe, file.name);
if (!info.hasAudio && info.kind === 'audio') throw new Error('no audio or video streams found in this file');
// Cover art: an uploaded image wins, else the embedded picture.
let art = null;
const artFile = body.art;
if (artFile && typeof artFile !== 'string' && artFile.size) {
const tmp = join(uploadDir, `${id}.art.src`);
await Bun.write(tmp, artFile);
try {
await run(ffmpeg, ['-v', 'error', '-y', '-i', tmp, '-frames:v', '1', '-vf', "scale='min(800,iw)':-2", artPath(id)]);
art = 'file';
} catch { /* unusable image — ignore */ }
try { unlinkSync(tmp); } catch { /* gone */ }
}
if (!art && info.coverIndex !== null) {
try {
await run(ffmpeg, ['-v', 'error', '-y', '-i', target, '-map', `0:${info.coverIndex}`, '-frames:v', '1', '-vf', "scale='min(800,iw)':-2", artPath(id)]);
art = 'embedded';
} catch { /* cover we can't decode */ }
}
const row = {
id, kind: info.kind, ext, mime: MIME[ext] || (info.kind === 'video' ? 'video/mp4' : 'audio/mpeg'),
size: file.size, art,
title: String(body.title || info.title || baseName(file.name)).slice(0, 300),
artist: String(body.artist || info.artist || '').slice(0, 200),
album: String(body.album || info.album || '').slice(0, 200),
duration: info.duration,
};
await db.createUpload(row);
// Embedded lyrics → the song's shared lyrics (synced if they are LRC).
let lyricLines = 0;
if (info.lyrics) {
const lines = parseLrc(info.lyrics);
if (lines.length) {
const synced = lines.some((l) => l.t !== null);
const data = sanitizeLyrics({ lines, tags: [synced ? 'from the file (synced)' : 'from the file'], offset: 0 });
await db.saveNote({ videoId: id, kind: 'lyrics', data, source: 'auto', updatedBy: 'upload', force: true });
lyricLines = data.lines.length;
}
}
return c.json({ ok: true, upload: await db.getUpload(id), lyricLines });
} catch (err) {
try { unlinkSync(target); } catch { /* not written */ }
try { unlinkSync(artPath(id)); } catch { /* none */ }
return c.json({ ok: false, error: err.message }, 500);
}
});
app.delete('/api/admin/uploads/:id', requireAdminOrToken, async (c) => {
const id = c.req.param('id');
if (!UPLOAD_ID_RE.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
const u = await db.getUpload(id);
if (!u) return c.json({ ok: false, error: 'not found' }, 404);
await db.deleteUpload(id);
try { unlinkSync(filePath(u)); } catch { /* gone */ }
try { unlinkSync(artPath(id)); } catch { /* none */ }
return c.json({ ok: true });
});
app.get('/api/uploads', async (c) => {
const q = (c.req.query('q') || '').trim();
const limit = Math.min(200, Math.max(1, Number(c.req.query('limit')) || 100));
return c.json({ ok: true, uploads: await db.listUploads({ q, limit }) });
});
app.get('/api/uploads/:id', async (c) => {
const id = c.req.param('id');
if (!UPLOAD_ID_RE.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
const u = await db.getUpload(id);
if (!u || !existsSync(filePath(u))) return c.json({ ok: false, error: 'not found' }, 404);
db.touchUpload(id);
return rangeFileResponse(c, filePath(u), u.mime, 'public, max-age=31536000, immutable');
});
app.get('/api/uploads/:id/art', async (c) => {
const id = c.req.param('id');
if (!UPLOAD_ID_RE.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
const p = artPath(id);
if (!existsSync(p)) return c.json({ ok: false, error: 'no cover art' }, 404);
return rangeFileResponse(c, p, 'image/jpeg', 'public, max-age=31536000, immutable');
});
// /api/streams payload for an upload: one "Original" quality for video,
// audio + cover art for audio (the app shows the art where the video goes).
function streamsPayload(u) {
const art = u.art ? `/api/uploads/${u.id}/art` : '';
const url = `/api/uploads/${u.id}`;
return {
meta: { id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', channelId: '', channelUrl: '', duration: u.duration, thumbnail: art },
audioUrl: u.kind === 'audio' ? url : '',
qualities: u.kind === 'video' ? [{ label: 'Original', height: 0, hasAudio: true, url }] : [],
upload: true,
kind: u.kind,
art,
};
}
const card = (u) => ({
id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', channelId: '', channelUrl: '',
duration: u.duration, thumbnail: u.art ? `/api/uploads/${u.id}/art` : '', upload: true, kind: u.kind,
});
return { streamsPayload, card, filePath, artPath, isUploadId: (id) => UPLOAD_ID_RE.test(id) };
}

133
server/uploads.test.js Normal file
View File

@@ -0,0 +1,133 @@
// 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);
});
});