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.PlaylistSongItem
import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import kotlinx.coroutines.flow.Flow
internal const val PLAYLIST_SONGS_SELECT =
@@ -250,7 +251,7 @@ interface LibraryDao {
"UPDATE artists SET songCount = c.songCount, credited = c.credited " +
"FROM (SELECT artists.id AS id, COALESCE(counts.songCount, 0) AS songCount, " +
"((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 " +
"WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " +
"OR EXISTS (SELECT 1 FROM song_artists sa " +
@@ -567,7 +568,7 @@ interface LibraryDao {
@RawQuery(observedEntities = [Album::class])
fun albumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
@RawQuery(observedEntities = [Artist::class])
@RawQuery(observedEntities = [Artist::class, Source::class])
fun artistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
@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"
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 =
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
@@ -32,12 +32,19 @@ interface SourcesDao {
@Query("SELECT creditsStale FROM sources WHERE id = :sourceId")
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(
sourceId: Long,
stale: Boolean,
)
@Query("UPDATE sources SET name = :name, address = :address WHERE id = :sourceId")
suspend fun updateSourceDetails(
sourceId: Long,
name: String,
address: String,
)
@Insert
suspend fun insertSource(source: Source): Long
@@ -220,8 +220,8 @@ class SourceRepository(
useTokenAuth: Boolean,
) {
val dao = db.sourcesDao()
val source = dao.sourceOnce(id) ?: return
dao.upsertSource(source.copy(name = name.trim(), address = normalizeAddress(address)))
if (dao.sourceOnce(id) == null) return
dao.updateSourceDetails(id, name.trim(), normalizeAddress(address))
dao.upsertSubsonicSource(
SubsonicSource(
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(
connection: SQLiteConnection,
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
fun aStaleCreditMarkerForcesARecomputeOnTheNextSync() =
runTest {
@@ -1513,6 +1551,7 @@ private class FakeMusicSource(
var songs: List<Song> = emptyList(),
var playlists: List<Playlist> = emptyList(),
var playlistSongs: List<PlaylistSong> = emptyList(),
var songsFailure: Throwable? = null,
private val batchSize: Int = 1000,
) : MusicSource {
val requestedPlaylistIds = mutableListOf<List<String>>()
@@ -1535,7 +1574,12 @@ private class FakeMusicSource(
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)