Files
ytplayer/server/server.js

1254 lines
51 KiB
JavaScript

/* ============================================================================
* server.js — YT Player PWA backend
*
* Runtime: Bun (https://bun.sh)
* Framework: Hono v4
* DB: libsql (concurrent SQLite fork, embedded file mode)
*
* Endpoints:
* GET /api/search?q=<query> yt-dlp search → slim card array
* GET /api/channel?c=<channel> yt-dlp channel uploads → slim card array
* GET /api/streams?v=<videoId> yt-dlp stream info → {meta, audioUrl, qualities} (proxied URLs)
* GET /api/play?v=<id>&f=<fmt> same-origin playback proxy (Range-aware) → media bytes
* GET /api/download/:videoId proxy best progressive stream → binary
* GET /api/version { version }
* POST /api/user/sync upsert user playlists + last-seen version
* GET /api/user/data?fp=<fp> retrieve stored playlists + history
* POST /api/playlist/share share a single playlist → { ok, code }
* GET /api/playlist/shared?code=<code> retrieve shared playlist → { ok, code, playlist, createdAt }
* GET /api/playlist/expand?url=<url> expand YouTube playlist → { ok, title, entries, truncated? }
* GET /* serve frontend/public static files
*
* JSON shapes mirror the Tauri (Rust) bridge exactly so the existing app.js
* UI code works without modification in WEB mode.
* ========================================================================== */
import { Hono } from 'hono';
import { serveStatic } from 'hono/bun';
import { logger } from 'hono/logger';
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { readFileSync, readdirSync, statSync, openSync, unlinkSync, createReadStream } from 'node:fs';
import { Readable } from 'node:stream';
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';
const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg';
// 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
// concurrent playback (stalled at ~27 s, 11 KB/s). Leave headroom.
const DOWNLOAD_RATE = process.env.DOWNLOAD_RATE || '2M';
// Saves run ONE AT A TIME. Two concurrent rate-capped saves plus playback
// still filled the homelab's ~7.5 MB/s downlink and playback starved, so
// additional saves wait their turn (the client just sees a longer save).
let saveChain = Promise.resolve();
function withSaveSlot(fn) {
const run = saveChain.then(fn, fn);
saveChain = run.catch(() => {});
return run;
}
// ----------------------------------------------------------------------------
// BUILD_TAG — must be DETERMINISTIC across restarts of identical code.
//
// Previously this was `Date.now().toString(36)`, which changes every time the
// process starts even if nothing was deployed (crash-loop, healthcheck
// restart, container reschedule). The frontend's checkBuildTag() polls
// /api/version and re-shows the "Update available" modal the instant the tag
// drifts — so a restarting-but-unchanged server kept re-announcing an update
// that never actually happened, and clicking "Refresh UI" (which itself
// reloads the page and re-polls) never made the prompt go away for good.
//
// Fix: hash the actual served frontend files. Identical code → identical
// hash → identical tag, no matter how many times the process restarts. A
// real deploy (changed files) still produces a new tag as intended.
// process.env.BUILD_TAG still wins if a CI pipeline already injects a git
// SHA — that's an even better source of truth than a content hash.
// ----------------------------------------------------------------------------
function computeBuildTag() {
try {
// Hash EVERY served frontend file (recursively, in sorted order), not a
// hand-picked subset — a change to any shell file (e.g. sw-update.js or
// opfs.js) must produce a new tag, or clients keep their old SW cache
// and never receive the change.
const hash = createHash('sha256');
const walk = (dir) => {
for (const name of readdirSync(dir).sort()) {
const path = `${dir}/${name}`;
if (statSync(path).isDirectory()) walk(path);
else { hash.update(path); hash.update(readFileSync(path)); }
}
};
walk('./public');
return hash.digest('hex').slice(0, 12);
} catch {
// Frontend files not readable (e.g. unit tests run outside ./public) —
// fall back to a fixed tag rather than Date.now(), so it still never
// drifts spuriously between restarts.
return 'dev-build';
}
}
const BUILD_TAG = process.env.BUILD_TAG || computeBuildTag();
// BUILD_TIME — human-readable "when was this image built". Written by the
// Dockerfile at image build time (never at container start, so restarts
// don't drift it). Kept OUTSIDE ./public so it can't perturb BUILD_TAG.
const BUILD_TIME = process.env.BUILD_TIME || (() => {
try { return readFileSync('./build-time.txt', 'utf8').trim(); }
catch { return null; }
})();
const SEARCH_LIMIT = 25;
const CHANNEL_LIMIT = 60;
// ============================================================================
// yt-dlp helpers
// ============================================================================
// Run yt-dlp asynchronously and resolve stdout as a string.
// MUST stay async (spawn, not spawnSync): a sync child process blocks Bun's
// event loop for the full yt-dlp runtime (~2-3s per call), which stalls every
// concurrent request — including in-flight /api/download proxy streams, which
// Bun then kills at its idle timeout ("fetch failed" mid-download on clients).
// Rejects on non-zero exit.
function runYtdlp(args, { signal } = {}) {
return new Promise((resolve, reject) => {
const child = spawn(YTDLP, args, { stdio: ['ignore', 'pipe', 'pipe'] });
// Kill the download when the requesting client goes away — otherwise an
// aborted/retried save leaves yt-dlp running to completion (8 copies of
// one video were found pulling in parallel after the client retried).
if (signal) {
if (signal.aborted) child.kill('SIGTERM');
else signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
}
let out = '';
let err = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (d) => { out += d; });
child.stderr.on('data', (d) => { err += d; });
child.on('error', (e) => reject(new Error('yt-dlp not found: ' + e.message)));
child.on('close', (code) => {
if (code !== 0) reject(new Error(err.trim() || 'yt-dlp exited with code ' + code));
else resolve(out);
});
});
}
// Run ffmpeg the same way — async spawn so a multi-minute trim/concat never
// blocks Bun's event loop. Rejects on non-zero exit with ffmpeg's stderr tail.
function runFfmpeg(args) {
return new Promise((resolve, reject) => {
const child = spawn(FFMPEG, args, { stdio: ['ignore', 'ignore', 'pipe'] });
let err = '';
child.stderr.setEncoding('utf8');
// ffmpeg is extremely chatty on stderr; keep only the tail so an error
// message stays useful without buffering the whole progress log.
child.stderr.on('data', (d) => { err = (err + d).slice(-4000); });
child.on('error', (e) => reject(new Error('ffmpeg not found: ' + e.message)));
child.on('close', (code) => {
if (code !== 0) reject(new Error(err.trim() || 'ffmpeg exited with code ' + code));
else resolve();
});
});
}
// Parse the compact "s-e,s-e" keep-segment string (see frontend/video-edit.js)
// into an array of {start,end} second ranges. Skips malformed / non-increasing
// tokens; returns [] on empty or all-garbage input. Kept in lockstep with the
// frontend parseKeepParam so both ends agree on the wire format.
function parseKeepParam(str) {
if (typeof str !== 'string') return [];
const out = [];
for (const tok of str.split(',')) {
const t = tok.trim();
if (!t) continue;
const m = t.match(/^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$/);
if (!m) continue;
const a = parseFloat(m[1]);
const b = parseFloat(m[2]);
if (!isFinite(a) || !isFinite(b) || b <= a) continue;
out.push({ start: a, end: b });
}
return out;
}
// Build an ffmpeg filter_complex that trims `src` to the keep segments and
// concatenates them back into a single continuous stream. Re-encodes (the cut
// points rarely fall on keyframes, so stream-copy would glitch), producing one
// clean mp4. Returns the ffmpeg argv (input already appended by the caller).
function buildTrimArgs(keep) {
const parts = [];
keep.forEach((k, i) => {
parts.push(
`[0:v]trim=start=${k.start}:end=${k.end},setpts=PTS-STARTPTS[v${i}]`,
`[0:a]atrim=start=${k.start}:end=${k.end},asetpts=PTS-STARTPTS[a${i}]`,
);
});
const concatInputs = keep.map((_, i) => `[v${i}][a${i}]`).join('');
const filter = parts.join(';') + ';' +
`${concatInputs}concat=n=${keep.length}:v=1:a=1[outv][outa]`;
return [
'-filter_complex', filter,
'-map', '[outv]', '-map', '[outa]',
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20',
'-c:a', 'aac', '-b:a', '160k',
'-movflags', '+faststart',
];
}
// Helpers to pick the right field from a yt-dlp JSON record
function pick(obj, ...keys) {
for (const k of keys) {
const v = obj[k];
if (v && typeof v === 'string' && v.trim()) return v.trim();
}
return '';
}
function pickChannel(obj) { return pick(obj, 'channel', 'uploader'); }
function pickChannelUrl(obj) { return pick(obj, 'channel_url', 'uploader_url'); }
function pickChannelId(obj) { return pick(obj, 'channel_id', 'uploader_id'); }
// Normalise a flat-playlist yt-dlp record into the slim UI card shape
function slimEntry(j) {
const id = pick(j, 'id');
if (!id) return null;
return {
id,
title: pick(j, 'title') || '(untitled)',
channel: pickChannel(j),
channelId: pickChannelId(j),
channelUrl: pickChannelUrl(j),
duration: typeof j.duration === 'number' ? j.duration : 0,
thumbnail: `https://i.ytimg.com/vi/${id}/mqdefault.jpg`,
};
}
// Parse multi-line JSON output from yt-dlp --dump-json --flat-playlist
function parseCards(output) {
const results = [];
for (const line of output.split('\n')) {
const t = line.trim();
if (!t) continue;
try {
const j = JSON.parse(t);
const card = slimEntry(j);
if (card) results.push(card);
} catch { /* skip malformed lines */ }
}
return results;
}
// Resolve a channel identifier to a /videos URL yt-dlp can fetch
function channelToUrl(c) {
let base = c.trim();
if (!base.startsWith('http')) {
base = base.startsWith('@') ? `https://www.youtube.com/${base}`
: base.startsWith('UC') ? `https://www.youtube.com/channel/${base}`
: `https://www.youtube.com/@${base}`;
}
base = base.replace(/\/$/, '');
return base.endsWith('/videos') ? base : base + '/videos';
}
// ============================================================================
// App setup
// ============================================================================
const app = new Hono();
app.use('*', logger());
// ============================================================================
// API routes
// ============================================================================
// GET /api/version
// Returns version string + a build tag that changes on every server restart/deploy.
// Clients poll this to detect when a new build is live and prompt a reload.
app.get('/api/version', (c) =>
c.json(
{ version: APP_VERSION, buildTag: BUILD_TAG, buildTime: BUILD_TIME },
200,
{ 'Cache-Control': 'no-store, no-cache, must-revalidate' }
)
);
// GET /api/search?q=<query>
app.get('/api/search', async (c) => {
const q = (c.req.query('q') || '').trim();
if (!q) return c.json({ ok: false, error: 'empty query' }, 400);
try {
const out = await runYtdlp([
`ytsearch${SEARCH_LIMIT}:${q}`,
'--dump-json', '--flat-playlist',
'--no-warnings', '--ignore-errors',
]);
return c.json({ ok: true, results: parseCards(out) });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// GET /api/channel?c=<channel>
app.get('/api/channel', async (c) => {
const chan = (c.req.query('c') || '').trim();
if (!chan) return c.json({ ok: false, error: 'missing channel' }, 400);
try {
const url = channelToUrl(chan);
const out = await runYtdlp([
url,
'--dump-json', '--flat-playlist',
'--no-warnings', '--ignore-errors',
'--playlist-end', String(CHANNEL_LIMIT),
]);
const results = parseCards(out);
// Extract channel name + URL from the first record
const first = results[0];
let channelName = '', channelUrl = '';
for (const line of out.split('\n')) {
const t = line.trim();
if (!t) continue;
try {
const j = JSON.parse(t);
channelName = channelName || pickChannel(j);
channelUrl = channelUrl || pickChannelUrl(j);
if (channelName && channelUrl) break;
} catch { /* skip */ }
}
return c.json({ ok: true, channel: channelName || (first?.channel || ''), channelUrl, results });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// ============================================================================
// Stream resolution + same-origin playback proxy
// ----------------------------------------------------------------------------
// googlevideo stream URLs are bound to the innertube client AND the IP that
// extracted them, and they expire (~6h via their `expire=` param). Handing
// them straight to the browser — which fetches from a DIFFERENT IP than this
// server — is what produced the intermittent 403s on playback (only the
// download path was ever fixed, in 70b5214). So playback now flows through
// /api/play: the browser hits our own origin, and THIS server fetches the
// googlevideo bytes (its IP matches the extractor) using yt-dlp's own
// http_headers, forwarding the browser's Range header so seeking still works.
// ============================================================================
// videoId -> { info, formats:[{formatId,height,vcodec,acodec,abr,ext,url,headers}], expiresAt }
const streamCache = new Map();
const STREAM_CACHE_MAX = 200;
// Max bytes per upstream googlevideo request (its adaptive streams are cut
// after ~10 MB; matches yt-dlp's http_chunk_size).
const PLAY_CHUNK = 10 * 1024 * 1024 - 1024;
// googlevideo URLs carry `expire=<unix-seconds>`; return that as an ms epoch.
function parseExpiry(url) {
const m = /[?&]expire=(\d+)/.exec(url || '');
return m ? Number(m[1]) * 1000 : 0;
}
// Resolve (and briefly cache) a video's playable formats via yt-dlp -J. The
// cache spares a fresh ~2-3s yt-dlp run on every Range request the media
// element fires; its TTL is bounded a minute inside the URLs' own expiry.
async function resolveStreams(videoId) {
const now = Date.now();
const cached = streamCache.get(videoId);
if (cached && now < cached.expiresAt) return cached;
const out = await runYtdlp(['-J', '--no-warnings', `https://www.youtube.com/watch?v=${videoId}`]);
const info = JSON.parse(out);
const raw = Array.isArray(info.formats) ? info.formats : [];
const formats = [];
let soonest = Infinity;
for (const f of raw) {
if (!f.url) continue;
// Only keep direct https byte streams. Newer yt-dlp also surfaces HLS
// (m3u8/m3u8_native) and DASH-segment formats whose `url` is a manifest,
// not media bytes — proxying those hands the <video> element an m3u8 it
// can't play (Chrome) and defeats Range seeking (416). https formats are
// exactly the ones the dual-stream engine relied on before this change.
if (f.protocol && f.protocol !== 'https') continue;
const exp = parseExpiry(f.url);
if (exp) soonest = Math.min(soonest, exp);
formats.push({
formatId: String(f.format_id || ''),
height: f.height || 0,
vcodec: f.vcodec,
acodec: f.acodec,
abr: f.abr || 0,
ext: f.ext || '',
url: f.url,
headers: f.http_headers || {},
});
}
const ttlUntil = soonest === Infinity ? now + 30 * 60_000 : soonest - 60_000;
const expiresAt = Math.max(now + 60_000, Math.min(ttlUntil, now + 3 * 3600_000));
const entry = { info, formats, expiresAt };
if (streamCache.size >= STREAM_CACHE_MAX) streamCache.delete(streamCache.keys().next().value);
streamCache.set(videoId, entry);
return entry;
}
const isVideoFmt = (f) => f.vcodec && f.vcodec !== 'none';
const isAudioFmt = (f) => f.acodec && f.acodec !== 'none';
// Highest-bitrate audio-only format (prefer mp4a/m4a).
function pickBestAudio(formats) {
let best = null, bestScore = -1;
for (const f of formats) {
if (isVideoFmt(f) || !isAudioFmt(f)) continue;
let score = f.abr || 0;
if (f.acodec && f.acodec.includes('mp4a')) score += 1000;
if (score > bestScore) { bestScore = score; best = f; }
}
return best;
}
// Pick the format /api/play should serve — by exact id first, then by the same
// height/progressive-first ordering /api/streams used to build the quality.
function pickFormat(formats, { formatId, wantAudio, wantHeight }) {
if (formatId) {
const byId = formats.find((f) => f.formatId === formatId);
if (byId) return byId;
}
if (wantAudio) return pickBestAudio(formats);
if (wantHeight) {
for (const wantProg of [false, true]) {
for (const f of formats) {
if (!isVideoFmt(f)) continue;
if (isAudioFmt(f) !== wantProg) continue;
if ((f.height || 0) === wantHeight) return f;
}
}
}
return null;
}
// GET /api/streams?v=<videoId> — meta + proxied audio/quality URLs.
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);
try {
const { info, formats } = await resolveStreams(videoId);
// Proxied audio URL (same-origin, re-resolved fresh on each play).
const bestAudio = pickBestAudio(formats);
const audioUrl = bestAudio
? `/api/play?v=${videoId}&audio=1&f=${encodeURIComponent(bestAudio.formatId)}`
: null;
// Quality list — one entry per height. ADAPTIVE (video-only, paired with
// the separate audioUrl for dual-stream playback) is preferred over
// progressive single-file: adaptive https streams proxy reliably, while
// progressive muxed formats (itag 18 and friends) are increasingly
// SABR-gated and flaky. Progressive is kept only when no adaptive stream
// exists at that height.
const qualities = [];
const seen = new Set();
for (const wantProg of [false, true]) {
for (const f of formats) {
if (!isVideoFmt(f)) continue;
if (isAudioFmt(f) !== wantProg) continue;
const h = f.height || 0;
if (h <= 0 || seen.has(h)) continue;
seen.add(h);
qualities.push({
label: h + 'p',
height: h,
hasAudio: wantProg,
url: `/api/play?v=${videoId}&h=${h}&f=${encodeURIComponent(f.formatId)}`,
ext: f.ext || '',
});
}
}
qualities.sort((a, b) => b.height - a.height);
return c.json({
ok: true,
data: {
meta: {
id: videoId,
title: pick(info, 'title') || '(untitled)',
channel: pickChannel(info),
channelId: pickChannelId(info),
channelUrl: pickChannelUrl(info),
duration: typeof info.duration === 'number' ? info.duration : 0,
thumbnail: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
},
audioUrl,
qualities,
},
});
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// Stream a single format straight out of yt-dlp's stdout. Some formats
// (notably progressive itag 18, and any SABR-gated stream) 403 on a plain GET
// even from the extractor IP — the bytes are only reachable through yt-dlp's
// full protocol. This can't honour a byte Range (yt-dlp writes start-to-end to
// the pipe), so it always answers 200; the media element still plays, it just
// can't seek past what it has buffered. Used only as the /api/play fallback.
//
// It PEEKS the first chunk before committing to a 200: if yt-dlp errors or
// exits without emitting any bytes (stale binary, ffmpeg segfault on merge,
// dead itag), it resolves to null so the caller can return a real error and
// the frontend advances to the next candidate instead of playing an empty 200.
function ytdlpPipeResponse(videoId, formatArgs, contentType) {
return new Promise((resolve) => {
const child = spawn(YTDLP, [
`https://www.youtube.com/watch?v=${videoId}`,
'--no-warnings', '--no-playlist',
...formatArgs,
'-o', '-',
], { stdio: ['ignore', 'pipe', 'ignore'] });
let settled = false;
const finish = (val) => { if (!settled) { settled = true; resolve(val); } };
child.stdout.once('data', (first) => {
// Re-emit the peeked chunk so the response stream is byte-complete.
child.stdout.unshift(first);
finish(new Response(Readable.toWeb(child.stdout), {
status: 200,
headers: {
'Content-Type': contentType,
'Accept-Ranges': 'none',
'Cache-Control': 'no-store',
},
}));
});
child.on('error', () => { try { child.kill(); } catch {} finish(null); });
child.on('close', () => finish(null)); // closed before any data → failure
});
}
// GET /api/play?v=<id>&f=<formatId>[&h=<height>][&audio=1]
// Same-origin streaming proxy for playback. Fetches the googlevideo bytes from
// THIS server (whose IP matches the extractor) with yt-dlp's own http_headers,
// forwarding the browser's Range header so seeking works. This is what makes
// the previously-403ing direct URLs play reliably. If the direct URL still
// 403s (SABR/itag-18 formats reachable only via yt-dlp's protocol), it falls
// back to piping yt-dlp itself.
app.get('/api/play', async (c) => {
const videoId = (c.req.query('v') || '').replace(/[/\\:?<>|*"]/g, '').trim();
if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400);
const sel = {
formatId: (c.req.query('f') || '').trim(),
wantAudio: c.req.query('audio') === '1',
wantHeight: Number(c.req.query('h') || 0),
};
const range = c.req.header('range');
// Fetch the chosen format's bytes; on a stale-URL 403/410 drop the cache and
// re-resolve once before giving up.
async function upstreamFetch(forceFresh) {
if (forceFresh) streamCache.delete(videoId);
const { formats } = await resolveStreams(videoId);
const fmt = pickFormat(formats, sel);
if (!fmt) return { error: 'no matching format' };
const headers = { ...fmt.headers };
// Always ask upstream for a bounded slice (see PLAY_CHUNK below); the
// splicer extends it to the client's full requested range.
const rq = /bytes=(\d+)-(\d*)/.exec(range || '');
const from = rq ? Number(rq[1]) : 0;
const to = rq && rq[2] !== '' ? Math.min(Number(rq[2]), from + PLAY_CHUNK - 1) : from + PLAY_CHUNK - 1;
headers['Range'] = range && !rq ? range : `bytes=${from}-${to}`;
return { fmt, res: await fetch(fmt.url, { headers, redirect: 'follow' }) };
}
try {
let attempt = await upstreamFetch(false);
if (attempt.error) return c.json({ ok: false, error: attempt.error }, 404);
if (attempt.res.status === 403 || attempt.res.status === 410) {
const retry = await upstreamFetch(true);
if (!retry.error && retry.res) attempt = retry;
}
// Direct URL is genuinely ungettable (SABR / itag-18) — let yt-dlp fetch it.
if (attempt.res.status === 403 || attempt.res.status === 410) {
const fmtArgs = sel.formatId ? ['-f', sel.formatId]
: sel.wantAudio ? ['-f', 'bestaudio[ext=m4a]/bestaudio']
: sel.wantHeight ? ['-f', `best[height=${sel.wantHeight}]/bv*[height=${sel.wantHeight}]`]
: ['-f', 'best'];
const piped = await ytdlpPipeResponse(videoId, fmtArgs, sel.wantAudio ? 'audio/mp4' : 'video/mp4');
if (piped) return piped;
return c.json({ ok: false, error: 'stream unavailable (403)' }, 502);
}
const up = attempt.res;
const headers = new Headers();
headers.set('Content-Type', up.headers.get('content-type') || (sel.wantAudio ? 'audio/mp4' : 'video/mp4'));
headers.set('Accept-Ranges', 'bytes');
headers.set('Cache-Control', 'no-store');
// googlevideo silently closes adaptive-stream connections after ~10 MB
// (yt-dlp's own `http_chunk_size` exists for exactly this), so a single
// forwarded `bytes=0-` truncated at ~30 s of 720p and the <video> element
// stalled with no error. Re-issue upstream fetches in ≤10 MB Range slices
// and splice them into ONE response body that is byte-complete for the
// client's requested range.
const cr = /bytes (\d+)-(\d+)\/(\d+|\*)/.exec(up.headers.get('content-range') || '');
const total = cr && cr[3] !== '*' ? Number(cr[3])
: up.status === 200 ? Number(up.headers.get('content-length') || 0) : 0;
if (!total || up.status !== 206 && up.status !== 200) {
for (const k of ['content-length', 'content-range']) {
const v = up.headers.get(k);
if (v) headers.set(k, v);
}
return new Response(up.body, { status: up.status, headers });
}
const rq = /bytes=(\d*)-(\d*)/.exec(range || '');
let start = cr ? Number(cr[1]) : 0;
let end = total - 1;
if (rq && rq[2] !== '') end = Math.min(total - 1, Number(rq[2]));
if (rq && rq[1] === '' && rq[2] !== '') { start = Math.max(0, total - Number(rq[2])); end = total - 1; }
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
// relative to `start`, and the first slice reuses the in-flight fetch.
let pos = start;
let res = up;
try {
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);
}
reader = res.body.getReader();
let got = 0;
while (got < sliceEnd - pos + 1 && !aborted) {
const { value, done } = await reader.read();
if (done) break;
const room = sliceEnd - pos + 1 - got;
const chunk = value.length > room ? value.subarray(0, room) : value;
ctrl.enqueue(chunk);
got += chunk.length;
}
try { await reader.cancel(); } catch {}
reader = null;
if (aborted) break;
if (got === 0) throw new Error('upstream returned no bytes');
pos += got;
res = null;
}
if (!aborted) ctrl.close();
} catch (err) {
if (!aborted) { try { ctrl.error(err); } catch {} }
}
},
cancel() {
aborted = true;
const r = reader;
if (r) r.cancel().catch(() => {});
},
});
headers.set('Content-Length', String(length));
if (range) {
headers.set('Content-Range', `bytes ${start}-${end}/${total}`);
return new Response(body, { status: 206, headers });
}
return new Response(body, { status: 200, headers });
} catch (err) {
return c.json({ ok: false, error: 'stream proxy failed: ' + err.message }, 502);
}
});
// Download via yt-dlp into a self-cleaning temp file, then stream it.
// yt-dlp MUST perform the HTTP fetch itself: googlevideo stream URLs are
// bound to the innertube client that extracted them, so resolving the URL
// with --get-url and re-fetching it server-side with hand-rolled browser
// headers intermittently got 403s from the YouTube CDN when the User-Agent
// didn't match the extraction client.
// Live streams have no end: yt-dlp/ffmpeg would pull the HLS manifest forever
// (one such "save" ran for an hour and ate 14 GB). Refuse them up front.
const MAX_SAVE_SECONDS = Number(process.env.MAX_SAVE_SECONDS || 3 * 3600);
async function assertNotLive(videoId) {
const { info } = await resolveStreams(videoId);
if (info.is_live || info.live_status === 'is_live' || info.live_status === 'post_live') {
throw new Error('live streams cannot be saved');
}
if (typeof info.duration === 'number' && info.duration > MAX_SAVE_SECONDS) {
throw new Error(`video is too long to save (${Math.round(info.duration / 3600)} h, limit ${MAX_SAVE_SECONDS / 3600} h)`);
}
}
// Same video + same format args requested while a download is already in
// flight (the OPFS worker retries, then the main-thread fallback retries
// again) share ONE yt-dlp run instead of spawning another each time.
const inflightDownloads = new Map(); // key -> { promise, waiters, tmpBase }
async function ytdlpDownloadResponse(videoId, fp, formatArgs, signal) {
await assertNotLive(videoId);
const key = videoId + '|' + formatArgs.join(' ');
let entry = inflightDownloads.get(key);
if (!entry) {
const tmpBase = `ytp-dl-${videoId}-${Date.now()}`;
const tmp = `${tmpdir()}/${tmpBase}.mp4`;
const ctl = new AbortController();
entry = { waiters: 0, tmpBase, ctl };
entry.promise = withSaveSlot(() => {
if (ctl.signal.aborted) throw new Error('save cancelled');
return runYtdlp([
`https://www.youtube.com/watch?v=${videoId}`,
'--no-warnings', '--no-playlist',
...formatArgs,
'--limit-rate', DOWNLOAD_RATE,
'-o', tmp,
], { signal: ctl.signal });
}).then(() => ({ tmp, size: statSync(tmp).size }));
inflightDownloads.set(key, entry);
}
entry.waiters++;
// Only abort the shared yt-dlp when EVERY waiter has gone away.
let gone = false;
const leave = () => { if (gone) return; gone = true; if (--entry.waiters <= 0) { entry.ctl.abort(); } };
if (signal) signal.addEventListener('abort', leave, { once: true });
let size, fd;
try {
const res = await entry.promise;
size = res.size;
// Open the fd BEFORE the sweep unlinks: on Linux the data stays
// readable until the fd closes, so the temp file cleans itself up even
// if the client disconnects mid-transfer.
fd = openSync(res.tmp, 'r');
} finally {
if (signal) signal.removeEventListener('abort', leave);
if (!gone) { gone = true; entry.waiters--; }
if (entry.waiters <= 0 && inflightDownloads.get(key) === entry) {
inflightDownloads.delete(key);
// Sweep everything yt-dlp may have left under this run's unique
// prefix: the output itself, .part partials, and .fNNN single-format
// intermediates (left when ffmpeg is missing — yt-dlp then downloads
// the streams separately, exits 0 without merging, and statSync above
// throws on the absent merged file).
for (const name of readdirSync(tmpdir())) {
if (name.startsWith(entry.tmpBase)) {
try { unlinkSync(`${tmpdir()}/${name}`); } catch { /* already gone */ }
}
}
}
}
const stream = createReadStream('', { fd });
if (fp) recordVideoAccess(fp, { id: videoId }).catch(() => {});
return new Response(Readable.toWeb(stream), {
status: 200,
headers: {
'Content-Type': 'video/mp4',
'Content-Length': String(size),
'Content-Disposition': `attachment; filename="${videoId}.mp4"`,
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*',
},
});
}
// "Edit & download": fetch the source with yt-dlp (muxed up to 720p, same as
// the mux path), then run ffmpeg to KEEP only the requested segments and
// concatenate them into one continuous mp4 — the user's custom cut. The result
// is streamed to the browser exactly like a normal save, so OPFS stores it
// under the caller-chosen custom id. Every temp file is swept afterwards.
async function ytdlpEditedDownloadResponse(videoId, fp, keep, signal) {
await assertNotLive(videoId);
const tmpBase = `ytp-edit-${videoId}-${Date.now()}`;
const srcTmp = `${tmpdir()}/${tmpBase}.src.mp4`;
const outTmp = `${tmpdir()}/${tmpBase}.out.mp4`;
let size, fd;
try {
// 1) Grab the full source (video+audio merged) so ffmpeg has both streams.
await withSaveSlot(() => runYtdlp([
`https://www.youtube.com/watch?v=${videoId}`,
'--no-warnings', '--no-playlist',
'-f', 'bv*[height<=720][ext=mp4]+ba[ext=m4a]/bv*[height<=720]+ba/b[ext=mp4]/b',
'--merge-output-format', 'mp4',
'--limit-rate', DOWNLOAD_RATE,
'-o', srcTmp,
], { signal }));
// 2) Trim + concat the keep segments into the final custom video.
await runFfmpeg([
'-y', '-hide_banner', '-loglevel', 'error',
'-i', srcTmp,
...buildTrimArgs(keep),
outTmp,
]);
size = statSync(outTmp).size;
fd = openSync(outTmp, 'r');
} finally {
for (const name of readdirSync(tmpdir())) {
if (name.startsWith(tmpBase)) {
try { unlinkSync(`${tmpdir()}/${name}`); } catch { /* already gone */ }
}
}
}
const stream = createReadStream('', { fd });
if (fp) recordVideoAccess(fp, { id: videoId }).catch(() => {});
return new Response(Readable.toWeb(stream), {
status: 200,
headers: {
'Content-Type': 'video/mp4',
'Content-Length': String(size),
'Content-Disposition': `attachment; filename="${videoId}-edited.mp4"`,
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*',
},
});
}
// GET /api/download/:videoId
// Downloads the video server-side via yt-dlp and streams the finished file
// to the browser so OPFS can store it. The browser never contacts YouTube
// CDN directly (CORS would block it).
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);
const fp = c.req.query('fp');
// ?edit=1&keep=s-e,s-e — "Edit & download" path: download the source, then
// ffmpeg-trim it to the requested keep segments and stream the custom cut.
// Requires ffmpeg; there is no progressive fallback because the whole point
// is the server-side edit. Invalid/empty keep params are rejected up front.
if (c.req.query('edit') === '1') {
const keep = parseKeepParam(c.req.query('keep') || '');
if (!keep.length) return c.json({ ok: false, error: 'missing or invalid keep segments' }, 400);
try {
return await ytdlpEditedDownloadResponse(videoId, fp, keep, c.req.raw.signal);
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
}
// ?mux=1 — "Save before playing" path: bestvideo up to 720p PLUS bestaudio
// compiled into one mp4 with ffmpeg on the server. Falls back to the
// progressive single-file save below when ffmpeg is missing or the merge
// fails.
if (c.req.query('mux') === '1') {
try {
return await ytdlpDownloadResponse(videoId, fp, [
'-f', 'bv*[height<=720][ext=mp4]+ba[ext=m4a]/bv*[height<=720]+ba/b[ext=mp4]/b',
'--merge-output-format', 'mp4',
], c.req.raw.signal);
} catch (err) {
console.warn(`[ytplayer] mux download failed for ${videoId}, falling back to progressive:`, err.message);
}
}
// Default save — best progressive (audio+video single-file) format when
// one exists, otherwise merge bestvideo (≤720p) + bestaudio with ffmpeg.
// YouTube now serves many videos with NO progressive format at all, which
// made the old progressive-only selector fail every save with
// "Requested format is not available".
try {
return await ytdlpDownloadResponse(videoId, fp, [
'-f', 'bestvideo[ext=mp4][acodec!=none]/bestvideo[acodec!=none]/best[ext=mp4][acodec!=none]/'
+ 'bv*[height<=720][ext=mp4]+ba[ext=m4a]/bv*[height<=720]+ba/b',
'--merge-output-format', 'mp4',
], c.req.raw.signal);
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// ============================================================================
// Online profiles — named cross-device sync. The NAME IS THE PASSKEY:
// anyone who knows it can load and overwrite that profile, so clients are
// encouraged to use the random generator. No other auth by design.
// ============================================================================
const PROFILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{2,39}$/;
const PROFILE_MAX_BYTES = 2_000_000; // full data blob; typical payloads are ~KBs
const RAND_ADJ = ['amber', 'brave', 'calm', 'coral', 'crimson', 'dusty', 'gentle', 'golden',
'hidden', 'ivory', 'jade', 'lunar', 'mellow', 'misty', 'noble', 'quiet',
'rapid', 'silver', 'solar', 'stormy', 'swift', 'velvet', 'wild', 'zesty'];
const RAND_NOUN = ['falcon', 'harbor', 'willow', 'ember', 'canyon', 'meadow', 'otter', 'pine',
'raven', 'reef', 'sparrow', 'summit', 'thicket', 'tundra', 'brook', 'cedar',
'dune', 'fjord', 'glade', 'heron', 'lagoon', 'maple', 'prairie', 'wren'];
function randomProfileName() {
const a = RAND_ADJ[Math.floor(Math.random() * RAND_ADJ.length)];
const n = RAND_NOUN[Math.floor(Math.random() * RAND_NOUN.length)];
return `${a}-${n}-${1000 + Math.floor(Math.random() * 9000)}`;
}
// POST /api/profile/create
// Body: { name?, data? } — empty/absent name asks the server to generate a
// unique random one. Fails with 409 when the requested name is taken.
app.post('/api/profile/create', async (c) => {
let body;
try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }
let name = (body.name || '').trim().toLowerCase();
const dataJson = JSON.stringify(body.data && typeof body.data === 'object' ? body.data : {});
if (dataJson.length > PROFILE_MAX_BYTES) return c.json({ ok: false, error: 'profile data too large' }, 413);
try {
if (name) {
if (!PROFILE_NAME_RE.test(name)) {
return c.json({ ok: false, error: 'invalid name — 3-40 characters: letters, digits, - or _' }, 400);
}
if (!(await createProfile(name, dataJson))) {
return c.json({ ok: false, error: `“${name}” is already taken — pick another name` }, 409);
}
} else {
let created = false;
for (let tries = 0; tries < 20 && !created; tries++) {
name = randomProfileName();
created = await createProfile(name, dataJson);
}
if (!created) return c.json({ ok: false, error: 'could not generate a unique name — try again' }, 500);
}
const row = await getProfile(name);
return c.json({ ok: true, name, updatedAt: row ? row.updatedAt : 0 });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// GET /api/profile/load?name=<name>
app.get('/api/profile/load', async (c) => {
const name = (c.req.query('name') || '').trim().toLowerCase();
if (!name) return c.json({ ok: false, error: 'missing name' }, 400);
try {
const row = await getProfile(name);
if (!row) return c.json({ ok: false, error: 'profile not found' }, 404);
let data = {};
try { data = JSON.parse(row.data || '{}'); } catch { /* corrupt blob — hand back empty */ }
return c.json({ ok: true, name, data, updatedAt: row.updatedAt });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// POST /api/profile/save
// Body: { name, data } — updates an EXISTING profile only (404 otherwise).
app.post('/api/profile/save', async (c) => {
let body;
try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }
const name = (body.name || '').trim().toLowerCase();
if (!name) return c.json({ ok: false, error: 'missing name' }, 400);
const dataJson = JSON.stringify(body.data && typeof body.data === 'object' ? body.data : {});
if (dataJson.length > PROFILE_MAX_BYTES) return c.json({ ok: false, error: 'profile data too large' }, 413);
try {
if (!(await saveProfile(name, dataJson))) {
return c.json({ ok: false, error: 'profile not found' }, 404);
}
const row = await getProfile(name);
return c.json({ ok: true, updatedAt: row ? row.updatedAt : 0 });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// ============================================================================
// Shared playlists — single-playlist sharing via a 10-character code.
// ============================================================================
const PLAYLIST_CODE_CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789';
function randomPlaylistCode() {
let code = '';
for (let i = 0; i < 10; i++) {
code += PLAYLIST_CODE_CHARS[Math.floor(Math.random() * PLAYLIST_CODE_CHARS.length)];
}
return code;
}
// A shared playlist is the ONLY path by which one user's video objects reach
// another user's DOM, so the blob is rebuilt field-by-field here rather than
// stored as sent. Anything not in this whitelist is dropped, and the two fields
// that end up in HTML attributes (thumbnail, channelUrl) must parse as http(s)
// URLs — otherwise a crafted `thumbnail` closes the src attribute and injects
// markup on the importing device. `custom` edits are dropped outright: their
// media only exists in the sharer's OPFS cache, so they are unplayable anywhere
// else and would just render as permanently broken entries.
const SHARED_STR_MAX = 300;
function safeStr(v, max = SHARED_STR_MAX) {
return typeof v === 'string' ? v.slice(0, max) : '';
}
function safeHttpUrl(v) {
if (typeof v !== 'string' || v.length > 2000) return '';
try {
const u = new URL(v);
return (u.protocol === 'http:' || u.protocol === 'https:') ? u.href : '';
} catch { return ''; }
}
function sanitizeSharedVideo(v) {
if (!v || typeof v !== 'object') return null;
const id = safeStr(v.id, 64);
if (!id || v.custom) return null;
const duration = Number(v.duration);
return {
id,
title: safeStr(v.title),
channel: safeStr(v.channel),
channelId: safeStr(v.channelId, 64),
channelUrl: safeHttpUrl(v.channelUrl),
duration: Number.isFinite(duration) && duration >= 0 ? duration : 0,
thumbnail: safeHttpUrl(v.thumbnail),
};
}
// POST /api/playlist/share
// Body: { playlist: { name, videos: [...] } }
app.post('/api/playlist/share', async (c) => {
let body;
try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }
const pl = body?.playlist;
if (!pl || typeof pl !== 'object') {
return c.json({ ok: false, error: 'missing playlist' }, 400);
}
const name = typeof pl.name === 'string' ? pl.name.trim() : '';
if (!name || name.length > 200) {
return c.json({ ok: false, error: 'invalid name — must be non-empty and <= 200 chars' }, 400);
}
if (!Array.isArray(pl.videos) || pl.videos.length < 1 || pl.videos.length > 500) {
return c.json({ ok: false, error: 'invalid videos — must be an array of 1 to 500 videos' }, 400);
}
const videos = pl.videos.map(sanitizeSharedVideo).filter(Boolean);
if (!videos.length) return c.json({ ok: false, error: 'no usable videos in that playlist' }, 400);
const dataJson = JSON.stringify({ name, videos });
if (dataJson.length > PROFILE_MAX_BYTES) {
return c.json({ ok: false, error: 'playlist data too large' }, 413);
}
try {
let code = '';
let created = false;
for (let tries = 0; tries < 20 && !created; tries++) {
code = randomPlaylistCode();
created = await createSharedPlaylist(code, dataJson);
}
if (!created) {
return c.json({ ok: false, error: 'could not generate a unique share code — try again' }, 500);
}
return c.json({ ok: true, code });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// GET /api/playlist/shared?code=<code>
app.get('/api/playlist/shared', async (c) => {
const code = (c.req.query('code') || '').trim().toLowerCase();
if (!code) return c.json({ ok: false, error: 'missing code' }, 400);
try {
const row = await getSharedPlaylist(code);
if (!row) {
return c.json({ ok: false, error: 'shared playlist not found' }, 404);
}
let pl = null;
try { pl = JSON.parse(row.data || '{}'); } catch { /* corrupt blob */ }
if (!pl || typeof pl !== 'object' || !pl.name || !Array.isArray(pl.videos)) {
return c.json({ ok: false, error: 'shared playlist not found' }, 404);
}
return c.json({
ok: true,
code,
playlist: { name: pl.name, videos: pl.videos },
createdAt: row.createdAt,
});
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
function isYoutubeHost(hostname) {
const h = (hostname || '').toLowerCase();
return h === 'youtube.com' || h.endsWith('.youtube.com') ||
h === 'youtu.be' || h.endsWith('.youtu.be');
}
function isValidYoutubeUrl(rawUrl) {
if (typeof rawUrl !== 'string' || !rawUrl.trim()) return false;
try {
const u = new URL(rawUrl.trim());
return (u.protocol === 'http:' || u.protocol === 'https:') && isYoutubeHost(u.hostname);
} catch {
return false;
}
}
// GET /api/playlist/expand?url=<youtube playlist url>
// Expands a YouTube playlist URL into slim video cards without resolving streams.
app.get('/api/playlist/expand', async (c) => {
const url = (c.req.query('url') || '').trim();
if (!url || !isValidYoutubeUrl(url)) {
return c.json({ ok: false, error: 'invalid YouTube URL' }, 400);
}
try {
const out = await runYtdlp([
url,
'--dump-json', '--flat-playlist',
'--no-warnings', '--ignore-errors',
'--playlist-end', '201',
]);
const parsed = parseCards(out);
let title = '';
for (const line of out.split('\n')) {
const t = line.trim();
if (!t) continue;
try {
const j = JSON.parse(t);
title = title || pick(j, 'playlist_title', 'playlist');
if (title) break;
} catch { /* skip */ }
}
const truncated = parsed.length > 200;
const entries = parsed.slice(0, 200);
const resp = {
ok: true,
title: title || '',
entries,
};
if (truncated) resp.truncated = true;
return c.json(resp);
} catch (err) {
return c.json({ ok: false, error: err.message }, 502);
}
});
// POST /api/user/sync
// Body: { fingerprint, playlists?, recentVideo?, appVersion? }
app.post('/api/user/sync', async (c) => {
let body;
try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }
const fp = (body.fingerprint || '').trim();
if (!fp) return c.json({ ok: false, error: 'missing fingerprint' }, 400);
try {
await upsertUser({ fingerprint: fp, appVersion: body.appVersion, playlists: body.playlists });
if (body.recentVideo && body.recentVideo.id) {
await recordVideoAccess(fp, body.recentVideo);
}
return c.json({ ok: true });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// GET /api/user/data?fp=<fingerprint>
app.get('/api/user/data', async (c) => {
const fp = (c.req.query('fp') || '').trim();
if (!fp) return c.json({ ok: false, error: 'missing fingerprint' }, 400);
try {
const userData = await getUserData(fp);
return c.json({ ok: true, ...userData, version: APP_VERSION });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// ============================================================================
// GET /sw.js — serve the service worker with BUILD_TAG injected
//
// The raw sw.js file contains the placeholder `__BUILD_TAG__` which is
// replaced here with the actual BUILD_TAG string so the SW's cache name
// tracks the deployment automatically — no manual version bump needed.
// Served with no-store cache headers so browsers always re-fetch it and
// pick up the substituted value rather than a browser-cached stale copy.
// ============================================================================
let _swSource = null;
app.get('/sw.js', (c) => {
if (!_swSource) {
try {
_swSource = readFileSync('./public/sw.js', 'utf8');
} catch {
return c.text('Service worker not found', 404);
}
}
// Inject the build tag: replace the whole fallback expression with the
// real value. Matched by REGEX, not an exact string — an exact match broke
// the moment the fallback literal in sw.js was bumped ('v1.0.3' → 'v1.0.4'),
// after which the replacement silently did nothing, the SW version froze,
// and clients never saw another update no matter how many times we deployed.
const src = _swSource.replace(
/typeof __BUILD_TAG__ !== 'undefined' \? __BUILD_TAG__ : '[^']*'/,
JSON.stringify(BUILD_TAG)
);
if (src === _swSource) {
console.error('[sw] BUILD_TAG injection failed — placeholder not found in sw.js');
}
return c.text(src, 200, {
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
});
});
// ============================================================================
// Static files — serve the frontend/public directory
// Must come AFTER all /api routes so API takes priority
// ============================================================================
app.use('/*', serveStatic({ root: './public' }));
// SPA fallback — return index.html for any unmatched path
app.get('/*', serveStatic({ path: './public/index.html' }));
// ============================================================================
// Boot
// ============================================================================
async function main() {
await initDb();
console.log(`[ytplayer] DB ready`);
console.log(`[ytplayer] Starting on port ${PORT}`);
// Bun.serve is the native Bun HTTP server
Bun.serve({
port: PORT,
fetch: app.fetch,
// 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 —
// long videos at the rate cap — and the client retried in a loop.
// 0 disables the idle timeout entirely (verified with a 300s request);
// runaway downloads are bounded by MAX_SAVE_SECONDS + kill-on-disconnect.
idleTimeout: 0,
});
console.log(`[ytplayer] Listening → http://localhost:${PORT}`);
}
main().catch((err) => {
console.error('[ytplayer] fatal:', err);
process.exit(1);
});