From 5006cf3e110ef84915d3027ee5efb2ee7240ce0d Mon Sep 17 00:00:00 2001 From: ocean <503259349@qq.com> Date: Thu, 12 Jun 2025 14:20:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=92=AD=E6=94=BE=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/AndroidProjectSystem.xml | 6 + .idea/runConfigurations.xml | 17 + app/build.gradle.kts | 4 + app/src/main/java/melody/offline/music/App.kt | 32 +- .../offline/music/innertube/MyInnerTube.kt | 151 ++++++++ .../innertube/models/MyPlayerResponse.kt | 108 ++++++ .../innertube/models/MyResponseContext.kt | 21 ++ .../music/innertube/models/Thumbnails.kt | 10 + .../innertube/models/bodies/MyPlayerBody.kt | 32 ++ .../music/innertube/requests/Player.kt | 2 + .../offline/music/innertube/utils/Utils.kt | 11 + .../offline/music/playernew/DataStore.kt | 8 + .../offline/music/playernew/MyContext.kt | 40 ++ .../melody/offline/music/playernew/NewPipe.kt | 100 +++++ .../offline/music/playernew/YTPlayerUtils.kt | 242 +++++++++++++ .../melody/offline/music/playernew/YouTube.kt | 74 ++++ .../offline/music/playernew/YouTubeClient.kt | 93 +++++ .../offline/music/playernew/YouTubeLocale.kt | 11 + .../music/playernew/constants/Settings.kt | 198 ++++++++++ .../music/playernew/potoken/JavaScriptUtil.kt | 129 +++++++ .../playernew/potoken/PoTokenException.kt | 13 + .../playernew/potoken/PoTokenGenerator.kt | 96 +++++ .../music/playernew/potoken/PoTokenResult.kt | 6 + .../music/playernew/potoken/PoTokenWebView.kt | 341 ++++++++++++++++++ .../offline/music/service/PlaybackService.kt | 119 ++++-- 25 files changed, 1824 insertions(+), 40 deletions(-) create mode 100644 .idea/AndroidProjectSystem.xml create mode 100644 .idea/runConfigurations.xml create mode 100644 app/src/main/java/melody/offline/music/innertube/MyInnerTube.kt create mode 100644 app/src/main/java/melody/offline/music/innertube/models/MyPlayerResponse.kt create mode 100644 app/src/main/java/melody/offline/music/innertube/models/MyResponseContext.kt create mode 100644 app/src/main/java/melody/offline/music/innertube/models/Thumbnails.kt create mode 100644 app/src/main/java/melody/offline/music/innertube/models/bodies/MyPlayerBody.kt create mode 100644 app/src/main/java/melody/offline/music/playernew/DataStore.kt create mode 100644 app/src/main/java/melody/offline/music/playernew/MyContext.kt create mode 100644 app/src/main/java/melody/offline/music/playernew/NewPipe.kt create mode 100644 app/src/main/java/melody/offline/music/playernew/YTPlayerUtils.kt create mode 100644 app/src/main/java/melody/offline/music/playernew/YouTube.kt create mode 100644 app/src/main/java/melody/offline/music/playernew/YouTubeClient.kt create mode 100644 app/src/main/java/melody/offline/music/playernew/YouTubeLocale.kt create mode 100644 app/src/main/java/melody/offline/music/playernew/constants/Settings.kt create mode 100644 app/src/main/java/melody/offline/music/playernew/potoken/JavaScriptUtil.kt create mode 100644 app/src/main/java/melody/offline/music/playernew/potoken/PoTokenException.kt create mode 100644 app/src/main/java/melody/offline/music/playernew/potoken/PoTokenGenerator.kt create mode 100644 app/src/main/java/melody/offline/music/playernew/potoken/PoTokenResult.kt create mode 100644 app/src/main/java/melody/offline/music/playernew/potoken/PoTokenWebView.kt diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c3677cd..1740741 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 diff --git a/app/src/main/java/melody/offline/music/App.kt b/app/src/main/java/melody/offline/music/App.kt index d17eec6..83073a8 100644 --- a/app/src/main/java/melody/offline/music/App.kt +++ b/app/src/main/java/melody/offline/music/App.kt @@ -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() { diff --git a/app/src/main/java/melody/offline/music/innertube/MyInnerTube.kt b/app/src/main/java/melody/offline/music/innertube/MyInnerTube.kt new file mode 100644 index 0000000..2efbfc0 --- /dev/null +++ b/app/src/main/java/melody/offline/music/innertube/MyInnerTube.kt @@ -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 = + 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() + + 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") + +} \ No newline at end of file diff --git a/app/src/main/java/melody/offline/music/innertube/models/MyPlayerResponse.kt b/app/src/main/java/melody/offline/music/innertube/models/MyPlayerResponse.kt new file mode 100644 index 0000000..86dc7c4 --- /dev/null +++ b/app/src/main/java/melody/offline/music/innertube/models/MyPlayerResponse.kt @@ -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?, + val adaptiveFormats: List, + 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?, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/melody/offline/music/innertube/models/MyResponseContext.kt b/app/src/main/java/melody/offline/music/innertube/models/MyResponseContext.kt new file mode 100644 index 0000000..82ff0f9 --- /dev/null +++ b/app/src/main/java/melody/offline/music/innertube/models/MyResponseContext.kt @@ -0,0 +1,21 @@ +package melody.offline.music.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class MyResponseContext( + val visitorData: String?, + val serviceTrackingParams: List?, +) { + @Serializable + data class ServiceTrackingParam( + val params: List, + val service: String, + ) { + @Serializable + data class Param( + val key: String, + val value: String, + ) + } +} diff --git a/app/src/main/java/melody/offline/music/innertube/models/Thumbnails.kt b/app/src/main/java/melody/offline/music/innertube/models/Thumbnails.kt new file mode 100644 index 0000000..89f86c0 --- /dev/null +++ b/app/src/main/java/melody/offline/music/innertube/models/Thumbnails.kt @@ -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, +) diff --git a/app/src/main/java/melody/offline/music/innertube/models/bodies/MyPlayerBody.kt b/app/src/main/java/melody/offline/music/innertube/models/bodies/MyPlayerBody.kt new file mode 100644 index 0000000..60f1798 --- /dev/null +++ b/app/src/main/java/melody/offline/music/innertube/models/bodies/MyPlayerBody.kt @@ -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 + ) +} diff --git a/app/src/main/java/melody/offline/music/innertube/requests/Player.kt b/app/src/main/java/melody/offline/music/innertube/requests/Player.kt index b0a0cb8..7c1148b 100644 --- a/app/src/main/java/melody/offline/music/innertube/requests/Player.kt +++ b/app/src/main/java/melody/offline/music/innertube/requests/Player.kt @@ -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) diff --git a/app/src/main/java/melody/offline/music/innertube/utils/Utils.kt b/app/src/main/java/melody/offline/music/innertube/utils/Utils.kt index 39f5797..2062849 100644 --- a/app/src/main/java/melody/offline/music/innertube/utils/Utils.kt +++ b/app/src/main/java/melody/offline/music/innertube/utils/Utils.kt @@ -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 Innertube.ItemsPage?.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") \ No newline at end of file diff --git a/app/src/main/java/melody/offline/music/playernew/DataStore.kt b/app/src/main/java/melody/offline/music/playernew/DataStore.kt new file mode 100644 index 0000000..fd94171 --- /dev/null +++ b/app/src/main/java/melody/offline/music/playernew/DataStore.kt @@ -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 by preferencesDataStore(name = "vo_settings") \ No newline at end of file diff --git a/app/src/main/java/melody/offline/music/playernew/MyContext.kt b/app/src/main/java/melody/offline/music/playernew/MyContext.kt new file mode 100644 index 0000000..313bd9c --- /dev/null +++ b/app/src/main/java/melody/offline/music/playernew/MyContext.kt @@ -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 = emptyArray(), + val useSsl: Boolean = true, + ) + + @Serializable + data class User( + val lockedSafetyMode: Boolean = false, + val onBehalfOfUser: String? = null, + ) +} \ No newline at end of file diff --git a/app/src/main/java/melody/offline/music/playernew/NewPipe.kt b/app/src/main/java/melody/offline/music/playernew/NewPipe.kt new file mode 100644 index 0000000..1b0ca28 --- /dev/null +++ b/app/src/main/java/melody/offline/music/playernew/NewPipe.kt @@ -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 = runCatching { + YoutubeJavaScriptPlayerManager.getSignatureTimestamp(videoId) + } + + fun getStreamUrl(format: MyPlayerResponse.StreamingData.Format, videoId: String): Result = + 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 + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/melody/offline/music/playernew/YTPlayerUtils.kt b/app/src/main/java/melody/offline/music/playernew/YTPlayerUtils.kt new file mode 100644 index 0000000..71c7c47 --- /dev/null +++ b/app/src/main/java/melody/offline/music/playernew/YTPlayerUtils.kt @@ -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 = 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 = 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/melody/offline/music/playernew/YouTube.kt b/app/src/main/java/melody/offline/music/playernew/YouTube.kt new file mode 100644 index 0000000..12c6836 --- /dev/null +++ b/app/src/main/java/melody/offline/music/playernew/YouTube.kt @@ -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 = runCatching { + innerTube.player(client, videoId, playlistId, signatureTimestamp, webPlayerPot).body() + } + + suspend fun visitorData(): Result = 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]") +} \ No newline at end of file diff --git a/app/src/main/java/melody/offline/music/playernew/YouTubeClient.kt b/app/src/main/java/melody/offline/music/playernew/YouTubeClient.kt new file mode 100644 index 0000000..563e1de --- /dev/null +++ b/app/src/main/java/melody/offline/music/playernew/YouTubeClient.kt @@ -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", + ) + } +} diff --git a/app/src/main/java/melody/offline/music/playernew/YouTubeLocale.kt b/app/src/main/java/melody/offline/music/playernew/YouTubeLocale.kt new file mode 100644 index 0000000..7a6961d --- /dev/null +++ b/app/src/main/java/melody/offline/music/playernew/YouTubeLocale.kt @@ -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 +) diff --git a/app/src/main/java/melody/offline/music/playernew/constants/Settings.kt b/app/src/main/java/melody/offline/music/playernew/constants/Settings.kt new file mode 100644 index 0000000..de3157d --- /dev/null +++ b/app/src/main/java/melody/offline/music/playernew/constants/Settings.kt @@ -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: + */ +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 { + 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): 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 +} diff --git a/app/src/main/java/melody/offline/music/playernew/potoken/JavaScriptUtil.kt b/app/src/main/java/melody/offline/music/playernew/potoken/JavaScriptUtil.kt new file mode 100644 index 0000000..748f8a9 --- /dev/null +++ b/app/src/main/java/melody/offline/music/playernew/potoken/JavaScriptUtil.kt @@ -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 { + 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() +} \ No newline at end of file diff --git a/app/src/main/java/melody/offline/music/playernew/potoken/PoTokenException.kt b/app/src/main/java/melody/offline/music/playernew/potoken/PoTokenException.kt new file mode 100644 index 0000000..e2ea610 --- /dev/null +++ b/app/src/main/java/melody/offline/music/playernew/potoken/PoTokenException.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/melody/offline/music/playernew/potoken/PoTokenGenerator.kt b/app/src/main/java/melody/offline/music/playernew/potoken/PoTokenGenerator.kt new file mode 100644 index 0000000..96aa46f --- /dev/null +++ b/app/src/main/java/melody/offline/music/playernew/potoken/PoTokenGenerator.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/melody/offline/music/playernew/potoken/PoTokenResult.kt b/app/src/main/java/melody/offline/music/playernew/potoken/PoTokenResult.kt new file mode 100644 index 0000000..5ef0bc2 --- /dev/null +++ b/app/src/main/java/melody/offline/music/playernew/potoken/PoTokenResult.kt @@ -0,0 +1,6 @@ +package melody.offline.music.playernew.potoken + +class PoTokenResult( + val playerRequestPoToken: String, + val streamingDataPoToken: String, +) \ No newline at end of file diff --git a/app/src/main/java/melody/offline/music/playernew/potoken/PoTokenWebView.kt b/app/src/main/java/melody/offline/music/playernew/potoken/PoTokenWebView.kt new file mode 100644 index 0000000..e469552 --- /dev/null +++ b/app/src/main/java/melody/offline/music/playernew/potoken/PoTokenWebView.kt @@ -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, +) { + private val webView = WebView(context) + private val scope = MainScope() + private val poTokenContinuations = + Collections.synchronizedMap(ArrayMap>()) + 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("", "\n$JS_INTERFACE.downloadAndRunBotguard()") + 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) { + 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? { + 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> { + 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() + } + } + } + } +} diff --git a/app/src/main/java/melody/offline/music/service/PlaybackService.kt b/app/src/main/java/melody/offline/music/service/PlaybackService.kt index 232d9b8..96bc0a7 100644 --- a/app/src/main/java/melody/offline/music/service/PlaybackService.kt +++ b/app/src/main/java/melody/offline/music/service/PlaybackService.kt @@ -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?>(2) { null } + val urlCache = HashMap>() // 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) } }