From 77e3fa87f1ce944ae01941ca10c898d48d9e0d66 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:23:02 +0000 Subject: [PATCH] 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. --- .../37.json | 1962 ----------------- .../kotlin/com/subtracks/data/db/DbLogging.kt | 4 + .../com/subtracks/data/db/LibraryDao.kt | 32 +- .../com/subtracks/data/db/Migrations.kt | 15 +- .../subtracks/data/db/SubtracksDatabase.kt | 2 +- .../subtracks/data/repo/SourceRepository.kt | 4 + .../ui/library/ArtistDetailViewModel.kt | 10 +- .../com/subtracks/data/db/LibraryDaoTest.kt | 18 + .../com/subtracks/data/db/MigrationsTest.kt | 18 +- .../data/db/QueryTimingMeasurementTest.kt | 7 +- .../data/repo/SourceRepositoryTest.kt | 9 + 11 files changed, 85 insertions(+), 1996 deletions(-) delete mode 100644 app/schemas/com.subtracks.data.db.SubtracksDatabase/37.json diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/37.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/37.json deleted file mode 100644 index 0c82dc90..00000000 --- a/app/schemas/com.subtracks.data.db.SubtracksDatabase/37.json +++ /dev/null @@ -1,1962 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 37, - "identityHash": "3f2c67aa23973cb1300aaffe38bdafec", - "entities": [ - { - "tableName": "sources", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `address` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `creditsStale` INTEGER NOT NULL DEFAULT 1)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "address", - "columnName": "address", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "isActive", - "columnName": "isActive", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "creditsStale", - "columnName": "creditsStale", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "1" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "subsonic_sources", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `useTokenAuth` INTEGER NOT NULL, PRIMARY KEY(`sourceId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "username", - "columnName": "username", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "password", - "columnName": "password", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "useTokenAuth", - "columnName": "useTokenAuth", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "sourceId" - ] - }, - "indices": [ - { - "name": "index_subsonic_sources_sourceId", - "unique": false, - "columnNames": [ - "sourceId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_subsonic_sources_sourceId` ON `${TABLE_NAME}` (`sourceId`)" - } - ], - "foreignKeys": [ - { - "table": "sources", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "sourceId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "artists", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL COLLATE NOCASE, `albumCount` INTEGER NOT NULL, `starred` INTEGER, `coverArt` TEXT, `artistImageUrl` TEXT, `coverArts` TEXT NOT NULL DEFAULT '', `downloadedCoverArts` TEXT NOT NULL DEFAULT '', `playCount` INTEGER NOT NULL DEFAULT 0, `played` INTEGER, `songCount` INTEGER NOT NULL DEFAULT 0, `sortName` TEXT NOT NULL COLLATE NOCASE DEFAULT '', `credited` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumCount", - "columnName": "albumCount", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "starred", - "columnName": "starred", - "affinity": "INTEGER" - }, - { - "fieldPath": "coverArt", - "columnName": "coverArt", - "affinity": "TEXT" - }, - { - "fieldPath": "artistImageUrl", - "columnName": "artistImageUrl", - "affinity": "TEXT" - }, - { - "fieldPath": "coverArts", - "columnName": "coverArts", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "''" - }, - { - "fieldPath": "downloadedCoverArts", - "columnName": "downloadedCoverArts", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "''" - }, - { - "fieldPath": "playCount", - "columnName": "playCount", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "played", - "columnName": "played", - "affinity": "INTEGER" - }, - { - "fieldPath": "songCount", - "columnName": "songCount", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "sortName", - "columnName": "sortName", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "''" - }, - { - "fieldPath": "credited", - "columnName": "credited", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "1" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "sourceId", - "id" - ] - }, - "indices": [ - { - "name": "index_artists_sourceId", - "unique": false, - "columnNames": [ - "sourceId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_sourceId` ON `${TABLE_NAME}` (`sourceId`)" - }, - { - "name": "index_artists_name", - "unique": false, - "columnNames": [ - "sourceId", - "sortName", - "id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `${TABLE_NAME}` (`sourceId`, `sortName`, `id`)" - }, - { - "name": "index_artists_albumCount", - "unique": false, - "columnNames": [ - "sourceId", - "albumCount", - "sortName", - "id" - ], - "orders": [ - "ASC", - "DESC", - "ASC", - "ASC" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_albumCount` ON `${TABLE_NAME}` (`sourceId` ASC, `albumCount` DESC, `sortName` ASC, `id` ASC)" - }, - { - "name": "index_artists_starred", - "unique": false, - "columnNames": [ - "sourceId", - "starred", - "sortName", - "id" - ], - "orders": [ - "ASC", - "DESC", - "ASC", - "ASC" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)" - }, - { - "name": "index_artists_frequent", - "unique": false, - "columnNames": [ - "sourceId", - "playCount", - "sortName", - "id" - ], - "orders": [ - "ASC", - "DESC", - "ASC", - "ASC" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_frequent` ON `${TABLE_NAME}` (`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)" - }, - { - "name": "index_artists_recent", - "unique": false, - "columnNames": [ - "sourceId", - "played", - "sortName", - "id" - ], - "orders": [ - "ASC", - "DESC", - "ASC", - "ASC" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_recent` ON `${TABLE_NAME}` (`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)" - } - ], - "foreignKeys": [ - { - "table": "sources", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "sourceId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "artist_bios", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `biography` TEXT, PRIMARY KEY(`sourceId`, `artistId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "artistId", - "columnName": "artistId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "biography", - "columnName": "biography", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "sourceId", - "artistId" - ] - }, - "foreignKeys": [ - { - "table": "sources", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "sourceId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "artist_search", - "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`name`, `sortName`, tokenize=`trigram`, content=`artists`)", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sortName", - "columnName": "sortName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowid" - ] - }, - "ftsVersion": "FTS5", - "ftsOptions": { - "tokenizer": "trigram", - "tokenizerArgs": [], - "contentTable": "artists", - "languageIdColumnName": "", - "matchInfo": "FTS4", - "notIndexedColumns": [], - "prefixSizes": [], - "preferredOrder": "ASC", - "contentRowId": "", - "columnSize": true, - "detail": "FULL" - }, - "contentSyncTriggers": [ - "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE BEFORE UPDATE ON `artists` BEGIN DELETE FROM `artist_search` WHERE `rowid`=OLD.`rowid`; END", - "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_BEFORE_DELETE BEFORE DELETE ON `artists` BEGIN DELETE FROM `artist_search` WHERE `rowid`=OLD.`rowid`; END", - "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE AFTER UPDATE ON `artists` BEGIN INSERT INTO `artist_search`(`rowid`, `name`, `sortName`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`sortName`); END", - "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_AFTER_INSERT AFTER INSERT ON `artists` BEGIN INSERT INTO `artist_search`(`rowid`, `name`, `sortName`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`sortName`); END" - ] - }, - { - "tableName": "albums", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `artistId` TEXT, `name` TEXT NOT NULL COLLATE NOCASE, `albumArtist` TEXT COLLATE NOCASE, `created` INTEGER NOT NULL, `coverArt` TEXT, `genre` TEXT, `year` INTEGER, `starred` INTEGER, `songCount` INTEGER NOT NULL, `playCount` INTEGER NOT NULL DEFAULT 0, `played` INTEGER, `artists` TEXT NOT NULL DEFAULT '', `sortName` TEXT NOT NULL COLLATE NOCASE DEFAULT '', `albumArtistSort` TEXT NOT NULL COLLATE NOCASE DEFAULT '', PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "artistId", - "columnName": "artistId", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumArtist", - "columnName": "albumArtist", - "affinity": "TEXT" - }, - { - "fieldPath": "created", - "columnName": "created", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "coverArt", - "columnName": "coverArt", - "affinity": "TEXT" - }, - { - "fieldPath": "genre", - "columnName": "genre", - "affinity": "TEXT" - }, - { - "fieldPath": "year", - "columnName": "year", - "affinity": "INTEGER" - }, - { - "fieldPath": "starred", - "columnName": "starred", - "affinity": "INTEGER" - }, - { - "fieldPath": "songCount", - "columnName": "songCount", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "playCount", - "columnName": "playCount", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "played", - "columnName": "played", - "affinity": "INTEGER" - }, - { - "fieldPath": "artists", - "columnName": "artists", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "''" - }, - { - "fieldPath": "sortName", - "columnName": "sortName", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "''" - }, - { - "fieldPath": "albumArtistSort", - "columnName": "albumArtistSort", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "''" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "sourceId", - "id" - ] - }, - "indices": [ - { - "name": "index_albums_sourceId", - "unique": false, - "columnNames": [ - "sourceId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId` ON `${TABLE_NAME}` (`sourceId`)" - }, - { - "name": "index_albums_sourceId_artistId", - "unique": false, - "columnNames": [ - "sourceId", - "artistId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" - }, - { - "name": "index_albums_name", - "unique": false, - "columnNames": [ - "sourceId", - "sortName", - "id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_name` ON `${TABLE_NAME}` (`sourceId`, `sortName`, `id`)" - }, - { - "name": "index_albums_artist", - "unique": false, - "columnNames": [ - "sourceId", - "albumArtistSort", - "year", - "sortName", - "id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist` ON `${TABLE_NAME}` (`sourceId`, `albumArtistSort`, `year`, `sortName`, `id`)" - }, - { - "name": "index_albums_year", - "unique": false, - "columnNames": [ - "sourceId", - "year", - "sortName", - "id" - ], - "orders": [ - "ASC", - "DESC", - "ASC", - "ASC" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_year` ON `${TABLE_NAME}` (`sourceId` ASC, `year` DESC, `sortName` ASC, `id` ASC)" - }, - { - "name": "index_albums_added", - "unique": false, - "columnNames": [ - "sourceId", - "created", - "sortName", - "id" - ], - "orders": [ - "ASC", - "DESC", - "ASC", - "ASC" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_added` ON `${TABLE_NAME}` (`sourceId` ASC, `created` DESC, `sortName` ASC, `id` ASC)" - }, - { - "name": "index_albums_starred", - "unique": false, - "columnNames": [ - "sourceId", - "starred", - "sortName", - "id" - ], - "orders": [ - "ASC", - "DESC", - "ASC", - "ASC" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)" - }, - { - "name": "index_albums_frequent", - "unique": false, - "columnNames": [ - "sourceId", - "playCount", - "sortName", - "id" - ], - "orders": [ - "ASC", - "DESC", - "ASC", - "ASC" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_frequent` ON `${TABLE_NAME}` (`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)" - }, - { - "name": "index_albums_recent", - "unique": false, - "columnNames": [ - "sourceId", - "played", - "sortName", - "id" - ], - "orders": [ - "ASC", - "DESC", - "ASC", - "ASC" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_recent` ON `${TABLE_NAME}` (`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)" - } - ], - "foreignKeys": [ - { - "table": "sources", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "sourceId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "album_search", - "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`name`, `albumArtist`, `sortName`, `albumArtistSort`, tokenize=`trigram`, content=`albums`)", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumArtist", - "columnName": "albumArtist", - "affinity": "TEXT" - }, - { - "fieldPath": "sortName", - "columnName": "sortName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumArtistSort", - "columnName": "albumArtistSort", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowid" - ] - }, - "ftsVersion": "FTS5", - "ftsOptions": { - "tokenizer": "trigram", - "tokenizerArgs": [], - "contentTable": "albums", - "languageIdColumnName": "", - "matchInfo": "FTS4", - "notIndexedColumns": [], - "prefixSizes": [], - "preferredOrder": "ASC", - "contentRowId": "", - "columnSize": true, - "detail": "FULL" - }, - "contentSyncTriggers": [ - "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_album_search_BEFORE_UPDATE BEFORE UPDATE ON `albums` BEGIN DELETE FROM `album_search` WHERE `rowid`=OLD.`rowid`; END", - "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_album_search_BEFORE_DELETE BEFORE DELETE ON `albums` BEGIN DELETE FROM `album_search` WHERE `rowid`=OLD.`rowid`; END", - "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_album_search_AFTER_UPDATE AFTER UPDATE ON `albums` BEGIN INSERT INTO `album_search`(`rowid`, `name`, `albumArtist`, `sortName`, `albumArtistSort`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`albumArtist`, NEW.`sortName`, NEW.`albumArtistSort`); END", - "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_album_search_AFTER_INSERT AFTER INSERT ON `albums` BEGIN INSERT INTO `album_search`(`rowid`, `name`, `albumArtist`, `sortName`, `albumArtistSort`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`albumArtist`, NEW.`sortName`, NEW.`albumArtistSort`); END" - ] - }, - { - "tableName": "discs", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `disc` INTEGER NOT NULL, `title` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `albumId`, `disc`), FOREIGN KEY(`sourceId`, `albumId`) REFERENCES `albums`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "disc", - "columnName": "disc", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "title", - "columnName": "title", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "sourceId", - "albumId", - "disc" - ] - }, - "indices": [ - { - "name": "index_discs_sourceId_albumId", - "unique": false, - "columnNames": [ - "sourceId", - "albumId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_discs_sourceId_albumId` ON `${TABLE_NAME}` (`sourceId`, `albumId`)" - } - ], - "foreignKeys": [ - { - "table": "albums", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "sourceId", - "albumId" - ], - "referencedColumns": [ - "sourceId", - "id" - ] - } - ] - }, - { - "tableName": "album_artists", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `albumId`, `position`), FOREIGN KEY(`sourceId`, `albumId`) REFERENCES `albums`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "artistId", - "columnName": "artistId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "sourceId", - "albumId", - "position" - ] - }, - "indices": [ - { - "name": "index_album_artists_sourceId_artistId", - "unique": false, - "columnNames": [ - "sourceId", - "artistId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artists_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" - } - ], - "foreignKeys": [ - { - "table": "albums", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "sourceId", - "albumId" - ], - "referencedColumns": [ - "sourceId", - "id" - ] - } - ] - }, - { - "tableName": "playlists", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL COLLATE NOCASE, `comment` TEXT, `coverArt` TEXT, `songCount` INTEGER NOT NULL, `created` INTEGER NOT NULL, `changed` INTEGER NOT NULL DEFAULT 0, `duration` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "comment", - "columnName": "comment", - "affinity": "TEXT" - }, - { - "fieldPath": "coverArt", - "columnName": "coverArt", - "affinity": "TEXT" - }, - { - "fieldPath": "songCount", - "columnName": "songCount", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "created", - "columnName": "created", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "changed", - "columnName": "changed", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "sourceId", - "id" - ] - }, - "indices": [ - { - "name": "index_playlists_sourceId", - "unique": false, - "columnNames": [ - "sourceId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_sourceId` ON `${TABLE_NAME}` (`sourceId`)" - }, - { - "name": "index_playlists_name", - "unique": false, - "columnNames": [ - "sourceId", - "name", - "id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`sourceId`, `name`, `id`)" - }, - { - "name": "index_playlists_added", - "unique": false, - "columnNames": [ - "sourceId", - "created", - "name", - "id" - ], - "orders": [ - "ASC", - "DESC", - "ASC", - "ASC" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_added` ON `${TABLE_NAME}` (`sourceId` ASC, `created` DESC, `name` ASC, `id` ASC)" - }, - { - "name": "index_playlists_updated", - "unique": false, - "columnNames": [ - "sourceId", - "changed", - "name", - "id" - ], - "orders": [ - "ASC", - "DESC", - "ASC", - "ASC" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_updated` ON `${TABLE_NAME}` (`sourceId` ASC, `changed` DESC, `name` ASC, `id` ASC)" - } - ], - "foreignKeys": [ - { - "table": "sources", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "sourceId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "playlist_search", - "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`name`, tokenize=`trigram`, content=`playlists`)", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowid" - ] - }, - "ftsVersion": "FTS5", - "ftsOptions": { - "tokenizer": "trigram", - "tokenizerArgs": [], - "contentTable": "playlists", - "languageIdColumnName": "", - "matchInfo": "FTS4", - "notIndexedColumns": [], - "prefixSizes": [], - "preferredOrder": "ASC", - "contentRowId": "", - "columnSize": true, - "detail": "FULL" - }, - "contentSyncTriggers": [ - "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_playlist_search_BEFORE_UPDATE BEFORE UPDATE ON `playlists` BEGIN DELETE FROM `playlist_search` WHERE `rowid`=OLD.`rowid`; END", - "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_playlist_search_BEFORE_DELETE BEFORE DELETE ON `playlists` BEGIN DELETE FROM `playlist_search` WHERE `rowid`=OLD.`rowid`; END", - "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_playlist_search_AFTER_UPDATE AFTER UPDATE ON `playlists` BEGIN INSERT INTO `playlist_search`(`rowid`, `name`) VALUES (NEW.`rowid`, NEW.`name`); END", - "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_playlist_search_AFTER_INSERT AFTER INSERT ON `playlists` BEGIN INSERT INTO `playlist_search`(`rowid`, `name`) VALUES (NEW.`rowid`, NEW.`name`); END" - ] - }, - { - "tableName": "playlist_songs", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`sourceId`, `playlistId`, `position`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "playlistId", - "columnName": "playlistId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "songId", - "columnName": "songId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "sourceId", - "playlistId", - "position" - ] - }, - "indices": [ - { - "name": "index_playlist_songs_sourceId_playlistId", - "unique": false, - "columnNames": [ - "sourceId", - "playlistId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_playlistId` ON `${TABLE_NAME}` (`sourceId`, `playlistId`)" - }, - { - "name": "index_playlist_songs_sourceId_songId", - "unique": false, - "columnNames": [ - "sourceId", - "songId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_songId` ON `${TABLE_NAME}` (`sourceId`, `songId`)" - } - ], - "foreignKeys": [ - { - "table": "sources", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "sourceId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "songs", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `albumId` TEXT, `artistId` TEXT, `title` TEXT NOT NULL, `album` TEXT, `artist` TEXT, `duration` INTEGER, `track` INTEGER, `disc` INTEGER, `starred` INTEGER, `created` INTEGER NOT NULL DEFAULT 0, `playCount` INTEGER NOT NULL DEFAULT 0, `played` INTEGER, `trackGain` REAL, `albumGain` REAL, `trackPeak` REAL, `albumPeak` REAL, `artists` TEXT NOT NULL DEFAULT '', `sortName` TEXT NOT NULL COLLATE NOCASE DEFAULT '', `artistSort` TEXT NOT NULL COLLATE NOCASE DEFAULT '', PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "TEXT" - }, - { - "fieldPath": "artistId", - "columnName": "artistId", - "affinity": "TEXT" - }, - { - "fieldPath": "title", - "columnName": "title", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "album", - "columnName": "album", - "affinity": "TEXT" - }, - { - "fieldPath": "artist", - "columnName": "artist", - "affinity": "TEXT" - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER" - }, - { - "fieldPath": "track", - "columnName": "track", - "affinity": "INTEGER" - }, - { - "fieldPath": "disc", - "columnName": "disc", - "affinity": "INTEGER" - }, - { - "fieldPath": "starred", - "columnName": "starred", - "affinity": "INTEGER" - }, - { - "fieldPath": "created", - "columnName": "created", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "playCount", - "columnName": "playCount", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "played", - "columnName": "played", - "affinity": "INTEGER" - }, - { - "fieldPath": "trackGain", - "columnName": "trackGain", - "affinity": "REAL" - }, - { - "fieldPath": "albumGain", - "columnName": "albumGain", - "affinity": "REAL" - }, - { - "fieldPath": "trackPeak", - "columnName": "trackPeak", - "affinity": "REAL" - }, - { - "fieldPath": "albumPeak", - "columnName": "albumPeak", - "affinity": "REAL" - }, - { - "fieldPath": "artists", - "columnName": "artists", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "''" - }, - { - "fieldPath": "sortName", - "columnName": "sortName", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "''" - }, - { - "fieldPath": "artistSort", - "columnName": "artistSort", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "''" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "sourceId", - "id" - ] - }, - "indices": [ - { - "name": "index_songs_sourceId_artistId", - "unique": false, - "columnNames": [ - "sourceId", - "artistId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" - }, - { - "name": "index_songs_sourceId_albumId_order", - "unique": false, - "columnNames": [ - "sourceId", - "albumId", - "disc", - "track", - "id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_albumId_order` ON `${TABLE_NAME}` (`sourceId`, `albumId`, `disc`, `track`, `id`)" - }, - { - "name": "index_songs_starred", - "unique": false, - "columnNames": [ - "sourceId", - "starred", - "sortName", - "id" - ], - "orders": [ - "ASC", - "DESC", - "ASC", - "ASC" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)" - } - ], - "foreignKeys": [ - { - "table": "sources", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "sourceId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "song_genres", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, `genre` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `songId`, `position`), FOREIGN KEY(`sourceId`, `songId`) REFERENCES `songs`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "songId", - "columnName": "songId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "genre", - "columnName": "genre", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "sourceId", - "songId", - "position" - ] - }, - "indices": [ - { - "name": "index_song_genres_sourceId_genre", - "unique": false, - "columnNames": [ - "sourceId", - "genre" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_song_genres_sourceId_genre` ON `${TABLE_NAME}` (`sourceId`, `genre`)" - } - ], - "foreignKeys": [ - { - "table": "songs", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "sourceId", - "songId" - ], - "referencedColumns": [ - "sourceId", - "id" - ] - } - ] - }, - { - "tableName": "song_artists", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `songId`, `position`), FOREIGN KEY(`sourceId`, `songId`) REFERENCES `songs`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "songId", - "columnName": "songId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "artistId", - "columnName": "artistId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "sourceId", - "songId", - "position" - ] - }, - "indices": [ - { - "name": "index_song_artists_sourceId_artistId", - "unique": false, - "columnNames": [ - "sourceId", - "artistId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artists_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" - } - ], - "foreignKeys": [ - { - "table": "songs", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "sourceId", - "songId" - ], - "referencedColumns": [ - "sourceId", - "id" - ] - } - ] - }, - { - "tableName": "song_search", - "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`title`, `artist`, `sortName`, `artistSort`, tokenize=`trigram`, content=`songs`)", - "fields": [ - { - "fieldPath": "title", - "columnName": "title", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "artist", - "columnName": "artist", - "affinity": "TEXT" - }, - { - "fieldPath": "sortName", - "columnName": "sortName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "artistSort", - "columnName": "artistSort", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowid" - ] - }, - "ftsVersion": "FTS5", - "ftsOptions": { - "tokenizer": "trigram", - "tokenizerArgs": [], - "contentTable": "songs", - "languageIdColumnName": "", - "matchInfo": "FTS4", - "notIndexedColumns": [], - "prefixSizes": [], - "preferredOrder": "ASC", - "contentRowId": "", - "columnSize": true, - "detail": "FULL" - }, - "contentSyncTriggers": [ - "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_song_search_BEFORE_UPDATE BEFORE UPDATE ON `songs` BEGIN DELETE FROM `song_search` WHERE `rowid`=OLD.`rowid`; END", - "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_song_search_BEFORE_DELETE BEFORE DELETE ON `songs` BEGIN DELETE FROM `song_search` WHERE `rowid`=OLD.`rowid`; END", - "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_song_search_AFTER_UPDATE AFTER UPDATE ON `songs` BEGIN INSERT INTO `song_search`(`rowid`, `title`, `artist`, `sortName`, `artistSort`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`artist`, NEW.`sortName`, NEW.`artistSort`); END", - "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_song_search_AFTER_INSERT AFTER INSERT ON `songs` BEGIN INSERT INTO `song_search`(`rowid`, `title`, `artist`, `sortName`, `artistSort`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`artist`, NEW.`sortName`, NEW.`artistSort`); END" - ] - }, - { - "tableName": "queue_entries", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `position` INTEGER NOT NULL, `sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, `refId` TEXT NOT NULL, `rangeStart` INTEGER, `rangeEnd` INTEGER, FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "kind", - "columnName": "kind", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "refId", - "columnName": "refId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "rangeStart", - "columnName": "rangeStart", - "affinity": "INTEGER" - }, - { - "fieldPath": "rangeEnd", - "columnName": "rangeEnd", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_queue_entries_sourceId", - "unique": false, - "columnNames": [ - "sourceId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_queue_entries_sourceId` ON `${TABLE_NAME}` (`sourceId`)" - }, - { - "name": "index_queue_entries_position", - "unique": false, - "columnNames": [ - "position" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_queue_entries_position` ON `${TABLE_NAME}` (`position`)" - } - ], - "foreignKeys": [ - { - "table": "sources", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "sourceId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "up_next_entries", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `position` INTEGER NOT NULL, `sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, `refId` TEXT NOT NULL, `rangeStart` INTEGER, `rangeEnd` INTEGER, FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "kind", - "columnName": "kind", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "refId", - "columnName": "refId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "rangeStart", - "columnName": "rangeStart", - "affinity": "INTEGER" - }, - { - "fieldPath": "rangeEnd", - "columnName": "rangeEnd", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_up_next_entries_sourceId", - "unique": false, - "columnNames": [ - "sourceId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_up_next_entries_sourceId` ON `${TABLE_NAME}` (`sourceId`)" - }, - { - "name": "index_up_next_entries_position", - "unique": false, - "columnNames": [ - "position" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_up_next_entries_position` ON `${TABLE_NAME}` (`position`)" - } - ], - "foreignKeys": [ - { - "table": "sources", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "sourceId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "playback_cursor", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `queuePosition` INTEGER NOT NULL, `shuffleEnabled` INTEGER NOT NULL DEFAULT 0, `repeatMode` INTEGER NOT NULL DEFAULT 0, `shuffleSeed` INTEGER NOT NULL DEFAULT 0, `shuffleSize` INTEGER NOT NULL DEFAULT 0, `positionMs` INTEGER NOT NULL DEFAULT 0, `upNextAnchor` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "queuePosition", - "columnName": "queuePosition", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "shuffleEnabled", - "columnName": "shuffleEnabled", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "repeatMode", - "columnName": "repeatMode", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "shuffleSeed", - "columnName": "shuffleSeed", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "shuffleSize", - "columnName": "shuffleSize", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "positionMs", - "columnName": "positionMs", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "upNextAnchor", - "columnName": "upNextAnchor", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "artwork_seeds", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cacheKey` TEXT NOT NULL, `primary` INTEGER NOT NULL, `secondary` INTEGER, `nameBusy` INTEGER, `sourceId` INTEGER NOT NULL, `storedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`cacheKey`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "cacheKey", - "columnName": "cacheKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "primary", - "columnName": "primary", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "secondary", - "columnName": "secondary", - "affinity": "INTEGER" - }, - { - "fieldPath": "nameBusy", - "columnName": "nameBusy", - "affinity": "INTEGER" - }, - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "storedAt", - "columnName": "storedAt", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "cacheKey" - ] - }, - "indices": [ - { - "name": "index_artwork_seeds_sourceId", - "unique": false, - "columnNames": [ - "sourceId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_artwork_seeds_sourceId` ON `${TABLE_NAME}` (`sourceId`)" - } - ], - "foreignKeys": [ - { - "table": "sources", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "sourceId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "song_downloads", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, `status` TEXT NOT NULL, `engineId` INTEGER, `bytes` INTEGER NOT NULL DEFAULT 0, `total` INTEGER NOT NULL DEFAULT 0, `error` TEXT, `downloadedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `songId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`sourceId`, `songId`) REFERENCES `songs`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "songId", - "columnName": "songId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "engineId", - "columnName": "engineId", - "affinity": "INTEGER" - }, - { - "fieldPath": "bytes", - "columnName": "bytes", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "total", - "columnName": "total", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "error", - "columnName": "error", - "affinity": "TEXT" - }, - { - "fieldPath": "downloadedAt", - "columnName": "downloadedAt", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "sourceId", - "songId" - ] - }, - "indices": [ - { - "name": "index_song_downloads_sourceId", - "unique": false, - "columnNames": [ - "sourceId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_song_downloads_sourceId` ON `${TABLE_NAME}` (`sourceId`)" - }, - { - "name": "index_song_downloads_engineId", - "unique": false, - "columnNames": [ - "engineId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_song_downloads_engineId` ON `${TABLE_NAME}` (`engineId`)" - } - ], - "foreignKeys": [ - { - "table": "sources", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "sourceId" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "songs", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "sourceId", - "songId" - ], - "referencedColumns": [ - "sourceId", - "id" - ] - } - ] - }, - { - "tableName": "pending_actions", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, `targetId` TEXT NOT NULL, `starType` TEXT, `time` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "kind", - "columnName": "kind", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "targetId", - "columnName": "targetId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "starType", - "columnName": "starType", - "affinity": "TEXT" - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_pending_actions_sourceId", - "unique": false, - "columnNames": [ - "sourceId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_actions_sourceId` ON `${TABLE_NAME}` (`sourceId`)" - } - ], - "foreignKeys": [ - { - "table": "sources", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "sourceId" - ], - "referencedColumns": [ - "id" - ] - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3f2c67aa23973cb1300aaffe38bdafec')" - ] - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/subtracks/data/db/DbLogging.kt b/app/src/main/kotlin/com/subtracks/data/db/DbLogging.kt index 5ed69462..310e1265 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/DbLogging.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/DbLogging.kt @@ -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() { diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index daa1c497..f1436cc1 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -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 " + diff --git a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt index 2530b8c9..6c3fe518 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -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() + 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_33_34, MIGRATION_34_35, MIGRATION_35_36, - MIGRATION_36_37, ) diff --git a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt index a663d027..908974c6 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -57,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry SongDownload::class, PendingAction::class, ], - version = 37, + version = 36, exportSchema = true, ) @ColumnTypeConverters( diff --git a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt index 2e26c9ee..92ad0d4c 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt @@ -70,6 +70,9 @@ class SourceRepository( private val _offline = MutableStateFlow(false) val offline: StateFlow = _offline + private val _activeSource = MutableStateFlow(null) + val activeSource: StateFlow = _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 { diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt index e90d2f1f..26829258 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt @@ -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() diff --git a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt index cea503b9..dfe72b5a 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt @@ -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 { diff --git a/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt b/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt index 0ee722d0..8d4d48dd 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt @@ -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)) + } } } diff --git a/app/src/test/kotlin/com/subtracks/data/db/QueryTimingMeasurementTest.kt b/app/src/test/kotlin/com/subtracks/data/db/QueryTimingMeasurementTest.kt index 52ae1204..c8b6a612 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/QueryTimingMeasurementTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/QueryTimingMeasurementTest.kt @@ -39,7 +39,12 @@ class QueryTimingMeasurementTest { fun setUp() { val context = ApplicationProvider.getApplicationContext() 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 diff --git a/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt index 242750c4..c36e4c60 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt @@ -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 {