sync: reset queues only for successfully changed origins

This commit is contained in:
austinried
2026-11-14 12:05:28 +00:00
parent acc820e263
commit 548bf39136
4 changed files with 942 additions and 73 deletions
@@ -3,8 +3,10 @@ package com.subtracks.data.sync
import com.subtracks.R import com.subtracks.R
import com.subtracks.UiMessage import com.subtracks.UiMessage
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.QueueOrigin
import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.QueueResetPoint
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.ServerActionSink import com.subtracks.data.source.ServerActionSink
import com.subtracks.data.source.subsonic.SubsonicException import com.subtracks.data.source.subsonic.SubsonicException
@@ -81,17 +83,25 @@ class SyncManager(
runCatching { preferences.setLastSyncAt(System.currentTimeMillis()) } runCatching { preferences.setLastSyncAt(System.currentTimeMillis()) }
.onFailure { Log.w("sync", "could not record the sync attempt", it) } .onFailure { Log.w("sync", "could not record the sync attempt", it) }
val startedAt = System.nanoTime() val startedAt = System.nanoTime()
var summary: SyncSummary? = null
var resetPoint: QueueResetPoint? = null
var syncSourceId: Long? = null
val result = val result =
try { try {
val source = sourceRepository.activeMusicSource() ?: throw NoServerException() val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
Log.i("sync", "started (source ${source.id})") Log.i("sync", "started (source ${source.id})")
serverActions.flush() serverActions.flush()
val summary = SyncService(db, source).sync() resetPoint = queueRepository.captureResetPoint()
syncSourceId = source.id
val service = SyncService(db, source)
val completed = service.sync()
val pending = db.queueDao().pendingSyncOrigins(source.id).map { QueueOrigin(it.kind, it.sourceId, it.refId) }
summary = completed.copy(changedOrigins = pending.toSet() + completed.changedOrigins)
Log.i( Log.i(
"sync", "sync",
"finished in ${elapsedMs(startedAt)}ms: " + "finished in ${elapsedMs(startedAt)}ms: " +
"${summary.artists} artists, ${summary.albums} albums, ${summary.songs} songs, " + "${completed.artists} artists, ${completed.albums} albums, ${completed.songs} songs, " +
"${summary.playlists} playlists, ${summary.playlistSongs} playlist tracks, ${summary.pruned} pruned", "${completed.playlists} playlists, ${completed.playlistSongs} playlist tracks, ${completed.pruned} pruned",
) )
SyncStatus.Success SyncStatus.Success
} catch (cancellation: CancellationException) { } catch (cancellation: CancellationException) {
@@ -112,7 +122,14 @@ class SyncManager(
if (!silent) showMessage(message) if (!silent) showMessage(message)
SyncStatus.Failed(message) SyncStatus.Failed(message)
} }
queueRepository.invalidateLibraryCache() if (result is SyncStatus.Success) {
val completed = summary
val point = resetPoint
if (completed != null && point != null) {
queueRepository.resetChangedOrigins(completed.changedOrigins, point)
db.queueDao().clearPendingSyncOrigins(sourceId = checkNotNull(syncSourceId))
}
}
_status.value = result _status.value = result
} }
@@ -14,6 +14,8 @@ import com.subtracks.data.model.Disc
import com.subtracks.data.model.DiscKey import com.subtracks.data.model.DiscKey
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.QueueOrigin
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongArtistRef import com.subtracks.data.model.SongArtistRef
import com.subtracks.data.model.SongGenre import com.subtracks.data.model.SongGenre
@@ -30,6 +32,27 @@ class SyncService(
private var playlistsSynced = 0 private var playlistsSynced = 0
private var playlistSongsSynced = 0 private var playlistSongsSynced = 0
private var pruned = 0 private var pruned = 0
private val committedOrigins = mutableSetOf<QueueOrigin>()
private var transactionOrigins: MutableSet<QueueOrigin>? = null
private val changedOrigins =
object : AbstractMutableSet<QueueOrigin>() {
override val size: Int get() = committedOrigins.size
override fun iterator(): MutableIterator<QueueOrigin> = committedOrigins.iterator()
override fun add(element: QueueOrigin): Boolean = (transactionOrigins ?: committedOrigins).add(element)
}
private suspend fun <T> commitOrigins(block: suspend () -> T): T {
check(transactionOrigins == null)
val pending = mutableSetOf<QueueOrigin>()
transactionOrigins = pending
return try {
block().also { committedOrigins.addAll(pending) }
} finally {
transactionOrigins = null
}
}
suspend fun sync(): SyncSummary { suspend fun sync(): SyncSummary {
val sources = db.sourcesDao() val sources = db.sourcesDao()
@@ -51,6 +74,7 @@ class SyncService(
playlists = playlistsSynced, playlists = playlistsSynced,
playlistSongs = playlistSongsSynced, playlistSongs = playlistSongsSynced,
pruned = pruned, pruned = pruned,
changedOrigins = changedOrigins.toSet(),
) )
} }
@@ -61,13 +85,24 @@ class SyncService(
source.artists().collect { batch -> source.artists().collect { batch ->
if (batch.isEmpty()) return@collect if (batch.isEmpty()) return@collect
changed = changed =
write("artists", primaryKey, batch, preserved = setOf("albumCount"), markStale = true) { it.columns() } || write(
"artists",
primaryKey,
batch,
preserved = setOf("albumCount"),
markStale = true,
onChanged = { changedOrigins += origin(QueueKind.Artist, it.id) },
) { it.columns() } ||
changed changed
batch.forEach { seen.add(idHash(it.id)) } batch.forEach { seen.add(idHash(it.id)) }
artistsSynced += batch.size artistsSynced += batch.size
} }
val prunedBefore = pruned val prunedBefore = pruned
pruned += pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteArtists(source.id, it) }) pruned +=
pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, {
it.forEach { id -> changedOrigins += origin(QueueKind.Artist, id) }
deleteSyncedIds("artists", it)
})
return changed || pruned > prunedBefore return changed || pruned > prunedBefore
} }
@@ -92,7 +127,18 @@ class SyncService(
pruneStaleIds( pruneStaleIds(
seenAlbums, seenAlbums,
{ library.albumIdsAfter(source.id, it, PRUNE_PAGE) }, { library.albumIdsAfter(source.id, it, PRUNE_PAGE) },
{ library.deleteAlbums(source.id, it) }, {
it.forEach { id -> changedOrigins += origin(QueueKind.Album, id) }
db.useWriterConnection { connection ->
connection.immediateTransaction {
val artistIds = connection.albumArtistIds(it.toSet()).values.flatten()
val trackArtistIds = connection.songArtistIds(it.toSet()).values.flatten()
changedOrigins += (artistIds + trackArtistIds).map { id -> origin(QueueKind.Artist, id) }
connection.deleteIds("albums", it)
connection.persistPendingOrigins()
}
}
},
markStale = { db.sourcesDao().setCreditsStale(source.id, true) }, markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
) )
pruned += pruned +=
@@ -117,7 +163,9 @@ class SyncService(
pruneStaleIds( pruneStaleIds(
seen, seen,
{ library.songIdsAfter(source.id, it, PRUNE_PAGE) }, { library.songIdsAfter(source.id, it, PRUNE_PAGE) },
{ library.deleteSongs(source.id, it) }, {
deleteSongsAndRecordOrigins(it)
},
markStale = { db.sourcesDao().setCreditsStale(source.id, true) }, markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
) )
return changed || pruned > prunedBefore return changed || pruned > prunedBefore
@@ -133,11 +181,17 @@ class SyncService(
batch.flatMap { song -> batch.flatMap { song ->
song.artistPositions().map { (position, ref) -> SongArtistRef(song.sourceId, song.id, position, ref.id, ref.name) } song.artistPositions().map { (position, ref) -> SongArtistRef(song.sourceId, song.id, position, ref.id, ref.name) }
} }
return db.useWriterConnection { connection -> return commitOrigins {
db.useWriterConnection { connection ->
connection.immediateTransaction { connection.immediateTransaction {
val before = connection.totalChanges() val before = connection.totalChanges()
val previousStates = connection.storedSongSyncStates(batch.map { it.id })
val albumArtists = connection.albumArtistIds(batch.mapNotNull { it.albumId }.toSet())
val resolved = connection.resolveSongSortNames(batch) val resolved = connection.resolveSongSortNames(batch)
connection.upsertChanged("songs", primaryKey, resolved) { it.columns() } val nextStates = resolved.associate { it.id to it.syncState(albumArtists) }
val downloadedIds = connection.completedDownloadIds(batch.map { it.id })
val changedSongIds = mutableSetOf<String>()
connection.upsertChanged("songs", primaryKey, resolved, onChanged = { changedSongIds += it.id }) { it.columns() }
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement -> connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
batch.forEach { song -> batch.forEach { song ->
statement.bind(1, song.sourceId) statement.bind(1, song.sourceId)
@@ -160,9 +214,190 @@ class SyncService(
} }
} }
connection.upsertChanged("song_artists", songArtistKey, artists) { it.columns() } connection.upsertChanged("song_artists", songArtistKey, artists) { it.columns() }
(connection.totalChanges() != before).also { changed -> batch.forEach { song ->
if (changed) connection.markCreditsStale(batch.first().sourceId) val previous = previousStates[song.id]
val next = nextStates.getValue(song.id)
if (song.id in changedSongIds || previous != next) {
recordSongOrigins(previous, next)
} }
if (song.id in downloadedIds && (previous == null || previous.sortName != next.sortName)) {
changedOrigins += origin(QueueKind.Downloaded, "")
}
}
val changed = connection.totalChanges() != before
if (changed) connection.markCreditsStale(batch.first().sourceId)
connection.persistPendingOrigins()
changed
}
}
}
}
private suspend fun deleteSongsAndRecordOrigins(ids: Collection<String>) {
ids.forEach { changedOrigins += origin(QueueKind.Song, it) }
db.useWriterConnection { connection ->
connection.immediateTransaction {
connection.storedSongSyncStates(ids.toList()).values.forEach { recordSongOrigins(it, null) }
if (connection.completedDownloadIds(ids.toList()).isNotEmpty()) changedOrigins += origin(QueueKind.Downloaded, "")
connection.deleteIds("songs", ids)
connection.persistPendingOrigins()
}
}
}
private suspend fun PooledConnection.recordSongOrigins(
previous: SongSyncState?,
next: SongSyncState?,
) {
if ((previous == null) != (next == null)) {
val songId = (previous ?: next)!!.id
usePrepared("SELECT playlistId FROM playlist_songs WHERE sourceId = ? AND songId = ?") { statement ->
statement.bindLong(1, source.id)
statement.bindText(2, songId)
while (statement.step()) changedOrigins += origin(QueueKind.Playlist, statement.getText(0))
}
}
val states = listOfNotNull(previous, next)
states.mapTo(changedOrigins) { origin(QueueKind.Song, it.id) }
states.mapNotNullTo(changedOrigins) { it.albumId?.let { id -> origin(QueueKind.Album, id) } }
states.flatMapTo(changedOrigins) { state -> state.artistIds.map { origin(QueueKind.Artist, it) } }
states.flatMapTo(changedOrigins) { state -> state.genres.map { origin(QueueKind.Genre, it) } }
if (states.any { it.starred }) changedOrigins += origin(QueueKind.Starred, "")
}
private suspend fun PooledConnection.storedSongSyncStates(ids: List<String>): Map<String, SongSyncState> {
val states = mutableMapOf<String, MutableSongSyncState>()
readSourceIds(
ids,
{ "SELECT id, albumId, artistId, sortName, starred FROM songs WHERE sourceId = ? AND id IN ($it)" },
) { statement ->
val state = MutableSongSyncState()
state.albumId = if (statement.isNull(1)) null else statement.getText(1)
if (!statement.isNull(2)) state.artistIds += statement.getText(2)
state.sortName = statement.getText(3)
state.starred = !statement.isNull(4)
states[statement.getText(0)] = state
}
readSourceIds(ids, { "SELECT songId, artistId FROM song_artists WHERE sourceId = ? AND songId IN ($it)" }) { statement ->
states[statement.getText(0)]?.artistIds?.plusAssign(statement.getText(1))
}
readSourceIds(ids, { "SELECT songId, genre FROM song_genres WHERE sourceId = ? AND songId IN ($it)" }) { statement ->
states[statement.getText(0)]?.genres?.plusAssign(statement.getText(1))
}
val albumArtists = albumArtistIds(states.values.mapNotNull { it.albumId }.toSet())
return states.mapValues { (id, state) ->
SongSyncState(
id = id,
albumId = state.albumId,
artistIds = state.artistIds + state.albumId?.let { albumArtists[it].orEmpty() }.orEmpty(),
genres = state.genres,
starred = state.starred,
sortName = state.sortName,
)
}
}
private suspend fun PooledConnection.completedDownloadIds(ids: List<String>): Set<String> {
val result = mutableSetOf<String>()
readSourceIds(
ids,
{ "SELECT songId FROM song_downloads WHERE sourceId = ? AND status = 'Completed' AND songId IN ($it)" },
) { statement ->
result += statement.getText(0)
}
return result
}
private suspend fun PooledConnection.albumArtistIds(albumIds: Set<String>): Map<String, Set<String>> {
val artists = mutableMapOf<String, MutableSet<String>>()
readSourceIds(albumIds, { "SELECT id, artistId FROM albums WHERE sourceId = ? AND artistId IS NOT NULL AND id IN ($it)" }) {
artists.getOrPut(it.getText(0)) { mutableSetOf() } += it.getText(1)
}
readSourceIds(albumIds, { "SELECT albumId, artistId FROM album_artists WHERE sourceId = ? AND albumId IN ($it)" }) {
artists.getOrPut(it.getText(0)) { mutableSetOf() } += it.getText(1)
}
return artists
}
private suspend fun PooledConnection.songArtistIds(albumIds: Set<String>): Map<String, Set<String>> {
val result = mutableMapOf<String, MutableSet<String>>()
readSourceIds(albumIds, {
"SELECT albumId, artistId FROM songs WHERE sourceId = ? AND artistId IS NOT NULL AND albumId IN ($it)"
}) { statement ->
result.getOrPut(statement.getText(0)) { mutableSetOf() } += statement.getText(1)
}
albumIds.chunked(SQL_VARIABLES).forEach { chunk ->
val placeholders = chunk.joinToString(",") { "?" }
usePrepared(
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"WHERE song_artists.sourceId = ? AND songs.albumId IN ($placeholders)",
) { statement ->
statement.bindLong(1, source.id)
chunk.forEachIndexed { index, id -> statement.bindText(index + 2, id) }
while (statement.step()) result.getOrPut(statement.getText(0)) { mutableSetOf() } += statement.getText(1)
}
}
return result
}
private suspend fun PooledConnection.readSourceIds(
ids: Collection<String>,
sql: (placeholders: String) -> String,
row: (SQLiteStatement) -> Unit,
) {
ids.chunked(SQL_VARIABLES).forEach { chunk ->
usePrepared(sql(chunk.joinToString(",") { "?" })) { statement ->
statement.bindLong(1, source.id)
chunk.forEachIndexed { index, id -> statement.bindText(index + 2, id) }
while (statement.step()) row(statement)
}
}
}
private fun origin(
kind: QueueKind,
refId: String,
) = QueueOrigin(kind, source.id, refId)
private suspend fun PooledConnection.persistPendingOrigins() {
val origins = transactionOrigins.orEmpty()
origins.chunked(SQL_VARIABLES).forEach { chunk ->
usePrepared("INSERT OR IGNORE INTO pending_sync_origins (sourceId, kind, refId) VALUES (?, ?, ?)") { statement ->
chunk.forEach { origin ->
statement.bindLong(1, origin.sourceId)
statement.bindText(2, origin.kind.name)
statement.bindText(3, origin.refId)
statement.step()
statement.reset()
statement.clearBindings()
}
}
}
}
private suspend fun PooledConnection.deleteIds(
table: String,
ids: Collection<String>,
) {
ids.chunked(SQL_VARIABLES).forEach { chunk ->
val placeholders = chunk.joinToString(",") { "?" }
usePrepared("DELETE FROM $table WHERE sourceId = ? AND id IN ($placeholders)") { statement ->
statement.bindLong(1, source.id)
chunk.forEachIndexed { index, id -> statement.bindText(index + 2, id) }
statement.step()
}
}
}
private suspend fun deleteSyncedIds(
table: String,
ids: Collection<String>,
) {
db.useWriterConnection { connection ->
connection.immediateTransaction {
connection.deleteIds(table, ids)
connection.persistPendingOrigins()
} }
} }
} }
@@ -180,6 +415,15 @@ class SyncService(
return value return value
} }
private suspend fun PooledConnection.changes(): Long {
var value = 0L
usePrepared("SELECT changes()") { statement ->
statement.step()
value = statement.getLong(0)
}
return value
}
private suspend fun PooledConnection.markCreditsStale(sourceId: Long) { private suspend fun PooledConnection.markCreditsStale(sourceId: Long) {
usePrepared("UPDATE sources SET creditsStale = 1 WHERE id = ? AND creditsStale = 0") { statement -> usePrepared("UPDATE sources SET creditsStale = 1 WHERE id = ? AND creditsStale = 0") { statement ->
statement.bindLong(1, sourceId) statement.bindLong(1, sourceId)
@@ -256,11 +500,15 @@ class SyncService(
val seen = HashedIds() val seen = HashedIds()
source.playlists().collect { batch -> source.playlists().collect { batch ->
if (batch.isEmpty()) return@collect if (batch.isEmpty()) return@collect
write("playlists", primaryKey, batch) { it.columns() } write("playlists", primaryKey, batch, onChanged = { changedOrigins += origin(QueueKind.Playlist, it.id) }) { it.columns() }
batch.forEach { seen.add(idHash(it.id)) } batch.forEach { seen.add(idHash(it.id)) }
playlistsSynced += batch.size playlistsSynced += batch.size
} }
pruned += pruneStaleIds(seen, { library.playlistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deletePlaylists(source.id, it) }) pruned +=
pruneStaleIds(seen, { library.playlistIdsAfter(source.id, it, PRUNE_PAGE) }, {
it.forEach { id -> changedOrigins += origin(QueueKind.Playlist, id) }
deleteSyncedIds("playlists", it)
})
} }
private suspend fun syncPlaylistSongs() { private suspend fun syncPlaylistSongs() {
@@ -269,14 +517,43 @@ class SyncService(
val counts = HashMap<String, Long>() val counts = HashMap<String, Long>()
source.playlistSongs(playlistIds).collect { batch -> source.playlistSongs(playlistIds).collect { batch ->
if (batch.isEmpty()) return@collect if (batch.isEmpty()) return@collect
write("playlist_songs", playlistSongKey, batch) { it.columns() } write("playlist_songs", playlistSongKey, batch, onChanged = { changedOrigins += origin(QueueKind.Playlist, it.playlistId) }) {
it.columns()
}
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L } batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
playlistSongsSynced += batch.size playlistSongsSynced += batch.size
} }
playlistIds.forEach { library.deletePlaylistSongsFrom(source.id, it, counts[it] ?: 0L) } playlistIds.forEach { playlistId ->
deletePlaylistSongsFrom(playlistId, counts[playlistId] ?: 0L)
}
library.deleteOrphanPlaylistSongs(source.id) library.deleteOrphanPlaylistSongs(source.id)
} }
private suspend fun deletePlaylistSongsFrom(
playlistId: String,
position: Long,
): Boolean =
commitOrigins {
db.useWriterConnection { connection ->
connection.immediateTransaction {
connection.usePrepared(
"DELETE FROM playlist_songs WHERE sourceId = ? AND playlistId = ? AND position >= ?",
) { statement ->
statement.bindLong(1, source.id)
statement.bindText(2, playlistId)
statement.bindLong(3, position)
statement.step()
}
val changed = connection.changes() > 0
if (changed) {
changedOrigins += origin(QueueKind.Playlist, playlistId)
connection.persistPendingOrigins()
}
changed
}
}
}
// One commit per batch, so Room notifies paged observers once per batch rather than per row. // One commit per batch, so Room notifies paged observers once per batch rather than per row.
private suspend fun <T> write( private suspend fun <T> write(
table: String, table: String,
@@ -284,14 +561,18 @@ class SyncService(
rows: List<T>, rows: List<T>,
preserved: Set<String> = emptySet(), preserved: Set<String> = emptySet(),
markStale: Boolean = false, markStale: Boolean = false,
onChanged: (T) -> Unit = {},
columns: (T) -> List<Pair<String, Any?>>, columns: (T) -> List<Pair<String, Any?>>,
): Boolean = ): Boolean =
commitOrigins {
db.useWriterConnection { connection -> db.useWriterConnection { connection ->
connection.immediateTransaction { connection.immediateTransaction {
val before = connection.totalChanges() val before = connection.totalChanges()
connection.upsertChanged(table, key, rows, preserved, columns) connection.upsertChanged(table, key, rows, preserved, onChanged, columns)
(connection.totalChanges() != before).also { changed -> val changed = connection.totalChanges() != before
if (changed && markStale) connection.markCreditsStale(source.id) if (changed && markStale) connection.markCreditsStale(source.id)
connection.persistPendingOrigins()
changed
} }
} }
} }
@@ -304,12 +585,29 @@ class SyncService(
albums.flatMap { album -> albums.flatMap { album ->
album.artistPositions().map { (position, ref) -> AlbumArtistRef(album.sourceId, album.id, position, ref.id, ref.name) } album.artistPositions().map { (position, ref) -> AlbumArtistRef(album.sourceId, album.id, position, ref.id, ref.name) }
} }
return db.useWriterConnection { connection -> return commitOrigins {
db.useWriterConnection { connection ->
connection.immediateTransaction { connection.immediateTransaction {
val before = connection.totalChanges() val before = connection.totalChanges()
val previousArtists = connection.albumArtistIds(albums.map { it.id }.toSet())
val previousTrackArtists = connection.songArtistIds(albums.map { it.id }.toSet())
val changedAlbums = mutableSetOf<String>()
val resolved = connection.resolveAlbumSortNames(albums) val resolved = connection.resolveAlbumSortNames(albums)
connection.upsertChanged("albums", primaryKey, resolved) { it.columns() } connection.upsertChanged(
connection.upsertChanged("discs", discKey, discs) { it.columns() } "albums",
primaryKey,
resolved,
onChanged = {
changedAlbums += it.id
changedOrigins += origin(QueueKind.Album, it.id)
},
) { it.columns() }
connection.upsertChanged(
"discs",
discKey,
discs,
onChanged = { changedOrigins += origin(QueueKind.Album, it.albumId) },
) { it.columns() }
connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement -> connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement ->
albums.forEach { album -> albums.forEach { album ->
statement.bind(1, album.sourceId) statement.bind(1, album.sourceId)
@@ -321,8 +619,20 @@ class SyncService(
} }
} }
connection.upsertChanged("album_artists", albumArtistKey, artists) { it.columns() } connection.upsertChanged("album_artists", albumArtistKey, artists) { it.columns() }
(connection.totalChanges() != before).also { changed -> albums.forEach { album ->
val previous = previousArtists[album.id].orEmpty()
val current = (album.artistPositions().map { it.second.id } + album.artistId).filterNotNull().toSet()
if (album.id in changedAlbums || previous != current) {
changedOrigins += (previous + current).map { origin(QueueKind.Artist, it) }
}
}
changedAlbums.forEach { albumId ->
changedOrigins += previousTrackArtists[albumId].orEmpty().map { origin(QueueKind.Artist, it) }
}
val changed = connection.totalChanges() != before
if (changed) connection.markCreditsStale(albums.first().sourceId) if (changed) connection.markCreditsStale(albums.first().sourceId)
connection.persistPendingOrigins()
changed
} }
} }
} }
@@ -330,6 +640,8 @@ class SyncService(
private suspend fun deleteStaleDiscs(stale: Collection<DiscKey>) { private suspend fun deleteStaleDiscs(stale: Collection<DiscKey>) {
stale.chunked(DELETE_CHUNK).forEach { chunk -> stale.chunked(DELETE_CHUNK).forEach { chunk ->
commitOrigins {
chunk.forEach { changedOrigins += origin(QueueKind.Album, it.albumId) }
db.useWriterConnection { connection -> db.useWriterConnection { connection ->
connection.immediateTransaction { connection.immediateTransaction {
connection.usePrepared("DELETE FROM discs WHERE sourceId = ? AND albumId = ? AND disc = ?") { statement -> connection.usePrepared("DELETE FROM discs WHERE sourceId = ? AND albumId = ? AND disc = ?") { statement ->
@@ -342,6 +654,8 @@ class SyncService(
statement.clearBindings() statement.clearBindings()
} }
} }
connection.persistPendingOrigins()
}
} }
} }
} }
@@ -361,7 +675,7 @@ class SyncService(
if (ids.isEmpty()) return pruned if (ids.isEmpty()) return pruned
val stale = ids.filterNot { seen.contains(idHash(it)) } val stale = ids.filterNot { seen.contains(idHash(it)) }
if (stale.isNotEmpty()) markStale?.invoke() if (stale.isNotEmpty()) markStale?.invoke()
stale.chunked(DELETE_CHUNK).forEach { delete(it) } stale.chunked(DELETE_CHUNK).forEach { commitOrigins { delete(it) } }
pruned += stale.size pruned += stale.size
if (ids.size < PRUNE_PAGE) return pruned if (ids.size < PRUNE_PAGE) return pruned
afterId = ids.last() afterId = ids.last()
@@ -397,8 +711,26 @@ data class SyncSummary(
val playlists: Int, val playlists: Int,
val playlistSongs: Int, val playlistSongs: Int,
val pruned: Int, val pruned: Int,
val changedOrigins: Set<QueueOrigin> = emptySet(),
) )
private data class SongSyncState(
val id: String,
val albumId: String?,
val artistIds: Set<String>,
val genres: Set<String>,
val starred: Boolean,
val sortName: String,
)
private class MutableSongSyncState {
var albumId: String? = null
var sortName: String = ""
val artistIds = mutableSetOf<String>()
val genres = mutableSetOf<String>()
var starred = false
}
private const val DELETE_CHUNK = 500 private const val DELETE_CHUNK = 500
private const val SQL_VARIABLES = 500 private const val SQL_VARIABLES = 500
private const val PRUNE_PAGE = 500 private const val PRUNE_PAGE = 500
@@ -446,6 +778,20 @@ private fun Song.genreNames(): List<String> =
.map { part -> id3v1Genre(part) ?: part } .map { part -> id3v1Genre(part) ?: part }
.distinct() .distinct()
private fun Song.syncState(albumArtists: Map<String, Set<String>>): SongSyncState {
val artistIds = artistPositions().mapTo(mutableSetOf()) { it.second.id }
this.artistId?.let(artistIds::add)
albumId?.let { artistIds += albumArtists[it].orEmpty() }
return SongSyncState(
id = id,
albumId = albumId,
artistIds = artistIds,
genres = genreNames().toSet(),
starred = starred != null,
sortName = sortName,
)
}
// Navidrome's default genre tag split (resources/mappings.yaml): semicolon, slash, comma. // Navidrome's default genre tag split (resources/mappings.yaml): semicolon, slash, comma.
private fun String.genreParts(): List<String> = split(';', '/', ',').map { it.trim() }.filter { it.isNotEmpty() } private fun String.genreParts(): List<String> = split(';', '/', ',').map { it.trim() }.filter { it.isNotEmpty() }
@@ -583,6 +929,7 @@ private suspend fun <T> PooledConnection.upsertChanged(
key: List<String>, key: List<String>,
rows: List<T>, rows: List<T>,
preserved: Set<String> = emptySet(), preserved: Set<String> = emptySet(),
onChanged: (T) -> Unit = {},
columns: (T) -> List<Pair<String, Any?>>, columns: (T) -> List<Pair<String, Any?>>,
) { ) {
if (rows.isEmpty()) return if (rows.isEmpty()) return
@@ -593,11 +940,12 @@ private suspend fun <T> PooledConnection.upsertChanged(
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " + "ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
updates.joinToString { "$it = excluded.$it" } + updates.joinToString { "$it = excluded.$it" } +
" WHERE " + " WHERE " +
updates.joinToString(" OR ") { "$table.$it COLLATE BINARY IS NOT excluded.$it" } updates.joinToString(" OR ") { "$table.$it COLLATE BINARY IS NOT excluded.$it" } +
" RETURNING 1"
usePrepared(sql) { statement -> usePrepared(sql) { statement ->
rows.forEach { row -> rows.forEach { row ->
columns(row).forEachIndexed { index, (_, value) -> statement.bind(index + 1, value) } columns(row).forEachIndexed { index, (_, value) -> statement.bind(index + 1, value) }
statement.step() if (statement.step()) onChanged(row)
statement.reset() statement.reset()
statement.clearBindings() statement.clearBindings()
} }
@@ -5,18 +5,27 @@ import android.content.res.Resources
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.prefs.fakeUserPreferences import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.NetworkServerActionSink 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.repo.replace
import com.subtracks.data.repo.setShuffle
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
@@ -28,6 +37,7 @@ import okhttp3.mockwebserver.RecordedRequest
import okhttp3.mockwebserver.SocketPolicy import okhttp3.mockwebserver.SocketPolicy
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
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
@@ -100,6 +110,39 @@ class SyncManagerTest {
assertTrue(messages.isEmpty()) assertTrue(messages.isEmpty())
} }
@Test
fun aNoOpSyncPreservesTheQueueResolutionCache() =
runBlocking {
val healthy = healthyDispatcher()
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
when {
request.path?.startsWith("/rest/search3.view") == true -> MockResponse().setBody(TWO_SONGS)
request.path?.startsWith("/rest/getPlaylists.view") == true -> MockResponse().setBody(PLAYLISTS)
request.path?.startsWith("/rest/getPlaylist.view") == true -> MockResponse().setBody(PLAYLIST)
else -> healthy.dispatch(request)
}
}
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Success } }
queueRepository.replace(listOf(queueRepository.playlistEntry(1, "p1")))
val snapshot = queueRepository.snapshot()
assertEquals(2L, snapshot.size)
assertEquals(listOf("s1"), queueRepository.range(snapshot, 0, 0).map { it.item.song.id })
assertEquals(listOf("s2"), queueRepository.range(snapshot, 1, 1).map { it.item.song.id })
manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.drop(1).first { it is SyncStatus.Success } }
val offsetSeeks = queueRepository.offsetSeeks
val keysetSeeks = queueRepository.keysetSeeks
assertEquals(listOf("s2"), queueRepository.range(snapshot, 1, 1).map { it.item.song.id })
assertEquals(offsetSeeks, queueRepository.offsetSeeks)
assertEquals(keysetSeeks + 1, queueRepository.keysetSeeks)
}
@Test @Test
fun aFailedSyncDoesNotAdvanceTheQueueContentVersion() = fun aFailedSyncDoesNotAdvanceTheQueueContentVersion() =
runBlocking { runBlocking {
@@ -112,6 +155,19 @@ class SyncManagerTest {
assertEquals(before, queueRepository.snapshot().version) assertEquals(before, queueRepository.snapshot().version)
} }
@Test
fun aFailedSyncDoesNotResetTheQueue() =
runBlocking {
db.sourcesDao().upsertSource(Source(1, "Local", "http://localhost", true, 0))
queueRepository.replace(listOf(queueRepository.albumEntry(1, "al1").copy(rangeStart = 0, rangeEnd = 0)))
val before = queueRepository.snapshot().entries.map { it.entry }
manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertEquals(before, queueRepository.snapshot().entries.map { it.entry })
}
@Test @Test
fun aSuccessfulSyncMirrorsTheLibraryIntoRoom() = fun aSuccessfulSyncMirrorsTheLibraryIntoRoom() =
runBlocking { runBlocking {
@@ -169,6 +225,162 @@ class SyncManagerTest {
assertEquals(listOf("al1"), db.libraryDao().albumIds(1)) assertEquals(listOf("al1"), db.libraryDao().albumIds(1))
} }
@Test
fun aFailedSyncCarriesCommittedOriginsIntoTheNextSuccessfulReset() =
runBlocking {
val healthy = healthyDispatcher()
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
if (request.path?.startsWith("/rest/getPlaylists.view") == true) {
MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)
} else {
healthy.dispatch(request)
}
}
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
val edited = queueRepository.albumEntry(1, "al1").copy(rangeStart = 0, rangeEnd = 0)
queueRepository.replace(listOf(edited))
manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertEquals(
0L,
queueRepository
.snapshot()
.entries
.single()
.entry.rangeStart,
)
assertTrue(db.queueDao().pendingSyncOrigins(1).isNotEmpty())
manager =
SyncManager(
db,
sourceRepository,
queueRepository,
NetworkServerActionSink(sourceRepository),
preferences = preferences,
showMessage = { messages += it.resolve(resources) },
scope = scope,
)
server.dispatcher = healthy
manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Success } }
assertNull(
queueRepository
.snapshot()
.entries
.single()
.entry.rangeStart,
)
assertTrue(db.queueDao().pendingSyncOrigins(1).isEmpty())
}
@Test
fun aFailedSyncPreservesResolvedQueueShuffle() =
runBlocking {
server.dispatcher = healthyDispatcher()
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Success } }
queueRepository.replace(listOf(queueRepository.albumEntry(1, "al1")))
queueRepository.setShuffle(42L)
assertTrue(queueRepository.snapshot().shuffled)
val healthy = healthyDispatcher()
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
when {
request.path?.startsWith("/rest/search3.view") == true -> {
MockResponse().setBody(TWO_SONGS)
}
request.path?.startsWith("/rest/getPlaylists.view") == true -> {
MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)
}
else -> {
healthy.dispatch(request)
}
}
}
manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
val snapshot = queueRepository.snapshot()
assertTrue(snapshot.shuffled)
assertEquals(1L, snapshot.contextSize)
}
@Test
fun aFailedSongPruneDoesNotResetAnEditedDownloadedQueue() =
runBlocking {
val healthy = healthyDispatcher()
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
if (request.path?.startsWith("/rest/search3.view") == true) {
MockResponse().setBody(TWO_SONGS)
} else {
healthy.dispatch(request)
}
}
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Success } }
val context = ApplicationProvider.getApplicationContext<Context>()
val downloadsDir = File(context.cacheDir, "sync-downloads-${System.nanoTime()}")
val downloads =
DownloadRepository(
db,
sourceRepository,
FakeDownloadEngine(),
downloadsDir,
artworkStore = ArtworkStore(File(context.cacheDir, "art")),
artworkFetcher = { ByteArray(0) },
scope = scope,
)
File(downloadsDir, "1").mkdirs()
File(downloadsDir, "1/s2").writeText("audio")
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 1))
downloads.start()
downloads.awaitLoaded()
awaitUntil("waiting for download baseline") { downloads.localUri("s2") != null }
queueRepository.replace(listOf(queueRepository.downloadedEntry(1).copy(rangeStart = 1)))
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
when {
request.path?.startsWith("/rest/search3.view") == true -> {
MockResponse().setBody(SONG)
}
request.path?.startsWith("/rest/getPlaylists.view") == true -> {
MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)
}
else -> {
healthy.dispatch(request)
}
}
}
manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertEquals(
1L,
queueRepository
.snapshot()
.entries
.single()
.entry.rangeStart,
)
}
@Test @Test
fun requestsWhileRunningCoalesceIntoOneSync() = fun requestsWhileRunningCoalesceIntoOneSync() =
runBlocking { runBlocking {
@@ -253,6 +465,24 @@ class SyncManagerTest {
"<subsonic-response status=\"ok\" version=\"1.16.1\"><searchResult3>" + "<subsonic-response status=\"ok\" version=\"1.16.1\"><searchResult3>" +
"<song id=\"s1\" title=\"Song One\" albumId=\"al1\" artistId=\"ar1\" track=\"1\"/>" + "<song id=\"s1\" title=\"Song One\" albumId=\"al1\" artistId=\"ar1\" track=\"1\"/>" +
"</searchResult3></subsonic-response>" "</searchResult3></subsonic-response>"
const val TWO_SONGS =
"<subsonic-response status=\"ok\" version=\"1.16.1\"><searchResult3>" +
"<song id=\"s1\" title=\"Song One\" albumId=\"al1\" artistId=\"ar1\" track=\"1\"/>" +
"<song id=\"s2\" title=\"Song Two\" albumId=\"al1\" artistId=\"ar1\" track=\"2\"/>" +
"</searchResult3></subsonic-response>"
const val PLAYLISTS =
"<subsonic-response status=\"ok\" version=\"1.16.1\"><playlists>" +
"<playlist id=\"p1\" name=\"Playlist\" songCount=\"2\"/>" +
"</playlists></subsonic-response>"
const val PLAYLIST =
"<subsonic-response status=\"ok\" version=\"1.16.1\">" +
"<playlist id=\"p1\" name=\"Playlist\" songCount=\"2\">" +
"<entry id=\"s1\" title=\"Song One\" albumId=\"al1\" artistId=\"ar1\" track=\"1\"/>" +
"<entry id=\"s2\" title=\"Song Two\" albumId=\"al1\" artistId=\"ar1\" track=\"2\"/>" +
"</playlist></subsonic-response>"
} }
@Test @Test
@@ -18,6 +18,8 @@ import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.QueueOrigin
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
@@ -1868,6 +1870,278 @@ class SyncServiceTest {
} }
} }
@Test
fun playlistMembershipChangesReportOnlyTheirOwnOrigins() =
runTest {
insertSource()
val source =
FakeMusicSource(
songs = listOf(song("s1"), song("s2")),
playlists = listOf(playlist("p1"), playlist("p2")),
playlistSongs =
listOf(
PlaylistSong(1, "p1", "s1", 0),
PlaylistSong(1, "p2", "s2", 0),
),
)
SyncService(db, source).sync()
source.playlistSongs =
listOf(
PlaylistSong(1, "p1", "s2", 0),
PlaylistSong(1, "p2", "s2", 0),
)
val summary = SyncService(db, source).sync()
assertEquals(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), summary.changedOrigins)
source.playlistSongs = listOf(PlaylistSong(1, "p2", "s2", 0))
val deletionSummary = SyncService(db, source).sync()
assertEquals(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), deletionSummary.changedOrigins)
}
@Test
fun albumAndSongChangesReportOnlyAffectedOrigins() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1"), artist("a2"), artist("a3")),
albums =
listOf(
album("al1"),
album("al2").copy(artistId = "a2"),
album("al3").copy(artistId = "a3"),
),
songs =
listOf(
song("s1").copy(genres = listOf("Rock")),
song("s2").copy(albumId = "al2", artistId = "a2", genres = listOf("Jazz")),
),
)
SyncService(db, source).sync()
source.albums =
listOf(
album("al1").copy(name = "Renamed"),
album("al2").copy(artistId = "a2"),
album("al3").copy(artistId = "a3"),
)
source.songs =
listOf(
song("s1").copy(
albumId = "al3",
artistId = "a3",
genres = listOf("Electronic"),
starred = 1234,
),
song("s2").copy(albumId = "al2", artistId = "a2", genres = listOf("Jazz")),
)
val summary = SyncService(db, source).sync()
assertEquals(
setOf(
QueueOrigin(QueueKind.Album, 1, "al1"),
QueueOrigin(QueueKind.Album, 1, "al3"),
QueueOrigin(QueueKind.Artist, 1, "a1"),
QueueOrigin(QueueKind.Artist, 1, "a3"),
QueueOrigin(QueueKind.Genre, 1, "Rock"),
QueueOrigin(QueueKind.Genre, 1, "Electronic"),
QueueOrigin(QueueKind.Starred, 1, ""),
QueueOrigin(QueueKind.Song, 1, "s1"),
),
summary.changedOrigins,
)
}
@Test
fun deletedSongsReportOnlyTheirPreviousOrigins() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1"), artist("a2")),
albums = listOf(album("al1"), album("al2").copy(artistId = "a2")),
songs =
listOf(
song("s1").copy(genres = listOf("Rock"), starred = 1234),
song("s2").copy(albumId = "al2", artistId = "a2", genres = listOf("Jazz")),
),
)
SyncService(db, source).sync()
source.songs = listOf(song("s2").copy(albumId = "al2", artistId = "a2", genres = listOf("Jazz")))
val summary = SyncService(db, source).sync()
assertEquals(
setOf(
QueueOrigin(QueueKind.Album, 1, "al1"),
QueueOrigin(QueueKind.Artist, 1, "a1"),
QueueOrigin(QueueKind.Genre, 1, "Rock"),
QueueOrigin(QueueKind.Starred, 1, ""),
QueueOrigin(QueueKind.Song, 1, "s1"),
),
summary.changedOrigins,
)
}
@Test
fun albumCreditChangesReportBothArtistOrigins() =
runTest {
insertSource()
val plural = listOf(ArtistRef("a2", "Artist a2"))
val source =
FakeMusicSource(
artists = listOf(artist("a1"), artist("a2"), artist("a3")),
albums = listOf(album("al1").copy(artists = plural)),
songs = listOf(song("s1")),
)
SyncService(db, source).sync()
source.albums = listOf(album("al1").copy(artistId = "a3", artists = plural, year = 2024))
val summary = SyncService(db, source).sync()
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Artist, 1, "a1")))
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Artist, 1, "a3")))
}
@Test
fun songPresenceChangesReportReferencingPlaylistOrigins() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1")),
albums = listOf(album("al1")),
songs = listOf(song("s1"), song("s2")),
playlists = listOf(playlist("p1")),
playlistSongs = listOf(PlaylistSong(1, "p1", "s1", 0), PlaylistSong(1, "p1", "s2", 1)),
)
SyncService(db, source).sync()
source.songs = listOf(song("s2"))
val deleted = SyncService(db, source).sync()
assertTrue(deleted.changedOrigins.contains(QueueOrigin(QueueKind.Playlist, 1, "p1")))
source.songs = listOf(song("s1"), song("s2"))
val restored = SyncService(db, source).sync()
assertTrue(restored.changedOrigins.contains(QueueOrigin(QueueKind.Playlist, 1, "p1")))
}
@Test
fun downloadedSongSortChangesReportDownloadedOrigins() =
runTest {
insertSource()
val source =
FakeMusicSource(artists = listOf(artist("a1")), albums = listOf(album("al1")), songs = listOf(song("s1"), song("s2")))
SyncService(db, source).sync()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed))
source.songs = listOf(song("s1").copy(title = "Zzz", sortName = "Zzz"), song("s2").copy(title = "Zulu"))
val summary = SyncService(db, source).sync()
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Downloaded, 1, "")))
}
@Test
fun albumChangesReportArtistsCreditedOnlyOnTracks() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1"), artist("a2")),
albums = listOf(album("al1")),
songs = listOf(song("s1").copy(artistId = "a2")),
)
SyncService(db, source).sync()
source.albums = listOf(album("al1").copy(year = 2024))
val summary = SyncService(db, source).sync()
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Artist, 1, "a2")))
}
@Test
fun albumPruningReportsArtistsCreditedOnlyOnRemainingTracks() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1"), artist("a2")),
albums = listOf(album("al1")),
songs = listOf(song("s1").copy(artistId = "a2")),
)
SyncService(db, source).sync()
source.albums = emptyList()
val summary = SyncService(db, source).sync()
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Artist, 1, "a2")))
}
@Test
fun albumChangesIgnoreSongsWithoutTrackArtists() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1")),
albums = listOf(album("al1")),
songs = listOf(song("s1").copy(artistId = null)),
)
SyncService(db, source).sync()
source.albums = listOf(album("al1").copy(year = 2024))
val summary = SyncService(db, source).sync()
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Album, 1, "al1")))
assertTrue(summary.changedOrigins.none { it.kind == QueueKind.Artist && it.refId.isBlank() })
}
@Test
fun committedOriginsArePersistedWithoutManagerRetention() =
runTest {
insertSource()
val source = FakeMusicSource(artists = listOf(artist("a1")), albums = listOf(album("al1")), songs = listOf(song("s1")))
SyncService(db, source).sync()
source.albums = listOf(album("al1").copy(name = "Changed"))
SyncService(db, source).sync()
val pending = db.queueDao().pendingSyncOrigins(1)
assertTrue(pending.any { it.kind == QueueKind.Album && it.refId == "al1" })
}
@Test
fun albumPruningReportsArtistsCreditedOnlyByTheDeletedAlbum() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1"), artist("a2")),
albums = listOf(album("al1"), album("al2")),
songs =
listOf(
song("s1").copy(artistId = "a2"),
song("s2").copy(albumId = "al2", artistId = "a2"),
),
)
SyncService(db, source).sync()
source.albums = listOf(album("al2"))
source.songs = listOf(song("s2").copy(albumId = "al2", artistId = "a2"))
val summary = SyncService(db, source).sync()
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Artist, 1, "a1")))
}
private suspend fun totalChanges(): Long = private suspend fun totalChanges(): Long =
db.useWriterConnection { connection -> db.useWriterConnection { connection ->
connection.usePrepared("SELECT total_changes()") { statement -> connection.usePrepared("SELECT total_changes()") { statement ->