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