修改播放失败。
This commit is contained in:
parent
3f6702d97c
commit
5006cf3e11
6
.idea/AndroidProjectSystem.xml
generated
Normal file
6
.idea/AndroidProjectSystem.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
17
.idea/runConfigurations.xml
generated
Normal file
17
.idea/runConfigurations.xml
generated
Normal file
@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RunConfigurationProducerService">
|
||||
<option name="ignoredProducers">
|
||||
<set>
|
||||
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
151
app/src/main/java/melody/offline/music/innertube/MyInnerTube.kt
Normal file
151
app/src/main/java/melody/offline/music/innertube/MyInnerTube.kt
Normal file
@ -0,0 +1,151 @@
|
||||
package melody.offline.music.innertube
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.compression.ContentEncoding
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.headers
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.userAgent
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import melody.offline.music.innertube.models.bodies.MyPlayerBody
|
||||
import melody.offline.music.playernew.MyContext
|
||||
import melody.offline.music.playernew.YouTubeClient
|
||||
import melody.offline.music.playernew.YouTubeLocale
|
||||
import melody.offline.music.innertube.utils.sha1
|
||||
import java.net.Proxy
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Provide access to InnerTube endpoints.
|
||||
* For making HTTP requests, not parsing response.
|
||||
*/
|
||||
class MyInnerTube {
|
||||
|
||||
fun parseCookieString(cookie: String): Map<String, String> =
|
||||
cookie.split("; ")
|
||||
.filter { it.isNotEmpty() }
|
||||
.associate {
|
||||
val (key, value) = it.split("=")
|
||||
key to value
|
||||
}
|
||||
|
||||
private var httpClient = createClient()
|
||||
|
||||
var locale = YouTubeLocale(
|
||||
gl = Locale.getDefault().country,
|
||||
hl = Locale.getDefault().toLanguageTag()
|
||||
)
|
||||
var visitorData: String? = null
|
||||
var dataSyncId: String? = null
|
||||
var cookie: String? = null
|
||||
set(value) {
|
||||
field = value
|
||||
cookieMap = if (value == null) emptyMap() else parseCookieString(value)
|
||||
}
|
||||
private var cookieMap = emptyMap<String, String>()
|
||||
|
||||
var proxy: Proxy? = null
|
||||
set(value) {
|
||||
field = value
|
||||
httpClient.close()
|
||||
httpClient = createClient()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
private fun createClient() = HttpClient(OkHttp) {
|
||||
expectSuccess = true
|
||||
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
})
|
||||
}
|
||||
|
||||
install(ContentEncoding) {
|
||||
gzip(0.9F)
|
||||
deflate(0.8F)
|
||||
}
|
||||
|
||||
if (proxy != null) {
|
||||
engine {
|
||||
proxy = this.proxy
|
||||
}
|
||||
}
|
||||
|
||||
defaultRequest {
|
||||
url(YouTubeClient.API_URL_YOUTUBE_MUSIC)
|
||||
}
|
||||
}
|
||||
|
||||
private fun HttpRequestBuilder.ytClient(client: YouTubeClient, setLogin: Boolean = false) {
|
||||
contentType(ContentType.Application.Json)
|
||||
headers {
|
||||
append("X-Goog-Api-Format-Version", "1")
|
||||
append("X-YouTube-Client-Name", client.clientId /* Not a typo. The Client-Name header does contain the client id. */)
|
||||
append("X-YouTube-Client-Version", client.clientVersion)
|
||||
append("X-Origin", YouTubeClient.Companion.ORIGIN_YOUTUBE_MUSIC)
|
||||
append("Referer", YouTubeClient.Companion.REFERER_YOUTUBE_MUSIC)
|
||||
if (setLogin && client.loginSupported) {
|
||||
cookie?.let { cookie ->
|
||||
append("cookie", cookie)
|
||||
if ("SAPISID" !in cookieMap) return@let
|
||||
val currentTime = System.currentTimeMillis() / 1000
|
||||
val sapisidHash = sha1("$currentTime ${cookieMap["SAPISID"]} ${YouTubeClient.Companion.ORIGIN_YOUTUBE_MUSIC}")
|
||||
append("Authorization", "SAPISIDHASH ${currentTime}_${sapisidHash}")
|
||||
}
|
||||
}
|
||||
}
|
||||
userAgent(client.userAgent)
|
||||
parameter("prettyPrint", false)
|
||||
}
|
||||
|
||||
suspend fun player(
|
||||
client: YouTubeClient,
|
||||
videoId: String,
|
||||
playlistId: String?,
|
||||
signatureTimestamp: Int?,
|
||||
webPlayerPot: String?,
|
||||
) = httpClient.post("player") {
|
||||
ytClient(client, setLogin = true)
|
||||
setBody(
|
||||
MyPlayerBody(
|
||||
context = client.toContext(locale, visitorData, dataSyncId).let {
|
||||
if (client.isEmbedded) {
|
||||
it.copy(
|
||||
thirdParty = MyContext.ThirdParty(
|
||||
embedUrl = "https://www.youtube.com/watch?v=${videoId}"
|
||||
)
|
||||
)
|
||||
} else it
|
||||
},
|
||||
videoId = videoId,
|
||||
playlistId = playlistId,
|
||||
playbackContext = if (client.useSignatureTimestamp && signatureTimestamp != null) {
|
||||
MyPlayerBody.PlaybackContext(
|
||||
MyPlayerBody.PlaybackContext.ContentPlaybackContext(
|
||||
signatureTimestamp
|
||||
)
|
||||
)
|
||||
} else null,
|
||||
serviceIntegrityDimensions = if (client.useWebPoTokens && webPlayerPot != null) {
|
||||
MyPlayerBody.ServiceIntegrityDimensions(webPlayerPot)
|
||||
} else null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getSwJsData() = httpClient.get("https://music.youtube.com/sw.js_data")
|
||||
|
||||
}
|
||||
@ -0,0 +1,108 @@
|
||||
package melody.offline.music.innertube.models
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* PlayerResponse with [com.zionhuang.innertube.models.YouTubeClient.WEB_REMIX] client
|
||||
*/
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
@Serializable
|
||||
data class MyPlayerResponse(
|
||||
val responseContext: MyResponseContext,
|
||||
val playabilityStatus: PlayabilityStatus,
|
||||
val playerConfig: PlayerConfig?,
|
||||
val streamingData: StreamingData?,
|
||||
val videoDetails: VideoDetails?,
|
||||
@SerialName("playbackTracking")
|
||||
val playbackTracking: PlaybackTracking?,
|
||||
) {
|
||||
@Serializable
|
||||
data class PlayabilityStatus(
|
||||
val status: String,
|
||||
val reason: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlayerConfig(
|
||||
val audioConfig: AudioConfig,
|
||||
) {
|
||||
@Serializable
|
||||
data class AudioConfig(
|
||||
val loudnessDb: Double?,
|
||||
val perceptualLoudnessDb: Double?,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class StreamingData(
|
||||
val formats: List<Format>?,
|
||||
val adaptiveFormats: List<Format>,
|
||||
val expiresInSeconds: Int,
|
||||
) {
|
||||
@Serializable
|
||||
data class Format(
|
||||
val itag: Int,
|
||||
val url: String?,
|
||||
val mimeType: String,
|
||||
val bitrate: Int,
|
||||
val width: Int?,
|
||||
val height: Int?,
|
||||
val contentLength: Long?,
|
||||
val quality: String,
|
||||
val fps: Int?,
|
||||
val qualityLabel: String?,
|
||||
val averageBitrate: Int?,
|
||||
val audioQuality: String?,
|
||||
val approxDurationMs: String?,
|
||||
val audioSampleRate: Int?,
|
||||
val audioChannels: Int?,
|
||||
val loudnessDb: Double?,
|
||||
val lastModified: Long?,
|
||||
val signatureCipher: String?,
|
||||
) {
|
||||
val isAudio: Boolean
|
||||
get() = width == null
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class VideoDetails(
|
||||
val videoId: String,
|
||||
val title: String,
|
||||
val author: String,
|
||||
val channelId: String,
|
||||
val lengthSeconds: String,
|
||||
val musicVideoType: String?,
|
||||
val viewCount: String,
|
||||
val thumbnail: Thumbnails,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlaybackTracking(
|
||||
@SerialName("videostatsPlaybackUrl")
|
||||
val videostatsPlaybackUrl: VideostatsPlaybackUrl?,
|
||||
@SerialName("videostatsWatchtimeUrl")
|
||||
val videostatsWatchtimeUrl: VideostatsWatchtimeUrl?,
|
||||
@SerialName("atrUrl")
|
||||
val atrUrl: AtrUrl?,
|
||||
) {
|
||||
@Serializable
|
||||
data class VideostatsPlaybackUrl(
|
||||
@SerialName("baseUrl")
|
||||
val baseUrl: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VideostatsWatchtimeUrl(
|
||||
@SerialName("baseUrl")
|
||||
val baseUrl: String?,
|
||||
)
|
||||
@Serializable
|
||||
data class AtrUrl(
|
||||
@SerialName("baseUrl")
|
||||
val baseUrl: String?,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package melody.offline.music.innertube.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MyResponseContext(
|
||||
val visitorData: String?,
|
||||
val serviceTrackingParams: List<ServiceTrackingParam>?,
|
||||
) {
|
||||
@Serializable
|
||||
data class ServiceTrackingParam(
|
||||
val params: List<Param>,
|
||||
val service: String,
|
||||
) {
|
||||
@Serializable
|
||||
data class Param(
|
||||
val key: String,
|
||||
val value: String,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package melody.offline.music.innertube.models
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
@Serializable
|
||||
data class Thumbnails(
|
||||
val thumbnails: List<Thumbnail>,
|
||||
)
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
package melody.offline.music.innertube.utils
|
||||
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import io.ktor.utils.io.CancellationException
|
||||
import melody.offline.music.innertube.Innertube
|
||||
import melody.offline.music.innertube.models.SectionListRenderer
|
||||
import java.security.MessageDigest
|
||||
|
||||
internal fun SectionListRenderer.findSectionByTitle(text: String): SectionListRenderer.Content? {
|
||||
return contents?.find { content ->
|
||||
@ -48,3 +50,12 @@ infix operator fun <T : Innertube.Item> Innertube.ItemsPage<T>?.plus(other: Inne
|
||||
items = (this?.items?.plus(other.items ?: emptyList())
|
||||
?: other.items)?.distinctBy(Innertube.Item::key)
|
||||
)
|
||||
fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }
|
||||
|
||||
fun sha1(str: String): String = MessageDigest.getInstance("SHA-1").digest(str.toByteArray()).toHex()
|
||||
|
||||
fun reportException(throwable: Throwable) {
|
||||
throwable.printStackTrace()
|
||||
}
|
||||
|
||||
val VisitorDataKey = stringPreferencesKey("visitorData")
|
||||
@ -0,0 +1,8 @@
|
||||
package melody.offline.music.playernew
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
|
||||
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "vo_settings")
|
||||
@ -0,0 +1,40 @@
|
||||
package melody.offline.music.playernew
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
@Serializable
|
||||
data class MyContext(
|
||||
val client: Client,
|
||||
val thirdParty: ThirdParty? = null,
|
||||
private val request: Request = Request(),
|
||||
private val user: User = User(),
|
||||
) {
|
||||
@Serializable
|
||||
data class Client(
|
||||
val clientName: String,
|
||||
val clientVersion: String,
|
||||
val osVersion: String?,
|
||||
val gl: String,
|
||||
val hl: String,
|
||||
val visitorData: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ThirdParty(
|
||||
val embedUrl: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Request(
|
||||
val internalExperimentFlags: Array<String> = emptyArray(),
|
||||
val useSsl: Boolean = true,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class User(
|
||||
val lockedSafetyMode: Boolean = false,
|
||||
val onBehalfOfUser: String? = null,
|
||||
)
|
||||
}
|
||||
100
app/src/main/java/melody/offline/music/playernew/NewPipe.kt
Normal file
100
app/src/main/java/melody/offline/music/playernew/NewPipe.kt
Normal file
@ -0,0 +1,100 @@
|
||||
package melody.offline.music.playernew
|
||||
|
||||
import io.ktor.http.URLBuilder
|
||||
import io.ktor.http.parseQueryString
|
||||
import melody.offline.music.innertube.models.MyPlayerResponse
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.schabi.newpipe.extractor.downloader.Downloader
|
||||
import org.schabi.newpipe.extractor.downloader.Request
|
||||
import org.schabi.newpipe.extractor.downloader.Response
|
||||
import org.schabi.newpipe.extractor.exceptions.ParsingException
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
||||
import org.schabi.newpipe.extractor.services.youtube.YoutubeJavaScriptPlayerManager
|
||||
import java.io.IOException
|
||||
import java.net.Proxy
|
||||
import kotlin.collections.forEach
|
||||
import kotlin.jvm.Throws
|
||||
import kotlin.let
|
||||
import kotlin.runCatching
|
||||
|
||||
private class NewPipeDownloaderImpl(proxy: Proxy?) : Downloader() {
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
.build()
|
||||
|
||||
@Throws(IOException::class, ReCaptchaException::class)
|
||||
override fun execute(request: Request): Response {
|
||||
val httpMethod = request.httpMethod()
|
||||
val url = request.url()
|
||||
val headers = request.headers()
|
||||
val dataToSend = request.dataToSend()
|
||||
|
||||
val requestBuilder = okhttp3.Request.Builder()
|
||||
.method(httpMethod, dataToSend?.toRequestBody())
|
||||
.url(url)
|
||||
.addHeader("User-Agent", YouTubeClient.USER_AGENT_WEB)
|
||||
|
||||
headers.forEach { (headerName, headerValueList) ->
|
||||
if (headerValueList.size > 1) {
|
||||
requestBuilder.removeHeader(headerName)
|
||||
headerValueList.forEach { headerValue ->
|
||||
requestBuilder.addHeader(headerName, headerValue)
|
||||
}
|
||||
} else if (headerValueList.size == 1) {
|
||||
requestBuilder.header(headerName, headerValueList[0])
|
||||
}
|
||||
}
|
||||
|
||||
val response = client.newCall(requestBuilder.build()).execute()
|
||||
|
||||
if (response.code == 429) {
|
||||
response.close()
|
||||
|
||||
throw ReCaptchaException("reCaptcha Challenge requested", url)
|
||||
}
|
||||
|
||||
val responseBodyToReturn = response.body?.string()
|
||||
|
||||
val latestUrl = response.request.url.toString()
|
||||
return Response(response.code, response.message, response.headers.toMultimap(), responseBodyToReturn, latestUrl)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
object NewPipeUtils {
|
||||
|
||||
init {
|
||||
// NewPipe.init(NewPipeDownloaderImpl(YouTube.proxy))
|
||||
}
|
||||
|
||||
fun getSignatureTimestamp(videoId: String): Result<Int> = runCatching {
|
||||
YoutubeJavaScriptPlayerManager.getSignatureTimestamp(videoId)
|
||||
}
|
||||
|
||||
fun getStreamUrl(format: MyPlayerResponse.StreamingData.Format, videoId: String): Result<String> =
|
||||
runCatching {
|
||||
val url = format.url ?: format.signatureCipher?.let { signatureCipher ->
|
||||
val params = parseQueryString(signatureCipher)
|
||||
val obfuscatedSignature = params["s"]
|
||||
?: throw ParsingException("Could not parse cipher signature")
|
||||
val signatureParam = params["sp"]
|
||||
?: throw ParsingException("Could not parse cipher signature parameter")
|
||||
val url = params["url"]?.let { URLBuilder(it) }
|
||||
?: throw ParsingException("Could not parse cipher url")
|
||||
url.parameters[signatureParam] =
|
||||
YoutubeJavaScriptPlayerManager.deobfuscateSignature(
|
||||
videoId,
|
||||
obfuscatedSignature
|
||||
)
|
||||
url.toString()
|
||||
} ?: throw ParsingException("Could not find format url")
|
||||
|
||||
return@runCatching YoutubeJavaScriptPlayerManager.getUrlWithThrottlingParameterDeobfuscated(
|
||||
videoId,
|
||||
url
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,242 @@
|
||||
package melody.offline.music.playernew
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.ConnectivityManager
|
||||
import android.util.Log
|
||||
import androidx.media3.common.PlaybackException
|
||||
import melody.offline.music.innertube.models.MyPlayerResponse
|
||||
import melody.offline.music.playernew.YouTubeClient.Companion.IOS
|
||||
import melody.offline.music.playernew.YouTubeClient.Companion.TVHTML5_SIMPLY_EMBEDDED_PLAYER
|
||||
import melody.offline.music.playernew.YouTubeClient.Companion.WEB_REMIX
|
||||
import melody.offline.music.playernew.potoken.PoTokenGenerator
|
||||
import melody.offline.music.playernew.potoken.PoTokenResult
|
||||
import melody.offline.music.innertube.utils.reportException
|
||||
import melody.offline.music.playernew.YTPlayerUtils.validateStatus
|
||||
import melody.offline.music.playernew.constants.AudioQuality
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
object YTPlayerUtils {
|
||||
|
||||
private const val TAG = "vo-YTPlayerUtils"
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.proxy(YouTube.proxy)
|
||||
.build()
|
||||
|
||||
private val poTokenGenerator = PoTokenGenerator()
|
||||
|
||||
private val MAIN_CLIENT: YouTubeClient = WEB_REMIX
|
||||
|
||||
private val STREAM_FALLBACK_CLIENTS: Array<YouTubeClient> = arrayOf(
|
||||
TVHTML5_SIMPLY_EMBEDDED_PLAYER,
|
||||
IOS,
|
||||
)
|
||||
|
||||
data class PlaybackData(
|
||||
val audioConfig: MyPlayerResponse.PlayerConfig.AudioConfig?,
|
||||
val videoDetails: MyPlayerResponse.VideoDetails?,
|
||||
val playbackTracking: MyPlayerResponse.PlaybackTracking?,
|
||||
val format: MyPlayerResponse.StreamingData.Format,
|
||||
val streamUrl: String,
|
||||
val streamExpiresInSeconds: Int,
|
||||
)
|
||||
|
||||
private fun getSignatureTimestampOrNull(
|
||||
videoId: String
|
||||
): Int? {
|
||||
return NewPipeUtils.getSignatureTimestamp(videoId)
|
||||
.onFailure {
|
||||
reportException(it)
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
suspend fun playerResponseForPlayback(
|
||||
videoId: String,
|
||||
playlistId: String? = null,
|
||||
connectivityManager: ConnectivityManager,
|
||||
): Result<PlaybackData> = runCatching {
|
||||
Log.d(TAG, "Playback info requested: $videoId")
|
||||
|
||||
val signatureTimestamp = getSignatureTimestampOrNull(videoId)
|
||||
|
||||
val isLoggedIn = false
|
||||
val sessionId = YouTube.visitorData
|
||||
|
||||
Log.d(TAG, "[$videoId] signatureTimestamp: $signatureTimestamp, isLoggedIn: $isLoggedIn , YouTube.visitorData:${YouTube.visitorData}")
|
||||
|
||||
val (webPlayerPot, webStreamingPot) = getWebClientPoTokenOrNull(videoId, sessionId)?.let {
|
||||
Pair(it.playerRequestPoToken, it.streamingDataPoToken)
|
||||
} ?: Pair(null, null).also {
|
||||
Log.w(TAG, "[$videoId] No po token")
|
||||
}
|
||||
|
||||
val mainPlayerResponse =
|
||||
YouTube.player(videoId, playlistId, MAIN_CLIENT, signatureTimestamp, webPlayerPot)
|
||||
.getOrThrow()
|
||||
|
||||
val audioConfig = mainPlayerResponse.playerConfig?.audioConfig
|
||||
val videoDetails = mainPlayerResponse.videoDetails
|
||||
val playbackTracking = mainPlayerResponse.playbackTracking
|
||||
|
||||
var format: MyPlayerResponse.StreamingData.Format? = null
|
||||
var streamUrl: String? = null
|
||||
var streamExpiresInSeconds: Int? = null
|
||||
|
||||
var streamPlayerResponse: MyPlayerResponse? = null
|
||||
for (clientIndex in (-1 until STREAM_FALLBACK_CLIENTS.size)) {
|
||||
// reset for each client
|
||||
format = null
|
||||
streamUrl = null
|
||||
streamExpiresInSeconds = null
|
||||
|
||||
// decide which client to use for streams and load its player response
|
||||
val client: YouTubeClient
|
||||
if (clientIndex == -1) {
|
||||
// try with streams from main client first
|
||||
client = MAIN_CLIENT
|
||||
streamPlayerResponse = mainPlayerResponse
|
||||
} else {
|
||||
// after main client use fallback clients
|
||||
client = STREAM_FALLBACK_CLIENTS[clientIndex]
|
||||
|
||||
if (client.loginRequired && !isLoggedIn) {
|
||||
// skip client if it requires login but user is not logged in
|
||||
continue
|
||||
}
|
||||
Log.d(TAG, "[$videoId] [$playlistId] [$client] [$signatureTimestamp] [$webPlayerPot]")
|
||||
|
||||
streamPlayerResponse =
|
||||
YouTube.player(videoId, playlistId, client, signatureTimestamp, webPlayerPot)
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
Log.d(TAG, "[$videoId] stream client: ${client.clientName}, " +
|
||||
"playabilityStatus: ${streamPlayerResponse?.playabilityStatus?.let {
|
||||
it.status + (it.reason?.let { " - $it" } ?: "")
|
||||
}}")
|
||||
|
||||
// process current client response
|
||||
if (streamPlayerResponse?.playabilityStatus?.status == "OK") {
|
||||
format =
|
||||
findFormat(
|
||||
streamPlayerResponse,
|
||||
connectivityManager,
|
||||
) ?: continue
|
||||
streamUrl = findUrlOrNull(format, videoId) ?: continue
|
||||
streamExpiresInSeconds =
|
||||
streamPlayerResponse.streamingData?.expiresInSeconds ?: continue
|
||||
|
||||
if (client.useWebPoTokens && webStreamingPot != null) {
|
||||
streamUrl += "&pot=$webStreamingPot";
|
||||
}
|
||||
|
||||
if (clientIndex == STREAM_FALLBACK_CLIENTS.size - 1) {
|
||||
/** skip [validateStatus] for last client */
|
||||
break
|
||||
}
|
||||
if (validateStatus(streamUrl)) {
|
||||
// working stream found
|
||||
break
|
||||
} else {
|
||||
Log.d(TAG, "[$videoId] [${client.clientName}] got bad http status code")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (streamPlayerResponse == null) {
|
||||
throw Exception("Bad stream player response")
|
||||
}
|
||||
if (streamPlayerResponse.playabilityStatus.status != "OK") {
|
||||
throw PlaybackException(
|
||||
streamPlayerResponse.playabilityStatus.reason,
|
||||
null,
|
||||
PlaybackException.ERROR_CODE_REMOTE_ERROR
|
||||
)
|
||||
}
|
||||
if (streamExpiresInSeconds == null) {
|
||||
throw Exception("Missing stream expire time")
|
||||
}
|
||||
if (format == null) {
|
||||
throw Exception("Could not find format")
|
||||
}
|
||||
if (streamUrl == null) {
|
||||
throw Exception("Could not find stream url")
|
||||
}
|
||||
|
||||
Log.d(TAG, "[$videoId] stream url: $streamUrl")
|
||||
|
||||
PlaybackData(
|
||||
audioConfig,
|
||||
videoDetails,
|
||||
playbackTracking,
|
||||
format,
|
||||
streamUrl,
|
||||
streamExpiresInSeconds,
|
||||
)
|
||||
}
|
||||
|
||||
private fun findFormat(
|
||||
playerResponse: MyPlayerResponse,
|
||||
connectivityManager: ConnectivityManager,
|
||||
): MyPlayerResponse.StreamingData.Format? =
|
||||
playerResponse.streamingData?.adaptiveFormats
|
||||
?.filter { it.isAudio }
|
||||
?.maxByOrNull {
|
||||
it.bitrate * when (AudioQuality.AUTO) {
|
||||
AudioQuality.AUTO -> if (connectivityManager.isActiveNetworkMetered) -1 else 1
|
||||
AudioQuality.HIGH -> 1
|
||||
AudioQuality.LOW -> -1
|
||||
} + (if (it.mimeType.startsWith("audio/webm")) 10240 else 0) // prefer opus stream
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around the [NewPipeUtils.getStreamUrl] function which reports exceptions
|
||||
*/
|
||||
private fun findUrlOrNull(
|
||||
format: MyPlayerResponse.StreamingData.Format,
|
||||
videoId: String
|
||||
): String? {
|
||||
return NewPipeUtils.getStreamUrl(format, videoId)
|
||||
.onFailure {
|
||||
reportException(it)
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the stream url returns a successful status.
|
||||
* If this returns true the url is likely to work.
|
||||
* If this returns false the url might cause an error during playback.
|
||||
*/
|
||||
private fun validateStatus(url: String): Boolean {
|
||||
try {
|
||||
val requestBuilder = Request.Builder()
|
||||
.head()
|
||||
.url(url)
|
||||
val response = httpClient.newCall(requestBuilder.build()).execute()
|
||||
return response.isSuccessful
|
||||
} catch (e: Exception) {
|
||||
reportException(e)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around the [PoTokenGenerator.getWebClientPoToken] function which reports exceptions
|
||||
*/
|
||||
private fun getWebClientPoTokenOrNull(videoId: String, sessionId: String?): PoTokenResult? {
|
||||
if (sessionId == null) {
|
||||
Log.d(TAG, "[$videoId] Session identifier is null")
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return poTokenGenerator.getWebClientPoToken(videoId, sessionId)
|
||||
} catch (e: Exception) {
|
||||
reportException(e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
74
app/src/main/java/melody/offline/music/playernew/YouTube.kt
Normal file
74
app/src/main/java/melody/offline/music/playernew/YouTube.kt
Normal file
@ -0,0 +1,74 @@
|
||||
package melody.offline.music.playernew
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import melody.offline.music.innertube.MyInnerTube
|
||||
import melody.offline.music.innertube.models.MyPlayerResponse
|
||||
import java.net.Proxy
|
||||
|
||||
/**
|
||||
* Parse useful data with [InnerTube] sending requests.
|
||||
* Modified from [ViMusic](https://github.com/vfsfitvnm/ViMusic)
|
||||
*/
|
||||
object YouTube {
|
||||
private val innerTube = MyInnerTube()
|
||||
|
||||
var proxy: Proxy?
|
||||
get() = innerTube.proxy
|
||||
set(value) {
|
||||
innerTube.proxy = value
|
||||
}
|
||||
|
||||
var visitorData: String?
|
||||
get() = innerTube.visitorData
|
||||
set(value) {
|
||||
innerTube.visitorData = value
|
||||
}
|
||||
|
||||
suspend fun player(videoId: String, playlistId: String? = null, client: YouTubeClient, signatureTimestamp: Int? = null, webPlayerPot: String? = null): Result<MyPlayerResponse> = runCatching {
|
||||
innerTube.player(client, videoId, playlistId, signatureTimestamp, webPlayerPot).body<MyPlayerResponse>()
|
||||
}
|
||||
|
||||
suspend fun visitorData(): Result<String> = runCatching {
|
||||
Json.parseToJsonElement(innerTube.getSwJsData().bodyAsText().substring(5))
|
||||
.jsonArray[0]
|
||||
.jsonArray[2]
|
||||
.jsonArray.first {
|
||||
(it as? JsonPrimitive)?.contentOrNull?.let { candidate ->
|
||||
VISITOR_DATA_REGEX.containsMatchIn(candidate)
|
||||
} ?: false
|
||||
}
|
||||
.jsonPrimitive.content
|
||||
}
|
||||
|
||||
@JvmInline
|
||||
value class SearchFilter(val value: String) {
|
||||
companion object {
|
||||
val FILTER_SONG = SearchFilter("EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D")
|
||||
val FILTER_VIDEO = SearchFilter("EgWKAQIQAWoKEAkQChAFEAMQBA%3D%3D")
|
||||
val FILTER_ALBUM = SearchFilter("EgWKAQIYAWoKEAkQChAFEAMQBA%3D%3D")
|
||||
val FILTER_ARTIST = SearchFilter("EgWKAQIgAWoKEAkQChAFEAMQBA%3D%3D")
|
||||
val FILTER_FEATURED_PLAYLIST = SearchFilter("EgeKAQQoADgBagwQDhAKEAMQBRAJEAQ%3D")
|
||||
val FILTER_COMMUNITY_PLAYLIST = SearchFilter("EgeKAQQoAEABagoQAxAEEAoQCRAF")
|
||||
}
|
||||
}
|
||||
|
||||
@JvmInline
|
||||
value class LibraryFilter(val value: String) {
|
||||
companion object {
|
||||
val FILTER_RECENT_ACTIVITY = LibraryFilter("4qmFsgIrEhdGRW11c2ljX2xpYnJhcnlfbGFuZGluZxoQZ2dNR0tnUUlCaEFCb0FZQg%3D%3D")
|
||||
val FILTER_RECENTLY_PLAYED = LibraryFilter("4qmFsgIrEhdGRW11c2ljX2xpYnJhcnlfbGFuZGluZxoQZ2dNR0tnUUlCUkFCb0FZQg%3D%3D")
|
||||
val FILTER_PLAYLISTS_ALPHABETICAL = LibraryFilter("4qmFsgIrEhdGRW11c2ljX2xpa2VkX3BsYXlsaXN0cxoQZ2dNR0tnUUlBUkFBb0FZQg%3D%3D")
|
||||
val FILTER_PLAYLISTS_RECENTLY_SAVED = LibraryFilter("4qmFsgIrEhdGRW11c2ljX2xpa2VkX3BsYXlsaXN0cxoQZ2dNR0tnUUlBQkFCb0FZQg%3D%3D")
|
||||
}
|
||||
}
|
||||
|
||||
const val MAX_GET_QUEUE_SIZE = 1000
|
||||
|
||||
private val VISITOR_DATA_REGEX = Regex("^Cg[t|s]")
|
||||
}
|
||||
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
@ -0,0 +1,198 @@
|
||||
package melody.offline.music.playernew.constants
|
||||
|
||||
/*
|
||||
---------------------------
|
||||
Appearance & interface
|
||||
---------------------------
|
||||
*/
|
||||
enum class DarkMode {
|
||||
ON, OFF, AUTO
|
||||
}
|
||||
|
||||
enum class PlayerBackgroundStyle {
|
||||
DEFAULT, GRADIENT, BLUR
|
||||
}
|
||||
|
||||
enum class LibraryViewType {
|
||||
LIST, GRID;
|
||||
|
||||
fun toggle() = when (this) {
|
||||
LIST -> GRID
|
||||
GRID -> LIST
|
||||
}
|
||||
}
|
||||
|
||||
enum class LyricsPosition {
|
||||
LEFT, CENTER, RIGHT
|
||||
}
|
||||
|
||||
const val DEFAULT_ENABLED_TABS = "HSFM"
|
||||
const val DEFAULT_ENABLED_FILTERS = "ARP"
|
||||
|
||||
/*
|
||||
---------------------------
|
||||
Sync
|
||||
---------------------------
|
||||
*/
|
||||
|
||||
enum class SyncMode {
|
||||
RO, RW, // USER_CHOICE
|
||||
}
|
||||
|
||||
enum class SyncConflictResolution {
|
||||
ADD_ONLY, OVERWRITE_WITH_REMOTE, // OVERWRITE_WITH_LOCAL, USER_CHOICE
|
||||
}
|
||||
|
||||
// when adding an enum:
|
||||
// 1. add settings checkbox string and state
|
||||
// 2. add to DEFAULT_SYNC_CONTENT
|
||||
// 3. add to encode/decode
|
||||
// 4. figure out if it's necessary to update existing user's keys
|
||||
enum class SyncContent {
|
||||
ALBUMS,
|
||||
ARTISTS,
|
||||
LIKED_SONGS,
|
||||
PLAYLISTS,
|
||||
PRIVATE_SONGS,
|
||||
RECENT_ACTIVITY,
|
||||
NULL
|
||||
}
|
||||
|
||||
/**
|
||||
* A: Albums
|
||||
* R: Artists
|
||||
* P: Playlists
|
||||
* L: Liked songs
|
||||
* S: Library (privately uploaded) songs
|
||||
* C: Recent activity
|
||||
* N: <Unused option>
|
||||
*/
|
||||
val syncPairs = listOf(
|
||||
SyncContent.ALBUMS to 'A',
|
||||
SyncContent.ARTISTS to 'R',
|
||||
SyncContent.PLAYLISTS to 'P',
|
||||
SyncContent.LIKED_SONGS to 'L',
|
||||
SyncContent.PRIVATE_SONGS to 'S',
|
||||
SyncContent.RECENT_ACTIVITY to 'C'
|
||||
)
|
||||
|
||||
/**
|
||||
* Converts the enable sync items list (string) to SyncContent
|
||||
*
|
||||
* @param sync Encoded string
|
||||
*/
|
||||
fun decodeSyncString(sync: String): List<SyncContent> {
|
||||
val charToSyncMap = syncPairs.associate { (screen, char) -> char to screen }
|
||||
|
||||
return sync.toCharArray().map { char -> charToSyncMap[char] ?: SyncContent.NULL }
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the SyncContent filters list to string
|
||||
*
|
||||
* @param list Decoded SyncContent list
|
||||
*/
|
||||
fun encodeSyncString(list: List<SyncContent>): String {
|
||||
val charToSyncMap = syncPairs.associate { (sync, char) -> char to sync }
|
||||
|
||||
return list.distinct().joinToString("") { sync ->
|
||||
charToSyncMap.entries.first { it.value == sync }.key.toString()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
---------------------------
|
||||
Local scanner
|
||||
---------------------------
|
||||
*/
|
||||
|
||||
enum class ScannerImpl {
|
||||
TAGLIB,
|
||||
FFMPEG_EXT,
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify how strict the metadata scanner should be
|
||||
*/
|
||||
enum class ScannerMatchCriteria {
|
||||
LEVEL_1, // Title only
|
||||
LEVEL_2, // Title and artists
|
||||
LEVEL_3, // Title, artists, albums
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
---------------------------
|
||||
Player & audio
|
||||
---------------------------
|
||||
*/
|
||||
enum class AudioQuality {
|
||||
AUTO, HIGH, LOW
|
||||
}
|
||||
|
||||
/*
|
||||
---------------------------
|
||||
Library & Content
|
||||
---------------------------
|
||||
*/
|
||||
|
||||
|
||||
enum class LikedAutodownloadMode {
|
||||
OFF, ON, WIFI_ONLY
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
---------------------------
|
||||
Misc preferences not bound
|
||||
to settings category
|
||||
---------------------------
|
||||
*/
|
||||
enum class SongSortType {
|
||||
CREATE_DATE, MODIFIED_DATE, RELEASE_DATE, NAME, ARTIST, PLAY_TIME, PLAY_COUNT
|
||||
}
|
||||
|
||||
enum class PlaylistSongSortType {
|
||||
CUSTOM, CREATE_DATE, NAME, ARTIST, PLAY_TIME
|
||||
}
|
||||
|
||||
enum class ArtistSortType {
|
||||
CREATE_DATE, NAME, SONG_COUNT, PLAY_TIME
|
||||
}
|
||||
|
||||
enum class ArtistSongSortType {
|
||||
CREATE_DATE, NAME, PLAY_TIME
|
||||
}
|
||||
|
||||
enum class AlbumSortType {
|
||||
CREATE_DATE, NAME, ARTIST, YEAR, SONG_COUNT, LENGTH, PLAY_TIME
|
||||
}
|
||||
|
||||
enum class PlaylistSortType {
|
||||
CREATE_DATE, NAME, SONG_COUNT
|
||||
}
|
||||
|
||||
enum class LibrarySortType {
|
||||
CREATE_DATE, NAME
|
||||
}
|
||||
|
||||
enum class SongFilter {
|
||||
LIBRARY, LIKED, DOWNLOADED
|
||||
}
|
||||
|
||||
enum class ArtistFilter {
|
||||
LIBRARY, LIKED, DOWNLOADED
|
||||
}
|
||||
|
||||
enum class AlbumFilter {
|
||||
LIBRARY, LIKED, DOWNLOADED
|
||||
}
|
||||
|
||||
enum class PlaylistFilter {
|
||||
LIBRARY, DOWNLOADED
|
||||
}
|
||||
|
||||
enum class SearchSource {
|
||||
LOCAL, ONLINE
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
package melody.offline.music.playernew.potoken
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import okio.ByteString.Companion.decodeBase64
|
||||
import okio.ByteString.Companion.toByteString
|
||||
|
||||
/**
|
||||
* Parses the raw challenge data obtained from the Create endpoint and returns an object that can be
|
||||
* embedded in a JavaScript snippet.
|
||||
*/
|
||||
fun parseChallengeData(rawChallengeData: String): String {
|
||||
val scrambled = Json.parseToJsonElement(rawChallengeData).jsonArray
|
||||
|
||||
val challengeData = if (scrambled.size > 1 && scrambled[1].jsonPrimitive.isString) {
|
||||
val descrambled = descramble(scrambled[1].jsonPrimitive.content)
|
||||
Json.parseToJsonElement(descrambled).jsonArray
|
||||
} else {
|
||||
scrambled[0].jsonArray
|
||||
}
|
||||
|
||||
val messageId = challengeData[0].jsonPrimitive.content
|
||||
val interpreterHash = challengeData[3].jsonPrimitive.content
|
||||
val program = challengeData[4].jsonPrimitive.content
|
||||
val globalName = challengeData[5].jsonPrimitive.content
|
||||
val clientExperimentsStateBlob = challengeData[7].jsonPrimitive.content
|
||||
|
||||
val privateDoNotAccessOrElseSafeScriptWrappedValue = challengeData[1]
|
||||
.takeIf { it !is JsonNull }
|
||||
?.jsonArray
|
||||
?.find { it.jsonPrimitive.isString }
|
||||
val privateDoNotAccessOrElseTrustedResourceUrlWrappedValue = challengeData[2]
|
||||
.takeIf { it !is JsonNull }
|
||||
?.jsonArray
|
||||
?.find { it.jsonPrimitive.isString }
|
||||
|
||||
return Json.encodeToString(
|
||||
JsonObject.serializer(), JsonObject(
|
||||
mapOf(
|
||||
"messageId" to JsonPrimitive(messageId),
|
||||
"interpreterJavascript" to JsonObject(
|
||||
mapOf(
|
||||
"privateDoNotAccessOrElseSafeScriptWrappedValue" to (privateDoNotAccessOrElseSafeScriptWrappedValue
|
||||
?: JsonNull),
|
||||
"privateDoNotAccessOrElseTrustedResourceUrlWrappedValue" to (privateDoNotAccessOrElseTrustedResourceUrlWrappedValue
|
||||
?: JsonNull)
|
||||
)
|
||||
),
|
||||
"interpreterHash" to JsonPrimitive(interpreterHash),
|
||||
"program" to JsonPrimitive(program),
|
||||
"globalName" to JsonPrimitive(globalName),
|
||||
"clientExperimentsStateBlob" to JsonPrimitive(clientExperimentsStateBlob)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the raw integrity token data obtained from the GenerateIT endpoint to a JavaScript
|
||||
* `Uint8Array` that can be embedded directly in JavaScript code, and an [Int] representing the
|
||||
* duration of this token in seconds.
|
||||
*/
|
||||
fun parseIntegrityTokenData(rawIntegrityTokenData: String): Pair<String, Long> {
|
||||
val integrityTokenData = Json.parseToJsonElement(rawIntegrityTokenData).jsonArray
|
||||
return base64ToU8(integrityTokenData[0].jsonPrimitive.content) to integrityTokenData[1].jsonPrimitive.long
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a string (usually the identifier used as input to `obtainPoToken`) to a JavaScript
|
||||
* `Uint8Array` that can be embedded directly in JavaScript code.
|
||||
*/
|
||||
fun stringToU8(identifier: String): String {
|
||||
return newUint8Array(identifier.toByteArray())
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a poToken encoded as a sequence of bytes represented as integers separated by commas
|
||||
* (e.g. "97,98,99" would be "abc"), which is the output of `Uint8Array::toString()` in JavaScript,
|
||||
* and converts it to the specific base64 representation for poTokens.
|
||||
*/
|
||||
fun u8ToBase64(poToken: String): String {
|
||||
return poToken.split(",")
|
||||
.map { it.toUByte().toByte() }
|
||||
.toByteArray()
|
||||
.toByteString()
|
||||
.base64()
|
||||
.replace("+", "-")
|
||||
.replace("/", "_")
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the scrambled challenge, decodes it from base64, adds 97 to each byte.
|
||||
*/
|
||||
private fun descramble(scrambledChallenge: String): String {
|
||||
return base64ToByteString(scrambledChallenge)
|
||||
.map { (it + 97).toByte() }
|
||||
.toByteArray()
|
||||
.decodeToString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a base64 string encoded in the specific base64 representation used by YouTube, and
|
||||
* returns a JavaScript `Uint8Array` that can be embedded directly in JavaScript code.
|
||||
*/
|
||||
private fun base64ToU8(base64: String): String {
|
||||
return newUint8Array(base64ToByteString(base64))
|
||||
}
|
||||
|
||||
private fun newUint8Array(contents: ByteArray): String {
|
||||
return "new Uint8Array([" + contents.joinToString(separator = ",") { it.toUByte().toString() } + "])"
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a base64 string encoded in the specific base64 representation used by YouTube.
|
||||
*/
|
||||
private fun base64ToByteString(base64: String): ByteArray {
|
||||
val base64Mod = base64
|
||||
.replace('-', '+')
|
||||
.replace('_', '/')
|
||||
.replace('.', '=')
|
||||
|
||||
return (base64Mod.decodeBase64() ?: throw PoTokenException("Cannot base64 decode"))
|
||||
.toByteArray()
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package melody.offline.music.playernew.potoken
|
||||
|
||||
class PoTokenResult(
|
||||
val playerRequestPoToken: String,
|
||||
val streamingDataPoToken: String,
|
||||
)
|
||||
@ -0,0 +1,341 @@
|
||||
package melody.offline.music.playernew.potoken
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.webkit.ConsoleMessage
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.collection.ArrayMap
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import melody.offline.music.BuildConfig
|
||||
import melody.offline.music.playernew.YouTube
|
||||
import okhttp3.Headers.Companion.toHeaders
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.Collections
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
class PoTokenWebView private constructor(
|
||||
context: Context,
|
||||
// to be used exactly once only during initialization!
|
||||
private val continuation: Continuation<PoTokenWebView>,
|
||||
) {
|
||||
private val webView = WebView(context)
|
||||
private val scope = MainScope()
|
||||
private val poTokenContinuations =
|
||||
Collections.synchronizedMap(ArrayMap<String, Continuation<String>>())
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, t ->
|
||||
onInitializationErrorCloseAndCancel(t)
|
||||
}
|
||||
private lateinit var expirationInstant: Instant
|
||||
|
||||
//region Initialization
|
||||
init {
|
||||
val webViewSettings = webView.settings
|
||||
//noinspection SetJavaScriptEnabled we want to use JavaScript!
|
||||
webViewSettings.javaScriptEnabled = true
|
||||
webViewSettings.safeBrowsingEnabled = false
|
||||
webViewSettings.userAgentString = USER_AGENT
|
||||
webViewSettings.blockNetworkLoads = true // the WebView does not need internet access
|
||||
|
||||
// so that we can run async functions and get back the result
|
||||
webView.addJavascriptInterface(this, JS_INTERFACE)
|
||||
|
||||
webView.webChromeClient = object : WebChromeClient() {
|
||||
override fun onConsoleMessage(m: ConsoleMessage): Boolean {
|
||||
if (m.message().contains("Uncaught")) {
|
||||
// There should not be any uncaught errors while executing the code, because
|
||||
// everything that can fail is guarded by try-catch. Therefore, this likely
|
||||
// indicates that there was a syntax error in the code, i.e. the WebView only
|
||||
// supports a really old version of JS.
|
||||
|
||||
val fmt = "\"${m.message()}\", source: ${m.sourceId()} (${m.lineNumber()})"
|
||||
val exception = BadWebViewException(fmt)
|
||||
Log.e(TAG, "This WebView implementation is broken: $fmt")
|
||||
|
||||
onInitializationErrorCloseAndCancel(exception)
|
||||
popAllPoTokenContinuations().forEach { (_, cont) -> cont.resumeWithException(exception) }
|
||||
}
|
||||
return super.onConsoleMessage(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be called right after instantiating [PoTokenWebView] to perform the actual
|
||||
* initialization. This will asynchronously go through all the steps needed to load BotGuard,
|
||||
* run it, and obtain an `integrityToken`.
|
||||
*/
|
||||
private fun loadHtmlAndObtainBotguard() {
|
||||
Log.d(TAG, "loadHtmlAndObtainBotguard() called")
|
||||
|
||||
scope.launch(exceptionHandler) {
|
||||
val html = withContext(Dispatchers.IO) {
|
||||
webView.context.assets.open("po_token.html").bufferedReader().use { it.readText() }
|
||||
}
|
||||
|
||||
// calls downloadAndRunBotguard() when the page has finished loading
|
||||
val data = html.replaceFirst("</script>", "\n$JS_INTERFACE.downloadAndRunBotguard()</script>")
|
||||
webView.loadDataWithBaseURL("https://www.youtube.com", data, "text/html", "utf-8", null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called during initialization by the JavaScript snippet appended to the HTML page content in
|
||||
* [loadHtmlAndObtainBotguard] after the WebView content has been loaded.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun downloadAndRunBotguard() {
|
||||
Log.d(TAG, "downloadAndRunBotguard() called")
|
||||
|
||||
makeBotguardServiceRequest(
|
||||
"https://www.youtube.com/api/jnn/v1/Create",
|
||||
"[ \"$REQUEST_KEY\" ]",
|
||||
) { responseBody ->
|
||||
val parsedChallengeData = parseChallengeData(responseBody)
|
||||
webView.evaluateJavascript(
|
||||
"""try {
|
||||
data = $parsedChallengeData
|
||||
runBotGuard(data).then(function (result) {
|
||||
this.webPoSignalOutput = result.webPoSignalOutput
|
||||
$JS_INTERFACE.onRunBotguardResult(result.botguardResponse)
|
||||
}, function (error) {
|
||||
$JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack)
|
||||
})
|
||||
} catch (error) {
|
||||
$JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack)
|
||||
}""",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called during initialization by the JavaScript snippets from either
|
||||
* [downloadAndRunBotguard] or [onRunBotguardResult].
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun onJsInitializationError(error: String) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.e(TAG, "Initialization error from JavaScript: $error")
|
||||
}
|
||||
onInitializationErrorCloseAndCancel(buildExceptionForJsError(error))
|
||||
}
|
||||
|
||||
/**
|
||||
* Called during initialization by the JavaScript snippet from [downloadAndRunBotguard] after
|
||||
* obtaining the BotGuard execution output [botguardResponse].
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun onRunBotguardResult(botguardResponse: String) {
|
||||
Log.d(TAG, "botguardResponse: $botguardResponse")
|
||||
makeBotguardServiceRequest(
|
||||
"https://www.youtube.com/api/jnn/v1/GenerateIT",
|
||||
"[ \"$REQUEST_KEY\", \"$botguardResponse\" ]",
|
||||
) { responseBody ->
|
||||
Log.d(TAG, "GenerateIT response: $responseBody")
|
||||
val (integrityToken, expirationTimeInSeconds) = parseIntegrityTokenData(responseBody)
|
||||
|
||||
// leave 10 minutes of margin just to be sure
|
||||
expirationInstant = Instant.now().plusSeconds(expirationTimeInSeconds).minus(10, ChronoUnit.MINUTES)
|
||||
|
||||
webView.evaluateJavascript("this.integrityToken = $integrityToken") {
|
||||
Log.d(TAG, "initialization finished, expiration=${expirationTimeInSeconds}s")
|
||||
continuation.resume(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Obtaining poTokens
|
||||
suspend fun generatePoToken(identifier: String): String {
|
||||
return withContext(Dispatchers.Main) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
Log.d(TAG, "generatePoToken() called with identifier $identifier")
|
||||
addPoTokenEmitter(identifier, cont)
|
||||
webView.evaluateJavascript(
|
||||
"""try {
|
||||
identifier = "$identifier"
|
||||
u8Identifier = ${stringToU8(identifier)}
|
||||
poTokenU8 = obtainPoToken(webPoSignalOutput, integrityToken, u8Identifier)
|
||||
poTokenU8String = poTokenU8.join(",")
|
||||
$JS_INTERFACE.onObtainPoTokenResult(identifier, poTokenU8String)
|
||||
} catch (error) {
|
||||
$JS_INTERFACE.onObtainPoTokenError(identifier, error + "\n" + error.stack)
|
||||
}""",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the JavaScript snippet from [generatePoToken] when an error occurs in calling the
|
||||
* JavaScript `obtainPoToken()` function.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun onObtainPoTokenError(identifier: String, error: String) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.e(TAG, "obtainPoToken error from JavaScript: $error")
|
||||
}
|
||||
popPoTokenContinuation(identifier)?.resumeWithException(buildExceptionForJsError(error))
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the JavaScript snippet from [generatePoToken] with the original identifier and the
|
||||
* result of the JavaScript `obtainPoToken()` function.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun onObtainPoTokenResult(identifier: String, poTokenU8: String) {
|
||||
Log.d(TAG, "Generated poToken (before decoding): identifier=$identifier poTokenU8=$poTokenU8")
|
||||
val poToken = try {
|
||||
u8ToBase64(poTokenU8)
|
||||
} catch (t: Throwable) {
|
||||
popPoTokenContinuation(identifier)?.resumeWithException(t)
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Generated poToken: identifier=$identifier poToken=$poToken")
|
||||
popPoTokenContinuation(identifier)?.resume(poToken)
|
||||
}
|
||||
|
||||
val isExpired: Boolean
|
||||
get() = Instant.now().isAfter(expirationInstant)
|
||||
//endregion
|
||||
|
||||
//region Handling multiple emitters
|
||||
/**
|
||||
* Adds the ([identifier], [continuation]) pair to the [poTokenContinuations] list. This makes
|
||||
* it so that multiple poToken requests can be generated in parallel, and the results will be
|
||||
* notified to the right continuations.
|
||||
*/
|
||||
private fun addPoTokenEmitter(identifier: String, continuation: Continuation<String>) {
|
||||
poTokenContinuations[identifier] = continuation
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and removes from the [poTokenContinuations] list a [Continuation] based on its
|
||||
* [identifier]. The continuation is supposed to be used immediately after to either signal a
|
||||
* success or an error.
|
||||
*/
|
||||
private fun popPoTokenContinuation(identifier: String): Continuation<String>? {
|
||||
return poTokenContinuations.remove(identifier)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears [poTokenContinuations] and returns its previous contents. The continuations are supposed
|
||||
* to be used immediately after to either signal a success or an error.
|
||||
*/
|
||||
private fun popAllPoTokenContinuations(): Map<String, Continuation<String>> {
|
||||
val result = poTokenContinuations.toMap()
|
||||
poTokenContinuations.clear()
|
||||
return result
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Utils
|
||||
/**
|
||||
* Makes a POST request to [url] with the given [data] by setting the correct headers. Calls
|
||||
* [onInitializationErrorCloseAndCancel] in case of any network errors and also if the response
|
||||
* does not have HTTP code 200, therefore this is supposed to be used only during
|
||||
* initialization. Calls [handleResponseBody] with the response body if the response is
|
||||
* successful. The request is performed in the background and a disposable is added to
|
||||
* [disposables].
|
||||
*/
|
||||
private fun makeBotguardServiceRequest(
|
||||
url: String,
|
||||
data: String,
|
||||
handleResponseBody: (String) -> Unit,
|
||||
) {
|
||||
scope.launch(exceptionHandler) {
|
||||
val requestBuilder = Request.Builder()
|
||||
.post(data.toRequestBody())
|
||||
.headers(mapOf(
|
||||
"User-Agent" to USER_AGENT,
|
||||
"Accept" to "application/json",
|
||||
"Content-Type" to "application/json+protobuf",
|
||||
"x-goog-api-key" to GOOGLE_API_KEY,
|
||||
"x-user-agent" to "grpc-web-javascript/0.1",
|
||||
).toHeaders())
|
||||
.url(url)
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
httpClient.newCall(requestBuilder.build()).execute()
|
||||
}
|
||||
val httpCode = response.code
|
||||
if (httpCode != 200) {
|
||||
onInitializationErrorCloseAndCancel(PoTokenException("Invalid response code: $httpCode"))
|
||||
} else {
|
||||
val body = withContext(Dispatchers.IO) {
|
||||
response.body!!.string()
|
||||
}
|
||||
handleResponseBody(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles any error happening during initialization, releasing resources and sending the error
|
||||
* to [continuation].
|
||||
*/
|
||||
private fun onInitializationErrorCloseAndCancel(error: Throwable) {
|
||||
close()
|
||||
continuation.resumeWithException(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases all [webView] resources.
|
||||
*/
|
||||
@MainThread
|
||||
fun close() {
|
||||
scope.cancel()
|
||||
|
||||
webView.clearHistory()
|
||||
// clears RAM cache and disk cache (globally for all WebViews)
|
||||
webView.clearCache(true)
|
||||
|
||||
// ensures that the WebView isn't doing anything when destroying it
|
||||
webView.loadUrl("about:blank")
|
||||
|
||||
webView.onPause()
|
||||
webView.removeAllViews()
|
||||
webView.destroy()
|
||||
}
|
||||
//endregion
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PoTokenWebView"
|
||||
private const val GOOGLE_API_KEY = "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw"
|
||||
private const val REQUEST_KEY = "O43z0dpjhgX20SCx4KAo"
|
||||
private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.3"
|
||||
private const val JS_INTERFACE = "PoTokenWebView"
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.proxy(YouTube.proxy)
|
||||
.build()
|
||||
|
||||
suspend fun getNewPoTokenGenerator(context: Context): PoTokenWebView {
|
||||
return withContext(Dispatchers.Main) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val potWv = PoTokenWebView(context, cont)
|
||||
potWv.loadHtmlAndObtainBotguard()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,12 @@
|
||||
package melody.offline.music.service
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
@ -30,17 +33,18 @@ import androidx.media3.extractor.mp4.FragmentedMp4Extractor
|
||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import melody.offline.music.R
|
||||
import melody.offline.music.innertube.Innertube
|
||||
import melody.offline.music.innertube.models.bodies.PlayerBody
|
||||
import melody.offline.music.innertube.requests.player
|
||||
import melody.offline.music.playernew.YTPlayerUtils.playerResponseForPlayback
|
||||
import melody.offline.music.sp.AppStore
|
||||
import melody.offline.music.util.CacheManager
|
||||
import melody.offline.music.util.LogTag
|
||||
import melody.offline.music.util.LogTag.LogD
|
||||
import melody.offline.music.util.PlayMode
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import melody.offline.music.util.RingBuffer
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@ -51,7 +55,7 @@ class PlaybackService : MediaSessionService(), Player.Listener {
|
||||
private lateinit var player: ExoPlayer
|
||||
private val playerCache = CacheManager.getPlayerCache()
|
||||
private val downloadCache = CacheManager.getDownloadCache()
|
||||
|
||||
private lateinit var connectivityManager: ConnectivityManager
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
player = ExoPlayer.Builder(this)
|
||||
@ -100,6 +104,8 @@ class PlaybackService : MediaSessionService(), Player.Listener {
|
||||
setMediaNotificationProvider(DefaultMediaNotificationProvider(this).apply {
|
||||
setSmallIcon(R.mipmap.app_logo_no_bg)
|
||||
})
|
||||
|
||||
connectivityManager = getSystemService()!!
|
||||
}
|
||||
|
||||
// The user dismissed the app from the recent tasks
|
||||
@ -147,6 +153,7 @@ class PlaybackService : MediaSessionService(), Player.Listener {
|
||||
private fun createDataSourceFactory(): DataSource.Factory {
|
||||
val chunkLength = 512 * 1024L
|
||||
val ringBuffer = RingBuffer<Pair<String, Uri>?>(2) { null }
|
||||
val urlCache = HashMap<String, Pair<String, Long>>() // URL缓存:url + 过期时间
|
||||
|
||||
return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec ->
|
||||
val videoId = dataSpec.key ?: error("A key must be set")
|
||||
@ -163,46 +170,82 @@ class PlaybackService : MediaSessionService(), Player.Listener {
|
||||
LogD(TAG, "playbackService playerCache contains data for $videoId at position $position")
|
||||
return@Factory dataSpec
|
||||
}
|
||||
// 使用缓存URL,且未过期
|
||||
urlCache[videoId]?.takeIf { it.second > System.currentTimeMillis() }?.let { (cachedUrl, _) ->
|
||||
LogD(TAG, "Using cached URL for $videoId")
|
||||
return@Factory dataSpec.withUri(cachedUrl.toUri()).subrange(dataSpec.uriPositionOffset, chunkLength)
|
||||
}
|
||||
|
||||
when (videoId) {
|
||||
ringBuffer.getOrNull(0)?.first -> return@Factory dataSpec.withUri(ringBuffer.getOrNull(0)!!.second)
|
||||
ringBuffer.getOrNull(1)?.first -> return@Factory dataSpec.withUri(ringBuffer.getOrNull(1)!!.second)
|
||||
else -> {
|
||||
val urlResult = runBlocking(Dispatchers.IO) {
|
||||
Innertube.player(PlayerBody(videoId = videoId))
|
||||
}?.mapCatching { body ->
|
||||
if (body.videoDetails?.videoId != videoId) {
|
||||
throw VideoIdMismatchException()
|
||||
}
|
||||
|
||||
when (val status = body.playabilityStatus?.status) {
|
||||
"OK" -> body.streamingData?.highestQualityFormat?.let { format ->
|
||||
format.url
|
||||
} ?: throw PlayableFormatNotFoundException()
|
||||
|
||||
"UNPLAYABLE" -> throw UnplayableException()
|
||||
"LOGIN_REQUIRED" -> throw LoginRequiredException()
|
||||
else -> throw PlaybackException(
|
||||
status,
|
||||
null,
|
||||
PlaybackException.ERROR_CODE_REMOTE_ERROR
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LogD(TAG, "playbackService urlResult->$urlResult")
|
||||
|
||||
urlResult?.getOrThrow()?.let { url ->
|
||||
ringBuffer.append(videoId to url.toUri())
|
||||
return@Factory dataSpec.withUri(url.toUri())
|
||||
.subrange(dataSpec.uriPositionOffset, chunkLength)
|
||||
} ?: throw PlaybackException(
|
||||
null,
|
||||
urlResult?.exceptionOrNull(),
|
||||
PlaybackException.ERROR_CODE_REMOTE_ERROR
|
||||
)
|
||||
}
|
||||
// else -> {
|
||||
// val urlResult = runBlocking(Dispatchers.IO) {
|
||||
// Innertube.player(PlayerBody(videoId = videoId))
|
||||
// }?.mapCatching { body ->
|
||||
// if (body.videoDetails?.videoId != videoId) {
|
||||
// throw VideoIdMismatchException()
|
||||
// }
|
||||
//
|
||||
// when (val status = body.playabilityStatus?.status) {
|
||||
// "OK" -> body.streamingData?.highestQualityFormat?.let { format ->
|
||||
// format.url
|
||||
// } ?: throw PlayableFormatNotFoundException()
|
||||
//
|
||||
// "UNPLAYABLE" -> throw UnplayableException()
|
||||
// "LOGIN_REQUIRED" -> throw LoginRequiredException()
|
||||
// else -> throw PlaybackException(
|
||||
// status,
|
||||
// null,
|
||||
// PlaybackException.ERROR_CODE_REMOTE_ERROR
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// LogD(TAG, "playbackService urlResult->$urlResult")
|
||||
//
|
||||
// urlResult?.getOrThrow()?.let { url ->
|
||||
// ringBuffer.append(videoId to url.toUri())
|
||||
// return@Factory dataSpec.withUri(url.toUri())
|
||||
// .subrange(dataSpec.uriPositionOffset, chunkLength)
|
||||
// } ?: throw PlaybackException(
|
||||
// null,
|
||||
// urlResult?.exceptionOrNull(),
|
||||
// PlaybackException.ERROR_CODE_REMOTE_ERROR
|
||||
// )
|
||||
// }
|
||||
}
|
||||
|
||||
// 进入协程安全调用你提供的获取方法
|
||||
val playbackResult = runBlocking(Dispatchers.IO) {
|
||||
playerResponseForPlayback(
|
||||
videoId = videoId,
|
||||
connectivityManager = connectivityManager
|
||||
)
|
||||
}
|
||||
|
||||
val playbackData = playbackResult.getOrElse { ex ->
|
||||
Log.e(TAG, "Failed to get playback data", ex)
|
||||
throw PlaybackException(
|
||||
ex.message ?: "Failed to get playback data",
|
||||
ex,
|
||||
PlaybackException.ERROR_CODE_REMOTE_ERROR
|
||||
)
|
||||
}
|
||||
|
||||
val streamUrl = playbackData.streamUrl
|
||||
if (streamUrl.isNullOrEmpty()) {
|
||||
throw PlaybackException("Stream URL is empty", null, PlaybackException.ERROR_CODE_REMOTE_ERROR)
|
||||
}
|
||||
|
||||
val uri = streamUrl.toUri()
|
||||
|
||||
// 缓存
|
||||
ringBuffer.append(videoId to uri)
|
||||
urlCache[videoId] = streamUrl to (System.currentTimeMillis() + playbackData.streamExpiresInSeconds * 1000L)
|
||||
|
||||
LogD(TAG, "Resolved URL for $videoId: $streamUrl")
|
||||
dataSpec.withUri(uri).subrange(dataSpec.uriPositionOffset, chunkLength)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user