mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
retry a read that lands past a cold transcode
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 give the load one retry after a second: the retry sees a real Content-Length and the stream plays and seeks. Further out-of-range errors are given up on rather than looped.
This commit is contained in:
@@ -103,6 +103,7 @@ class PlaybackService : MediaSessionService() {
|
|||||||
.setMediaSourceFactory(
|
.setMediaSourceFactory(
|
||||||
DefaultMediaSourceFactory(this)
|
DefaultMediaSourceFactory(this)
|
||||||
.setEnableClippingInMediaPeriod(true)
|
.setEnableClippingInMediaPeriod(true)
|
||||||
|
.setLoadErrorHandlingPolicy(RetryOutOfRangePolicy())
|
||||||
.setDataSourceFactory(mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()))),
|
.setDataSourceFactory(mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()))),
|
||||||
).build()
|
).build()
|
||||||
exoPlayer.addListener(
|
exoPlayer.addListener(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user