From fab60107e33a1604578ee987d2d365b208fdf2e8 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 2 Oct 2026 20:57:50 +0600 Subject: [PATCH] feat(scrobble): implement ScrobbleManager to handle track scrobbling and integrate with audio player --- .../dev/krtirtho/spotube/core/di/Modules.kt | 6 + .../spotube/core/scrobble/ScrobbleManager.kt | 272 ++++++++++++ .../spotube/modules/plugin/PluginManager.kt | 30 +- .../core/audioplayer/FakeAudioPlayer.kt | 27 +- .../core/scrobble/ScrobbleManagerTest.kt | 408 ++++++++++++++++++ 5 files changed, 730 insertions(+), 13 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManager.kt create mode 100644 composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManagerTest.kt diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index e94d4222..d9fd4290 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -32,6 +32,7 @@ import dev.krtirtho.spotube.core.remote.RemoteControlClient import dev.krtirtho.spotube.core.remote.RemoteControlHandler import dev.krtirtho.spotube.core.remote.RemoteControlService import dev.krtirtho.spotube.core.remote.RemotePlaybackController +import dev.krtirtho.spotube.core.scrobble.ScrobbleManager import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.server.AlternativeTracksRepository import dev.krtirtho.spotube.core.server.CacheManager @@ -69,6 +70,7 @@ import dev.krtirtho.spotube.modules.plugin.AudioPluginSource import dev.krtirtho.spotube.modules.plugin.PluginManager import dev.krtirtho.spotube.modules.plugin.PluginProvider import dev.krtirtho.spotube.modules.plugin.PluginViewModel +import dev.krtirtho.spotube.modules.plugin.ScrobblePluginSource import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.modules.search.SearchRepository @@ -130,6 +132,7 @@ val sharedModules = module { singleOf(::PluginManager) { bind() bind() + bind() } viewModelOf(::PluginViewModel) @@ -268,6 +271,9 @@ val sharedModules = module { single { DiscordRpcService(get(), get()) } withOptions { createdAtStart() } + // Scrobbling + single { ScrobbleManager(get(), get(), get()) } withOptions { createdAtStart() } + factory { (tag: String?) -> if (tag != null) { Logger.withTag(tag) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManager.kt new file mode 100644 index 00000000..7e622b32 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManager.kt @@ -0,0 +1,272 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.scrobble + +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue +import dev.krtirtho.spotube.core.audioplayer.PlayerState +import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.modules.plugin.ScrobblePluginSource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import kotlin.time.Clock + +/** + * Watches local playback and submits scrobbles through the currently selected scrobble plugin. + * + * Scrobbling follows the standard Last.fm rules, which plugins are expected to honour as well: + * - Tracks shorter than [MIN_TRACK_DURATION_MS] are never scrobbled. + * - The track must be listened to for at least half of its duration, or [MAX_SCROBBLE_THRESHOLD_MS], + * whichever comes first. + * + * Only time during which the player is actively [PlayerState.PLAYING] is counted, so pauses are + * ignored, and forward seeks don't credit unheard audio. The timestamp sent with the scrobble is + * the moment playback of the track started. Each play is scrobbled at most once; replaying or + * repeating the track starts a new scrobble session. + */ +class ScrobbleManager( + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, + private val scrobblePluginSource: ScrobblePluginSource, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), +) : KoinComponent { + + private val logger by injectLogger() + + private var activeTrackKey: String? = null + private var playbackStartedAtEpochSeconds: Long? = null + private var listenedMs: Long = 0L + private var lastPositionMs: Long? = null + private var scrobbled = false + + init { + scope.launch { + combine( + audioPlayerQueue.currentQueueEntryFlow, + audioPlayer.playerStateFlow, + audioPlayer.positionFlow, + audioPlayer.durationFlow, + ) { entry, playerState, position, duration -> + PlaybackSnapshot( + entry = entry, + playerState = playerState, + positionMs = position.inWholeMilliseconds, + playerDurationMs = duration.inWholeMilliseconds, + ) + }.collect(::onPlaybackSnapshot) + } + } + + private fun onPlaybackSnapshot(snapshot: PlaybackSnapshot) { + val entry = snapshot.entry + if (entry == null) { + clearSession() + return + } + + val trackKey = entry.trackKey() + if (trackKey != activeTrackKey) { + beginSession(trackKey, snapshot.positionMs) + return + } + + val previousPositionMs = lastPositionMs + lastPositionMs = snapshot.positionMs + + if (snapshot.playerState != PlayerState.PLAYING) return + if (previousPositionMs == null) { + playbackStartedAtEpochSeconds = playbackStartedAtEpochSeconds ?: nowEpochSeconds() + return + } + + if (playbackStartedAtEpochSeconds == null) { + playbackStartedAtEpochSeconds = nowEpochSeconds() + } + + val deltaMs = snapshot.positionMs - previousPositionMs + if (deltaMs < 0) { + // A backward seek to the very beginning means the play started over + // (e.g. repeat one), so allow it to be scrobbled again. + if (snapshot.positionMs <= RESTART_POSITION_THRESHOLD_MS) { + restartSession(snapshot.positionMs) + } + return + } + + // A jump larger than a couple of position ticks is a forward seek or a + // buffering/app-suspension stall. Never credit unplayed audio for it. + if (deltaMs > MAX_COUNTED_POSITION_DELTA_MS) return + + listenedMs += deltaMs + if (scrobbled) return + + val durationMs = entry.durationMs().takeIf { it > 0 } ?: snapshot.playerDurationMs + val thresholdMs = scrobbleThresholdMs(durationMs) ?: return + if (listenedMs < thresholdMs) return + + if (scrobblePluginSource.selectedScrobblePlugin.value == null) { + // No scrobble plugin selected yet: keep the session hot so it can be + // submitted if the user selects one while the track is still playing. + return + } + + scrobbled = true + val track = entry.toScrobbleTrack( + timestamp = playbackStartedAtEpochSeconds ?: nowEpochSeconds(), + durationMs = durationMs, + ) + scope.launch { submitScrobble(track) } + } + + private suspend fun submitScrobble(track: ScrobbleTrack) { + val plugin = scrobblePluginSource.selectedScrobblePlugin.value ?: return + try { + plugin.use { scrobbleAPI.scrobble(track) } + logger.d { "Scrobbled \"${track.trackName}\" by ${track.artistName} via ${plugin.pluginId}" } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.e(e) { "Failed to scrobble \"${track.trackName}\" by ${track.artistName}" } + } + } + + private fun beginSession(trackKey: String, positionMs: Long) { + activeTrackKey = trackKey + restartSession(positionMs) + } + + private fun restartSession(positionMs: Long) { + listenedMs = 0L + playbackStartedAtEpochSeconds = null + lastPositionMs = positionMs + scrobbled = false + } + + private fun clearSession() { + activeTrackKey = null + listenedMs = 0L + playbackStartedAtEpochSeconds = null + lastPositionMs = null + scrobbled = false + } + + private fun nowEpochSeconds(): Long = Clock.System.now().epochSeconds + + private fun QueueEntry.trackKey(): String = when (this) { + is QueueEntry.StreamingTrack -> track.id + is QueueEntry.LocalTrack -> url + } + + private fun QueueEntry.durationMs(): Long = when (this) { + is QueueEntry.StreamingTrack -> track.durationMs + is QueueEntry.LocalTrack -> duration + } + + private fun QueueEntry.toScrobbleTrack(timestamp: Long, durationMs: Long): ScrobbleTrack { + return when (this) { + is QueueEntry.StreamingTrack -> ScrobbleTrack( + timestamp = timestamp, + trackId = track.id, + artistId = track.artists.firstOrNull()?.id.orEmpty(), + albumId = track.album?.id, + trackName = track.title, + artistName = track.artists.joinToString(", ") { it.name }, + albumName = track.album?.title, + streamingProvider = resolveStreamingProvider(track), + durationMs = durationMs, + ) + + is QueueEntry.LocalTrack -> ScrobbleTrack( + timestamp = timestamp, + trackId = url, + artistId = "", + albumId = null, + trackName = name, + artistName = artists.joinToString(", "), + albumName = album, + streamingProvider = LOCAL_PROVIDER, + durationMs = durationMs, + ) + } + } + + /** Maps a track's external URI to the streaming provider key scrobble plugins expect. */ + private fun resolveStreamingProvider(track: MetadataTrack): String { + val host = track.externalUri + ?.substringAfter("://", missingDelimiterValue = "") + ?.substringBefore('/') + ?.substringAfter('@') + ?.substringBefore(':') + ?.lowercase() + ?.removePrefix("www.") + .orEmpty() + + if (host.isBlank()) return UNKNOWN_PROVIDER + + return when { + "spotify" in host -> "spotify" + "apple" in host -> "apple_music" + "music.youtube" in host -> "youtube_music" + "youtube" in host || "youtu.be" in host -> "youtube" + "deezer" in host -> "deezer" + "tidal" in host -> "tidal" + "soundcloud" in host -> "soundcloud" + "amazon" in host -> "amazon_music" + "pandora" in host -> "pandora" + else -> host.substringBefore('.') + } + } + + private data class PlaybackSnapshot( + val entry: QueueEntry?, + val playerState: PlayerState, + val positionMs: Long, + val playerDurationMs: Long, + ) + + companion object { + /** Tracks shorter than this are never scrobbled (Last.fm rule). */ + private const val MIN_TRACK_DURATION_MS = 30_000L + + /** Listening threshold is capped at four minutes (Last.fm rule). */ + private const val MAX_SCROBBLE_THRESHOLD_MS = 4 * 60 * 1000L + + /** Largest position delta credited as listening, covers position polling jitter. */ + private const val MAX_COUNTED_POSITION_DELTA_MS = 2_000L + + /** Positions at or below this are treated as the start of a fresh play. */ + private const val RESTART_POSITION_THRESHOLD_MS = 5_000L + + private const val UNKNOWN_PROVIDER = "unknown" + private const val LOCAL_PROVIDER = "local" + + private fun scrobbleThresholdMs(durationMs: Long): Long? { + if (durationMs < MIN_TRACK_DURATION_MS) return null + return minOf(durationMs / 2, MAX_SCROBBLE_THRESHOLD_MS) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt index 4212733b..dfc636e7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt @@ -76,11 +76,18 @@ interface AudioPluginSource { val selectedAudioPlugin: StateFlow } +/** Narrow read-only view of the active scrobble plugin, used by consumers that only need + * to submit scrobbles against the currently selected plugin without depending on the full + * [PluginManager] (which also owns plugin installation/lifecycle machinery). */ +interface ScrobblePluginSource { + val selectedScrobblePlugin: StateFlow +} + class PluginManager( val database: Database, val paths: Paths, -) : KoinComponent, PluginProvider, AudioPluginSource { +) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource { private val logger by injectLogger() private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception -> logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." } @@ -116,7 +123,6 @@ class PluginManager( val json = p[DatabaseKeys.PLUGINS_STATE_KEY] val defaultSelectedPlugins = mapOf( PluginAbility.AUDIO to NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN, - PluginAbility.SCROBBLE to LRCLIB_BUILT_IN_PLUGIN, ) if (json == null) { PluginManagerStates.Data( @@ -136,11 +142,21 @@ class PluginManager( } else it }.toSet() - res.copy( - plugins = (plugins union BUILT_IN_PLUGINS).toList(), - selectedPlugins = res.selectedPlugins.ifEmpty { - defaultSelectedPlugins + val resolvedPlugins = (plugins union BUILT_IN_PLUGINS).toList() + val resolvedPluginsById = resolvedPlugins.associateBy { it.id } + // Drop selections for plugins that don't actually declare the ability + // (e.g. a scrobble selection left over from a removed built-in plugin) + // and always point selections at the resolved plugin entry. + val selectedPlugins = res.selectedPlugins.ifEmpty { defaultSelectedPlugins } + .mapNotNull { (ability, selected) -> + val plugin = resolvedPluginsById[selected.id] ?: return@mapNotNull null + if (ability in plugin.abilities) ability to plugin else null } + .toMap() + + res.copy( + plugins = resolvedPlugins, + selectedPlugins = selectedPlugins, ) } catch (_: Exception) { PluginManagerStates.Data( @@ -256,7 +272,7 @@ class PluginManager( filterSelectedPluginByType(PluginAbility.AUDIO) val selectedLyricsPlugin = filterSelectedPluginByType(PluginAbility.LYRICS) - val selectedScrobblePlugin = + override val selectedScrobblePlugin = filterSelectedPluginByType(PluginAbility.SCROBBLE) diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayer.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayer.kt index 406134b9..ebfbc450 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayer.kt +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayer.kt @@ -9,10 +9,13 @@ import kotlin.time.Duration class FakeAudioPlayer : AudioPlayerInterface { private val _playlistFlow = MutableStateFlow>(emptyList()) override val playlistFlow: StateFlow> = _playlistFlow.asStateFlow() - override val durationFlow: StateFlow - get() = TODO("Not yet implemented") - override val positionFlow: StateFlow - get() = TODO("Not yet implemented") + + private val _durationFlow = MutableStateFlow(Duration.ZERO) + override val durationFlow: StateFlow = _durationFlow.asStateFlow() + + private val _positionFlow = MutableStateFlow(Duration.ZERO) + override val positionFlow: StateFlow = _positionFlow.asStateFlow() + override val bufferingPositionFlow: StateFlow get() = TODO("Not yet implemented") @@ -152,8 +155,8 @@ class FakeAudioPlayer : AudioPlayerInterface { _currentMediaItemFlow.value = _playlistFlow.value.getOrNull(index) } - override val playerStateFlow: StateFlow - get() = TODO("Not yet implemented") + private val _playerStateFlow = MutableStateFlow(PlayerState.IDLE) + override val playerStateFlow: StateFlow = _playerStateFlow.asStateFlow() fun setPlaylist(items: List) { _playlistFlow.value = items @@ -167,6 +170,18 @@ class FakeAudioPlayer : AudioPlayerInterface { _loopStateFlow.value = state } + fun setPlayerState(state: PlayerState) { + _playerStateFlow.value = state + } + + fun setPosition(position: Duration) { + _positionFlow.value = position + } + + fun setDuration(duration: Duration) { + _durationFlow.value = duration + } + fun resetCallCounts() { loadCallCount = 0 playCallCount = 0 diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManagerTest.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManagerTest.kt new file mode 100644 index 00000000..8b0043e9 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManagerTest.kt @@ -0,0 +1,408 @@ +package dev.krtirtho.spotube.core.scrobble + +import co.touchlab.kermit.Logger +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue +import dev.krtirtho.spotube.core.audioplayer.FakeAudioPlayer +import dev.krtirtho.spotube.core.audioplayer.PlayerState +import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry +import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.zipline.PluginService +import dev.krtirtho.spotube.core.zipline.PluginServiceScope +import dev.krtirtho.spotube.modules.plugin.ScrobblePluginSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class ScrobbleManagerTest { + + private lateinit var player: FakeAudioPlayer + private lateinit var queue: FakeQueue + private lateinit var plugin: FakeScrobblePluginService + private lateinit var pluginSource: FakeScrobblePluginSource + + /** Virtual playback position advanced by [playSeconds]; mirrors a real player clock. */ + private var currentPositionMs = 0L + + @BeforeTest + fun setup() { + startKoin { + modules(module { + factory { (tag: String?) -> Logger.withTag(tag ?: "test") } + }) + } + player = FakeAudioPlayer() + queue = FakeQueue() + plugin = FakeScrobblePluginService() + pluginSource = FakeScrobblePluginSource(plugin) + currentPositionMs = 0L + } + + @AfterTest + fun teardown() { + stopKoin() + } + + private fun TestScope.createManager(): ScrobbleManager { + return ScrobbleManager( + audioPlayer = player, + audioPlayerQueue = queue, + scrobblePluginSource = pluginSource, + scope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler)), + ) + } + + private fun playSeconds(seconds: Int) { + repeat(seconds) { + currentPositionMs += 1_000 + player.setPosition(currentPositionMs.milliseconds) + } + } + + @Test + fun `scrobbles once half of the track has been played`() = runTest { + createManager() + queue.setCurrentEntry( + streamingEntry( + id = "t1", + durationMs = 200_000, + externalUri = "https://open.spotify.com/track/t1", + ) + ) + player.setPlayerState(PlayerState.PLAYING) + player.setDuration(200.seconds) + + playSeconds(99) + assertEquals(0, plugin.scrobbled.size) + + playSeconds(1) + assertEquals(1, plugin.scrobbled.size) + + val scrobble = plugin.scrobbled.single() + assertEquals("t1", scrobble.trackId) + assertEquals("artist-t1", scrobble.artistId) + assertEquals("album-t1", scrobble.albumId) + assertEquals("Track t1", scrobble.trackName) + assertEquals("Artist t1", scrobble.artistName) + assertEquals("Album t1", scrobble.albumName) + assertEquals("spotify", scrobble.streamingProvider) + assertEquals(200_000L, scrobble.durationMs) + assertTrue(scrobble.timestamp > 0) + } + + @Test + fun `does not scrobble tracks shorter than 30 seconds`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "short", durationMs = 20_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(20) + + assertEquals(0, plugin.scrobbled.size) + } + + @Test + fun `caps the listening threshold at four minutes`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "long", durationMs = 1_800_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(239) + assertEquals(0, plugin.scrobbled.size) + + playSeconds(1) + assertEquals(1, plugin.scrobbled.size) + } + + @Test + fun `only scrobbles a track once per play`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(20) + playSeconds(15) + + assertEquals(1, plugin.scrobbled.size) + } + + @Test + fun `does not credit forward seeks`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 100_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(10) + currentPositionMs = 60_000 + player.setPosition(currentPositionMs.milliseconds) + + playSeconds(39) + assertEquals(0, plugin.scrobbled.size) + + playSeconds(1) + assertEquals(1, plugin.scrobbled.size) + } + + @Test + fun `does not credit time while paused`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 100_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(30) + + player.setPlayerState(PlayerState.PAUSED) + currentPositionMs = 80_000 + player.setPosition(currentPositionMs.milliseconds) + + player.setPlayerState(PlayerState.PLAYING) + playSeconds(19) + assertEquals(0, plugin.scrobbled.size) + + playSeconds(1) + assertEquals(1, plugin.scrobbled.size) + } + + @Test + fun `restarting the track allows scrobbling it again`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(20) + assertEquals(1, plugin.scrobbled.size) + + // Repeat-one restarts playback from the beginning without a queue entry change. + currentPositionMs = 0 + player.setPosition(0.milliseconds) + playSeconds(20) + + assertEquals(2, plugin.scrobbled.size) + } + + @Test + fun `switching tracks starts a fresh scrobble session`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(20) + assertEquals(1, plugin.scrobbled.size) + + queue.setCurrentEntry(streamingEntry(id = "t2", durationMs = 40_000)) + currentPositionMs = 0 + player.setPosition(0.milliseconds) + playSeconds(20) + + assertEquals(2, plugin.scrobbled.size) + assertEquals("t2", plugin.scrobbled.last().trackId) + } + + @Test + fun `waits for a scrobble plugin to be selected`() = runTest { + pluginSource.setPlugin(null) + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(25) + assertEquals(0, plugin.scrobbled.size) + + pluginSource.setPlugin(plugin) + playSeconds(1) + + assertEquals(1, plugin.scrobbled.size) + } + + @Test + fun `scrobbles local tracks`() = runTest { + createManager() + queue.setCurrentEntry( + localEntry( + name = "Local Song", + url = "file:///music/local.mp3", + duration = 100_000, + ) + ) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(50) + + val scrobble = plugin.scrobbled.single() + assertEquals("file:///music/local.mp3", scrobble.trackId) + assertEquals("Local Song", scrobble.trackName) + assertEquals("Local Artist", scrobble.artistName) + assertEquals("Local Album", scrobble.albumName) + assertEquals("local", scrobble.streamingProvider) + assertEquals(100_000L, scrobble.durationMs) + } + + @Test + fun `uses unknown provider when the track has no external uri`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000, externalUri = null)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(20) + + assertEquals("unknown", plugin.scrobbled.single().streamingProvider) + } + + // --- fixtures --- + + private fun streamingTrack( + id: String, + durationMs: Long = 200_000, + externalUri: String? = null, + ): MetadataTrack { + val artist = MetadataArtist.Basic( + id = "artist-$id", + name = "Artist $id", + thumbnails = emptyList(), + externalUri = null, + ) + val album = MetadataAlbum.Detailed( + releaseDate = "2024", + genres = emptyList(), + trackCount = 1, + id = "album-$id", + title = "Album $id", + description = null, + thumbnails = emptyList(), + albumType = MetadataAlbumType.Album, + artists = listOf(artist), + externalUri = null, + ) + return MetadataTrack( + id = id, + title = "Track $id", + durationMs = durationMs, + trackNumber = 1, + discNumber = 1, + artists = listOf(artist), + album = album, + thumbnails = null, + explicit = false, + popularity = null, + isrcCode = null, + externalUri = externalUri, + ) + } + + private fun streamingEntry( + id: String, + durationMs: Long = 200_000, + externalUri: String? = null, + ): QueueEntry.StreamingTrack { + return QueueEntry.StreamingTrack( + track = streamingTrack(id, durationMs, externalUri), + url = "http://127.0.0.1:14769/stream/$id", + ) + } + + private fun localEntry( + name: String = "Local Track", + url: String = "file:///music/local.mp3", + duration: Long = 180_000, + ): QueueEntry.LocalTrack { + return QueueEntry.LocalTrack( + name = name, + artists = listOf("Local Artist"), + duration = duration, + album = "Local Album", + coverBytes = null, + url = url, + ) + } + + private class FakeQueue : AudioPlayerQueue { + private val queueState = MutableStateFlow>(emptyList()) + private val currentEntryState = MutableStateFlow(null) + + override val queueFlow: StateFlow> = queueState + override val currentQueueEntryFlow: StateFlow = currentEntryState + override val currentCollectionEntryFlow: StateFlow = + MutableStateFlow(null) + override val collectionHistoryFlow: StateFlow> = + MutableStateFlow(emptyList()) + + fun setCurrentEntry(entry: QueueEntry?) { + currentEntryState.value = entry + } + + override suspend fun load( + entries: List, + autoPlay: Boolean, + startPosition: Int, + collectionEntry: QueueCollectionEntry?, + ) = error("not used") + + override suspend fun addToQueue(entry: QueueEntry) = error("not used") + override suspend fun addAllToQueue( + entries: List, + collectionEntry: QueueCollectionEntry?, + ) = error("not used") + + override suspend fun addAllAfterCurrent(entries: List) = error("not used") + override suspend fun removeFromQueue(entry: QueueEntry) = error("not used") + override suspend fun removeFromQueueByMediaUrl(mediaUrl: String) = error("not used") + override suspend fun move(fromIndex: Int, toIndex: Int) = error("not used") + override suspend fun jumpTo(index: Int, autoPlay: Boolean) = error("not used") + override suspend fun reloadCurrent() = error("not used") + override suspend fun clear() = error("not used") + override suspend fun getQueue(): List = queueState.value + override suspend fun getCurrentQueueEntry(): QueueEntry? = currentEntryState.value + override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? = null + override suspend fun getCollectionHistory(): List = emptyList() + } + + private class FakeScrobblePluginSource( + initialPlugin: PluginService? = null, + ) : ScrobblePluginSource { + private val selected = MutableStateFlow(initialPlugin) + override val selectedScrobblePlugin: StateFlow = selected + + fun setPlugin(service: PluginService?) { + selected.value = service + } + } + + private class FakeScrobblePluginService : PluginService { + val scrobbled = mutableListOf() + override val pluginId: String = "fake-scrobble-plugin" + override val loggedInFlow: StateFlow = MutableStateFlow(true) + + override suspend fun start() = Unit + override suspend fun stop() = Unit + + override suspend fun use(block: suspend PluginServiceScope.() -> T): T { + val api = object : ScrobbleAPI { + override suspend fun scrobble(track: ScrobbleTrack) { + scrobbled.add(track) + } + } + val scope = PluginServiceScope(mapOf(ScrobbleAPI::class to api)) + return scope.block() + } + } +}