mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
feat(resilience): add RateLimiter, CircuitBreaker, ErrorClassifier, ResiliencePipeline with tests
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
08f8f65a2a
commit
764d45ddb0
@ -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