Add presenter view, stats, lyrics worker, watch party with voice chat, timestamp sharing, soundbites, notes, transcript search, PiP, EQ, sleep fade, gestures and external players; fix the crossfade end-of-song race

This commit is contained in:
Jonathan Sykes
2026-09-20 00:07:12 +08:00
parent ca07e1fdf1
commit 9c1ec4ac51
17 changed files with 2912 additions and 111 deletions

View File

@@ -13,6 +13,8 @@
* GET /api/download/:videoId server-cached copy (fetched by a background job) → binary
* GET /api/media/:id?g=<gen>[&a=1] Range-aware server-cached mp4 (or m4a audio sidecar)
* GET /api/media/:id/peaks loudness envelope (400 buckets, 0..100) for the waveform seek bar
* GET /api/media/:id/gif?t=&d=&w= short looping GIF of a moment (timestamp sharing)
* GET /api/media/:id/clip?start=&end=&fmt=mp3|m4r soundbite / iPhone ringtone
* GET /api/media/:id/status { status, size, height, optimized, error }
* POST /api/media/:id/redownload "Broken" button: drop the copy, fetch it again
* GET /api/media/stats cache totals, budget, free disk, queue
@@ -43,6 +45,7 @@ import { createMediaCache, HIGH, LOW } from './media-cache.js';
import * as notesDb from './db.js';
import { registerNoteRoutes } from './notes.js';
import { createRemoteHub } from './remote.js';
import { createPartyHub } from './party.js';
import QRCode from 'qrcode';
import { dirname, join as pathJoin } from 'node:path';
@@ -1120,6 +1123,103 @@ app.get('/api/media/:id/peaks', async (c) => {
return c.json({ ok: true, ...hit }, 200, { 'Cache-Control': 'public, max-age=3600' });
});
// GET /api/media/:id/gif?t=<sec>&d=<sec>&w=<px> — a short looping GIF of an
// exact moment, cut from the server-cached copy (timestamp sharing). Palette
// is generated per clip (palettegen/paletteuse) so it stays small and clean.
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);
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');
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);
if (!gif) {
if (gifRunning >= 2) return c.json({ ok: false, error: 'busy — try again in a moment' }, 503);
gifRunning++;
try {
gif = await new Promise((resolve, reject) => {
const child = spawn(FFMPEG, ['-v', 'error', '-ss', t.toFixed(2), '-t', String(d), '-i', path, '-an',
'-vf', `fps=12,scale=${w}:-2:flags=lanczos,split[a][b];[a]palettegen=max_colors=128:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4`,
'-loop', '0', '-f', 'gif', 'pipe:1'], { stdio: ['ignore', 'pipe', 'pipe'] });
const chunks = [];
let err = '';
child.stdout.on('data', (b) => chunks.push(b));
child.stderr.on('data', (b) => { err = (err + b).slice(-1500); });
child.on('error', reject);
child.on('close', (code) => {
const buf = Buffer.concat(chunks);
if (code !== 0 || buf.length < 100) reject(new Error(err.trim() || 'could not make the GIF'));
else resolve(buf);
});
});
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
} finally {
gifRunning--;
}
gifCache.set(key, gif);
if (gifCache.size > 40) gifCache.delete(gifCache.keys().next().value);
}
return c.body(gif, 200, {
'Content-Type': 'image/gif',
'Cache-Control': 'public, max-age=86400',
'Content-Disposition': `inline; filename="${id}-${Math.floor(t)}s.gif"`,
});
});
// GET /api/media/:id/clip?start=&end=&fmt=mp3|m4r — a soundbite of the
// server-cached audio with short fades: MP3 (≤ 60 s) or an iPhone ringtone
// (.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);
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'));
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}`;
const args = ['-v', 'error', '-ss', start.toFixed(2), '-t', len.toFixed(2), '-i', path, '-vn', '-af', af];
// A ringtone must be a regular (non-fragmented) MP4 for iPhone imports,
// which needs a seekable output — so .m4r goes through a temp file.
const tmpOut = fmt === 'm4r' ? `${tmpdir()}/ytp-clip-${id}-${Date.now()}-${Math.random().toString(36).slice(2)}.m4r` : null;
if (fmt === 'mp3') args.push('-c:a', 'libmp3lame', '-b:a', '192k', '-f', 'mp3', 'pipe:1');
else args.push('-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', '-f', 'ipod', '-y', tmpOut);
try {
const buf = await new Promise((resolve, reject) => {
const child = spawn(FFMPEG, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const chunks = [];
let err = '';
child.stdout.on('data', (b) => chunks.push(b));
child.stderr.on('data', (b) => { err = (err + b).slice(-1500); });
child.on('error', reject);
child.on('close', (code) => {
let out = Buffer.concat(chunks);
if (tmpOut) {
try { out = readFileSync(tmpOut); } catch { out = Buffer.alloc(0); }
try { unlinkSync(tmpOut); } catch { /* gone */ }
}
if (code !== 0 || out.length < 200) reject(new Error(err.trim() || 'could not cut the soundbite'));
else resolve(out);
});
});
return c.body(buf, 200, {
'Content-Type': fmt === 'mp3' ? 'audio/mpeg' : 'audio/mp4',
'Content-Disposition': `attachment; filename="${id}-${Math.floor(start)}s.${fmt}"`,
'Cache-Control': 'public, max-age=86400',
});
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
app.get('/api/media/:id/status', async (c) => {
try {
return c.json({ ok: true, ...(await media.status(c.req.param('id'))) }, 200, { 'Cache-Control': 'no-store' });
@@ -1617,6 +1717,18 @@ app.get('/api/user/data', async (c) => {
// Phone remote for a desktop instance — see remote.js
// ============================================================================
const remote = createRemoteHub({ requireSameNetwork: process.env.REMOTE_SAME_NETWORK === '1' });
const party = createPartyHub();
// Bun allows ONE websocket handler per server: party sockets are tagged
// (ws.data.hub === 'party'), everything else belongs to the remote relay.
const pickHub = (ws) => (ws.data && ws.data.hub === 'party' ? party.websocket : remote.websocket);
const websocketHandler = {
maxPayloadLength: 256 * 1024,
idleTimeout: 120,
open: (ws) => pickHub(ws).open(ws),
message: (ws, msg) => pickHub(ws).message(ws, msg),
close: (ws, code, reason) => pickHub(ws).close(ws, code, reason),
};
// The public IP a request came from. Behind Traefik that is the first
// X-Forwarded-For hop; locally, the socket address.
@@ -1640,7 +1752,9 @@ app.get('/api/remote/qr/:code', async (c) => {
if (!/^\d{6}$/.test(code)) return c.text('bad code', 400);
const proto = c.req.header('x-forwarded-proto') || new URL(c.req.url).protocol.replace(':', '');
const host = c.req.header('x-forwarded-host') || c.req.header('host') || new URL(c.req.url).host;
const svg = await QRCode.toString(`${proto}://${host}/?pair=${code}`, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' });
// ?kind=present → the presenter-screen link instead of the phone-remote one.
const param = c.req.query('kind') === 'present' ? 'present' : 'pair';
const svg = await QRCode.toString(`${proto}://${host}/?${param}=${code}`, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' });
return c.body(svg, 200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-store' });
});
@@ -1655,6 +1769,7 @@ const notes = registerNoteRoutes(app, {
adminPassword: process.env.ADMIN_PASSWORD || '',
backupDir: pathJoin(dirname(process.env.DB_PATH || './data/ytplayer.db'), 'backups'),
adminHtmlPath: './public/admin.html',
workerToken: process.env.LYRICS_WORKER_TOKEN || '',
});
// ============================================================================
@@ -1734,10 +1849,12 @@ async function main() {
port: PORT,
// /ws/remote is upgraded here, before Hono — see remote.js.
fetch(req, server) {
if (new URL(req.url).pathname === '/ws/remote') return remote.upgrade(req, server, clientIpOf(req, server));
const path = new URL(req.url).pathname;
if (path === '/ws/remote') return remote.upgrade(req, server, clientIpOf(req, server));
if (path === '/ws/party') return party.upgrade(req, server, clientIpOf(req, server));
return app.fetch(req, server);
},
websocket: remote.websocket,
websocket: websocketHandler,
// Default is 10s, which killed /api/download proxy streams whenever the
// connection went idle mid-transfer. 240s then killed every save whose
// server-side yt-dlp phase (no bytes sent yet) ran longer than 4 min —