mirror of
https://github.com/libre-tube/LibreTube.git
synced 2025-04-28 16:00:31 +05:30
feat(StreamsExtractor): generate PoToken
Implements support for locally generating PoTokens using the device webview. This is a direct port of https://github.com/TeamNewPipe/NewPipe/pull/11955 to native Kotlin. Closes: https://github.com/libre-tube/LibreTube/issues/7065
This commit is contained in:
parent
7b0d4caf31
commit
d927dffce4
@ -134,6 +134,7 @@ dependencies {
|
|||||||
/* NewPipe Extractor */
|
/* NewPipe Extractor */
|
||||||
implementation(libs.newpipeextractor)
|
implementation(libs.newpipeextractor)
|
||||||
|
|
||||||
|
|
||||||
/* Coil */
|
/* Coil */
|
||||||
coreLibraryDesugaring(libs.desugaring)
|
coreLibraryDesugaring(libs.desugaring)
|
||||||
implementation(libs.coil)
|
implementation(libs.coil)
|
||||||
|
127
app/src/main/assets/po_token.html
vendored
Normal file
127
app/src/main/assets/po_token.html
vendored
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en"><head><title></title><script>
|
||||||
|
/**
|
||||||
|
* Factory method to create and load a BotGuardClient instance.
|
||||||
|
* @param options - Configuration options for the BotGuardClient.
|
||||||
|
* @returns A promise that resolves to a loaded BotGuardClient instance.
|
||||||
|
*/
|
||||||
|
function loadBotGuard(challengeData) {
|
||||||
|
this.vm = this[challengeData.globalName];
|
||||||
|
this.program = challengeData.program;
|
||||||
|
this.vmFunctions = {};
|
||||||
|
this.syncSnapshotFunction = null;
|
||||||
|
|
||||||
|
if (!this.vm)
|
||||||
|
throw new Error('[BotGuardClient]: VM not found in the global object');
|
||||||
|
|
||||||
|
if (!this.vm.a)
|
||||||
|
throw new Error('[BotGuardClient]: Could not load program');
|
||||||
|
|
||||||
|
const vmFunctionsCallback = function (
|
||||||
|
asyncSnapshotFunction,
|
||||||
|
shutdownFunction,
|
||||||
|
passEventFunction,
|
||||||
|
checkCameraFunction
|
||||||
|
) {
|
||||||
|
this.vmFunctions = {
|
||||||
|
asyncSnapshotFunction: asyncSnapshotFunction,
|
||||||
|
shutdownFunction: shutdownFunction,
|
||||||
|
passEventFunction: passEventFunction,
|
||||||
|
checkCameraFunction: checkCameraFunction
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
this.syncSnapshotFunction = this.vm.a(this.program, vmFunctionsCallback, true, this.userInteractionElement, function () {/** no-op */ }, [ [], [] ])[0]
|
||||||
|
|
||||||
|
// an asynchronous function runs in the background and it will eventually call
|
||||||
|
// `vmFunctionsCallback`, however we need to manually tell JavaScript to pass
|
||||||
|
// control to the things running in the background by interrupting this async
|
||||||
|
// function in any way, e.g. with a delay of 1ms. The loop is most probably not
|
||||||
|
// needed but is there just because.
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
i = 0
|
||||||
|
refreshIntervalId = setInterval(function () {
|
||||||
|
if (!!this.vmFunctions.asyncSnapshotFunction) {
|
||||||
|
resolve(this)
|
||||||
|
clearInterval(refreshIntervalId);
|
||||||
|
}
|
||||||
|
if (i >= 10000) {
|
||||||
|
reject("asyncSnapshotFunction is null even after 10 seconds")
|
||||||
|
clearInterval(refreshIntervalId);
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}, 1);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a snapshot asynchronously.
|
||||||
|
* @returns The snapshot result.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const result = await botguard.snapshot({
|
||||||
|
* contentBinding: {
|
||||||
|
* c: "a=6&a2=10&b=SZWDwKVIuixOp7Y4euGTgwckbJA&c=1729143849&d=1&t=7200&c1a=1&c6a=1&c6b=1&hh=HrMb5mRWTyxGJphDr0nW2Oxonh0_wl2BDqWuLHyeKLo",
|
||||||
|
* e: "ENGAGEMENT_TYPE_VIDEO_LIKE",
|
||||||
|
* encryptedVideoId: "P-vC09ZJcnM"
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* console.log(result);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
function snapshot(args) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
if (!this.vmFunctions.asyncSnapshotFunction)
|
||||||
|
return reject(new Error('[BotGuardClient]: Async snapshot function not found'));
|
||||||
|
|
||||||
|
this.vmFunctions.asyncSnapshotFunction(function (response) { resolve(response) }, [
|
||||||
|
args.contentBinding,
|
||||||
|
args.signedTimestamp,
|
||||||
|
args.webPoSignalOutput,
|
||||||
|
args.skipPrivacyBuffer
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runBotGuard(challengeData) {
|
||||||
|
const interpreterJavascript = challengeData.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue;
|
||||||
|
|
||||||
|
if (interpreterJavascript) {
|
||||||
|
new Function(interpreterJavascript)();
|
||||||
|
} else throw new Error('Could not load VM');
|
||||||
|
|
||||||
|
const webPoSignalOutput = [];
|
||||||
|
return loadBotGuard({
|
||||||
|
globalName: challengeData.globalName,
|
||||||
|
globalObj: this,
|
||||||
|
program: challengeData.program
|
||||||
|
}).then(function (botguard) {
|
||||||
|
return botguard.snapshot({ webPoSignalOutput: webPoSignalOutput })
|
||||||
|
}).then(function (botguardResponse) {
|
||||||
|
return { webPoSignalOutput: webPoSignalOutput, botguardResponse: botguardResponse }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function obtainPoToken(webPoSignalOutput, integrityToken, identifier) {
|
||||||
|
const getMinter = webPoSignalOutput[0];
|
||||||
|
|
||||||
|
if (!getMinter)
|
||||||
|
throw new Error('PMD:Undefined');
|
||||||
|
|
||||||
|
const mintCallback = getMinter(integrityToken);
|
||||||
|
|
||||||
|
if (!(mintCallback instanceof Function))
|
||||||
|
throw new Error('APF:Failed');
|
||||||
|
|
||||||
|
const result = mintCallback(identifier);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
throw new Error('YNJ:Undefined');
|
||||||
|
|
||||||
|
if (!(result instanceof Uint8Array))
|
||||||
|
throw new Error('ODM:Invalid');
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
</script></head><body></body></html>
|
@ -8,8 +8,10 @@ import com.github.libretube.api.obj.SegmentData
|
|||||||
import com.github.libretube.api.obj.SubmitSegmentResponse
|
import com.github.libretube.api.obj.SubmitSegmentResponse
|
||||||
import com.github.libretube.api.obj.VoteInfo
|
import com.github.libretube.api.obj.VoteInfo
|
||||||
import com.github.libretube.obj.update.UpdateInfo
|
import com.github.libretube.obj.update.UpdateInfo
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Headers
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
@ -18,6 +20,8 @@ import retrofit2.http.Url
|
|||||||
private const val GITHUB_API_URL = "https://api.github.com/repos/libre-tube/LibreTube/releases/latest"
|
private const val GITHUB_API_URL = "https://api.github.com/repos/libre-tube/LibreTube/releases/latest"
|
||||||
private const val SB_API_URL = "https://sponsor.ajay.app"
|
private const val SB_API_URL = "https://sponsor.ajay.app"
|
||||||
private const val RYD_API_URL = "https://returnyoutubedislikeapi.com"
|
private const val RYD_API_URL = "https://returnyoutubedislikeapi.com"
|
||||||
|
private const val GOOGLE_API_KEY = "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw"
|
||||||
|
const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.3"
|
||||||
|
|
||||||
interface ExternalApi {
|
interface ExternalApi {
|
||||||
// only for fetching servers list
|
// only for fetching servers list
|
||||||
@ -68,4 +72,17 @@ interface ExternalApi {
|
|||||||
|
|
||||||
@GET("$SB_API_URL/api/branding/{videoId}")
|
@GET("$SB_API_URL/api/branding/{videoId}")
|
||||||
suspend fun getDeArrowContent(@Path("videoId") videoId: String): Map<String, DeArrowContent>
|
suspend fun getDeArrowContent(@Path("videoId") videoId: String): Map<String, DeArrowContent>
|
||||||
|
|
||||||
|
@Headers(
|
||||||
|
"User-Agent: $USER_AGENT",
|
||||||
|
"Accept: application/json",
|
||||||
|
"Content-Type: application/json+protobuf",
|
||||||
|
"x-goog-api-key: $GOOGLE_API_KEY",
|
||||||
|
"x-user-agent: grpc-web-javascript/0.1",
|
||||||
|
)
|
||||||
|
@POST
|
||||||
|
suspend fun botguardRequest(
|
||||||
|
@Url url: String,
|
||||||
|
@Body jsonPayload: List<String>
|
||||||
|
): JsonElement
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package com.github.libretube.api
|
package com.github.libretube.api
|
||||||
|
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import com.github.libretube.api.poToken.PoTokenGenerator
|
||||||
import com.github.libretube.api.obj.Channel
|
import com.github.libretube.api.obj.Channel
|
||||||
import com.github.libretube.api.obj.ChannelTab
|
import com.github.libretube.api.obj.ChannelTab
|
||||||
import com.github.libretube.api.obj.ChannelTabResponse
|
import com.github.libretube.api.obj.ChannelTabResponse
|
||||||
@ -47,6 +48,7 @@ import org.schabi.newpipe.extractor.localization.ContentCountry
|
|||||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfo
|
import org.schabi.newpipe.extractor.playlist.PlaylistInfo
|
||||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
|
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
|
||||||
import org.schabi.newpipe.extractor.search.SearchInfo
|
import org.schabi.newpipe.extractor.search.SearchInfo
|
||||||
|
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor
|
||||||
import org.schabi.newpipe.extractor.stream.AudioStream
|
import org.schabi.newpipe.extractor.stream.AudioStream
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfo
|
import org.schabi.newpipe.extractor.stream.StreamInfo
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
@ -237,6 +239,11 @@ fun String.toListLinkHandler() = with(JsonHelper.json.decodeFromString<TabData>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
class NewPipeMediaServiceRepository : MediaServiceRepository {
|
class NewPipeMediaServiceRepository : MediaServiceRepository {
|
||||||
|
|
||||||
|
init {
|
||||||
|
YoutubeStreamExtractor.setPoTokenProvider(PoTokenGenerator());
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getTrending(region: String): List<StreamItem> {
|
override suspend fun getTrending(region: String): List<StreamItem> {
|
||||||
val kioskList = NewPipeExtractorInstance.extractor.kioskList
|
val kioskList = NewPipeExtractorInstance.extractor.kioskList
|
||||||
kioskList.forceContentCountry(ContentCountry(region))
|
kioskList.forceContentCountry(ContentCountry(region))
|
||||||
|
@ -0,0 +1,133 @@
|
|||||||
|
package com.github.libretube.api.poToken
|
||||||
|
|
||||||
|
import okio.ByteString.Companion.decodeBase64
|
||||||
|
import okio.ByteString.Companion.toByteString
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the raw challenge data obtained from the Create endpoint and returns an object that can be
|
||||||
|
* embedded in a JavaScript snippet.
|
||||||
|
*/
|
||||||
|
fun parseChallengeData(rawChallengeData: String): String {
|
||||||
|
val scrambled = Json.parseToJsonElement(rawChallengeData).jsonArray
|
||||||
|
|
||||||
|
val challengeData = if (scrambled.size > 1 && scrambled[1].jsonPrimitive.isString) {
|
||||||
|
val descrambled = descramble(scrambled[1].jsonPrimitive.content)
|
||||||
|
Json.parseToJsonElement(descrambled).jsonArray
|
||||||
|
} else {
|
||||||
|
scrambled[0].jsonArray
|
||||||
|
}
|
||||||
|
|
||||||
|
val messageId = challengeData[0].jsonPrimitive.content
|
||||||
|
val interpreterHash = challengeData[3].jsonPrimitive.content
|
||||||
|
val program = challengeData[4].jsonPrimitive.content
|
||||||
|
val globalName = challengeData[5].jsonPrimitive.content
|
||||||
|
val clientExperimentsStateBlob = challengeData[7].jsonPrimitive.content
|
||||||
|
|
||||||
|
|
||||||
|
val privateDoNotAccessOrElseSafeScriptWrappedValue = challengeData[1]
|
||||||
|
.takeIf { it !is JsonNull }
|
||||||
|
?.jsonArray
|
||||||
|
?.find { it.jsonPrimitive.isString }
|
||||||
|
|
||||||
|
val privateDoNotAccessOrElseTrustedResourceUrlWrappedValue = challengeData[2]
|
||||||
|
.takeIf { it !is JsonNull }
|
||||||
|
?.jsonArray
|
||||||
|
?.find { it.jsonPrimitive.isString }
|
||||||
|
|
||||||
|
|
||||||
|
return Json.encodeToString(
|
||||||
|
JsonObject.serializer(), JsonObject(
|
||||||
|
mapOf(
|
||||||
|
"messageId" to JsonPrimitive(messageId),
|
||||||
|
"interpreterJavascript" to JsonObject(
|
||||||
|
mapOf(
|
||||||
|
"privateDoNotAccessOrElseSafeScriptWrappedValue" to (privateDoNotAccessOrElseSafeScriptWrappedValue
|
||||||
|
?: JsonNull),
|
||||||
|
"privateDoNotAccessOrElseTrustedResourceUrlWrappedValue" to (privateDoNotAccessOrElseTrustedResourceUrlWrappedValue
|
||||||
|
?: JsonNull)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"interpreterHash" to JsonPrimitive(interpreterHash),
|
||||||
|
"program" to JsonPrimitive(program),
|
||||||
|
"globalName" to JsonPrimitive(globalName),
|
||||||
|
"clientExperimentsStateBlob" to JsonPrimitive(clientExperimentsStateBlob)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the raw integrity token data obtained from the GenerateIT endpoint to a JavaScript
|
||||||
|
* `Uint8Array` that can be embedded directly in JavaScript code, and an [Int] representing the
|
||||||
|
* duration of this token in seconds.
|
||||||
|
*/
|
||||||
|
fun parseIntegrityTokenData(rawIntegrityTokenData: String): Pair<String, Long> {
|
||||||
|
val integrityTokenData = Json.parseToJsonElement(rawIntegrityTokenData).jsonArray
|
||||||
|
return base64ToU8(integrityTokenData[0].jsonPrimitive.content) to integrityTokenData[1].jsonPrimitive.long
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a string (usually the identifier used as input to `obtainPoToken`) to a JavaScript
|
||||||
|
* `Uint8Array` that can be embedded directly in JavaScript code.
|
||||||
|
*/
|
||||||
|
fun stringToU8(identifier: String): String {
|
||||||
|
return newUint8Array(identifier.toByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a poToken encoded as a sequence of bytes represented as integers separated by commas
|
||||||
|
* (e.g. "97,98,99" would be "abc"), which is the output of `Uint8Array::toString()` in JavaScript,
|
||||||
|
* and converts it to the specific base64 representation for poTokens.
|
||||||
|
*/
|
||||||
|
fun u8ToBase64(poToken: String): String {
|
||||||
|
return poToken.split(",")
|
||||||
|
.map { it.toUByte().toByte() }
|
||||||
|
.toByteArray()
|
||||||
|
.toByteString()
|
||||||
|
.base64()
|
||||||
|
.replace("+", "-")
|
||||||
|
.replace("/", "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes the scrambled challenge, decodes it from base64, adds 97 to each byte.
|
||||||
|
*/
|
||||||
|
private fun descramble(scrambledChallenge: String): String {
|
||||||
|
return base64ToByteString(scrambledChallenge)
|
||||||
|
.map { (it + 97).toByte() }
|
||||||
|
.toByteArray()
|
||||||
|
.decodeToString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a base64 string encoded in the specific base64 representation used by YouTube, and
|
||||||
|
* returns a JavaScript `Uint8Array` that can be embedded directly in JavaScript code.
|
||||||
|
*/
|
||||||
|
private fun base64ToU8(base64: String): String {
|
||||||
|
return newUint8Array(base64ToByteString(base64))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newUint8Array(contents: ByteArray): String {
|
||||||
|
return "new Uint8Array([" + contents.joinToString(separator = ",") { it.toUByte().toString() } + "])"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a base64 string encoded in the specific base64 representation used by YouTube.
|
||||||
|
*/
|
||||||
|
private fun base64ToByteString(base64: String): ByteArray {
|
||||||
|
val base64Mod = base64
|
||||||
|
.replace('-', '+')
|
||||||
|
.replace('_', '/')
|
||||||
|
.replace('.', '=')
|
||||||
|
|
||||||
|
return (base64Mod.decodeBase64() ?: throw PoTokenException("Cannot base64 decode"))
|
||||||
|
.toByteArray()
|
||||||
|
}
|
@ -0,0 +1,123 @@
|
|||||||
|
package com.github.libretube.api.poToken
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import android.webkit.CookieManager
|
||||||
|
import com.github.libretube.BuildConfig
|
||||||
|
import com.github.libretube.LibreTubeApp
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.schabi.newpipe.extractor.NewPipe
|
||||||
|
import org.schabi.newpipe.extractor.services.youtube.InnertubeClientRequestInfo
|
||||||
|
import org.schabi.newpipe.extractor.services.youtube.PoTokenProvider
|
||||||
|
import org.schabi.newpipe.extractor.services.youtube.PoTokenResult
|
||||||
|
import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper
|
||||||
|
|
||||||
|
class PoTokenGenerator : PoTokenProvider {
|
||||||
|
private val TAG = PoTokenGenerator::class.simpleName
|
||||||
|
private val supportsWebView by lazy { runCatching { CookieManager.getInstance() }.isSuccess }
|
||||||
|
|
||||||
|
private object WebPoTokenGenLock
|
||||||
|
private var webPoTokenVisitorData: String? = null
|
||||||
|
private var webPoTokenStreamingPot: String? = null
|
||||||
|
private var webPoTokenGenerator: PoTokenWebView? = null
|
||||||
|
|
||||||
|
|
||||||
|
override fun getWebClientPoToken(videoId: String): PoTokenResult? {
|
||||||
|
if (!supportsWebView) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return getWebClientPoToken(videoId, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param forceRecreate whether to force the recreation of [webPoTokenGenerator], to be used in
|
||||||
|
* case the current [webPoTokenGenerator] threw an error last time
|
||||||
|
* [PoTokenGenerator.getWebClientPoToken] was called
|
||||||
|
*/
|
||||||
|
private fun getWebClientPoToken(videoId: String, forceRecreate: Boolean): PoTokenResult {
|
||||||
|
// just a helper class since Kotlin does not have builtin support for 4-tuples
|
||||||
|
data class Quadruple<T1, T2, T3, T4>(val t1: T1, val t2: T2, val t3: T3, val t4: T4)
|
||||||
|
|
||||||
|
val (poTokenGenerator, visitorData, streamingPot, hasBeenRecreated) =
|
||||||
|
synchronized(WebPoTokenGenLock) {
|
||||||
|
val shouldRecreate = webPoTokenGenerator == null || forceRecreate || webPoTokenGenerator!!.isExpired()
|
||||||
|
|
||||||
|
if (shouldRecreate) {
|
||||||
|
val innertubeClientRequestInfo = InnertubeClientRequestInfo.ofWebClient()
|
||||||
|
innertubeClientRequestInfo.clientInfo.clientVersion =
|
||||||
|
YoutubeParsingHelper.getClientVersion()
|
||||||
|
|
||||||
|
webPoTokenVisitorData = YoutubeParsingHelper.getVisitorDataFromInnertube(
|
||||||
|
innertubeClientRequestInfo,
|
||||||
|
NewPipe.getPreferredLocalization(),
|
||||||
|
NewPipe.getPreferredContentCountry(),
|
||||||
|
YoutubeParsingHelper.getYouTubeHeaders(),
|
||||||
|
YoutubeParsingHelper.YOUTUBEI_V1_URL,
|
||||||
|
null,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
// close the current webPoTokenGenerator on the main thread
|
||||||
|
webPoTokenGenerator?.let { Handler(Looper.getMainLooper()).post { it.close() } }
|
||||||
|
|
||||||
|
// create a new webPoTokenGenerator
|
||||||
|
webPoTokenGenerator = PoTokenWebView
|
||||||
|
.newPoTokenGenerator(LibreTubeApp.instance)
|
||||||
|
|
||||||
|
// The streaming poToken needs to be generated exactly once before generating
|
||||||
|
// any other (player) tokens.
|
||||||
|
webPoTokenStreamingPot = webPoTokenGenerator!!.generatePoToken(webPoTokenVisitorData!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return@synchronized Quadruple(
|
||||||
|
webPoTokenGenerator!!,
|
||||||
|
webPoTokenVisitorData!!,
|
||||||
|
webPoTokenStreamingPot!!,
|
||||||
|
shouldRecreate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val playerPot = try {
|
||||||
|
// Not using synchronized here, since poTokenGenerator would be able to generate
|
||||||
|
// multiple poTokens in parallel if needed. The only important thing is for exactly one
|
||||||
|
// visitorData/streaming poToken to be generated before anything else.
|
||||||
|
runBlocking {
|
||||||
|
poTokenGenerator.generatePoToken(videoId)
|
||||||
|
}
|
||||||
|
} catch (throwable: Throwable) {
|
||||||
|
if (hasBeenRecreated) {
|
||||||
|
// the poTokenGenerator has just been recreated (and possibly this is already the
|
||||||
|
// second time we try), so there is likely nothing we can do
|
||||||
|
throw throwable
|
||||||
|
} else {
|
||||||
|
// retry, this time recreating the [webPoTokenGenerator] from scratch;
|
||||||
|
// this might happen for example if NewPipe goes in the background and the WebView
|
||||||
|
// content is lost
|
||||||
|
Log.e(TAG, "Failed to obtain poToken, retrying", throwable)
|
||||||
|
return getWebClientPoToken(videoId = videoId, forceRecreate = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"poToken for $videoId: playerPot=$playerPot, " +
|
||||||
|
"streamingPot=$streamingPot, visitor_data=$visitorData"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return PoTokenResult(visitorData, playerPot, streamingPot)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getWebEmbedClientPoToken(videoId: String?): PoTokenResult? = null
|
||||||
|
|
||||||
|
override fun getAndroidClientPoToken(videoId: String?): PoTokenResult? = null
|
||||||
|
|
||||||
|
override fun getIosClientPoToken(videoId: String?): PoTokenResult? = null
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,279 @@
|
|||||||
|
package com.github.libretube.api.poToken
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.annotation.MainThread
|
||||||
|
import com.github.libretube.BuildConfig
|
||||||
|
import com.github.libretube.api.RetrofitInstance
|
||||||
|
import com.github.libretube.api.USER_AGENT
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import java.time.Instant
|
||||||
|
import kotlin.coroutines.Continuation
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
|
class PoTokenWebView private constructor(
|
||||||
|
context: Context,
|
||||||
|
private val generatorContinuation: Continuation<PoTokenWebView>
|
||||||
|
) {
|
||||||
|
private val webView = WebView(context)
|
||||||
|
private val poTokenContinuations = mutableMapOf<String, Continuation<String>>()
|
||||||
|
private val exceptionHandler = CoroutineExceptionHandler { context, exception ->
|
||||||
|
onInitializationError(exception)
|
||||||
|
}
|
||||||
|
private lateinit var expirationInstant: Instant
|
||||||
|
|
||||||
|
//region Initialization
|
||||||
|
init {
|
||||||
|
webView.settings.apply {
|
||||||
|
//noinspection SetJavaScriptEnabled we want to use JavaScript!
|
||||||
|
javaScriptEnabled = true
|
||||||
|
safeBrowsingEnabled = false
|
||||||
|
userAgentString = USER_AGENT
|
||||||
|
blockNetworkLoads = true // the WebView does not need internet access
|
||||||
|
}
|
||||||
|
|
||||||
|
// so that we can run async functions and get back the result
|
||||||
|
webView.addJavascriptInterface(this, JS_INTERFACE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Must be called right after instantiating [PoTokenWebView] to perform the actual
|
||||||
|
* initialization. This will asynchronously go through all the steps needed to load BotGuard,
|
||||||
|
* run it, and obtain an `integrityToken`.
|
||||||
|
*/
|
||||||
|
private fun loadHtmlAndObtainBotguard(context: Context) {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.d(TAG, "loadHtmlAndObtainBotguard() called")
|
||||||
|
}
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch(exceptionHandler) {
|
||||||
|
try {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called during initialization by the JavaScript snippet appended to the HTML page content in
|
||||||
|
* [loadHtmlAndObtainBotguard] after the WebView content has been loaded.
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
fun downloadAndRunBotguard() {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.d(TAG, "downloadAndRunBotguard() called")
|
||||||
|
}
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch(exceptionHandler) {
|
||||||
|
val responseBody = makeBotguardServiceRequest(
|
||||||
|
"https://www.youtube.com/api/jnn/v1/Create",
|
||||||
|
listOf(REQUEST_KEY)
|
||||||
|
)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called during initialization by the JavaScript snippets from either
|
||||||
|
* [downloadAndRunBotguard] or [onRunBotguardResult].
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onJsInitializationError(error: String) {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.e(TAG, "Initialization error from JavaScript: $error")
|
||||||
|
}
|
||||||
|
onInitializationError(PoTokenException(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called during initialization by the JavaScript snippet from [downloadAndRunBotguard] after
|
||||||
|
* obtaining the BotGuard execution output [botguardResponse].
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onRunBotguardResult(botguardResponse: String) {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch(exceptionHandler) {
|
||||||
|
val response = makeBotguardServiceRequest(
|
||||||
|
"https://www.youtube.com/api/jnn/v1/GenerateIT",
|
||||||
|
listOf(REQUEST_KEY, botguardResponse)
|
||||||
|
)
|
||||||
|
val (integrityToken, expirationTimeInSeconds) = parseIntegrityTokenData(response)
|
||||||
|
|
||||||
|
// leave 10 minutes of margin just to be sure
|
||||||
|
expirationInstant = Instant.now().plusSeconds(expirationTimeInSeconds - 600)
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
webView.evaluateJavascript(
|
||||||
|
"this.integrityToken = $integrityToken"
|
||||||
|
) {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.d(TAG, "initialization finished, expiration=${expirationTimeInSeconds}s")
|
||||||
|
}
|
||||||
|
generatorContinuation.resume(this@PoTokenWebView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region Obtaining poTokens
|
||||||
|
suspend fun generatePoToken(identifier: String): String {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.d(TAG, "generatePoToken() called with identifier $identifier")
|
||||||
|
}
|
||||||
|
return suspendCancellableCoroutine { continuation ->
|
||||||
|
poTokenContinuations[identifier] = continuation
|
||||||
|
val u8Identifier = stringToU8(identifier)
|
||||||
|
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
webView.evaluateJavascript(
|
||||||
|
"""try {
|
||||||
|
identifier = "$identifier"
|
||||||
|
u8Identifier = $u8Identifier
|
||||||
|
poTokenU8 = obtainPoToken(webPoSignalOutput, integrityToken, u8Identifier)
|
||||||
|
poTokenU8String = ""
|
||||||
|
for (i = 0; i < poTokenU8.length; i++) {
|
||||||
|
if (i != 0) poTokenU8String += ","
|
||||||
|
poTokenU8String += poTokenU8[i]
|
||||||
|
}
|
||||||
|
$JS_INTERFACE.onObtainPoTokenResult(identifier, poTokenU8String)
|
||||||
|
} catch (error) {
|
||||||
|
$JS_INTERFACE.onObtainPoTokenError(identifier, error + "\n" + error.stack)
|
||||||
|
}""",
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the JavaScript snippet from [generatePoToken] when an error occurs in calling the
|
||||||
|
* JavaScript `obtainPoToken()` function.
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onObtainPoTokenError(identifier: String, error: String) {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.e(TAG, "obtainPoToken error from JavaScript: $error")
|
||||||
|
}
|
||||||
|
poTokenContinuations.remove(identifier)?.resumeWithException(PoTokenException(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the JavaScript snippet from [generatePoToken] with the original identifier and the
|
||||||
|
* result of the JavaScript `obtainPoToken()` function.
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onObtainPoTokenResult(identifier: String, poTokenU8: String) {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.d(TAG, "Generated poToken (before decoding): identifier=$identifier poTokenU8=$poTokenU8")
|
||||||
|
}
|
||||||
|
val poToken = try {
|
||||||
|
u8ToBase64(poTokenU8)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
poTokenContinuations.remove(identifier)?.resumeWithException(t)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.d(TAG, "Generated poToken: identifier=$identifier poToken=$poToken")
|
||||||
|
}
|
||||||
|
poTokenContinuations.remove(identifier)?.resume(poToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isExpired(): Boolean {
|
||||||
|
return Instant.now().isAfter(expirationInstant)
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region Utils
|
||||||
|
/**
|
||||||
|
* Makes a POST request to [url] with the given [data] by setting the correct headers.
|
||||||
|
* This is supposed to be used only during initialization. Returns the response body
|
||||||
|
* as a String if the response is successful.
|
||||||
|
*/
|
||||||
|
private suspend fun makeBotguardServiceRequest(url: String, data: List<String>): String = withContext(Dispatchers.IO) {
|
||||||
|
val response = RetrofitInstance.externalApi.botguardRequest(url, data)
|
||||||
|
response.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles any error happening during initialization, releasing resources and sending the error
|
||||||
|
* to [generatorContinuation].
|
||||||
|
*/
|
||||||
|
private fun onInitializationError(error: Throwable) {
|
||||||
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
|
close()
|
||||||
|
generatorContinuation.resumeWithException(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases all [webView] resources.
|
||||||
|
*/
|
||||||
|
@MainThread
|
||||||
|
fun close() = with(webView) {
|
||||||
|
clearHistory()
|
||||||
|
// clears RAM cache and disk cache (globally for all WebViews)
|
||||||
|
clearCache(true)
|
||||||
|
|
||||||
|
// ensures that the WebView isn't doing anything when destroying it
|
||||||
|
loadUrl("about:blank")
|
||||||
|
|
||||||
|
onPause()
|
||||||
|
removeAllViews()
|
||||||
|
destroy()
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = PoTokenWebView::class.simpleName
|
||||||
|
private const val REQUEST_KEY = "O43z0dpjhgX20SCx4KAo"
|
||||||
|
private val JS_INTERFACE = PoTokenWebView::class.simpleName!!
|
||||||
|
|
||||||
|
suspend fun newPoTokenGenerator(context: Context): PoTokenWebView {
|
||||||
|
return suspendCancellableCoroutine { continuation ->
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
val poTokenWebView = PoTokenWebView(context, continuation)
|
||||||
|
poTokenWebView.loadHtmlAndObtainBotguard(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PoTokenException(message: String) : Exception(message)
|
@ -60,7 +60,7 @@ androidx-media3-exoplayer-hls = { group = "androidx.media3", name="media3-exopla
|
|||||||
androidx-media3-exoplayer-dash = { group = "androidx.media3", name="media3-exoplayer-dash", version.ref="media3" }
|
androidx-media3-exoplayer-dash = { group = "androidx.media3", name="media3-exoplayer-dash", version.ref="media3" }
|
||||||
androidx-media3-session = { group="androidx.media3", name="media3-session", version.ref="media3" }
|
androidx-media3-session = { group="androidx.media3", name="media3-session", version.ref="media3" }
|
||||||
androidx-media3-ui = { group="androidx.media3", name="media3-ui", version.ref="media3" }
|
androidx-media3-ui = { group="androidx.media3", name="media3-ui", version.ref="media3" }
|
||||||
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }
|
newpipeextractor = { module = "com.github.TeamNewPipe:NewPipeExtractor", version.ref = "newpipeextractor" }
|
||||||
square-retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
square-retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
||||||
converter-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
|
converter-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
|
||||||
desugaring = { group = "com.android.tools", name = "desugar_jdk_libs_nio", version.ref = "desugaring" }
|
desugaring = { group = "com.android.tools", name = "desugar_jdk_libs_nio", version.ref = "desugaring" }
|
||||||
|
Loading…
x
Reference in New Issue
Block a user