mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
feat: refactor track source management to support plugin-specific caching and retrieval
This commit is contained in:
parent
0c6479570c
commit
50c3b04eb1
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>()
|
||||
|
||||
@ -52,6 +52,7 @@ class PluginServiceScope(private val registry: Map<KClass<*>, ZiplineService>) {
|
||||
}
|
||||
|
||||
interface PluginService {
|
||||
val pluginId: String
|
||||
val loggedInFlow: StateFlow<Boolean>
|
||||
|
||||
suspend fun start()
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user