fix intermittent 403 on video save by letting yt-dlp fetch the stream itself

This commit is contained in:
Jonathan Sykes
2026-07-11 00:45:37 +08:00
parent 88c89d66af
commit 70b5214ce8

View File

@@ -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);
}