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 }
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user