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