diff --git a/examples/browser/web/package-lock.json b/examples/browser/web/package-lock.json index ef93ad59..f3447731 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 e2916bea..09a76c15 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 12dd7008..139098cf 100644 --- a/examples/browser/web/src/main.ts +++ b/examples/browser/web/src/main.ts @@ -1,260 +1,11 @@ -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'; -const title = document.getElementById('title') as HTMLHeadingElement; -const description = document.getElementById('description') as HTMLDivElement; -const metadata = document.getElementById('metadata') as HTMLDivElement; -const loader = document.getElementById('loader') as HTMLDivElement; -const form = document.querySelector('form') as HTMLFormElement; - - -async function main() { - const visitorData = ProtoUtils.encodeVisitorData(Utils.generateRandomString(11), Math.floor(Date.now() / 1000)); - const poToken = await getPo(visitorData); - - const yt = await Innertube.create({ - po_token: poToken, - visitor_data: visitorData, - generate_session_locally: true, - fetch: fetchFn, - cache: new UniversalCache(true), - }); - - form.animate({ opacity: [0, 1] }, { duration: 300, easing: 'ease-in-out' }); - form.style.display = 'block'; - - showUI({ hidePlayer: true }); - - let player: shaka.Player | undefined; - let ui: shaka.ui.Overlay | undefined; - - form.addEventListener('submit', async (e) => { - e.preventDefault(); - - if (player) { - player.destroy(); - } - - hideUI(); - - let videoId; - - const videoIdOrURL = document.querySelector('input[type=text]')?.value; - - if (!videoIdOrURL) { - title.textContent = 'No video id or URL provided'; - showUI({ hidePlayer: true }); - return; - } - - try { - if (videoIdOrURL.match(/(http|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#-]*[\w@?^=%&\/~+#-])/)) { - const endpoint = await yt.resolveURL(videoIdOrURL); - - if (!endpoint.payload.videoId) { - title.textContent = 'Could not resolve URL'; - showUI({ hidePlayer: true }); - return; - } - - videoId = endpoint.payload.videoId; - } else { - videoId = videoIdOrURL; - } - - const info = await yt.getInfo(videoId); - - title.textContent = info.basic_info.title || null; - description.innerHTML = info.secondary_info?.description.toHTML() || ''; - title.textContent = info.basic_info.title || null; - - document.title = info.basic_info.title || ''; - - metadata.innerHTML = ''; - metadata.innerHTML += `
${info.primary_info?.published.toHTML()}
`; - metadata.innerHTML += `
${info.primary_info?.view_count.toHTML()}
`; - metadata.innerHTML += `
${info.basic_info.like_count} likes
`; - - showUI({ hidePlayer: false }); - - const dash = await info.toDash(); - - const uri = 'data:application/dash+xml;charset=utf-8;base64,' + btoa(dash); - - if (player) { - await player.destroy(); - player = undefined; - } - - if (ui) { - ui.destroy(); - ui = undefined; - } - - const videoEl = document.getElementById('videoel') as HTMLVideoElement; - const shakaContainer = document.getElementById('shaka-container') as HTMLDivElement; - - shakaContainer - .querySelectorAll("div") - .forEach(node => node.remove()); - - shaka.polyfill.installAll(); - - if (shaka.Player.isBrowserSupported()) { - videoEl.poster = info.basic_info.thumbnail![0].url; - - player = new shaka.Player(); - await player.attach(videoEl); - ui = new shaka.ui.Overlay(player, shakaContainer, videoEl); - - const config = { - seekBarColors: { - base: 'rgba(255,255,255,.2)', - buffered: 'rgba(255,255,255,.4)', - played: 'rgb(255,0,0)', - }, - fadeDelay: 0, - }; - - ui.configure(config); - - const overflowMenuButton = document.querySelector('.shaka-overflow-menu-button'); - if (overflowMenuButton) { - overflowMenuButton.innerHTML = 'settings'; - } - - const backToOverflowButton = document.querySelector('.shaka-back-to-overflow-button .material-icons-round'); - if (backToOverflowButton) { - backToOverflowButton.innerHTML = 'arrow_back_ios_new'; - } - - player.configure({ - streaming: { - bufferingGoal: 180, - rebufferingGoal: 0.02, - bufferBehind: 300 - } - }); - - player.getNetworkingEngine()?.registerRequestFilter((_type: any, request: any) => { - const uri = request.uris[0]; - const url = new URL(uri); - const headers = request.headers; - - 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 (headers.Range) { - request.headers = {}; - url.searchParams.set("range", headers.Range.split("=")[1]); - url.searchParams.set("alr", "yes"); - delete headers.Range; - } - } - - 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; - } - - 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 { - await player.load(uri); - } catch (e) { - console.error('Could not load manifest', e); - } - } else { - console.error('Browser not supported!'); - } - } catch (error) { - title.textContent = 'An error occurred (see console)'; - showUI({ hidePlayer: true }); - console.error(error); - } - }); -} - -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; -} +import 'shaka-player/dist/controls.css'; function fetchFn(input: RequestInfo | URL, init?: RequestInit) { const url = typeof input === 'string' @@ -298,8 +49,344 @@ function fetchFn(input: RequestInfo | URL, init?: RequestInit) { }); } +const title = document.getElementById('title') as HTMLHeadingElement; +const description = document.getElementById('description') as HTMLDivElement; +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); + + let yt = await Innertube.create({ + po_token: poToken, + visitor_data: visitorData, + fetch: fetchFn, + 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'; + + showUI({ hidePlayer: true }); + + let player: shaka.Player | undefined; + let ui: shaka.ui.Overlay | undefined; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + + if (player) { + player.destroy(); + } + + hideUI(); + + let videoId; + + const videoIdOrURL = document.querySelector('input[type=text]')?.value; + + if (!videoIdOrURL) { + title.textContent = 'No video id or URL provided'; + showUI({ hidePlayer: true }); + return; + } + + try { + if (videoIdOrURL.match(/(http|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])/)) { + const endpoint = await yt.resolveURL(videoIdOrURL); + + if (!endpoint.payload.videoId) { + title.textContent = 'Could not resolve URL'; + showUI({ hidePlayer: true }); + return; + } + + videoId = endpoint.payload.videoId; + } else { + 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; + description.innerHTML = info.secondary_info?.description.toHTML() || ''; + title.textContent = info.basic_info.title || null; + + document.title = info.basic_info.title || ''; + + metadata.innerHTML = ''; + metadata.innerHTML += `
${info.primary_info?.published.toHTML()}
`; + metadata.innerHTML += `
${info.primary_info?.view_count.toHTML()}
`; + metadata.innerHTML += `
${info.basic_info.like_count} likes
`; + + showUI({ hidePlayer: false }); + + const dash = await info.toDash(); + + const uri = `data:application/dash+xml;charset=utf-8;base64,${btoa(dash)}`; + + if (player) { + await player.destroy(); + player = undefined; + } + + if (ui) { + ui.destroy(); + ui = undefined; + } + + const videoEl = document.getElementById('videoel') as HTMLVideoElement; + const shakaContainer = document.getElementById('shaka-container') as HTMLDivElement; + + shakaContainer + .querySelectorAll('div') + .forEach((node) => node.remove()); + + shaka.polyfill.installAll(); + + if (shaka.Player.isBrowserSupported()) { + videoEl.poster = info.basic_info.thumbnail![0].url; + + player = new shaka.Player(); + await player.attach(videoEl); + ui = new shaka.ui.Overlay(player, shakaContainer, videoEl); + + const config = { + seekBarColors: { + base: 'rgba(255,255,255,.2)', + buffered: 'rgba(255,255,255,.4)', + played: 'rgb(255,0,0)' + }, + fadeDelay: 0 + }; + + ui.configure(config); + + const overflowMenuButton = document.querySelector('.shaka-overflow-menu-button'); + if (overflowMenuButton) { + overflowMenuButton.innerHTML = 'settings'; + } + + const backToOverflowButton = document.querySelector('.shaka-back-to-overflow-button .material-icons-round'); + if (backToOverflowButton) { + backToOverflowButton.innerHTML = 'arrow_back_ios_new'; + } + + player.configure({ + streaming: { + 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) + } + } + }); + + 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) { + url.searchParams.set('__host', url.host); + url.host = 'localhost:8080'; + url.protocol = 'http'; + } + + request.method = 'POST'; + request.body = new Uint8Array([120, 0]); + + if (url.pathname === '/videoplayback') { + if (headers.Range) { + request.headers = {}; + 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(); + }); + + const RequestType = shaka.net.NetworkingEngine.RequestType; + + 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; + } + }); + + try { + await player.load(uri); + } catch (e) { + console.error('Could not load manifest', e); + } + } else { + console.error('Browser not supported!'); + } + } catch (error) { + title.textContent = 'An error occurred (see console)'; + showUI({ hidePlayer: true }); + console.error(error); + } + }); +} + function showUI(args: { hidePlayer?: boolean } = { - hidePlayer: true, + hidePlayer: true }) { const ytplayer = document.getElementById('shaka-container') as HTMLDivElement;