/* ============================================================================ * 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= yt-dlp search → slim card array * GET /api/channel?c= yt-dlp channel uploads → slim card array * GET /api/streams?v= yt-dlp stream info → {meta, audioUrl, qualities} (proxied URLs) * GET /api/play?v=&f= 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= retrieve stored playlists + history * POST /api/playlist/share share a single playlist → { ok, code } * GET /api/playlist/shared?code= retrieve shared playlist → { ok, code, playlist, createdAt } * GET /api/playlist/expand?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, existsSync, 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); }); }); } // YouTube intermittently answers the default (web) innertube client with // "Sign in to confirm you're not a bot" — a per-IP rate signal, not a // per-video one, so the SAME video that just saved fine fails minutes later // and the user sees "sign in required". Other player clients are not gated by // that check from a datacentre IP, so retry the whole yt-dlp call against each // in turn instead of demanding cookies. Order is quality-first: web_embedded // still exposes the adaptive DASH ladder (399+251), while tv_simply / // android_vr / mweb typically only offer progressive itag 18 (360p) — a 360p // save beats a failed save. const BOT_CHECK_RE = /Sign in to confirm|not a bot|confirm you.{0,3}re not a bot/i; const FALLBACK_CLIENTS = (process.env.YTDLP_FALLBACK_CLIENTS || 'web_embedded,tv_simply,android_vr,mweb').split(',').map((s) => s.trim()).filter(Boolean); // Optional cookies jar (Netscape format) for the rare case every client is // gated. Mounted read-only; absent by default and never required. const YTDLP_COOKIES = process.env.YTDLP_COOKIES || ''; function withCookies(args) { if (!YTDLP_COOKIES || !existsSync(YTDLP_COOKIES)) return args; return ['--cookies', YTDLP_COOKIES, ...args]; } // runYtdlp + bot-check fallback. Every YouTube-facing call goes through this. async function runYtdlpResilient(args, opts = {}) { const hasClientArg = args.some((a) => String(a).includes('player_client=')); try { return await runYtdlp(withCookies(args), opts); } catch (err) { if (hasClientArg || !BOT_CHECK_RE.test(err.message)) throw err; if (opts.signal?.aborted) throw err; let last = err; for (const client of FALLBACK_CLIENTS) { if (opts.signal?.aborted) throw last; try { const out = await runYtdlp( withCookies(['--extractor-args', `youtube:player_client=${client}`, ...args]), opts, ); console.warn(`[ytplayer] bot check on default client, succeeded via player_client=${client}`); return out; } catch (e) { last = e; // A client that simply lacks the requested format is not a bot check; // keep walking the list either way, but surface the last real error. if (!BOT_CHECK_RE.test(e.message) && !/format is not available/i.test(e.message)) throw e; } } throw last; } } // 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= 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 runYtdlpResilient([ `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= 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 runYtdlpResilient([ 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=`; 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 runYtdlpResilient(['-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