count a play locally when it is scrobbled

A submitted scrobble now bumps the song's playCount/played and its album
and artist ranks, so the play sorts move without waiting for a sync. The
sync then overwrites them with the server's own numbers, so the
increment cannot double-count.
This commit is contained in:
austinried
2026-10-04 00:30:05 +00:00
parent 06ca2da48d
commit d3aaeca405
7 changed files with 116 additions and 4 deletions
@@ -179,6 +179,50 @@ interface LibraryDao {
recomputeArtistPlayData(sourceId)
}
@Query("UPDATE songs SET playCount = playCount + 1, played = :at WHERE sourceId = :sourceId AND id = :songId")
suspend fun bumpSongPlay(
sourceId: Long,
songId: String,
at: Long,
)
@Query("UPDATE albums SET playCount = playCount + 1, played = :at WHERE sourceId = :sourceId AND id = :albumId")
suspend fun bumpAlbumPlay(
sourceId: Long,
albumId: String,
at: Long,
)
@Query(
"UPDATE artists SET playCount = playCount + 1, played = :at WHERE sourceId = :sourceId " +
"AND id = (SELECT artistId FROM albums WHERE sourceId = :sourceId AND id = :albumId)",
)
suspend fun bumpArtistPlay(
sourceId: Long,
albumId: String,
at: Long,
)
@Query("SELECT albumId FROM songs WHERE sourceId = :sourceId AND id = :songId")
suspend fun albumIdForSong(
sourceId: Long,
songId: String,
): String?
// Counted locally when a scrobble is submitted, so the play sorts move before the next sync; the
// sync then overwrites with the server's own number.
@Transaction
suspend fun recordPlay(
sourceId: Long,
songId: String,
at: Long,
) {
bumpSongPlay(sourceId, songId, at)
val albumId = albumIdForSong(sourceId, songId) ?: return
bumpAlbumPlay(sourceId, albumId, at)
bumpArtistPlay(sourceId, albumId, at)
}
// Albums, not songs: these flows re-run on every write to their table, and songs are written
// one batch at a time during a sync.
@Query("SELECT EXISTS(SELECT 1 FROM albums WHERE sourceId = :sourceId AND playCount > 0)")
@@ -217,6 +217,14 @@ class LibraryRepository(
songId: String,
): Flow<Song?> = db.libraryDao().song(sourceId, songId)
suspend fun recordPlay(
songId: String,
at: Long,
) {
val sourceId = sourceRepository.activeSourceIdOnce() ?: return
db.libraryDao().recordPlay(sourceId, songId, at)
}
fun artistAlbums(
sourceId: Long,
artistId: String,
@@ -78,7 +78,11 @@ fun appModule(
single { PlaybackController(get(), get(), get(), get(), showMessage = toast) }
single(createdAtStart = true) {
val playback = get<PlaybackController>()
Scrobbler(sink = get(), enabled = get<UserPreferences>().scrobbling()).also {
Scrobbler(
sink = get(),
enabled = get<UserPreferences>().scrobbling(),
recordPlay = get<LibraryRepository>()::recordPlay,
).also {
it.attach(playback.state, playback.positionMs)
}
}
@@ -103,6 +103,7 @@ class Scrobbler(
private val enabled: Flow<Boolean>,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
private val policy: ScrobblePolicy = ScrobblePolicy(),
private val recordPlay: suspend (songId: String, at: Long) -> Unit = { _, _ -> },
) {
private val submitLock = Mutex()
@@ -127,8 +128,14 @@ class Scrobbler(
private suspend fun submit(event: Scrobble) {
try {
when (event) {
is Scrobble.NowPlaying -> sink.nowPlaying(event.songId)
is Scrobble.Submission -> sink.scrobble(event.songId, event.time)
is Scrobble.NowPlaying -> {
sink.nowPlaying(event.songId)
}
is Scrobble.Submission -> {
sink.scrobble(event.songId, event.time)
recordPlay(event.songId, event.time / 1000)
}
}
} catch (cancellation: CancellationException) {
throw cancellation
@@ -213,6 +213,25 @@ class LibraryDaoTest {
)
}
@Test
fun aRecordedPlayBumpsTheSongAlbumAndArtist() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 1)))
dao.upsertAlbums(listOf(album(sourceId, "al-1", "Album", year = null, starred = null)))
dao.upsertSongs(listOf(song(sourceId, "s1", "One", starred = null)))
dao.recordPlay(sourceId, "s1", 500)
assertEquals(1L, dao.songOnce(sourceId, "s1")!!.playCount)
assertEquals(500L, dao.songOnce(sourceId, "s1")!!.played)
assertEquals(1L, dao.albumOnce(sourceId, "al-1")!!.playCount)
assertEquals(500L, dao.albumOnce(sourceId, "al-1")!!.played)
assertEquals(1L, dao.artistOnce(sourceId, "ar-1")!!.playCount)
assertEquals(500L, dao.artistOnce(sourceId, "ar-1")!!.played)
}
@Test
fun reversedAlbumSortsReverseEveryTiebreaker() =
runTest {
@@ -106,6 +106,36 @@ class ScrobblerTest {
)
}
@Test
fun aSubmissionRecordsThePlayLocally() =
runTest {
val sink = RecordingSink()
val recorded = mutableListOf<Pair<String, Long>>()
val scrobbler =
Scrobbler(
sink = sink,
enabled = flowOf(true),
scope = testScope(),
policy = ScrobblePolicy(now = { 1_000L }),
recordPlay = { songId, at -> recorded += songId to at },
)
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(emptyList<Pair<String, Long>>(), recorded)
var tick = 500L
while (tick <= 15_000L) {
position.value = tick
tick += 500
}
assertEquals(listOf("s1" to 1L), recorded)
}
@Test
fun disabledSendsNothing() =
runTest {