library: fix credited fallback and stale marker writes

Restore the both-tables-empty fallback in the credit recompute, skip
redundant marker writes, append albumless tracks after album tracks, and make
the paged artist query observe sources. Add tests for an interrupted sync and
the partial-relation migration guard.
This commit is contained in:
austinried
2026-10-18 07:00:04 +00:00
parent 9d9550be48
commit a41f77d907
6 changed files with 85 additions and 7 deletions
@@ -18,6 +18,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.PlaylistSongItem import com.subtracks.data.model.PlaylistSongItem
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
internal const val PLAYLIST_SONGS_SELECT = internal const val PLAYLIST_SONGS_SELECT =
@@ -250,7 +251,7 @@ interface LibraryDao {
"UPDATE artists SET songCount = c.songCount, credited = c.credited " + "UPDATE artists SET songCount = c.songCount, credited = c.credited " +
"FROM (SELECT artists.id AS id, COALESCE(counts.songCount, 0) AS songCount, " + "FROM (SELECT artists.id AS id, COALESCE(counts.songCount, 0) AS songCount, " +
"((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " + "((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " +
"OR NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " + "AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " +
"OR EXISTS (SELECT 1 FROM album_artists aa " + "OR EXISTS (SELECT 1 FROM album_artists aa " +
"WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " + "WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " +
"OR EXISTS (SELECT 1 FROM song_artists sa " + "OR EXISTS (SELECT 1 FROM song_artists sa " +
@@ -567,7 +568,7 @@ interface LibraryDao {
@RawQuery(observedEntities = [Album::class]) @RawQuery(observedEntities = [Album::class])
fun albumsPaging(query: RoomRawQuery): PagingSource<Int, Album> fun albumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
@RawQuery(observedEntities = [Artist::class]) @RawQuery(observedEntities = [Artist::class, Source::class])
fun artistsPaging(query: RoomRawQuery): PagingSource<Int, Artist> fun artistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
@RawQuery(observedEntities = [Playlist::class]) @RawQuery(observedEntities = [Playlist::class])
@@ -38,7 +38,7 @@ private const val ARTIST_SONGS_SELECT =
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS" "WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS"
private const val ARTIST_SONGS_ORDER = private const val ARTIST_SONGS_ORDER =
" ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id" " ORDER BY albums.id IS NULL, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
private const val SONG_SQL = private const val SONG_SQL =
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
@@ -32,12 +32,19 @@ interface SourcesDao {
@Query("SELECT creditsStale FROM sources WHERE id = :sourceId") @Query("SELECT creditsStale FROM sources WHERE id = :sourceId")
suspend fun creditsStale(sourceId: Long): Boolean? suspend fun creditsStale(sourceId: Long): Boolean?
@Query("UPDATE sources SET creditsStale = :stale WHERE id = :sourceId") @Query("UPDATE sources SET creditsStale = :stale WHERE id = :sourceId AND creditsStale != :stale")
suspend fun setCreditsStale( suspend fun setCreditsStale(
sourceId: Long, sourceId: Long,
stale: Boolean, stale: Boolean,
) )
@Query("UPDATE sources SET name = :name, address = :address WHERE id = :sourceId")
suspend fun updateSourceDetails(
sourceId: Long,
name: String,
address: String,
)
@Insert @Insert
suspend fun insertSource(source: Source): Long suspend fun insertSource(source: Source): Long
@@ -220,8 +220,8 @@ class SourceRepository(
useTokenAuth: Boolean, useTokenAuth: Boolean,
) { ) {
val dao = db.sourcesDao() val dao = db.sourcesDao()
val source = dao.sourceOnce(id) ?: return if (dao.sourceOnce(id) == null) return
dao.upsertSource(source.copy(name = name.trim(), address = normalizeAddress(address))) dao.updateSourceDetails(id, name.trim(), normalizeAddress(address))
dao.upsertSubsonicSource( dao.upsertSubsonicSource(
SubsonicSource( SubsonicSource(
sourceId = id, sourceId = id,
@@ -246,6 +246,32 @@ class MigrationsTest {
} }
} }
@Test
fun theCreditedBackfillKeepsArtistsVisibleWhileRelationsAreIncomplete() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 31)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'a1', 'Alpha', 1)")
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'combo', 'Alpha & Beta', 1)")
migrated.execSQL(
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
"VALUES (1, 'al1', 'a1', 'Album', 'Alpha', 0, 0)",
)
migrated.execSQL(
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
"VALUES (1, 'al1', 0, 'a1', 'Alpha')",
)
MIGRATION_31_32.migrate(migrated)
assertEquals(1L, long(migrated, "SELECT credited FROM artists WHERE id = 'a1'"))
assertEquals(1L, long(migrated, "SELECT credited FROM artists WHERE id = 'combo'"))
}
}
private fun text( private fun text(
connection: SQLiteConnection, connection: SQLiteConnection,
sql: String, sql: String,
@@ -1167,6 +1167,44 @@ class SyncServiceTest {
) )
} }
@Test
fun anInterruptedSyncLeavesTheCreditMarkerStale() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "Alpha"), artist("combo", "Alpha & Beta")),
albums = listOf(album("al1")),
songs = listOf(song("s1")),
songsFailure = java.io.IOException("boom"),
)
runCatching { SyncService(db, source).sync() }
assertEquals(true, db.sourcesDao().creditsStale(1))
assertEquals(
listOf("a1", "combo"),
db
.libraryDao()
.artistsByName(1, 0, "")
.allRows()
.map { it.id },
)
source.songsFailure = null
SyncService(db, source).sync()
assertEquals(false, db.sourcesDao().creditsStale(1))
assertEquals(
listOf("a1"),
db
.libraryDao()
.artistsByName(1, 0, "")
.allRows()
.map { it.id },
)
}
@Test @Test
fun aStaleCreditMarkerForcesARecomputeOnTheNextSync() = fun aStaleCreditMarkerForcesARecomputeOnTheNextSync() =
runTest { runTest {
@@ -1513,6 +1551,7 @@ private class FakeMusicSource(
var songs: List<Song> = emptyList(), var songs: List<Song> = emptyList(),
var playlists: List<Playlist> = emptyList(), var playlists: List<Playlist> = emptyList(),
var playlistSongs: List<PlaylistSong> = emptyList(), var playlistSongs: List<PlaylistSong> = emptyList(),
var songsFailure: Throwable? = null,
private val batchSize: Int = 1000, private val batchSize: Int = 1000,
) : MusicSource { ) : MusicSource {
val requestedPlaylistIds = mutableListOf<List<String>>() val requestedPlaylistIds = mutableListOf<List<String>>()
@@ -1535,7 +1574,12 @@ private class FakeMusicSource(
override fun albums(): Flow<List<Album>> = batches(albums) override fun albums(): Flow<List<Album>> = batches(albums)
override fun songs(): Flow<List<Song>> = batches(songs) override fun songs(): Flow<List<Song>> =
if (songsFailure == null) {
batches(songs)
} else {
flow { throw songsFailure!! }
}
override fun playlists(): Flow<List<Playlist>> = batches(playlists) override fun playlists(): Flow<List<Playlist>> = batches(playlists)