keep transcoded streams seekable without cutting them short

Media3 treats a progressive stream as live, and ignores seeks, when neither the HTTP length nor the extractor duration is known. A transcoded response is chunked with no length and ffmpeg writes no MP3 duration header when streaming, so seeks stopped working once estimateContentLength was dropped. The server estimate is not a fix either: it under-counts (23% at mp3/24) and truncates the track.

Transcoded URLs now carry a declared upper-bound length in the fragment (never sent to the server). KnownLengthDataSource hands that length to Media3, keeping the stream seekable while never bounding reads below the real size. MetadataDurationPlayer reports the library duration to the session so the notification and UI show the true length rather than the upper bound.
This commit is contained in:
austinried
2026-10-04 00:30:00 +00:00
parent 525c3fa0d4
commit 22feb2372d
8 changed files with 202 additions and 5 deletions
@@ -0,0 +1,43 @@
package com.subtracks.data.source
import com.subtracks.data.prefs.StreamQuality
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class StreamLengthTest {
@Test
fun directPlayHasNoDeclaredLength() {
assertNull(declaredStreamLength(180_000, StreamQuality()))
}
@Test
fun missingDurationHasNoDeclaredLength() {
assertNull(declaredStreamLength(null, StreamQuality(96, "mp3")))
assertNull(declaredStreamLength(0, StreamQuality(96, "mp3")))
}
@Test
fun transcodedLengthIsAboveTheRequestedBitrate() {
val length = declaredStreamLength(180_000, StreamQuality(96, "mp3"))!!
assertTrue(length > 180 * 96_000 / 8)
}
@Test
fun aTinyBitrateStillClearsTheEncodersFloor() {
val length = declaredStreamLength(180_000, StreamQuality(24, "mp3"))!!
assertTrue(length >= 180 * 32_000 / 8)
}
@Test
fun theLengthSuffixRoundTripsThroughTheFragment() {
val uri = "http://host/rest/stream.view?id=s1" + streamLengthSuffix(1_234_567)
assertEquals(1_234_567L, declaredLengthFromFragment(uri.substringAfter('#')))
assertNull(declaredLengthFromFragment(null))
assertNull(declaredLengthFromFragment("somethingElse=1"))
}
}
@@ -0,0 +1,63 @@
package com.subtracks.playback
import android.net.Uri
import androidx.media3.common.C
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.TransferListener
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.source.streamLengthSuffix
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class KnownLengthDataSourceTest {
private class StubDataSource(
private val length: Long,
) : DataSource {
override fun open(dataSpec: DataSpec): Long = length
override fun read(
buffer: ByteArray,
offset: Int,
length: Int,
): Int = C.RESULT_END_OF_INPUT
override fun getUri(): Uri? = null
override fun close() = Unit
override fun addTransferListener(transferListener: TransferListener) = Unit
}
private fun transcodedUrl(length: Long) = Uri.parse("http://host/stream" + streamLengthSuffix(length))
@Test
fun declaresTheFragmentLengthWhenTheServerDoesNot() {
val dataSource = KnownLengthDataSource(StubDataSource(C.LENGTH_UNSET.toLong()))
assertEquals(1_000_000L, dataSource.open(DataSpec(transcodedUrl(1_000_000))))
}
@Test
fun subtractsThePositionFromTheDeclaredLength() {
val dataSource = KnownLengthDataSource(StubDataSource(C.LENGTH_UNSET.toLong()))
assertEquals(600_000L, dataSource.open(DataSpec(transcodedUrl(1_000_000), 400_000L, C.LENGTH_UNSET.toLong())))
}
@Test
fun keepsTheServerLengthWhenItHasOne() {
val dataSource = KnownLengthDataSource(StubDataSource(500_000))
assertEquals(500_000L, dataSource.open(DataSpec(transcodedUrl(1_000_000))))
}
@Test
fun passesUnknownLengthThroughWithoutAFragment() {
val dataSource = KnownLengthDataSource(StubDataSource(C.LENGTH_UNSET.toLong()))
assertEquals(C.LENGTH_UNSET.toLong(), dataSource.open(DataSpec(Uri.parse("http://host/stream"))))
}
}