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) {
}
}
}
@@ -15,6 +15,7 @@ import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -545,6 +546,33 @@ class SubsonicSourceTest {
assertFalse(uri, uri.contains("estimateContentLength"))
}
@Test
fun scrobbleSubmitsTheSongWithItsTime() =
runBlocking {
server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\"/>"))
SubsonicSource(1, client()).scrobble("s1", submission = true, time = 1_700_000_000_000L)
val url = server.takeRequest().requestUrl!!
assertEquals("/rest/scrobble.view", url.encodedPath)
assertEquals("s1", url.queryParameter("id"))
assertEquals("true", url.queryParameter("submission"))
assertEquals("1700000000000", url.queryParameter("time"))
}
@Test
fun scrobbleSendsNowPlayingWithoutATime() =
runBlocking {
server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\"/>"))
SubsonicSource(1, client()).scrobble("s1", submission = false)
val url = server.takeRequest().requestUrl!!
assertEquals("s1", url.queryParameter("id"))
assertEquals("false", url.queryParameter("submission"))
assertNull(url.queryParameter("time"))
}
private fun client() =
SubsonicClient(
baseUrl = server.url("/"),
@@ -730,6 +730,12 @@ private class FakeMusicSource(
starred: Boolean,
) = Unit
override suspend fun scrobble(
songId: String,
submission: Boolean,
time: Long?,
) = Unit
override fun artists(): Flow<List<Artist>> = batches(artists)
override fun albums(): Flow<List<Album>> = batches(albums)
@@ -0,0 +1,112 @@
package com.subtracks.playback
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ScrobblePolicyTest {
private var clock = 1_000L
private val policy = ScrobblePolicy(now = { clock })
private fun track(
id: String = "s1",
durationMs: Long = 200_000,
) = QueueItem(id = id, title = "Title", artist = "Artist", album = "Album", coverArtId = null, durationMs = durationMs)
private fun play(
item: QueueItem?,
toPositionMs: Long,
stepMs: Long = 500,
playing: Boolean = true,
): List<Scrobble> {
val events = mutableListOf<Scrobble>()
var position = 0L
while (position <= toPositionMs) {
policy.advance(item, playing, position)?.let(events::add)
position += stepMs
}
return events
}
@Test
fun sendsNowPlayingWhenPlaybackStarts() {
val events = play(track(), toPositionMs = 0)
assertEquals(listOf<Scrobble>(Scrobble.NowPlaying("s1")), events)
}
@Test
fun waitsForPlaybackBeforeNowPlaying() {
val item = track()
assertNull(policy.advance(item, isPlaying = false, positionMs = 0))
clock = 5_000L
assertEquals(Scrobble.NowPlaying("s1"), policy.advance(item, isPlaying = true, positionMs = 0))
}
@Test
fun submitsAfterHalfTheDuration() {
val events = play(track(durationMs = 200_000), toPositionMs = 100_000)
assertEquals(Scrobble.NowPlaying("s1"), events.first())
assertEquals(1, events.filterIsInstance<Scrobble.Submission>().size)
assertEquals(Scrobble.Submission("s1", time = 1_000L), events.last())
}
@Test
fun doesNotSubmitBeforeHalfTheDuration() {
val events = play(track(durationMs = 200_000), toPositionMs = 99_500)
assertEquals(listOf<Scrobble>(Scrobble.NowPlaying("s1")), events)
}
@Test
fun submitsAfterFourMinutesForLongTracks() {
val events = play(track(durationMs = 10 * 60_000), toPositionMs = 4 * 60_000)
assertEquals(Scrobble.Submission("s1", time = 1_000L), events.last())
}
@Test
fun doesNotSubmitShortTracks() {
val events = play(track(durationMs = 20_000), toPositionMs = 20_000)
assertEquals(listOf<Scrobble>(Scrobble.NowPlaying("s1")), events)
}
@Test
fun submitsOnlyOncePerTrack() {
val events = play(track(durationMs = 200_000), toPositionMs = 200_000)
assertEquals(1, events.filterIsInstance<Scrobble.Submission>().size)
}
@Test
fun seekingAheadDoesNotCountAsPlayback() {
val item = track(durationMs = 200_000)
assertEquals(Scrobble.NowPlaying("s1"), policy.advance(item, isPlaying = true, positionMs = 0))
assertNull(policy.advance(item, isPlaying = true, positionMs = 180_000))
assertNull(policy.advance(item, isPlaying = true, positionMs = 180_500))
}
@Test
fun pausedTimeIsNotCounted() {
val item = track(durationMs = 200_000)
assertEquals(Scrobble.NowPlaying("s1"), policy.advance(item, isPlaying = true, positionMs = 0))
assertNull(policy.advance(item, isPlaying = false, positionMs = 120_000))
assertNull(policy.advance(item, isPlaying = true, positionMs = 120_500))
}
@Test
fun newTrackResetsAndScrobblesAgain() {
val first = play(track(id = "s1"), toPositionMs = 100_000)
assertEquals(Scrobble.Submission("s1", 1_000L), first.last())
clock = 60_000L
val second = play(track(id = "s2"), toPositionMs = 100_000)
assertEquals(Scrobble.NowPlaying("s2"), second.first())
assertEquals(Scrobble.Submission("s2", 60_000L), second.last())
}
@Test
fun restartingTheSameTrackScrobblesAgain() {
val item = track(durationMs = 200_000)
play(item, toPositionMs = 100_000)
clock = 120_000L
val repeat = play(item, toPositionMs = 100_000)
assertEquals(Scrobble.NowPlaying("s1"), repeat.first())
assertEquals(Scrobble.Submission("s1", 120_000L), repeat.last())
}
}
@@ -0,0 +1,91 @@
package com.subtracks.playback
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
import com.subtracks.data.source.MusicSource
import com.subtracks.data.source.StarType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ScrobblerTest {
private data class Call(
val songId: String,
val submission: Boolean,
val time: Long?,
)
private class RecordingSource : MusicSource {
override val id: Long = 1
val calls = mutableListOf<Call>()
override suspend fun ping() = Unit
override suspend fun setStar(
type: StarType,
id: String,
starred: Boolean,
) = Unit
override suspend fun scrobble(
songId: String,
submission: Boolean,
time: Long?,
) {
calls += Call(songId, submission, time)
}
override fun artists(): Flow<List<Artist>> = emptyFlow()
override fun albums(): Flow<List<Album>> = emptyFlow()
override fun songs(): Flow<List<Song>> = emptyFlow()
override fun playlists(): Flow<List<Playlist>> = emptyFlow()
override fun playlistSongs(playlistIds: List<String>): Flow<List<PlaylistSong>> = emptyFlow()
}
@Test
fun sendsNowPlayingThenScrobbleOnce() =
runTest {
val source = RecordingSource()
val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + SupervisorJob())
val scrobbler = Scrobbler(source = { source }, scope = scope, policy = ScrobblePolicy(now = { 1_000L }))
val state = MutableStateFlow(PlaybackState())
val position = MutableStateFlow(0L)
scrobbler.attach(state, position)
val item = QueueItem("s1", "Title", "Artist", "Album", null, durationMs = 30_000)
state.value = PlaybackState(item = item, isPlaying = true)
assertEquals(listOf(Call("s1", submission = false, time = null)), source.calls)
var tick = 500L
while (tick <= 15_000L) {
position.value = tick
tick += 500
}
assertEquals(
listOf(
Call("s1", submission = false, time = null),
Call("s1", submission = true, time = 1_000L),
),
source.calls,
)
position.value = 25_000L
assertEquals(2, source.calls.size)
}
}