修改播放失败。

This commit is contained in:
ocean 2025-06-12 14:20:19 +08:00
parent 3f6702d97c
commit 5006cf3e11
25 changed files with 1824 additions and 40 deletions

6
.idea/AndroidProjectSystem.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

17
.idea/runConfigurations.xml generated Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

View File

@ -76,6 +76,8 @@ dependencies {
//noinspection KaptUsageInsteadOfKsp
kapt("androidx.room:room-compiler:2.6.1")
implementation("androidx.datastore:datastore-preferences:1.1.4")
implementation("androidx.media3:media3-exoplayer:1.3.1")
implementation("androidx.media3:media3-exoplayer-dash:1.3.1")
implementation("androidx.media3:media3-ui:1.3.1")
@ -101,6 +103,8 @@ dependencies {
implementation("io.github.scwang90:refresh-footer-ball:2.1.0")
implementation("com.google.code.gson:gson:2.10.1")
implementation("com.github.libre-tube:NewPipeExtractor:bc9a5a220e")
implementation("com.google.android.ump:user-messaging-platform:3.0.0")
//fb

View File

@ -2,10 +2,11 @@ package melody.offline.music
import android.app.Application
import android.content.Context
import android.util.Log
import android.widget.Toast
import android.widget.Toast.LENGTH_SHORT
import androidx.annotation.OptIn
import androidx.datastore.preferences.core.edit
import androidx.media3.common.util.UnstableApi
import com.google.android.gms.ads.MobileAds
import com.lol.apex.ok.google.adlibrary.LoLAds
import com.lol.apex.ok.google.adlibrary.bean.constants.TestMode
import melody.offline.music.bean.Audio
@ -21,6 +22,9 @@ import melody.offline.music.util.DownloadUtil
import melody.offline.music.util.parseResources
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import melody.offline.music.database.AppFavoriteDBManager
@ -28,6 +32,10 @@ import melody.offline.music.database.AppPlaylistDBManager
import melody.offline.music.firebase.RemoteConfig
import melody.offline.music.http.CommonIpInfoUtil
import melody.offline.music.http.UploadEventName
import melody.offline.music.innertube.utils.VisitorDataKey
import melody.offline.music.playernew.YouTube
import melody.offline.music.playernew.dataStore
import melody.offline.music.innertube.utils.reportException
import melody.offline.music.sp.AppStore
import melody.offline.music.util.AnalysisUtil
import melody.offline.music.util.AppLifecycleHandler
@ -146,6 +154,26 @@ class App : Application() {
initImportAudio()
CacheManager.initializeCaches(this)
DownloadUtil.getDownloadManager(this)
GlobalScope.launch {
dataStore.data
.map { it[VisitorDataKey] }
.distinctUntilChanged()
.collect { visitorData ->
YouTube.visitorData = visitorData
?.takeIf { it != "null" } // Previously visitorData was sometimes saved as "null" due to a bug
?: YouTube.visitorData().onFailure {
withContext(Dispatchers.Main) {
Toast.makeText(this@App, "Failed to get visitorData.", LENGTH_SHORT).show()
}
reportException(it)
}.getOrNull()?.also { newVisitorData ->
dataStore.edit { settings ->
settings[VisitorDataKey] = newVisitorData
}
}
}
}
}
private fun initAd() {

View File

@ -0,0 +1,151 @@
package melody.offline.music.innertube
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.compression.ContentEncoding
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.get
import io.ktor.client.request.headers
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.userAgent
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import melody.offline.music.innertube.models.bodies.MyPlayerBody
import melody.offline.music.playernew.MyContext
import melody.offline.music.playernew.YouTubeClient
import melody.offline.music.playernew.YouTubeLocale
import melody.offline.music.innertube.utils.sha1
import java.net.Proxy
import java.util.Locale
/**
* Provide access to InnerTube endpoints.
* For making HTTP requests, not parsing response.
*/
class MyInnerTube {
fun parseCookieString(cookie: String): Map<String, String> =
cookie.split("; ")
.filter { it.isNotEmpty() }
.associate {
val (key, value) = it.split("=")
key to value
}
private var httpClient = createClient()
var locale = YouTubeLocale(
gl = Locale.getDefault().country,
hl = Locale.getDefault().toLanguageTag()
)
var visitorData: String? = null
var dataSyncId: String? = null
var cookie: String? = null
set(value) {
field = value
cookieMap = if (value == null) emptyMap() else parseCookieString(value)
}
private var cookieMap = emptyMap<String, String>()
var proxy: Proxy? = null
set(value) {
field = value
httpClient.close()
httpClient = createClient()
}
@OptIn(ExperimentalSerializationApi::class)
private fun createClient() = HttpClient(OkHttp) {
expectSuccess = true
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
})
}
install(ContentEncoding) {
gzip(0.9F)
deflate(0.8F)
}
if (proxy != null) {
engine {
proxy = this.proxy
}
}
defaultRequest {
url(YouTubeClient.API_URL_YOUTUBE_MUSIC)
}
}
private fun HttpRequestBuilder.ytClient(client: YouTubeClient, setLogin: Boolean = false) {
contentType(ContentType.Application.Json)
headers {
append("X-Goog-Api-Format-Version", "1")
append("X-YouTube-Client-Name", client.clientId /* Not a typo. The Client-Name header does contain the client id. */)
append("X-YouTube-Client-Version", client.clientVersion)
append("X-Origin", YouTubeClient.Companion.ORIGIN_YOUTUBE_MUSIC)
append("Referer", YouTubeClient.Companion.REFERER_YOUTUBE_MUSIC)
if (setLogin && client.loginSupported) {
cookie?.let { cookie ->
append("cookie", cookie)
if ("SAPISID" !in cookieMap) return@let
val currentTime = System.currentTimeMillis() / 1000
val sapisidHash = sha1("$currentTime ${cookieMap["SAPISID"]} ${YouTubeClient.Companion.ORIGIN_YOUTUBE_MUSIC}")
append("Authorization", "SAPISIDHASH ${currentTime}_${sapisidHash}")
}
}
}
userAgent(client.userAgent)
parameter("prettyPrint", false)
}
suspend fun player(
client: YouTubeClient,
videoId: String,
playlistId: String?,
signatureTimestamp: Int?,
webPlayerPot: String?,
) = httpClient.post("player") {
ytClient(client, setLogin = true)
setBody(
MyPlayerBody(
context = client.toContext(locale, visitorData, dataSyncId).let {
if (client.isEmbedded) {
it.copy(
thirdParty = MyContext.ThirdParty(
embedUrl = "https://www.youtube.com/watch?v=${videoId}"
)
)
} else it
},
videoId = videoId,
playlistId = playlistId,
playbackContext = if (client.useSignatureTimestamp && signatureTimestamp != null) {
MyPlayerBody.PlaybackContext(
MyPlayerBody.PlaybackContext.ContentPlaybackContext(
signatureTimestamp
)
)
} else null,
serviceIntegrityDimensions = if (client.useWebPoTokens && webPlayerPot != null) {
MyPlayerBody.ServiceIntegrityDimensions(webPlayerPot)
} else null
)
)
}
suspend fun getSwJsData() = httpClient.get("https://music.youtube.com/sw.js_data")
}

View File

@ -0,0 +1,108 @@
package melody.offline.music.innertube.models
import android.annotation.SuppressLint
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* PlayerResponse with [com.zionhuang.innertube.models.YouTubeClient.WEB_REMIX] client
*/
@SuppressLint("UnsafeOptInUsageError")
@Serializable
data class MyPlayerResponse(
val responseContext: MyResponseContext,
val playabilityStatus: PlayabilityStatus,
val playerConfig: PlayerConfig?,
val streamingData: StreamingData?,
val videoDetails: VideoDetails?,
@SerialName("playbackTracking")
val playbackTracking: PlaybackTracking?,
) {
@Serializable
data class PlayabilityStatus(
val status: String,
val reason: String?,
)
@Serializable
data class PlayerConfig(
val audioConfig: AudioConfig,
) {
@Serializable
data class AudioConfig(
val loudnessDb: Double?,
val perceptualLoudnessDb: Double?,
)
}
@Serializable
data class StreamingData(
val formats: List<Format>?,
val adaptiveFormats: List<Format>,
val expiresInSeconds: Int,
) {
@Serializable
data class Format(
val itag: Int,
val url: String?,
val mimeType: String,
val bitrate: Int,
val width: Int?,
val height: Int?,
val contentLength: Long?,
val quality: String,
val fps: Int?,
val qualityLabel: String?,
val averageBitrate: Int?,
val audioQuality: String?,
val approxDurationMs: String?,
val audioSampleRate: Int?,
val audioChannels: Int?,
val loudnessDb: Double?,
val lastModified: Long?,
val signatureCipher: String?,
) {
val isAudio: Boolean
get() = width == null
}
}
@Serializable
data class VideoDetails(
val videoId: String,
val title: String,
val author: String,
val channelId: String,
val lengthSeconds: String,
val musicVideoType: String?,
val viewCount: String,
val thumbnail: Thumbnails,
)
@Serializable
data class PlaybackTracking(
@SerialName("videostatsPlaybackUrl")
val videostatsPlaybackUrl: VideostatsPlaybackUrl?,
@SerialName("videostatsWatchtimeUrl")
val videostatsWatchtimeUrl: VideostatsWatchtimeUrl?,
@SerialName("atrUrl")
val atrUrl: AtrUrl?,
) {
@Serializable
data class VideostatsPlaybackUrl(
@SerialName("baseUrl")
val baseUrl: String?,
)
@Serializable
data class VideostatsWatchtimeUrl(
@SerialName("baseUrl")
val baseUrl: String?,
)
@Serializable
data class AtrUrl(
@SerialName("baseUrl")
val baseUrl: String?,
)
}
}

View File

@ -0,0 +1,21 @@
package melody.offline.music.innertube.models
import kotlinx.serialization.Serializable
@Serializable
data class MyResponseContext(
val visitorData: String?,
val serviceTrackingParams: List<ServiceTrackingParam>?,
) {
@Serializable
data class ServiceTrackingParam(
val params: List<Param>,
val service: String,
) {
@Serializable
data class Param(
val key: String,
val value: String,
)
}
}

View File

@ -0,0 +1,10 @@
package melody.offline.music.innertube.models
import android.annotation.SuppressLint
import kotlinx.serialization.Serializable
@SuppressLint("UnsafeOptInUsageError")
@Serializable
data class Thumbnails(
val thumbnails: List<Thumbnail>,
)

View File

@ -0,0 +1,32 @@
package melody.offline.music.innertube.models.bodies
import android.annotation.SuppressLint
import kotlinx.serialization.Serializable
import melody.offline.music.playernew.MyContext
@SuppressLint("UnsafeOptInUsageError")
@Serializable
data class MyPlayerBody(
val context: MyContext,
val videoId: String,
val playlistId: String?,
val playbackContext: PlaybackContext? = null,
val serviceIntegrityDimensions: ServiceIntegrityDimensions? = null,
val contentCheckOk: Boolean = true,
val racyCheckOk: Boolean = true,
) {
@Serializable
data class PlaybackContext(
val contentPlaybackContext: ContentPlaybackContext
) {
@Serializable
data class ContentPlaybackContext(
val signatureTimestamp: Int
)
}
@Serializable
data class ServiceIntegrityDimensions(
val poToken: String
)
}

View File

@ -1,5 +1,6 @@
package melody.offline.music.innertube.requests
import android.annotation.SuppressLint
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.post
@ -13,6 +14,7 @@ import melody.offline.music.innertube.models.bodies.PlayerBody
import melody.offline.music.innertube.utils.runCatchingNonCancellable
import kotlinx.serialization.Serializable
@SuppressLint("UnsafeOptInUsageError")
suspend fun Innertube.player(body: PlayerBody) = runCatchingNonCancellable {
val response = client.post(player) {
setBody(body)

View File

@ -1,8 +1,10 @@
package melody.offline.music.innertube.utils
import androidx.datastore.preferences.core.stringPreferencesKey
import io.ktor.utils.io.CancellationException
import melody.offline.music.innertube.Innertube
import melody.offline.music.innertube.models.SectionListRenderer
import java.security.MessageDigest
internal fun SectionListRenderer.findSectionByTitle(text: String): SectionListRenderer.Content? {
return contents?.find { content ->
@ -48,3 +50,12 @@ infix operator fun <T : Innertube.Item> Innertube.ItemsPage<T>?.plus(other: Inne
items = (this?.items?.plus(other.items ?: emptyList())
?: other.items)?.distinctBy(Innertube.Item::key)
)
fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }
fun sha1(str: String): String = MessageDigest.getInstance("SHA-1").digest(str.toByteArray()).toHex()
fun reportException(throwable: Throwable) {
throwable.printStackTrace()
}
val VisitorDataKey = stringPreferencesKey("visitorData")

View File

@ -0,0 +1,8 @@
package melody.offline.music.playernew
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "vo_settings")

View File

@ -0,0 +1,40 @@
package melody.offline.music.playernew
import android.annotation.SuppressLint
import kotlinx.serialization.Serializable
@SuppressLint("UnsafeOptInUsageError")
@Serializable
data class MyContext(
val client: Client,
val thirdParty: ThirdParty? = null,
private val request: Request = Request(),
private val user: User = User(),
) {
@Serializable
data class Client(
val clientName: String,
val clientVersion: String,
val osVersion: String?,
val gl: String,
val hl: String,
val visitorData: String?,
)
@Serializable
data class ThirdParty(
val embedUrl: String,
)
@Serializable
data class Request(
val internalExperimentFlags: Array<String> = emptyArray(),
val useSsl: Boolean = true,
)
@Serializable
data class User(
val lockedSafetyMode: Boolean = false,
val onBehalfOfUser: String? = null,
)
}

View File

@ -0,0 +1,100 @@
package melody.offline.music.playernew
import io.ktor.http.URLBuilder
import io.ktor.http.parseQueryString
import melody.offline.music.innertube.models.MyPlayerResponse
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import org.schabi.newpipe.extractor.downloader.Downloader
import org.schabi.newpipe.extractor.downloader.Request
import org.schabi.newpipe.extractor.downloader.Response
import org.schabi.newpipe.extractor.exceptions.ParsingException
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
import org.schabi.newpipe.extractor.services.youtube.YoutubeJavaScriptPlayerManager
import java.io.IOException
import java.net.Proxy
import kotlin.collections.forEach
import kotlin.jvm.Throws
import kotlin.let
import kotlin.runCatching
private class NewPipeDownloaderImpl(proxy: Proxy?) : Downloader() {
private val client = OkHttpClient.Builder()
.proxy(proxy)
.build()
@Throws(IOException::class, ReCaptchaException::class)
override fun execute(request: Request): Response {
val httpMethod = request.httpMethod()
val url = request.url()
val headers = request.headers()
val dataToSend = request.dataToSend()
val requestBuilder = okhttp3.Request.Builder()
.method(httpMethod, dataToSend?.toRequestBody())
.url(url)
.addHeader("User-Agent", YouTubeClient.USER_AGENT_WEB)
headers.forEach { (headerName, headerValueList) ->
if (headerValueList.size > 1) {
requestBuilder.removeHeader(headerName)
headerValueList.forEach { headerValue ->
requestBuilder.addHeader(headerName, headerValue)
}
} else if (headerValueList.size == 1) {
requestBuilder.header(headerName, headerValueList[0])
}
}
val response = client.newCall(requestBuilder.build()).execute()
if (response.code == 429) {
response.close()
throw ReCaptchaException("reCaptcha Challenge requested", url)
}
val responseBodyToReturn = response.body?.string()
val latestUrl = response.request.url.toString()
return Response(response.code, response.message, response.headers.toMultimap(), responseBodyToReturn, latestUrl)
}
}
object NewPipeUtils {
init {
// NewPipe.init(NewPipeDownloaderImpl(YouTube.proxy))
}
fun getSignatureTimestamp(videoId: String): Result<Int> = runCatching {
YoutubeJavaScriptPlayerManager.getSignatureTimestamp(videoId)
}
fun getStreamUrl(format: MyPlayerResponse.StreamingData.Format, videoId: String): Result<String> =
runCatching {
val url = format.url ?: format.signatureCipher?.let { signatureCipher ->
val params = parseQueryString(signatureCipher)
val obfuscatedSignature = params["s"]
?: throw ParsingException("Could not parse cipher signature")
val signatureParam = params["sp"]
?: throw ParsingException("Could not parse cipher signature parameter")
val url = params["url"]?.let { URLBuilder(it) }
?: throw ParsingException("Could not parse cipher url")
url.parameters[signatureParam] =
YoutubeJavaScriptPlayerManager.deobfuscateSignature(
videoId,
obfuscatedSignature
)
url.toString()
} ?: throw ParsingException("Could not find format url")
return@runCatching YoutubeJavaScriptPlayerManager.getUrlWithThrottlingParameterDeobfuscated(
videoId,
url
)
}
}

View File

@ -0,0 +1,242 @@
package melody.offline.music.playernew
import android.annotation.SuppressLint
import android.net.ConnectivityManager
import android.util.Log
import androidx.media3.common.PlaybackException
import melody.offline.music.innertube.models.MyPlayerResponse
import melody.offline.music.playernew.YouTubeClient.Companion.IOS
import melody.offline.music.playernew.YouTubeClient.Companion.TVHTML5_SIMPLY_EMBEDDED_PLAYER
import melody.offline.music.playernew.YouTubeClient.Companion.WEB_REMIX
import melody.offline.music.playernew.potoken.PoTokenGenerator
import melody.offline.music.playernew.potoken.PoTokenResult
import melody.offline.music.innertube.utils.reportException
import melody.offline.music.playernew.YTPlayerUtils.validateStatus
import melody.offline.music.playernew.constants.AudioQuality
import okhttp3.OkHttpClient
import okhttp3.Request
object YTPlayerUtils {
private const val TAG = "vo-YTPlayerUtils"
private val httpClient = OkHttpClient.Builder()
.proxy(YouTube.proxy)
.build()
private val poTokenGenerator = PoTokenGenerator()
private val MAIN_CLIENT: YouTubeClient = WEB_REMIX
private val STREAM_FALLBACK_CLIENTS: Array<YouTubeClient> = arrayOf(
TVHTML5_SIMPLY_EMBEDDED_PLAYER,
IOS,
)
data class PlaybackData(
val audioConfig: MyPlayerResponse.PlayerConfig.AudioConfig?,
val videoDetails: MyPlayerResponse.VideoDetails?,
val playbackTracking: MyPlayerResponse.PlaybackTracking?,
val format: MyPlayerResponse.StreamingData.Format,
val streamUrl: String,
val streamExpiresInSeconds: Int,
)
private fun getSignatureTimestampOrNull(
videoId: String
): Int? {
return NewPipeUtils.getSignatureTimestamp(videoId)
.onFailure {
reportException(it)
}
.getOrNull()
}
@SuppressLint("UnsafeOptInUsageError")
suspend fun playerResponseForPlayback(
videoId: String,
playlistId: String? = null,
connectivityManager: ConnectivityManager,
): Result<PlaybackData> = runCatching {
Log.d(TAG, "Playback info requested: $videoId")
val signatureTimestamp = getSignatureTimestampOrNull(videoId)
val isLoggedIn = false
val sessionId = YouTube.visitorData
Log.d(TAG, "[$videoId] signatureTimestamp: $signatureTimestamp, isLoggedIn: $isLoggedIn , YouTube.visitorData:${YouTube.visitorData}")
val (webPlayerPot, webStreamingPot) = getWebClientPoTokenOrNull(videoId, sessionId)?.let {
Pair(it.playerRequestPoToken, it.streamingDataPoToken)
} ?: Pair(null, null).also {
Log.w(TAG, "[$videoId] No po token")
}
val mainPlayerResponse =
YouTube.player(videoId, playlistId, MAIN_CLIENT, signatureTimestamp, webPlayerPot)
.getOrThrow()
val audioConfig = mainPlayerResponse.playerConfig?.audioConfig
val videoDetails = mainPlayerResponse.videoDetails
val playbackTracking = mainPlayerResponse.playbackTracking
var format: MyPlayerResponse.StreamingData.Format? = null
var streamUrl: String? = null
var streamExpiresInSeconds: Int? = null
var streamPlayerResponse: MyPlayerResponse? = null
for (clientIndex in (-1 until STREAM_FALLBACK_CLIENTS.size)) {
// reset for each client
format = null
streamUrl = null
streamExpiresInSeconds = null
// decide which client to use for streams and load its player response
val client: YouTubeClient
if (clientIndex == -1) {
// try with streams from main client first
client = MAIN_CLIENT
streamPlayerResponse = mainPlayerResponse
} else {
// after main client use fallback clients
client = STREAM_FALLBACK_CLIENTS[clientIndex]
if (client.loginRequired && !isLoggedIn) {
// skip client if it requires login but user is not logged in
continue
}
Log.d(TAG, "[$videoId] [$playlistId] [$client] [$signatureTimestamp] [$webPlayerPot]")
streamPlayerResponse =
YouTube.player(videoId, playlistId, client, signatureTimestamp, webPlayerPot)
.getOrNull()
}
Log.d(TAG, "[$videoId] stream client: ${client.clientName}, " +
"playabilityStatus: ${streamPlayerResponse?.playabilityStatus?.let {
it.status + (it.reason?.let { " - $it" } ?: "")
}}")
// process current client response
if (streamPlayerResponse?.playabilityStatus?.status == "OK") {
format =
findFormat(
streamPlayerResponse,
connectivityManager,
) ?: continue
streamUrl = findUrlOrNull(format, videoId) ?: continue
streamExpiresInSeconds =
streamPlayerResponse.streamingData?.expiresInSeconds ?: continue
if (client.useWebPoTokens && webStreamingPot != null) {
streamUrl += "&pot=$webStreamingPot";
}
if (clientIndex == STREAM_FALLBACK_CLIENTS.size - 1) {
/** skip [validateStatus] for last client */
break
}
if (validateStatus(streamUrl)) {
// working stream found
break
} else {
Log.d(TAG, "[$videoId] [${client.clientName}] got bad http status code")
}
}
}
if (streamPlayerResponse == null) {
throw Exception("Bad stream player response")
}
if (streamPlayerResponse.playabilityStatus.status != "OK") {
throw PlaybackException(
streamPlayerResponse.playabilityStatus.reason,
null,
PlaybackException.ERROR_CODE_REMOTE_ERROR
)
}
if (streamExpiresInSeconds == null) {
throw Exception("Missing stream expire time")
}
if (format == null) {
throw Exception("Could not find format")
}
if (streamUrl == null) {
throw Exception("Could not find stream url")
}
Log.d(TAG, "[$videoId] stream url: $streamUrl")
PlaybackData(
audioConfig,
videoDetails,
playbackTracking,
format,
streamUrl,
streamExpiresInSeconds,
)
}
private fun findFormat(
playerResponse: MyPlayerResponse,
connectivityManager: ConnectivityManager,
): MyPlayerResponse.StreamingData.Format? =
playerResponse.streamingData?.adaptiveFormats
?.filter { it.isAudio }
?.maxByOrNull {
it.bitrate * when (AudioQuality.AUTO) {
AudioQuality.AUTO -> if (connectivityManager.isActiveNetworkMetered) -1 else 1
AudioQuality.HIGH -> 1
AudioQuality.LOW -> -1
} + (if (it.mimeType.startsWith("audio/webm")) 10240 else 0) // prefer opus stream
}
/**
* Wrapper around the [NewPipeUtils.getStreamUrl] function which reports exceptions
*/
private fun findUrlOrNull(
format: MyPlayerResponse.StreamingData.Format,
videoId: String
): String? {
return NewPipeUtils.getStreamUrl(format, videoId)
.onFailure {
reportException(it)
}
.getOrNull()
}
/**
* Checks if the stream url returns a successful status.
* If this returns true the url is likely to work.
* If this returns false the url might cause an error during playback.
*/
private fun validateStatus(url: String): Boolean {
try {
val requestBuilder = Request.Builder()
.head()
.url(url)
val response = httpClient.newCall(requestBuilder.build()).execute()
return response.isSuccessful
} catch (e: Exception) {
reportException(e)
}
return false
}
/**
* Wrapper around the [PoTokenGenerator.getWebClientPoToken] function which reports exceptions
*/
private fun getWebClientPoTokenOrNull(videoId: String, sessionId: String?): PoTokenResult? {
if (sessionId == null) {
Log.d(TAG, "[$videoId] Session identifier is null")
return null
}
try {
return poTokenGenerator.getWebClientPoToken(videoId, sessionId)
} catch (e: Exception) {
reportException(e)
}
return null
}
}

View File

@ -0,0 +1,74 @@
package melody.offline.music.playernew
import io.ktor.client.call.body
import io.ktor.client.statement.bodyAsText
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import melody.offline.music.innertube.MyInnerTube
import melody.offline.music.innertube.models.MyPlayerResponse
import java.net.Proxy
/**
* Parse useful data with [InnerTube] sending requests.
* Modified from [ViMusic](https://github.com/vfsfitvnm/ViMusic)
*/
object YouTube {
private val innerTube = MyInnerTube()
var proxy: Proxy?
get() = innerTube.proxy
set(value) {
innerTube.proxy = value
}
var visitorData: String?
get() = innerTube.visitorData
set(value) {
innerTube.visitorData = value
}
suspend fun player(videoId: String, playlistId: String? = null, client: YouTubeClient, signatureTimestamp: Int? = null, webPlayerPot: String? = null): Result<MyPlayerResponse> = runCatching {
innerTube.player(client, videoId, playlistId, signatureTimestamp, webPlayerPot).body<MyPlayerResponse>()
}
suspend fun visitorData(): Result<String> = runCatching {
Json.parseToJsonElement(innerTube.getSwJsData().bodyAsText().substring(5))
.jsonArray[0]
.jsonArray[2]
.jsonArray.first {
(it as? JsonPrimitive)?.contentOrNull?.let { candidate ->
VISITOR_DATA_REGEX.containsMatchIn(candidate)
} ?: false
}
.jsonPrimitive.content
}
@JvmInline
value class SearchFilter(val value: String) {
companion object {
val FILTER_SONG = SearchFilter("EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D")
val FILTER_VIDEO = SearchFilter("EgWKAQIQAWoKEAkQChAFEAMQBA%3D%3D")
val FILTER_ALBUM = SearchFilter("EgWKAQIYAWoKEAkQChAFEAMQBA%3D%3D")
val FILTER_ARTIST = SearchFilter("EgWKAQIgAWoKEAkQChAFEAMQBA%3D%3D")
val FILTER_FEATURED_PLAYLIST = SearchFilter("EgeKAQQoADgBagwQDhAKEAMQBRAJEAQ%3D")
val FILTER_COMMUNITY_PLAYLIST = SearchFilter("EgeKAQQoAEABagoQAxAEEAoQCRAF")
}
}
@JvmInline
value class LibraryFilter(val value: String) {
companion object {
val FILTER_RECENT_ACTIVITY = LibraryFilter("4qmFsgIrEhdGRW11c2ljX2xpYnJhcnlfbGFuZGluZxoQZ2dNR0tnUUlCaEFCb0FZQg%3D%3D")
val FILTER_RECENTLY_PLAYED = LibraryFilter("4qmFsgIrEhdGRW11c2ljX2xpYnJhcnlfbGFuZGluZxoQZ2dNR0tnUUlCUkFCb0FZQg%3D%3D")
val FILTER_PLAYLISTS_ALPHABETICAL = LibraryFilter("4qmFsgIrEhdGRW11c2ljX2xpa2VkX3BsYXlsaXN0cxoQZ2dNR0tnUUlBUkFBb0FZQg%3D%3D")
val FILTER_PLAYLISTS_RECENTLY_SAVED = LibraryFilter("4qmFsgIrEhdGRW11c2ljX2xpa2VkX3BsYXlsaXN0cxoQZ2dNR0tnUUlBQkFCb0FZQg%3D%3D")
}
}
const val MAX_GET_QUEUE_SIZE = 1000
private val VISITOR_DATA_REGEX = Regex("^Cg[t|s]")
}

View File

@ -0,0 +1,93 @@
package melody.offline.music.playernew
import android.annotation.SuppressLint
import kotlinx.serialization.Serializable
import melody.offline.music.playernew.MyContext
@SuppressLint("UnsafeOptInUsageError")
@Serializable
data class YouTubeClient(
val clientName: String,
val clientVersion: String,
val clientId: String,
val userAgent: String,
val osVersion: String? = null,
val loginSupported: Boolean = false,
val loginRequired: Boolean = false,
val useSignatureTimestamp: Boolean = false,
val useWebPoTokens: Boolean = false,
val isEmbedded: Boolean = false,
// val origin: String? = null,
// val referer: String? = null,
) {
fun toContext(locale: YouTubeLocale, visitorData: String?, dataSyncId: String?) = MyContext(
client = MyContext.Client(
clientName = clientName,
clientVersion = clientVersion,
osVersion = osVersion,
gl = locale.gl,
hl = locale.hl,
visitorData = visitorData
),
user = MyContext.User(
onBehalfOfUser = if (loginSupported) dataSyncId else null
),
)
companion object {
/**
* Should be the latest Firefox ESR version.
*/
const val USER_AGENT_WEB = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0"
const val ORIGIN_YOUTUBE_MUSIC = "https://music.youtube.com"
const val REFERER_YOUTUBE_MUSIC = "$ORIGIN_YOUTUBE_MUSIC/"
const val API_URL_YOUTUBE_MUSIC = "$ORIGIN_YOUTUBE_MUSIC/youtubei/v1/"
val WEB = YouTubeClient(
clientName = "WEB",
clientVersion = "2.20250312.04.00",
clientId = "1",
userAgent = USER_AGENT_WEB,
)
val WEB_REMIX = YouTubeClient(
clientName = "WEB_REMIX",
clientVersion = "1.20250310.01.00",
clientId = "67",
userAgent = USER_AGENT_WEB,
loginSupported = true,
useSignatureTimestamp = true,
useWebPoTokens = true,
)
val WEB_CREATOR = YouTubeClient(
clientName = "WEB_CREATOR",
clientVersion = "1.20250312.03.01",
clientId = "62",
userAgent = USER_AGENT_WEB,
loginSupported = true,
loginRequired = true,
useSignatureTimestamp = true,
)
val TVHTML5_SIMPLY_EMBEDDED_PLAYER = YouTubeClient(
clientName = "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
clientVersion = "2.0",
clientId = "85",
userAgent = "Mozilla/5.0 (PlayStation; PlayStation 4/12.02) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Safari/605.1.15",
loginSupported = true,
loginRequired = true,
useSignatureTimestamp = true,
isEmbedded = true,
)
val IOS = YouTubeClient(
clientName = "IOS",
clientVersion = "20.10.4",
clientId = "5",
userAgent = "com.google.ios.youtube/20.10.4 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)",
osVersion = "18.3.2.22D82",
)
}
}

View File

@ -0,0 +1,11 @@
package melody.offline.music.playernew
import android.annotation.SuppressLint
import kotlinx.serialization.Serializable
@SuppressLint("UnsafeOptInUsageError")
@Serializable
data class YouTubeLocale(
val gl: String, // geolocation
val hl: String, // host language
)

View File

@ -0,0 +1,198 @@
package melody.offline.music.playernew.constants
/*
---------------------------
Appearance & interface
---------------------------
*/
enum class DarkMode {
ON, OFF, AUTO
}
enum class PlayerBackgroundStyle {
DEFAULT, GRADIENT, BLUR
}
enum class LibraryViewType {
LIST, GRID;
fun toggle() = when (this) {
LIST -> GRID
GRID -> LIST
}
}
enum class LyricsPosition {
LEFT, CENTER, RIGHT
}
const val DEFAULT_ENABLED_TABS = "HSFM"
const val DEFAULT_ENABLED_FILTERS = "ARP"
/*
---------------------------
Sync
---------------------------
*/
enum class SyncMode {
RO, RW, // USER_CHOICE
}
enum class SyncConflictResolution {
ADD_ONLY, OVERWRITE_WITH_REMOTE, // OVERWRITE_WITH_LOCAL, USER_CHOICE
}
// when adding an enum:
// 1. add settings checkbox string and state
// 2. add to DEFAULT_SYNC_CONTENT
// 3. add to encode/decode
// 4. figure out if it's necessary to update existing user's keys
enum class SyncContent {
ALBUMS,
ARTISTS,
LIKED_SONGS,
PLAYLISTS,
PRIVATE_SONGS,
RECENT_ACTIVITY,
NULL
}
/**
* A: Albums
* R: Artists
* P: Playlists
* L: Liked songs
* S: Library (privately uploaded) songs
* C: Recent activity
* N: <Unused option>
*/
val syncPairs = listOf(
SyncContent.ALBUMS to 'A',
SyncContent.ARTISTS to 'R',
SyncContent.PLAYLISTS to 'P',
SyncContent.LIKED_SONGS to 'L',
SyncContent.PRIVATE_SONGS to 'S',
SyncContent.RECENT_ACTIVITY to 'C'
)
/**
* Converts the enable sync items list (string) to SyncContent
*
* @param sync Encoded string
*/
fun decodeSyncString(sync: String): List<SyncContent> {
val charToSyncMap = syncPairs.associate { (screen, char) -> char to screen }
return sync.toCharArray().map { char -> charToSyncMap[char] ?: SyncContent.NULL }
}
/**
* Converts the SyncContent filters list to string
*
* @param list Decoded SyncContent list
*/
fun encodeSyncString(list: List<SyncContent>): String {
val charToSyncMap = syncPairs.associate { (sync, char) -> char to sync }
return list.distinct().joinToString("") { sync ->
charToSyncMap.entries.first { it.value == sync }.key.toString()
}
}
/*
---------------------------
Local scanner
---------------------------
*/
enum class ScannerImpl {
TAGLIB,
FFMPEG_EXT,
}
/**
* Specify how strict the metadata scanner should be
*/
enum class ScannerMatchCriteria {
LEVEL_1, // Title only
LEVEL_2, // Title and artists
LEVEL_3, // Title, artists, albums
}
/*
---------------------------
Player & audio
---------------------------
*/
enum class AudioQuality {
AUTO, HIGH, LOW
}
/*
---------------------------
Library & Content
---------------------------
*/
enum class LikedAutodownloadMode {
OFF, ON, WIFI_ONLY
}
/*
---------------------------
Misc preferences not bound
to settings category
---------------------------
*/
enum class SongSortType {
CREATE_DATE, MODIFIED_DATE, RELEASE_DATE, NAME, ARTIST, PLAY_TIME, PLAY_COUNT
}
enum class PlaylistSongSortType {
CUSTOM, CREATE_DATE, NAME, ARTIST, PLAY_TIME
}
enum class ArtistSortType {
CREATE_DATE, NAME, SONG_COUNT, PLAY_TIME
}
enum class ArtistSongSortType {
CREATE_DATE, NAME, PLAY_TIME
}
enum class AlbumSortType {
CREATE_DATE, NAME, ARTIST, YEAR, SONG_COUNT, LENGTH, PLAY_TIME
}
enum class PlaylistSortType {
CREATE_DATE, NAME, SONG_COUNT
}
enum class LibrarySortType {
CREATE_DATE, NAME
}
enum class SongFilter {
LIBRARY, LIKED, DOWNLOADED
}
enum class ArtistFilter {
LIBRARY, LIKED, DOWNLOADED
}
enum class AlbumFilter {
LIBRARY, LIKED, DOWNLOADED
}
enum class PlaylistFilter {
LIBRARY, DOWNLOADED
}
enum class SearchSource {
LOCAL, ONLINE
}

View File

@ -0,0 +1,129 @@
package melody.offline.music.playernew.potoken
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
import okio.ByteString.Companion.decodeBase64
import okio.ByteString.Companion.toByteString
/**
* 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()
}

View File

@ -0,0 +1,13 @@
package melody.offline.music.playernew.potoken
class PoTokenException(message: String) : Exception(message)
// to be thrown if the WebView provided by the system is broken
class BadWebViewException(message: String) : Exception(message)
fun buildExceptionForJsError(error: String): Exception {
return if (error.contains("SyntaxError"))
BadWebViewException(error)
else
PoTokenException(error)
}

View File

@ -0,0 +1,96 @@
package melody.offline.music.playernew.potoken
import android.util.Log
import android.webkit.CookieManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import melody.offline.music.App
class PoTokenGenerator {
private val TAG = "PoTokenGenerator"
private val webViewSupported by lazy { runCatching { CookieManager.getInstance() }.isSuccess }
private var webViewBadImpl = false // whether the system has a bad WebView implementation
private val webPoTokenGenLock = Mutex()
private var webPoTokenSessionId: String? = null
private var webPoTokenStreamingPot: String? = null
private var webPoTokenGenerator: PoTokenWebView? = null
fun getWebClientPoToken(videoId: String, sessionId: String): PoTokenResult? {
if (!webViewSupported || webViewBadImpl) {
return null
}
return try {
runBlocking { getWebClientPoToken(videoId, sessionId, forceRecreate = false) }
} catch (e: Exception) {
when (e) {
is BadWebViewException -> {
Log.e(TAG, "Could not obtain poToken because WebView is broken", e)
webViewBadImpl = true
null
}
else -> throw e // includes PoTokenException
}
}
}
/**
* @param forceRecreate whether to force the recreation of [webPoTokenGenerator], to be used in
* case the current [webPoTokenGenerator] threw an error last time
* [PoTokenWebView.generatePoToken] was called
*/
private suspend fun getWebClientPoToken(videoId: String, sessionId: String, forceRecreate: Boolean): PoTokenResult {
Log.d(TAG, "Web poToken requested: $videoId, $sessionId")
val (poTokenGenerator, streamingPot, hasBeenRecreated) =
webPoTokenGenLock.withLock {
val shouldRecreate =
forceRecreate || webPoTokenGenerator == null || webPoTokenGenerator!!.isExpired || webPoTokenSessionId != sessionId
if (shouldRecreate) {
webPoTokenSessionId = sessionId
withContext(Dispatchers.Main) {
webPoTokenGenerator?.close()
}
// create a new webPoTokenGenerator
webPoTokenGenerator = PoTokenWebView.Companion.getNewPoTokenGenerator(App.app)
// The streaming poToken needs to be generated exactly once before generating
// any other (player) tokens.
webPoTokenStreamingPot = webPoTokenGenerator!!.generatePoToken(webPoTokenSessionId!!)
}
Triple(webPoTokenGenerator!!, 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
// streaming poToken (based on [sessionId]) to be generated before anything else.
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 the app goes in the background and the WebView
// content is lost
Log.e(TAG, "Failed to obtain poToken, retrying", throwable)
return getWebClientPoToken(videoId = videoId, sessionId = sessionId, forceRecreate = true)
}
}
Log.d(TAG, "[$videoId] playerPot=$playerPot, streamingPot=$streamingPot")
return PoTokenResult(playerPot, streamingPot)
}
}

View File

@ -0,0 +1,6 @@
package melody.offline.music.playernew.potoken
class PoTokenResult(
val playerRequestPoToken: String,
val streamingDataPoToken: String,
)

View File

@ -0,0 +1,341 @@
package melody.offline.music.playernew.potoken
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import android.webkit.ConsoleMessage
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebView
import androidx.annotation.MainThread
import androidx.collection.ArrayMap
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import melody.offline.music.BuildConfig
import melody.offline.music.playernew.YouTube
import okhttp3.Headers.Companion.toHeaders
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.Collections
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@SuppressLint("NewApi")
class PoTokenWebView private constructor(
context: Context,
// to be used exactly once only during initialization!
private val continuation: Continuation<PoTokenWebView>,
) {
private val webView = WebView(context)
private val scope = MainScope()
private val poTokenContinuations =
Collections.synchronizedMap(ArrayMap<String, Continuation<String>>())
private val exceptionHandler = CoroutineExceptionHandler { _, t ->
onInitializationErrorCloseAndCancel(t)
}
private lateinit var expirationInstant: Instant
//region Initialization
init {
val webViewSettings = webView.settings
//noinspection SetJavaScriptEnabled we want to use JavaScript!
webViewSettings.javaScriptEnabled = true
webViewSettings.safeBrowsingEnabled = false
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
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 = BadWebViewException(fmt)
Log.e(TAG, "This WebView implementation is broken: $fmt")
onInitializationErrorCloseAndCancel(exception)
popAllPoTokenContinuations().forEach { (_, cont) -> cont.resumeWithException(exception) }
}
return super.onConsoleMessage(m)
}
}
}
/**
* 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() {
Log.d(TAG, "loadHtmlAndObtainBotguard() called")
scope.launch(exceptionHandler) {
val html = withContext(Dispatchers.IO) {
webView.context.assets.open("po_token.html").bufferedReader().use { it.readText() }
}
// 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)
}
}
/**
* 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() {
Log.d(TAG, "downloadAndRunBotguard() called")
makeBotguardServiceRequest(
"https://www.youtube.com/api/jnn/v1/Create",
"[ \"$REQUEST_KEY\" ]",
) { responseBody ->
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
)
}
}
/**
* 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")
}
onInitializationErrorCloseAndCancel(buildExceptionForJsError(error))
}
/**
* Called during initialization by the JavaScript snippet from [downloadAndRunBotguard] after
* obtaining the BotGuard execution output [botguardResponse].
*/
@JavascriptInterface
fun onRunBotguardResult(botguardResponse: String) {
Log.d(TAG, "botguardResponse: $botguardResponse")
makeBotguardServiceRequest(
"https://www.youtube.com/api/jnn/v1/GenerateIT",
"[ \"$REQUEST_KEY\", \"$botguardResponse\" ]",
) { responseBody ->
Log.d(TAG, "GenerateIT response: $responseBody")
val (integrityToken, expirationTimeInSeconds) = parseIntegrityTokenData(responseBody)
// leave 10 minutes of margin just to be sure
expirationInstant = Instant.now().plusSeconds(expirationTimeInSeconds).minus(10, ChronoUnit.MINUTES)
webView.evaluateJavascript("this.integrityToken = $integrityToken") {
Log.d(TAG, "initialization finished, expiration=${expirationTimeInSeconds}s")
continuation.resume(this)
}
}
}
//endregion
//region Obtaining poTokens
suspend fun generatePoToken(identifier: String): String {
return withContext(Dispatchers.Main) {
suspendCancellableCoroutine { cont ->
Log.d(TAG, "generatePoToken() called with identifier $identifier")
addPoTokenEmitter(identifier, cont)
webView.evaluateJavascript(
"""try {
identifier = "$identifier"
u8Identifier = ${stringToU8(identifier)}
poTokenU8 = obtainPoToken(webPoSignalOutput, integrityToken, u8Identifier)
poTokenU8String = poTokenU8.join(",")
$JS_INTERFACE.onObtainPoTokenResult(identifier, poTokenU8String)
} catch (error) {
$JS_INTERFACE.onObtainPoTokenError(identifier, error + "\n" + error.stack)
}""",
null
)
}
}
}
/**
* 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")
}
popPoTokenContinuation(identifier)?.resumeWithException(buildExceptionForJsError(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) {
Log.d(TAG, "Generated poToken (before decoding): identifier=$identifier poTokenU8=$poTokenU8")
val poToken = try {
u8ToBase64(poTokenU8)
} catch (t: Throwable) {
popPoTokenContinuation(identifier)?.resumeWithException(t)
return
}
Log.d(TAG, "Generated poToken: identifier=$identifier poToken=$poToken")
popPoTokenContinuation(identifier)?.resume(poToken)
}
val isExpired: Boolean
get() = 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
//region Utils
/**
* Makes a POST request to [url] with the given [data] by setting the correct headers. Calls
* [onInitializationErrorCloseAndCancel] in case of any network errors and also if the response
* does not have HTTP code 200, therefore this is supposed to be used only during
* 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(
url: String,
data: String,
handleResponseBody: (String) -> Unit,
) {
scope.launch(exceptionHandler) {
val requestBuilder = Request.Builder()
.post(data.toRequestBody())
.headers(mapOf(
"User-Agent" to USER_AGENT,
"Accept" to "application/json",
"Content-Type" to "application/json+protobuf",
"x-goog-api-key" to GOOGLE_API_KEY,
"x-user-agent" to "grpc-web-javascript/0.1",
).toHeaders())
.url(url)
val response = withContext(Dispatchers.IO) {
httpClient.newCall(requestBuilder.build()).execute()
}
val httpCode = response.code
if (httpCode != 200) {
onInitializationErrorCloseAndCancel(PoTokenException("Invalid response code: $httpCode"))
} else {
val body = withContext(Dispatchers.IO) {
response.body!!.string()
}
handleResponseBody(body)
}
}
}
/**
* Handles any error happening during initialization, releasing resources and sending the error
* to [continuation].
*/
private fun onInitializationErrorCloseAndCancel(error: Throwable) {
close()
continuation.resumeWithException(error)
}
/**
* Releases all [webView] resources.
*/
@MainThread
fun close() {
scope.cancel()
webView.clearHistory()
// clears RAM cache and disk cache (globally for all WebViews)
webView.clearCache(true)
// ensures that the WebView isn't doing anything when destroying it
webView.loadUrl("about:blank")
webView.onPause()
webView.removeAllViews()
webView.destroy()
}
//endregion
companion object {
private const val TAG = "PoTokenWebView"
private const val GOOGLE_API_KEY = "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw"
private const val REQUEST_KEY = "O43z0dpjhgX20SCx4KAo"
private 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"
private const val JS_INTERFACE = "PoTokenWebView"
private val httpClient = OkHttpClient.Builder()
.proxy(YouTube.proxy)
.build()
suspend fun getNewPoTokenGenerator(context: Context): PoTokenWebView {
return withContext(Dispatchers.Main) {
suspendCancellableCoroutine { cont ->
val potWv = PoTokenWebView(context, cont)
potWv.loadHtmlAndObtainBotguard()
}
}
}
}
}

View File

@ -1,9 +1,12 @@
package melody.offline.music.service
import android.content.Intent
import android.net.ConnectivityManager
import android.net.Uri
import android.os.Handler
import android.util.Log
import androidx.annotation.OptIn
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
@ -30,17 +33,18 @@ import androidx.media3.extractor.mp4.FragmentedMp4Extractor
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import melody.offline.music.R
import melody.offline.music.innertube.Innertube
import melody.offline.music.innertube.models.bodies.PlayerBody
import melody.offline.music.innertube.requests.player
import melody.offline.music.playernew.YTPlayerUtils.playerResponseForPlayback
import melody.offline.music.sp.AppStore
import melody.offline.music.util.CacheManager
import melody.offline.music.util.LogTag
import melody.offline.music.util.LogTag.LogD
import melody.offline.music.util.PlayMode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import melody.offline.music.util.RingBuffer
@OptIn(UnstableApi::class)
@ -51,7 +55,7 @@ class PlaybackService : MediaSessionService(), Player.Listener {
private lateinit var player: ExoPlayer
private val playerCache = CacheManager.getPlayerCache()
private val downloadCache = CacheManager.getDownloadCache()
private lateinit var connectivityManager: ConnectivityManager
override fun onCreate() {
super.onCreate()
player = ExoPlayer.Builder(this)
@ -100,6 +104,8 @@ class PlaybackService : MediaSessionService(), Player.Listener {
setMediaNotificationProvider(DefaultMediaNotificationProvider(this).apply {
setSmallIcon(R.mipmap.app_logo_no_bg)
})
connectivityManager = getSystemService()!!
}
// The user dismissed the app from the recent tasks
@ -147,6 +153,7 @@ class PlaybackService : MediaSessionService(), Player.Listener {
private fun createDataSourceFactory(): DataSource.Factory {
val chunkLength = 512 * 1024L
val ringBuffer = RingBuffer<Pair<String, Uri>?>(2) { null }
val urlCache = HashMap<String, Pair<String, Long>>() // URL缓存url + 过期时间
return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec ->
val videoId = dataSpec.key ?: error("A key must be set")
@ -163,46 +170,82 @@ class PlaybackService : MediaSessionService(), Player.Listener {
LogD(TAG, "playbackService playerCache contains data for $videoId at position $position")
return@Factory dataSpec
}
// 使用缓存URL且未过期
urlCache[videoId]?.takeIf { it.second > System.currentTimeMillis() }?.let { (cachedUrl, _) ->
LogD(TAG, "Using cached URL for $videoId")
return@Factory dataSpec.withUri(cachedUrl.toUri()).subrange(dataSpec.uriPositionOffset, chunkLength)
}
when (videoId) {
ringBuffer.getOrNull(0)?.first -> return@Factory dataSpec.withUri(ringBuffer.getOrNull(0)!!.second)
ringBuffer.getOrNull(1)?.first -> return@Factory dataSpec.withUri(ringBuffer.getOrNull(1)!!.second)
else -> {
val urlResult = runBlocking(Dispatchers.IO) {
Innertube.player(PlayerBody(videoId = videoId))
}?.mapCatching { body ->
if (body.videoDetails?.videoId != videoId) {
throw VideoIdMismatchException()
}
when (val status = body.playabilityStatus?.status) {
"OK" -> body.streamingData?.highestQualityFormat?.let { format ->
format.url
} ?: throw PlayableFormatNotFoundException()
"UNPLAYABLE" -> throw UnplayableException()
"LOGIN_REQUIRED" -> throw LoginRequiredException()
else -> throw PlaybackException(
status,
null,
PlaybackException.ERROR_CODE_REMOTE_ERROR
)
}
}
LogD(TAG, "playbackService urlResult->$urlResult")
urlResult?.getOrThrow()?.let { url ->
ringBuffer.append(videoId to url.toUri())
return@Factory dataSpec.withUri(url.toUri())
.subrange(dataSpec.uriPositionOffset, chunkLength)
} ?: throw PlaybackException(
null,
urlResult?.exceptionOrNull(),
PlaybackException.ERROR_CODE_REMOTE_ERROR
)
}
// else -> {
// val urlResult = runBlocking(Dispatchers.IO) {
// Innertube.player(PlayerBody(videoId = videoId))
// }?.mapCatching { body ->
// if (body.videoDetails?.videoId != videoId) {
// throw VideoIdMismatchException()
// }
//
// when (val status = body.playabilityStatus?.status) {
// "OK" -> body.streamingData?.highestQualityFormat?.let { format ->
// format.url
// } ?: throw PlayableFormatNotFoundException()
//
// "UNPLAYABLE" -> throw UnplayableException()
// "LOGIN_REQUIRED" -> throw LoginRequiredException()
// else -> throw PlaybackException(
// status,
// null,
// PlaybackException.ERROR_CODE_REMOTE_ERROR
// )
// }
// }
//
// LogD(TAG, "playbackService urlResult->$urlResult")
//
// urlResult?.getOrThrow()?.let { url ->
// ringBuffer.append(videoId to url.toUri())
// return@Factory dataSpec.withUri(url.toUri())
// .subrange(dataSpec.uriPositionOffset, chunkLength)
// } ?: throw PlaybackException(
// null,
// urlResult?.exceptionOrNull(),
// PlaybackException.ERROR_CODE_REMOTE_ERROR
// )
// }
}
// 进入协程安全调用你提供的获取方法
val playbackResult = runBlocking(Dispatchers.IO) {
playerResponseForPlayback(
videoId = videoId,
connectivityManager = connectivityManager
)
}
val playbackData = playbackResult.getOrElse { ex ->
Log.e(TAG, "Failed to get playback data", ex)
throw PlaybackException(
ex.message ?: "Failed to get playback data",
ex,
PlaybackException.ERROR_CODE_REMOTE_ERROR
)
}
val streamUrl = playbackData.streamUrl
if (streamUrl.isNullOrEmpty()) {
throw PlaybackException("Stream URL is empty", null, PlaybackException.ERROR_CODE_REMOTE_ERROR)
}
val uri = streamUrl.toUri()
// 缓存
ringBuffer.append(videoId to uri)
urlCache[videoId] = streamUrl to (System.currentTimeMillis() + playbackData.streamExpiresInSeconds * 1000L)
LogD(TAG, "Resolved URL for $videoId: $streamUrl")
dataSpec.withUri(uri).subrange(dataSpec.uriPositionOffset, chunkLength)
}
}