mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Merge 764d45ddb0 into 08f8f65a2a
This commit is contained in:
commit
f7c1b19545
@ -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
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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>) {}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
@ -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()
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
@ -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 }
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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() { }
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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()) }
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user