library: align artist counts with credited songs

Artist badges and play rankings only counted album credits, while the artist
page, shuffle queue and download attribution already included track credits.
Derive albumCount and the artist plays from the same four-way credit union,
count lead ids that the server omits from the plural refs, and bump the song's
credited artists on a local scrobble even when the track has no album.

Sort the downloaded artist list by the downloaded album count it displays
instead of the synced total, and backfill the artist totals on upgrade so the
fix applies before the next sync.
This commit is contained in:
austinried
2026-10-18 14:25:07 +00:00
parent 916da72339
commit e0cfba489e
7 changed files with 2199 additions and 39 deletions
@@ -182,8 +182,10 @@ class LibraryDaoTest {
dao.upsertSongs(
listOf(
song(sourceId, "s1", "One", starred = null).copy(albumId = "al-1", playCount = 3, played = 100),
song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2", playCount = 4, played = 200),
song(sourceId, "s3", "Three", starred = null).copy(albumId = "al-2", playCount = 5, played = 400),
song(sourceId, "s2", "Two", starred = null)
.copy(albumId = "al-2", artistId = "ar-2", playCount = 4, played = 200),
song(sourceId, "s3", "Three", starred = null)
.copy(albumId = "al-2", artistId = "ar-2", playCount = 5, played = 400),
),
)
@@ -607,7 +609,7 @@ class LibraryDaoTest {
}
@Test
fun downloadedFilterShowsDownloadedAlbumCountsButSortsByTheSyncedCount() =
fun downloadedFilterSortsByTheDownloadedAlbumCountItShows() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
@@ -635,11 +637,11 @@ class LibraryDaoTest {
db.downloadDao().upsert(SongDownload(sourceId, it, DownloadStatus.Completed))
}
// ar-1 has two downloaded albums, ar-2 one. The list still orders by the synced count
// (so the album-count index stays usable) but reports the downloaded count.
// ar-1 has two downloaded albums, ar-2 one, so the downloaded order is ar-1 first even
// though the synced counts would put ar-2 first.
val filtered = dao.artistsByAlbumCount(sourceId, 0, "", downloadedFilter = 1).page()
assertEquals(listOf("ar-2", "ar-1"), filtered.map { it.id })
assertEquals(listOf(1L, 2L), filtered.map { it.albumCount })
assertEquals(listOf("ar-1", "ar-2"), filtered.map { it.id })
assertEquals(listOf(2L, 1L), filtered.map { it.albumCount })
}
@Test
@@ -272,6 +272,67 @@ class MigrationsTest {
}
}
@Test
fun theCreditTotalsBackfillUsesEveryCreditedSong() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 33)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (2, 't', 'http://y', 1, 0)",
)
migrated.execSQL(
"INSERT INTO artists (sourceId, id, name, albumCount, songCount, playCount, played) " +
"VALUES (1, 'a1', 'Album Artist', 1, 0, 0, NULL)",
)
migrated.execSQL(
"INSERT INTO artists (sourceId, id, name, albumCount, songCount, playCount, played) " +
"VALUES (1, 'a2', 'Guest', 0, 0, 0, NULL)",
)
migrated.execSQL(
"INSERT INTO artists (sourceId, id, name, albumCount, songCount, playCount, played) " +
"VALUES (2, 'b1', 'Unsynced', 7, 6, 5, 100)",
)
migrated.execSQL(
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
"VALUES (1, 'al1', 'a1', 'Album', 'Album Artist', 0, 1)",
)
migrated.execSQL(
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
"VALUES (1, 'al1', 0, 'a1', 'Album Artist')",
)
migrated.execSQL(
"INSERT INTO songs (sourceId, id, albumId, artistId, title, playCount, played) " +
"VALUES (1, 's1', 'al1', 'a1', 'One', 4, 200)",
)
migrated.execSQL(
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
"VALUES (1, 's1', 0, 'a1', 'Album Artist')",
)
migrated.execSQL(
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
"VALUES (1, 's1', 1, 'a2', 'Guest')",
)
MIGRATION_33_34.migrate(migrated)
assertEquals(1L, long(migrated, "SELECT songCount FROM artists WHERE id = 'a1'"))
assertEquals(1L, long(migrated, "SELECT albumCount FROM artists WHERE id = 'a1'"))
assertEquals(4L, long(migrated, "SELECT playCount FROM artists WHERE id = 'a1'"))
assertEquals(200L, long(migrated, "SELECT played FROM artists WHERE id = 'a1'"))
assertEquals(1L, long(migrated, "SELECT songCount FROM artists WHERE id = 'a2'"))
assertEquals(1L, long(migrated, "SELECT albumCount FROM artists WHERE id = 'a2'"))
assertEquals(4L, long(migrated, "SELECT playCount FROM artists WHERE id = 'a2'"))
assertEquals(200L, long(migrated, "SELECT played FROM artists WHERE id = 'a2'"))
assertEquals(6L, long(migrated, "SELECT songCount FROM artists WHERE id = 'b1'"))
assertEquals(7L, long(migrated, "SELECT albumCount FROM artists WHERE id = 'b1'"))
assertEquals(5L, long(migrated, "SELECT playCount FROM artists WHERE id = 'b1'"))
assertEquals(100L, long(migrated, "SELECT played FROM artists WHERE id = 'b1'"))
}
}
private fun text(
connection: SQLiteConnection,
sql: String,
@@ -1465,6 +1465,137 @@ class SyncServiceTest {
assertEquals(500L, db.libraryDao().artistOnce(1, "a2")!!.played)
}
@Test
fun trackOnlyCreditsReachTheArtistBadgeAndPlays() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "Album Artist"), artist("a2", "Guest").copy(albumCount = 7)),
albums =
listOf(
album("al1").copy(
artistId = "a1",
artists = listOf(ArtistRef("a1", "Album Artist")),
),
),
songs =
listOf(
song("s1").copy(
albumId = "al1",
artistId = "a1",
artists = listOf(ArtistRef("a1", "Album Artist"), ArtistRef("a2", "Guest")),
playCount = 3,
played = 100,
),
),
)
SyncService(db, source).sync()
val guest = db.libraryDao().artistOnce(1, "a2")!!
assertEquals(1L, guest.albumCount)
assertEquals(1L, guest.songCount)
assertEquals(3L, guest.playCount)
assertEquals(100L, guest.played)
assertEquals(
listOf("al1"),
db
.libraryDao()
.albumsForArtist(1, "a2")
.first()
.map { it.id },
)
assertEquals(
db
.libraryDao()
.albumsForArtist(1, "a2")
.first()
.size
.toLong(),
db
.libraryDao()
.artistsByName(1, 0, "")
.allRows()
.first { it.id == "a2" }
.albumCount,
)
}
@Test
fun albumlessTrackCreditsCountAndBumpOnPlay() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "Album Artist"), artist("a2", "Guest")),
albums = emptyList(),
songs =
listOf(
song("s1").copy(
albumId = null,
artistId = "a1",
artists = listOf(ArtistRef("a1", "Album Artist"), ArtistRef("a2", "Guest")),
playCount = 2,
played = 50,
),
),
)
SyncService(db, source).sync()
val guest = db.libraryDao().artistOnce(1, "a2")!!
assertEquals(0L, guest.albumCount)
assertEquals(1L, guest.songCount)
assertEquals(2L, guest.playCount)
assertEquals(50L, guest.played)
db.libraryDao().recordPlay(1, "s1", 300)
assertEquals(3L, db.libraryDao().artistOnce(1, "a2")!!.playCount)
assertEquals(300L, db.libraryDao().artistOnce(1, "a2")!!.played)
}
@Test
fun leadIdsOutsideThePluralRefsKeepArtistTotalsAndShufflesAligned() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "Lead"), artist("a2", "Ref")),
albums =
listOf(
album("al1").copy(
artistId = "a1",
artists = listOf(ArtistRef("a2", "Ref")),
),
),
songs =
listOf(
song("s1").copy(
albumId = "al1",
artistId = "a1",
artists = listOf(ArtistRef("a2", "Ref")),
),
),
)
SyncService(db, source).sync()
val lead = db.libraryDao().artistOnce(1, "a1")!!
assertEquals(1L, lead.songCount)
assertEquals(1L, lead.albumCount)
assertEquals(db.queueDao().artistLength(1, "a1"), lead.songCount)
assertEquals(
lead.songCount,
db
.downloadDao()
.artistStatus(1, "a1")
.first()
.total,
)
}
private suspend fun totalChanges(): Long =
db.useWriterConnection { connection ->
connection.usePrepared("SELECT total_changes()") { statement ->