library: keep artist art maintenance self-healing and cheap

Move change detection into a desired CTE with UPDATE ... FROM so mass updates do not turn the trailing WHERE into a quadratic correlated scan, and so no-op passes write zero rows. Fold the trigger optimization into the 35->36 migration and re-check/rewrite it from onOpen, so a future migration cannot silently restore the full UPDATE triggers. Re-resolve artist detail art when the active source becomes ready, and cover artist search renames in a Room-level test.
This commit is contained in:
austinried
2026-10-20 11:23:02 +00:00
parent 0a5b3512da
commit 77e3fa87f1
11 changed files with 85 additions and 1996 deletions
@@ -118,6 +118,10 @@ internal object ArtistSearchTriggerOptimization : RoomDatabase.Callback() {
override suspend fun onCreate(connection: SQLiteConnection) {
optimizeArtistSearchTriggers(connection)
}
override suspend fun onOpen(connection: SQLiteConnection) {
if (!artistSearchTriggersOptimized(connection)) optimizeArtistSearchTriggers(connection)
}
}
internal object SqliteLifecycleLogging : RoomDatabase.Callback() {
@@ -225,15 +225,15 @@ internal const val ARTIST_ART_UPDATE_SQL =
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_downloaded), " +
"downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
"SELECT artistId, coverArt, coverRank FROM downloaded_deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
") GROUP BY artistId) " +
"UPDATE artists SET " +
"coverArts = COALESCE((SELECT coverArts FROM covers WHERE covers.artistId = artists.id), ''), " +
"downloadedCoverArts = COALESCE((SELECT coverArts FROM downloaded_covers " +
"WHERE downloaded_covers.artistId = artists.id), '') " +
"WHERE sourceId = :sourceId AND (" +
"coverArts != COALESCE((SELECT coverArts FROM covers WHERE covers.artistId = artists.id), '') " +
"OR downloadedCoverArts != COALESCE((SELECT coverArts FROM downloaded_covers " +
"WHERE downloaded_covers.artistId = artists.id), ''))"
") GROUP BY artistId), " +
"desired AS (SELECT artists.rowid AS rowid, " +
"COALESCE((SELECT coverArts FROM covers WHERE covers.artistId = artists.id), '') AS coverArts, " +
"COALESCE((SELECT coverArts FROM downloaded_covers " +
"WHERE downloaded_covers.artistId = artists.id), '') AS downloadedCoverArts " +
"FROM artists WHERE artists.sourceId = :sourceId) " +
"UPDATE artists SET coverArts = desired.coverArts, downloadedCoverArts = desired.downloadedCoverArts " +
"FROM desired WHERE artists.rowid = desired.rowid AND " +
"(artists.coverArts != desired.coverArts OR artists.downloadedCoverArts != desired.downloadedCoverArts)"
internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
"WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " +
@@ -248,12 +248,14 @@ internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_downloaded), " +
"downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
"SELECT artistId, coverArt, coverRank FROM downloaded_deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
") GROUP BY artistId) " +
"UPDATE artists SET downloadedCoverArts = COALESCE((SELECT coverArts FROM downloaded_covers " +
"WHERE downloaded_covers.artistId = artists.id), '') " +
"WHERE sourceId = :sourceId AND id IN (:artistIds) AND " +
"downloadedCoverArts != COALESCE((SELECT coverArts FROM downloaded_covers " +
"WHERE downloaded_covers.artistId = artists.id), '')"
") GROUP BY artistId), " +
"desired AS (SELECT artists.rowid AS rowid, " +
"COALESCE((SELECT coverArts FROM downloaded_covers " +
"WHERE downloaded_covers.artistId = artists.id), '') AS downloadedCoverArts " +
"FROM artists WHERE artists.sourceId = :sourceId AND artists.id IN (:artistIds)) " +
"UPDATE artists SET downloadedCoverArts = desired.downloadedCoverArts " +
"FROM desired WHERE artists.rowid = desired.rowid AND " +
"artists.downloadedCoverArts != desired.downloadedCoverArts"
internal const val ARTISTS_FILTER =
"FROM artists WHERE sourceId = :sourceId " +
@@ -701,16 +701,20 @@ val MIGRATION_35_36 =
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `artistImageUrl` TEXT")
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `coverArts` TEXT NOT NULL DEFAULT ''")
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `downloadedCoverArts` TEXT NOT NULL DEFAULT ''")
optimizeArtistSearchTriggers(connection)
backfillArtistArt(connection)
}
}
val MIGRATION_36_37 =
object : Migration(36, 37) {
override suspend fun migrate(connection: SQLiteConnection) {
optimizeArtistSearchTriggers(connection)
}
internal fun artistSearchTriggersOptimized(connection: SQLiteConnection): Boolean {
val triggers = mutableListOf<String>()
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?)").use { statement ->
statement.bindText(1, "room_fts_content_sync_artist_search_BEFORE_UPDATE")
statement.bindText(2, "room_fts_content_sync_artist_search_AFTER_UPDATE")
while (statement.step()) triggers += statement.getText(0)
}
return triggers.size == 2 && triggers.all { it.contains("UPDATE OF `name`, `sortName`") }
}
internal fun optimizeArtistSearchTriggers(connection: SQLiteConnection) {
connection.execSQL("DROP TRIGGER IF EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE")
@@ -778,5 +782,4 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_33_34,
MIGRATION_34_35,
MIGRATION_35_36,
MIGRATION_36_37,
)
@@ -57,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry
SongDownload::class,
PendingAction::class,
],
version = 37,
version = 36,
exportSchema = true,
)
@ColumnTypeConverters(
@@ -70,6 +70,9 @@ class SourceRepository(
private val _offline = MutableStateFlow(false)
val offline: StateFlow<Boolean> = _offline
private val _activeSource = MutableStateFlow<Long?>(null)
val activeSource: StateFlow<Long?> = _activeSource
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
fun setOfflineMode(enabled: Boolean) {
@@ -110,6 +113,7 @@ class SourceRepository(
Log.i("net", "active source -> ${config?.name ?: "none"}")
}
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
_activeSource.value = config?.id
_quality.value = quality
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
scope.launch {
@@ -17,7 +17,6 @@ import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -143,8 +142,13 @@ class ArtistDetailViewModel(
init {
viewModelScope.launch {
combine(artist.filterNotNull(), sourceRepository.offline) { loaded, _ -> loaded }
.collectLatest { loaded ->
combine(
artist.filterNotNull(),
sourceRepository.offline,
sourceRepository.activeSource,
) { loaded, _, activeSource -> loaded to activeSource }
.collectLatest { (loaded, activeSource) ->
if (activeSource == null) return@collectLatest
val hero = sourceRepository.artistArt(loaded, forIcon = false)
val icon = sourceRepository.artistArt(loaded, forIcon = true)
_art.value = hero.image ?: hero.covers.firstOrNull()