address review: count a play even when its scrobble fails

A submitted scrobble now counts locally regardless of the HTTP result
(offline already did), so a transient server error no longer drops the
play from the sorts. Last-played uses MAX(existing, at) so a backwards
clock step cannot move an album or artist back down the list.
This commit is contained in:
austinried
2026-10-04 00:30:05 +00:00
parent 95ac4ae6ce
commit 4d4eb7b758
5 changed files with 64 additions and 15 deletions
@@ -179,14 +179,20 @@ interface LibraryDao {
recomputeArtistPlayData(sourceId)
}
@Query("UPDATE songs SET playCount = playCount + 1, played = :at WHERE sourceId = :sourceId AND id = :songId")
@Query(
"UPDATE songs SET playCount = playCount + 1, played = MAX(COALESCE(played, :at), :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")
@Query(
"UPDATE albums SET playCount = playCount + 1, played = MAX(COALESCE(played, :at), :at) " +
"WHERE sourceId = :sourceId AND id = :albumId",
)
suspend fun bumpAlbumPlay(
sourceId: Long,
albumId: String,
@@ -194,8 +200,8 @@ interface LibraryDao {
)
@Query(
"UPDATE artists SET playCount = playCount + 1, played = :at WHERE sourceId = :sourceId " +
"AND id = (SELECT artistId FROM albums WHERE sourceId = :sourceId AND id = :albumId)",
"UPDATE artists SET playCount = playCount + 1, played = MAX(COALESCE(played, :at), :at) " +
"WHERE sourceId = :sourceId AND id = (SELECT artistId FROM albums WHERE sourceId = :sourceId AND id = :albumId)",
)
suspend fun bumpArtistPlay(
sourceId: Long,
@@ -126,17 +126,21 @@ 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)
recordPlay(event.songId, event.time / 1000)
}
when (event) {
is Scrobble.NowPlaying -> {
attempt { sink.nowPlaying(event.songId) }
}
is Scrobble.Submission -> {
attempt { recordPlay(event.songId, event.time / 1000) }
attempt { sink.scrobble(event.songId, event.time) }
}
}
}
private suspend fun attempt(block: suspend () -> Unit) {
try {
block()
} catch (cancellation: CancellationException) {
throw cancellation
} catch (_: Exception) {
@@ -230,6 +230,13 @@ class LibraryDaoTest {
assertEquals(500L, dao.albumOnce(sourceId, "al-1")!!.played)
assertEquals(1L, dao.artistOnce(sourceId, "ar-1")!!.playCount)
assertEquals(500L, dao.artistOnce(sourceId, "ar-1")!!.played)
dao.recordPlay(sourceId, "s1", 100)
assertEquals(2L, dao.songOnce(sourceId, "s1")!!.playCount)
assertEquals(500L, dao.songOnce(sourceId, "s1")!!.played)
assertEquals(500L, dao.albumOnce(sourceId, "al-1")!!.played)
assertEquals(500L, dao.artistOnce(sourceId, "ar-1")!!.played)
}
@Test
@@ -12,6 +12,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class ScrobblerTest {
@@ -28,6 +29,7 @@ class ScrobblerTest {
private class RecordingSink : ServerActionSink {
val calls = mutableListOf<Call>()
var failScrobble = false
override suspend fun nowPlaying(songId: String) {
calls += Call.NowPlaying(songId)
@@ -37,6 +39,7 @@ class ScrobblerTest {
songId: String,
time: Long,
) {
if (failScrobble) throw IOException("scrobble failed")
calls += Call.Scrobble(songId, time)
}
@@ -136,6 +139,35 @@ class ScrobblerTest {
assertEquals(listOf("s1" to 1L), recorded)
}
@Test
fun aFailedSubmissionStillRecordsThePlayLocally() =
runTest {
val sink = RecordingSink().apply { failScrobble = true }
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)
var tick = 500L
while (tick <= 15_000L) {
position.value = tick
tick += 500
}
assertEquals(emptyList<Call>(), sink.calls.filterIsInstance<Call.Scrobble>())
assertEquals(listOf("s1" to 1L), recorded)
}
@Test
fun disabledSendsNothing() =
runTest {