diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/31.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/31.json new file mode 100644 index 00000000..d7b65573 --- /dev/null +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/31.json @@ -0,0 +1,1884 @@ +{ + "formatVersion": 1, + "database": { + "version": 31, + "identityHash": "d93ecfd2b4bfb29ae8407bce59522a12", + "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, `songCount` INTEGER NOT NULL DEFAULT 0, `sortName` TEXT NOT NULL COLLATE NOCASE DEFAULT '', PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "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" + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_artists_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_artists_name", + "unique": false, + "columnNames": [ + "sourceId", + "sortName", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `${TABLE_NAME}` (`sourceId`, `sortName`, `id`)" + }, + { + "name": "index_artists_albumCount", + "unique": false, + "columnNames": [ + "sourceId", + "albumCount", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_albumCount` ON `${TABLE_NAME}` (`sourceId` ASC, `albumCount` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_artists_starred", + "unique": false, + "columnNames": [ + "sourceId", + "starred", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_artists_frequent", + "unique": false, + "columnNames": [ + "sourceId", + "playCount", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_frequent` ON `${TABLE_NAME}` (`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_artists_recent", + "unique": false, + "columnNames": [ + "sourceId", + "played", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_recent` ON `${TABLE_NAME}` (`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "artist_search", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`name`, `sortName`, tokenize=`trigram`, content=`artists`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowid" + ] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "trigram", + "tokenizerArgs": [], + "contentTable": "artists", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE BEFORE UPDATE ON `artists` BEGIN DELETE FROM `artist_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_BEFORE_DELETE BEFORE DELETE ON `artists` BEGIN DELETE FROM `artist_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE AFTER UPDATE ON `artists` BEGIN INSERT INTO `artist_search`(`rowid`, `name`, `sortName`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`sortName`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_AFTER_INSERT AFTER INSERT ON `artists` BEGIN INSERT INTO `artist_search`(`rowid`, `name`, `sortName`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`sortName`); END" + ] + }, + { + "tableName": "albums", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `artistId` TEXT, `name` TEXT NOT NULL COLLATE NOCASE, `albumArtist` TEXT COLLATE NOCASE, `created` INTEGER NOT NULL, `coverArt` TEXT, `genre` TEXT, `year` INTEGER, `starred` INTEGER, `songCount` INTEGER NOT NULL, `playCount` INTEGER NOT NULL DEFAULT 0, `played` INTEGER, `artists` TEXT NOT NULL DEFAULT '', `sortName` TEXT NOT NULL COLLATE NOCASE DEFAULT '', `albumArtistSort` TEXT NOT NULL COLLATE NOCASE DEFAULT '', PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "albumArtist", + "affinity": "TEXT" + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverArt", + "columnName": "coverArt", + "affinity": "TEXT" + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER" + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "playCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "played", + "columnName": "played", + "affinity": "INTEGER" + }, + { + "fieldPath": "artists", + "columnName": "artists", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "albumArtistSort", + "columnName": "albumArtistSort", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_albums_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_albums_sourceId_artistId", + "unique": false, + "columnNames": [ + "sourceId", + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" + }, + { + "name": "index_albums_name", + "unique": false, + "columnNames": [ + "sourceId", + "sortName", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_name` ON `${TABLE_NAME}` (`sourceId`, `sortName`, `id`)" + }, + { + "name": "index_albums_artist", + "unique": false, + "columnNames": [ + "sourceId", + "albumArtistSort", + "year", + "sortName", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist` ON `${TABLE_NAME}` (`sourceId`, `albumArtistSort`, `year`, `sortName`, `id`)" + }, + { + "name": "index_albums_year", + "unique": false, + "columnNames": [ + "sourceId", + "year", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_year` ON `${TABLE_NAME}` (`sourceId` ASC, `year` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_albums_added", + "unique": false, + "columnNames": [ + "sourceId", + "created", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_added` ON `${TABLE_NAME}` (`sourceId` ASC, `created` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_albums_starred", + "unique": false, + "columnNames": [ + "sourceId", + "starred", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_albums_frequent", + "unique": false, + "columnNames": [ + "sourceId", + "playCount", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_frequent` ON `${TABLE_NAME}` (`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_albums_recent", + "unique": false, + "columnNames": [ + "sourceId", + "played", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_recent` ON `${TABLE_NAME}` (`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "album_search", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`name`, `albumArtist`, `sortName`, `albumArtistSort`, tokenize=`trigram`, content=`albums`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "albumArtist", + "affinity": "TEXT" + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtistSort", + "columnName": "albumArtistSort", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowid" + ] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "trigram", + "tokenizerArgs": [], + "contentTable": "albums", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_album_search_BEFORE_UPDATE BEFORE UPDATE ON `albums` BEGIN DELETE FROM `album_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_album_search_BEFORE_DELETE BEFORE DELETE ON `albums` BEGIN DELETE FROM `album_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_album_search_AFTER_UPDATE AFTER UPDATE ON `albums` BEGIN INSERT INTO `album_search`(`rowid`, `name`, `albumArtist`, `sortName`, `albumArtistSort`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`albumArtist`, NEW.`sortName`, NEW.`albumArtistSort`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_album_search_AFTER_INSERT AFTER INSERT ON `albums` BEGIN INSERT INTO `album_search`(`rowid`, `name`, `albumArtist`, `sortName`, `albumArtistSort`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`albumArtist`, NEW.`sortName`, NEW.`albumArtistSort`); END" + ] + }, + { + "tableName": "discs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `disc` INTEGER NOT NULL, `title` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `albumId`, `disc`), FOREIGN KEY(`sourceId`, `albumId`) REFERENCES `albums`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disc", + "columnName": "disc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "albumId", + "disc" + ] + }, + "indices": [ + { + "name": "index_discs_sourceId_albumId", + "unique": false, + "columnNames": [ + "sourceId", + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discs_sourceId_albumId` ON `${TABLE_NAME}` (`sourceId`, `albumId`)" + } + ], + "foreignKeys": [ + { + "table": "albums", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId", + "albumId" + ], + "referencedColumns": [ + "sourceId", + "id" + ] + } + ] + }, + { + "tableName": "album_artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `albumId`, `position`), FOREIGN KEY(`sourceId`, `albumId`) REFERENCES `albums`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "albumId", + "position" + ] + }, + "indices": [ + { + "name": "index_album_artists_sourceId_artistId", + "unique": false, + "columnNames": [ + "sourceId", + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artists_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" + } + ], + "foreignKeys": [ + { + "table": "albums", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId", + "albumId" + ], + "referencedColumns": [ + "sourceId", + "id" + ] + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL COLLATE NOCASE, `comment` TEXT, `coverArt` TEXT, `songCount` INTEGER NOT NULL, `created` INTEGER NOT NULL, `changed` INTEGER NOT NULL DEFAULT 0, `duration` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT" + }, + { + "fieldPath": "coverArt", + "columnName": "coverArt", + "affinity": "TEXT" + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "changed", + "columnName": "changed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_playlists_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_playlists_name", + "unique": false, + "columnNames": [ + "sourceId", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`sourceId`, `name`, `id`)" + }, + { + "name": "index_playlists_added", + "unique": false, + "columnNames": [ + "sourceId", + "created", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_added` ON `${TABLE_NAME}` (`sourceId` ASC, `created` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_playlists_updated", + "unique": false, + "columnNames": [ + "sourceId", + "changed", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_updated` ON `${TABLE_NAME}` (`sourceId` ASC, `changed` DESC, `name` ASC, `id` ASC)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_search", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`name`, tokenize=`trigram`, content=`playlists`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowid" + ] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "trigram", + "tokenizerArgs": [], + "contentTable": "playlists", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_playlist_search_BEFORE_UPDATE BEFORE UPDATE ON `playlists` BEGIN DELETE FROM `playlist_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_playlist_search_BEFORE_DELETE BEFORE DELETE ON `playlists` BEGIN DELETE FROM `playlist_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_playlist_search_AFTER_UPDATE AFTER UPDATE ON `playlists` BEGIN INSERT INTO `playlist_search`(`rowid`, `name`) VALUES (NEW.`rowid`, NEW.`name`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_playlist_search_AFTER_INSERT AFTER INSERT ON `playlists` BEGIN INSERT INTO `playlist_search`(`rowid`, `name`) VALUES (NEW.`rowid`, NEW.`name`); END" + ] + }, + { + "tableName": "playlist_songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`sourceId`, `playlistId`, `position`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "playlistId", + "position" + ] + }, + "indices": [ + { + "name": "index_playlist_songs_sourceId_playlistId", + "unique": false, + "columnNames": [ + "sourceId", + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_playlistId` ON `${TABLE_NAME}` (`sourceId`, `playlistId`)" + }, + { + "name": "index_playlist_songs_sourceId_songId", + "unique": false, + "columnNames": [ + "sourceId", + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_songId` ON `${TABLE_NAME}` (`sourceId`, `songId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `albumId` TEXT, `artistId` TEXT, `title` TEXT NOT NULL, `album` TEXT, `artist` TEXT, `duration` INTEGER, `track` INTEGER, `disc` INTEGER, `starred` INTEGER, `created` INTEGER NOT NULL DEFAULT 0, `playCount` INTEGER NOT NULL DEFAULT 0, `played` INTEGER, `trackGain` REAL, `albumGain` REAL, `trackPeak` REAL, `albumPeak` REAL, `artists` TEXT NOT NULL DEFAULT '', `sortName` TEXT NOT NULL COLLATE NOCASE DEFAULT '', `artistSort` TEXT NOT NULL COLLATE NOCASE DEFAULT '', PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT" + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT" + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER" + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER" + }, + { + "fieldPath": "disc", + "columnName": "disc", + "affinity": "INTEGER" + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER" + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "playCount", + "columnName": "playCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "played", + "columnName": "played", + "affinity": "INTEGER" + }, + { + "fieldPath": "trackGain", + "columnName": "trackGain", + "affinity": "REAL" + }, + { + "fieldPath": "albumGain", + "columnName": "albumGain", + "affinity": "REAL" + }, + { + "fieldPath": "trackPeak", + "columnName": "trackPeak", + "affinity": "REAL" + }, + { + "fieldPath": "albumPeak", + "columnName": "albumPeak", + "affinity": "REAL" + }, + { + "fieldPath": "artists", + "columnName": "artists", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "artistSort", + "columnName": "artistSort", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_songs_sourceId_artistId", + "unique": false, + "columnNames": [ + "sourceId", + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" + }, + { + "name": "index_songs_sourceId_albumId_order", + "unique": false, + "columnNames": [ + "sourceId", + "albumId", + "disc", + "track", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_albumId_order` ON `${TABLE_NAME}` (`sourceId`, `albumId`, `disc`, `track`, `id`)" + }, + { + "name": "index_songs_starred", + "unique": false, + "columnNames": [ + "sourceId", + "starred", + "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`, `sortName`, `artistSort`, tokenize=`trigram`, content=`songs`)", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT" + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistSort", + "columnName": "artistSort", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowid" + ] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "trigram", + "tokenizerArgs": [], + "contentTable": "songs", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_song_search_BEFORE_UPDATE BEFORE UPDATE ON `songs` BEGIN DELETE FROM `song_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_song_search_BEFORE_DELETE BEFORE DELETE ON `songs` BEGIN DELETE FROM `song_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_song_search_AFTER_UPDATE AFTER UPDATE ON `songs` BEGIN INSERT INTO `song_search`(`rowid`, `title`, `artist`, `sortName`, `artistSort`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`artist`, NEW.`sortName`, NEW.`artistSort`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_song_search_AFTER_INSERT AFTER INSERT ON `songs` BEGIN INSERT INTO `song_search`(`rowid`, `title`, `artist`, `sortName`, `artistSort`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`artist`, NEW.`sortName`, NEW.`artistSort`); END" + ] + }, + { + "tableName": "queue_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `position` INTEGER NOT NULL, `sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, `refId` TEXT NOT NULL, `rangeStart` INTEGER, `rangeEnd` INTEGER, FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refId", + "columnName": "refId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rangeStart", + "columnName": "rangeStart", + "affinity": "INTEGER" + }, + { + "fieldPath": "rangeEnd", + "columnName": "rangeEnd", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_queue_entries_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_queue_entries_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_queue_entries_position", + "unique": false, + "columnNames": [ + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_queue_entries_position` ON `${TABLE_NAME}` (`position`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "up_next_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `position` INTEGER NOT NULL, `sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, `refId` TEXT NOT NULL, `rangeStart` INTEGER, `rangeEnd` INTEGER, FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refId", + "columnName": "refId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rangeStart", + "columnName": "rangeStart", + "affinity": "INTEGER" + }, + { + "fieldPath": "rangeEnd", + "columnName": "rangeEnd", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_up_next_entries_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_up_next_entries_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_up_next_entries_position", + "unique": false, + "columnNames": [ + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_up_next_entries_position` ON `${TABLE_NAME}` (`position`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playback_cursor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `queuePosition` INTEGER NOT NULL, `shuffleEnabled` INTEGER NOT NULL DEFAULT 0, `repeatMode` INTEGER NOT NULL DEFAULT 0, `shuffleSeed` INTEGER NOT NULL DEFAULT 0, `shuffleSize` INTEGER NOT NULL DEFAULT 0, `positionMs` INTEGER NOT NULL DEFAULT 0, `upNextAnchor` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "queuePosition", + "columnName": "queuePosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shuffleEnabled", + "columnName": "shuffleEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "repeatMode", + "columnName": "repeatMode", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "shuffleSeed", + "columnName": "shuffleSeed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "shuffleSize", + "columnName": "shuffleSize", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "positionMs", + "columnName": "positionMs", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "upNextAnchor", + "columnName": "upNextAnchor", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "artwork_seeds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cacheKey` TEXT NOT NULL, `primary` INTEGER NOT NULL, `secondary` INTEGER, `nameBusy` INTEGER, `sourceId` INTEGER NOT NULL, `storedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`cacheKey`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "cacheKey", + "columnName": "cacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primary", + "columnName": "primary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "secondary", + "columnName": "secondary", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameBusy", + "columnName": "nameBusy", + "affinity": "INTEGER" + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "storedAt", + "columnName": "storedAt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "cacheKey" + ] + }, + "indices": [ + { + "name": "index_artwork_seeds_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artwork_seeds_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "song_downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, `status` TEXT NOT NULL, `engineId` INTEGER, `bytes` INTEGER NOT NULL DEFAULT 0, `total` INTEGER NOT NULL DEFAULT 0, `error` TEXT, `downloadedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `songId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`sourceId`, `songId`) REFERENCES `songs`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "engineId", + "columnName": "engineId", + "affinity": "INTEGER" + }, + { + "fieldPath": "bytes", + "columnName": "bytes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "total", + "columnName": "total", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadedAt", + "columnName": "downloadedAt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "songId" + ] + }, + "indices": [ + { + "name": "index_song_downloads_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_downloads_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_song_downloads_engineId", + "unique": false, + "columnNames": [ + "engineId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_downloads_engineId` ON `${TABLE_NAME}` (`engineId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "songs", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId", + "songId" + ], + "referencedColumns": [ + "sourceId", + "id" + ] + } + ] + }, + { + "tableName": "pending_actions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, `targetId` TEXT NOT NULL, `starType` TEXT, `time` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetId", + "columnName": "targetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starType", + "columnName": "starType", + "affinity": "TEXT" + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_pending_actions_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_actions_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd93ecfd2b4bfb29ae8407bce59522a12')" + ] + } +} \ No newline at end of file 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 76833f0c..326df3b0 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -44,26 +44,29 @@ internal const val ALBUMS_FILTER = "OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM album_search " + "WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " + "OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " + - "OR instr(lower(albumArtist), lower(:search)) > 0))) " + "OR instr(lower(albumArtist), lower(:search)) > 0 " + + "OR instr(lower(sortName), lower(:search)) > 0 " + + "OR instr(lower(albumArtistSort), lower(:search)) > 0))) " -internal const val ALBUM_ORDER_BY_NAME = "name COLLATE NOCASE, id" -internal const val ALBUM_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC" -internal const val ALBUM_ORDER_BY_ARTIST = "albumArtist COLLATE NOCASE, year, name COLLATE NOCASE, id" -internal const val ALBUM_ORDER_BY_ARTIST_REVERSED = "albumArtist COLLATE NOCASE DESC, year DESC, name COLLATE NOCASE DESC, id DESC" -internal const val ALBUM_ORDER_BY_YEAR = "year DESC, name COLLATE NOCASE, id" -internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, name COLLATE NOCASE DESC, id DESC" -internal const val ALBUM_ORDER_BY_ADDED = "created DESC, name COLLATE NOCASE, id" -internal const val ALBUM_ORDER_BY_ADDED_REVERSED = "created ASC, name COLLATE NOCASE DESC, id DESC" +internal const val ALBUM_ORDER_BY_NAME = "sortName COLLATE NOCASE, id" +internal const val ALBUM_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC" +internal const val ALBUM_ORDER_BY_ARTIST = "albumArtistSort COLLATE NOCASE, year, sortName COLLATE NOCASE, id" +internal const val ALBUM_ORDER_BY_ARTIST_REVERSED = + "albumArtistSort COLLATE NOCASE DESC, year DESC, sortName COLLATE NOCASE DESC, id DESC" +internal const val ALBUM_ORDER_BY_YEAR = "year DESC, sortName COLLATE NOCASE, id" +internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, sortName COLLATE NOCASE DESC, id DESC" +internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id" +internal const val ALBUM_ORDER_BY_ADDED_REVERSED = "created ASC, sortName COLLATE NOCASE DESC, id DESC" // Starred orders keep unstarred rows last in both directions, matching the list grouping, so the // reversed order is not a literal mirror of the base order. -internal const val ALBUM_ORDER_BY_STARRED = "starred DESC NULLS LAST, name COLLATE NOCASE, id" -internal const val ALBUM_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC" +internal const val ALBUM_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id" +internal const val ALBUM_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC" -internal const val ALBUM_ORDER_BY_FREQUENT = "playCount DESC, name COLLATE NOCASE, id" -internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, name COLLATE NOCASE DESC, id DESC" -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 ALBUM_ORDER_BY_FREQUENT = "playCount DESC, sortName COLLATE NOCASE, id" +internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, sortName COLLATE NOCASE DESC, id DESC" +internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, sortName COLLATE NOCASE, id" +internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC" private const val DOWNLOADED_ARTIST_ALBUMS = "SELECT albums.id FROM albums " + @@ -99,7 +102,8 @@ internal const val ARTISTS_FILTER = "AND (:search = '' " + "OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " + "WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " + - "OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)) " + "OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " + + "OR instr(lower(sortName), lower(:search)) > 0))) " // With the Downloaded filter on, count the albums that actually have a download so the number under // an artist matches the albums its detail screen lists. @@ -108,18 +112,18 @@ internal const val ARTISTS_SELECT = "CASE WHEN :downloadedFilter = 1 THEN (" + "SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" + ") ELSE artists.albumCount END AS albumCount, " + - "artists.starred, artists.coverArt, artists.playCount, artists.played, artists.songCount " + "artists.starred, artists.coverArt, artists.playCount, artists.played, artists.songCount, artists.sortName " -internal const val ARTIST_ORDER_BY_NAME = "name COLLATE NOCASE, id" -internal const val ARTIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC" -internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "artists.albumCount DESC, name COLLATE NOCASE, id" -internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "artists.albumCount ASC, name COLLATE NOCASE DESC, id DESC" -internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, name COLLATE NOCASE, id" -internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC" -internal const val ARTIST_ORDER_BY_FREQUENT = "artists.playCount DESC, name COLLATE NOCASE, id" -internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, name COLLATE NOCASE DESC, id DESC" -internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, name COLLATE NOCASE, id" -internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC" +internal const val ARTIST_ORDER_BY_NAME = "sortName COLLATE NOCASE, id" +internal const val ARTIST_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC" +internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "artists.albumCount DESC, sortName COLLATE NOCASE, id" +internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "artists.albumCount ASC, sortName COLLATE NOCASE DESC, id DESC" +internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id" +internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC" +internal const val ARTIST_ORDER_BY_FREQUENT = "artists.playCount DESC, sortName COLLATE NOCASE, id" +internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, sortName COLLATE NOCASE DESC, id DESC" +internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, sortName COLLATE NOCASE, id" +internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC" internal const val PLAYLISTS_FILTER = "FROM playlists WHERE sourceId = :sourceId " + @@ -313,7 +317,7 @@ interface LibraryDao { @Query( "SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL " + - "ORDER BY played DESC, name COLLATE NOCASE, id LIMIT :limit", + "ORDER BY played DESC, sortName COLLATE NOCASE, id LIMIT :limit", ) fun recentlyPlayedAlbums( sourceId: Long, @@ -322,7 +326,7 @@ interface LibraryDao { @Query( "SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL " + - "ORDER BY played DESC, name COLLATE NOCASE, id LIMIT :limit", + "ORDER BY played DESC, sortName COLLATE NOCASE, id LIMIT :limit", ) fun recentlyPlayedArtists( sourceId: Long, @@ -331,7 +335,7 @@ interface LibraryDao { @Query( "SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 " + - "ORDER BY playCount DESC, name COLLATE NOCASE, id LIMIT :limit", + "ORDER BY playCount DESC, sortName COLLATE NOCASE, id LIMIT :limit", ) fun mostPlayedAlbums( sourceId: Long, @@ -340,7 +344,7 @@ interface LibraryDao { @Query( "SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 " + - "ORDER BY playCount DESC, name COLLATE NOCASE, id LIMIT :limit", + "ORDER BY playCount DESC, sortName COLLATE NOCASE, id LIMIT :limit", ) fun mostPlayedArtists( sourceId: Long, @@ -349,7 +353,7 @@ interface LibraryDao { @Query( "SELECT * FROM albums WHERE sourceId = :sourceId " + - "ORDER BY created DESC, name COLLATE NOCASE, id LIMIT :limit", + "ORDER BY created DESC, sortName COLLATE NOCASE, id LIMIT :limit", ) fun recentlyAddedAlbums( sourceId: Long, @@ -358,7 +362,7 @@ interface LibraryDao { @Query( "SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL AND played < :cutoff " + - "ORDER BY played ASC, name COLLATE NOCASE, id LIMIT :limit", + "ORDER BY played ASC, sortName COLLATE NOCASE, id LIMIT :limit", ) fun rediscoverAlbums( sourceId: Long, @@ -370,7 +374,7 @@ interface LibraryDao { "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " + - "ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit", + "ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit", ) fun recentlyStarredSongs( sourceId: Long, @@ -379,7 +383,7 @@ interface LibraryDao { @Query( "SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " + - "ORDER BY name COLLATE NOCASE, id LIMIT :limit", + "ORDER BY sortName COLLATE NOCASE, id LIMIT :limit", ) fun searchAlbums( sourceId: Long, @@ -389,7 +393,7 @@ interface LibraryDao { @Query( "SELECT * FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER " + - "ORDER BY name COLLATE NOCASE, id LIMIT :limit", + "ORDER BY sortName COLLATE NOCASE, id LIMIT :limit", ) fun searchArtists( sourceId: Long, @@ -411,7 +415,7 @@ interface LibraryDao { "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "WHERE songs.sourceId = :sourceId AND $SONG_SEARCH_FILTER " + - "ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit", + "ORDER BY songs.sortName COLLATE NOCASE, songs.id LIMIT :limit", ) fun searchSongs( sourceId: Long, @@ -790,32 +794,32 @@ interface LibraryDao { downloadedFilter: Int = 0, ): PagingSource - @Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, name COLLATE NOCASE, id") + @Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, sortName COLLATE NOCASE, id") fun homeRecentlyPlayedAlbums(sourceId: Long): PagingSource - @Query("SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, name COLLATE NOCASE, id") + @Query("SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, sortName COLLATE NOCASE, id") fun homeMostPlayedAlbums(sourceId: Long): PagingSource - @Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE, id") + @Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, sortName COLLATE NOCASE, id") fun homeRecentlyAddedAlbums(sourceId: Long): PagingSource - @Query("SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, name COLLATE NOCASE, id") + @Query("SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, sortName COLLATE NOCASE, id") fun homeRecentlyPlayedArtists(sourceId: Long): PagingSource - @Query("SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, name COLLATE NOCASE, id") + @Query("SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, sortName COLLATE NOCASE, id") fun homeMostPlayedArtists(sourceId: Long): PagingSource @Query( "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " + - "ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id", + "ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id", ) fun starredSongs(sourceId: Long): PagingSource @Query( "SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " + - "ORDER BY starred DESC, title COLLATE NOCASE, id", + "ORDER BY starred DESC, sortName COLLATE NOCASE, id", ) suspend fun starredSongIds(sourceId: Long): List @@ -824,7 +828,7 @@ interface LibraryDao { "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " + "WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " + - "ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit", + "ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit", ) fun downloadedSongs( sourceId: Long, @@ -835,7 +839,7 @@ interface LibraryDao { "SELECT songs.id FROM songs " + "JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " + "WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " + - "ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id", + "ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id", ) suspend fun downloadedSongIds(sourceId: Long): List @@ -844,7 +848,7 @@ interface LibraryDao { "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " + "WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " + - "ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id", + "ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id", ) fun homeDownloadedSongs(sourceId: Long): PagingSource @@ -854,7 +858,7 @@ interface LibraryDao { "JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " + "WHERE albums.sourceId = :sourceId " + "GROUP BY albums.sourceId, albums.id " + - "ORDER BY MAX(d.downloadedAt) DESC, albums.name COLLATE NOCASE, albums.id", + "ORDER BY MAX(d.downloadedAt) DESC, albums.sortName COLLATE NOCASE, albums.id", ) fun downloadedAlbums(sourceId: Long): PagingSource @@ -863,7 +867,7 @@ interface LibraryDao { "JOIN (SELECT id, MAX(downloadedAt) AS lastDownloadedAt FROM ($DOWNLOADED_ARTIST_ROWS) " + "WHERE status = 'Completed' GROUP BY id) dl ON dl.id = artists.id " + "WHERE artists.sourceId = :sourceId " + - "ORDER BY dl.lastDownloadedAt DESC, artists.name COLLATE NOCASE, artists.id", + "ORDER BY dl.lastDownloadedAt DESC, artists.sortName COLLATE NOCASE, artists.id", ) fun downloadedArtists(sourceId: Long): PagingSource @@ -872,7 +876,7 @@ interface LibraryDao { "JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " + - "ORDER BY songs.title COLLATE NOCASE, songs.id", + "ORDER BY songs.sortName COLLATE NOCASE, songs.id", ) fun songsByGenre( sourceId: Long, @@ -883,7 +887,7 @@ interface LibraryDao { "SELECT songs.id FROM song_genres " + "JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " + "WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " + - "ORDER BY songs.title COLLATE NOCASE, songs.id", + "ORDER BY songs.sortName COLLATE NOCASE, songs.id", ) suspend fun songsByGenreIds( sourceId: Long, @@ -894,12 +898,12 @@ interface LibraryDao { "SELECT COUNT(*) FROM song_genres " + "JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " + "WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " + - "AND (songs.title COLLATE NOCASE < :title OR (songs.title COLLATE NOCASE = :title AND songs.id < :id))", + "AND (songs.sortName COLLATE NOCASE < :sortName OR (songs.sortName COLLATE NOCASE = :sortName AND songs.id < :id))", ) suspend fun genreSongOrdinal( sourceId: Long, genre: String, - title: String, + sortName: String, id: String, ): Long @@ -909,8 +913,8 @@ interface LibraryDao { "WHERE d.sourceId = :sourceId AND d.status = 'Completed' " + "AND (d.downloadedAt > (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " + "OR (d.downloadedAt = (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " + - "AND (s.title COLLATE NOCASE < (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) " + - "OR (s.title COLLATE NOCASE = (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))", + "AND (s.sortName COLLATE NOCASE < (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) " + + "OR (s.sortName COLLATE NOCASE = (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))", ) suspend fun downloadedSongOrdinal( sourceId: Long, @@ -922,8 +926,8 @@ interface LibraryDao { "WHERE s.sourceId = :sourceId AND s.starred IS NOT NULL " + "AND (s.starred > (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " + "OR (s.starred = (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " + - "AND (s.title COLLATE NOCASE < (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) " + - "OR (s.title COLLATE NOCASE = (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))", + "AND (s.sortName COLLATE NOCASE < (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) " + + "OR (s.sortName COLLATE NOCASE = (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))", ) suspend fun starredSongOrdinal( sourceId: Long, @@ -932,7 +936,7 @@ interface LibraryDao { @Query( "SELECT * FROM albums WHERE sourceId = :sourceId AND year >= :start AND year < :end " + - "ORDER BY year DESC, name COLLATE NOCASE, id", + "ORDER BY year DESC, sortName COLLATE NOCASE, id", ) fun albumsByDecade( sourceId: Long, @@ -1017,7 +1021,7 @@ interface LibraryDao { "SELECT albums.* FROM songs " + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" + - ") ORDER BY year DESC, name COLLATE NOCASE, id", + ") ORDER BY year DESC, sortName COLLATE NOCASE, id", ) fun albumsForArtist( sourceId: Long, @@ -1084,7 +1088,7 @@ interface LibraryDao { "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 AND sd.status = 'Completed' " + "WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" + - ") ORDER BY year DESC, name COLLATE NOCASE, id", + ") ORDER BY year DESC, sortName COLLATE NOCASE, id", ) fun albumsForArtistDownloaded( sourceId: Long, diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryPaging.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryPaging.kt index c0f1c2d1..6bfb79f2 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryPaging.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryPaging.kt @@ -15,7 +15,9 @@ internal const val ALBUMS_FILTER_BASE = "OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " + "WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " + "OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " + - "OR instr(lower(albumArtist), lower(?)) > 0))) " + "OR instr(lower(albumArtist), lower(?)) > 0 " + + "OR instr(lower(sortName), lower(?)) > 0 " + + "OR instr(lower(albumArtistSort), lower(?)) > 0))) " internal const val ARTISTS_FILTER_BASE = "FROM artists WHERE sourceId = ? " + @@ -23,7 +25,8 @@ internal const val ARTISTS_FILTER_BASE = "AND (? = '' " + "OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " + "WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " + - "OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) " + "OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " + + "OR instr(lower(sortName), lower(?)) > 0))) " internal const val PLAYLISTS_FILTER_BASE = "FROM playlists WHERE sourceId = ? " + @@ -53,10 +56,10 @@ internal fun rediscoverAlbumsQuery( ): RoomRawQuery = RoomRawQuery( "SELECT * $ALBUMS_FILTER_BASE AND played IS NOT NULL AND played < ? " + - "ORDER BY played ASC, name COLLATE NOCASE, id", + "ORDER BY played ASC, sortName COLLATE NOCASE, id", ) { stmt -> stmt.bindAlbumFilters(sourceId, starredFilter, search) - stmt.bindLong(11, cutoff) + stmt.bindLong(13, cutoff) } private fun SQLiteStatement.bindAlbumFilters( @@ -74,6 +77,8 @@ private fun SQLiteStatement.bindAlbumFilters( bindText(8, search) bindText(9, search) bindText(10, search) + bindText(11, search) + bindText(12, search) } internal fun artistsDefaultQuery( @@ -94,6 +99,7 @@ internal fun artistsDefaultQuery( stmt.bindText(7, search) stmt.bindText(8, search) stmt.bindText(9, search) + stmt.bindText(10, search) } } 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 a27b14ac..b580593a 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -453,6 +453,114 @@ val MIGRATION_29_30 = } } +val MIGRATION_30_31 = + object : Migration(30, 31) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE `artists` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE") + connection.execSQL("ALTER TABLE `albums` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE") + connection.execSQL("ALTER TABLE `albums` ADD COLUMN `albumArtistSort` TEXT NOT NULL DEFAULT '' COLLATE NOCASE") + connection.execSQL("ALTER TABLE `songs` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE") + connection.execSQL("ALTER TABLE `songs` ADD COLUMN `artistSort` TEXT NOT NULL DEFAULT '' COLLATE NOCASE") + connection.execSQL("UPDATE `artists` SET `sortName` = `name`") + connection.execSQL("UPDATE `albums` SET `sortName` = `name`") + connection.execSQL("UPDATE `songs` SET `sortName` = `title`") + connection.execSQL( + "UPDATE `albums` SET `albumArtistSort` = COALESCE(" + + "(SELECT a.`sortName` FROM `artists` a " + + "WHERE a.`sourceId` = `albums`.`sourceId` AND a.`id` = `albums`.`artistId`), " + + "(SELECT a.`sortName` FROM `album_artists` aa JOIN `artists` a " + + "ON a.`sourceId` = aa.`sourceId` AND a.`id` = aa.`artistId` " + + "WHERE aa.`sourceId` = `albums`.`sourceId` AND aa.`albumId` = `albums`.`id` " + + "ORDER BY aa.`position` LIMIT 1), " + + "`albumArtist`, '')", + ) + connection.execSQL( + "UPDATE `songs` SET `artistSort` = COALESCE(" + + "(SELECT a.`sortName` FROM `artists` a " + + "WHERE a.`sourceId` = `songs`.`sourceId` AND a.`id` = `songs`.`artistId`), " + + "(SELECT a.`sortName` FROM `song_artists` sa JOIN `artists` a " + + "ON a.`sourceId` = sa.`sourceId` AND a.`id` = sa.`artistId` " + + "WHERE sa.`sourceId` = `songs`.`sourceId` AND sa.`songId` = `songs`.`id` " + + "ORDER BY sa.`position` LIMIT 1), " + + "`artist`, '')", + ) + ARTIST_INDEXES.forEach { connection.execSQL("DROP INDEX IF EXISTS `$it`") } + ALBUM_INDEXES.forEach { connection.execSQL("DROP INDEX IF EXISTS `$it`") } + ARTIST_INDEX_SQL.forEach { connection.execSQL(it) } + ALBUM_INDEX_SQL.forEach { connection.execSQL(it) } + dropSearchIndex(connection, "artist_search") + dropSearchIndex(connection, "album_search") + dropSearchIndex(connection, "song_search") + createSearchIndex(connection, "artist_search", "artists", listOf("name", "sortName")) + createSearchIndex(connection, "album_search", "albums", listOf("name", "albumArtist", "sortName", "albumArtistSort")) + createSearchIndex(connection, "song_search", "songs", listOf("title", "artist", "sortName", "artistSort")) + rebuildSearchIndex(connection, "artist_search") + rebuildSearchIndex(connection, "album_search") + rebuildSearchIndex(connection, "song_search") + } + } + +private val ARTIST_INDEXES = + listOf("index_artists_name", "index_artists_albumCount", "index_artists_starred", "index_artists_frequent", "index_artists_recent") + +private val ALBUM_INDEXES = + listOf( + "index_albums_name", + "index_albums_artist", + "index_albums_year", + "index_albums_added", + "index_albums_starred", + "index_albums_frequent", + "index_albums_recent", + ) + +private val ARTIST_INDEX_SQL = + listOf( + "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `artists` (`sourceId`, `sortName`, `id`)", + "CREATE INDEX IF NOT EXISTS `index_artists_albumCount` ON `artists` " + + "(`sourceId` ASC, `albumCount` DESC, `sortName` ASC, `id` ASC)", + "CREATE INDEX IF NOT EXISTS `index_artists_starred` ON `artists` " + + "(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)", + "CREATE INDEX IF NOT EXISTS `index_artists_frequent` ON `artists` " + + "(`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)", + "CREATE INDEX IF NOT EXISTS `index_artists_recent` ON `artists` " + + "(`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)", + ) + +private val ALBUM_INDEX_SQL = + listOf( + "CREATE INDEX IF NOT EXISTS `index_albums_name` ON `albums` (`sourceId`, `sortName`, `id`)", + "CREATE INDEX IF NOT EXISTS `index_albums_artist` ON `albums` " + + "(`sourceId`, `albumArtistSort`, `year`, `sortName`, `id`)", + "CREATE INDEX IF NOT EXISTS `index_albums_year` ON `albums` " + + "(`sourceId` ASC, `year` DESC, `sortName` ASC, `id` ASC)", + "CREATE INDEX IF NOT EXISTS `index_albums_added` ON `albums` " + + "(`sourceId` ASC, `created` DESC, `sortName` ASC, `id` ASC)", + "CREATE INDEX IF NOT EXISTS `index_albums_starred` ON `albums` " + + "(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)", + "CREATE INDEX IF NOT EXISTS `index_albums_frequent` ON `albums` " + + "(`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)", + "CREATE INDEX IF NOT EXISTS `index_albums_recent` ON `albums` " + + "(`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)", + ) + +private suspend fun dropSearchIndex( + connection: SQLiteConnection, + table: String, +) { + listOf("BEFORE_UPDATE", "BEFORE_DELETE", "AFTER_UPDATE", "AFTER_INSERT").forEach { + connection.execSQL("DROP TRIGGER IF EXISTS `room_fts_content_sync_${table}_$it`") + } + connection.execSQL("DROP TABLE IF EXISTS `$table`") +} + +private suspend fun rebuildSearchIndex( + connection: SQLiteConnection, + table: String, +) { + connection.execSQL("INSERT INTO `$table`(`$table`) VALUES('rebuild')") +} + // Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities. private suspend fun createSearchIndex( connection: SQLiteConnection, @@ -516,4 +624,5 @@ val MIGRATIONS: Array = MIGRATION_27_28, MIGRATION_28_29, MIGRATION_29_30, + MIGRATION_30_31, ) 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 8d0f381f..ec778442 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt @@ -38,7 +38,7 @@ private const val ARTIST_SONGS_SELECT = "WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS" private const val ARTIST_SONGS_ORDER = - " ORDER BY albums.year DESC, albums.name COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id" + " ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id" private const val SONG_SQL = "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + @@ -244,7 +244,7 @@ interface QueueDao { "JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " + - "ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset", + "ORDER BY songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset", ) suspend fun genreSongs( sourceId: Long, @@ -257,7 +257,7 @@ interface QueueDao { "SELECT songs.id FROM song_genres " + "JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " + "WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " + - "ORDER BY songs.title COLLATE NOCASE, songs.id", + "ORDER BY songs.sortName COLLATE NOCASE, songs.id", ) suspend fun genreSongIds( sourceId: Long, @@ -274,7 +274,7 @@ interface QueueDao { "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " + - "ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset", + "ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset", ) suspend fun starredSongs( sourceId: Long, @@ -284,7 +284,7 @@ interface QueueDao { @Query( "SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " + - "ORDER BY starred DESC, title COLLATE NOCASE, id", + "ORDER BY starred DESC, sortName COLLATE NOCASE, id", ) suspend fun starredSongIds(sourceId: Long): List @@ -293,7 +293,7 @@ interface QueueDao { "JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "WHERE d.sourceId = :sourceId AND d.status = 'Completed' " + - "ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset", + "ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset", ) suspend fun downloadedSongs( sourceId: Long, @@ -305,7 +305,7 @@ interface QueueDao { "SELECT songs.id FROM song_downloads d " + "JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " + "WHERE d.sourceId = :sourceId AND d.status = 'Completed' " + - "ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id", + "ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id", ) suspend fun downloadedSongIds(sourceId: Long): List } 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 dfd1ed26..f52a78e2 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -54,7 +54,7 @@ import com.subtracks.data.model.UpNextEntry SongDownload::class, PendingAction::class, ], - version = 30, + version = 31, exportSchema = true, ) @ColumnTypeConverters( 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 7852febf..aa645807 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -57,25 +57,25 @@ data class ArtistRef( ], indices = [ Index("sourceId"), - Index(name = "index_artists_name", value = ["sourceId", "name", "id"]), + Index(name = "index_artists_name", value = ["sourceId", "sortName", "id"]), Index( name = "index_artists_albumCount", - value = ["sourceId", "albumCount", "name", "id"], + value = ["sourceId", "albumCount", "sortName", "id"], orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], ), Index( name = "index_artists_starred", - value = ["sourceId", "starred", "name", "id"], + value = ["sourceId", "starred", "sortName", "id"], orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], ), Index( name = "index_artists_frequent", - value = ["sourceId", "playCount", "name", "id"], + value = ["sourceId", "playCount", "sortName", "id"], orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], ), Index( name = "index_artists_recent", - value = ["sourceId", "played", "name", "id"], + value = ["sourceId", "played", "sortName", "id"], orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], ), ], @@ -90,6 +90,7 @@ data class Artist( @ColumnInfo(defaultValue = "0") val playCount: Long = 0, val played: Long? = null, @ColumnInfo(defaultValue = "0") val songCount: Long = 0, + @ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = name, ) @Entity( @@ -106,31 +107,31 @@ data class Artist( indices = [ Index("sourceId"), Index("sourceId", "artistId"), - Index(name = "index_albums_name", value = ["sourceId", "name", "id"]), - Index(name = "index_albums_artist", value = ["sourceId", "albumArtist", "year", "name", "id"]), + Index(name = "index_albums_name", value = ["sourceId", "sortName", "id"]), + Index(name = "index_albums_artist", value = ["sourceId", "albumArtistSort", "year", "sortName", "id"]), Index( name = "index_albums_year", - value = ["sourceId", "year", "name", "id"], + value = ["sourceId", "year", "sortName", "id"], orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], ), Index( name = "index_albums_added", - value = ["sourceId", "created", "name", "id"], + value = ["sourceId", "created", "sortName", "id"], orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], ), Index( name = "index_albums_starred", - value = ["sourceId", "starred", "name", "id"], + value = ["sourceId", "starred", "sortName", "id"], orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], ), Index( name = "index_albums_frequent", - value = ["sourceId", "playCount", "name", "id"], + value = ["sourceId", "playCount", "sortName", "id"], orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], ), Index( name = "index_albums_recent", - value = ["sourceId", "played", "name", "id"], + value = ["sourceId", "played", "sortName", "id"], orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], ), ], @@ -151,6 +152,8 @@ data class Album( @ColumnInfo(defaultValue = "0") val playCount: Long = 0, val played: Long? = null, @ColumnInfo(defaultValue = "''") val artists: List = emptyList(), + @ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = name, + @ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val albumArtistSort: String = albumArtist.orEmpty(), ) { val artistRefs: List get() = @@ -286,6 +289,8 @@ data class Song( val albumPeak: Float? = null, @Ignore val genres: List = emptyList(), @ColumnInfo(defaultValue = "''") val artists: List = emptyList(), + @ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = title, + @ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val artistSort: String = artist.orEmpty(), ) { val artistRefs: List get() = @@ -506,6 +511,8 @@ data class AlbumSearch( @PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0, val name: String, val albumArtist: String?, + val sortName: String, + val albumArtistSort: String, ) @Entity(tableName = "artist_search") @@ -513,6 +520,7 @@ data class AlbumSearch( data class ArtistSearch( @PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0, val name: String, + val sortName: String, ) @Entity(tableName = "playlist_search") @@ -528,6 +536,8 @@ data class SongSearch( @PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0, val title: String, val artist: String?, + val sortName: String, + val artistSort: String, ) class QueueKindConverter { diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index d2495c78..ef911286 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -136,9 +136,9 @@ class LibraryRepository( suspend fun genreSongOrdinal( sourceId: Long, genre: String, - title: String, + sortName: String, id: String, - ): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, title, id) + ): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, sortName, id) suspend fun downloadedSongOrdinal( sourceId: Long, 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 1cce96e2..45640f65 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt @@ -32,6 +32,7 @@ object SubsonicXml { albumCount = attrs.longAttr("albumCount") ?: 0L, starred = attrs.dateAttr("starred"), coverArt = attrs.attr("coverArt").ifEmpty { null }, + sortName = attrs.attr("sortName").ifEmpty { attrs.attr("name") }, ) internal fun albumDraft( @@ -45,6 +46,7 @@ object SubsonicXml { name = attrs.attr("name"), albumArtist = attrs.attr("artist").ifEmpty { null }, displayArtist = attrs.attr("displayArtist").ifEmpty { null }, + sortName = attrs.attr("sortName").ifEmpty { null }, created = attrs.dateAttr("created") ?: 0L, coverArt = attrs.attr("coverArt").ifEmpty { null }, genre = attrs.attr("genre").ifEmpty { null }, @@ -71,6 +73,7 @@ object SubsonicXml { album = attrs.attr("album").ifEmpty { null }, artist = attrs.attr("artist").ifEmpty { null }, displayArtist = attrs.attr("displayArtist").ifEmpty { null }, + sortName = attrs.attr("sortName").ifEmpty { null }, duration = attrs.longAttr("duration"), track = attrs.longAttr("track"), disc = attrs.longAttr("discNumber"), @@ -171,6 +174,7 @@ internal class AlbumDraft( var name: String = "", var albumArtist: String? = null, var displayArtist: String? = null, + var sortName: String? = null, var created: Long = 0L, var coverArt: String? = null, var genre: String? = null, @@ -222,6 +226,7 @@ internal class AlbumDraft( songCount = songCount, discTitles = discTitles.toMap(), artists = refs, + sortName = sortName?.takeIf { it.isNotBlank() } ?: name, ) } } @@ -235,6 +240,7 @@ internal class SongDraft( private val album: String? = null, private val artist: String? = null, private val displayArtist: String? = null, + private val sortName: String? = null, private val duration: Long? = null, private val track: Long? = null, private val disc: Long? = null, @@ -309,6 +315,7 @@ internal class SongDraft( albumPeak = albumPeak, genres = genres.toList(), artists = refs, + sortName = sortName?.takeIf { it.isNotBlank() } ?: title, ) } } 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 b5142c4c..78041b47 100644 --- a/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt +++ b/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt @@ -116,7 +116,8 @@ class SyncService( db.useWriterConnection { connection -> connection.immediateTransaction { if (connection.albumIdsChanged(batch)) creditsChanged = true - connection.upsertChanged("songs", songColumns, primaryKey, batch) { it.values() } + val resolved = connection.resolveSongSortNames(batch) + connection.upsertChanged("songs", songColumns, primaryKey, resolved) { it.values() } connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement -> batch.forEach { song -> statement.bind(1, song.sourceId) @@ -184,6 +185,57 @@ class SyncService( return changed || expected.isNotEmpty() } + private suspend fun PooledConnection.resolveSongSortNames(batch: List): List { + if (batch.isEmpty()) return batch + val sortNames = + artistSortNames( + batch.first().sourceId, + batch.mapNotNull { it.artistRefs.firstOrNull()?.id }.toSet(), + ) + return batch.map { song -> + val primary = song.artistId ?: song.artists.firstOrNull()?.id + val sortName = + primary?.let(sortNames::get)?.takeIf { it.isNotBlank() } + ?: song.artists.firstNotNullOfOrNull { ref -> sortNames[ref.id]?.takeIf { it.isNotBlank() } } + ?: song.artistSort + if (sortName == song.artistSort) song else song.copy(artistSort = sortName) + } + } + + private suspend fun PooledConnection.resolveAlbumSortNames(batch: List): List { + if (batch.isEmpty()) return batch + val sortNames = + artistSortNames( + batch.first().sourceId, + batch.mapNotNull { it.artistId ?: it.artists.firstOrNull()?.id }.toSet(), + ) + return batch.map { album -> + val primary = album.artistId ?: album.artists.firstOrNull()?.id + val sortName = + primary?.let(sortNames::get)?.takeIf { it.isNotBlank() } + ?: album.artists.firstNotNullOfOrNull { ref -> sortNames[ref.id]?.takeIf { it.isNotBlank() } } + ?: album.albumArtistSort + if (sortName == album.albumArtistSort) album else album.copy(albumArtistSort = sortName) + } + } + + private suspend fun PooledConnection.artistSortNames( + sourceId: Long, + ids: Collection, + ): Map { + if (ids.isEmpty()) return emptyMap() + val sortNames = HashMap(ids.size) + ids.toList().chunked(SQL_VARIABLES).forEach { chunk -> + val placeholders = chunk.joinToString(",") { "?" } + usePrepared("SELECT id, sortName FROM artists WHERE sourceId = ? AND id IN ($placeholders)") { statement -> + statement.bindLong(1, sourceId) + chunk.forEachIndexed { index, id -> statement.bindText(index + 2, id) } + while (statement.step()) sortNames[statement.getText(0)] = statement.getText(1) + } + } + return sortNames + } + // Without sqlite_stat1 the artist and relation queries can fall back to whole-table scans. private suspend fun optimize() { db.useWriterConnection { connection -> @@ -242,7 +294,8 @@ class SyncService( } db.useWriterConnection { connection -> connection.immediateTransaction { - connection.upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() } + val resolved = connection.resolveAlbumSortNames(albums) + connection.upsertChanged("albums", albumColumns, primaryKey, resolved) { it.values() } connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() } val artistRowsBefore = connection.totalChanges() connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement -> @@ -368,7 +421,7 @@ private fun discHash( private val primaryKey = listOf("sourceId", "id") -private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt") +private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "sortName") private val albumColumns = listOf( @@ -384,6 +437,8 @@ private val albumColumns = "starred", "songCount", "artists", + "sortName", + "albumArtistSort", ) private val discKey = listOf("sourceId", "albumId", "disc") @@ -411,6 +466,8 @@ private val songColumns = "trackPeak", "albumPeak", "artists", + "sortName", + "artistSort", ) private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration") @@ -441,7 +498,7 @@ private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "po private val playlistSongKey = listOf("sourceId", "playlistId", "position") -private fun Artist.values() = listOf(sourceId, id, name, albumCount, starred, coverArt) +private fun Artist.values() = listOf(sourceId, id, name, albumCount, starred, coverArt, sortName) private fun Album.values() = listOf( @@ -457,6 +514,8 @@ private fun Album.values() = starred, songCount, ArtistRefsCodec.encode(artistRefs), + sortName, + albumArtistSort, ) private fun AlbumArtistRef.values() = listOf(sourceId, albumId, position, artistId, name) @@ -486,6 +545,8 @@ private fun Song.values() = trackPeak, albumPeak, ArtistRefsCodec.encode(artistRefs), + sortName, + artistSort, ) private fun List.positions(): List> = diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt index c1ae8d12..4b43f886 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt @@ -213,7 +213,7 @@ internal fun CoroutineScope.playGenreList( song: Song, ) { launch { - val ordinal = libraryRepository.genreSongOrdinal(song.sourceId, genre, song.title, song.id) + val ordinal = libraryRepository.genreSongOrdinal(song.sourceId, genre, song.sortName, song.id) playbackController.playGenre(song.sourceId, genre, ordinal) } } diff --git a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt index b8ad315a..8270bfe2 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt @@ -978,6 +978,73 @@ class LibraryDaoTest { awaitUntil("the default list observes album writes") { defaultInvalidated.get() } } + @Test + fun sortNamesDriveArtistAndAlbumOrder() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertArtists( + listOf( + artist(sourceId, "ar-1", "宇多田ヒカル", albumCount = 1).copy(sortName = "Utada Hikaru"), + artist(sourceId, "ar-2", "あいみょん", albumCount = 1).copy(sortName = "Aimyon"), + ), + ) + dao.upsertAlbums( + listOf( + album(sourceId, "al-1", "First Love", year = null, starred = null) + .copy(sortName = "First Love", albumArtistSort = "Utada Hikaru"), + album(sourceId, "al-2", "Zutto", year = null, starred = null) + .copy(sortName = "Zutto", albumArtistSort = "Aimyon"), + ), + ) + + assertEquals( + listOf("あいみょん", "宇多田ヒカル"), + dao.artistsByName(sourceId, 0, "").page().map { it.name }, + ) + assertEquals( + listOf("First Love", "Zutto"), + dao.albumsByName(sourceId, 0, "").page().map { it.name }, + ) + assertEquals( + listOf("Zutto", "First Love"), + dao.albumsByArtist(sourceId, 0, "").page().map { it.name }, + ) + } + + @Test + fun sortNamesAreSearchableInArtistsAlbumsAndSongs() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertArtists( + listOf( + artist(sourceId, "ar-1", "宇多田ヒカル", albumCount = 1) + .copy(sortName = "Utada Hikaru"), + ), + ) + dao.upsertAlbums( + listOf( + album(sourceId, "al-1", "First Love", year = null, starred = null) + .copy(sortName = "First Love", albumArtistSort = "Utada Hikaru"), + ), + ) + dao.upsertSongs( + listOf( + song(sourceId, "s1", "Automatic", starred = null) + .copy(sortName = "Automatic", artist = "宇多田ヒカル", artistSort = "Utada Hikaru"), + ), + ) + + assertEquals(listOf("宇多田ヒカル"), dao.artistsByName(sourceId, 0, "utada").page().map { it.name }) + assertEquals(listOf("宇多田ヒカル"), dao.artistsByName(sourceId, 0, "ut").page().map { it.name }) + assertEquals(listOf("宇多田ヒカル"), dao.searchArtists(sourceId, "utada", 10).first().map { it.name }) + assertEquals(listOf("First Love"), dao.albumsByName(sourceId, 0, "first").page().map { it.name }) + assertEquals(listOf("First Love"), dao.albumsByName(sourceId, 0, "fi").page().map { it.name }) + assertEquals(listOf("First Love"), dao.searchAlbums(sourceId, "first", 10).first().map { it.name }) + assertEquals(listOf("Automatic"), dao.searchSongs(sourceId, "utada", 10).first().map { it.song.title }) + } + private suspend fun source(): Long = db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0)) diff --git a/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt b/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt index d05a220a..d59032d0 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt @@ -54,17 +54,17 @@ class MigrationsTest { applySchema(migrated, versions.first()) MIGRATIONS.sortedBy { it.startVersion }.forEach { it.migrate(migrated) } - assertEquals("NOCASE", indexColumn(migrated, "index_albums_name", "name").collation) - assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "albumArtist").collation) - assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "name").collation) + assertEquals("NOCASE", indexColumn(migrated, "index_albums_name", "sortName").collation) + assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "albumArtistSort").collation) + assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "sortName").collation) assertEquals(1, indexColumn(migrated, "index_albums_year", "year").descending) - assertEquals("NOCASE", indexColumn(migrated, "index_albums_year", "name").collation) - assertEquals(0, indexColumn(migrated, "index_albums_year", "name").descending) + assertEquals("NOCASE", indexColumn(migrated, "index_albums_year", "sortName").collation) + assertEquals(0, indexColumn(migrated, "index_albums_year", "sortName").descending) assertEquals(1, indexColumn(migrated, "index_albums_added", "created").descending) assertEquals(1, indexColumn(migrated, "index_albums_starred", "starred").descending) assertEquals(1, indexColumn(migrated, "index_albums_frequent", "playCount").descending) assertEquals(1, indexColumn(migrated, "index_albums_recent", "played").descending) - assertEquals("NOCASE", indexColumn(migrated, "index_artists_name", "name").collation) + assertEquals("NOCASE", indexColumn(migrated, "index_artists_name", "sortName").collation) assertEquals(1, indexColumn(migrated, "index_artists_albumCount", "albumCount").descending) assertEquals(1, indexColumn(migrated, "index_artists_starred", "starred").descending) assertEquals(1, indexColumn(migrated, "index_artists_frequent", "playCount").descending) @@ -177,6 +177,40 @@ class MigrationsTest { } } + @Test + fun theSortNameBackfillUsesDisplayNamesAndArtistSortNames() = + runTest { + driver.open(":memory:").use { migrated -> + applySchema(migrated, 30) + migrated.execSQL( + "INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)", + ) + migrated.execSQL( + "INSERT INTO artists (sourceId, id, name, albumCount) " + + "VALUES (1, 'ar1', 'Utada Hikaru', 1)", + ) + migrated.execSQL( + "INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " + + "VALUES (1, 'al1', 'ar1', 'First Love', 'Utada Hikaru', 0, 1)", + ) + migrated.execSQL( + "INSERT INTO songs (sourceId, id, albumId, artistId, title) " + + "VALUES (1, 's1', 'al1', 'ar1', 'Automatic')", + ) + + MIGRATION_30_31.migrate(migrated) + + assertEquals("Utada Hikaru", text(migrated, "SELECT sortName FROM artists WHERE id = 'ar1'")) + assertEquals("First Love", text(migrated, "SELECT sortName FROM albums WHERE id = 'al1'")) + assertEquals("Utada Hikaru", text(migrated, "SELECT albumArtistSort FROM albums WHERE id = 'al1'")) + assertEquals("Automatic", text(migrated, "SELECT sortName FROM songs WHERE id = 's1'")) + assertEquals("Utada Hikaru", text(migrated, "SELECT artistSort FROM songs WHERE id = 's1'")) + assertEquals(1L, long(migrated, "SELECT COUNT(*) FROM artist_search WHERE artist_search MATCH 'utada'")) + assertEquals(1L, long(migrated, "SELECT COUNT(*) FROM album_search WHERE album_search MATCH 'first'")) + assertEquals(1L, long(migrated, "SELECT COUNT(*) FROM song_search WHERE song_search MATCH 'utada'")) + } + } + private fun text( connection: SQLiteConnection, sql: String, 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 ddc9027c..f861dcc2 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 @@ -314,6 +314,51 @@ class SubsonicXmlTest { assertEquals("A feat. B", songs.single().artist) } + @Test + fun mapsSortNamesAndFallsBackToTheDisplayName() { + val artists = + parse( + """ + + + + + + + """.trimIndent(), + tag = "artist", + create = { SubsonicXml.artist(7, it) }, + ) + + assertEquals("Utada Hikaru", artists[0].sortName) + assertEquals("No Sort", artists[1].sortName) + } + + @Test + fun mapsAlbumAndSongSortNames() { + val albums = + albums( + """ + + + + + + """.trimIndent(), + ) + val songs = + songs( + """ + + + + """.trimIndent(), + ) + + assertEquals("First Love", albums.single().sortName) + assertEquals("Automatic", songs.single().sortName) + } + @Test fun parsesTimestampsWithFractionsAndOffsets() { assertEquals(1789906657L, IsoDate.parse("2026-09-20T12:17:37.370827413Z")) 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 06879f96..63ac82ed 100644 --- a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt @@ -932,6 +932,67 @@ class SyncServiceTest { assertEquals(null, cleared.albumPeak) } + @Test + fun albumArtistSortPrefersTheSingularArtistOverThePluralList() = + runTest { + insertSource() + val source = + FakeMusicSource( + artists = + listOf( + artist("various", "Various Artists").copy(sortName = "Various Artists"), + artist("utada", "宇多田ヒカル").copy(sortName = "Utada Hikaru"), + ), + albums = + listOf( + album("al1").copy( + artistId = "various", + albumArtist = "Various Artists", + artists = listOf(ArtistRef("utada", "宇多田ヒカル")), + ), + ), + ) + + SyncService(db, source).sync() + + assertEquals("Various Artists", db.libraryDao().albumOnce(1, "al1")!!.albumArtistSort) + } + + @Test + fun syncStoresSortNamesAndResolvesArtistSorts() = + runTest { + insertSource() + val source = + FakeMusicSource( + artists = listOf(artist("a1", "宇多田ヒカル").copy(sortName = "Utada Hikaru")), + albums = + listOf( + album("al1").copy( + name = "First Love", + sortName = "First Love", + albumArtist = "宇多田ヒカル", + ), + ), + songs = + listOf( + song("s1").copy( + title = "Automatic", + sortName = "Automatic", + artist = "宇多田ヒカル", + artistSort = "", + ), + ), + ) + + SyncService(db, source).sync() + + assertEquals("Utada Hikaru", db.libraryDao().artistOnce(1, "a1")!!.sortName) + assertEquals("First Love", db.libraryDao().albumOnce(1, "al1")!!.sortName) + assertEquals("Utada Hikaru", db.libraryDao().albumOnce(1, "al1")!!.albumArtistSort) + assertEquals("Automatic", db.libraryDao().songOnce(1, "s1")!!.sortName) + assertEquals("Utada Hikaru", db.libraryDao().songOnce(1, "s1")!!.artistSort) + } + @Test fun everyAlbumArtistAndTrackArtistReachesTheSharedAlbum() = runTest {