Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change player clients & deobfuscate params with NewPipeExtractor #1774

Closed
wants to merge 18 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/build_pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.**
6 changes: 5 additions & 1 deletion app/src/main/java/com/zionhuang/music/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
14 changes: 8 additions & 6 deletions app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
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)
}
25 changes: 14 additions & 11 deletions innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.*

Expand Down Expand Up @@ -74,32 +75,29 @@ class InnerTube {
}

defaultRequest {
url("https://music.youtube.com/youtubei/v1/")
url(YouTubeClient.API_URL_YOUTUBE_MUSIC)
}
}

private fun HttpRequestBuilder.ytClient(client: YouTubeClient, setLogin: Boolean = false) {
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)
}

Expand Down Expand Up @@ -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
)
)
}
Expand Down Expand Up @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}

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

/**
Expand All @@ -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) {
Expand Down Expand Up @@ -431,8 +436,8 @@ object YouTube {

suspend fun player(videoId: String, playlistId: String? = null): Result<PlayerResponse> = 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<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<PlayerResponse>()
if (playerResponse.playabilityStatus.status == "OK") {
return@runCatching playerResponse
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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",
gechoto marked this conversation as resolved.
Show resolved Hide resolved
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",
)
}
}
Loading
Loading