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..17b71aa17 100644 --- a/app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt +++ b/app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt @@ -108,8 +108,10 @@ class DownloadUtil @Inject constructor( ) } - songUrlCache[mediaId] = format.url!! to playerResponse.streamingData!!.expiresInSeconds * 1000L - dataSpec.withUri(format.url!!.toUri()) + val streamUrl = playerResponse.findUrl(format.itag) + + 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..29c55a51e 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 = playerResponse.findUrl(format.itag) + + 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..340ad468a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -89,6 +89,8 @@ 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" } + [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..e3bde481f 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt @@ -88,7 +88,7 @@ class InnerTube { if (client.referer != null) { append("Referer", client.referer) } - if (setLogin) { + if (setLogin && client.supportsLogin) { cookie?.let { cookie -> append("cookie", cookie) if ("SAPISID" !in cookieMap) return@let diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt index 62349e442..1a1b180c8 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -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_REMIX client first because IOS client does not support login + playerResponse = innerTube.player(WEB_REMIX, videoId, playlistId).body() if (playerResponse.playabilityStatus.status == "OK") { return@runCatching playerResponse } @@ -443,7 +448,7 @@ object YouTube { } val safePlayerResponse = innerTube.player(TVHTML5, videoId, playlistId).body() if (safePlayerResponse.playabilityStatus.status != "OK") { - return@runCatching playerResponse + return@runCatching safePlayerResponse } val audioStreams = innerTube.pipedStreams(videoId).body().audioStreams safePlayerResponse.copy( 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..006168dc7 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt @@ -10,6 +10,7 @@ data class YouTubeClient( val userAgent: String, val osVersion: String? = null, val referer: String? = null, + val supportsLogin: Boolean = false, ) { fun toContext(locale: YouTubeLocale, visitorData: String?) = Context( client = Context.Client( @@ -25,15 +26,14 @@ data class YouTubeClient( companion object { private const val REFERER_YOUTUBE_MUSIC = "https://music.youtube.com/" - 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_WEB = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 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 + userAgent = USER_AGENT_ANDROID, ) val ANDROID = YouTubeClient( @@ -47,30 +47,31 @@ data class YouTubeClient( clientName = "WEB", clientVersion = "2.2021111", api_key = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX3", - userAgent = USER_AGENT_WEB + 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", + api_key = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30", // TODO: remove userAgent = USER_AGENT_WEB, - referer = REFERER_YOUTUBE_MUSIC + referer = REFERER_YOUTUBE_MUSIC, + supportsLogin = 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)" + userAgent = "Mozilla/5.0 (PlayStation 4 5.55) AppleWebKit/601.2 (KHTML, like Gecko)", ) 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", + api_key = "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc", // TODO: remove + 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/response/PlayerResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/PlayerResponse.kt index 23d426879..b37c10c71 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,6 +60,7 @@ data class PlayerResponse( val audioChannels: Int?, val loudnessDb: Double?, val lastModified: Long?, + val signatureCipher: String?, ) { val isAudio: Boolean get() = width == null @@ -74,4 +78,22 @@ data class PlayerResponse( val viewCount: String, val thumbnail: Thumbnails, ) + + fun findUrl(itag: Int): String? { + this.streamingData?.adaptiveFormats?.find { it.itag == itag }?.let { format -> + if (format.url != null) { + return format.url + } + if (this.videoDetails?.videoId != null && format.signatureCipher != null) { + val params = parseQueryString(format.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) + val streamUrl = YoutubeJavaScriptPlayerManager.getUrlWithThrottlingParameterDeobfuscated("", url.toString()) + return streamUrl + } + } + return null + } } \ No newline at end of file