From 8008732eb28a0645c00872e7505372ffde25c4a1 Mon Sep 17 00:00:00 2001 From: absidue <48293849+absidue@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:22:25 +0200 Subject: [PATCH 1/2] Fallback to extracting the potoken challenge data and config from the YouTube homepage (#9637) --- src/main/index.js | 5 +- src/renderer/helpers/api/local.js | 90 +++++++++++++++++++++---------- 2 files changed, 65 insertions(+), 30 deletions(-) diff --git a/src/main/index.js b/src/main/index.js index 716c281fdb06a..38b708bc486b9 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -590,7 +590,10 @@ function runApp() { requestHeaders['Sec-Fetch-Site'] = 'same-origin' requestHeaders['Sec-Fetch-Mode'] = 'same-origin' requestHeaders['X-Youtube-Bootstrap-Logged-In'] = 'false' - } else if (url.startsWith('https://www.youtube.com/watch')) { + } else if ( + url.startsWith('https://www.youtube.com/watch') || + (urlObj.origin === 'www.youtube.com' && urlObj.pathname === '/') + ) { delete requestHeaders.Referer delete requestHeaders.Origin requestHeaders['Sec-Fetch-Dest'] = 'document' diff --git a/src/renderer/helpers/api/local.js b/src/renderer/helpers/api/local.js index 4aba2028d7cd6..95bbf843a430a 100644 --- a/src/renderer/helpers/api/local.js +++ b/src/renderer/helpers/api/local.js @@ -379,12 +379,12 @@ export async function getLocalSearchContinuation(continuationData) { } /** - * @param {string} videoId - * @param {(input, init) => Promise} fetchFunc + * @param {string} url + * @param {string} kind */ -async function getWatchHTMLWatchPage(videoId, fetchFunc) { +async function getHTMLPage(url, kind) { // This returns session/tracking cookies but they get removed in onHeadersReceived in the main process before they are saved by Electron - const htmlResponse = await fetch(`https://www.youtube.com/watch?v=${videoId}&bpctr=9999999999&has_verified=1`, + const htmlResponse = await fetch(url, { headers: { // We need to be able to parse the localised strings in the /next response data (e.g. view counts and published dates) @@ -398,7 +398,7 @@ async function getWatchHTMLWatchPage(videoId, fetchFunc) { const ytConfigStr = htmlPage.match(/ytcfg\.set\(({.+?})\);/s)?.[1] if (!ytConfigStr) { // required for botguard - throw new Error('Could not find ytcfg in the HTML page') + throw new Error(`Could not find ytcfg in the ${kind} HTML page`) } const ytConfig = JSON.parse(ytConfigStr) @@ -407,7 +407,7 @@ async function getWatchHTMLWatchPage(videoId, fetchFunc) { if (!initialAttestationDataMatch) { // required for botguard - throw new Error('Could not find challenge in the HTML page') + throw new Error(`Could not find challenge in the ${kind} HTML page`) } let initialAttestationData @@ -415,42 +415,74 @@ async function getWatchHTMLWatchPage(videoId, fetchFunc) { try { initialAttestationData = parseLooseJSON(initialAttestationDataMatch[1]) } catch (e) { - const error = new Error('Failed to parse the initial attestation data', { cause: e }) + const error = new Error(`Failed to parse the ${kind} home page initial attestation data`, { cause: e }) console.error(error, initialAttestationDataMatch[1]) throw error } - /** @type {string | undefined} */ let playerId = ytConfig.PLAYER_JS_URL?.match(/player\/([^/]+)\//)?.[1] playerId ??= htmlPage.match(/]+src="[^">]+player\/([^/]+)\/[^"]+\/base.js"/)?.[1] - const playerResponseStr = htmlPage.match(/ytInitialPlayerResponse\s*=\s*(\{.+?\});/)?.[1] - let playerResponse - - // not fatal if it is missing as we can retrieve it from Innertube ourselves - if (playerResponseStr) { - try { - playerResponse = JSON.parse(playerResponseStr) - } catch (e) { - console.warn('/player response extracted from the HTML page is invalid JSON', e) - } - } else { - console.warn('Could not find /player response in the HTML page') + return { + htmlPage, + ytConfig, + initialAttestationData, + playerId } +} - const nextResponseStr = htmlPage.match(/(?:window\s*\[\s*["']ytInitialData["']\s*\]|ytInitialData)\s*=\s*(\{.+?\});/)?.[1] - let nextResponse +/** + * @param {string} videoId + * @param {(input, init) => Promise} fetchFunc + */ +async function getWatchHTMLWatchPage(videoId, fetchFunc) { + let htmlPage, ytConfig, initialAttestationData - // not fatal if it is missing as we can retrieve it from Innertube ourselves - if (nextResponseStr) { + let playerResponse, nextResponse + /** @type {string | undefined} */ + let playerId + + if (sessionStorage.getItem('playerHtmlHomepageFallback') === '1') { + ({ ytConfig, initialAttestationData, playerId } = await getHTMLPage('https://www.youtube.com', 'home')) + } else { try { - nextResponse = JSON.parse(nextResponseStr) - } catch (e) { - console.warn('/next response extracted from the HTML page is invalid JSON', e) + ({ htmlPage, ytConfig, initialAttestationData, playerId } = await getHTMLPage(`https://www.youtube.com/watch?v=${videoId}&bpctr=9999999999&has_verified=1`, 'watch')) + + const playerResponseStr = htmlPage.match(/ytInitialPlayerResponse\s*=\s*(\{.+?\});/)?.[1] + + // not fatal if it is missing as we can retrieve it from Innertube ourselves + if (playerResponseStr) { + try { + playerResponse = JSON.parse(playerResponseStr) + } catch (e) { + console.warn('/player response extracted from the HTML page is invalid JSON', e) + } + } else { + console.warn('Could not find /player response in the HTML page') + } + + const nextResponseStr = htmlPage.match(/(?:window\s*\[\s*["']ytInitialData["']\s*\]|ytInitialData)\s*=\s*(\{.+?\});/)?.[1] + + // not fatal if it is missing as we can retrieve it from Innertube ourselves + if (nextResponseStr) { + try { + nextResponse = JSON.parse(nextResponseStr) + } catch (e) { + console.warn('/next response extracted from the HTML page is invalid JSON', e) + } + } else { + console.warn('Could not find /next response in the HTML page') + } + } catch (watchError) { + console.warn('Falling back to the YT home page for the rest of the session because of:', watchError) + + // Fall back to the home page for the rest of the session/until FreeTube is restarted + // assuming that getting the watch page captcha once is a sign that it will continue happening + sessionStorage.setItem('playerHtmlHomepageFallback', '1'); + + ({ ytConfig, initialAttestationData, playerId } = await getHTMLPage('https://www.youtube.com', 'home')) } - } else { - console.warn('Could not find /next response in the HTML page') } const session = buildSessionFromYtConfig(ytConfig, fetchFunc) From 47e4bcec227b792957f19218f5c494306a64b71a Mon Sep 17 00:00:00 2001 From: PikachuEXE Date: Sat, 22 Aug 2026 00:09:15 +0800 Subject: [PATCH 2/2] ! Fix legacy as default format but unavailable won't fallback to other formats (#9644) --- .../ft-shaka-video-player/ft-shaka-video-player.js | 3 ++- .../player-components/LegacyQualitySelection.js | 6 ++++-- src/renderer/views/Watch/Watch.js | 10 ++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js b/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js index 799ef9600eb00..f7bb8929f26c6 100644 --- a/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js +++ b/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js @@ -1518,6 +1518,7 @@ export default defineComponent({ /** @type {object[]} */ const legacyFormats = props.legacyFormats + if (legacyFormats.length === 0) { return } const isPortrait = legacyFormats[0].height > legacyFormats[0].width @@ -2830,7 +2831,7 @@ export default defineComponent({ forceAspectRatio.value = firstVariant.width / firstVariant.height < 1.5 } }) - } else { + } else if (props.legacyFormats.length > 0) { // force the player aspect ratio to 16:9 to avoid overflowing the layout, when the video is too tall const firstFormat = props.legacyFormats[0] diff --git a/src/renderer/components/ft-shaka-video-player/player-components/LegacyQualitySelection.js b/src/renderer/components/ft-shaka-video-player/player-components/LegacyQualitySelection.js index b5a286c8a129b..70179771be2ae 100644 --- a/src/renderer/components/ft-shaka-video-player/player-components/LegacyQualitySelection.js +++ b/src/renderer/components/ft-shaka-video-player/player-components/LegacyQualitySelection.js @@ -31,8 +31,10 @@ export class LegacyQualitySelection extends shaka.ui.SettingsMenu { const sortedLegacyFormats = [...legacyFormats] - const isPortrait = legacyFormats[0].height > legacyFormats[0].width - sortedLegacyFormats.sort((a, b) => isPortrait ? b.width - a.width : b.height - a.height) + if (legacyFormats.length > 0) { + const isPortrait = legacyFormats[0].height > legacyFormats[0].width + sortedLegacyFormats.sort((a, b) => isPortrait ? b.width - a.width : b.height - a.height) + } /** @private */ this.legacyFormats_ = sortedLegacyFormats diff --git a/src/renderer/views/Watch/Watch.js b/src/renderer/views/Watch/Watch.js index 526aa88984e35..1b4aa2522f2ca 100644 --- a/src/renderer/views/Watch/Watch.js +++ b/src/renderer/views/Watch/Watch.js @@ -942,6 +942,12 @@ export default defineComponent({ } } + if (this.activeFormat === 'legacy' && (this.isLive || this.isPostLiveDvr || this.legacyFormats.length === 0)) { + // Legacy wanted as default but unavailable + showToast(this.t('Change Format.Legacy formats are not available for this video')) + this.handleActiveFormatUnavailable() + } + this.isLoading = false this.updateTitle() } catch (err) { @@ -1602,6 +1608,10 @@ export default defineComponent({ } } + this.handleActiveFormatUnavailable() + }, + + handleActiveFormatUnavailable: function() { if (this.isLive || this.isPostLiveDvr) { // live streams don't have legacy formats, so only switch between dash and audio