diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/17.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/17.json new file mode 100644 index 00000000..69e777ff --- /dev/null +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/17.json @@ -0,0 +1,1112 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "identityHash": "f7666d92ab17450b28ffdc6f9a021af1", + "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": "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" + ] + } + ] + } + ], + "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, 'f7666d92ab17450b28ffdc6f9a021af1')" + ] + } +} \ No newline at end of file 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 fdab336e..e431af35 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -163,6 +163,14 @@ val MIGRATION_15_16 = } } +val MIGRATION_16_17 = + object : Migration(16, 17) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE `playback_cursor` ADD COLUMN `shuffleSize` INTEGER NOT NULL DEFAULT 0") + connection.execSQL("DROP TABLE IF EXISTS `shuffle_order`") + } + } + private suspend fun rebuildTable( connection: SQLiteConnection, table: String, @@ -252,4 +260,5 @@ val MIGRATIONS: Array = MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, + MIGRATION_16_17, ) 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 8a9f0518..aa1d5795 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt @@ -6,7 +6,6 @@ import androidx.room3.Query import androidx.room3.Upsert import com.subtracks.data.model.PlaybackCursor import com.subtracks.data.model.QueueEntry -import com.subtracks.data.model.ShuffleOrder import com.subtracks.data.model.SongListItem import com.subtracks.data.model.UpNextEntry @@ -41,15 +40,6 @@ interface QueueDao { @Query("SELECT * FROM up_next_entries ORDER BY position, id") suspend fun upNextEntries(): List - @Query("SELECT * FROM shuffle_order ORDER BY sequence") - suspend fun shuffleOrder(): List - - @Query("DELETE FROM shuffle_order") - suspend fun clearShuffleOrder() - - @Insert - suspend fun insertShuffleOrder(entries: List) - @Query("SELECT * FROM playback_cursor WHERE id = 1") suspend fun cursor(): PlaybackCursor? 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 24f71f52..6f3ef79d 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -12,7 +12,6 @@ import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueKindConverter -import com.subtracks.data.model.ShuffleOrder import com.subtracks.data.model.Song import com.subtracks.data.model.Source import com.subtracks.data.model.SubsonicSource @@ -31,10 +30,9 @@ import com.subtracks.data.model.UpNextEntry QueueEntry::class, UpNextEntry::class, PlaybackCursor::class, - ShuffleOrder::class, ArtworkSeed::class, ], - version = 16, + version = 17, 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 7d9f8035..587d6811 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -305,16 +305,11 @@ data class PlaybackCursor( @ColumnInfo(defaultValue = "0") val shuffleEnabled: Boolean = false, @ColumnInfo(defaultValue = "0") val repeatMode: Int = 0, @ColumnInfo(defaultValue = "0") val shuffleSeed: Long = 0, + @ColumnInfo(defaultValue = "0") val shuffleSize: Long = 0, @ColumnInfo(defaultValue = "0") val positionMs: Long = 0, @ColumnInfo(defaultValue = "0") val upNextAnchor: Long = 0, ) -@Entity(tableName = "shuffle_order") -data class ShuffleOrder( - @PrimaryKey val sequence: Long, - val flatPosition: Long, -) - @Entity( tableName = "artwork_seeds", foreignKeys = [ diff --git a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt index fbb3cf4d..7ae286f5 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -8,7 +8,6 @@ import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.model.PlaybackCursor import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueKind -import com.subtracks.data.model.ShuffleOrder import com.subtracks.data.model.SongListItem import com.subtracks.data.model.UpNextEntry import kotlinx.coroutines.flow.first @@ -33,7 +32,7 @@ private data class EntryLengthKey( private data class LoadedSnapshot( val entries: List, val upNext: List, - val shuffleOrder: LongArray?, + val shuffleSeed: Long?, val anchor: Long, ) @@ -45,7 +44,7 @@ data class ResolvedQueueEntry( data class QueueSnapshot( val entries: List, val upNext: List = emptyList(), - val shuffleOrder: LongArray? = null, + val shuffleSeed: Long? = null, val upNextAnchor: Long = 0, val version: Long = 0, ) { @@ -55,13 +54,14 @@ data class QueueSnapshot( val size: Long = contextSize + upNextSize - val shuffled: Boolean get() = shuffleOrder != null + val shuffled: Boolean get() = shuffleSeed != null val anchorPlay: Long = ( - when (val order = shuffleOrder) { - null -> upNextAnchor - else -> if (upNextAnchor < 0L) -1L else order.indexOf(upNextAnchor).takeIf { it >= 0 }?.toLong() ?: 0L + when { + shuffleSeed == null -> upNextAnchor + upNextAnchor < 0L -> -1L + else -> playContext(upNextAnchor) } ).coerceAtMost(contextSize - 1L) @@ -75,11 +75,15 @@ data class QueueSnapshot( fun combined(contextPlay: Long): Long = if (contextPlay <= anchorPlay) contextPlay else contextPlay + upNextSize - fun flatContext(contextPlay: Long): Long? = - when (val order = shuffleOrder) { - null -> contextPlay.takeIf { it in 0 until contextSize } - else -> order.getOrNull(contextPlay.toInt()) - } + fun flatContext(contextPlay: Long): Long? { + if (contextPlay !in 0 until contextSize) return null + return shuffleSeed?.let { Shuffle.toFlat(it, contextSize, contextPlay) } ?: contextPlay + } + + fun playContext(flat: Long): Long { + if (flat !in 0 until contextSize) return flat + return shuffleSeed?.let { Shuffle.toSequence(it, contextSize, flat) } ?: flat + } fun locate(position: Long): Pair? { if (isUpNext(position)) return locateIn(upNext, upNextIndex(position)) @@ -134,12 +138,6 @@ class QueueRepository( ) { private val dao get() = db.queueDao() - @Volatile - private var cachedShuffleSeed = 0L - - @Volatile - private var cachedShuffleOrder: LongArray? = null - private val queueVersion = AtomicLong() @Volatile @@ -204,13 +202,13 @@ class QueueRepository( suspend fun replace( entries: List, - shuffleOrder: LongArray? = null, + shuffleSeed: Long? = null, upNext: List = emptyList(), upNextAnchor: Long = 0, ) { write(entries) writeUpNext(upNext) - setShuffle(shuffleOrder != null, shuffleOrder) + setShuffle(shuffleSeed != null, shuffleSeed ?: 0L) cursorMutex.withLock { dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0, upNextAnchor = upNextAnchor)) } @@ -228,11 +226,12 @@ class QueueRepository( ResolvedQueueEntry(entry, lengthOf(entry, version)) } val row = dao.cursor() - val order = if (row?.shuffleEnabled == true) loadShuffleOrder(row.shuffleSeed, entries.sumOf { it.length }) else null - LoadedSnapshot(entries, upNext, order, row?.upNextAnchor ?: 0L) + val size = entries.sumOf { it.length } + val seed = row?.takeIf { it.shuffleEnabled && size > 0L && it.shuffleSize == size }?.shuffleSeed + LoadedSnapshot(entries, upNext, seed, row?.upNextAnchor ?: 0L) } } - return QueueSnapshot(loaded.entries, loaded.upNext, loaded.shuffleOrder, loaded.anchor, version) + return QueueSnapshot(loaded.entries, loaded.upNext, loaded.shuffleSeed, loaded.anchor, version) } suspend fun modes(): QueueModes { @@ -242,24 +241,22 @@ class QueueRepository( suspend fun setShuffle( enabled: Boolean, - order: LongArray?, + seed: Long, ) { - val seed = if (enabled && order != null) System.nanoTime() else 0L + val size = if (enabled) contextSize() else 0L cursorMutex.withLock { - db.useWriterConnection { transactor -> - transactor.immediateTransaction { - dao.clearShuffleOrder() - if (enabled && order != null) { - dao.insertShuffleOrder(order.mapIndexed { index, flat -> ShuffleOrder(index.toLong(), flat) }) - } - dao.setCursor(cursorRow().copy(shuffleEnabled = enabled, shuffleSeed = seed)) - } - } + dao.setCursor( + cursorRow().copy( + shuffleEnabled = enabled, + shuffleSeed = if (enabled) seed else 0L, + shuffleSize = size, + ), + ) } - cachedShuffleSeed = 0 - cachedShuffleOrder = null } + private suspend fun setShuffleSize(size: Long) = cursorMutex.withLock { dao.setCursor(cursorRow().copy(shuffleSize = size)) } + fun invalidateLibraryCache() { cachedFlatIds = null lengthCache.clear() @@ -268,18 +265,6 @@ class QueueRepository( suspend fun setRepeat(mode: Int) = cursorMutex.withLock { dao.setCursor(cursorRow().copy(repeatMode = mode)) } - private suspend fun loadShuffleOrder( - seed: Long, - size: Long, - ): LongArray? { - val cached = cachedShuffleOrder - if (seed == cachedShuffleSeed && cached != null && cached.size.toLong() == size) return cached - val loaded = dao.shuffleOrder().map { it.flatPosition }.toLongArray() - cachedShuffleSeed = seed - cachedShuffleOrder = loaded.takeIf { it.size.toLong() == size } - return cachedShuffleOrder - } - suspend fun itemAt( snapshot: QueueSnapshot, position: Long, @@ -303,9 +288,7 @@ class QueueRepository( ): Long? { val flat = flatIds(snapshot).indexOf(songId) if (flat < 0) return null - val play = snapshot.shuffleOrder?.indexOf(flat.toLong())?.toLong() ?: flat.toLong() - if (play < 0) return null - return snapshot.combined(play) + return snapshot.combined(snapshot.playContext(flat.toLong())) } suspend fun range( @@ -407,18 +390,18 @@ class QueueRepository( return snapshot() } val play = snapshot.contextPlay(position) - val order = snapshot.shuffleOrder - if (order == null) { + if (snapshot.shuffled) { + // The permutation is derived from (seed, size), so removing a track re-derives it at + // the smaller size; keep the same seed and store the new size so the queue stays + // shuffled. There is no stable "next" to preserve here. + val flat = snapshot.flatContext(play) ?: return snapshot + write(compact(removeEntry(snapshot.entries, flat)).map { it.entry }) + adjustAnchorOnRemove(flat) + setShuffleSize(contextSize()) + } else { val removed = compact(removeEntry(snapshot.entries, play)) write(removed.map { it.entry }) adjustAnchorOnRemove(play) - } else { - val flat = order.getOrNull(play.toInt()) ?: return snapshot - write(compact(removeEntry(snapshot.entries, flat)).map { it.entry }) - val reordered = order.toMutableList().apply { removeAt(play.toInt()) } - for (index in reordered.indices) if (reordered[index] > flat) reordered[index] -= 1 - setShuffle(true, reordered.toLongArray()) - adjustAnchorOnRemove(flat) } return snapshot() } @@ -466,16 +449,12 @@ class QueueRepository( return true } if (fromUpNext != toUpNext) return false + // Reordering the context while shuffled would have to make the derived order match the + // drop, which needs materialising the order again. The queue view disables context drags + // while shuffled; manual ordering goes through the up-next block instead. + if (snapshot.shuffled) return false val playFrom = snapshot.contextPlay(from) val playTo = snapshot.contextPlay(to) - val order = snapshot.shuffleOrder - if (order != null) { - val reordered = order.toMutableList() - val moved = reordered.removeAt(playFrom.toInt()) - reordered.add(playTo.coerceIn(0L, reordered.size.toLong()).toInt(), moved) - setShuffle(true, reordered.toLongArray()) - return true - } val located = snapshot.locateContext(playFrom) ?: return false val song = itemAt(snapshot, from) ?: return false val removed = removeEntry(snapshot.entries, playFrom) @@ -503,6 +482,8 @@ class QueueRepository( private suspend fun cursorRow(): PlaybackCursor = dao.cursor() ?: PlaybackCursor(queuePosition = 0) + private suspend fun contextSize(): Long = dao.entries().sumOf { it.resolvedLength() } + private suspend fun write(entries: List) { db.useWriterConnection { transactor -> transactor.immediateTransaction { diff --git a/app/src/main/kotlin/com/subtracks/data/repo/Shuffle.kt b/app/src/main/kotlin/com/subtracks/data/repo/Shuffle.kt new file mode 100644 index 00000000..bcde387e --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/repo/Shuffle.kt @@ -0,0 +1,97 @@ +package com.subtracks.data.repo + +/** + * A deterministic, seekable permutation of `[0, size)`: `toFlat(seed, size, s)` is the track played at + * shuffled position `s` and `toSequence` is its inverse. Nothing per track is stored; the order is derived + * from the seed and the queue length. + * + * The permutation is a balanced four-round Feistel network over the smallest power of two domain that + * covers `size`, with `[size, domain)` skipped by cycle walking. A Feistel round is always a bijection, + * so the only requirement is that the two halves have equal width; cycle walking keeps it a bijection on + * `[0, size)`. The round function is a SplitMix64 mix of the seed, the half value and the round index. + */ +object Shuffle { + private const val ROUNDS = 4 + private const val MAX_SIZE = 1L shl 40 + + private val GOLDEN = 0x9E3779B97F4A7C15uL.toLong() + private val MIX_A = 0xBF58476D1CE4E5B9uL.toLong() + private val MIX_B = 0x94D049BB133111EBuL.toLong() + + fun toFlat( + seed: Long, + size: Long, + sequence: Long, + ): Long { + require(size <= MAX_SIZE) { "queue of $size tracks is too large to shuffle" } + if (size <= 1L) return 0L + if (sequence !in 0 until size) return sequence + var value = encrypt(seed, sequence, halfBits(size)) + while (value >= size) value = encrypt(seed, value, halfBits(size)) + return value + } + + fun toSequence( + seed: Long, + size: Long, + flat: Long, + ): Long { + require(size <= MAX_SIZE) { "queue of $size tracks is too large to shuffle" } + if (size <= 1L) return 0L + if (flat !in 0 until size) return flat + var value = decrypt(seed, flat, halfBits(size)) + while (value >= size) value = decrypt(seed, value, halfBits(size)) + return value + } + + private fun halfBits(size: Long): Int { + if (size <= 1L) return 1 + val bits = 64 - java.lang.Long.numberOfLeadingZeros(size - 1) + return (bits + 1) / 2 + } + + private fun encrypt( + seed: Long, + value: Long, + halfBits: Int, + ): Long { + val mask = (1L shl halfBits) - 1 + var left = value ushr halfBits + var right = value and mask + for (round in 0 until ROUNDS) { + val nextLeft = right + val nextRight = left xor (mix(seed, right, round) and mask) + left = nextLeft + right = nextRight + } + return (left shl halfBits) or right + } + + private fun decrypt( + seed: Long, + value: Long, + halfBits: Int, + ): Long { + val mask = (1L shl halfBits) - 1 + var left = value ushr halfBits + var right = value and mask + for (round in ROUNDS - 1 downTo 0) { + val previousLeft = right xor (mix(seed, left, round) and mask) + val previousRight = left + left = previousLeft + right = previousRight + } + return (left shl halfBits) or right + } + + private fun mix( + seed: Long, + value: Long, + round: Int, + ): Long { + var z = seed + value * GOLDEN + round * MIX_A + z = (z xor (z ushr 30)) * MIX_A + z = (z xor (z ushr 27)) * MIX_B + return z xor (z ushr 31) + } +} diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 413d8d55..83e5276c 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -6,6 +6,7 @@ import com.subtracks.data.model.QueueKind import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueSnapshot import com.subtracks.data.repo.QueueWindowItem +import com.subtracks.data.repo.Shuffle import com.subtracks.data.repo.SourceRepository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher @@ -21,7 +22,6 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext import kotlin.random.Random data class QueueItem( @@ -59,7 +59,7 @@ private sealed interface QueueUndo { val entries: List val cursor: Long val current: Long? - val shuffleOrder: LongArray? + val shuffleSeed: Long? val upNext: List val upNextAnchor: Long } @@ -70,7 +70,7 @@ private data class RemovedUndo( override val current: Long, val position: Long, val item: QueueItem?, - override val shuffleOrder: LongArray?, + override val shuffleSeed: Long?, override val upNext: List, override val upNextAnchor: Long, ) : QueueUndo @@ -81,7 +81,7 @@ private data class MovedUndo( override val current: Long, val from: Long, val to: Long, - override val shuffleOrder: LongArray?, + override val shuffleSeed: Long?, override val upNext: List, override val upNextAnchor: Long, ) : QueueUndo @@ -89,7 +89,7 @@ private data class MovedUndo( private data class ReloadUndo( override val entries: List, override val cursor: Long, - override val shuffleOrder: LongArray?, + override val shuffleSeed: Long?, override val upNext: List, override val upNextAnchor: Long, ) : QueueUndo { @@ -253,14 +253,15 @@ class PlaybackController( return@withLock } val start = randomIndex(snap.contextSize, avoid) - val order = withContext(Dispatchers.Default) { shuffledOrder(snap.contextSize, start) } - queueRepository.setShuffle(true, order) + val seed = Random.nextLong() + val position = Shuffle.toSequence(seed, snap.contextSize, start) + queueRepository.setShuffle(true, seed) shuffleEnabled = true queueSourceId = entry.sourceId - queueRepository.setCursor(0) + queueRepository.setCursor(position) snap = queueRepository.snapshot() this@PlaybackController.snapshot = snap - loadWindow(0, autoplay = true) + loadWindow(position, autoplay = true) } private fun randomIndex( @@ -314,7 +315,7 @@ class PlaybackController( if (snapshot.size == 1L) { queueRepository.removeAt(snapshot, position) stopLocked() - lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor) + lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor) return@withLock } val current = currentPosition() ?: return@withLock @@ -327,24 +328,40 @@ class PlaybackController( windowJob?.cancel() if (updated.size == 0L) { stopLocked() - lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor) + lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor) return@withLock } val currentId = player.currentItem?.id + val fallback = + if (snapshot.shuffled && !wasUpNext) { + // The permutation is re-derived at the smaller size, so there is no stable + // "next"; play whatever now fills the removed track's slot. + val seed = snapshot.shuffleSeed!! + val removedFlat = snapshot.flatContext(snapshot.contextPlay(position)) ?: 0L + val newContextSize = (snapshot.contextSize - 1).coerceAtLeast(0) + if (newContextSize == 0L) { + 0L + } else { + val slot = removedFlat.coerceIn(0, newContextSize - 1) + updated.combined(Shuffle.toSequence(seed, newContextSize, slot)) + } + } else { + (if (position < current) current - 1 else current) + } val target = ( currentId?.let { if (wasUpNext) queueRepository.combinedIndexOf(updated, it) else queueRepository.combinedContextIndexOf(updated, it) - } ?: (if (position < current) current - 1 else current) + } ?: fallback ).coerceIn(0, updated.size - 1) lastPosition = target lastEdit = if (removed == null) { - ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor) + ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor) } else { - RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor) + RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor) } - if (snapshot.upNextSize > 0L || updated.upNextSize > 0L) { + if (snapshot.upNextSize > 0L || updated.upNextSize > 0L || snapshot.shuffled) { queueRepository.setCursor(target) if (position != current) { rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target) @@ -410,9 +427,9 @@ class PlaybackController( val toInWindow = to in windowStart..windowEnd lastEdit = if (fromInWindow == toInWindow) { - MovedUndo(entries, cursor, target, from, to, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor) + MovedUndo(entries, cursor, target, from, to, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor) } else { - ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor) + ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor) } when { fromInWindow && toInWindow -> { @@ -452,8 +469,8 @@ class PlaybackController( val player = player ?: return@withLock val playing = player.playWhenReady val positionMs = player.currentPositionMs - queueRepository.replace(undo.entries, undo.shuffleOrder, undo.upNext, undo.upNextAnchor) - shuffleEnabled = undo.shuffleOrder != null + queueRepository.replace(undo.entries, undo.shuffleSeed, undo.upNext, undo.upNextAnchor) + shuffleEnabled = undo.shuffleSeed != null val restored = readSnapshot() this.snapshot = restored lastEdit = null @@ -702,11 +719,11 @@ class PlaybackController( val flat = snapshot.flatContext(snapshot.anchorContextPlay(current)) ?: return@withLock windowJob?.cancel() val enabling = !shuffleEnabled + val seed = if (enabling) Random.nextLong() else 0L if (enabling) { - val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.contextSize, flat) } - queueRepository.setShuffle(true, order) + queueRepository.setShuffle(true, seed) } else { - queueRepository.setShuffle(false, null) + queueRepository.setShuffle(false, 0L) } shuffleEnabled = enabling val reordered = readSnapshot() @@ -714,7 +731,7 @@ class PlaybackController( val position = when { wasUpNext -> reordered.anchorPlay + 1 + blockIndex - enabling -> reordered.combined(0) + enabling -> reordered.combined(Shuffle.toSequence(seed, reordered.contextSize, flat)) else -> reordered.combined(flat) } val anchor = queueRepository.reanchorFor(reordered, position) @@ -784,9 +801,9 @@ class PlaybackController( val start = startPosition.coerceIn(0, snapshot.contextSize - 1) val position = if (shuffleEnabled) { - val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.contextSize, start) } - queueRepository.setShuffle(true, order) - 0L + val seed = Random.nextLong() + queueRepository.setShuffle(true, seed) + Shuffle.toSequence(seed, snapshot.contextSize, start) } else { start } @@ -808,9 +825,9 @@ class PlaybackController( var snapshot = queueRepository.snapshot() if (shuffleEnabled && !snapshot.shuffled && snapshot.contextSize > 0L) { val start = snapshot.anchorContextPlay(queueRepository.cursor()).coerceIn(0, snapshot.contextSize - 1) - val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.contextSize, start) } - queueRepository.setShuffle(true, order) - queueRepository.setCursor(0) + val seed = Random.nextLong() + queueRepository.setShuffle(true, seed) + queueRepository.setCursor(Shuffle.toSequence(seed, snapshot.contextSize, start)) snapshot = queueRepository.snapshot() } else if (!snapshot.shuffled) { shuffleEnabled = false @@ -1072,20 +1089,12 @@ class PlaybackController( if (repeatMode == RepeatMode.All) jumpTo(0) } - private fun shuffledOrder( - size: Long, - first: Long, - ): LongArray { - val rest = (0 until size).filter { it != first }.shuffled() - return (listOf(first) + rest).toLongArray() - } - private fun Int.toRepeatMode(): RepeatMode = RepeatMode.entries.getOrElse(this) { RepeatMode.Off } private suspend fun readSnapshot(): QueueSnapshot { val snapshot = queueRepository.snapshot() if (!snapshot.shuffled) { - if (shuffleEnabled && snapshot.size > 0L) queueRepository.setShuffle(false, null) + if (shuffleEnabled && snapshot.size > 0L) queueRepository.setShuffle(false, 0L) shuffleEnabled = false } return snapshot diff --git a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt index 49032676..a62b8dff 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt @@ -2,6 +2,7 @@ package com.subtracks.data.repo import android.content.Context 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 @@ -440,15 +441,20 @@ class QueueRepositoryTest { runTest { seedLibrary() val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) - repository.setShuffle(true, longArrayOf(2, 0, 1)) + repository.setShuffle(true, 12345L) val shuffled = repository.snapshot() + val contextBefore = (0 until shuffled.contextSize).mapNotNull { repository.itemAt(shuffled, it)?.song?.id } repository.addUpNext(shuffled, currentPosition = 0, entry = repository.songEntry(1, "s5"), playNext = false) val updated = repository.snapshot() assertEquals(4, updated.size) - assertEquals("s5", repository.itemAt(updated, 1)?.song?.id) - assertEquals(listOf("s3", "s5", "s1", "s2"), resolveAll(updated)) + assertEquals("s5", repository.itemAt(updated, updated.anchorPlay + 1)?.song?.id) + val contextAfter = + (0 until updated.size) + .filterNot { updated.isUpNext(it) } + .mapNotNull { repository.itemAt(updated, it)?.song?.id } + assertEquals(contextBefore, contextAfter) assertTrue(updated.shuffled) } @@ -535,11 +541,11 @@ class QueueRepositoryTest { (0 until snapshot.size).mapNotNull { repository.itemAt(snapshot, it)?.song?.id } @Test - fun aStaleShuffleOrderIsIgnoredWhenTheQueueLengthChanges() = + fun aStaleShuffleSizeIsIgnoredWhenTheQueueLengthChanges() = runTest { seedLibrary() repository.replace(listOf(repository.albumEntry(1, "al1"))) - repository.setShuffle(true, longArrayOf(2, 0, 1)) + repository.setShuffle(true, 1234L) assertTrue(repository.snapshot().shuffled) db.libraryDao().upsertSongs(listOf(song("s6", "al1", track = 4, album = "First Album"))) @@ -548,6 +554,91 @@ class QueueRepositoryTest { assertFalse(repository.snapshot().shuffled) } + @Test + fun enablingShuffleStoresNoPerTrackRows() = + runTest { + seedLibrary() + repository.replace(listOf(repository.albumEntry(1, "al1"))) + val entriesBefore = db.queueDao().entries().size + + repository.setShuffle(true, 5678L) + val snapshot = repository.snapshot() + + assertTrue(snapshot.shuffled) + assertEquals(3L, snapshot.contextSize) + assertEquals(entriesBefore, db.queueDao().entries().size) + assertFalse(shuffleOrderTableExists()) + } + + private suspend fun shuffleOrderTableExists(): Boolean = + db.useReaderConnection { transactor -> + transactor.usePrepared( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'shuffle_order'", + ) { statement -> + if (statement.step()) statement.getLong(0) > 0 else false + } + } + + @Test + fun shuffledPositionsResolveToEveryTrackExactlyOnce() = + runTest { + seedLibrary() + repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.setShuffle(true, 42L) + val shuffled = repository.snapshot() + + val resolved = (0 until shuffled.size).mapNotNull { repository.itemAt(shuffled, it)?.song?.id } + + assertEquals(3, resolved.distinct().size) + assertEquals(listOf("s1", "s2", "s3"), resolved.sorted()) + val flatPositions = (0 until shuffled.size).map { shuffled.flatContext(it)!! } + assertEquals((0L until shuffled.size).toList(), flatPositions.sorted()) + } + + @Test + fun togglingShuffleOffRestoresTheOriginalOrder() = + runTest { + seedLibrary() + repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.setShuffle(true, 99L) + assertTrue(repository.snapshot().shuffled) + + repository.setShuffle(false, 0L) + + val restored = repository.snapshot() + assertFalse(restored.shuffled) + assertEquals(listOf("s1", "s2", "s3"), resolveAll(restored)) + } + + @Test + fun shuffledRangeResolvesEntriesInPositionOrder() = + runTest { + seedLibrary() + repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4"))) + repository.setShuffle(true, 42L) + val snapshot = repository.snapshot() + + val range = repository.range(snapshot, 0, snapshot.size - 1) + + assertEquals((0L until snapshot.size).toList(), range.map { it.position }) + assertEquals(setOf("s1", "s2", "s3", "s4"), range.map { it.item.song.id }.toSet()) + } + + @Test + fun shuffledRangeResolvesASplitEntryWithoutRepeatingOrSkipping() = + runTest { + seedLibrary() + val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.removeAt(snapshot, 1) + repository.setShuffle(true, 7L) + val shuffled = repository.snapshot() + + val range = repository.range(shuffled, 0, shuffled.size - 1) + + assertEquals(listOf(0L, 1L), range.map { it.position }) + assertEquals(setOf("s1", "s3"), range.map { it.item.song.id }.toSet()) + } + private suspend fun seedLibrary() { db.sourcesDao().upsertSource( Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0), diff --git a/app/src/test/kotlin/com/subtracks/data/repo/ShuffleTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/ShuffleTest.kt new file mode 100644 index 00000000..c6ab9846 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/repo/ShuffleTest.kt @@ -0,0 +1,113 @@ +package com.subtracks.data.repo + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ShuffleTest { + private val sizes = + listOf( + 1L, + 2L, + 3L, + 4L, + 5L, + 7L, + 8L, + 9L, + 15L, + 16L, + 17L, + 31L, + 32L, + 33L, + 63L, + 64L, + 65L, + 100L, + 127L, + 128L, + 129L, + 255L, + 256L, + 257L, + 999L, + 1000L, + 1023L, + 1024L, + 1025L, + 4096L, + 65535L, + 65536L, + 65537L, + 100_000L, + 999_983L, + ) + + private val seeds = listOf(0L, 1L, -1L, 42L, Long.MIN_VALUE, Long.MAX_VALUE, 0x5DEECE66DL) + + @Test + fun mapsEverySizeAsABijection() { + for (size in sizes) { + for (seed in seeds) { + val mapped = (0 until size).map { Shuffle.toFlat(seed, size, it) } + assertEquals("size=$size seed=$seed has duplicates", mapped.distinct().size, size.toInt()) + assertTrue("size=$size seed=$seed out of range", mapped.all { it in 0 until size }) + for (sequence in 0 until size) { + assertEquals( + "size=$size seed=$seed sequence=$sequence did not round trip", + sequence, + Shuffle.toSequence(seed, size, Shuffle.toFlat(seed, size, sequence)), + ) + } + } + } + } + + @Test + fun mapsLargeSizesAsABijectionOnSamples() { + for (size in listOf(1_000_003L, 16_777_216L, 16_777_217L, 1_000_000_000L)) { + for (seed in seeds) { + val samples = (0 until 5000).map { size / 5000 * it + it % 97 } + val mapped = samples.map { Shuffle.toFlat(seed, size, it) } + assertEquals(mapped.distinct().size, mapped.size) + assertTrue(mapped.all { it in 0 until size }) + samples.forEach { sequence -> + assertEquals(sequence, Shuffle.toSequence(seed, size, Shuffle.toFlat(seed, size, sequence))) + } + } + } + } + + @Test + fun spreadsTheFirstPositionAcrossSeeds() { + val size = 1000L + val buckets = LongArray(10) + val draws = 2000 + for (i in 0 until draws) { + val flat = Shuffle.toFlat(i.toLong() * 2654435761L + 17L, size, 0L) + buckets[(flat * buckets.size / size).toInt()]++ + } + val expected = draws / buckets.size + buckets.forEach { count -> + assertTrue("bucket $count is far from $expected", count in expected / 2..expected * 2) + } + } + + @Test + fun doesNotLeaveMostTracksInPlace() { + val size = 1000L + for (seed in seeds) { + val moved = (0 until size).count { Shuffle.toFlat(seed, size, it) != it } + assertTrue("seed=$seed left too many tracks in place", moved > size * 9 / 10) + } + } + + @Test + fun consecutivePositionsAreNotASingleStride() { + val size = 1000L + val order = (0 until size).map { Shuffle.toFlat(7L, size, it) } + val strides = order.zipWithNext { a, b -> ((b - a) % size + size) % size }.toSet() + assertTrue("the order looks like one fixed stride", strides.size > size / 2) + } +} diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 5f2b52fb..d6ccf537 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -17,6 +17,7 @@ import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.prefs.fakeUserPreferences import com.subtracks.data.repo.QueueRepository +import com.subtracks.data.repo.Shuffle import com.subtracks.data.repo.SourceRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asCoroutineDispatcher @@ -1526,12 +1527,13 @@ class PlaybackControllerTest { controller.state.value.item ?.id, ) - assertEquals(0L, controller.state.value.position) - assertEquals("s3", handle.items.first().id) + val shuffled = runBlocking { queues.snapshot() } + assertEquals("s3", runBlocking { queues.itemAt(shuffled, controller.state.value.position!!)?.song?.id }) controller.toggleShuffle() await { !controller.state.value.shuffle } + assertEquals(2L, controller.state.value.position) assertEquals(listOf("s1", "s2", "s3", "s4", "s5"), handle.items.map { it.id }) assertEquals( "s3", @@ -1540,6 +1542,109 @@ class PlaybackControllerTest { ) } + @Test + fun removingATrackWhileShuffledKeepsTheCurrentTrackPlaying() { + seedAlbum(6, sourceId = 1) + + controller.playAlbum(1, "al1", 2) + await { + controller.state.value.item + ?.id == "s3" + } + + controller.toggleShuffle() + await { controller.state.value.shuffle } + val currentId = + controller.state.value.item!! + .id + val removeAt = (controller.state.value.position!! + 1) % 6 + + runBlocking { controller.removeAt(removeAt) } + await { + controller.state.value.item + ?.id == currentId && controller.state.value.shuffle + } + + assertTrue(handle.items.map { it.id }.contains(currentId)) + val remaining = + runBlocking { + val snap = queues.snapshot() + (0 until snap.size).mapNotNull { queues.itemAt(snap, it)?.song?.id } + } + assertEquals(5, remaining.size) + assertEquals(5, remaining.distinct().size) + } + + @Test + fun removingTheCurrentTrackWhileShuffledPlaysTheSongThatFillsItsSlot() { + seedAlbum(6, sourceId = 1) + + controller.playAlbum(1, "al1", 2) + await { + controller.state.value.item + ?.id == "s3" + } + + controller.toggleShuffle() + await { controller.state.value.shuffle } + val current = controller.state.value.position!! + val before = runBlocking { queues.snapshot() } + val removedFlat = before.flatContext(current)!! + + runBlocking { controller.removeAt(current) } + + val after = runBlocking { queues.snapshot() } + val newContextSize = (before.contextSize - 1).coerceAtLeast(0) + val slot = removedFlat.coerceIn(0, (newContextSize - 1).coerceAtLeast(0)) + val expected = after.combined(Shuffle.toSequence(before.shuffleSeed!!, newContextSize, slot)) + assertEquals(5L, after.size) + assertEquals(expected, controller.state.value.position) + assertEquals( + runBlocking { queues.itemAt(after, expected)?.song?.id }, + controller.state.value.item + ?.id, + ) + } + + @Test + fun movingContextTracksWhileShuffledIsIgnored() { + seedAlbum(6, sourceId = 1) + + controller.playAlbum(1, "al1", 2) + await { + controller.state.value.item + ?.id == "s3" + } + + controller.toggleShuffle() + await { controller.state.value.shuffle } + val currentId = + controller.state.value.item!! + .id + val before = + runBlocking { + val snap = queues.snapshot() + (0 until snap.size).mapNotNull { queues.itemAt(snap, it)?.song?.id } + } + val position = controller.state.value.position!! + val from = (position + 1) % 6 + val to = (position + 3) % 6 + + runBlocking { controller.move(from, to) } + + val after = + runBlocking { + val snap = queues.snapshot() + (0 until snap.size).mapNotNull { queues.itemAt(snap, it)?.song?.id } + } + assertEquals(before, after) + assertEquals( + currentId, + controller.state.value.item + ?.id, + ) + } + @Test fun nextWrapsToTheStartWhenRepeating() { seedAlbum(3, sourceId = 1) @@ -1679,8 +1784,9 @@ class PlaybackControllerTest { ?.id == "s4" } - assertEquals(0L, controller.state.value.position) - assertEquals("s4", handle.items.first().id) + assertEquals("s4", handle.currentItem?.id) + val shuffled = runBlocking { queues.snapshot() } + assertEquals("s4", runBlocking { queues.itemAt(shuffled, controller.state.value.position!!)?.song?.id }) } @Test @@ -1761,8 +1867,15 @@ class PlaybackControllerTest { await { controller.state.value.item != null && controller.state.value.shuffle } assertTrue(controller.state.value.shuffle) - assertEquals(0L, controller.state.value.position) - assertTrue(handle.items.first().id in listOf("s1", "s2", "s3", "s4", "s5")) + assertTrue( + controller.state.value.item!! + .id in listOf("s1", "s2", "s3", "s4", "s5"), + ) + assertEquals( + controller.state.value.item!! + .id, + handle.currentItem?.id, + ) } @Test diff --git a/docs/architecture.md b/docs/architecture.md index c84e1b5d..1dd25d9e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -84,7 +84,7 @@ The queue lives in SQL, not memory. `queue_entries` is an ordered list of *refer `QueueRepository` resolves a queue position to a song lazily: it walks the entries summing their lengths (a `COUNT(*)` per entry) and then fetches one row with `LIMIT 1 OFFSET`. `PlaybackController` keeps only the resolved entry lengths and the cursor in memory — never the songs — and hands the player a bounded window (currently `2 * 25 + 1` items) centred on the cursor. A player index maps back to a queue position as `windowStart + index`. The window is loaded once with `setMediaItems` and then shifted incrementally with `addMediaItems`/`removeMediaItem` as playback advances; a shift never replaces the playlist, so the current item and its prepared next source survive and transitions stay gapless. `hasNext`/`hasPrevious` are intentionally always true rather than derived from the SQL queue size: the previous/next buttons stay usable at both ends of the queue so repeat mode and restart-the-track keep working, and `next()`/`previous()` decide the actual end-of-queue action (advance, wrap when repeating, restart, or no-op). The snapshot is re-read whenever the window shifts and on skip, so a sync that shrinks the referenced album or playlist cannot strand the window on stale lengths; if the active source changes under a loaded queue, playback stops rather than resolving the old queue's stream URLs against the new server. -Playback is a *context* plus a manual *up next* list. `queue_entries` is the context — the album, playlist or song you started, and the only thing shuffle permutes. `up_next_entries` is the manual queue: the same reference-plus-range rows, ordered, never shuffled. A `playback_cursor.upNextAnchor` holds the context track the block sits behind; the controller re-anchors it whenever playback lands on a different context track, so the block always plays next. `QueueRepository` resolves a combined position by mapping it through the context order and splicing the block after the anchor, so the controller's window and next/previous logic keep working. Add to queue appends to the block and play next inserts at its front; both reuse the context edit machinery (range split, insert, compact). Advancing out of a block track removes it — consume as played, with no history — and a new context clears the block, so a manual edit never disturbs the shuffle permutation. +Playback is a *context* plus a manual *up next* list. `queue_entries` is the context — the album, playlist or song you started, and the only thing shuffle permutes. `up_next_entries` is the manual queue: the same reference-plus-range rows, ordered, never shuffled. A `playback_cursor.upNextAnchor` holds the context track the block sits behind; the controller re-anchors it whenever playback lands on a different context track, so the block always plays next. `QueueRepository` resolves a combined position by mapping it through the context order and splicing the block after the anchor, so the controller's window and next/previous logic keep working. Add to queue appends to the block and play next inserts at its front; both reuse the context edit machinery (range split, insert, compact). Advancing out of a block track removes it — consume as played, with no history — and a new context clears the block, so a manual edit never disturbs the shuffle permutation. Shuffle itself stores no per-track order: `playback_cursor.shuffleSeed`/`shuffleSize` are the whole state, and a position maps through a seekable Feistel permutation of `[0, size)`, so a very large album cannot materialise an order list. Reordering the context while shuffled would need that order back, so the context is playback-only while shuffled and manual ordering goes through the up-next block. Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index cd192112..6f7aa5eb 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -57,7 +57,7 @@ Not done yet in this slice (deliberately): cover-art tonal colour extraction, lo Follow-up work for very large libraries, playlists and long sessions, grouped so each block can be one PR. - **Library read path.** Index the sort keys used by album/artist/playlist browsing (a denormalized `albumArtist` on `albums` plus `NOCASE` indices) and replace `LIMIT 1 OFFSET` with keyset/cursor seeks in `QueueRepository`/`QueueDao`. Shared dependency for the read-side fixes; can split into "indices" then "keyset" if too large for one review. -- **Shuffle.** Drop `shuffle_order` and the retained whole-order/whole-id arrays; derive the permutation from `(seed, size)` and resolve positions through the same positional lookup. Recommended after the read-path PR, since it shares `QueueRepository`. +- **Shuffle.** Landed: the permutation is derived from `(seed, size)` (a seekable Feistel in `Shuffle.kt`) and positions resolve through the same positional lookup, so `shuffle_order` and the retained whole-order/whole-id arrays are gone. The context is read-only while shuffled; manual ordering goes through the up-next block. - **Sync pipeline.** Prune with a memory-bounded id set, remove `fetchRanks` and the write-only rank columns, fetch playlists/albums with bounded concurrency and skip unchanged ones, and raise/make configurable the page cap with a clear "library too large" failure. Independent of the read path. - **Queue view and edits.** Bound the queue view's loaded rows (or page over the queue ordinal) and scope its ViewModel to the overlay; compact adjacent same-ref ranges after an edit and cache resolved entry lengths. The view half is independent; the edit half shares `QueueRepository` with the read-path PR. - **Artwork.** Give `artwork_seeds` a `sourceId` with `ON DELETE CASCADE` and an LRU/TTL (or drop the table and rely on Coil's disk cache); prefetch the thumbnail, not the original, on now-playing transitions. Independent.