library: keep artist queries index-driven on large libraries

Rewrite artist queue membership as a UNION of indexed song-id branches
instead of an OR that scanned every song, and recompute artist play data from
an explicit credited-album set instead of scanning all albums per artist.

Run PRAGMA optimize after syncing songs and before play aggregation, and
derive downloaded artist lists and filters from downloads rather than all
songs. Count downloaded albums per artist with artist-first branches.

Extend the query timing harness with the artist playback, download and status
queries plus a partial-download fixture.
This commit is contained in:
austinried
2026-10-18 03:40:16 +00:00
parent 97d33a781a
commit ef0c34d973
6 changed files with 150 additions and 47 deletions
@@ -3,6 +3,8 @@ package com.subtracks.data.db
import android.content.Context
import androidx.paging.PagingSource
import androidx.room3.Room
import androidx.room3.immediateTransaction
import androidx.room3.useWriterConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -61,6 +63,27 @@ class QueryTimingMeasurementTest {
val secondSyncMs = wall { SyncService(db, source).sync() }
val secondSyncExecutions = executions.toList()
db.useWriterConnection { connection ->
connection.immediateTransaction {
connection.usePrepared(
"INSERT INTO song_downloads (sourceId, songId, status, downloadedAt) VALUES (?, ?, ?, ?)",
) { statement ->
(0 until songCount step 10).forEach { i ->
statement.bindLong(1, 1L)
statement.bindText(2, "s$i")
statement.bindText(3, "Completed")
statement.bindLong(4, i.toLong())
statement.step()
statement.reset()
statement.clearBindings()
}
}
}
}
db.useWriterConnection { connection ->
connection.usePrepared("ANALYZE") { it.step() }
}
executions.clear()
measureReads()
val readExecutions = executions.toList()
@@ -93,6 +116,13 @@ class QueryTimingMeasurementTest {
timed("artists first page (frequent)") { dao.artistsByFrequent(1, 0, "").firstPage() }
timed("artist albums") { dao.albumsForArtist(1, "ar0").first() }
timed("artist name") { dao.artistName(1, "ar0").first() }
timed("artist songs") { db.queueDao().artistSongs(1, "ar0", 0, 100) }
timed("artist length") { db.queueDao().artistLength(1, "ar0") }
timed("artist song ids") { db.queueDao().artistSongIds(1, "ar0") }
timed("downloaded artists") { dao.downloadedArtists(1).firstPage() }
timed("artists downloaded filter") { dao.artistsByName(1, 0, "", downloadedFilter = 1).firstPage() }
timed("artist statuses") { db.downloadDao().artistStatuses(1).first() }
timed("artist status") { db.downloadDao().artistStatus(1, "ar0").first() }
timed("playlists first page (name)") { dao.playlistsByName(1, "").firstPage() }
timed("albums deep page") { dao.albumsByName(1, 0, "").deepPage(1000) }
timed("albums search FTS") { dao.albumsByName(1, 0, "Album 1").firstPage() }
@@ -1026,7 +1026,7 @@ class SyncServiceTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "A"), artist("a2", "B")),
artists = listOf(artist("a1", "A"), artist("a2", "B"), artist("a3", "C")),
albums =
listOf(
album("al1").copy(
@@ -1054,6 +1054,15 @@ class SyncServiceTest {
.map { it.name }
.toSet(),
)
val statuses =
db
.downloadDao()
.artistStatuses(1)
.first()
.associateBy { it.id }
assertEquals(setOf("a1", "a2"), statuses.keys)
assertEquals(1L, statuses.getValue("a2").downloaded)
}
@Test