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