修改播放失败。
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
|
//noinspection KaptUsageInsteadOfKsp
|
||||||
kapt("androidx.room:room-compiler:2.6.1")
|
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:1.3.1")
|
||||||
implementation("androidx.media3:media3-exoplayer-dash:1.3.1")
|
implementation("androidx.media3:media3-exoplayer-dash:1.3.1")
|
||||||
implementation("androidx.media3:media3-ui: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("io.github.scwang90:refresh-footer-ball:2.1.0")
|
||||||
implementation("com.google.code.gson:gson:2.10.1")
|
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")
|
implementation("com.google.android.ump:user-messaging-platform:3.0.0")
|
||||||
|
|
||||||
//fb
|
//fb
|
||||||
|
|||||||
@ -2,10 +2,11 @@ package melody.offline.music
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.widget.Toast
|
||||||
|
import android.widget.Toast.LENGTH_SHORT
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.media3.common.util.UnstableApi
|
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.LoLAds
|
||||||
import com.lol.apex.ok.google.adlibrary.bean.constants.TestMode
|
import com.lol.apex.ok.google.adlibrary.bean.constants.TestMode
|
||||||
import melody.offline.music.bean.Audio
|
import melody.offline.music.bean.Audio
|
||||||
@ -21,6 +22,9 @@ import melody.offline.music.util.DownloadUtil
|
|||||||
import melody.offline.music.util.parseResources
|
import melody.offline.music.util.parseResources
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
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.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import melody.offline.music.database.AppFavoriteDBManager
|
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.firebase.RemoteConfig
|
||||||
import melody.offline.music.http.CommonIpInfoUtil
|
import melody.offline.music.http.CommonIpInfoUtil
|
||||||
import melody.offline.music.http.UploadEventName
|
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.sp.AppStore
|
||||||
import melody.offline.music.util.AnalysisUtil
|
import melody.offline.music.util.AnalysisUtil
|
||||||
import melody.offline.music.util.AppLifecycleHandler
|
import melody.offline.music.util.AppLifecycleHandler
|
||||||
@ -146,6 +154,26 @@ class App : Application() {
|
|||||||
initImportAudio()
|
initImportAudio()
|
||||||
CacheManager.initializeCaches(this)
|
CacheManager.initializeCaches(this)
|
||||||
DownloadUtil.getDownloadManager(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() {
|
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
|
package melody.offline.music.innertube.requests
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.request.post
|
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 melody.offline.music.innertube.utils.runCatchingNonCancellable
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@SuppressLint("UnsafeOptInUsageError")
|
||||||
suspend fun Innertube.player(body: PlayerBody) = runCatchingNonCancellable {
|
suspend fun Innertube.player(body: PlayerBody) = runCatchingNonCancellable {
|
||||||
val response = client.post(player) {
|
val response = client.post(player) {
|
||||||
setBody(body)
|
setBody(body)
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
package melody.offline.music.innertube.utils
|
package melody.offline.music.innertube.utils
|
||||||
|
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import io.ktor.utils.io.CancellationException
|
import io.ktor.utils.io.CancellationException
|
||||||
import melody.offline.music.innertube.Innertube
|
import melody.offline.music.innertube.Innertube
|
||||||
import melody.offline.music.innertube.models.SectionListRenderer
|
import melody.offline.music.innertube.models.SectionListRenderer
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
internal fun SectionListRenderer.findSectionByTitle(text: String): SectionListRenderer.Content? {
|
internal fun SectionListRenderer.findSectionByTitle(text: String): SectionListRenderer.Content? {
|
||||||
return contents?.find { 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())
|
items = (this?.items?.plus(other.items ?: emptyList())
|
||||||
?: other.items)?.distinctBy(Innertube.Item::key)
|
?: 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
|
package melody.offline.music.service
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.ConnectivityManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
|
import android.util.Log
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.media3.common.AudioAttributes
|
import androidx.media3.common.AudioAttributes
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
@ -30,17 +33,18 @@ import androidx.media3.extractor.mp4.FragmentedMp4Extractor
|
|||||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||||
import androidx.media3.session.MediaSession
|
import androidx.media3.session.MediaSession
|
||||||
import androidx.media3.session.MediaSessionService
|
import androidx.media3.session.MediaSessionService
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import melody.offline.music.R
|
import melody.offline.music.R
|
||||||
import melody.offline.music.innertube.Innertube
|
import melody.offline.music.innertube.Innertube
|
||||||
import melody.offline.music.innertube.models.bodies.PlayerBody
|
import melody.offline.music.innertube.models.bodies.PlayerBody
|
||||||
import melody.offline.music.innertube.requests.player
|
import melody.offline.music.innertube.requests.player
|
||||||
|
import melody.offline.music.playernew.YTPlayerUtils.playerResponseForPlayback
|
||||||
import melody.offline.music.sp.AppStore
|
import melody.offline.music.sp.AppStore
|
||||||
import melody.offline.music.util.CacheManager
|
import melody.offline.music.util.CacheManager
|
||||||
import melody.offline.music.util.LogTag
|
import melody.offline.music.util.LogTag
|
||||||
import melody.offline.music.util.LogTag.LogD
|
import melody.offline.music.util.LogTag.LogD
|
||||||
import melody.offline.music.util.PlayMode
|
import melody.offline.music.util.PlayMode
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import melody.offline.music.util.RingBuffer
|
import melody.offline.music.util.RingBuffer
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@ -51,7 +55,7 @@ class PlaybackService : MediaSessionService(), Player.Listener {
|
|||||||
private lateinit var player: ExoPlayer
|
private lateinit var player: ExoPlayer
|
||||||
private val playerCache = CacheManager.getPlayerCache()
|
private val playerCache = CacheManager.getPlayerCache()
|
||||||
private val downloadCache = CacheManager.getDownloadCache()
|
private val downloadCache = CacheManager.getDownloadCache()
|
||||||
|
private lateinit var connectivityManager: ConnectivityManager
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
player = ExoPlayer.Builder(this)
|
player = ExoPlayer.Builder(this)
|
||||||
@ -100,6 +104,8 @@ class PlaybackService : MediaSessionService(), Player.Listener {
|
|||||||
setMediaNotificationProvider(DefaultMediaNotificationProvider(this).apply {
|
setMediaNotificationProvider(DefaultMediaNotificationProvider(this).apply {
|
||||||
setSmallIcon(R.mipmap.app_logo_no_bg)
|
setSmallIcon(R.mipmap.app_logo_no_bg)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
connectivityManager = getSystemService()!!
|
||||||
}
|
}
|
||||||
|
|
||||||
// The user dismissed the app from the recent tasks
|
// The user dismissed the app from the recent tasks
|
||||||
@ -147,6 +153,7 @@ class PlaybackService : MediaSessionService(), Player.Listener {
|
|||||||
private fun createDataSourceFactory(): DataSource.Factory {
|
private fun createDataSourceFactory(): DataSource.Factory {
|
||||||
val chunkLength = 512 * 1024L
|
val chunkLength = 512 * 1024L
|
||||||
val ringBuffer = RingBuffer<Pair<String, Uri>?>(2) { null }
|
val ringBuffer = RingBuffer<Pair<String, Uri>?>(2) { null }
|
||||||
|
val urlCache = HashMap<String, Pair<String, Long>>() // URL缓存:url + 过期时间
|
||||||
|
|
||||||
return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec ->
|
return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec ->
|
||||||
val videoId = dataSpec.key ?: error("A key must be set")
|
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")
|
LogD(TAG, "playbackService playerCache contains data for $videoId at position $position")
|
||||||
return@Factory dataSpec
|
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) {
|
when (videoId) {
|
||||||
ringBuffer.getOrNull(0)?.first -> return@Factory dataSpec.withUri(ringBuffer.getOrNull(0)!!.second)
|
ringBuffer.getOrNull(0)?.first -> return@Factory dataSpec.withUri(ringBuffer.getOrNull(0)!!.second)
|
||||||
ringBuffer.getOrNull(1)?.first -> return@Factory dataSpec.withUri(ringBuffer.getOrNull(1)!!.second)
|
ringBuffer.getOrNull(1)?.first -> return@Factory dataSpec.withUri(ringBuffer.getOrNull(1)!!.second)
|
||||||
else -> {
|
// else -> {
|
||||||
val urlResult = runBlocking(Dispatchers.IO) {
|
// val urlResult = runBlocking(Dispatchers.IO) {
|
||||||
Innertube.player(PlayerBody(videoId = videoId))
|
// Innertube.player(PlayerBody(videoId = videoId))
|
||||||
}?.mapCatching { body ->
|
// }?.mapCatching { body ->
|
||||||
if (body.videoDetails?.videoId != videoId) {
|
// if (body.videoDetails?.videoId != videoId) {
|
||||||
throw VideoIdMismatchException()
|
// 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
|
||||||
|
// )
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
when (val status = body.playabilityStatus?.status) {
|
// 进入协程安全调用你提供的获取方法
|
||||||
"OK" -> body.streamingData?.highestQualityFormat?.let { format ->
|
val playbackResult = runBlocking(Dispatchers.IO) {
|
||||||
format.url
|
playerResponseForPlayback(
|
||||||
} ?: throw PlayableFormatNotFoundException()
|
videoId = videoId,
|
||||||
|
connectivityManager = connectivityManager
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
"UNPLAYABLE" -> throw UnplayableException()
|
val playbackData = playbackResult.getOrElse { ex ->
|
||||||
"LOGIN_REQUIRED" -> throw LoginRequiredException()
|
Log.e(TAG, "Failed to get playback data", ex)
|
||||||
else -> throw PlaybackException(
|
throw PlaybackException(
|
||||||
status,
|
ex.message ?: "Failed to get playback data",
|
||||||
null,
|
ex,
|
||||||
PlaybackException.ERROR_CODE_REMOTE_ERROR
|
PlaybackException.ERROR_CODE_REMOTE_ERROR
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val streamUrl = playbackData.streamUrl
|
||||||
|
if (streamUrl.isNullOrEmpty()) {
|
||||||
|
throw PlaybackException("Stream URL is empty", null, PlaybackException.ERROR_CODE_REMOTE_ERROR)
|
||||||
}
|
}
|
||||||
|
|
||||||
LogD(TAG, "playbackService urlResult->$urlResult")
|
val uri = streamUrl.toUri()
|
||||||
|
|
||||||
urlResult?.getOrThrow()?.let { url ->
|
// 缓存
|
||||||
ringBuffer.append(videoId to url.toUri())
|
ringBuffer.append(videoId to uri)
|
||||||
return@Factory dataSpec.withUri(url.toUri())
|
urlCache[videoId] = streamUrl to (System.currentTimeMillis() + playbackData.streamExpiresInSeconds * 1000L)
|
||||||
.subrange(dataSpec.uriPositionOffset, chunkLength)
|
|
||||||
} ?: throw PlaybackException(
|
LogD(TAG, "Resolved URL for $videoId: $streamUrl")
|
||||||
null,
|
dataSpec.withUri(uri).subrange(dataSpec.uriPositionOffset, chunkLength)
|
||||||
urlResult?.exceptionOrNull(),
|
|
||||||
PlaybackException.ERROR_CODE_REMOTE_ERROR
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user