Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
2a9007f
update clients & deobfuscate stream urls with NewPipeExtractor
gechoto Jan 5, 2025
f75f321
rework player requests & response handling
gechoto Jan 5, 2025
d50c019
also build foss flavor for PRs
gechoto Jan 5, 2025
599622d
YTPlayerUtils: catch & report url validation errors
gechoto Jan 5, 2025
a81c1df
update NewPipeExtractor
gechoto Jan 5, 2025
850f16c
YouTubeTest: use IOS client
gechoto Jan 5, 2025
2f816d0
improved url deobfuscation error handling
gechoto Jan 5, 2025
d1a0c91
reduce duplicate code in YTPlayerUtils and skip url status validation…
gechoto Jan 5, 2025
b8050ae
move NewPipe functions to `NewPipeUtils`
gechoto Jan 6, 2025
9696332
make `signatureTimestamp` optional and report exception if it could n…
gechoto Jan 6, 2025
cc1b8be
update NewPipeExtractor
gechoto Jan 6, 2025
81febb4
skip WEB_CREATOR client for logged out users
gechoto Jan 7, 2025
6b96b6d
TVHTML5 requires login
gechoto Jan 7, 2025
3239896
innertube: PlayerResponse: clean up imports
gechoto Jan 8, 2025
01f4df1
move NewPipe init to `NewPipeUtils`
gechoto Jan 9, 2025
18c7caa
add proxy support to `NewPipeDownloaderImpl` & `YTPlayerUtils`
gechoto Jan 9, 2025
482168f
gradle: add comment for how to use local NewPipeExtractor
gechoto Jan 11, 2025
c579fb6
MusicService: pass full `PlaybackData` to `recoverSong`
gechoto Jan 11, 2025
930cc4c
fixed `songUrlCache` returning expired urls
gechoto Jan 11, 2025
a4a597e
rename TVHTML5 client to its full name
gechoto Jan 17, 2025
166ebdb
update `STREAM_FALLBACK_CLIENTS`
gechoto Jan 17, 2025
89fc1a0
YouTubeClient: add `isEmbedded` for clients
gechoto Jan 17, 2025
21cb4d2
innertube: add comment explaining `X-YouTube-Client-Name` header
gechoto Jan 17, 2025
3a162ca
innertube: remove unused `PipedResponse` model
gechoto Jan 17, 2025
dac607a
innertube: update IOS client
gechoto Jan 24, 2025
b80ed63
gradle: update to release version of NewPipeExtractor
gechoto Feb 5, 2025
bbb437a
YouTubeClient: update `WEB` & `WEB_REMIX`
gechoto Feb 5, 2025
5b59451
innertube: add `request` & `user` fields to client context
gechoto Feb 6, 2025
d0655c7
innertube: always deobfuscate throttling parameter of streaming urls
gechoto Feb 9, 2025
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
46 changes: 18 additions & 28 deletions app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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 {
Expand Down
50 changes: 20 additions & 30 deletions app/src/main/java/com/zionhuang/music/playback/MusicService.kt
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.YTPlayerUtils
import com.zionhuang.music.utils.dataStore
import com.zionhuang.music.utils.enumPreference
import com.zionhuang.music.utils.get
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -630,18 +631,25 @@ 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())
}

// 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)
}
Expand All @@ -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(
Expand All @@ -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)
}
}

Expand Down
Loading