playback: cache streamed audio to stop redundant refetches

A streamed MP3 with no Xing/Info/VBRI header gets an estimated duration
from Mp3Extractor. At end of stream the extractor rewrites it, which
refreshes the timeline; MediaPeriodQueue.updateQueuedPeriods then sees
the playing period's duration change, drops every period after it and
re-enqueues the next one, so the next track is opened from byte 0 again.
After a seek near the end that repeats a few times.

Route streamed bytes through a bounded SimpleCache (DefaultDataSource ->
CacheDataSource -> KnownLengthDataSource -> OkHttp). A re-opened next
period is served from the bytes the prefetch already committed, so the
redundant network fetches stop without changing playback length or
gapless padding. DefaultDataSource still handles file:/content:, so
downloads bypass the cache.

The cache is a process-scoped 64 MiB LRU in cacheDir/stream-cache; a
construction failure falls back to no cache so playback still starts.
This commit is contained in:
austinried
2026-10-09 13:36:55 +00:00
parent 0cef3657bc
commit e806eaef89
5 changed files with 105 additions and 4 deletions
@@ -12,8 +12,13 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.ExperimentalApi
import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
@@ -41,6 +46,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.koin.core.context.GlobalContext
import java.io.File
@OptIn(UnstableApi::class)
class PlaybackService : MediaSessionService() {
@@ -91,6 +97,7 @@ class PlaybackService : MediaSessionService() {
val sources = GlobalContext.get().get<SourceRepository>()
sourceRepository = sources
library = GlobalContext.get().get<LibraryRepository>()
val cache = StreamCache.open(this)
val exoPlayer =
ExoPlayer
.Builder(this)
@@ -106,7 +113,11 @@ class PlaybackService : MediaSessionService() {
DefaultMediaSourceFactory(this)
.setEnableClippingInMediaPeriod(true)
.setDataSourceFactory(
mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()).setUserAgent(UserAgent.value)),
mediaDataSourceFactory(
this,
cache,
OkHttpDataSource.Factory(streamingClient()).setUserAgent(UserAgent.value),
),
),
).build()
exoPlayer.addListener(
@@ -216,8 +227,46 @@ class PlaybackService : MediaSessionService() {
}
}
// Process-scoped so it is never released while a loader thread is still committing to it, and so
// a recreated service does not try to lock the directory a second time.
@OptIn(UnstableApi::class)
private object StreamCache {
@Volatile
private var instance: SimpleCache? = null
fun open(context: Context): SimpleCache? =
instance ?: synchronized(this) {
instance ?: runCatching {
SimpleCache(
File(context.cacheDir, STREAM_CACHE_DIR),
LeastRecentlyUsedCacheEvictor(STREAM_CACHE_BYTES),
StandaloneDatabaseProvider(context),
)
}.onFailure { Log.w("playback", "stream cache unavailable: ${it.message}") }
.getOrNull()
?.also { instance = it }
}
private const val STREAM_CACHE_DIR = "stream-cache"
private const val STREAM_CACHE_BYTES = 64L * 1024 * 1024
}
@OptIn(UnstableApi::class)
internal fun mediaDataSourceFactory(
context: Context,
cache: Cache?,
upstream: DataSource.Factory,
): DataSource.Factory = DefaultDataSource.Factory(context, KnownLengthDataSourceFactory(upstream))
): DataSource.Factory {
val known = KnownLengthDataSourceFactory(upstream)
val source: DataSource.Factory =
if (cache == null) {
known
} else {
CacheDataSource
.Factory()
.setCache(cache)
.setUpstreamDataSourceFactory(known)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
return DefaultDataSource.Factory(context, source)
}
@@ -2,7 +2,12 @@ package com.subtracks.playback
import android.content.Context
import android.net.Uri
import androidx.media3.common.C
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.cache.NoOpCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -13,13 +18,16 @@ import okhttp3.mockwebserver.MockWebServer
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.charset.StandardCharsets
@RunWith(AndroidJUnit4::class)
class MediaDataSourceTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private fun dataSource() = mediaDataSourceFactory(context, OkHttpDataSource.Factory(OkHttpClient())).createDataSource()
private fun dataSource(cache: SimpleCache? = null) =
mediaDataSourceFactory(context, cache, OkHttpDataSource.Factory(OkHttpClient())).createDataSource()
@Test
fun aDownloadedFileIsReadThroughTheStreamingChain() {
@@ -50,4 +58,44 @@ class MediaDataSourceTest {
assertEquals(1_000_000L, length)
}
@Test
fun reopeningAStreamIsServedFromTheCache() {
val server = MockWebServer()
server.enqueue(MockResponse().setBody("0123456789"))
server.start()
val url = "http://${server.hostName}:${server.port}/rest/stream"
val cache =
SimpleCache(
File(context.cacheDir, "stream-cache-${System.nanoTime()}"),
NoOpCacheEvictor(),
StandaloneDatabaseProvider(context),
)
val first = readAll(dataSource(cache), url)
val second = readAll(dataSource(cache), url)
val requests = server.requestCount
server.shutdown()
cache.release()
assertEquals("0123456789", String(first, StandardCharsets.UTF_8))
assertEquals(first.toList(), second.toList())
assertEquals(1, requests)
}
private fun readAll(
dataSource: DataSource,
url: String,
): ByteArray {
dataSource.open(DataSpec(Uri.parse(url)))
val out = ByteArrayOutputStream()
val buffer = ByteArray(1024)
while (true) {
val read = dataSource.read(buffer, 0, buffer.size)
if (read == C.RESULT_END_OF_INPUT) break
out.write(buffer, 0, read)
}
dataSource.close()
return out.toByteArray()
}
}