Compare commits

...

4 Commits

Author SHA1 Message Date
Jonatan Nyberg
321b8ea784
Merge b0327fd224 into fab60107e3 2026-10-02 18:23:28 +03:00
Kingkor Roy Tirtho
fab60107e3 feat(scrobble): implement ScrobbleManager to handle track scrobbling and integrate with audio player 2026-10-02 20:57:50 +06:00
Kingkor Roy Tirtho
eeaff3c1db refactor(main): simplify main function and remove unused WebKit GPU compositing logic 2026-10-02 20:05:23 +06:00
Jonatan Nyberg
b0327fd224 translation: add Swedish resource translations
Signed-off-by: Jonatan Nyberg <84130654+NickWick13@users.noreply.github.com>
2026-09-20 14:17:16 +03:00
10 changed files with 923 additions and 59 deletions

View File

@ -0,0 +1,22 @@
<!--
~ 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/>.
-->
<resources>
<string name="app_name">Spotube</string>
<string name="now_playing">Spelas nu</string>
<string name="car_connect_hint">Spela musik från telefonen för att börja lyssna i bilen</string>
</resources>

View File

@ -0,0 +1,169 @@
<!--
~ 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/>.
-->
<resources>
<string name="settings_screen_title">Inställningar</string>
<string name="settings_section_plugins">Tillägg</string>
<string name="settings_section_language_region">Språk och region</string>
<string name="settings_section_appearance">Utseende</string>
<string name="settings_section_playback">Uppspelning</string>
<string name="settings_section_downloads">Nedladdningar</string>
<string name="settings_section_caching">Cachelagring</string>
<string name="settings_section_desktop">Dator</string>
<string name="settings_section_updates">Uppdateringar</string>
<string name="settings_action_close">Stäng</string>
<string name="settings_action_save">Spara</string>
<string name="settings_action_cancel">Avbryt</string>
<string name="settings_language_title">Språk</string>
<string name="settings_language_subtitle_current">Språk för appens gränssnitt. Nuvarande: %1$s</string>
<string name="settings_country_title">Land</string>
<string name="settings_country_subtitle_current">Land som styr innehåll och tillgänglighet. Nuvarande: %1$s</string>
<string name="settings_option_name_and_code">%1$s (%2$s)</string>
<string name="settings_theme_title">Tema</string>
<string name="settings_theme_subtitle_current">Välj hur Spotube anpassar sig till systemets utseende. Nuvarande: %1$s</string>
<string name="settings_theme_light">Ljust</string>
<string name="settings_theme_dark">Mörkt</string>
<string name="settings_theme_system">System</string>
<string name="settings_accent_title">Accentfärg</string>
<string name="settings_accent_subtitle_current">Nuvarande färgpalett: %1$s</string>
<string name="settings_accent_dialog_title">Accentfärg</string>
<string name="settings_accent_dialog_description">Välj en accentpalett och förhandsgranska den i både ljust och mörkt tema.</string>
<string name="settings_preview_light">Ljust</string>
<string name="settings_preview_dark">Mörkt</string>
<string name="settings_preview_label">Förhandsgranskning</string>
<string name="settings_preview_button">Knapp</string>
<string name="settings_accent_green_goblin">Gröna trollet</string>
<string name="settings_accent_electric_violet">Elektriskt violett</string>
<string name="settings_accent_oceanic_cyan">Havscyan</string>
<string name="settings_accent_sunset_orange">Solnedgångsorange</string>
<string name="settings_accent_rose_garden">Rosenträdgård</string>
<string name="settings_accent_midnight_blue">Midnattsblått</string>
<string name="settings_accent_metallic_slate">Metalliskt skiffergrått</string>
<string name="settings_download_folder_title">Egen nedladdningsmapp</string>
<string name="settings_download_folder_current">Nuvarande mapp: %1$s</string>
<string name="settings_download_folder_default">Nuvarande mapp: standardmappen för musik</string>
<string name="settings_download_folder_description">Lämna fältet tomt för att använda standardmappen för musik.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Lokala mediemappar</string>
<string name="settings_local_media_folders_subtitle_current">Egna mappar att skanna på datorn. Tillagda: %1$d</string>
<string name="settings_local_media_folders_subtitle_empty">Ingen egen mapp har lagts till ännu</string>
<string name="settings_local_media_folders_description">Lägg till mappar med mappväljaren. Den egna nedladdningsmappen tas med automatiskt.</string>
<string name="settings_local_media_folders_manage">Hantera</string>
<string name="settings_local_media_folders_add_action">Lägg till mapp</string>
<string name="settings_local_media_folders_remove_action">Ta bort mapp</string>
<string name="settings_local_media_folders_none_added">Inga egna mappar har lagts till.</string>
<string name="settings_download_format_title">Musikformat för nedladdning</string>
<string name="settings_download_format_subtitle_current">Önskad kodek och önskat behållarformat för nedladdningar. Nuvarande: %1$s</string>
<string name="settings_download_quality_title">Musikkvalitet för nedladdning</string>
<string name="settings_streaming_format_title">Musikformat för strömning</string>
<string name="settings_streaming_format_subtitle_current">Önskad kodek och önskat behållarformat för musikströmning. Nuvarande: %1$s</string>
<string name="settings_streaming_quality_title">Musikkvalitet för strömning</string>
<string name="settings_subtitle_current">Nuvarande: %1$s</string>
<string name="settings_enable_music_caching_title">Aktivera cachelagring av musik</string>
<string name="settings_enable_music_caching_subtitle">Lagra strömmat ljud lokalt så att det går snabbare att spela upp igen.</string>
<string name="settings_cache_folder_title">Cachemapp</string>
<string name="settings_cache_folder_current">Nuvarande: %1$s</string>
<string name="settings_cache_folder_default">Nuvarande: appens standardcache</string>
<string name="settings_cache_size_limit_title">Maximal cachestorlek</string>
<string name="settings_cache_size_limit_unlimited">Ingen gräns</string>
<string name="settings_cache_size_limit_current">Nuvarande gräns: %1$d MB</string>
<string name="settings_cache_size_limit_description">Ange den maximala cachestorleken i MB. Ange 0 eller lämna tomt för obegränsad storlek.</string>
<string name="settings_cache_size_limit_placeholder">500</string>
<string name="settings_error_cache_size_range">Storleken måste vara 0 eller ett positivt tal.</string>
<string name="settings_enable_endless_playback_title">Fortsätt spela automatiskt</string>
<string name="settings_enable_endless_playback_subtitle">Fortsätt spela rekommenderade låtar när kön tar slut.</string>
<string name="settings_enable_connect_title">Aktivera Connect</string>
<string name="settings_enable_connect_subtitle">Gör det möjligt att fjärrstyra uppspelningen via Spotube Connect.</string>
<string name="settings_playback_port_title">Port för uppspelningens proxyserver</string>
<string name="settings_playback_port_subtitle_current">Port som uppspelningens proxyserver använder. Nuvarande: %1$d</string>
<string name="settings_playback_port_description">Välj en port mellan 1 och 65535.</string>
<string name="settings_playback_port_placeholder">14769</string>
<string name="settings_error_whole_number">Ange ett giltigt heltal.</string>
<string name="settings_error_port_range">Porten måste vara mellan 1 och 65535.</string>
<string name="settings_blacklist_title">Spärrlista</string>
<string name="settings_blacklist_subtitle">Hantera spärrade låtar och artister</string>
<string name="settings_desktop_minimize_title">Minimera till meddelandefältet</string>
<string name="settings_desktop_minimize_subtitle">Låt Spotube fortsätta köras i meddelandefältet när fönstret minimeras.</string>
<string name="settings_desktop_discord_title">Discord Rich Presence</string>
<string name="settings_desktop_discord_subtitle">Visa din aktuella uppspelningsstatus i Discord på datorn.</string>
<string name="settings_updates_auto_check_title">Sök automatiskt efter uppdateringar</string>
<string name="settings_updates_auto_check_subtitle">Sök efter nya versioner av Spotube i bakgrunden.</string>
<string name="settings_plugins_manage_title">Hantera tillägg</string>
<string name="settings_plugins_manage_subtitle">Hantera dina tillägg</string>
<string name="settings_plugins_default_ability_title">Standardtillägg: %1$s</string>
<string name="settings_plugins_ability_metadata">Metadata</string>
<string name="settings_plugins_ability_audio">Ljud</string>
<string name="settings_plugins_ability_lyrics">Låttexter</string>
<string name="settings_plugins_ability_scrobble">Skrobbling</string>
<string name="settings_plugins_no_selection">Inget tillägg valt</string>
<string name="settings_plugins_action_change">Ändra</string>
<string name="settings_plugins_action_select">Välj</string>
<string name="settings_plugins_selected">Valt</string>
<string name="settings_plugins_clear">Rensa val</string>
<string name="settings_plugins_no_plugins">Inga tillägg är tillgängliga</string>
<string name="settings_plugins_plugin_content_description">Tillägget %1$s</string>
<string name="settings_plugins_clear_content_description">Rensa</string>
<string name="plugin_configure_title">Konfigurera</string>
<string name="plugin_screen_title">Tilläggshanterare</string>
<string name="plugin_error_enter_url">Ange en URL</string>
<string name="plugin_error_url_scheme">URL:en måste börja med http:// eller https://</string>
<string name="plugin_error_download_failed">Det gick inte att ladda ner tillägget</string>
<string name="plugin_empty_title">Inga tillägg har installerats ännu</string>
<string name="plugin_empty_subtitle">Klistra in en nedladdningsadress eller välj en fil ovan.</string>
<string name="plugin_installed_count">Installerade %2$s: %1$d</string>
<string name="plugin_installed_singular">tillägg</string>
<string name="plugin_installed_plural">tillägg</string>
<string name="plugin_install_section_title">Installera ett tillägg</string>
<string name="plugin_url_placeholder">https://example.com/plugin.smplug</string>
<string name="plugin_action_download">Ladda ner</string>
<string name="plugin_action_install_from_file">Installera från fil (.smplug)</string>
<string name="plugin_state_active">Aktivt</string>
<string name="plugin_version_label">v%1$s</string>
<string name="plugin_state_builtin">Inbyggt</string>
<string name="plugin_action_remove">Ta bort tillägg</string>
<string name="plugin_action_login">Logga in</string>
<string name="plugin_action_logout">Logga ut</string>
<string name="plugin_section_url_title">Ladda ner från URL</string>
<string name="plugin_section_file_title">Installera från fil</string>
<string name="plugin_section_install">Installera</string>
<string name="plugin_permissions_author_version">• %1$s</string>
<string name="plugin_permissions_requested_title">Begärda behörigheter</string>
<string name="plugin_permissions_none">Det här tillägget kräver inga särskilda behörigheter.</string>
<string name="plugin_permissions_compare_title">Installerat och tillhandahållet</string>
<string name="plugin_permissions_installed_label">Installerat</string>
<string name="plugin_permissions_supplied_label">Tillhandahållet</string>
<string name="plugin_permissions_api_diff">API %1$s → %2$s</string>
<string name="plugin_permissions_capability_storage_title">Beständig lagring</string>
<string name="plugin_permissions_capability_storage_desc">Läsa och skriva data på disken</string>
<string name="plugin_permissions_capability_network_title">Nätverksförfrågningar</string>
<string name="plugin_permissions_capability_network_desc">Skicka och ta emot data via internet</string>
<string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Visa webbinnehåll i appen</string>
</resources>

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"