From daf305f680f3c4ef0e009dcd644e5818f9d358fd Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:50:34 +0000 Subject: [PATCH] library: sync every artist credited on an album or song OpenSubsonic servers send plural artists/albumArtists refs and can omit the singular artistId when an album or song has more than one artist. Parse and store the ordered refs on the item and in indexed album_artists/song_artists tables, so artist pages, shuffles, downloads and the artist filters include every credited artist. Album and now playing artist taps open a chooser when several artists are credited; context menus get one artist row per artist. --- .../29.json | 1812 +++++++++++++++++ .../com/subtracks/data/db/DownloadDao.kt | 16 +- .../com/subtracks/data/db/LibraryDao.kt | 61 +- .../com/subtracks/data/db/Migrations.kt | 29 + .../kotlin/com/subtracks/data/db/QueueDao.kt | 8 +- .../subtracks/data/db/SubtracksDatabase.kt | 8 +- .../kotlin/com/subtracks/data/model/Models.kt | 99 +- .../data/source/subsonic/SubsonicSource.kt | 4 +- .../data/source/subsonic/SubsonicXml.kt | 90 +- .../com/subtracks/data/sync/SyncService.kt | 74 +- .../ui/components/ArtistChooserSheet.kt | 68 + .../ui/components/ItemContextMenu.kt | 12 +- .../subtracks/ui/library/AlbumDetailScreen.kt | 28 +- .../ui/library/ArtistDetailScreen.kt | 2 +- .../subtracks/ui/playback/NowPlayingScreen.kt | 23 +- app/src/main/res/values/strings.xml | 1 + .../data/source/subsonic/SubsonicXmlTest.kt | 114 +- .../subtracks/data/sync/SyncServiceTest.kt | 119 ++ .../com/subtracks/ui/DemoScreenshotTest.kt | 2 +- .../ui/components/ArtistChooserSheetTest.kt | 46 + .../components/ItemContextMenuArtistTest.kt | 70 + 21 files changed, 2628 insertions(+), 58 deletions(-) create mode 100644 app/schemas/com.subtracks.data.db.SubtracksDatabase/29.json create mode 100644 app/src/main/kotlin/com/subtracks/ui/components/ArtistChooserSheet.kt create mode 100644 app/src/test/kotlin/com/subtracks/ui/components/ArtistChooserSheetTest.kt create mode 100644 app/src/test/kotlin/com/subtracks/ui/components/ItemContextMenuArtistTest.kt diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/29.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/29.json new file mode 100644 index 00000000..e2127066 --- /dev/null +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/29.json @@ -0,0 +1,1812 @@ +{ + "formatVersion": 1, + "database": { + "version": 29, + "identityHash": "4a8efcd004e438ea0cbdc6d08956eecf", + "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)", + "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 + } + ], + "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, `playCount` INTEGER NOT NULL DEFAULT 0, `played` INTEGER, 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": "playCount", + "columnName": "playCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "played", + "columnName": "played", + "affinity": "INTEGER" + } + ], + "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", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `${TABLE_NAME}` (`sourceId`, `name`, `id`)" + }, + { + "name": "index_artists_albumCount", + "unique": false, + "columnNames": [ + "sourceId", + "albumCount", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_albumCount` ON `${TABLE_NAME}` (`sourceId` ASC, `albumCount` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_artists_starred", + "unique": false, + "columnNames": [ + "sourceId", + "starred", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_artists_frequent", + "unique": false, + "columnNames": [ + "sourceId", + "playCount", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_frequent` ON `${TABLE_NAME}` (`sourceId` ASC, `playCount` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_artists_recent", + "unique": false, + "columnNames": [ + "sourceId", + "played", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_recent` ON `${TABLE_NAME}` (`sourceId` ASC, `played` DESC, `name` ASC, `id` ASC)" + } + ], + "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`, tokenize=`trigram`, content=`artists`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "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`) VALUES (NEW.`rowid`, NEW.`name`); 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`) VALUES (NEW.`rowid`, NEW.`name`); 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 '', 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": "''" + } + ], + "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", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_name` ON `${TABLE_NAME}` (`sourceId`, `name`, `id`)" + }, + { + "name": "index_albums_artist", + "unique": false, + "columnNames": [ + "sourceId", + "albumArtist", + "year", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist` ON `${TABLE_NAME}` (`sourceId`, `albumArtist`, `year`, `name`, `id`)" + }, + { + "name": "index_albums_year", + "unique": false, + "columnNames": [ + "sourceId", + "year", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_year` ON `${TABLE_NAME}` (`sourceId` ASC, `year` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_albums_added", + "unique": false, + "columnNames": [ + "sourceId", + "created", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_added` ON `${TABLE_NAME}` (`sourceId` ASC, `created` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_albums_starred", + "unique": false, + "columnNames": [ + "sourceId", + "starred", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_albums_frequent", + "unique": false, + "columnNames": [ + "sourceId", + "playCount", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_frequent` ON `${TABLE_NAME}` (`sourceId` ASC, `playCount` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_albums_recent", + "unique": false, + "columnNames": [ + "sourceId", + "played", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_recent` ON `${TABLE_NAME}` (`sourceId` ASC, `played` DESC, `name` 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`, tokenize=`trigram`, content=`albums`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "albumArtist", + "affinity": "TEXT" + } + ], + "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`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`albumArtist`); 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`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`albumArtist`); 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 '', 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": "''" + } + ], + "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", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `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`, tokenize=`trigram`, content=`songs`)", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT" + } + ], + "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`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`artist`); 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`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`artist`); 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, '4a8efcd004e438ea0cbdc6d08956eecf')" + ] + } +} \ 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 b3b4e1fb..d1ccec9f 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt @@ -20,11 +20,25 @@ private const val ARTIST_DOWNLOAD_ROWS = "LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " + "WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " + "UNION ALL " + + "SELECT album_artists.artistId AS id, songs.id AS songId, song_downloads.status AS status " + + "FROM songs " + + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + + "JOIN album_artists ON album_artists.sourceId = albums.sourceId AND album_artists.albumId = albums.id " + + "LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " + + "WHERE songs.sourceId = :sourceId " + + "UNION ALL " + "SELECT songs.artistId AS id, songs.id AS songId, song_downloads.status AS status " + "FROM songs " + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " + - "WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL" + "WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " + + "UNION ALL " + + "SELECT song_artists.artistId AS id, songs.id AS songId, song_downloads.status AS status " + + "FROM songs " + + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + + "JOIN song_artists ON song_artists.sourceId = songs.sourceId AND song_artists.songId = songs.id " + + "LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " + + "WHERE songs.sourceId = :sourceId" @Dao interface DownloadDao { 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 2e0e73bf..9ac53dfc 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -65,18 +65,25 @@ internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, name COLLA internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, name COLLATE NOCASE, id" internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC" +internal const val SONG_MATCHES_ARTIST = + "(songs.artistId = :artistId OR songs.id IN (SELECT songId FROM song_artists " + + "WHERE sourceId = songs.sourceId AND artistId = :artistId))" + +internal const val ALBUM_MATCHES_ARTIST = + "(albums.artistId = :artistId OR albums.id IN (SELECT albumId FROM album_artists " + + "WHERE sourceId = albums.sourceId AND artistId = :artistId))" + internal const val ARTISTS_FILTER = "FROM artists WHERE sourceId = :sourceId " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + - "AND (:downloadedFilter = 0 OR " + - "EXISTS (SELECT 1 FROM songs " + - "JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " + + "AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 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 = artists.sourceId AND dl.artistId = artists.id AND sd.status = 'Completed') " + - "OR EXISTS (SELECT 1 FROM songs " + - "JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " + - "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + - "WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id AND sd.status = 'Completed')) " + + "WHERE songs.sourceId = artists.sourceId AND sd.status = 'Completed' " + + "AND (albums.artistId = artists.id " + + "OR albums.id IN (SELECT albumId FROM album_artists aa WHERE aa.sourceId = albums.sourceId AND aa.artistId = artists.id) " + + "OR songs.artistId = artists.id " + + "OR songs.id IN (SELECT songId FROM song_artists sa WHERE sa.sourceId = songs.sourceId AND sa.artistId = artists.id)))) " + "AND (:search = '' " + "OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " + "WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " + @@ -87,17 +94,15 @@ internal const val ARTISTS_FILTER = internal const val ARTISTS_SELECT = "SELECT artists.sourceId, artists.id, artists.name, " + "CASE WHEN :downloadedFilter = 1 THEN (" + - "SELECT COUNT(*) FROM (" + - "SELECT dl.id FROM songs " + - "JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " + + "SELECT COUNT(DISTINCT albums.id) 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 = artists.sourceId AND dl.artistId = artists.id AND sd.status = 'Completed' " + - "UNION " + - "SELECT dl.id FROM songs " + - "JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " + - "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + - "WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id AND sd.status = 'Completed'" + - ")) ELSE artists.albumCount END AS albumCount, " + + "WHERE songs.sourceId = artists.sourceId AND sd.status = 'Completed' " + + "AND (albums.artistId = artists.id " + + "OR albums.id IN (SELECT albumId FROM album_artists aa WHERE aa.sourceId = albums.sourceId AND aa.artistId = artists.id) " + + "OR songs.artistId = artists.id " + + "OR songs.id IN (SELECT songId FROM song_artists sa WHERE sa.sourceId = songs.sourceId AND sa.artistId = artists.id))" + + ") ELSE artists.albumCount END AS albumCount, " + "artists.starred, artists.coverArt, artists.playCount, artists.played " internal const val ARTIST_ORDER_BY_NAME = "name COLLATE NOCASE, id" @@ -942,10 +947,13 @@ interface LibraryDao { artistId: String, ): Flow - // A credited track artist may not have an artists row; its name only lives on the songs. + // A credited track artist may not have an artists row; its name lives on song_artists or the songs. @Query( - "SELECT artist FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " + - "AND artist IS NOT NULL AND artist != '' ORDER BY id LIMIT 1", + "SELECT COALESCE(" + + "(SELECT name FROM song_artists WHERE sourceId = :sourceId AND artistId = :artistId " + + "AND name != '' ORDER BY songId, position LIMIT 1), " + + "(SELECT artist FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " + + "AND artist IS NOT NULL AND artist != '' ORDER BY id LIMIT 1))", ) fun artistName( sourceId: Long, @@ -971,8 +979,8 @@ interface LibraryDao { ): Flow> @Query( - "SELECT * FROM albums WHERE sourceId = :sourceId AND (artistId = :artistId " + - "OR id IN (SELECT albumId FROM songs WHERE sourceId = :sourceId AND artistId = :artistId)) " + + "SELECT * FROM albums WHERE sourceId = :sourceId AND ($ALBUM_MATCHES_ARTIST " + + "OR id IN (SELECT albumId FROM songs WHERE sourceId = :sourceId AND $SONG_MATCHES_ARTIST)) " + "ORDER BY year DESC, name COLLATE NOCASE, id", ) fun albumsForArtist( @@ -1017,12 +1025,13 @@ interface LibraryDao { @Query( "SELECT * FROM albums WHERE sourceId = :sourceId AND (" + - "(artistId = :artistId AND EXISTS (SELECT 1 FROM songs " + + "($ALBUM_MATCHES_ARTIST 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')) " + - "OR id IN (SELECT songs.albumId FROM songs " + + "OR EXISTS (SELECT 1 FROM songs " + "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + - "WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId AND sd.status = 'Completed')) " + + "WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id " + + "AND sd.status = 'Completed' AND $SONG_MATCHES_ARTIST)) " + "ORDER BY year DESC, name COLLATE NOCASE, id", ) fun albumsForArtistDownloaded( 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 03271843..d082e575 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -394,6 +394,34 @@ val MIGRATION_27_28 = } } +val MIGRATION_28_29 = + object : Migration(28, 29) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE `songs` ADD COLUMN `artists` TEXT NOT NULL DEFAULT ''") + connection.execSQL("ALTER TABLE `albums` ADD COLUMN `artists` TEXT NOT NULL DEFAULT ''") + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `album_artists` (`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 )", + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_album_artists_sourceId_artistId` ON `album_artists` (`sourceId`, `artistId`)", + ) + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `song_artists` (`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 )", + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_song_artists_sourceId_artistId` ON `song_artists` (`sourceId`, `artistId`)", + ) + } + } + // Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities. private suspend fun createSearchIndex( connection: SQLiteConnection, @@ -455,4 +483,5 @@ val MIGRATIONS: Array = MIGRATION_25_26, MIGRATION_26_27, MIGRATION_27_28, + MIGRATION_28_29, ) diff --git a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt index d44ec921..9688f72e 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt @@ -19,10 +19,12 @@ private const val ALBUM_SONGS_ORDER = " ORDER BY songs.disc, songs.track, songs. private const val ALBUM_SONGS_SQL = ALBUM_SONGS_SELECT + ALBUM_SONGS_ORDER +private const val ARTIST_SONGS_MATCH = "($ALBUM_MATCHES_ARTIST OR $SONG_MATCHES_ARTIST)" + private const val ARTIST_SONGS_SELECT = "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)" + "WHERE songs.sourceId = :sourceId AND $ARTIST_SONGS_MATCH" private const val ARTIST_SONGS_ORDER = " ORDER BY albums.year DESC, albums.name COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id" @@ -83,7 +85,7 @@ interface QueueDao { @Query( "SELECT COUNT(*) FROM songs " + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)", + "WHERE songs.sourceId = :sourceId AND $ARTIST_SONGS_MATCH", ) suspend fun artistLength( sourceId: Long, @@ -191,7 +193,7 @@ interface QueueDao { @Query( "SELECT songs.id FROM songs " + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)" + + "WHERE songs.sourceId = :sourceId AND $ARTIST_SONGS_MATCH" + ARTIST_SONGS_ORDER, ) suspend fun artistSongIds( 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 fb023c0a..b7172576 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -4,8 +4,10 @@ import androidx.room3.ColumnTypeConverters import androidx.room3.Database import androidx.room3.RoomDatabase import com.subtracks.data.model.Album +import com.subtracks.data.model.AlbumArtistRef import com.subtracks.data.model.AlbumSearch import com.subtracks.data.model.Artist +import com.subtracks.data.model.ArtistRefsConverter import com.subtracks.data.model.ArtistSearch import com.subtracks.data.model.ArtworkSeed import com.subtracks.data.model.Disc @@ -20,6 +22,7 @@ import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueKindConverter import com.subtracks.data.model.Song +import com.subtracks.data.model.SongArtistRef import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongGenre import com.subtracks.data.model.SongSearch @@ -36,11 +39,13 @@ import com.subtracks.data.model.UpNextEntry Album::class, AlbumSearch::class, Disc::class, + AlbumArtistRef::class, Playlist::class, PlaylistSearch::class, PlaylistSong::class, Song::class, SongGenre::class, + SongArtistRef::class, SongSearch::class, QueueEntry::class, UpNextEntry::class, @@ -49,7 +54,7 @@ import com.subtracks.data.model.UpNextEntry SongDownload::class, PendingAction::class, ], - version = 28, + version = 29, exportSchema = true, ) @ColumnTypeConverters( @@ -57,6 +62,7 @@ import com.subtracks.data.model.UpNextEntry DownloadStatusConverter::class, DownloadErrorConverter::class, PendingActionKindConverter::class, + ArtistRefsConverter::class, ) abstract class SubtracksDatabase : RoomDatabase() { abstract fun libraryDao(): LibraryDao 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 3c908928..415faafe 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -39,6 +39,11 @@ data class SubsonicSource( val useTokenAuth: Boolean = true, ) +data class ArtistRef( + val id: String, + val name: String, +) + @Entity( tableName = "artists", primaryKeys = ["sourceId", "id"], @@ -144,7 +149,17 @@ data class Album( @Ignore val discTitles: Map = emptyMap(), @ColumnInfo(defaultValue = "0") val playCount: Long = 0, val played: Long? = null, -) + @ColumnInfo(defaultValue = "''") val artists: List = emptyList(), +) { + val artistRefs: List + get() = + artists.ifEmpty { + artistId + ?.takeIf { it.isNotBlank() } + ?.let { listOf(ArtistRef(it, albumArtist.orEmpty())) } + .orEmpty() + } +} @Entity( tableName = "discs", @@ -269,7 +284,17 @@ data class Song( val trackPeak: Float? = null, val albumPeak: Float? = null, @Ignore val genres: List = emptyList(), -) + @ColumnInfo(defaultValue = "''") val artists: List = emptyList(), +) { + val artistRefs: List + get() = + artists.ifEmpty { + artistId + ?.takeIf { it.isNotBlank() } + ?.let { listOf(ArtistRef(it, artist.orEmpty())) } + .orEmpty() + } +} @Entity( tableName = "song_genres", @@ -291,6 +316,48 @@ data class SongGenre( val genre: String, ) +@Entity( + tableName = "album_artists", + primaryKeys = ["sourceId", "albumId", "position"], + foreignKeys = [ + ForeignKey( + entity = Album::class, + parentColumns = ["sourceId", "id"], + childColumns = ["sourceId", "albumId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("sourceId", "artistId")], +) +data class AlbumArtistRef( + val sourceId: Long, + val albumId: String, + val position: Long, + val artistId: String, + val name: String, +) + +@Entity( + tableName = "song_artists", + primaryKeys = ["sourceId", "songId", "position"], + foreignKeys = [ + ForeignKey( + entity = Song::class, + parentColumns = ["sourceId", "id"], + childColumns = ["sourceId", "songId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("sourceId", "artistId")], +) +data class SongArtistRef( + val sourceId: Long, + val songId: String, + val position: Long, + val artistId: String, + val name: String, +) + enum class PendingActionKind { Scrobble, Star, Unstar } @Entity( @@ -478,3 +545,31 @@ class PendingActionKindConverter { fun toPendingActionKind(value: String): PendingActionKind = PendingActionKind.entries.firstOrNull { it.name == value } ?: PendingActionKind.Scrobble } + +class ArtistRefsConverter { + @ColumnTypeConverter + fun fromArtistRefs(refs: List): String = ArtistRefsCodec.encode(refs) + + @ColumnTypeConverter + fun toArtistRefs(value: String): List = ArtistRefsCodec.decode(value) +} + +object ArtistRefsCodec { + fun encode(refs: List): String = refs.joinToString(RECORD) { "${it.id}$FIELD${it.name}" } + + fun decode(value: String): List = + value + .takeIf { it.isNotEmpty() } + ?.split(RECORD) + ?.map { entry -> + val separator = entry.indexOf(FIELD) + if (separator < 0) { + ArtistRef(entry, "") + } else { + ArtistRef(entry.substring(0, separator), entry.substring(separator + 1)) + } + }.orEmpty() + + private const val FIELD = "\u001F" + private const val RECORD = "\u001E" +} diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt index c902a6ef..b21cade2 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt @@ -103,7 +103,7 @@ class SubsonicSource( params = page("newest", offset), tag = "album", create = { SubsonicXml.albumDraft(id, it) }, - onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) }, + onChild = SubsonicXml::albumChild, ).collect { batch -> raw += batch.size val accepted = batch.filter { it.id.isNotEmpty() } @@ -323,7 +323,7 @@ class SubsonicSource( params: Map = emptyMap(), tag: String, create: (Attributes) -> T, - onChild: (T, String, Attributes) -> Unit = { _, _, _ -> }, + onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> }, ): Flow> = channelFlow { val buffer = ArrayList(PAGE_SIZE) 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 eaa31944..40c744b6 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 @@ -4,6 +4,7 @@ import com.subtracks.R import com.subtracks.UiMessage import com.subtracks.data.model.Album import com.subtracks.data.model.Artist +import com.subtracks.data.model.ArtistRef import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.Song @@ -82,10 +83,27 @@ object SubsonicXml { entity: SongDraft, name: String, attrs: Attributes, + level: Int, ) { when (name) { - "genres" -> entity.addGenre(attrs) - "replayGain" -> entity.addReplayGain(attrs) + "genres" -> if (level == 1) entity.addGenre(attrs) + "replayGain" -> if (level == 1) entity.addReplayGain(attrs) + "artists" -> if (level == 1) entity.addArtists(attrs) + "albumArtists" -> entity.clearNestedArtists() + "artist" -> if (level == 2) entity.addNestedArtist(attrs) + } + } + + internal fun albumChild( + entity: AlbumDraft, + name: String, + attrs: Attributes, + level: Int, + ) { + when (name) { + "discTitles" -> if (level == 1) entity.addDiscTitle(attrs) + "artists" -> if (level == 1) entity.addArtists(attrs) + "artist" -> if (level == 2) entity.addNestedArtist(attrs) } } @@ -126,7 +144,7 @@ object SubsonicXml { input: InputStream, entityTag: String, create: (Attributes) -> T, - onChild: (T, String, Attributes) -> Unit = { _, _, _ -> }, + onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> }, onEnd: (T) -> Unit, ) { parse(input, EntityHandler(entityTag, create, onChild, onEnd, stopAfterFirst = false)) @@ -136,7 +154,7 @@ object SubsonicXml { input: InputStream, entityTag: String, ): Boolean { - val handler = EntityHandler(entityTag, { Unit }, { _, _, _ -> }, { }, stopAfterFirst = true) + val handler = EntityHandler(entityTag, { Unit }, { _, _, _, _ -> }, { }, stopAfterFirst = true) parse(input, handler) return handler.found } @@ -156,12 +174,29 @@ internal class AlbumDraft( var songCount: Long = 0L, val discTitles: MutableMap = mutableMapOf(), ) { + private val artists = mutableListOf() + private var artistContainer = false + fun addDiscTitle(attrs: Attributes) { val disc = attrs.longAttr("disc") ?: return val title = attrs.attr("title").takeIf { it.isNotBlank() } ?: return discTitles[disc] = title } + fun addArtists(attrs: Attributes) { + val ref = attrs.artistRef() + if (ref == null) { + artistContainer = true + } else { + artists += ref + artistContainer = false + } + } + + fun addNestedArtist(attrs: Attributes) { + if (artistContainer) attrs.artistRef()?.let { artists += it } + } + fun toAlbum(): Album = Album( sourceId = sourceId, @@ -176,6 +211,7 @@ internal class AlbumDraft( starred = starred, songCount = songCount, discTitles = discTitles.toMap(), + artists = artistFallback(artists, artistId, albumArtist), ) } @@ -201,6 +237,9 @@ internal class SongDraft( private var albumPeak: Float? = null, private val genres: MutableList = mutableListOf(), ) { + private val artists = mutableListOf() + private var nestedArtists: MutableList? = null + fun addGenre(attrs: Attributes) { attrs .attr("name") @@ -216,6 +255,24 @@ internal class SongDraft( attrs.floatAttr("albumPeak")?.let { albumPeak = it } } + fun addArtists(attrs: Attributes) { + val ref = attrs.artistRef() + if (ref == null) { + nestedArtists = artists + } else { + artists += ref + nestedArtists = null + } + } + + fun clearNestedArtists() { + nestedArtists = null + } + + fun addNestedArtist(attrs: Attributes) { + nestedArtists?.let { list -> attrs.artistRef()?.let { list += it } } + } + fun toSong(): Song = Song( sourceId = sourceId, @@ -238,9 +295,28 @@ internal class SongDraft( trackPeak = trackPeak, albumPeak = albumPeak, genres = genres.toList(), + artists = artistFallback(artists, artistId, artist), ) } +private fun Attributes.artistRef(): ArtistRef? { + val id = attr("id") + val name = attr("name") + if (id.isEmpty() && name.isEmpty()) return null + return ArtistRef(id, name) +} + +private fun artistFallback( + parsed: List, + artistId: String?, + name: String?, +): List { + val refs = parsed.filter { it.id.isNotBlank() }.distinctBy { it.id } + if (refs.isNotEmpty()) return refs + if (artistId.isNullOrBlank()) return emptyList() + return listOf(ArtistRef(artistId, name.orEmpty())) +} + internal fun Attributes.attr(name: String): String = getValue(name) ?: "" internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull() @@ -311,7 +387,7 @@ private open class SubsonicResponseHandler : DefaultHandler() { private class EntityHandler( private val entityTag: String, private val create: (Attributes) -> T, - private val onChild: (T, String, Attributes) -> Unit, + private val onChild: (T, String, Attributes, Int) -> Unit, private val onEnd: (T) -> Unit, private val stopAfterFirst: Boolean, ) : SubsonicResponseHandler() { @@ -332,8 +408,8 @@ private class EntityHandler( current = create(attributes) entityDepth = depth } - } else if (depth == entityDepth + 1) { - onChild(active, name, attributes) + } else if (depth > entityDepth) { + onChild(active, name, attributes, depth - entityDepth) } } 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 3dd1cb57..cc828992 100644 --- a/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt +++ b/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt @@ -6,12 +6,16 @@ import androidx.room3.useWriterConnection import androidx.sqlite.SQLiteStatement import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.model.Album +import com.subtracks.data.model.AlbumArtistRef import com.subtracks.data.model.Artist +import com.subtracks.data.model.ArtistRef +import com.subtracks.data.model.ArtistRefsCodec import com.subtracks.data.model.Disc import com.subtracks.data.model.DiscKey import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.Song +import com.subtracks.data.model.SongArtistRef import com.subtracks.data.model.SongGenre import com.subtracks.data.source.MusicSource import java.util.Arrays @@ -96,6 +100,10 @@ class SyncService( batch.flatMap { song -> names.getValue(song.id).mapIndexed { index, genre -> SongGenre(song.sourceId, song.id, index.toLong(), genre) } } + val artists = + batch.flatMap { song -> + song.artistPositions().map { (position, ref) -> SongArtistRef(song.sourceId, song.id, position, ref.id, ref.name) } + } db.useWriterConnection { connection -> connection.immediateTransaction { connection.upsertChanged("songs", songColumns, primaryKey, batch) { it.values() } @@ -110,6 +118,17 @@ class SyncService( } } connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() } + connection.usePrepared("DELETE FROM song_artists WHERE sourceId = ? AND songId = ? AND position >= ?") { statement -> + batch.forEach { song -> + statement.bind(1, song.sourceId) + statement.bind(2, song.id) + statement.bind(3, song.artistPositions().size.toLong()) + statement.step() + statement.reset() + statement.clearBindings() + } + } + connection.upsertChanged("song_artists", songArtistColumns, songArtistKey, artists) { it.values() } } } } @@ -163,10 +182,25 @@ class SyncService( albums: List, discs: List, ) { + val artists = + albums.flatMap { album -> + album.artistPositions().map { (position, ref) -> AlbumArtistRef(album.sourceId, album.id, position, ref.id, ref.name) } + } db.useWriterConnection { connection -> connection.immediateTransaction { connection.upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() } connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() } + connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement -> + albums.forEach { album -> + statement.bind(1, album.sourceId) + statement.bind(2, album.id) + statement.bind(3, album.artistPositions().size.toLong()) + statement.step() + statement.reset() + statement.clearBindings() + } + } + connection.upsertChanged("album_artists", albumArtistColumns, albumArtistKey, artists) { it.values() } } } } @@ -292,6 +326,7 @@ private val albumColumns = "year", "starred", "songCount", + "artists", ) private val discKey = listOf("sourceId", "albumId", "disc") @@ -318,6 +353,7 @@ private val songColumns = "albumGain", "trackPeak", "albumPeak", + "artists", ) private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration") @@ -336,13 +372,39 @@ private val songGenreColumns = listOf("sourceId", "songId", "position", "genre") private val songGenreKey = listOf("sourceId", "songId", "position") +private val albumArtistColumns = listOf("sourceId", "albumId", "position", "artistId", "name") + +private val albumArtistKey = listOf("sourceId", "albumId", "position") + +private val songArtistColumns = listOf("sourceId", "songId", "position", "artistId", "name") + +private val songArtistKey = listOf("sourceId", "songId", "position") + private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position") private val playlistSongKey = listOf("sourceId", "playlistId", "position") private fun Artist.values() = listOf(sourceId, id, name, albumCount, starred, coverArt) -private fun Album.values() = listOf(sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount) +private fun Album.values() = + listOf( + sourceId, + id, + artistId, + name, + albumArtist, + created, + coverArt, + genre, + year, + starred, + songCount, + ArtistRefsCodec.encode(artistRefs), + ) + +private fun AlbumArtistRef.values() = listOf(sourceId, albumId, position, artistId, name) + +private fun SongArtistRef.values() = listOf(sourceId, songId, position, artistId, name) private fun Disc.values() = listOf(sourceId, albumId, disc, title) @@ -366,8 +428,18 @@ private fun Song.values() = albumGain, trackPeak, albumPeak, + ArtistRefsCodec.encode(artistRefs), ) +private fun List.positions(): List> = + filter { it.id.isNotBlank() } + .distinctBy { it.id } + .mapIndexed { index, ref -> index.toLong() to ref } + +private fun Album.artistPositions(): List> = artistRefs.positions() + +private fun Song.artistPositions(): List> = artistRefs.positions() + private fun SongGenre.values() = listOf(sourceId, songId, position, genre) private fun Playlist.values() = listOf(sourceId, id, name, comment, coverArt, songCount, created, changed, duration) diff --git a/app/src/main/kotlin/com/subtracks/ui/components/ArtistChooserSheet.kt b/app/src/main/kotlin/com/subtracks/ui/components/ArtistChooserSheet.kt new file mode 100644 index 00000000..4e8453f7 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/components/ArtistChooserSheet.kt @@ -0,0 +1,68 @@ +package com.subtracks.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.subtracks.R +import com.subtracks.data.model.ArtistRef +import kotlinx.coroutines.launch + +const val ARTIST_CHOOSER_TAG = "artistChooser" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ArtistChooserSheet( + artists: List, + onArtistClick: (ArtistRef) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + dragHandle = null, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.testTag(ARTIST_CHOOSER_TAG), + ) { + Text( + text = stringResource(R.string.artist_chooser_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp), + ) + artists.forEach { artist -> + ListItem( + headlineContent = { Text(artist.name.ifBlank { stringResource(R.string.resources_filter_artist) }) }, + leadingContent = { Icon(Icons.Rounded.Person, contentDescription = null) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = + Modifier.clickable { + scope.launch { + sheetState.hide() + onArtistClick(artist) + } + }, + ) + } + Spacer(Modifier.height(16.dp)) + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt b/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt index 41921066..958f3129 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt @@ -246,13 +246,13 @@ fun ItemContextMenu( } } } - target.song.artistId?.let { artistId -> + target.song.artistRefs.forEach { artist -> actions.viewArtist?.let { view -> MenuItem( Icons.Rounded.Person, - target.song.artist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist), + artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist), ) { - dismiss { view(artistId) } + dismiss { view(artist.id) } } } } @@ -269,13 +269,13 @@ fun ItemContextMenu( onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) }, onDismiss = { dismiss() }, ) - target.album.artistId?.let { artistId -> + target.album.artistRefs.forEach { artist -> actions.viewArtist?.let { view -> MenuItem( Icons.Rounded.Person, - target.album.albumArtist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist), + artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist), ) { - dismiss { view(artistId) } + dismiss { view(artist.id) } } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt index e2c49d52..99e7860b 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -36,6 +36,7 @@ import com.subtracks.data.model.Song import com.subtracks.data.model.SongDownload import com.subtracks.data.source.StarType import com.subtracks.playback.PlaybackController +import com.subtracks.ui.components.ArtistChooserSheet import com.subtracks.ui.components.CloseWhenDownloadsGone import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.DeleteDownloadsDialog @@ -75,7 +76,11 @@ fun AlbumDetailRoute( CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack) val scope = rememberCoroutineScope() var pendingDelete by remember { mutableStateOf(null) } - DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null } + var showArtistChooser by remember { mutableStateOf(false) } + DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { + pendingDelete = null + showArtistChooser = false + } val playback by playbackController.state.collectAsStateWithLifecycle() val context = playback.context val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } @@ -126,9 +131,13 @@ fun AlbumDetailRoute( }, onArtistClick = album - ?.takeIf { !it.albumArtist.isNullOrBlank() } - ?.let { it.artistId } - ?.let { id -> { onViewArtist(id) } }, + ?.takeIf { it.artistRefs.isNotEmpty() } + ?.let { + { + val artists = it.artistRefs + if (artists.size == 1) onViewArtist(artists.first().id) else showArtistChooser = true + } + }, playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId }, ) @@ -140,6 +149,17 @@ fun AlbumDetailRoute( onDismiss = { pendingDelete = null }, ) } + + if (showArtistChooser) { + ArtistChooserSheet( + artists = album?.artistRefs.orEmpty(), + onArtistClick = { artist -> + showArtistChooser = false + onViewArtist(artist.id) + }, + onDismiss = { showArtistChooser = false }, + ) + } } @Composable 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 386c6bb1..ec196937 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -187,7 +187,7 @@ fun ArtistDetailRoute( ArtistDetailScreen( artist = artist, artistName = artistName.orEmpty(), - isAlbumArtist = albums.any { it.artistId == artistId }, + isAlbumArtist = albums.any { album -> album.artistRefs.any { it.id == artistId } }, albums = albums, art = art, artThumbnail = artThumbnail, diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt index 9c02c20a..2e9db6bb 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -85,6 +85,7 @@ import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackState import com.subtracks.playback.QueueItem import com.subtracks.playback.RepeatMode +import com.subtracks.ui.components.ArtistChooserSheet import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.ItemActions @@ -203,6 +204,7 @@ fun NowPlayingRoute( val downloads by downloadRepository.states().collectAsStateWithLifecycle() val download = songId?.let { downloads[it] } val scope = rememberCoroutineScope() + var showArtistChooser by remember { mutableStateOf(false) } val song by produceState(null, sourceId, songId) { if (sourceId != null && songId != null) { @@ -272,13 +274,32 @@ fun NowPlayingRoute( onRepeat = controller::cycleRepeat, onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } }, onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } }, - onArtistClick = song?.artistId?.let { id -> { onViewArtist(id) } }, + onArtistClick = + song + ?.takeIf { it.artistRefs.isNotEmpty() } + ?.let { + { + val artists = it.artistRefs + if (artists.size == 1) onViewArtist(artists.first().id) else showArtistChooser = true + } + }, starred = song?.starred != null, onToggleStar = song?.let { s -> { setStar(StarType.Song, s.id, s.starred == null) } }, onSeek = controller::seekTo, keepScreenOn = keepScreenOn, modifier = modifier, ) + + if (showArtistChooser) { + ArtistChooserSheet( + artists = song?.artistRefs.orEmpty(), + onArtistClick = { artist -> + showArtistChooser = false + onViewArtist(artist.id) + }, + onDismiss = { showArtistChooser = false }, + ) + } } @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 435d69c8..d4129531 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -140,6 +140,7 @@ More options Open album Open artist + Artists Unknown artist Unknown album 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 a33750e2..1beb53c0 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 @@ -1,5 +1,6 @@ package com.subtracks.data.source.subsonic +import com.subtracks.data.model.ArtistRef import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -12,7 +13,7 @@ class SubsonicXmlTest { xml: String, tag: String, create: (Attributes) -> T, - onChild: (T, String, Attributes) -> Unit = { _, _, _ -> }, + onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> }, ): List { val result = mutableListOf() SubsonicXml.readEntities( @@ -30,7 +31,7 @@ class SubsonicXmlTest { xml, tag = "album", create = { SubsonicXml.albumDraft(1, it) }, - onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) }, + onChild = SubsonicXml::albumChild, ).map { it.toAlbum() } private fun songs(xml: String) = @@ -107,6 +108,114 @@ class SubsonicXmlTest { assertEquals(mapOf(1L to "The Calm", 2L to "The Storm"), albums.single().discTitles) } + @Test + fun mapsAlbumArtistsFromRepeatedOpenSubsonicRefs() { + val albums = + albums( + """ + + + + + + + + + """.trimIndent(), + ) + + assertEquals( + listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")), + albums.single().artists, + ) + } + + @Test + fun mapsNestedAlbumArtistRefs() { + val albums = + albums( + """ + + + + + + + + + + + """.trimIndent(), + ) + + assertEquals( + listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")), + albums.single().artists, + ) + } + + @Test + fun fallsBackToTheSingularAlbumArtist() { + val albums = + albums( + """ + + + + + + """.trimIndent(), + ) + + assertEquals(listOf(ArtistRef("ar1", "Radiohead")), albums.single().artists) + } + + @Test + fun mapsSongArtistsAndIgnoresAlbumArtists() { + val songs = + songs( + """ + + + + + + + + """.trimIndent(), + ) + + assertEquals( + listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")), + songs.single().artists, + ) + } + + @Test + fun mapsNestedSongArtistRefs() { + val songs = + songs( + """ + + + + + + + + + + + + """.trimIndent(), + ) + + assertEquals( + listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")), + songs.single().artists, + ) + } + @Test fun parsesTimestampsWithFractionsAndOffsets() { assertEquals(1789906657L, IsoDate.parse("2026-09-20T12:17:37.370827413Z")) @@ -138,6 +247,7 @@ class SubsonicXmlTest { assertEquals(1, songs.size) assertEquals("Idioteque", songs[0].title) assertEquals(300L, songs[0].duration) + assertEquals(listOf(ArtistRef("ar1", "Radiohead")), songs[0].artists) assertEquals(0L, entries[0].position) assertEquals("sg1", entries[0].songId) } diff --git a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt index 707faaf3..ee06e1e0 100644 --- a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt @@ -10,10 +10,14 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.model.Album import com.subtracks.data.model.Artist +import com.subtracks.data.model.ArtistRef import com.subtracks.data.model.DiscKey +import com.subtracks.data.model.DownloadStatus +import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.Song +import com.subtracks.data.model.SongDownload import com.subtracks.data.model.Source import com.subtracks.data.source.MusicSource import com.subtracks.data.source.StarType @@ -928,6 +932,121 @@ class SyncServiceTest { assertEquals(null, cleared.albumPeak) } + @Test + fun everyAlbumArtistAndTrackArtistReachesTheSharedAlbum() = + runTest { + insertSource() + val source = + FakeMusicSource( + artists = listOf(artist("a1", "A"), artist("a2", "B")), + albums = + listOf( + album("al1").copy( + artistId = "a1", + albumArtist = "A • B", + artists = listOf(ArtistRef("a1", "A"), ArtistRef("a2", "B")), + ), + ), + songs = + listOf( + song("s1").copy( + artistId = "a1", + artist = "A • B", + artists = listOf(ArtistRef("a1", "A"), ArtistRef("a2", "B")), + ), + ), + ) + + SyncService(db, source).sync() + + assertEquals( + listOf("al1"), + db + .libraryDao() + .albumsForArtist(1, "a1") + .first() + .map { it.id }, + ) + assertEquals( + listOf("al1"), + db + .libraryDao() + .albumsForArtist(1, "a2") + .first() + .map { it.id }, + ) + assertEquals(listOf("s1"), db.queueDao().artistSongIds(1, "a1")) + assertEquals(listOf("s1"), db.queueDao().artistSongIds(1, "a2")) + assertEquals(1L, db.queueDao().artistLength(1, "a2")) + assertEquals("B", db.libraryDao().artistName(1, "a2").first()) + + source.albums = listOf(album("al1").copy(artistId = "a1", artists = listOf(ArtistRef("a1", "A")))) + source.songs = listOf(song("s1").copy(artistId = "a1", artists = listOf(ArtistRef("a1", "A")))) + + SyncService(db, source).sync() + + assertEquals( + emptyList(), + db + .libraryDao() + .albumsForArtist(1, "a2") + .first() + .map { it.id }, + ) + assertEquals(emptyList(), db.queueDao().artistSongIds(1, "a2")) + } + + @Test + fun anIdenticalSyncDoesNotRewriteArtistRows() = + runTest { + insertSource() + val source = + FakeMusicSource( + albums = + listOf( + album("al1").copy(artists = listOf(ArtistRef("a1", "Artist"), ArtistRef("a2", "Other"))), + ), + songs = + listOf( + song("s1").copy(artists = listOf(ArtistRef("a1", "Artist"), ArtistRef("a2", "Other"))), + ), + ) + + SyncService(db, source).sync() + val before = totalChanges() + + SyncService(db, source).sync() + + assertEquals(before, totalChanges()) + } + + @Test + fun theDownloadedArtistFilterIncludesEveryAlbumArtist() = + runTest { + insertSource() + val source = + FakeMusicSource( + artists = listOf(artist("a1", "A"), artist("a2", "B")), + albums = + listOf( + album("al1").copy( + artistId = "a1", + albumArtist = "A • B", + artists = listOf(ArtistRef("a1", "A"), ArtistRef("a2", "B")), + ), + ), + songs = listOf(song("s1").copy(artistId = "a1")), + ) + + SyncService(db, source).sync() + db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed)) + + val filtered = db.libraryDao().artistsByName(1, 0, "", downloadedFilter = 1).allRows() + assertEquals(listOf("A", "B"), filtered.map { it.name }) + assertEquals(listOf(1L, 1L), filtered.map { it.albumCount }) + assertEquals(ListDownloadStatus(total = 1, downloaded = 1), db.downloadDao().artistStatus(1, "a2").first()) + } + private suspend fun totalChanges(): Long = db.useWriterConnection { connection -> connection.usePrepared("SELECT total_changes()") { statement -> diff --git a/app/src/test/kotlin/com/subtracks/ui/DemoScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/DemoScreenshotTest.kt index 4db39e20..3ce54ea0 100644 --- a/app/src/test/kotlin/com/subtracks/ui/DemoScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/DemoScreenshotTest.kt @@ -177,7 +177,7 @@ class DemoScreenshotTest { ArtistDetailScreen( artist = demo.artist, artistName = demo.artist.name, - isAlbumArtist = demo.artistAlbums.any { it.artistId == demo.artist.id }, + isAlbumArtist = demo.artistAlbums.any { album -> album.artistRefs.any { it.id == demo.artist.id } }, albums = demo.artistAlbums, art = demo.coverArt(demo.artist.coverArt, false), artThumbnail = demo.coverArt(demo.artist.coverArt, true), diff --git a/app/src/test/kotlin/com/subtracks/ui/components/ArtistChooserSheetTest.kt b/app/src/test/kotlin/com/subtracks/ui/components/ArtistChooserSheetTest.kt new file mode 100644 index 00000000..68786571 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/components/ArtistChooserSheetTest.kt @@ -0,0 +1,46 @@ +package com.subtracks.ui.components + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.model.ArtistRef +import com.subtracks.ui.theme.SubtracksTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ArtistChooserSheetTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun listsEveryArtistAndOpensTheChosenOne() { + var opened: ArtistRef? = null + composeRule.setContent { + SubtracksTheme { + ArtistChooserSheet( + artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")), + onArtistClick = { opened = it }, + onDismiss = {}, + ) + } + } + + composeRule.onNodeWithText("Artists").assertIsDisplayed() + composeRule.onNodeWithText("A").assertIsDisplayed() + composeRule.onNodeWithText("B").assertIsDisplayed() + + composeRule.mainClock.autoAdvance = false + composeRule.onNodeWithText("B").performClick() + composeRule.mainClock.advanceTimeBy(1_000) + + assertEquals(ArtistRef("ar2", "B"), opened) + } +} diff --git a/app/src/test/kotlin/com/subtracks/ui/components/ItemContextMenuArtistTest.kt b/app/src/test/kotlin/com/subtracks/ui/components/ItemContextMenuArtistTest.kt new file mode 100644 index 00000000..dcbbe245 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/components/ItemContextMenuArtistTest.kt @@ -0,0 +1,70 @@ +package com.subtracks.ui.components + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.model.ArtistRef +import com.subtracks.data.model.Song +import com.subtracks.ui.theme.SubtracksTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ItemContextMenuArtistTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private val song = + Song( + sourceId = 1, + id = "s1", + albumId = "al1", + artistId = "ar1", + title = "Song", + album = "Album", + artist = "A • B", + duration = 100, + track = 1, + disc = 1, + starred = null, + genre = null, + artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")), + ) + + @Test + fun showsOneEntryPerArtistAndOpensTheChosenOne() { + var opened: String? = null + composeRule.setContent { + SubtracksTheme { + var show by remember { mutableStateOf(true) } + if (show) { + ItemContextMenu( + target = MenuTarget.Song(song), + actions = ItemActions(viewArtist = { opened = it }), + onDismiss = { show = false }, + ) + } + } + } + + composeRule.onNodeWithText("A").assertIsDisplayed() + composeRule.onNodeWithText("B").assertIsDisplayed() + + composeRule.mainClock.autoAdvance = false + composeRule.onNodeWithText("B").performClick() + composeRule.mainClock.advanceTimeBy(1_000) + + assertEquals("ar2", opened) + } +}