Queue stars and scrobbles while the server is unreachable

Actions that cannot be delivered are persisted in a pending_actions
table and replayed once the server is reachable again. Stars coalesce so
only the latest state per item is sent, and replay happens on the next
successful action, when offline mode is turned off, or before a sync.
This commit is contained in:
austinried
2026-10-04 00:37:29 +00:00
parent eb47748ee4
commit 0e70268129
14 changed files with 2083 additions and 9 deletions
File diff suppressed because it is too large Load Diff
@@ -369,6 +369,21 @@ val MIGRATION_25_26 =
} }
} }
val MIGRATION_26_27 =
object : Migration(26, 27) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `pending_actions` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, `targetId` TEXT NOT NULL, " +
"`starType` TEXT, `time` INTEGER NOT NULL DEFAULT 0, " +
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
)
connection.execSQL(
"CREATE INDEX IF NOT EXISTS `index_pending_actions_sourceId` ON `pending_actions` (`sourceId`)",
)
}
}
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities. // Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
private suspend fun createSearchIndex( private suspend fun createSearchIndex(
connection: SQLiteConnection, connection: SQLiteConnection,
@@ -428,4 +443,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_23_24, MIGRATION_23_24,
MIGRATION_24_25, MIGRATION_24_25,
MIGRATION_25_26, MIGRATION_25_26,
MIGRATION_26_27,
) )
@@ -0,0 +1,28 @@
package com.subtracks.data.db
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.Query
import com.subtracks.data.model.PendingAction
@Dao
interface PendingActionDao {
@Query("SELECT * FROM pending_actions WHERE sourceId = :sourceId ORDER BY id")
suspend fun pending(sourceId: Long): List<PendingAction>
@Insert
suspend fun insert(action: PendingAction): Long
@Query("DELETE FROM pending_actions WHERE id = :id")
suspend fun delete(id: Long)
@Query(
"DELETE FROM pending_actions WHERE sourceId = :sourceId " +
"AND kind IN ('Star', 'Unstar') AND starType = :starType AND targetId = :targetId",
)
suspend fun clearStar(
sourceId: Long,
starType: String,
targetId: String,
)
}
@@ -11,6 +11,8 @@ import com.subtracks.data.model.ArtworkSeed
import com.subtracks.data.model.Disc import com.subtracks.data.model.Disc
import com.subtracks.data.model.DownloadErrorConverter import com.subtracks.data.model.DownloadErrorConverter
import com.subtracks.data.model.DownloadStatusConverter import com.subtracks.data.model.DownloadStatusConverter
import com.subtracks.data.model.PendingAction
import com.subtracks.data.model.PendingActionKindConverter
import com.subtracks.data.model.PlaybackCursor import com.subtracks.data.model.PlaybackCursor
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSearch import com.subtracks.data.model.PlaylistSearch
@@ -45,11 +47,17 @@ import com.subtracks.data.model.UpNextEntry
PlaybackCursor::class, PlaybackCursor::class,
ArtworkSeed::class, ArtworkSeed::class,
SongDownload::class, SongDownload::class,
PendingAction::class,
], ],
version = 26, version = 27,
exportSchema = true, exportSchema = true,
) )
@ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class, DownloadErrorConverter::class) @ColumnTypeConverters(
QueueKindConverter::class,
DownloadStatusConverter::class,
DownloadErrorConverter::class,
PendingActionKindConverter::class,
)
abstract class SubtracksDatabase : RoomDatabase() { abstract class SubtracksDatabase : RoomDatabase() {
abstract fun libraryDao(): LibraryDao abstract fun libraryDao(): LibraryDao
@@ -60,4 +68,6 @@ abstract class SubtracksDatabase : RoomDatabase() {
abstract fun artworkSeedDao(): ArtworkSeedDao abstract fun artworkSeedDao(): ArtworkSeedDao
abstract fun downloadDao(): DownloadDao abstract fun downloadDao(): DownloadDao
abstract fun pendingActionDao(): PendingActionDao
} }
@@ -287,6 +287,29 @@ data class SongGenre(
val genre: String, val genre: String,
) )
enum class PendingActionKind { Scrobble, Star, Unstar }
@Entity(
tableName = "pending_actions",
foreignKeys = [
ForeignKey(
entity = Source::class,
parentColumns = ["id"],
childColumns = ["sourceId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("sourceId")],
)
data class PendingAction(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val sourceId: Long,
val kind: PendingActionKind,
val targetId: String,
val starType: String? = null,
@ColumnInfo(defaultValue = "0") val time: Long = 0,
)
sealed interface SongItem { sealed interface SongItem {
val song: Song val song: Song
val coverArt: String? val coverArt: String?
@@ -442,3 +465,12 @@ class QueueKindConverter {
@ColumnTypeConverter @ColumnTypeConverter
fun toQueueKind(value: String): QueueKind = QueueKind.entries.firstOrNull { it.name == value } ?: QueueKind.Song fun toQueueKind(value: String): QueueKind = QueueKind.entries.firstOrNull { it.name == value } ?: QueueKind.Song
} }
class PendingActionKindConverter {
@ColumnTypeConverter
fun fromPendingActionKind(kind: PendingActionKind): String = kind.name
@ColumnTypeConverter
fun toPendingActionKind(value: String): PendingActionKind =
PendingActionKind.entries.firstOrNull { it.name == value } ?: PendingActionKind.Scrobble
}
@@ -25,4 +25,6 @@ class NetworkServerActionSink(
val source = sources.activeMusicSource() ?: throw IllegalStateException("No active server") val source = sources.activeMusicSource() ?: throw IllegalStateException("No active server")
source.setStar(type, id, starred) source.setStar(type, id, starred)
} }
override suspend fun flush() = Unit
} }
@@ -0,0 +1,135 @@
package com.subtracks.data.repo
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.PendingAction
import com.subtracks.data.model.PendingActionKind
import com.subtracks.data.source.ServerActionSink
import com.subtracks.data.source.StarType
import com.subtracks.data.source.subsonic.SubsonicException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.IOException
class PendingServerActionSink(
private val delegate: ServerActionSink,
private val db: SubtracksDatabase,
private val activeSourceId: suspend () -> Long?,
private val offline: StateFlow<Boolean>,
scope: CoroutineScope,
) : ServerActionSink {
private val flushLock = Mutex()
init {
scope.launch {
// The first emission is the StateFlow's default, not necessarily the persisted value, and
// replaying onto the network must not race a cold start with offline mode on.
offline
.drop(1)
.collect { isOffline -> if (!isOffline) flush() }
}
}
override suspend fun nowPlaying(songId: String) {
if (offline.value) return
try {
delegate.nowPlaying(songId)
flush()
} catch (cancellation: CancellationException) {
throw cancellation
} catch (_: Exception) {
}
}
override suspend fun scrobble(
songId: String,
time: Long,
) {
if (offline.value) {
queue(PendingActionKind.Scrobble, songId, null, time)
return
}
try {
delegate.scrobble(songId, time)
flush()
} catch (cancellation: CancellationException) {
throw cancellation
} catch (_: Exception) {
queue(PendingActionKind.Scrobble, songId, null, time)
}
}
override suspend fun setStar(
type: StarType,
id: String,
starred: Boolean,
) {
val kind = if (starred) PendingActionKind.Star else PendingActionKind.Unstar
if (offline.value) {
if (!queue(kind, id, type.name, 0)) throw IllegalStateException("No active server")
return
}
try {
delegate.setStar(type, id, starred)
flush()
} catch (cancellation: CancellationException) {
throw cancellation
} catch (rejected: SubsonicException) {
// The server answered and refused; retrying the same request will not help.
throw rejected
} catch (failure: Exception) {
if (!queue(kind, id, type.name, 0)) throw failure
}
}
override suspend fun flush() {
flushLock.withLock {
if (offline.value) return
val sourceId = activeSourceId() ?: return
val dao = db.pendingActionDao()
for (action in dao.pending(sourceId)) {
try {
deliver(action)
} catch (cancellation: CancellationException) {
throw cancellation
} catch (_: IOException) {
// Still unreachable: keep this and the remaining actions for the next attempt.
return
} catch (_: Exception) {
// The server answered and refused; drop the action rather than block the queue.
}
dao.delete(action.id)
}
}
}
private suspend fun deliver(action: PendingAction) {
when (action.kind) {
PendingActionKind.Scrobble -> {
delegate.scrobble(action.targetId, action.time)
}
PendingActionKind.Star, PendingActionKind.Unstar -> {
val type = StarType.valueOf(action.starType ?: error("Star action without a type"))
delegate.setStar(type, action.targetId, action.kind == PendingActionKind.Star)
}
}
}
private suspend fun queue(
kind: PendingActionKind,
targetId: String,
starType: String?,
time: Long,
): Boolean {
val sourceId = activeSourceId() ?: return false
val dao = db.pendingActionDao()
if (starType != null) dao.clearStar(sourceId, starType, targetId)
dao.insert(PendingAction(sourceId = sourceId, kind = kind, targetId = targetId, starType = starType, time = time))
return true
}
}
@@ -1,9 +1,8 @@
package com.subtracks.data.source package com.subtracks.data.source
/** /**
* Writes the app pushes back to the server. Today they go straight over the * Writes the app pushes back to the server. Actions that could not be delivered
* network, but funneling them through one seam leaves room to persist them and * while the server was unreachable are persisted and replayed by [flush].
* replay later while the server is unreachable.
*/ */
interface ServerActionSink { interface ServerActionSink {
suspend fun nowPlaying(songId: String) suspend fun nowPlaying(songId: String)
@@ -18,4 +17,6 @@ interface ServerActionSink {
id: String, id: String,
starred: Boolean, starred: Boolean,
) )
suspend fun flush()
} }
@@ -6,6 +6,7 @@ import com.subtracks.UiMessage
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.ServerActionSink
import com.subtracks.data.source.subsonic.SubsonicException import com.subtracks.data.source.subsonic.SubsonicException
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -32,6 +33,7 @@ class SyncManager(
private val db: SubtracksDatabase, private val db: SubtracksDatabase,
private val sourceRepository: SourceRepository, private val sourceRepository: SourceRepository,
private val queueRepository: QueueRepository, private val queueRepository: QueueRepository,
private val serverActions: ServerActionSink,
private val showMessage: (UiMessage) -> Unit = {}, private val showMessage: (UiMessage) -> Unit = {},
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
) { ) {
@@ -60,6 +62,7 @@ class SyncManager(
val result = val result =
try { try {
val source = sourceRepository.activeMusicSource() ?: throw NoServerException() val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
serverActions.flush()
SyncService(db, source).sync() SyncService(db, source).sync()
SyncStatus.Success SyncStatus.Success
} catch (cancellation: CancellationException) { } catch (cancellation: CancellationException) {
@@ -22,6 +22,7 @@ import com.subtracks.data.repo.ArtworkSeedStore
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.NetworkServerActionSink import com.subtracks.data.repo.NetworkServerActionSink
import com.subtracks.data.repo.PendingServerActionSink
import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.ServerActionSink import com.subtracks.data.source.ServerActionSink
@@ -77,10 +78,18 @@ fun appModule(
scope = get(), scope = get(),
) )
} }
single<ServerActionSink> { NetworkServerActionSink(get()) } single<ServerActionSink> {
PendingServerActionSink(
delegate = NetworkServerActionSink(get()),
db = get(),
activeSourceId = get<SourceRepository>()::activeSourceIdOnce,
offline = get<SourceRepository>().offline,
scope = get(),
)
}
single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) } single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) }
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) } single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
single { SyncManager(get(), get(), get(), showMessage = toast, scope = get()) } single { SyncManager(get(), get(), get(), get(), showMessage = toast, scope = get()) }
single { QueueRepository(get()) } single { QueueRepository(get()) }
single { ArtworkStore(downloadsRoot(context)) } single { ArtworkStore(downloadsRoot(context)) }
single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) } single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) }
@@ -0,0 +1,189 @@
package com.subtracks.data.repo
import android.content.Context
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.PendingActionKind
import com.subtracks.data.model.Source
import com.subtracks.data.source.ServerActionSink
import com.subtracks.data.source.StarType
import com.subtracks.data.source.subsonic.SubsonicException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
import java.util.concurrent.CopyOnWriteArrayList
@RunWith(AndroidJUnit4::class)
class PendingServerActionSinkTest {
private sealed interface Call {
data class Star(
val type: StarType,
val id: String,
val starred: Boolean,
) : Call
data class Scrobble(
val songId: String,
val time: Long,
) : Call
}
private class RecordingSink : ServerActionSink {
val calls = CopyOnWriteArrayList<Call>()
var starFailure: Exception? = null
var scrobbleFailure: Exception? = null
override suspend fun nowPlaying(songId: String) = Unit
override suspend fun scrobble(
songId: String,
time: Long,
) {
scrobbleFailure?.let { throw it }
calls += Call.Scrobble(songId, time)
}
override suspend fun setStar(
type: StarType,
id: String,
starred: Boolean,
) {
starFailure?.let { throw it }
calls += Call.Star(type, id, starred)
}
override suspend fun flush() = Unit
}
private lateinit var db: SubtracksDatabase
private lateinit var scope: CoroutineScope
private lateinit var delegate: RecordingSink
private lateinit var sink: PendingServerActionSink
private val offline = MutableStateFlow(true)
private var sourceId = 0L
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
sourceId = runBlocking { db.sourcesDao().insertSource(Source(name = "server", address = "http://x/", createdAt = 0)) }
delegate = RecordingSink()
sink = PendingServerActionSink(delegate, db, activeSourceId = { sourceId }, offline = offline, scope = scope)
}
@After
fun tearDown() {
cancelAndJoinBlocking(scope)
db.close()
}
@Test
fun anOfflineStarIsQueuedAndReplayedWhenOnline() =
runBlocking {
sink.setStar(StarType.Song, "s1", true)
assertEquals(emptyList<Call>(), delegate.calls)
assertEquals(1, db.pendingActionDao().pending(sourceId).size)
offline.value = false
sink.flush()
assertEquals(listOf(Call.Star(StarType.Song, "s1", true)), delegate.calls)
assertEquals(0, db.pendingActionDao().pending(sourceId).size)
}
@Test
fun anOfflineUnstarCoalescesAwayAnEarlierStar() =
runBlocking {
sink.setStar(StarType.Song, "s1", true)
sink.setStar(StarType.Song, "s1", false)
val queued = db.pendingActionDao().pending(sourceId)
assertEquals(1, queued.size)
assertEquals(PendingActionKind.Unstar, queued.single().kind)
offline.value = false
sink.flush()
assertEquals(listOf(Call.Star(StarType.Song, "s1", false)), delegate.calls)
}
@Test
fun offlineScrobblesReplayInOrderWithTheirTimes() =
runBlocking {
sink.scrobble("s1", 1_000)
sink.scrobble("s2", 2_000)
offline.value = false
sink.flush()
assertEquals(listOf(Call.Scrobble("s1", 1_000), Call.Scrobble("s2", 2_000)), delegate.calls)
}
@Test
fun aConnectionFailureQueuesTheScrobbleAndFlushRetries() =
runBlocking {
offline.value = false
delegate.scrobbleFailure = IOException("down")
sink.scrobble("s1", 1_000)
assertEquals(emptyList<Call>(), delegate.calls)
assertEquals(1, db.pendingActionDao().pending(sourceId).size)
delegate.scrobbleFailure = null
sink.flush()
assertEquals(listOf(Call.Scrobble("s1", 1_000)), delegate.calls)
assertEquals(0, db.pendingActionDao().pending(sourceId).size)
}
@Test
fun aStarTheServerRejectsIsNotQueued() =
runBlocking {
offline.value = false
delegate.starFailure = SubsonicException(40, "nope")
assertThrows(SubsonicException::class.java) {
runBlocking { sink.setStar(StarType.Song, "s1", true) }
}
assertEquals(0, db.pendingActionDao().pending(sourceId).size)
}
@Test
fun flushKeepsQueuedActionsWhileTheServerIsStillUnreachable() =
runBlocking {
sink.setStar(StarType.Song, "s1", true)
offline.value = false
delegate.starFailure = IOException("down")
sink.flush()
assertEquals(1, db.pendingActionDao().pending(sourceId).size)
delegate.starFailure = null
sink.flush()
assertEquals(listOf(Call.Star(StarType.Song, "s1", true)), delegate.calls)
assertEquals(0, db.pendingActionDao().pending(sourceId).size)
}
}
@@ -11,6 +11,7 @@ import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.prefs.fakeUserPreferences import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.NetworkServerActionSink
import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -61,7 +62,15 @@ class SyncManagerTest {
sourceRepository = sourceRepository =
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope) SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
queueRepository = QueueRepository(db) queueRepository = QueueRepository(db)
manager = SyncManager(db, sourceRepository, queueRepository, showMessage = { messages += it.resolve(resources) }, scope = scope) manager =
SyncManager(
db,
sourceRepository,
queueRepository,
NetworkServerActionSink(sourceRepository),
showMessage = { messages += it.resolve(resources) },
scope = scope,
)
server = MockWebServer() server = MockWebServer()
server.start() server.start()
} }
@@ -48,6 +48,8 @@ class ScrobblerTest {
id: String, id: String,
starred: Boolean, starred: Boolean,
) = Unit ) = Unit
override suspend fun flush() = Unit
} }
private fun TestScope.testScope() = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + SupervisorJob()) private fun TestScope.testScope() = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + SupervisorJob())
@@ -13,6 +13,7 @@ import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.prefs.fakeUserPreferences import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.NetworkServerActionSink
import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.sync.SyncManager import com.subtracks.data.sync.SyncManager
@@ -154,7 +155,8 @@ class AddSourceViewModelTest {
} }
private fun viewModel(sourceId: Long? = null): AddSourceViewModel { private fun viewModel(sourceId: Long? = null): AddSourceViewModel {
val syncManager = SyncManager(db, sourceRepository, QueueRepository(db), scope = repoScope) val syncManager =
SyncManager(db, sourceRepository, QueueRepository(db), NetworkServerActionSink(sourceRepository), scope = repoScope)
val resources = ApplicationProvider.getApplicationContext<Context>().resources val resources = ApplicationProvider.getApplicationContext<Context>().resources
return AddSourceViewModel(sourceRepository, syncManager, downloadRepository, sourceId, resources).also { createdViewModels += it } return AddSourceViewModel(sourceRepository, syncManager, downloadRepository, sourceId, resources).also { createdViewModels += it }
} }