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