queue: preserve ambiguous legacy reset identity

This commit is contained in:
austinried
2026-11-14 12:03:42 +00:00
parent ddc6811c34
commit 94c7fba536
2 changed files with 94 additions and 7 deletions
@@ -505,8 +505,8 @@ class QueueRepository(
upNextAnchor: Long, upNextAnchor: Long,
) { ) {
invalidateLibraryCache() invalidateLibraryCache()
writeLocked(withOriginEntryIds(entries)) writeLocked(withOriginEntryIds(entries, assignFragmentIds = false))
writeUpNextLocked(withOriginEntryIds(upNext)) writeUpNextLocked(withOriginEntryIds(upNext, assignFragmentIds = false))
setShuffleLocked(shuffleSeed) setShuffleLocked(shuffleSeed)
cursorMutex.withLock { cursorMutex.withLock {
dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0, upNextAnchor = upNextAnchor)) dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0, upNextAnchor = upNextAnchor))
@@ -588,7 +588,9 @@ class QueueRepository(
val before = snapshot() val before = snapshot()
val entries = canonicalEntries(before.entries.map { it.entry }, changed) val entries = canonicalEntries(before.entries.map { it.entry }, changed)
val upNext = canonicalEntries(before.upNext.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) val updated = writeResetLocked(before, entries, upNext, point)
resetListener?.invoke(this, QueueReset(point, updated)) resetListener?.invoke(this, QueueReset(point, updated))
} }
@@ -856,8 +858,14 @@ class QueueRepository(
} else { } else {
resolverFor(entry.kind).rank(dao, entry, item.songId) ?: fallbackRank(entry, resolved.length, item.songId) 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) { if (rank != null) {
val flat = group.starts[index] + rank - entry.offset 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 if (item.upNext) return snapshot.anchorPlay + 1 + flat
val play = snapshot.playContext(flat) ?: return null val play = snapshot.playContext(flat) ?: return null
return snapshot.combined(play) return snapshot.combined(play)
@@ -7,9 +7,12 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.sqlite.execSQL import androidx.sqlite.execSQL
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.QueueOrigin import com.subtracks.data.model.QueueOrigin
import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.removeAt
import com.subtracks.data.repo.replace
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.json.JSONObject import org.json.JSONObject
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -524,24 +527,100 @@ class MigrationsTest {
.build() .build()
try { try {
val repository = QueueRepository(db) val repository = QueueRepository(db)
val before = repository.snapshot()
val point = repository.captureResetPoint() val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
val after = repository.snapshot() val after = repository.snapshot()
assertEquals(4, after.entries.size) assertEquals(4, after.entries.size)
assertEquals(before.entries, after.entries) assertEquals(listOf(null, null, null, null), after.entries.map { it.entry.originEntryId })
assertEquals( assertEquals(
listOf("s1", "s3", "s1", "s3"), listOf("s1", "s3", "s1", "s3"),
(0 until after.size).map { repository.itemAt(after, it)?.song?.id }, (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 { } finally {
db.close() db.close()
context.deleteDatabase(name) context.deleteDatabase(name)
} }
} }
@Test
fun aRetainedMigratedOriginStillRemapsAndPublishesWhenItsRowsChange() =
runTest {
val context = ApplicationProvider.getApplicationContext<Context>()
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<SubtracksDatabase>(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( private fun text(
connection: SQLiteConnection, connection: SQLiteConnection,
sql: String, sql: String,