Skip to content
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
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ dependencies {
/* NewPipe Extractor */
implementation(libs.newpipeextractor)


/* Coil */
coreLibraryDesugaring(libs.desugaring)
implementation(libs.coil)
Expand Down
127 changes: 127 additions & 0 deletions app/src/main/assets/po_token.html
Original file line number Diff line number Diff line change
@@ -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>
17 changes: 17 additions & 0 deletions app/src/main/java/com/github/libretube/api/ExternalApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import com.github.libretube.api.obj.SegmentData
import com.github.libretube.api.obj.SubmitSegmentResponse
import com.github.libretube.api.obj.VoteInfo
import com.github.libretube.obj.update.UpdateInfo
import kotlinx.serialization.json.JsonElement
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
Expand All @@ -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 SB_API_URL = "https://sponsor.ajay.app"
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 {
// only for fetching servers list
Expand Down Expand Up @@ -68,4 +72,17 @@ interface ExternalApi {

@GET("$SB_API_URL/api/branding/{videoId}")
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
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.github.libretube.api

import android.util.Base64
import com.github.libretube.api.poToken.PoTokenGenerator
import com.github.libretube.api.obj.Channel
import com.github.libretube.api.obj.ChannelTab
import com.github.libretube.api.obj.ChannelTabResponse
Expand Down Expand Up @@ -47,6 +48,7 @@ import org.schabi.newpipe.extractor.localization.ContentCountry
import org.schabi.newpipe.extractor.playlist.PlaylistInfo
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
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.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
Expand Down Expand Up @@ -237,6 +239,11 @@ fun String.toListLinkHandler() = with(JsonHelper.json.decodeFromString<TabData>(
}

class NewPipeMediaServiceRepository : MediaServiceRepository {

init {
YoutubeStreamExtractor.setPoTokenProvider(PoTokenGenerator());
}

override suspend fun getTrending(region: String): List<StreamItem> {
val kioskList = NewPipeExtractorInstance.extractor.kioskList
kioskList.forceContentCountry(ContentCountry(region))
Expand Down
133 changes: 133 additions & 0 deletions app/src/main/java/com/github/libretube/api/poToken/JavaScriptUtil.kt
Original file line number Diff line number Diff line change
@@ -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 }
Comment on lines +35 to +43
Copy link

@gechoto gechoto Mar 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since you access challengeData null-safe now
ae23ba3

this could also be applied to these two

but I don't think this is a perfect solution
because there are still other things which can go wrong and make the app crash

accessing something on challengeData out of bounds was only one example

there should be better error handling in general instead
maybe this whole parseChallengeData should be ran in try/catch

there is this NewPipe PR now which seems to handle errors better:
TeamNewPipe/NewPipe#12028



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()
}
Loading
Loading