From eb9311e482da9dd0f39f9e3ff1434fef80cc454c Mon Sep 17 00:00:00 2001 From: Luan Date: Thu, 10 Oct 2024 02:57:43 -0300 Subject: [PATCH] chore: simplify onesie example --- examples/onesie-request/main.ts | 372 ++++++++++++++++++-------------- 1 file changed, 211 insertions(+), 161 deletions(-) diff --git a/examples/onesie-request/main.ts b/examples/onesie-request/main.ts index c2a3e54..60ab3be 100644 --- a/examples/onesie-request/main.ts +++ b/examples/onesie-request/main.ts @@ -1,191 +1,241 @@ -import Innertube, { Endpoints, Parser, UniversalCache } from 'youtubei.js'; - -import GoogleVideo, { base64ToU8, Protos, PART, QUALITY } from '../../dist/src/index.js'; +import Innertube, { UniversalCache } from 'youtubei.js'; +import { type Context, Endpoints, YT } from 'youtubei.js'; +import GoogleVideo, { base64ToU8, PART, Protos, QUALITY } from '../../dist/src/index.js'; import { decryptResponse, encryptRequest } from './utils.js'; -const innertube = await Innertube.create({ cache: new UniversalCache(true) }); - -const tvConfigResponse = await fetch('https://www.youtube.com/tv_config?action_get_config=true&client=lb4&theme=cl', { - method: 'GET', - headers: { - 'User-Agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version' - } -}); - -const tvConfigResponseData = await tvConfigResponse.text(); - -if (!tvConfigResponseData.startsWith(')]}')) - throw new Error('Invalid JSPB response'); - -const configData = JSON.parse(tvConfigResponseData.slice(4)); - -const webPlayerContextConfig = configData.webPlayerContextConfig.WEB_PLAYER_CONTEXT_CONFIG_ID_LIVING_ROOM_WATCH; -const onesieHotConfig = webPlayerContextConfig.onesieHotConfig; - -const clientKeyData = base64ToU8(onesieHotConfig.clientKey); -const encryptedClientKey = base64ToU8(onesieHotConfig.encryptedClientKey); -const onesieUstreamerConfig = base64ToU8(onesieHotConfig.onesieUstreamerConfig); -const baseUrl = onesieHotConfig.baseUrl; - -const videoId = 'JAs6WyK-Kr0'; - -const clonedContext = JSON.parse(JSON.stringify(innertube.session.context)); // Clone the context to avoid modifying the original one -const playerRequest = { - context: clonedContext, - ...Endpoints.PlayerEndpoint.build({ - video_id: videoId, - sts: innertube.session.player?.sts - }) +type ClientConfig = { + clientKeyData: Uint8Array; + encryptedClientKey: Uint8Array; + onesieUstreamerConfig: Uint8Array; + baseUrl: string; }; -// Change or remove these if you want to use a different client. I chose TVHTML5 purely for testing. -clonedContext.client.clientName = 'TVHTML5'; -clonedContext.client.clientVersion = '7.20240717.18.00'; +/** + * Fetches and parses the YouTube TV client configuration. + * Configurations from other clients can be used as well. I chose TVHTML5 for its simplicity. + */ +async function getYouTubeTVClientConfig(): Promise { + const tvConfigResponse = await fetch('https://www.youtube.com/tv_config?action_get_config=true&client=lb4&theme=cl', { + method: 'GET', + headers: { + 'User-Agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version' + } + }); -const headers = [ - { + const tvConfig = await tvConfigResponse.text(); + if (!tvConfig.startsWith(')]}')) + throw new Error('Invalid response from YouTube TV config endpoint.'); + + const tvConfigJson = JSON.parse(tvConfig.slice(4)); + + const webPlayerContextConfig = tvConfigJson.webPlayerContextConfig.WEB_PLAYER_CONTEXT_CONFIG_ID_LIVING_ROOM_WATCH; + const onesieHotConfig = webPlayerContextConfig.onesieHotConfig; + + const clientKeyData = base64ToU8(onesieHotConfig.clientKey); + const encryptedClientKey = base64ToU8(onesieHotConfig.encryptedClientKey); + const onesieUstreamerConfig = base64ToU8(onesieHotConfig.onesieUstreamerConfig); + const baseUrl = onesieHotConfig.baseUrl; + + return { + clientKeyData, + encryptedClientKey, + onesieUstreamerConfig, + baseUrl + }; +} + +type OnesieRequestArgs = { + videoId: string; + poToken?: string; + clientConfig: ClientConfig; + innertube: Innertube; +}; + +type OnesieRequest = { + body: Uint8Array; + encodedVideoId: string; +} + +/** + * Prepares a Onesie request. + */ +async function prepareOnesieRequest(args: OnesieRequestArgs): Promise { + const { videoId, poToken, clientConfig, innertube } = args; + const { clientKeyData, encryptedClientKey, onesieUstreamerConfig } = clientConfig; + + const clonedInnerTubeContext: Context = JSON.parse(JSON.stringify(innertube.session.context)); + + // Change or remove these if you want to use a different client. I chose TVHTML5 purely for testing. + clonedInnerTubeContext.client.clientName = 'TVHTML5'; + clonedInnerTubeContext.client.clientVersion = '7.20240717.18.00'; + + const playerRequestJson = { + context: clonedInnerTubeContext, + ...Endpoints.PlayerEndpoint.build({ + video_id: videoId, + po_token: poToken, + sts: innertube.session.player?.sts + }) + }; + + const headers = [ { name: 'Content-Type', value: 'application/json' }, { name: 'User-Agent', - value: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36' + value: innertube.session.context.client.userAgent }, { name: 'X-Goog-Visitor-Id', value: innertube.session.context.client.visitorData + } ]; + + const onesieRequest = Protos.OnesieRequest.encode({ + url: 'https://youtubei.googleapis.com/youtubei/v1/player?key=AIzaSyDCU8hByM-4DrUqRUYnGn-3llEO78bcxq8', + headers, + body: JSON.stringify(playerRequestJson), + field4: false, + field6: false + }).finish(); + + const { encrypted, hmac, iv } = await encryptRequest(clientKeyData, onesieRequest); + + const body = Protos.OnesieInnertubeRequest.encode({ + encryptedRequest: { + encryptedClientKey, + encryptedOnesieRequest: encrypted, + enableCompression: false, + hmac: hmac, + iv: iv, + TQ: true, + YP: true + }, + clientAbrState: { + timeSinceLastManualFormatSelectionMs: 0, + lastManualDirection: 0, + quality: QUALITY.HD720, + selectedQualityHeight: QUALITY.HD720, + startTimeMs: 0, + visibility: 0 + }, + streamerContext: { + field5: [], + field6: [], + poToken: poToken ? base64ToU8(poToken) : undefined, + playbackCookie: undefined, + clientInfo: { + clientName: 7, + clientVersion: innertube.session.context.client.clientVersion + } + }, + onesieUstreamerConfig + }).finish(); + + const videoIdBytes = base64ToU8(videoId); + const encodedVideoIdChars = []; + + for (const byte of videoIdBytes) { + encodedVideoIdChars.push(byte.toString(16).padStart(2, '0')); } -]; -const onesieRequest = Protos.OnesieRequest.encode({ - url: 'https://youtubei.googleapis.com/youtubei/v1/player?key=AIzaSyDCU8hByM-4DrUqRUYnGn-3llEO78bcxq8', - headers, - body: JSON.stringify(playerRequest), - field4: false, - field6: false -}).finish(); + const encodedVideoId = encodedVideoIdChars.join(''); -const { encrypted, hmac, iv } = await encryptRequest(clientKeyData, onesieRequest); - -const body = Protos.OnesieInnertubeRequest.encode({ - encryptedRequest: { - encryptedClientKey, - encryptedOnesieRequest: encrypted, - enableCompression: false, - hmac: hmac, - iv: iv, - TQ: true, - YP: true - }, - mediaInfo: { - timeSinceLastManualFormatSelectionMs: 0, - lastManualDirection: 0, - quality: QUALITY.HD720, - selectedQualityHeight: QUALITY.HD720, - startTimeMs: 0, - visibility: 0 - }, - streamerContext: { - field5: [], - field6: [], - poToken: undefined, - playbackCookie: undefined, - clientInfo: { - clientName: 7, - clientVersion: '7.20240915.19.00' - } - }, - onesieUstreamerConfig -}).finish(); - -const redirectorResponse = await fetch(`https://redirector.googlevideo.com/initplayback?source=youtube&itag=0&pvi=0&pai=0&owc=yes&id=${Math.round(Math.random() * 1E5)}`, { - method: 'GET', - redirect: 'manual' -}); - -const redirectorResponseUrl = redirectorResponse.headers.get('location'); - -if (!redirectorResponseUrl) - throw new Error('Invalid redirector response'); - -let url = `${redirectorResponseUrl.split('/initplayback')[0]}${baseUrl}`; - -const queryParams = []; - -const videoIdBytes = base64ToU8(videoId); -const encodedVideoId = []; - -for (const byte of videoIdBytes) { - encodedVideoId.push(byte.toString(16).padStart(2, '0')); + return { body, encodedVideoId }; } -queryParams.push(`id=${encodedVideoId.join('')}`); -queryParams.push('&opr=1'); -queryParams.push('&por=1'); -queryParams.push('rn=1'); - /** - * Add the following search params to get media data parts along with the onesie response: - * Video: searchParams.push('pvi=337,336,335,787,788,313,271,248,247,780,779,244,243,242,137,136,135,134,133,160,360,358,357,274,317,273,318,280,279,225,224,145,144,222,223,143,142,359'); - * Audio: searchParams.push('pai=141,140,149,251,250'); + * Fetches basic video info (streaming data, video details, etc.) using a Onesie request (/initplayback). */ +async function getBasicInfo(innertube: Innertube, videoId: string): Promise { + const redirectorResponse = await fetch(`https://redirector.googlevideo.com/initplayback?source=youtube&itag=0&pvi=0&pai=0&owc=yes&id=${Math.round(Math.random() * 1E5)}`, { + method: 'GET', + redirect: 'manual' + }); -url += `&${queryParams.join('&')}`; + const redirectorResponseUrl = redirectorResponse.headers.get('location'); -const response = await fetch(url, { - method: 'POST', - headers: { - 'accept': '*/*', - 'content-type': 'text/plain' - }, - referrer: 'https://www.youtube.com/', - body -}); + if (!redirectorResponseUrl) + throw new Error('Invalid redirector response'); -const arrayBuffer = await response.arrayBuffer(); + const clientConfig = await getYouTubeTVClientConfig(); + const onesieRequest = await prepareOnesieRequest({ videoId, /* If needed - poToken,*/ clientConfig, innertube }); -const googUmp = new GoogleVideo.UMP(new GoogleVideo.ChunkedDataBuffer([ new Uint8Array(arrayBuffer) ])); + let url = `${redirectorResponseUrl.split('/initplayback')[0]}${clientConfig.baseUrl}`; -const onesie: (Protos.OnesieHeader & { data?: Uint8Array })[] = []; + const queryParams = []; + queryParams.push(`id=${onesieRequest.encodedVideoId}`); + queryParams.push('&opr=1'); + queryParams.push('&por=1'); + queryParams.push('rn=1'); -googUmp.parse((part) => { - const data = part.data.chunks[0]; - switch (part.type) { - case PART.SABR_ERROR: - console.log('[SABR_ERROR]:', Protos.SabrError.decode(data)); - break; - case PART.ONESIE_HEADER: - onesie.push(Protos.OnesieHeader.decode(data)); - break; - case PART.ONESIE_DATA: - onesie[onesie.length - 1].data = data; - break; - default: - break; + url += `&${queryParams.join('&')}`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'accept': '*/*', + 'content-type': 'text/plain' + }, + referrer: 'https://www.youtube.com/', + body: onesieRequest.body + }); + + const arrayBuffer = await response.arrayBuffer(); + const googUmp = new GoogleVideo.UMP(new GoogleVideo.ChunkedDataBuffer([ new Uint8Array(arrayBuffer) ])); + + const onesie: (Protos.OnesieHeader & { data?: Uint8Array })[] = []; + + googUmp.parse((part) => { + const data = part.data.chunks[0]; + switch (part.type) { + case PART.SABR_ERROR: + console.log('[SABR_ERROR]:', Protos.SabrError.decode(data)); + break; + case PART.ONESIE_HEADER: + onesie.push(Protos.OnesieHeader.decode(data)); + break; + case PART.ONESIE_DATA: + onesie[onesie.length - 1].data = data; + break; + default: + break; + } + }); + + const onesiePlayerResponse = onesie.find((header) => header.type === Protos.OnesieHeaderType.PLAYER_RESPONSE); + + if (onesiePlayerResponse) { + if (!onesiePlayerResponse.cryptoParams) + throw new Error('Crypto params not found'); + + const iv = onesiePlayerResponse.cryptoParams.iv; + const hmac = onesiePlayerResponse.cryptoParams.hmac; + const encrypted = onesiePlayerResponse.data; + + const decryptedData = await decryptResponse(iv, hmac, encrypted, clientConfig.clientKeyData); + const response = Protos.OnesieInnertubeResponse.decode(decryptedData); + + if (response.proxyStatus !== 1) + throw new Error('Proxy status not OK'); + + if (response.status !== 200) + throw new Error('Status not OK'); + + const playerResponse = { + success: true, + status_code: 200, + data: JSON.parse(new TextDecoder().decode(response.body)) + }; + + return new YT.VideoInfo([ playerResponse ], innertube.actions, ''); } -}); -const onesiePlayerResponse = onesie.find((header) => header.type === Protos.OnesieHeaderType.PLAYER_RESPONSE); - -if (onesiePlayerResponse) { - if (!onesiePlayerResponse.cryptoParams) - throw new Error('Crypto params not found'); - - const iv = onesiePlayerResponse.cryptoParams.iv; - const hmac = onesiePlayerResponse.cryptoParams.hmac; - const encrypted = onesiePlayerResponse.data; - - const decryptedData = await decryptResponse(iv, hmac, encrypted, clientKeyData); - const response = Protos.OnesieInnertubeResponse.decode(decryptedData); - - if (response.proxyStatus !== 1) - throw new Error('Proxy status not OK'); - - if (response.status !== 200) - throw new Error('Status not OK'); - - const playerResponse = Parser.parseResponse(JSON.parse(new TextDecoder().decode(response.body))); - - console.log('Player response:', playerResponse); - console.log('Deciphered audio URL:', playerResponse.streaming_data?.adaptive_formats.find((fmt) => fmt.has_audio)?.decipher(innertube.session.player)); + throw new Error('Player response not found'); } + +const innertube = await Innertube.create({ cache: new UniversalCache(true) }); + +const videoInfo = await getBasicInfo(innertube, 'JAs6WyK-Kr0'); +console.log('Basic info:', videoInfo); +console.log('Deciphered audio URL:', videoInfo.chooseFormat({ format: 'mp4', quality: 'best', type: 'audio' }).decipher(innertube.session.player)); \ No newline at end of file