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 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..7c6bdd947 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 @@ -63,35 +63,20 @@ 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()) } 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 - } - }!!.let { - // Specify range to avoid YouTube's throttling - it.copy(url = "${it.url}&range=0-${it.contentLength ?: 10000000}") - } + val format = playbackData.format database.query { upsert( @@ -103,13 +88,18 @@ class DownloadUtil @Inject constructor( bitrate = format.bitrate, sampleRate = format.audioSampleRate, contentLength = format.contentLength!!, - loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb + loudnessDb = playbackData.audioConfig?.loudnessDb ) ) } - songUrlCache[mediaId] = format.url!! to playerResponse.streamingData!!.expiresInSeconds * 1000L - dataSpec.withUri(format.url!!.toUri()) + val streamUrl = playbackData.streamUrl.let { + // Specify range to avoid YouTube's throttling + "${it}&range=0-${format.contentLength ?: 10000000}" + } + + songUrlCache[mediaId] = streamUrl to System.currentTimeMillis() + (playbackData.streamExpiresInSeconds * 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..fe80e62a6 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, 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 } - ?: (playerResponse ?: YouTube.player(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)) @@ -630,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()) } @@ -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,14 +673,16 @@ 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) } + + val streamUrl = playbackData.streamUrl - songUrlCache[mediaId] = format.url!! to playerResponse.streamingData!!.expiresInSeconds * 1000L - dataSpec.withUri(format.url!!.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) + songUrlCache[mediaId] = streamUrl to System.currentTimeMillis() + (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..d7704a7c4 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/utils/YTPlayerUtils.kt @@ -0,0 +1,228 @@ +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 +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 +import com.zionhuang.music.db.entities.FormatEntity +import okhttp3.OkHttpClient + +object YTPlayerUtils { + + private val httpClient = OkHttpClient.Builder() + .proxy(YouTube.proxy) + .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( + TVHTML5_SIMPLY_EMBEDDED_PLAYER, + 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 { + /** + * 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, MAIN_CLIENT, signatureTimestamp).getOrThrow() + + val audioConfig = mainPlayerResponse.playerConfig?.audioConfig + val videoDetails = mainPlayerResponse.videoDetails + + var format: PlayerResponse.StreamingData.Format? = null + var streamUrl: String? = null + var streamExpiresInSeconds: Int? = null + + 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] + 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() + } + + // process current client response + if (streamPlayerResponse?.playabilityStatus?.status == "OK") { + format = + findFormat( + streamPlayerResponse, + playedFormat, + audioQuality, + connectivityManager, + ) ?: continue + streamUrl = findUrlOrNull(format, videoId) ?: 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 + } + } + } + + if (streamPlayerResponse == null) { + throw Exception("Bad stream player response") + } + if (streamPlayerResponse.playabilityStatus.status != "OK") { + throw PlaybackException( + streamPlayerResponse.playabilityStatus.reason, + null, + PlaybackException.ERROR_CODE_REMOTE_ERROR + ) + } + if (streamExpiresInSeconds == null) { + throw Exception("Missing stream expire time") + } + if (format == null) { + throw Exception("Could not find format") + } + if (streamUrl == null) { + throw Exception("Could not find stream url") + } + + 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 + } + } + + /** + * 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() + .head() + .url(url) + val response = httpClient.newCall(requestBuilder.build()).execute() + return response.isSuccessful + } catch (e: Exception) { + reportException(e) + } + 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 + */ + private fun findUrlOrNull( + format: PlayerResponse.StreamingData.Format, + videoId: String + ): String? { + return NewPipeUtils.getStreamUrl(format, videoId) + .onFailure { + reportException(it) + } + .getOrNull() + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 91c965d27..38b088039 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.5" } + [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..31ba55a6a 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt @@ -74,7 +74,7 @@ class InnerTube { } defaultRequest { - url("https://music.youtube.com/youtubei/v1/") + url(YouTubeClient.API_URL_YOUTUBE_MUSIC) } } @@ -82,24 +82,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 /* Not a typo. The Client-Name header does contain the client id. */) 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.loginSupported) { 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) } @@ -125,12 +122,13 @@ class InnerTube { client: YouTubeClient, videoId: String, playlistId: String?, + signatureTimestamp: Int?, ) = httpClient.post("player") { ytClient(client, setLogin = true) setBody( PlayerBody( context = client.toContext(locale, visitorData).let { - if (client == YouTubeClient.TVHTML5) { + if (client.isEmbedded) { it.copy( thirdParty = Context.ThirdParty( embedUrl = "https://www.youtube.com/watch?v=${videoId}" @@ -139,16 +137,17 @@ class InnerTube { } else it }, videoId = videoId, - playlistId = playlistId + playlistId = playlistId, + playbackContext = + if (client.useSignatureTimestamp && signatureTimestamp != null) { + PlayerBody.PlaybackContext(PlayerBody.PlaybackContext.ContentPlaybackContext( + signatureTimestamp + )) + } else null ) ) } - 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, @@ -226,7 +225,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/NewPipe.kt b/innertube/src/main/java/com/zionhuang/innertube/NewPipe.kt new file mode 100644 index 000000000..23f8c7bc9 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/NewPipe.kt @@ -0,0 +1,98 @@ +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.NewPipe +import org.schabi.newpipe.extractor.downloader.Downloader +import org.schabi.newpipe.extractor.downloader.Request +import org.schabi.newpipe.extractor.downloader.Response +import org.schabi.newpipe.extractor.exceptions.ParsingException +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException +import org.schabi.newpipe.extractor.services.youtube.YoutubeJavaScriptPlayerManager +import java.io.IOException +import java.net.Proxy + +private class NewPipeDownloaderImpl(proxy: Proxy?) : Downloader() { + + private val client = OkHttpClient.Builder() + .proxy(proxy) + .build() + + @Throws(IOException::class, ReCaptchaException::class) + override fun execute(request: Request): Response { + val httpMethod = request.httpMethod() + val url = request.url() + val headers = request.headers() + val dataToSend = request.dataToSend() + + val requestBuilder = okhttp3.Request.Builder() + .method(httpMethod, dataToSend?.toRequestBody()) + .url(url) + .addHeader("User-Agent", YouTubeClient.USER_AGENT_WEB) + + headers.forEach { (headerName, headerValueList) -> + if (headerValueList.size > 1) { + requestBuilder.removeHeader(headerName) + headerValueList.forEach { headerValue -> + requestBuilder.addHeader(headerName, headerValue) + } + } else if (headerValueList.size == 1) { + requestBuilder.header(headerName, headerValueList[0]) + } + } + + val response = client.newCall(requestBuilder.build()).execute() + + if (response.code == 429) { + response.close() + + throw ReCaptchaException("reCaptcha Challenge requested", url) + } + + val responseBodyToReturn = response.body?.string() + + val latestUrl = response.request.url.toString() + return Response(response.code, response.message, response.headers.toMultimap(), responseBodyToReturn, latestUrl) + } + +} + +object NewPipeUtils { + + init { + NewPipe.init(NewPipeDownloaderImpl(YouTube.proxy)) + } + + fun getSignatureTimestamp(videoId: String): Result = runCatching { + YoutubeJavaScriptPlayerManager.getSignatureTimestamp(videoId) + } + + 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 + ) + } + +} \ 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..370e7b268 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -12,9 +12,7 @@ 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 import com.zionhuang.innertube.models.YouTubeClient.Companion.WEB import com.zionhuang.innertube.models.YouTubeClient.Companion.WEB_REMIX import com.zionhuang.innertube.models.YouTubeLocale @@ -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 @@ -429,34 +426,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 (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, signatureTimestamp: Int? = null): Result = runCatching { + innerTube.player(client, videoId, playlistId, signatureTimestamp).body() } suspend fun next(endpoint: WatchEndpoint, continuation: String? = null): Result = runCatching { 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, + ) } 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..8ef170838 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,15 @@ 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 loginSupported: Boolean = false, + val loginRequired: Boolean = false, + val useSignatureTimestamp: Boolean = false, + val isEmbedded: Boolean = false, + // val origin: String? = null, + // val referer: String? = null, ) { fun toContext(locale: YouTubeLocale, visitorData: String?) = Context( client = Context.Client( @@ -23,54 +28,58 @@ 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.20250122.04.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.20250122.01.00", + clientId = "67", + userAgent = USER_AGENT_WEB, + loginSupported = 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 + loginSupported = true, + loginRequired = true, + useSignatureTimestamp = true, ) - val TVHTML5 = YouTubeClient( + val TVHTML5_SIMPLY_EMBEDDED_PLAYER = 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", + loginSupported = true, + loginRequired = true, + useSignatureTimestamp = true, + isEmbedded = 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 = "20.03.02", + clientId = "5", + 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", ) } } 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/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, - ) -} 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..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 @@ -5,7 +5,7 @@ import com.zionhuang.innertube.models.Thumbnails import kotlinx.serialization.Serializable /** - * PlayerResponse with [com.zionhuang.innertube.models.YouTubeClient.ANDROID_MUSIC] client + * PlayerResponse with [com.zionhuang.innertube.models.YouTubeClient.WEB_REMIX] client */ @Serializable data class PlayerResponse( @@ -57,6 +57,7 @@ data class PlayerResponse( val audioChannels: Int?, val loudnessDb: Double?, val lastModified: Long?, + val signatureCipher: String?, ) { val isAudio: Boolean get() = width == null 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) 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")) +// } +//}