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.CacheManager
|
||||||
import dev.krtirtho.spotube.core.server.LocalServer
|
import dev.krtirtho.spotube.core.server.LocalServer
|
||||||
import dev.krtirtho.spotube.core.server.MatchedTracksRepository
|
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.server.StreamingUrlRepository
|
||||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||||
import dev.krtirtho.spotube.modules.album.AlbumRepository
|
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.lyrics.LyricsViewModel
|
||||||
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
||||||
import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel
|
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.PluginManager
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginProvider
|
import dev.krtirtho.spotube.modules.plugin.PluginProvider
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginViewModel
|
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.search.SearchScreenViewModel
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
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.SettingsViewModel
|
||||||
import dev.krtirtho.spotube.modules.settings.JamSettingsViewModel
|
import dev.krtirtho.spotube.modules.settings.JamSettingsViewModel
|
||||||
import dev.krtirtho.spotube.modules.shell.AppShellViewModel
|
import dev.krtirtho.spotube.modules.shell.AppShellViewModel
|
||||||
@ -122,11 +125,14 @@ val sharedModules = module {
|
|||||||
viewModelOf(::LibraryLocalTracksViewModel)
|
viewModelOf(::LibraryLocalTracksViewModel)
|
||||||
|
|
||||||
// Plugin system
|
// Plugin system
|
||||||
singleOf(::PluginManager) { bind<PluginProvider>() }
|
singleOf(::PluginManager) {
|
||||||
|
bind<PluginProvider>()
|
||||||
|
bind<AudioPluginSource>()
|
||||||
|
}
|
||||||
viewModelOf(::PluginViewModel)
|
viewModelOf(::PluginViewModel)
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
singleOf(::SettingsRepository)
|
singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
|
||||||
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
|
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
|
||||||
viewModelOf(::JamSettingsViewModel)
|
viewModelOf(::JamSettingsViewModel)
|
||||||
|
|
||||||
@ -226,8 +232,15 @@ val sharedModules = module {
|
|||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
singleOf(::MatchedTracksRepository)
|
singleOf(::MatchedTracksRepository) { bind<TrackSourceRepository>() }
|
||||||
singleOf(::StreamingUrlRepository)
|
single {
|
||||||
|
StreamingUrlRepository(
|
||||||
|
pluginManager = get(),
|
||||||
|
audioPlayerQueue = get(),
|
||||||
|
matchedTracksRepository = get(),
|
||||||
|
settingsRepository = get(),
|
||||||
|
)
|
||||||
|
}
|
||||||
singleOf(::CacheManager)
|
singleOf(::CacheManager)
|
||||||
singleOf(::AlternativeTracksRepository)
|
singleOf(::AlternativeTracksRepository)
|
||||||
singleOf(::LocalServer) withOptions {
|
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.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
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
|
import org.koin.core.component.KoinComponent
|
||||||
|
|
||||||
class AlternativeTracksRepository(
|
class AlternativeTracksRepository(
|
||||||
private val pluginManager: PluginManager,
|
private val pluginManager: AudioPluginSource,
|
||||||
private val matchedTracksRepository: MatchedTracksRepository,
|
private val matchedTracksRepository: TrackSourceRepository,
|
||||||
private val streamingUrlRepository: StreamingUrlRepository,
|
private val streamingUrlRepository: StreamingUrlRepository,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val cacheManager: CacheManager,
|
private val cacheManager: CacheManager,
|
||||||
@ -36,10 +36,6 @@ class AlternativeTracksRepository(
|
|||||||
|
|
||||||
suspend fun resolveAlternatives(track: MetadataTrack): List<AudioSource> {
|
suspend fun resolveAlternatives(track: MetadataTrack): List<AudioSource> {
|
||||||
val trackId = track.id
|
val trackId = track.id
|
||||||
streamingUrlRepository.getCachedAlternatives(trackId)?.let { cached ->
|
|
||||||
logger.v { "Using cached alternatives for track $trackId" }
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
val audioPlugin = pluginManager.selectedAudioPlugin.value
|
val audioPlugin = pluginManager.selectedAudioPlugin.value
|
||||||
if (audioPlugin == null) {
|
if (audioPlugin == null) {
|
||||||
@ -47,6 +43,11 @@ class AlternativeTracksRepository(
|
|||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
streamingUrlRepository.getCachedAlternatives(trackId, audioPlugin.pluginId)?.let { cached ->
|
||||||
|
logger.v { "Using cached alternatives for track $trackId" }
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
val sources = runCatching {
|
val sources = runCatching {
|
||||||
audioPlugin.use { audioAPI.getStreamsByTrack(track) }
|
audioPlugin.use { audioAPI.getStreamsByTrack(track) }
|
||||||
}.getOrElse { throwable ->
|
}.getOrElse { throwable ->
|
||||||
@ -59,24 +60,30 @@ class AlternativeTracksRepository(
|
|||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
streamingUrlRepository.cacheAlternatives(trackId, sources)
|
streamingUrlRepository.cacheAlternatives(trackId, sources, audioPlugin.pluginId)
|
||||||
logger.d { "Resolved ${sources.size} alternative sources for track $trackId" }
|
logger.d { "Resolved ${sources.size} alternative sources for track $trackId" }
|
||||||
return sources
|
return sources
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getActiveSourceId(track: MetadataTrack): String? {
|
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) {
|
suspend fun selectAlternative(track: MetadataTrack, source: AudioSource) {
|
||||||
val trackId = track.id
|
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) {
|
val basic = when (source) {
|
||||||
is AudioSource.Streamed -> source.toBasic()
|
is AudioSource.Streamed -> source.toBasic()
|
||||||
is AudioSource.Basic -> source
|
is AudioSource.Basic -> source
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.i { "Selecting alternative source for track $trackId: ${basic.id} (${basic.title})" }
|
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.invalidateCachedStreamUrl(trackId)
|
||||||
streamingUrlRepository.invalidateCachedAlternatives(trackId)
|
streamingUrlRepository.invalidateCachedAlternatives(trackId)
|
||||||
cacheManager.invalidateCacheEntry(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 dev.krtirtho.spotube.core.db.Database
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
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 ->
|
return database.matchedTracksDataStore.data.map { prefs ->
|
||||||
val json = prefs[stringPreferencesKey(track.id)]
|
val json = prefs[stringPreferencesKey(track.id)] ?: return@map null
|
||||||
if (json != null) {
|
val tagged = try {
|
||||||
Json.decodeFromString<AudioSource.Basic>(json as String)
|
Json.decodeFromString<PluginTaggedAudioSource>(json)
|
||||||
} else {
|
} 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
|
return@map null
|
||||||
}
|
}
|
||||||
|
if (tagged.pluginId != pluginId) return@map null
|
||||||
|
tagged.source
|
||||||
}.first()
|
}.first()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic) {
|
override suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) {
|
||||||
database.matchedTracksDataStore.edit { prefs ->
|
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.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
@ -45,6 +45,15 @@ data class CachedStreamUrl(
|
|||||||
val selectedStream: AudioStream? = null,
|
val selectedStream: AudioStream? = null,
|
||||||
val preferredFormat: AudioFormat,
|
val preferredFormat: AudioFormat,
|
||||||
val preferredQuality: AudioQuality,
|
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(
|
data class StreamInfo(
|
||||||
@ -55,21 +64,18 @@ data class StreamInfo(
|
|||||||
)
|
)
|
||||||
|
|
||||||
class StreamingUrlRepository(
|
class StreamingUrlRepository(
|
||||||
private val pluginManager: PluginManager,
|
private val pluginManager: AudioPluginSource,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val matchedTracksRepository: MatchedTracksRepository,
|
private val matchedTracksRepository: TrackSourceRepository,
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: UserSettingsSource,
|
||||||
|
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
|
||||||
) : KoinComponent {
|
) : KoinComponent {
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val STREAM_URL_CACHE_TTL_MS = 30.seconds.inWholeMilliseconds
|
|
||||||
}
|
|
||||||
|
|
||||||
private val logger by injectLogger<StreamingUrlRepository>()
|
private val logger by injectLogger<StreamingUrlRepository>()
|
||||||
private val streamUrlCacheMutex = Mutex()
|
private val streamUrlCacheMutex = Mutex()
|
||||||
private val streamUrlCache = mutableMapOf<String, CachedStreamUrl>()
|
private val streamUrlCache = mutableMapOf<String, CachedStreamUrl>()
|
||||||
private val alternativesCacheMutex = Mutex()
|
private val alternativesCacheMutex = Mutex()
|
||||||
private val alternativesCache = mutableMapOf<String, List<AudioSource>>()
|
private val alternativesCache = mutableMapOf<String, CachedAlternatives>()
|
||||||
|
|
||||||
suspend fun resolveStreamInfo(
|
suspend fun resolveStreamInfo(
|
||||||
trackId: String,
|
trackId: String,
|
||||||
@ -95,19 +101,20 @@ class StreamingUrlRepository(
|
|||||||
val preferredFormat = settings.streamingMusicFormat
|
val preferredFormat = settings.streamingMusicFormat
|
||||||
val preferredQuality = settings.streamingMusicQuality
|
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
|
val audioPlugin = pluginManager.selectedAudioPlugin.value
|
||||||
if (audioPlugin == null) {
|
if (audioPlugin == null) {
|
||||||
logger.w { "No audio plugin selected while resolving stream for track $trackId" }
|
logger.w { "No audio plugin selected while resolving stream for track $trackId" }
|
||||||
return null
|
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) {
|
if (source != null) {
|
||||||
logger.d { "Attempting stream resolution for track $trackId using cached source" }
|
logger.d { "Attempting stream resolution for track $trackId using cached source" }
|
||||||
@ -130,7 +137,7 @@ class StreamingUrlRepository(
|
|||||||
)
|
)
|
||||||
if (resolvedStream != null) {
|
if (resolvedStream != null) {
|
||||||
val info = resolvedStream.toStreamInfo()
|
val info = resolvedStream.toStreamInfo()
|
||||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream)
|
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -153,19 +160,19 @@ class StreamingUrlRepository(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheAlternatives(trackId, sources)
|
cacheAlternatives(trackId, sources, audioPlugin.pluginId)
|
||||||
|
|
||||||
val stream = runCatching {
|
val stream = runCatching {
|
||||||
audioPlugin.use {
|
audioPlugin.use {
|
||||||
sources.firstNotNullOfOrNull { src ->
|
sources.firstNotNullOfOrNull { src ->
|
||||||
when (src) {
|
when (src) {
|
||||||
is AudioSource.Streamed -> {
|
is AudioSource.Streamed -> {
|
||||||
matchedTracksRepository.saveTrackSource(track, src.toBasic())
|
matchedTracksRepository.saveTrackSource(track, src.toBasic(), audioPlugin.pluginId)
|
||||||
src
|
src
|
||||||
}
|
}
|
||||||
|
|
||||||
is AudioSource.Basic -> {
|
is AudioSource.Basic -> {
|
||||||
matchedTracksRepository.saveTrackSource(track, src)
|
matchedTracksRepository.saveTrackSource(track, src, audioPlugin.pluginId)
|
||||||
audioAPI.getStreamsOfAudioSource(src)
|
audioAPI.getStreamsOfAudioSource(src)
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
}
|
}
|
||||||
@ -190,7 +197,7 @@ class StreamingUrlRepository(
|
|||||||
)
|
)
|
||||||
if (resolvedStream != null) {
|
if (resolvedStream != null) {
|
||||||
val info = resolvedStream.toStreamInfo()
|
val info = resolvedStream.toStreamInfo()
|
||||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream)
|
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@ -207,10 +214,15 @@ class StreamingUrlRepository(
|
|||||||
trackId: String,
|
trackId: String,
|
||||||
preferredFormat: AudioFormat,
|
preferredFormat: AudioFormat,
|
||||||
preferredQuality: AudioQuality,
|
preferredQuality: AudioQuality,
|
||||||
|
pluginId: String,
|
||||||
): StreamInfo? {
|
): StreamInfo? {
|
||||||
val now = Clock.System.now().toEpochMilliseconds()
|
val now = Clock.System.now().toEpochMilliseconds()
|
||||||
return streamUrlCacheMutex.withLock {
|
return streamUrlCacheMutex.withLock {
|
||||||
val cached = streamUrlCache[trackId] ?: return@withLock null
|
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) {
|
if (cached.preferredFormat != preferredFormat || cached.preferredQuality != preferredQuality) {
|
||||||
return@withLock null
|
return@withLock null
|
||||||
}
|
}
|
||||||
@ -229,8 +241,9 @@ class StreamingUrlRepository(
|
|||||||
preferredQuality: AudioQuality,
|
preferredQuality: AudioQuality,
|
||||||
source: AudioSource.Streamed,
|
source: AudioSource.Streamed,
|
||||||
selectedStream: AudioStream,
|
selectedStream: AudioStream,
|
||||||
|
pluginId: String,
|
||||||
) {
|
) {
|
||||||
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + STREAM_URL_CACHE_TTL_MS
|
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs
|
||||||
streamUrlCacheMutex.withLock {
|
streamUrlCacheMutex.withLock {
|
||||||
streamUrlCache[trackId] = CachedStreamUrl(
|
streamUrlCache[trackId] = CachedStreamUrl(
|
||||||
url = info.url,
|
url = info.url,
|
||||||
@ -242,6 +255,7 @@ class StreamingUrlRepository(
|
|||||||
selectedStream = selectedStream,
|
selectedStream = selectedStream,
|
||||||
preferredFormat = preferredFormat,
|
preferredFormat = preferredFormat,
|
||||||
preferredQuality = preferredQuality,
|
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.
|
// 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 {
|
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 {
|
alternativesCacheMutex.withLock {
|
||||||
alternativesCache[trackId] = sources
|
alternativesCache[trackId] = CachedAlternatives(pluginId, sources)
|
||||||
logger.v { "Cached ${sources.size} alternative sources for track $trackId" }
|
logger.v { "Cached ${sources.size} alternative sources for track $trackId" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,6 +41,7 @@ class BuiltInPluginService(
|
|||||||
private val pluginInfo: PluginEntry,
|
private val pluginInfo: PluginEntry,
|
||||||
) : PluginService, KoinComponent {
|
) : PluginService, KoinComponent {
|
||||||
|
|
||||||
|
override val pluginId: String = pluginInfo.id
|
||||||
private val loggedInStateFlow = MutableStateFlow(false)
|
private val loggedInStateFlow = MutableStateFlow(false)
|
||||||
override val loggedInFlow = loggedInStateFlow.asStateFlow()
|
override val loggedInFlow = loggedInStateFlow.asStateFlow()
|
||||||
val servicesRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
val servicesRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
||||||
|
|||||||
@ -52,6 +52,7 @@ class PluginServiceScope(private val registry: Map<KClass<*>, ZiplineService>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PluginService {
|
interface PluginService {
|
||||||
|
val pluginId: String
|
||||||
val loggedInFlow: StateFlow<Boolean>
|
val loggedInFlow: StateFlow<Boolean>
|
||||||
|
|
||||||
suspend fun start()
|
suspend fun start()
|
||||||
|
|||||||
@ -105,6 +105,8 @@ open class ZiplinePluginService(
|
|||||||
private val pluginInfo: PluginEntry,
|
private val pluginInfo: PluginEntry,
|
||||||
) : PluginService, KoinComponent {
|
) : PluginService, KoinComponent {
|
||||||
|
|
||||||
|
override val pluginId: String = pluginInfo.id
|
||||||
|
|
||||||
// QuickJS compile() uses deep C-level recursion on the native thread stack.
|
// 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
|
// 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.
|
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
|
||||||
|
|||||||
@ -69,11 +69,18 @@ interface PluginProvider {
|
|||||||
val selectedMetadataPlugin: StateFlow<PluginService?>
|
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(
|
class PluginManager(
|
||||||
val database: Database,
|
val database: Database,
|
||||||
val paths: Paths,
|
val paths: Paths,
|
||||||
) : KoinComponent, PluginProvider {
|
) : KoinComponent, PluginProvider, AudioPluginSource {
|
||||||
private val logger by injectLogger<PluginManager>()
|
private val logger by injectLogger<PluginManager>()
|
||||||
private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception ->
|
private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception ->
|
||||||
logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." }
|
logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." }
|
||||||
@ -245,7 +252,7 @@ class PluginManager(
|
|||||||
|
|
||||||
override val selectedMetadataPlugin =
|
override val selectedMetadataPlugin =
|
||||||
filterSelectedPluginByType(PluginAbility.METADATA)
|
filterSelectedPluginByType(PluginAbility.METADATA)
|
||||||
val selectedAudioPlugin =
|
override val selectedAudioPlugin =
|
||||||
filterSelectedPluginByType(PluginAbility.AUDIO)
|
filterSelectedPluginByType(PluginAbility.AUDIO)
|
||||||
val selectedLyricsPlugin =
|
val selectedLyricsPlugin =
|
||||||
filterSelectedPluginByType(PluginAbility.LYRICS)
|
filterSelectedPluginByType(PluginAbility.LYRICS)
|
||||||
|
|||||||
@ -27,14 +27,21 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.serialization.json.Json
|
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 {
|
companion object {
|
||||||
private val SETTINGS_KEY = stringPreferencesKey("user_settings")
|
private val SETTINGS_KEY = stringPreferencesKey("user_settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
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]
|
val json = prefs[SETTINGS_KEY]
|
||||||
if (json != null) {
|
if (json != null) {
|
||||||
Json.decodeFromString<UserSettings>(json as String)
|
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.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.server.CacheEntry
|
import dev.krtirtho.spotube.core.server.CacheEntry
|
||||||
import dev.krtirtho.spotube.core.server.CacheManager
|
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.StreamingUrlRepository
|
||||||
|
import dev.krtirtho.spotube.core.server.TrackSourceRepository
|
||||||
import dev.krtirtho.spotube.core.server.normalizeManifestUrl
|
import dev.krtirtho.spotube.core.server.normalizeManifestUrl
|
||||||
import dev.krtirtho.spotube.core.server.selectPreferredAudioStream
|
import dev.krtirtho.spotube.core.server.selectPreferredAudioStream
|
||||||
|
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -72,10 +73,11 @@ data class PlayerOptionsUiState(
|
|||||||
class PlayerOptionsViewModel(
|
class PlayerOptionsViewModel(
|
||||||
private val audioPlayer: AudioPlayerInterface,
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val matchedTracksRepository: MatchedTracksRepository,
|
private val matchedTracksRepository: TrackSourceRepository,
|
||||||
private val streamingUrlRepository: StreamingUrlRepository,
|
private val streamingUrlRepository: StreamingUrlRepository,
|
||||||
private val cacheManager: CacheManager,
|
private val cacheManager: CacheManager,
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
|
private val pluginManager: AudioPluginSource,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _uiState = MutableStateFlow(PlayerOptionsUiState())
|
private val _uiState = MutableStateFlow(PlayerOptionsUiState())
|
||||||
val uiState: StateFlow<PlayerOptionsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<PlayerOptionsUiState> = _uiState.asStateFlow()
|
||||||
@ -155,7 +157,8 @@ class PlayerOptionsViewModel(
|
|||||||
runCatching {
|
runCatching {
|
||||||
val settings = settingsRepository.userSettings.value
|
val settings = settingsRepository.userSettings.value
|
||||||
withContext(Dispatchers.IO) {
|
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 cachedStream = streamingUrlRepository.getCachedStreamUrlEntry(track.id)
|
||||||
val localCacheEntry = if (settings.enableMusicCaching) {
|
val localCacheEntry = if (settings.enableMusicCaching) {
|
||||||
cacheManager.findCachedEntry(track.id)?.second
|
cacheManager.findCachedEntry(track.id)?.second
|
||||||
|
|||||||
@ -33,7 +33,8 @@ class FakeMetadataTrackAPI(
|
|||||||
}
|
}
|
||||||
|
|
||||||
class FakePluginService(
|
class FakePluginService(
|
||||||
private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI()
|
private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI(),
|
||||||
|
override val pluginId: String = "fake-plugin",
|
||||||
) : PluginService {
|
) : PluginService {
|
||||||
override val loggedInFlow: StateFlow<Boolean> = MutableStateFlow(true)
|
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