diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index 511332ba..4853e271 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -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() } + singleOf(::PluginManager) { + bind() + bind() + } viewModelOf(::PluginViewModel) // Settings - singleOf(::SettingsRepository) + singleOf(::SettingsRepository) { bind() } viewModelOf(::SettingsViewModel) { bind() } viewModelOf(::JamSettingsViewModel) @@ -226,8 +232,15 @@ val sharedModules = module { blacklistRepository = get(), ) } - singleOf(::MatchedTracksRepository) - singleOf(::StreamingUrlRepository) + singleOf(::MatchedTracksRepository) { bind() } + single { + StreamingUrlRepository( + pluginManager = get(), + audioPlayerQueue = get(), + matchedTracksRepository = get(), + settingsRepository = get(), + ) + } singleOf(::CacheManager) singleOf(::AlternativeTracksRepository) singleOf(::LocalServer) withOptions { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt index 42e431af..77ad48f0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt @@ -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 { 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) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt index 3dcda56c..d0170c1b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt @@ -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(json as String) - } else { + val json = prefs[stringPreferencesKey(track.id)] ?: return@map null + val tagged = try { + Json.decodeFromString(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)) } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt index 00a215c5..7ba99621 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt @@ -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, ) 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() private val streamUrlCacheMutex = Mutex() private val streamUrlCache = mutableMapOf() private val alternativesCacheMutex = Mutex() - private val alternativesCache = mutableMapOf>() + private val alternativesCache = mutableMapOf() 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? { + /** Returns cached alternatives only if they were produced by [pluginId]. */ + suspend fun getCachedAlternatives(trackId: String, pluginId: String): List? { 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) { + suspend fun cacheAlternatives(trackId: String, sources: List, pluginId: String) { alternativesCacheMutex.withLock { - alternativesCache[trackId] = sources + alternativesCache[trackId] = CachedAlternatives(pluginId, sources) logger.v { "Cached ${sources.size} alternative sources for track $trackId" } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt index a2be45f6..13829efa 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt @@ -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, ZiplineService>() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/PluginService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/PluginService.kt index 36e1ac7d..6db523e3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/PluginService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/PluginService.kt @@ -52,6 +52,7 @@ class PluginServiceScope(private val registry: Map, ZiplineService>) { } interface PluginService { + val pluginId: String val loggedInFlow: StateFlow suspend fun start() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt index d055b64e..ccd966a4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt @@ -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. diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt index 0fe5276e..4212733b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt @@ -69,11 +69,18 @@ interface PluginProvider { val selectedMetadataPlugin: StateFlow } +/** 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 +} + class PluginManager( val database: Database, val paths: Paths, -) : KoinComponent, PluginProvider { +) : KoinComponent, PluginProvider, AudioPluginSource { private val logger by injectLogger() 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) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt index ac6a987f..8a31bd06 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt @@ -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 +} + +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 = database.settingsDataStore.data.map { prefs -> + override val userSettings: StateFlow = database.settingsDataStore.data.map { prefs -> val json = prefs[SETTINGS_KEY] if (json != null) { Json.decodeFromString(json as String) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt index 8a22546e..4b28e100 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt @@ -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 = _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 diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt index 40891c1e..0a204cfd 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt @@ -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 = MutableStateFlow(true) diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt new file mode 100644 index 00000000..8b0fbf82 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt @@ -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, + 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) + } +} diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryFakes.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryFakes.kt new file mode 100644 index 00000000..27e5550f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryFakes.kt @@ -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 = emptyList(), + var onGetStreamsByTrack: suspend (MetadataTrack) -> List = { emptyList() }, + var onGetStreamsOfAudioSource: suspend (AudioSource.Basic) -> List = { emptyList() }, +) : AudioAPI { + var getStreamsByTrackCallCount = 0 + var getStreamsOfAudioSourceCallCount = 0 + val getStreamsOfAudioSourceCalls = mutableListOf() + + override suspend fun getStreamsByTrack(track: MetadataTrack): List { + getStreamsByTrackCallCount++ + return onGetStreamsByTrack(track) + } + + override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List { + 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 = MutableStateFlow(true) + + override suspend fun start() = Unit + override suspend fun stop() = Unit + + override suspend fun 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 = _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() + 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 + + 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>(emptyList()) + override val queueFlow: StateFlow> = _queueFlow + override val currentQueueEntryFlow: StateFlow = MutableStateFlow(null) + override val currentCollectionEntryFlow: StateFlow = MutableStateFlow(null) + override val collectionHistoryFlow: StateFlow> = MutableStateFlow(emptyList()) + + fun setQueue(entries: List) { + _queueFlow.value = entries + } + + override suspend fun load( + entries: List, + autoPlay: Boolean, + startPosition: Int, + collectionEntry: QueueCollectionEntry?, + ) = error("not used") + + override suspend fun addToQueue(entry: QueueEntry) = error("not used") + override suspend fun addAllToQueue(entries: List, collectionEntry: QueueCollectionEntry?) = + error("not used") + + override suspend fun addAllAfterCurrent(entries: List) = 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 = _queueFlow.value + override suspend fun getCurrentQueueEntry(): QueueEntry? = null + override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? = null + override suspend fun getCollectionHistory(): List = emptyList() +}