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

Add yt-dlp fallback for stream extraction #1768

Closed
wants to merge 5 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
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -170,4 +170,6 @@ dependencies {
"fullImplementation"(libs.opencc4j)

implementation(libs.timber)

implementation(libs.youtubedl.android)
}
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
</queries>
<application
android:name=".App"
android:extractNativeLibs="true"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
Expand Down
21 changes: 20 additions & 1 deletion app/src/main/java/com/zionhuang/music/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.edit
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import com.yausername.youtubedl_android.YoutubeDLException
import com.zionhuang.innertube.YouTube
import com.zionhuang.innertube.models.YouTubeLocale
import com.zionhuang.kugou.KuGou
Expand All @@ -25,6 +26,7 @@ import com.zionhuang.music.constants.UseLoginForBrowse
import com.zionhuang.music.constants.VisitorDataKey
import com.zionhuang.music.extensions.toEnum
import com.zionhuang.music.extensions.toInetSocketAddress
import com.zionhuang.music.utils.YoutubeDL
import com.zionhuang.music.utils.dataStore
import com.zionhuang.music.utils.get
import com.zionhuang.music.utils.reportException
Expand All @@ -37,6 +39,8 @@ import kotlinx.coroutines.launch
import timber.log.Timber
import java.net.Proxy
import java.util.Locale
import kotlin.concurrent.thread


@HiltAndroidApp
class App : Application(), ImageLoaderFactory {
Expand All @@ -45,6 +49,16 @@ class App : Application(), ImageLoaderFactory {
super.onCreate()
Timber.plant(Timber.DebugTree())

try {
YoutubeDL.init(this)
} catch (e: YoutubeDLException) {
Toast.makeText(this, "failed to initialize youtubedl-android", LENGTH_SHORT).show()
}

thread {
YoutubeDL.update()
}

val locale = Locale.getDefault()
val languageTag = locale.toLanguageTag().replace("-Hant", "") // replace zh-Hant-* to zh-*
YouTube.locale = YouTubeLocale(
Expand Down Expand Up @@ -94,8 +108,13 @@ 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
YoutubeDL.saveCookies(cookie)
}
}
}
Expand Down
15 changes: 9 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 @@ -20,6 +20,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.YoutubeDL
import com.zionhuang.music.utils.enumPreference
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -88,10 +89,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 +106,13 @@ class DownloadUtil @Inject constructor(
)
}

songUrlCache[mediaId] = format.url!! to playerResponse.streamingData!!.expiresInSeconds * 1000L
dataSpec.withUri(format.url!!.toUri())
val streamUrl = (format.url ?: YoutubeDL.getStreamUrl(mediaId, format.itag, YouTube.cookie != null)).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 @@ -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.YoutubeDL
import com.zionhuang.music.utils.dataStore
import com.zionhuang.music.utils.enumPreference
import com.zionhuang.music.utils.get
Expand Down Expand Up @@ -691,8 +692,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.url ?: YoutubeDL.getStreamUrl(mediaId, format.itag, YouTube.cookie != null)

songUrlCache[mediaId] = streamUrl to playerResponse.streamingData!!.expiresInSeconds * 1000L
dataSpec.withUri(streamUrl.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH)
}
}

Expand Down
91 changes: 91 additions & 0 deletions app/src/main/java/com/zionhuang/music/utils/YoutubeDL.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.zionhuang.music.utils

import android.content.Context
import androidx.media3.common.PlaybackException
import com.yausername.youtubedl_android.YoutubeDL
import com.yausername.youtubedl_android.YoutubeDLRequest
import com.zionhuang.innertube.utils.parseCookieString
import com.zionhuang.music.R
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import kotlin.runCatching

class YoutubeDL {
companion object {
private lateinit var appContext: Context
private lateinit var cookiesFile: File

fun init (context: Context) {
this.appContext = context
YoutubeDL.getInstance().init(this.appContext)
this.cookiesFile = File(this.appContext.cacheDir.absolutePath, "cookies.txt")
}

fun update () {
YoutubeDL.getInstance().updateYoutubeDL(this.appContext, updateChannel = YoutubeDL.UpdateChannel._STABLE)
}

fun getStreamUrl(videoId: String, itag: Int, withLogin: Boolean = false): String {
if (withLogin && !cookiesFile.exists()) {
throw IllegalStateException("cookies missing")
}

runCatching {
val request = YoutubeDLRequest("https://music.youtube.com/watch?v=$videoId")

val client: String = if (withLogin) "web_creator" else "ios"
request.addOption("--extractor-args", "youtube:player_client=$client")

if (withLogin) {
request.addOption("--cookies", this.cookiesFile.absolutePath)
}

request.addOption("--skip-download")
request.addOption("--no-warnings")
request.addOption("--format", itag)
request.addOption("--print", "url")

// this options should never be added during normal usage of the app - enable this only while debugging !!!
// request.addOption("--no-check-certificate")

val streamUrl = YoutubeDL.getInstance().execute(request).out
return streamUrl
}.getOrElse { throwable ->
// TODO: handle ytdl errors and display more precise error messages to the user
// when (throwable) {
// // ...
// }
throw PlaybackException(this.appContext.getString(R.string.error_unknown), throwable, PlaybackException.ERROR_CODE_REMOTE_ERROR)
}
}

fun saveCookies(
cookieStr: String?,
) {
if (cookieStr == null) {
this.cookiesFile.delete()
} else {
val domain = ".youtube.com"
val path = "/"
val secure = false // TODO: check if this can be set to true
val expiration = 0

val cookies: Map<String, String> = parseCookieString(cookieStr)

BufferedWriter(FileWriter(this.cookiesFile)).use { writer ->
writer.write("# Netscape HTTP Cookie File\n")
writer.write("# This file is generated by an Android app\n")
writer.write("#\n")
writer.write("# This file is formatted for Netscape's cookie format\n")

val domainFlag = if (domain.startsWith(".")) "TRUE" else "FALSE"
val secureFlag = if (secure) "TRUE" else "FALSE"
for ((key, value) in cookies) {
writer.write("$domain\t$domainFlag\t$path\t$secureFlag\t$expiration\t$key\t$value\n")
}
}
}
}
}
}
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" }

youtubedl-android = { group = "io.github.junkfood02.youtubedl-android", name = "library", version = "0.17.2" }

[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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions innertube/src/main/java/com/zionhuang/innertube/YouTube.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ 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
Expand Down Expand Up @@ -431,8 +430,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_REMIX client first because IOS client does not play age restricted songs
playerResponse = innerTube.player(WEB_REMIX, videoId, playlistId).body<PlayerResponse>()
if (playerResponse.playabilityStatus.status == "OK") {
return@runCatching playerResponse
}
Expand All @@ -441,9 +440,11 @@ object YouTube {
if (playerResponse.playabilityStatus.status == "OK") {
return@runCatching playerResponse
}

// TODO: check if this still works -> update or remove it
val safePlayerResponse = innerTube.player(TVHTML5, videoId, playlistId).body<PlayerResponse>()
if (safePlayerResponse.playabilityStatus.status != "OK") {
return@runCatching playerResponse
return@runCatching safePlayerResponse
}
val audioStreams = innerTube.pipedStreams(videoId).body<PipedResponse>().audioStreams
safePlayerResponse.copy(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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",
)
}
}
Loading