stop syncing from rewriting unchanged rows

This commit is contained in:
austinried
2026-10-04 00:29:51 +00:00
parent ebe76cd5bb
commit 320a9db597
3 changed files with 184 additions and 13 deletions
@@ -82,8 +82,12 @@ interface LibraryDao {
ids: Collection<String>, ids: Collection<String>,
) )
@Query("DELETE FROM playlist_songs WHERE sourceId = :sourceId") @Query("DELETE FROM playlist_songs WHERE sourceId = :sourceId AND playlistId = :playlistId AND position >= :position")
suspend fun deletePlaylistSongs(sourceId: Long) suspend fun deletePlaylistSongsFrom(
sourceId: Long,
playlistId: String,
position: Long,
)
@Query("SELECT * FROM artists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE") @Query("SELECT * FROM artists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE")
fun artists(sourceId: Long): PagingSource<Int, Artist> fun artists(sourceId: Long): PagingSource<Int, Artist>
@@ -1,9 +1,16 @@
package com.subtracks.data.sync package com.subtracks.data.sync
import androidx.room3.PooledConnection
import androidx.room3.immediateTransaction import androidx.room3.immediateTransaction
import androidx.room3.useWriterConnection import androidx.room3.useWriterConnection
import androidx.sqlite.SQLiteStatement
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
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.SearchIndex import com.subtracks.data.model.SearchIndex
import com.subtracks.data.model.Song
import com.subtracks.data.source.MusicSource import com.subtracks.data.source.MusicSource
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@@ -24,19 +31,28 @@ class SyncService(
db.useWriterConnection { transactor -> db.useWriterConnection { transactor ->
transactor.immediateTransaction { transactor.immediateTransaction {
upsertChanged("artists", artistColumns, primaryKey, artists) { it.values() }
upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() }
upsertChanged("songs", songColumns, primaryKey, songs) { it.values() }
upsertChanged("playlists", playlistColumns, primaryKey, playlists) { it.values() }
upsertChanged("playlist_songs", playlistSongColumns, playlistSongKey, playlistSongs) { it.values() }
val library = db.libraryDao() val library = db.libraryDao()
library.upsertArtists(artists) val existingArtistIds = library.artistIds(source.id)
library.upsertAlbums(albums) val existingAlbumIds = library.albumIds(source.id)
library.upsertSongs(songs) val existingSongIds = library.songIds(source.id)
library.upsertPlaylists(playlists) val existingPlaylistIds = library.playlistIds(source.id)
deleteMissing(library.artistIds(source.id), artists.map { it.id }) { library.deleteArtists(source.id, it) } prune(existingArtistIds, artists.map { it.id }) { library.deleteArtists(source.id, it) }
deleteMissing(library.albumIds(source.id), albums.map { it.id }) { library.deleteAlbums(source.id, it) } prune(existingAlbumIds, albums.map { it.id }) { library.deleteAlbums(source.id, it) }
deleteMissing(library.songIds(source.id), songs.map { it.id }) { library.deleteSongs(source.id, it) } prune(existingSongIds, songs.map { it.id }) { library.deleteSongs(source.id, it) }
deleteMissing(library.playlistIds(source.id), playlists.map { it.id }) { library.deletePlaylists(source.id, it) } prune(existingPlaylistIds, playlists.map { it.id }) { library.deletePlaylists(source.id, it) }
library.deletePlaylistSongs(source.id) val playlistSongCounts = playlistSongs.groupingBy { it.playlistId }.eachCount()
library.upsertPlaylistSongs(playlistSongs) playlists.forEach { playlist ->
library.deletePlaylistSongsFrom(source.id, playlist.id, (playlistSongCounts[playlist.id] ?: 0).toLong())
}
(existingPlaylistIds - playlists.map { it.id }).forEach { library.deletePlaylistSongsFrom(source.id, it, 0) }
val search = db.searchDao() val search = db.searchDao()
val sourceKey = source.id.toString() val sourceKey = source.id.toString()
@@ -53,7 +69,7 @@ class SyncService(
} }
} }
private suspend fun deleteMissing( private suspend fun prune(
existing: List<String>, existing: List<String>,
current: List<String>, current: List<String>,
delete: suspend (Collection<String>) -> Unit, delete: suspend (Collection<String>) -> Unit,
@@ -65,3 +81,97 @@ class SyncService(
.forEach { delete(it) } .forEach { delete(it) }
} }
} }
private val primaryKey = listOf("sourceId", "id")
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt")
private val albumColumns =
listOf(
"sourceId",
"id",
"artistId",
"name",
"albumArtist",
"created",
"coverArt",
"genre",
"year",
"starred",
"songCount",
"frequentRank",
"recentRank",
)
private val songColumns =
listOf(
"sourceId",
"id",
"albumId",
"artistId",
"title",
"album",
"artist",
"duration",
"track",
"disc",
"starred",
"genre",
)
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created")
private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt)
private fun Album.values() =
listOf<Any?>(sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount, frequentRank, recentRank)
private fun Song.values() = listOf<Any?>(sourceId, id, albumId, artistId, title, album, artist, duration, track, disc, starred, genre)
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created)
private fun PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position)
private suspend fun <T> PooledConnection.upsertChanged(
table: String,
columns: List<String>,
key: List<String>,
rows: List<T>,
values: (T) -> List<Any?>,
) {
if (rows.isEmpty()) return
val updates = columns.filterNot { it in key }
val sql =
"INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " +
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
updates.joinToString { "$it = excluded.$it" } +
" WHERE " +
updates.joinToString(" OR ") { "$table.$it IS NOT excluded.$it" }
usePrepared(sql) { statement ->
rows.forEach { row ->
values(row).forEachIndexed { index, value -> statement.bind(index + 1, value) }
statement.step()
statement.reset()
statement.clearBindings()
}
}
}
private fun SQLiteStatement.bind(
index: Int,
value: Any?,
) {
when (value) {
null -> bindNull(index)
is Long -> bindLong(index, value)
is Int -> bindLong(index, value.toLong())
is Boolean -> bindLong(index, if (value) 1L else 0L)
is Double -> bindDouble(index, value)
is String -> bindText(index, value)
else -> error("Cannot bind ${value::class}")
}
}
@@ -14,6 +14,7 @@ import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.source.MusicSource import com.subtracks.data.source.MusicSource
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -103,6 +104,62 @@ class SyncServiceTest {
) )
} }
@Test
fun aSecondIdenticalSyncLeavesTheLibraryUntouched() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1"), artist("a2")),
albums = listOf(album("al1")),
songs = listOf(song("s1")),
playlists = listOf(playlist("p1")),
playlistSongs = listOf(PlaylistSong(1, "p1", "s1", 0)),
)
SyncService(db, source).sync()
SyncService(db, source).sync()
assertEquals(2, db.libraryDao().artistIds(1).size)
assertEquals(1, db.libraryDao().albumIds(1).size)
assertEquals(1, db.libraryDao().songIds(1).size)
assertEquals(1, db.libraryDao().playlistIds(1).size)
assertEquals(
1,
db
.libraryDao()
.playlistSongs(1, "p1")
.allRows()
.size,
)
}
@Test
fun albumsAreUpsertedAndPruned() =
runTest {
insertSource()
val source = FakeMusicSource(albums = listOf(album("al1"), album("al2")))
SyncService(db, source).sync()
SyncService(db, source).sync()
assertEquals(listOf("al1", "al2"), db.libraryDao().albumIds(1).sorted())
source.albums = listOf(album("al1").copy(name = "Renamed"))
SyncService(db, source).sync()
assertEquals(listOf("al1"), db.libraryDao().albumIds(1))
assertEquals(
"Renamed",
db
.libraryDao()
.album(1, "al1")
.first()
?.name,
)
}
@Test @Test
fun searchIndexMatchesSubstrings() = fun searchIndexMatchesSubstrings() =
runTest { runTest {