/* ============================================================================ * 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} * 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 * 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 } from './db.js'; 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'; // ---------------------------------------------------------------------------- // 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) { return new Promise((resolve, reject) => { const child = spawn(YTDLP, args, { stdio: ['ignore', 'pipe', 'pipe'] }); 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= 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= 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); } }); // GET /api/streams?v= 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 url = `https://www.youtube.com/watch?v=${videoId}`; const out = await runYtdlp(['-J', '--no-warnings', url]); const info = JSON.parse(out); const formats = Array.isArray(info.formats) ? info.formats : []; // Best audio-only stream (prefer mp4a/m4a by bitrate) let bestAudioUrl = null; let bestAudioScore = -1; for (const f of formats) { if (!f.url) continue; const hasVideo = f.vcodec && f.vcodec !== 'none'; const hasAudio = f.acodec && f.acodec !== 'none'; if (hasVideo || !hasAudio) continue; let score = f.abr || 0; if (f.acodec && f.acodec.includes('mp4a')) score += 1000; if (score > bestAudioScore) { bestAudioScore = score; bestAudioUrl = f.url; } } // Quality list — progressive (single-file, hasAudio) first, then adaptive const qualities = []; const seen = new Set(); for (const wantProg of [true, false]) { for (const f of formats) { if (!f.url) continue; const hasVideo = f.vcodec && f.vcodec !== 'none'; const hasAudio = f.acodec && f.acodec !== 'none'; if (!hasVideo) continue; if ((hasAudio) !== 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: !!hasAudio, url: f.url, 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: bestAudioUrl, qualities, }, }); } catch (err) { return c.json({ ok: false, error: err.message }, 500); } }); // 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. async function ytdlpDownloadResponse(videoId, fp, formatArgs) { const tmpBase = `ytp-dl-${videoId}-${Date.now()}`; const tmp = `${tmpdir()}/${tmpBase}.mp4`; let size, fd; try { await runYtdlp([ `https://www.youtube.com/watch?v=${videoId}`, '--no-warnings', '--no-playlist', ...formatArgs, '-N', '4', '-o', tmp, ]); size = statSync(tmp).size; // Open the fd BEFORE the finally 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(tmp, 'r'); } finally { // Sweep everything yt-dlp may have left under this request'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(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) { 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 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', '-N', '4', '-o', srcTmp, ]); // 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); } 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', ]); } 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, so no // ffmpeg is required anywhere in the chain. try { return await ytdlpDownloadResponse(videoId, fp, [ '-f', 'bestvideo[ext=mp4][acodec!=none]/bestvideo[acodec!=none]/best[ext=mp4]/best', ]); } 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= 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); } }); // 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= 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 covers slow saves; Bun caps // this field at 255. idleTimeout: 240, }); console.log(`[ytplayer] Listening → http://localhost:${PORT}`); } main().catch((err) => { console.error('[ytplayer] fatal:', err); process.exit(1); });