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

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