more fixes

This commit is contained in:
deniscerri 2025-04-06 11:18:20 +02:00
parent 5b31612462
commit 1bd3704890
No known key found for this signature in database
GPG key ID: 95C43D517D830350
6 changed files with 159 additions and 191 deletions

View file

@ -83,10 +83,12 @@ class YoutubePlayerClientAdapter(onItemClickListener: OnItemClickListener, activ
if (item.urlRegex.isNotEmpty()) { if (item.urlRegex.isNotEmpty()) {
val text = "URL Regex: " + item.urlRegex.joinToString(", ") val text = "URL Regex: " + item.urlRegex.joinToString(", ")
content.findViewById<TextView>(R.id.urlRegex).apply { val tmp = activity.layoutInflater.inflate(R.layout.textview_chip, content, false) as TextView
isVisible = true tmp.maxWidth = 500
setText(text) tmp.maxLines = 1
} tmp.ellipsize = TextUtils.TruncateAt.END
tmp.text = text
content.addView(tmp)
} }
title.alpha = if (item.enabled) 1f else 0.3f title.alpha = if (item.enabled) 1f else 0.3f

View file

@ -136,13 +136,13 @@ class HistoryFragment : Fragment(), HistoryPaginatedAdapter.OnItemClickListener{
noResults.isVisible = false noResults.isVisible = false
historyViewModel = ViewModelProvider(this)[HistoryViewModel::class.java] historyViewModel = ViewModelProvider(this)[HistoryViewModel::class.java]
recyclerView.adapter = historyAdapter recyclerView.adapter = historyAdapter
lifecycleScope.launch { lifecycleScope.launch {
historyViewModel.paginatedItems.collectLatest { historyViewModel.paginatedItems.collectLatest {
withContext(Dispatchers.IO){ historyAdapter.submitData(it)
historyAdapter.submitData(it)
}
} }
} }
lifecycleScope.launch { lifecycleScope.launch {
historyViewModel.websites.collectLatest { historyViewModel.websites.collectLatest {
if(it.isEmpty()) { if(it.isEmpty()) {

View file

@ -1253,6 +1253,12 @@ class YTDLPUtil(private val context: Context, private val commandTemplateDao: Co
request.merge(metadataCommands) request.merge(metadataCommands)
if (downloadItem.extraCommands.isNotBlank() && downloadItem.type != DownloadViewModel.Type.command){ if (downloadItem.extraCommands.isNotBlank() && downloadItem.type != DownloadViewModel.Type.command){
// check for cache dir as extra command and add it as an actual option to prevent --no-cache-dir in youtubedl_android
val cacheDirArg = """(--cache-dir (".*"))""".toRegex().find(downloadItem.extraCommands)
if (cacheDirArg != null) {
ytDlRequest.addOption("--cache-dir", cacheDirArg.groupValues.last().replace("\"", ""))
downloadItem.extraCommands.replace(cacheDirArg.value, "")
}
request.addOption(downloadItem.extraCommands) request.addOption(downloadItem.extraCommands)
} }

View file

@ -45,7 +45,7 @@ class NewPipePoTokenGenerator : PoTokenProvider {
val (poTokenGenerator, visitorData, streamingPot, hasBeenRecreated) = val (poTokenGenerator, visitorData, streamingPot, hasBeenRecreated) =
synchronized(WebPoTokenGenLock) { synchronized(WebPoTokenGenLock) {
val shouldRecreate = webPoTokenGenerator == null || forceRecreate || webPoTokenGenerator!!.isExpired val shouldRecreate = webPoTokenGenerator == null || forceRecreate || webPoTokenGenerator!!.isExpired()
if (shouldRecreate) { if (shouldRecreate) {
val innertubeClientRequestInfo = InnertubeClientRequestInfo.ofWebClient() val innertubeClientRequestInfo = InnertubeClientRequestInfo.ofWebClient()

View file

@ -2,6 +2,8 @@ package com.deniscerri.ytdl.util.extractors.newpipe.potoken
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import android.webkit.ConsoleMessage import android.webkit.ConsoleMessage
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
@ -21,6 +23,8 @@ import com.deniscerri.ytdl.util.extractors.newpipe.potoken.JavascriptUtil.parseC
import com.deniscerri.ytdl.util.extractors.newpipe.potoken.JavascriptUtil.parseIntegrityTokenData import com.deniscerri.ytdl.util.extractors.newpipe.potoken.JavascriptUtil.parseIntegrityTokenData
import com.deniscerri.ytdl.util.extractors.newpipe.potoken.JavascriptUtil.stringToU8 import com.deniscerri.ytdl.util.extractors.newpipe.potoken.JavascriptUtil.stringToU8
import com.deniscerri.ytdl.util.extractors.newpipe.potoken.JavascriptUtil.u8ToBase64 import com.deniscerri.ytdl.util.extractors.newpipe.potoken.JavascriptUtil.u8ToBase64
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import okhttp3.Headers.Companion.toHeaders import okhttp3.Headers.Companion.toHeaders
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
@ -34,48 +38,29 @@ import kotlin.coroutines.resumeWithException
class PoTokenWebView private constructor( class PoTokenWebView private constructor(
context: Context, context: Context,
// to be used exactly once only during initialization! // to be used exactly once only during initialization!
private val continuation: Continuation<PoTokenWebView>, private val generatorContinuation: Continuation<PoTokenWebView>,
) { ) {
private val webView = WebView(context) private val webView = WebView(context)
private val scope = MainScope() private val poTokenContinuations = mutableMapOf<String, Continuation<String>>()
private val poTokenContinuations = Collections.synchronizedMap(ArrayMap<String, Continuation<String>>()) private val exceptionHandler = CoroutineExceptionHandler { context, exception ->
private val exceptionHandler = CoroutineExceptionHandler { _, t -> onInitializationError(exception)
onInitializationErrorCloseAndCancel(t)
} }
private lateinit var expirationInstant: Instant private lateinit var expirationInstant: Instant
//region Initialization //region Initialization
init { init {
val webViewSettings = webView.settings webView.settings.apply {
//noinspection SetJavaScriptEnabled we want to use JavaScript! //noinspection SetJavaScriptEnabled we want to use JavaScript!
webViewSettings.javaScriptEnabled = true javaScriptEnabled = true
if (Build.VERSION.SDK_INT >= 26) { if (Build.VERSION.SDK_INT >= 26) {
webViewSettings.safeBrowsingEnabled = false safeBrowsingEnabled = false
}
userAgentString = USER_AGENT
blockNetworkLoads = true // the WebView does not need internet access
} }
webViewSettings.userAgentString = USER_AGENT
webViewSettings.blockNetworkLoads = true // the WebView does not need internet access
// so that we can run async functions and get back the result // so that we can run async functions and get back the result
webView.addJavascriptInterface(this, JS_INTERFACE) webView.addJavascriptInterface(this, JS_INTERFACE)
webView.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(m: ConsoleMessage): Boolean {
if (m.message().contains("Uncaught")) {
// There should not be any uncaught errors while executing the code, because
// everything that can fail is guarded by try-catch. Therefore, this likely
// indicates that there was a syntax error in the code, i.e. the WebView only
// supports a really old version of JS.
val fmt = "\"${m.message()}\", source: ${m.sourceId()} (${m.lineNumber()})"
val exception = Exception(fmt)
Log.e(TAG, "This WebView implementation is broken: $fmt")
onInitializationErrorCloseAndCancel(exception)
popAllPoTokenContinuations().forEach { (_, cont) -> cont.resumeWithException(exception) }
}
return super.onConsoleMessage(m)
}
}
} }
/** /**
@ -83,17 +68,30 @@ class PoTokenWebView private constructor(
* initialization. This will asynchronously go through all the steps needed to load BotGuard, * initialization. This will asynchronously go through all the steps needed to load BotGuard,
* run it, and obtain an `integrityToken`. * run it, and obtain an `integrityToken`.
*/ */
private fun loadHtmlAndObtainBotguard() { private fun loadHtmlAndObtainBotguard(context: Context) {
Log.d(TAG, "loadHtmlAndObtainBotguard() called") if (BuildConfig.DEBUG) {
Log.d(TAG, "loadHtmlAndObtainBotguard() called")
}
scope.launch(exceptionHandler) { CoroutineScope(Dispatchers.IO).launch(exceptionHandler) {
val html = withContext(Dispatchers.IO) { try {
webView.context.assets.open("po_token.html").bufferedReader().use { it.readText() } val html = context.assets.open("po_token.html").bufferedReader().use { it.readText() }
withContext(Dispatchers.Main) {
webView.loadDataWithBaseURL(
"https://www.youtube.com",
html.replaceFirst(
"</script>",
// calls downloadAndRunBotguard() when the page has finished loading
"\n$JS_INTERFACE.downloadAndRunBotguard()</script>"
),
"text/html",
"utf-8",
null,
)
}
} catch (e: Exception) {
onInitializationError(e)
} }
// calls downloadAndRunBotguard() when the page has finished loading
val data = html.replaceFirst("</script>", "\n$JS_INTERFACE.downloadAndRunBotguard()</script>")
webView.loadDataWithBaseURL("https://www.youtube.com", data, "text/html", "utf-8", null)
} }
} }
@ -103,27 +101,32 @@ class PoTokenWebView private constructor(
*/ */
@JavascriptInterface @JavascriptInterface
fun downloadAndRunBotguard() { fun downloadAndRunBotguard() {
Log.d(TAG, "downloadAndRunBotguard() called") if (BuildConfig.DEBUG) {
Log.d(TAG, "downloadAndRunBotguard() called")
}
makeBotguardServiceRequest( CoroutineScope(Dispatchers.IO).launch(exceptionHandler) {
"https://www.youtube.com/api/jnn/v1/Create", val responseBody = makeBotguardServiceRequest(
"[ \"$REQUEST_KEY\" ]", "https://www.youtube.com/api/jnn/v1/Create",
) { responseBody -> listOf(REQUEST_KEY)
val parsedChallengeData = parseChallengeData(responseBody)
webView.evaluateJavascript(
"""try {
data = $parsedChallengeData
runBotGuard(data).then(function (result) {
this.webPoSignalOutput = result.webPoSignalOutput
$JS_INTERFACE.onRunBotguardResult(result.botguardResponse)
}, function (error) {
$JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack)
})
} catch (error) {
$JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack)
}""",
null
) )
val parsedChallengeData = parseChallengeData(responseBody)
withContext(Dispatchers.Main) {
webView.evaluateJavascript(
"""try {
data = $parsedChallengeData
runBotGuard(data).then(function (result) {
this.webPoSignalOutput = result.webPoSignalOutput
$JS_INTERFACE.onRunBotguardResult(result.botguardResponse)
}, function (error) {
$JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack)
})
} catch (error) {
$JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack)
}""",
null
)
}
} }
} }
@ -136,7 +139,7 @@ class PoTokenWebView private constructor(
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.e(TAG, "Initialization error from JavaScript: $error") Log.e(TAG, "Initialization error from JavaScript: $error")
} }
onInitializationErrorCloseAndCancel(Exception(error)) onInitializationError(Exception(error))
} }
/** /**
@ -145,20 +148,25 @@ class PoTokenWebView private constructor(
*/ */
@JavascriptInterface @JavascriptInterface
fun onRunBotguardResult(botguardResponse: String) { fun onRunBotguardResult(botguardResponse: String) {
Log.d(TAG, "botguardResponse: $botguardResponse") CoroutineScope(Dispatchers.IO).launch(exceptionHandler) {
makeBotguardServiceRequest( val response = makeBotguardServiceRequest(
"https://www.youtube.com/api/jnn/v1/GenerateIT", "https://www.youtube.com/api/jnn/v1/GenerateIT",
"[ \"$REQUEST_KEY\", \"$botguardResponse\" ]", listOf(REQUEST_KEY, botguardResponse)
) { responseBody -> )
Log.d(TAG, "GenerateIT response: $responseBody") val (integrityToken, expirationTimeInSeconds) = parseIntegrityTokenData(response)
val (integrityToken, expirationTimeInSeconds) = parseIntegrityTokenData(responseBody)
// leave 10 minutes of margin just to be sure // leave 10 minutes of margin just to be sure
expirationInstant = Instant.now().plusSeconds(expirationTimeInSeconds).minus(10, ChronoUnit.MINUTES) expirationInstant = Instant.now().plusSeconds(expirationTimeInSeconds - 600)
webView.evaluateJavascript("this.integrityToken = $integrityToken") { withContext(Dispatchers.Main) {
Log.d(TAG, "initialization finished, expiration=${expirationTimeInSeconds}s") webView.evaluateJavascript(
continuation.resume(this) "this.integrityToken = $integrityToken"
) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "initialization finished, expiration=${expirationTimeInSeconds}s")
}
generatorContinuation.resume(this@PoTokenWebView)
}
} }
} }
} }
@ -166,22 +174,29 @@ class PoTokenWebView private constructor(
//region Obtaining poTokens //region Obtaining poTokens
suspend fun generatePoToken(identifier: String): String { suspend fun generatePoToken(identifier: String): String {
return withContext(Dispatchers.Main) { if (BuildConfig.DEBUG) {
suspendCancellableCoroutine { cont -> Log.d(TAG, "generatePoToken() called with identifier $identifier")
Log.d(TAG, "generatePoToken() called with identifier $identifier") }
addPoTokenEmitter(identifier, cont) return suspendCancellableCoroutine { continuation ->
poTokenContinuations[identifier] = continuation
val u8Identifier = stringToU8(identifier)
Handler(Looper.getMainLooper()).post {
webView.evaluateJavascript( webView.evaluateJavascript(
"""try { """try {
identifier = "$identifier" identifier = "$identifier"
u8Identifier = ${stringToU8(identifier)} u8Identifier = $u8Identifier
poTokenU8 = obtainPoToken(webPoSignalOutput, integrityToken, u8Identifier) poTokenU8 = obtainPoToken(webPoSignalOutput, integrityToken, u8Identifier)
poTokenU8String = poTokenU8.join(",") poTokenU8String = ""
for (i = 0; i < poTokenU8.length; i++) {
if (i != 0) poTokenU8String += ","
poTokenU8String += poTokenU8[i]
}
$JS_INTERFACE.onObtainPoTokenResult(identifier, poTokenU8String) $JS_INTERFACE.onObtainPoTokenResult(identifier, poTokenU8String)
} catch (error) { } catch (error) {
$JS_INTERFACE.onObtainPoTokenError(identifier, error + "\n" + error.stack) $JS_INTERFACE.onObtainPoTokenError(identifier, error + "\n" + error.stack)
}""", }""",
null ) {}
)
} }
} }
} }
@ -195,7 +210,7 @@ class PoTokenWebView private constructor(
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.e(TAG, "obtainPoToken error from JavaScript: $error") Log.e(TAG, "obtainPoToken error from JavaScript: $error")
} }
popPoTokenContinuation(identifier)?.resumeWithException(Exception(error)) poTokenContinuations.remove(identifier)?.resumeWithException(Exception(error))
} }
/** /**
@ -204,118 +219,84 @@ class PoTokenWebView private constructor(
*/ */
@JavascriptInterface @JavascriptInterface
fun onObtainPoTokenResult(identifier: String, poTokenU8: String) { fun onObtainPoTokenResult(identifier: String, poTokenU8: String) {
Log.d(TAG, "Generated poToken (before decoding): identifier=$identifier poTokenU8=$poTokenU8") if (BuildConfig.DEBUG) {
Log.d(TAG, "Generated poToken (before decoding): identifier=$identifier poTokenU8=$poTokenU8")
}
val poToken = try { val poToken = try {
u8ToBase64(poTokenU8) u8ToBase64(poTokenU8)
} catch (t: Throwable) { } catch (t: Throwable) {
popPoTokenContinuation(identifier)?.resumeWithException(t) poTokenContinuations.remove(identifier)?.resumeWithException(t)
return return
} }
Log.d(TAG, "Generated poToken: identifier=$identifier poToken=$poToken") if (BuildConfig.DEBUG) {
popPoTokenContinuation(identifier)?.resume(poToken) Log.d(TAG, "Generated poToken: identifier=$identifier poToken=$poToken")
}
poTokenContinuations.remove(identifier)?.resume(poToken)
} }
val isExpired: Boolean fun isExpired(): Boolean {
get() = Instant.now().isAfter(expirationInstant) return Instant.now().isAfter(expirationInstant)
//endregion
//region Handling multiple emitters
/**
* Adds the ([identifier], [continuation]) pair to the [poTokenContinuations] list. This makes
* it so that multiple poToken requests can be generated in parallel, and the results will be
* notified to the right continuations.
*/
private fun addPoTokenEmitter(identifier: String, continuation: Continuation<String>) {
poTokenContinuations[identifier] = continuation
}
/**
* Extracts and removes from the [poTokenContinuations] list a [Continuation] based on its
* [identifier]. The continuation is supposed to be used immediately after to either signal a
* success or an error.
*/
private fun popPoTokenContinuation(identifier: String): Continuation<String>? {
return poTokenContinuations.remove(identifier)
}
/**
* Clears [poTokenContinuations] and returns its previous contents. The continuations are supposed
* to be used immediately after to either signal a success or an error.
*/
private fun popAllPoTokenContinuations(): Map<String, Continuation<String>> {
val result = poTokenContinuations.toMap()
poTokenContinuations.clear()
return result
} }
//endregion //endregion
//region Utils //region Utils
/** /**
* Makes a POST request to [url] with the given [data] by setting the correct headers. Calls * Makes a POST request to [url] with the given [data] by setting the correct headers.
* [onInitializationErrorCloseAndCancel] in case of any network errors and also if the response * This is supposed to be used only during initialization. Returns the response body
* does not have HTTP code 200, therefore this is supposed to be used only during * as a String if the response is successful.
* initialization. Calls [handleResponseBody] with the response body if the response is
* successful. The request is performed in the background and a disposable is added to
* [disposables].
*/ */
private fun makeBotguardServiceRequest( private suspend fun makeBotguardServiceRequest(url: String, data: List<String>): String = withContext(Dispatchers.IO) {
url: String, val requestBuilder = okhttp3.Request.Builder()
data: String, .post(Gson().toJson(data).toRequestBody())
handleResponseBody: (String) -> Unit, .headers(mapOf(
) { "User-Agent" to USER_AGENT,
scope.launch(exceptionHandler) { "Accept" to "application/json",
val requestBuilder = okhttp3.Request.Builder() "Content-Type" to "application/json+protobuf",
.post(data.toRequestBody()) "x-goog-api-key" to GOOGLE_API_KEY,
.headers(mapOf( "x-user-agent" to "grpc-web-javascript/0.1",
"User-Agent" to USER_AGENT, ).toHeaders())
"Accept" to "application/json", .url(url)
"Content-Type" to "application/json+protobuf", val response = withContext(Dispatchers.IO) {
"x-goog-api-key" to GOOGLE_API_KEY, httpClient.newCall(requestBuilder.build()).execute()
"x-user-agent" to "grpc-web-javascript/0.1", }
).toHeaders()) val httpCode = response.code
.url(url) if (httpCode != 200) {
val response = withContext(Dispatchers.IO) { throw Exception("Invalid response code: $httpCode")
httpClient.newCall(requestBuilder.build()).execute() } else {
} val body = withContext(Dispatchers.IO) {
val httpCode = response.code response.body.string()
if (httpCode != 200) {
onInitializationErrorCloseAndCancel(Exception("Invalid response code: $httpCode"))
} else {
val body = withContext(Dispatchers.IO) {
response.body.string()
}
handleResponseBody(body)
} }
body
} }
} }
/** /**
* Handles any error happening during initialization, releasing resources and sending the error * Handles any error happening during initialization, releasing resources and sending the error
* to [continuation]. * to [generatorContinuation].
*/ */
private fun onInitializationErrorCloseAndCancel(error: Throwable) { private fun onInitializationError(error: Throwable) {
close() CoroutineScope(Dispatchers.Main).launch {
continuation.resumeWithException(error) close()
generatorContinuation.resumeWithException(error)
}
} }
/** /**
* Releases all [webView] resources. * Releases all [webView] resources.
*/ */
@MainThread @MainThread
fun close() { fun close() = with(webView) {
scope.cancel() clearHistory()
webView.clearHistory()
// clears RAM cache and disk cache (globally for all WebViews) // clears RAM cache and disk cache (globally for all WebViews)
webView.clearCache(true) clearCache(true)
// ensures that the WebView isn't doing anything when destroying it // ensures that the WebView isn't doing anything when destroying it
webView.loadUrl("about:blank") loadUrl("about:blank")
webView.onPause() onPause()
webView.removeAllViews() removeAllViews()
webView.destroy() destroy()
} }
//endregion //endregion
@ -336,7 +317,7 @@ class PoTokenWebView private constructor(
return withContext(Dispatchers.Main) { return withContext(Dispatchers.Main) {
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
val potWv = PoTokenWebView(context, cont) val potWv = PoTokenWebView(context, cont)
potWv.loadHtmlAndObtainBotguard() potWv.loadHtmlAndObtainBotguard(context)
} }
} }
} }

View file

@ -61,27 +61,6 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<TextView
android:id="@+id/urlRegex"
style="@style/Widget.Material3.FloatingActionButton.Large.Secondary"
android:layout_width="wrap_content"
android:visibility="gone"
android:layout_height="wrap_content"
android:text="URL Regex"
android:background="@drawable/rounded_corner"
android:backgroundTint="?attr/colorAccent"
android:clickable="false"
android:gravity="center"
android:minWidth="30dp"
android:paddingHorizontal="5dp"
android:textSize="12sp"
android:textStyle="bold"
app:cornerRadius="10dp"
app:layout_constraintTop_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:ignore="HardcodedText" />
</com.google.android.material.chip.ChipGroup> </com.google.android.material.chip.ChipGroup>