scrobble playback back to the server

Send a now-playing notification when a track starts playing and one
submission once it passes half its duration or four minutes, using the
Subsonic scrobble endpoint's submission and time parameters.
This commit is contained in:
austinried
2026-10-04 00:30:04 +00:00
parent d3425269ba
commit 29e5029b56
9 changed files with 390 additions and 1 deletions
@@ -20,6 +20,12 @@ interface MusicSource {
starred: Boolean,
)
suspend fun scrobble(
songId: String,
submission: Boolean,
time: Long? = null,
)
fun artists(): Flow<List<Artist>>
fun albums(): Flow<List<Album>>
@@ -63,6 +63,23 @@ class SubsonicSource(
}
}
override suspend fun scrobble(
songId: String,
submission: Boolean,
time: Long?,
) {
withContext(Dispatchers.IO) {
client.check(
"scrobble",
buildMap {
put("id", songId)
put("submission", submission.toString())
time?.let { put("time", it.toString()) }
},
)
}
}
override fun artists(): Flow<List<Artist>> =
entityBatches(
method = "getArtists",
@@ -24,6 +24,7 @@ import com.subtracks.data.sync.SyncManager
import com.subtracks.playback.MediaSessionConnection
import com.subtracks.playback.PlaybackController
import com.subtracks.playback.PlayerConnection
import com.subtracks.playback.Scrobbler
import com.subtracks.ui.RootViewModel
import com.subtracks.ui.downloads.DownloadsViewModel
import com.subtracks.ui.library.AlbumDetailViewModel
@@ -71,6 +72,12 @@ fun appModule(
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get()).also { it.start() } }
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
single { PlaybackController(get(), get(), get(), get(), showMessage = toast) }
single(createdAtStart = true) {
val playback = get<PlaybackController>()
Scrobbler(source = { get<SourceRepository>().activeMusicSource() }).also {
it.attach(playback.state, playback.positionMs)
}
}
viewModel { RootViewModel(get()) }
viewModel { LibraryViewModel(get(), get(), get(), get(), get(), get()) }
viewModel { SettingsViewModel(get(), get()) }
@@ -0,0 +1,120 @@
package com.subtracks.playback
import com.subtracks.data.source.MusicSource
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
sealed interface Scrobble {
val songId: String
data class NowPlaying(
override val songId: String,
) : Scrobble
data class Submission(
override val songId: String,
val time: Long,
) : Scrobble
}
class ScrobblePolicy(
private val now: () -> Long = System::currentTimeMillis,
) {
private var songId: String? = null
private var durationMs = 0L
private var startedAt = 0L
private var playedMs = 0L
private var lastPositionMs = 0L
private var nowPlayingSent = false
private var submitted = false
fun advance(
item: QueueItem?,
isPlaying: Boolean,
positionMs: Long,
): Scrobble? {
val previousPosition = lastPositionMs
if (item?.id != songId || positionMs < previousPosition) {
songId = item?.id
durationMs = item?.durationMs ?: 0L
playedMs = 0
lastPositionMs = positionMs
nowPlayingSent = false
submitted = false
if (item == null || !isPlaying) return null
nowPlayingSent = true
startedAt = now()
return Scrobble.NowPlaying(item.id)
}
lastPositionMs = positionMs
val id = songId ?: return null
if (!isPlaying) return null
if (!nowPlayingSent) {
nowPlayingSent = true
startedAt = now()
return Scrobble.NowPlaying(id)
}
playedMs += (positionMs - previousPosition).coerceIn(0, MAX_STEP_MS)
if (submitted || playedMs < thresholdMs()) return null
submitted = true
return Scrobble.Submission(id, startedAt)
}
private fun thresholdMs(): Long {
if (durationMs in 1 until MIN_DURATION_MS) return Long.MAX_VALUE
if (durationMs <= 0) return MAX_THRESHOLD_MS
return minOf(durationMs / 2, MAX_THRESHOLD_MS)
}
private companion object {
const val MIN_DURATION_MS = 30_000L
const val MAX_THRESHOLD_MS = 4 * 60_000L
const val MAX_STEP_MS = 2_000L
}
}
class Scrobbler(
private val source: suspend () -> MusicSource?,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
private val policy: ScrobblePolicy = ScrobblePolicy(),
) {
fun attach(
state: StateFlow<PlaybackState>,
positionMs: StateFlow<Long>,
) {
scope.launch {
combine(state, positionMs) { current, position -> current to position }.collect { (current, position) ->
val event = policy.advance(current.item, current.isPlaying, position) ?: return@collect
submit(event)
}
}
}
fun close() = scope.cancel()
private suspend fun submit(event: Scrobble) {
val source =
try {
source()
} catch (cancellation: CancellationException) {
throw cancellation
} catch (_: Exception) {
null
} ?: return
try {
when (event) {
is Scrobble.NowPlaying -> source.scrobble(event.songId, submission = false)
is Scrobble.Submission -> source.scrobble(event.songId, submission = true, time = event.time)
}
} catch (cancellation: CancellationException) {
throw cancellation
} catch (_: Exception) {
}
}
}