library: store artist song counts for download badges

Add artists.songCount, recomputed after syncs that change albums or songs,
and derive the Artists tab status map from the download rows plus that count
instead of walking every song. The badge query is now proportional to
downloads rather than library size.
This commit is contained in:
austinried
2026-10-18 04:02:48 +00:00
parent ef0c34d973
commit ba77b31dc4
9 changed files with 1897 additions and 19 deletions
File diff suppressed because it is too large Load Diff
@@ -47,30 +47,30 @@ internal const val ARTIST_DOWNLOAD_ROWS =
// Starts from downloads so lists and filters over partially downloaded libraries do not scan songs. // Starts from downloads so lists and filters over partially downloaded libraries do not scan songs.
internal const val DOWNLOADED_ARTIST_ROWS = internal const val DOWNLOADED_ARTIST_ROWS =
"SELECT albums.artistId AS id, song_downloads.downloadedAt AS downloadedAt, " + "SELECT albums.artistId AS id, song_downloads.songId AS songId, " +
"song_downloads.status AS status " + "song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
"FROM song_downloads " + "FROM song_downloads " +
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " + "JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_downloads.sourceId = :sourceId AND albums.artistId IS NOT NULL " + "WHERE song_downloads.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"UNION ALL " + "UNION ALL " +
"SELECT album_artists.artistId AS id, song_downloads.downloadedAt AS downloadedAt, " + "SELECT album_artists.artistId AS id, song_downloads.songId AS songId, " +
"song_downloads.status AS status " + "song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
"FROM song_downloads " + "FROM song_downloads " +
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " + "JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN album_artists ON album_artists.sourceId = albums.sourceId AND album_artists.albumId = albums.id " + "JOIN album_artists ON album_artists.sourceId = albums.sourceId AND album_artists.albumId = albums.id " +
"WHERE song_downloads.sourceId = :sourceId " + "WHERE song_downloads.sourceId = :sourceId " +
"UNION ALL " + "UNION ALL " +
"SELECT songs.artistId AS id, song_downloads.downloadedAt AS downloadedAt, " + "SELECT songs.artistId AS id, song_downloads.songId AS songId, " +
"song_downloads.status AS status " + "song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
"FROM song_downloads " + "FROM song_downloads " +
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " + "JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_downloads.sourceId = :sourceId AND songs.artistId IS NOT NULL " + "WHERE song_downloads.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
"UNION ALL " + "UNION ALL " +
"SELECT song_artists.artistId AS id, song_downloads.downloadedAt AS downloadedAt, " + "SELECT song_artists.artistId AS id, song_downloads.songId AS songId, " +
"song_downloads.status AS status " + "song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
"FROM song_downloads " + "FROM song_downloads " +
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " + "JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
@@ -140,15 +140,15 @@ interface DownloadDao {
fun albumStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>> fun albumStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
@Query( @Query(
"SELECT id, " + "SELECT statuses.id, artists.songCount AS total, statuses.downloaded, statuses.downloading " +
"COUNT(DISTINCT songId) AS total, " + "FROM (SELECT id, " +
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " + "COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " + "COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
"FROM (" + ARTIST_DOWNLOAD_ROWS + ") " + "FROM (" + DOWNLOADED_ARTIST_ROWS + ") " +
"WHERE id IN (SELECT id FROM (" + DOWNLOADED_ARTIST_ROWS + ") " + "WHERE status IN ('Completed', 'Queued', 'Running') " +
"WHERE status IN ('Completed', 'Queued', 'Running')) " + "GROUP BY id) statuses " +
"GROUP BY id " + "JOIN artists ON artists.sourceId = :sourceId AND artists.id = statuses.id " +
"HAVING downloaded > 0 OR downloading > 0", "WHERE statuses.downloaded > 0 OR statuses.downloading > 0",
) )
fun artistStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>> fun artistStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
@@ -225,6 +225,26 @@ interface LibraryDao {
) )
suspend fun recomputeArtistPlayData(sourceId: Long) suspend fun recomputeArtistPlayData(sourceId: Long)
// Stored so the download badge query can report an artist's total without walking every song.
@Query(
"WITH credited AS (" +
"SELECT artistId, songId FROM song_artists WHERE sourceId = :sourceId " +
"UNION " +
"SELECT album_artists.artistId AS artistId, songs.id AS songId " +
"FROM album_artists " +
"JOIN songs ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId" +
"), counts AS (" +
"SELECT artistId AS id, COUNT(*) AS songCount FROM credited GROUP BY artistId" +
") " +
"UPDATE artists SET songCount = c.songCount " +
"FROM (SELECT artists.id AS id, COALESCE(counts.songCount, 0) AS songCount " +
"FROM artists LEFT JOIN counts ON counts.id = artists.id " +
"WHERE artists.sourceId = :sourceId) c " +
"WHERE artists.sourceId = :sourceId AND artists.id = c.id AND artists.songCount != c.songCount",
)
suspend fun recomputeArtistSongCounts(sourceId: Long)
@Transaction @Transaction
suspend fun recomputePlayData(sourceId: Long) { suspend fun recomputePlayData(sourceId: Long) {
recomputeAlbumPlayData(sourceId) recomputeAlbumPlayData(sourceId)
@@ -422,6 +422,13 @@ val MIGRATION_28_29 =
} }
} }
val MIGRATION_29_30 =
object : Migration(29, 30) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `songCount` INTEGER NOT NULL DEFAULT 0")
}
}
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities. // Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
private suspend fun createSearchIndex( private suspend fun createSearchIndex(
connection: SQLiteConnection, connection: SQLiteConnection,
@@ -484,4 +491,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_26_27, MIGRATION_26_27,
MIGRATION_27_28, MIGRATION_27_28,
MIGRATION_28_29, MIGRATION_28_29,
MIGRATION_29_30,
) )
@@ -54,7 +54,7 @@ import com.subtracks.data.model.UpNextEntry
SongDownload::class, SongDownload::class,
PendingAction::class, PendingAction::class,
], ],
version = 29, version = 30,
exportSchema = true, exportSchema = true,
) )
@ColumnTypeConverters( @ColumnTypeConverters(
@@ -89,6 +89,7 @@ data class Artist(
val coverArt: String? = null, val coverArt: String? = null,
@ColumnInfo(defaultValue = "0") val playCount: Long = 0, @ColumnInfo(defaultValue = "0") val playCount: Long = 0,
val played: Long? = null, val played: Long? = null,
@ColumnInfo(defaultValue = "0") val songCount: Long = 0,
) )
@Entity( @Entity(
@@ -33,9 +33,12 @@ class SyncService(
suspend fun sync(): SyncSummary { suspend fun sync(): SyncSummary {
syncArtists() syncArtists()
val libraryBefore = totalChanges()
syncAlbums() syncAlbums()
syncSongs() syncSongs()
val libraryChanged = totalChanges() != libraryBefore
optimize() optimize()
if (libraryChanged) recomputeArtistSongCounts()
aggregatePlayData() aggregatePlayData()
syncPlaylists() syncPlaylists()
syncPlaylistSongs() syncPlaylistSongs()
@@ -138,6 +141,18 @@ class SyncService(
db.libraryDao().recomputePlayData(source.id) db.libraryDao().recomputePlayData(source.id)
} }
private suspend fun recomputeArtistSongCounts() {
db.libraryDao().recomputeArtistSongCounts(source.id)
}
private suspend fun totalChanges(): Long =
db.useWriterConnection { connection ->
connection.usePrepared("SELECT total_changes()") { statement ->
statement.step()
statement.getLong(0)
}
}
// Without sqlite_stat1 the artist and relation queries can fall back to whole-table scans. // Without sqlite_stat1 the artist and relation queries can fall back to whole-table scans.
private suspend fun optimize() { private suspend fun optimize() {
db.useWriterConnection { connection -> db.useWriterConnection { connection ->
@@ -682,7 +682,17 @@ class DownloadRepositoryTest {
), ),
) )
db.libraryDao().upsertArtists( db.libraryDao().upsertArtists(
listOf(Artist(sourceId = 1, id = "ar-guest", name = "Guest", albumCount = 0, starred = null, coverArt = null)), listOf(
Artist(
sourceId = 1,
id = "ar-guest",
name = "Guest",
albumCount = 0,
starred = null,
coverArt = null,
songCount = 1,
),
),
) )
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed)) db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed))
} }
@@ -910,7 +920,9 @@ class DownloadRepositoryTest {
), ),
) )
db.libraryDao().upsertArtists( db.libraryDao().upsertArtists(
listOf(Artist(sourceId = 1, id = "ar2", name = "Other", albumCount = 1, starred = null, coverArt = null)), listOf(
Artist(sourceId = 1, id = "ar2", name = "Other", albumCount = 1, starred = null, coverArt = null, songCount = 1),
),
) )
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed)) db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Queued)) db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Queued))
@@ -1166,7 +1178,7 @@ class DownloadRepositoryTest {
) )
private fun artist(coverArt: String?) = private fun artist(coverArt: String?) =
Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt) Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt, songCount = 2)
private fun seedLibrary() { private fun seedLibrary() {
runBlocking { runBlocking {
@@ -979,6 +979,8 @@ class SyncServiceTest {
assertEquals(listOf("s1"), db.queueDao().artistSongIds(1, "a2")) assertEquals(listOf("s1"), db.queueDao().artistSongIds(1, "a2"))
assertEquals(1L, db.queueDao().artistLength(1, "a2")) assertEquals(1L, db.queueDao().artistLength(1, "a2"))
assertEquals("B", db.libraryDao().artistName(1, "a2").first()) assertEquals("B", db.libraryDao().artistName(1, "a2").first())
assertEquals(1L, db.libraryDao().artistOnce(1, "a1")!!.songCount)
assertEquals(1L, db.libraryDao().artistOnce(1, "a2")!!.songCount)
source.albums = listOf(album("al1").copy(artistId = "a1", artists = listOf(ArtistRef("a1", "A")))) source.albums = listOf(album("al1").copy(artistId = "a1", artists = listOf(ArtistRef("a1", "A"))))
source.songs = listOf(song("s1").copy(artistId = "a1", artists = listOf(ArtistRef("a1", "A")))) source.songs = listOf(song("s1").copy(artistId = "a1", artists = listOf(ArtistRef("a1", "A"))))
@@ -994,6 +996,7 @@ class SyncServiceTest {
.map { it.id }, .map { it.id },
) )
assertEquals(emptyList<String>(), db.queueDao().artistSongIds(1, "a2")) assertEquals(emptyList<String>(), db.queueDao().artistSongIds(1, "a2"))
assertEquals(0L, db.libraryDao().artistOnce(1, "a2")!!.songCount)
} }
@Test @Test