From 2a9007f3c86e56238c9da98c51f1bb03b8475dc0 Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Sun, 5 Jan 2025 04:32:36 +0000 Subject: [PATCH 01/29] update clients & deobfuscate stream urls with NewPipeExtractor --- app/proguard-rules.pro | 9 ++- app/src/main/java/com/zionhuang/music/App.kt | 6 +- .../zionhuang/music/playback/DownloadUtil.kt | 14 ++-- .../zionhuang/music/playback/MusicService.kt | 6 +- gradle/libs.versions.toml | 4 ++ innertube/build.gradle.kts | 1 + .../java/com/zionhuang/innertube/InnerTube.kt | 25 ++++--- .../innertube/NewPipeDownloaderImpl.kt | 53 +++++++++++++++ .../java/com/zionhuang/innertube/YouTube.kt | 11 ++- .../innertube/models/YouTubeClient.kt | 68 ++++++++++--------- .../innertube/models/body/PlayerBody.kt | 14 +++- .../models/response/PlayerResponse.kt | 19 ++++++ 12 files changed, 173 insertions(+), 57 deletions(-) create mode 100644 innertube/src/main/java/com/zionhuang/innertube/NewPipeDownloaderImpl.kt diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index f828764f5..a5aabb12c 100755 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -78,4 +78,11 @@ # Keep Data data classes -keep class com.my.kizzy.remote.** { ; } # Keep Gateway data classes --keep class com.my.kizzy.gateway.entities.** { ; } \ No newline at end of file +-keep class com.my.kizzy.gateway.entities.** { ; } + +## Rules for NewPipeExtractor +-keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; } +-keep class org.mozilla.javascript.** { *; } +-keep class org.mozilla.classfile.ClassFileWriter +-dontwarn org.mozilla.javascript.JavaToJSONConverters +-dontwarn org.mozilla.javascript.tools.** \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/App.kt b/app/src/main/java/com/zionhuang/music/App.kt index fe609d1a4..f2ef3a767 100644 --- a/app/src/main/java/com/zionhuang/music/App.kt +++ b/app/src/main/java/com/zionhuang/music/App.kt @@ -94,7 +94,11 @@ class App : Application(), ImageLoaderFactory { dataStore.data .map { it[InnerTubeCookieKey] } .distinctUntilChanged() - .collect { cookie -> + .collect { rawCookie -> + // quick hack until https://github.com/z-huang/InnerTune/pull/1694 is done + val isLoggedIn: Boolean = rawCookie?.contains("SAPISID") ?: false + val cookie = if (isLoggedIn) rawCookie else null + YouTube.cookie = cookie } } diff --git a/app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt b/app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt index 42f75ab9c..136f45711 100644 --- a/app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt +++ b/app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt @@ -88,10 +88,7 @@ class DownloadUtil @Inject constructor( AudioQuality.LOW -> -1 } + (if (it.mimeType.startsWith("audio/webm")) 10240 else 0) // prefer opus stream } - }!!.let { - // Specify range to avoid YouTube's throttling - it.copy(url = "${it.url}&range=0-${it.contentLength ?: 10000000}") - } + }!! database.query { upsert( @@ -108,8 +105,13 @@ class DownloadUtil @Inject constructor( ) } - songUrlCache[mediaId] = format.url!! to playerResponse.streamingData!!.expiresInSeconds * 1000L - dataSpec.withUri(format.url!!.toUri()) + val streamUrl = format.findUrl()?.let { + // Specify range to avoid YouTube's throttling + "${it}&range=0-${format.contentLength ?: 10000000}" + } + + songUrlCache[mediaId] = streamUrl!! to playerResponse.streamingData!!.expiresInSeconds * 1000L + dataSpec.withUri(streamUrl.toUri()) } val downloadNotificationHelper = DownloadNotificationHelper(context, ExoDownloadService.CHANNEL_ID) val downloadManager: DownloadManager = DownloadManager(context, databaseProvider, downloadCache, dataSourceFactory, Executor(Runnable::run)).apply { diff --git a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt index dbae89d14..20695bba9 100644 --- a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt +++ b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt @@ -691,8 +691,10 @@ class MusicService : MediaLibraryService(), } scope.launch(Dispatchers.IO) { recoverSong(mediaId, playerResponse) } - songUrlCache[mediaId] = format.url!! to playerResponse.streamingData!!.expiresInSeconds * 1000L - dataSpec.withUri(format.url!!.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) + val streamUrl = format.findUrl() + + songUrlCache[mediaId] = streamUrl!! to playerResponse.streamingData!!.expiresInSeconds * 1000L + dataSpec.withUri(streamUrl.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 91c965d27..ecc303140 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -89,6 +89,10 @@ firebase-perf-plugin = { module = "com.google.firebase:perf-plugin", version = " mlkit-language-id = { group = "com.google.mlkit", name = "language-id", version = "17.0.6" } mlkit-translate = { group = "com.google.mlkit", name = "translate", version = "17.0.3" } +#newpipe-extractor = { group = "com.github.TeamNewPipe", name = "NewPipeExtractor", version = "v0.24.3" } +# Use fork of NewPipeExtractor until https://github.com/TeamNewPipe/NewPipeExtractor/pull/1253 is merged +newpipe-extractor = { group = "com.github.gechoto", name = "NewPipeExtractor", version = "0a5158d9052d57a9dbf184814de1988a8cb7824d" } + [plugins] kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/innertube/build.gradle.kts b/innertube/build.gradle.kts index c2828a46f..ab489df12 100644 --- a/innertube/build.gradle.kts +++ b/innertube/build.gradle.kts @@ -15,5 +15,6 @@ dependencies { implementation(libs.ktor.serialization.json) implementation(libs.ktor.client.encoding) implementation(libs.brotli) + implementation(libs.newpipe.extractor) testImplementation(libs.junit) } \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt index ae0775f1d..97d1c8d9a 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt @@ -18,6 +18,7 @@ import io.ktor.serialization.kotlinx.json.* import io.ktor.util.encodeBase64 import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json +import org.schabi.newpipe.extractor.services.youtube.YoutubeJavaScriptPlayerManager import java.net.Proxy import java.util.* @@ -74,7 +75,7 @@ class InnerTube { } defaultRequest { - url("https://music.youtube.com/youtubei/v1/") + url(YouTubeClient.API_URL_YOUTUBE_MUSIC) } } @@ -82,24 +83,21 @@ class InnerTube { contentType(ContentType.Application.Json) headers { append("X-Goog-Api-Format-Version", "1") - append("X-YouTube-Client-Name", client.clientName) + append("X-YouTube-Client-Name", client.clientId) append("X-YouTube-Client-Version", client.clientVersion) - append("x-origin", "https://music.youtube.com") - if (client.referer != null) { - append("Referer", client.referer) - } - if (setLogin) { + append("X-Origin", YouTubeClient.ORIGIN_YOUTUBE_MUSIC) + append("Referer", YouTubeClient.REFERER_YOUTUBE_MUSIC) + if (setLogin && client.supportsLogin) { cookie?.let { cookie -> append("cookie", cookie) if ("SAPISID" !in cookieMap) return@let val currentTime = System.currentTimeMillis() / 1000 - val sapisidHash = sha1("$currentTime ${cookieMap["SAPISID"]} https://music.youtube.com") + val sapisidHash = sha1("$currentTime ${cookieMap["SAPISID"]} ${YouTubeClient.ORIGIN_YOUTUBE_MUSIC}") append("Authorization", "SAPISIDHASH ${currentTime}_${sapisidHash}") } } } userAgent(client.userAgent) - parameter("key", client.api_key) parameter("prettyPrint", false) } @@ -139,7 +137,13 @@ class InnerTube { } else it }, videoId = videoId, - playlistId = playlistId + playlistId = playlistId, + playbackContext = + if (client.useSignatureTimestamp) { + PlayerBody.PlaybackContext(PlayerBody.PlaybackContext.ContentPlaybackContext( + signatureTimestamp = YoutubeJavaScriptPlayerManager.getSignatureTimestamp(videoId) + )) + } else null ) ) } @@ -226,7 +230,6 @@ class InnerTube { client: YouTubeClient, videoId: String, ) = httpClient.post("https://music.youtube.com/youtubei/v1/get_transcript") { - parameter("key", "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX3") headers { append("Content-Type", "application/json") } diff --git a/innertube/src/main/java/com/zionhuang/innertube/NewPipeDownloaderImpl.kt b/innertube/src/main/java/com/zionhuang/innertube/NewPipeDownloaderImpl.kt new file mode 100644 index 000000000..44f4052db --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/NewPipeDownloaderImpl.kt @@ -0,0 +1,53 @@ +package com.zionhuang.innertube + +import com.zionhuang.innertube.models.YouTubeClient +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.ReCaptchaException +import java.io.IOException + +object NewPipeDownloaderImpl : Downloader() { + + private val client = OkHttpClient.Builder().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) + } + +} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt index 62349e442..da0a6fed5 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -12,10 +12,10 @@ import com.zionhuang.innertube.models.SearchSuggestions import com.zionhuang.innertube.models.SongItem import com.zionhuang.innertube.models.WatchEndpoint import com.zionhuang.innertube.models.WatchEndpoint.WatchEndpointMusicSupportedConfigs.WatchEndpointMusicConfig.Companion.MUSIC_VIDEO_TYPE_ATV -import com.zionhuang.innertube.models.YouTubeClient.Companion.ANDROID_MUSIC import com.zionhuang.innertube.models.YouTubeClient.Companion.IOS import com.zionhuang.innertube.models.YouTubeClient.Companion.TVHTML5 import com.zionhuang.innertube.models.YouTubeClient.Companion.WEB +import com.zionhuang.innertube.models.YouTubeClient.Companion.WEB_CREATOR import com.zionhuang.innertube.models.YouTubeClient.Companion.WEB_REMIX import com.zionhuang.innertube.models.YouTubeLocale import com.zionhuang.innertube.models.getContinuation @@ -54,6 +54,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive +import org.schabi.newpipe.extractor.NewPipe import java.net.Proxy /** @@ -63,6 +64,10 @@ import java.net.Proxy object YouTube { private val innerTube = InnerTube() + init { + NewPipe.init(NewPipeDownloaderImpl) + } + var locale: YouTubeLocale get() = innerTube.locale set(value) { @@ -431,8 +436,8 @@ object YouTube { suspend fun player(videoId: String, playlistId: String? = null): Result = runCatching { var playerResponse: PlayerResponse - if (this.cookie != null) { // if logged in: try ANDROID_MUSIC client first because IOS client does not play age restricted songs - playerResponse = innerTube.player(ANDROID_MUSIC, videoId, playlistId).body() + if (this.cookie != null) { // if logged in: try WEB_CREATOR client first because IOS client does not support login + playerResponse = innerTube.player(WEB_CREATOR, videoId, playlistId).body() if (playerResponse.playabilityStatus.status == "OK") { return@runCatching playerResponse } diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt index ae12004f2..742ee5270 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt @@ -6,10 +6,13 @@ import kotlinx.serialization.Serializable data class YouTubeClient( val clientName: String, val clientVersion: String, - val api_key: String, + val clientId: String, val userAgent: String, val osVersion: String? = null, - val referer: String? = null, + val supportsLogin: Boolean = false, + val useSignatureTimestamp: Boolean = false, + // val origin: String? = null, + // val referer: String? = null, ) { fun toContext(locale: YouTubeLocale, visitorData: String?) = Context( client = Context.Client( @@ -23,54 +26,55 @@ data class YouTubeClient( ) companion object { - private const val REFERER_YOUTUBE_MUSIC = "https://music.youtube.com/" + /** + * 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" - private const val USER_AGENT_WEB = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.157 Safari/537.36" - private const val USER_AGENT_ANDROID = "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.181 Mobile Safari/537.36" - private const val USER_AGENT_IOS = "com.google.ios.youtube/19.29.1 (iPhone16,2; U; CPU iOS 17_5_1 like Mac OS X;)" - - val ANDROID_MUSIC = YouTubeClient( - clientName = "ANDROID_MUSIC", - clientVersion = "5.01", - api_key = "AIzaSyAOghZGza2MQSZkY_zfZ370N-PUdXEo8AI", - userAgent = USER_AGENT_ANDROID - ) - - val ANDROID = YouTubeClient( - clientName = "ANDROID", - clientVersion = "17.13.3", - api_key = "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w", - userAgent = USER_AGENT_ANDROID, - ) + 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.2021111", - api_key = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX3", - userAgent = USER_AGENT_WEB + clientVersion = "2.20241126.01.00", + clientId = "1", + userAgent = USER_AGENT_WEB, ) val WEB_REMIX = YouTubeClient( clientName = "WEB_REMIX", - clientVersion = "1.20220606.03.00", - api_key = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30", + clientVersion = "1.20241127.01.00", + clientId = "67", + userAgent = USER_AGENT_WEB, + supportsLogin = true, + useSignatureTimestamp = true, + ) + + val WEB_CREATOR = YouTubeClient( + clientName = "WEB_CREATOR", + clientVersion = "1.20241203.01.00", + clientId = "62", userAgent = USER_AGENT_WEB, - referer = REFERER_YOUTUBE_MUSIC + supportsLogin = true, + useSignatureTimestamp = true, ) val TVHTML5 = YouTubeClient( clientName = "TVHTML5_SIMPLY_EMBEDDED_PLAYER", clientVersion = "2.0", - api_key = "AIzaSyDCU8hByM-4DrUqRUYnGn-3llEO78bcxq8", - userAgent = "Mozilla/5.0 (PlayStation 4 5.55) AppleWebKit/601.2 (KHTML, like Gecko)" + clientId = "85", + userAgent = "Mozilla/5.0 (PlayStation; PlayStation 4/12.00) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Safari/605.1.15", + supportsLogin = true, + useSignatureTimestamp = true, ) val IOS = YouTubeClient( clientName = "IOS", - clientVersion = "19.29.1", - api_key = "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc", - userAgent = USER_AGENT_IOS, - osVersion = "17.5.1.21F90", + clientVersion = "19.45.4", + clientId = "5", + userAgent = "com.google.ios.youtube/19.45.4 (iPhone16,2; U; CPU iOS 18_1_0 like Mac OS X;)", + osVersion = "18.1.0.22B83", ) } } diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/body/PlayerBody.kt b/innertube/src/main/java/com/zionhuang/innertube/models/body/PlayerBody.kt index 3522294db..642d660ea 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/body/PlayerBody.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/body/PlayerBody.kt @@ -8,5 +8,17 @@ data class PlayerBody( val context: Context, val videoId: String, val playlistId: String?, + val playbackContext: PlaybackContext? = null, val contentCheckOk: Boolean = true, -) + val racyCheckOk: Boolean = true, +) { + @Serializable + data class PlaybackContext( + val contentPlaybackContext: ContentPlaybackContext + ) { + @Serializable + data class ContentPlaybackContext( + val signatureTimestamp: Int + ) + } +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt index 23d426879..87394ee76 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt @@ -2,7 +2,10 @@ package com.zionhuang.innertube.models.response import com.zionhuang.innertube.models.ResponseContext import com.zionhuang.innertube.models.Thumbnails +import io.ktor.http.URLBuilder +import io.ktor.http.parseQueryString import kotlinx.serialization.Serializable +import org.schabi.newpipe.extractor.services.youtube.YoutubeJavaScriptPlayerManager /** * PlayerResponse with [com.zionhuang.innertube.models.YouTubeClient.ANDROID_MUSIC] client @@ -57,9 +60,25 @@ data class PlayerResponse( val audioChannels: Int?, val loudnessDb: Double?, val lastModified: Long?, + val signatureCipher: String?, ) { val isAudio: Boolean get() = width == null + + fun findUrl(): String? { + this.url?.let { + return it + } + this.signatureCipher?.let { signatureCipher -> + val params = parseQueryString(signatureCipher) + val obfuscatedSignature = params["s"] ?: return null + val signatureParam = params["sp"] ?: return null + val url = params["url"]?.let { URLBuilder(it) } ?: return null + url.parameters[signatureParam] = YoutubeJavaScriptPlayerManager.deobfuscateSignature("", obfuscatedSignature) + return YoutubeJavaScriptPlayerManager.getUrlWithThrottlingParameterDeobfuscated("", url.toString()) + } + return null + } } } From f75f321fa37ae84748df778124608da16915c20c Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Sun, 5 Jan 2025 04:44:28 +0000 Subject: [PATCH 02/29] rework player requests & response handling --- .../zionhuang/music/playback/DownloadUtil.kt | 36 ++-- .../zionhuang/music/playback/MusicService.kt | 46 ++--- .../zionhuang/music/utils/YTPlayerUtils.kt | 181 ++++++++++++++++++ .../java/com/zionhuang/innertube/InnerTube.kt | 5 - .../java/com/zionhuang/innertube/YouTube.kt | 35 +--- .../models/response/PlayerResponse.kt | 2 +- 6 files changed, 214 insertions(+), 91 deletions(-) create mode 100644 app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt diff --git a/app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt b/app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt index 136f45711..30b7282a8 100644 --- a/app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt +++ b/app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt @@ -4,7 +4,6 @@ import android.content.Context import android.net.ConnectivityManager import androidx.core.content.getSystemService import androidx.core.net.toUri -import androidx.media3.common.PlaybackException import androidx.media3.database.DatabaseProvider import androidx.media3.datasource.ResolvingDataSource import androidx.media3.datasource.cache.CacheDataSource @@ -20,6 +19,7 @@ import com.zionhuang.music.db.MusicDatabase import com.zionhuang.music.db.entities.FormatEntity import com.zionhuang.music.di.DownloadCache import com.zionhuang.music.di.PlayerCache +import com.zionhuang.music.utils.YTPlayerUtils import com.zionhuang.music.utils.enumPreference import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -68,27 +68,15 @@ class DownloadUtil @Inject constructor( } val playedFormat = runBlocking(Dispatchers.IO) { database.format(mediaId).first() } - val playerResponse = runBlocking(Dispatchers.IO) { - YouTube.player(mediaId) + val playbackData = runBlocking(Dispatchers.IO) { + YTPlayerUtils.playerResponseForPlayback( + mediaId, + playedFormat = playedFormat, + audioQuality = audioQuality, + connectivityManager = connectivityManager, + ) }.getOrThrow() - if (playerResponse.playabilityStatus.status != "OK") { - throw PlaybackException(playerResponse.playabilityStatus.reason, null, PlaybackException.ERROR_CODE_REMOTE_ERROR) - } - - val format = - if (playedFormat != null) { - playerResponse.streamingData?.adaptiveFormats?.find { it.itag == playedFormat.itag } - } else { - playerResponse.streamingData?.adaptiveFormats - ?.filter { it.isAudio } - ?.maxByOrNull { - it.bitrate * when (audioQuality) { - 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 - } - }!! + val format = playbackData.format database.query { upsert( @@ -100,17 +88,17 @@ class DownloadUtil @Inject constructor( bitrate = format.bitrate, sampleRate = format.audioSampleRate, contentLength = format.contentLength!!, - loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb + loudnessDb = playbackData.audioConfig?.loudnessDb ) ) } - val streamUrl = format.findUrl()?.let { + val streamUrl = playbackData.streamUrl.let { // Specify range to avoid YouTube's throttling "${it}&range=0-${format.contentLength ?: 10000000}" } - songUrlCache[mediaId] = streamUrl!! to playerResponse.streamingData!!.expiresInSeconds * 1000L + songUrlCache[mediaId] = streamUrl to playbackData.streamExpiresInSeconds * 1000L dataSpec.withUri(streamUrl.toUri()) } val downloadNotificationHelper = DownloadNotificationHelper(context, ExoDownloadService.CHANNEL_ID) diff --git a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt index 20695bba9..f78def810 100644 --- a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt +++ b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt @@ -99,6 +99,7 @@ import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.playback.queues.filterExplicit import com.zionhuang.music.utils.CoilBitmapLoader import com.zionhuang.music.utils.DiscordRPC +import com.zionhuang.music.utils.YTPlayerUtils import com.zionhuang.music.utils.dataStore import com.zionhuang.music.utils.enumPreference import com.zionhuang.music.utils.get @@ -398,14 +399,14 @@ class MusicService : MediaLibraryService(), ) } - private suspend fun recoverSong(mediaId: String, playerResponse: PlayerResponse? = null) { + private suspend fun recoverSong(mediaId: String, videoDetails: PlayerResponse.VideoDetails? = null) { val song = database.song(mediaId).first() val mediaMetadata = withContext(Dispatchers.Main) { player.findNextMediaItemById(mediaId)?.metadata } ?: return val duration = song?.song?.duration?.takeIf { it != -1 } ?: mediaMetadata.duration.takeIf { it != -1 } - ?: (playerResponse ?: YouTube.player(mediaId).getOrNull())?.videoDetails?.lengthSeconds?.toInt() + ?: (videoDetails ?: YTPlayerUtils.playerResponseForMetadata(mediaId).getOrNull()?.videoDetails)?.lengthSeconds?.toInt() ?: -1 database.query { if (song == null) insert(mediaMetadata.copy(duration = duration)) @@ -638,10 +639,17 @@ class MusicService : MediaLibraryService(), // Check whether format exists so that users from older version can view format details // There may be inconsistent between the downloaded file and the displayed info if user change audio quality frequently val playedFormat = runBlocking(Dispatchers.IO) { database.format(mediaId).first() } - val playerResponse = runBlocking(Dispatchers.IO) { - YouTube.player(mediaId) + val playbackData = runBlocking(Dispatchers.IO) { + YTPlayerUtils.playerResponseForPlayback( + mediaId, + playedFormat = playedFormat, + audioQuality = audioQuality, + connectivityManager = connectivityManager, + ) }.getOrElse { throwable -> when (throwable) { + is PlaybackException -> throw throwable + is ConnectException, is UnknownHostException -> { throw PlaybackException(getString(R.string.error_no_internet), throwable, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED) } @@ -653,27 +661,7 @@ class MusicService : MediaLibraryService(), else -> throw PlaybackException(getString(R.string.error_unknown), throwable, PlaybackException.ERROR_CODE_REMOTE_ERROR) } } - if (playerResponse.playabilityStatus.status != "OK") { - throw PlaybackException(playerResponse.playabilityStatus.reason, null, PlaybackException.ERROR_CODE_REMOTE_ERROR) - } - - val format = - if (playedFormat != null) { - playerResponse.streamingData?.adaptiveFormats?.find { - // Use itag to identify previously played format - it.itag == playedFormat.itag - } - } else { - playerResponse.streamingData?.adaptiveFormats - ?.filter { it.isAudio } - ?.maxByOrNull { - it.bitrate * when (audioQuality) { - 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 - } - } ?: throw PlaybackException(getString(R.string.error_no_stream), null, ERROR_CODE_NO_STREAM) + val format = playbackData.format database.query { upsert( @@ -685,15 +673,15 @@ class MusicService : MediaLibraryService(), bitrate = format.bitrate, sampleRate = format.audioSampleRate, contentLength = format.contentLength!!, - loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb + loudnessDb = playbackData.audioConfig?.loudnessDb ) ) } - scope.launch(Dispatchers.IO) { recoverSong(mediaId, playerResponse) } + scope.launch(Dispatchers.IO) { recoverSong(mediaId, playbackData.videoDetails) } - val streamUrl = format.findUrl() + val streamUrl = playbackData.streamUrl - songUrlCache[mediaId] = streamUrl!! to playerResponse.streamingData!!.expiresInSeconds * 1000L + songUrlCache[mediaId] = streamUrl to playbackData.streamExpiresInSeconds * 1000L dataSpec.withUri(streamUrl.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) } } diff --git a/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt b/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt new file mode 100644 index 000000000..7bff95984 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt @@ -0,0 +1,181 @@ +package com.zionhuang.music.utils + +import android.net.ConnectivityManager +import androidx.media3.common.PlaybackException +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.YouTubeClient +import com.zionhuang.innertube.models.YouTubeClient.Companion.IOS +import com.zionhuang.innertube.models.YouTubeClient.Companion.WEB_CREATOR +import com.zionhuang.innertube.models.YouTubeClient.Companion.WEB_REMIX +import com.zionhuang.innertube.models.response.PlayerResponse +import com.zionhuang.music.constants.AudioQuality +import com.zionhuang.music.db.entities.FormatEntity +import okhttp3.OkHttpClient + +object YTPlayerUtils { + + private val httpClient = OkHttpClient.Builder().build() + + /** + * The main client is used for metadata and initial streams. + * Do not use other clients for this because it can result in inconsistent metadata. + * For example other clients can have different normalization targets (loudnessDb). + * + * [com.zionhuang.innertube.models.YouTubeClient.WEB_REMIX] should be preferred here because currently it is the only client which provides: + * - the correct metadata (like loudnessDb) + * - premium formats + */ + private val MAIN_CLIENT: YouTubeClient = WEB_REMIX + + /** + * Clients used for fallback streams in case the streams of the main client do not work. + */ + private val STREAM_FALLBACK_CLIENTS: Array = arrayOf(WEB_CREATOR, IOS) + + data class PlaybackData( + val audioConfig: PlayerResponse.PlayerConfig.AudioConfig?, + val videoDetails: PlayerResponse.VideoDetails?, + val format: PlayerResponse.StreamingData.Format, + val streamUrl: String, + val streamExpiresInSeconds: Int, + ) + + /** + * Custom player response intended to use for playback. + * Metadata like audioConfig and videoDetails are from [MAIN_CLIENT]. + * Format & stream can be from [MAIN_CLIENT] or [STREAM_FALLBACK_CLIENTS]. + */ + suspend fun playerResponseForPlayback( + videoId: String, + playlistId: String? = null, + playedFormat: FormatEntity?, + audioQuality: AudioQuality, + connectivityManager: ConnectivityManager, + ): Result = runCatching { + val mainPlayerResponse = + YouTube.player(videoId, playlistId, client = MAIN_CLIENT).getOrThrow() + + val audioConfig = mainPlayerResponse.playerConfig?.audioConfig + val videoDetails = mainPlayerResponse.videoDetails + + var format: PlayerResponse.StreamingData.Format? = null + var streamUrl: String? = null + var streamExpiresInSeconds: Int? = null + + if (mainPlayerResponse.playabilityStatus.status == "OK") { + format = findFormat( + mainPlayerResponse, + playedFormat, + audioQuality, + connectivityManager, + ) + if (format != null) { + streamUrl = format.findUrl() + streamExpiresInSeconds = mainPlayerResponse.streamingData?.expiresInSeconds + if (streamUrl != null && streamExpiresInSeconds != null && validateStatus(streamUrl)) { + return@runCatching PlaybackData( + audioConfig, + videoDetails, + format, + streamUrl, + streamExpiresInSeconds, + ) + } + } + } + + var fallbackPlayerResponse: PlayerResponse? = null + for (client in STREAM_FALLBACK_CLIENTS) { + // reset for each client + format = null + streamUrl = null + streamExpiresInSeconds = null + + // process current client response + fallbackPlayerResponse = + YouTube.player(videoId, playlistId, client).getOrNull() + if (fallbackPlayerResponse?.playabilityStatus?.status == "OK") { + format = + findFormat( + fallbackPlayerResponse, + playedFormat, + audioQuality, + connectivityManager, + ) ?: continue + streamUrl = format.findUrl() ?: continue + streamExpiresInSeconds = fallbackPlayerResponse.streamingData?.expiresInSeconds ?: continue + + if (validateStatus(streamUrl)) { + // working stream found + break + } + } + } + + if (fallbackPlayerResponse == null) { + throw Exception("Bad fallback player response") + } + if (fallbackPlayerResponse.playabilityStatus.status != "OK") { + throw PlaybackException( + fallbackPlayerResponse.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") + } + + PlaybackData( + audioConfig, + videoDetails, + format, + streamUrl, + streamExpiresInSeconds, + ) + } + + /** + * Simple player response intended to use for metadata only. + * Stream URLs of this response might not work so don't use them. + */ + suspend fun playerResponseForMetadata( + videoId: String, + playlistId: String? = null, + ): Result = + YouTube.player(videoId, playlistId, client = MAIN_CLIENT) + + private fun findFormat( + playerResponse: PlayerResponse, + playedFormat: FormatEntity?, + audioQuality: AudioQuality, + connectivityManager: ConnectivityManager, + ): PlayerResponse.StreamingData.Format? = + if (playedFormat != null) { + playerResponse.streamingData?.adaptiveFormats?.find { it.itag == playedFormat.itag } + } else { + playerResponse.streamingData?.adaptiveFormats + ?.filter { it.isAudio } + ?.maxByOrNull { + it.bitrate * when (audioQuality) { + 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 + } + } + + private fun validateStatus(url: String): Boolean { + val requestBuilder = okhttp3.Request.Builder() + .head() + .url(url) + val response = httpClient.newCall(requestBuilder.build()).execute() + return response.isSuccessful + } +} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt index 97d1c8d9a..b46ef6df6 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt @@ -148,11 +148,6 @@ class InnerTube { ) } - suspend fun pipedStreams(videoId: String) = - httpClient.get("https://pipedapi.kavin.rocks/streams/${videoId}") { - contentType(ContentType.Application.Json) - } - suspend fun browse( client: YouTubeClient, browseId: String? = null, diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt index da0a6fed5..211f2d9c2 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -12,10 +12,8 @@ import com.zionhuang.innertube.models.SearchSuggestions import com.zionhuang.innertube.models.SongItem import com.zionhuang.innertube.models.WatchEndpoint import com.zionhuang.innertube.models.WatchEndpoint.WatchEndpointMusicSupportedConfigs.WatchEndpointMusicConfig.Companion.MUSIC_VIDEO_TYPE_ATV -import com.zionhuang.innertube.models.YouTubeClient.Companion.IOS -import com.zionhuang.innertube.models.YouTubeClient.Companion.TVHTML5 +import com.zionhuang.innertube.models.YouTubeClient import com.zionhuang.innertube.models.YouTubeClient.Companion.WEB -import com.zionhuang.innertube.models.YouTubeClient.Companion.WEB_CREATOR import com.zionhuang.innertube.models.YouTubeClient.Companion.WEB_REMIX import com.zionhuang.innertube.models.YouTubeLocale import com.zionhuang.innertube.models.getContinuation @@ -26,7 +24,6 @@ import com.zionhuang.innertube.models.response.GetQueueResponse import com.zionhuang.innertube.models.response.GetSearchSuggestionsResponse import com.zionhuang.innertube.models.response.GetTranscriptResponse import com.zionhuang.innertube.models.response.NextResponse -import com.zionhuang.innertube.models.response.PipedResponse import com.zionhuang.innertube.models.response.PlayerResponse import com.zionhuang.innertube.models.response.SearchResponse import com.zionhuang.innertube.pages.AlbumPage @@ -434,34 +431,8 @@ object YouTube { } } - suspend fun player(videoId: String, playlistId: String? = null): Result = runCatching { - var playerResponse: PlayerResponse - if (this.cookie != null) { // if logged in: try WEB_CREATOR client first because IOS client does not support login - playerResponse = innerTube.player(WEB_CREATOR, videoId, playlistId).body() - if (playerResponse.playabilityStatus.status == "OK") { - return@runCatching playerResponse - } - } - playerResponse = innerTube.player(IOS, videoId, playlistId).body() - if (playerResponse.playabilityStatus.status == "OK") { - return@runCatching playerResponse - } - val safePlayerResponse = innerTube.player(TVHTML5, videoId, playlistId).body() - if (safePlayerResponse.playabilityStatus.status != "OK") { - return@runCatching playerResponse - } - val audioStreams = innerTube.pipedStreams(videoId).body().audioStreams - safePlayerResponse.copy( - streamingData = safePlayerResponse.streamingData?.copy( - adaptiveFormats = safePlayerResponse.streamingData.adaptiveFormats.mapNotNull { adaptiveFormat -> - audioStreams.find { it.bitrate == adaptiveFormat.bitrate }?.let { - adaptiveFormat.copy( - url = it.url - ) - } - } - ) - ) + suspend fun player(videoId: String, playlistId: String? = null, client: YouTubeClient): Result = runCatching { + innerTube.player(client, videoId, playlistId).body() } suspend fun next(endpoint: WatchEndpoint, continuation: String? = null): Result = runCatching { diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt index 87394ee76..ab3bf3a2b 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt @@ -8,7 +8,7 @@ import kotlinx.serialization.Serializable import org.schabi.newpipe.extractor.services.youtube.YoutubeJavaScriptPlayerManager /** - * PlayerResponse with [com.zionhuang.innertube.models.YouTubeClient.ANDROID_MUSIC] client + * PlayerResponse with [com.zionhuang.innertube.models.YouTubeClient.WEB_REMIX] client */ @Serializable data class PlayerResponse( From d50c0196e9aec74650dba7cf00dd8cba2932fa59 Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Sun, 5 Jan 2025 04:46:08 +0000 Subject: [PATCH 03/29] also build foss flavor for PRs --- .github/workflows/build_pr.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/build_pr.yml b/.github/workflows/build_pr.yml index ac657000a..47c9811f3 100644 --- a/.github/workflows/build_pr.yml +++ b/.github/workflows/build_pr.yml @@ -27,3 +27,26 @@ jobs: with: name: app path: app/build/outputs/apk/full/debug/*.apk + + build-foss: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: "zulu" + cache: 'gradle' + + - name: Build debug APK and run jvm tests + run: ./gradlew assembleFossDebug lintFossDebug testFossDebugUnitTest --stacktrace -DskipFormatKtlint + env: + PULL_REQUEST: 'true' + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: app-foss + path: app/build/outputs/apk/foss/debug/*.apk From 599622dcabdc7a4c899342f9a6aafcbcb5c95a29 Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Sun, 5 Jan 2025 20:24:22 +0000 Subject: [PATCH 04/29] YTPlayerUtils: catch & report url validation errors --- .../com/zionhuang/music/utils/YTPlayerUtils.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt b/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt index 7bff95984..5e4c03bcc 100644 --- a/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt +++ b/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt @@ -172,10 +172,15 @@ object YTPlayerUtils { } private fun validateStatus(url: String): Boolean { - val requestBuilder = okhttp3.Request.Builder() - .head() - .url(url) - val response = httpClient.newCall(requestBuilder.build()).execute() - return response.isSuccessful + try { + val requestBuilder = okhttp3.Request.Builder() + .head() + .url(url) + val response = httpClient.newCall(requestBuilder.build()).execute() + return response.isSuccessful + } catch (e: Exception) { + reportException(e) + } + return false } } \ No newline at end of file From a81c1df9b3ca0c9561819f98878861992bd28584 Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Sun, 5 Jan 2025 20:48:09 +0000 Subject: [PATCH 05/29] update NewPipeExtractor --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ecc303140..6e95d0e08 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -91,7 +91,7 @@ mlkit-translate = { group = "com.google.mlkit", name = "translate", version = "1 #newpipe-extractor = { group = "com.github.TeamNewPipe", name = "NewPipeExtractor", version = "v0.24.3" } # Use fork of NewPipeExtractor until https://github.com/TeamNewPipe/NewPipeExtractor/pull/1253 is merged -newpipe-extractor = { group = "com.github.gechoto", name = "NewPipeExtractor", version = "0a5158d9052d57a9dbf184814de1988a8cb7824d" } +newpipe-extractor = { group = "com.github.gechoto", name = "NewPipeExtractor", version = "a4b0ee902e9717144aa9504df2d85cdd16476b90" } [plugins] kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } From 850f16c040a77554106a5bb4112e8b7162aa8d62 Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Sun, 5 Jan 2025 21:03:20 +0000 Subject: [PATCH 06/29] YouTubeTest: use IOS client --- .../src/test/java/com/zionhuang/innertube/YouTubeTest.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt b/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt index 324cb4c2a..c6ed90aa2 100644 --- a/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt +++ b/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt @@ -7,6 +7,7 @@ import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_FEATURED_PL import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_SONG import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_VIDEO import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.innertube.models.YouTubeClient import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.request.get @@ -24,7 +25,7 @@ class YouTubeTest { @Test fun `Check 'player' endpoint`() = runBlocking { VIDEO_IDS.forEach { videoId -> - val playerResponse = youTube.player(videoId).getOrThrow() + val playerResponse = youTube.player(videoId, client = YouTubeClient.IOS).getOrThrow() assertTrue(playerResponse.playabilityStatus.status == "OK") } } @@ -32,7 +33,7 @@ class YouTubeTest { @Test fun `Check playable stream`() = runBlocking { VIDEO_IDS.forEach { videoId -> - val playerResponse = youTube.player(videoId).getOrThrow() + val playerResponse = youTube.player(videoId, client = YouTubeClient.IOS).getOrThrow() val format = playerResponse.streamingData!!.adaptiveFormats[0] val url = format.url!! println(url) From 2f816d09176c95485a0acd8c2e9288828dc818f9 Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Sun, 5 Jan 2025 22:16:47 +0000 Subject: [PATCH 07/29] improved url deobfuscation error handling --- .../zionhuang/music/utils/YTPlayerUtils.kt | 23 +++++++++++++++++-- .../models/response/PlayerResponse.kt | 17 +++++++------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt b/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt index 5e4c03bcc..687f18e81 100644 --- a/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt +++ b/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt @@ -70,7 +70,7 @@ object YTPlayerUtils { connectivityManager, ) if (format != null) { - streamUrl = format.findUrl() + streamUrl = findUrlOrNull(format, videoId) streamExpiresInSeconds = mainPlayerResponse.streamingData?.expiresInSeconds if (streamUrl != null && streamExpiresInSeconds != null && validateStatus(streamUrl)) { return@runCatching PlaybackData( @@ -102,7 +102,7 @@ object YTPlayerUtils { audioQuality, connectivityManager, ) ?: continue - streamUrl = format.findUrl() ?: continue + streamUrl = findUrlOrNull(format, videoId) ?: continue streamExpiresInSeconds = fallbackPlayerResponse.streamingData?.expiresInSeconds ?: continue if (validateStatus(streamUrl)) { @@ -171,6 +171,11 @@ object YTPlayerUtils { } } + /** + * 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 = okhttp3.Request.Builder() @@ -183,4 +188,18 @@ object YTPlayerUtils { } return false } + + /** + * Wrapper around the [PlayerResponse.StreamingData.Format.findUrl] function which reports exceptions + */ + private fun findUrlOrNull( + format: PlayerResponse.StreamingData.Format, + videoId: String + ): String? { + return format.findUrl(videoId) + .onFailure { + reportException(it) + } + .getOrNull() + } } \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt index ab3bf3a2b..9e5589ba8 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt @@ -5,6 +5,7 @@ import com.zionhuang.innertube.models.Thumbnails import io.ktor.http.URLBuilder import io.ktor.http.parseQueryString import kotlinx.serialization.Serializable +import org.schabi.newpipe.extractor.exceptions.ParsingException import org.schabi.newpipe.extractor.services.youtube.YoutubeJavaScriptPlayerManager /** @@ -65,19 +66,19 @@ data class PlayerResponse( val isAudio: Boolean get() = width == null - fun findUrl(): String? { + fun findUrl(videoId: String): Result = runCatching { this.url?.let { - return it + return@runCatching it } this.signatureCipher?.let { signatureCipher -> val params = parseQueryString(signatureCipher) - val obfuscatedSignature = params["s"] ?: return null - val signatureParam = params["sp"] ?: return null - val url = params["url"]?.let { URLBuilder(it) } ?: return null - url.parameters[signatureParam] = YoutubeJavaScriptPlayerManager.deobfuscateSignature("", obfuscatedSignature) - return YoutubeJavaScriptPlayerManager.getUrlWithThrottlingParameterDeobfuscated("", url.toString()) + 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) + return@runCatching YoutubeJavaScriptPlayerManager.getUrlWithThrottlingParameterDeobfuscated(videoId, url.toString()) } - return null + throw ParsingException("Could not find format url") } } } From d1a0c91815cf7e53d6b34fadfebf8306210ca1ab Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Sun, 5 Jan 2025 23:25:25 +0000 Subject: [PATCH 08/29] reduce duplicate code in YTPlayerUtils and skip url status validation for last client --- .../zionhuang/music/utils/YTPlayerUtils.kt | 57 ++++++++----------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt b/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt index 687f18e81..f4f099218 100644 --- a/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt +++ b/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt @@ -62,49 +62,40 @@ object YTPlayerUtils { var streamUrl: String? = null var streamExpiresInSeconds: Int? = null - if (mainPlayerResponse.playabilityStatus.status == "OK") { - format = findFormat( - mainPlayerResponse, - playedFormat, - audioQuality, - connectivityManager, - ) - if (format != null) { - streamUrl = findUrlOrNull(format, videoId) - streamExpiresInSeconds = mainPlayerResponse.streamingData?.expiresInSeconds - if (streamUrl != null && streamExpiresInSeconds != null && validateStatus(streamUrl)) { - return@runCatching PlaybackData( - audioConfig, - videoDetails, - format, - streamUrl, - streamExpiresInSeconds, - ) - } - } - } - - var fallbackPlayerResponse: PlayerResponse? = null - for (client in STREAM_FALLBACK_CLIENTS) { + var streamPlayerResponse: PlayerResponse? = 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 + if (clientIndex == -1) { + // try with streams from main client first + streamPlayerResponse = mainPlayerResponse + } else { + // after main client use fallback clients + val client = STREAM_FALLBACK_CLIENTS[clientIndex] + streamPlayerResponse = + YouTube.player(videoId, playlistId, client).getOrNull() + } + // process current client response - fallbackPlayerResponse = - YouTube.player(videoId, playlistId, client).getOrNull() - if (fallbackPlayerResponse?.playabilityStatus?.status == "OK") { + if (streamPlayerResponse?.playabilityStatus?.status == "OK") { format = findFormat( - fallbackPlayerResponse, + streamPlayerResponse, playedFormat, audioQuality, connectivityManager, ) ?: continue streamUrl = findUrlOrNull(format, videoId) ?: continue - streamExpiresInSeconds = fallbackPlayerResponse.streamingData?.expiresInSeconds ?: continue + streamExpiresInSeconds = streamPlayerResponse.streamingData?.expiresInSeconds ?: continue + if (clientIndex == STREAM_FALLBACK_CLIENTS.size - 1) { + /** skip [validateStatus] for last client */ + break + } if (validateStatus(streamUrl)) { // working stream found break @@ -112,12 +103,12 @@ object YTPlayerUtils { } } - if (fallbackPlayerResponse == null) { - throw Exception("Bad fallback player response") + if (streamPlayerResponse == null) { + throw Exception("Bad stream player response") } - if (fallbackPlayerResponse.playabilityStatus.status != "OK") { + if (streamPlayerResponse.playabilityStatus.status != "OK") { throw PlaybackException( - fallbackPlayerResponse.playabilityStatus.reason, + streamPlayerResponse.playabilityStatus.reason, null, PlaybackException.ERROR_CODE_REMOTE_ERROR ) From b8050ae998cf1ad052cd58f4f30da0439fe12a85 Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Mon, 6 Jan 2025 19:58:02 +0000 Subject: [PATCH 09/29] move NewPipe functions to `NewPipeUtils` --- .../zionhuang/music/utils/YTPlayerUtils.kt | 5 ++-- .../java/com/zionhuang/innertube/InnerTube.kt | 3 +- .../{NewPipeDownloaderImpl.kt => NewPipe.kt} | 28 +++++++++++++++++++ .../models/response/PlayerResponse.kt | 15 ---------- 4 files changed, 32 insertions(+), 19 deletions(-) rename innertube/src/main/java/com/zionhuang/innertube/{NewPipeDownloaderImpl.kt => NewPipe.kt} (57%) diff --git a/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt b/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt index f4f099218..bbd3ed5c8 100644 --- a/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt +++ b/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt @@ -2,6 +2,7 @@ package com.zionhuang.music.utils import android.net.ConnectivityManager import androidx.media3.common.PlaybackException +import com.zionhuang.innertube.NewPipeUtils import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.YouTubeClient import com.zionhuang.innertube.models.YouTubeClient.Companion.IOS @@ -181,13 +182,13 @@ object YTPlayerUtils { } /** - * Wrapper around the [PlayerResponse.StreamingData.Format.findUrl] function which reports exceptions + * Wrapper around the [NewPipeUtils.getStreamUrl] function which reports exceptions */ private fun findUrlOrNull( format: PlayerResponse.StreamingData.Format, videoId: String ): String? { - return format.findUrl(videoId) + return NewPipeUtils.getStreamUrl(format, videoId) .onFailure { reportException(it) } diff --git a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt index b46ef6df6..1e2561a9c 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt @@ -18,7 +18,6 @@ import io.ktor.serialization.kotlinx.json.* import io.ktor.util.encodeBase64 import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json -import org.schabi.newpipe.extractor.services.youtube.YoutubeJavaScriptPlayerManager import java.net.Proxy import java.util.* @@ -141,7 +140,7 @@ class InnerTube { playbackContext = if (client.useSignatureTimestamp) { PlayerBody.PlaybackContext(PlayerBody.PlaybackContext.ContentPlaybackContext( - signatureTimestamp = YoutubeJavaScriptPlayerManager.getSignatureTimestamp(videoId) + signatureTimestamp = NewPipeUtils.getSignatureTimestamp(videoId).getOrThrow() )) } else null ) diff --git a/innertube/src/main/java/com/zionhuang/innertube/NewPipeDownloaderImpl.kt b/innertube/src/main/java/com/zionhuang/innertube/NewPipe.kt similarity index 57% rename from innertube/src/main/java/com/zionhuang/innertube/NewPipeDownloaderImpl.kt rename to innertube/src/main/java/com/zionhuang/innertube/NewPipe.kt index 44f4052db..9d5e762a0 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/NewPipeDownloaderImpl.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/NewPipe.kt @@ -1,12 +1,17 @@ package com.zionhuang.innertube import com.zionhuang.innertube.models.YouTubeClient +import com.zionhuang.innertube.models.response.PlayerResponse +import io.ktor.http.URLBuilder +import io.ktor.http.parseQueryString 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 object NewPipeDownloaderImpl : Downloader() { @@ -50,4 +55,27 @@ object NewPipeDownloaderImpl : Downloader() { return Response(response.code, response.message, response.headers.toMultimap(), responseBodyToReturn, latestUrl) } +} + +object NewPipeUtils { + + fun getSignatureTimestamp(videoId: String): Result = runCatching { + YoutubeJavaScriptPlayerManager.getSignatureTimestamp(videoId) + } + + fun getStreamUrl(format: PlayerResponse.StreamingData.Format, videoId: String) = runCatching { + format.url?.let { + return@runCatching it + } + 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) + return@runCatching YoutubeJavaScriptPlayerManager.getUrlWithThrottlingParameterDeobfuscated(videoId, url.toString()) + } + throw ParsingException("Could not find format url") + } + } \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt index 9e5589ba8..f30b375e6 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt @@ -65,21 +65,6 @@ data class PlayerResponse( ) { val isAudio: Boolean get() = width == null - - fun findUrl(videoId: String): Result = runCatching { - this.url?.let { - return@runCatching it - } - this.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) - return@runCatching YoutubeJavaScriptPlayerManager.getUrlWithThrottlingParameterDeobfuscated(videoId, url.toString()) - } - throw ParsingException("Could not find format url") - } } } From 9696332c5350a47bd43e8289ed50c517b3cef28d Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Mon, 6 Jan 2025 21:05:39 +0000 Subject: [PATCH 10/29] make `signatureTimestamp` optional and report exception if it could not be parsed --- .../zionhuang/music/utils/YTPlayerUtils.kt | 25 +++++++++++++++++-- .../java/com/zionhuang/innertube/InnerTube.kt | 5 ++-- .../java/com/zionhuang/innertube/YouTube.kt | 4 +-- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt b/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt index bbd3ed5c8..6442d9ad7 100644 --- a/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt +++ b/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt @@ -53,8 +53,16 @@ object YTPlayerUtils { audioQuality: AudioQuality, connectivityManager: ConnectivityManager, ): Result = runCatching { + /** + * This is required for some clients to get working streams however + * it should not be forced for the [MAIN_CLIENT] because the response of the [MAIN_CLIENT] + * is required even if the streams won't work from this client. + * This is why it is allowed to be null. + */ + val signatureTimestamp = getSignatureTimestampOrNull(videoId) + val mainPlayerResponse = - YouTube.player(videoId, playlistId, client = MAIN_CLIENT).getOrThrow() + YouTube.player(videoId, playlistId, MAIN_CLIENT, signatureTimestamp).getOrThrow() val audioConfig = mainPlayerResponse.playerConfig?.audioConfig val videoDetails = mainPlayerResponse.videoDetails @@ -78,7 +86,7 @@ object YTPlayerUtils { // after main client use fallback clients val client = STREAM_FALLBACK_CLIENTS[clientIndex] streamPlayerResponse = - YouTube.player(videoId, playlistId, client).getOrNull() + YouTube.player(videoId, playlistId, client, signatureTimestamp).getOrNull() } // process current client response @@ -181,6 +189,19 @@ object YTPlayerUtils { return false } + /** + * Wrapper around the [NewPipeUtils.getSignatureTimestamp] function which reports exceptions + */ + private fun getSignatureTimestampOrNull( + videoId: String + ): Int? { + return NewPipeUtils.getSignatureTimestamp(videoId) + .onFailure { + reportException(it) + } + .getOrNull() + } + /** * Wrapper around the [NewPipeUtils.getStreamUrl] function which reports exceptions */ diff --git a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt index 1e2561a9c..a1756ad2b 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt @@ -122,6 +122,7 @@ class InnerTube { client: YouTubeClient, videoId: String, playlistId: String?, + signatureTimestamp: Int?, ) = httpClient.post("player") { ytClient(client, setLogin = true) setBody( @@ -138,9 +139,9 @@ class InnerTube { videoId = videoId, playlistId = playlistId, playbackContext = - if (client.useSignatureTimestamp) { + if (client.useSignatureTimestamp && signatureTimestamp != null) { PlayerBody.PlaybackContext(PlayerBody.PlaybackContext.ContentPlaybackContext( - signatureTimestamp = NewPipeUtils.getSignatureTimestamp(videoId).getOrThrow() + signatureTimestamp )) } else null ) diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt index 211f2d9c2..7240df7b8 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -431,8 +431,8 @@ object YouTube { } } - suspend fun player(videoId: String, playlistId: String? = null, client: YouTubeClient): Result = runCatching { - innerTube.player(client, videoId, playlistId).body() + suspend fun player(videoId: String, playlistId: String? = null, client: YouTubeClient, signatureTimestamp: Int? = null): Result = runCatching { + innerTube.player(client, videoId, playlistId, signatureTimestamp).body() } suspend fun next(endpoint: WatchEndpoint, continuation: String? = null): Result = runCatching { From cc1b8bee78c1e3bfb344817b7119c7e170a19fd9 Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Mon, 6 Jan 2025 21:52:50 +0000 Subject: [PATCH 11/29] update NewPipeExtractor --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6e95d0e08..02dff1ebe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -91,7 +91,7 @@ mlkit-translate = { group = "com.google.mlkit", name = "translate", version = "1 #newpipe-extractor = { group = "com.github.TeamNewPipe", name = "NewPipeExtractor", version = "v0.24.3" } # Use fork of NewPipeExtractor until https://github.com/TeamNewPipe/NewPipeExtractor/pull/1253 is merged -newpipe-extractor = { group = "com.github.gechoto", name = "NewPipeExtractor", version = "a4b0ee902e9717144aa9504df2d85cdd16476b90" } +newpipe-extractor = { group = "com.github.gechoto", name = "NewPipeExtractor", version = "00cf7dad9e49933c7faaa382c9c7b1e7afb28a7d" } [plugins] kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } From 81febb4a4207bb9494b77aeba0e33548f272f1b6 Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Tue, 7 Jan 2025 01:17:38 +0000 Subject: [PATCH 12/29] skip WEB_CREATOR client for logged out users --- .../java/com/zionhuang/music/utils/YTPlayerUtils.kt | 5 +++++ .../src/main/java/com/zionhuang/innertube/InnerTube.kt | 2 +- .../com/zionhuang/innertube/models/YouTubeClient.kt | 10 ++++++---- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt b/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt index 6442d9ad7..b0d8a7c31 100644 --- a/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt +++ b/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt @@ -85,6 +85,11 @@ object YTPlayerUtils { } else { // after main client use fallback clients val client = STREAM_FALLBACK_CLIENTS[clientIndex] + if (client.loginRequired && YouTube.cookie == null) { + // skip client if it requires login but user is not logged in + continue + } + streamPlayerResponse = YouTube.player(videoId, playlistId, client, signatureTimestamp).getOrNull() } diff --git a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt index a1756ad2b..e4b65beb2 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt @@ -86,7 +86,7 @@ class InnerTube { append("X-YouTube-Client-Version", client.clientVersion) append("X-Origin", YouTubeClient.ORIGIN_YOUTUBE_MUSIC) append("Referer", YouTubeClient.REFERER_YOUTUBE_MUSIC) - if (setLogin && client.supportsLogin) { + if (setLogin && client.loginSupported) { cookie?.let { cookie -> append("cookie", cookie) if ("SAPISID" !in cookieMap) return@let diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt index 742ee5270..ded69cb89 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt @@ -9,7 +9,8 @@ data class YouTubeClient( val clientId: String, val userAgent: String, val osVersion: String? = null, - val supportsLogin: Boolean = false, + val loginSupported: Boolean = false, + val loginRequired: Boolean = false, val useSignatureTimestamp: Boolean = false, // val origin: String? = null, // val referer: String? = null, @@ -47,7 +48,7 @@ data class YouTubeClient( clientVersion = "1.20241127.01.00", clientId = "67", userAgent = USER_AGENT_WEB, - supportsLogin = true, + loginSupported = true, useSignatureTimestamp = true, ) @@ -56,7 +57,8 @@ data class YouTubeClient( clientVersion = "1.20241203.01.00", clientId = "62", userAgent = USER_AGENT_WEB, - supportsLogin = true, + loginSupported = true, + loginRequired = true, useSignatureTimestamp = true, ) @@ -65,7 +67,7 @@ data class YouTubeClient( clientVersion = "2.0", clientId = "85", userAgent = "Mozilla/5.0 (PlayStation; PlayStation 4/12.00) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Safari/605.1.15", - supportsLogin = true, + loginSupported = true, useSignatureTimestamp = true, ) From 6b96b6d635d36dc1f0e3ceb899523601d08979f0 Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Tue, 7 Jan 2025 02:36:12 +0000 Subject: [PATCH 13/29] TVHTML5 requires login --- .../main/java/com/zionhuang/innertube/models/YouTubeClient.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt index ded69cb89..818d2c83b 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt @@ -68,6 +68,7 @@ data class YouTubeClient( clientId = "85", userAgent = "Mozilla/5.0 (PlayStation; PlayStation 4/12.00) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Safari/605.1.15", loginSupported = true, + loginRequired = true, useSignatureTimestamp = true, ) From 32398960ae1dff0a1ed33e83301e853e99173a33 Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Wed, 8 Jan 2025 23:18:28 +0000 Subject: [PATCH 14/29] innertube: PlayerResponse: clean up imports --- .../com/zionhuang/innertube/models/response/PlayerResponse.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt index f30b375e6..de91cef0d 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt @@ -2,11 +2,7 @@ package com.zionhuang.innertube.models.response import com.zionhuang.innertube.models.ResponseContext import com.zionhuang.innertube.models.Thumbnails -import io.ktor.http.URLBuilder -import io.ktor.http.parseQueryString import kotlinx.serialization.Serializable -import org.schabi.newpipe.extractor.exceptions.ParsingException -import org.schabi.newpipe.extractor.services.youtube.YoutubeJavaScriptPlayerManager /** * PlayerResponse with [com.zionhuang.innertube.models.YouTubeClient.WEB_REMIX] client From 01f4df116226eb01d0d69c68139687673a14851c Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Thu, 9 Jan 2025 03:13:57 +0000 Subject: [PATCH 15/29] move NewPipe init to `NewPipeUtils` --- innertube/src/main/java/com/zionhuang/innertube/NewPipe.kt | 7 ++++++- innertube/src/main/java/com/zionhuang/innertube/YouTube.kt | 5 ----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/innertube/src/main/java/com/zionhuang/innertube/NewPipe.kt b/innertube/src/main/java/com/zionhuang/innertube/NewPipe.kt index 9d5e762a0..0162f77e1 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/NewPipe.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/NewPipe.kt @@ -6,6 +6,7 @@ import io.ktor.http.URLBuilder import io.ktor.http.parseQueryString import okhttp3.OkHttpClient import okhttp3.RequestBody.Companion.toRequestBody +import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.downloader.Downloader import org.schabi.newpipe.extractor.downloader.Request import org.schabi.newpipe.extractor.downloader.Response @@ -14,7 +15,7 @@ import org.schabi.newpipe.extractor.exceptions.ReCaptchaException import org.schabi.newpipe.extractor.services.youtube.YoutubeJavaScriptPlayerManager import java.io.IOException -object NewPipeDownloaderImpl : Downloader() { +private object NewPipeDownloaderImpl : Downloader() { private val client = OkHttpClient.Builder().build() @@ -59,6 +60,10 @@ object NewPipeDownloaderImpl : Downloader() { object NewPipeUtils { + init { + NewPipe.init(NewPipeDownloaderImpl) + } + fun getSignatureTimestamp(videoId: String): Result = runCatching { YoutubeJavaScriptPlayerManager.getSignatureTimestamp(videoId) } diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt index 7240df7b8..370e7b268 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -51,7 +51,6 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive -import org.schabi.newpipe.extractor.NewPipe import java.net.Proxy /** @@ -61,10 +60,6 @@ import java.net.Proxy object YouTube { private val innerTube = InnerTube() - init { - NewPipe.init(NewPipeDownloaderImpl) - } - var locale: YouTubeLocale get() = innerTube.locale set(value) { From 18c7caa965a2fc7338f5eac6eccdc6a793376582 Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Thu, 9 Jan 2025 03:30:30 +0000 Subject: [PATCH 16/29] add proxy support to `NewPipeDownloaderImpl` & `YTPlayerUtils` --- .../main/java/com/zionhuang/music/utils/YTPlayerUtils.kt | 4 +++- .../src/main/java/com/zionhuang/innertube/NewPipe.kt | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt b/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt index b0d8a7c31..8186c9ddc 100644 --- a/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt +++ b/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt @@ -15,7 +15,9 @@ import okhttp3.OkHttpClient object YTPlayerUtils { - private val httpClient = OkHttpClient.Builder().build() + private val httpClient = OkHttpClient.Builder() + .proxy(YouTube.proxy) + .build() /** * The main client is used for metadata and initial streams. diff --git a/innertube/src/main/java/com/zionhuang/innertube/NewPipe.kt b/innertube/src/main/java/com/zionhuang/innertube/NewPipe.kt index 0162f77e1..c93bfa4b5 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/NewPipe.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/NewPipe.kt @@ -14,10 +14,13 @@ 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 -private object NewPipeDownloaderImpl : Downloader() { +private class NewPipeDownloaderImpl(proxy: Proxy?) : Downloader() { - private val client = OkHttpClient.Builder().build() + private val client = OkHttpClient.Builder() + .proxy(proxy) + .build() @Throws(IOException::class, ReCaptchaException::class) override fun execute(request: Request): Response { @@ -61,7 +64,7 @@ private object NewPipeDownloaderImpl : Downloader() { object NewPipeUtils { init { - NewPipe.init(NewPipeDownloaderImpl) + NewPipe.init(NewPipeDownloaderImpl(YouTube.proxy)) } fun getSignatureTimestamp(videoId: String): Result = runCatching { From 482168f30b59bfc0b90c524fe0943abdbb5bc101 Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Sat, 11 Jan 2025 03:01:31 +0000 Subject: [PATCH 17/29] gradle: add comment for how to use local NewPipeExtractor --- settings.gradle.kts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/settings.gradle.kts b/settings.gradle.kts index 06a0c2243..86e8825b3 100755 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,3 +17,20 @@ include(":kugou") include(":lrclib") include(":material-color-utilities") include(":kizzy") + +// Use a local copy of NewPipe Extractor by uncommenting the lines below. +// We assume, that InnerTune and NewPipe Extractor have the same parent directory. +// If this is not the case, please change the path in includeBuild(). +// +// For this to work you also need to change the implementation in innertube/build.gradle.kts +// to one which does not specify a version. +// From: +// implementation(libs.newpipe.extractor) +// To: +// implementation("com.github.teamnewpipe:NewPipeExtractor") + +//includeBuild("../NewPipeExtractor") { +// dependencySubstitution { +// substitute(module("com.github.teamnewpipe:NewPipeExtractor")).using(project(":extractor")) +// } +//} From c579fb65a1627eb2144d528759725568eda2b3b9 Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Sat, 11 Jan 2025 03:57:35 +0000 Subject: [PATCH 18/29] MusicService: pass full `PlaybackData` to `recoverSong` --- .../main/java/com/zionhuang/music/playback/MusicService.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt index f78def810..a1f673823 100644 --- a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt +++ b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt @@ -399,14 +399,14 @@ class MusicService : MediaLibraryService(), ) } - private suspend fun recoverSong(mediaId: String, videoDetails: PlayerResponse.VideoDetails? = null) { + private suspend fun recoverSong(mediaId: String, playbackData: YTPlayerUtils.PlaybackData? = null) { val song = database.song(mediaId).first() val mediaMetadata = withContext(Dispatchers.Main) { player.findNextMediaItemById(mediaId)?.metadata } ?: return val duration = song?.song?.duration?.takeIf { it != -1 } ?: mediaMetadata.duration.takeIf { it != -1 } - ?: (videoDetails ?: YTPlayerUtils.playerResponseForMetadata(mediaId).getOrNull()?.videoDetails)?.lengthSeconds?.toInt() + ?: (playbackData?.videoDetails ?: YTPlayerUtils.playerResponseForMetadata(mediaId).getOrNull()?.videoDetails)?.lengthSeconds?.toInt() ?: -1 database.query { if (song == null) insert(mediaMetadata.copy(duration = duration)) @@ -677,7 +677,7 @@ class MusicService : MediaLibraryService(), ) ) } - scope.launch(Dispatchers.IO) { recoverSong(mediaId, playbackData.videoDetails) } + scope.launch(Dispatchers.IO) { recoverSong(mediaId, playbackData) } val streamUrl = playbackData.streamUrl From 930cc4c8faeec0a84a88f677c8475c2fe0c03685 Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Sat, 11 Jan 2025 04:46:03 +0000 Subject: [PATCH 19/29] fixed `songUrlCache` returning expired urls --- .../main/java/com/zionhuang/music/playback/DownloadUtil.kt | 4 ++-- .../main/java/com/zionhuang/music/playback/MusicService.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt b/app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt index 30b7282a8..7c6bdd947 100644 --- a/app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt +++ b/app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt @@ -63,7 +63,7 @@ class DownloadUtil @Inject constructor( return@Factory dataSpec } - songUrlCache[mediaId]?.takeIf { it.second < System.currentTimeMillis() }?.let { + songUrlCache[mediaId]?.takeIf { it.second > System.currentTimeMillis() }?.let { return@Factory dataSpec.withUri(it.first.toUri()) } @@ -98,7 +98,7 @@ class DownloadUtil @Inject constructor( "${it}&range=0-${format.contentLength ?: 10000000}" } - songUrlCache[mediaId] = streamUrl to playbackData.streamExpiresInSeconds * 1000L + songUrlCache[mediaId] = streamUrl to System.currentTimeMillis() + (playbackData.streamExpiresInSeconds * 1000L) dataSpec.withUri(streamUrl.toUri()) } val downloadNotificationHelper = DownloadNotificationHelper(context, ExoDownloadService.CHANNEL_ID) diff --git a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt index a1f673823..fe80e62a6 100644 --- a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt +++ b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt @@ -631,7 +631,7 @@ class MusicService : MediaLibraryService(), return@Factory dataSpec } - songUrlCache[mediaId]?.takeIf { it.second < System.currentTimeMillis() }?.let { + songUrlCache[mediaId]?.takeIf { it.second > System.currentTimeMillis() }?.let { scope.launch(Dispatchers.IO) { recoverSong(mediaId) } return@Factory dataSpec.withUri(it.first.toUri()) } @@ -681,7 +681,7 @@ class MusicService : MediaLibraryService(), val streamUrl = playbackData.streamUrl - songUrlCache[mediaId] = streamUrl to playbackData.streamExpiresInSeconds * 1000L + songUrlCache[mediaId] = streamUrl to System.currentTimeMillis() + (playbackData.streamExpiresInSeconds * 1000L) dataSpec.withUri(streamUrl.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) } } From a4a597efe890a5dc3d4ddf46c0e41549e3f764a4 Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Fri, 17 Jan 2025 04:43:21 +0000 Subject: [PATCH 20/29] rename TVHTML5 client to its full name --- innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt | 2 +- .../main/java/com/zionhuang/innertube/models/YouTubeClient.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt index e4b65beb2..c1e576640 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt @@ -128,7 +128,7 @@ class InnerTube { setBody( PlayerBody( context = client.toContext(locale, visitorData).let { - if (client == YouTubeClient.TVHTML5) { + if (client == YouTubeClient.TVHTML5_SIMPLY_EMBEDDED_PLAYER) { it.copy( thirdParty = Context.ThirdParty( embedUrl = "https://www.youtube.com/watch?v=${videoId}" diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt index 818d2c83b..3a18bed31 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt @@ -62,7 +62,7 @@ data class YouTubeClient( useSignatureTimestamp = true, ) - val TVHTML5 = YouTubeClient( + val TVHTML5_SIMPLY_EMBEDDED_PLAYER = YouTubeClient( clientName = "TVHTML5_SIMPLY_EMBEDDED_PLAYER", clientVersion = "2.0", clientId = "85", From 166ebdbb358562db8516b7c69cb472d49ab5b1af Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Fri, 17 Jan 2025 04:48:10 +0000 Subject: [PATCH 21/29] update `STREAM_FALLBACK_CLIENTS` --- .../main/java/com/zionhuang/music/utils/YTPlayerUtils.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt b/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt index 8186c9ddc..d7704a7c4 100644 --- a/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt +++ b/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt @@ -6,7 +6,7 @@ import com.zionhuang.innertube.NewPipeUtils import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.YouTubeClient import com.zionhuang.innertube.models.YouTubeClient.Companion.IOS -import com.zionhuang.innertube.models.YouTubeClient.Companion.WEB_CREATOR +import com.zionhuang.innertube.models.YouTubeClient.Companion.TVHTML5_SIMPLY_EMBEDDED_PLAYER import com.zionhuang.innertube.models.YouTubeClient.Companion.WEB_REMIX import com.zionhuang.innertube.models.response.PlayerResponse import com.zionhuang.music.constants.AudioQuality @@ -33,7 +33,10 @@ object YTPlayerUtils { /** * Clients used for fallback streams in case the streams of the main client do not work. */ - private val STREAM_FALLBACK_CLIENTS: Array = arrayOf(WEB_CREATOR, IOS) + private val STREAM_FALLBACK_CLIENTS: Array = arrayOf( + TVHTML5_SIMPLY_EMBEDDED_PLAYER, + IOS, + ) data class PlaybackData( val audioConfig: PlayerResponse.PlayerConfig.AudioConfig?, From 89fc1a0917292bdb88f814d0259dc495a48d5159 Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Fri, 17 Jan 2025 08:43:18 +0000 Subject: [PATCH 22/29] YouTubeClient: add `isEmbedded` for clients --- innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt | 2 +- .../main/java/com/zionhuang/innertube/models/YouTubeClient.kt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt index c1e576640..cb11fbfed 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt @@ -128,7 +128,7 @@ class InnerTube { setBody( PlayerBody( context = client.toContext(locale, visitorData).let { - if (client == YouTubeClient.TVHTML5_SIMPLY_EMBEDDED_PLAYER) { + if (client.isEmbedded) { it.copy( thirdParty = Context.ThirdParty( embedUrl = "https://www.youtube.com/watch?v=${videoId}" diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt index 3a18bed31..0af48ee34 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt @@ -12,6 +12,7 @@ data class YouTubeClient( val loginSupported: Boolean = false, val loginRequired: Boolean = false, val useSignatureTimestamp: Boolean = false, + val isEmbedded: Boolean = false, // val origin: String? = null, // val referer: String? = null, ) { @@ -70,6 +71,7 @@ data class YouTubeClient( loginSupported = true, loginRequired = true, useSignatureTimestamp = true, + isEmbedded = true, ) val IOS = YouTubeClient( From 21cb4d212ab66f4df4dc2c6869fd3f08f61dd932 Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Fri, 17 Jan 2025 09:10:18 +0000 Subject: [PATCH 23/29] innertube: add comment explaining `X-YouTube-Client-Name` header --- innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt index cb11fbfed..31ba55a6a 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt @@ -82,7 +82,7 @@ class InnerTube { contentType(ContentType.Application.Json) headers { append("X-Goog-Api-Format-Version", "1") - append("X-YouTube-Client-Name", client.clientId) + 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.ORIGIN_YOUTUBE_MUSIC) append("Referer", YouTubeClient.REFERER_YOUTUBE_MUSIC) From 3a162ca6b74d85b5c83cc95e655393bd9dee9e4d Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Fri, 17 Jan 2025 09:19:11 +0000 Subject: [PATCH 24/29] innertube: remove unused `PipedResponse` model --- .../innertube/models/response/PipedResponse.kt | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/response/PipedResponse.kt diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/response/PipedResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/PipedResponse.kt deleted file mode 100644 index 8170fd707..000000000 --- a/innertube/src/main/java/com/zionhuang/innertube/models/response/PipedResponse.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.zionhuang.innertube.models.response - -import kotlinx.serialization.Serializable - -@Serializable -data class PipedResponse( - val audioStreams: List, -) { - @Serializable - data class AudioStream( - val itag: Int, - val url: String, - val bitrate: Int, - ) -} From dac607a4298e7df296b32fded20fd68b12aca05a Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Fri, 24 Jan 2025 09:09:39 +0000 Subject: [PATCH 25/29] innertube: update IOS client --- .../java/com/zionhuang/innertube/models/YouTubeClient.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt index 0af48ee34..95bd33d49 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt @@ -76,10 +76,10 @@ data class YouTubeClient( val IOS = YouTubeClient( clientName = "IOS", - clientVersion = "19.45.4", + clientVersion = "20.03.02", clientId = "5", - userAgent = "com.google.ios.youtube/19.45.4 (iPhone16,2; U; CPU iOS 18_1_0 like Mac OS X;)", - osVersion = "18.1.0.22B83", + userAgent = "com.google.ios.youtube/20.03.02 (iPhone16,2; U; CPU iOS 18_2_1 like Mac OS X;)", + osVersion = "18.2.1.22C161", ) } } From b80ed63fdc91bb425d1b263118821e58f7c8e620 Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Wed, 5 Feb 2025 15:00:58 +0000 Subject: [PATCH 26/29] gradle: update to release version of NewPipeExtractor --- gradle/libs.versions.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 02dff1ebe..38b088039 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -89,9 +89,7 @@ firebase-perf-plugin = { module = "com.google.firebase:perf-plugin", version = " mlkit-language-id = { group = "com.google.mlkit", name = "language-id", version = "17.0.6" } mlkit-translate = { group = "com.google.mlkit", name = "translate", version = "17.0.3" } -#newpipe-extractor = { group = "com.github.TeamNewPipe", name = "NewPipeExtractor", version = "v0.24.3" } -# Use fork of NewPipeExtractor until https://github.com/TeamNewPipe/NewPipeExtractor/pull/1253 is merged -newpipe-extractor = { group = "com.github.gechoto", name = "NewPipeExtractor", version = "00cf7dad9e49933c7faaa382c9c7b1e7afb28a7d" } +newpipe-extractor = { group = "com.github.TeamNewPipe", name = "NewPipeExtractor", version = "v0.24.5" } [plugins] kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } From bbb437ac00ae0cd7d937ab76eb9e5550ad23781e Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Wed, 5 Feb 2025 15:14:54 +0000 Subject: [PATCH 27/29] YouTubeClient: update `WEB` & `WEB_REMIX` --- .../main/java/com/zionhuang/innertube/models/YouTubeClient.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt index 95bd33d49..8ef170838 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt @@ -39,14 +39,14 @@ data class YouTubeClient( val WEB = YouTubeClient( clientName = "WEB", - clientVersion = "2.20241126.01.00", + clientVersion = "2.20250122.04.00", clientId = "1", userAgent = USER_AGENT_WEB, ) val WEB_REMIX = YouTubeClient( clientName = "WEB_REMIX", - clientVersion = "1.20241127.01.00", + clientVersion = "1.20250122.01.00", clientId = "67", userAgent = USER_AGENT_WEB, loginSupported = true, From 5b5945148abf5f5291b6704fa2799af2d3d3a91c Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Thu, 6 Feb 2025 17:24:06 +0000 Subject: [PATCH 28/29] innertube: add `request` & `user` fields to client context --- .../java/com/zionhuang/innertube/models/Context.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Context.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Context.kt index db433f9fb..713984053 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/Context.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Context.kt @@ -6,6 +6,8 @@ import kotlinx.serialization.Serializable data class Context( val client: Client, val thirdParty: ThirdParty? = null, + private val request: Request = Request(), + private val user: User = User(), ) { @Serializable data class Client( @@ -21,4 +23,15 @@ data class Context( data class ThirdParty( val embedUrl: String, ) + + @Serializable + data class Request( + val internalExperimentFlags: Array = emptyArray(), + val useSsl: Boolean = true, + ) + + @Serializable + data class User( + val lockedSafetyMode: Boolean = false, + ) } From d0655c7034a45f46e98dbd42b8f663ce66773947 Mon Sep 17 00:00:00 2001 From: gechoto <124326167+gechoto@users.noreply.github.com> Date: Sun, 9 Feb 2025 01:02:31 +0000 Subject: [PATCH 29/29] innertube: always deobfuscate throttling parameter of streaming urls --- .../java/com/zionhuang/innertube/NewPipe.kt | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/innertube/src/main/java/com/zionhuang/innertube/NewPipe.kt b/innertube/src/main/java/com/zionhuang/innertube/NewPipe.kt index c93bfa4b5..23f8c7bc9 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/NewPipe.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/NewPipe.kt @@ -71,19 +71,28 @@ object NewPipeUtils { YoutubeJavaScriptPlayerManager.getSignatureTimestamp(videoId) } - fun getStreamUrl(format: PlayerResponse.StreamingData.Format, videoId: String) = runCatching { - format.url?.let { - return@runCatching it + fun getStreamUrl(format: PlayerResponse.StreamingData.Format, videoId: String): Result = + runCatching { + val url = format.url ?: format.signatureCipher?.let { signatureCipher -> + val params = parseQueryString(signatureCipher) + val obfuscatedSignature = params["s"] + ?: throw ParsingException("Could not parse cipher signature") + val signatureParam = params["sp"] + ?: throw ParsingException("Could not parse cipher signature parameter") + val url = params["url"]?.let { URLBuilder(it) } + ?: throw ParsingException("Could not parse cipher url") + url.parameters[signatureParam] = + YoutubeJavaScriptPlayerManager.deobfuscateSignature( + videoId, + obfuscatedSignature + ) + url.toString() + } ?: throw ParsingException("Could not find format url") + + return@runCatching YoutubeJavaScriptPlayerManager.getUrlWithThrottlingParameterDeobfuscated( + videoId, + url + ) } - 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) - return@runCatching YoutubeJavaScriptPlayerManager.getUrlWithThrottlingParameterDeobfuscated(videoId, url.toString()) - } - throw ParsingException("Could not find format url") - } } \ No newline at end of file