diff --git a/server/server.js b/server/server.js index bd4e2bb..de779f1 100644 --- a/server/server.js +++ b/server/server.js @@ -34,6 +34,11 @@ import { tmpdir } from 'node:os'; import { createHash } from 'node:crypto'; import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, createSharedPlaylist, getSharedPlaylist } from './db.js'; +// A media proxy must not die because one client's stream hit an edge case +// (see /api/play cancel()): log and keep serving instead of crash-looping. +process.on('uncaughtException', (err) => console.error('[ytplayer] uncaught exception:', err)); +process.on('unhandledRejection', (err) => console.error('[ytplayer] unhandled rejection:', err)); + 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'; @@ -597,6 +602,13 @@ app.get('/api/play', async (c) => { const length = end - start + 1; const fmt = attempt.fmt; + // Client-disconnect handling: the active upstream body is LOCKED by its + // reader, so `body.cancel()` on it throws ("Cannot cancel a locked + // ReadableStream") — and an exception thrown from cancel() took the whole + // Bun process down on every seek / quality switch / aborted fetch. Cancel + // through the reader instead, and stop the slice loop via a flag. + let aborted = false; + let reader = null; const body = new ReadableStream({ async start(ctrl) { // Upstream body is a slice already; the slice boundaries below are @@ -604,15 +616,15 @@ app.get('/api/play', async (c) => { let pos = start; let res = up; try { - while (pos <= end) { + while (pos <= end && !aborted) { const sliceEnd = Math.min(end, pos + PLAY_CHUNK - 1); if (res === null) { res = await fetch(fmt.url, { headers: { ...fmt.headers, Range: `bytes=${pos}-${sliceEnd}` }, redirect: 'follow' }); if (res.status !== 206 && res.status !== 200) throw new Error('upstream ' + res.status); } - const reader = res.body.getReader(); + reader = res.body.getReader(); let got = 0; - while (got < sliceEnd - pos + 1) { + while (got < sliceEnd - pos + 1 && !aborted) { const { value, done } = await reader.read(); if (done) break; const room = sliceEnd - pos + 1 - got; @@ -620,17 +632,23 @@ app.get('/api/play', async (c) => { ctrl.enqueue(chunk); got += chunk.length; } - try { reader.cancel(); } catch {} + try { await reader.cancel(); } catch {} + reader = null; + if (aborted) break; if (got === 0) throw new Error('upstream returned no bytes'); pos += got; res = null; } - ctrl.close(); + if (!aborted) ctrl.close(); } catch (err) { - ctrl.error(err); + if (!aborted) { try { ctrl.error(err); } catch {} } } }, - cancel() { try { up.body?.cancel(); } catch {} }, + cancel() { + aborted = true; + const r = reader; + if (r) r.cancel().catch(() => {}); + }, }); headers.set('Content-Length', String(length));