diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index cbda5c498e..76e8278e84 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -158,6 +158,10 @@ dependencies {
implementation(libs.room)
ksp(libs.room.compiler)
+ implementation(libs.log4j)
+ implementation(libs.slf4j)
+ implementation(libs.logback)
+
implementation(projects.providers.github)
implementation(projects.providers.innertube)
implementation(projects.providers.kugou)
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 40fc0b2bb0..bd987ede67 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -39,4 +39,10 @@
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
-dontwarn org.openjsse.net.ssl.OpenJSSE
--dontwarn org.slf4j.impl.StaticLoggerBinder
\ No newline at end of file
+-dontwarn org.slf4j.impl.StaticLoggerBinder
+
+# Rhino
+-keep class org.mozilla.javascript.** { *; }
+-keep class org.mozilla.classfile.ClassFileWriter
+-dontwarn org.mozilla.javascript.JavaToJSONConverters
+-dontwarn org.mozilla.javascript.tools.**
diff --git a/app/src/main/assets/logback.xml b/app/src/main/assets/logback.xml
new file mode 100644
index 0000000000..c46036ac00
--- /dev/null
+++ b/app/src/main/assets/logback.xml
@@ -0,0 +1,18 @@
+
+
+
+ %logger{12}
+
+
+ [%-20thread] %msg
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/app/vitune/android/service/PlaybackExceptions.kt b/app/src/main/kotlin/app/vitune/android/service/PlaybackExceptions.kt
index ea7ae14da4..8ea337f530 100644
--- a/app/src/main/kotlin/app/vitune/android/service/PlaybackExceptions.kt
+++ b/app/src/main/kotlin/app/vitune/android/service/PlaybackExceptions.kt
@@ -6,26 +6,32 @@ import androidx.annotation.OptIn
import androidx.media3.common.PlaybackException
import androidx.media3.common.util.UnstableApi
-class PlayableFormatNotFoundException : PlaybackException(
+class PlayableFormatNotFoundException(cause: Throwable? = null) : PlaybackException(
/* message = */ "Playable format not found",
- /* cause = */ null,
+ /* cause = */ cause,
/* errorCode = */ ERROR_CODE_IO_FILE_NOT_FOUND
)
-class UnplayableException : PlaybackException(
+class UnplayableException(cause: Throwable? = null) : PlaybackException(
/* message = */ "Unplayable",
- /* cause = */ null,
+ /* cause = */ cause,
/* errorCode = */ ERROR_CODE_IO_UNSPECIFIED
)
-class LoginRequiredException : PlaybackException(
+class LoginRequiredException(cause: Throwable? = null) : PlaybackException(
/* message = */ "Login required",
- /* cause = */ null,
+ /* cause = */ cause,
/* errorCode = */ ERROR_CODE_AUTHENTICATION_EXPIRED
)
-class VideoIdMismatchException : PlaybackException(
+class VideoIdMismatchException(cause: Throwable? = null) : PlaybackException(
/* message = */ "Requested video ID doesn't match returned video ID",
- /* cause = */ null,
+ /* cause = */ cause,
/* errorCode = */ ERROR_CODE_IO_UNSPECIFIED
)
+
+class RestrictedVideoException(cause: Throwable? = null) : PlaybackException(
+ /* message = */ "This video is restricted",
+ /* cause = */ cause,
+ /* errorCode = */ ERROR_CODE_PARENTAL_CONTROL_RESTRICTED
+)
diff --git a/app/src/main/kotlin/app/vitune/android/service/PlayerService.kt b/app/src/main/kotlin/app/vitune/android/service/PlayerService.kt
index 1300308a43..9e7bbd4df4 100644
--- a/app/src/main/kotlin/app/vitune/android/service/PlayerService.kt
+++ b/app/src/main/kotlin/app/vitune/android/service/PlayerService.kt
@@ -500,8 +500,7 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
super.onPlayerError(error)
if (
- error.findCause()?.responseCode == 416 ||
- error.findCause() != null
+ error.findCause()?.responseCode == 416
) {
player.pause()
player.prepare()
@@ -1389,7 +1388,13 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
}
}
- format.url
+ runCatching {
+ runBlocking(Dispatchers.IO) {
+ format.findUrl()
+ }
+ }.getOrElse {
+ throw RestrictedVideoException(it)
+ }
}
"UNPLAYABLE" -> throw UnplayableException()
@@ -1402,7 +1407,13 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
)
} ?: throw UnplayableException()
- val uri = url.toUri()
+ val uri = url.toUri().let {
+ if (body.cpn == null) it
+ else it
+ .buildUpon()
+ .appendQueryParameter("cpn", body.cpn)
+ .build()
+ }
uriCache.push(
key = mediaId,
diff --git a/app/src/main/kotlin/app/vitune/android/ui/components/themed/SecondaryTextButton.kt b/app/src/main/kotlin/app/vitune/android/ui/components/themed/SecondaryTextButton.kt
index 868f92f854..3e793d93e3 100644
--- a/app/src/main/kotlin/app/vitune/android/ui/components/themed/SecondaryTextButton.kt
+++ b/app/src/main/kotlin/app/vitune/android/ui/components/themed/SecondaryTextButton.kt
@@ -7,8 +7,9 @@ import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import app.vitune.android.utils.center
+import app.vitune.android.utils.disabled
import app.vitune.android.utils.medium
import app.vitune.core.ui.LocalAppearance
import app.vitune.core.ui.primaryButton
@@ -26,7 +27,7 @@ fun SecondaryTextButton(
BasicText(
text = text,
- style = typography.xxs.medium.copy(textAlign = TextAlign.Center),
+ style = typography.xxs.medium.center.let { if (enabled) it else it.disabled },
modifier = modifier
.clip(16.dp.roundedShape)
.clickable(enabled = enabled, onClick = onClick)
diff --git a/app/src/main/kotlin/app/vitune/android/ui/screens/player/StatsForNerds.kt b/app/src/main/kotlin/app/vitune/android/ui/screens/player/StatsForNerds.kt
index bbfb30096d..2af6e3d477 100644
--- a/app/src/main/kotlin/app/vitune/android/ui/screens/player/StatsForNerds.kt
+++ b/app/src/main/kotlin/app/vitune/android/ui/screens/player/StatsForNerds.kt
@@ -8,7 +8,6 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@@ -21,6 +20,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -35,6 +36,8 @@ import app.vitune.android.Database
import app.vitune.android.LocalPlayerServiceBinder
import app.vitune.android.R
import app.vitune.android.models.Format
+import app.vitune.android.service.PlayerService
+import app.vitune.android.ui.components.themed.SecondaryTextButton
import app.vitune.android.utils.color
import app.vitune.android.utils.medium
import app.vitune.core.ui.LocalAppearance
@@ -47,6 +50,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.roundToInt
@@ -66,12 +70,45 @@ fun StatsForNerds(
val context = LocalContext.current
val binder = LocalPlayerServiceBinder.current
+ val coroutineScope = rememberCoroutineScope()
+
var cachedBytes by remember(binder, mediaId) {
mutableLongStateOf(binder?.cache?.getCachedBytes(mediaId, 0, -1) ?: 0L)
}
var format by remember { mutableStateOf(null) }
+ var hasReloaded by rememberSaveable { mutableStateOf(false) }
+
+ suspend fun reload(binder: PlayerService.Binder) {
+ binder.player.currentMediaItem
+ ?.takeIf { it.mediaId == mediaId }
+ ?.let { mediaItem ->
+ withContext(Dispatchers.IO) {
+ delay(2000)
+
+ Innertube
+ .player(PlayerBody(videoId = mediaId))
+ ?.onSuccess { response ->
+ response?.streamingData?.highestQualityFormat?.let { format ->
+ Database.insert(mediaItem)
+ Database.insert(
+ Format(
+ songId = mediaId,
+ itag = format.itag,
+ mimeType = format.mimeType,
+ bitrate = format.bitrate,
+ loudnessDb = response.playerConfig?.audioConfig?.normalizedLoudnessDb,
+ contentLength = format.contentLength,
+ lastModified = format.lastModified
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+
LaunchedEffect(binder, mediaId) {
val currentBinder = binder ?: return@LaunchedEffect
@@ -80,32 +117,7 @@ fun StatsForNerds(
.distinctUntilChanged()
.collectLatest { currentFormat ->
if (currentFormat?.itag != null) format = currentFormat
- else currentBinder.player.currentMediaItem
- ?.takeIf { it.mediaId == mediaId }
- ?.let { mediaItem ->
- withContext(Dispatchers.IO) {
- delay(2000)
-
- Innertube
- .player(PlayerBody(videoId = mediaId))
- ?.onSuccess { response ->
- response.streamingData?.highestQualityFormat?.let { format ->
- Database.insert(mediaItem)
- Database.insert(
- Format(
- songId = mediaId,
- itag = format.itag,
- mimeType = format.mimeType,
- bitrate = format.bitrate,
- loudnessDb = response.playerConfig?.audioConfig?.normalizedLoudnessDb,
- contentLength = format.contentLength,
- lastModified = format.lastModified
- )
- )
- }
- }
- }
- }
+ else reload(currentBinder)
}
}
@@ -131,19 +143,19 @@ fun StatsForNerds(
}
}
- Box(
+ Column(
modifier = modifier
.pointerInput(Unit) {
detectTapGestures(onTap = { onDismiss() })
}
.background(colorPalette.overlay)
- .fillMaxSize()
+ .fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
- modifier = Modifier
- .align(Alignment.Center)
- .padding(all = 16.dp)
+ modifier = Modifier.padding(all = 16.dp)
) {
@Composable
fun Text(text: String) = BasicText(
@@ -195,5 +207,19 @@ fun StatsForNerds(
)
}
}
+
+ binder?.let {
+ SecondaryTextButton(
+ text = stringResource(R.string.reload),
+ onClick = {
+ hasReloaded = true
+
+ coroutineScope.launch {
+ reload(it)
+ }
+ },
+ enabled = !hasReloaded
+ )
+ }
}
}
diff --git a/app/src/main/kotlin/app/vitune/android/ui/screens/player/Thumbnail.kt b/app/src/main/kotlin/app/vitune/android/ui/screens/player/Thumbnail.kt
index 9db6a6050f..5af027f443 100644
--- a/app/src/main/kotlin/app/vitune/android/ui/screens/player/Thumbnail.kt
+++ b/app/src/main/kotlin/app/vitune/android/ui/screens/player/Thumbnail.kt
@@ -52,6 +52,7 @@ import app.vitune.android.R
import app.vitune.android.preferences.PlayerPreferences
import app.vitune.android.service.LoginRequiredException
import app.vitune.android.service.PlayableFormatNotFoundException
+import app.vitune.android.service.RestrictedVideoException
import app.vitune.android.service.UnplayableException
import app.vitune.android.service.VideoIdMismatchException
import app.vitune.android.service.isLocal
@@ -247,7 +248,9 @@ fun Thumbnail(
is PlayableFormatNotFoundException -> stringResource(R.string.error_unplayable)
is UnplayableException -> stringResource(R.string.error_source_deleted)
- is LoginRequiredException -> stringResource(R.string.error_server_restrictions)
+ is LoginRequiredException, is RestrictedVideoException ->
+ stringResource(R.string.error_server_restrictions)
+
is VideoIdMismatchException -> stringResource(R.string.error_id_mismatch)
else -> stringResource(R.string.error_unknown_playback)
}
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index dfad80d10f..e846050999 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -173,6 +173,7 @@
Grootte
Gecachet
Luidheid
+ Herlaad
Toon album
Toon afspeellijst
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9fa35fc275..98f090cd32 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -173,6 +173,7 @@
Size
Cached
Loudness
+ Reload
View album
View playlist
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 2d16d18b5e..878637adc4 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -68,10 +68,16 @@ ktor_client_core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor_client_cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor_client_okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor_client_content_negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
+ktor_client_logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor_client_encoding = { module = "io.ktor:ktor-client-encoding", version.ref = "ktor" }
ktor_client_serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktor" }
ktor_serialization_json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
+rhino = { module = "org.mozilla:rhino", version = "1.7.15" }
+log4j = { module = "org.apache.logging.log4j:log4j-api", version = "2.3" }
+slf4j = { module = "org.slf4j:slf4j-api", version = "2.0.16" }
+logback = { module = "com.github.tony19:logback-android", version = "3.0.0" }
+
brotli = { module = "org.brotli:dec", version = "0.1.2" }
palette = { module = "androidx.palette:palette", version = "1.0.0" }
monet = { module = "com.github.KieronQuinn:MonetCompat", version = "0.4.1" }
diff --git a/providers/innertube/build.gradle.kts b/providers/innertube/build.gradle.kts
index 291d3a8984..95ff661d97 100644
--- a/providers/innertube/build.gradle.kts
+++ b/providers/innertube/build.gradle.kts
@@ -11,10 +11,14 @@ dependencies {
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.content.negotiation)
+ implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.encoding)
implementation(libs.ktor.client.serialization)
implementation(libs.ktor.serialization.json)
+ implementation(libs.rhino)
+ implementation(libs.log4j)
+
detektPlugins(libs.detekt.compose)
detektPlugins(libs.detekt.formatting)
}
diff --git a/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/Innertube.kt b/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/Innertube.kt
index 0e458026e7..21aabfc825 100644
--- a/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/Innertube.kt
+++ b/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/Innertube.kt
@@ -4,29 +4,69 @@ import app.vitune.providers.innertube.models.MusicNavigationButtonRenderer
import app.vitune.providers.innertube.models.NavigationEndpoint
import app.vitune.providers.innertube.models.Runs
import app.vitune.providers.innertube.models.Thumbnail
+import app.vitune.providers.innertube.models.UserAgents
+import app.vitune.providers.utils.runCatchingCancellable
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
+import io.ktor.client.plugins.api.createClientPlugin
import io.ktor.client.plugins.compression.ContentEncoding
import io.ktor.client.plugins.compression.brotli
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
+import io.ktor.client.plugins.logging.LogLevel
+import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.HttpRequestBuilder
+import io.ktor.client.request.HttpSendPipeline
+import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.headers
+import io.ktor.client.request.host
+import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.parameters
+import io.ktor.http.parseQueryString
import io.ktor.serialization.kotlinx.json.json
-import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
+import org.slf4j.LoggerFactory
object Innertube {
+ private var javascriptChallenge: JavaScriptChallenge? = null
+
+ private val javascriptClient = HttpClient(OkHttp) {
+ expectSuccess = true
+
+ install(ContentEncoding) {
+ brotli(1.0f)
+ gzip(0.9f)
+ deflate(0.8f)
+ }
+
+ install(Logging)
+
+ defaultRequest {
+ header("User-Agent", UserAgents.DESKTOP)
+ }
+ }
+
+ private val OriginInterceptor = createClientPlugin("OriginInterceptor") {
+ client.sendPipeline.intercept(HttpSendPipeline.State) {
+ context.headers {
+ val host = if (context.host == "youtubei.googleapis.com") "www.youtube.com" else context.host
+ val origin = "${context.url.protocol.name}://$host"
+ append("host", host)
+ append("x-origin", origin)
+ append("origin", origin)
+ }
+ }
+ }
+
+ val logger = LoggerFactory.getLogger(Innertube::class.java)
val client = HttpClient(OkHttp) {
expectSuccess = true
install(ContentNegotiation) {
- @OptIn(ExperimentalSerializationApi::class)
json(
Json {
ignoreUnknownKeys = true
@@ -42,13 +82,17 @@ object Innertube {
deflate(0.8f)
}
+ install(Logging) {
+ level = LogLevel.INFO
+ }
+
+ install(OriginInterceptor)
+
defaultRequest {
url(scheme = "https", host = "music.youtube.com") {
contentType(ContentType.Application.Json)
headers {
append("X-Goog-Api-Key", API_KEY)
- append("x-origin", ORIGIN)
- append("origin", ORIGIN)
}
parameters {
append("prettyPrint", "false")
@@ -58,13 +102,66 @@ object Innertube {
}
}
+ private suspend fun getJavaScriptChallenge(): JavaScriptChallenge? {
+ if (javascriptChallenge != null) return javascriptChallenge
+
+ val iframe = javascriptClient.get("https://www.youtube.com/iframe_api").bodyAsText()
+ val version = "player\\\\?/([0-9a-fA-F]{8})\\\\?/".toRegex()
+ .matchEntire(iframe)
+ ?.groups
+ ?.get(1)
+ ?.value
+ ?.trim()
+ ?.takeIf { it.isNotBlank() } ?: return null
+
+ val sourceFile = javascriptClient
+ .get("https://www.youtube.com/s/player/$version/player_ias.vflset/en_US/base.js")
+ .bodyAsText()
+ val timestamp = "(?:signatureTimestamp|sts):(\\d{5})".toRegex()
+ .matchEntire(sourceFile)
+ ?.groups
+ ?.get(1)
+ ?.value
+ ?.trim()
+ ?.takeIf { it.isNotBlank() } ?: return null
+ val functionName = "(\\w+)=function\\(a\\)\\{a=a.split\\(\"\"\\);\\w+".toRegex()
+ .matchEntire(sourceFile)
+ ?.groups
+ ?.get(1)
+ ?.value
+ ?.trim()
+ ?.takeIf { it.isNotBlank() } ?: return null
+
+ return JavaScriptChallenge(
+ source = sourceFile,
+ timestamp = timestamp,
+ functionName = functionName
+ ).also { javascriptChallenge = it }
+ }
+
+ // TODO: not stable as of right now, is the implementation correct?
+ suspend fun decodeSignatureCipher(cipher: String): String? = runCatchingCancellable {
+ val params = parseQueryString(cipher)
+ val signature = params["s"] ?: return@runCatchingCancellable null
+ val signatureParam = params["sp"] ?: return@runCatchingCancellable null
+ val url = params["url"] ?: return@runCatchingCancellable null
+
+ val actualSignature = getJavaScriptChallenge()?.decode(signature)
+ ?: return@runCatchingCancellable null
+ "$url&$signatureParam=$actualSignature"
+ }?.onFailure { it.printStackTrace() }?.getOrNull()
+
+ suspend fun getSignatureTimestamp(): String? = runCatchingCancellable {
+ getJavaScriptChallenge()?.timestamp
+ }?.onFailure { it.printStackTrace() }?.getOrNull()
+
private const val API_KEY = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30"
- private const val ORIGIN = "https://music.youtube.com"
private const val BASE = "/youtubei/v1"
internal const val BROWSE = "$BASE/browse"
internal const val NEXT = "$BASE/next"
- internal const val PLAYER = "$BASE/player"
+ internal const val PLAYER = "https://youtubei.googleapis.com/youtubei/v1/player"
+ internal const val PLAYER_MUSIC = "$BASE/player"
internal const val QUEUE = "$BASE/music/get_queue"
internal const val SEARCH = "$BASE/search"
internal const val SEARCH_SUGGESTIONS = "$BASE/music/get_search_suggestions"
diff --git a/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/JavaScriptChallenge.kt b/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/JavaScriptChallenge.kt
new file mode 100644
index 0000000000..78820619bd
--- /dev/null
+++ b/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/JavaScriptChallenge.kt
@@ -0,0 +1,27 @@
+package app.vitune.providers.innertube
+
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.mozilla.javascript.Context
+import org.mozilla.javascript.Function
+
+data class JavaScriptChallenge(
+ val timestamp: String,
+ val source: String,
+ val functionName: String
+) {
+ private val cache = mutableMapOf()
+ private val mutex = Mutex()
+
+ suspend fun decode(cipher: String) = mutex.withLock {
+ cache.getOrPut(cipher) {
+ with(Context.enter()) {
+ optimizationLevel = -1
+ val scope = initSafeStandardObjects()
+ evaluateString(scope, source, functionName, 1, null)
+ val function = scope.get(functionName, scope) as Function
+ function.call(this, scope, scope, arrayOf(cipher)).toString()
+ }
+ }
+ }
+}
diff --git a/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/models/Context.kt b/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/models/Context.kt
index 9e3aea989f..0d8a967b27 100644
--- a/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/models/Context.kt
+++ b/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/models/Context.kt
@@ -2,26 +2,38 @@ package app.vitune.providers.innertube.models
import io.ktor.client.request.headers
import io.ktor.http.HttpMessageBuilder
+import io.ktor.http.parameters
import io.ktor.http.userAgent
import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
import java.util.Locale
@Serializable
data class Context(
val client: Client,
- val thirdParty: ThirdParty? = null
+ val thirdParty: ThirdParty? = null,
+ val user: User? = User()
) {
@Serializable
data class Client(
val clientName: String,
val clientVersion: String,
- val platform: String,
+ val platform: String? = null,
val hl: String = "en",
val gl: String = "US",
val visitorData: String = DEFAULT_VISITOR_DATA,
val androidSdkVersion: Int? = null,
val userAgent: String? = null,
- val referer: String? = null
+ val referer: String? = null,
+ val deviceMake: String? = null,
+ val deviceModel: String? = null,
+ val osName: String? = null,
+ val osVersion: String? = null,
+ val acceptHeader: String? = null,
+ val timeZone: String? = "UTC",
+ val utcOffsetMinutes: Int? = 0,
+ @Transient
+ val apiKey: String? = null
)
@Serializable
@@ -29,6 +41,11 @@ data class Context(
val embedUrl: String
)
+ @Serializable
+ data class User(
+ val lockedSafetyMode: Boolean = false
+ )
+
context(HttpMessageBuilder)
fun apply() {
client.userAgent?.let { userAgent(it) }
@@ -38,46 +55,88 @@ data class Context(
append("X-Youtube-Bootstrap-Logged-In", "false")
append("X-YouTube-Client-Name", client.clientName)
append("X-YouTube-Client-Version", client.clientVersion)
+ client.apiKey?.let { append("X-Goog-Api-Key", it) }
+ }
+
+ parameters {
+ client.apiKey?.let { append("key", it) }
}
}
companion object {
- const val DEFAULT_VISITOR_DATA = "CgtsZG1ySnZiQWtSbyiMjuGSBg%3D%3D"
+ private val Context.withLang: Context get() {
+ val locale = Locale.getDefault()
- val DefaultWeb
- get() = DefaultWebNoLang.let { context ->
- val locale = Locale.getDefault()
-
- context.copy(
- client = context.client.copy(
- hl = locale
- .toLanguageTag()
- .replace("-Hant", "")
- .takeIf { it in validLanguageCodes } ?: "en",
- gl = locale
- .country
- .takeIf { it in validCountryCodes } ?: "US"
- )
+ return copy(
+ client = client.copy(
+ hl = locale
+ .toLanguageTag()
+ .replace("-Hant", "")
+ .takeIf { it in validLanguageCodes } ?: "en",
+ gl = locale
+ .country
+ .takeIf { it in validCountryCodes } ?: "US"
)
- }
+ )
+ }
+ const val DEFAULT_VISITOR_DATA = "CgtsZG1ySnZiQWtSbyiMjuGSBg%3D%3D"
+
+ val DefaultWeb get() = DefaultWebNoLang.withLang
val DefaultWebNoLang = Context(
client = Client(
clientName = "WEB_REMIX",
- clientVersion = "1.20230306.01.00",
+ clientVersion = "1.20220606.03.00",
platform = "DESKTOP",
userAgent = UserAgents.DESKTOP,
referer = "https://music.youtube.com/"
)
)
+ val DefaultWebOld = Context(
+ client = Client(
+ clientName = "WEB",
+ clientVersion = "2.20240509.00.00",
+ platform = "DESKTOP",
+ userAgent = UserAgents.DESKTOP,
+ referer = "https://music.youtube.com/",
+ apiKey = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
+ )
+ )
+
+ val DefaultIOS = Context(
+ client = Client(
+ clientName = "IOS",
+ clientVersion = "19.29.1",
+ deviceMake = "Apple",
+ deviceModel = "iPhone16,2",
+ osName = "iOS",
+ osVersion = "17.5.1.21F90",
+ acceptHeader = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
+ userAgent = UserAgents.IOS,
+ apiKey = "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc"
+ )
+ )
+
val DefaultAndroid = Context(
+ client = Client(
+ clientName = "ANDROID",
+ clientVersion = "17.36.4",
+ platform = "MOBILE",
+ androidSdkVersion = 30,
+ userAgent = UserAgents.ANDROID,
+ apiKey = "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w"
+ )
+ )
+
+ val DefaultAndroidMusic = Context(
client = Client(
clientName = "ANDROID_MUSIC",
- clientVersion = "5.28.1",
+ clientVersion = "5.22.1",
platform = "MOBILE",
androidSdkVersion = 30,
- userAgent = UserAgents.ANDROID
+ userAgent = UserAgents.ANDROID_MUSIC,
+ apiKey = "AIzaSyAOghZGza2MQSZkY_zfZ370N-PUdXEo8AI"
)
)
@@ -105,6 +164,8 @@ val validCountryCodes =
@Suppress("MaximumLineLength")
object UserAgents {
const val DESKTOP = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.157 Safari/537.36"
- const val ANDROID = "com.google.android.apps.youtube.music/5.28.1 (Linux; U; Android 11) gzip"
+ const val ANDROID = "com.google.android.youtube/17.36.4 (Linux; U; Android 11) gzip"
+ const val ANDROID_MUSIC = "com.google.android.youtube/19.29.1 (Linux; U; Android 11) gzip"
const val PLAYSTATION = "Mozilla/5.0 (PlayStation 4 5.55) AppleWebKit/601.2 (KHTML, like Gecko)"
+ const val IOS = "com.google.ios.youtube/19.29.1 (iPhone16,2; U; CPU iOS 17_5_1 like Mac OS X;)"
}
diff --git a/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/models/PlayerResponse.kt b/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/models/PlayerResponse.kt
index d68c5ab6d1..9245793e5a 100644
--- a/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/models/PlayerResponse.kt
+++ b/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/models/PlayerResponse.kt
@@ -1,13 +1,17 @@
package app.vitune.providers.innertube.models
+import app.vitune.providers.innertube.Innertube
import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
@Serializable
data class PlayerResponse(
val playabilityStatus: PlayabilityStatus?,
val playerConfig: PlayerConfig?,
val streamingData: StreamingData?,
- val videoDetails: VideoDetails?
+ val videoDetails: VideoDetails?,
+ @Transient
+ val cpn: String? = null
) {
@Serializable
data class PlayabilityStatus(
@@ -20,7 +24,7 @@ data class PlayerResponse(
) {
@Serializable
data class AudioConfig(
- private val loudnessDb: Double?
+ internal val loudnessDb: Double?
) {
// For music clients only
val normalizedLoudnessDb: Float?
@@ -34,7 +38,10 @@ data class PlayerResponse(
val expiresInSeconds: Long?
) {
val highestQualityFormat: AdaptiveFormat?
- get() = adaptiveFormats?.findLast { it.itag == 251 || it.itag == 140 }
+ get() = adaptiveFormats?.filter { it.url != null || it.signatureCipher != null }?.let { formats ->
+ formats.findLast { it.itag == 251 || it.itag == 140 }
+ ?: formats.maxBy { it.bitrate ?: 0L }
+ }
@Serializable
data class AdaptiveFormat(
@@ -48,8 +55,11 @@ data class PlayerResponse(
val lastModified: Long?,
val loudnessDb: Double?,
val audioSampleRate: Int?,
- val url: String?
- )
+ val url: String?,
+ val signatureCipher: String?
+ ) {
+ suspend fun findUrl() = url ?: signatureCipher?.let { Innertube.decodeSignatureCipher(it) }
+ }
}
@Serializable
diff --git a/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/models/bodies/PlayerBody.kt b/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/models/bodies/PlayerBody.kt
index 79ddcec882..fb43c4d8ee 100644
--- a/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/models/bodies/PlayerBody.kt
+++ b/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/models/bodies/PlayerBody.kt
@@ -5,7 +5,21 @@ import kotlinx.serialization.Serializable
@Serializable
data class PlayerBody(
- val context: Context = Context.DefaultAndroid,
+ val context: Context = Context.DefaultAndroidMusic,
val videoId: String,
- val playlistId: String? = null
-)
+ val playlistId: String? = null,
+ val cpn: String? = null,
+ val contentCheckOk: String = "true",
+ val racyCheckOn: String = "true",
+ val playbackContext: PlaybackContext? = null
+) {
+ @Serializable
+ data class PlaybackContext(
+ val contentPlaybackContext: ContentPlaybackContext? = null
+ ) {
+ @Serializable
+ data class ContentPlaybackContext(
+ val signatureTimestamp: String? = null
+ )
+ }
+}
diff --git a/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/requests/Player.kt b/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/requests/Player.kt
index cb11a642f5..10b3655578 100644
--- a/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/requests/Player.kt
+++ b/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/requests/Player.kt
@@ -6,67 +6,96 @@ import app.vitune.providers.innertube.models.PlayerResponse
import app.vitune.providers.innertube.models.bodies.PlayerBody
import app.vitune.providers.utils.runCatchingCancellable
import io.ktor.client.call.body
-import io.ktor.client.request.get
+import io.ktor.client.request.header
+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 kotlinx.serialization.Serializable
+import io.ktor.util.generateNonce
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.isActive
-suspend fun Innertube.player(
+private suspend fun Innertube.tryContexts(
body: PlayerBody,
- pipedHost: String = "pipedapi.adminforge.de"
-): Result? = runCatchingCancellable {
- val response = client.post(PLAYER) {
- setBody(body)
- }.body()
+ music: Boolean,
+ vararg contexts: Context
+): PlayerResponse? {
+ contexts.forEach { context ->
+ if (!currentCoroutineContext().isActive) return null
- if (response.playabilityStatus?.status == "OK") return@runCatchingCancellable response
- val safePlayerResponse = client.post(PLAYER) {
+ logger.info("Trying ${context.client.clientName} ${context.client.clientVersion} ${context.client.platform}")
+ val cpn =
+ if (context.client.clientName == "IOS") generateNonce(16).decodeToString() else null
+ runCatchingCancellable {
+ client.post(if (music) PLAYER_MUSIC else PLAYER) {
+ setBody(
+ body.copy(
+ context = context,
+ cpn = cpn
+ )
+ )
+
+ context.apply()
+
+ if (cpn != null) {
+ parameter("t", generateNonce(12))
+ header("X-Goog-Api-Format-Version", "2")
+ parameter("id", body.videoId)
+ }
+ }.body().also { logger.info("Got $it") }
+ }
+ ?.getOrNull()
+ ?.takeIf { it.isValid }
+ ?.let { return it.copy(cpn = cpn) }
+ }
+
+ return null
+}
+
+private val PlayerResponse.isValid
+ get() = playabilityStatus?.status == "OK" &&
+ streamingData?.adaptiveFormats?.any { it.url != null || it.signatureCipher != null } == true
+
+suspend fun Innertube.player(body: PlayerBody): Result? = runCatchingCancellable {
+ tryContexts(
+ body = body,
+ music = false,
+ Context.DefaultIOS
+ )?.let { response ->
+ if (response.playerConfig?.audioConfig?.loudnessDb == null) {
+ // On non-music clients, the loudness doesn't get accounted for, resulting in really bland audio
+ // Try to recover from this or gracefully accept the user's ears' fate
+ tryContexts(
+ body = body,
+ music = true,
+ Context.DefaultWebNoLang
+ )?.playerConfig?.let {
+ response.copy(playerConfig = it)
+ } ?: response
+ } else response
+ } ?: client.post(PLAYER) {
setBody(
body.copy(
context = Context.DefaultAgeRestrictionBypass.copy(
thirdParty = Context.ThirdParty(
embedUrl = "https://www.youtube.com/watch?v=${body.videoId}"
)
+ ),
+ playbackContext = PlayerBody.PlaybackContext(
+ contentPlaybackContext = PlayerBody.PlaybackContext.ContentPlaybackContext(
+ signatureTimestamp = getSignatureTimestamp()
+ )
)
)
)
- }.body()
-
- if (safePlayerResponse.playabilityStatus?.status != "OK") return@runCatchingCancellable response
-
- val audioStreams = client.get("https://$pipedHost/streams/${body.videoId}") {
- contentType(ContentType.Application.Json)
- }.body().audioStreams
-
- safePlayerResponse.copy(
- streamingData = safePlayerResponse.streamingData?.copy(
- adaptiveFormats = safePlayerResponse.streamingData.adaptiveFormats?.map { adaptiveFormat ->
- adaptiveFormat.copy(
- url = audioStreams.find { it.bitrate == adaptiveFormat.bitrate }?.url
- )
- }
- )
+ }.body().takeIf { it.isValid } ?: tryContexts(
+ body = body,
+ music = false,
+ Context.DefaultWebOld,
+ Context.DefaultAndroid
+ ) ?: tryContexts(
+ body = body,
+ music = true,
+ Context.DefaultWeb,
+ Context.DefaultAndroidMusic
)
-}?.recoverCatching {
- if (body.context.client.clientName == "WEB_REMIX") throw it
-
- Innertube.player(
- body = body.copy(
- context = Context.DefaultWeb
- ),
- pipedHost = pipedHost
- )?.getOrThrow() ?: return@player null
}
-
-@Serializable
-data class AudioStream(
- val url: String,
- val bitrate: Long
-)
-
-@Serializable
-data class PipedResponse(
- val audioStreams: List
-)