Skip to content

Commit

Permalink
Add NewPipeExtractor for stream extraction
Browse files Browse the repository at this point in the history
  • Loading branch information
javdc committed Dec 22, 2024
1 parent bb11093 commit 6ce71c5
Show file tree
Hide file tree
Showing 6 changed files with 148 additions and 1 deletion.
9 changes: 8 additions & 1 deletion app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,11 @@
# Keep Data data classes
-keep class com.my.kizzy.remote.** { <fields>; }
# Keep Gateway data classes
-keep class com.my.kizzy.gateway.entities.** { <fields>; }
-keep class com.my.kizzy.gateway.entities.** { <fields>; }

## 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.**
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions innertube/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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)
}

}
18 changes: 18 additions & 0 deletions innertube/src/main/java/com/zionhuang/innertube/YouTube.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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
Expand Down Expand Up @@ -430,6 +440,14 @@ object YouTube {
}

suspend fun player(videoId: String, playlistId: String? = null): Result<PlayerResponse> = 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<PlayerResponse>()
Expand Down
55 changes: 55 additions & 0 deletions innertube/src/main/java/com/zionhuang/innertube/utils/Utils.kt
Original file line number Diff line number Diff line change
@@ -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<PlaylistPage>.completed() = runCatching {
Expand Down Expand Up @@ -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)
}
)
)
)

0 comments on commit 6ce71c5

Please sign in to comment.