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

@@ -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
//