From f23972ea86d5d5d54fc6a756c99080010e4eb205 Mon Sep 17 00:00:00 2001 From: Younes Aassila <47226184+younesaassila@users.noreply.github.com> Date: Sun, 24 Dec 2023 16:27:41 +0100 Subject: [PATCH] Code improvements --- src/content/content.ts | 7 +- src/page/getFetch.ts | 212 ++++++++++++++++++++++++----------------- src/page/page.ts | 9 +- src/types.ts | 7 +- 4 files changed, 140 insertions(+), 95 deletions(-) diff --git a/src/content/content.ts b/src/content/content.ts index 8057fb96..7e58db4a 100644 --- a/src/content/content.ts +++ b/src/content/content.ts @@ -4,6 +4,7 @@ import findChannelFromTwitchTvUrl from "../common/ts/findChannelFromTwitchTvUrl" import isChromium from "../common/ts/isChromium"; import { getStreamStatus, setStreamStatus } from "../common/ts/streamStatus"; import store from "../store"; +import { MessageType } from "../types"; console.info("[TTV LOL PRO] ๐Ÿš€ Content script running."); @@ -36,11 +37,11 @@ function injectPageScript() { function onStoreReady() { // Send store state to page script. const message = { - type: "StoreReady", + type: MessageType.StoreReady, state: JSON.parse(JSON.stringify(store.state)), }; window.postMessage({ - type: "PageScriptMessage", + type: MessageType.PageScriptMessage, message, }); // Clear stats for stream on page load/reload. @@ -66,7 +67,7 @@ function clearStats() { function onMessage(event: MessageEvent) { if (event.source !== window) return; - if (event.data?.type === "UsherResponse") { + if (event.data?.type === MessageType.UsherResponse) { const { channel, videoWeaverUrls, proxyCountry } = event.data; // Update Video Weaver URLs. store.state.videoWeaverUrlsByChannel[channel] = [ diff --git a/src/page/getFetch.ts b/src/page/getFetch.ts index 2990d4cf..263bb8b3 100644 --- a/src/page/getFetch.ts +++ b/src/page/getFetch.ts @@ -72,7 +72,7 @@ export function getFetch(options: FetchOptions): typeof fetch { // if (options.scope === "worker") { // setTimeout( // () => - // setVideoWeaverReplacementMap( + // updateVideoWeaverReplacementMap( // options.scope, // cachedUsherRequestUrl, // videoWeavers[videoWeavers.length - 1] @@ -194,7 +194,7 @@ export function getFetch(options: FetchOptions): typeof fetch { // Video Weaver requests. if (host != null && videoWeaverHostRegex.test(host)) { const videoWeaver = videoWeavers.find(videoWeaver => - [...(videoWeaver.assigned.values() ?? [])].includes(url) + [...videoWeaver.assigned.values()].includes(url) ); if (videoWeaver == null) { console.warn( @@ -204,22 +204,22 @@ export function getFetch(options: FetchOptions): typeof fetch { let videoWeaverUrl = url; if (videoWeaver?.replacement != null) { - const video = [...(videoWeaver.assigned.entries() ?? [])].find( + const video = [...videoWeaver.assigned].find( ([, url]) => url === videoWeaverUrl )?.[0]; // Replace Video Weaver URL with replacement URL. if (video != null && videoWeaver.replacement.has(video)) { videoWeaverUrl = videoWeaver.replacement.get(video)!; - console.log( + console.debug( `[TTV LOL PRO] ๐Ÿ”„ Replaced Video Weaver URL '${url}' with '${videoWeaverUrl}'.` ); } else if (videoWeaver.replacement.size > 0) { videoWeaverUrl = [...videoWeaver.replacement.values()][0]; - console.log( + console.warn( `[TTV LOL PRO] ๐Ÿ”„ Replaced Video Weaver URL '${url}' with '${videoWeaverUrl}' (fallback).` ); } else { - console.log( + console.error( `[TTV LOL PRO] ๐Ÿ”„ No replacement Video Weaver URL found for '${url}'.` ); } @@ -276,7 +276,7 @@ export function getFetch(options: FetchOptions): typeof fetch { channel: findChannelFromUsherUrl(url), videoWeaverUrls, proxyCountry: - /USER-COUNTRY="([A-Z]+)"/i.exec(responseBody)?.[1] || null, + /USER-COUNTRY="([A-Z]+)"/i.exec(responseBody)?.[1] || undefined, }); // Remove all Video Weaver URLs from known URLs. videoWeaverUrls.forEach(url => proxiedVideoWeaverUrls.delete(url)); @@ -286,7 +286,7 @@ export function getFetch(options: FetchOptions): typeof fetch { if (host != null && videoWeaverHostRegex.test(host)) { responseBody ??= await readResponseBody(); const videoWeaver = videoWeavers.find(videoWeaver => - [...(videoWeaver.assigned.values() ?? [])].includes(url) + [...videoWeaver.assigned.values()].includes(url) ); if (videoWeaver == null) { console.warn( @@ -306,7 +306,7 @@ export function getFetch(options: FetchOptions): typeof fetch { videoWeaver.consecutiveMidrollResponses += 1; // Avoid infinite loops. if (videoWeaver.consecutiveMidrollResponses <= 2) { - await setVideoWeaverReplacementMap( + await updateVideoWeaverReplacementMap( options.scope, cachedUsherRequestUrl, videoWeaver @@ -318,7 +318,6 @@ export function getFetch(options: FetchOptions): typeof fetch { } else { // No ad, clear attempts. videoWeaver.consecutiveMidrollResponses = 0; - console.debug("[TTV LOL PRO] Caught Video Weaver response WITHOUT ad."); } } @@ -516,62 +515,81 @@ async function sendMessageToPageScriptAndWaitForResponse( //#endregion -//#region Video Weaver +//#region Video Weaver URL replacement -// FIXME: +/** + * Returns a PlaybackAccessToken request that can be used when Twitch doesn't send one. + * @returns + */ +function getFallbackPlaybackAccessTokenRequest(): Request | null { + // We can use `location.href` because we're in the page script. + const channelName = findChannelFromTwitchTvUrl(location.href); + if (!channelName) return null; + const isVod = /^\d+$/.test(channelName); // VODs have numeric IDs. + + const headersMap = new Map([ + ["Authorization", "undefined"], // TODO: Cache this value if anonymous mode is disabled. + ["Client-ID", "kimne78kx3ncx6brgo4mv6wki5h1ko"], + ["Device-ID", generateRandomString(32)], + ["Pragma", "no-cache"], + ["Cache-Control", "no-cache"], + ]); + flagRequest(headersMap); + + return new Request("https://gql.twitch.tv/gql", { + method: "POST", + headers: Object.fromEntries(headersMap), + body: JSON.stringify({ + operationName: "PlaybackAccessToken_Template", + query: + 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature authorization { isForbidden forbiddenReasonCode } __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}', + variables: { + isLive: !isVod, + login: isVod ? "" : channelName, + isVod: isVod, + vodID: isVod ? channelName : "", + playerType: "site", + }, + }), + }); +} + +/** + * Fetches a new PlaybackAccessToken from Twitch. + * @param cachedPlaybackTokenRequestHeaders + * @param cachedPlaybackTokenRequestBody + * @returns + */ async function fetchReplacementPlaybackAccessToken( cachedPlaybackTokenRequestHeaders: Map | null, cachedPlaybackTokenRequestBody: string | null ): Promise { - let request: Request; + let request: Request | null = null; if ( cachedPlaybackTokenRequestHeaders != null && cachedPlaybackTokenRequestBody != null ) { request = new Request("https://gql.twitch.tv/gql", { method: "POST", - headers: Object.fromEntries(cachedPlaybackTokenRequestHeaders), // Headers already flagged. + headers: Object.fromEntries(cachedPlaybackTokenRequestHeaders), // Headers already contain the flag. body: cachedPlaybackTokenRequestBody, }); } else { - // Twitch sometimes doesn't send a playback access token request on page reload, - // so we need this fallback. - - const channelName = findChannelFromTwitchTvUrl(location.href); - if (channelName == null) return null; - const isVod = /^\d+$/.test(channelName); - - const headersMap = new Map([ - ["Authorization", "undefined"], // Anonymous mode. - ["Client-ID", "kimne78kx3ncx6brgo4mv6wki5h1ko"], - ["Device-ID", generateRandomString(32)], - ["Pragma", "no-cache"], - ["Cache-Control", "no-cache"], - ]); - flagRequest(headersMap); + // This fallback request is used when Twitch doesn't send a PlaybackAccessToken request. + // This can happen when the user refreshes the page. + request = getFallbackPlaybackAccessTokenRequest(); + } + if (request == null) return null; - request = new Request("https://gql.twitch.tv/gql", { - method: "POST", - headers: Object.fromEntries(headersMap), - body: JSON.stringify({ - operationName: "PlaybackAccessToken_Template", - query: - 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature authorization { isForbidden forbiddenReasonCode } __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}', - variables: { - isLive: !isVod, - login: isVod ? "" : channelName, - isVod: isVod, - vodID: isVod ? channelName : "", - playerType: "site", - }, - }), - }); + try { + const response = await NATIVE_FETCH(request); + const json = await response.json(); + const newPlaybackAccessToken = json?.data?.streamPlaybackAccessToken; + if (newPlaybackAccessToken == null) return null; + return newPlaybackAccessToken; + } catch { + return null; } - const response = await NATIVE_FETCH(request); - const json = await response.json(); - const newPlaybackAccessToken = json?.data?.streamPlaybackAccessToken; - if (newPlaybackAccessToken == null) return null; - return newPlaybackAccessToken; } /** @@ -597,33 +615,23 @@ function getReplacementUsherUrl( } } -// FIXME: +/** + * Fetches a new Usher manifest from Twitch. + * @param cachedUsherRequestUrl + * @param playbackAccessToken + * @returns + */ async function fetchReplacementUsherManifest( cachedUsherRequestUrl: string | null, - scope: "page" | "worker" + playbackAccessToken: PlaybackAccessToken ): Promise { - if (cachedUsherRequestUrl == null) return null; + if (cachedUsherRequestUrl == null) return null; // Very unlikely. try { - const newPlaybackAccessToken: PlaybackAccessToken | null = - await sendMessageToPageScriptAndWaitForResponse( - scope, - { - type: MessageType.NewPlaybackAccessToken, - }, - MessageType.NewPlaybackAccessTokenResponse - ); - if (newPlaybackAccessToken == null) { - console.log("[TTV LOL PRO] ๐Ÿ”„ No new playback token found."); - return null; - } const newUsherUrl = getReplacementUsherUrl( cachedUsherRequestUrl, - newPlaybackAccessToken + playbackAccessToken ); - if (newUsherUrl == null) { - console.log("[TTV LOL PRO] ๐Ÿ”„ No new Usher URL found."); - return null; - } + if (newUsherUrl == null) return null; const response = await NATIVE_FETCH(newUsherUrl); const text = await response.text(); return text; @@ -653,45 +661,79 @@ function parseUsherManifest(manifest: string): Map | null { ); } -// FIXME: -async function setVideoWeaverReplacementMap( +/** + * Updates the replacement Video Weaver URLs. + * @param scope + * @param cachedUsherRequestUrl + * @param videoWeaver + * @returns + */ +async function updateVideoWeaverReplacementMap( scope: "page" | "worker", cachedUsherRequestUrl: string | null, videoWeaver: VideoWeaver -) { +): Promise { + console.log("[TTV LOL PRO] ๐Ÿ”„ Getting replacement Video Weaver URLsโ€ฆ"); try { - console.log("[TTV LOL PRO] ๐Ÿ”„ Checking for new Video Weaver URLsโ€ฆ"); + console.log("[TTV LOL PRO] ๐Ÿ”„ (1/3) Getting new PlaybackAccessTokenโ€ฆ"); + const newPlaybackAccessTokenResponse = + await sendMessageToPageScriptAndWaitForResponse( + scope, + { + type: MessageType.NewPlaybackAccessToken, + }, + MessageType.NewPlaybackAccessTokenResponse + ); + const newPlaybackAccessToken: PlaybackAccessToken | undefined = + newPlaybackAccessTokenResponse?.newPlaybackAccessToken; + if (newPlaybackAccessToken == null) { + console.error("[TTV LOL PRO] โŒ Failed to get new PlaybackAccessToken."); + return false; + } + + console.log("[TTV LOL PRO] ๐Ÿ”„ (2/3) Fetching new Usher manifestโ€ฆ"); const newUsherManifest = await fetchReplacementUsherManifest( cachedUsherRequestUrl, - scope + newPlaybackAccessToken ); if (newUsherManifest == null) { - console.log("[TTV LOL PRO] ๐Ÿ”„ No new Video Weaver URLs found."); - return; + console.error("[TTV LOL PRO] โŒ Failed to fetch new Usher manifest."); + return false; } - videoWeaver.replacement = parseUsherManifest(newUsherManifest); + + console.log("[TTV LOL PRO] ๐Ÿ”„ (3/3) Parsing new Usher manifestโ€ฆ"); + const replacement = parseUsherManifest(newUsherManifest); + if (replacement == null || replacement.size === 0) { + console.error("[TTV LOL PRO] โŒ Failed to parse new Usher manifest."); + return false; + } + console.log( - "[TTV LOL PRO] ๐Ÿ”„ Found new Video Weaver URLs:", - Object.fromEntries(videoWeaver.replacement?.entries() ?? []) + "[TTV LOL PRO] ๐Ÿ”„ Replacement Video Weaver URLs:", + Object.fromEntries(replacement) ); + videoWeaver.replacement = replacement; + // Send replacement Video Weaver URLs to content script. - const videoWeaverUrls = [...(videoWeaver.replacement?.values() ?? [])]; + const videoWeaverUrls = [...replacement.values()]; if (cachedUsherRequestUrl != null && videoWeaverUrls.length > 0) { - // Send Video Weaver URLs to content script. sendMessageToContentScript(scope, { type: MessageType.UsherResponse, channel: findChannelFromUsherUrl(cachedUsherRequestUrl), videoWeaverUrls, proxyCountry: - /USER-COUNTRY="([A-Z]+)"/i.exec(newUsherManifest)?.[1] || null, + /USER-COUNTRY="([A-Z]+)"/i.exec(newUsherManifest)?.[1] || undefined, }); } + + return true; } catch (error) { - videoWeaver.replacement = null; console.error( - "[TTV LOL PRO] ๐Ÿ”„ Failed to get new Video Weaver URLs:", + "[TTV LOL PRO] โŒ Failed to get replacement Video Weaver URLs:", error ); + videoWeaver.replacement = null; + return false; } } diff --git a/src/page/page.ts b/src/page/page.ts index 7ac376af..50491606 100644 --- a/src/page/page.ts +++ b/src/page/page.ts @@ -1,3 +1,4 @@ +import { MessageType } from "../types"; import { FetchOptions, getFetch } from "./getFetch"; console.info("[TTV LOL PRO] ๐Ÿš€ Page script running."); @@ -53,8 +54,8 @@ window.Worker = class Worker extends NATIVE_WORKER { super(newScriptURL, options); this.addEventListener("message", event => { if ( - event.data?.type === "ContentScriptMessage" || - event.data?.type === "PageScriptMessage" + event.data?.type === MessageType.ContentScriptMessage || + event.data?.type === MessageType.PageScriptMessage ) { window.postMessage(event.data.message); } @@ -64,9 +65,9 @@ window.Worker = class Worker extends NATIVE_WORKER { }; window.addEventListener("message", event => { - if (event.data?.type === "PageScriptMessage") { + if (event.data?.type === MessageType.PageScriptMessage) { const message = event.data.message; - if (message.type === "StoreReady") { + if (message.type === MessageType.StoreReady) { console.log( "[TTV LOL PRO] ๐Ÿ“ฆ Page received store state from content script." ); diff --git a/src/types.ts b/src/types.ts index cc88813a..6c78a1fc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,9 +53,10 @@ export interface DnsResponse { } export const enum MessageType { - ContentScriptMessage = "ContentScriptMessage", - PageScriptMessage = "PageScriptMessage", - UsherResponse = "UsherResponse", + ContentScriptMessage = "TLP_ContentScriptMessage", + PageScriptMessage = "TLP_PageScriptMessage", + StoreReady = "TLP_StoreReady", + UsherResponse = "TLP_UsherResponse", NewPlaybackAccessToken = "TLP_NewPlaybackAccessToken", NewPlaybackAccessTokenResponse = "TLP_NewPlaybackAccessTokenResponse", }