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, starred: Boolean,
) )
suspend fun scrobble(
songId: String,
submission: Boolean,
time: Long? = null,
)
fun artists(): Flow<List<Artist>> fun artists(): Flow<List<Artist>>
fun albums(): Flow<List<Album>> 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>> = override fun artists(): Flow<List<Artist>> =
entityBatches( entityBatches(
method = "getArtists", method = "getArtists",
@@ -24,6 +24,7 @@ import com.subtracks.data.sync.SyncManager
import com.subtracks.playback.MediaSessionConnection import com.subtracks.playback.MediaSessionConnection
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.playback.PlayerConnection import com.subtracks.playback.PlayerConnection
import com.subtracks.playback.Scrobbler
import com.subtracks.ui.RootViewModel import com.subtracks.ui.RootViewModel
import com.subtracks.ui.downloads.DownloadsViewModel import com.subtracks.ui.downloads.DownloadsViewModel
import com.subtracks.ui.library.AlbumDetailViewModel import com.subtracks.ui.library.AlbumDetailViewModel
@@ -71,6 +72,12 @@ fun appModule(
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get()).also { it.start() } } single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get()).also { it.start() } }
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) } single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
single { PlaybackController(get(), get(), get(), get(), showMessage = toast) } 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 { RootViewModel(get()) }
viewModel { LibraryViewModel(get(), get(), get(), get(), get(), get()) } viewModel { LibraryViewModel(get(), get(), get(), get(), get(), get()) }
viewModel { SettingsViewModel(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.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@@ -545,6 +546,33 @@ class SubsonicSourceTest {
assertFalse(uri, uri.contains("estimateContentLength")) 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() = private fun client() =
SubsonicClient( SubsonicClient(
baseUrl = server.url("/"), baseUrl = server.url("/"),
@@ -730,6 +730,12 @@ private class FakeMusicSource(
starred: Boolean, starred: Boolean,
) = Unit ) = Unit
override suspend fun scrobble(
songId: String,
submission: Boolean,
time: Long?,
) = Unit
override fun artists(): Flow<List<Artist>> = batches(artists) override fun artists(): Flow<List<Artist>> = batches(artists)
override fun albums(): Flow<List<Album>> = batches(albums) 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)
}
}
+3 -1
View File
@@ -90,7 +90,9 @@ Because the resolver reports *which entry* a position falls in, `PlaybackState`
Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances.
The window is also the queue the media notification and Android Auto can browse — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics/gapless format preferences, scrobbling and downloads are not wired yet. The window is also the queue the media notification and Android Auto can browse — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics and gapless format preferences are not wired yet.
Scrobbling is a `Scrobbler` that collects the controller's `state` and `positionMs` and drives the Subsonic `scrobble` endpoint: a now-playing notification (`submission=false`) when a track starts playing, and one submission (`submission=true`, with the playback start time) when the track passes half its duration or four minutes, whichever comes first. Tracks under 30 seconds are never submitted, and position jumps are treated as seeks rather than listened time. The policy is a pure state machine with no Android dependencies, so the threshold logic is unit-tested directly.
The media notification carries a star button for the playing track. `PlaybackService` adds a `com.subtracks.STAR` session command and a `CommandButton` to the `MediaSession`, and drives its filled/unfilled icon from the playing song's Room row. That path — `MediaSession.Callback`, `SessionCommand`, `CommandButton`, `SessionResult`/`SessionError` and `MediaSession.setMediaButtonPreferences` — is annotated `@UnstableApi` in Media3 1.11.1 and is therefore opted into explicitly: when bumping Media3, re-check those signatures and the notification's button resolution. The stable `setCustomLayout` is not a substitute here, because the notification manager only refreshes on `onMediaButtonPreferencesChanged`, so the icon would not update mid-playback. The media notification carries a star button for the playing track. `PlaybackService` adds a `com.subtracks.STAR` session command and a `CommandButton` to the `MediaSession`, and drives its filled/unfilled icon from the playing song's Room row. That path — `MediaSession.Callback`, `SessionCommand`, `CommandButton`, `SessionResult`/`SessionError` and `MediaSession.setMediaButtonPreferences` — is annotated `@UnstableApi` in Media3 1.11.1 and is therefore opted into explicitly: when bumping Media3, re-check those signatures and the notification's button resolution. The stable `setCustomLayout` is not a substitute here, because the notification manager only refreshes on `onMediaButtonPreferencesChanged`, so the icon would not update mid-playback.