mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
-1962
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
@@ -682,6 +682,24 @@ class LibraryDaoTest {
|
||||
assertEquals(listOf("art-2", "art-1"), dao.artistOnce(sourceId, "ar-1")!!.downloadedCoverArts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistNameUpdatesKeepTheSearchIndexValid() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 0)))
|
||||
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Renamed", albumCount = 0)))
|
||||
|
||||
val hits =
|
||||
db.useReaderConnection { connection ->
|
||||
connection.usePrepared("SELECT COUNT(*) FROM artist_search WHERE artist_search MATCH 'Renamed'") {
|
||||
it.step()
|
||||
it.getLong(0)
|
||||
}
|
||||
}
|
||||
assertEquals(1L, hits)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistArtDeduplicatesCoversBeforeApplyingTheFourCoverBudget() =
|
||||
runTest {
|
||||
|
||||
@@ -222,9 +222,10 @@ class MigrationsTest {
|
||||
fun theArtistSearchTriggersIgnoreArtOnlyUpdates() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { migrated ->
|
||||
applySchema(migrated, 36)
|
||||
MIGRATION_36_37.migrate(migrated)
|
||||
applySchema(migrated, 35)
|
||||
MIGRATION_35_36.migrate(migrated)
|
||||
|
||||
assertTrue(artistSearchTriggersOptimized(migrated))
|
||||
assertTrue(
|
||||
triggerSql(migrated, "room_fts_content_sync_artist_search_BEFORE_UPDATE")
|
||||
.contains("UPDATE OF `name`, `sortName`"),
|
||||
@@ -240,7 +241,7 @@ class MigrationsTest {
|
||||
fun theFreshDatabaseCallbackOptimizesTheArtistSearchTriggers() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { connection ->
|
||||
applySchema(connection, 37)
|
||||
applySchema(connection, 36)
|
||||
connection.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
|
||||
)
|
||||
@@ -249,15 +250,12 @@ class MigrationsTest {
|
||||
"VALUES (1, 'ar1', 'Artist', 0, NULL)",
|
||||
)
|
||||
|
||||
ArtistSearchTriggerOptimization.onCreate(connection)
|
||||
ArtistSearchTriggerOptimization.onOpen(connection)
|
||||
assertTrue(artistSearchTriggersOptimized(connection))
|
||||
val before = long(connection, "SELECT total_changes()")
|
||||
connection.execSQL("UPDATE artists SET coverArts = 'a' WHERE id = 'ar1'")
|
||||
|
||||
assertEquals(before + 1, long(connection, "SELECT total_changes()"))
|
||||
assertTrue(
|
||||
triggerSql(connection, "room_fts_content_sync_artist_search_AFTER_UPDATE")
|
||||
.contains("UPDATE OF `name`, `sortName`"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,6 +544,10 @@ class MigrationsTest {
|
||||
for (i in 0 until indices.length()) {
|
||||
connection.execSQL(indices.getJSONObject(i).getString("createSql").replace("\${TABLE_NAME}", name))
|
||||
}
|
||||
val triggers = entity.optJSONArray("contentSyncTriggers") ?: continue
|
||||
for (i in 0 until triggers.length()) {
|
||||
connection.execSQL(triggers.getString(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,12 @@ class QueryTimingMeasurementTest {
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
|
||||
db = Room.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java).setDriver(driver).build()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(driver)
|
||||
.addCallback(ArtistSearchTriggerOptimization)
|
||||
.build()
|
||||
}
|
||||
|
||||
@After
|
||||
|
||||
@@ -285,6 +285,15 @@ class SourceRepositoryTest {
|
||||
assertTrue("expected the stored file, was ${ref.url}", ref.url.startsWith("file:"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activeSourceBecomesReadyAfterThePreferencesLoad() =
|
||||
runBlocking {
|
||||
repository.addSource("nav", "http://a.example/", "u", "p", false)
|
||||
|
||||
val active = withTimeout(TEST_TIMEOUT_MS) { repository.activeSource.first { it != null } }
|
||||
assertEquals(repository.activeSourceId().first(), active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistImageUsesTheNetworkOnlineAndOnlyCachedFilesOffline() =
|
||||
runBlocking {
|
||||
|
||||
Reference in New Issue
Block a user