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
|
// Download via yt-dlp into a self-cleaning temp file, then stream it.
|
||||||
// Resolves the best progressive (audio+video single-file) stream URL via
|
// yt-dlp MUST perform the HTTP fetch itself: googlevideo stream URLs are
|
||||||
// yt-dlp and proxies the binary to the browser so OPFS can store it.
|
// bound to the innertube client that extracted them, so resolving the URL
|
||||||
// The browser never contacts YouTube CDN directly (CORS would block it).
|
// with --get-url and re-fetching it server-side with hand-rolled browser
|
||||||
app.get('/api/download/:videoId', async (c) => {
|
// headers intermittently got 403s from the YouTube CDN when the User-Agent
|
||||||
const videoId = (c.req.param('videoId') || '').replace(/[/\\:?<>|*"]/g, '').trim();
|
// didn't match the extraction client.
|
||||||
if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400);
|
async function ytdlpDownloadResponse(videoId, fp, formatArgs) {
|
||||||
|
const tmpBase = `ytp-dl-${videoId}-${Date.now()}`;
|
||||||
|
const tmp = `${tmpdir()}/${tmpBase}.mp4`;
|
||||||
|
let size, fd;
|
||||||
try {
|
try {
|
||||||
const url = `https://www.youtube.com/watch?v=${videoId}`;
|
await runYtdlp([
|
||||||
|
`https://www.youtube.com/watch?v=${videoId}`,
|
||||||
// ?mux=1 — "Save before playing" path: let yt-dlp download bestvideo up
|
'--no-warnings', '--no-playlist',
|
||||||
// to 720p PLUS bestaudio and compile them into one mp4 with ffmpeg on
|
...formatArgs,
|
||||||
// the server, then stream the finished file. Default saves (no mux)
|
'-N', '4',
|
||||||
// keep the progressive single-stream behavior below, unchanged.
|
'-o', tmp,
|
||||||
if (c.req.query('mux') === '1') {
|
]);
|
||||||
const tmp = `${tmpdir()}/ytp-mux-${videoId}-${Date.now()}.mp4`;
|
size = statSync(tmp).size;
|
||||||
try {
|
// Open the fd BEFORE the finally unlinks: on Linux the data stays
|
||||||
await runYtdlp([
|
// readable until the fd closes, so the temp file cleans itself up even
|
||||||
url,
|
// if the client disconnects mid-transfer.
|
||||||
'--no-warnings', '--no-playlist',
|
fd = openSync(tmp, 'r');
|
||||||
'-f', 'bv*[height<=720][ext=mp4]+ba[ext=m4a]/bv*[height<=720]+ba/b[ext=mp4]/b',
|
} finally {
|
||||||
'--merge-output-format', 'mp4',
|
// Sweep everything yt-dlp may have left under this request's unique
|
||||||
'-N', '4',
|
// prefix: the output itself, .part partials, and .fNNN single-format
|
||||||
'-o', tmp,
|
// intermediates (left when ffmpeg is missing — yt-dlp then downloads
|
||||||
]);
|
// the streams separately, exits 0 without merging, and statSync above
|
||||||
const size = statSync(tmp).size;
|
// throws on the absent merged file).
|
||||||
// Open the fd first, then unlink: on Linux the data stays readable
|
for (const name of readdirSync(tmpdir())) {
|
||||||
// until the fd closes, so the temp file cleans itself up even if the
|
if (name.startsWith(tmpBase)) {
|
||||||
// client disconnects mid-transfer.
|
try { unlinkSync(`${tmpdir()}/${name}`); } catch { /* already gone */ }
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
const stream = createReadStream('', { fd });
|
||||||
|
|
||||||
// --get-url with bestvideo+bestaudio/best format is not what we want
|
if (fp) recordVideoAccess(fp, { id: videoId }).catch(() => {});
|
||||||
// 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');
|
|
||||||
|
|
||||||
// Fetch from YouTube and pipe to the client
|
return new Response(Readable.toWeb(stream), {
|
||||||
const upstream = await fetch(streamUrl, {
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
// Mimic a browser to avoid 403s from YouTube CDN
|
'Content-Type': 'video/mp4',
|
||||||
'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',
|
'Content-Length': String(size),
|
||||||
'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,
|
|
||||||
'Content-Disposition': `attachment; filename="${videoId}.mp4"`,
|
'Content-Disposition': `attachment; filename="${videoId}.mp4"`,
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
});
|
},
|
||||||
if (contentLength) headers.set('Content-Length', contentLength);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Log the access if a fingerprint was supplied (fire-and-forget)
|
// GET /api/download/:videoId
|
||||||
const fp = c.req.query('fp');
|
// Downloads the video server-side via yt-dlp and streams the finished file
|
||||||
if (fp) {
|
// to the browser so OPFS can store it. The browser never contacts YouTube
|
||||||
recordVideoAccess(fp, { id: videoId }).catch(() => {});
|
// 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) {
|
} catch (err) {
|
||||||
return c.json({ ok: false, error: err.message }, 500);
|
return c.json({ ok: false, error: err.message }, 500);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user