Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(Master) Update YouTube.js 10.3.0 & Use streams from the iOS client to workaround playback issues #5508

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.7",
Expand Down
21 changes: 18 additions & 3 deletions src/main/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -408,9 +408,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']
Expand Down
125 changes: 108 additions & 17 deletions src/renderer/helpers/api/local.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
calculatePublishedDate,
escapeHTML,
extractNumberFromString,
randomArrayItem,
toLocalePublicationString
} from '../utils'

Expand All @@ -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
Expand Down Expand Up @@ -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
})
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/helpers/colors.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import i18n from '../i18n/index'
import { randomArrayItem } from './utils'

export const colors = [
{ name: 'Red', value: '#d50000' },
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions src/renderer/helpers/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
}
35 changes: 2 additions & 33 deletions src/renderer/views/Watch/Watch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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') {
/**
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 9 additions & 9 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5497,10 +5497,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"

Expand Down Expand Up @@ -9016,11 +9016,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"