diff --git a/server/server.js b/server/server.js index da3fa09..2f4f742 100644 --- a/server/server.js +++ b/server/server.js @@ -305,103 +305,87 @@ app.get('/api/streams', async (c) => { } }); -// GET /api/download/:videoId -// Resolves the best progressive (audio+video single-file) stream URL via -// yt-dlp and proxies the binary 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); - +// 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 { - const url = `https://www.youtube.com/watch?v=${videoId}`; - - // ?mux=1 — "Save before playing" path: let yt-dlp download bestvideo up - // to 720p PLUS bestaudio and compile them into one mp4 with ffmpeg on - // the server, then stream the finished file. Default saves (no mux) - // keep the progressive single-stream behavior below, unchanged. - if (c.req.query('mux') === '1') { - const tmp = `${tmpdir()}/ytp-mux-${videoId}-${Date.now()}.mp4`; - try { - await runYtdlp([ - url, - '--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', tmp, - ]); - const size = statSync(tmp).size; - // Open the fd first, then unlink: on Linux the data stays readable - // until the fd closes, so the temp file cleans itself up even if the - // client disconnects mid-transfer. - const fd = openSync(tmp, 'r'); - unlinkSync(tmp); - const stream = createReadStream('', { fd }); - - const fp = c.req.query('fp'); - 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': '*', - }, - }); - } catch (err) { - // ffmpeg missing or extraction failed — clean up any partial output - // and fall through to the progressive proxy below. - for (const leftover of [tmp, tmp + '.part']) { - try { unlinkSync(leftover); } catch { /* not created */ } - } - console.warn(`[ytplayer] mux download failed for ${videoId}, falling back to progressive:`, err.message); + 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 }); - // --get-url with bestvideo+bestaudio/best format is not what we want - // here — we need a SINGLE FILE so no ffmpeg muxing is required in the - // browser. Use -f "bestvideo[ext=mp4][acodec!=none]/best[ext=mp4]/best" - const out = await runYtdlp([ - url, - '--no-warnings', - '-f', 'bestvideo[ext=mp4][acodec!=none]/bestvideo[acodec!=none]/best[ext=mp4]/best', - '--get-url', - ]); - const streamUrl = out.trim(); - if (!streamUrl) throw new Error('No stream URL returned'); + if (fp) recordVideoAccess(fp, { id: videoId }).catch(() => {}); - // Fetch from YouTube and pipe to the client - const upstream = await fetch(streamUrl, { - headers: { - // Mimic a browser to avoid 403s from YouTube CDN - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', - 'Referer': 'https://www.youtube.com/', - }, - }); - if (!upstream.ok) throw new Error(`Upstream error ${upstream.status}`); - - const contentType = upstream.headers.get('content-type') || 'video/mp4'; - const contentLength = upstream.headers.get('content-length'); - - const headers = new Headers({ - 'Content-Type': contentType, + 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': '*', - }); - if (contentLength) headers.set('Content-Length', contentLength); + }, + }); +} - // Log the access if a fingerprint was supplied (fire-and-forget) - const fp = c.req.query('fp'); - if (fp) { - recordVideoAccess(fp, { id: videoId }).catch(() => {}); +// 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'); + + // ?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); } + } - return new Response(upstream.body, { status: 200, headers }); + // 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); }