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, + ) +}