From 0378a1ae15ed6d1cefdded8efbd7e5a993b01491 Mon Sep 17 00:00:00 2001 From: jj Date: Mon, 20 Jan 2025 12:37:36 +0000 Subject: [PATCH 1/7] api/youtube: fix error when downloading stuff from WEB --- api/src/processing/services/youtube.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/src/processing/services/youtube.js b/api/src/processing/services/youtube.js index 6559978c..e0dc8594 100644 --- a/api/src/processing/services/youtube.js +++ b/api/src/processing/services/youtube.js @@ -491,12 +491,12 @@ export default async function (o) { filenameAttributes.resolution = `${video.width}x${video.height}`; filenameAttributes.extension = codecList[codec].container; - video = video.url; - audio = audio.url; - if (innertubeClient === "WEB" && innertube) { video = video.decipher(innertube.session.player); audio = audio.decipher(innertube.session.player); + } else { + video = video.url; + audio = audio.url; } } From ec0d7737926d5d4f7c4db0dc71964c9291452770 Mon Sep 17 00:00:00 2001 From: jj Date: Mon, 20 Jan 2025 12:38:12 +0000 Subject: [PATCH 2/7] api/youtube: use Math.min instead of ternary operator --- api/src/processing/services/youtube.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/processing/services/youtube.js b/api/src/processing/services/youtube.js index e0dc8594..e16e86e7 100644 --- a/api/src/processing/services/youtube.js +++ b/api/src/processing/services/youtube.js @@ -240,7 +240,7 @@ export default async function (o) { const quality = o.quality === "max" ? 9000 : Number(o.quality); const normalizeQuality = res => { - const shortestSide = res.height > res.width ? res.width : res.height; + const shortestSide = Math.min(res.height, res.width); return videoQualities.find(qual => qual >= shortestSide); } From 035825bc0555fa2e1c2084a407ee14d04be97445 Mon Sep 17 00:00:00 2001 From: jj Date: Mon, 20 Jan 2025 14:38:55 +0000 Subject: [PATCH 3/7] api: cache original request parameters in stream --- api/src/processing/match-action.js | 3 ++- api/src/stream/manage.js | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index 64f86836..19896ceb 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -15,7 +15,8 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab filename: r.filenameAttributes ? createFilename(r.filenameAttributes, filenameStyle, isAudioOnly, isAudioMuted) : r.filename, fileMetadata: !disableMetadata ? r.fileMetadata : false, - requestIP + requestIP, + originalRequest: r.originalRequest }, params = {}; diff --git a/api/src/stream/manage.js b/api/src/stream/manage.js index 79b5c1db..3323ce5d 100644 --- a/api/src/stream/manage.js +++ b/api/src/stream/manage.js @@ -40,6 +40,7 @@ export function createStream(obj) { audioFormat: obj.audioFormat, isHLS: obj.isHLS || false, + originalRequest: obj.parameters }; // FIXME: this is now a Promise, but it is not awaited From 7767a5f5bb49d05b3e4d6f5a9fdf86e4a038c3a1 Mon Sep 17 00:00:00 2001 From: jj Date: Mon, 20 Jan 2025 14:46:55 +0000 Subject: [PATCH 4/7] api/youtube: add support for pinning client/itag --- api/src/processing/services/youtube.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/api/src/processing/services/youtube.js b/api/src/processing/services/youtube.js index e16e86e7..3ee673b9 100644 --- a/api/src/processing/services/youtube.js +++ b/api/src/processing/services/youtube.js @@ -149,7 +149,7 @@ export default async function (o) { useHLS = false; } - let innertubeClient = "ANDROID"; + let innertubeClient = o.innertubeClient || "ANDROID"; if (cookie) { useHLS = false; @@ -245,7 +245,7 @@ export default async function (o) { } let video, audio, dubbedLanguage, - codec = o.format || "h264"; + codec = o.format || "h264", itag = o.itag; if (useHLS) { const hlsManifest = info.streaming_data.hls_manifest_url; @@ -351,17 +351,21 @@ export default async function (o) { Number(b.bitrate) - Number(a.bitrate) ).forEach(format => { Object.keys(codecList).forEach(yCodec => { + const matchingItag = slot => !itag || itag[slot] === format.itag; const sorted = sorted_formats[yCodec]; const goodFormat = checkFormat(format, yCodec); if (!goodFormat) return; - if (format.has_video) { + if (format.has_video && matchingItag('video')) { sorted.video.push(format); - if (!sorted.bestVideo) sorted.bestVideo = format; + if (!sorted.bestVideo) + sorted.bestVideo = format; } - if (format.has_audio) { + + if (format.has_audio && matchingItag('audio')) { sorted.audio.push(format); - if (!sorted.bestAudio) sorted.bestAudio = format; + if (!sorted.bestAudio) + sorted.bestAudio = format; } }) }); From 19ade7c9053dc9323c35ccee25dffb25a6e19f27 Mon Sep 17 00:00:00 2001 From: jj Date: Mon, 20 Jan 2025 14:47:09 +0000 Subject: [PATCH 5/7] api/youtube: return internal metadata for replaying request --- api/src/processing/services/youtube.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/api/src/processing/services/youtube.js b/api/src/processing/services/youtube.js index 3ee673b9..ff4a95cf 100644 --- a/api/src/processing/services/youtube.js +++ b/api/src/processing/services/youtube.js @@ -452,6 +452,18 @@ export default async function (o) { youtubeDubName: dubbedLanguage || false, } + itag = { + video: video.itag, + audio: audio.itag + }; + + const originalRequest = { + ...o, + dispatcher: undefined, + itag, + innertubeClient + }; + if (audio && o.isAudioOnly) { let bestAudio = codec === "h264" ? "m4a" : "opus"; let urls = audio.url; @@ -473,6 +485,7 @@ export default async function (o) { fileMetadata, bestAudio, isHLS: useHLS, + originalRequest } } @@ -516,6 +529,7 @@ export default async function (o) { filenameAttributes, fileMetadata, isHLS: useHLS, + originalRequest } } From c07940bfa4fc5b5c26d2892074b901ba928a5184 Mon Sep 17 00:00:00 2001 From: jj Date: Mon, 20 Jan 2025 15:46:03 +0000 Subject: [PATCH 6/7] api/itunnel: pass itunnel object by reference --- api/src/core/api.js | 2 +- api/src/stream/stream.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/core/api.js b/api/src/core/api.js index 153f2ca6..e4d3dfcf 100644 --- a/api/src/core/api.js +++ b/api/src/core/api.js @@ -313,7 +313,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { ...Object.entries(req.headers) ]); - return stream(res, { type: 'internal', ...streamInfo }); + return stream(res, { type: 'internal', data: streamInfo }); }; app.get('/itunnel', itunnelHandler); diff --git a/api/src/stream/stream.js b/api/src/stream/stream.js index a6d41200..c7cf7b56 100644 --- a/api/src/stream/stream.js +++ b/api/src/stream/stream.js @@ -10,7 +10,7 @@ export default async function(res, streamInfo) { return await stream.proxy(streamInfo, res); case "internal": - return internalStream(streamInfo, res); + return internalStream(streamInfo.data, res); case "merge": return stream.merge(streamInfo, res); From 600c7691414f07568e038cca7877426b4ab9057d Mon Sep 17 00:00:00 2001 From: jj Date: Mon, 20 Jan 2025 15:55:26 +0000 Subject: [PATCH 7/7] api/stream: implement itunnel transplants --- api/src/misc/utils.js | 4 +++ api/src/stream/internal.js | 12 ++++++- api/src/stream/manage.js | 71 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/api/src/misc/utils.js b/api/src/misc/utils.js index fd497d18..e15690b0 100644 --- a/api/src/misc/utils.js +++ b/api/src/misc/utils.js @@ -29,3 +29,7 @@ export function splitFilenameExtension(filename) { return [ parts.join('.'), ext ] } } + +export function zip(a, b) { + return a.map((value, i) => [ value, b[i] ]); +} diff --git a/api/src/stream/internal.js b/api/src/stream/internal.js index 7d8bf4c9..8c94c485 100644 --- a/api/src/stream/internal.js +++ b/api/src/stream/internal.js @@ -7,7 +7,7 @@ const CHUNK_SIZE = BigInt(8e6); // 8 MB const min = (a, b) => a < b ? a : b; async function* readChunks(streamInfo, size) { - let read = 0n; + let read = 0n, chunksSinceTransplant = 0; while (read < size) { if (streamInfo.controller.signal.aborted) { throw new Error("controller aborted"); @@ -22,6 +22,16 @@ async function* readChunks(streamInfo, size) { signal: streamInfo.controller.signal }); + if (chunk.statusCode === 403 && chunksSinceTransplant >= 3 && streamInfo.transplant) { + chunksSinceTransplant = 0; + try { + await streamInfo.transplant(streamInfo.dispatcher); + continue; + } catch {} + } + + chunksSinceTransplant++; + const expected = min(CHUNK_SIZE, size - read); const received = BigInt(chunk.headers['content-length']); diff --git a/api/src/stream/manage.js b/api/src/stream/manage.js index 3323ce5d..ebb5c6c7 100644 --- a/api/src/stream/manage.js +++ b/api/src/stream/manage.js @@ -9,6 +9,7 @@ import { env } from "../config.js"; import { closeRequest } from "./shared.js"; import { decryptStream, encryptStream } from "../misc/crypto.js"; import { hashHmac } from "../security/secrets.js"; +import { zip } from "../misc/utils.js"; // optional dependency const freebind = env.freebindCIDR && await import('freebind').catch(() => {}); @@ -40,7 +41,7 @@ export function createStream(obj) { audioFormat: obj.audioFormat, isHLS: obj.isHLS || false, - originalRequest: obj.parameters + originalRequest: obj.originalRequest }; // FIXME: this is now a Promise, but it is not awaited @@ -101,6 +102,7 @@ export function createInternalStream(url, obj = {}) { controller, dispatcher, isHLS: obj.isHLS, + transplant: obj.transplant }); let streamLink = new URL('/itunnel', `http://127.0.0.1:${env.tunnelPort}`); @@ -116,13 +118,17 @@ export function createInternalStream(url, obj = {}) { return streamLink.toString(); } -export function destroyInternalStream(url) { +function getInternalTunnelId(url) { url = new URL(url); if (url.hostname !== '127.0.0.1') { return; } - const id = url.searchParams.get('id'); + return url.searchParams.get('id'); +} + +export function destroyInternalStream(url) { + const id = getInternalTunnelId(url); if (internalStreamCache.has(id)) { closeRequest(getInternalStream(id)?.controller); @@ -130,9 +136,68 @@ export function destroyInternalStream(url) { } } +const transplantInternalTunnels = function(tunnelUrls, transplantUrls) { + if (tunnelUrls.length !== transplantUrls.length) { + return; + } + + for (const [ tun, url ] of zip(tunnelUrls, transplantUrls)) { + const id = getInternalTunnelId(tun); + const itunnel = getInternalStream(id); + + if (!itunnel) continue; + itunnel.url = url; + } +} + +const transplantTunnel = async function (dispatcher) { + if (this.pendingTransplant) { + await this.pendingTransplant; + return; + } + + let finished; + this.pendingTransplant = new Promise(r => finished = r); + + try { + const handler = await import(`../processing/services/${this.service}.js`); + const response = await handler.default({ + ...this.originalRequest, + dispatcher + }); + + if (!response.urls) { + return; + } + + response.urls = [response.urls].flat(); + if (this.originalRequest.isAudioOnly && response.urls.length > 1) { + response.urls = [response.urls[1]]; + } else if (this.originalRequest.isAudioMuted) { + response.urls = [response.urls[0]]; + } + + const tunnels = [this.urls].flat(); + if (tunnels.length !== response.urls.length) { + return; + } + + transplantInternalTunnels(tunnels, response.urls); + } + catch {} + finally { + finished(); + delete this.pendingTransplant; + } +} + function wrapStream(streamInfo) { const url = streamInfo.urls; + if (streamInfo.originalRequest) { + streamInfo.transplant = transplantTunnel.bind(streamInfo); + } + if (typeof url === 'string') { streamInfo.urls = createInternalStream(url, streamInfo); } else if (Array.isArray(url)) {