diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt index 0b1ec74d..7052d186 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt @@ -103,6 +103,7 @@ class PlaybackService : MediaSessionService() { .setMediaSourceFactory( DefaultMediaSourceFactory(this) .setEnableClippingInMediaPeriod(true) + .setLoadErrorHandlingPolicy(RetryOutOfRangePolicy()) .setDataSourceFactory(mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()))), ).build() exoPlayer.addListener( diff --git a/app/src/main/kotlin/com/subtracks/playback/RetryOutOfRangePolicy.kt b/app/src/main/kotlin/com/subtracks/playback/RetryOutOfRangePolicy.kt new file mode 100644 index 00000000..60d100ad --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/playback/RetryOutOfRangePolicy.kt @@ -0,0 +1,38 @@ +package com.subtracks.playback + +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSourceException +import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy + +/** + * A transcoded stream is chunked with no length, so the app declares an upper-bound length, and on + * the first request the server is still producing the stream: a seek near that bound lands past the + * data and comes back as POSITION_OUT_OF_RANGE (Navidrome answers 416 until it has cached the + * transcode). That first request is what warms the cache, so one retry lands on a response with a + * real Content-Length and the stream plays and seeks properly. + */ +@UnstableApi +internal class RetryOutOfRangePolicy( + private val delayMs: Long = RETRY_DELAY_MS, +) : DefaultLoadErrorHandlingPolicy() { + override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long = + when { + loadErrorInfo.exception.isOutOfRange() -> { + if (loadErrorInfo.errorCount <= MAX_RETRIES) delayMs else C.TIME_UNSET + } + + else -> { + super.getRetryDelayMsFor(loadErrorInfo) + } + } + + private fun Throwable.isOutOfRange(): Boolean = + generateSequence(this as Throwable?) { it.cause }.any { + it is DataSourceException && it.reason == DataSourceException.POSITION_OUT_OF_RANGE + } +} + +private const val MAX_RETRIES = 1 +private const val RETRY_DELAY_MS = 1_000L diff --git a/app/src/test/kotlin/com/subtracks/playback/RetryOutOfRangePolicyTest.kt b/app/src/test/kotlin/com/subtracks/playback/RetryOutOfRangePolicyTest.kt new file mode 100644 index 00000000..0ecc812c --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/playback/RetryOutOfRangePolicyTest.kt @@ -0,0 +1,57 @@ +package com.subtracks.playback + +import android.net.Uri +import androidx.media3.common.C +import androidx.media3.datasource.DataSourceException +import androidx.media3.datasource.DataSpec +import androidx.media3.exoplayer.source.LoadEventInfo +import androidx.media3.exoplayer.source.MediaLoadData +import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class RetryOutOfRangePolicyTest { + private val policy = RetryOutOfRangePolicy(delayMs = 123L) + + @Test + fun anOutOfRangeReadIsRetriedOnce() { + assertEquals(123L, policy.getRetryDelayMsFor(info(outOfRange(), errorCount = 1))) + } + + @Test + fun anOutOfRangeReadIsNotRetriedForever() { + assertEquals(C.TIME_UNSET.toLong(), policy.getRetryDelayMsFor(info(outOfRange(), errorCount = 2))) + } + + @Test + fun aWrappedOutOfRangeIsStillSeen() { + val wrapped = IOException("load failed", outOfRange()) + + assertEquals(123L, policy.getRetryDelayMsFor(info(wrapped, errorCount = 1))) + } + + @Test + fun otherErrorsGoToTheDefaultPolicy() { + val plain = IOException("network") + val expected = DefaultLoadErrorHandlingPolicy().getRetryDelayMsFor(info(plain, errorCount = 1)) + + assertEquals(expected, policy.getRetryDelayMsFor(info(plain, errorCount = 1))) + } + + private fun outOfRange() = DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE) + + private fun info( + error: IOException, + errorCount: Int, + ) = LoadErrorHandlingPolicy.LoadErrorInfo( + LoadEventInfo(0, DataSpec(Uri.EMPTY), 0), + MediaLoadData(C.DATA_TYPE_MEDIA), + error, + errorCount, + ) +}