From 18118492c7c02512db5445a9b7a96f5811aa7c2e Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:53:23 +0000 Subject: [PATCH] library: materialize artist art and provider images Store the OpenSubsonic artistImageUrl and two materialized cover-id lists on each artist row, backfill them in the 35->36 migration, and refresh downloaded covers only for affected artists when downloads change. Icons and the artist hero now resolve provider art, server artist art, then the same materialized grid synchronously, with a definitive-failure fallback so a dead provider URL does not strand the letter placeholder. Download artwork also caches the provider image for the track and album artists, falls back to the artist cover when that fetch fails, and keeps both keys across sweeps. Query timing for the materialization pass is down from roughly 3.4s to 1.9s on the 20k-song benchmark. --- .../36.json | 1962 +++++++++++++++++ .../com/subtracks/data/db/DownloadDao.kt | 7 +- .../com/subtracks/data/db/LibraryDao.kt | 189 +- .../com/subtracks/data/db/Migrations.kt | 24 + .../subtracks/data/db/SubtracksDatabase.kt | 4 +- .../com/subtracks/data/model/CoverArtRef.kt | 5 + .../com/subtracks/data/model/Download.kt | 2 + .../kotlin/com/subtracks/data/model/Models.kt | 13 + .../subtracks/data/repo/DownloadRepository.kt | 78 +- .../subtracks/data/repo/LibraryRepository.kt | 10 - .../subtracks/data/repo/SourceRepository.kt | 13 + .../data/source/subsonic/SubsonicXml.kt | 1 + .../com/subtracks/data/sync/SyncService.kt | 4 +- .../main/kotlin/com/subtracks/ui/AppRoot.kt | 60 +- .../com/subtracks/ui/components/ArtistArt.kt | 133 +- .../com/subtracks/ui/components/Components.kt | 12 +- .../com/subtracks/ui/home/HomeListScreen.kt | 26 +- .../subtracks/ui/home/HomeListViewModel.kt | 8 +- .../com/subtracks/ui/home/HomeScreen.kt | 39 +- .../com/subtracks/ui/home/HomeViewModel.kt | 8 +- .../ui/library/ArtistDetailScreen.kt | 34 +- .../ui/library/ArtistDetailViewModel.kt | 37 +- .../subtracks/ui/library/ArtistsContent.kt | 11 +- .../com/subtracks/ui/library/LibraryScreen.kt | 7 +- .../subtracks/ui/library/LibraryViewModel.kt | 8 +- .../com/subtracks/ui/search/SearchScreen.kt | 16 +- .../subtracks/ui/search/SearchViewModel.kt | 8 +- .../com/subtracks/data/db/LibraryDaoTest.kt | 119 +- .../com/subtracks/data/db/MigrationsTest.kt | 64 + .../data/repo/DownloadRepositoryTest.kt | 108 +- .../data/repo/SourceRepositoryTest.kt | 22 + .../data/source/subsonic/SubsonicXmlTest.kt | 5 +- .../com/subtracks/ui/AppScreenshotTest.kt | 16 +- .../subtracks/ui/components/ArtistArtTest.kt | 174 ++ 34 files changed, 2965 insertions(+), 262 deletions(-) create mode 100644 app/schemas/com.subtracks.data.db.SubtracksDatabase/36.json create mode 100644 app/src/test/kotlin/com/subtracks/ui/components/ArtistArtTest.kt diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/36.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/36.json new file mode 100644 index 00000000..c41e6c2b --- /dev/null +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/36.json @@ -0,0 +1,1962 @@ +{ + "formatVersion": 1, + "database": { + "version": 36, + "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/DownloadDao.kt b/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt index 1ca7d61b..92a64a36 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt @@ -162,12 +162,15 @@ interface DownloadDao { @Query( "SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " + + "al.artistId AS albumArtistId, " + "al.id AS albumRow, al.coverArt AS albumCoverArt, " + - "ar.id AS artistRow, ar.coverArt AS artistCoverArt " + + "ar.id AS artistRow, ar.coverArt AS artistCoverArt, " + + "aar.coverArt AS albumArtistCoverArt " + "FROM song_downloads d " + "LEFT JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " + "LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " + - "LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId", + "LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId " + + "LEFT JOIN artists aar ON aar.sourceId = al.sourceId AND aar.id = al.artistId", ) suspend fun artwork(): List 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 b4f891ac..8135c8cf 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -124,32 +124,125 @@ internal const val ALBUM_CREDIT_ALBUM_IDS = // Download visibility follows the credit path: an album credit includes the album when any song is // downloaded, while a track credit includes it only when the credited track is downloaded. -private const val DOWNLOADED_ARTIST_ALBUMS = - "SELECT albums.id FROM albums " + - "WHERE albums.sourceId = :sourceId AND albums.artistId = artists.id " + +internal const val DOWNLOADED_ALBUM_ROWS = + "SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " + + "WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " + "AND EXISTS (SELECT 1 FROM songs " + "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + "WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " + "UNION " + - "SELECT albums.id FROM album_artists " + + "SELECT albums.id, album_artists.artistId FROM album_artists " + "JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " + - "WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = artists.id " + + "WHERE album_artists.sourceId = :sourceId " + "AND EXISTS (SELECT 1 FROM songs " + "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + "WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " + "UNION " + - "SELECT songs.albumId FROM songs " + + "SELECT songs.albumId, songs.artistId FROM songs " + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + - "WHERE songs.sourceId = :sourceId AND songs.artistId = artists.id " + - "AND sd.status = 'Completed' " + + "WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND sd.status = 'Completed' " + "UNION " + - "SELECT songs.albumId FROM song_artists " + + "SELECT songs.albumId, song_artists.artistId FROM song_artists " + "JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + - "WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = artists.id " + - "AND sd.status = 'Completed'" + "WHERE song_artists.sourceId = :sourceId AND sd.status = 'Completed'" + +private const val DOWNLOADED_ARTIST_ALBUMS = + "SELECT albumId AS id FROM ($DOWNLOADED_ALBUM_ROWS) WHERE artistId = artists.id" + +private const val DOWNLOADED_COVER_ALBUM_ROWS = + "SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " + + "WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " + + "AND EXISTS (SELECT 1 FROM songs " + + "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + + "WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " + + "UNION " + + "SELECT albums.id, album_artists.artistId FROM album_artists " + + "JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " + + "WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " + + "AND EXISTS (SELECT 1 FROM songs " + + "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + + "WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " + + "UNION " + + "SELECT songs.albumId, songs.artistId FROM songs " + + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + + "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + + "WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " + + "AND sd.status = 'Completed' " + + "UNION " + + "SELECT songs.albumId, song_artists.artistId FROM song_artists " + + "JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " + + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + + "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + + "WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL AND sd.status = 'Completed'" + +// Cover art only needs credited albums that actually have a cover, so filter before the window sort. +private const val ARTIST_COVER_CREDITS = + "SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " + + "FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " + + "AND albums.coverArt IS NOT NULL " + + "UNION ALL " + + "SELECT album_artists.artistId, albums.id, 'album' " + + "FROM album_artists JOIN albums " + + "ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " + + "WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " + + "UNION ALL " + + "SELECT songs.artistId, songs.albumId, 'track' " + + "FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + + "WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " + + "UNION ALL " + + "SELECT song_artists.artistId, songs.albumId, 'track' " + + "FROM song_artists JOIN songs " + + "ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " + + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + + "WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL" + +internal const val ARTIST_ART_UPDATE_SQL = + "WITH credits AS (SELECT artistId, albumId, " + + "MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " + + "FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " + + "ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " + + "ROW_NUMBER() OVER (PARTITION BY credits.artistId " + + "ORDER BY credits.creditRank, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " + + "FROM credits JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " + + "WHERE albums.coverArt IS NOT NULL), " + + "unique_ranked AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM ranked GROUP BY artistId, coverArt), " + + "covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" + + "SELECT artistId, coverArt, coverRank FROM unique_ranked WHERE coverRank <= 4 ORDER BY artistId, coverRank" + + ") GROUP BY artistId), " + + "downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS)), " + + "downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " + + "ROW_NUMBER() OVER (PARTITION BY dl.artistId " + + "ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " + + "FROM downloaded_albums dl JOIN albums ON albums.sourceId = :sourceId AND albums.id = dl.albumId " + + "WHERE albums.coverArt IS NOT NULL), " + + "unique_downloaded AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM downloaded GROUP BY artistId, coverArt), " + + "downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" + + "SELECT artistId, coverArt, coverRank FROM unique_downloaded 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" + +internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL = + "WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " + + "WHERE artistId IN (:artistIds)), " + + "downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " + + "ROW_NUMBER() OVER (PARTITION BY dl.artistId " + + "ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " + + "FROM downloaded_albums dl JOIN albums ON albums.sourceId = :sourceId AND albums.id = dl.albumId " + + "WHERE albums.coverArt IS NOT NULL), " + + "unique_downloaded AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM downloaded GROUP BY artistId, coverArt), " + + "downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" + + "SELECT artistId, coverArt, coverRank FROM unique_downloaded 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)" internal const val ARTISTS_FILTER = "FROM artists WHERE sourceId = :sourceId " + @@ -164,40 +257,10 @@ internal const val ARTISTS_FILTER = "OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " + "OR instr(lower(sortName), lower(:search)) > 0))) " -internal const val ARTIST_ALBUM_CREDIT_ART = - "SELECT albums.coverArt FROM (" + - "SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM albums " + - "INDEXED BY index_albums_sourceId_artistId " + - "WHERE albums.sourceId = artists.sourceId AND albums.artistId = artists.id " + - "UNION ALL " + - "SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM album_artists " + - "JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " + - "WHERE album_artists.sourceId = artists.sourceId AND album_artists.artistId = artists.id" + - ") albums ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id LIMIT 1" - -internal const val ARTIST_TRACK_CREDIT_ART = - "SELECT albums.coverArt FROM (" + - "SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM songs " + - "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id " + - "UNION ALL " + - "SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM song_artists " + - "JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " + - "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "WHERE song_artists.sourceId = artists.sourceId AND song_artists.artistId = artists.id" + - ") albums ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id LIMIT 1" - -internal const val ARTIST_COVER_ART = - "COALESCE(artists.coverArt, CASE " + - "WHEN EXISTS (SELECT 1 FROM albums WHERE albums.sourceId = artists.sourceId AND albums.artistId = artists.id) " + - "OR EXISTS (SELECT 1 FROM album_artists " + - "WHERE album_artists.sourceId = artists.sourceId AND album_artists.artistId = artists.id) " + - "THEN ($ARTIST_ALBUM_CREDIT_ART) ELSE ($ARTIST_TRACK_CREDIT_ART) END)" - internal const val ARTIST_COLUMNS = "artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " + - "$ARTIST_COVER_ART AS coverArt, artists.playCount, artists.played, artists.songCount, " + - "artists.sortName, artists.credited" + "artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " + + "artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited" // With the Downloaded filter on, count the albums that actually have a download so the number under // an artist matches the albums its detail screen lists. @@ -206,8 +269,8 @@ internal const val ARTISTS_SELECT = "CASE WHEN :downloadedFilter = 1 THEN (" + "SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" + ") ELSE artists.albumCount END AS albumCount, " + - "artists.starred, $ARTIST_COVER_ART AS coverArt, artists.playCount, artists.played, artists.songCount, " + - "artists.sortName, artists.credited " + "artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " + + "artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited " internal const val ARTIST_ORDER_BY_NAME = "sortName COLLATE NOCASE, id" internal const val ARTIST_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC" @@ -334,10 +397,20 @@ interface LibraryDao { ) suspend fun recomputeArtistAggregates(sourceId: Long) + @Query(ARTIST_ART_UPDATE_SQL) + suspend fun recomputeArtistArt(sourceId: Long) + + @Query(DOWNLOADED_ARTIST_ART_UPDATE_SQL) + suspend fun recomputeDownloadedArtistArt( + sourceId: Long, + artistIds: List, + ) + @Transaction suspend fun recomputeAggregates(sourceId: Long) { recomputeAlbumPlayData(sourceId) recomputeArtistAggregates(sourceId) + recomputeArtistArt(sourceId) } @Query( @@ -381,6 +454,12 @@ interface LibraryDao { songId: String, ): String? + @Query("SELECT DISTINCT artistId FROM ($CREDITED_SONG_ROWS) WHERE songId IN (:songIds)") + suspend fun creditedArtistIdsForSongs( + sourceId: Long, + songIds: List, + ): List + // Counted locally when a scrobble is submitted, so the play sorts move before the next sync; the // sync then overwrites with the server's own number. @Transaction @@ -1070,26 +1149,6 @@ interface LibraryDao { artistId: String, ): Flow - @Query("SELECT coverArt FROM artists WHERE sourceId = :sourceId AND id = :artistId") - suspend fun artistOwnCoverArt( - sourceId: Long, - artistId: String, - ): String? - - @Query( - "SELECT albums.coverArt FROM (" + - "SELECT albumId, MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " + - "FROM ($CREDITED_ALBUM_ROWS) WHERE artistId = :artistId GROUP BY albumId" + - ") credits " + - "JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " + - "WHERE albums.coverArt IS NOT NULL " + - "ORDER BY credits.creditRank, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id LIMIT 4", - ) - suspend fun artistFallbackCoverArts( - sourceId: Long, - artistId: String, - ): List - @Query("SELECT * FROM artist_bios WHERE sourceId = :sourceId AND artistId = :artistId") fun artistBio( sourceId: Long, 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 5f6c6bc9..eff893cc 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -695,6 +695,29 @@ private suspend fun createSearchIndex( connection.execSQL("INSERT INTO `$table`(`$table`) VALUES('rebuild')") } +val MIGRATION_35_36 = + object : Migration(35, 36) { + override suspend fun migrate(connection: SQLiteConnection) { + 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 ''") + backfillArtistArt(connection) + } + } + +private suspend fun backfillArtistArt(connection: SQLiteConnection) { + val sourceIds = mutableListOf() + connection.prepare("SELECT id FROM sources").use { statement -> + while (statement.step()) sourceIds += statement.getLong(0) + } + sourceIds.forEach { sourceId -> + connection.prepare(ARTIST_ART_UPDATE_SQL).use { statement -> + statement.bindLong(1, sourceId) + statement.step() + } + } +} + val MIGRATIONS: Array = arrayOf( MIGRATION_1_2, @@ -731,4 +754,5 @@ val MIGRATIONS: Array = MIGRATION_32_33, MIGRATION_33_34, MIGRATION_34_35, + MIGRATION_35_36, ) 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 9806f85d..908974c6 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -28,6 +28,7 @@ import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongGenre import com.subtracks.data.model.SongSearch import com.subtracks.data.model.Source +import com.subtracks.data.model.StringListConverter import com.subtracks.data.model.SubsonicSource import com.subtracks.data.model.UpNextEntry @@ -56,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry SongDownload::class, PendingAction::class, ], - version = 35, + version = 36, exportSchema = true, ) @ColumnTypeConverters( @@ -65,6 +66,7 @@ import com.subtracks.data.model.UpNextEntry DownloadErrorConverter::class, PendingActionKindConverter::class, ArtistRefsConverter::class, + StringListConverter::class, ) abstract class SubtracksDatabase : RoomDatabase() { abstract fun libraryDao(): LibraryDao diff --git a/app/src/main/kotlin/com/subtracks/data/model/CoverArtRef.kt b/app/src/main/kotlin/com/subtracks/data/model/CoverArtRef.kt index 39c2e19a..b7691aec 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/CoverArtRef.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/CoverArtRef.kt @@ -10,3 +10,8 @@ fun coverArtKey( coverArt: String, thumbnail: Boolean, ): String = "$sourceId:$coverArt:$thumbnail" + +fun artistImageKey( + sourceId: Long, + artistId: String, +): String = "$sourceId:artist-image:$artistId" diff --git a/app/src/main/kotlin/com/subtracks/data/model/Download.kt b/app/src/main/kotlin/com/subtracks/data/model/Download.kt index d4412882..c01cebe1 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Download.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Download.kt @@ -106,10 +106,12 @@ data class DownloadArtwork( val sourceId: Long, val albumId: String?, val artistId: String?, + val albumArtistId: String?, val albumRow: String?, val artistRow: String?, val albumCoverArt: String?, val artistCoverArt: String?, + val albumArtistCoverArt: String?, ) { val hasMissingLibraryRow: Boolean get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null) diff --git a/app/src/main/kotlin/com/subtracks/data/model/Models.kt b/app/src/main/kotlin/com/subtracks/data/model/Models.kt index 76def365..40f940db 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -88,6 +88,9 @@ data class Artist( val albumCount: Long, val starred: Long?, val coverArt: String? = null, + val artistImageUrl: String? = null, + @ColumnInfo(defaultValue = "''") val coverArts: List = emptyList(), + @ColumnInfo(defaultValue = "''") val downloadedCoverArts: List = emptyList(), @ColumnInfo(defaultValue = "0") val playCount: Long = 0, val played: Long? = null, @ColumnInfo(defaultValue = "0") val songCount: Long = 0, @@ -592,6 +595,16 @@ class ArtistRefsConverter { fun toArtistRefs(value: String): List = ArtistRefsCodec.decode(value) } +class StringListConverter { + @ColumnTypeConverter + fun fromList(values: List): String = values.joinToString(LIST_SEPARATOR) + + @ColumnTypeConverter + fun toList(value: String): List = if (value.isEmpty()) emptyList() else value.split(LIST_SEPARATOR) +} + +private const val LIST_SEPARATOR = "\u001F" + object ArtistRefsCodec { fun encode(refs: List): String = buildString { diff --git a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt index 5e0e39db..ec8c9715 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt @@ -11,6 +11,7 @@ import com.subtracks.data.download.EngineDownload import com.subtracks.data.download.EngineRequest import com.subtracks.data.download.EngineStatus import com.subtracks.data.download.looksLikeAudio +import com.subtracks.data.model.Artist import com.subtracks.data.model.ArtistBio import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.DownloadError @@ -19,6 +20,7 @@ import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.DownloadedSong import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.SongDownload +import com.subtracks.data.model.artistImageKey import com.subtracks.data.model.coverArtKey import com.subtracks.log.Log import kotlinx.coroutines.CancellationException @@ -350,6 +352,9 @@ class DownloadRepository( rows.groupBy { it.sourceId }.forEach { (sourceId, group) -> group.forEach { file(sourceId, it.songId).delete() } group.map { it.songId }.chunked(DELETE_CHUNK).forEach { db.downloadDao().deleteSongs(sourceId, it) } + if (group.any { it.status == DownloadStatus.Completed }) { + refreshDownloadedArtistArt(sourceId, group.map { it.songId }) + } } sweep(db.downloadDao().all()) } @@ -413,13 +418,18 @@ class DownloadRepository( ) { val song = db.libraryDao().songOnce(sourceId, songId) val album = song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it) } + val songArtist = song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it) } + val albumArtist = album?.artistId?.let { db.libraryDao().artistOnce(sourceId, it) } listOfNotNull(song?.artistId, album?.artistId).forEach(artistIds::add) + listOfNotNull(songArtist, albumArtist).forEach { storeArtistImage(sourceId, it) } + val artistFallbackCovers = + listOfNotNull(songArtist, albumArtist) + .filter { it.artistImageUrl.isNullOrBlank() } + .mapNotNull { it.coverArt } val coverArt = ( - listOfNotNull( - album?.coverArt, - song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it)?.coverArt }, - ) + db.downloadDao().playlistCoversForSong(sourceId, songId) + listOfNotNull(album?.coverArt) + artistFallbackCovers + + db.downloadDao().playlistCoversForSong(sourceId, songId) ).distinct() coverArt.forEach { cover -> storeArtwork(sourceId, cover, thumbnail = false) @@ -427,6 +437,33 @@ class DownloadRepository( } } + private suspend fun storeArtistImage( + sourceId: Long, + artist: Artist, + ) { + val imageUrl = artist.artistImageUrl + if (!imageUrl.isNullOrBlank()) { + val cacheKey = artistImageKey(sourceId, artist.id) + if (artworkStore.file(sourceId, cacheKey).exists()) return + val bytes = + try { + artworkFetcher.fetch(imageUrl) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + if (bytes != null && bytes.isNotEmpty()) { + artworkStore.write(sourceId, cacheKey, bytes) + return + } + } + artist.coverArt?.let { + storeArtwork(sourceId, it, thumbnail = false) + storeArtwork(sourceId, it, thumbnail = true) + } + } + private suspend fun fetchArtistBio( sourceId: Long, artistId: String, @@ -491,11 +528,15 @@ class DownloadRepository( .mapValues { (sourceId, rows) -> if (rows.any { it.hasMissingLibraryRow }) return@mapValues null val covers = - rows.flatMap { row -> listOfNotNull(row.albumCoverArt, row.artistCoverArt) } + - playlistCovers[sourceId].orEmpty() - covers - .flatMap { cover -> listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) } } - .toSet() + rows.flatMap { row -> + listOfNotNull(row.albumCoverArt, row.artistCoverArt, row.albumArtistCoverArt) + } + playlistCovers[sourceId].orEmpty() + val artistImages = rows.flatMap { row -> listOfNotNull(row.artistId, row.albumArtistId) } + ( + covers.flatMap { cover -> + listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) } + } + artistImages.map { artistImageKey(sourceId, it) } + ).toSet() } } @@ -545,6 +586,7 @@ class DownloadRepository( row.engineId?.let { engine.cancel(listOf(it)) } file(sourceId, songId).delete() db.downloadDao().delete(sourceId, songId) + if (row.status == DownloadStatus.Completed) refreshDownloadedArtistArt(sourceId, listOf(songId)) return true } @@ -625,6 +667,11 @@ class DownloadRepository( }, ) if (updated != row) db.downloadDao().upsert(updated) + if (updated.status != row.status && + (updated.status == DownloadStatus.Completed || row.status == DownloadStatus.Completed) + ) { + refreshDownloadedArtistArt(row.sourceId, listOf(row.songId)) + } if (updated.status != row.status) { when (updated.status) { DownloadStatus.Completed -> Log.i("download", "completed ${row.songId} (${updated.total} bytes)") @@ -642,9 +689,22 @@ class DownloadRepository( row.engineId?.let { engine.cancel(listOf(it)) } file(row.sourceId, row.songId).delete() db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error)) + if (row.status == DownloadStatus.Completed) { + refreshDownloadedArtistArt(row.sourceId, listOf(row.songId)) + } Log.w("download", "failed ${row.songId}: $error") } + private suspend fun refreshDownloadedArtistArt( + sourceId: Long, + songIds: Collection, + ) { + songIds.chunked(500).forEach { songs -> + val artistIds = db.libraryDao().creditedArtistIdsForSongs(sourceId, songs).distinct() + artistIds.chunked(500).forEach { db.libraryDao().recomputeDownloadedArtistArt(sourceId, it) } + } + } + private fun ensurePolling() { if (pollJob?.isActive == true) return pollJob = diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index 0a60accb..6ba3d6b1 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -353,16 +353,6 @@ class LibraryRepository( artistId: String, ): Flow = db.libraryDao().artist(sourceId, artistId) - suspend fun artistOwnCoverArt( - sourceId: Long, - artistId: String, - ): String? = db.libraryDao().artistOwnCoverArt(sourceId, artistId) - - suspend fun artistFallbackCoverArts( - sourceId: Long, - artistId: String, - ): List = db.libraryDao().artistFallbackCoverArts(sourceId, artistId) - fun artistName( sourceId: Long, artistId: String, 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 4e82a104..2e26c9ee 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt @@ -9,6 +9,7 @@ import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.Source import com.subtracks.data.model.SubsonicConfig import com.subtracks.data.model.SubsonicSource +import com.subtracks.data.model.artistImageKey import com.subtracks.data.model.coverArtKey import com.subtracks.data.net.NetworkMode import com.subtracks.data.prefs.StreamQuality @@ -143,6 +144,18 @@ class SourceRepository( return CoverArtRef(url = url, cacheKey = cacheKey) } + fun artistImage( + artistId: String, + url: String?, + ): CoverArtRef? { + val current = active ?: return null + if (url.isNullOrBlank()) return null + val cacheKey = artistImageKey(current.id, artistId) + artworkStore.uri(current.id, cacheKey)?.let { return CoverArtRef(url = it, cacheKey = cacheKey) } + if (_offline.value) return null + return CoverArtRef(url = url, cacheKey = cacheKey) + } + fun networkCoverArt( sourceId: Long, coverArt: String, diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt index 3d7854be..4222b9e9 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt @@ -32,6 +32,7 @@ object SubsonicXml { albumCount = attrs.longAttr("albumCount") ?: 0L, starred = attrs.dateAttr("starred"), coverArt = attrs.attr("coverArt").ifEmpty { null }, + artistImageUrl = attrs.attr("artistImageUrl").ifEmpty { null }, sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"), ) diff --git a/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt b/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt index b7bde755..1717374a 100644 --- a/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt +++ b/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt @@ -438,7 +438,7 @@ private fun discHash( private val primaryKey = listOf("sourceId", "id") -private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "sortName") +private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "artistImageUrl", "sortName") private val albumColumns = listOf( @@ -515,7 +515,7 @@ private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "po private val playlistSongKey = listOf("sourceId", "playlistId", "position") -private fun Artist.values() = listOf(sourceId, id, name, albumCount, starred, coverArt, sortName) +private fun Artist.values() = listOf(sourceId, id, name, albumCount, starred, coverArt, artistImageUrl, sortName) private fun Album.values() = listOf( diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index cd32bb00..38c4dc9c 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -375,7 +375,15 @@ private fun MainNavigation() { navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt)) }, onArtistClick = { artist -> - navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt)) + navigateDetail( + Routes.ARTIST_DETAIL, + "artistId", + artist.id, + Routes.artist( + artist.id, + artist.coverArt ?: artist.coverArts.firstOrNull(), + ), + ) }, onPlaylistClick = { playlist -> navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id)) @@ -418,7 +426,15 @@ private fun MainNavigation() { navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt)) }, onArtistClick = { artist -> - navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt)) + navigateDetail( + Routes.ARTIST_DETAIL, + "artistId", + artist.id, + Routes.artist( + artist.id, + artist.coverArt ?: artist.coverArts.firstOrNull(), + ), + ) }, onPlaylistClick = { playlist -> navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id)) @@ -514,7 +530,15 @@ private fun MainNavigation() { navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt)) }, onArtistClick = { artist -> - navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt)) + navigateDetail( + Routes.ARTIST_DETAIL, + "artistId", + artist.id, + Routes.artist( + artist.id, + artist.coverArt ?: artist.coverArts.firstOrNull(), + ), + ) }, onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) }, onViewArtist = { artistId -> @@ -538,7 +562,15 @@ private fun MainNavigation() { navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt)) }, onArtistClick = { artist -> - navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt)) + navigateDetail( + Routes.ARTIST_DETAIL, + "artistId", + artist.id, + Routes.artist( + artist.id, + artist.coverArt ?: artist.coverArts.firstOrNull(), + ), + ) }, onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) }, onViewArtist = { artistId -> @@ -562,7 +594,15 @@ private fun MainNavigation() { navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt)) }, onArtistClick = { artist -> - navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt)) + navigateDetail( + Routes.ARTIST_DETAIL, + "artistId", + artist.id, + Routes.artist( + artist.id, + artist.coverArt ?: artist.coverArts.firstOrNull(), + ), + ) }, onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) }, onViewArtist = { artistId -> @@ -588,7 +628,15 @@ private fun MainNavigation() { navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt)) }, onArtistClick = { artist -> - navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt)) + navigateDetail( + Routes.ARTIST_DETAIL, + "artistId", + artist.id, + Routes.artist( + artist.id, + artist.coverArt ?: artist.coverArts.firstOrNull(), + ), + ) }, onPlaylistClick = { playlist -> navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id)) diff --git a/app/src/main/kotlin/com/subtracks/ui/components/ArtistArt.kt b/app/src/main/kotlin/com/subtracks/ui/components/ArtistArt.kt index fea0e6ba..a07104fd 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/ArtistArt.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/ArtistArt.kt @@ -7,7 +7,9 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset @@ -19,49 +21,120 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import com.subtracks.data.model.Artist import com.subtracks.data.model.CoverArtRef -import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.SourceRepository -import kotlinx.coroutines.flow.first import kotlin.math.PI import kotlin.math.cos import kotlin.math.min import kotlin.math.sin -internal suspend fun loadArtistArtCovers( - library: LibraryRepository, - source: SourceRepository, - artistId: String, -): List { - val sourceId = library.activeSourceId.first() ?: return emptyList() - val own = library.artistOwnCoverArt(sourceId, artistId) - val ids = - if (own != null) { - listOf(own) - } else { - library.artistFallbackCoverArts(sourceId, artistId) - } - return ids.take(4).mapNotNull { source.coverArt(it, thumbnail = true) } +data class ArtistArt( + val image: CoverArtRef? = null, + val covers: List = emptyList(), + val fallback: ArtistArt? = null, +) { + val primary: CoverArtRef? get() = image ?: covers.firstOrNull() +} + +fun SourceRepository.artistArt( + artist: Artist, + forIcon: Boolean = true, +): ArtistArt = + resolveArtistArt( + artist = artist, + coverArt = this::coverArt, + artistImage = this::artistImage, + offline = offline.value, + forIcon = forIcon, + ) + +internal fun resolveArtistArt( + artist: Artist, + coverArt: (String?, Boolean) -> CoverArtRef?, + artistImage: (String, String?) -> CoverArtRef?, + offline: Boolean, + forIcon: Boolean, +): ArtistArt { + val coverIds = if (offline) artist.downloadedCoverArts else artist.coverArts + val grid = + ArtistArt( + covers = coverIds.mapNotNull { coverArt(it, forIcon || coverIds.size > 1) }, + ) + val own = + artist.coverArt + ?.let { coverArt(it, forIcon) } + ?.let { ArtistArt(image = it, fallback = grid) } + return artist.artistImageUrl + ?.let { artistImage(artist.id, it) } + ?.let { ArtistArt(image = it, fallback = own ?: grid) } + ?: own + ?: grid } @Composable fun ArtistIcon( artist: Artist, - fallback: CoverArtRef?, - loadCovers: suspend (String) -> List, + art: ArtistArt, modifier: Modifier = Modifier, ) { - val covers by produceState(initialValue = emptyList(), artist.id) { - value = loadCovers(artist.id) + var shown by remember(artist.id, art) { mutableStateOf(art) } + val showFallback = { + shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) } } - if (covers.size >= 2) { - ArtistArtGrid(covers = covers, name = artist.name, modifier = modifier) - } else { - CoverArt( - ref = covers.firstOrNull() ?: fallback, - name = artist.name, - showPlaceholder = covers.isEmpty() && fallback == null, - modifier = modifier, - ) + when { + shown.image != null -> { + CoverArt( + ref = shown.image, + name = artist.name, + modifier = modifier, + onError = showFallback, + ) + } + + shown.covers.size >= 2 -> { + ArtistArtGrid(covers = shown.covers, name = artist.name, modifier = modifier) + } + + else -> { + CoverArt(ref = shown.covers.firstOrNull(), name = artist.name, modifier = modifier) + } + } +} + +@Composable +fun ArtistHeroArt( + art: ArtistArt, + name: String, + thumbnail: CoverArtRef?, + modifier: Modifier = Modifier, +) { + var shown by remember(art) { mutableStateOf(art) } + val showFallback = { + shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) } + } + when { + shown.image != null -> { + CoverArt( + ref = shown.image, + name = name, + thumbnailRef = thumbnail, + showPlaceholder = false, + modifier = modifier, + onError = showFallback, + ) + } + + shown.covers.isNotEmpty() -> { + ArtistArtGrid( + covers = shown.covers, + name = name, + singleThumbnail = thumbnail, + modifier = modifier, + ) + } + + else -> { + CoverArt(ref = null, name = name, modifier = modifier) + } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt index 9516cebe..3261bb4e 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt @@ -85,10 +85,12 @@ fun CoverArt( square: Boolean = true, elevation: Dp = 0.dp, colorFilter: ColorFilter? = null, + onError: (() -> Unit)? = null, ) { val context = LocalPlatformContext.current val refKey = ref?.cacheKey val thumbnailKey = thumbnailRef?.cacheKey + val hasThumbnailFallback = thumbnailRef != null && thumbnailRef != ref var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) } var thumbnailLoaded by remember(refKey, thumbnailKey) { mutableStateOf(false) } var thumbnailRatio by remember(refKey, thumbnailKey) { mutableStateOf(thumbnailKey?.let(ArtworkRatioCache::get)) } @@ -131,7 +133,10 @@ fun CoverArt( .ratioOrNull() ?.let { ArtworkRatioCache.put(ref.cacheKey, it) } }, - onError = { failed = true }, + onError = { + failed = true + if (!hasThumbnailFallback) onError?.invoke() + }, modifier = Modifier.fillMaxSize(), ) } @@ -160,7 +165,10 @@ fun CoverArt( ) } }, - onError = { failed = true }, + onError = { + failed = true + if (!hasThumbnailFallback) onError?.invoke() + }, contentScale = ContentScale.Fit, filterQuality = FilterQuality.High, ) diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt index 91dff9b0..f858e91a 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt @@ -57,6 +57,7 @@ import com.subtracks.data.model.Playlist import com.subtracks.data.model.Song import com.subtracks.data.source.StarType import com.subtracks.playback.PlaybackController +import com.subtracks.ui.components.ArtistArt import com.subtracks.ui.components.ArtistIcon import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.CoverArt @@ -109,7 +110,7 @@ fun HomeListRoute( genres = genres, decades = decades, coverArt = viewModel::coverArt, - artistCovers = viewModel::artistArtCovers, + artistArt = { viewModel.artistArt(it) }, playingSongId = playback.item?.id, onBack = onBack, onAlbumClick = onAlbumClick, @@ -135,7 +136,7 @@ fun HomeListScreen( genres: List = emptyList(), decades: List = emptyList(), coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null }, - artistCovers: suspend (String) -> List = { emptyList() }, + artistArt: (Artist) -> ArtistArt = { ArtistArt() }, playingSongId: String? = null, onBack: () -> Unit = {}, onAlbumClick: (Album) -> Unit = {}, @@ -194,8 +195,7 @@ fun HomeListScreen( OfflineListKind.Artists -> { ArtistsList( artists = artists, - coverArt = coverArt, - artistCovers = artistCovers, + artistArt = artistArt, info = { artistListInfo(request, it, now, resources) }, onArtistClick = onArtistClick, onLongClick = onLongClick, @@ -249,8 +249,7 @@ fun HomeListScreen( HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> { ArtistsList( artists = artists, - coverArt = coverArt, - artistCovers = artistCovers, + artistArt = artistArt, info = { artistListInfo(request, it, now, resources) }, onArtistClick = onArtistClick, onLongClick = onLongClick, @@ -448,8 +447,7 @@ internal fun AlbumListRow( @Composable private fun ArtistsList( artists: LazyPagingItems, - coverArt: (String?, Boolean) -> CoverArtRef?, - artistCovers: suspend (String) -> List, + artistArt: (Artist) -> ArtistArt, info: (Artist) -> String?, onArtistClick: (Artist) -> Unit, onLongClick: (MenuTarget) -> Unit, @@ -466,13 +464,13 @@ private fun ArtistsList( items(count = artists.itemCount, key = artists.itemKey { it.id }) { index -> val artist = artists[index] if (artist != null) { + val art = artistArt(artist) ArtistListRow( artist = artist, - coverArt = coverArt, - artistCovers = artistCovers, + art = art, info = info(artist), onClick = { onArtistClick(artist) }, - onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) }, + onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) }, ) } } @@ -482,11 +480,10 @@ private fun ArtistsList( @Composable internal fun ArtistListRow( artist: Artist, - coverArt: (String?, Boolean) -> CoverArtRef?, + art: ArtistArt, info: String?, onClick: () -> Unit, onLongClick: () -> Unit, - artistCovers: suspend (String) -> List = { emptyList() }, ) { Row( modifier = @@ -498,8 +495,7 @@ internal fun ArtistListRow( ) { ArtistIcon( artist = artist, - fallback = coverArt(artist.coverArt, true), - loadCovers = artistCovers, + art = art, modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape), ) Spacer(Modifier.width(12.dp)) diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeListViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeListViewModel.kt index 6511e5cc..75eacb92 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeListViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeListViewModel.kt @@ -16,7 +16,8 @@ import com.subtracks.data.prefs.PlaylistSort import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.SourceRepository import com.subtracks.playback.PlaybackController -import com.subtracks.ui.components.loadArtistArtCovers +import com.subtracks.ui.components.ArtistArt +import com.subtracks.ui.components.artistArt import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted @@ -157,7 +158,10 @@ class HomeListViewModel( thumbnail: Boolean, ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) - suspend fun artistArtCovers(artistId: String): List = loadArtistArtCovers(libraryRepository, sourceRepository, artistId) + fun artistArt( + artist: Artist, + forIcon: Boolean = true, + ): ArtistArt = sourceRepository.artistArt(artist, forIcon) fun play(song: Song) { val genre = request.genre diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt index 3182c4b8..22fdad84 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt @@ -72,6 +72,7 @@ import com.subtracks.data.model.Playlist import com.subtracks.data.model.Song import com.subtracks.data.source.StarType import com.subtracks.playback.PlaybackController +import com.subtracks.ui.components.ArtistArt import com.subtracks.ui.components.ArtistIcon import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.CoverArt @@ -138,7 +139,7 @@ fun HomeRoute( playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(), songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value, coverArt = viewModel::coverArt, - artistCovers = viewModel::artistArtCovers, + artistArt = { viewModel.artistArt(it) }, playingSongId = playback.item?.id, topInset = topInset, bottomInset = bottomInset, @@ -154,7 +155,7 @@ fun HomeRoute( HomeScreen( feed = feed, coverArt = viewModel::coverArt, - artistCovers = viewModel::artistArtCovers, + artistArt = { viewModel.artistArt(it) }, playingSongId = playback.item?.id, topInset = topInset, bottomInset = bottomInset, @@ -176,7 +177,7 @@ fun HomeScreen( coverArt: (String?, Boolean) -> CoverArtRef?, playingSongId: String?, modifier: Modifier = Modifier, - artistCovers: suspend (String) -> List = { emptyList() }, + artistArt: (Artist) -> ArtistArt = { ArtistArt() }, topInset: Dp = 0.dp, bottomInset: Dp = 0.dp, onAlbumClick: (Album) -> Unit = {}, @@ -219,7 +220,7 @@ fun HomeScreen( HomeSection.RecentlyPlayedArtists, feed.recentlyPlayedArtists, coverArt, - artistCovers, + artistArt, onArtistClick, onLongClick, onMore, @@ -254,7 +255,7 @@ fun HomeScreen( HomeSection.MostPlayedArtists, feed.mostPlayedArtists, coverArt, - artistCovers, + artistArt, onArtistClick, onLongClick, onMore, @@ -302,7 +303,7 @@ fun OfflineHomeScreen( coverArt: (String?, Boolean) -> CoverArtRef?, playingSongId: String?, modifier: Modifier = Modifier, - artistCovers: suspend (String) -> List = { emptyList() }, + artistArt: (Artist) -> ArtistArt = { ArtistArt() }, topInset: Dp = 0.dp, bottomInset: Dp = 0.dp, onAlbumClick: (Album) -> Unit = {}, @@ -319,7 +320,7 @@ fun OfflineHomeScreen( ) { homeTitle() downloadedAlbumRow(albums, coverArt, onAlbumClick, onLongClick) { onMore(OfflineListKind.Albums) } - downloadedArtistRow(artists, coverArt, artistCovers, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) } + downloadedArtistRow(artists, coverArt, artistArt, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) } downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) } songList( key = "downloaded-songs", @@ -372,7 +373,7 @@ private fun LazyListScope.downloadedAlbumRow( private fun LazyListScope.downloadedArtistRow( artists: LazyPagingItems, coverArt: (String?, Boolean) -> CoverArtRef?, - artistCovers: suspend (String) -> List, + artistArt: (Artist) -> ArtistArt, onArtistClick: (Artist) -> Unit, onLongClick: (MenuTarget) -> Unit, onMore: () -> Unit, @@ -390,13 +391,12 @@ private fun LazyListScope.downloadedArtistRow( items(count = artists.itemCount, key = artists.itemKey { it.id }) { index -> val artist = artists[index] if (artist != null) { - val ref = coverArt(artist.coverArt, true) + val art = artistArt(artist) ArtistTile( artist = artist, - ref = ref, - artistCovers = artistCovers, + art = art, onClick = { onArtistClick(artist) }, - onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) }, + onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) }, ) } } @@ -616,7 +616,7 @@ private fun LazyListScope.artistRow( section: HomeSection, artists: List, coverArt: (String?, Boolean) -> CoverArtRef?, - artistCovers: suspend (String) -> List, + artistArt: (Artist) -> ArtistArt, onArtistClick: (Artist) -> Unit, onLongClick: (MenuTarget) -> Unit, onMore: (HomeSection) -> Unit, @@ -635,13 +635,12 @@ private fun LazyListScope.artistRow( modifier = Modifier.padding(top = SECTION_CONTENT_TOP), ) { items(artists, key = { it.id }) { artist -> - val ref = coverArt(artist.coverArt, true) + val art = artistArt(artist) ArtistTile( artist = artist, - ref = ref, - artistCovers = artistCovers, + art = art, onClick = { onArtistClick(artist) }, - onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) }, + onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) }, ) } } @@ -790,8 +789,7 @@ private fun AlbumTile( @Composable private fun ArtistTile( artist: Artist, - ref: CoverArtRef?, - artistCovers: suspend (String) -> List, + art: ArtistArt, onClick: () -> Unit, onLongClick: () -> Unit, ) { @@ -801,8 +799,7 @@ private fun ArtistTile( ) { ArtistIcon( artist = artist, - fallback = ref, - loadCovers = artistCovers, + art = art, modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape), ) Spacer(Modifier.height(6.dp)) diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt index d2aae31b..27c5457a 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt @@ -17,7 +17,8 @@ import com.subtracks.data.repo.rediscoverCutoff import com.subtracks.data.sync.SyncManager import com.subtracks.data.sync.SyncStatus import com.subtracks.playback.PlaybackController -import com.subtracks.ui.components.loadArtistArtCovers +import com.subtracks.ui.components.ArtistArt +import com.subtracks.ui.components.artistArt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -128,7 +129,10 @@ class HomeViewModel( thumbnail: Boolean, ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) - suspend fun artistArtCovers(artistId: String): List = loadArtistArtCovers(libraryRepository, sourceRepository, artistId) + fun artistArt( + artist: Artist, + forIcon: Boolean = true, + ): ArtistArt = sourceRepository.artistArt(artist, forIcon) fun playStarred(song: Song) { viewModelScope.playStarredList(libraryRepository, playbackController, song) diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt index e0549155..1f4699a3 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -102,7 +102,8 @@ import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.source.StarType import com.subtracks.playback.PlaybackController -import com.subtracks.ui.components.ArtistArtGrid +import com.subtracks.ui.components.ArtistArt +import com.subtracks.ui.components.ArtistHeroArt import com.subtracks.ui.components.CloseWhenDownloadsGone import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.CoverArt @@ -205,7 +206,7 @@ fun ArtistDetailRoute( LaunchedEffect(artistId) { viewModel.refreshBio() } val art by viewModel.art.collectAsStateWithLifecycle() val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle() - val artCovers by viewModel.artCovers.collectAsStateWithLifecycle() + val heroArt by viewModel.heroArt.collectAsStateWithLifecycle() val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle() val offline by viewModel.offline.collectAsStateWithLifecycle() CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack) @@ -245,7 +246,7 @@ fun ArtistDetailRoute( bioExpanded = bioExpanded, art = art, artThumbnail = artThumbnail, - artCovers = artCovers, + heroArt = heroArt, artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName), coverArt = viewModel::coverArt, onBack = onBack, @@ -286,6 +287,7 @@ fun ArtistDetailScreen( art: CoverArtRef?, artThumbnail: CoverArtRef? = null, artCovers: List = emptyList(), + heroArt: ArtistArt? = null, artwork: ArtworkColors? = null, coverArt: (String?, Boolean) -> CoverArtRef?, onBack: () -> Unit, @@ -403,22 +405,16 @@ fun ArtistDetailScreen( .onSizeChanged { imageHeightPx = it.height.toFloat() }, ) { Box(Modifier.fillMaxWidth()) { - if (artCovers.isNotEmpty()) { - ArtistArtGrid( - covers = artCovers, - name = displayName, - singleThumbnail = artThumbnail, - modifier = Modifier.fillMaxWidth().height(ART_HEIGHT), - ) - } else { - CoverArt( - ref = art, - name = displayName, - thumbnailRef = artThumbnail, - showPlaceholder = art == null, - modifier = Modifier.fillMaxWidth().height(ART_HEIGHT), - ) - } + ArtistHeroArt( + art = + heroArt ?: ArtistArt( + image = art.takeIf { artCovers.isEmpty() }, + covers = artCovers, + ), + name = displayName, + thumbnail = artThumbnail, + modifier = Modifier.fillMaxWidth().height(ART_HEIGHT), + ) if (nameScrimAlpha > 0f) { Box( modifier = 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 01390919..e90d2f1f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt @@ -13,6 +13,8 @@ import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.SourceRepository import com.subtracks.playback.PlaybackController +import com.subtracks.ui.components.ArtistArt +import com.subtracks.ui.components.artistArt import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -136,41 +138,22 @@ class ArtistDetailViewModel( private val _artThumbnail = MutableStateFlow(null) val artThumbnail: StateFlow = _artThumbnail - private val _artCovers = MutableStateFlow>(emptyList()) - val artCovers: StateFlow> = _artCovers + private val _heroArt = MutableStateFlow(null) + val heroArt: StateFlow = _heroArt init { viewModelScope.launch { - artist - .filterNotNull() + combine(artist.filterNotNull(), sourceRepository.offline) { loaded, _ -> loaded } .collectLatest { loaded -> - val (full, thumbnail, covers) = resolveArt(loaded) - _art.value = full - _artThumbnail.value = thumbnail - _artCovers.value = covers + val hero = sourceRepository.artistArt(loaded, forIcon = false) + val icon = sourceRepository.artistArt(loaded, forIcon = true) + _art.value = hero.image ?: hero.covers.firstOrNull() + _artThumbnail.value = icon.image ?: icon.covers.firstOrNull() + _heroArt.value = hero } } } - private suspend fun resolveArt(artist: Artist): Triple> { - val id = sourceId.first() - val own = libraryRepository.artistOwnCoverArt(id, artistId) - val coverIds = - if (own != null) { - listOf(own) - } else { - libraryRepository.artistFallbackCoverArts(id, artistId).distinct() - } - if (coverIds.isEmpty()) return Triple(null, null, emptyList()) - - val thumbnails = coverIds.mapNotNull { sourceRepository.coverArt(it, thumbnail = true) } - val fulls = coverIds.mapNotNull { sourceRepository.coverArt(it) } - val full = fulls.firstOrNull() - val thumbnail = thumbnails.firstOrNull() - val covers = if (coverIds.size == 1) listOfNotNull(full ?: thumbnail) else thumbnails - return Triple(full ?: thumbnail, thumbnail, covers) - } - fun coverArt( coverArt: String?, thumbnail: Boolean, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt index d4ca1c27..5239ca98 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt @@ -29,6 +29,7 @@ import com.subtracks.R import com.subtracks.data.model.Artist import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.ListDownloadStatus +import com.subtracks.ui.components.ArtistArt import com.subtracks.ui.components.ArtistIcon import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.FilteredEmptyState @@ -55,7 +56,7 @@ fun ArtistsContent( onSync: () -> Unit = {}, downloadStatuses: Map = emptyMap(), scrollToTopKey: Any? = null, - artistCovers: suspend (String) -> List = { emptyList() }, + artistArt: (Artist) -> ArtistArt = { ArtistArt() }, ) { when { items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> { @@ -83,7 +84,8 @@ fun ArtistsContent( items(count = items.itemCount, key = items.itemKey { it.id }) { index -> val artist = items[index] if (artist != null) { - PrefetchArtworkSeeds(coverArt(artist.coverArt, true)) + val art = artistArt(artist) + PrefetchArtworkSeeds(art.primary) ListItem( headlineContent = { Text( @@ -107,8 +109,7 @@ fun ArtistsContent( leadingContent = { ArtistIcon( artist = artist, - fallback = coverArt(artist.coverArt, true), - loadCovers = artistCovers, + art = art, modifier = Modifier.size(48.dp).clip(CircleShape), ) }, @@ -119,7 +120,7 @@ fun ArtistsContent( Modifier.combinedClickable( onClick = { onArtistClick(artist) }, onLongClick = { - onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true), downloadStatuses[artist.id])) + onLongClick(MenuTarget.Artist(artist, art.primary, downloadStatuses[artist.id])) }, ), colors = ListItemDefaults.colors(containerColor = Color.Transparent), diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index f6fc0bb4..61dd3a61 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -120,6 +120,7 @@ import com.subtracks.data.prefs.PlaylistSort import com.subtracks.data.prefs.StarredFilter import com.subtracks.data.source.StarType import com.subtracks.playback.PlaybackController +import com.subtracks.ui.components.ArtistArt import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.DeleteDownloadsDialog import com.subtracks.ui.components.DismissOnRequest @@ -308,7 +309,7 @@ fun LibraryRoute( artists = viewModel.artists, playlists = viewModel.playlists, coverArt = viewModel::coverArt, - artistCovers = viewModel::artistArtCovers, + artistArt = { viewModel.artistArt(it) }, onAlbumClick = onAlbumClick, onArtistClick = onArtistClick, onPlaylistClick = onPlaylistClick, @@ -397,7 +398,7 @@ fun LibraryScreen( onSync: () -> Unit, onOpenSettings: () -> Unit, modifier: Modifier = Modifier, - artistCovers: suspend (String) -> List = { emptyList() }, + artistArt: (Artist) -> ArtistArt = { ArtistArt() }, onOpenSearch: () -> Unit = {}, offline: Boolean = false, onExitOffline: () -> Unit = {}, @@ -541,7 +542,7 @@ fun LibraryScreen( onSync = onSync, downloadStatuses = artistDownloads, scrollToTopKey = scrollToTopKey, - artistCovers = artistCovers, + artistArt = artistArt, ) } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt index 701e3541..5c818257 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt @@ -23,7 +23,8 @@ import com.subtracks.data.repo.SourceRepository import com.subtracks.data.sync.SyncManager import com.subtracks.data.sync.SyncStatus import com.subtracks.playback.PlaybackController -import com.subtracks.ui.components.loadArtistArtCovers +import com.subtracks.ui.components.ArtistArt +import com.subtracks.ui.components.artistArt import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -212,7 +213,10 @@ class LibraryViewModel( thumbnail: Boolean, ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) - suspend fun artistArtCovers(artistId: String): List = loadArtistArtCovers(libraryRepository, sourceRepository, artistId) + fun artistArt( + artist: Artist, + forIcon: Boolean = true, + ): ArtistArt = sourceRepository.artistArt(artist, forIcon) fun sync() { if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync() diff --git a/app/src/main/kotlin/com/subtracks/ui/search/SearchScreen.kt b/app/src/main/kotlin/com/subtracks/ui/search/SearchScreen.kt index f9178683..e3d39b37 100644 --- a/app/src/main/kotlin/com/subtracks/ui/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/search/SearchScreen.kt @@ -46,6 +46,7 @@ import com.subtracks.data.model.Playlist import com.subtracks.data.model.Song import com.subtracks.data.source.StarType import com.subtracks.playback.PlaybackController +import com.subtracks.ui.components.ArtistArt import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.ItemActions @@ -106,7 +107,7 @@ fun SearchRoute( if (next != 0) filterMask = next }, coverArt = viewModel::coverArt, - artistCovers = viewModel::artistArtCovers, + artistArt = { viewModel.artistArt(it) }, playingSongId = playback.item?.id, onBack = onBack, onAlbumClick = { @@ -147,7 +148,7 @@ fun SearchScreen( filters: Set = ALL_FILTERS, onToggleFilter: (SearchFilter) -> Unit = {}, coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null }, - artistCovers: suspend (String) -> List = { emptyList() }, + artistArt: (Artist) -> ArtistArt = { ArtistArt() }, playingSongId: String? = null, onBack: () -> Unit = {}, onAlbumClick: (Album) -> Unit = {}, @@ -217,7 +218,7 @@ fun SearchScreen( contentPadding = PaddingValues(bottom = 24.dp), ) { if (showPlaylists) playlistResults(results.playlists, playlistsLabel, coverArt, onPlaylistClick, onLongClick) - if (showArtists) artistResults(results.artists, artistsLabel, coverArt, artistCovers, onArtistClick, onLongClick) + if (showArtists) artistResults(results.artists, artistsLabel, artistArt, onArtistClick, onLongClick) if (showAlbums) albumResults(results.albums, albumsLabel, coverArt, onAlbumClick, onLongClick) if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick) } @@ -310,21 +311,20 @@ private fun LazyListScope.albumResults( private fun LazyListScope.artistResults( artists: List, header: String, - coverArt: (String?, Boolean) -> CoverArtRef?, - artistCovers: suspend (String) -> List, + artistArt: (Artist) -> ArtistArt, onClick: (Artist) -> Unit, onLongClick: (MenuTarget) -> Unit, ) { if (artists.isEmpty()) return resultHeader(header) items(artists, key = { "artist-${it.id}" }) { artist -> + val art = artistArt(artist) ArtistListRow( artist = artist, - coverArt = coverArt, - artistCovers = artistCovers, + art = art, info = null, onClick = { onClick(artist) }, - onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) }, + onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) }, ) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/search/SearchViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/search/SearchViewModel.kt index b1a06843..a865d524 100644 --- a/app/src/main/kotlin/com/subtracks/ui/search/SearchViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/search/SearchViewModel.kt @@ -12,7 +12,8 @@ import com.subtracks.data.model.Song import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.SourceRepository import com.subtracks.playback.PlaybackController -import com.subtracks.ui.components.loadArtistArtCovers +import com.subtracks.ui.components.ArtistArt +import com.subtracks.ui.components.artistArt import com.subtracks.ui.home.playSongInContext import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -75,7 +76,10 @@ class SearchViewModel( thumbnail: Boolean, ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) - suspend fun artistArtCovers(artistId: String): List = loadArtistArtCovers(libraryRepository, sourceRepository, artistId) + fun artistArt( + artist: Artist, + forIcon: Boolean = true, + ): ArtistArt = sourceRepository.artistArt(artist, forIcon) fun play(song: Song) { viewModelScope.playSongInContext(libraryRepository, playbackController, song) 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 40c3ce64..710d82be 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt @@ -548,7 +548,7 @@ class LibraryDaoTest { } @Test - fun artistArtFallsBackToTheFirstAlbumCreditThenTheFirstTrackCredit() = + fun artistArtIsMaterializedFromAlbumCreditsThenTrackCredits() = runTest { val sourceId = source() val dao = db.libraryDao() @@ -615,40 +615,121 @@ class LibraryDaoTest { } } + dao.recomputeArtistArt(sourceId) + val artists = dao.artistsByName(sourceId, 0, "").page().associateBy { it.id } assertEquals("own-art", artists.getValue("ar-own").coverArt) - assertEquals("new-art", artists.getValue("ar-album").coverArt) - assertEquals("track-art", artists.getValue("ar-track").coverArt) - assertEquals("album-ref-art", artists.getValue("ar-album-ref").coverArt) - assertEquals("track-ref-art", artists.getValue("ar-track-ref").coverArt) + assertNull(artists.getValue("ar-album").coverArt) + assertNull(artists.getValue("ar-track").coverArt) + assertNull(artists.getValue("ar-album-ref").coverArt) + assertNull(artists.getValue("ar-track-ref").coverArt) assertNull(artists.getValue("ar-null-art").coverArt) assertNull(artists.getValue("ar-none").coverArt) - assertEquals("new-art", dao.artistOnce(sourceId, "ar-album")!!.coverArt) + assertEquals(listOf("own-album"), artists.getValue("ar-own").coverArts) + assertEquals(listOf("new-art", "old-art"), artists.getValue("ar-album").coverArts) + assertEquals(listOf("track-art"), artists.getValue("ar-track").coverArts) + assertEquals(listOf("album-ref-art"), artists.getValue("ar-album-ref").coverArts) + assertEquals(listOf("track-ref-art"), artists.getValue("ar-track-ref").coverArts) + assertEquals(listOf("later-art"), artists.getValue("ar-null-art").coverArts) + assertEquals(emptyList(), artists.getValue("ar-none").coverArts) + assertEquals(listOf("new-art", "old-art"), dao.artistOnce(sourceId, "ar-album")!!.coverArts) assertEquals( - "album-ref-art", + listOf("album-ref-art"), dao .artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false)) .page() .first { it.id == "ar-album-ref" } - .coverArt, + .coverArts, ) } @Test - fun artistArtFallbackFindsCreditsThroughArtistIndexes() = + fun downloadedArtistArtUsesOnlyDownloadedAlbums() = runTest { - val plan = plan("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME LIMIT 20") - - assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)")) - assertTrue( - plan, - plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"), + val sourceId = source() + val dao = db.libraryDao() + dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 3))) + dao.upsertAlbums( + listOf( + album(sourceId, "al-1", "One", year = 2020, starred = null) + .copy(artistId = "ar-1", coverArt = "art-1"), + album(sourceId, "al-2", "Two", year = 2021, starred = null) + .copy(artistId = "ar-1", coverArt = "art-2"), + album(sourceId, "al-3", "Three", year = 2022, starred = null) + .copy(artistId = "ar-1", coverArt = "art-3"), + ), ) - assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)")) - assertTrue( - plan, - plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"), + dao.upsertSongs( + listOf( + song(sourceId, "s1", "One", starred = null).copy(albumId = "al-1", artistId = "ar-1"), + song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2", artistId = "ar-1"), + song(sourceId, "s3", "Three", starred = null).copy(albumId = "al-3", artistId = "ar-1"), + ), + ) + db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed)) + db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Queued)) + + dao.recomputeArtistArt(sourceId) + + val artist = dao.artistOnce(sourceId, "ar-1")!! + assertEquals(listOf("art-3", "art-2", "art-1"), artist.coverArts) + assertEquals(listOf("art-1"), artist.downloadedCoverArts) + + db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Completed)) + dao.recomputeDownloadedArtistArt(sourceId, listOf("ar-1")) + + assertEquals(listOf("art-2", "art-1"), dao.artistOnce(sourceId, "ar-1")!!.downloadedCoverArts) + } + + @Test + fun artistArtMaterializationUsesTheCreditIndexes() = + runTest { + val plan = plan(ARTIST_ART_UPDATE_SQL) + + assertTrue(plan, plan.contains("index_albums_sourceId_artistId")) + assertTrue(plan, plan.contains("index_album_artists_sourceId_artistId")) + assertTrue(plan, plan.contains("index_songs_sourceId_artistId")) + assertTrue(plan, plan.contains("index_song_artists_sourceId_artistId")) + } + + @Test + fun creditedSongArtistsIncludeTheAlbumArtistAndTrackCredits() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertArtists( + listOf( + artist(sourceId, "ar-album", "Album Artist", albumCount = 1), + artist(sourceId, "ar-track", "Track Artist", albumCount = 0), + artist(sourceId, "ar-guest", "Guest", albumCount = 0), + ), + ) + dao.upsertAlbums( + listOf( + album(sourceId, "al-1", "Album", year = null, starred = null) + .copy(artistId = "ar-album"), + ), + ) + dao.upsertSongs( + listOf( + song(sourceId, "s1", "Song", starred = null) + .copy(albumId = "al-1", artistId = "ar-track"), + ), + ) + db.useWriterConnection { connection -> + connection.usePrepared( + "INSERT INTO song_artists (sourceId, songId, position, artistId, name) " + + "VALUES (?, 's1', 0, 'ar-guest', 'Guest')", + ) { statement -> + statement.bindLong(1, sourceId) + statement.step() + } + } + + assertEquals( + setOf("ar-album", "ar-track", "ar-guest"), + dao.creditedArtistIdsForSongs(sourceId, listOf("s1")).toSet(), ) } 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 e7ccd9fb..0486431c 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt @@ -153,6 +153,70 @@ class MigrationsTest { } } + @Test + fun theArtistArtColumnsDefaultToEmptyAndAcceptValues() = + runTest { + driver.open(":memory:").use { migrated -> + applySchema(migrated, 35) + migrated.execSQL( + "INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)", + ) + migrated.execSQL( + "INSERT INTO artists (sourceId, id, name, albumCount, starred) " + + "VALUES (1, 'ar1', 'Artist', 0, NULL)", + ) + + MIGRATION_35_36.migrate(migrated) + + assertEquals(1L, long(migrated, "SELECT artistImageUrl IS NULL FROM artists WHERE id = 'ar1'")) + assertEquals("", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'")) + assertEquals("", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'")) + migrated.execSQL( + "UPDATE artists SET artistImageUrl = 'http://img', coverArts = 'a\u001fb', " + + "downloadedCoverArts = 'c' WHERE id = 'ar1'", + ) + assertEquals("http://img", text(migrated, "SELECT artistImageUrl FROM artists WHERE id = 'ar1'")) + assertEquals("a\u001fb", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'")) + assertEquals("c", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'")) + } + } + + @Test + fun theArtistArtMigrationBackfillsExistingLibraries() = + runTest { + driver.open(":memory:").use { migrated -> + applySchema(migrated, 35) + migrated.execSQL( + "INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)", + ) + migrated.execSQL( + "INSERT INTO artists (sourceId, id, name, albumCount, starred) " + + "VALUES (1, 'ar1', 'Artist', 3, NULL)", + ) + migrated.execSQL( + "INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, coverArt, genre, " + + "year, starred, songCount) VALUES " + + "(1, 'al1', 'ar1', 'Shared', 'Artist', 0, 'shared', NULL, 2020, NULL, 1), " + + "(1, 'al2', 'ar1', 'Shared Newer', 'Artist', 0, 'shared', NULL, 2021, NULL, 1), " + + "(1, 'al3', 'ar1', 'Other', 'Artist', 0, 'other', NULL, 2019, NULL, 1)", + ) + migrated.execSQL( + "INSERT INTO songs (sourceId, id, albumId, artistId, title) VALUES " + + "(1, 's1', 'al1', 'ar1', 'One'), (1, 's2', 'al2', 'ar1', 'Two'), " + + "(1, 's3', 'al3', 'ar1', 'Three')", + ) + migrated.execSQL( + "INSERT INTO song_downloads (sourceId, songId, status, bytes, total) VALUES " + + "(1, 's1', 'Completed', 1, 1), (1, 's2', 'Completed', 1, 1)", + ) + + MIGRATION_35_36.migrate(migrated) + + assertEquals("shared\u001fother", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'")) + assertEquals("shared", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'")) + } + } + @Test fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() = runTest { diff --git a/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt index ff19df54..2a1bd98f 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt @@ -26,6 +26,7 @@ import com.subtracks.data.model.Song import com.subtracks.data.model.SongDownload import com.subtracks.data.model.Source import com.subtracks.data.model.SubsonicSource +import com.subtracks.data.model.artistImageKey import com.subtracks.data.model.coverArtKey import com.subtracks.data.prefs.fakeUserPreferences import kotlinx.coroutines.CoroutineScope @@ -70,11 +71,12 @@ class DownloadRepositoryTest { private val requestedArt = CopyOnWriteArrayList() private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList() private var failArtworkFetch = false + private val failingArtUrls = CopyOnWriteArrayList() private val fetcher = ArtworkFetcher { url -> requestedArt += url artFetchedAfterTheEngineRequest += engine.requests.isNotEmpty() - if (failArtworkFetch) throw IOException("boom") + if (failArtworkFetch || url in failingArtUrls) throw IOException("boom") byteArrayOf(1, 2, 3) } @@ -381,6 +383,95 @@ class DownloadRepositoryTest { } } + @Test + fun startingADownloadStoresTheProviderArtistImageAndKeepsItUntilTheDownloadGoes() = + runTest { + seedLibrary() + runBlocking { + db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART, artistImageUrl = "https://img.example/ar1.jpg"))) + } + + runBlocking { repository.download(1, "s1") } + + assertTrue(requestedArt.contains("https://img.example/ar1.jpg")) + val key = artistImageKey(1, "ar1") + assertNotNull(artwork.uri(1, key)) + + runBlocking { repository.reconcile() } + assertNotNull(artwork.uri(1, key)) + + runBlocking { repository.remove(1, "s1") } + assertNull(artwork.uri(1, key)) + } + + @Test + fun providerArtworkForTheAlbumArtistSurvivesASweepAndGoesWithTheDownload() = + runTest { + seedLibrary() + runBlocking { + db.libraryDao().upsertArtists( + listOf( + artist(ARTIST_ART, "https://img.example/track.jpg").copy(id = "ar-track"), + artist(ARTIST_ART, "https://img.example/album.jpg").copy(id = "ar-album"), + ), + ) + db.libraryDao().upsertAlbums(listOf(album(ALBUM_ART).copy(artistId = "ar-album"))) + db.libraryDao().upsertSongs(listOf(db.libraryDao().songOnce(1, "s1")!!.copy(artistId = "ar-track"))) + } + + runBlocking { repository.download(1, "s1") } + val trackKey = artistImageKey(1, "ar-track") + val albumKey = artistImageKey(1, "ar-album") + assertNotNull(artwork.uri(1, trackKey)) + assertNotNull(artwork.uri(1, albumKey)) + + runBlocking { repository.reconcile() } + assertNotNull(artwork.uri(1, trackKey)) + assertNotNull(artwork.uri(1, albumKey)) + + runBlocking { repository.remove(1, "s1") } + assertNull(artwork.uri(1, trackKey)) + assertNull(artwork.uri(1, albumKey)) + } + + @Test + fun aFailedProviderImageFetchCachesTheArtistCoverFallback() = + runTest { + seedLibrary() + runBlocking { + db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART, artistImageUrl = "https://img.example/ar1.jpg"))) + } + failingArtUrls += "https://img.example/ar1.jpg" + + runBlocking { repository.download(1, "s1") } + + assertNull(artwork.uri(1, artistImageKey(1, "ar1"))) + assertNotNull(artwork.uri(1, coverArtKey(1, ARTIST_ART, false))) + assertNotNull(artwork.uri(1, coverArtKey(1, ARTIST_ART, true))) + } + + @Test + fun completingADownloadMaterializesOnlyItsAlbumForOfflineArtistArt() = + runTest { + seedLibrary() + repository.start() + runBlocking { repository.download(1, "s1") } + val id = engine.requests.single().first + + engine.complete(id, total = 100) + writeFile(1, "s1", "audio") + runBlocking { repository.reconcile() } + + await { row(1, "s1")?.status == DownloadStatus.Completed } + assertEquals( + listOf(ALBUM_ART), + db.libraryDao().artistOnce(1, "ar1")!!.downloadedCoverArts, + ) + + runBlocking { repository.remove(1, "s1") } + assertEquals(emptyList(), db.libraryDao().artistOnce(1, "ar1")!!.downloadedCoverArts) + } + @Test fun downloadingASongAlsoStoresTheArtworkOfPlaylistsThatContainIt() = runTest { @@ -1282,8 +1373,19 @@ class DownloadRepositoryTest { songCount = 2, ) - private fun artist(coverArt: String?) = - Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt, songCount = 2) + private fun artist( + coverArt: String?, + artistImageUrl: String? = null, + ) = Artist( + sourceId = 1, + id = "ar1", + name = "Artist", + albumCount = 1, + starred = null, + coverArt = coverArt, + artistImageUrl = artistImageUrl, + songCount = 2, + ) private fun seedLibrary(address: String = "http://localhost:4533/") { runBlocking { 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 bd8b8f29..242750c4 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt @@ -11,6 +11,7 @@ import com.subtracks.cancelAndJoinBlocking import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.download.ArtworkStore import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.artistImageKey import com.subtracks.data.model.coverArtKey import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.UserPreferences @@ -33,6 +34,7 @@ import okhttp3.mockwebserver.RecordedRequest import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -283,6 +285,26 @@ class SourceRepositoryTest { assertTrue("expected the stored file, was ${ref.url}", ref.url.startsWith("file:")) } + @Test + fun artistImageUsesTheNetworkOnlineAndOnlyCachedFilesOffline() = + runBlocking { + repository.addSource("nav", "http://a.example/", "u", "p", false) + val sourceId = awaitActiveSourceId() + val url = "https://img.example/ar1.jpg" + val online = withTimeout(TEST_TIMEOUT_MS) { repository.artistImage("ar1", url) } + assertEquals(url, online?.url) + + val key = artistImageKey(sourceId, "ar1") + artwork.write(sourceId, key, byteArrayOf(1, 2, 3)) + assertEquals(artwork.uri(sourceId, key), repository.artistImage("ar1", url)?.url) + + repository.setOfflineMode(true) + withTimeout(TEST_TIMEOUT_MS) { repository.offline.first { it } } + + assertEquals(artwork.uri(sourceId, key), repository.artistImage("ar1", url)?.url) + assertNull(repository.artistImage("ar2", "https://img.example/ar2.jpg")) + } + private suspend fun awaitActiveSourceId(): Long = withTimeout(TEST_TIMEOUT_MS) { repository.activeSourceId().first { it != null }!! } private suspend fun awaitCoverArt(coverArt: String): CoverArtRef = diff --git a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt index bb503416..bf060ee1 100644 --- a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt @@ -74,7 +74,8 @@ class SubsonicXmlTest { """ - + @@ -88,6 +89,8 @@ class SubsonicXmlTest { assertEquals(7L, artists[0].sourceId) assertEquals(9L, artists[0].albumCount) assertEquals(1577934245L, artists[0].starred) + assertEquals("https://img.example/radiohead.jpg", artists[0].artistImageUrl) + assertEquals(null, artists[1].artistImageUrl) assertEquals(null, artists[1].starred) } diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index 6a5b8612..ce455e68 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -48,6 +48,7 @@ import com.subtracks.data.prefs.StreamQuality import com.subtracks.playback.PlaybackState import com.subtracks.playback.QueueItem import com.subtracks.playback.RepeatMode +import com.subtracks.ui.components.ArtistArt import com.subtracks.ui.components.DOWNLOADING_INDICATOR_TAG import com.subtracks.ui.library.ALBUM_COVER_TAG import com.subtracks.ui.library.ARTIST_NAME_SCRIM_TAG @@ -613,12 +614,15 @@ class AppScreenshotTest { artists = remember { flowOf(PagingData.from(Fixtures.artists)) }, playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) }, coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } }, - artistCovers = { id -> - Fixtures.albums - .filter { it.artistId == id } - .take(4) - .mapNotNull { it.coverArt } - .map { CoverArtRef(it, "test:$it") } + artistArt = { artist -> + ArtistArt( + covers = + Fixtures.albums + .filter { it.artistId == artist.id } + .take(4) + .mapNotNull { it.coverArt } + .map { CoverArtRef(it, "test:$it") }, + ) }, onAlbumClick = {}, onArtistClick = {}, diff --git a/app/src/test/kotlin/com/subtracks/ui/components/ArtistArtTest.kt b/app/src/test/kotlin/com/subtracks/ui/components/ArtistArtTest.kt new file mode 100644 index 00000000..d12a4f91 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/components/ArtistArtTest.kt @@ -0,0 +1,174 @@ +package com.subtracks.ui.components + +import com.subtracks.data.model.Artist +import com.subtracks.data.model.CoverArtRef +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ArtistArtTest { + @Test + fun providerImageWinsOverArtistAndAlbumArt() { + val calls = mutableListOf() + val art = + resolveArtistArt( + artist = artist(artistImageUrl = "https://img/artist"), + coverArt = { id, _ -> id?.let { CoverArtRef(it, "cache:$it") } }, + artistImage = { id, url -> + calls += "image:$id:$url" + CoverArtRef("https://img/artist", "cache:image:$id") + }, + offline = false, + forIcon = true, + ) + + assertEquals(CoverArtRef("https://img/artist", "cache:image:ar1"), art.image) + assertTrue(art.covers.isEmpty()) + assertEquals(listOf("image:ar1:https://img/artist"), calls) + } + + @Test + fun artistCoverArtWinsOverMaterializedAlbumArt() { + val thumbnails = mutableListOf() + val art = + resolveArtistArt( + artist = artist(coverArt = "own", coverArts = listOf("a", "b")), + coverArt = { id, thumbnail -> + thumbnails += thumbnail + id?.let { CoverArtRef(it, "cache:$it:$thumbnail") } + }, + artistImage = { _, _ -> null }, + offline = false, + forIcon = true, + ) + + assertEquals(CoverArtRef("own", "cache:own:true"), art.image) + assertTrue(art.covers.isEmpty()) + assertTrue(thumbnails.all { it }) + } + + @Test + fun iconUsesThumbnailsAndLimitsTheGridToFourCovers() { + val thumbnails = mutableListOf() + val art = + resolveArtistArt( + artist = artist(coverArts = listOf("a", "b", "c", "d", "e")), + coverArt = { id, thumbnail -> + thumbnails += thumbnail + id?.let { CoverArtRef(it, "cache:$it") } + }, + artistImage = { _, _ -> null }, + offline = false, + forIcon = true, + ) + + assertEquals(listOf("a", "b", "c", "d"), art.covers.map { it.url }) + assertEquals(listOf(true, true, true, true), thumbnails) + } + + @Test + fun aSingleHeroCoverLoadsFullSizeWhileMultipleCoversUseThumbnails() { + val thumbnails = mutableListOf() + val coverArt = { id: String?, thumbnail: Boolean -> + thumbnails += thumbnail + id?.let { CoverArtRef(it, "cache:$it") } + } + val artistImage: (String, String?) -> CoverArtRef? = { _, _ -> null } + + resolveArtistArt(artist(coverArts = listOf("a")), coverArt, artistImage, offline = false, forIcon = false) + assertEquals(listOf(false), thumbnails) + + thumbnails.clear() + resolveArtistArt(artist(coverArts = listOf("a", "b")), coverArt, artistImage, offline = false, forIcon = false) + assertEquals(listOf(true, true), thumbnails) + } + + @Test + fun offlineUsesDownloadedCoversAndNeverOnlineOnlyOnes() { + val requested = mutableListOf() + val art = + resolveArtistArt( + artist = + artist( + coverArt = "online-own", + coverArts = listOf("online-a", "online-b"), + downloadedCoverArts = listOf("downloaded-a", "downloaded-b"), + ), + coverArt = { id, _ -> + requested += id ?: "null" + id?.takeIf { it.startsWith("downloaded") }?.let { CoverArtRef(it, "cache:$it") } + }, + artistImage = { _, _ -> null }, + offline = true, + forIcon = true, + ) + + assertEquals(listOf("downloaded-a", "downloaded-b"), art.covers.map { it.url }) + assertEquals(setOf("online-own", "downloaded-a", "downloaded-b"), requested.toSet()) + } + + @Test + fun theProviderAndArtistImagesCarryTheNextSourceAsAFallback() { + val provider = + resolveArtistArt( + artist = artist(coverArt = "own", artistImageUrl = "https://img/artist", coverArts = listOf("a", "b")), + coverArt = { id, _ -> id?.let { CoverArtRef(it, "cache:$it") } }, + artistImage = { _, _ -> CoverArtRef("https://img/artist", "cache:image") }, + offline = false, + forIcon = true, + ) + + assertEquals(CoverArtRef("own", "cache:own"), provider.fallback?.image) + assertEquals( + listOf("a", "b"), + provider.fallback + ?.fallback + ?.covers + ?.map { it.url }, + ) + + val own = + resolveArtistArt( + artist = artist(coverArt = "own", coverArts = listOf("a", "b")), + coverArt = { id, _ -> id?.let { CoverArtRef(it, "cache:$it") } }, + artistImage = { _, _ -> null }, + offline = false, + forIcon = true, + ) + + assertEquals(listOf("a", "b"), own.fallback?.covers?.map { it.url }) + } + + @Test + fun noArtResolvesToAnEmptySelectionInsteadOfAStaleFallback() { + val art = + resolveArtistArt( + artist = artist(coverArt = "online-own", coverArts = listOf("a")), + coverArt = { _, _ -> null }, + artistImage = { _, _ -> null }, + offline = true, + forIcon = true, + ) + + assertNull(art.image) + assertTrue(art.covers.isEmpty()) + } + + private fun artist( + coverArt: String? = null, + artistImageUrl: String? = null, + coverArts: List = emptyList(), + downloadedCoverArts: List = emptyList(), + ) = Artist( + sourceId = 1, + id = "ar1", + name = "Artist", + albumCount = 0, + starred = null, + coverArt = coverArt, + artistImageUrl = artistImageUrl, + coverArts = coverArts, + downloadedCoverArts = downloadedCoverArts, + ) +}