Skip to content

Commit 704167e

Browse files
committed
Merge branch 'development' into custom-builds/current
* development: Translated using Weblate (English (United Kingdom)) Bump electron from 35.0.1 to 35.0.2 (FreeTubeApp#7020) Bump @babel/core from 7.26.9 to 7.26.10 in the babel group (FreeTubeApp#7017) Bump eslint-plugin-jsdoc from 50.6.3 to 50.6.6 in the eslint group (FreeTubeApp#7019) Implement content and session PO tokens (FreeTubeApp#6931) Bump stylelint from 16.15.0 to 16.16.0 in the stylelint group (FreeTubeApp#7018) Translated using Weblate (Icelandic) Translated using Weblate (Persian) Translated using Weblate (French) Translated using Weblate (Bulgarian) Translated using Weblate (Polish) Translated using Weblate (Turkish) Translated using Weblate (Serbian) Translated using Weblate (Italian) Translated using Weblate (French)
2 parents 4e22071 + 68a2764 commit 704167e

File tree

16 files changed

+334
-190
lines changed

16 files changed

+334
-190
lines changed

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
"youtubei.js": "^13.1.0"
7878
},
7979
"devDependencies": {
80-
"@babel/core": "^7.26.9",
80+
"@babel/core": "^7.26.10",
8181
"@babel/plugin-transform-class-properties": "^7.25.9",
8282
"@babel/preset-env": "^7.26.9",
8383
"@double-great/stylelint-a11y": "^3.0.4",
@@ -87,10 +87,10 @@
8787
"copy-webpack-plugin": "^13.0.0",
8888
"css-loader": "^7.1.2",
8989
"css-minimizer-webpack-plugin": "^7.0.2",
90-
"electron": "^35.0.1",
90+
"electron": "^35.0.2",
9191
"electron-builder": "^25.1.8",
9292
"eslint": "^9.22.0",
93-
"eslint-plugin-jsdoc": "^50.6.3",
93+
"eslint-plugin-jsdoc": "^50.6.6",
9494
"eslint-plugin-jsonc": "^2.19.1",
9595
"eslint-plugin-unicorn": "^57.0.0",
9696
"eslint-plugin-vue": "^10.0.0",
@@ -108,7 +108,7 @@
108108
"postcss-scss": "^4.0.9",
109109
"sass": "^1.85.1",
110110
"sass-loader": "^16.0.5",
111-
"stylelint": "^16.15.0",
111+
"stylelint": "^16.16.0",
112112
"stylelint-config-sass-guidelines": "^12.1.0",
113113
"stylelint-config-standard": "^37.0.0",
114114
"stylelint-high-performance-animation": "^1.11.0",

src/botGuardScript.js

Lines changed: 70 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,90 @@
1-
import { BG } from 'bgutils-js'
1+
import { BG, buildURL, GOOG_API_KEY } from 'bgutils-js'
22

33
// This script has it's own webpack config, as it gets passed as a string to Electron's evaluateJavaScript function
44
// in src/main/poTokenGenerator.js
5-
export default async function(visitorData) {
5+
6+
/**
7+
* Based on: https://github.com/LuanRT/BgUtils/blob/main/examples/node/innertube-challenge-fetcher-example.ts
8+
* @param {string} videoId
9+
* @param {string} visitorData
10+
* @param {import('youtubei.js').Session['context']} context
11+
*/
12+
export default async function (videoId, visitorData, context) {
613
const requestKey = 'O43z0dpjhgX20SCx4KAo'
714

8-
const bgConfig = {
9-
fetch: (input, init) => fetch(input, init),
10-
requestKey,
11-
globalObj: window,
12-
identifier: visitorData
15+
const challengeResponse = await fetch(
16+
'https://www.youtube.com/youtubei/v1/att/get?prettyPrint=false&alt=json',
17+
{
18+
method: 'POST',
19+
headers: {
20+
Accept: '*/*',
21+
'Content-Type': 'application/json',
22+
'X-Goog-Visitor-Id': visitorData,
23+
'X-Youtube-Client-Version': context.client.clientVersion,
24+
'X-Youtube-Client-Name': '1'
25+
},
26+
body: JSON.stringify({
27+
engagementType: 'ENGAGEMENT_TYPE_UNBOUND',
28+
context
29+
}),
30+
}
31+
)
32+
33+
if (!challengeResponse.ok) {
34+
throw new Error(`Request to ${challengeResponse.url} failed with status ${challengeResponse.status}\n${await challengeResponse.text()}`)
1335
}
1436

15-
const challenge = await BG.Challenge.create(bgConfig)
37+
const challengeData = await challengeResponse.json()
1638

17-
if (!challenge) {
18-
throw new Error('Could not get challenge')
39+
if (!challengeData.bgChallenge) {
40+
throw new Error('Failed to get BotGuard challenge')
1941
}
2042

21-
const interpreterJavascript = challenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue
43+
let interpreterUrl = challengeData.bgChallenge.interpreterUrl.privateDoNotAccessOrElseTrustedResourceUrlWrappedValue
44+
45+
if (interpreterUrl.startsWith('//')) {
46+
interpreterUrl = `https:${interpreterUrl}`
47+
}
48+
49+
const bgScriptResponse = await fetch(interpreterUrl)
50+
const interpreterJavascript = await bgScriptResponse.text()
2251

2352
if (interpreterJavascript) {
2453
// eslint-disable-next-line no-new-func
2554
new Function(interpreterJavascript)()
2655
} else {
27-
console.warn('Unable to load VM.')
56+
throw new Error('Could not load VM.')
2857
}
2958

30-
const poTokenResult = await BG.PoToken.generate({
31-
program: challenge.program,
32-
globalName: challenge.globalName,
33-
bgConfig
59+
const botGuard = await BG.BotGuardClient.create({
60+
program: challengeData.bgChallenge.program,
61+
globalName: challengeData.bgChallenge.globalName,
62+
globalObj: window
3463
})
3564

36-
return poTokenResult.poToken
65+
const webPoSignalOutput = []
66+
const botGuardResponse = await botGuard.snapshot({ webPoSignalOutput })
67+
68+
const integrityTokenResponse = await fetch(buildURL('GenerateIT', true), {
69+
method: 'POST',
70+
headers: {
71+
'content-type': 'application/json+protobuf',
72+
'x-goog-api-key': GOOG_API_KEY,
73+
'x-user-agent': 'grpc-web-javascript/0.1',
74+
},
75+
body: JSON.stringify([requestKey, botGuardResponse])
76+
})
77+
78+
const response = await integrityTokenResponse.json()
79+
80+
if (typeof response[0] !== 'string') {
81+
throw new Error('Could not get integrity token')
82+
}
83+
84+
const integrityTokenBasedMinter = await BG.WebPoMinter.create({ integrityToken: response[0] }, webPoSignalOutput)
85+
86+
const contentPoToken = await integrityTokenBasedMinter.mintAsWebsafeString(videoId)
87+
const sessionPoToken = await integrityTokenBasedMinter.mintAsWebsafeString(visitorData)
88+
89+
return { contentPoToken, sessionPoToken }
3790
}

src/constants.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const IpcChannels = {
4545

4646
SET_INVIDIOUS_AUTHORIZATION: 'set-invidious-authorization',
4747

48-
GENERATE_PO_TOKEN: 'generate-po-token',
48+
GENERATE_PO_TOKENS: 'generate-po-tokens',
4949

5050
GET_SCREENSHOT_FALLBACK_FOLDER: 'get-screenshot-fallback-folder',
5151
CHOOSE_DEFAULT_FOLDER: 'choose-default-folder',

src/main/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -901,8 +901,8 @@ function runApp() {
901901
})
902902
})
903903

904-
ipcMain.handle(IpcChannels.GENERATE_PO_TOKEN, (_, visitorData) => {
905-
return generatePoToken(visitorData, proxyUrl)
904+
ipcMain.handle(IpcChannels.GENERATE_PO_TOKENS, (_, videoId, visitorData, context) => {
905+
return generatePoToken(videoId, visitorData, context, proxyUrl)
906906
})
907907

908908
ipcMain.on(IpcChannels.ENABLE_PROXY, (_, url) => {

src/main/poTokenGenerator.js

Lines changed: 36 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ import { join } from 'path'
99
* This is intentionally split out into it's own thing, with it's own temporary in-memory session,
1010
* as the BotGuard stuff accesses the global `document` and `window` objects and also requires making some requests.
1111
* So we definitely don't want it running in the same places as the rest of the FreeTube code with the user data.
12+
* @param {string} videoId
1213
* @param {string} visitorData
14+
* @param {string} context
1315
* @param {string|undefined} proxyUrl
14-
* @returns {Promise<string>}
16+
* @returns {Promise<{ contentPoToken: string, sessionPoToken: string }>}
1517
*/
16-
export async function generatePoToken(visitorData, proxyUrl) {
18+
export async function generatePoToken(videoId, visitorData, context, proxyUrl) {
1719
const sessionUuid = crypto.randomUUID()
1820

1921
const theSession = session.fromPartition(`potoken-${sessionUuid}`, { cache: false })
@@ -22,27 +24,44 @@ export async function generatePoToken(visitorData, proxyUrl) {
2224
// eslint-disable-next-line n/no-callback-literal
2325
theSession.setPermissionRequestHandler((webContents, permission, callback) => callback(false))
2426

25-
theSession.setUserAgent(
26-
theSession.getUserAgent()
27-
.split(' ')
28-
.filter(part => !part.includes('Electron'))
29-
.join(' ')
30-
)
27+
theSession.setUserAgent(session.defaultSession.getUserAgent())
3128

3229
if (proxyUrl) {
3330
await theSession.setProxy({
3431
proxyRules: proxyUrl
3532
})
3633
}
3734

35+
theSession.webRequest.onBeforeSendHeaders({
36+
urls: ['https://www.google.com/js/*', 'https://www.youtube.com/youtubei/*']
37+
}, ({ requestHeaders, url }, callback) => {
38+
if (url.startsWith('https://www.youtube.com/youtubei/')) {
39+
// make InnerTube requests work with the fetch function
40+
// InnerTube rejects requests if the referer isn't YouTube or empty
41+
requestHeaders.Referer = 'https://www.youtube.com/'
42+
requestHeaders.Origin = 'https://www.youtube.com'
43+
44+
requestHeaders['Sec-Fetch-Site'] = 'same-origin'
45+
requestHeaders['Sec-Fetch-Mode'] = 'same-origin'
46+
requestHeaders['X-Youtube-Bootstrap-Logged-In'] = 'false'
47+
} else {
48+
requestHeaders['Sec-Fetch-Dest'] = 'script'
49+
requestHeaders['Sec-Fetch-Site'] = 'cross-site'
50+
requestHeaders['Accept-Language'] = '*'
51+
}
52+
53+
callback({ requestHeaders })
54+
})
55+
3856
const webContentsView = new WebContentsView({
3957
webPreferences: {
4058
backgroundThrottling: false,
4159
safeDialogs: true,
4260
sandbox: true,
4361
v8CacheOptions: 'none',
4462
session: theSession,
45-
offscreen: true
63+
offscreen: true,
64+
webSecurity: false
4665
}
4766
})
4867

@@ -58,43 +77,8 @@ export async function generatePoToken(visitorData, proxyUrl) {
5877

5978
webContentsView.webContents.debugger.attach()
6079

61-
await webContentsView.webContents.loadURL('data:text/html,', {
62-
baseURLForDataURL: 'https://www.youtube.com'
63-
})
64-
65-
await webContentsView.webContents.debugger.sendCommand('Emulation.setUserAgentOverride', {
66-
userAgent: theSession.getUserAgent(),
67-
acceptLanguage: 'en-US',
68-
platform: 'Win32',
69-
userAgentMetadata: {
70-
brands: [
71-
{
72-
brand: 'Not/A)Brand',
73-
version: '99'
74-
},
75-
{
76-
brand: 'Chromium',
77-
version: process.versions.chrome.split('.')[0]
78-
}
79-
],
80-
fullVersionList: [
81-
{
82-
brand: 'Not/A)Brand',
83-
version: '99.0.0.0'
84-
},
85-
{
86-
brand: 'Chromium',
87-
version: process.versions.chrome
88-
}
89-
],
90-
platform: 'Windows',
91-
platformVersion: '10.0.0',
92-
architecture: 'x86',
93-
model: '',
94-
mobile: false,
95-
bitness: '64',
96-
wow64: false
97-
}
80+
await webContentsView.webContents.loadURL('data:text/html,<!DOCTYPE html><html lang="en"><head><title></title></head><body></body></html>', {
81+
baseURLForDataURL: 'https://www.youtube.com/'
9882
})
9983

10084
await webContentsView.webContents.debugger.sendCommand('Emulation.setDeviceMetricsOverride', {
@@ -112,7 +96,7 @@ export async function generatePoToken(visitorData, proxyUrl) {
11296
}
11397
})
11498

115-
const script = await getScript(visitorData)
99+
const script = await getScript(videoId, visitorData, context)
116100

117101
const response = await webContentsView.webContents.executeJavaScript(script)
118102

@@ -125,9 +109,11 @@ export async function generatePoToken(visitorData, proxyUrl) {
125109
let cachedScript
126110

127111
/**
112+
* @param {string} videoId
128113
* @param {string} visitorData
114+
* @param {string} context
129115
*/
130-
async function getScript(visitorData) {
116+
async function getScript(videoId, visitorData, context) {
131117
if (!cachedScript) {
132118
const pathToScript = process.env.NODE_ENV === 'development'
133119
? join(__dirname, '../../dist/botGuardScript.js')
@@ -140,8 +126,8 @@ async function getScript(visitorData) {
140126

141127
const functionName = match[1]
142128

143-
cachedScript = content.replace(match[0], `;${functionName}("FT_VISITOR_DATA")`)
129+
cachedScript = content.replace(match[0], `;${functionName}(FT_PARAMS)`)
144130
}
145131

146-
return cachedScript.replace('FT_VISITOR_DATA', visitorData)
132+
return cachedScript.replace('FT_PARAMS', `"${videoId}","${visitorData}",${context}`)
147133
}

src/renderer/helpers/api/local.js

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -198,16 +198,24 @@ export async function getLocalSearchContinuation(continuationData) {
198198
export async function getLocalVideoInfo(id) {
199199
const webInnertube = await createInnertube({ withPlayer: true, generateSessionLocally: false })
200200

201-
let poToken
201+
// based on the videoId (added to the body of the /player request)
202+
let contentPoToken
203+
// based on the visitor data (added to the streaming URLs)
204+
let sessionPoToken
202205

203206
if (process.env.IS_ELECTRON) {
204207
const { ipcRenderer } = require('electron')
205208

206209
try {
207-
poToken = await ipcRenderer.invoke(IpcChannels.GENERATE_PO_TOKEN, webInnertube.session.context.client.visitorData)
208-
209-
webInnertube.session.po_token = poToken
210-
webInnertube.session.player.po_token = poToken
210+
({ contentPoToken, sessionPoToken } = await ipcRenderer.invoke(
211+
IpcChannels.GENERATE_PO_TOKENS,
212+
id,
213+
webInnertube.session.context.client.visitorData,
214+
JSON.stringify(webInnertube.session.context)
215+
))
216+
217+
webInnertube.session.po_token = contentPoToken
218+
webInnertube.session.player.po_token = sessionPoToken
211219
} catch (error) {
212220
console.error('Local API, poToken generation failed', error)
213221
throw error
@@ -227,8 +235,8 @@ export async function getLocalVideoInfo(id) {
227235
const webEmbeddedInnertube = await createInnertube({ clientType: ClientType.WEB_EMBEDDED })
228236
webEmbeddedInnertube.session.context.client.visitorData = webInnertube.session.context.client.visitorData
229237

230-
if (poToken) {
231-
webEmbeddedInnertube.session.po_token = poToken
238+
if (contentPoToken) {
239+
webEmbeddedInnertube.session.po_token = contentPoToken
232240
}
233241

234242
const videoId = hasTrailer && trailerIsAgeRestricted ? info.playability_status.error_screen.video_id : id
@@ -283,9 +291,9 @@ export async function getLocalVideoInfo(id) {
283291
let url = info.streaming_data.dash_manifest_url
284292

285293
if (url.includes('?')) {
286-
url += `&pot=${encodeURIComponent(poToken)}&mpd_version=7`
294+
url += `&pot=${encodeURIComponent(sessionPoToken)}&mpd_version=7`
287295
} else {
288-
url += `${url.endsWith('/') ? '' : '/'}pot/${encodeURIComponent(poToken)}/mpd_version/7`
296+
url += `${url.endsWith('/') ? '' : '/'}pot/${encodeURIComponent(sessionPoToken)}/mpd_version/7`
289297
}
290298

291299
info.streaming_data.dash_manifest_url = url

0 commit comments

Comments
 (0)