This repository has been archived on 2025-03-25. You can view files and clone it, but cannot push or open issues or pull requests.
invidious-companion/src/lib/helpers/youtubePlayerHandling.ts

250 lines
8.7 KiB
TypeScript

import { ApiResponse, Innertube, YT } from "youtubei.js";
import { generateRandomString } from "youtubei.js/Utils";
import { compress, decompress } from "https://deno.land/x/brotli@0.1.7/mod.ts";
import { Store } from "@willsoto/node-konfig-core";
import { failedRequests, retryCount, successfulRequests } from "metrics";
import { innertubeEmbeddedClient } from "../../main.ts";
import type { BG } from "bgutils";
let youtubePlayerReqLocation = "youtubePlayerReq";
if (Deno.env.get("YT_PLAYER_REQ_LOCATION")) {
if (Deno.env.has("DENO_COMPILED")) {
youtubePlayerReqLocation = Deno.mainModule.replace("src/main.ts", "") +
Deno.env.get("YT_PLAYER_REQ_LOCATION");
} else {
youtubePlayerReqLocation = Deno.env.get(
"YT_PLAYER_REQ_LOCATION",
) as string;
}
}
const { youtubePlayerReq } = await import(youtubePlayerReqLocation);
const videoCachePath = Deno.env.get("VIDEO_CACHE_PATH") as string ||
"/var/tmp/youtubei.js/video_cache";
const maxRetries = Deno.env.get("MAX_PROXY_RETRIES") as string || 3;
export let kv: Deno.Kv;
if ((Deno.env.get("VIDEO_CACHE_ON_DISK")?.toLowerCase() ?? false) == "true") {
console.log("[INFO] Storing video cache on disk");
kv = await Deno.openKv(videoCachePath);
} else {
kv = await Deno.openKv();
}
export const youtubePlayerParsing = async ({
innertubeClient,
videoId,
konfigStore,
tokenMinter,
overrideCache = false,
}: {
innertubeClient: Innertube;
videoId: string;
konfigStore: Store;
tokenMinter: BG.WebPoMinter;
overrideCache?: boolean;
}): Promise<object> => {
const cacheEnabled = overrideCache
? false
: konfigStore.get("cache.enabled");
const videoCached = (await kv.get(["video_cache", videoId]))
.value as Uint8Array;
if (videoCached != null && cacheEnabled) {
return JSON.parse(new TextDecoder().decode(decompress(videoCached)));
} else {
let youtubePlayerResponse = await youtubePlayerReq(
innertubeClient,
videoId,
konfigStore,
tokenMinter,
);
for (let retries = 1; retries <= (maxRetries as number); retries++) {
if (
!youtubePlayerResponse.data.playabilityStatus?.errorScreen
?.playerErrorMessageRenderer?.subreason?.runs?.[0]?.text
?.includes("This helps protect our community")
) {
break;
}
console.log(
`[DEBUG] Got 'This helps protect our community', retrying request for ${videoId}. Retry ${retries} of ${maxRetries}`,
);
retryCount.inc();
youtubePlayerResponse = await youtubePlayerReq(
innertubeClient,
videoId,
konfigStore,
);
}
if (
youtubePlayerResponse.data.playabilityStatus.status ===
"UNPLAYABLE" ||
youtubePlayerResponse.data.playabilityStatus.status ===
"LOGIN_REQUIRED" ||
youtubePlayerResponse.data.playabilityStatus.status ===
"CONTENT_CHECK_REQUIRED" ||
youtubePlayerResponse.data.playabilityStatus.reason ===
"Sign in to confirm your age"
) {
innertubeEmbeddedClient.session.context.client.visitorData =
innertubeClient.session.context.client.visitorData;
if (innertubeClient.session.po_token) {
innertubeEmbeddedClient.session.po_token =
innertubeClient.session.po_token;
}
innertubeEmbeddedClient.session.player =
innertubeClient.session.player;
youtubePlayerResponse = await youtubePlayerReq(
innertubeEmbeddedClient,
videoId,
konfigStore,
);
}
const videoData = youtubePlayerResponse.data;
const video = new YT.VideoInfo(
[youtubePlayerResponse],
innertubeClient.actions,
generateRandomString(16),
);
const streamingData = video.streaming_data;
// Modify the original YouTube response to include deciphered URLs
if (streamingData && videoData && videoData.streamingData) {
const ecatcherServiceTracking = videoData.responseContext
?.serviceTrackingParams.find((o: { service: string }) =>
o.service === "ECATCHER"
);
const clientNameUsed = ecatcherServiceTracking?.params?.find((
o: { key: string },
) => o.key === "client.name");
// no need to decipher on IOS nor ANDROID
if (
!clientNameUsed?.value.includes("IOS") &&
!clientNameUsed?.value.includes("ANDROID")
) {
for (const [index, format] of streamingData.formats.entries()) {
videoData.streamingData.formats[index].url = format
.decipher(
innertubeClient.session.player,
);
if (
videoData.streamingData.formats[index]
.signatureCipher !==
undefined
) {
delete videoData.streamingData.formats[index]
.signatureCipher;
}
if (
videoData.streamingData.formats[index].url.includes(
"alr=yes",
)
) {
videoData.streamingData.formats[index].url.replace(
"alr=yes",
"alr=no",
);
} else {
videoData.streamingData.formats[index].url += "&alr=no";
}
}
for (
const [index, adaptive_format] of streamingData
.adaptive_formats
.entries()
) {
videoData.streamingData.adaptiveFormats[index].url =
adaptive_format
.decipher(
innertubeClient.session.player,
);
if (
videoData.streamingData.adaptiveFormats[index]
.signatureCipher !==
undefined
) {
delete videoData.streamingData.adaptiveFormats[index]
.signatureCipher;
}
if (
videoData.streamingData.adaptiveFormats[index].url
.includes("alr=yes")
) {
videoData.streamingData.adaptiveFormats[index].url
.replace("alr=yes", "alr=no");
} else {
videoData.streamingData.adaptiveFormats[index].url +=
"&alr=no";
}
}
}
}
const videoOnlyNecessaryInfo = ((
{
captions,
playabilityStatus,
storyboards,
streamingData,
videoDetails,
microformat,
},
) => ({
captions,
playabilityStatus,
storyboards,
streamingData,
videoDetails,
microformat,
}))(videoData);
if (videoData.playabilityStatus?.status == "OK") {
successfulRequests.inc();
if (
cacheEnabled == true
) {
(async () => {
await kv.set(
["video_cache", videoId],
compress(
new TextEncoder().encode(
JSON.stringify(videoOnlyNecessaryInfo),
),
),
{
expireIn: 1000 * 60 * 60,
},
);
})();
} else {
failedRequests.inc();
}
}
return videoOnlyNecessaryInfo;
}
};
export const youtubeVideoInfo = (
innertubeClient: Innertube,
youtubePlayerResponseJson: object,
): YT.VideoInfo => {
const playerResponse = {
success: true,
status_code: 200,
data: youtubePlayerResponseJson,
} as ApiResponse;
return new YT.VideoInfo(
[playerResponse],
innertubeClient.actions,
"",
);
};