This commit is contained in:
Aleksandr Rodionov 2026-09-28 19:03:59 +00:00 • committed by GitHub
commit ed0fd48375
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 63 additions and 44 deletions

View file

@ -6,7 +6,7 @@
"exports": "./src/cobalt.js",
"type": "module",
"engines": {
"node": ">=18.17"
"node": ">=20"
},
"scripts": {
"start": "node src/cobalt",
@ -40,7 +40,7 @@
"set-cookie-parser": "2.7.2",
"undici": "^6.24.1",
"url-pattern": "1.0.3",
"youtubei.js": "17.0.1",
"youtubei.js": "18.1.0",
"zod": "^4.2.1"
},
"optionalDependencies": {

View file

@ -23,7 +23,6 @@ Platform.shim.eval = async (data) => {
}
const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms
let innertube, lastRefreshedAt;
const codecList = {
@ -57,7 +56,10 @@ const hlsCodecList = {
}
}
const clientsWithNoCipher = ['IOS', 'ANDROID', 'YTSTUDIO_ANDROID', 'YTMUSIC_ANDROID'];
const clientsWithNoCipher = [
'IOS', 'ANDROID', 'ANDROID_VR', 'VISIONOS',
'YTSTUDIO_ANDROID', 'YTMUSIC_ANDROID'
];
const videoQualities = [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320];

View file

@ -87,7 +87,8 @@ const render = async (res, streamInfo, ffargs, estimateMultiplier) => {
await estimateTunnelLength(streamInfo, estimateMultiplier)
);
pipe(muxOutput, res, shutdown);
// Keep the response open until shutdown can report an empty ffmpeg output as an error.
pipe(muxOutput, res, shutdown, { end: false });
process.on('close', shutdown);
res.on('finish', shutdown);

View file

@ -1,6 +1,6 @@
import { request } from "undici";
import { Readable } from "node:stream";
import { closeRequest, getHeaders, pipe } from "./shared.js";
import { closeRequest, closeResponse, getHeaders, pipe } from "./shared.js";
import { handleHlsPlaylist, isHlsResponse, probeInternalHLSTunnel } from "./internal-hls.js";
const CHUNK_SIZE = BigInt(8e6); // 8 MB
@ -9,31 +9,37 @@ const min = (a, b) => a < b ? a : b;
const serviceNeedsChunks = new Set(["youtube", "vk"]);
async function* readChunks(streamInfo, size) {
let read = 0n, chunksSinceTransplant = 0;
let read = 0n;
while (read < size) {
if (streamInfo.controller.signal.aborted) {
throw new Error("controller aborted");
}
const chunk = await request(streamInfo.url, {
headers: {
...getHeaders(streamInfo.service),
Range: `bytes=${read}-${read + CHUNK_SIZE}`
},
dispatcher: streamInfo.dispatcher,
signal: streamInfo.controller.signal,
maxRedirections: 4
});
let chunk, attempts = 0;
while (true) {
chunk = await request(streamInfo.url, {
headers: {
...getHeaders(streamInfo.service),
Range: `bytes=${read}-${read + CHUNK_SIZE}`
},
dispatcher: streamInfo.dispatcher,
signal: streamInfo.controller.signal,
maxRedirections: 4
});
if (chunk.statusCode === 403 && chunksSinceTransplant >= 3 && streamInfo.transplant) {
chunksSinceTransplant = 0;
try {
if (chunk.statusCode === 403 && streamInfo.transplant && attempts++ < 3) {
await chunk.body.dump();
await streamInfo.transplant(streamInfo.dispatcher);
continue;
} catch {}
}
}
chunksSinceTransplant++;
if (chunk.statusCode !== 200 && chunk.statusCode !== 206) {
await chunk.body.dump();
throw new Error(`media request failed with status ${chunk.statusCode}`);
}
break;
}
const expected = min(CHUNK_SIZE, size - read);
const received = BigInt(chunk.headers['content-length']);
@ -52,11 +58,18 @@ async function* readChunks(streamInfo, size) {
async function handleChunkedStream(streamInfo, res) {
const { signal } = streamInfo.controller;
const cleanup = () => (res.end(), closeRequest(streamInfo.controller));
const cleanup = () => {
if (!res.headersSent) {
res.removeHeader('content-length');
res.removeHeader('content-type');
}
closeResponse(res);
closeRequest(streamInfo.controller);
};
try {
let req, attempts = 3;
while (attempts--) {
let req;
for (let attempts = 0; ; attempts++) {
req = await fetch(streamInfo.url, {
headers: getHeaders(streamInfo.service),
method: 'HEAD',
@ -65,13 +78,8 @@ async function handleChunkedStream(streamInfo, res) {
});
streamInfo.url = req.url;
if (req.status === 403 && streamInfo.transplant) {
try {
await streamInfo.transplant(streamInfo.dispatcher);
} catch {
break;
}
} else break;
if (req.status !== 403 || !streamInfo.transplant || attempts >= 3) break;
await streamInfo.transplant(streamInfo.dispatcher);
}
const size = BigInt(req.headers.get('content-length'));

View file

@ -43,14 +43,14 @@ export function getHeaders(service) {
.reduce((p, [key, val]) => ({ ...p, [key]: String(val) }), {})
}
export function pipe(from, to, done) {
export function pipe(from, to, done, options) {
from.on('error', done)
.on('close', done);
to.on('error', done)
.on('close', done);
from.pipe(to);
from.pipe(to, options);
}
export async function estimateTunnelLength(streamInfo, multiplier = 1.1) {

View file

@ -260,6 +260,8 @@ the value is a number, either `0` or `1`.
### CUSTOM_INNERTUBE_CLIENT
innertube client that will be used instead of the default one.
`VISIONOS` is available as an alternative for videos whose media URLs are rejected with the default client. It does not work for every video (for example, made-for-kids videos). The default remains `IOS`.
the value is a string.
### YOUTUBE_SESSION_SERVER

26
pnpm-lock.yaml generated
View file

@ -62,8 +62,8 @@ importers:
specifier: 1.0.3
version: 1.0.3
youtubei.js:
specifier: 17.0.1
version: 17.0.1
specifier: 18.1.0
version: 18.1.0
zod:
specifier: ^4.2.1
version: 4.3.6
@ -1322,6 +1322,9 @@ packages:
picomatch:
optional: true
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
ffmpeg-static@5.3.0:
resolution: {integrity: sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==}
engines: {node: '>=16'}
@ -1645,9 +1648,9 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
meriyah@6.1.4:
resolution: {integrity: sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==}
engines: {node: '>=18.0.0'}
meriyah@7.3.3:
resolution: {integrity: sha512-uE5cnoNj+UYhoMdZDuymCzr5TzEuE3ZF2C4kn6Z76rLhkwnvZ/+6dUZnM41T3fifyuhW0+n8vK7eq6DKJxUfPA==}
engines: {node: '>=20.0.0'}
methods@1.1.2:
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
@ -2268,8 +2271,8 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
youtubei.js@17.0.1:
resolution: {integrity: sha512-1lO4b8UqMDzE0oh2qEGzbBOd4UYRdxn/4PdpRM7BGTHxM6ddsEsKZTu90jp8V9FHVgC2h1UirQyqoqLiKwl+Zg==}
youtubei.js@18.1.0:
resolution: {integrity: sha512-4Goo3ZeO6/kE8Mq67eC8Z9K1rijeqRwyKxf/YM4lqf8bB4r/s8JC0V3YFx7+l/AJR3LnAsulTsiIj+AFe0cduA==}
zimmerframe@1.1.2:
resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==}
@ -3286,6 +3289,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
fflate@0.8.3: {}
ffmpeg-static@5.3.0:
dependencies:
'@derhuerst/http-basic': 8.2.4
@ -3580,7 +3585,7 @@ snapshots:
merge2@1.4.1: {}
meriyah@6.1.4: {}
meriyah@7.3.3: {}
methods@1.1.2: {}
@ -4147,10 +4152,11 @@ snapshots:
yocto-queue@0.1.0: {}
youtubei.js@17.0.1:
youtubei.js@18.1.0:
dependencies:
'@bufbuild/protobuf': 2.2.5
meriyah: 6.1.4
fflate: 0.8.3
meriyah: 7.3.3
zimmerframe@1.1.2: {}