From 3a245ec178bcd637ad1a6fa6cffa0e63a4324cfa Mon Sep 17 00:00:00 2001 From: absidue <48293849+absidue@users.noreply.github.com> Date: Thu, 1 Aug 2024 14:49:01 +0200 Subject: [PATCH 1/4] Use streams from the iOS client to workaround playback issues (#5472) * Use streams from the iOS client to workaround playback issues * Fix for unplayable videos * Hoist version arrays and introduce a randomArrayItem helper --- src/main/index.js | 21 ++++- src/renderer/helpers/api/local.js | 125 ++++++++++++++++++++++++++---- src/renderer/helpers/colors.js | 4 +- src/renderer/helpers/utils.js | 9 +++ src/renderer/views/Watch/Watch.js | 35 +-------- 5 files changed, 139 insertions(+), 55 deletions(-) diff --git a/src/main/index.js b/src/main/index.js index c6eb719ee9986..942cdb5827a1e 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -410,9 +410,24 @@ function runApp() { requestHeaders.Origin = 'https://www.youtube.com' if (url.startsWith('https://www.youtube.com/youtubei/')) { - requestHeaders['Sec-Fetch-Site'] = 'same-origin' - requestHeaders['Sec-Fetch-Mode'] = 'same-origin' - requestHeaders['X-Youtube-Bootstrap-Logged-In'] = 'false' + // Make iOS requests work and look more realistic + if (requestHeaders['x-youtube-client-name'] === '5') { + delete requestHeaders.Referer + delete requestHeaders.Origin + delete requestHeaders['Sec-Fetch-Site'] + delete requestHeaders['Sec-Fetch-Mode'] + delete requestHeaders['Sec-Fetch-Dest'] + delete requestHeaders['sec-ch-ua'] + delete requestHeaders['sec-ch-ua-mobile'] + delete requestHeaders['sec-ch-ua-platform'] + + requestHeaders['User-Agent'] = requestHeaders['x-user-agent'] + delete requestHeaders['x-user-agent'] + } else { + requestHeaders['Sec-Fetch-Site'] = 'same-origin' + requestHeaders['Sec-Fetch-Mode'] = 'same-origin' + requestHeaders['X-Youtube-Bootstrap-Logged-In'] = 'false' + } } else { // YouTube doesn't send the Content-Type header for the media requests, so we shouldn't either delete requestHeaders['Content-Type'] diff --git a/src/renderer/helpers/api/local.js b/src/renderer/helpers/api/local.js index 77b8f9a481a7f..e7fce2be2b5dc 100644 --- a/src/renderer/helpers/api/local.js +++ b/src/renderer/helpers/api/local.js @@ -8,6 +8,7 @@ import { calculatePublishedDate, escapeHTML, extractNumberFromString, + randomArrayItem, toLocalePublicationString } from '../utils' @@ -19,6 +20,25 @@ const TRACKING_PARAM_NAMES = [ 'utm_content', ] +const IOS_VERSIONS = [ + '17.5.1', + '17.5', + '17.4.1', + '17.4', + '17.3.1', + '17.3', +] + +const YOUTUBE_IOS_CLIENT_VERSIONS = [ + '19.29.1', + '19.28.1', + '19.26.5', + '19.25.4', + '19.25.3', + '19.24.3', + '19.24.2', +] + /** * Creates a lightweight Innertube instance, which is faster to create or * an instance that can decode the streaming URLs, which is slower to create @@ -56,7 +76,36 @@ async function createInnertube({ withPlayer = false, location = undefined, safet client_type: clientType, // use browser fetch - fetch: (input, init) => fetch(input, init), + fetch: (input, init) => { + // Make iOS requests work and look more realistic + if (init?.headers instanceof Headers && init.headers.get('x-youtube-client-name') === '5') { + // Use a random iOS version and YouTube iOS client version to make the requests look less suspicious + const clientVersion = randomArrayItem(YOUTUBE_IOS_CLIENT_VERSIONS) + const iosVersion = randomArrayItem(IOS_VERSIONS) + + init.headers.set('x-youtube-client-version', clientVersion) + + // We can't set the user-agent here, but in the main process we take the x-user-agent and set it as the user-agent + init.headers.delete('user-agent') + init.headers.set('x-user-agent', `com.google.ios.youtube/${clientVersion} (iPhone16,2; CPU iOS ${iosVersion.replaceAll('.', '_')} like Mac OS X; en_US)`) + + const bodyJson = JSON.parse(init.body) + + const client = bodyJson.context.client + + client.clientVersion = clientVersion + client.deviceMake = 'Apple' + client.deviceModel = 'iPhone16,2' // iPhone 15 Pro Max + client.osName = 'iOS' + client.osVersion = iosVersion + delete client.browserName + delete client.browserVersion + + init.body = JSON.stringify(bodyJson) + } + + return fetch(input, init) + }, cache, generate_session_locally: !!generateSessionLocally }) @@ -190,27 +239,69 @@ export async function getLocalSearchContinuation(continuationData) { return handleSearchResponse(response) } -export async function getLocalVideoInfo(id, attemptBypass = false) { - let info - let player +/** + * @param {string} id + */ +export async function getLocalVideoInfo(id) { + const webInnertube = await createInnertube({ withPlayer: true, generateSessionLocally: false }) - if (attemptBypass) { - const innertube = await createInnertube({ withPlayer: true, clientType: ClientType.TV_EMBEDDED, generateSessionLocally: false }) - player = innertube.actions.session.player + const info = await webInnertube.getInfo(id) - // the second request that getInfo makes 404s with the bypass, so we use getBasicInfo instead - // that's fine as we have most of the information from the original getInfo request - info = await innertube.getBasicInfo(id, 'TV_EMBEDDED') - } else { - const innertube = await createInnertube({ withPlayer: true, generateSessionLocally: false }) - player = innertube.actions.session.player + const hasTrailer = info.has_trailer + const trailerIsAgeRestricted = info.getTrailerInfo() === null - info = await innertube.getInfo(id) + if (hasTrailer) { + /** @type {import('youtubei.js').YTNodes.PlayerLegacyDesktopYpcTrailer} */ + const trailerScreen = info.playability_status.error_screen + id = trailerScreen.video_id } - if (info.streaming_data) { - decipherFormats(info.streaming_data.adaptive_formats, player) - decipherFormats(info.streaming_data.formats, player) + // try to bypass the age restriction + if (info.playability_status.status === 'LOGIN_REQUIRED' || (hasTrailer && trailerIsAgeRestricted)) { + const tvInnertube = await createInnertube({ withPlayer: true, clientType: ClientType.TV_EMBEDDED, generateSessionLocally: false }) + + const tvInfo = await tvInnertube.getBasicInfo(id, 'TV_EMBEDDED') + + if (tvInfo.streaming_data) { + decipherFormats(tvInfo.streaming_data.adaptive_formats, tvInnertube.actions.session.player) + decipherFormats(tvInfo.streaming_data.formats, tvInnertube.actions.session.player) + } + + info.playability_status = tvInfo.playability_status + info.streaming_data = tvInfo.streaming_data + info.basic_info.start_timestamp = tvInfo.basic_info.start_timestamp + info.basic_info.duration = tvInfo.basic_info.duration + info.captions = tvInfo.captions + info.storyboards = tvInfo.storyboards + } else { + const iosInnertube = await createInnertube({ clientType: ClientType.IOS }) + + const iosInfo = await iosInnertube.getBasicInfo(id, 'iOS') + + if (hasTrailer) { + info.playability_status = iosInfo.playability_status + info.streaming_data = iosInfo.streaming_data + info.basic_info.start_timestamp = iosInfo.basic_info.start_timestamp + info.basic_info.duration = iosInfo.basic_info.duration + info.captions = iosInfo.captions + info.storyboards = iosInfo.storyboards + } else if (iosInfo.streaming_data) { + info.streaming_data.adaptive_formats = iosInfo.streaming_data.adaptive_formats + // Use the legacy formats from the original web response as the iOS client doesn't have any legacy formats + + for (const format of info.streaming_data.adaptive_formats) { + format.freeTubeUrl = format.url + } + + // don't overwrite for live streams + if (!info.streaming_data.hls_manifest_url) { + info.streaming_data.hls_manifest_url = iosInfo.streaming_data.hls_manifest_url + } + } + + if (info.streaming_data) { + decipherFormats(info.streaming_data.formats, webInnertube.actions.session.player) + } } return info diff --git a/src/renderer/helpers/colors.js b/src/renderer/helpers/colors.js index fbd0f7a874d6a..cd1e1d4aebc1f 100644 --- a/src/renderer/helpers/colors.js +++ b/src/renderer/helpers/colors.js @@ -1,4 +1,5 @@ import i18n from '../i18n/index' +import { randomArrayItem } from './utils' export const colors = [ { name: 'Red', value: '#d50000' }, @@ -103,8 +104,7 @@ export function getRandomColorClass() { } export function getRandomColor() { - const randomInt = Math.floor(Math.random() * colors.length) - return colors[randomInt] + return randomArrayItem(colors) } export function calculateColorLuminance(colorValue) { diff --git a/src/renderer/helpers/utils.js b/src/renderer/helpers/utils.js index 3f645343d3a6e..f65161b4a60f4 100644 --- a/src/renderer/helpers/utils.js +++ b/src/renderer/helpers/utils.js @@ -868,3 +868,12 @@ export function ctrlFHandler(event, inputElement) { } } } + +/** + * @template T + * @param {T[]} array + * @returns {T} + */ +export function randomArrayItem(array) { + return array[Math.floor(Math.random() * array.length)] +} diff --git a/src/renderer/views/Watch/Watch.js b/src/renderer/views/Watch/Watch.js index 37799bf07a751..ff8d4ec69e79f 100644 --- a/src/renderer/views/Watch/Watch.js +++ b/src/renderer/views/Watch/Watch.js @@ -308,7 +308,7 @@ export default defineComponent({ } try { - let result = await getLocalVideoInfo(this.videoId) + const result = await getLocalVideoInfo(this.videoId) this.isFamilyFriendly = result.basic_info.is_family_safe @@ -328,31 +328,7 @@ export default defineComponent({ return } - let playabilityStatus = result.playability_status - let bypassedResult = null - let streamingVideoId = this.videoId - let trailerIsNull = false - - // if widevine support is added then we should check if playabilityStatus.status is UNPLAYABLE too - if (result.has_trailer) { - bypassedResult = result.getTrailerInfo() - /** - * @type {import ('youtubei.js').YTNodes.PlayerLegacyDesktopYpcTrailer} - */ - const trailerScreen = result.playability_status.error_screen - streamingVideoId = trailerScreen.video_id - // if the trailer is null then it is likely age restricted. - trailerIsNull = bypassedResult == null - if (!trailerIsNull) { - playabilityStatus = bypassedResult.playability_status - } - } - - if (playabilityStatus.status === 'LOGIN_REQUIRED' || trailerIsNull) { - // try to bypass the age restriction - bypassedResult = await getLocalVideoInfo(streamingVideoId, true) - playabilityStatus = bypassedResult.playability_status - } + const playabilityStatus = result.playability_status if (playabilityStatus.status === 'UNPLAYABLE') { /** @@ -482,13 +458,6 @@ export default defineComponent({ this.commentsEnabled = result.comments_entry_point_header != null // endregion No comment detection - // the bypassed result is missing some of the info that we extract in the code above - // so we only overwrite the result here - // we need the bypassed result for the streaming data and the subtitles - if (bypassedResult) { - result = bypassedResult - } - if ((this.isLive || this.isPostLiveDvr) && !this.isUpcoming) { try { const formats = await getFormatsFromHLSManifest(result.streaming_data.hls_manifest_url) From 42837c841b690a0956717ef8fb3c5c8c1f743b00 Mon Sep 17 00:00:00 2001 From: PikachuEXE Date: Thu, 1 Aug 2024 20:49:19 +0800 Subject: [PATCH 2/4] ^ Update youtubei.js (#5507) --- package.json | 2 +- yarn.lock | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index d55e85304bad4..dfeb6b97c54d9 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "vue-observe-visibility": "^1.0.0", "vue-router": "^3.6.5", "vuex": "^3.6.2", - "youtubei.js": "^10.2.0" + "youtubei.js": "^10.3.0" }, "devDependencies": { "@babel/core": "^7.24.9", diff --git a/yarn.lock b/yarn.lock index f13a2e552b514..115cfc5bf0a5c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5586,10 +5586,10 @@ jest-worker@^29.7.0: merge-stream "^2.0.0" supports-color "^8.0.0" -jintr@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/jintr/-/jintr-2.0.0.tgz#bc8e78efc04743f5c67c625587ce4d1c94afad9a" - integrity sha512-RiVlevxttZ4eHEYB2dXKXDXluzHfRuw0DJQGsYuKCc5IvZj5/GbOakeqVX+Bar/G9kTty9xDJREcxukurkmYLA== +jintr@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/jintr/-/jintr-2.1.1.tgz#84d555df06d26128c2a1d0e1eebd6fecdf8eb280" + integrity sha512-89cwX4ouogeDGOBsEVsVYsnWWvWjchmwXBB4kiBhmjOKw19FiOKhNhMhpxhTlK2ctl7DS+d/ethfmuBpzoNNgA== dependencies: acorn "^8.8.0" @@ -9088,11 +9088,11 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== -youtubei.js@^10.2.0: - version "10.2.0" - resolved "https://registry.yarnpkg.com/youtubei.js/-/youtubei.js-10.2.0.tgz#0f5fbacf3e64c965d6e7378c870a7329ceca7228" - integrity sha512-JLKW9AHQ1qrTwBbre1aDkH8UJFmNcc4+kOSaVou5jSY7AzfFPFJK0yvX6afnLst0UVC9wfXHrLiNx93sutVErA== +youtubei.js@^10.3.0: + version "10.3.0" + resolved "https://registry.yarnpkg.com/youtubei.js/-/youtubei.js-10.3.0.tgz#30a942e6b92ac8039a3d830563e383fdc9fd4772" + integrity sha512-tLmeJCECK2xF2hZZtF2nEqirdKVNLFSDpa0LhTaXY3tngtL7doQXyy7M2CLueramDTlmCnFaW+rctHirTPFaRQ== dependencies: - jintr "^2.0.0" + jintr "^2.1.1" tslib "^2.5.0" undici "^5.19.1" From b9ee5958dab2622ae7846b9a10f2639821018f92 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Aug 2024 17:09:49 +0200 Subject: [PATCH 3/4] Bump sass-loader from 14.2.1 to 16.0.0 (#5495) Bumps [sass-loader](https://github.com/webpack-contrib/sass-loader) from 14.2.1 to 16.0.0. - [Release notes](https://github.com/webpack-contrib/sass-loader/releases) - [Changelog](https://github.com/webpack-contrib/sass-loader/blob/v16.0.0/CHANGELOG.md) - [Commits](https://github.com/webpack-contrib/sass-loader/compare/v14.2.1...v16.0.0) --- updated-dependencies: - dependency-name: sass-loader dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index dfeb6b97c54d9..40911dc745337 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "prettier": "^2.8.8", "rimraf": "^6.0.1", "sass": "^1.77.8", - "sass-loader": "^14.2.1", + "sass-loader": "^16.0.0", "stylelint": "^16.7.0", "stylelint-config-sass-guidelines": "^12.0.0", "stylelint-config-standard": "^36.0.1", diff --git a/yarn.lock b/yarn.lock index 115cfc5bf0a5c..daa8ad999eb26 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7501,10 +7501,10 @@ sanitize-filename@^1.6.3: dependencies: truncate-utf8-bytes "^1.0.0" -sass-loader@^14.2.1: - version "14.2.1" - resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-14.2.1.tgz#db9ad96b56dc1c1ea546101e76375d5b008fec70" - integrity sha512-G0VcnMYU18a4N7VoNDegg2OuMjYtxnqzQWARVWCIVSZwJeiL9kg8QMsuIZOplsJgTzZLF6jGxI3AClj8I9nRdQ== +sass-loader@^16.0.0: + version "16.0.0" + resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-16.0.0.tgz#9b8d497e24bc176dc368df2b5b9e90b4ad24bf4e" + integrity sha512-n13Z+3rU9A177dk4888czcVFiC8CL9dii4qpXWUg3YIIgZEvi9TCFKjOQcbK0kJM7DJu9VucrZFddvNfYCPwtw== dependencies: neo-async "^2.6.2" From 79ca2a798740042a9eba4d8263a782989e8cffba Mon Sep 17 00:00:00 2001 From: PrestonN Date: Fri, 2 Aug 2024 11:13:36 -0400 Subject: [PATCH 4/4] Bump version number to v0.21.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 40911dc745337..a0b37ad031404 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "freetube", "productName": "FreeTube", "description": "A private YouTube client", - "version": "0.21.2", + "version": "0.21.3", "license": "AGPL-3.0-or-later", "main": "./dist/main.js", "private": true,