From b5c45750dea436be271e2f70798b95ac7a794266 Mon Sep 17 00:00:00 2001 From: "rexarrior@yandex.ru" Date: Sun, 9 Aug 2026 15:01:19 +0300 Subject: [PATCH 1/2] api/youtube: support VISIONOS media client Upgrade youtubei.js to 18.1.0 for native VISIONOS requests and make it available through CUSTOM_INNERTUBE_CLIENT. This replaces the Android VR visitor-data workaround, whose media URLs are now rejected with 403. The dependency requires Node.js 20 or newer; the Docker image already uses Node.js 24. --- api/package.json | 4 ++-- api/src/processing/services/youtube.js | 6 ++++-- docs/api-env-variables.md | 2 ++ pnpm-lock.yaml | 26 ++++++++++++++++---------- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/api/package.json b/api/package.json index 3166ba79..2e1ce91b 100644 --- a/api/package.json +++ b/api/package.json @@ -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": { diff --git a/api/src/processing/services/youtube.js b/api/src/processing/services/youtube.js index 55385971..8d2f457a 100644 --- a/api/src/processing/services/youtube.js +++ b/api/src/processing/services/youtube.js @@ -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]; diff --git a/docs/api-env-variables.md b/docs/api-env-variables.md index 113b667e..2eb27198 100644 --- a/docs/api-env-variables.md +++ b/docs/api-env-variables.md @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 405d37fb..95b8b74d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} From 2e8c384defaefa381bbdd51053ee0a7ebca2ee2a Mon Sep 17 00:00:00 2001 From: Aleksandr Rodionov Date: Mon, 28 Sep 2026 22:01:14 +0300 Subject: [PATCH 2/2] api/stream: report rejected chunked media requests Retry rejected media requests after refreshing their URLs. Return HTTP 500 when a source refuses or provides an empty stream, including when ffmpeg produces no output, instead of returning an empty successful download. --- api/src/stream/ffmpeg.js | 3 +- api/src/stream/internal.js | 62 +++++++++++++++++++++----------------- api/src/stream/shared.js | 4 +-- 3 files changed, 39 insertions(+), 30 deletions(-) diff --git a/api/src/stream/ffmpeg.js b/api/src/stream/ffmpeg.js index 4a72a309..bb61e77a 100644 --- a/api/src/stream/ffmpeg.js +++ b/api/src/stream/ffmpeg.js @@ -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); diff --git a/api/src/stream/internal.js b/api/src/stream/internal.js index aa8333ae..bab7c91f 100644 --- a/api/src/stream/internal.js +++ b/api/src/stream/internal.js @@ -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')); diff --git a/api/src/stream/shared.js b/api/src/stream/shared.js index 6d268564..3d71ab2a 100644 --- a/api/src/stream/shared.js +++ b/api/src/stream/shared.js @@ -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) {