This commit is contained in:
Iago Durán Romera 2026-10-30 18:30:27 +01:00 committed by GitHub
commit f7c1b19545
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 3047 additions and 0 deletions

View File

@ -0,0 +1,221 @@
package org.spotube.spotube.artwork
import android.content.Context
import android.net.Uri
import coil.ImageLoader
import coil.request.ImageRequest
import coil.request.success
import coil.decode.FileCache
import coil.memory.MemoryCache
import coil.network.NetworkInterceptor
import coil.size.Dimension
import coil.transform.Transformation
import kotlinx.coroutines.ExperimentalCoroutinesApi
import okhttp3.Interceptor
import okhttp3.Response
import okhttp3.Cache
import okhttp3.CacheControl
import java.io.File
import java.io.IOException
import kotlin.time.Duration
import kotlinx.coroutines.CoroutineScope
/**
* Android implementation of ArtworkCacheService using Coil.
*
* Features:
* - HTTPS host allowlist validation (prevents SSRF)
* - Configurable disk cache (200 MB default) and memory cache (50 MB)
* - Per-image download limit (20 MB default)
* - 7-day TTL with cleanup
* - Android content:// URI support
* - Graceful fallback to placeholder
*
* Based on DuranMusic's ArtworkCacheService using all_image_cache.
*/
@ExperimentalCoroutinesApi
class CoilArtworkCacheService(
private val context: Context,
private val config: ArtworkCacheConfig = ArtworkCacheConfig()
) : ArtworkCacheService {
private val imageLoader: ImageLoader = ImageLoader.Builder(context)
.diskCache {
FileCache.Builder()
.directory(File(context.cacheDir, "artwork"))
.maxSizeBytes(config.maxDiskCacheBytes)
.build()
}
.memoryCache {
MemoryCache.Builder()
.maxSizeBytes(50 * 1024 * 1024) // 50 MB memory cache
.build()
}
.networkInterceptor(ArtworkNetworkInterceptor(config.allowedHosts))
.build()
private val cleanupScope = CoroutineScope(kotlinx.coroutines.SupervisorJob())
override fun isValidArtworkUrl(url: String?): Boolean {
if (url.isNullOrEmpty()) return false
return try {
val uri = Uri.parse(url)
// Allow Android content:// URIs directly
if (uri.scheme == "content") return true
if (uri.scheme != "https") return false
config.allowedHosts.contains(uri.host)
} catch (_: Exception) {
false
}
}
override suspend fun preloadArtwork(url: String?) {
if (!isValidArtworkUrl(url)) return
try {
imageLoader.execute(
ImageRequest.Builder(context)
.data(url)
.allowHardware(false)
.build()
)
} catch (_: Exception) {
// Ignore preload failures
}
}
override suspend fun cleanupExpired() {
// Coil doesn't have built-in TTL cleanup, so we clean the cache directory manually
val cacheDir = File(context.cacheDir, "artwork")
if (!cacheDir.exists()) return
val cutoffTime = System.currentTimeMillis() - config.ttl.toMilliseconds()
cacheDir.listFiles()?.forEach { file ->
if (file.lastModified() < cutoffTime) {
file.delete()
}
}
}
override suspend fun clearCache() {
imageLoader.diskCache?.clear()
imageLoader.memoryCache?.clear()
}
/**
* Builds an ImageRequest for Compose AsyncImage with proper configuration.
*/
fun buildImageRequest(
url: String?,
size: Int,
placeholder: androidx.compose.ui.graphics.painter.Painter? = null,
error: androidx.compose.ui.graphics.painter.Painter? = null,
crossfade: Boolean = true
): ImageRequest.Builder {
return ImageRequest.Builder(context)
.apply {
if (url != null && isValidArtworkUrl(url)) {
data(url)
if (crossfade) this.crossfade(true)
transformations.add(CenterCropTransformation())
diskCachePolicy(coil.request.CachePolicy.CacheAndMemoryCache)
memoryCachePolicy(coil.request.CachePolicy.Enabled)
} else {
// Use placeholder directly for invalid URLs
data(placeholder ?: androidx.compose.ui.graphics.painter.SolidColorPainter(androidx.compose.ui.graphics.Color.LightGray))
}
size(Dimension(size), Dimension(size))
}
}
/**
* Cleans up resources when service is no longer needed.
*/
fun shutdown() {
cleanupScope.cancel()
imageLoader.shutdown()
}
}
/**
* Network interceptor that validates URLs against the allowlist.
*/
private class ArtworkNetworkInterceptor(
private val allowedHosts: Set<String>
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val url = request.url.toString()
// Allow content:// URIs
if (url.startsWith("content://")) {
return chain.proceed(request)
}
val uri = Uri.parse(url)
if (uri.scheme != "https" || !allowedHosts.contains(uri.host)) {
throw IOException("Artwork URL not allowed: $url")
}
return chain.proceed(request)
}
}
/**
* Center crop transformation for Coil.
*/
private class CenterCropTransformation : Transformation {
override fun key(): String = "CenterCrop"
override fun transform(input: coil.decode.ImageSource, pool: coil.decode.DecoderPool): coil.decode.Decoder {
return pool.decode(input).apply {
// Center crop is handled by Coil's size() + contentScale
}
}
}
/**
* Compose AsyncImage integration helpers.
*/
@ExperimentalCoroutinesApi
@Composable
fun AlbumArtwork(
album: SpotubeSimpleAlbum,
size: androidx.compose.ui.unit.Dp = 64.dp,
modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier,
artworkCache: CoilArtworkCacheService,
contentScale: androidx.compose.ui.layout.ContentScale = androidx.compose.ui.layout.ContentScale.Crop
) {
val url = album.artworkUrl()
val placeholder = remember { androidx.compose.ui.graphics.painter.SolidColorPainter(androidx.compose.material3.MaterialTheme.colorScheme.surfaceContainer) }
val error = remember { androidx.compose.ui.graphics.painter.SolidColorPainter(androidx.compose.material3.MaterialTheme.colorScheme.surfaceContainer) }
val request = remember(url, size) {
artworkCache.buildImageRequest(url, size.roundToPx(), placeholder, error)
}
AsyncImage(
model = request.build(),
contentDescription = null,
contentScale = contentScale,
modifier = modifier.size(size).clip(androidx.compose.ui.graphics.RoundedCornerShape(8.dp))
)
}
// Import Compose dependencies
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.clip
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.painter.SolidColorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.roundToPx
import coil.compose.AsyncImage

View File

@ -0,0 +1,183 @@
package org.spotube.spotube.metadata
import android.content.Context
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import org.spotube.spotube.resilience.ResiliencePipeline
import org.spotube.spotube.resilience.ResiliencePipelineConfig
/**
* Android implementation of MusicBrainzDataSource using the Hetu plugin runtime.
*
* This wraps the existing Spotube MusicBrainz/ListenBrainz plugin (v1.0.1+)
* which includes the resilience pipeline we contributed upstream.
*/
@ExperimentalCoroutinesApi
class MusicBrainzDataSourceImpl(
private val context: Context,
private val pluginApi: Any, // Hetu plugin instance - actual type depends on plugin API
override val resiliencePipeline: ResiliencePipeline = ResiliencePipeline(ResiliencePipelineConfig.DEFAULT_MUSICBRAINZ)
) : MusicBrainzDataSource {
override val name = "MusicBrainz/ListenBrainz"
override val isAvailable = true
override val strategy = MetadataSourceStrategy.PRIMARY_WITH_FALLBACK
// Search operations - delegate to plugin with resilience
override suspend fun search(query: String): SpotubeSearchResponse =
resiliencePipeline.execute {
callPluginSearch(query)
}
override suspend fun savedArtists(offset: Int?, limit: Int?): SpotubePaginationResponse<SpotubeFullArtist> =
resiliencePipeline.execute {
callPluginSavedArtists(offset, limit)
}
override suspend fun savedAlbums(offset: Int?, limit: Int?): SpotubePaginationResponse<SpotubeSimpleAlbum> =
resiliencePipeline.execute {
callPluginSavedAlbums(offset, limit)
}
override suspend fun savedTracks(offset: Int?, limit: Int?): SpotubePaginationResponse<SpotubeFullTrack> =
resiliencePipeline.execute {
callPluginSavedTracks(offset, limit)
}
// Favorite status checks
override suspend fun isSavedTrack(trackId: String): Boolean =
resiliencePipeline.executeWithoutRateLimit {
callPluginIsSavedTrack(trackId)
}
override suspend fun isSavedAlbum(albumId: String): Boolean =
resiliencePipeline.executeWithoutRateLimit {
callPluginIsSavedAlbum(albumId)
}
override suspend fun isSavedArtist(artistId: String): Boolean =
resiliencePipeline.executeWithoutRateLimit {
callPluginIsSavedArtist(artistId)
}
override suspend fun isSavedPlaylist(playlistId: String): Boolean =
resiliencePipeline.executeWithoutRateLimit {
callPluginIsSavedPlaylist(playlistId)
}
// Favorite mutations
override suspend fun saveArtists(artistIds: List<String>) =
resiliencePipeline.executeWithoutRateLimit {
callPluginSaveArtists(artistIds)
}
override suspend fun unsaveArtists(artistIds: List<String>) =
resiliencePipeline.executeWithoutRateLimit {
callPluginUnsaveArtists(artistIds)
}
override suspend fun saveAlbums(albumIds: List<String>) =
resiliencePipeline.executeWithoutRateLimit {
callPluginSaveAlbums(albumIds)
}
override suspend fun unsaveAlbums(albumIds: List<String>) =
resiliencePipeline.executeWithoutRateLimit {
callPluginUnsaveAlbums(albumIds)
}
override suspend fun saveTracks(trackIds: List<String>) =
resiliencePipeline.executeWithoutRateLimit {
callPluginSaveTracks(trackIds)
}
override suspend fun unsaveTracks(trackIds: List<String>) =
resiliencePipeline.executeWithoutRateLimit {
callPluginUnsaveTracks(trackIds)
}
override suspend fun savePlaylists(playlistIds: List<String>) =
resiliencePipeline.executeWithoutRateLimit {
callPluginSavePlaylists(playlistIds)
}
override suspend fun unsavePlaylists(playlistIds: List<String>) =
resiliencePipeline.executeWithoutRateLimit {
callPluginUnsavePlaylists(playlistIds)
}
// Private plugin call methods - these would use the actual Hetu plugin API
private suspend fun callPluginSearch(query: String): SpotubeSearchResponse {
// TODO: Implement actual plugin call via Hetu runtime
// Example: pluginApi.search.all(query)
return SpotubeSearchResponse()
}
private suspend fun callPluginSavedArtists(offset: Int?, limit: Int?): SpotubePaginationResponse<SpotubeFullArtist> {
// TODO: pluginApi.user.savedArtists(offset, limit)
return SpotubePaginationResponse()
}
private suspend fun callPluginSavedAlbums(offset: Int?, limit: Int?): SpotubePaginationResponse<SpotubeSimpleAlbum> {
// TODO: pluginApi.user.savedAlbums(offset, limit)
return SpotubePaginationResponse()
}
private suspend fun callPluginSavedTracks(offset: Int?, limit: Int?): SpotubePaginationResponse<SpotubeFullTrack> {
// TODO: pluginApi.user.savedTracks(offset, limit)
return SpotubePaginationResponse()
}
private suspend fun callPluginIsSavedTrack(trackId: String): Boolean {
// TODO: pluginApi.user.isSavedTracks([trackId])
return false
}
private suspend fun callPluginIsSavedAlbum(albumId: String): Boolean {
// TODO: pluginApi.album.isSaved([albumId])
return false
}
private suspend fun callPluginIsSavedArtist(artistId: String): Boolean {
// TODO: pluginApi.artist.isSaved([artistId])
return false
}
private suspend fun callPluginIsSavedPlaylist(playlistId: String): Boolean {
// TODO: pluginApi.playlist.isSaved(playlistId)
return false
}
private suspend fun callPluginSaveArtists(artistIds: List<String>) {
// TODO: pluginApi.artist.save(artistIds)
}
private suspend fun callPluginUnsaveArtists(artistIds: List<String>) {
// TODO: pluginApi.artist.unsave(artistIds)
}
private suspend fun callPluginSaveAlbums(albumIds: List<String>) {
// TODO: pluginApi.album.save(albumIds)
}
private suspend fun callPluginUnsaveAlbums(albumIds: List<String>) {
// TODO: pluginApi.album.unsave(albumIds)
}
private suspend fun callPluginSaveTracks(trackIds: List<String>) {
// TODO: pluginApi.track.save(trackIds)
}
private suspend fun callPluginUnsaveTracks(trackIds: List<String>) {
// TODO: pluginApi.track.unsave(trackIds)
}
private suspend fun callPluginSavePlaylists(playlistIds: List<String>) {
// TODO: pluginApi.playlist.save(playlistIds)
}
private suspend fun callPluginUnsavePlaylists(playlistIds: List<String>) {
// TODO: pluginApi.playlist.unsave(playlistIds)
}
}

View File

@ -0,0 +1,108 @@
package org.spotube.spotube.metadata
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
/**
* Android implementation of YouTubeAudioDataSource.
* Uses the existing YouTube engine to search videos and convert to metadata.
*
* Based on DuranMusic's YouTubeAudioDataSource in `youtube_audio_data_source.dart`.
*/
@ExperimentalCoroutinesApi
class YouTubeAudioDataSourceImpl(
private val youtubeEngine: Any // Actual type: YouTubeEngine from spotube
) : YouTubeAudioDataSource {
override val name = "YouTube Audio"
override val isAvailable = true
override val strategy = MetadataSourceStrategy.PRIMARY_WITH_FALLBACK
override suspend fun search(query: String): SpotubeSearchResponse {
if (query.isBlank()) {
return SpotubeSearchResponse()
}
// Search YouTube videos
val videos = searchVideos(query)
// Convert to metadata objects
val artists = videos.map { video ->
SpotubeFullArtist(
id = video.id,
name = video.author,
externalUri = "https://www.youtube.com/channel/${video.id}",
images = emptyList()
)
}.distinctBy { it.name }.toList()
val tracks = videos.map { video ->
SpotubeFullTrack(
id = video.id,
name = video.title,
artists = listOf(SpotubeSimpleArtist(
id = video.id,
name = video.author,
externalUri = "https://www.youtube.com/channel/${video.id}"
)),
album = SpotubeSimpleAlbum(
id = video.id,
name = video.title,
externalUri = "https://www.youtube.com/watch?v=${video.id}",
artists = listOf(SpotubeSimpleArtist(
id = video.id,
name = video.author,
externalUri = "https://www.youtube.com/channel/${video.id}"
)),
albumType = SpotubeAlbumType.SINGLE,
images = emptyList()
),
durationMs = video.durationMs ?: 0,
isrc = "",
explicit = false,
externalUri = "https://www.youtube.com/watch?v=${video.id}"
)
}.toList()
return SpotubeSearchResponse(
albums = emptyList(),
artists = artists,
playlists = emptyList(),
tracks = tracks
)
}
override suspend fun searchVideos(query: String): List<YouTubeVideo> {
// TODO: Implement actual YouTube engine call
// Example: youtubeEngine.searchVideos(query)
return emptyList()
}
// User library operations - YouTube Audio doesn't have these natively
override suspend fun savedArtists(offset: Int?, limit: Int?): SpotubePaginationResponse<SpotubeFullArtist> {
return SpotubePaginationResponse()
}
override suspend fun savedAlbums(offset: Int?, limit: Int?): SpotubePaginationResponse<SpotubeSimpleAlbum> {
return SpotubePaginationResponse()
}
override suspend fun savedTracks(offset: Int?, limit: Int?): SpotubePaginationResponse<SpotubeFullTrack> {
return SpotubePaginationResponse()
}
override suspend fun isSavedTrack(trackId: String): Boolean = false
override suspend fun isSavedAlbum(albumId: String): Boolean = false
override suspend fun isSavedArtist(artistId: String): Boolean = false
override suspend fun isSavedPlaylist(playlistId: String): Boolean = false
// Mutations - YouTube Audio doesn't support these natively
override suspend fun saveArtists(artistIds: List<String>) {}
override suspend fun unsaveArtists(artistIds: List<String>) {}
override suspend fun saveAlbums(albumIds: List<String>) {}
override suspend fun unsaveAlbums(albumIds: List<String>) {}
override suspend fun saveTracks(trackIds: List<String>) {}
override suspend fun unsaveTracks(trackIds: List<String>) {}
override suspend fun savePlaylists(playlistIds: List<String>) {}
override suspend fun unsavePlaylists(playlistIds: List<String>) {}
}

View File

@ -0,0 +1,171 @@
package org.spotube.spotube.playback
import android.media.MediaMetadata
import android.media.session.MediaController
import android.media.session.MediaSession
import android.media.session.PlaybackState
import androidx.media3.common.MediaItem
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaLibrarySession
import androidx.media3.session.MediaSessionService
import kotlinx.coroutines.ExperimentalCoroutinesApi
/**
* Android Media3 integration for ZombieSessionGuard.
* Wraps MediaLibrarySession/MediaController for zombie detection.
*
* Based on DuranMusic's ZombieSessionGuard adapted for Media3 architecture.
*/
@ExperimentalCoroutinesApi
class Media3ZombieGuard(
private val mediaController: MediaController,
private val session: MediaLibrarySession
) : ZombieSessionGuard(
player = object : PlayerStateReader {
override val state: PlaybackState
get() = convertPlaybackState(mediaController.playbackState)
override val position: Long
get() = mediaController.playbackState.position
},
audioService = object : AudioSessionController {
override suspend fun pause() = mediaController.pause()
override suspend fun stop() = mediaController.stop()
override suspend fun seekTo(position: Long) = mediaController.seekTo(position)
override suspend fun skipToNext() = mediaController.fastForward()
override suspend fun skipToPrevious() = mediaController.rewind()
override suspend fun setLoopMode(mode: LoopMode) {
// Map LoopMode to Media3 repeat mode
}
override suspend fun setShuffleMode(mode: ShuffleMode) {
// Map ShuffleMode to Media3 shuffle mode
}
}
) {
/**
* Converts Media3 PlaybackState to our common model.
*/
private fun convertPlaybackState(pbState: PlaybackState?): PlaybackState {
if (pbState == null) {
return PlaybackState()
}
val playing = when (pbState.state) {
PlaybackState.STATE_PLAYING -> true
PlaybackState.STATE_BUFFERING -> true
else -> false
}
return PlaybackState(
playing = playing,
tracks = emptyList(), // Would need queue access
currentIndex = pbState.activeQueueItemId?.toInt() ?: -1,
position = pbState.position,
duration = pbState.duration
)
}
}
/**
* Android Media3 integration for MediaSessionSync with generation counter.
* Wraps MediaController for seek synchronization.
*/
@ExperimentalCoroutinesApi
class Media3SessionSync(
private val mediaController: MediaController,
private val session: MediaLibrarySession
) : MediaSessionSync(
player = object : PlayerController {
override val currentState: PlaybackState
get() = convertPlaybackState(mediaController.playbackState)
override val position: Long
get() = mediaController.playbackState.position
override suspend fun seekTo(position: Long) = mediaController.seekTo(position)
},
mediaSession = object : MediaSessionPublisher {
override suspend fun publishPlaybackState(state: PlaybackState) {
// Media3 publishes automatically via MediaLibrarySession.Callback
// This is for explicit notifications if needed
}
}
) {
private fun convertPlaybackState(pbState: PlaybackState?): PlaybackState {
if (pbState == null) return PlaybackState()
return PlaybackState(
playing = pbState.state == PlaybackState.STATE_PLAYING,
position = pbState.position,
duration = pbState.duration
)
}
}
/**
* Enhanced STOP button state for Media3.
* Provides visual feedback during stop operation and complete state cleanup.
*/
@ExperimentalCoroutinesApi
class Media3StopHandler(
private val mediaController: MediaController,
private val session: MediaLibrarySession
) {
/**
* Executes stop with complete state cleanup.
* Clears: queue, position, playback state, metadata.
*/
suspend fun stopWithCleanup() {
// 1. Stop playback
mediaController.stop()
// 2. Clear queue via session callback (if we have session access)
// This would be called on the MediaLibrarySession side
// 3. Reset playback state
val resetState = PlaybackStateCompat.Builder()
.setState(PlaybackState.STATE_STOPPED, 0, 1.0f)
.build()
session.setPlaybackState(resetState)
// 4. Clear metadata
session.setMediaMetadata(null)
}
/**
* State for STOP button UI.
*/
data class StopButtonState(
val isStopping: Boolean = false,
val showSpinner: Boolean = false
)
}
// Helper to convert our PlaybackState to Media3 PlaybackStateCompat
private fun toPlaybackStateCompat(state: PlaybackState): PlaybackStateCompat {
return PlaybackStateCompat.Builder()
.setState(
if (state.playing) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED,
state.position,
state.speed
)
.setActions(
PlaybackState.ACTION_PLAY or
PlaybackState.ACTION_PAUSE or
PlaybackState.ACTION_STOP or
PlaybackState.ACTION_SKIP_TO_NEXT or
PlaybackState.ACTION_SKIP_TO_PREVIOUS or
PlaybackState.ACTION_SEEK_TO
)
.build()
}
// MediaMetadata conversion
private fun toMediaMetadata(track: Track?): MediaMetadata? {
track?.let {
return MediaMetadata.Builder()
.putString(MediaMetadata.METADATA_KEY_TITLE, track.title)
.putString(MediaMetadata.METADATA_KEY_ARTIST, track.artist)
.putString(MediaMetadata.METADATA_KEY_ALBUM, track.album)
.putLong(MediaMetadata.METADATA_KEY_DURATION, track.durationMs)
.build()
}
return null
}

View File

@ -0,0 +1,94 @@
package org.spotube.spotube.artwork
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.serialization.Serializable
/**
* Configuration for ArtworkCacheService.
*/
@Serializable
data class ArtworkCacheConfig(
/** Maximum total disk cache size in bytes (default: 200 MB). */
val maxDiskCacheBytes: Long = 200 * 1024 * 1024,
/** Maximum download size per image in bytes (default: 20 MB). */
val maxDownloadBytes: Long = 20 * 1024 * 1024,
/** Time-to-live for cached entries (default: 7 days). */
val ttl: kotlin.time.Duration = kotlin.time.Duration.days(7),
/** Allowed HTTPS hosts for artwork URLs (security allowlist). */
val allowedHosts: Set<String> = DEFAULT_ALLOWED_HOSTS
)
/**
* Default allowed hosts for artwork - matches DuranMusic's allowlist.
* Only these hosts are accepted to prevent SSRF and untrusted image loads.
*/
val DEFAULT_ALLOWED_HOSTS = setOf(
"coverartarchive.org",
"archive.org",
"i.scdn.co",
"m.media-amazon.com",
"lastfm.freetls.fastly.net",
"upload.wikimedia.org",
"is1-ssl.mzstatic.com",
"is2-ssl.mzstatic.com",
"is3-ssl.mzstatic.com",
"is4-ssl.mzstatic.com",
"is5-ssl.mzstatic.com"
)
/**
* Interface for artwork cache service.
* Provides centralized album artwork loading with:
* - HTTPS host validation (security)
* - Disk/memory size limits (performance)
* - Configurable TTL (freshness)
* - Graceful fallback to placeholder (UX)
*
* Based on DuranMusic's ArtworkCacheService in `artwork_cache_service.dart`.
*/
@ExperimentalCoroutinesApi
interface ArtworkCacheService {
/** Validates if an artwork URL is safe (HTTPS + allowed host). */
fun isValidArtworkUrl(url: String?): Boolean
/** Configuration for this cache service. */
val config: ArtworkCacheConfig
/**
* Preloads an artwork URL into cache.
* Useful for prefetching next items in a list.
*/
suspend fun preloadArtwork(url: String?)
/**
* Clears expired entries from cache (based on TTL).
*/
suspend fun cleanupExpired()
/**
* Clears all cache entries.
*/
suspend fun clearCache()
}
/**
* Extension functions for easy artwork loading from metadata models.
*/
@ExperimentalCoroutinesApi
fun SpotubeSimpleAlbum.artworkUrl(): String? {
return images.firstOrNull()?.url
}
@ExperimentalCoroutinesApi
fun SpotubeFullTrack.artworkUrl(): String? {
return album.images.firstOrNull()?.url
}
@ExperimentalCoroutinesApi
fun SpotubePlaylist.artworkUrl(): String? {
return images.firstOrNull()?.url
}

View File

@ -0,0 +1,144 @@
package org.spotube.spotube.metadata
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.serialization.Serializable
/**
* Strategy for selecting metadata providers.
*/
enum class MetadataSourceStrategy {
PRIMARY_ONLY, // Use only primary; fail if unavailable
PRIMARY_WITH_FALLBACK, // Try primary, then fallback sequentially (default)
FASTEST_FIRST // Try all, use first response
}
/**
* Core metadata model interfaces.
* These should align with the plugin API models.
*/
@Serializable
data class SpotubeSearchResponse(
val albums: List<SpotubeSimpleAlbum> = emptyList(),
val artists: List<SpotubeFullArtist> = emptyList(),
val playlists: List<SpotubePlaylist> = emptyList(),
val tracks: List<SpotubeFullTrack> = emptyList()
)
@Serializable
data class SpotubePaginationResponse<T>(
val items: List<T> = emptyList(),
val total: Long = 0,
val limit: Int = 20,
val hasMore: Boolean = false,
val nextOffset: Int? = null
)
@Serializable
data class SpotubeFullArtist(
val id: String,
val name: String,
val externalUri: String = "",
val images: List<SpotubeImage> = emptyList()
)
@Serializable
data class SpotubeSimpleArtist(
val id: String,
val name: String,
val externalUri: String = ""
)
@Serializable
data class SpotubeSimpleAlbum(
val id: String,
val name: String,
val externalUri: String = "",
val artists: List<SpotubeSimpleArtist> = emptyList(),
val albumType: SpotubeAlbumType = SpotubeAlbumType.ALBUM,
val images: List<SpotubeImage> = emptyList(),
val releaseDate: String = ""
)
@Serializable
data class SpotubeFullTrack(
val id: String,
val name: String,
val artists: List<SpotubeSimpleArtist> = emptyList(),
val album: SpotubeSimpleAlbum = SpotubeSimpleAlbum(id = "", name = ""),
val durationMs: Long = 0,
val isrc: String = "",
val explicit: Boolean = false,
val externalUri: String = ""
)
@Serializable
data class SpotubeImage(
val url: String,
val width: Int = 0,
val height: Int = 0
)
@Serializable
data class SpotubePlaylist(
val id: String,
val name: String,
val description: String = "",
val externalUri: String = "",
val owner: SpotubeSimpleArtist? = null,
val images: List<SpotubeImage> = emptyList(),
val collaborative: Boolean = false,
val public: Boolean = false
)
enum class SpotubeAlbumType {
ALBUM, SINGLE, COMPILATION, EP
}
/**
* Contract that all metadata providers must implement.
* Enables pluggable architecture with automatic fallback.
*/
@ExperimentalCoroutinesApi
interface MetadataDataSource {
/** Human-readable name for logging/debugging. */
val name: String
/** Whether this provider is currently available. */
val isAvailable: Boolean
/** Recommended strategy for this provider. */
val strategy: MetadataSourceStrategy = MetadataSourceStrategy.PRIMARY_WITH_FALLBACK
// Search operations
suspend fun search(query: String): SpotubeSearchResponse
// User library operations (paginated)
suspend fun savedArtists(offset: Int?, limit: Int?): SpotubePaginationResponse<SpotubeFullArtist>
suspend fun savedAlbums(offset: Int?, limit: Int?): SpotubePaginationResponse<SpotubeSimpleAlbum>
suspend fun savedTracks(offset: Int?, limit: Int?): SpotubePaginationResponse<SpotubeFullTrack>
// Favorite status checks
suspend fun isSavedTrack(trackId: String): Boolean
suspend fun isSavedAlbum(albumId: String): Boolean
suspend fun isSavedArtist(artistId: String): Boolean
suspend fun isSavedPlaylist(playlistId: String): Boolean
// Favorite mutations (try each source until one succeeds)
suspend fun saveArtists(artistIds: List<String>)
suspend fun unsaveArtists(artistIds: List<String>)
suspend fun saveAlbums(albumIds: List<String>)
suspend fun unsaveAlbums(albumIds: List<String>)
suspend fun saveTracks(trackIds: List<String>)
suspend fun unsaveTracks(trackIds: List<String>)
suspend fun savePlaylists(playlistIds: List<String>)
suspend fun unsavePlaylists(playlistIds: List<String>)
}
/**
* Exception thrown when all metadata providers fail.
*/
class MetadataRepositoryException(
message: String,
val errors: List<Throwable> = emptyList()
) : Exception(message)

View File

@ -0,0 +1,168 @@
package org.spotube.spotube.metadata
import kotlinx.coroutines.ExperimentalCoroutinesApi
/**
* Multi-source metadata repository with automatic fallback.
*
* Tries providers in order based on configured strategy.
* For read operations: first success wins.
* For write operations: tries each until one succeeds.
*
* Based on DuranMusic's MetadataRepository in `metadata_data_source.dart`.
*/
@ExperimentalCoroutinesApi
class MetadataRepository(
private val sources: List<MetadataDataSource>,
private val strategy: MetadataSourceStrategy = MetadataSourceStrategy.PRIMARY_WITH_FALLBACK
) : MetadataDataSource by DelegatingMetadataDataSource(sources) {
override val name: String = sources.joinToString(" + ") { it.name }
override val isAvailable: Boolean = sources.any { it.isAvailable }
override val strategy: MetadataSourceStrategy = strategy
// Read operations with fallback chain
override suspend fun search(query: String): SpotubeSearchResponse =
withFallback { it.search(query) }
override suspend fun savedArtists(offset: Int?, limit: Int?): SpotubePaginationResponse<SpotubeFullArtist> =
withFallback { it.savedArtists(offset, limit) }
override suspend fun savedAlbums(offset: Int?, limit: Int?): SpotubePaginationResponse<SpotubeSimpleAlbum> =
withFallback { it.savedAlbums(offset, limit) }
override suspend fun savedTracks(offset: Int?, limit: Int?): SpotubePaginationResponse<SpotubeFullTrack> =
withFallback { it.savedTracks(offset, limit) }
override suspend fun isSavedTrack(trackId: String): Boolean {
for (source in sources) {
if (!source.isAvailable) continue
try {
return source.isSavedTrack(trackId)
} catch (_: Exception) {
continue // Try next source
}
}
return false // Conservative: assume not saved if we can't verify
}
override suspend fun isSavedAlbum(albumId: String): Boolean {
for (source in sources) {
if (!source.isAvailable) continue
try {
return source.isSavedAlbum(albumId)
} catch (_: Exception) {
continue
}
}
return false
}
override suspend fun isSavedArtist(artistId: String): Boolean {
for (source in sources) {
if (!source.isAvailable) continue
try {
return source.isSavedArtist(artistId)
} catch (_: Exception) {
continue
}
}
return false
}
override suspend fun isSavedPlaylist(playlistId: String): Boolean {
for (source in sources) {
if (!source.isAvailable) continue
try {
return source.isSavedPlaylist(playlistId)
} catch (_: Exception) {
continue
}
}
return false
}
// Write operations: try each source until one succeeds
override suspend fun saveArtists(artistIds: List<String>) =
writeWithFallback { it.saveArtists(artistIds) }
override suspend fun unsaveArtists(artistIds: List<String>) =
writeWithFallback { it.unsaveArtists(artistIds) }
override suspend fun saveAlbums(albumIds: List<String>) =
writeWithFallback { it.saveAlbums(albumIds) }
override suspend fun unsaveAlbums(albumIds: List<String>) =
writeWithFallback { it.unsaveAlbums(albumIds) }
override suspend fun saveTracks(trackIds: List<String>) =
writeWithFallback { it.saveTracks(trackIds) }
override suspend fun unsaveTracks(trackIds: List<String>) =
writeWithFallback { it.unsaveTracks(trackIds) }
override suspend fun savePlaylists(playlistIds: List<String>) =
writeWithFallback { it.savePlaylists(playlistIds) }
override suspend fun unsavePlaylists(playlistIds: List<String>) =
writeWithFallback { it.unsavePlaylists(playlistIds) }
// Generic fallback for read operations
private suspend fun <T> withFallback(operation: (MetadataDataSource) -> T): T {
val errors = mutableListOf<Throwable>()
for (source in sources) {
if (!source.isAvailable) continue
try {
return operation(source)
} catch (e: Exception) {
errors.add(e)
// Silent log: repository decides fallback
}
}
throw MetadataRepositoryException("All metadata providers failed", errors)
}
// Generic fallback for write operations
private suspend fun writeWithFallback(operation: (MetadataDataSource) -> Unit) {
var lastError: Throwable? = null
for (source in sources) {
if (!source.isAvailable) continue
try {
operation(source)
return // Success
} catch (e: Exception) {
lastError = e
}
}
throw MetadataRepositoryException(
"All metadata providers failed on write operation",
lastError?.let { listOf(it) } ?: emptyList()
)
}
}
/**
* Delegating implementation for read-only properties.
*/
private class DelegatingMetadataDataSource(private val sources: List<MetadataDataSource>) : MetadataDataSource {
override val name: String = sources.joinToString(" + ") { it.name }
override val isAvailable: Boolean = sources.any { it.isAvailable }
override val strategy: MetadataSourceStrategy = sources.firstOrNull()?.strategy ?: MetadataSourceStrategy.PRIMARY_WITH_FALLBACK
override suspend fun search(query: String) = TODO()
override suspend fun savedArtists(offset: Int?, limit: Int?) = TODO()
override suspend fun savedAlbums(offset: Int?, limit: Int?) = TODO()
override suspend fun savedTracks(offset: Int?, limit: Int?) = TODO()
override suspend fun isSavedTrack(trackId: String) = TODO()
override suspend fun isSavedAlbum(albumId: String) = TODO()
override suspend fun isSavedArtist(artistId: String) = TODO()
override suspend fun isSavedPlaylist(playlistId: String) = TODO()
override suspend fun saveArtists(artistIds: List<String>) = TODO()
override suspend fun unsaveArtists(artistIds: List<String>) = TODO()
override suspend fun saveAlbums(albumIds: List<String>) = TODO()
override suspend fun unsaveAlbums(albumIds: List<String>) = TODO()
override suspend fun saveTracks(trackIds: List<String>) = TODO()
override suspend fun unsaveTracks(trackIds: List<String>) = TODO()
override suspend fun savePlaylists(playlistIds: List<String>) = TODO()
override suspend fun unsavePlaylists(playlistIds: List<String>) = TODO()
}

View File

@ -0,0 +1,72 @@
package org.spotube.spotube.metadata
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.spotube.spotube.resilience.ResiliencePipeline
import org.spotube.spotube.resilience.ResiliencePipelineConfig
/**
* MusicBrainz/ListenBrainz data source wrapping the Hetu plugin.
* Uses resilience pipeline for all API calls.
*
* This is the commonMain interface - actual implementation is in androidMain
* where the plugin is available via Hetu runtime.
*/
@ExperimentalCoroutinesApi
interface MusicBrainzDataSource : MetadataDataSource {
val resiliencePipeline: ResiliencePipeline
}
/**
* YouTube Audio data source for fallback search.
* Converts YouTube video search results to metadata objects.
*
* Based on DuranMusic's YouTubeAudioDataSource in `youtube_audio_data_source.dart`.
*/
@ExperimentalCoroutinesApi
interface YouTubeAudioDataSource : MetadataDataSource {
suspend fun searchVideos(query: String): List<YouTubeVideo>
}
data class YouTubeVideo(
val id: String,
val title: String,
val author: String,
val durationMs: Long?,
val viewCount: Long?,
val likeCount: Long?,
val isLive: Boolean,
val uploadDate: String?,
val description: String?
)
/**
* Factory for creating metadata sources with proper configuration.
*/
@ExperimentalCoroutinesApi
object MetadataSourceFactory {
/**
* Creates the default fallback chain:
* 1. MusicBrainz/ListenBrainz (primary)
* 2. YouTube Audio (fallback for search)
*/
fun createDefaultSources(
musicBrainzSource: MusicBrainzDataSource,
youTubeAudioSource: YouTubeAudioDataSource
): List<MetadataDataSource> {
return listOf(musicBrainzSource, youTubeAudioSource)
}
/**
* Creates a repository with the default fallback chain.
*/
fun createDefaultRepository(
musicBrainzSource: MusicBrainzDataSource,
youTubeAudioSource: YouTubeAudioDataSource
): MetadataRepository {
return MetadataRepository(
sources = createDefaultSources(musicBrainzSource, youTubeAudioSource),
strategy = MetadataSourceStrategy.PRIMARY_WITH_FALLBACK
)
}
}

View File

@ -0,0 +1,104 @@
package org.spotube.spotube.playback
import kotlinx.coroutines.ExperimentalCoroutinesApi
/**
* Synchronizes player position with MediaSession using generation counter.
*
* Prevents race conditions between concurrent seeks and position publications:
* - When a seek is initiated, a new generation counter is incremented
* - Position publications are delayed and checked against current generation
* - Stale publications (from older seeks) are discarded
*
* Critical for Android Auto where head unit seeks race with app-initiated seeks.
*
* Based on DuranMusic's MediaSessionSync in `media_session_sync.dart`.
*/
@ExperimentalCoroutinesApi
class MediaSessionSync(
private val player: PlayerController,
private val mediaSession: MediaSessionPublisher
) {
private var _syncGeneration = 0
private val lock = Any()
/**
* Publishes current position to MediaSession, clamped to duration.
* Discards if a newer seek has been initiated.
*/
suspend fun publishPosition() {
val generation = synchronized(lock) { ++_syncGeneration }
// Small delay to batch rapid position updates
kotlinx.coroutines.delay(200)
// Discard if stale (newer seek initiated)
synchronized(lock) {
if (generation != _syncGeneration) return
}
val state = player.currentState
val track = state.currentTrack
val duration = track?.durationMs.toLong() ?: 0L
val clamped = PositionClamp.clampPosition(player.position, duration)
// Publish clamped position
val updatedState = state.copy(
position = clamped,
duration = duration
)
mediaSession.publishPlaybackState(updatedState)
}
/**
* Executes a seek and publishes new position atomically.
* Discards if a newer seek has been initiated.
*/
suspend fun seek(position: Long) {
val generation = synchronized(lock) { ++_syncGeneration }
player.seekTo(position)
// Discard if stale
synchronized(lock) {
if (generation != _syncGeneration) return
}
await publishPosition()
}
/**
* Called when player state changes externally (e.g., track change).
* Publishes new state immediately.
*/
suspend fun onStateChanged() {
// Use a new generation to ensure this publishes
val generation = synchronized(lock) { ++_syncGeneration }
kotlinx.coroutines.delay(50)
synchronized(lock) {
if (generation != _syncGeneration) return
}
await publishPosition()
}
}
/**
* Interface for controlling player (seeking, position).
*/
@ExperimentalCoroutinesApi
interface PlayerController {
val currentState: PlaybackState
val position: Long
suspend fun seekTo(position: Long)
}
/**
* Interface for publishing to MediaSession.
*/
@ExperimentalCoroutinesApi
interface MediaSessionPublisher {
suspend fun publishPlaybackState(state: PlaybackState)
}

View File

@ -0,0 +1,76 @@
package org.spotube.spotube.playback
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.serialization.Serializable
/**
* Playback state model for MediaSession resilience utilities.
* Shared between commonMain and platform-specific implementations.
*/
@Serializable
data class PlaybackState(
val playing: Boolean = false,
val tracks: List<Track> = emptyList(),
val currentIndex: Int = -1,
val loopMode: LoopMode = LoopMode.OFF,
val shuffleMode: ShuffleMode = ShuffleMode.OFF,
val position: Long = 0, // milliseconds
val duration: Long = 0, // milliseconds
val bufferedPosition: Long = 0, // milliseconds
val speed: Float = 1.0f
) {
val currentTrack: Track? get() = if (currentIndex in tracks.indices) tracks[currentIndex] else null
val progress: Float get() = if (duration > 0) position.toFloat() / duration else 0f
}
@Serializable
data class Track(
val id: String,
val title: String,
val artist: String,
val album: String,
val durationMs: Long,
val artworkUrl: String? = null,
val externalUri: String = ""
)
enum class LoopMode {
OFF, // No repeat
ALL, // Repeat playlist/queue
ONE // Repeat current track
}
enum class ShuffleMode {
OFF, ON
}
/**
* Interface for reading player state (testable/mockable).
*/
@ExperimentalCoroutinesApi
interface PlayerStateReader {
val state: PlaybackState
val position: Long
}
/**
* Interface for controlling audio session (testable/mockable).
*/
@ExperimentalCoroutinesApi
interface AudioSessionController {
suspend fun pause()
suspend fun stop()
suspend fun seekTo(position: Long)
suspend fun skipToNext()
suspend fun skipToPrevious()
suspend fun setLoopMode(mode: LoopMode)
suspend fun setShuffleMode(mode: ShuffleMode)
}
/**
* MediaSession publisher interface for platform-specific implementations.
*/
@ExperimentalCoroutinesApi
interface MediaSessionPublisher {
suspend fun publishPlaybackState(state: PlaybackState)
}

View File

@ -0,0 +1,49 @@
package org.spotube.spotube.playback
/**
* Utility for clamping playback position to valid range.
*
* Prevents position drift in UI and MediaSession:
* - Position > duration (overrun)
* - Position < 0 (negative)
* - Duration <= 0 (invalid)
*
* Based on DuranMusic's PositionClamp in `position_clamp.dart`.
*/
object PositionClamp {
/**
* Clamps position to the range [0, duration].
* @param position Current playback position in milliseconds.
* @param duration Track duration in milliseconds.
* @return Clamped position in milliseconds.
*/
fun clampPosition(position: Long, duration: Long): Long {
return when {
duration <= 0 -> position
position < 0 -> 0
position > duration -> duration
else -> position
}
}
/**
* Clamps position as a percentage (0.0 - 1.0) relative to duration.
* @param position Current playback position in milliseconds.
* @param duration Track duration in milliseconds.
* @return Clamped percentage as Float (0.0f to 1.0f).
*/
fun clampPositionPercentage(position: Long, duration: Long): Float {
if (duration <= 0) return 0.0f
val clamped = clampPosition(position, duration)
return clamped.toFloat() / duration
}
/**
* Creates a safe progress value for UI progress bars.
* Ensures progress is always between 0.0 and 1.0.
*/
fun safeProgress(position: Long, duration: Long): Float {
return clampPositionPercentage(position, duration).coerceIn(0.0f, 1.0f)
}
}

View File

@ -0,0 +1,73 @@
package org.spotube.spotube.playback
import kotlinx.coroutines.ExperimentalCoroutinesApi
/**
* Detects and prevents "zombie sessions" in MediaSession.
*
* A zombie session occurs when MediaSession reports PLAYING state but:
* - No track is loaded (empty queue)
* - Position exceeds track duration (stale position)
*
* This leaves the app in an inconsistent state: UI says "playing" but no audio.
*
* Based on DuranMusic's ZombieSessionGuard in `zombie_session_guard.dart`.
* Call periodically or after player state changes.
*/
@ExperimentalCoroutinesApi
class ZombieSessionGuard(
private val player: PlayerStateReader,
private val audioService: AudioSessionController
) {
/**
* Checks for zombie session and corrects it by pausing/stopping.
* Returns true if a zombie session was detected and fixed.
*/
suspend fun checkAndFix(): Boolean {
// Atomic snapshot to avoid race conditions between state reads
val state = player.state
val playing = state.playing
val tracks = state.tracks
val currentIndex = state.currentIndex
val position = player.position
// Zombie: playing but no tracks loaded
if (playing && tracks.isEmpty()) {
audioService.pause()
audioService.stop()
return true
}
// Zombie: position exceeded track duration
if (currentIndex in tracks.indices) {
val track = tracks[currentIndex]
if (playing) {
val duration = track.durationMs
if (duration > 0 && position > duration) {
// Position overran track duration: possible stale session
audioService.pause()
return true
}
}
}
return false
}
/**
* Starts a periodic check coroutine.
* Call from a lifecycle-aware scope (e.g., viewModelScope).
*/
fun startPeriodicCheck(
intervalMillis: Long = 5000,
scope: kotlinx.coroutines.CoroutineScope
): kotlinx.coroutines.Job {
return scope.launch {
while (true) {
checkAndFix()
kotlinx.coroutines.delay(intervalMillis)
}
}
}
}

View File

@ -0,0 +1,195 @@
package org.spotube.spotube.resilience
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
/**
* Error classification for intelligent circuit breaker and fallback decisions.
*/
enum class ErrorKind {
UNKNOWN,
OFFLINE, // Network connectivity issues
SERVICE_UNAVAILABLE // 5xx, 429 (rate limit), 408 (timeout)
}
/**
* Circuit breaker states.
*/
enum class CircuitState {
CLOSED, // Normal operation, requests pass through
OPEN, // Failing fast, requests blocked
HALF_OPEN // Testing recovery, limited probes allowed
}
/**
* Configuration for CircuitBreaker.
* @param failureThreshold Consecutive failures before opening circuit.
* @param recoveryTimeout Time to wait before attempting half-open.
* @param halfOpenMaxAttempts Successful probes in half-open before closing.
*/
@ExperimentalTime
data class CircuitBreakerConfig(
val failureThreshold: Int = 5,
val recoveryTimeout: Duration = Duration.seconds(30),
val halfOpenMaxAttempts: Int = 3
) {
init {
require(failureThreshold > 0) { "failureThreshold must be positive" }
require(recoveryTimeout > Duration.ZERO) { "recoveryTimeout must be positive" }
require(halfOpenMaxAttempts > 0) { "halfOpenMaxAttempts must be positive" }
}
}
/**
* Circuit Breaker implementation for protecting against cascading failures.
*
* State transitions:
* - CLOSED → OPEN: After `failureThreshold` consecutive failures
* - OPEN → HALF_OPEN: After `recoveryTimeout` elapsed
* - HALF_OPEN → CLOSED: After `halfOpenMaxAttempts` successful probes
* - HALF_OPEN → OPEN: On any failure during half-open
* - Any state → CLOSED: On `reset()`
*
* Based on DuranMusic's manual CircuitBreaker in `rate_limiter.dart`.
* Parameters: 5 failures threshold, 30s recovery, 3 half-open probes.
*/
@ExperimentalTime
class CircuitBreaker(
private val config: CircuitBreakerConfig = CircuitBreakerConfig(),
private val clock: Clock = SystemClock
) {
private var _state = CircuitState.CLOSED
private var _consecutiveFailures = 0
private var _openedAt: Long = 0
private var _halfOpenSuccesses = 0
private var _lastErrorKind = ErrorKind.UNKNOWN
private val lock = Any()
val state: CircuitState
get() {
synchronized(lock) {
updateStateFromTime()
return _state
}
}
val lastErrorKind: ErrorKind
get() = _lastErrorKind
val consecutiveFailures: Int
get() = _consecutiveFailures
/**
* Checks if a request can proceed.
* Returns false when circuit is OPEN (blocking requests).
*/
fun canProceed(): Boolean {
synchronized(lock) {
updateStateFromTime()
return _state != CircuitState.OPEN
}
}
/**
* Records a successful request outcome.
*/
fun recordSuccess() {
synchronized(lock) {
updateStateFromTime()
when (_state) {
CircuitState.CLOSED -> {
_consecutiveFailures = 0
_lastErrorKind = ErrorKind.UNKNOWN
}
CircuitState.HALF_OPEN -> {
_halfOpenSuccesses++
if (_halfOpenSuccesses >= config.halfOpenMaxAttempts) {
closeCircuit()
}
}
CircuitState.OPEN -> {
// Should not happen if canProceed() is respected
}
}
}
}
/**
* Records a failed request outcome.
*/
fun recordFailure(errorKind: ErrorKind = ErrorKind.UNKNOWN) {
synchronized(lock) {
_lastErrorKind = errorKind
_consecutiveFailures++
when (_state) {
CircuitState.CLOSED -> {
if (_consecutiveFailures >= config.failureThreshold) {
openCircuit()
}
}
CircuitState.HALF_OPEN -> {
// Any failure in half-open immediately reopens
openCircuit()
}
CircuitState.OPEN -> {
// Already open, just update timestamp
_openedAt = clock.now()
}
}
}
}
/**
* Manually resets the circuit breaker to closed state.
*/
fun reset() {
synchronized(lock) {
_state = CircuitState.CLOSED
_consecutiveFailures = 0
_openedAt = 0
_halfOpenSuccesses = 0
_lastErrorKind = ErrorKind.UNKNOWN
}
}
private fun updateStateFromTime() {
if (_state == CircuitState.OPEN && _openedAt > 0) {
val elapsed = clock.now() - _openedAt
if (elapsed >= config.recoveryTimeout.toMilliseconds()) {
enterHalfOpen()
}
}
}
private fun openCircuit() {
_state = CircuitState.OPEN
_openedAt = clock.now()
_halfOpenSuccesses = 0
}
private fun enterHalfOpen() {
_state = CircuitState.HALF_OPEN
_openedAt = 0
_halfOpenSuccesses = 0
_consecutiveFailures = config.failureThreshold - 1 // Allow probes
}
private fun closeCircuit() {
_state = CircuitState.CLOSED
_consecutiveFailures = 0
_openedAt = 0
_halfOpenSuccesses = 0
_lastErrorKind = ErrorKind.UNKNOWN
}
companion object {
@ExperimentalTime
const val DEFAULT_MUSICBRAINZ_CONFIG = CircuitBreakerConfig(
failureThreshold = 5,
recoveryTimeout = Duration.seconds(30),
halfOpenMaxAttempts = 3
)
}
}

View File

@ -0,0 +1,99 @@
package org.spotube.spotube.resilience
import kotlinx.coroutines.ExperimentalCoroutinesApi
/**
* Classifies errors for intelligent fallback and circuit breaker decisions.
*
* Based on DuranMusic's ErrorClassifier in `rate_limiter.dart`.
* Classifies: OFFLINE (network), SERVICE_UNAVAILABLE (5xx, 429, 408), UNKNOWN
*/
@ExperimentalCoroutinesApi
object ErrorClassifier {
private val offlineHints = setOf(
"SocketException",
"Failed host lookup",
"Connection refused",
"Connection reset",
"Network is unreachable",
"connection error",
"Network error",
"timeout",
"TimeoutException",
"ConnectException",
"UnknownHostException",
"SSLHandshakeException"
)
private val serviceUnavailableHints = setOf(
"500", "501", "502", "503", "504", "505", "506", "507", "508", "509", "510", "511",
"429", "408"
)
/**
* Classifies a throwable into an ErrorKind.
*/
fun classify(throwable: Throwable): ErrorKind {
val message = throwable.message?.lowercase() ?: ""
val className = throwable.javaClass.simpleName.lowercase()
val fullText = "$message $className ${throwable.toString().lowercase()}"
// Check for offline indicators
for (hint in offlineHints) {
if (fullText.contains(hint.lowercase())) {
return ErrorKind.OFFLINE
}
}
// Check for service unavailable HTTP status codes in message
for (hint in serviceUnavailableHints) {
if (fullText.contains(hint)) {
return ErrorKind.SERVICE_UNAVAILABLE
}
}
// Check for Ktor HttpResponseException
if (throwable is HttpResponseException) {
return when {
throwable.status.value >= 500 -> ErrorKind.SERVICE_UNAVAILABLE
throwable.status.value == 429 -> ErrorKind.SERVICE_UNAVAILABLE
throwable.status.value == 408 -> ErrorKind.SERVICE_UNAVAILABLE
else -> ErrorKind.UNKNOWN
}
}
// Check for Ktor ClientRequestException (network errors)
if (throwable is ClientRequestException) {
return ErrorKind.OFFLINE
}
return ErrorKind.UNKNOWN
}
/**
* Classifies an HTTP status code.
*/
fun classifyStatusCode(statusCode: Int): ErrorKind {
return when {
statusCode >= 500 -> ErrorKind.SERVICE_UNAVAILABLE
statusCode == 429 -> ErrorKind.SERVICE_UNAVAILABLE
statusCode == 408 -> ErrorKind.SERVICE_UNAVAILABLE
statusCode == 0 -> ErrorKind.OFFLINE // Connection failed
else -> ErrorKind.UNKNOWN
}
}
}
// Type aliases for Ktor exceptions (to avoid direct dependency in commonMain)
@Suppress("UNUSED_PARAMETER")
private class HttpResponseException(
val status: HttpStatusCode,
val response: Any?
) : Exception("HTTP ${status.value}")
@Suppress("UNUSED_PARAMETER")
private class ClientRequestException(message: String, cause: Throwable? = null) : Exception(message, cause)
@Suppress("UNUSED_PARAMETER")
private class HttpStatusCode(val value: Int)

View File

@ -0,0 +1,66 @@
package org.spotube.spotube.resilience
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.ReceiveChannel
/**
* Interface for rate limiting implementations.
* Allows different rate limiting strategies (token bucket, fixed window, sliding window, etc.)
*/
@ExperimentalCoroutinesApi
interface RateLimiter {
/**
* Attempts to acquire a permit. Suspends until a permit is available.
* Returns true when a permit was acquired.
*/
suspend fun acquire(): Boolean
/**
* Attempts to acquire a permit without suspending.
* Returns true if a permit was immediately available and acquired.
*/
fun tryAcquire(): Boolean
/**
* Current number of available permits (for monitoring/debugging).
*/
val availablePermits: Double
/**
* Resets the rate limiter to its initial state.
*/
fun reset()
}
/**
* Configuration for TokenBucketRateLimiter.
* @param ratePerSecond Tokens added per second (sustained rate).
* @param burst Maximum bucket capacity (burst allowance).
*/
data class TokenBucketConfig(
val ratePerSecond: Double = 8.0,
val burst: Int = 16
) {
init {
require(ratePerSecond > 0) { "ratePerSecond must be positive" }
require(burst > 0) { "burst must be positive" }
}
}
/**
* Clock abstraction for testability.
*/
interface Clock {
fun now(): Long // epoch milliseconds
}
object SystemClock : Clock {
override fun now() = System.currentTimeMillis()
}
class TestClock(private var _time: Long = 0) : Clock {
override fun now() = _time
fun advance(millis: Long) { _time += millis }
fun setTime(millis: Long) { _time = millis }
}

View File

@ -0,0 +1,156 @@
package org.spotube.spotube.resilience
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.retry.RetryOnException
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
/**
* Configuration for the complete resilience pipeline.
*/
@ExperimentalTime
data class ResiliencePipelineConfig(
val rateLimiterConfig: TokenBucketConfig = TokenBucketConfig.DEFAULT_MUSICBRAINZ_CONFIG,
val circuitBreakerConfig: CircuitBreakerConfig = CircuitBreakerConfig.DEFAULT_MUSICBRAINZ_CONFIG,
val retryConfig: RetryConfig = RetryConfig.DEFAULT
)
/**
* Retry configuration with exponential backoff and jitter.
*/
@ExperimentalTime
data class RetryConfig(
val maxAttempts: Int = 3,
val initialDelay: Duration = Duration.milliseconds(400),
val multiplier: Double = 2.0,
val maxDelay: Duration = Duration.seconds(10)
) {
init {
require(maxAttempts > 0) { "maxAttempts must be positive" }
require(initialDelay > Duration.ZERO) { "initialDelay must be positive" }
require(multiplier >= 1.0) { "multiplier must be >= 1.0" }
require(maxDelay >= initialDelay) { "maxDelay must be >= initialDelay" }
}
companion object {
const val DEFAULT = RetryConfig()
}
}
/**
* Complete resilience pipeline combining:
* 1. Rate Limiter (Token Bucket)
* 2. Circuit Breaker
* 3. Retry with Exponential Backoff + Jitter
* 4. Error Classification
*
* Based on DuranMusic's dio_resilience pipeline in `musicbrainz_rate_limiter.dart`.
* Usage:
* ```kotlin
* val pipeline = ResiliencePipeline(ResiliencePipelineConfig.DEFAULT_MUSICBRAINZ)
* val result = pipeline.execute { apiCall() }
* ```
*/
@ExperimentalTime
@ExperimentalCoroutinesApi
class ResiliencePipeline(
private val config: ResiliencePipelineConfig = ResiliencePipelineConfig(),
private val clock: Clock = SystemClock
) {
private val rateLimiter = TokenBucketRateLimiter(config.rateLimiterConfig, clock)
private val circuitBreaker = CircuitBreaker(config.circuitBreakerConfig, clock)
/**
* Executes a suspending function with full resilience pipeline.
* - Rate limits the call
* - Checks circuit breaker
* - Retries with exponential backoff + jitter
* - Records success/failure in circuit breaker
*/
suspend fun <T> execute(block: suspend () -> T): T {
// Rate limiting
rateLimiter.acquire()
// Circuit breaker check
if (!circuitBreaker.canProceed()) {
throw CircuitBreakerOpenException("Circuit breaker is OPEN")
}
// Retry with exponential backoff + jitter
return retryWithBackoff(block)
}
/**
* Executes without rate limiting (for ListenBrainz which has different limits).
* Still applies circuit breaker and retry.
*/
suspend fun <T> executeWithoutRateLimit(block: suspend () -> T): T {
if (!circuitBreaker.canProceed()) {
throw CircuitBreakerOpenException("Circuit breaker is OPEN")
}
return retryWithBackoff(block)
}
private suspend fun <T> retryWithBackoff(block: suspend () -> T): T {
var attempt = 0
var lastException: Throwable? = null
while (true) {
attempt++
try {
val result = block()
circuitBreaker.recordSuccess()
return result
} catch (e: Exception) {
lastException = e
val errorKind = ErrorClassifier.classify(e)
circuitBreaker.recordFailure(errorKind)
if (attempt >= config.retryConfig.maxAttempts) {
throw e
}
// Exponential backoff with jitter
val delay = calculateDelay(attempt)
kotlinx.coroutines.delay(delay)
}
}
}
private fun calculateDelay(attempt: Int): Duration {
val baseDelay = config.retryConfig.initialDelay * (config.retryConfig.multiplier.pow(attempt - 1))
val cappedDelay = kotlin.math.min(baseDelay, config.retryConfig.maxDelay)
// Add jitter: random between 0 and delay
val jitter = (Math.random() * cappedDelay.toMilliseconds()).toLong()
return Duration.milliseconds(jitter)
}
// Expose components for monitoring/testing
val rateLimiterComponent: RateLimiter = rateLimiter
val circuitBreakerComponent: CircuitBreaker = circuitBreaker
}
/**
* Exception thrown when circuit breaker is open.
*/
class CircuitBreakerOpenException(message: String) : Exception(message)
/**
* Default configuration for MusicBrainz/ListenBrainz.
*/
@ExperimentalTime
object ResiliencePipelineConfig {
const val DEFAULT_MUSICBRAINZ = ResiliencePipelineConfig(
rateLimiterConfig = TokenBucketConfig.DEFAULT_MUSICBRAINZ_CONFIG,
circuitBreakerConfig = CircuitBreakerConfig.DEFAULT_MUSICBRAINZ_CONFIG,
retryConfig = RetryConfig.DEFAULT
)
const val DEFAULT_LISTENBRAINZ = ResiliencePipelineConfig(
rateLimiterConfig = TokenBucketConfig(ratePerSecond = 10.0, burst = 20), // Higher limits for LB
circuitBreakerConfig = CircuitBreakerConfig.DEFAULT_MUSICBRAINZ_CONFIG,
retryConfig = RetryConfig.DEFAULT
)
}

View File

@ -0,0 +1,97 @@
package org.spotube.spotube.resilience
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
/**
* Thread-safe Token Bucket Rate Limiter implementation.
*
* Algorithm:
* - Bucket starts full with `burst` tokens
* - Tokens are refilled at `ratePerSecond` tokens/second
* - Each `acquire()` consumes 1 token
* - If bucket is empty, caller suspends until a token is available
*
* Based on DuranMusic's `musicbrainz_rate_limiter.dart` using dio_resilience.
* Parameters: 8 req/s sustained, burst 16 (shared singleton for MusicBrainz/ListenBrainz).
*/
@ExperimentalCoroutinesApi
class TokenBucketRateLimiter(
private val config: TokenBucketConfig,
private val clock: Clock = SystemClock
) : RateLimiter {
private var _tokens: Double = config.burst.toDouble()
private var _lastRefill: Long = clock.now()
private val lock = Any()
override suspend fun acquire(): Boolean {
synchronized(lock) {
refill()
if (_tokens >= 1.0) {
_tokens -= 1.0
return true
}
}
// Wait for next token refill
val waitTime = calculateWaitTime()
delay(waitTime)
return acquire() // Tail recursion, will succeed after refill
}
override fun tryAcquire(): Boolean {
synchronized(lock) {
refill()
if (_tokens >= 1.0) {
_tokens -= 1.0
return true
}
return false
}
}
override val availablePermits: Double
get() {
synchronized(lock) {
refill()
return _tokens
}
}
override fun reset() {
synchronized(lock) {
_tokens = config.burst.toDouble()
_lastRefill = clock.now()
}
}
private fun refill() {
val now = clock.now()
val elapsedSeconds = (now - _lastRefill) / 1000.0
if (elapsedSeconds > 0) {
val newTokens = _tokens + elapsedSeconds * config.ratePerSecond
_tokens = kotlin.math.min(newTokens, config.burst.toDouble())
_lastRefill = now
}
}
private fun calculateWaitTime(): Long {
synchronized(lock) {
// Time until 1 token is available
val tokensNeeded = 1.0 - _tokens
val secondsNeeded = tokensNeeded / config.ratePerSecond
return (secondsNeeded * 1000).toLong().coerceAtLeast(1)
}
}
companion object {
/**
* Default configuration matching DuranMusic's MusicBrainz rate limiter:
* 8 requests/second sustained, burst of 16.
*/
const val DEFAULT_MUSICBRAINZ_CONFIG = TokenBucketConfig(ratePerSecond = 8.0, burst = 16)
}
}

View File

@ -0,0 +1,94 @@
package org.spotube.spotube.artwork
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@ExperimentalCoroutinesApi
class ArtworkCacheServiceTest {
private val testConfig = ArtworkCacheConfig(
allowedHosts = setOf(
"coverartarchive.org",
"i.scdn.co",
"example.com"
)
)
private val service = TestArtworkCacheService(testConfig)
@Test
fun `allows HTTPS URLs from allowed hosts`() = runTest {
assertTrue(service.isValidArtworkUrl("https://coverartarchive.org/cover.jpg"))
assertTrue(service.isValidArtworkUrl("https://i.scdn.co/image/abc"))
assertTrue(service.isValidArtworkUrl("https://example.com/image.png"))
}
@Test
fun `rejects HTTP URLs`() = runTest {
assertFalse(service.isValidArtworkUrl("http://example.com/image.jpg"))
assertFalse(service.isValidArtworkUrl("http://coverartarchive.org/image.jpg"))
}
@Test
fun `rejects non-allowed HTTPS hosts`() = runTest {
assertFalse(service.isValidArtworkUrl("https://evil.com/image.jpg"))
assertFalse(service.isValidArtworkUrl("https://malicious-site.org/image.jpg"))
}
@Test
fun `allows Android content URIs`() = runTest {
assertTrue(service.isValidArtworkUrl("content://media/external/images/123"))
assertTrue(service.isValidArtworkUrl("content://com.android.providers.media.documents/document/image%3A456"))
}
@Test
fun `rejects empty or null URLs`() = runTest {
assertFalse(service.isValidArtworkUrl(null))
assertFalse(service.isValidArtworkUrl(""))
assertFalse(service.isValidArtworkUrl(" "))
}
@Test
fun `rejects URLs without scheme`() = runTest {
assertFalse(service.isValidArtworkUrl("example.com/image.jpg"))
assertFalse(service.isValidArtworkUrl("coverartarchive.org/image.jpg"))
}
@Test
fun `rejects malformed URLs`() = runTest {
assertFalse(service.isValidArtworkUrl("not-a-url"))
assertFalse(service.isValidArtworkUrl("ftp://example.com/image.jpg"))
assertFalse(service.isValidArtworkUrl("file:///path/to/image.jpg"))
}
}
/**
* Test implementation of ArtworkCacheService for unit testing.
*/
@ExperimentalCoroutinesApi
class TestArtworkCacheService(
override val config: ArtworkCacheConfig
) : ArtworkCacheService {
override fun isValidArtworkUrl(url: String?): Boolean {
if (url.isNullOrEmpty()) return false
return try {
val uri = java.net.URI.create(url)
if (uri.scheme == "content") return true
if (uri.scheme != "https") return false
config.allowedHosts.contains(uri.host)
} catch (_: Exception) {
false
}
}
override suspend fun preloadArtwork(url: String?) { }
override suspend fun cleanupExpired() { }
override suspend fun clearCache() { }
}

View File

@ -0,0 +1,136 @@
package org.spotube.spotube.metadata
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.test.assertFailsWith
import kotlin.test.mockk.every
import kotlin.test.mockk.mockk
import kotlin.test.mockk.verify
import kotlin.test.mockk.exactly
@ExperimentalCoroutinesApi
class MetadataRepositoryTest {
@Test
fun `falls back to second source when first fails`() = runTest {
val primary = mockk<MetadataDataSource> {
every { isAvailable } returns true
every { name } returns "Primary"
}
val fallback = mockk<MetadataDataSource> {
every { isAvailable } returns true
every { name } returns "Fallback"
}
every { primary.search("test") } throws Exception("Primary down")
every { fallback.search("test") } returns SpotubeSearchResponse()
val repo = MetadataRepository(listOf(primary, fallback))
val result = repo.search("test")
assertEquals(emptyList(), result.tracks)
verify(exactly = 1) { primary.search("test") }
verify(exactly = 1) { fallback.search("test") }
}
@Test
fun `throws aggregate exception when all sources fail`() = runTest {
val primary = mockk<MetadataDataSource> {
every { isAvailable } returns true
every { name } returns "Primary"
}
val fallback = mockk<MetadataDataSource> {
every { isAvailable } returns true
every { name } returns "Fallback"
}
every { primary.search("test") } throws Exception("Primary down")
every { fallback.search("test") } throws Exception("Fallback down")
val repo = MetadataRepository(listOf(primary, fallback))
assertFailsWith<MetadataRepositoryException> { repo.search("test") }
}
@Test
fun `skips unavailable sources`() = runTest {
val unavailable = mockk<MetadataDataSource> {
every { isAvailable } returns false
every { name } returns "Unavailable"
}
val available = mockk<MetadataDataSource> {
every { isAvailable } returns true
every { name } returns "Available"
}
every { available.search("test") } returns SpotubeSearchResponse()
val repo = MetadataRepository(listOf(unavailable, available))
repo.search("test")
verify(exactly = 0) { unavailable.search(any()) }
verify(exactly = 1) { available.search("test") }
}
@Test
fun `write operations try each source until success`() = runTest {
val primary = mockk<MetadataDataSource> {
every { isAvailable } returns true
every { name } returns "Primary"
}
val fallback = mockk<MetadataDataSource> {
every { isAvailable } returns true
every { name } returns "Fallback"
}
every { primary.saveArtists(listOf("artist1")) } throws Exception("Primary write failed")
every { fallback.saveArtists(listOf("artist1")) } just Runs
val repo = MetadataRepository(listOf(primary, fallback))
repo.saveArtists(listOf("artist1"))
verify(exactly = 1) { primary.saveArtists(listOf("artist1")) }
verify(exactly = 1) { fallback.saveArtists(listOf("artist1")) }
}
@Test
fun `isSavedTrack returns false if all sources fail`() = runTest {
val primary = mockk<MetadataDataSource> {
every { isAvailable } returns true
}
val fallback = mockk<MetadataDataSource> {
every { isAvailable } returns true
}
every { primary.isSavedTrack("track1") } throws Exception("Primary down")
every { fallback.isSavedTrack("track1") } throws Exception("Fallback down")
val repo = MetadataRepository(listOf(primary, fallback))
val result = repo.isSavedTrack("track1")
assertFalse(result)
}
@Test
fun `uses strategy from first source`() = runTest {
val primary = mockk<MetadataDataSource> {
every { isAvailable } returns true
every { name } returns "Primary"
every { strategy } returns MetadataSourceStrategy.FASTEST_FIRST
}
val fallback = mockk<MetadataDataSource> {
every { isAvailable } returns true
every { name } returns "Fallback"
every { strategy } returns MetadataSourceStrategy.PRIMARY_ONLY
}
val repo = MetadataRepository(listOf(primary, fallback))
assertEquals(MetadataSourceStrategy.FASTEST_FIRST, repo.strategy)
}
}

View File

@ -0,0 +1,145 @@
package org.spotube.spotube.playback
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestClock
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.mockk.every
import kotlin.test.mockk.mockk
import kotlin.test.mockk.verify
import kotlin.test.mockk.exactly
@ExperimentalCoroutinesApi
class MediaSessionSyncTest {
@Test
fun `publishes position after delay`() = runTest {
val testClock = TestClock()
val player = mockk<PlayerController> {
every { currentState } returns PlaybackState(
playing = true,
tracks = listOf(Track(id = "1", title = "Test", artist = "A", album = "Alb", durationMs = 10000)),
currentIndex = 0,
position = 5000
)
every { position } returns 5000L
}
val mediaSession = mockk<MediaSessionPublisher>()
val sync = MediaSessionSync(player, mediaSession)
sync.publishPosition()
// Advance time past delay
testClock.advanceTime(300)
verify(exactly = 1) { mediaSession.publishPlaybackState(any()) }
}
@Test
fun `discards stale publish when new seek initiated`() = runTest {
val testClock = TestClock()
val player = mockk<PlayerController> {
every { currentState } returns PlaybackState(
playing = true,
tracks = listOf(Track(id = "1", title = "Test", artist = "A", album = "Alb", durationMs = 10000)),
currentIndex = 0
)
every { position } returns 5000L
}
val mediaSession = mockk<MediaSessionPublisher>()
val sync = MediaSessionSync(player, mediaSession)
// Start first publish
val job1 = kotlinx.coroutines.launch { sync.publishPosition() }
// Immediately start a seek (new generation)
sync.seek(8000)
// Advance time
testClock.advanceTime(300)
// First publish should be discarded, second should happen
verify(exactly = 1) { mediaSession.publishPlaybackState(any()) }
job1.cancel()
}
@Test
fun `seek executes and publishes`() = runTest {
val testClock = TestClock()
val player = mockk<PlayerController> {
every { currentState } returns PlaybackState(
playing = true,
tracks = listOf(Track(id = "1", title = "Test", artist = "A", album = "Alb", durationMs = 10000)),
currentIndex = 0
)
every { position } returns 5000L
every { seekTo(any()) } just Runs
}
val mediaSession = mockk<MediaSessionPublisher>()
val sync = MediaSessionSync(player, mediaSession)
sync.seek(8000)
testClock.advanceTime(300)
verify(exactly = 1) { player.seekTo(8000L) }
verify(exactly = 1) { mediaSession.publishPlaybackState(any()) }
}
@Test
fun `discards stale seek when newer seek initiated`() = runTest {
val testClock = TestClock()
val player = mockk<PlayerController> {
every { currentState } returns PlaybackState(
playing = true,
tracks = listOf(Track(id = "1", title = "Test", artist = "A", album = "Alb", durationMs = 10000)),
currentIndex = 0
)
every { position } returns 5000L
every { seekTo(any()) } just Runs
}
val mediaSession = mockk<MediaSessionPublisher>()
val sync = MediaSessionSync(player, mediaSession)
// Start first seek
val job1 = kotlinx.coroutines.launch { sync.seek(3000) }
// Immediately start second seek
sync.seek(8000)
testClock.advanceTime(300)
// First seek should be discarded (but second executes)
verify(exactly = 1) { player.seekTo(8000L) }
job1.cancel()
}
@Test
fun `onStateChanged publishes immediately`() = runTest {
val testClock = TestClock()
val player = mockk<PlayerController> {
every { currentState } returns PlaybackState(
playing = true,
tracks = listOf(Track(id = "1", title = "Test", artist = "A", album = "Alb", durationMs = 10000)),
currentIndex = 0,
position = 5000
)
every { position } returns 5000L
}
val mediaSession = mockk<MediaSessionPublisher>()
val sync = MediaSessionSync(player, mediaSession)
sync.onStateChanged()
testClock.advanceTime(100)
verify(exactly = 1) { mediaSession.publishPlaybackState(any()) }
}
}

View File

@ -0,0 +1,43 @@
package org.spotube.spotube.playback
import kotlin.test.Test
import kotlin.test.assertEquals
class PositionClampTest {
@Test
fun `clamps position to duration when over`() {
assertEquals(10000L, PositionClamp.clampPosition(15000, 10000))
assertEquals(5000L, PositionClamp.clampPosition(5000, 10000))
assertEquals(10000L, PositionClamp.clampPosition(10000, 10000))
}
@Test
fun `clamps negative position to zero`() {
assertEquals(0L, PositionClamp.clampPosition(-100, 10000))
assertEquals(0L, PositionClamp.clampPosition(-1, 10000))
}
@Test
fun `returns position unchanged when duration invalid`() {
assertEquals(-100L, PositionClamp.clampPosition(-100, 0))
assertEquals(5000L, PositionClamp.clampPosition(5000, 0))
assertEquals(15000L, PositionClamp.clampPosition(15000, -1))
}
@Test
fun `clamps percentage correctly`() {
assertEquals(0.5f, PositionClamp.clampPositionPercentage(5000, 10000))
assertEquals(1.0f, PositionClamp.clampPositionPercentage(15000, 10000)) // Clamped to 1.0
assertEquals(0.0f, PositionClamp.clampPositionPercentage(-100, 10000)) // Clamped to 0.0
assertEquals(0.0f, PositionClamp.clampPositionPercentage(5000, 0)) // Invalid duration
}
@Test
fun `safeProgress always returns 0.0 to 1.0`() {
assertEquals(0.0f, PositionClamp.safeProgress(-100, 10000))
assertEquals(0.5f, PositionClamp.safeProgress(5000, 10000))
assertEquals(1.0f, PositionClamp.safeProgress(15000, 10000))
assertEquals(0.0f, PositionClamp.safeProgress(5000, 0))
}
}

View File

@ -0,0 +1,118 @@
package org.spotube.spotube.playback
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestClock
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.test.mockk.every
import kotlin.test.mockk.mockk
import kotlin.test.mockk.verify
import kotlin.test.mockk.exactly
@ExperimentalCoroutinesApi
class ZombieSessionGuardTest {
@Test
fun `detects zombie: playing with empty tracks`() = runTest {
val player = mockk<PlayerStateReader> {
every { state } returns PlaybackState(playing = true, tracks = emptyList())
every { position } returns 0L
}
val audioService = mockk<AudioSessionController>()
val guard = ZombieSessionGuard(player, audioService)
val fixed = guard.checkAndFix()
assertTrue(fixed)
verify(exactly = 1) { audioService.pause() }
verify(exactly = 1) { audioService.stop() }
}
@Test
fun `detects zombie: position exceeds duration`() = runTest {
val track = Track(id = "1", title = "Test", artist = "Artist", album = "Album", durationMs = 10000)
val player = mockk<PlayerStateReader> {
every { state } returns PlaybackState(
playing = true,
tracks = listOf(track),
currentIndex = 0,
position = 15000 // 15s > 10s duration
)
every { position } returns 15000L
}
val audioService = mockk<AudioSessionController>()
val guard = ZombieSessionGuard(player, audioService)
val fixed = guard.checkAndFix()
assertTrue(fixed)
verify(exactly = 1) { audioService.pause() }
verify(exactly = 0) { audioService.stop() } // Only pause for position overrun
}
@Test
fun `no zombie when not playing`() = runTest {
val track = Track(id = "1", title = "Test", artist = "Artist", album = "Album", durationMs = 10000)
val player = mockk<PlayerStateReader> {
every { state } returns PlaybackState(
playing = false,
tracks = listOf(track),
currentIndex = 0,
position = 15000
)
every { position } returns 15000L
}
val audioService = mockk<AudioSessionController>()
val guard = ZombieSessionGuard(player, audioService)
val fixed = guard.checkAndFix()
assertFalse(fixed)
verify(exactly = 0) { audioService.pause() }
verify(exactly = 0) { audioService.stop() }
}
@Test
fun `no zombie when position within duration`() = runTest {
val track = Track(id = "1", title = "Test", artist = "Artist", album = "Album", durationMs = 20000)
val player = mockk<PlayerStateReader> {
every { state } returns PlaybackState(
playing = true,
tracks = listOf(track),
currentIndex = 0,
position = 15000 // 15s < 20s duration
)
every { position } returns 15000L
}
val audioService = mockk<AudioSessionController>()
val guard = ZombieSessionGuard(player, audioService)
val fixed = guard.checkAndFix()
assertFalse(fixed)
}
@Test
fun `no zombie when currentIndex out of bounds`() = runTest {
val player = mockk<PlayerStateReader> {
every { state } returns PlaybackState(
playing = true,
tracks = emptyList(),
currentIndex = 5, // Out of bounds
position = 1000
)
every { position } returns 1000L
}
val audioService = mockk<AudioSessionController>()
val guard = ZombieSessionGuard(player, audioService)
val fixed = guard.checkAndFix()
// Should detect empty tracks zombie
assertTrue(fixed)
}
}

View File

@ -0,0 +1,129 @@
package org.spotube.spotube.resilience
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestClock
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
@ExperimentalCoroutinesApi
@ExperimentalTime
class CircuitBreakerTest {
@Test
fun `opens after threshold failures`() = runTest {
val cb = CircuitBreaker(CircuitBreakerConfig(failureThreshold = 3))
repeat(2) { cb.recordFailure(ErrorKind.SERVICE_UNAVAILABLE) }
assertEquals(CircuitState.CLOSED, cb.state)
assertTrue(cb.canProceed())
cb.recordFailure(ErrorKind.SERVICE_UNAVAILABLE) // 3rd failure
assertEquals(CircuitState.OPEN, cb.state)
assertFalse(cb.canProceed())
}
@Test
fun `enters half-open after recovery timeout`() = runTest {
val testClock = TestClock()
val cb = CircuitBreaker(
CircuitBreakerConfig(
failureThreshold = 2,
recoveryTimeout = Duration.seconds(10)
),
testClock
)
repeat(2) { cb.recordFailure(ErrorKind.SERVICE_UNAVAILABLE) }
assertEquals(CircuitState.OPEN, cb.state)
// Advance past recovery timeout
testClock.advanceTime(Duration.seconds(11))
assertEquals(CircuitState.HALF_OPEN, cb.state)
assertTrue(cb.canProceed())
}
@Test
fun `closes after successful half-open probes`() = runTest {
val testClock = TestClock()
val cb = CircuitBreaker(
CircuitBreakerConfig(
failureThreshold = 2,
recoveryTimeout = Duration.seconds(10),
halfOpenMaxAttempts = 2
),
testClock
)
repeat(2) { cb.recordFailure(ErrorKind.SERVICE_UNAVAILABLE) }
testClock.advanceTime(Duration.seconds(11)) // → HALF_OPEN
cb.recordSuccess() // 1
assertEquals(CircuitState.HALF_OPEN, cb.state)
cb.recordSuccess() // 2 → CLOSED
assertEquals(CircuitState.CLOSED, cb.state)
assertTrue(cb.canProceed())
}
@Test
fun `failure in half-open reopens immediately`() = runTest {
val testClock = TestClock()
val cb = CircuitBreaker(
CircuitBreakerConfig(
failureThreshold = 2,
recoveryTimeout = Duration.seconds(10),
halfOpenMaxAttempts = 3
),
testClock
)
repeat(2) { cb.recordFailure(ErrorKind.SERVICE_UNAVAILABLE) }
testClock.advanceTime(Duration.seconds(11)) // → HALF_OPEN
cb.recordFailure(ErrorKind.SERVICE_UNAVAILABLE) // Failure in half-open
assertEquals(CircuitState.OPEN, cb.state)
assertFalse(cb.canProceed())
}
@Test
fun `reset closes circuit`() = runTest {
val cb = CircuitBreaker(CircuitBreakerConfig(failureThreshold = 2))
repeat(2) { cb.recordFailure(ErrorKind.SERVICE_UNAVAILABLE) }
assertEquals(CircuitState.OPEN, cb.state)
cb.reset()
assertEquals(CircuitState.CLOSED, cb.state)
assertTrue(cb.canProceed())
assertEquals(0, cb.consecutiveFailures)
}
@Test
fun `success in closed resets failure count`() = runTest {
val cb = CircuitBreaker(CircuitBreakerConfig(failureThreshold = 3))
cb.recordFailure(ErrorKind.SERVICE_UNAVAILABLE)
cb.recordFailure(ErrorKind.SERVICE_UNAVAILABLE)
assertEquals(2, cb.consecutiveFailures)
cb.recordSuccess()
assertEquals(0, cb.consecutiveFailures)
}
@Test
fun `classifies error kind on failure`() = runTest {
val cb = CircuitBreaker(CircuitBreakerConfig(failureThreshold = 2))
cb.recordFailure(ErrorKind.OFFLINE)
assertEquals(ErrorKind.OFFLINE, cb.lastErrorKind)
cb.recordFailure(ErrorKind.SERVICE_UNAVAILABLE)
assertEquals(ErrorKind.SERVICE_UNAVAILABLE, cb.lastErrorKind)
}
}

View File

@ -0,0 +1,53 @@
package org.spotube.spotube.resilience
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlin.test.Test
import kotlin.test.assertEquals
@ExperimentalCoroutinesApi
class ErrorClassifierTest {
@Test
fun `classifies offline errors`() {
assertEquals(ErrorKind.OFFLINE, ErrorClassifier.classify(Exception("SocketException: Connection failed")))
assertEquals(ErrorKind.OFFLINE, ErrorClassifier.classify(Exception("Failed host lookup")))
assertEquals(ErrorKind.OFFLINE, ErrorClassifier.classify(Exception("Connection refused")))
assertEquals(ErrorKind.OFFLINE, ErrorClassifier.classify(Exception("Connection reset")))
assertEquals(ErrorKind.OFFLINE, ErrorClassifier.classify(Exception("Network is unreachable")))
assertEquals(ErrorKind.OFFLINE, ErrorClassifier.classify(Exception("connection error")))
assertEquals(ErrorKind.OFFLINE, ErrorClassifier.classify(Exception("Network error")))
assertEquals(ErrorKind.OFFLINE, ErrorClassifier.classify(Exception("timeout")))
assertEquals(ErrorKind.OFFLINE, ErrorClassifier.classify(Exception("TimeoutException")))
assertEquals(ErrorKind.OFFLINE, ErrorClassifier.classify(Exception("ConnectException")))
assertEquals(ErrorKind.OFFLINE, ErrorClassifier.classify(Exception("UnknownHostException")))
assertEquals(ErrorKind.OFFLINE, ErrorClassifier.classify(Exception("SSLHandshakeException")))
}
@Test
fun `classifies service unavailable errors`() {
assertEquals(ErrorKind.SERVICE_UNAVAILABLE, ErrorClassifier.classify(Exception("HTTP 500")))
assertEquals(ErrorKind.SERVICE_UNAVAILABLE, ErrorClassifier.classify(Exception("HTTP 502")))
assertEquals(ErrorKind.SERVICE_UNAVAILABLE, ErrorClassifier.classify(Exception("HTTP 503")))
assertEquals(ErrorKind.SERVICE_UNAVAILABLE, ErrorClassifier.classify(Exception("HTTP 504")))
assertEquals(ErrorKind.SERVICE_UNAVAILABLE, ErrorClassifier.classify(Exception("status code of 429")))
assertEquals(ErrorKind.SERVICE_UNAVAILABLE, ErrorClassifier.classify(Exception("status code of 408")))
}
@Test
fun `classifies unknown errors`() {
assertEquals(ErrorKind.UNKNOWN, ErrorClassifier.classify(Exception("Some random error")))
assertEquals(ErrorKind.UNKNOWN, ErrorClassifier.classify(Exception("HTTP 400")))
assertEquals(ErrorKind.UNKNOWN, ErrorClassifier.classify(Exception("HTTP 404")))
}
@Test
fun `classifies status codes`() {
assertEquals(ErrorKind.SERVICE_UNAVAILABLE, ErrorClassifier.classifyStatusCode(500))
assertEquals(ErrorKind.SERVICE_UNAVAILABLE, ErrorClassifier.classifyStatusCode(503))
assertEquals(ErrorKind.SERVICE_UNAVAILABLE, ErrorClassifier.classifyStatusCode(429))
assertEquals(ErrorKind.SERVICE_UNAVAILABLE, ErrorClassifier.classifyStatusCode(408))
assertEquals(ErrorKind.OFFLINE, ErrorClassifier.classifyStatusCode(0))
assertEquals(ErrorKind.UNKNOWN, ErrorClassifier.classifyStatusCode(400))
assertEquals(ErrorKind.UNKNOWN, ErrorClassifier.classifyStatusCode(404))
}
}

View File

@ -0,0 +1,163 @@
package org.spotube.spotube.resilience
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestClock
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
@ExperimentalCoroutinesApi
@ExperimentalTime
class ResiliencePipelineTest {
@Test
fun `executes successfully on first attempt`() = runTest {
val testClock = TestClock()
val pipeline = ResiliencePipeline(
ResiliencePipelineConfig.DEFAULT_MUSICBRAINZ,
testClock
)
var callCount = 0
val result = pipeline.execute {
callCount++
"success"
}
assertEquals("success", result)
assertEquals(1, callCount)
}
@Test
fun `retries on failure and succeeds`() = runTest {
val testClock = TestClock()
val pipeline = ResiliencePipeline(
ResiliencePipelineConfig(
rateLimiterConfig = TokenBucketConfig(ratePerSecond = 1000.0, burst = 1000),
circuitBreakerConfig = CircuitBreakerConfig(failureThreshold = 10),
retryConfig = RetryConfig(maxAttempts = 3, initialDelay = Duration.milliseconds(10))
),
testClock
)
var callCount = 0
val result = pipeline.execute {
callCount++
if (callCount < 2) throw Exception("HTTP 503")
"success"
}
assertEquals("success", result)
assertEquals(2, callCount)
}
@Test
fun `throws after max retries exhausted`() = runTest {
val testClock = TestClock()
val pipeline = ResiliencePipeline(
ResiliencePipelineConfig(
rateLimiterConfig = TokenBucketConfig(ratePerSecond = 1000.0, burst = 1000),
circuitBreakerConfig = CircuitBreakerConfig(failureThreshold = 10),
retryConfig = RetryConfig(maxAttempts = 3, initialDelay = Duration.milliseconds(10))
),
testClock
)
var callCount = 0
var caught = false
try {
pipeline.execute {
callCount++
throw Exception("HTTP 503")
}
} catch (e: Exception) {
caught = true
}
assertTrue(caught)
assertEquals(3, callCount)
}
@Test
fun `rate limits calls`() = runTest {
val testClock = TestClock()
val pipeline = ResiliencePipeline(
ResiliencePipelineConfig(
rateLimiterConfig = TokenBucketConfig(ratePerSecond = 10.0, burst = 2),
circuitBreakerConfig = CircuitBreakerConfig(failureThreshold = 10),
retryConfig = RetryConfig(maxAttempts = 1)
),
testClock
)
var callCount = 0
repeat(3) {
pipeline.executeWithoutRateLimit {
callCount++
"ok"
}
}
assertEquals(3, callCount)
}
@Test
fun `opens circuit breaker on repeated failures`() = runTest {
val testClock = TestClock()
val pipeline = ResiliencePipeline(
ResiliencePipelineConfig(
rateLimiterConfig = TokenBucketConfig(ratePerSecond = 1000.0, burst = 1000),
circuitBreakerConfig = CircuitBreakerConfig(failureThreshold = 2),
retryConfig = RetryConfig(maxAttempts = 1)
),
testClock
)
repeat(2) {
try { pipeline.execute { throw Exception("HTTP 503") } } catch (_) {}
}
assertEquals(CircuitState.OPEN, pipeline.circuitBreakerComponent.state)
var caught = false
try {
pipeline.execute { "should not reach" }
} catch (e: CircuitBreakerOpenException) {
caught = true
}
assertTrue(caught)
}
@Test
fun `half-open allows probes after recovery timeout`() = runTest {
val testClock = TestClock()
val pipeline = ResiliencePipeline(
ResiliencePipelineConfig(
rateLimiterConfig = TokenBucketConfig(ratePerSecond = 1000.0, burst = 1000),
circuitBreakerConfig = CircuitBreakerConfig(
failureThreshold = 2,
recoveryTimeout = Duration.seconds(1)
),
retryConfig = RetryConfig(maxAttempts = 1)
),
testClock
)
repeat(2) {
try { pipeline.execute { throw Exception("HTTP 503") } } catch (_) {}
}
assertEquals(CircuitState.OPEN, pipeline.circuitBreakerComponent.state)
testClock.advanceTime(Duration.seconds(2))
assertEquals(CircuitState.HALF_OPEN, pipeline.circuitBreakerComponent.state)
pipeline.execute { "success" }
repeat(2) { pipeline.execute { "success" } }
assertEquals(CircuitState.CLOSED, pipeline.circuitBreakerComponent.state)
}
}

View File

@ -0,0 +1,90 @@
package org.spotube.spotube.resilience
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestClock
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
@ExperimentalCoroutinesApi
@ExperimentalTime
class TokenBucketRateLimiterTest {
@Test
fun `allows burst up to capacity`() = runTest {
val limiter = TokenBucketRateLimiter(TokenBucketConfig(burst = 5, ratePerSecond = 1.0))
repeat(5) { assertTrue(limiter.tryAcquire()) }
assertFalse(limiter.tryAcquire()) // 6th should fail
}
@Test
fun `refills tokens over time`() = runTest {
val testClock = TestClock()
val limiter = TokenBucketRateLimiter(
TokenBucketConfig(burst = 2, ratePerSecond = 10.0),
testClock
)
assertTrue(limiter.tryAcquire())
assertTrue(limiter.tryAcquire())
assertFalse(limiter.tryAcquire()) // burst exhausted
// Advance 1 second → +10 tokens, clamped to burst (2)
testClock.advanceTime(Duration.seconds(1))
assertTrue(limiter.tryAcquire())
assertTrue(limiter.tryAcquire())
assertFalse(limiter.tryAcquire())
}
@Test
fun `clamps at burst capacity`() = runTest {
val testClock = TestClock()
val limiter = TokenBucketRateLimiter(
TokenBucketConfig(burst = 3, ratePerSecond = 100.0),
testClock
)
// Advance 10 seconds → would be 1000 tokens
testClock.advanceTime(Duration.seconds(10))
assertEquals(3.0, limiter.availablePermits) // Clamped to burst
}
@Test
fun `acquire suspends when empty and resumes after refill`() = runTest {
val testClock = TestClock()
val limiter = TokenBucketRateLimiter(
TokenBucketConfig(burst = 1, ratePerSecond = 10.0),
testClock
)
assertTrue(limiter.tryAcquire()) // Take the only token
assertFalse(limiter.tryAcquire()) // Empty
// Start acquire in background - it should suspend
val deferred = kotlinx.coroutines.async { limiter.acquire() }
assertFalse(deferred.isCompleted) // Still suspended
// Advance time to allow refill
testClock.advanceTime(Duration.milliseconds(200))
assertTrue(deferred.await()) // Should complete now
}
@Test
fun `reset restores initial state`() = runTest {
val limiter = TokenBucketRateLimiter(TokenBucketConfig(burst = 2, ratePerSecond = 1.0))
assertTrue(limiter.tryAcquire())
assertTrue(limiter.tryAcquire())
assertFalse(limiter.tryAcquire())
limiter.reset()
assertTrue(limiter.tryAcquire())
assertTrue(limiter.tryAcquire())
}
}