Chunk playback proxy at 10MB and merge adaptive streams for saves

This commit is contained in:
Jonathan Sykes
2026-08-23 15:43:01 +08:00
parent c8a9755e93
commit 8177f5c665

View File

@@ -325,6 +325,9 @@ app.get('/api/channel', async (c) => {
// videoId -> { info, formats:[{formatId,height,vcodec,acodec,abr,ext,url,headers}], expiresAt }
const streamCache = new Map();
const STREAM_CACHE_MAX = 200;
// Max bytes per upstream googlevideo request (its adaptive streams are cut
// after ~10 MB; matches yt-dlp's http_chunk_size).
const PLAY_CHUNK = 10 * 1024 * 1024 - 1024;
// googlevideo URLs carry `expire=<unix-seconds>`; return that as an ms epoch.
function parseExpiry(url) {
@@ -535,7 +538,12 @@ app.get('/api/play', async (c) => {
const fmt = pickFormat(formats, sel);
if (!fmt) return { error: 'no matching format' };
const headers = { ...fmt.headers };
if (range) headers['Range'] = range;
// Always ask upstream for a bounded slice (see PLAY_CHUNK below); the
// splicer extends it to the client's full requested range.
const rq = /bytes=(\d+)-(\d*)/.exec(range || '');
const from = rq ? Number(rq[1]) : 0;
const to = rq && rq[2] !== '' ? Math.min(Number(rq[2]), from + PLAY_CHUNK - 1) : from + PLAY_CHUNK - 1;
headers['Range'] = range && !rq ? range : `bytes=${from}-${to}`;
return { fmt, res: await fetch(fmt.url, { headers, redirect: 'follow' }) };
}
@@ -560,15 +568,77 @@ app.get('/api/play', async (c) => {
const up = attempt.res;
const headers = new Headers();
for (const k of ['content-type', 'content-length', 'content-range', 'accept-ranges']) {
const v = up.headers.get(k);
if (v) headers.set(k, v);
}
if (!headers.has('accept-ranges')) headers.set('Accept-Ranges', 'bytes');
if (!headers.has('content-type')) headers.set('Content-Type', sel.wantAudio ? 'audio/mp4' : 'video/mp4');
headers.set('Content-Type', up.headers.get('content-type') || (sel.wantAudio ? 'audio/mp4' : 'video/mp4'));
headers.set('Accept-Ranges', 'bytes');
headers.set('Cache-Control', 'no-store');
return new Response(up.body, { status: up.status, headers });
// googlevideo silently closes adaptive-stream connections after ~10 MB
// (yt-dlp's own `http_chunk_size` exists for exactly this), so a single
// forwarded `bytes=0-` truncated at ~30 s of 720p and the <video> element
// stalled with no error. Re-issue upstream fetches in ≤10 MB Range slices
// and splice them into ONE response body that is byte-complete for the
// client's requested range.
const cr = /bytes (\d+)-(\d+)\/(\d+|\*)/.exec(up.headers.get('content-range') || '');
const total = cr && cr[3] !== '*' ? Number(cr[3])
: up.status === 200 ? Number(up.headers.get('content-length') || 0) : 0;
if (!total || up.status !== 206 && up.status !== 200) {
for (const k of ['content-length', 'content-range']) {
const v = up.headers.get(k);
if (v) headers.set(k, v);
}
return new Response(up.body, { status: up.status, headers });
}
const rq = /bytes=(\d*)-(\d*)/.exec(range || '');
let start = cr ? Number(cr[1]) : 0;
let end = total - 1;
if (rq && rq[2] !== '') end = Math.min(total - 1, Number(rq[2]));
if (rq && rq[1] === '' && rq[2] !== '') { start = Math.max(0, total - Number(rq[2])); end = total - 1; }
const length = end - start + 1;
const fmt = attempt.fmt;
const body = new ReadableStream({
async start(ctrl) {
// Upstream body is a slice already; the slice boundaries below are
// relative to `start`, and the first slice reuses the in-flight fetch.
let pos = start;
let res = up;
try {
while (pos <= end) {
const sliceEnd = Math.min(end, pos + PLAY_CHUNK - 1);
if (res === null) {
res = await fetch(fmt.url, { headers: { ...fmt.headers, Range: `bytes=${pos}-${sliceEnd}` }, redirect: 'follow' });
if (res.status !== 206 && res.status !== 200) throw new Error('upstream ' + res.status);
}
const reader = res.body.getReader();
let got = 0;
while (got < sliceEnd - pos + 1) {
const { value, done } = await reader.read();
if (done) break;
const room = sliceEnd - pos + 1 - got;
const chunk = value.length > room ? value.subarray(0, room) : value;
ctrl.enqueue(chunk);
got += chunk.length;
}
try { reader.cancel(); } catch {}
if (got === 0) throw new Error('upstream returned no bytes');
pos += got;
res = null;
}
ctrl.close();
} catch (err) {
ctrl.error(err);
}
},
cancel() { try { up.body?.cancel(); } catch {} },
});
headers.set('Content-Length', String(length));
if (range) {
headers.set('Content-Range', `bytes ${start}-${end}/${total}`);
return new Response(body, { status: 206, headers });
}
return new Response(body, { status: 200, headers });
} catch (err) {
return c.json({ ok: false, error: 'stream proxy failed: ' + err.message }, 502);
}
@@ -715,11 +785,16 @@ app.get('/api/download/:videoId', async (c) => {
}
}
// Default save — best progressive (audio+video single-file) format, so no
// ffmpeg is required anywhere in the chain.
// Default save — best progressive (audio+video single-file) format when
// one exists, otherwise merge bestvideo (≤720p) + bestaudio with ffmpeg.
// YouTube now serves many videos with NO progressive format at all, which
// made the old progressive-only selector fail every save with
// "Requested format is not available".
try {
return await ytdlpDownloadResponse(videoId, fp, [
'-f', 'bestvideo[ext=mp4][acodec!=none]/bestvideo[acodec!=none]/best[ext=mp4]/best',
'-f', 'bestvideo[ext=mp4][acodec!=none]/bestvideo[acodec!=none]/best[ext=mp4][acodec!=none]/'
+ 'bv*[height<=720][ext=mp4]+ba[ext=m4a]/bv*[height<=720]+ba/b',
'--merge-output-format', 'mp4',
]);
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);