feat: refactor track source management to support plugin-specific caching and retrieval

This commit is contained in:
Kingkor Roy Tirtho 2026-09-25 18:29:08 +06:00
parent 0c6479570c
commit 50c3b04eb1
13 changed files with 842 additions and 57 deletions

View File

@ -37,6 +37,7 @@ import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
import dev.krtirtho.spotube.core.server.CacheManager
import dev.krtirtho.spotube.core.server.LocalServer
import dev.krtirtho.spotube.core.server.MatchedTracksRepository
import dev.krtirtho.spotube.core.server.TrackSourceRepository
import dev.krtirtho.spotube.core.server.StreamingUrlRepository
import dev.krtirtho.spotube.core.webview.WebViewController
import dev.krtirtho.spotube.modules.album.AlbumRepository
@ -64,6 +65,7 @@ import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsViewModel
import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.plugin.PluginProvider
import dev.krtirtho.spotube.modules.plugin.PluginViewModel
@ -73,6 +75,7 @@ import dev.krtirtho.spotube.modules.search.SearchRepository
import dev.krtirtho.spotube.modules.search.SearchScreenViewModel
import dev.krtirtho.spotube.modules.settings.SettingsProvider
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.JamSettingsViewModel
import dev.krtirtho.spotube.modules.shell.AppShellViewModel
@ -122,11 +125,14 @@ val sharedModules = module {
viewModelOf(::LibraryLocalTracksViewModel)
// Plugin system
singleOf(::PluginManager) { bind<PluginProvider>() }
singleOf(::PluginManager) {
bind<PluginProvider>()
bind<AudioPluginSource>()
}
viewModelOf(::PluginViewModel)
// Settings
singleOf(::SettingsRepository)
singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
viewModelOf(::JamSettingsViewModel)
@ -226,8 +232,15 @@ val sharedModules = module {
blacklistRepository = get(),
)
}
singleOf(::MatchedTracksRepository)
singleOf(::StreamingUrlRepository)
singleOf(::MatchedTracksRepository) { bind<TrackSourceRepository>() }
single {
StreamingUrlRepository(
pluginManager = get(),
audioPlayerQueue = get(),
matchedTracksRepository = get(),
settingsRepository = get(),
)
}
singleOf(::CacheManager)
singleOf(::AlternativeTracksRepository)
singleOf(::LocalServer) withOptions {

View File

@ -21,12 +21,12 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
import org.koin.core.component.KoinComponent
class AlternativeTracksRepository(
private val pluginManager: PluginManager,
private val matchedTracksRepository: MatchedTracksRepository,
private val pluginManager: AudioPluginSource,
private val matchedTracksRepository: TrackSourceRepository,
private val streamingUrlRepository: StreamingUrlRepository,
private val audioPlayerQueue: AudioPlayerQueue,
private val cacheManager: CacheManager,
@ -36,10 +36,6 @@ class AlternativeTracksRepository(
suspend fun resolveAlternatives(track: MetadataTrack): List<AudioSource> {
val trackId = track.id
streamingUrlRepository.getCachedAlternatives(trackId)?.let { cached ->
logger.v { "Using cached alternatives for track $trackId" }
return cached
}
val audioPlugin = pluginManager.selectedAudioPlugin.value
if (audioPlugin == null) {
@ -47,6 +43,11 @@ class AlternativeTracksRepository(
return emptyList()
}
streamingUrlRepository.getCachedAlternatives(trackId, audioPlugin.pluginId)?.let { cached ->
logger.v { "Using cached alternatives for track $trackId" }
return cached
}
val sources = runCatching {
audioPlugin.use { audioAPI.getStreamsByTrack(track) }
}.getOrElse { throwable ->
@ -59,24 +60,30 @@ class AlternativeTracksRepository(
return emptyList()
}
streamingUrlRepository.cacheAlternatives(trackId, sources)
streamingUrlRepository.cacheAlternatives(trackId, sources, audioPlugin.pluginId)
logger.d { "Resolved ${sources.size} alternative sources for track $trackId" }
return sources
}
suspend fun getActiveSourceId(track: MetadataTrack): String? {
return matchedTracksRepository.getTrackSource(track)?.id
val audioPlugin = pluginManager.selectedAudioPlugin.value ?: return null
return matchedTracksRepository.getTrackSource(track, audioPlugin.pluginId)?.id
}
suspend fun selectAlternative(track: MetadataTrack, source: AudioSource) {
val trackId = track.id
val audioPlugin = pluginManager.selectedAudioPlugin.value
if (audioPlugin == null) {
logger.w { "No audio plugin selected while selecting alternative for track $trackId" }
return
}
val basic = when (source) {
is AudioSource.Streamed -> source.toBasic()
is AudioSource.Basic -> source
}
logger.i { "Selecting alternative source for track $trackId: ${basic.id} (${basic.title})" }
matchedTracksRepository.saveTrackSource(track, basic)
matchedTracksRepository.saveTrackSource(track, basic, audioPlugin.pluginId)
streamingUrlRepository.invalidateCachedStreamUrl(trackId)
streamingUrlRepository.invalidateCachedAlternatives(trackId)
cacheManager.invalidateCacheEntry(trackId)

View File

@ -24,23 +24,53 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.db.Database
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
class MatchedTracksRepository(private val database: Database) {
suspend fun getTrackSource(track: MetadataTrack): AudioSource.Basic? {
/**
* A matched [AudioSource.Basic] tagged with the id of the audio plugin that produced it.
* The [pluginId] tag lets [MatchedTracksRepository] detect and discard matches that were
* made by a plugin the user is no longer using, since a source id from one plugin (e.g. a
* YouTube video id) is meaningless - or worse, silently wrong - when handed to a different
* plugin's `getStreamsOfAudioSource`.
*/
@Serializable
data class PluginTaggedAudioSource(
val pluginId: String,
val source: AudioSource.Basic,
)
/** Persists (and looks up) the audio source matched to a [MetadataTrack] by a given plugin. */
interface TrackSourceRepository {
suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic?
suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String)
}
class MatchedTracksRepository(private val database: Database) : TrackSourceRepository {
/**
* Returns the previously matched source for [track], but only if it was produced by
* [pluginId]. A match saved by a different (or since-removed) plugin is treated as a
* cache miss rather than being handed to the wrong plugin.
*/
override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? {
return database.matchedTracksDataStore.data.map { prefs ->
val json = prefs[stringPreferencesKey(track.id)]
if (json != null) {
Json.decodeFromString<AudioSource.Basic>(json as String)
} else {
val json = prefs[stringPreferencesKey(track.id)] ?: return@map null
val tagged = try {
Json.decodeFromString<PluginTaggedAudioSource>(json)
} catch (_: Exception) {
// Legacy entries stored before plugin-tagging was introduced; treat as
// unverifiable and force a fresh match instead of risking a wrong plugin.
return@map null
}
if (tagged.pluginId != pluginId) return@map null
tagged.source
}.first()
}
suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic) {
override suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) {
database.matchedTracksDataStore.edit { prefs ->
prefs[stringPreferencesKey(track.id)] = Json.encodeToString(source)
prefs[stringPreferencesKey(track.id)] =
Json.encodeToString(PluginTaggedAudioSource(pluginId, source))
}
}
}

View File

@ -26,8 +26,8 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koin.core.component.KoinComponent
@ -45,6 +45,15 @@ data class CachedStreamUrl(
val selectedStream: AudioStream? = null,
val preferredFormat: AudioFormat,
val preferredQuality: AudioQuality,
// The id of the audio plugin that produced this entry. Guards against a stale entry
// (e.g. a YouTube video URL) being served after the user switches to a different
// audio plugin, which would otherwise play back completely unrelated content.
val pluginId: String,
)
private data class CachedAlternatives(
val pluginId: String,
val sources: List<AudioSource>,
)
data class StreamInfo(
@ -55,21 +64,18 @@ data class StreamInfo(
)
class StreamingUrlRepository(
private val pluginManager: PluginManager,
private val pluginManager: AudioPluginSource,
private val audioPlayerQueue: AudioPlayerQueue,
private val matchedTracksRepository: MatchedTracksRepository,
private val settingsRepository: SettingsRepository,
private val matchedTracksRepository: TrackSourceRepository,
private val settingsRepository: UserSettingsSource,
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
) : KoinComponent {
companion object {
private val STREAM_URL_CACHE_TTL_MS = 30.seconds.inWholeMilliseconds
}
private val logger by injectLogger<StreamingUrlRepository>()
private val streamUrlCacheMutex = Mutex()
private val streamUrlCache = mutableMapOf<String, CachedStreamUrl>()
private val alternativesCacheMutex = Mutex()
private val alternativesCache = mutableMapOf<String, List<AudioSource>>()
private val alternativesCache = mutableMapOf<String, CachedAlternatives>()
suspend fun resolveStreamInfo(
trackId: String,
@ -95,19 +101,20 @@ class StreamingUrlRepository(
val preferredFormat = settings.streamingMusicFormat
val preferredQuality = settings.streamingMusicQuality
if (!forceRefresh) {
getCachedStreamUrl(trackId, preferredFormat, preferredQuality)?.let { cached ->
logger.v { "Using cached stream URL for track $trackId" }
return cached
}
}
val audioPlugin = pluginManager.selectedAudioPlugin.value
if (audioPlugin == null) {
logger.w { "No audio plugin selected while resolving stream for track $trackId" }
return null
}
val source = matchedTracksRepository.getTrackSource(track)
if (!forceRefresh) {
getCachedStreamUrl(trackId, preferredFormat, preferredQuality, audioPlugin.pluginId)?.let { cached ->
logger.v { "Using cached stream URL for track $trackId" }
return cached
}
}
val source = matchedTracksRepository.getTrackSource(track, audioPlugin.pluginId)
if (source != null) {
logger.d { "Attempting stream resolution for track $trackId using cached source" }
@ -130,7 +137,7 @@ class StreamingUrlRepository(
)
if (resolvedStream != null) {
val info = resolvedStream.toStreamInfo()
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream)
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
return info
}
}
@ -153,19 +160,19 @@ class StreamingUrlRepository(
return null
}
cacheAlternatives(trackId, sources)
cacheAlternatives(trackId, sources, audioPlugin.pluginId)
val stream = runCatching {
audioPlugin.use {
sources.firstNotNullOfOrNull { src ->
when (src) {
is AudioSource.Streamed -> {
matchedTracksRepository.saveTrackSource(track, src.toBasic())
matchedTracksRepository.saveTrackSource(track, src.toBasic(), audioPlugin.pluginId)
src
}
is AudioSource.Basic -> {
matchedTracksRepository.saveTrackSource(track, src)
matchedTracksRepository.saveTrackSource(track, src, audioPlugin.pluginId)
audioAPI.getStreamsOfAudioSource(src)
.firstOrNull()
}
@ -190,7 +197,7 @@ class StreamingUrlRepository(
)
if (resolvedStream != null) {
val info = resolvedStream.toStreamInfo()
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream)
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
return info
}
return null
@ -207,10 +214,15 @@ class StreamingUrlRepository(
trackId: String,
preferredFormat: AudioFormat,
preferredQuality: AudioQuality,
pluginId: String,
): StreamInfo? {
val now = Clock.System.now().toEpochMilliseconds()
return streamUrlCacheMutex.withLock {
val cached = streamUrlCache[trackId] ?: return@withLock null
if (cached.pluginId != pluginId) {
logger.v { "Cached stream URL for track $trackId belongs to a different plugin; ignoring" }
return@withLock null
}
if (cached.preferredFormat != preferredFormat || cached.preferredQuality != preferredQuality) {
return@withLock null
}
@ -229,8 +241,9 @@ class StreamingUrlRepository(
preferredQuality: AudioQuality,
source: AudioSource.Streamed,
selectedStream: AudioStream,
pluginId: String,
) {
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + STREAM_URL_CACHE_TTL_MS
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs
streamUrlCacheMutex.withLock {
streamUrlCache[trackId] = CachedStreamUrl(
url = info.url,
@ -242,6 +255,7 @@ class StreamingUrlRepository(
selectedStream = selectedStream,
preferredFormat = preferredFormat,
preferredQuality = preferredQuality,
pluginId = pluginId,
)
}
}
@ -257,15 +271,21 @@ class StreamingUrlRepository(
// Stream metadata is part of the same URL-cache entry and is removed with it.
}
suspend fun getCachedAlternatives(trackId: String): List<AudioSource>? {
/** Returns cached alternatives only if they were produced by [pluginId]. */
suspend fun getCachedAlternatives(trackId: String, pluginId: String): List<AudioSource>? {
return alternativesCacheMutex.withLock {
alternativesCache[trackId]
val cached = alternativesCache[trackId] ?: return@withLock null
if (cached.pluginId != pluginId) {
logger.v { "Cached alternatives for track $trackId belong to a different plugin; ignoring" }
return@withLock null
}
cached.sources
}
}
suspend fun cacheAlternatives(trackId: String, sources: List<AudioSource>) {
suspend fun cacheAlternatives(trackId: String, sources: List<AudioSource>, pluginId: String) {
alternativesCacheMutex.withLock {
alternativesCache[trackId] = sources
alternativesCache[trackId] = CachedAlternatives(pluginId, sources)
logger.v { "Cached ${sources.size} alternative sources for track $trackId" }
}
}

View File

@ -41,6 +41,7 @@ class BuiltInPluginService(
private val pluginInfo: PluginEntry,
) : PluginService, KoinComponent {
override val pluginId: String = pluginInfo.id
private val loggedInStateFlow = MutableStateFlow(false)
override val loggedInFlow = loggedInStateFlow.asStateFlow()
val servicesRegistry = mutableMapOf<KClass<*>, ZiplineService>()

View File

@ -52,6 +52,7 @@ class PluginServiceScope(private val registry: Map<KClass<*>, ZiplineService>) {
}
interface PluginService {
val pluginId: String
val loggedInFlow: StateFlow<Boolean>
suspend fun start()

View File

@ -105,6 +105,8 @@ open class ZiplinePluginService(
private val pluginInfo: PluginEntry,
) : PluginService, KoinComponent {
override val pluginId: String = pluginInfo.id
// QuickJS compile() uses deep C-level recursion on the native thread stack.
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.

View File

@ -69,11 +69,18 @@ interface PluginProvider {
val selectedMetadataPlugin: StateFlow<PluginService?>
}
/** Narrow read-only view of the active audio plugin, used by consumers that only need to
* key caches or make calls against the currently selected audio plugin without depending
* on the full [PluginManager] (which also owns plugin installation/lifecycle machinery). */
interface AudioPluginSource {
val selectedAudioPlugin: StateFlow<PluginService?>
}
class PluginManager(
val database: Database,
val paths: Paths,
) : KoinComponent, PluginProvider {
) : KoinComponent, PluginProvider, AudioPluginSource {
private val logger by injectLogger<PluginManager>()
private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception ->
logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." }
@ -245,7 +252,7 @@ class PluginManager(
override val selectedMetadataPlugin =
filterSelectedPluginByType(PluginAbility.METADATA)
val selectedAudioPlugin =
override val selectedAudioPlugin =
filterSelectedPluginByType(PluginAbility.AUDIO)
val selectedLyricsPlugin =
filterSelectedPluginByType(PluginAbility.LYRICS)

View File

@ -27,14 +27,21 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.*
import kotlinx.serialization.json.Json
class SettingsRepository(private val database: Database) {
/** Narrow read-only view over the current [UserSettings], for consumers (like
* [dev.krtirtho.spotube.core.server.StreamingUrlRepository]) that only need to read the
* user's streaming preferences without depending on the full [SettingsRepository]. */
interface UserSettingsSource {
val userSettings: StateFlow<UserSettings>
}
class SettingsRepository(private val database: Database) : UserSettingsSource {
companion object {
private val SETTINGS_KEY = stringPreferencesKey("user_settings")
}
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
override val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
val json = prefs[SETTINGS_KEY]
if (json != null) {
Json.decodeFromString<UserSettings>(json as String)

View File

@ -27,10 +27,11 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.server.CacheEntry
import dev.krtirtho.spotube.core.server.CacheManager
import dev.krtirtho.spotube.core.server.MatchedTracksRepository
import dev.krtirtho.spotube.core.server.StreamingUrlRepository
import dev.krtirtho.spotube.core.server.TrackSourceRepository
import dev.krtirtho.spotube.core.server.normalizeManifestUrl
import dev.krtirtho.spotube.core.server.selectPreferredAudioStream
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@ -72,10 +73,11 @@ data class PlayerOptionsUiState(
class PlayerOptionsViewModel(
private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue,
private val matchedTracksRepository: MatchedTracksRepository,
private val matchedTracksRepository: TrackSourceRepository,
private val streamingUrlRepository: StreamingUrlRepository,
private val cacheManager: CacheManager,
private val settingsRepository: SettingsRepository,
private val pluginManager: AudioPluginSource,
) : ViewModel() {
private val _uiState = MutableStateFlow(PlayerOptionsUiState())
val uiState: StateFlow<PlayerOptionsUiState> = _uiState.asStateFlow()
@ -155,7 +157,8 @@ class PlayerOptionsViewModel(
runCatching {
val settings = settingsRepository.userSettings.value
withContext(Dispatchers.IO) {
val matchedSource = matchedTracksRepository.getTrackSource(track)
val pluginId = pluginManager.selectedAudioPlugin.value?.pluginId
val matchedSource = pluginId?.let { matchedTracksRepository.getTrackSource(track, it) }
val cachedStream = streamingUrlRepository.getCachedStreamUrlEntry(track.id)
val localCacheEntry = if (settings.enableMusicCaching) {
cacheManager.findCachedEntry(track.id)?.second

View File

@ -33,7 +33,8 @@ class FakeMetadataTrackAPI(
}
class FakePluginService(
private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI()
private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI(),
override val pluginId: String = "fake-plugin",
) : PluginService {
override val loggedInFlow: StateFlow<Boolean> = MutableStateFlow(true)

View File

@ -0,0 +1,556 @@
package dev.krtirtho.spotube.core.server
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.modules.settings.UserSettings
import co.touchlab.kermit.Logger
import kotlinx.coroutines.test.runTest
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Behavioral / caching tests for [StreamingUrlRepository]: the in-memory stream-url cache,
* the matched-source lookup, and the interaction between the two when the active audio
* plugin changes.
*
* These specifically target the "wrong track plays" class of bug: a stale match or cached
* URL from one plugin (or one track) being silently reused for a different plugin/track.
*/
class StreamingUrlRepositoryBehaviorTest {
@BeforeTest
fun setup() {
startKoin {
modules(module {
factory { (tag: String?) -> Logger.withTag(tag ?: "test") }
})
}
}
@AfterTest
fun teardown() {
stopKoin()
}
private fun track(id: String, title: String = "Track $id") = MetadataTrack(
id = id,
title = title,
durationMs = 200_000,
trackNumber = 1,
discNumber = 1,
artists = emptyList(),
album = null,
thumbnails = null,
explicit = false,
popularity = null,
isrcCode = null,
externalUri = null,
)
private fun basicSource(id: String, confidence: Float = 1f) = AudioSource.Basic(
id = id,
title = "source-$id",
artist = null,
album = null,
thumbnails = emptyList(),
externalUri = null,
confidence = confidence,
)
private fun streamedSource(
sourceId: String,
streams: List<AudioStream>,
confidence: Float = 1f,
) = AudioSource.Streamed(
id = sourceId,
title = "source-$sourceId",
artist = null,
album = null,
thumbnails = emptyList(),
externalUri = null,
confidence = confidence,
streams = streams,
)
private fun stream(url: String, bitrate: Int = 256_000) = AudioStream.Lossy(
url = url,
codec = "opus",
container = "webm",
protocol = StreamProtocol.PROGRESSIVE,
bitrate = bitrate,
)
private class Harness(cacheTtlMs: Long = 30_000) {
val queue = FakeStreamingAudioPlayerQueue()
val matchedTracksRepository = FakeTrackSourceRepository()
val settings = FakeUserSettingsSource()
val pluginSource = FakeAudioPluginSource()
val repository = StreamingUrlRepository(
pluginManager = pluginSource,
audioPlayerQueue = queue,
matchedTracksRepository = matchedTracksRepository,
settingsRepository = settings,
streamUrlCacheTtlMs = cacheTtlMs,
)
fun selectPlugin(pluginId: String, audioAPI: FakeAudioAPI = FakeAudioAPI()): FakeAudioPluginService {
val service = FakeAudioPluginService(pluginId, audioAPI)
pluginSource.setPlugin(service)
return service
}
}
// ---------- basic resolution ----------
@Test
fun `resolves and returns a stream via track lookup when nothing is cached`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
val info = h.repository.resolveStreamInfo(t)
assertNotNull(info)
assertEquals("https://example.com/a.opus", info.url)
}
@Test
fun `returns null when no audio plugin is selected`() = runTest {
val h = Harness()
val info = h.repository.resolveStreamInfo(track("t1"))
assertNull(info)
}
@Test
fun `returns null when the plugin yields no sources`() = runTest {
val h = Harness()
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = { emptyList() }
assertNull(h.repository.resolveStreamInfo(track("t1")))
}
@Test
fun `resolveStreamInfo by trackId looks up the matching queue entry`() = runTest {
val h = Harness()
val t = track("t1")
h.queue.setQueue(listOf(QueueEntry.StreamingTrack(track = t, url = "")))
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
val info = h.repository.resolveStreamInfo(trackId = "t1")
assertNotNull(info)
assertEquals("https://example.com/a.opus", info.url)
}
@Test
fun `resolveStreamInfo by trackId returns null when track is not queued`() = runTest {
val h = Harness()
h.selectPlugin("pluginA")
assertNull(h.repository.resolveStreamInfo(trackId = "not-queued"))
}
// ---------- in-memory URL cache ----------
@Test
fun `second resolution for the same track reuses the cache instead of calling the plugin again`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
h.repository.resolveStreamInfo(t)
h.repository.resolveStreamInfo(t)
assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `forceRefresh bypasses the cache and re-resolves`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
var counter = 0
plugin.audioAPI.onGetStreamsByTrack = {
counter++
listOf(streamedSource("src$counter", listOf(stream("https://example.com/$counter.opus"))))
}
val first = h.repository.resolveStreamInfo(t)
val second = h.repository.resolveStreamInfo(t, forceRefresh = true)
assertEquals("https://example.com/1.opus", first?.url)
assertEquals("https://example.com/2.opus", second?.url)
assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `changing the preferred format invalidates the cached url`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(
streamedSource(
"src1",
listOf(
stream("https://example.com/opus.webm"),
AudioStream.Lossy(
url = "https://example.com/aac.mp4",
codec = "aac",
container = "mp4",
bitrate = 256_000,
),
)
)
)
}
val first = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/opus.webm", first?.url)
h.settings.update {
copy(streamingMusicFormat = streamingMusicFormat.copy(codec = "aac", container = "mp4"))
}
val second = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/aac.mp4", second?.url)
// Preference change forced a second real lookup rather than serving the stale URL.
assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `changing the preferred quality invalidates the cached url`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(
streamedSource(
"src1",
listOf(stream("https://example.com/128.opus", 128_000), stream("https://example.com/320.opus", 320_000))
)
)
}
h.settings.update { copy(streamingMusicQuality = AudioQuality.Lossy(128_000)) }
val first = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/128.opus", first?.url)
h.settings.update { copy(streamingMusicQuality = AudioQuality.Lossy(320_000)) }
val second = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/320.opus", second?.url)
}
@Test
fun `cached url expires after the configured ttl`() = kotlinx.coroutines.runBlocking {
val h = Harness(cacheTtlMs = 10)
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
var counter = 0
plugin.audioAPI.onGetStreamsByTrack = {
counter++
listOf(streamedSource("src$counter", listOf(stream("https://example.com/$counter.opus"))))
}
h.repository.resolveStreamInfo(t)
kotlinx.coroutines.delay(50)
h.repository.resolveStreamInfo(t)
assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `invalidateCachedStreamUrl removes the cached entry unconditionally when no url is given`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
h.repository.resolveStreamInfo(t)
h.repository.invalidateCachedStreamUrl(t.id)
h.repository.resolveStreamInfo(t)
assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `invalidateCachedStreamUrl with a mismatching url is a no-op`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
h.repository.resolveStreamInfo(t)
h.repository.invalidateCachedStreamUrl(t.id, url = "https://not-the-cached-url")
h.repository.resolveStreamInfo(t)
// Cache entry survived because the supplied url didn't match; plugin isn't hit again.
assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount)
}
// ---------- matched-source fast path ----------
@Test
fun `a previously matched source from the same plugin is reused via getStreamsOfAudioSource`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
h.matchedTracksRepository.seed(t, basicSource("matched-1"), pluginId = "pluginA")
plugin.audioAPI.onGetStreamsOfAudioSource = {
listOf(streamedSource(it.id, listOf(stream("https://example.com/matched.opus"))))
}
plugin.audioAPI.onGetStreamsByTrack = {
error("should not fall back to track lookup when a matched source resolves")
}
val info = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/matched.opus", info?.url)
assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount)
assertEquals(0, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `THE BUG - a source matched by a previous plugin is never handed to a newly selected plugin`() = runTest {
val h = Harness()
val t = track("t1")
// The user matched this track while "pluginA" (e.g. a YouTube-backed plugin) was active.
// "yt-video-123" is meaningful *only* to pluginA.
h.matchedTracksRepository.seed(t, basicSource("yt-video-123"), pluginId = "pluginA")
// The user then switches the active audio plugin to "pluginB" (a different backend).
val pluginB = h.selectPlugin("pluginB")
pluginB.audioAPI.onGetStreamsOfAudioSource = {
error(
"pluginB must never receive a source id matched by a different plugin " +
"(id=${it.id}); this is exactly how playback of the wrong track happens."
)
}
pluginB.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("pluginB-native-source", listOf(stream("https://example.com/correct.opus"))))
}
val info = h.repository.resolveStreamInfo(t)
// pluginB never saw the stale "yt-video-123" id...
assertEquals(0, pluginB.audioAPI.getStreamsOfAudioSourceCallCount)
// ...and instead correctly fell back to a fresh, pluginB-native track lookup.
assertEquals(1, pluginB.audioAPI.getStreamsByTrackCallCount)
assertEquals("https://example.com/correct.opus", info?.url)
}
@Test
fun `a cached stream url from a previous plugin is not served after switching plugins`() = runTest {
val h = Harness()
val t = track("t1")
val pluginA = h.selectPlugin("pluginA")
pluginA.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("srcA", listOf(stream("https://example.com/pluginA.opus"))))
}
val first = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/pluginA.opus", first?.url)
val pluginB = h.selectPlugin("pluginB")
pluginB.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("srcB", listOf(stream("https://example.com/pluginB.opus"))))
}
val second = h.repository.resolveStreamInfo(t)
// Must re-resolve via pluginB rather than replaying pluginA's cached URL.
assertEquals("https://example.com/pluginB.opus", second?.url)
assertEquals(1, pluginB.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `matched source is saved and tagged with the resolving plugin id`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
h.repository.resolveStreamInfo(t)
val saved = h.matchedTracksRepository.rawEntryFor(t.id)
assertNotNull(saved)
assertEquals("pluginA", saved.pluginId)
assertEquals("src1", saved.source.id)
}
@Test
fun `matched source is looked up but ignored when the format preference has no matching stream`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
h.matchedTracksRepository.seed(t, basicSource("matched-1"), pluginId = "pluginA")
// Matched source resolves to a stream, so preferred-format mismatch is irrelevant here;
// this documents that selection still happens on top of the matched-source stream list.
plugin.audioAPI.onGetStreamsOfAudioSource = {
listOf(streamedSource(it.id, listOf(stream("https://example.com/only.opus"))))
}
val info = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/only.opus", info?.url)
}
@Test
fun `falls back to track lookup when the matched source resolves no streams`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
h.matchedTracksRepository.seed(t, basicSource("matched-1"), pluginId = "pluginA")
plugin.audioAPI.onGetStreamsOfAudioSource = { emptyList() }
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("fresh", listOf(stream("https://example.com/fresh.opus"))))
}
val info = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/fresh.opus", info?.url)
assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount)
assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `track lookup persists a Basic source by resolving its streams before caching`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = { listOf(basicSource("basic-1")) }
plugin.audioAPI.onGetStreamsOfAudioSource = {
listOf(streamedSource(it.id, listOf(stream("https://example.com/resolved.opus"))))
}
val info = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/resolved.opus", info?.url)
assertEquals("basic-1", h.matchedTracksRepository.rawEntryFor(t.id)?.source?.id)
}
@Test
fun `distinct tracks never share cache entries even with concurrent resolution`() = runTest {
val h = Harness()
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = { t ->
listOf(streamedSource("src-${t.id}", listOf(stream("https://example.com/${t.id}.opus"))))
}
val t1 = track("t1")
val t2 = track("t2")
val info1 = h.repository.resolveStreamInfo(t1)
val info2 = h.repository.resolveStreamInfo(t2)
assertEquals("https://example.com/t1.opus", info1?.url)
assertEquals("https://example.com/t2.opus", info2?.url)
assertTrue(info1?.url != info2?.url)
}
// ---------- alternatives cache ----------
@Test
fun `getCachedAlternatives returns null before anything is cached`() = runTest {
val h = Harness()
assertNull(h.repository.getCachedAlternatives("t1", "pluginA"))
}
@Test
fun `alternatives resolved via track lookup are cached and retrievable`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
val candidates = listOf(basicSource("alt-1"), basicSource("alt-2"))
plugin.audioAPI.onGetStreamsByTrack = { candidates }
plugin.audioAPI.onGetStreamsOfAudioSource = {
listOf(streamedSource(it.id, listOf(stream("https://example.com/${it.id}.opus"))))
}
h.repository.resolveStreamInfo(t)
val cached = h.repository.getCachedAlternatives(t.id, "pluginA")
assertNotNull(cached)
assertEquals(2, cached.size)
}
@Test
fun `alternatives cached by one plugin are not visible to a different plugin`() = runTest {
val h = Harness()
val t = track("t1")
val pluginA = h.selectPlugin("pluginA")
pluginA.audioAPI.onGetStreamsByTrack = { listOf(basicSource("alt-1")) }
pluginA.audioAPI.onGetStreamsOfAudioSource = {
listOf(streamedSource(it.id, listOf(stream("https://example.com/a.opus"))))
}
h.repository.resolveStreamInfo(t)
assertNotNull(h.repository.getCachedAlternatives(t.id, "pluginA"))
assertNull(h.repository.getCachedAlternatives(t.id, "pluginB"))
}
@Test
fun `cacheAlternatives followed by invalidateCachedAlternatives clears the entry`() = runTest {
val h = Harness()
h.repository.cacheAlternatives("t1", listOf(basicSource("alt-1")), "pluginA")
assertNotNull(h.repository.getCachedAlternatives("t1", "pluginA"))
h.repository.invalidateCachedAlternatives("t1")
assertNull(h.repository.getCachedAlternatives("t1", "pluginA"))
}
// ---------- getCachedStreamUrlEntry ----------
@Test
fun `getCachedStreamUrlEntry returns null when nothing has been cached`() = runTest {
val h = Harness()
assertNull(h.repository.getCachedStreamUrlEntry("t1"))
}
@Test
fun `getCachedStreamUrlEntry never fetches - it only reflects the existing cache`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
assertNull(h.repository.getCachedStreamUrlEntry(t.id))
h.repository.resolveStreamInfo(t)
val entry = h.repository.getCachedStreamUrlEntry(t.id)
assertNotNull(entry)
assertEquals("https://example.com/a.opus", entry.url)
assertEquals("pluginA", entry.pluginId)
// Fetching the entry doesn't call the plugin again.
assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount)
}
}

View File

@ -0,0 +1,137 @@
package dev.krtirtho.spotube.core.server
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.zipline.PluginService
import dev.krtirtho.spotube.core.zipline.PluginServiceScope
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/** In-memory [AudioAPI] whose behaviour for each call is fully scripted by the test. */
class FakeAudioAPI(
override val supportedQualities: List<AudioFormat> = emptyList(),
var onGetStreamsByTrack: suspend (MetadataTrack) -> List<AudioSource> = { emptyList() },
var onGetStreamsOfAudioSource: suspend (AudioSource.Basic) -> List<AudioSource.Streamed> = { emptyList() },
) : AudioAPI {
var getStreamsByTrackCallCount = 0
var getStreamsOfAudioSourceCallCount = 0
val getStreamsOfAudioSourceCalls = mutableListOf<AudioSource.Basic>()
override suspend fun getStreamsByTrack(track: MetadataTrack): List<AudioSource> {
getStreamsByTrackCallCount++
return onGetStreamsByTrack(track)
}
override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List<AudioSource.Streamed> {
getStreamsOfAudioSourceCallCount++
getStreamsOfAudioSourceCalls.add(source)
return onGetStreamsOfAudioSource(source)
}
}
/** Minimal [PluginService] that only ever exposes an [AudioAPI], identified by [pluginId]. */
class FakeAudioPluginService(
override val pluginId: String,
val audioAPI: FakeAudioAPI = FakeAudioAPI(),
) : PluginService {
override val loggedInFlow: StateFlow<Boolean> = MutableStateFlow(true)
override suspend fun start() = Unit
override suspend fun stop() = Unit
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
val scope = PluginServiceScope(mapOf(AudioAPI::class to audioAPI))
return scope.block()
}
}
class FakeAudioPluginSource(
initialPlugin: PluginService? = null
) : AudioPluginSource {
private val _selectedAudioPlugin = MutableStateFlow(initialPlugin)
override val selectedAudioPlugin: StateFlow<PluginService?> = _selectedAudioPlugin
fun setPlugin(service: PluginService?) {
_selectedAudioPlugin.value = service
}
}
/** In-memory [TrackSourceRepository], mirroring the plugin-scoped persistence contract of
* the real [MatchedTracksRepository] without touching disk. */
class FakeTrackSourceRepository : TrackSourceRepository {
private val store = mutableMapOf<String, PluginTaggedAudioSource>()
var getCallCount = 0
var saveCallCount = 0
override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? {
getCallCount++
val tagged = store[track.id] ?: return null
if (tagged.pluginId != pluginId) return null
return tagged.source
}
override suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) {
saveCallCount++
store[track.id] = PluginTaggedAudioSource(pluginId, source)
}
fun rawEntryFor(trackId: String): PluginTaggedAudioSource? = store[trackId]
fun seed(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) {
store[track.id] = PluginTaggedAudioSource(pluginId, source)
}
}
class FakeUserSettingsSource(initial: UserSettings = UserSettings()) : UserSettingsSource {
private val _userSettings = MutableStateFlow(initial)
override val userSettings: StateFlow<UserSettings> = _userSettings
fun update(transform: UserSettings.() -> UserSettings) {
_userSettings.value = _userSettings.value.transform()
}
}
/** Bare-bones [AudioPlayerQueue] that only backs [queueFlow]; every other member is unused
* by [StreamingUrlRepository] and throws if accidentally exercised. */
class FakeStreamingAudioPlayerQueue : AudioPlayerQueue {
private val _queueFlow = MutableStateFlow<List<QueueEntry>>(emptyList())
override val queueFlow: StateFlow<List<QueueEntry>> = _queueFlow
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = MutableStateFlow(null)
override val currentCollectionEntryFlow: StateFlow<QueueCollectionEntry?> = MutableStateFlow(null)
override val collectionHistoryFlow: StateFlow<List<QueueCollectionEntry>> = MutableStateFlow(emptyList())
fun setQueue(entries: List<QueueEntry>) {
_queueFlow.value = entries
}
override suspend fun load(
entries: List<QueueEntry>,
autoPlay: Boolean,
startPosition: Int,
collectionEntry: QueueCollectionEntry?,
) = error("not used")
override suspend fun addToQueue(entry: QueueEntry) = error("not used")
override suspend fun addAllToQueue(entries: List<QueueEntry>, collectionEntry: QueueCollectionEntry?) =
error("not used")
override suspend fun addAllAfterCurrent(entries: List<QueueEntry>) = error("not used")
override suspend fun removeFromQueue(entry: QueueEntry) = error("not used")
override suspend fun removeFromQueueByMediaUrl(mediaUrl: String) = error("not used")
override suspend fun move(fromIndex: Int, toIndex: Int) = error("not used")
override suspend fun jumpTo(index: Int, autoPlay: Boolean) = error("not used")
override suspend fun reloadCurrent() = error("not used")
override suspend fun clear() = error("not used")
override suspend fun getQueue(): List<QueueEntry> = _queueFlow.value
override suspend fun getCurrentQueueEntry(): QueueEntry? = null
override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? = null
override suspend fun getCollectionHistory(): List<QueueCollectionEntry> = emptyList()
}