playback: preserve null keys and ranged duplicate ranks

This commit is contained in:
austinried
2026-11-04 13:15:54 +00:00
parent 95ad993996
commit eb55858cc4
3 changed files with 64 additions and 39 deletions
@@ -210,7 +210,7 @@ data class Reanchored(
) )
private interface QueueKindResolver { private interface QueueKindResolver {
val supportsKeyset: Boolean get() = false fun supportsKeyset(key: OrderKey): Boolean = false
suspend fun length( suspend fun length(
dao: com.subtracks.data.db.QueueDao, dao: com.subtracks.data.db.QueueDao,
@@ -247,9 +247,9 @@ private interface QueueKindResolver {
): Long? = null ): Long? = null
} }
private fun resolverFor(kind: QueueKind): QueueKindResolver = private val queueKindResolvers =
when (kind) { mapOf<QueueKind, QueueKindResolver>(
QueueKind.Song -> { QueueKind.Song to
object : QueueKindResolver { object : QueueKindResolver {
override suspend fun length( override suspend fun length(
dao: com.subtracks.data.db.QueueDao, dao: com.subtracks.data.db.QueueDao,
@@ -268,12 +268,10 @@ private fun resolverFor(kind: QueueKind): QueueKindResolver =
entry: QueueEntry, entry: QueueEntry,
songId: String, songId: String,
) = if (entry.refId == songId && length(dao, entry) > 0) 0L else null ) = if (entry.refId == songId && length(dao, entry) > 0) 0L else null
} },
} QueueKind.Playlist to
QueueKind.Playlist -> {
object : QueueKindResolver { object : QueueKindResolver {
override val supportsKeyset = true override fun supportsKeyset(key: OrderKey) = key.position != null
override suspend fun length( override suspend fun length(
dao: com.subtracks.data.db.QueueDao, dao: com.subtracks.data.db.QueueDao,
@@ -308,12 +306,10 @@ private fun resolverFor(kind: QueueKind): QueueKindResolver =
entry: QueueEntry, entry: QueueEntry,
songId: String, songId: String,
) = dao.playlistRank(entry.sourceId, entry.refId, songId) ) = dao.playlistRank(entry.sourceId, entry.refId, songId)
} },
} QueueKind.Album to
QueueKind.Album -> {
object : QueueKindResolver { object : QueueKindResolver {
override val supportsKeyset = true override fun supportsKeyset(key: OrderKey) = key.disc != null && key.track != null
override suspend fun length( override suspend fun length(
dao: com.subtracks.data.db.QueueDao, dao: com.subtracks.data.db.QueueDao,
@@ -348,10 +344,8 @@ private fun resolverFor(kind: QueueKind): QueueKindResolver =
entry: QueueEntry, entry: QueueEntry,
songId: String, songId: String,
) = dao.albumRank(entry.sourceId, entry.refId, songId) ) = dao.albumRank(entry.sourceId, entry.refId, songId)
} },
} QueueKind.Artist to
QueueKind.Artist -> {
object : QueueKindResolver { object : QueueKindResolver {
override suspend fun length( override suspend fun length(
dao: com.subtracks.data.db.QueueDao, dao: com.subtracks.data.db.QueueDao,
@@ -364,10 +358,8 @@ private fun resolverFor(kind: QueueKind): QueueKindResolver =
offset: Long, offset: Long,
limit: Int, limit: Int,
) = dao.artistSongs(entry.sourceId, entry.refId, offset, limit) ) = dao.artistSongs(entry.sourceId, entry.refId, offset, limit)
} },
} QueueKind.Genre to
QueueKind.Genre -> {
object : QueueKindResolver { object : QueueKindResolver {
override suspend fun length( override suspend fun length(
dao: com.subtracks.data.db.QueueDao, dao: com.subtracks.data.db.QueueDao,
@@ -380,10 +372,8 @@ private fun resolverFor(kind: QueueKind): QueueKindResolver =
offset: Long, offset: Long,
limit: Int, limit: Int,
) = dao.genreSongs(entry.sourceId, entry.refId, offset, limit) ) = dao.genreSongs(entry.sourceId, entry.refId, offset, limit)
} },
} QueueKind.Downloaded to
QueueKind.Downloaded -> {
object : QueueKindResolver { object : QueueKindResolver {
override suspend fun length( override suspend fun length(
dao: com.subtracks.data.db.QueueDao, dao: com.subtracks.data.db.QueueDao,
@@ -396,10 +386,8 @@ private fun resolverFor(kind: QueueKind): QueueKindResolver =
offset: Long, offset: Long,
limit: Int, limit: Int,
) = dao.downloadedSongs(entry.sourceId, offset, limit) ) = dao.downloadedSongs(entry.sourceId, offset, limit)
} },
} QueueKind.Starred to
QueueKind.Starred -> {
object : QueueKindResolver { object : QueueKindResolver {
override suspend fun length( override suspend fun length(
dao: com.subtracks.data.db.QueueDao, dao: com.subtracks.data.db.QueueDao,
@@ -412,9 +400,10 @@ private fun resolverFor(kind: QueueKind): QueueKindResolver =
offset: Long, offset: Long,
limit: Int, limit: Int,
) = dao.starredSongs(entry.sourceId, offset, limit) ) = dao.starredSongs(entry.sourceId, offset, limit)
} },
} )
}
private fun resolverFor(kind: QueueKind): QueueKindResolver = queueKindResolvers.getValue(kind)
class QueueRepository( class QueueRepository(
private val db: SubtracksDatabase, private val db: SubtracksDatabase,
@@ -858,10 +847,12 @@ class QueueRepository(
var start = 0L var start = 0L
for (resolved in entries) { for (resolved in entries) {
val entry = resolved.entry val entry = resolved.entry
val rank = resolverFor(entry.kind).rank(dao, entry, songId) ?: fallbackRank(entry, songId) val rank = resolverFor(entry.kind).rank(dao, entry, songId)
if (rank != null && rank >= entry.offset && rank < entry.offset + resolved.length) { if (rank != null && rank >= entry.offset && rank < entry.offset + resolved.length) {
return start + rank - entry.offset return start + rank - entry.offset
} }
val fallback = fallbackRank(entry, resolved.length, songId)
if (fallback != null) return start + fallback - entry.offset
start += resolved.length start += resolved.length
} }
return null return null
@@ -869,16 +860,20 @@ class QueueRepository(
private suspend fun fallbackRank( private suspend fun fallbackRank(
entry: QueueEntry, entry: QueueEntry,
length: Long,
songId: String, songId: String,
): Long? { ): Long? {
var offset = 0L var offset = entry.offset
while (true) { val end = entry.offset + length
val batch = rows(entry, offset, QUEUE_CHUNK) while (offset < end) {
val limit = minOf(QUEUE_CHUNK.toLong(), end - offset).toInt()
val batch = rows(entry, offset, limit)
val index = batch.indexOfFirst { it.song.id == songId } val index = batch.indexOfFirst { it.song.id == songId }
if (index >= 0) return offset + index if (index >= 0) return offset + index
if (batch.size < QUEUE_CHUNK) return null if (batch.size < limit) return null
offset += batch.size offset += batch.size
} }
return null
} }
private suspend fun contextPlayRange( private suspend fun contextPlayRange(
@@ -994,7 +989,7 @@ class QueueRepository(
limit: Int, limit: Int,
cursor: KeyCursor, cursor: KeyCursor,
): List<SongItem>? { ): List<SongItem>? {
if (!resolverFor(entry.kind).supportsKeyset) return null if (!resolverFor(entry.kind).supportsKeyset(cursor.key)) return null
val from = cursor.ordinal val from = cursor.ordinal
// A backward seek from a far cursor is not cheaper than the offset query: the album // A backward seek from a far cursor is not cheaper than the offset query: the album
// predicate is not sargable (it has to include NULL disc/track), so it scans the partition // predicate is not sargable (it has to include NULL disc/track), so it scans the partition
@@ -150,6 +150,18 @@ class QueueKeysetTest {
assertTrue("backward keyset seeks", repository.keysetSeeks > beforeBackward) assertTrue("backward keyset seeks", repository.keysetSeeks > beforeBackward)
} }
@Test
fun scanningAnAlbumWithNullDiscAndTrackForwardFallsBackToOffsetRows() =
runTest {
seedLibrary()
repository.replace(listOf(repository.albumEntry(1, "mixed")))
val snapshot = repository.snapshot()
val resolved = (0 until snapshot.size).map { repository.itemAt(snapshot, it)?.song?.id }
assertEquals(listOf("n1", "n2", "s1", "s2"), resolved)
}
@Test @Test
fun scanningAnAlbumWithNullDiscAndTrackBackwardsResolvesEveryRow() = fun scanningAnAlbumWithNullDiscAndTrackBackwardsResolvesEveryRow() =
runTest { runTest {
@@ -895,6 +895,24 @@ class QueueRepositoryTest {
assertEquals(1L, repository.combinedContextIndexOf(snapshot, "s1")) assertEquals(1L, repository.combinedContextIndexOf(snapshot, "s1"))
} }
@Test
fun contextIndexFindsADuplicateInsideTheEntryRange() =
runTest {
seedLibrary()
db.libraryDao().upsertPlaylists(listOf(playlist("duplicates", "Duplicates")))
db.libraryDao().upsertPlaylistSongs(
listOf(
PlaylistSong(1, "duplicates", "s2", 10),
PlaylistSong(1, "duplicates", "s1", 20),
PlaylistSong(1, "duplicates", "s2", 30),
),
)
val entry = repository.playlistEntry(1, "duplicates").copy(rangeStart = 1, rangeEnd = 2)
val snapshot = repository.snapshotAfter(listOf(entry))
assertEquals(1L, repository.combinedContextIndexOf(snapshot, "s2"))
}
@Test @Test
fun contextIndexFallsBackToBoundedRowsForGenre() = fun contextIndexFallsBackToBoundedRowsForGenre() =
runTest { runTest {