From 6ce71c599e7df4f2436b898ea514b73c0e8b4834 Mon Sep 17 00:00:00 2001 From: Javi <45560967+javdc@users.noreply.github.com> Date: Sun, 22 Dec 2024 20:04:35 +0100 Subject: [PATCH] Add NewPipeExtractor for stream extraction --- app/proguard-rules.pro | 9 ++- gradle/libs.versions.toml | 2 + innertube/build.gradle.kts | 1 + .../innertube/NewPipeDownloaderImpl.kt | 64 +++++++++++++++++++ .../java/com/zionhuang/innertube/YouTube.kt | 18 ++++++ .../com/zionhuang/innertube/utils/Utils.kt | 55 ++++++++++++++++ 6 files changed, 148 insertions(+), 1 deletion(-) 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/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/NewPipeDownloaderImpl.kt b/innertube/src/main/java/com/zionhuang/innertube/NewPipeDownloaderImpl.kt new file mode 100644 index 000000000..1ddf70ee6 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/NewPipeDownloaderImpl.kt @@ -0,0 +1,64 @@ +package com.zionhuang.innertube + +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() { + + /** + * Should be the latest Firefox ESR version. + */ + private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0" + + 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", USER_AGENT) + + YouTube.cookie?.let { + requestBuilder.addHeader("Cookie", it) + } + + headers.forEach { (headerName, headerValueList) -> + if (headerName == "Cookie" && YouTube.cookie != null) { + // Add YouTube login cookie from InnerTune to the other cookies from NewPipeExtractor + requestBuilder.header(headerName, (headerValueList + YouTube.cookie).joinToString("; ")) + } else 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..49bf81c7d 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -48,12 +48,17 @@ import com.zionhuang.innertube.pages.SearchResult import com.zionhuang.innertube.pages.SearchSuggestionPage import com.zionhuang.innertube.pages.SearchSummary import com.zionhuang.innertube.pages.SearchSummaryPage +import com.zionhuang.innertube.utils.mapToPlayerResponse import io.ktor.client.call.body import io.ktor.client.statement.bodyAsText 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 org.schabi.newpipe.extractor.ServiceList +import org.schabi.newpipe.extractor.localization.Localization +import org.schabi.newpipe.extractor.stream.StreamInfo import java.net.Proxy /** @@ -63,10 +68,15 @@ import java.net.Proxy object YouTube { private val innerTube = InnerTube() + init { + NewPipe.init(NewPipeDownloaderImpl) + } + var locale: YouTubeLocale get() = innerTube.locale set(value) { innerTube.locale = value + NewPipe.setupLocalization(Localization(value.hl, value.gl)) } var visitorData: String get() = innerTube.visitorData @@ -430,6 +440,14 @@ object YouTube { } suspend fun player(videoId: String, playlistId: String? = null): Result = runCatching { + runCatching { + StreamInfo.getInfo(ServiceList.YouTube, "https://www.youtube.com/watch?v=${videoId}") + }.getOrElse { exception -> + exception.printStackTrace() + null + }?.let { + return@runCatching it.mapToPlayerResponse() + } 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() diff --git a/innertube/src/main/java/com/zionhuang/innertube/utils/Utils.kt b/innertube/src/main/java/com/zionhuang/innertube/utils/Utils.kt index 2e727f0e6..21d622100 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/utils/Utils.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/utils/Utils.kt @@ -1,7 +1,12 @@ package com.zionhuang.innertube.utils import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.ResponseContext +import com.zionhuang.innertube.models.Thumbnail +import com.zionhuang.innertube.models.Thumbnails +import com.zionhuang.innertube.models.response.PlayerResponse import com.zionhuang.innertube.pages.PlaylistPage +import org.schabi.newpipe.extractor.stream.StreamInfo import java.security.MessageDigest suspend fun Result.completed() = runCatching { @@ -47,3 +52,53 @@ fun String.parseTime(): Int? { } return null } + +/** + * Maps NewPipeExtractor StreamInfo to InnerTube PlayerResponse. Not a perfect match as there are + * some parameters missing, but this will allow us to play streams correctly. + */ +fun StreamInfo.mapToPlayerResponse() = PlayerResponse( + responseContext = ResponseContext(null, null), + playabilityStatus = PlayerResponse.PlayabilityStatus("OK", null), + playerConfig = null, + streamingData = PlayerResponse.StreamingData( + formats = null, + adaptiveFormats = audioStreams.map { stream -> + PlayerResponse.StreamingData.Format( + itag = stream.itag, + url = stream.content, + mimeType = "${stream.format?.mimeType}; codecs=\"${stream.codec}\"", + bitrate = stream.bitrate, + width = null, + height = null, + contentLength = stream.content.substringAfter("clen=").substringBefore("&").toLongOrNull() ?: 10000000, + quality = stream.quality, + fps = null, + qualityLabel = null, + averageBitrate = stream.averageBitrate, + audioQuality = null, + approxDurationMs = null, + audioSampleRate = null, + audioChannels = null, + loudnessDb = null, + lastModified = null, + ) + }, + expiresInSeconds = 21540 // NewPipeExtractor doesn't give us this data, but it seems to always be this value + ), + videoDetails = PlayerResponse.VideoDetails( + videoId = id, + title = name, + author = uploaderName, + channelId = uploaderUrl.removePrefix("https://www.youtube.com/channel/"), + lengthSeconds = duration.toString(), + musicVideoType = null, + viewCount = viewCount.toString(), + thumbnail = Thumbnails( + thumbnails = thumbnails.map { + Thumbnail(it.url, it.width, it.height) + } + ) + ) +) +