diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/15.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/15.json new file mode 100644 index 00000000..482a5f4f --- /dev/null +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/15.json @@ -0,0 +1,1037 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "2e5a6cae53d7892329b79f00b4c0973a", + "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, 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" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_artists_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_artists_name", + "unique": false, + "columnNames": [ + "sourceId", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `${TABLE_NAME}` (`sourceId`, `name`, `id`)" + }, + { + "name": "index_artists_albumCount", + "unique": false, + "columnNames": [ + "sourceId", + "albumCount", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_albumCount` ON `${TABLE_NAME}` (`sourceId` ASC, `albumCount` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_artists_starred", + "unique": false, + "columnNames": [ + "sourceId", + "starred", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `name` ASC, `id` ASC)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "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, 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 + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_albums_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_albums_sourceId_artistId", + "unique": false, + "columnNames": [ + "sourceId", + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" + }, + { + "name": "index_albums_name", + "unique": false, + "columnNames": [ + "sourceId", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_name` ON `${TABLE_NAME}` (`sourceId`, `name`, `id`)" + }, + { + "name": "index_albums_artist", + "unique": false, + "columnNames": [ + "sourceId", + "albumArtist", + "year", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist` ON `${TABLE_NAME}` (`sourceId`, `albumArtist`, `year`, `name`, `id`)" + }, + { + "name": "index_albums_year", + "unique": false, + "columnNames": [ + "sourceId", + "year", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_year` ON `${TABLE_NAME}` (`sourceId` ASC, `year` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_albums_added", + "unique": false, + "columnNames": [ + "sourceId", + "created", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_added` ON `${TABLE_NAME}` (`sourceId` ASC, `created` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_albums_starred", + "unique": false, + "columnNames": [ + "sourceId", + "starred", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `name` ASC, `id` ASC)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "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": "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_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, `genre` TEXT, `created` 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": "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": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_songs_sourceId_albumId", + "unique": false, + "columnNames": [ + "sourceId", + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_albumId` ON `${TABLE_NAME}` (`sourceId`, `albumId`)" + }, + { + "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`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "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": "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, `positionMs` 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": "positionMs", + "columnName": "positionMs", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "shuffle_order", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sequence` INTEGER NOT NULL, `flatPosition` INTEGER NOT NULL, PRIMARY KEY(`sequence`))", + "fields": [ + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flatPosition", + "columnName": "flatPosition", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sequence" + ] + } + }, + { + "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" + ] + } + ] + } + ], + "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, '2e5a6cae53d7892329b79f00b4c0973a')" + ] + } +} \ 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 75c803f5..d571c47c 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -23,6 +23,48 @@ internal const val PLAYLIST_SONGS_SQL = "WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " + "ORDER BY playlist_songs.position" +internal const val ALBUMS_FILTER = + "FROM albums WHERE sourceId = :sourceId " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + + "AND (:search = '' OR instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), 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" + +// 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 ARTISTS_FILTER = + "FROM artists WHERE sourceId = :sourceId " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + + "AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " + +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 = "albumCount DESC, name COLLATE NOCASE, id" +internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "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 PLAYLISTS_FILTER = + "FROM playlists WHERE sourceId = :sourceId " + + "AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " + +internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id" +internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC" +internal const val PLAYLIST_ORDER_BY_ADDED = "created DESC, name COLLATE NOCASE, id" +internal const val PLAYLIST_ORDER_BY_ADDED_REVERSED = "created ASC, name COLLATE NOCASE DESC, id DESC" +internal const val PLAYLIST_ORDER_BY_UPDATED = "changed DESC, name COLLATE NOCASE, id" +internal const val PLAYLIST_ORDER_BY_UPDATED_REVERSED = "changed ASC, name COLLATE NOCASE DESC, id DESC" + @Dao @DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class) interface LibraryDao { @@ -155,253 +197,149 @@ interface LibraryDao { ) suspend fun deleteOrphanPlaylistSongs(sourceId: Long) - @Query( - "SELECT * FROM albums WHERE sourceId = :sourceId " + - "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " + - "ORDER BY name COLLATE NOCASE, id", - ) + @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME") fun albumsByName( sourceId: Long, starredFilter: Int, search: String, ): PagingSource - @Query( - "SELECT * FROM albums WHERE sourceId = :sourceId " + - "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " + - "ORDER BY name COLLATE NOCASE DESC, id", - ) + @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_REVERSED") fun albumsByNameReversed( sourceId: Long, starredFilter: Int, search: String, ): PagingSource - @Query( - "SELECT * FROM albums WHERE sourceId = :sourceId " + - "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " + - "ORDER BY albumArtist COLLATE NOCASE, year, name COLLATE NOCASE, id", - ) + @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST") fun albumsByArtist( sourceId: Long, starredFilter: Int, search: String, ): PagingSource - @Query( - "SELECT * FROM albums WHERE sourceId = :sourceId " + - "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " + - "ORDER BY albumArtist COLLATE NOCASE DESC, year DESC, name COLLATE NOCASE DESC, id", - ) + @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_REVERSED") fun albumsByArtistReversed( sourceId: Long, starredFilter: Int, search: String, ): PagingSource - @Query( - "SELECT * FROM albums WHERE sourceId = :sourceId " + - "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " + - "ORDER BY year DESC, name COLLATE NOCASE, id", - ) + @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR") fun albumsByYear( sourceId: Long, starredFilter: Int, search: String, ): PagingSource - @Query( - "SELECT * FROM albums WHERE sourceId = :sourceId " + - "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " + - "ORDER BY year ASC, name COLLATE NOCASE, id", - ) + @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR_REVERSED") fun albumsByYearReversed( sourceId: Long, starredFilter: Int, search: String, ): PagingSource - @Query( - "SELECT * FROM albums WHERE sourceId = :sourceId " + - "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " + - "ORDER BY created DESC, name COLLATE NOCASE, id", - ) + @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED") fun albumsByRecentlyAdded( sourceId: Long, starredFilter: Int, search: String, ): PagingSource - @Query( - "SELECT * FROM albums WHERE sourceId = :sourceId " + - "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " + - "ORDER BY created ASC, name COLLATE NOCASE, id", - ) + @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED_REVERSED") fun albumsByRecentlyAddedReversed( sourceId: Long, starredFilter: Int, search: String, ): PagingSource - @Query( - "SELECT * FROM albums WHERE sourceId = :sourceId " + - "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " + - "ORDER BY starred IS NULL, starred DESC, name COLLATE NOCASE, id", - ) + @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED") fun albumsByStarred( sourceId: Long, starredFilter: Int, search: String, ): PagingSource - @Query( - "SELECT * FROM albums WHERE sourceId = :sourceId " + - "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " + - "ORDER BY starred IS NULL, starred ASC, name COLLATE NOCASE, id", - ) + @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED_REVERSED") fun albumsByStarredReversed( sourceId: Long, starredFilter: Int, search: String, ): PagingSource - @Query( - "SELECT * FROM artists WHERE sourceId = :sourceId " + - "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " + - "ORDER BY name COLLATE NOCASE, id", - ) + @Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME") fun artistsByName( sourceId: Long, starredFilter: Int, search: String, ): PagingSource - @Query( - "SELECT * FROM artists WHERE sourceId = :sourceId " + - "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " + - "ORDER BY name COLLATE NOCASE DESC, id", - ) + @Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_REVERSED") fun artistsByNameReversed( sourceId: Long, starredFilter: Int, search: String, ): PagingSource - @Query( - "SELECT * FROM artists WHERE sourceId = :sourceId " + - "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " + - "ORDER BY albumCount DESC, name COLLATE NOCASE, id", - ) + @Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT") fun artistsByAlbumCount( sourceId: Long, starredFilter: Int, search: String, ): PagingSource - @Query( - "SELECT * FROM artists WHERE sourceId = :sourceId " + - "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " + - "ORDER BY albumCount ASC, name COLLATE NOCASE, id", - ) + @Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED") fun artistsByAlbumCountReversed( sourceId: Long, starredFilter: Int, search: String, ): PagingSource - @Query( - "SELECT * FROM artists WHERE sourceId = :sourceId " + - "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " + - "ORDER BY starred IS NULL, starred DESC, name COLLATE NOCASE, id", - ) + @Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED") fun artistsByStarred( sourceId: Long, starredFilter: Int, search: String, ): PagingSource - @Query( - "SELECT * FROM artists WHERE sourceId = :sourceId " + - "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " + - "ORDER BY starred IS NULL, starred ASC, name COLLATE NOCASE, id", - ) + @Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED_REVERSED") fun artistsByStarredReversed( sourceId: Long, starredFilter: Int, search: String, ): PagingSource - @Query( - "SELECT * FROM playlists WHERE sourceId = :sourceId " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " + - "ORDER BY name COLLATE NOCASE, id", - ) + @Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME") fun playlistsByName( sourceId: Long, search: String, ): PagingSource - @Query( - "SELECT * FROM playlists WHERE sourceId = :sourceId " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " + - "ORDER BY name COLLATE NOCASE DESC, id", - ) + @Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME_REVERSED") fun playlistsByNameReversed( sourceId: Long, search: String, ): PagingSource - @Query( - "SELECT * FROM playlists WHERE sourceId = :sourceId " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " + - "ORDER BY created DESC, name COLLATE NOCASE, id", - ) + @Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED") fun playlistsByAdded( sourceId: Long, search: String, ): PagingSource - @Query( - "SELECT * FROM playlists WHERE sourceId = :sourceId " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " + - "ORDER BY created ASC, name COLLATE NOCASE, id", - ) + @Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED_REVERSED") fun playlistsByAddedReversed( sourceId: Long, search: String, ): PagingSource - @Query( - "SELECT * FROM playlists WHERE sourceId = :sourceId " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " + - "ORDER BY changed DESC, name COLLATE NOCASE, id", - ) + @Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED") fun playlistsByUpdated( sourceId: Long, search: String, ): PagingSource - @Query( - "SELECT * FROM playlists WHERE sourceId = :sourceId " + - "AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " + - "ORDER BY changed ASC, name COLLATE NOCASE, id", - ) + @Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED_REVERSED") fun playlistsByUpdatedReversed( sourceId: Long, search: String, 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 301365d6..eb1667c0 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -135,6 +135,87 @@ val MIGRATION_13_14 = } } +val MIGRATION_14_15 = + object : Migration(14, 15) { + override suspend fun migrate(connection: SQLiteConnection) { + rebuildTable(connection, "albums", ALBUMS_V14, ALBUMS_V14_COPY, ALBUMS_V14_INDICES) + rebuildTable(connection, "artists", ARTISTS_V14, ARTISTS_V14_COPY, ARTISTS_V14_INDICES) + rebuildTable(connection, "playlists", PLAYLISTS_V14, PLAYLISTS_V14_COPY, PLAYLISTS_V14_INDICES) + } + } + +private suspend fun rebuildTable( + connection: SQLiteConnection, + table: String, + createSql: String, + copySql: String, + indices: List, +) { + val temp = "${table}_new" + connection.execSQL(createSql.replace("\${TABLE_NAME}", temp)) + connection.execSQL(copySql.replace("\${TABLE_NAME}", temp)) + connection.execSQL("DROP TABLE `$table`") + connection.execSQL("ALTER TABLE `$temp` RENAME TO `$table`") + indices.forEach { connection.execSQL(it.replace("\${TABLE_NAME}", table)) } +} + +private const val ALBUMS_V14 = + "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, " + + "PRIMARY KEY(`sourceId`, `id`), " + + "FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )" + +private const val ALBUMS_V14_COPY = + "INSERT INTO `\${TABLE_NAME}` (sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount) " + + "SELECT sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount FROM `albums`" + +private val ALBUMS_V14_INDICES = + listOf( + "CREATE INDEX IF NOT EXISTS `index_albums_sourceId` ON `\${TABLE_NAME}` (`sourceId`)", + "CREATE INDEX IF NOT EXISTS `index_albums_sourceId_artistId` ON `\${TABLE_NAME}` (`sourceId`, `artistId`)", + "CREATE INDEX IF NOT EXISTS `index_albums_name` ON `\${TABLE_NAME}` (`sourceId`, `name`, `id`)", + "CREATE INDEX IF NOT EXISTS `index_albums_artist` ON `\${TABLE_NAME}` (`sourceId`, `albumArtist`, `year`, `name`, `id`)", + "CREATE INDEX IF NOT EXISTS `index_albums_year` ON `\${TABLE_NAME}` (`sourceId` ASC, `year` DESC, `name` ASC, `id` ASC)", + "CREATE INDEX IF NOT EXISTS `index_albums_added` ON `\${TABLE_NAME}` (`sourceId` ASC, `created` DESC, `name` ASC, `id` ASC)", + "CREATE INDEX IF NOT EXISTS `index_albums_starred` ON `\${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `name` ASC, `id` ASC)", + ) + +private const val ARTISTS_V14 = + "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, " + + "PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )" + +private const val ARTISTS_V14_COPY = + "INSERT INTO `\${TABLE_NAME}` (sourceId, id, name, albumCount, starred, coverArt) " + + "SELECT sourceId, id, name, albumCount, starred, coverArt FROM `artists`" + +private val ARTISTS_V14_INDICES = + listOf( + "CREATE INDEX IF NOT EXISTS `index_artists_sourceId` ON `\${TABLE_NAME}` (`sourceId`)", + "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `\${TABLE_NAME}` (`sourceId`, `name`, `id`)", + "CREATE INDEX IF NOT EXISTS `index_artists_albumCount` ON `\${TABLE_NAME}` (`sourceId` ASC, `albumCount` DESC, `name` ASC, `id` ASC)", + "CREATE INDEX IF NOT EXISTS `index_artists_starred` ON `\${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `name` ASC, `id` ASC)", + ) + +private const val PLAYLISTS_V14 = + "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 )" + +private const val PLAYLISTS_V14_COPY = + "INSERT INTO `\${TABLE_NAME}` (sourceId, id, name, comment, coverArt, songCount, created, changed, duration) " + + "SELECT sourceId, id, name, comment, coverArt, songCount, created, changed, duration FROM `playlists`" + +private val PLAYLISTS_V14_INDICES = + listOf( + "CREATE INDEX IF NOT EXISTS `index_playlists_sourceId` ON `\${TABLE_NAME}` (`sourceId`)", + "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `\${TABLE_NAME}` (`sourceId`, `name`, `id`)", + "CREATE INDEX IF NOT EXISTS `index_playlists_added` ON `\${TABLE_NAME}` (`sourceId` ASC, `created` DESC, `name` ASC, `id` ASC)", + "CREATE INDEX IF NOT EXISTS `index_playlists_updated` ON `\${TABLE_NAME}` (`sourceId` ASC, `changed` DESC, `name` ASC, `id` ASC)", + ) + val MIGRATIONS: Array = arrayOf( MIGRATION_1_2, @@ -150,4 +231,5 @@ val MIGRATIONS: Array = MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, + MIGRATION_14_15, ) 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 a1e05130..37e8d8f7 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -32,7 +32,7 @@ import com.subtracks.data.model.SubsonicSource ShuffleOrder::class, ArtworkSeed::class, ], - version = 14, + version = 15, exportSchema = true, ) @ColumnTypeConverters(QueueKindConverter::class) 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 f8f7dc99..adcd6cce 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -48,12 +48,25 @@ data class SubsonicSource( onDelete = ForeignKey.CASCADE, ), ], - indices = [Index("sourceId")], + indices = [ + Index("sourceId"), + Index(name = "index_artists_name", value = ["sourceId", "name", "id"]), + Index( + name = "index_artists_albumCount", + value = ["sourceId", "albumCount", "name", "id"], + orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], + ), + Index( + name = "index_artists_starred", + value = ["sourceId", "starred", "name", "id"], + orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], + ), + ], ) data class Artist( val sourceId: Long, val id: String, - val name: String, + @ColumnInfo(collate = ColumnInfo.NOCASE) val name: String, val albumCount: Long, val starred: Long?, val coverArt: String? = null, @@ -70,14 +83,34 @@ data class Artist( onDelete = ForeignKey.CASCADE, ), ], - indices = [Index("sourceId"), Index("sourceId", "artistId")], + 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_year", + value = ["sourceId", "year", "name", "id"], + orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], + ), + Index( + name = "index_albums_added", + value = ["sourceId", "created", "name", "id"], + orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], + ), + Index( + name = "index_albums_starred", + value = ["sourceId", "starred", "name", "id"], + orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], + ), + ], ) data class Album( val sourceId: Long, val id: String, val artistId: String?, - val name: String, - val albumArtist: String?, + @ColumnInfo(collate = ColumnInfo.NOCASE) val name: String, + @ColumnInfo(collate = ColumnInfo.NOCASE) val albumArtist: String?, val created: Long, val coverArt: String?, val genre: String?, @@ -118,12 +151,25 @@ data class Disc( onDelete = ForeignKey.CASCADE, ), ], - indices = [Index("sourceId")], + indices = [ + Index("sourceId"), + Index(name = "index_playlists_name", value = ["sourceId", "name", "id"]), + Index( + name = "index_playlists_added", + value = ["sourceId", "created", "name", "id"], + orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], + ), + Index( + name = "index_playlists_updated", + value = ["sourceId", "changed", "name", "id"], + orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], + ), + ], ) data class Playlist( val sourceId: Long, val id: String, - val name: String, + @ColumnInfo(collate = ColumnInfo.NOCASE) val name: String, val comment: String?, val coverArt: String?, val songCount: Long, 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 9ccb4fe8..848e0147 100644 --- a/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt +++ b/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt @@ -285,7 +285,7 @@ private suspend fun PooledConnection.upsertChanged( "ON CONFLICT(${key.joinToString()}) DO UPDATE SET " + updates.joinToString { "$it = excluded.$it" } + " WHERE " + - updates.joinToString(" OR ") { "$table.$it IS NOT excluded.$it" } + updates.joinToString(" OR ") { "$table.$it COLLATE BINARY IS NOT excluded.$it" } usePrepared(sql) { statement -> rows.forEach { row -> values(row).forEachIndexed { index, value -> statement.bind(index + 1, value) } 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 47b7f3cc..b0f347e2 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt @@ -3,16 +3,21 @@ package com.subtracks.data.db import android.content.Context import androidx.paging.PagingSource import androidx.room3.Room +import androidx.room3.useReaderConnection import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.subtracks.data.model.Album +import com.subtracks.data.model.Artist +import com.subtracks.data.model.Playlist import com.subtracks.data.model.Song import com.subtracks.data.model.Source import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -108,6 +113,192 @@ class LibraryDaoTest { ) } + @Test + fun starredSortsKeepUnstarredLastInBothDirections() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertAlbums( + listOf( + album(sourceId, "a1", "Alpha", year = null, starred = 100), + album(sourceId, "a2", "Beta", year = null, starred = 200), + album(sourceId, "a3", "Gamma", year = null, starred = null), + album(sourceId, "a4", "Delta", year = null, starred = null), + ), + ) + dao.upsertArtists( + listOf( + artist(sourceId, "r1", "Alpha", albumCount = 1).copy(starred = 100), + artist(sourceId, "r2", "Beta", albumCount = 1).copy(starred = 200), + artist(sourceId, "r3", "Gamma", albumCount = 1).copy(starred = null), + artist(sourceId, "r4", "Delta", albumCount = 1).copy(starred = null), + ), + ) + + assertEquals( + listOf("Beta", "Alpha", "Delta", "Gamma"), + dao.albumsByStarred(sourceId, 0, "").page().map { it.name }, + ) + assertEquals( + listOf("Alpha", "Beta", "Gamma", "Delta"), + dao.albumsByStarredReversed(sourceId, 0, "").page().map { it.name }, + ) + assertEquals( + listOf("Beta", "Alpha", "Delta", "Gamma"), + dao.artistsByStarred(sourceId, 0, "").page().map { it.name }, + ) + assertEquals( + listOf("Alpha", "Beta", "Gamma", "Delta"), + dao.artistsByStarredReversed(sourceId, 0, "").page().map { it.name }, + ) + } + + @Test + fun reversedAlbumSortsReverseEveryTiebreaker() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertAlbums( + listOf( + album(sourceId, "a1", "Same", year = 2000, starred = null, created = 100).copy(albumArtist = "Zed"), + album(sourceId, "a2", "Same", year = 2000, starred = null, created = 100).copy(albumArtist = "Amy"), + album(sourceId, "a3", "Same", year = 1990, starred = null, created = 200).copy(albumArtist = "Amy"), + album(sourceId, "a4", "Other", year = 2000, starred = null, created = 100).copy(albumArtist = "Amy"), + ), + ) + + val pairs = + listOf( + dao.albumsByName(sourceId, 0, "") to dao.albumsByNameReversed(sourceId, 0, ""), + dao.albumsByArtist(sourceId, 0, "") to dao.albumsByArtistReversed(sourceId, 0, ""), + dao.albumsByYear(sourceId, 0, "") to dao.albumsByYearReversed(sourceId, 0, ""), + dao.albumsByRecentlyAdded(sourceId, 0, "") to dao.albumsByRecentlyAddedReversed(sourceId, 0, ""), + ) + for ((base, reversed) in pairs) { + val ids = base.page().map { it.id } + assertEquals(ids.reversed(), reversed.page().map { it.id }) + } + } + + @Test + fun reversedArtistAndPlaylistSortsReverseEveryTiebreaker() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertArtists( + listOf( + artist(sourceId, "r1", "Same", albumCount = 2), + artist(sourceId, "r2", "Other", albumCount = 1), + artist(sourceId, "r3", "Same", albumCount = 1), + ), + ) + dao.upsertPlaylists( + listOf( + playlist(sourceId, "l1", "Same", created = 100, changed = 100), + playlist(sourceId, "l2", "Other", created = 100, changed = 200), + playlist(sourceId, "l3", "Same", created = 200, changed = 100), + ), + ) + + val artistPairs = + listOf( + dao.artistsByName(sourceId, 0, "").page().map { it.id } to + dao.artistsByNameReversed(sourceId, 0, "").page().map { it.id }, + dao.artistsByAlbumCount(sourceId, 0, "").page().map { it.id } to + dao.artistsByAlbumCountReversed(sourceId, 0, "").page().map { it.id }, + ) + val playlistPairs = + listOf( + dao.playlistsByName(sourceId, "").page().map { it.id } to + dao.playlistsByNameReversed(sourceId, "").page().map { it.id }, + dao.playlistsByAdded(sourceId, "").page().map { it.id } to + dao.playlistsByAddedReversed(sourceId, "").page().map { it.id }, + dao.playlistsByUpdated(sourceId, "").page().map { it.id } to + dao.playlistsByUpdatedReversed(sourceId, "").page().map { it.id }, + ) + for ((base, reversed) in artistPairs + playlistPairs) { + assertEquals(base.reversed(), reversed) + } + } + + @Test + fun albumOrdersAreIndexBacked() = + runTest { + val orders = + listOf( + ALBUM_ORDER_BY_NAME to "index_albums_name", + ALBUM_ORDER_BY_NAME_REVERSED to "index_albums_name", + ALBUM_ORDER_BY_ARTIST to "index_albums_artist", + ALBUM_ORDER_BY_ARTIST_REVERSED to "index_albums_artist", + ALBUM_ORDER_BY_YEAR to "index_albums_year", + ALBUM_ORDER_BY_YEAR_REVERSED to "index_albums_year", + ALBUM_ORDER_BY_ADDED to "index_albums_added", + ALBUM_ORDER_BY_ADDED_REVERSED to "index_albums_added", + ALBUM_ORDER_BY_STARRED to "index_albums_starred", + ALBUM_ORDER_BY_STARRED_REVERSED to "index_albums_starred", + ) + for ((order, index) in orders) { + val plan = plan("SELECT * $ALBUMS_FILTER ORDER BY $order LIMIT 20 OFFSET 0") + assertTrue(plan, plan.contains("USING INDEX $index")) + assertFalse(plan, plan.contains("TEMP B-TREE")) + } + } + + @Test + fun artistOrdersAreIndexBacked() = + runTest { + val orders = + listOf( + ARTIST_ORDER_BY_NAME to "index_artists_name", + ARTIST_ORDER_BY_NAME_REVERSED to "index_artists_name", + ARTIST_ORDER_BY_ALBUM_COUNT to "index_artists_albumCount", + ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED to "index_artists_albumCount", + ARTIST_ORDER_BY_STARRED to "index_artists_starred", + ARTIST_ORDER_BY_STARRED_REVERSED to "index_artists_starred", + ) + for ((order, index) in orders) { + val plan = plan("SELECT * $ARTISTS_FILTER ORDER BY $order LIMIT 20 OFFSET 0") + assertTrue(plan, plan.contains("USING INDEX $index")) + assertFalse(plan, plan.contains("TEMP B-TREE")) + } + } + + @Test + fun playlistOrdersAreIndexBacked() = + runTest { + val orders = + listOf( + PLAYLIST_ORDER_BY_NAME to "index_playlists_name", + PLAYLIST_ORDER_BY_NAME_REVERSED to "index_playlists_name", + PLAYLIST_ORDER_BY_ADDED to "index_playlists_added", + PLAYLIST_ORDER_BY_ADDED_REVERSED to "index_playlists_added", + PLAYLIST_ORDER_BY_UPDATED to "index_playlists_updated", + PLAYLIST_ORDER_BY_UPDATED_REVERSED to "index_playlists_updated", + ) + for ((order, index) in orders) { + val plan = plan("SELECT * $PLAYLISTS_FILTER ORDER BY $order LIMIT 20 OFFSET 0") + assertTrue(plan, plan.contains("USING INDEX $index")) + assertFalse(plan, plan.contains("TEMP B-TREE")) + } + } + + @Test + fun anUnindexedAlbumOrderStillShowsATempSort() = + runTest { + val plan = plan("SELECT * $ALBUMS_FILTER ORDER BY songCount, id LIMIT 20 OFFSET 0") + + assertTrue(plan, plan.contains("TEMP B-TREE")) + } + + private suspend fun plan(sql: String): String = + db.useReaderConnection { connection -> + connection.usePrepared("EXPLAIN QUERY PLAN $sql") { statement -> + val details = mutableListOf() + while (statement.step()) details += statement.getText(3) + details.joinToString("\n") + } + } + private suspend fun source(): Long = db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0)) @@ -139,6 +330,38 @@ class LibraryDaoTest { songCount = 1, ) + private fun artist( + sourceId: Long, + id: String, + name: String, + albumCount: Long, + ) = Artist( + sourceId = sourceId, + id = id, + name = name, + albumCount = albumCount, + starred = null, + coverArt = null, + ) + + private fun playlist( + sourceId: Long, + id: String, + name: String, + created: Long, + changed: Long, + ) = Playlist( + sourceId = sourceId, + id = id, + name = name, + comment = null, + coverArt = null, + songCount = 1, + created = created, + changed = changed, + duration = 100, + ) + private fun song( sourceId: Long, id: String, 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 26493dd9..a4dd61ba 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt @@ -47,6 +47,109 @@ class MigrationsTest { } } + @Test + fun theLibrarySortIndicesInheritCaseInsensitiveCollationAndDirection() = + runTest { + driver.open(":memory:").use { migrated -> + 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(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(1, indexColumn(migrated, "index_albums_added", "created").descending) + assertEquals(1, indexColumn(migrated, "index_albums_starred", "starred").descending) + assertEquals("NOCASE", indexColumn(migrated, "index_artists_name", "name").collation) + assertEquals(1, indexColumn(migrated, "index_artists_albumCount", "albumCount").descending) + assertEquals(1, indexColumn(migrated, "index_artists_starred", "starred").descending) + assertEquals("NOCASE", indexColumn(migrated, "index_playlists_name", "name").collation) + assertEquals(1, indexColumn(migrated, "index_playlists_added", "created").descending) + assertEquals(1, indexColumn(migrated, "index_playlists_updated", "changed").descending) + } + } + + @Test + fun thePlaylistChangedAndDurationColumnsSurviveTheRebuild() = + runTest { + driver.open(":memory:").use { migrated -> + applySchema(migrated, versions.first()) + migrated.execSQL( + "INSERT INTO sources (id, name, address, isActive, createdAt) " + + "VALUES (1, 'server', 'http://localhost', 1, 100)", + ) + migrated.execSQL( + "INSERT INTO artists (sourceId, id, name, albumCount, starred) " + + "VALUES (1, 'ar1', 'The Artist', 1, NULL)", + ) + migrated.execSQL( + "INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, " + + "starred, songCount, frequentRank, recentRank) " + + "VALUES (1, 'al1', 'ar1', 'The Album', 'The Artist', 101, NULL, NULL, 1999, NULL, 1, NULL, NULL)", + ) + migrated.execSQL( + "INSERT INTO playlists (sourceId, id, name, comment, coverArt, songCount, created) " + + "VALUES (1, 'pl1', 'The Playlist', NULL, NULL, 3, 102)", + ) + + MIGRATIONS.sortedBy { it.startVersion }.forEach { migration -> + migration.migrate(migrated) + when (migration.endVersion) { + 5 -> migrated.execSQL("UPDATE playlists SET duration = 987654 WHERE id = 'pl1'") + 8 -> migrated.execSQL("UPDATE playlists SET changed = 123456 WHERE id = 'pl1'") + 11 -> migrated.execSQL("INSERT INTO discs (sourceId, albumId, disc, title) VALUES (1, 'al1', 2, 'The Disc')") + } + } + + assertEquals("The Playlist", text(migrated, "SELECT name FROM playlists WHERE id = 'pl1'")) + assertEquals(123456L, long(migrated, "SELECT changed FROM playlists WHERE id = 'pl1'")) + assertEquals(987654L, long(migrated, "SELECT duration FROM playlists WHERE id = 'pl1'")) + assertEquals("The Album", text(migrated, "SELECT name FROM albums WHERE id = 'al1'")) + assertEquals("The Artist", text(migrated, "SELECT name FROM artists WHERE id = 'ar1'")) + assertEquals("The Disc", text(migrated, "SELECT title FROM discs WHERE albumId = 'al1' AND disc = 2")) + } + } + + private fun text( + connection: SQLiteConnection, + sql: String, + ): String = + connection.prepare(sql).use { statement -> + check(statement.step()) { "No row for $sql" } + statement.getText(0) + } + + private fun long( + connection: SQLiteConnection, + sql: String, + ): Long = + connection.prepare(sql).use { statement -> + check(statement.step()) { "No row for $sql" } + statement.getLong(0) + } + + private fun indexColumn( + connection: SQLiteConnection, + index: String, + column: String, + ): IndexColumn { + connection.prepare("PRAGMA index_xinfo(`$index`)").use { statement -> + while (statement.step()) { + if (!statement.isNull(2) && statement.getText(2) == column) { + return IndexColumn(collation = statement.getText(4), descending = statement.getInt(3)) + } + } + } + error("Column $column not found in index $index") + } + + private data class IndexColumn( + val collation: String, + val descending: Int, + ) + private fun applySchema( connection: SQLiteConnection, version: Int, 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 ec9aaf85..d0f979cc 100644 --- a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt @@ -182,6 +182,59 @@ class SyncServiceTest { ) } + @Test + fun aCaseOnlyNameChangeRewritesTheRow() = + runTest { + insertSource() + val source = + FakeMusicSource( + artists = listOf(artist("a1", name = "the beatles")), + albums = listOf(album("al1").copy(name = "abbey road", albumArtist = "the beatles")), + playlists = listOf(playlist("p1").copy(name = "driving")), + ) + + SyncService(db, source).sync() + + source.artists = listOf(artist("a1", name = "The Beatles")) + source.albums = listOf(album("al1").copy(name = "Abbey Road", albumArtist = "The Beatles")) + source.playlists = listOf(playlist("p1").copy(name = "Driving")) + + SyncService(db, source).sync() + + assertEquals( + "The Beatles", + db + .libraryDao() + .artist(1, "a1") + .first() + ?.name, + ) + assertEquals( + "Abbey Road", + db + .libraryDao() + .album(1, "al1") + .first() + ?.name, + ) + assertEquals( + "The Beatles", + db + .libraryDao() + .album(1, "al1") + .first() + ?.albumArtist, + ) + assertEquals( + "Driving", + db + .libraryDao() + .playlist(1, "p1") + .first() + ?.name, + ) + } + @Test fun syncStoresStarredAndAddedTimestamps() = runTest { diff --git a/docs/architecture.md b/docs/architecture.md index 45823441..e577c137 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -100,7 +100,7 @@ UI and playback preferences live in DataStore (`user_prefs`), not in SQLite: the Every schema version step ships a hand-written `Migration` (`data/db/Migrations.kt`), and there is no destructive fallback, so an upgrade never silently wipes the local mirror. The exported schemas under `app/schemas/` are the source of truth for the migration SQL, and `MigrationsTest` runs each step against a real SQLite connection and asserts the resulting tables, columns and indices. -Library screens page over Room with `PagingSource` queries, ordering by a sort chosen in preferences (name, artist, year, recently added). Paging is local, but it keeps memory bounded on large libraries and lets a huge playlist render incrementally. Room 3 requires `@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)` on the DAO for this. +Library screens page over Room with `PagingSource` queries, ordering by a sort chosen in preferences (name, artist, year, recently added). Every album, artist and playlist order has an index on its own table, and the sortable text columns carry `COLLATE NOCASE`, so a page seeks the index in order instead of re-sorting the table for each `LIMIT`/`OFFSET`. Starred orders pin unstarred rows last in both directions, matching the list grouping, so a starred "reversed" order is not a literal mirror of the base order. Paging is local, but it keeps memory bounded on large libraries and lets a huge playlist render incrementally. Room 3 requires `@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)` on the DAO for this. Song rows show the album's cover art, fetched by joining `songs` to `albums` into a `SongListItem` rather than storing cover art on the song: some servers return a distinct cover ID per track, and keeping those would fragment the image cache for no visual gain. Artists show their image cropped to a circle.