mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
@@ -9,6 +9,8 @@ import com.subtracks.data.net.NetworkMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.source.MusicSource
|
||||
import com.subtracks.data.source.declaredStreamLength
|
||||
import com.subtracks.data.source.streamLengthSuffix
|
||||
import com.subtracks.data.source.subsonic.SubsonicClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -82,7 +84,14 @@ class SourceRepository(
|
||||
return CoverArtRef(url = url, cacheKey = "${source.id}:$coverArt:$thumbnail")
|
||||
}
|
||||
|
||||
fun streamUri(songId: String): String? = active?.streamUri(songId)?.toString()
|
||||
fun streamUri(
|
||||
songId: String,
|
||||
durationMs: Long? = null,
|
||||
): String? {
|
||||
val uri = active?.streamUri(songId)?.toString() ?: return null
|
||||
val length = declaredStreamLength(durationMs, quality.value) ?: return uri
|
||||
return uri + streamLengthSuffix(length)
|
||||
}
|
||||
|
||||
suspend fun addSource(
|
||||
name: String,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.subtracks.data.source
|
||||
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
|
||||
internal const val STREAM_LENGTH_FRAGMENT = "streamLength"
|
||||
|
||||
private const val MIN_TRANSCODE_BPS = 64_000L
|
||||
private const val UNCAPPED_BPS = 2_000_000L
|
||||
private const val OVERHEAD_BYTES = 256L * 1024L
|
||||
|
||||
fun declaredStreamLength(
|
||||
durationMs: Long?,
|
||||
quality: StreamQuality,
|
||||
): Long? {
|
||||
if (durationMs == null || durationMs <= 0) return null
|
||||
if (quality.maxBitrate <= 0 && quality.format.isNullOrEmpty()) return null
|
||||
val bps = if (quality.maxBitrate > 0) maxOf(quality.maxBitrate * 1000L, MIN_TRANSCODE_BPS) else UNCAPPED_BPS
|
||||
return durationMs / 1000 * bps / 8 * 5 / 4 + OVERHEAD_BYTES
|
||||
}
|
||||
|
||||
internal fun streamLengthSuffix(length: Long): String = "#$STREAM_LENGTH_FRAGMENT=$length"
|
||||
|
||||
internal fun declaredLengthFromFragment(fragment: String?): Long? {
|
||||
val marker = "$STREAM_LENGTH_FRAGMENT="
|
||||
if (fragment == null || !fragment.startsWith(marker)) return null
|
||||
return fragment.substring(marker.length).toLongOrNull()
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.TransferListener
|
||||
import com.subtracks.data.source.declaredLengthFromFragment
|
||||
|
||||
@UnstableApi
|
||||
internal class KnownLengthDataSource(
|
||||
private val delegate: DataSource,
|
||||
) : DataSource {
|
||||
override fun open(dataSpec: DataSpec): Long {
|
||||
val length = delegate.open(dataSpec)
|
||||
if (length != C.LENGTH_UNSET.toLong()) return length
|
||||
val declared = declaredLengthFromFragment(dataSpec.uri.fragment) ?: return length
|
||||
return (declared - dataSpec.position).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
override fun read(
|
||||
buffer: ByteArray,
|
||||
offset: Int,
|
||||
length: Int,
|
||||
): Int = delegate.read(buffer, offset, length)
|
||||
|
||||
override fun getUri(): Uri? = delegate.uri
|
||||
|
||||
override fun getResponseHeaders(): Map<String, List<String>> = delegate.responseHeaders
|
||||
|
||||
override fun close() = delegate.close()
|
||||
|
||||
override fun addTransferListener(transferListener: TransferListener) = delegate.addTransferListener(transferListener)
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
internal class KnownLengthDataSourceFactory(
|
||||
private val upstream: DataSource.Factory,
|
||||
) : DataSource.Factory {
|
||||
override fun createDataSource(): DataSource = KnownLengthDataSource(upstream.createDataSource())
|
||||
}
|
||||
@@ -133,6 +133,7 @@ class Media3PlayerHandle(
|
||||
.setArtist(item.artist)
|
||||
.setAlbumTitle(item.album)
|
||||
.setArtworkUri(item.coverArtId?.takeIf { it.isNotEmpty() }?.let(CoverArtArtwork::uri))
|
||||
.setDurationMs(item.durationMs)
|
||||
.setIsBrowsable(false)
|
||||
.setIsPlayable(true)
|
||||
.setExtras(
|
||||
@@ -144,7 +145,7 @@ class Media3PlayerHandle(
|
||||
return MediaItem
|
||||
.Builder()
|
||||
.setMediaId(item.id)
|
||||
.setUri(sourceRepository.streamUri(item.id))
|
||||
.setUri(sourceRepository.streamUri(item.id, item.durationMs))
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.ForwardingPlayer
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
@@ -26,7 +28,7 @@ class PlaybackService : MediaSessionService() {
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val player =
|
||||
val exoPlayer =
|
||||
ExoPlayer
|
||||
.Builder(this)
|
||||
.setAudioAttributes(
|
||||
@@ -38,8 +40,11 @@ class PlaybackService : MediaSessionService() {
|
||||
true,
|
||||
).setHandleAudioBecomingNoisy(true)
|
||||
.setMediaSourceFactory(
|
||||
DefaultMediaSourceFactory(this).setDataSourceFactory(OkHttpDataSource.Factory(streamingClient())),
|
||||
DefaultMediaSourceFactory(this).setDataSourceFactory(
|
||||
KnownLengthDataSourceFactory(OkHttpDataSource.Factory(streamingClient())),
|
||||
),
|
||||
).build()
|
||||
val player = MetadataDurationPlayer(exoPlayer)
|
||||
val loader =
|
||||
CoverArtBitmapLoader(
|
||||
applicationContext,
|
||||
@@ -98,3 +103,10 @@ class PlaybackService : MediaSessionService() {
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private class MetadataDurationPlayer(
|
||||
player: Player,
|
||||
) : ForwardingPlayer(player) {
|
||||
override fun getDuration(): Long = currentMediaItem?.mediaMetadata?.durationMs?.takeIf { it > 0 } ?: super.getDuration()
|
||||
}
|
||||
|
||||
@@ -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"))))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user