diff --git a/examples/browser/web/package-lock.json b/examples/browser/web/package-lock.json index ef93ad590..f34477317 100644 --- a/examples/browser/web/package-lock.json +++ b/examples/browser/web/package-lock.json @@ -8,7 +8,8 @@ "name": "web", "version": "0.0.0", "dependencies": { - "bgutils-js": "^1.1.0", + "bgutils-js": "^2.0.1", + "googlevideo": "github:LuanRT/googlevideo", "shaka-player": "^4.3.8" }, "devDependencies": { @@ -16,10 +17,15 @@ "vite": "^3.2.10" } }, + "node_modules/@bufbuild/protobuf": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.0.0.tgz", + "integrity": "sha512-sw2JhwJyvyL0zlhG61aDzOVryEfJg2PDZFSV7i7IdC7nAE41WuXCru3QWLGiP87At0BMzKOoKO/FqEGoKygGZQ==" + }, "node_modules/bgutils-js": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/bgutils-js/-/bgutils-js-1.1.0.tgz", - "integrity": "sha512-+v+MWO02VAfSKuuh9gpjxBTllFGkIiqzZT7ELwScOtm2UWk6MOm7lqkVtzctcjCrG0sgRZccfEbgaEWHozXLSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bgutils-js/-/bgutils-js-2.0.1.tgz", + "integrity": "sha512-Cf0eidVlipmnEBJw/T3gjj3C/4s1eKLyNZF8MDzb/5XRCn52rW0WjJlMf9xF6xyn5nqt8wO9BiQIcBymKOJZNQ==", "funding": [ "https://github.com/sponsors/LuanRT" ] @@ -101,6 +107,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/googlevideo": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/LuanRT/googlevideo.git#ae7f419ca07a0856c63d4aa7ddcbeeed029990ab", + "funding": [ + "https://github.com/sponsors/LuanRT" + ], + "dependencies": { + "@bufbuild/protobuf": "^2.0.0" + } + }, "node_modules/hasown": { "version": "2.0.0", "dev": true, diff --git a/examples/browser/web/package.json b/examples/browser/web/package.json index e2916beab..09a76c15a 100644 --- a/examples/browser/web/package.json +++ b/examples/browser/web/package.json @@ -13,7 +13,8 @@ "vite": "^3.2.10" }, "dependencies": { - "bgutils-js": "^1.1.0", + "bgutils-js": "^2.0.1", + "googlevideo": "github:LuanRT/googlevideo", "shaka-player": "^4.3.8" } } diff --git a/examples/browser/web/src/main.ts b/examples/browser/web/src/main.ts index 12dd70088..139098cfe 100644 --- a/examples/browser/web/src/main.ts +++ b/examples/browser/web/src/main.ts @@ -1,9 +1,53 @@ -import { Innertube, ProtoUtils, UniversalCache, Utils } from '../../../../bundle/browser'; -import BG from 'bgutils-js'; +import { BG } from 'bgutils-js'; +import GoogleVideo, { PART, Protos } from 'googlevideo'; +import { Innertube, ProtoUtils, UniversalCache, Utils, YTNodes } from '../../../..'; -// @ts-ignore - Shaka's TS support is not the best. -import shaka from 'shaka-player/dist/shaka-player.ui.js'; -import "shaka-player/dist/controls.css"; +// @ts-expect-error - x +import shaka from 'shaka-player/dist/shaka-player.ui'; + +import 'shaka-player/dist/controls.css'; + +function fetchFn(input: RequestInfo | URL, init?: RequestInit) { + const url = typeof input === 'string' + ? new URL(input) + : input instanceof URL + ? input + : new URL(input.url); + + // Transform the url for use with our proxy. + url.searchParams.set('__host', url.host); + url.host = 'localhost:8080'; + url.protocol = 'http'; + + const headers = init?.headers + ? new Headers(init.headers) + : input instanceof Request + ? input.headers + : new Headers(); + + // Now serialize the headers. + url.searchParams.set('__headers', JSON.stringify([...headers])); + + if (input instanceof Request) { + // @ts-expect-error - x + input.duplex = 'half'; + } + + // Copy over the request. + const request = new Request( + url, + input instanceof Request ? input : undefined + ); + + headers.delete('user-agent'); + + return fetch(request, init ? { + ...init, + headers + } : { + headers + }); +} const title = document.getElementById('title') as HTMLHeadingElement; const description = document.getElementById('description') as HTMLDivElement; @@ -11,19 +55,62 @@ const metadata = document.getElementById('metadata') as HTMLDivElement; const loader = document.getElementById('loader') as HTMLDivElement; const form = document.querySelector('form') as HTMLFormElement; +async function getPo(identifier: string): Promise { + const requestKey = 'O43z0dpjhgX20SCx4KAo'; + + const bgConfig = { + fetch: fetchFn, + globalObj: window, + requestKey, + identifier + }; + + const challenge = await BG.Challenge.create(bgConfig); + + if (!challenge) + throw new Error('Could not get challenge'); + + if (challenge.script) { + const script = challenge.script.find((sc) => sc !== null); + if (script) + new Function(script)(); + } else { + console.warn('Unable to load VM.'); + } + + const poToken = await BG.PoToken.generate({ + program: challenge.challenge, + globalName: challenge.globalName, + bgConfig + }); + + return poToken; +} async function main() { + const oauthCreds = undefined; + // Const oauthCreds = { + // Access_token: 'ya29.abcd', + // Refresh_token: '1//0abcd', + // Scope: 'https://www.googleapis.com/auth/youtube-paid-content https://www.googleapis.com/auth/youtube', + // Token_type: 'Bearer', + // Expiry_date: '2024-08-13T04:41:34.757Z' + // }; + const visitorData = ProtoUtils.encodeVisitorData(Utils.generateRandomString(11), Math.floor(Date.now() / 1000)); const poToken = await getPo(visitorData); - const yt = await Innertube.create({ + let yt = await Innertube.create({ po_token: poToken, visitor_data: visitorData, - generate_session_locally: true, fetch: fetchFn, - cache: new UniversalCache(true), + generate_session_locally: true, + cache: new UniversalCache(false) }); + if (oauthCreds) + await yt.session.signIn(oauthCreds); + form.animate({ opacity: [0, 1] }, { duration: 300, easing: 'ease-in-out' }); form.style.display = 'block'; @@ -52,7 +139,7 @@ async function main() { } try { - if (videoIdOrURL.match(/(http|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#-]*[\w@?^=%&\/~+#-])/)) { + if (videoIdOrURL.match(/(http|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])/)) { const endpoint = await yt.resolveURL(videoIdOrURL); if (!endpoint.payload.videoId) { @@ -66,6 +153,28 @@ async function main() { videoId = videoIdOrURL; } + if (yt.session.logged_in) { + const user = await yt.account.getInfo(); + const accountItemSections = user.page.contents_memo?.getType(YTNodes.AccountItemSection); + + if (accountItemSections) { + const accountItemSection = accountItemSections.first(); + const accountItem = accountItemSection.contents.first(); + const datasyncIdToken = `${accountItem.endpoint.payload.directSigninIdentity.effectiveObfuscatedGaiaId}||`; + const poToken = await getPo(datasyncIdToken); + + yt = await Innertube.create({ + po_token: poToken, + visitor_data: visitorData, + fetch: fetchFn, + generate_session_locally: true, + cache: new UniversalCache(false) + }); + + await yt.session.signIn(oauthCreds); + } + } + const info = await yt.getInfo(videoId); title.textContent = info.basic_info.title || null; @@ -83,7 +192,7 @@ async function main() { const dash = await info.toDash(); - const uri = 'data:application/dash+xml;charset=utf-8;base64,' + btoa(dash); + const uri = `data:application/dash+xml;charset=utf-8;base64,${btoa(dash)}`; if (player) { await player.destroy(); @@ -99,8 +208,8 @@ async function main() { const shakaContainer = document.getElementById('shaka-container') as HTMLDivElement; shakaContainer - .querySelectorAll("div") - .forEach(node => node.remove()); + .querySelectorAll('div') + .forEach((node) => node.remove()); shaka.polyfill.installAll(); @@ -115,9 +224,9 @@ async function main() { seekBarColors: { base: 'rgba(255,255,255,.2)', buffered: 'rgba(255,255,255,.4)', - played: 'rgb(255,0,0)', + played: 'rgb(255,0,0)' }, - fadeDelay: 0, + fadeDelay: 0 }; ui.configure(config); @@ -134,78 +243,130 @@ async function main() { player.configure({ streaming: { - bufferingGoal: 180, - rebufferingGoal: 0.02, - bufferBehind: 300 + bufferingGoal: (info.page[0].player_config?.media_common_config.dynamic_readahead_config.max_read_ahead_media_time_ms || 0) / 1000, + rebufferingGoal: (info.page[0].player_config?.media_common_config.dynamic_readahead_config.read_ahead_growth_rate_ms || 0) / 1000, + bufferBehind: 300, + autoLowLatencyMode: true + }, + abr: { + enabled: true, + restrictions: { + maxBandwidth: Number(info.page[0].player_config?.stream_selection_config.max_bitrate) + } } }); - player.getNetworkingEngine()?.registerRequestFilter((_type: any, request: any) => { + let rn = 0; + + player.getNetworkingEngine()?.registerRequestFilter((_type: unknown, request: Record) => { const uri = request.uris[0]; const url = new URL(uri); const headers = request.headers; - if (url.host.endsWith(".googlevideo.com") || headers.Range) { + if (url.host.endsWith('.googlevideo.com') || headers.Range) { url.searchParams.set('__host', url.host); url.host = 'localhost:8080'; url.protocol = 'http'; } request.method = 'POST'; - - // protobuf - { 15: 0 } request.body = new Uint8Array([120, 0]); - if (url.pathname === "/videoplayback") { + if (url.pathname === '/videoplayback') { if (headers.Range) { request.headers = {}; - url.searchParams.set("range", headers.Range.split("=")[1]); - url.searchParams.set("alr", "yes"); + url.searchParams.set('range', headers.Range.split('=')[1]); + url.searchParams.set('ump', '1'); + url.searchParams.set('srfvp', '1'); + url.searchParams.set('rn', rn.toString()); delete headers.Range; } + + rn += 1; } request.uris[0] = url.toString(); }); - // The UTF-8 characters "h", "t", "t", and "p". - const HTTP_IN_HEX = 0x68747470; - const RequestType = shaka.net.NetworkingEngine.RequestType; - player.getNetworkingEngine()?.registerResponseFilter(async (type: any, response: any) => { - const dataView = new DataView(response.data); - - if (response.data.byteLength < 4 || - dataView.getUint32(0) != HTTP_IN_HEX) { - return; + player.getNetworkingEngine()?.registerResponseFilter(async (type: unknown, response: Record) => { + let mediaData = new Uint8Array(0); + + const handleRedirect = async (redirectData: Protos.SabrRedirect) => { + const redirectRequest = shaka.net.NetworkingEngine.makeRequest([redirectData.url], player!.getConfiguration().streaming.retryParameters); + const requestOperation = player!.getNetworkingEngine()!.request(type, redirectRequest); + const redirectResponse = await requestOperation.promise; + + response.data = redirectResponse.data; + response.headers = redirectResponse.headers; + response.uri = redirectResponse.uri; + }; + + const handleMediaData = async (data: Uint8Array) => { + const combinedLength = mediaData.length + data.length; + const tempMediaData = new Uint8Array(combinedLength); + + tempMediaData.set(mediaData); + tempMediaData.set(data, mediaData.length); + + mediaData = tempMediaData; + }; + + if (type == RequestType.SEGMENT) { + const dataBuffer = new GoogleVideo.ChunkedDataBuffer([new Uint8Array(response.data)]); + + const googUmp = new GoogleVideo.UMP(dataBuffer); + + let redirect: Protos.SabrRedirect | undefined; + + googUmp.parse((part) => { + try { + const data = part.data.chunks[0]; + switch (part.type) { + case PART.MEDIA_HEADER: { + const mediaHeader = Protos.MediaHeader.decode(data); + console.info('[MediaHeader]:', mediaHeader); + break; + } + case PART.MEDIA: { + handleMediaData(part.data.split(1).remainingBuffer.chunks[0]); + break; + } + case PART.SABR_REDIRECT: { + redirect = Protos.SabrRedirect.decode(data); + console.info('[SabrRedirect]:', redirect); + break; + } + case PART.STREAM_PROTECTION_STATUS: { + const streamProtectionStatus = Protos.StreamProtectionStatus.decode(data); + switch (streamProtectionStatus.status) { + case 1: + console.info('[StreamProtectionStatus]: Good'); + break; + case 2: + console.error('[StreamProtectionStatus]: Attestation pending'); + break; + case 3: + console.error('[StreamProtectionStatus]: Attestation required'); + break; + default: + break; + } + break; + } + } + } catch (error) { + console.error('An error occurred while processing the part:', error); + } + }); + + if (redirect) + return handleRedirect(redirect); + + if (mediaData.length) + response.data = mediaData; } - - const response_as_string = shaka.util.StringUtils.fromUTF8(response.data); - - let retry_parameters; - - if (type == RequestType.MANIFEST) { - retry_parameters = player!.getConfiguration().manifest.retryParameters; - } else if (type == RequestType.SEGMENT) { - retry_parameters = player!.getConfiguration().streaming.retryParameters; - } else if (type == RequestType.LICENSE) { - retry_parameters = player!.getConfiguration().drm.retryParameters; - } else { - retry_parameters = shaka.net.NetworkingEngine.defaultRetryParameters(); - } - - // Make another request for the redirect URL. - const uris = [response_as_string]; - const redirect_request = shaka.net.NetworkingEngine.makeRequest(uris, retry_parameters); - const request_operation = player!.getNetworkingEngine()!.request(type, redirect_request); - const redirect_response = await request_operation.promise; - - // Modify the original response to contain the results of the redirect - // response. - response.data = redirect_response.data; - response.headers = redirect_response.headers; - response.uri = redirect_response.uri; }); try { @@ -224,82 +385,8 @@ async function main() { }); } -async function getPo(identity: string): Promise { - const requestKey = 'O43z0dpjhgX20SCx4KAo'; - - const bgConfig = { - fetch: fetchFn, - globalObj: window, - requestKey, - identity - }; - - const challenge = await BG.Challenge.create(bgConfig); - - if (!challenge) - throw new Error('Could not get challenge'); - - if (challenge.script) { - const script = challenge.script.find((sc) => sc !== null); - if (script) - new Function(script)(); - } else { - console.warn('Unable to load VM.'); - } - - const poToken = await BG.PoToken.generate({ - program: challenge.challenge, - globalName: challenge.globalName, - bgConfig - }); - - return poToken; -} - -function fetchFn(input: RequestInfo | URL, init?: RequestInit) { - const url = typeof input === 'string' - ? new URL(input) - : input instanceof URL - ? input - : new URL(input.url); - - // Transform the url for use with our proxy. - url.searchParams.set('__host', url.host); - url.host = 'localhost:8080'; - url.protocol = 'http'; - - const headers = init?.headers - ? new Headers(init.headers) - : input instanceof Request - ? input.headers - : new Headers(); - - // Now serialize the headers. - url.searchParams.set('__headers', JSON.stringify([...headers])); - - if (input instanceof Request) { - // @ts-expect-error - x - input.duplex = 'half'; - } - - // Copy over the request. - const request = new Request( - url, - input instanceof Request ? input : undefined - ); - - headers.delete('user-agent'); - - return fetch(request, init ? { - ...init, - headers - } : { - headers - }); -} - function showUI(args: { hidePlayer?: boolean } = { - hidePlayer: true, + hidePlayer: true }) { const ytplayer = document.getElementById('shaka-container') as HTMLDivElement;