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:
@@ -211,7 +211,7 @@ export function verifyAdminCookie(secret, value, nowSec = Math.floor(Date.now()
|
||||
|
||||
export function registerNoteRoutes(app, deps) {
|
||||
const {
|
||||
db, getProfile, profileNameRe, runYtdlp, adminPassword, backupDir, adminHtmlPath,
|
||||
db, getProfile, profileNameRe, runYtdlp, adminPassword, backupDir, adminHtmlPath, workerToken,
|
||||
} = deps;
|
||||
const ADMIN_COOKIE = 'ytp_admin';
|
||||
const ADMIN_TTL = 30 * 24 * 3600;
|
||||
@@ -226,6 +226,8 @@ export function registerNoteRoutes(app, deps) {
|
||||
async function resolveWriter(c, body) {
|
||||
const m = (c.req.header('authorization') || '').match(/^Bearer\s+(\S+)$/i);
|
||||
if (m) {
|
||||
// The lyrics-worker container's shared token (env, never stored in the DB).
|
||||
if (workerToken && workerToken.length >= 24 && safeEqual(m[1], workerToken)) return { via: 'api', by: 'api:lyrics-worker' };
|
||||
const tok = await db.useApiToken(hashToken(m[1]));
|
||||
return tok ? { via: 'api', by: `api:${tok.label}` } : { invalid: true };
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ beforeAll(async () => {
|
||||
adminPassword: PASSWORD,
|
||||
backupDir: join(root, 'backups'),
|
||||
adminHtmlPath: htmlPath,
|
||||
workerToken: 'w'.repeat(32),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,6 +196,13 @@ describe('routes', () => {
|
||||
expect((await app.request('/api/admin/media')).status).toBe(401);
|
||||
expect((await app.request('/api/admin/media', { headers: auth })).status).toBe(200);
|
||||
|
||||
// The lyrics-worker's env token works without a DB row.
|
||||
const wk = { Authorization: `Bearer ${'w'.repeat(32)}` };
|
||||
expect((await app.request('/api/admin/media', { headers: wk })).status).toBe(200);
|
||||
res = await app.request(`/api/notes/${VID}/chapters`, json('PUT', { data: { items: [{ t: 5, title: 'W' }] }, baseRev: 0, force: true }, wk));
|
||||
expect(res.status).toBe(200);
|
||||
expect((await (await app.request(`/api/notes/${VID}`)).json()).chapters.updatedBy).toBe('api:lyrics-worker');
|
||||
|
||||
// Revoked tokens stop working.
|
||||
await app.request(`/api/admin/tokens/${created.id}`, { method: 'DELETE', headers: { Cookie: cookie } });
|
||||
res = await app.request(`/api/notes/${VID}/lyrics`, json('PUT', { data: { lines: [] }, baseRev: 4 }, auth));
|
||||
|
||||
@@ -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"
|
||||
"test": "bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./remote.test.js && bun test ./party.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.14.0",
|
||||
|
||||
170
server/party.js
Normal file
170
server/party.js
Normal file
@@ -0,0 +1,170 @@
|
||||
/* ============================================================================
|
||||
* party.js — watch party relay: synced playback, text chat, voice signalling
|
||||
*
|
||||
* One host drives playback; guests follow. The server only relays:
|
||||
* /ws/party?role=host[&code=…&secret=…]&pid=…&name=… create or resume a party
|
||||
* /ws/party?role=guest&code=…&pid=…&name=… join one
|
||||
*
|
||||
* Messages (JSON):
|
||||
* host → all {type:'state', state} (server stamps state.ts = server ms)
|
||||
* host → all {type:'settings', allowControl}
|
||||
* any → all {type:'chat', text} (server adds from/pid/at; ≤ 500 chars)
|
||||
* any → all {type:'voice', on} (voice-chat presence)
|
||||
* any → one {type:'rtc', to, data} (WebRTC offer/answer/ICE, relayed)
|
||||
* guest → host {type:'cmd', cmd, …} (only when the host allows control)
|
||||
* server → you {type:'hello', code, secret?, you, now, members, state, chat, allowControl}
|
||||
* server → all {type:'members', members} · {type:'ended'}
|
||||
* A party survives its host dropping for PARTY_GRACE_MS (a reload, a flaky
|
||||
* link); the host resumes it with the secret it was given.
|
||||
* ========================================================================== */
|
||||
|
||||
import { randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O, 1/I
|
||||
const PARTY_GRACE_MS = 5 * 60_000;
|
||||
const CHAT_KEEP = 60;
|
||||
const MAX_MSG = 64 * 1024;
|
||||
export const PARTY_COMMANDS = new Set(['toggle', 'play', 'pause', 'seek', 'next', 'prev']);
|
||||
|
||||
const clean = (v, max, fallback = '') =>
|
||||
String(v || '').replace(/[\u0000-\u001f\u007f<>]+/g, ' ').trim().slice(0, max) || fallback;
|
||||
const same = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && timingSafeEqual(x, y); };
|
||||
|
||||
export function createPartyHub({ now = () => Date.now() } = {}) {
|
||||
const parties = new Map(); // code → party
|
||||
const joinFails = new Map();
|
||||
|
||||
const send = (ws, m) => { try { ws.send(JSON.stringify(m)); } catch { /* gone */ } };
|
||||
const members = (p) => [...p.members.values()].map((m) => ({ pid: m.pid, name: m.name, host: m.pid === p.hostPid, voice: !!m.voice }));
|
||||
const broadcast = (p, m, except) => { for (const x of p.members.values()) if (x.ws !== except) send(x.ws, m); };
|
||||
|
||||
function newCode() {
|
||||
let c;
|
||||
do { c = Array.from({ length: 6 }, () => ALPHABET[randomInt(ALPHABET.length)]).join(''); } while (parties.has(c));
|
||||
return c;
|
||||
}
|
||||
|
||||
const sweeper = setInterval(() => {
|
||||
const t = now();
|
||||
for (const [code, p] of parties) {
|
||||
if (!p.hostOnline && t - p.hostLeftAt > PARTY_GRACE_MS) {
|
||||
broadcast(p, { type: 'ended', reason: 'The host left' });
|
||||
for (const m of p.members.values()) { try { m.ws.close(4010, 'party ended'); } catch { /* gone */ } }
|
||||
parties.delete(code);
|
||||
}
|
||||
}
|
||||
}, 30_000);
|
||||
sweeper.unref?.();
|
||||
|
||||
function upgrade(req, server, ip) {
|
||||
const u = new URL(req.url);
|
||||
const q = (k) => u.searchParams.get(k) || '';
|
||||
const role = q('role');
|
||||
const pid = q('pid');
|
||||
if (!/^[A-Za-z0-9_-]{8,40}$/.test(pid)) return new Response('bad pid', { status: 400 });
|
||||
const name = clean(q('name'), 40, 'Guest');
|
||||
const code = q('code').toUpperCase();
|
||||
if (role === 'guest') {
|
||||
const t = now();
|
||||
const fails = (joinFails.get(ip) || []).filter((x) => t - x < 10 * 60_000);
|
||||
if (fails.length >= 20) return new Response('too many attempts', { status: 429 });
|
||||
if (!parties.has(code)) {
|
||||
fails.push(t); joinFails.set(ip, fails);
|
||||
if (joinFails.size > 5000) joinFails.clear();
|
||||
return new Response('no such party', { status: 404 });
|
||||
}
|
||||
} else if (role !== 'host') {
|
||||
return new Response('unknown role', { status: 400 });
|
||||
}
|
||||
const ok = server.upgrade(req, { data: { hub: 'party', role, pid, name, code, secret: q('secret') } });
|
||||
return ok ? undefined : new Response('upgrade failed', { status: 400 });
|
||||
}
|
||||
|
||||
function open(ws) {
|
||||
const d = ws.data;
|
||||
let p;
|
||||
if (d.role === 'host') {
|
||||
p = d.code && parties.get(d.code);
|
||||
if (p && !same(p.secret, d.secret)) { ws.close(4003, 'not the host of this party'); return; }
|
||||
if (!p) {
|
||||
const code = newCode();
|
||||
p = { code, secret: randomBytes(18).toString('base64url'), hostPid: d.pid, members: new Map(), state: null, chat: [], allowControl: false, hostOnline: true, hostLeftAt: 0 };
|
||||
parties.set(code, p);
|
||||
}
|
||||
p.hostPid = d.pid;
|
||||
p.hostOnline = true;
|
||||
d.code = p.code;
|
||||
} else {
|
||||
p = parties.get(d.code);
|
||||
if (!p) { ws.close(4004, 'no such party'); return; }
|
||||
}
|
||||
const prev = p.members.get(d.pid);
|
||||
if (prev && prev.ws !== ws) { try { prev.ws.close(4000, 'replaced'); } catch { /* gone */ } }
|
||||
p.members.set(d.pid, { ws, pid: d.pid, name: d.name, voice: false, lastChat: 0 });
|
||||
send(ws, {
|
||||
type: 'hello', code: p.code, secret: d.role === 'host' ? p.secret : undefined, you: d.pid,
|
||||
now: now(), members: members(p), state: p.state, chat: p.chat, allowControl: p.allowControl,
|
||||
});
|
||||
broadcast(p, { type: 'members', members: members(p), joined: d.name }, ws);
|
||||
}
|
||||
|
||||
function message(ws, raw) {
|
||||
if (typeof raw !== 'string' || raw.length > MAX_MSG) return;
|
||||
let m;
|
||||
try { m = JSON.parse(raw); } catch { return; }
|
||||
const d = ws.data;
|
||||
const p = parties.get(d.code);
|
||||
const me = p && p.members.get(d.pid);
|
||||
if (!me || me.ws !== ws || !m || typeof m !== 'object') return;
|
||||
const isHost = d.pid === p.hostPid;
|
||||
if (m.type === 'state' && isHost) {
|
||||
p.state = { ...(m.state || {}), ts: now() };
|
||||
broadcast(p, { type: 'state', state: p.state }, ws);
|
||||
} else if (m.type === 'settings' && isHost) {
|
||||
p.allowControl = !!m.allowControl;
|
||||
broadcast(p, { type: 'settings', allowControl: p.allowControl });
|
||||
} else if (m.type === 'chat') {
|
||||
const t = now();
|
||||
if (t - me.lastChat < 400) return; // flood guard
|
||||
me.lastChat = t;
|
||||
const text = clean(m.text, 500);
|
||||
if (!text) return;
|
||||
const msg = { from: me.name, pid: me.pid, text, at: t };
|
||||
p.chat.push(msg);
|
||||
if (p.chat.length > CHAT_KEEP) p.chat.shift();
|
||||
broadcast(p, { type: 'chat', msg });
|
||||
} else if (m.type === 'voice') {
|
||||
me.voice = !!m.on;
|
||||
broadcast(p, { type: 'members', members: members(p) });
|
||||
} else if (m.type === 'rtc' && typeof m.to === 'string') {
|
||||
const target = p.members.get(m.to);
|
||||
if (target) send(target.ws, { type: 'rtc', from: me.pid, data: m.data });
|
||||
} else if (m.type === 'cmd' && !isHost && p.allowControl && PARTY_COMMANDS.has(m.cmd)) {
|
||||
const host = p.members.get(p.hostPid);
|
||||
const { type, ...args } = m;
|
||||
if (host) send(host.ws, { type: 'cmd', from: me.name, ...args });
|
||||
} else if (m.type === 'end' && isHost) {
|
||||
broadcast(p, { type: 'ended', reason: 'The host ended the party' });
|
||||
for (const x of p.members.values()) { try { x.ws.close(4010, 'party ended'); } catch { /* gone */ } }
|
||||
parties.delete(p.code);
|
||||
}
|
||||
}
|
||||
|
||||
function close(ws) {
|
||||
const d = ws.data;
|
||||
const p = parties.get(d.code);
|
||||
if (!p) return;
|
||||
const me = p.members.get(d.pid);
|
||||
if (!me || me.ws !== ws) return;
|
||||
p.members.delete(d.pid);
|
||||
if (d.pid === p.hostPid) { p.hostOnline = false; p.hostLeftAt = now(); }
|
||||
broadcast(p, { type: 'members', members: members(p), left: d.name, hostAway: !p.hostOnline });
|
||||
}
|
||||
|
||||
return {
|
||||
upgrade,
|
||||
websocket: { maxPayloadLength: MAX_MSG, idleTimeout: 120, open, message: (ws, msg) => message(ws, typeof msg === 'string' ? msg : Buffer.from(msg).toString('utf8')), close },
|
||||
_parties: parties,
|
||||
stop() { clearInterval(sweeper); },
|
||||
};
|
||||
}
|
||||
117
server/party.test.js
Normal file
117
server/party.test.js
Normal file
@@ -0,0 +1,117 @@
|
||||
// Watch-party relay: real Bun server + real WebSocket clients.
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { createPartyHub } from './party.js';
|
||||
|
||||
let hub, server, base;
|
||||
beforeAll(() => {
|
||||
hub = createPartyHub();
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req, srv) {
|
||||
if (new URL(req.url).pathname === '/ws/party') return hub.upgrade(req, srv, '203.0.113.9');
|
||||
return new Response('nope', { status: 404 });
|
||||
},
|
||||
websocket: hub.websocket,
|
||||
});
|
||||
base = `localhost:${server.port}`;
|
||||
});
|
||||
afterAll(() => { server.stop(true); hub.stop(); });
|
||||
|
||||
function client(query) {
|
||||
const ws = new WebSocket(`ws://${base}/ws/party?${new URLSearchParams(query)}`);
|
||||
const inbox = [], waiters = [];
|
||||
ws.onmessage = (e) => {
|
||||
const m = JSON.parse(e.data);
|
||||
const i = waiters.findIndex((w) => w.type === m.type);
|
||||
if (i >= 0) waiters.splice(i, 1)[0].resolve(m); else inbox.push(m);
|
||||
};
|
||||
const closed = new Promise((r) => { ws.onclose = (e) => r(e.code); });
|
||||
const errored = new Promise((r) => { ws.onerror = () => r(true); });
|
||||
return {
|
||||
ws, closed, errored,
|
||||
send: (m) => ws.send(JSON.stringify(m)),
|
||||
next(type, ms = 2000) {
|
||||
const i = inbox.findIndex((m) => m.type === type);
|
||||
if (i >= 0) return Promise.resolve(inbox.splice(i, 1)[0]);
|
||||
return new Promise((resolve, reject) => {
|
||||
const w = { type, resolve };
|
||||
waiters.push(w);
|
||||
setTimeout(() => { const k = waiters.indexOf(w); if (k >= 0) { waiters.splice(k, 1); reject(new Error('timeout ' + type)); } }, ms);
|
||||
});
|
||||
},
|
||||
drain(type) { for (let i = inbox.length - 1; i >= 0; i--) if (inbox[i].type === type) inbox.splice(i, 1); },
|
||||
};
|
||||
}
|
||||
|
||||
describe('watch party', () => {
|
||||
let host, guest, code, secret;
|
||||
|
||||
test('host creates a party with a 6-char code', async () => {
|
||||
host = client({ role: 'host', pid: 'hostpid0001', name: 'Josh' });
|
||||
const hello = await host.next('hello');
|
||||
expect(hello.code).toMatch(/^[A-HJ-NP-Z2-9]{6}$/);
|
||||
expect(hello.secret.length).toBeGreaterThan(10);
|
||||
code = hello.code; secret = hello.secret;
|
||||
});
|
||||
|
||||
test('unknown code is refused; guest joins and gets the state + chat history', async () => {
|
||||
const bad = client({ role: 'guest', pid: 'guestpid9999', name: 'X', code: 'ZZZZZZ' });
|
||||
expect(await Promise.race([bad.errored, bad.closed.then(() => true)])).toBe(true);
|
||||
host.send({ type: 'state', state: { v: { id: 'rXFZsRhdT78', title: 'As the Deer' }, cur: 12, paused: false, rate: 1 } });
|
||||
host.send({ type: 'chat', text: 'welcome!' });
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
guest = client({ role: 'guest', pid: 'guestpid0001', name: 'Rex', code: code.toLowerCase() });
|
||||
const hello = await guest.next('hello');
|
||||
expect(hello.state.v.id).toBe('rXFZsRhdT78');
|
||||
expect(typeof hello.state.ts).toBe('number');
|
||||
expect(hello.chat.map((c) => c.text)).toEqual(['welcome!']);
|
||||
expect(hello.members.map((m) => m.name).sort()).toEqual(['Josh', 'Rex']);
|
||||
expect(hello.secret).toBeUndefined();
|
||||
const m = await host.next('members');
|
||||
expect(m.joined).toBe('Rex');
|
||||
});
|
||||
|
||||
test('state, chat (flood-guarded), voice presence and targeted rtc relay', async () => {
|
||||
host.send({ type: 'state', state: { v: { id: 'x' }, cur: 30, paused: true } });
|
||||
expect((await guest.next('state')).state.cur).toBe(30);
|
||||
host.drain('chat'); // its own "welcome!" echo
|
||||
guest.send({ type: 'chat', text: 'hi <b>all</b>' });
|
||||
guest.send({ type: 'chat', text: 'spam' }); // < 400 ms later → dropped
|
||||
const c = await host.next('chat');
|
||||
expect(c.msg).toMatchObject({ from: 'Rex', text: 'hi b all /b' });
|
||||
await expect(host.next('chat', 300)).rejects.toThrow();
|
||||
guest.send({ type: 'voice', on: true });
|
||||
const mem = await host.next('members');
|
||||
expect(mem.members.find((x) => x.name === 'Rex').voice).toBe(true);
|
||||
guest.send({ type: 'rtc', to: 'hostpid0001', data: { sdp: 'offer' } });
|
||||
expect(await host.next('rtc')).toEqual({ type: 'rtc', from: 'guestpid0001', data: { sdp: 'offer' } });
|
||||
});
|
||||
|
||||
test('guest commands reach the host only when control is allowed', async () => {
|
||||
guest.send({ type: 'cmd', cmd: 'toggle' });
|
||||
await expect(host.next('cmd', 300)).rejects.toThrow();
|
||||
host.send({ type: 'settings', allowControl: true });
|
||||
expect((await guest.next('settings')).allowControl).toBe(true);
|
||||
guest.send({ type: 'cmd', cmd: 'seek', t: 50 });
|
||||
guest.send({ type: 'cmd', cmd: 'rm' });
|
||||
const cmd = await host.next('cmd');
|
||||
expect(cmd).toMatchObject({ cmd: 'seek', t: 50, from: 'Rex' });
|
||||
});
|
||||
|
||||
test('host drops and resumes with its secret; a stranger cannot take over', async () => {
|
||||
guest.drain('members');
|
||||
host.ws.close();
|
||||
const away = await guest.next('members');
|
||||
expect(away.hostAway).toBe(true);
|
||||
const thief = client({ role: 'host', pid: 'thief000001', name: 'T', code, secret: 'wrong' });
|
||||
expect(await thief.closed).toBe(4003);
|
||||
host = client({ role: 'host', pid: 'hostpid0001', name: 'Josh', code, secret });
|
||||
expect((await host.next('hello')).code).toBe(code);
|
||||
});
|
||||
|
||||
test('host ends the party', async () => {
|
||||
host.send({ type: 'end' });
|
||||
expect((await guest.next('ended')).reason).toContain('ended');
|
||||
expect(await guest.closed).toBe(4010);
|
||||
});
|
||||
});
|
||||
123
server/server.js
123
server/server.js
@@ -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 —
|
||||
|
||||
Reference in New Issue
Block a user