Compare commits

...

2 Commits

8 changed files with 732 additions and 59 deletions

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -215,7 +215,6 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
webViewJsBridge = webViewBridge,
onCreated = { webView ->
navigator.evaluateJavaScript(bridgeBootstrapScript)
},
)
}

View File

@ -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)

View File

@ -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

View File

@ -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()
}
}
}

View File

@ -58,52 +58,9 @@ private object KoinServicesProvider : KoinComponent {
val settingsProvider: SettingsProvider get() = get()
}
private interface LibC : Library {
fun setenv(name: String, value: String, overwrite: Int): Int
companion object {
val INSTANCE: LibC = Native.load("c", LibC::class.java)
}
}
/**
* WebKitGTK is only used on Linux (Windows = WebView2, macOS = WKWebView).
* The webview is created *after* the window is already mapped and the Tao
* render/swap loop is running. WebKitGTK's accelerated-compositing path then
* initialises its own GL context in-process, racing Tao's swap thread on the
* same Mesa display, which deterministically segfaults `libgallium` on the
* next Compose flush. Disabling WebKit's hardware-accelerated compositing
* (and its DMABUF renderer) removes that GL context entirely — login pages
* render fine in software. Must run before libwebkit2gtk is loaded.
*/
private fun disableWebKitGpuCompositing() {
if (!System.getProperty("os.name").lowercase().contains("linux")) return
LibC.INSTANCE.setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", 1)
LibC.INSTANCE.setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", 1)
}
/**
* Routes `spotube://` deep links into [ExternalUriHandler].
* macOS delivers them through the open-URI handler; on Linux/Windows they arrive
* as command line arguments (scheme registration is handled by the distribution
* packaging, e.g. the `.desktop` file's `Exec %u`).
*/
private fun handleStartupDeepLinks(args: Array<String>) {
runCatching {
if (java.awt.Desktop.isDesktopSupported()) {
java.awt.Desktop.getDesktop().setOpenURIHandler { event ->
ExternalUriHandler.onNewUri(event.uri.toString())
}
}
}
args.firstOrNull { it.startsWith("spotube:", ignoreCase = true) }
?.let(ExternalUriHandler::onNewUri)
}
@OptIn(ExperimentalComposeUiApi::class)
fun main(args: Array<String>) {
disableWebKitGpuCompositing()
handleStartupDeepLinks(args)
fun main() {
FileKit.init(appId = "dev.krtirtho.spotube")
initKoin()
NewPipeDownloader.init(KoinPathsProvider.paths)

View File

@ -61,7 +61,7 @@ kotlinStdlib = "2.4.10"
runner = "1.7.0"
core = "1.7.0"
filekit = "0.14.2"
compose-webview = "1.0.3"
compose-webview = "0.1.0-SNAPSHOT"
composeNativeTray = "2.1.6"
koin = "4.2.2"
multiplatform-nav3-ui = "1.1.1"