From 94c7fba536e81935850d4f2412e1719a79e8edcd Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:25:05 +0000 Subject: [PATCH] queue: preserve ambiguous legacy reset identity --- .../subtracks/data/repo/QueueRepository.kt | 18 ++-- .../com/subtracks/data/db/MigrationsTest.kt | 83 ++++++++++++++++++- 2 files changed, 94 insertions(+), 7 deletions(-) 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 0b3d593a..24fa9a0f 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -505,8 +505,8 @@ class QueueRepository( upNextAnchor: Long, ) { invalidateLibraryCache() - writeLocked(withOriginEntryIds(entries)) - writeUpNextLocked(withOriginEntryIds(upNext)) + writeLocked(withOriginEntryIds(entries, assignFragmentIds = false)) + writeUpNextLocked(withOriginEntryIds(upNext, assignFragmentIds = false)) setShuffleLocked(shuffleSeed) cursorMutex.withLock { dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0, upNextAnchor = upNextAnchor)) @@ -588,7 +588,9 @@ class QueueRepository( 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 matchingOrigin = + before.entries.any { it.entry.origin in changed } || before.upNext.any { it.entry.origin in changed } + if (!matchingOrigin) return@withMutationLock val updated = writeResetLocked(before, entries, upNext, point) resetListener?.invoke(this, QueueReset(point, updated)) } @@ -856,8 +858,14 @@ class QueueRepository( } 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 (rank != null) { + val insideEntry = rank >= entry.offset && rank < entry.offset + resolved.length + val flat = + when { + insideEntry -> group.starts[index] + rank - entry.offset + entry.originEntryId == null -> rank + else -> continue + } if (item.upNext) return snapshot.anchorPlay + 1 + flat val play = snapshot.playContext(flat) ?: return null return snapshot.combined(play) diff --git a/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt b/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt index 06745823..08587138 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt @@ -7,9 +7,12 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.execSQL import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueOrigin import com.subtracks.data.repo.QueueRepository +import com.subtracks.data.repo.removeAt +import com.subtracks.data.repo.replace import kotlinx.coroutines.test.runTest import org.json.JSONObject import org.junit.Assert.assertEquals @@ -524,24 +527,100 @@ class MigrationsTest { .build() try { val repository = QueueRepository(db) - val before = repository.snapshot() val point = repository.captureResetPoint() repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) val after = repository.snapshot() assertEquals(4, after.entries.size) - assertEquals(before.entries, after.entries) + assertEquals(listOf(null, null, null, null), after.entries.map { it.entry.originEntryId }) assertEquals( listOf("s1", "s3", "s1", "s3"), (0 until after.size).map { repository.itemAt(after, it)?.song?.id }, ) + + repository.removeAt(after, 0) + repository.replace(after.entries.map { it.entry }) + val undoPoint = repository.captureResetPoint() + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), undoPoint) + + val undone = repository.snapshot() + assertEquals(4, undone.entries.size) + assertEquals( + listOf("s1", "s3", "s1", "s3"), + (0 until undone.size).map { repository.itemAt(undone, it)?.song?.id }, + ) } finally { db.close() context.deleteDatabase(name) } } + @Test + fun aRetainedMigratedOriginStillRemapsAndPublishesWhenItsRowsChange() = + runTest { + val context = ApplicationProvider.getApplicationContext() + val name = "migration-38-remap-${System.nanoTime()}.db" + databaseFile(context, name).parentFile?.mkdirs() + driver.open(databaseFile(context, name).absolutePath).use { migrated -> + applySchema(migrated, 38) + migrated.execSQL("PRAGMA user_version = 38") + migrated.execSQL( + "INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 'server', 'http://localhost', 1, 0)", + ) + migrated.execSQL( + "INSERT INTO songs (sourceId, id, title) VALUES " + + "(1, 's0', 'Zero'), (1, 's1', 'One'), (1, 's2', 'Two'), (1, 's3', 'Three'), (1, 's4', 'Four')", + ) + migrated.execSQL( + "INSERT INTO playlist_songs (sourceId, playlistId, songId, position) VALUES " + + "(1, 'p1', 's1', 0), (1, 'p1', 's2', 1), (1, 'p1', 's3', 2), (1, 'p1', 's4', 3)", + ) + migrated.execSQL( + "INSERT INTO queue_entries (position, sourceId, kind, refId, rangeStart, rangeEnd) VALUES " + + "(0, 1, 'Playlist', 'p1', 0, 1), (1, 1, 'Playlist', 'p1', 2, 3)", + ) + } + val db = + Room + .databaseBuilder(context, name) + .setDriver(driver) + .addMigrations(MIGRATION_38_39) + .build() + try { + val repository = QueueRepository(db) + repository.setCursor(2) + val point = repository.captureResetPoint() + var resets = 0 + repository.setResetListener { resets++ } + db.libraryDao().deletePlaylistSongsFrom(1, "p1", 0) + db.libraryDao().upsertPlaylistSongs( + listOf( + PlaylistSong(1, "p1", "s0", 0), + PlaylistSong(1, "p1", "s1", 1), + PlaylistSong(1, "p1", "s2", 2), + PlaylistSong(1, "p1", "s3", 3), + PlaylistSong(1, "p1", "s4", 4), + ), + ) + + repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) + + val snapshot = repository.snapshot() + assertEquals(1, resets) + assertEquals(3L, repository.cursor()) + assertEquals("s3", repository.itemAt(snapshot, repository.cursor())?.song?.id) + } finally { + db.close() + context.deleteDatabase(name) + } + } + + private fun databaseFile( + context: Context, + name: String, + ): File = context.getDatabasePath(name) + private fun text( connection: SQLiteConnection, sql: String,