Skip to content

Commit 16d6ccc

Browse files
Cherry pick upstream hotfix + dev branch changes which add needed functions
- ^Update youtubei.js - Use streams from the iOS client to workaround playback issues (FreeTubeApp#5472) - Allow user agent spoofing for usage of iOS client
1 parent 8ac44bb commit 16d6ccc

File tree

10 files changed

+272
-66
lines changed

10 files changed

+272
-66
lines changed

android/app/src/main/java/io/freetubeapp/freetube/FreeTubeJavaScriptInterface.kt

+7
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,13 @@ class FreeTubeJavaScriptInterface {
664664
context.isInAPrompt = false
665665
}
666666

667+
@JavascriptInterface
668+
fun queueFetchBody(id: String, body: String) {
669+
if (body != "undefined") {
670+
context.pendingRequestBodies[id] = body
671+
}
672+
}
673+
667674
private fun addNamedCallbackToPromise(promise: String, name: String) {
668675
context.runOnUiThread {
669676
context.webView.loadUrl("javascript: window['${promise}'].callbacks = window['${promise}'].callbacks || {}; window['${promise}'].callbacks.notify = (key, message) => window['${promise}'].callbacks[key].forEach(callback => callback(message)); window['${promise}'].callbacks['${name}'] = window['${promise}'].callbacks['${name}'] || []")

android/app/src/main/java/io/freetubeapp/freetube/MainActivity.kt

+90
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import android.os.Bundle
99
import android.view.View
1010
import android.view.ViewGroup
1111
import android.view.ViewTreeObserver
12+
import android.webkit.ConsoleMessage
1213
import android.webkit.WebChromeClient
1314
import android.webkit.WebResourceRequest
15+
import android.webkit.WebResourceResponse
1416
import android.webkit.WebView
1517
import android.webkit.WebViewClient
1618
import android.widget.FrameLayout
@@ -24,9 +26,15 @@ import androidx.core.view.WindowCompat
2426
import androidx.core.view.WindowInsetsCompat
2527
import androidx.core.view.WindowInsetsControllerCompat
2628
import io.freetubeapp.freetube.databinding.ActivityMainBinding
29+
import org.json.JSONObject
30+
import java.io.Serializable
31+
import java.net.HttpURLConnection
32+
import java.net.URL
2733
import java.net.URLEncoder
2834
import java.util.Base64
35+
import java.util.UUID
2936
import java.util.concurrent.BlockingQueue
37+
import java.util.concurrent.ConcurrentHashMap.KeySetView
3038
import java.util.concurrent.LinkedBlockingQueue
3139
import java.util.concurrent.ThreadPoolExecutor
3240
import java.util.concurrent.TimeUnit
@@ -44,10 +52,12 @@ class MainActivity : AppCompatActivity(), OnRequestPermissionsResultCallback {
4452
lateinit var jsInterface: FreeTubeJavaScriptInterface
4553
lateinit var activityResultLauncher: ActivityResultLauncher<Intent>
4654
lateinit var content: View
55+
var consoleMessages: MutableList<JSONObject> = mutableListOf()
4756
var showSplashScreen: Boolean = true
4857
var darkMode: Boolean = false
4958
var paused: Boolean = false
5059
var isInAPrompt: Boolean = false
60+
var pendingRequestBodies: MutableMap<String, String> = mutableMapOf()
5161
/*
5262
* Gets the number of available cores
5363
* (not always the same as the maximum number of cores)
@@ -164,6 +174,20 @@ class MainActivity : AppCompatActivity(), OnRequestPermissionsResultCallback {
164174
webView.addJavascriptInterface(jsInterface, "Android")
165175
webView.webChromeClient = object: WebChromeClient() {
166176

177+
override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
178+
val messageData = JSONObject()
179+
messageData.put("content", consoleMessage.message())
180+
messageData.put("level", consoleMessage.messageLevel())
181+
messageData.put("timestamp", System.currentTimeMillis())
182+
messageData.put("id", UUID.randomUUID())
183+
messageData.put("key", "${messageData["id"]}-${messageData["timestamp"]}")
184+
messageData.put("sourceId", consoleMessage.sourceId())
185+
messageData.put("lineNumber", consoleMessage.lineNumber())
186+
consoleMessages.add(messageData)
187+
webView.loadUrl("javascript: var event = new Event(\"console-message\"); event.data = JSON.parse(${btoa(messageData.toString())}); window.dispatchEvent(event)")
188+
return super.onConsoleMessage(consoleMessage);
189+
}
190+
167191
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
168192
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
169193
fullscreenView = view!!
@@ -186,6 +210,72 @@ class MainActivity : AppCompatActivity(), OnRequestPermissionsResultCallback {
186210
}
187211
}
188212
webView.webViewClient = object: WebViewClient() {
213+
override fun shouldInterceptRequest(
214+
view: WebView?,
215+
request: WebResourceRequest?
216+
): WebResourceResponse? {
217+
if (request!!.requestHeaders.containsKey("x-user-agent")) {
218+
with (URL(request!!.url.toString()).openConnection() as HttpURLConnection) {
219+
requestMethod = request.method
220+
val isClient5 = request.requestHeaders.containsKey("x-youtube-client-name") && request.requestHeaders["x-youtube-client-name"] == "5"
221+
// map headers
222+
for (header in request!!.requestHeaders) {
223+
fun getReal(key: String, value: String): Array<String>? {
224+
if (key == "x-user-agent") {
225+
return arrayOf("User-Agent", value)
226+
}
227+
if (key == "User-Agent") {
228+
return null
229+
}
230+
if (key == "x-fta-request-id") {
231+
return null
232+
}
233+
if (isClient5) {
234+
if (key == "referrer") {
235+
return null
236+
}
237+
if (key == "origin") {
238+
return null
239+
}
240+
if (key == "Sec-Fetch-Site") {
241+
return null
242+
}
243+
if (key == "Sec-Fetch-Mode") {
244+
return null
245+
}
246+
if (key == "Sec-Fetch-Dest") {
247+
return null
248+
}
249+
if (key == "sec-ch-ua") {
250+
return null
251+
}
252+
if (key == "sec-ch-ua-mobile") {
253+
return null
254+
}
255+
if (key == "sec-ch-ua-platform") {
256+
return null
257+
}
258+
}
259+
return arrayOf(key, value)
260+
}
261+
val real = getReal(header.key, header.value)
262+
if (real !== null) {
263+
setRequestProperty(real[0], real[1])
264+
}
265+
}
266+
if (request.requestHeaders.containsKey("x-fta-request-id")) {
267+
if (pendingRequestBodies.containsKey(request.requestHeaders["x-fta-request-id"])) {
268+
val body = pendingRequestBodies[request.requestHeaders["x-fta-request-id"]]
269+
pendingRequestBodies.remove(request.requestHeaders["x-fta-request-id"])
270+
outputStream.write(body!!.toByteArray())
271+
}
272+
}
273+
// 🧝‍♀️ magic
274+
return WebResourceResponse(this.contentType, this.contentEncoding, inputStream!!)
275+
}
276+
}
277+
return super.shouldInterceptRequest(view, request)
278+
}
189279
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
190280
if (request!!.url!!.scheme == "file") {
191281
// don't send file url requests to a web browser (it will crash the app)

package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "freetube",
33
"productName": "FreeTube",
44
"description": "A private YouTube client",
5-
"version": "0.21.2",
5+
"version": "0.21.3",
66
"license": "AGPL-3.0-or-later",
77
"main": "./dist/main.js",
88
"private": true,
@@ -81,7 +81,7 @@
8181
"vue-observe-visibility": "^1.0.0",
8282
"vue-router": "^3.6.5",
8383
"vuex": "^3.6.2",
84-
"youtubei.js": "^10.2.0"
84+
"youtubei.js": "^10.3.0"
8585
},
8686
"devDependencies": {
8787
"@babel/core": "^7.24.7",

src/index.ejs

+25
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,31 @@
77
<script>
88
window.ofetch = window.fetch
99
window.fetch = async (...args) => {
10+
const requestId = crypto.randomUUID()
11+
let body
12+
for (const arg of args) {
13+
if (typeof arg === 'object') {
14+
if ('body' in arg) {
15+
body = arg.body
16+
}
17+
if ('headers' in arg) {
18+
if ('append' in arg.headers) {
19+
if (arg.headers.get('x-youtube-client-name') == 5) {
20+
arg.headers.append('x-fta-request-id', requestId)
21+
Android.queueFetchBody(requestId, body)
22+
}
23+
} else {
24+
if ('x-youtube-client-name' in arg.headers) {
25+
if (arg.headers['x-youtube-client-name'] == 5) {
26+
arg.headers['x-fta-request-id'] = requestId
27+
Android.queueFetchBody(requestId, body)
28+
}
29+
}
30+
}
31+
}
32+
}
33+
}
34+
1035
if (typeof args[0] === 'string' && args[0].startsWith('file://')) {
1136
// forward to xml http request
1237
/** @type {Response} */

src/main/index.js

+18-3
Original file line numberDiff line numberDiff line change
@@ -408,9 +408,24 @@ function runApp() {
408408
requestHeaders.Origin = 'https://www.youtube.com'
409409

410410
if (url.startsWith('https://www.youtube.com/youtubei/')) {
411-
requestHeaders['Sec-Fetch-Site'] = 'same-origin'
412-
requestHeaders['Sec-Fetch-Mode'] = 'same-origin'
413-
requestHeaders['X-Youtube-Bootstrap-Logged-In'] = 'false'
411+
// Make iOS requests work and look more realistic
412+
if (requestHeaders['x-youtube-client-name'] === '5') {
413+
delete requestHeaders.Referer
414+
delete requestHeaders.Origin
415+
delete requestHeaders['Sec-Fetch-Site']
416+
delete requestHeaders['Sec-Fetch-Mode']
417+
delete requestHeaders['Sec-Fetch-Dest']
418+
delete requestHeaders['sec-ch-ua']
419+
delete requestHeaders['sec-ch-ua-mobile']
420+
delete requestHeaders['sec-ch-ua-platform']
421+
422+
requestHeaders['User-Agent'] = requestHeaders['x-user-agent']
423+
delete requestHeaders['x-user-agent']
424+
} else {
425+
requestHeaders['Sec-Fetch-Site'] = 'same-origin'
426+
requestHeaders['Sec-Fetch-Mode'] = 'same-origin'
427+
requestHeaders['X-Youtube-Bootstrap-Logged-In'] = 'false'
428+
}
414429
} else {
415430
// YouTube doesn't send the Content-Type header for the media requests, so we shouldn't either
416431
delete requestHeaders['Content-Type']

src/renderer/helpers/api/local.js

+108-17
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
calculatePublishedDate,
99
escapeHTML,
1010
extractNumberFromString,
11+
randomArrayItem,
1112
toLocalePublicationString
1213
} from '../utils'
1314

@@ -19,6 +20,25 @@ const TRACKING_PARAM_NAMES = [
1920
'utm_content',
2021
]
2122

23+
const IOS_VERSIONS = [
24+
'17.5.1',
25+
'17.5',
26+
'17.4.1',
27+
'17.4',
28+
'17.3.1',
29+
'17.3',
30+
]
31+
32+
const YOUTUBE_IOS_CLIENT_VERSIONS = [
33+
'19.29.1',
34+
'19.28.1',
35+
'19.26.5',
36+
'19.25.4',
37+
'19.25.3',
38+
'19.24.3',
39+
'19.24.2',
40+
]
41+
2242
/**
2343
* Creates a lightweight Innertube instance, which is faster to create or
2444
* 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
5676
client_type: clientType,
5777

5878
// use browser fetch
59-
fetch: (input, init) => fetch(input, init),
79+
fetch: (input, init) => {
80+
// Make iOS requests work and look more realistic
81+
if (init?.headers instanceof Headers && init.headers.get('x-youtube-client-name') === '5') {
82+
// Use a random iOS version and YouTube iOS client version to make the requests look less suspicious
83+
const clientVersion = randomArrayItem(YOUTUBE_IOS_CLIENT_VERSIONS)
84+
const iosVersion = randomArrayItem(IOS_VERSIONS)
85+
86+
init.headers.set('x-youtube-client-version', clientVersion)
87+
88+
// 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
89+
init.headers.delete('user-agent')
90+
init.headers.set('x-user-agent', `com.google.ios.youtube/${clientVersion} (iPhone16,2; CPU iOS ${iosVersion.replaceAll('.', '_')} like Mac OS X; en_US)`)
91+
92+
const bodyJson = JSON.parse(init.body)
93+
94+
const client = bodyJson.context.client
95+
96+
client.clientVersion = clientVersion
97+
client.deviceMake = 'Apple'
98+
client.deviceModel = 'iPhone16,2' // iPhone 15 Pro Max
99+
client.osName = 'iOS'
100+
client.osVersion = iosVersion
101+
delete client.browserName
102+
delete client.browserVersion
103+
104+
init.body = JSON.stringify(bodyJson)
105+
}
106+
107+
return fetch(input, init)
108+
},
60109
cache,
61110
generate_session_locally: !!generateSessionLocally
62111
})
@@ -190,27 +239,69 @@ export async function getLocalSearchContinuation(continuationData) {
190239
return handleSearchResponse(response)
191240
}
192241

193-
export async function getLocalVideoInfo(id, attemptBypass = false) {
194-
let info
195-
let player
242+
/**
243+
* @param {string} id
244+
*/
245+
export async function getLocalVideoInfo(id) {
246+
const webInnertube = await createInnertube({ withPlayer: true, generateSessionLocally: false })
196247

197-
if (attemptBypass) {
198-
const innertube = await createInnertube({ withPlayer: true, clientType: ClientType.TV_EMBEDDED, generateSessionLocally: false })
199-
player = innertube.actions.session.player
248+
const info = await webInnertube.getInfo(id)
200249

201-
// the second request that getInfo makes 404s with the bypass, so we use getBasicInfo instead
202-
// that's fine as we have most of the information from the original getInfo request
203-
info = await innertube.getBasicInfo(id, 'TV_EMBEDDED')
204-
} else {
205-
const innertube = await createInnertube({ withPlayer: true, generateSessionLocally: false })
206-
player = innertube.actions.session.player
250+
const hasTrailer = info.has_trailer
251+
const trailerIsAgeRestricted = info.getTrailerInfo() === null
207252

208-
info = await innertube.getInfo(id)
253+
if (hasTrailer) {
254+
/** @type {import('youtubei.js').YTNodes.PlayerLegacyDesktopYpcTrailer} */
255+
const trailerScreen = info.playability_status.error_screen
256+
id = trailerScreen.video_id
209257
}
210258

211-
if (info.streaming_data) {
212-
decipherFormats(info.streaming_data.adaptive_formats, player)
213-
decipherFormats(info.streaming_data.formats, player)
259+
// try to bypass the age restriction
260+
if (info.playability_status.status === 'LOGIN_REQUIRED' || (hasTrailer && trailerIsAgeRestricted)) {
261+
const tvInnertube = await createInnertube({ withPlayer: true, clientType: ClientType.TV_EMBEDDED, generateSessionLocally: false })
262+
263+
const tvInfo = await tvInnertube.getBasicInfo(id, 'TV_EMBEDDED')
264+
265+
if (tvInfo.streaming_data) {
266+
decipherFormats(tvInfo.streaming_data.adaptive_formats, tvInnertube.actions.session.player)
267+
decipherFormats(tvInfo.streaming_data.formats, tvInnertube.actions.session.player)
268+
}
269+
270+
info.playability_status = tvInfo.playability_status
271+
info.streaming_data = tvInfo.streaming_data
272+
info.basic_info.start_timestamp = tvInfo.basic_info.start_timestamp
273+
info.basic_info.duration = tvInfo.basic_info.duration
274+
info.captions = tvInfo.captions
275+
info.storyboards = tvInfo.storyboards
276+
} else {
277+
const iosInnertube = await createInnertube({ clientType: ClientType.IOS })
278+
279+
const iosInfo = await iosInnertube.getBasicInfo(id, 'iOS')
280+
281+
if (hasTrailer) {
282+
info.playability_status = iosInfo.playability_status
283+
info.streaming_data = iosInfo.streaming_data
284+
info.basic_info.start_timestamp = iosInfo.basic_info.start_timestamp
285+
info.basic_info.duration = iosInfo.basic_info.duration
286+
info.captions = iosInfo.captions
287+
info.storyboards = iosInfo.storyboards
288+
} else if (iosInfo.streaming_data) {
289+
info.streaming_data.adaptive_formats = iosInfo.streaming_data.adaptive_formats
290+
// Use the legacy formats from the original web response as the iOS client doesn't have any legacy formats
291+
292+
for (const format of info.streaming_data.adaptive_formats) {
293+
format.freeTubeUrl = format.url
294+
}
295+
296+
// don't overwrite for live streams
297+
if (!info.streaming_data.hls_manifest_url) {
298+
info.streaming_data.hls_manifest_url = iosInfo.streaming_data.hls_manifest_url
299+
}
300+
}
301+
302+
if (info.streaming_data) {
303+
decipherFormats(info.streaming_data.formats, webInnertube.actions.session.player)
304+
}
214305
}
215306

216307
return info

src/renderer/helpers/colors.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import i18n from '../i18n/index'
2+
import { randomArrayItem } from './utils'
23

34
export const colors = [
45
{ name: 'Red', value: '#d50000' },
@@ -103,8 +104,7 @@ export function getRandomColorClass() {
103104
}
104105

105106
export function getRandomColor() {
106-
const randomInt = Math.floor(Math.random() * colors.length)
107-
return colors[randomInt]
107+
return randomArrayItem(colors)
108108
}
109109

110110
export function calculateColorLuminance(colorValue) {

0 commit comments

Comments
 (0)