Skip to content
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
36 changes: 29 additions & 7 deletions app/src/main/kotlin/com/metrolist/music/utils/InnerTubeXPlayer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,18 @@ object InnerTubeXPlayer {
allowBoundedRange = allowBoundedRange,
)
val excludedClients = failedStreamClients(videoId)
val resolvedQuality = audioQuality.toInnerTubeX(connectivityManager)
val stream =
requireNotNull(
bundle().extractor.extract(
videoId = videoId,
hints = hints,
excludedClients = excludedClients,
audioQuality = audioQuality.toInnerTubeX(connectivityManager),
clientPlaybackNonce = generateClientPlaybackNonce(),
),
resolveWithClientFallback(excludedClients) { clients, useLowQuality ->
bundle().extractor.extract(
videoId = videoId,
hints = hints,
excludedClients = clients,
audioQuality = if (useLowQuality) InnerTubeXAudioQuality.LOW else resolvedQuality,
clientPlaybackNonce = generateClientPlaybackNonce(),
)
},
) { "InnerTubeX returned no playable stream" }
check(stream.sabrBootstrap == null) { "SABR is not supported by this playback engine" }
Result.success(stream.toPlaybackData())
Expand Down Expand Up @@ -115,6 +118,25 @@ object InnerTubeXPlayer {
streamClientFailures.clear()
}

internal suspend fun <T : Any> resolveWithClientFallback(
excludedClients: Set<String>,
extract: suspend (Set<String>, Boolean) -> T?,
): T? {
if (excludedClients.isEmpty()) return extract(emptySet(), false)

val alternative =
try {
extract(excludedClients, false)
} catch (error: StreamResolveException) {
if (error.reason != StreamResolveException.Reason.NO_PLAYABLE_STREAM) throw error
null
}
if (alternative != null) return alternative

Timber.tag(TAG).w("Alternate stream clients exhausted; retrying failed client at low quality")
return extract(emptySet(), true)
}

suspend fun refreshAfterStreamRejection(): Boolean = bundle().cipherService.refreshAfterStreamRejection()

internal fun failedStreamClients(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package com.metrolist.music.utils

import com.metrolist.innertubex.extraction.PlayerConfig
import com.metrolist.innertubex.extraction.StreamResolveException
import com.metrolist.innertubex.extraction.YtConfigParser
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test

class InnerTubeXPlayerTest {
Expand All @@ -27,6 +29,86 @@ class InnerTubeXPlayerTest {
assertTrue(InnerTubeXPlayer.failedStreamClients("song", nowMs = 302_000L).isEmpty())
}

@Test
fun `exhausted alternatives retry rejected client at low quality`() =
runBlocking {
val attempts = mutableListOf<Pair<Set<String>, Boolean>>()
val result = InnerTubeXPlayer.resolveWithClientFallback(setOf("WEB_REMIX")) { clients, lowQuality ->
attempts += clients to lowQuality
if (lowQuality) "recovered" else null
}

assertEquals("recovered", result)
assertEquals(
listOf(setOf("WEB_REMIX") to false, emptySet<String>() to true),
attempts,
)
}

@Test
fun `no playable stream exception reopens rejected client`() =
runBlocking {
val attempts = mutableListOf<Pair<Set<String>, Boolean>>()
val result = InnerTubeXPlayer.resolveWithClientFallback(setOf("WEB_REMIX")) { clients, lowQuality ->
attempts += clients to lowQuality
if (!lowQuality) {
throw StreamResolveException(StreamResolveException.Reason.NO_PLAYABLE_STREAM, "no playable stream")
}
"recovered"
}

assertEquals("recovered", result)
assertEquals(
listOf(setOf("WEB_REMIX") to false, emptySet<String>() to true),
attempts,
)
}

@Test
fun `network errors do not reopen rejected clients`() =
runBlocking {
val expected = StreamResolveException(StreamResolveException.Reason.NETWORK, "network unavailable")
var attempts = 0
try {
InnerTubeXPlayer.resolveWithClientFallback(setOf("WEB_REMIX")) { _, _ ->
attempts++
throw expected
}
fail("Expected the network error to propagate")
} catch (actual: StreamResolveException) {
assertTrue(expected === actual)
}
assertEquals(1, attempts)
}

@Test
fun `working alternate client is kept without retrying rejected one`() =
runBlocking {
var attempts = 0
val result = InnerTubeXPlayer.resolveWithClientFallback(setOf("WEB_REMIX")) { clients, lowQuality ->
attempts++
assertEquals(setOf("WEB_REMIX"), clients)
assertEquals(false, lowQuality)
"alternate"
}

assertEquals("alternate", result)
assertEquals(1, attempts)
}

@Test
fun `normal resolution does not downgrade quality or repeat`() =
runBlocking {
var attempts = 0
InnerTubeXPlayer.resolveWithClientFallback(emptySet()) { clients, lowQuality ->
attempts++
assertTrue(clients.isEmpty())
assertEquals(false, lowQuality)
"normal"
}
assertEquals(1, attempts)
}

@Test
fun `failed watch page falls back to anonymous embedded config`() =
runBlocking {
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ junit = "4.13.2"
timber = "5.0.1"
materialKolor = "5.0.1"
kuromojiIpadic = "0.9.0"
innertubex = "v0.11.0"
innertubex = "v0.11.2"
tinypinyin = "2.0.3"
protobuf = "4.36.2"

Expand Down