fix intermittent 403 on video save by letting yt-dlp fetch the stream itself
This commit is contained in:
156
server/server.js
156
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user