diff --git a/app/src/main/kotlin/com/subtracks/data/source/MusicSource.kt b/app/src/main/kotlin/com/subtracks/data/source/MusicSource.kt index e784b9b8..141f5034 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/MusicSource.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/MusicSource.kt @@ -20,6 +20,12 @@ interface MusicSource { starred: Boolean, ) + suspend fun scrobble( + songId: String, + submission: Boolean, + time: Long? = null, + ) + fun artists(): Flow> fun albums(): Flow> diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt index 8726d4e8..af679ced 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt @@ -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> = entityBatches( method = "getArtists", diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index c93e30b1..901a09fb 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -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 { MediaSessionConnection(context.applicationContext, get(), get()) } single { PlaybackController(get(), get(), get(), get(), showMessage = toast) } + single(createdAtStart = true) { + val playback = get() + Scrobbler(source = { get().activeMusicSource() }).also { + it.attach(playback.state, playback.positionMs) + } + } viewModel { RootViewModel(get()) } viewModel { LibraryViewModel(get(), get(), get(), get(), get(), get()) } viewModel { SettingsViewModel(get(), get()) } diff --git a/app/src/main/kotlin/com/subtracks/playback/Scrobbler.kt b/app/src/main/kotlin/com/subtracks/playback/Scrobbler.kt new file mode 100644 index 00000000..8093c123 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/playback/Scrobbler.kt @@ -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, + positionMs: StateFlow, + ) { + 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) { + } + } +} diff --git a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt index 91dbcef5..8f5570e8 100644 --- a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt @@ -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("")) + + 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("")) + + 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("/"), diff --git a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt index d0f979cc..4e89b896 100644 --- a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt @@ -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> = batches(artists) override fun albums(): Flow> = batches(albums) diff --git a/app/src/test/kotlin/com/subtracks/playback/ScrobblePolicyTest.kt b/app/src/test/kotlin/com/subtracks/playback/ScrobblePolicyTest.kt new file mode 100644 index 00000000..80f7c6b5 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/playback/ScrobblePolicyTest.kt @@ -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 { + val events = mutableListOf() + 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.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().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.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.NowPlaying("s1")), events) + } + + @Test + fun submitsOnlyOncePerTrack() { + val events = play(track(durationMs = 200_000), toPositionMs = 200_000) + assertEquals(1, events.filterIsInstance().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()) + } +} diff --git a/app/src/test/kotlin/com/subtracks/playback/ScrobblerTest.kt b/app/src/test/kotlin/com/subtracks/playback/ScrobblerTest.kt new file mode 100644 index 00000000..8d27d3fe --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/playback/ScrobblerTest.kt @@ -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() + + 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> = emptyFlow() + + override fun albums(): Flow> = emptyFlow() + + override fun songs(): Flow> = emptyFlow() + + override fun playlists(): Flow> = emptyFlow() + + override fun playlistSongs(playlistIds: List): Flow> = 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) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index f2d79601..6b20d38a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. -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.