mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
stop syncing from rewriting unchanged rows
This commit is contained in:
@@ -82,8 +82,12 @@ interface LibraryDao {
|
||||
ids: Collection<String>,
|
||||
)
|
||||
|
||||
@Query("DELETE FROM playlist_songs WHERE sourceId = :sourceId")
|
||||
suspend fun deletePlaylistSongs(sourceId: Long)
|
||||
@Query("DELETE FROM playlist_songs WHERE sourceId = :sourceId AND playlistId = :playlistId AND position >= :position")
|
||||
suspend fun deletePlaylistSongsFrom(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
position: Long,
|
||||
)
|
||||
|
||||
@Query("SELECT * FROM artists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE")
|
||||
fun artists(sourceId: Long): PagingSource<Int, Artist>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import androidx.room3.PooledConnection
|
||||
import androidx.room3.immediateTransaction
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.SQLiteStatement
|
||||
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.Song
|
||||
import com.subtracks.data.source.MusicSource
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -24,19 +31,28 @@ class SyncService(
|
||||
|
||||
db.useWriterConnection { transactor ->
|
||||
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()
|
||||
library.upsertArtists(artists)
|
||||
library.upsertAlbums(albums)
|
||||
library.upsertSongs(songs)
|
||||
library.upsertPlaylists(playlists)
|
||||
val existingArtistIds = library.artistIds(source.id)
|
||||
val existingAlbumIds = library.albumIds(source.id)
|
||||
val existingSongIds = library.songIds(source.id)
|
||||
val existingPlaylistIds = library.playlistIds(source.id)
|
||||
|
||||
deleteMissing(library.artistIds(source.id), artists.map { it.id }) { library.deleteArtists(source.id, it) }
|
||||
deleteMissing(library.albumIds(source.id), albums.map { it.id }) { library.deleteAlbums(source.id, it) }
|
||||
deleteMissing(library.songIds(source.id), songs.map { it.id }) { library.deleteSongs(source.id, it) }
|
||||
deleteMissing(library.playlistIds(source.id), playlists.map { it.id }) { library.deletePlaylists(source.id, it) }
|
||||
prune(existingArtistIds, artists.map { it.id }) { library.deleteArtists(source.id, it) }
|
||||
prune(existingAlbumIds, albums.map { it.id }) { library.deleteAlbums(source.id, it) }
|
||||
prune(existingSongIds, songs.map { it.id }) { library.deleteSongs(source.id, it) }
|
||||
prune(existingPlaylistIds, playlists.map { it.id }) { library.deletePlaylists(source.id, it) }
|
||||
|
||||
library.deletePlaylistSongs(source.id)
|
||||
library.upsertPlaylistSongs(playlistSongs)
|
||||
val playlistSongCounts = playlistSongs.groupingBy { it.playlistId }.eachCount()
|
||||
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 sourceKey = source.id.toString()
|
||||
@@ -53,7 +69,7 @@ class SyncService(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteMissing(
|
||||
private suspend fun prune(
|
||||
existing: List<String>,
|
||||
current: List<String>,
|
||||
delete: suspend (Collection<String>) -> Unit,
|
||||
@@ -65,3 +81,97 @@ class SyncService(
|
||||
.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.Source
|
||||
import com.subtracks.data.source.MusicSource
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
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
|
||||
fun searchIndexMatchesSubstrings() =
|
||||
runTest {
|
||||
|
||||
Reference in New Issue
Block a user