From 44e2eb7ae893391f87e7cc2328b9f92c978ce0e2 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Thu, 12 Nov 2026 07:42:46 +0000 Subject: [PATCH] queue: add serialized, origin-aware mutations --- .../39.json | 2082 +++++++++++++++++ .../com/subtracks/data/db/DownloadDao.kt | 2 +- .../com/subtracks/data/db/Migrations.kt | 30 + .../kotlin/com/subtracks/data/db/QueueDao.kt | 51 +- .../subtracks/data/db/SubtracksDatabase.kt | 4 +- .../com/subtracks/data/model/Download.kt | 9 +- .../kotlin/com/subtracks/data/model/Models.kt | 49 +- .../subtracks/data/repo/QueueRepository.kt | 579 +++-- .../subtracks/playback/PlaybackController.kt | 39 +- .../data/repo/DownloadRepositoryTest.kt | 4 +- .../data/repo/QueueMutationTestSupport.kt | 49 + .../data/repo/QueueRepositoryTest.kt | 550 ++++- .../subtracks/data/sync/SyncManagerTest.kt | 4 +- .../playback/PlaybackControllerTest.kt | 177 +- .../ui/playback/QueueViewModelTest.kt | 2 + docs/architecture.md | 8 +- 16 files changed, 3208 insertions(+), 431 deletions(-) create mode 100644 app/schemas/com.subtracks.data.db.SubtracksDatabase/39.json create mode 100644 app/src/test/kotlin/com/subtracks/data/repo/QueueMutationTestSupport.kt diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/39.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/39.json new file mode 100644 index 00000000..fbc1eacf --- /dev/null +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/39.json @@ -0,0 +1,2082 @@ +{ + "formatVersion": 1, + "database": { + "version": 39, + "identityHash": "ee91ebd8ca8608584ff44c68b6d9f224", + "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, `creditsStale` INTEGER NOT NULL DEFAULT 1)", + "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 + }, + { + "fieldPath": "creditsStale", + "columnName": "creditsStale", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "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, `artistImageUrl` TEXT, `coverArts` TEXT NOT NULL DEFAULT '', `downloadedCoverArts` TEXT NOT NULL DEFAULT '', `playCount` INTEGER NOT NULL DEFAULT 0, `played` INTEGER, `songCount` INTEGER NOT NULL DEFAULT 0, `sortName` TEXT NOT NULL COLLATE NOCASE DEFAULT '', `credited` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumCount", + "columnName": "albumCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER" + }, + { + "fieldPath": "coverArt", + "columnName": "coverArt", + "affinity": "TEXT" + }, + { + "fieldPath": "artistImageUrl", + "columnName": "artistImageUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "coverArts", + "columnName": "coverArts", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "downloadedCoverArts", + "columnName": "downloadedCoverArts", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "playCount", + "columnName": "playCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "played", + "columnName": "played", + "affinity": "INTEGER" + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "credited", + "columnName": "credited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_artists_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_artists_name", + "unique": false, + "columnNames": [ + "sourceId", + "sortName", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `${TABLE_NAME}` (`sourceId`, `sortName`, `id`)" + }, + { + "name": "index_artists_name_plain", + "unique": false, + "columnNames": [ + "sourceId", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name_plain` ON `${TABLE_NAME}` (`sourceId`, `name`, `id`)" + }, + { + "name": "index_artists_albumCount", + "unique": false, + "columnNames": [ + "sourceId", + "albumCount", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_albumCount` ON `${TABLE_NAME}` (`sourceId` ASC, `albumCount` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_artists_starred", + "unique": false, + "columnNames": [ + "sourceId", + "starred", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_artists_frequent", + "unique": false, + "columnNames": [ + "sourceId", + "playCount", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_frequent` ON `${TABLE_NAME}` (`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_artists_recent", + "unique": false, + "columnNames": [ + "sourceId", + "played", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_recent` ON `${TABLE_NAME}` (`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "artist_bios", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `biography` TEXT, PRIMARY KEY(`sourceId`, `artistId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "biography", + "columnName": "biography", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "artistId" + ] + }, + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "artist_search", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`name`, `sortName`, tokenize=`trigram`, content=`artists`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowid" + ] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "trigram", + "tokenizerArgs": [], + "contentTable": "artists", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE BEFORE UPDATE ON `artists` BEGIN DELETE FROM `artist_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_BEFORE_DELETE BEFORE DELETE ON `artists` BEGIN DELETE FROM `artist_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE AFTER UPDATE ON `artists` BEGIN INSERT INTO `artist_search`(`rowid`, `name`, `sortName`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`sortName`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_AFTER_INSERT AFTER INSERT ON `artists` BEGIN INSERT INTO `artist_search`(`rowid`, `name`, `sortName`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`sortName`); END" + ] + }, + { + "tableName": "albums", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `artistId` TEXT, `name` TEXT NOT NULL COLLATE NOCASE, `albumArtist` TEXT COLLATE NOCASE, `created` INTEGER NOT NULL, `coverArt` TEXT, `genre` TEXT, `year` INTEGER, `starred` INTEGER, `songCount` INTEGER NOT NULL, `playCount` INTEGER NOT NULL DEFAULT 0, `played` INTEGER, `artists` TEXT NOT NULL DEFAULT '', `sortName` TEXT NOT NULL COLLATE NOCASE DEFAULT '', `albumArtistSort` TEXT NOT NULL COLLATE NOCASE DEFAULT '', PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "albumArtist", + "affinity": "TEXT" + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverArt", + "columnName": "coverArt", + "affinity": "TEXT" + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER" + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "playCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "played", + "columnName": "played", + "affinity": "INTEGER" + }, + { + "fieldPath": "artists", + "columnName": "artists", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "albumArtistSort", + "columnName": "albumArtistSort", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_albums_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_albums_sourceId_artistId", + "unique": false, + "columnNames": [ + "sourceId", + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" + }, + { + "name": "index_albums_name", + "unique": false, + "columnNames": [ + "sourceId", + "sortName", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_name` ON `${TABLE_NAME}` (`sourceId`, `sortName`, `id`)" + }, + { + "name": "index_albums_name_plain", + "unique": false, + "columnNames": [ + "sourceId", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_name_plain` ON `${TABLE_NAME}` (`sourceId`, `name`, `id`)" + }, + { + "name": "index_albums_artist", + "unique": false, + "columnNames": [ + "sourceId", + "albumArtistSort", + "year", + "sortName", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist` ON `${TABLE_NAME}` (`sourceId`, `albumArtistSort`, `year`, `sortName`, `id`)" + }, + { + "name": "index_albums_artist_plain", + "unique": false, + "columnNames": [ + "sourceId", + "albumArtist", + "year", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist_plain` ON `${TABLE_NAME}` (`sourceId`, `albumArtist`, `year`, `name`, `id`)" + }, + { + "name": "index_albums_year", + "unique": false, + "columnNames": [ + "sourceId", + "year", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_year` ON `${TABLE_NAME}` (`sourceId` ASC, `year` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_albums_added", + "unique": false, + "columnNames": [ + "sourceId", + "created", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_added` ON `${TABLE_NAME}` (`sourceId` ASC, `created` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_albums_starred", + "unique": false, + "columnNames": [ + "sourceId", + "starred", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_albums_frequent", + "unique": false, + "columnNames": [ + "sourceId", + "playCount", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_frequent` ON `${TABLE_NAME}` (`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_albums_recent", + "unique": false, + "columnNames": [ + "sourceId", + "played", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_recent` ON `${TABLE_NAME}` (`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "album_search", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`name`, `albumArtist`, `sortName`, `albumArtistSort`, tokenize=`trigram`, content=`albums`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "albumArtist", + "affinity": "TEXT" + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtistSort", + "columnName": "albumArtistSort", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowid" + ] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "trigram", + "tokenizerArgs": [], + "contentTable": "albums", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_album_search_BEFORE_UPDATE BEFORE UPDATE ON `albums` BEGIN DELETE FROM `album_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_album_search_BEFORE_DELETE BEFORE DELETE ON `albums` BEGIN DELETE FROM `album_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_album_search_AFTER_UPDATE AFTER UPDATE ON `albums` BEGIN INSERT INTO `album_search`(`rowid`, `name`, `albumArtist`, `sortName`, `albumArtistSort`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`albumArtist`, NEW.`sortName`, NEW.`albumArtistSort`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_album_search_AFTER_INSERT AFTER INSERT ON `albums` BEGIN INSERT INTO `album_search`(`rowid`, `name`, `albumArtist`, `sortName`, `albumArtistSort`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`albumArtist`, NEW.`sortName`, NEW.`albumArtistSort`); END" + ] + }, + { + "tableName": "discs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `disc` INTEGER NOT NULL, `title` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `albumId`, `disc`), FOREIGN KEY(`sourceId`, `albumId`) REFERENCES `albums`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disc", + "columnName": "disc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "albumId", + "disc" + ] + }, + "indices": [ + { + "name": "index_discs_sourceId_albumId", + "unique": false, + "columnNames": [ + "sourceId", + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discs_sourceId_albumId` ON `${TABLE_NAME}` (`sourceId`, `albumId`)" + } + ], + "foreignKeys": [ + { + "table": "albums", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId", + "albumId" + ], + "referencedColumns": [ + "sourceId", + "id" + ] + } + ] + }, + { + "tableName": "album_artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `albumId`, `position`), FOREIGN KEY(`sourceId`, `albumId`) REFERENCES `albums`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "albumId", + "position" + ] + }, + "indices": [ + { + "name": "index_album_artists_sourceId_artistId", + "unique": false, + "columnNames": [ + "sourceId", + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artists_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" + } + ], + "foreignKeys": [ + { + "table": "albums", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId", + "albumId" + ], + "referencedColumns": [ + "sourceId", + "id" + ] + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL COLLATE NOCASE, `comment` TEXT, `coverArt` TEXT, `songCount` INTEGER NOT NULL, `created` INTEGER NOT NULL, `changed` INTEGER NOT NULL DEFAULT 0, `duration` INTEGER NOT NULL DEFAULT 0, `owner` TEXT, `shared` 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" + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT" + }, + { + "fieldPath": "shared", + "columnName": "shared", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_playlists_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_playlists_name", + "unique": false, + "columnNames": [ + "sourceId", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`sourceId`, `name`, `id`)" + }, + { + "name": "index_playlists_added", + "unique": false, + "columnNames": [ + "sourceId", + "created", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_added` ON `${TABLE_NAME}` (`sourceId` ASC, `created` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_playlists_updated", + "unique": false, + "columnNames": [ + "sourceId", + "changed", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_updated` ON `${TABLE_NAME}` (`sourceId` ASC, `changed` DESC, `name` ASC, `id` ASC)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_search", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`name`, tokenize=`trigram`, content=`playlists`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowid" + ] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "trigram", + "tokenizerArgs": [], + "contentTable": "playlists", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_playlist_search_BEFORE_UPDATE BEFORE UPDATE ON `playlists` BEGIN DELETE FROM `playlist_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_playlist_search_BEFORE_DELETE BEFORE DELETE ON `playlists` BEGIN DELETE FROM `playlist_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_playlist_search_AFTER_UPDATE AFTER UPDATE ON `playlists` BEGIN INSERT INTO `playlist_search`(`rowid`, `name`) VALUES (NEW.`rowid`, NEW.`name`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_playlist_search_AFTER_INSERT AFTER INSERT ON `playlists` BEGIN INSERT INTO `playlist_search`(`rowid`, `name`) VALUES (NEW.`rowid`, NEW.`name`); END" + ] + }, + { + "tableName": "playlist_songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`sourceId`, `playlistId`, `position`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "playlistId", + "position" + ] + }, + "indices": [ + { + "name": "index_playlist_songs_sourceId_playlistId", + "unique": false, + "columnNames": [ + "sourceId", + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_playlistId` ON `${TABLE_NAME}` (`sourceId`, `playlistId`)" + }, + { + "name": "index_playlist_songs_sourceId_songId", + "unique": false, + "columnNames": [ + "sourceId", + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_songId` ON `${TABLE_NAME}` (`sourceId`, `songId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `albumId` TEXT, `artistId` TEXT, `title` TEXT NOT NULL, `album` TEXT, `artist` TEXT, `duration` INTEGER, `track` INTEGER, `disc` INTEGER, `starred` INTEGER, `created` INTEGER NOT NULL DEFAULT 0, `playCount` INTEGER NOT NULL DEFAULT 0, `played` INTEGER, `trackGain` REAL, `albumGain` REAL, `trackPeak` REAL, `albumPeak` REAL, `artists` TEXT NOT NULL DEFAULT '', `sortName` TEXT NOT NULL COLLATE NOCASE DEFAULT '', `artistSort` TEXT NOT NULL COLLATE NOCASE DEFAULT '', PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT" + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT" + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER" + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER" + }, + { + "fieldPath": "disc", + "columnName": "disc", + "affinity": "INTEGER" + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER" + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "playCount", + "columnName": "playCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "played", + "columnName": "played", + "affinity": "INTEGER" + }, + { + "fieldPath": "trackGain", + "columnName": "trackGain", + "affinity": "REAL" + }, + { + "fieldPath": "albumGain", + "columnName": "albumGain", + "affinity": "REAL" + }, + { + "fieldPath": "trackPeak", + "columnName": "trackPeak", + "affinity": "REAL" + }, + { + "fieldPath": "albumPeak", + "columnName": "albumPeak", + "affinity": "REAL" + }, + { + "fieldPath": "artists", + "columnName": "artists", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "artistSort", + "columnName": "artistSort", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_songs_sourceId_artistId", + "unique": false, + "columnNames": [ + "sourceId", + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" + }, + { + "name": "index_songs_sourceId_albumId_order", + "unique": false, + "columnNames": [ + "sourceId", + "albumId", + "disc", + "track", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_albumId_order` ON `${TABLE_NAME}` (`sourceId`, `albumId`, `disc`, `track`, `id`)" + }, + { + "name": "index_songs_starred", + "unique": false, + "columnNames": [ + "sourceId", + "starred", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "song_genres", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, `genre` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `songId`, `position`), FOREIGN KEY(`sourceId`, `songId`) REFERENCES `songs`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "songId", + "position" + ] + }, + "indices": [ + { + "name": "index_song_genres_sourceId_genre", + "unique": false, + "columnNames": [ + "sourceId", + "genre" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_genres_sourceId_genre` ON `${TABLE_NAME}` (`sourceId`, `genre`)" + } + ], + "foreignKeys": [ + { + "table": "songs", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId", + "songId" + ], + "referencedColumns": [ + "sourceId", + "id" + ] + } + ] + }, + { + "tableName": "song_artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `songId`, `position`), FOREIGN KEY(`sourceId`, `songId`) REFERENCES `songs`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "songId", + "position" + ] + }, + "indices": [ + { + "name": "index_song_artists_sourceId_artistId", + "unique": false, + "columnNames": [ + "sourceId", + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artists_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" + } + ], + "foreignKeys": [ + { + "table": "songs", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId", + "songId" + ], + "referencedColumns": [ + "sourceId", + "id" + ] + } + ] + }, + { + "tableName": "song_search", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`title`, `artist`, `sortName`, `artistSort`, tokenize=`trigram`, content=`songs`)", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT" + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistSort", + "columnName": "artistSort", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowid" + ] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "trigram", + "tokenizerArgs": [], + "contentTable": "songs", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_song_search_BEFORE_UPDATE BEFORE UPDATE ON `songs` BEGIN DELETE FROM `song_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_song_search_BEFORE_DELETE BEFORE DELETE ON `songs` BEGIN DELETE FROM `song_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_song_search_AFTER_UPDATE AFTER UPDATE ON `songs` BEGIN INSERT INTO `song_search`(`rowid`, `title`, `artist`, `sortName`, `artistSort`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`artist`, NEW.`sortName`, NEW.`artistSort`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_song_search_AFTER_INSERT AFTER INSERT ON `songs` BEGIN INSERT INTO `song_search`(`rowid`, `title`, `artist`, `sortName`, `artistSort`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`artist`, NEW.`sortName`, NEW.`artistSort`); END" + ] + }, + { + "tableName": "queue_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `position` INTEGER NOT NULL, `sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, `refId` TEXT NOT NULL, `rangeStart` INTEGER, `rangeEnd` INTEGER, `originKind` TEXT, `originRefId` TEXT, `originOccurrence` 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" + }, + { + "fieldPath": "originKind", + "columnName": "originKind", + "affinity": "TEXT" + }, + { + "fieldPath": "originRefId", + "columnName": "originRefId", + "affinity": "TEXT" + }, + { + "fieldPath": "originOccurrence", + "columnName": "originOccurrence", + "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, `originKind` TEXT, `originRefId` TEXT, `originOccurrence` 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" + }, + { + "fieldPath": "originKind", + "columnName": "originKind", + "affinity": "TEXT" + }, + { + "fieldPath": "originRefId", + "columnName": "originRefId", + "affinity": "TEXT" + }, + { + "fieldPath": "originOccurrence", + "columnName": "originOccurrence", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_up_next_entries_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_up_next_entries_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_up_next_entries_position", + "unique": false, + "columnNames": [ + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_up_next_entries_position` ON `${TABLE_NAME}` (`position`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playback_cursor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `queuePosition` INTEGER NOT NULL, `shuffleEnabled` INTEGER NOT NULL DEFAULT 0, `repeatMode` INTEGER NOT NULL DEFAULT 0, `shuffleSeed` INTEGER NOT NULL DEFAULT 0, `shuffleSize` INTEGER NOT NULL DEFAULT 0, `positionMs` INTEGER NOT NULL DEFAULT 0, `upNextAnchor` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "queuePosition", + "columnName": "queuePosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shuffleEnabled", + "columnName": "shuffleEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "repeatMode", + "columnName": "repeatMode", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "shuffleSeed", + "columnName": "shuffleSeed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "shuffleSize", + "columnName": "shuffleSize", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "positionMs", + "columnName": "positionMs", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "upNextAnchor", + "columnName": "upNextAnchor", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "artwork_seeds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cacheKey` TEXT NOT NULL, `primary` INTEGER NOT NULL, `secondary` INTEGER, `nameBusy` INTEGER, `sourceId` INTEGER NOT NULL, `storedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`cacheKey`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "cacheKey", + "columnName": "cacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primary", + "columnName": "primary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "secondary", + "columnName": "secondary", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameBusy", + "columnName": "nameBusy", + "affinity": "INTEGER" + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "storedAt", + "columnName": "storedAt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "cacheKey" + ] + }, + "indices": [ + { + "name": "index_artwork_seeds_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artwork_seeds_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "song_downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, `status` TEXT NOT NULL, `engineId` INTEGER, `bytes` INTEGER NOT NULL DEFAULT 0, `total` INTEGER NOT NULL DEFAULT 0, `error` TEXT, `downloadedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `songId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "engineId", + "columnName": "engineId", + "affinity": "INTEGER" + }, + { + "fieldPath": "bytes", + "columnName": "bytes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "total", + "columnName": "total", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadedAt", + "columnName": "downloadedAt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "songId" + ] + }, + "indices": [ + { + "name": "index_song_downloads_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_downloads_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_song_downloads_engineId", + "unique": false, + "columnNames": [ + "engineId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_downloads_engineId` ON `${TABLE_NAME}` (`engineId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "pending_actions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, `targetId` TEXT NOT NULL, `starType` TEXT, `time` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetId", + "columnName": "targetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starType", + "columnName": "starType", + "affinity": "TEXT" + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_pending_actions_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_actions_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "pending_sync_origins", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, `refId` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `kind`, `refId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refId", + "columnName": "refId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "kind", + "refId" + ] + }, + "indices": [ + { + "name": "index_pending_sync_origins_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_sync_origins_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, 'ee91ebd8ca8608584ff44c68b6d9f224')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt b/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt index 92a64a36..a4d45784 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt @@ -161,7 +161,7 @@ interface DownloadDao { fun downloadedSongs(sourceId: Long): Flow> @Query( - "SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " + + "SELECT d.sourceId AS sourceId, s.id AS songRow, s.albumId AS albumId, s.artistId AS artistId, " + "al.artistId AS albumArtistId, " + "al.id AS albumRow, al.coverArt AS albumCoverArt, " + "ar.id AS artistRow, ar.coverArt AS artistCoverArt, " + 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 e944d758..8487abed 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -730,6 +730,35 @@ val MIGRATION_37_38 = } } +val MIGRATION_38_39 = + object : Migration(38, 39) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE `queue_entries` ADD COLUMN `originKind` TEXT") + connection.execSQL("ALTER TABLE `queue_entries` ADD COLUMN `originRefId` TEXT") + connection.execSQL("ALTER TABLE `queue_entries` ADD COLUMN `originOccurrence` INTEGER") + connection.execSQL("ALTER TABLE `up_next_entries` ADD COLUMN `originKind` TEXT") + connection.execSQL("ALTER TABLE `up_next_entries` ADD COLUMN `originRefId` TEXT") + connection.execSQL("ALTER TABLE `up_next_entries` ADD COLUMN `originOccurrence` INTEGER") + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `pending_sync_origins` (`sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, " + + "`refId` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `kind`, `refId`), " + + "FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + ) + connection.execSQL("CREATE INDEX IF NOT EXISTS `index_pending_sync_origins_sourceId` ON `pending_sync_origins` (`sourceId`)") + connection.execSQL( + "CREATE TABLE `song_downloads_new` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, `status` TEXT NOT NULL, " + + "`engineId` INTEGER, `bytes` INTEGER NOT NULL DEFAULT 0, `total` INTEGER NOT NULL DEFAULT 0, `error` TEXT, " + + "`downloadedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `songId`), " + + "FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + ) + connection.execSQL("INSERT INTO `song_downloads_new` SELECT * FROM `song_downloads`") + connection.execSQL("DROP TABLE `song_downloads`") + connection.execSQL("ALTER TABLE `song_downloads_new` RENAME TO `song_downloads`") + connection.execSQL("CREATE INDEX IF NOT EXISTS `index_song_downloads_sourceId` ON `song_downloads` (`sourceId`)") + connection.execSQL("CREATE INDEX IF NOT EXISTS `index_song_downloads_engineId` ON `song_downloads` (`engineId`)") + } + } + internal fun artistSearchTriggersOptimized(connection: SQLiteConnection): Boolean { val triggers = mutableListOf() connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?)").use { statement -> @@ -808,4 +837,5 @@ val MIGRATIONS: Array = MIGRATION_35_36, MIGRATION_36_37, MIGRATION_37_38, + MIGRATION_38_39, ) 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 7bba28b1..739cdeac 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt @@ -5,6 +5,7 @@ import androidx.room3.Insert import androidx.room3.OnConflictStrategy import androidx.room3.Query import com.subtracks.data.model.AlbumSongItem +import com.subtracks.data.model.PendingSyncOrigin import com.subtracks.data.model.PlaybackCursor import com.subtracks.data.model.PlaylistSongItem import com.subtracks.data.model.QueueEntry @@ -37,6 +38,12 @@ private const val SONG_SQL = @Dao interface QueueDao { + @Query("SELECT * FROM pending_sync_origins WHERE sourceId = :sourceId") + suspend fun pendingSyncOrigins(sourceId: Long): List + + @Query("DELETE FROM pending_sync_origins WHERE sourceId = :sourceId") + suspend fun clearPendingSyncOrigins(sourceId: Long) + @Query("DELETE FROM queue_entries") suspend fun clear() @@ -243,6 +250,44 @@ interface QueueDao { songId: String, ): Long? + @Query( + "SELECT COUNT(*) FROM playlist_songs " + + "JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " + + "WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " + + "AND playlist_songs.songId = :songId AND playlist_songs.position < :position", + ) + suspend fun playlistOccurrenceRank( + sourceId: Long, + playlistId: String, + songId: String, + position: Long, + ): Long + + @Query( + "SELECT playlist_songs.position FROM playlist_songs " + + "JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " + + "WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " + + "AND playlist_songs.songId = :songId ORDER BY playlist_songs.position LIMIT 1 OFFSET :occurrence", + ) + suspend fun playlistPositionForOccurrence( + sourceId: Long, + playlistId: String, + songId: String, + occurrence: Long, + ): Long? + + @Query( + "SELECT COUNT(*) - 1 FROM playlist_songs " + + "JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " + + "WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " + + "AND playlist_songs.position <= :position", + ) + suspend fun playlistRankAtPosition( + sourceId: Long, + playlistId: String, + position: Long, + ): Long + @Query("SELECT COUNT(*) FROM song_genres WHERE sourceId = :sourceId AND genre = :genre") suspend fun genreLength( sourceId: Long, @@ -263,7 +308,11 @@ interface QueueDao { limit: Int, ): List - @Query("SELECT COUNT(*) FROM song_downloads WHERE sourceId = :sourceId AND status = 'Completed'") + @Query( + "SELECT COUNT(*) FROM song_downloads d " + + "JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " + + "WHERE d.sourceId = :sourceId AND d.status = 'Completed'", + ) suspend fun downloadedLength(sourceId: Long): Long @Query("SELECT COUNT(*) FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL") 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 4f09c6b3..95fb7d62 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -16,6 +16,7 @@ import com.subtracks.data.model.DownloadErrorConverter import com.subtracks.data.model.DownloadStatusConverter import com.subtracks.data.model.PendingAction import com.subtracks.data.model.PendingActionKindConverter +import com.subtracks.data.model.PendingSyncOrigin import com.subtracks.data.model.PlaybackCursor import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSearch @@ -56,8 +57,9 @@ import com.subtracks.data.model.UpNextEntry ArtworkSeed::class, SongDownload::class, PendingAction::class, + PendingSyncOrigin::class, ], - version = 38, + version = 39, exportSchema = true, ) @ColumnTypeConverters( diff --git a/app/src/main/kotlin/com/subtracks/data/model/Download.kt b/app/src/main/kotlin/com/subtracks/data/model/Download.kt index a257b140..dde4bd64 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Download.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Download.kt @@ -38,12 +38,6 @@ enum class DownloadError( childColumns = ["sourceId"], onDelete = ForeignKey.CASCADE, ), - ForeignKey( - entity = Song::class, - parentColumns = ["sourceId", "id"], - childColumns = ["sourceId", "songId"], - onDelete = ForeignKey.CASCADE, - ), ], indices = [Index("sourceId"), Index("engineId")], ) @@ -109,6 +103,7 @@ data class DownloadedSong( data class DownloadArtwork( val sourceId: Long, + val songRow: String?, val albumId: String?, val artistId: String?, val albumArtistId: String?, @@ -119,7 +114,7 @@ data class DownloadArtwork( val albumArtistCoverArt: String?, ) { val hasMissingLibraryRow: Boolean - get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null) + get() = songRow == null || (albumId != null && albumRow == null) || (artistId != null && artistRow == null) } data class SourceCoverArt( 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 714b86d4..8f92bded 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -448,6 +448,31 @@ data class DiscKey( enum class QueueKind { Playlist, Album, Song, Artist, Genre, Downloaded, Starred } +data class QueueOrigin( + val kind: QueueKind, + val sourceId: Long, + val refId: String, +) + +@Entity( + tableName = "pending_sync_origins", + primaryKeys = ["sourceId", "kind", "refId"], + foreignKeys = [ + ForeignKey( + entity = Source::class, + parentColumns = ["id"], + childColumns = ["sourceId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("sourceId")], +) +data class PendingSyncOrigin( + val sourceId: Long, + val kind: QueueKind, + val refId: String, +) + @Entity( tableName = "queue_entries", foreignKeys = [ @@ -468,10 +493,15 @@ data class QueueEntry( val refId: String, val rangeStart: Long? = null, val rangeEnd: Long? = null, + val originKind: QueueKind? = null, + val originRefId: String? = null, + val originOccurrence: Long? = null, ) { val offset: Long get() = rangeStart ?: 0 val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) } + + val origin: QueueOrigin get() = QueueOrigin(originKind ?: kind, sourceId, originRefId ?: refId) } @Entity( @@ -494,16 +524,31 @@ data class UpNextEntry( val refId: String, val rangeStart: Long? = null, val rangeEnd: Long? = null, + val originKind: QueueKind? = null, + val originRefId: String? = null, + val originOccurrence: Long? = null, ) { val offset: Long get() = rangeStart ?: 0 val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) } - fun toQueueEntry(): QueueEntry = QueueEntry(id, position, sourceId, kind, refId, rangeStart, rangeEnd) + fun toQueueEntry(): QueueEntry = + QueueEntry(id, position, sourceId, kind, refId, rangeStart, rangeEnd, originKind, originRefId, originOccurrence) companion object { fun from(entry: QueueEntry) = - UpNextEntry(entry.id, entry.position, entry.sourceId, entry.kind, entry.refId, entry.rangeStart, entry.rangeEnd) + UpNextEntry( + entry.id, + entry.position, + entry.sourceId, + entry.kind, + entry.refId, + entry.rangeStart, + entry.rangeEnd, + entry.originKind, + entry.originRefId, + entry.originOccurrence, + ) } } 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 1febde46..46c07d63 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -8,6 +8,7 @@ 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.QueueOrigin import com.subtracks.data.model.SongItem import com.subtracks.data.model.UpNextEntry import kotlinx.coroutines.flow.first @@ -36,12 +37,6 @@ private data class LoadedSnapshot( val anchor: Long, ) -private data class QueueRef( - val kind: QueueKind, - val sourceId: Long, - val refId: String, -) - private data class OrderKey( val id: String, val disc: Long? = null, @@ -59,58 +54,13 @@ data class ResolvedQueueEntry( val length: Long, ) -/** - * A shuffled order over a fixed domain of [domain] tracks, plus the domain positions removed since - * the order was created. The permutation stays fixed and removals are subtracted (sparsely, in - * memory) rather than re-derived, so editing a shuffled queue keeps the remaining tracks in place. - * Only the seed and domain are persisted; on restart the removals are gone, so a queue with edits - * falls back to unshuffled rather than guessing. - */ class ShuffleState( val seed: Long, val domain: Long, - private val removedFlats: LongArray = LongArray(0), - private val removedSlots: LongArray = LongArray(0), ) { - fun flatFor(play: Long): Long { - val domainFlat = Shuffle.toFlat(seed, domain, liveSlot(play)) - return domainFlat - removedFlats.count { it < domainFlat } - } + fun flatFor(play: Long): Long = Shuffle.toFlat(seed, domain, play) - fun playFor(canonical: Long): Long { - val slot = Shuffle.toSequence(seed, domain, liveFlat(canonical)) - return slot - removedSlots.count { it < slot } - } - - fun domainFor(canonical: Long): Long = liveFlat(canonical) - - val liveSize: Long get() = domain - removedFlats.size.toLong() - - fun withRemoved(domainFlat: Long): ShuffleState = - ShuffleState( - seed, - domain, - (removedFlats + domainFlat).sortedArray(), - (removedSlots + Shuffle.toSequence(seed, domain, domainFlat)).sortedArray(), - ) - - private fun liveSlot(play: Long): Long { - var slot = play - while (true) { - val next = play + removedSlots.count { it <= slot } - if (next <= slot) return slot - slot = next - } - } - - private fun liveFlat(canonical: Long): Long { - var flat = canonical - while (true) { - val next = canonical + removedFlats.count { it <= flat } - if (next <= flat) return flat - flat = next - } - } + fun playFor(canonical: Long): Long = Shuffle.toSequence(seed, domain, canonical) } data class QueueSnapshot( @@ -119,6 +69,7 @@ data class QueueSnapshot( val shuffle: ShuffleState? = null, val upNextAnchor: Long = 0, val version: Long = 0, + val resetVersion: Long = 0, ) { val contextSize: Long = entries.sumOf { it.length } @@ -202,11 +153,33 @@ data class QueueWindowItem( val item: SongItem, val playlistPosition: Long? = null, val upNext: Boolean = false, + val resetItem: QueueResetItem? = null, ) data class Reanchored( val position: Long, val changed: Boolean, + val stale: Boolean = false, +) + +data class QueueResetItem( + val origin: QueueOrigin, + val songId: String, + val position: Long, + val upNext: Boolean, + val entryOccurrence: Int = 0, + val occurrence: Long = 0, +) + +data class QueueResetPoint( + val current: QueueResetItem?, + val anchor: QueueResetItem?, + val upNextAnchor: Long? = null, +) + +data class QueueReset( + val point: QueueResetPoint, + val snapshot: QueueSnapshot, ) private interface QueueKindResolver { @@ -411,13 +384,23 @@ class QueueRepository( private val dao get() = db.queueDao() private val queueVersion = AtomicLong() + private val resetVersion = AtomicLong() + private val libraryVersion = AtomicLong() + private val queueMutex = Mutex() + private var resetListener: (suspend QueueMutationScope.(QueueReset) -> Unit)? = null - @Volatile - private var shuffleRemoved: ShuffleState? = null + fun setResetListener(listener: suspend QueueMutationScope.(QueueReset) -> Unit) { + resetListener = listener + } + + suspend fun withMutationLock(block: suspend QueueMutationScope.() -> T): T = + queueMutex.withLock { + QueueMutationScope().block() + } private val lengthCache = ConcurrentHashMap() - private val keyCache = ConcurrentHashMap() + private val keyCache = ConcurrentHashMap() internal var keysetSeeks = 0L private set @@ -428,7 +411,7 @@ class QueueRepository( internal var resolvedRowCount = 0L private set - private val cursorMutex = Mutex() + internal val cursorMutex = Mutex() fun playlistEntry( sourceId: Long, @@ -514,51 +497,46 @@ class QueueRepository( } } - suspend fun replace( + private suspend fun replaceLocked( entries: List, - shuffleSeed: Long? = null, - upNext: List = emptyList(), - upNextAnchor: Long = 0, + shuffleSeed: Long?, + upNext: List, + upNextAnchor: Long, ) { - write(entries) - writeUpNext(upNext) - setShuffle(shuffleSeed) + invalidateLibraryCache() + writeLocked(entries) + writeUpNextLocked(upNext) + setShuffleLocked(shuffleSeed) cursorMutex.withLock { dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0, upNextAnchor = upNextAnchor)) } } - suspend fun snapshot(): QueueSnapshot { - val version = queueVersion.get() + suspend fun snapshot(): QueueSnapshot = withMutationLock { snapshot() } + + private suspend fun snapshotLocked(): QueueSnapshot { + val libraryRevision = libraryVersion.get() val loaded = db.useReaderConnection { transactor -> transactor.deferredTransaction { - val entries = dao.entries().map { ResolvedQueueEntry(it, lengthOf(it, version)) } + val entries = dao.entries().map { ResolvedQueueEntry(it, lengthOf(it, libraryRevision)) } val upNext = dao.upNextEntries().map { val entry = it.toQueueEntry() - ResolvedQueueEntry(entry, lengthOf(entry, version)) + ResolvedQueueEntry(entry, lengthOf(entry, libraryRevision)) } val row = dao.cursor() val size = entries.sumOf { it.length } - val state = shuffleRemoved val shuffle = - if (row != null && row.shuffleEnabled && size > 0L) { - when { - state != null && state.seed == row.shuffleSeed && state.domain == row.shuffleSize && - state.liveSize == size -> state - - state == null && row.shuffleSize == size -> ShuffleState(row.shuffleSeed, row.shuffleSize) - - else -> null - } + if (row != null && row.shuffleEnabled && row.shuffleSize == size && size > 0L) { + ShuffleState(row.shuffleSeed, row.shuffleSize) } else { null } LoadedSnapshot(entries, upNext, shuffle, row?.upNextAnchor ?: 0L) } } - return QueueSnapshot(loaded.entries, loaded.upNext, loaded.shuffle, loaded.anchor, version) + return QueueSnapshot(loaded.entries, loaded.upNext, loaded.shuffle, loaded.anchor, queueVersion.get(), resetVersion.get()) } suspend fun modes(): QueueModes { @@ -566,7 +544,7 @@ class QueueRepository( return QueueModes(row.shuffleEnabled, row.repeatMode) } - suspend fun setShuffle(seed: Long?) { + private suspend fun setShuffleLocked(seed: Long?) { val size = if (seed != null) contextSize() else 0L cursorMutex.withLock { dao.setCursor( @@ -577,14 +555,274 @@ class QueueRepository( ), ) } - shuffleRemoved = seed?.let { ShuffleState(it, size) } + queueVersion.incrementAndGet() } + fun contentVersion(): Long = queueVersion.get() + fun invalidateLibraryCache() { lengthCache.clear() keyCache.clear() - shuffleRemoved = null + libraryVersion.incrementAndGet() + } + + suspend fun captureResetPoint(): QueueResetPoint { + val snapshot = snapshot() + val row = cursorRow() + val current = + resetItemAt(snapshot, row.queuePosition) + ?: snapshot.size.takeIf { it > 0L }?.let { resetItemAt(snapshot, 0L) } + val anchor = if (snapshot.upNextSize == 0L) null else resetItemAt(snapshot, snapshot.anchorPlay) + return QueueResetPoint(current, anchor, snapshot.upNextAnchor.takeIf { snapshot.upNextSize > 0L }) + } + + suspend fun resetChangedOrigins( + changed: Set, + point: QueueResetPoint, + ) { + if (changed.isEmpty()) return + withMutationLock { + val before = snapshot() + val entries = canonicalEntries(before.entries.map { it.entry }, changed) + val upNext = canonicalEntries(before.upNext.map { it.entry }, changed) + if (entries == before.entries.map { it.entry } && upNext == before.upNext.map { it.entry }) return@withMutationLock + val updated = writeResetLocked(before, entries, upNext, point) + resetListener?.invoke(this, QueueReset(point, updated)) + } + } + + private suspend fun writeResetLocked( + before: QueueSnapshot, + entries: List, + upNext: List, + point: QueueResetPoint, + ): QueueSnapshot { + invalidateLibraryCache() + val resolvedEntries = entries.map { ResolvedQueueEntry(it, it.resolvedLength()) } + val resolvedUpNext = upNext.map { ResolvedQueueEntry(it, it.resolvedLength()) } + val shuffle = before.shuffleSeed?.let { ShuffleState(it, resolvedEntries.sumOf { it.length }) } + var updated = + QueueSnapshot( + entries = resolvedEntries, + upNext = resolvedUpNext, + shuffle = shuffle, + upNextAnchor = before.upNextAnchor, + version = queueVersion.get(), + resetVersion = resetVersion.get(), + ) + val anchorChanged = before.upNextSize > 0L && before.upNextAnchor != point.upNextAnchor + val latestAnchor = if (anchorChanged) resetItemAt(before, before.anchorPlay) else null + val anchor = + if (anchorChanged) { + remapAnchor(updated, latestAnchor) ?: fallbackAnchor(updated, before.anchorPlay) + } else { + remapAnchor(updated, point.anchor) + ?: fallbackAnchor(updated, point.anchor?.position ?: before.upNextAnchor) + } + updated = updated.copy(upNextAnchor = anchor) + cursorMutex.withLock { + val row = cursorRow() + val current = point.current ?: resetItemAt(before, row.queuePosition) + val remappedCurrent = current?.let { remapPosition(updated, it) } + val cursor = + when { + updated.size == 0L -> 0L + remappedCurrent != null -> remappedCurrent.coerceIn(0, updated.size - 1) + current != null -> current.position.coerceIn(0, updated.size - 1) + else -> row.queuePosition.coerceIn(0, updated.size - 1) + } + val previousSongId = current?.songId + val updatedSongId = itemAt(updated, cursor)?.song?.id + val positionMs = if (remappedCurrent != null && previousSongId == updatedSongId) row.positionMs else 0L + db.useWriterConnection { connection -> + connection.immediateTransaction { + dao.clear() + dao.insert(entries.mapIndexed { index, entry -> entry.copy(id = 0, position = index.toLong()) }) + dao.clearUpNext() + dao.insertUpNext( + upNext.mapIndexed { index, entry -> UpNextEntry.from(entry).copy(id = 0, position = index.toLong()) }, + ) + dao.setCursor( + row.copy( + queuePosition = cursor, + positionMs = positionMs, + upNextAnchor = anchor, + shuffleEnabled = shuffle != null, + shuffleSeed = shuffle?.seed ?: 0L, + shuffleSize = shuffle?.domain ?: 0L, + ), + ) + } + } + } queueVersion.incrementAndGet() + resetVersion.incrementAndGet() + return updated.copy(version = queueVersion.get(), resetVersion = resetVersion.get()) + } + + private suspend fun remapAnchor( + snapshot: QueueSnapshot, + item: QueueResetItem?, + ): Long? { + val target = item?.let { remapPosition(snapshot, it) } ?: return null + return snapshot.flatContext(snapshot.contextPlay(target)) + } + + private fun fallbackAnchor( + snapshot: QueueSnapshot, + position: Long, + ): Long { + val play = position.coerceIn(-1L, snapshot.contextSize - 1L) + return if (play < 0L) play else snapshot.flatContext(play) ?: play + } + + suspend fun resetItemAt( + snapshot: QueueSnapshot, + position: Long, + songId: String? = null, + item: SongItem? = null, + ): QueueResetItem? { + if (position !in 0 until snapshot.size) return null + val entry = snapshot.locate(position)?.first ?: return null + val upNext = snapshot.isUpNext(position) + val entries = if (upNext) snapshot.upNext else snapshot.entries + val blockPosition = + if (upNext) { + snapshot.upNextIndex(position) + } else { + snapshot.flatContext(snapshot.contextPlay(position)) + ?: return null + } + var start = 0L + var entryIndex = -1 + for (index in entries.indices) { + val end = start + entries[index].length + if (blockPosition >= start && blockPosition < end) { + entryIndex = index + break + } + start = end + } + if (entryIndex < 0) return null + val origin = entry.origin + val entryOccurrence = resetEntryOccurrence(entries, entryIndex) + val resolvedItem = item ?: itemAt(snapshot, position) + val resolvedSongId = songId ?: resolvedItem?.song?.id ?: return null + val occurrence = + entry.originOccurrence + ?: if (entry.kind == QueueKind.Playlist && resolvedItem?.playlistPosition != null) { + dao.playlistOccurrenceRank(entry.sourceId, entry.refId, resolvedSongId, resolvedItem.playlistPosition!!) + } else { + 0L + } + return QueueResetItem(origin, resolvedSongId, position, upNext, entryOccurrence, occurrence) + } + + private fun resetEntryOccurrence( + entries: List, + targetIndex: Int, + ): Int { + val origin = entries[targetIndex].entry.origin + val targetWhole = entries[targetIndex].entry.isWholeOrigin() + var occurrence = 0 + var fragmentSeen = false + for (index in 0..targetIndex) { + val entry = entries[index].entry + if (entry.origin != origin) continue + if (entry.isWholeOrigin()) { + if (index == targetIndex) return occurrence + occurrence++ + } else if (!fragmentSeen) { + fragmentSeen = true + if (!targetWhole) return occurrence + occurrence++ + } + } + return occurrence + } + + private fun QueueEntry.isWholeOrigin(): Boolean { + val origin = origin + return kind == origin.kind && refId == origin.refId && rangeStart == null && rangeEnd == null + } + + private fun canonicalEntries( + entries: List, + changed: Set, + ): List { + val result = mutableListOf() + val reset = mutableSetOf() + for (entry in entries) { + val origin = entry.origin + val whole = entry.isWholeOrigin() + if (origin !in changed) { + result += entry + } else if (whole || reset.add(origin)) { + result += QueueEntry(position = 0, sourceId = origin.sourceId, kind = origin.kind, refId = origin.refId) + } + } + return result + } + + private data class OriginEntryGroup( + val starts: List, + val entries: List, + ) + + private fun originEntryGroups( + entries: List, + origin: QueueOrigin, + ): List { + val groups = mutableListOf() + var fragmentIndex: Int? = null + var start = 0L + for (resolved in entries) { + if (resolved.entry.origin == origin) { + if (resolved.entry.isWholeOrigin()) { + groups += OriginEntryGroup(listOf(start), listOf(resolved)) + } else { + val index = fragmentIndex + if (index == null) { + fragmentIndex = groups.size + groups += OriginEntryGroup(listOf(start), listOf(resolved)) + } else { + groups[index] = + groups[index].copy( + starts = groups[index].starts + start, + entries = groups[index].entries + resolved, + ) + } + } + } + start += resolved.length + } + return groups + } + + suspend fun remapPosition( + snapshot: QueueSnapshot, + item: QueueResetItem, + ): Long? { + val entries = if (item.upNext) snapshot.upNext else snapshot.entries + val groups = originEntryGroups(entries, item.origin) + val group = groups.getOrNull(item.entryOccurrence) ?: return null + for ((index, resolved) in group.entries.withIndex()) { + val entry = resolved.entry + val rank = + if (entry.kind == QueueKind.Playlist) { + val position = dao.playlistPositionForOccurrence(entry.sourceId, entry.refId, item.songId, item.occurrence) + position?.let { dao.playlistRankAtPosition(entry.sourceId, entry.refId, it) } + } else { + resolverFor(entry.kind).rank(dao, entry, item.songId) ?: fallbackRank(entry, resolved.length, item.songId) + } + if (rank != null && rank >= entry.offset && rank < entry.offset + resolved.length) { + val flat = group.starts[index] + rank - entry.offset + if (item.upNext) return snapshot.anchorPlay + 1 + flat + val play = snapshot.playContext(flat) ?: return null + return snapshot.combined(play) + } + } + return null } suspend fun setRepeat(mode: Int) = cursorMutex.withLock { dao.setCursor(cursorRow().copy(repeatMode = mode)) } @@ -644,17 +882,18 @@ class QueueRepository( return range(snapshot, (center - radius).coerceAtLeast(0), (center + radius).coerceAtMost(snapshot.size - 1)) } - suspend fun addUpNext( - snapshot: QueueSnapshot, + private suspend fun addUpNextLocked( currentPosition: Long, entry: QueueEntry, playNext: Boolean, ): Long { - val list = snapshot.upNext.map { it.entry }.toMutableList() + invalidateLibraryCache() + val current = snapshotLocked() + val list = current.upNext.map { it.entry }.toMutableList() val insertIndex = when { - playNext && snapshot.isUpNext(currentPosition) -> { - entryIndexAfter(snapshot.upNext, snapshot.upNextIndex(currentPosition)) + playNext && current.isUpNext(currentPosition) -> { + entryIndexAfter(current.upNext, current.upNextIndex(currentPosition)) } playNext -> { @@ -667,75 +906,54 @@ class QueueRepository( }.coerceIn(0, list.size) list.add(insertIndex, entry.copy(id = 0, position = 0)) val anchor = - if (snapshot.upNextSize == 0L) { - val play = snapshot.anchorContextPlay(currentPosition) - snapshot.flatContext(play) ?: 0L + if (current.upNextSize == 0L) { + val play = current.anchorContextPlay(currentPosition) + current.flatContext(play) ?: 0L } else { - snapshot.upNextAnchor + current.upNextAnchor } - writeUpNext(list) + writeUpNextLocked(list) cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = anchor)) } - if (snapshot.upNextSize == 0L) return currentPosition - val before = snapshot.upNext.take(insertIndex).sumOf { it.length } - return if (currentPosition >= snapshot.anchorPlay + 1 + before) currentPosition + 1 else currentPosition + if (current.upNextSize == 0L) return currentPosition + val before = current.upNext.take(insertIndex).sumOf { it.length } + return if (currentPosition >= current.anchorPlay + 1 + before) currentPosition + 1 else currentPosition } - suspend fun reanchorFor( + private suspend fun reanchorForLocked( snapshot: QueueSnapshot, target: Long, ): Reanchored { + if (snapshot.version != queueVersion.get()) return Reanchored(target, false, stale = true) if (snapshot.upNextSize == 0L || snapshot.isUpNext(target)) return Reanchored(target, false) val play = snapshot.contextPlay(target) val flat = snapshot.flatContext(play) ?: return Reanchored(target, false) if (flat == snapshot.upNextAnchor) return Reanchored(target, false) cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = flat)) } + queueVersion.incrementAndGet() return Reanchored(play, true) } - suspend fun setUpNextAnchor(anchor: Long) = cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = anchor)) } - - suspend fun clearUpNext() { - writeUpNext(emptyList()) - } - - suspend fun removeUpNextAt( + private suspend fun removeUpNextAtLocked( snapshot: QueueSnapshot, index: Long, - ) { - if (index !in 0 until snapshot.upNextSize) return - writeUpNext(compact(removeEntry(snapshot.upNext, index)).map { it.entry }) + ): Boolean { + if (index !in 0 until snapshot.upNextSize) return false + writeUpNextLocked(compact(removeEntry(snapshot.upNext, index)).map { it.entry }) + return true } - suspend fun removeAt( + private suspend fun removeAtLocked( snapshot: QueueSnapshot, position: Long, - ) { - if (snapshot.isUpNext(position)) { - removeUpNextAt(snapshot, snapshot.upNextIndex(position)) - return - } + ): Boolean { + if (snapshot.version != queueVersion.get()) return false + if (snapshot.isUpNext(position)) return removeUpNextAtLocked(snapshot, snapshot.upNextIndex(position)) + if (snapshot.shuffled) return false val play = snapshot.contextPlay(position) - if (snapshot.shuffled) { - // Subtract the track from the fixed shuffle order instead of re-deriving it, so the - // tracks that remain keep their positions. - val order = snapshot.shuffle ?: return - val flat = snapshot.flatContext(play) ?: return - val predecessor = if (play <= 0L) -1L else snapshot.flatContext(play - 1L) ?: -1L - shuffleRemoved = order.withRemoved(order.domainFor(flat)) - write(compact(removeEntry(snapshot.entries, flat)).map { it.entry }) - if (cursorRow().upNextAnchor == flat) { - // The removed track carried the block; move the anchor to the track before it in - // play order so the block stays in place instead of jumping to the canonical - // predecessor, which in a shuffled queue is an unrelated position. - setUpNextAnchor(if (predecessor > flat) predecessor - 1 else predecessor) - } else { - adjustAnchorOnRemove(flat) - } - } else { - val removed = compact(removeEntry(snapshot.entries, play)) - write(removed.map { it.entry }) - adjustAnchorOnRemove(play) - } + val removed = compact(removeEntry(snapshot.entries, play)) + writeLocked(removed.map { it.entry }) + adjustAnchorOnRemove(play) + return true } private suspend fun adjustAnchorOnRemove(removedFlat: Long) = @@ -765,19 +983,21 @@ class QueueRepository( if (moved != anchor) dao.setCursor(row.copy(upNextAnchor = moved)) } - suspend fun move( + private suspend fun moveLocked( snapshot: QueueSnapshot, from: Long, to: Long, ): Boolean { + if (snapshot.version != queueVersion.get()) return false val fromUpNext = snapshot.isUpNext(from) val toUpNext = snapshot.isUpNext(to) if (fromUpNext && toUpNext) { val song = itemAt(snapshot, from) ?: return false val located = snapshot.locate(from) ?: return false + val resetItem = resetItemAt(snapshot, from, item = song) val removed = removeEntry(snapshot.upNext, snapshot.upNextIndex(from)) - val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L) - writeUpNext(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry }) + val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second, resetItem), 1L) + writeUpNextLocked(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry }) return true } if (fromUpNext != toUpNext) return false @@ -789,10 +1009,11 @@ class QueueRepository( val playTo = snapshot.contextPlay(to) val located = snapshot.locateContext(playFrom) ?: return false val song = itemAt(snapshot, from) ?: return false + val resetItem = resetItemAt(snapshot, from, item = song) val removed = removeEntry(snapshot.entries, playFrom) - val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L) + val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second, resetItem), 1L) val insertAt = playTo.coerceIn(0, removed.sumOf { it.length }) - write(compact(insertEntry(removed, insertAt, moved)).map { it.entry }) + writeLocked(compact(insertEntry(removed, insertAt, moved)).map { it.entry }) adjustAnchorOnMove(playFrom, insertAt) return true } @@ -816,7 +1037,7 @@ class QueueRepository( private suspend fun contextSize(): Long = dao.entries().sumOf { it.resolvedLength() } - private suspend fun write(entries: List) { + private suspend fun writeLocked(entries: List) { db.useWriterConnection { transactor -> transactor.immediateTransaction { dao.clear() @@ -826,7 +1047,7 @@ class QueueRepository( queueVersion.incrementAndGet() } - private suspend fun writeUpNext(entries: List) { + private suspend fun writeUpNextLocked(entries: List) { db.useWriterConnection { transactor -> transactor.immediateTransaction { dao.clearUpNext() @@ -840,6 +1061,51 @@ class QueueRepository( queueVersion.incrementAndGet() } + inner class QueueMutationScope internal constructor() { + suspend fun snapshot(): QueueSnapshot = snapshotLocked() + + suspend fun replace( + entries: List, + shuffleSeed: Long? = null, + upNext: List = emptyList(), + upNextAnchor: Long = 0, + ) = replaceLocked(entries, shuffleSeed, upNext, upNextAnchor) + + suspend fun setShuffle(seed: Long?) = setShuffleLocked(seed) + + suspend fun addUpNext( + currentPosition: Long, + entry: QueueEntry, + playNext: Boolean, + ): Long = addUpNextLocked(currentPosition, entry, playNext) + + suspend fun reanchorFor( + snapshot: QueueSnapshot, + target: Long, + ): Reanchored = reanchorForLocked(snapshot, target) + + suspend fun clearUpNext() = writeUpNextLocked(emptyList()) + + suspend fun removeUpNextAt( + snapshot: QueueSnapshot, + index: Long, + ): Boolean { + if (snapshot.version != queueVersion.get()) return false + return removeUpNextAtLocked(snapshot, index) + } + + suspend fun removeAt( + snapshot: QueueSnapshot, + position: Long, + ): Boolean = removeAtLocked(snapshot, position) + + suspend fun move( + snapshot: QueueSnapshot, + from: Long, + to: Long, + ): Boolean = moveLocked(snapshot, from, to) + } + private suspend fun flatIndexOf( entries: List, songId: String, @@ -887,7 +1153,14 @@ class QueueRepository( for (play in first..last) { val located = snapshot.locateContext(play) ?: continue val item = rows(located.first, located.first.offset + located.second, 1).firstOrNull() ?: continue - items += QueueWindowItem(snapshot.combined(play), item, playlistPosition = item.playlistPosition) + val position = snapshot.combined(play) + items += + QueueWindowItem( + position, + item, + playlistPosition = item.playlistPosition, + resetItem = resetItemAt(snapshot, position, item = item), + ) } return items } @@ -901,7 +1174,14 @@ class QueueRepository( val to = minOf(last, end) val offset = resolved.entry.offset + (from - entryStart) rows(resolved.entry, offset, (to - from + 1).toInt()).forEachIndexed { index, item -> - items += QueueWindowItem(snapshot.combined(from + index), item, playlistPosition = item.playlistPosition) + val position = snapshot.combined(from + index) + items += + QueueWindowItem( + position, + item, + playlistPosition = item.playlistPosition, + resetItem = resetItemAt(snapshot, position, item = item), + ) } } return items @@ -924,12 +1204,14 @@ class QueueRepository( val to = minOf(last, end) val offset = resolved.entry.offset + (from - entryStart) rows(resolved.entry, offset, (to - from + 1).toInt()).forEachIndexed { index, item -> + val position = snapshot.anchorPlay + 1 + from + index items += QueueWindowItem( - snapshot.anchorPlay + 1 + from + index, + position, item, playlistPosition = item.playlistPosition, upNext = true, + resetItem = resetItemAt(snapshot, position, item = item), ) } } @@ -945,7 +1227,7 @@ class QueueRepository( offsetSeeks++ return resolverFor(entry.kind).rows(dao, entry, offset, limit).also { resolvedRowCount += it.size } } - val ref = QueueRef(entry.kind, entry.sourceId, entry.refId) + val ref = QueueOrigin(entry.kind, entry.sourceId, entry.refId) val anchored = keyCache[ref]?.let { keyedRows(entry, offset, limit, it) } if (anchored != null) { keysetSeeks++ @@ -961,7 +1243,7 @@ class QueueRepository( } private fun remember( - ref: QueueRef, + ref: QueueOrigin, offset: Long, resolved: List, ) { @@ -1056,12 +1338,17 @@ class QueueRepository( source: QueueEntry, songId: String, offsetInEntry: Long, + resetItem: QueueResetItem?, ): QueueEntry = if (source.kind == QueueKind.Album) { val offset = source.offset + offsetInEntry source.range(offset, offset) } else { - songEntry(source.sourceId, songId) + songEntry(source.sourceId, songId).copy( + originKind = source.origin.kind, + originRefId = source.origin.refId, + originOccurrence = resetItem?.occurrence ?: source.originOccurrence, + ) } private fun QueueEntry.range( diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 97fb0419..709d28bd 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -299,7 +299,7 @@ class PlaybackController( return@withLock } val start = randomIndex(snap.contextSize, avoid) - queueRepository.setShuffle(pinnedSeed(snap.contextSize, start)) + queueRepository.withMutationLock { setShuffle(pinnedSeed(snap.contextSize, start)) } shuffleEnabled = true queueSourceId = entry.sourceId queueRepository.setCursor(0) @@ -314,7 +314,7 @@ class PlaybackController( windowEnd = -1 lastEdit = null endedHandled = false - queueRepository.replace(listOf(entry)) + queueRepository.withMutationLock { replace(listOf(entry)) } val snapshot = queueRepository.snapshot() this.snapshot = snapshot return snapshot @@ -350,7 +350,7 @@ class PlaybackController( val player = player ?: return@withLock var snapshot = snapshot ?: return@withLock if (snapshot.size == 0L) return@withLock - val anchor = queueRepository.reanchorFor(snapshot, position) + val anchor = queueRepository.withMutationLock { reanchorFor(snapshot, position) } if (anchor.changed) { snapshot = readSnapshot() this@PlaybackController.snapshot = snapshot @@ -385,11 +385,12 @@ class PlaybackController( val snapshot = snapshot ?: return@withLock if (position !in 0 until snapshot.size) return@withLock val player = player ?: return@withLock + if (snapshot.shuffled && !snapshot.isUpNext(position)) return@withLock val cursor = queueRepository.cursor() val entries = snapshot.entries.map { it.entry } val upNext = snapshot.upNext.map { it.entry } if (snapshot.size == 1L) { - queueRepository.removeAt(snapshot, position) + queueRepository.withMutationLock { removeAt(snapshot, position) } stopLocked() lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor) return@withLock @@ -398,7 +399,7 @@ class PlaybackController( val playing = player.playWhenReady val wasUpNext = snapshot.isUpNext(current) val removed = if (position == current) null else queueItemAt(snapshot, position) - queueRepository.removeAt(snapshot, position) + queueRepository.withMutationLock { removeAt(snapshot, position) } var updated = readSnapshot() this.snapshot = updated windowJob?.cancel() @@ -437,7 +438,7 @@ class PlaybackController( // Keep the up-next block next: after a removal the cursor may have landed on a context // track that no longer carries the block (its anchor moved), so re-anchor it there. if (updated.upNextSize > 0L && !updated.isUpNext(target)) { - val reanchored = queueRepository.reanchorFor(updated, target) + val reanchored = queueRepository.withMutationLock { reanchorFor(updated, target) } if (reanchored.changed) { updated = readSnapshot() this.snapshot = updated @@ -506,7 +507,7 @@ class PlaybackController( val entries = snapshot.entries.map { it.entry } val upNext = snapshot.upNext.map { it.entry } val wasUpNext = snapshot.isUpNext(current) - if (!queueRepository.move(snapshot, from, to)) return@withLock + if (!queueRepository.withMutationLock { move(snapshot, from, to) }) return@withLock val updated = readSnapshot() this.snapshot = updated // The current track still exists, so derive its new position from the edit instead of @@ -586,7 +587,7 @@ class PlaybackController( val player = player ?: return@withLock val playing = player.playWhenReady val positionMs = player.currentPositionMs - queueRepository.replace(undo.entries, undo.shuffleSeed, undo.upNext, undo.upNextAnchor) + queueRepository.withMutationLock { replace(undo.entries, undo.shuffleSeed, undo.upNext, undo.upNextAnchor) } shuffleEnabled = undo.shuffleSeed != null val restored = readSnapshot() this.snapshot = restored @@ -725,7 +726,7 @@ class PlaybackController( val snapshot = snapshot ?: return@withLock if (!snapshot.isUpNext(position)) return@withLock if (position >= snapshot.size - 1 && repeatMode == RepeatMode.Off) return@withLock - queueRepository.removeUpNextAt(snapshot, snapshot.upNextIndex(position)) + queueRepository.withMutationLock { removeUpNextAt(snapshot, snapshot.upNextIndex(position)) } val updated = readSnapshot() this.snapshot = updated layoutVersion++ @@ -758,7 +759,7 @@ class PlaybackController( player ?: return@withLock val current = currentPosition() ?: return@withLock val wasUpNext = snapshot.isUpNext(current) - queueRepository.clearUpNext() + queueRepository.withMutationLock { clearUpNext() } val updated = readSnapshot() this@PlaybackController.snapshot = updated layoutVersion++ @@ -861,7 +862,7 @@ class PlaybackController( val player = player ?: return@withLock if (snapshot.size == 0L) return@withLock val current = currentPosition() ?: return@withLock - val position = queueRepository.addUpNext(snapshot, current, entry, playNext) + val position = queueRepository.withMutationLock { addUpNext(current, entry, playNext) } val updated = readSnapshot() this.snapshot = updated layoutVersion++ @@ -898,9 +899,9 @@ class PlaybackController( windowJob?.cancel() val enabling = !shuffleEnabled if (enabling) { - queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, flat)) + queueRepository.withMutationLock { setShuffle(pinnedSeed(snapshot.contextSize, flat)) } } else { - queueRepository.setShuffle(null) + queueRepository.withMutationLock { setShuffle(null) } } shuffleEnabled = enabling val reordered = readSnapshot() @@ -911,7 +912,7 @@ class PlaybackController( enabling -> reordered.combined(0L) else -> reordered.combined(flat) } - val anchor = queueRepository.reanchorFor(reordered, position) + val anchor = queueRepository.withMutationLock { reanchorFor(reordered, position) } val finalSnapshot = if (anchor.changed) readSnapshot() else reordered if (anchor.changed) layoutVersion++ this@PlaybackController.snapshot = finalSnapshot @@ -1028,7 +1029,7 @@ class PlaybackController( val start = startPosition.coerceIn(0, snapshot.contextSize - 1) val position = if (shuffle) { - queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, start)) + queueRepository.withMutationLock { setShuffle(pinnedSeed(snapshot.contextSize, start)) } 0L } else { start @@ -1052,7 +1053,7 @@ class PlaybackController( if (shuffleEnabled && !snapshot.shuffled && snapshot.contextSize > 0L) { val start = snapshot.anchorContextPlay(queueRepository.cursor()).coerceIn(0, snapshot.contextSize - 1) val flat = snapshot.flatContext(start) ?: start - queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, flat)) + queueRepository.withMutationLock { setShuffle(pinnedSeed(snapshot.contextSize, flat)) } queueRepository.setCursor(0) snapshot = queueRepository.snapshot() } else if (!snapshot.shuffled) { @@ -1072,7 +1073,7 @@ class PlaybackController( this.queueSourceId = queueSourceId val savedPositionMs = queueRepository.cursorPositionMs().coerceAtLeast(0) val cursor = queueRepository.cursor().coerceIn(0, snapshot.size - 1) - val anchor = queueRepository.reanchorFor(snapshot, cursor) + val anchor = queueRepository.withMutationLock { reanchorFor(snapshot, cursor) } if (anchor.changed) { snapshot = queueRepository.snapshot() this.snapshot = snapshot @@ -1211,7 +1212,7 @@ class PlaybackController( val player = player ?: return@withLock var snapshot = readSnapshot() if (snapshot.size == 0L) return@withLock - val anchor = queueRepository.reanchorFor(snapshot, target) + val anchor = queueRepository.withMutationLock { reanchorFor(snapshot, target) } if (anchor.changed) { snapshot = readSnapshot() layoutVersion++ @@ -1490,7 +1491,7 @@ class PlaybackController( private suspend fun readSnapshot(): QueueSnapshot { val snapshot = queueRepository.snapshot() if (!snapshot.shuffled) { - if (shuffleEnabled && snapshot.size > 0L) queueRepository.setShuffle(null) + if (shuffleEnabled && snapshot.size > 0L) queueRepository.withMutationLock { setShuffle(null) } shuffleEnabled = false } return snapshot diff --git a/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt index b571ffc3..8ff29905 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt @@ -313,14 +313,14 @@ class DownloadRepositoryTest { } @Test - fun aSongRemovedFromTheServerTakesItsDownloadRowWithIt() = + fun aSongRemovedFromTheServerKeepsItsDownloadRowForRecovery() = runTest { seedLibrary() runBlocking { repository.download(1, "s1") } runBlocking { db.libraryDao().deleteSongs(1, listOf("s1")) } - assertNull(row(1, "s1")) + assertEquals(DownloadStatus.Queued, row(1, "s1")?.status) } @Test diff --git a/app/src/test/kotlin/com/subtracks/data/repo/QueueMutationTestSupport.kt b/app/src/test/kotlin/com/subtracks/data/repo/QueueMutationTestSupport.kt new file mode 100644 index 00000000..30db96d6 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/repo/QueueMutationTestSupport.kt @@ -0,0 +1,49 @@ +package com.subtracks.data.repo + +import com.subtracks.data.model.QueueEntry + +suspend fun QueueRepository.replace( + entries: List, + shuffleSeed: Long? = null, + upNext: List = emptyList(), + upNextAnchor: Long = 0, +) = withMutationLock { + replace(entries, shuffleSeed, upNext, upNextAnchor) +} + +suspend fun QueueRepository.setShuffle(seed: Long?) = + withMutationLock { + setShuffle(seed) + } + +suspend fun QueueRepository.addUpNext( + currentPosition: Long, + entry: QueueEntry, + playNext: Boolean, +): Long = withMutationLock { addUpNext(currentPosition, entry, playNext) } + +suspend fun QueueRepository.reanchorFor( + snapshot: QueueSnapshot, + target: Long, +): Reanchored = withMutationLock { reanchorFor(snapshot, target) } + +suspend fun QueueRepository.clearUpNext() = + withMutationLock { + clearUpNext() + } + +suspend fun QueueRepository.removeUpNextAt( + snapshot: QueueSnapshot, + index: Long, +): Boolean = withMutationLock { removeUpNextAt(snapshot, index) } + +suspend fun QueueRepository.removeAt( + snapshot: QueueSnapshot, + position: Long, +): Boolean = withMutationLock { removeAt(snapshot, position) } + +suspend fun QueueRepository.move( + snapshot: QueueSnapshot, + from: Long, + to: Long, +): Boolean = withMutationLock { move(snapshot, from, to) } 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 89b4e236..d44305e3 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt @@ -11,13 +11,18 @@ import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueKind +import com.subtracks.data.model.QueueOrigin import com.subtracks.data.model.Song import com.subtracks.data.model.SongDownload import com.subtracks.data.model.Source +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -79,6 +84,20 @@ class QueueRepositoryTest { assertEquals(listOf("s1", "s3"), resolveAll(snapshot)) } + @Test + fun downloadedEntryIgnoresRetainedRowsWhoseSongsArePruned() = + runTest { + seedLibrary() + db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed)) + db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed)) + db.libraryDao().deleteSongs(1, listOf("s2")) + + val snapshot = repository.snapshotAfter(listOf(repository.downloadedEntry(1))) + + assertEquals(1, snapshot.size) + assertEquals(listOf("s1"), resolveAll(snapshot)) + } + @Test fun starredEntryResolvesStarredTracksNewestFirst() = runTest { @@ -416,28 +435,427 @@ class QueueRepositoryTest { assertEquals(4, repository.snapshot().size) } + @Test + fun aChangedOriginResetMergesEditsAndRemapsTheCurrentItem() = + runTest { + seedLibrary() + db.libraryDao().upsertSongs(listOf(song("s0", "al1", track = 0, album = "First Album"))) + setPlaylist("p1", "s1", "s2", "s3") + val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1"))) + repository.removeAt(snapshot, 1) + repository.setCursor(1) + val point = repository.captureResetPoint() + + setPlaylist("p1", "s0", "s1", "s2", "s3") + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) + + val updated = repository.snapshot() + val entry = updated.entries.single().entry + assertEquals(QueueKind.Playlist, entry.kind) + assertEquals("p1", entry.refId) + assertNull(entry.rangeStart) + assertNull(entry.rangeEnd) + assertEquals(3L, repository.remapPosition(updated, point.current!!)) + } + + @Test + fun anUnrelatedOriginResetRemapsWithinTheSecondUnchangedFragment() = + runTest { + seedLibrary() + db.libraryDao().upsertSongs(listOf(song("s0", "al1", track = 0, album = "First Album"))) + setPlaylist("p2", "s4") + setPlaylist("p1", "s1", "s2", "s3") + val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p2"), repository.playlistEntry(1, "p1"))) + repository.removeAt(snapshot, 2) + repository.setCursor(2) + val point = repository.captureResetPoint() + + setPlaylist("p2", "s0", "s4") + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p2")), point) + + assertEquals(3L, repository.remapPosition(repository.snapshot(), point.current!!)) + assertEquals(3L, repository.cursor()) + } + + @Test + fun anUnrelatedOriginResetPreservesInterleavedFragmentStarts() = + runTest { + seedLibrary() + val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.albumEntry(1, "al2"))) + repository.move(snapshot, 3, 1) + repository.setCursor(3) + val point = repository.captureResetPoint() + + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al2")), point) + + assertEquals(4L, repository.remapPosition(repository.snapshot(), point.current!!)) + assertEquals(4L, repository.cursor()) + } + + @Test + fun aMovedPlaylistOccurrenceSurvivesReconstructionAndReset() = + runTest { + seedLibrary() + setPlaylist("p1", "s1", "s2", "s1") + val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1"))) + repository.move(snapshot, 2, 0) + repository.setCursor(0) + val point = repository.captureResetPoint() + + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) + + assertEquals(2L, repository.remapPosition(repository.snapshot(), point.current!!)) + assertEquals(2L, repository.cursor()) + } + + @Test + fun aRemovedDuplicateOccurrenceDoesNotLeaveItsOffsetOnTheSurvivor() = + runTest { + seedLibrary() + setPlaylist("p1", "s1", "s1") + repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1"))) + repository.setCursor(1) + repository.setPosition(42_000L) + val point = repository.captureResetPoint() + + setPlaylist("p1", "s1") + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) + + assertEquals(0L, repository.cursorPositionMs()) + } + + @Test + fun anUnrelatedOriginResetKeepsTheEditedQueue() = + runTest { + seedLibrary() + setPlaylist("p1", "s1", "s2", "s3") + val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1"))) + repository.removeAt(snapshot, 1) + val edited = repository.snapshot().entries.map { it.entry } + val point = repository.captureResetPoint() + + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p2")), point) + + assertEquals(edited, repository.snapshot().entries.map { it.entry }) + } + + @Test + fun anOriginResetRebuildsOnlyTheChangedUpNextBlock() = + runTest { + seedLibrary() + setPlaylist("p1", "s1", "s2", "s3") + val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.addUpNext(0, repository.playlistEntry(1, "p1").copy(rangeStart = 0, rangeEnd = 1), playNext = false) + repository.addUpNext( + 0, + repository.albumEntry(1, "al2").copy(rangeStart = 1, rangeEnd = 1), + playNext = false, + ) + val point = repository.captureResetPoint() + + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) + + val upNext = repository.snapshot().upNext.map { it.entry } + assertEquals(QueueKind.Playlist, upNext[0].kind) + assertNull(upNext[0].rangeStart) + assertEquals(QueueKind.Album, upNext[1].kind) + assertEquals(1L, upNext[1].rangeStart) + assertEquals(1L, upNext[1].rangeEnd) + } + + @Test + fun anOriginResetPreservesDuplicateWholeUpNextEntries() = + runTest { + seedLibrary() + setPlaylist("p1", "s1", "s2", "s3") + repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false) + repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false) + val point = repository.captureResetPoint() + + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) + + val updated = repository.snapshot() + assertEquals(2, updated.upNext.size) + assertEquals(6L, updated.upNextSize) + assertTrue(updated.upNext.all { it.entry.rangeStart == null && it.entry.rangeEnd == null }) + } + + @Test + fun aResetRemapsCurrentWithinTheSecondDuplicateUpNextEntry() = + runTest { + seedLibrary() + setPlaylist("p1", "s1", "s2") + repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false) + repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false) + repository.setCursor(4) + val point = repository.captureResetPoint() + + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) + + assertEquals(4L, repository.cursor()) + } + + @Test + fun aResetRemapsCurrentAndAnchorWithinTheSecondDuplicateContextEntry() = + runTest { + seedLibrary() + setPlaylist("p1", "s1", "s2") + repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1"), repository.playlistEntry(1, "p1"))) + repository.addUpNext(3, repository.songEntry(1, "s4"), playNext = false) + repository.setCursor(3) + val point = repository.captureResetPoint() + + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) + + assertEquals(3L, repository.cursor()) + assertEquals(3L, repository.snapshot().upNextAnchor) + } + + @Test + fun aResetRemapsTheSecondOccurrenceOfASongWithinAPlaylist() = + runTest { + seedLibrary() + setPlaylist("p1", "s1", "s2", "s1") + repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1"))) + repository.setCursor(2) + val point = repository.captureResetPoint() + + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) + + assertEquals(2L, repository.cursor()) + } + + @Test + fun aRemovedShuffledAnchorFallbackStaysInPlayCoordinates() = + runTest { + seedLibrary() + setPlaylist("p1", "s1", "s2", "s3", "s4", "s5") + val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1"))) + repository.addUpNext(2, repository.songEntry(1, "s4"), playNext = false) + val seed = (1L..100L).first { Shuffle.toSequence(it, 4, 2) != 2L } + repository.setShuffle(seed) + val point = repository.captureResetPoint() + + setPlaylist("p1", "s1", "s2", "s4", "s5") + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) + repository.setShuffle(seed) + + val updated = repository.snapshot() + assertEquals(updated.flatContext(point.anchor!!.position), updated.upNextAnchor) + } + + @Test + fun aResetDoesNotRestoreShuffleAfterTheUserDisablesIt() = + runTest { + seedLibrary() + setPlaylist("p1", "s1", "s2", "s3") + val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1"))) + repository.removeAt(snapshot, 1) + repository.setShuffle(42L) + val point = repository.captureResetPoint() + repository.setShuffle(null) + + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) + + assertFalse(repository.snapshot().shuffled) + } + + @Test + fun aResetPreservesShuffleEnabledDuringSync() = + runTest { + seedLibrary() + setPlaylist("p1", "s1", "s2", "s3") + val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1"))) + repository.removeAt(snapshot, 1) + val point = repository.captureResetPoint() + repository.setShuffle(99L) + + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) + + assertTrue(repository.snapshot().shuffled) + assertEquals(99L, repository.snapshot().shuffleSeed) + } + + @Test + fun aConcurrentUpNextEditSurvivesAReset() = + runTest { + seedLibrary() + setPlaylist("p1", "s1", "s2", "s3") + val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1"))) + repository.removeAt(snapshot, 1) + val point = repository.captureResetPoint() + + val reset = launch { repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) } + val edit = launch { repository.addUpNext(0, repository.songEntry(1, "s4"), playNext = false) } + reset.join() + edit.join() + + assertEquals(1L, repository.snapshot().upNextSize) + } + + @Test + fun staleContextEditsCannotOverwriteACompletedReset() = + runTest { + seedLibrary() + setPlaylist("p1", "s1", "s2", "s3") + val initial = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1"))) + repository.removeAt(initial, 1) + val stale = repository.snapshot() + val point = repository.captureResetPoint() + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) + val canonical = repository.snapshot().entries.map { it.entry } + + repository.removeAt(stale, 0) + assertFalse(repository.move(stale, 0, 1)) + + assertEquals(canonical, repository.snapshot().entries.map { it.entry }) + } + + @Test + fun staleUpNextRemovalCannotOverwriteACompletedReset() = + runTest { + seedLibrary() + setPlaylist("p1", "s1", "s2", "s3") + val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.addUpNext(0, repository.playlistEntry(1, "p1").copy(rangeStart = 0, rangeEnd = 1), playNext = false) + val stale = repository.snapshot() + val point = repository.captureResetPoint() + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) + val canonical = repository.snapshot().upNext.map { it.entry } + + repository.removeUpNextAt(stale, 0) + + assertEquals(canonical, repository.snapshot().upNext.map { it.entry }) + } + + @Test + fun cacheInvalidationDoesNotAdvanceTheQueueContentVersion() = + runTest { + seedLibrary() + repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + val before = repository.snapshot().version + + repository.invalidateLibraryCache() + + assertEquals(before, repository.snapshot().version) + } + + @Test + fun aResetPrefersTheLatestUpNextAnchorOverTheCapturedOne() = + runTest { + seedLibrary() + repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.addUpNext(0, repository.albumEntry(1, "al2").copy(rangeStart = 0, rangeEnd = 0), playNext = false) + val point = repository.captureResetPoint() + val navigated = repository.snapshot() + repository.reanchorFor(navigated, 2) + repository.setCursor(repository.snapshot().anchorPlay + 1) + + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al2")), point) + + assertEquals(1L, repository.snapshot().upNextAnchor) + } + + @Test + fun aResetPreservesANewBeforeFirstUpNextAnchor() = + runTest { + seedLibrary() + repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.addUpNext(0, repository.albumEntry(1, "al2").copy(rangeStart = 0, rangeEnd = 0), playNext = false) + val point = repository.captureResetPoint() + repository.removeAt(repository.snapshot(), 0) + assertEquals(-1L, repository.snapshot().upNextAnchor) + + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al2")), point) + + assertEquals(-1L, repository.snapshot().upNextAnchor) + } + + @Test + fun aDeletedCurrentSongDoesNotLeaveItsPositionOnTheSuccessor() = + runTest { + seedLibrary() + setPlaylist("p1", "s1", "s2", "s3") + repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1").copy(rangeStart = 0, rangeEnd = 2))) + repository.setCursor(0) + repository.setPosition(50_000) + val point = repository.captureResetPoint() + setPlaylist("p1", "s2", "s3") + + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) + + assertEquals(0L, repository.cursorPositionMs()) + } + + @Test + fun resetWaitsForCursorUpdatesBeforeWritingItsCursor() = + runTest { + seedLibrary() + setPlaylist("p1", "s1", "s2", "s3") + val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1"))) + repository.removeAt(snapshot, 0) + val point = repository.captureResetPoint() + + repository.cursorMutex.lock() + val reset = launch { repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) } + val repeat = launch { repository.setRepeat(2) } + try { + yield() + assertFalse(reset.isCompleted) + assertFalse(repeat.isCompleted) + } finally { + repository.cursorMutex.unlock() + } + reset.join() + repeat.join() + + assertEquals(2, repository.modes().repeat) + } + @Test fun upNextResolvesAfterTheCurrentTrack() = runTest { seedLibrary() val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) - repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) + repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) val updated = repository.snapshot() assertEquals(4, updated.size) assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(updated)) } + @Test + fun reAddingUpNextAfterItsLibraryOriginChangedResolvesFreshRows() = + runTest { + seedLibrary() + setPlaylist("p1", "s1", "s2") + repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false) + val initial = repository.snapshot() + assertEquals(listOf("s1", "s2"), (1L..2L).map { repository.itemAt(initial, it)?.song?.id }) + repository.clearUpNext() + setPlaylist("p1", "s1", "s2", "s3") + val point = repository.captureResetPoint() + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) + + repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false) + + val updated = repository.snapshot() + assertEquals(3L, updated.upNextSize) + assertEquals(listOf("s1", "s2", "s3"), (1L..3L).map { repository.itemAt(updated, it)?.song?.id }) + } + @Test fun playNextGoesToTheFrontOfTheUpNextQueue() = runTest { seedLibrary() val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) - repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) + repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) repository.addUpNext( - repository.snapshot(), currentPosition = 0, entry = repository.songEntry(1, "s5"), playNext = true, @@ -451,7 +869,7 @@ class QueueRepositoryTest { runTest { seedLibrary() val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) - repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) + repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) repository.removeAt(repository.snapshot(), 1) @@ -465,9 +883,8 @@ class QueueRepositoryTest { runTest { seedLibrary() val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) - repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) + repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) repository.addUpNext( - repository.snapshot(), currentPosition = 0, entry = repository.songEntry(1, "s5"), playNext = false, @@ -486,7 +903,7 @@ class QueueRepositoryTest { runTest { seedLibrary() val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) - repository.addUpNext(snapshot, currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false) + repository.addUpNext(currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false) repository.move(repository.snapshot(), from = 2, to = 1) @@ -509,9 +926,8 @@ class QueueRepositoryTest { ), ) val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al2"))) - repository.addUpNext(snapshot, currentPosition = 0, entry = albumRange(1, 4), playNext = false) + repository.addUpNext(currentPosition = 0, entry = albumRange(1, 4), playNext = false) repository.addUpNext( - repository.snapshot(), currentPosition = 0, entry = repository.songEntry(1, "x1"), playNext = false, @@ -562,7 +978,7 @@ class QueueRepositoryTest { 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) + repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s5"), playNext = false) val updated = repository.snapshot() assertEquals(4, updated.size) @@ -580,7 +996,7 @@ class QueueRepositoryTest { runTest { seedLibrary() val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) - repository.addUpNext(snapshot, currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false) + repository.addUpNext(currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false) assertEquals(listOf("s1", "s4", "s5", "s2", "s3"), resolveAll(repository.snapshot())) repository.removeAt(repository.snapshot(), 2) @@ -593,7 +1009,7 @@ class QueueRepositoryTest { runTest { seedLibrary() val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) - repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) + repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(repository.snapshot())) repository.removeAt(repository.snapshot(), 0) @@ -606,7 +1022,7 @@ class QueueRepositoryTest { runTest { seedLibrary() val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) - repository.addUpNext(snapshot, currentPosition = 2, entry = repository.songEntry(1, "s4"), playNext = false) + repository.addUpNext(currentPosition = 2, entry = repository.songEntry(1, "s4"), playNext = false) assertEquals(listOf("s1", "s2", "s3", "s4"), resolveAll(repository.snapshot())) repository.removeAt(repository.snapshot(), 2) @@ -621,7 +1037,7 @@ class QueueRepositoryTest { runTest { seedLibrary() val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) - repository.addUpNext(snapshot, currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false) + repository.addUpNext(currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false) assertEquals(listOf("s1", "s2", "s4", "s3"), resolveAll(repository.snapshot())) repository.removeAt(repository.snapshot(), 0) @@ -634,7 +1050,7 @@ class QueueRepositoryTest { runTest { seedLibrary() val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) - repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) + repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) repository.replace(listOf(repository.songEntry(1, "s5"))) @@ -783,98 +1199,22 @@ class QueueRepositoryTest { } @Test - fun removingAContextTrackWhileShuffledKeepsTheRemainingOrder() = + fun removingAContextTrackWhileShuffledIsRejected() = runTest { seedLibrary() val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) repository.setShuffle(42L) val before = repository.snapshot() + val entriesBefore = before.entries.map { it.entry } val orderBefore = (0 until before.contextSize).mapNotNull { repository.itemAt(before, it)?.song?.id } repository.removeAt(before, 1) val after = repository.snapshot() assertTrue(after.shuffled) - assertEquals(2L, after.contextSize) - val orderAfter = (0 until after.contextSize).mapNotNull { repository.itemAt(after, it)?.song?.id } - assertEquals(orderBefore.filterIndexed { index, _ -> index != 1 }, orderAfter) - } - - @Test - fun severalRemovalsWhileShuffledKeepTheRemainingOrder() = - runTest { - seedLibrary() - val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) - repository.setShuffle(42L) - val before = repository.snapshot() - val orderBefore = (0 until before.contextSize).mapNotNull { repository.itemAt(before, it)?.song?.id } - - repository.removeAt(before, 0) - repository.removeAt(repository.snapshot(), 0) - - val after = repository.snapshot() - assertTrue(after.shuffled) - assertEquals(1L, after.contextSize) - val orderAfter = (0 until after.contextSize).mapNotNull { repository.itemAt(after, it)?.song?.id } - assertEquals(orderBefore.drop(2), orderAfter) - } - - @Test - fun removingWhileShuffledKeepsTheOrderInverse() = - runTest { - seedLibrary() - val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4"))) - repository.setShuffle(9L) - repository.removeAt(repository.snapshot(), 2) - - val after = repository.snapshot() - assertEquals(3L, after.contextSize) - for (play in 0 until after.contextSize) { - assertEquals(play, after.playContext(after.flatContext(play)!!)) - } - assertEquals((0L until after.contextSize).toList(), (0 until after.contextSize).map { after.flatContext(it)!! }.sorted()) - } - - @Test - fun removingAnyPositionWhileShuffledKeepsTheCoordinatesConsistent() = - runTest { - seedLibrary() - for (seed in listOf(1L, 42L, 999L)) { - for (position in 0L..4L) { - repository.replace(listOf(repository.albumEntry(1, "al1"))) - repository.setShuffle(seed) - repository.addUpNext( - repository.snapshot(), - currentPosition = 0, - entry = repository.songEntry(1, "s4"), - playNext = false, - ) - val before = repository.snapshot() - if (position >= before.size) continue - - repository.removeAt(before, position) - - val after = repository.snapshot() - assertTrue("seed=$seed position=$position stopped being shuffled", after.shuffled) - assertEquals(before.size - 1, after.size) - val flats = (0 until after.contextSize).map { after.flatContext(it)!! } - assertEquals((0L until after.contextSize).toList(), flats.sorted()) - for (play in 0 until after.contextSize) { - assertEquals(play, after.playContext(after.flatContext(play)!!)) - } - for (p in 0 until after.size) { - if (after.isUpNext(p)) continue - assertEquals(p, after.combined(after.contextPlay(p))) - } - val block = (0 until after.size).filter { after.isUpNext(it) } - assertEquals( - (after.anchorPlay + 1..after.anchorPlay + after.upNextSize).toList(), - block, - ) - val ids = (0 until after.size).mapNotNull { repository.itemAt(after, it)?.song?.id } - assertEquals(ids.distinct().size, ids.size) - } - } + assertEquals(before.contextSize, after.contextSize) + assertEquals(entriesBefore, after.entries.map { it.entry }) + assertEquals(orderBefore, (0 until after.contextSize).mapNotNull { repository.itemAt(after, it)?.song?.id }) } @Test @@ -932,7 +1272,7 @@ class QueueRepositoryTest { seedLibrary() val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) - repository.addUpNext(snapshot, currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false) + repository.addUpNext(currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false) val updated = repository.snapshot() assertEquals(1L, updated.anchorPlay) @@ -947,13 +1287,13 @@ class QueueRepositoryTest { runTest { seedLibrary() val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) - repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) + repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) val before = repository.snapshot() assertEquals(0L, before.anchorPlay) assertEquals(3L, repository.combinedContextIndexOf(before, "s3")) - repository.setUpNextAnchor(2) + repository.reanchorFor(before, 3) val after = repository.snapshot() assertEquals(2L, after.anchorPlay) @@ -966,7 +1306,7 @@ class QueueRepositoryTest { runTest { seedLibrary() val snapshot = repository.snapshotAfter(listOf(repository.songEntry(1, "s4"))) - repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) + repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) val updated = repository.snapshot() assertEquals(0L, repository.combinedContextIndexOf(updated, "s4")) @@ -1105,6 +1445,16 @@ class QueueRepositoryTest { created = 0, ) + private suspend fun setPlaylist( + id: String, + vararg songIds: String, + ) { + db.libraryDao().deletePlaylistSongsFrom(1, id, 0) + db.libraryDao().upsertPlaylistSongs( + songIds.mapIndexed { position, songId -> PlaylistSong(1, id, songId, position.toLong()) }, + ) + } + 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/sync/SyncManagerTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt index f0e139f2..1b28c057 100644 --- a/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt @@ -101,7 +101,7 @@ class SyncManagerTest { } @Test - fun aFailedSyncStillInvalidatesTheLibraryCache() = + fun aFailedSyncDoesNotAdvanceTheQueueContentVersion() = runBlocking { val before = queueRepository.snapshot().version @@ -109,7 +109,7 @@ class SyncManagerTest { val status = withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } } assertTrue(status is SyncStatus.Failed) - assertEquals(before + 1, queueRepository.snapshot().version) + assertEquals(before, queueRepository.snapshot().version) } @Test diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 4f836e22..fa851777 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -36,6 +36,9 @@ import com.subtracks.data.prefs.fakeUserPreferences import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.SourceRepository +import com.subtracks.data.repo.removeAt +import com.subtracks.data.repo.replace +import com.subtracks.data.repo.setShuffle import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -55,7 +58,6 @@ import okhttp3.OkHttpClient import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -2116,40 +2118,28 @@ class PlaybackControllerTest { } @Test - fun removingATrackWhileShuffledKeepsTheCurrentTrackPlaying() { + fun removingATrackWhileShuffledIsRejected() { 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 + val before = runBlocking { queues.snapshot() } + val itemsBefore = handle.items.map { it.id } - runBlocking { controller.removeAt(removeAt) } - await { - controller.state.value.item - ?.id == currentId && controller.state.value.shuffle - } + runBlocking { controller.removeAt((controller.state.value.position!! + 1) % 6) } - 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) + val after = runBlocking { queues.snapshot() } + assertEquals(before.contextSize, after.contextSize) + assertEquals(before.entries.map { it.entry }, after.entries.map { it.entry }) + assertEquals(itemsBefore, handle.items.map { it.id }) } @Test - fun removingThePlayingTrackWhileShuffledLeavesTheBlockReachable() { + fun removingThePlayingTrackWhileShuffledLeavesTheBlockUnchanged() { seedAlbum(6, sourceId = 1) seedSong("x1", "al2") runBlocking { @@ -2161,30 +2151,24 @@ class PlaybackControllerTest { await { controller.state.value.item != null && controller.state.value.shuffle } controller.addToQueue(1, QueueKind.Song, "x1") await { runBlocking { controller.upcomingItem()?.id } == "x1" } + val before = runBlocking { queues.snapshot() } + val currentId = handle.currentItem?.id - val current = controller.state.value.position!! - runBlocking { controller.removeAt(current) } + runBlocking { controller.removeAt(controller.state.value.position!!) } - // The block was queued behind the removed track, so it takes the slot and plays now. - await { - controller.state.value.item - ?.id == "x1" - } val after = runBlocking { queues.snapshot() } - assertTrue(after.shuffled) - assertEquals( - "x1", - runBlocking { queues.itemAt(after, controller.state.value.position!!)?.song?.id }, - ) + assertEquals(before.entries.map { it.entry }, after.entries.map { it.entry }) + assertEquals(before.upNextAnchor, after.upNextAnchor) + assertEquals(1L, after.upNextSize) + assertEquals(currentId, handle.currentItem?.id) } @Test - fun removingTheAnchorWhilePlayingABlockTrackKeepsTheBlockInPlace() { + fun removingTheAnchorWhilePlayingABlockTrackIsRejected() { seedShuffledQueue(seed = 5L) seedSong("x1", "al2") controller.addToQueue(1, QueueKind.Song, "x1") await { runBlocking { controller.upcomingItem()?.id } == "x1" } - controller.playAt(1) await { controller.state.value.item @@ -2195,8 +2179,9 @@ class PlaybackControllerTest { runBlocking { controller.removeAt(before.anchorPlay) } val after = runBlocking { queues.snapshot() } - assertEquals(-1L, after.anchorPlay) - assertEquals("x1", runBlocking { queues.itemAt(after, 0)?.song?.id }) + assertEquals(before.upNextAnchor, after.upNextAnchor) + assertEquals(before.contextSize, after.contextSize) + assertEquals("x1", runBlocking { queues.itemAt(after, 1)?.song?.id }) assertEquals( "x1", controller.state.value.item @@ -2205,20 +2190,18 @@ class PlaybackControllerTest { } @Test - fun removingThePlayingTrackWhileShuffledPlaysTheNextShuffledTrack() { + fun removingThePlayingTrackWhileShuffledDoesNotAdvance() { seedShuffledQueue(seed = 12345L) val orderBefore = resolvedIds() - val current = controller.state.value.position!! - val expected = orderBefore.toMutableList().apply { removeAt(current.toInt()) }[current.toInt()] + val currentId = handle.currentItem?.id + val before = runBlocking { queues.snapshot() } - runBlocking { controller.removeAt(current) } + runBlocking { controller.removeAt(controller.state.value.position!!) } - await { - controller.state.value.item - ?.id == expected - } val after = runBlocking { queues.snapshot() } - assertEquals(expected, runBlocking { queues.itemAt(after, controller.state.value.position!!)?.song?.id }) + assertEquals(before.entries.map { it.entry }, after.entries.map { it.entry }) + assertEquals(currentId, handle.currentItem?.id) + assertEquals(orderBefore, resolvedIds()) } @Test @@ -2275,73 +2258,6 @@ class PlaybackControllerTest { assertEquals("x1", runBlocking { queues.itemAt(after, after.anchorPlay + 2)?.song?.id }) } - @Test - fun undoingARemovalRestoresTheShuffledOrder() { - seedShuffledQueue(seed = 42L) - val orderBefore = resolvedIds() - val currentPosition = controller.state.value.position!! - val before = runBlocking { queues.snapshot() } - val removePosition = (0 until before.size).first { it != currentPosition } - - runBlocking { controller.removeAt(removePosition) } - assertNotEquals(orderBefore, resolvedIds()) - - runBlocking { controller.undo() } - - await { controller.state.value.shuffle && resolvedIds() == orderBefore } - assertEquals(orderBefore, resolvedIds()) - assertEquals(currentPosition, controller.state.value.position) - } - - @Test - fun removingANonCurrentTrackWhileShuffledKeepsTheCursorOnThePlayingTrack() { - seedShuffledQueue(seed = 12345L) - val currentId = - controller.state.value.item!! - .id - val before = runBlocking { queues.snapshot() } - val currentPosition = controller.state.value.position!! - val removePosition = (0 until before.size).first { it != currentPosition } - - runBlocking { controller.removeAt(removePosition) } - - val after = runBlocking { queues.snapshot() } - assertEquals( - currentId, - controller.state.value.item - ?.id, - ) - assertEquals(currentId, runBlocking { queues.itemAt(after, controller.state.value.position!!)?.song?.id }) - } - - @Test - fun removingANonCurrentTrackWhileShuffledKeepsTheBlockNext() { - seedAlbum(6, sourceId = 1) - seedSong("x1", "al2") - runBlocking { - queues.replace(listOf(queues.albumEntry(1, "al1"))) - queues.setShuffle(999L) - queues.setCursor(0) - } - controller.connect() - await { controller.state.value.item != null && controller.state.value.shuffle } - controller.addToQueue(1, QueueKind.Song, "x1") - await { runBlocking { controller.upcomingItem()?.id } == "x1" } - - val currentId = - controller.state.value.item!! - .id - val before = runBlocking { queues.snapshot() } - val currentPosition = controller.state.value.position!! - val removePosition = (0 until before.size).first { !before.isUpNext(it) && it != currentPosition } - - runBlocking { controller.removeAt(removePosition) } - - val after = runBlocking { queues.snapshot() } - assertEquals(currentId, runBlocking { queues.itemAt(after, controller.state.value.position!!)?.song?.id }) - await { runBlocking { controller.upcomingItem()?.id } == "x1" } - } - @Test fun movingContextTracksWhileShuffledIsIgnored() { seedAlbum(6, sourceId = 1) @@ -2799,39 +2715,6 @@ class PlaybackControllerTest { ) } - @Test - fun removingAContextTrackWhileShuffledKeepsThePlayingTrackAndTheBlock() { - seedAlbum(6, sourceId = 1) - seedSong("x1", "al2") - - controller.playAlbum(1, "al1", 2) - await { - controller.state.value.item - ?.id == "s3" - } - controller.addToQueue(1, QueueKind.Song, "x1") - await { runBlocking { controller.upcomingItem()?.id } == "x1" } - controller.toggleShuffle() - await { controller.state.value.shuffle && runBlocking { controller.upcomingItem()?.id } == "x1" } - - val currentId = - controller.state.value.item!! - .id - val before = runBlocking { queues.snapshot() } - val currentPosition = controller.state.value.position!! - val removePosition = (0 until before.size).first { !before.isUpNext(it) && it != currentPosition } - - runBlocking { controller.removeAt(removePosition) } - - await { - val snap = runBlocking { queues.snapshot() } - controller.state.value.item - ?.id == currentId && - runBlocking { queues.itemAt(snap, controller.state.value.position!!)?.song?.id } == currentId - } - await { runBlocking { controller.upcomingItem()?.id } == "x1" } - } - @Test fun shufflingTheSameAlbumAgainPicksADifferentTrack() { seedAlbum(5, sourceId = 1) diff --git a/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt index 482e3369..1b6cd98b 100644 --- a/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt @@ -21,6 +21,8 @@ import com.subtracks.data.prefs.fakeUserPreferences import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.SourceRepository +import com.subtracks.data.repo.replace +import com.subtracks.data.repo.setShuffle import com.subtracks.playback.FakePlayerConnection import com.subtracks.playback.FakePlayerHandle import com.subtracks.playback.PlaybackController diff --git a/docs/architecture.md b/docs/architecture.md index d0b4e92e..8382415b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -84,13 +84,15 @@ 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. 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. +All queue and player-window mutations run through one `QueueMutationScope`. Snapshot reads, edits, sync/download resets, Undo and playback reconciliation share that lock, and reset reconciliation is invoked synchronously before the reset operation returns; there is no asynchronous reset collector. Content revisions reject stale writes, a separate reset generation rejects row operations created before a canonical reset, and a library revision only controls resolution caches. Navigation computes its target inside the mutation operation, while position-based UI edits carry their reset generation from operation creation. External initialization waits, including download-state loading, stay outside the mutation lock. + +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. Every entry also retains its origin context, so explicit songs created by rearranges can later be merged back into the album, playlist, artist or genre they came from. 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. Context removal and reordering are disabled while shuffled; manual ordering and deletion still work in the up-next block and in non-shuffled contexts. 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. Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. Some streams stay length-unknown anyway: an Opus/Ogg/Vorbis transcode (a length estimate breaks the end-seek they rely on), a transcode of a track with no library duration, and a direct play the server does not give a `Content-Length`. Media3 marks an unknown-length, unknown-duration progressive stream live, and a live item makes the media session publish no playback position, so the notification would show no progress even though the item carries a real duration; `KnownDurationPlayer` wraps the `ExoPlayer` and reports an item with a library duration as finite, restoring the position and progress the session publishes without making the stream seekable. Streamed bytes also pass through a bounded `SimpleCache` (`CacheDataSource` sits under `DefaultDataSource`, so downloads bypass it): a headerless MP3's duration is only estimated, and when the extractor rewrites it at end of stream Media3 drops and re-opens the prefetched next period. The cache serves the bytes that prefetch already buffered from disk, so the re-open does not re-fetch them (only bytes the cancelled prefetch never committed are fetched again). Keys are the salted stream URLs, so nothing is reused across a service restart; it is a bounded scratch buffer, not a persistent download. -The window is also the queue the media notification browses — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics are not wired yet. +The window is also the queue the media notification browses — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index while retaining its origin context, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics are not wired yet. Scrobbling is a `Scrobbler` that collects the controller's `state` and `positionMs` and drives the Subsonic `scrobble` endpoint: a now-playing notification (`submission=false`) when a track starts playing, and one submission (`submission=true`, with the playback start time) when the track passes half its duration or four minutes, whichever comes first. That split is the endpoint's own `submission` flag, and the threshold is the Last.fm rule the server forwards to; servers such as Navidrome trust the client and apply no threshold themselves. Tracks under 30 seconds are never submitted, and only time actually played counts: forward and backward seeks are ignored, and a rewind only starts a fresh listen when it lands in the track's first few seconds (a repeat or an explicit restart). The duration is the player's resolved value, falling back to the library row when the player has not reported one. Sending is decoupled from the collector so a slow or offline write cannot stall position tracking, while a lock keeps the writes in order. The policy is a pure state machine with no Android dependencies. A Settings toggle (`UserPreferences.scrobbling`, on by default) gates both calls. A submission also bumps the played song's local `playCount`/`played` and its album and artist ranks, so the play sorts move before the next sync; the sync then overwrites them with the server's own numbers, so a play the server never accepted is rolled back at that point. @@ -126,7 +128,7 @@ Songs have a primary and a fallback fetch. The primary probes `search3` with an Cleanup is the "everything I had, minus everything the server just sent" step described above: artists, albums, songs and playlists delete the leftover IDs in 500-row chunks, disc titles are deleted per `(album, disc)` for albums that still exist, and playlist entries beyond the new count are removed along with entries for playlists that disappeared. -Sync is not atomic. Each batch commits on its own, so a failure leaves the batches already written in place and skips that entity's prune; stale rows can linger until the next successful sync, which also invalidates the queue's cached library. The one hazard is a scan that stops early — a server returning a short page by mistake, or a library larger than the page cap — because the prune trusts that the stream actually reached the end. Each batch commit also makes Room notify paged observers once per batch (a few per second, bounded by the network round-trips), which is cheap but can make an on-screen list churn during a sync; gate UI observation on `SyncStatus.Running` if that is ever noticeable. +Sync is not atomic. Each batch commits on its own, so a failure leaves the batches already written in place and skips that entity's prune; stale rows can linger until the next successful sync, which also invalidates the queue's cached library. Changed origins are merged only after their batch transaction commits and are persisted per source after failures, so the next successful sync includes them even after a process restart. A successful sync reports the origins it actually changed and collapses only those context and up-next blocks back to their canonical references; unrelated origins and failed syncs leave local edits alone. The current item and up-next anchor are remapped by origin, song ID and position as a best effort, without promising exact duplicate-occurrence identity. If the current song disappeared it finishes in a detached slot before the refreshed queue continues at a deterministic clamped position. Each batch commit also makes Room notify paged observers once per batch (a few per second, bounded by the network round-trips), which is cheap but can make an on-screen list churn during a sync; gate UI observation on `SyncStatus.Running` if that is ever noticeable. ## Search