Chunk playback proxy at 10MB and merge adaptive streams for saves
This commit is contained in:
@@ -325,6 +325,9 @@ app.get('/api/channel', async (c) => {
|
|||||||
// videoId -> { info, formats:[{formatId,height,vcodec,acodec,abr,ext,url,headers}], expiresAt }
|
// videoId -> { info, formats:[{formatId,height,vcodec,acodec,abr,ext,url,headers}], expiresAt }
|
||||||
const streamCache = new Map();
|
const streamCache = new Map();
|
||||||
const STREAM_CACHE_MAX = 200;
|
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.
|
// googlevideo URLs carry `expire=<unix-seconds>`; return that as an ms epoch.
|
||||||
function parseExpiry(url) {
|
function parseExpiry(url) {
|
||||||
@@ -535,7 +538,12 @@ app.get('/api/play', async (c) => {
|
|||||||
const fmt = pickFormat(formats, sel);
|
const fmt = pickFormat(formats, sel);
|
||||||
if (!fmt) return { error: 'no matching format' };
|
if (!fmt) return { error: 'no matching format' };
|
||||||
const headers = { ...fmt.headers };
|
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' }) };
|
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 up = attempt.res;
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
for (const k of ['content-type', 'content-length', 'content-range', 'accept-ranges']) {
|
headers.set('Content-Type', up.headers.get('content-type') || (sel.wantAudio ? 'audio/mp4' : 'video/mp4'));
|
||||||
const v = up.headers.get(k);
|
headers.set('Accept-Ranges', 'bytes');
|
||||||
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('Cache-Control', 'no-store');
|
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) {
|
} catch (err) {
|
||||||
return c.json({ ok: false, error: 'stream proxy failed: ' + err.message }, 502);
|
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
|
// Default save — best progressive (audio+video single-file) format when
|
||||||
// ffmpeg is required anywhere in the chain.
|
// 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 {
|
try {
|
||||||
return await ytdlpDownloadResponse(videoId, fp, [
|
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) {
|
} 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