mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
feat(scrobble): implement ScrobbleManager to handle track scrobbling and integrate with audio player
This commit is contained in:
parent
eeaff3c1db
commit
fab60107e3
@ -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<PluginProvider>()
|
||||
bind<AudioPluginSource>()
|
||||
bind<ScrobblePluginSource>()
|
||||
}
|
||||
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)
|
||||
|
||||
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<ScrobbleManager>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -76,11 +76,18 @@ interface AudioPluginSource {
|
||||
val selectedAudioPlugin: StateFlow<PluginService?>
|
||||
}
|
||||
|
||||
/** 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<PluginService?>
|
||||
}
|
||||
|
||||
|
||||
class PluginManager(
|
||||
val database: Database,
|
||||
val paths: Paths,
|
||||
) : KoinComponent, PluginProvider, AudioPluginSource {
|
||||
) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource {
|
||||
private val logger by injectLogger<PluginManager>()
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@ -9,10 +9,13 @@ import kotlin.time.Duration
|
||||
class FakeAudioPlayer : AudioPlayerInterface {
|
||||
private val _playlistFlow = MutableStateFlow<List<MediaItem>>(emptyList())
|
||||
override val playlistFlow: StateFlow<List<MediaItem>> = _playlistFlow.asStateFlow()
|
||||
override val durationFlow: StateFlow<Duration>
|
||||
get() = TODO("Not yet implemented")
|
||||
override val positionFlow: StateFlow<Duration>
|
||||
get() = TODO("Not yet implemented")
|
||||
|
||||
private val _durationFlow = MutableStateFlow(Duration.ZERO)
|
||||
override val durationFlow: StateFlow<Duration> = _durationFlow.asStateFlow()
|
||||
|
||||
private val _positionFlow = MutableStateFlow(Duration.ZERO)
|
||||
override val positionFlow: StateFlow<Duration> = _positionFlow.asStateFlow()
|
||||
|
||||
override val bufferingPositionFlow: StateFlow<Duration>
|
||||
get() = TODO("Not yet implemented")
|
||||
|
||||
@ -152,8 +155,8 @@ class FakeAudioPlayer : AudioPlayerInterface {
|
||||
_currentMediaItemFlow.value = _playlistFlow.value.getOrNull(index)
|
||||
}
|
||||
|
||||
override val playerStateFlow: StateFlow<PlayerState>
|
||||
get() = TODO("Not yet implemented")
|
||||
private val _playerStateFlow = MutableStateFlow(PlayerState.IDLE)
|
||||
override val playerStateFlow: StateFlow<PlayerState> = _playerStateFlow.asStateFlow()
|
||||
|
||||
fun setPlaylist(items: List<MediaItem>) {
|
||||
_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
|
||||
|
||||
@ -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<List<QueueEntry>>(emptyList())
|
||||
private val currentEntryState = MutableStateFlow<QueueEntry?>(null)
|
||||
|
||||
override val queueFlow: StateFlow<List<QueueEntry>> = queueState
|
||||
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = currentEntryState
|
||||
override val currentCollectionEntryFlow: StateFlow<QueueCollectionEntry?> =
|
||||
MutableStateFlow(null)
|
||||
override val collectionHistoryFlow: StateFlow<List<QueueCollectionEntry>> =
|
||||
MutableStateFlow(emptyList())
|
||||
|
||||
fun setCurrentEntry(entry: QueueEntry?) {
|
||||
currentEntryState.value = entry
|
||||
}
|
||||
|
||||
override suspend fun load(
|
||||
entries: List<QueueEntry>,
|
||||
autoPlay: Boolean,
|
||||
startPosition: Int,
|
||||
collectionEntry: QueueCollectionEntry?,
|
||||
) = error("not used")
|
||||
|
||||
override suspend fun addToQueue(entry: QueueEntry) = error("not used")
|
||||
override suspend fun addAllToQueue(
|
||||
entries: List<QueueEntry>,
|
||||
collectionEntry: QueueCollectionEntry?,
|
||||
) = error("not used")
|
||||
|
||||
override suspend fun addAllAfterCurrent(entries: List<QueueEntry>) = error("not used")
|
||||
override suspend fun removeFromQueue(entry: QueueEntry) = error("not used")
|
||||
override suspend fun removeFromQueueByMediaUrl(mediaUrl: String) = error("not used")
|
||||
override suspend fun move(fromIndex: Int, toIndex: Int) = error("not used")
|
||||
override suspend fun jumpTo(index: Int, autoPlay: Boolean) = error("not used")
|
||||
override suspend fun reloadCurrent() = error("not used")
|
||||
override suspend fun clear() = error("not used")
|
||||
override suspend fun getQueue(): List<QueueEntry> = queueState.value
|
||||
override suspend fun getCurrentQueueEntry(): QueueEntry? = currentEntryState.value
|
||||
override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? = null
|
||||
override suspend fun getCollectionHistory(): List<QueueCollectionEntry> = emptyList()
|
||||
}
|
||||
|
||||
private class FakeScrobblePluginSource(
|
||||
initialPlugin: PluginService? = null,
|
||||
) : ScrobblePluginSource {
|
||||
private val selected = MutableStateFlow(initialPlugin)
|
||||
override val selectedScrobblePlugin: StateFlow<PluginService?> = selected
|
||||
|
||||
fun setPlugin(service: PluginService?) {
|
||||
selected.value = service
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeScrobblePluginService : PluginService {
|
||||
val scrobbled = mutableListOf<ScrobbleTrack>()
|
||||
override val pluginId: String = "fake-scrobble-plugin"
|
||||
override val loggedInFlow: StateFlow<Boolean> = MutableStateFlow(true)
|
||||
|
||||
override suspend fun start() = Unit
|
||||
override suspend fun stop() = Unit
|
||||
|
||||
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
|
||||
val api = object : ScrobbleAPI {
|
||||
override suspend fun scrobble(track: ScrobbleTrack) {
|
||||
scrobbled.add(track)
|
||||
}
|
||||
}
|
||||
val scope = PluginServiceScope(mapOf(ScrobbleAPI::class to api))
|
||||
return scope.block()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user