From 95ad993996d1ddc587171b508c518b18499860db Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:06:45 +0000 Subject: [PATCH] playback: resolve shuffled windows without flat ID lists --- .../kotlin/com/subtracks/data/db/QueueDao.kt | 57 ++- .../subtracks/data/repo/LibraryRepository.kt | 5 +- .../subtracks/data/repo/QueueRepository.kt | 374 ++++++++++++------ .../subtracks/data/repo/QueueKeysetTest.kt | 16 + .../data/repo/QueueRepositoryTest.kt | 57 +++ 5 files changed, 350 insertions(+), 159 deletions(-) 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 c96cc2b8..7bba28b1 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt @@ -214,14 +214,34 @@ interface QueueDao { ): List @Query( - "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + - "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "WHERE songs.sourceId = :sourceId AND songs.id IN (:ids)", + "SELECT CASE WHEN EXISTS (SELECT 1 FROM songs WHERE sourceId = :sourceId AND albumId = :albumId AND id = :songId) " + + "THEN (SELECT COUNT(*) FROM songs ranked CROSS JOIN songs target " + + "WHERE ranked.sourceId = :sourceId AND ranked.albumId = :albumId " + + "AND target.sourceId = :sourceId AND target.albumId = :albumId AND target.id = :songId " + + "AND ((ranked.disc IS NULL AND target.disc IS NOT NULL) OR ranked.disc < target.disc OR " + + "(ranked.disc IS target.disc AND ((ranked.track IS NULL AND target.track IS NOT NULL) OR ranked.track < target.track OR " + + "(ranked.track IS target.track AND ranked.id < target.id))))) END", ) - suspend fun songsByIds( + suspend fun albumRank( sourceId: Long, - ids: List, - ): List + albumId: String, + songId: String, + ): Long? + + @Query( + "SELECT CASE WHEN firstPosition IS NULL THEN NULL ELSE " + + "(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 position <= firstPosition) END " + + "FROM (SELECT MIN(position) AS firstPosition 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 songId = :songId)", + ) + suspend fun playlistRank( + sourceId: Long, + playlistId: String, + songId: String, + ): Long? @Query("SELECT COUNT(*) FROM song_genres WHERE sourceId = :sourceId AND genre = :genre") suspend fun genreLength( @@ -243,17 +263,6 @@ interface QueueDao { limit: Int, ): List - @Query( - "SELECT songs.id FROM song_genres " + - "JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " + - "WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " + - "ORDER BY songs.sortName COLLATE NOCASE, songs.id", - ) - suspend fun genreSongIds( - sourceId: Long, - genre: String, - ): List - @Query("SELECT COUNT(*) FROM song_downloads WHERE sourceId = :sourceId AND status = 'Completed'") suspend fun downloadedLength(sourceId: Long): Long @@ -272,12 +281,6 @@ interface QueueDao { limit: Int, ): List - @Query( - "SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " + - "ORDER BY starred DESC, sortName COLLATE NOCASE, id", - ) - suspend fun starredSongIds(sourceId: Long): List - @Query( "SELECT songs.*, albums.coverArt AS coverArt FROM song_downloads d " + "JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " + @@ -290,12 +293,4 @@ interface QueueDao { offset: Long, limit: Int, ): List - - @Query( - "SELECT songs.id FROM song_downloads d " + - "JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " + - "WHERE d.sourceId = :sourceId AND d.status = 'Completed' " + - "ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id", - ) - suspend fun downloadedSongIds(sourceId: Long): List } diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index 9a59d3d2..d7510757 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -360,10 +360,7 @@ class LibraryRepository( sourceId: Long, albumId: String, songId: String, - ): Long { - val index = db.queueDao().albumSongIds(sourceId, albumId).indexOf(songId) - return if (index < 0) 0L else index.toLong() - } + ): Long = db.queueDao().albumRank(sourceId, albumId, songId) ?: 0L fun playlist( sourceId: Long, 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 79e79772..f6ef7bb5 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -209,6 +209,213 @@ data class Reanchored( val changed: Boolean, ) +private interface QueueKindResolver { + val supportsKeyset: Boolean get() = false + + suspend fun length( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + ): Long + + suspend fun rows( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + offset: Long, + limit: Int, + ): List + + suspend fun rowsFrom( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + key: OrderKey, + skip: Long, + limit: Int, + ): List? = null + + suspend fun rowsBefore( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + key: OrderKey, + skip: Long, + limit: Int, + ): List? = null + + suspend fun rank( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + songId: String, + ): Long? = null +} + +private fun resolverFor(kind: QueueKind): QueueKindResolver = + when (kind) { + QueueKind.Song -> { + object : QueueKindResolver { + override suspend fun length( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + ) = dao.songLength(entry.sourceId, entry.refId) + + override suspend fun rows( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + offset: Long, + limit: Int, + ) = dao.song(entry.sourceId, entry.refId, offset, limit) + + override suspend fun rank( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + songId: String, + ) = if (entry.refId == songId && length(dao, entry) > 0) 0L else null + } + } + + QueueKind.Playlist -> { + object : QueueKindResolver { + override val supportsKeyset = true + + override suspend fun length( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + ) = dao.playlistLength(entry.sourceId, entry.refId) + + override suspend fun rows( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + offset: Long, + limit: Int, + ) = dao.playlistSongs(entry.sourceId, entry.refId, offset, limit) + + override suspend fun rowsFrom( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + key: OrderKey, + skip: Long, + limit: Int, + ) = dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit) + + override suspend fun rowsBefore( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + key: OrderKey, + skip: Long, + limit: Int, + ) = dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit) + + override suspend fun rank( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + songId: String, + ) = dao.playlistRank(entry.sourceId, entry.refId, songId) + } + } + + QueueKind.Album -> { + object : QueueKindResolver { + override val supportsKeyset = true + + override suspend fun length( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + ) = dao.albumLength(entry.sourceId, entry.refId) + + override suspend fun rows( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + offset: Long, + limit: Int, + ) = dao.albumSongs(entry.sourceId, entry.refId, offset, limit) + + override suspend fun rowsFrom( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + key: OrderKey, + skip: Long, + limit: Int, + ) = dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit) + + override suspend fun rowsBefore( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + key: OrderKey, + skip: Long, + limit: Int, + ) = dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit) + + override suspend fun rank( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + songId: String, + ) = dao.albumRank(entry.sourceId, entry.refId, songId) + } + } + + QueueKind.Artist -> { + object : QueueKindResolver { + override suspend fun length( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + ) = dao.artistLength(entry.sourceId, entry.refId) + + override suspend fun rows( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + offset: Long, + limit: Int, + ) = dao.artistSongs(entry.sourceId, entry.refId, offset, limit) + } + } + + QueueKind.Genre -> { + object : QueueKindResolver { + override suspend fun length( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + ) = dao.genreLength(entry.sourceId, entry.refId) + + override suspend fun rows( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + offset: Long, + limit: Int, + ) = dao.genreSongs(entry.sourceId, entry.refId, offset, limit) + } + } + + QueueKind.Downloaded -> { + object : QueueKindResolver { + override suspend fun length( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + ) = dao.downloadedLength(entry.sourceId) + + override suspend fun rows( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + offset: Long, + limit: Int, + ) = dao.downloadedSongs(entry.sourceId, offset, limit) + } + } + + QueueKind.Starred -> { + object : QueueKindResolver { + override suspend fun length( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + ) = dao.starredLength(entry.sourceId) + + override suspend fun rows( + dao: com.subtracks.data.db.QueueDao, + entry: QueueEntry, + offset: Long, + limit: Int, + ) = dao.starredSongs(entry.sourceId, offset, limit) + } + } + } + class QueueRepository( private val db: SubtracksDatabase, ) { @@ -216,12 +423,6 @@ class QueueRepository( private val queueVersion = AtomicLong() - @Volatile - private var cachedFlatIds: List? = null - - @Volatile - private var cachedFlatVersion = -1L - @Volatile private var shuffleRemoved: ShuffleState? = null @@ -235,6 +436,9 @@ class QueueRepository( internal var offsetSeeks = 0L private set + internal var resolvedRowCount = 0L + private set + private val cursorMutex = Mutex() fun playlistEntry( @@ -388,7 +592,6 @@ class QueueRepository( } fun invalidateLibraryCache() { - cachedFlatIds = null lengthCache.clear() keyCache.clear() shuffleRemoved = null @@ -409,8 +612,8 @@ class QueueRepository( snapshot: QueueSnapshot, songId: String, ): Long? { - val upIndex = idsFor(snapshot.upNext).indexOf(songId) - if (upIndex >= 0) return snapshot.anchorPlay + 1 + upIndex + val upIndex = flatIndexOf(snapshot.upNext, songId) + if (upIndex != null) return snapshot.anchorPlay + 1 + upIndex return combinedContextIndexOf(snapshot, songId) } @@ -418,9 +621,8 @@ class QueueRepository( snapshot: QueueSnapshot, songId: String, ): Long? { - val flat = flatIds(snapshot).indexOf(songId) - if (flat < 0) return null - return snapshot.playContext(flat.toLong())?.let { snapshot.combined(it) } + val flat = flatIndexOf(snapshot.entries, songId) ?: return null + return snapshot.playContext(flat)?.let { snapshot.combined(it) } } suspend fun range( @@ -633,7 +835,6 @@ class QueueRepository( } } queueVersion.incrementAndGet() - cachedFlatIds = null } private suspend fun writeUpNext(entries: List) { @@ -650,34 +851,34 @@ class QueueRepository( queueVersion.incrementAndGet() } - private suspend fun flatIds(snapshot: QueueSnapshot): List { - val cached = cachedFlatIds - if (cached != null && cachedFlatVersion == snapshot.version && cached.size.toLong() == snapshot.contextSize) return cached - val ids = idsFor(snapshot.entries) - cachedFlatIds = ids - cachedFlatVersion = snapshot.version - return ids + private suspend fun flatIndexOf( + entries: List, + songId: String, + ): Long? { + var start = 0L + for (resolved in entries) { + val entry = resolved.entry + val rank = resolverFor(entry.kind).rank(dao, entry, songId) ?: fallbackRank(entry, songId) + if (rank != null && rank >= entry.offset && rank < entry.offset + resolved.length) { + return start + rank - entry.offset + } + start += resolved.length + } + return null } - private suspend fun idsFor(resolved: List): List { - val ids = ArrayList() - for (entry in resolved) { - val ref = entry.entry - val entryIds = - when (ref.kind) { - QueueKind.Song -> listOf(ref.refId) - QueueKind.Album -> dao.albumSongIds(ref.sourceId, ref.refId) - QueueKind.Playlist -> dao.playlistSongIds(ref.sourceId, ref.refId) - QueueKind.Artist -> dao.artistSongIds(ref.sourceId, ref.refId) - QueueKind.Genre -> dao.genreSongIds(ref.sourceId, ref.refId) - QueueKind.Downloaded -> dao.downloadedSongIds(ref.sourceId) - QueueKind.Starred -> dao.starredSongIds(ref.sourceId) - } - val from = ref.offset.toInt().coerceIn(0, entryIds.size) - val to = (ref.offset + entry.length).toInt().coerceIn(from, entryIds.size) - ids.addAll(entryIds.subList(from, to)) + private suspend fun fallbackRank( + entry: QueueEntry, + songId: String, + ): Long? { + var offset = 0L + while (true) { + val batch = rows(entry, offset, QUEUE_CHUNK) + val index = batch.indexOfFirst { it.song.id == songId } + if (index >= 0) return offset + index + if (batch.size < QUEUE_CHUNK) return null + offset += batch.size } - return ids } private suspend fun contextPlayRange( @@ -688,21 +889,11 @@ class QueueRepository( if (first > last) return emptyList() val items = mutableListOf() if (snapshot.shuffled) { - val sourceId = - snapshot.entries - .firstOrNull() - ?.entry - ?.sourceId ?: return emptyList() - val ids = flatIds(snapshot) - val requested = mutableListOf>() for (play in first..last) { - val flat = snapshot.flatContext(play) ?: continue - val id = ids.getOrNull(flat.toInt()) ?: continue - requested += play to id + 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) } - if (requested.isEmpty()) return emptyList() - val byId = dao.songsByIds(sourceId, requested.map { it.second }.distinct()).associateBy { it.song.id } - requested.forEach { (play, id) -> byId[id]?.let { items += QueueWindowItem(snapshot.combined(play), it) } } return items } var start = 0L @@ -757,27 +948,20 @@ class QueueRepository( ): List { if (entry.kind == QueueKind.Song) { offsetSeeks++ - return dao.song(entry.sourceId, entry.refId, offset, limit) + return resolverFor(entry.kind).rows(dao, entry, offset, limit).also { resolvedRowCount += it.size } } val ref = QueueRef(entry.kind, entry.sourceId, entry.refId) val anchored = keyCache[ref]?.let { keyedRows(entry, offset, limit, it) } if (anchored != null) { keysetSeeks++ if (anchored.isNotEmpty()) remember(ref, offset, anchored) + resolvedRowCount += anchored.size return anchored } offsetSeeks++ - val fresh = - when (entry.kind) { - QueueKind.Playlist -> dao.playlistSongs(entry.sourceId, entry.refId, offset, limit) - QueueKind.Album -> dao.albumSongs(entry.sourceId, entry.refId, offset, limit) - QueueKind.Artist -> dao.artistSongs(entry.sourceId, entry.refId, offset, limit) - QueueKind.Genre -> dao.genreSongs(entry.sourceId, entry.refId, offset, limit) - QueueKind.Downloaded -> dao.downloadedSongs(entry.sourceId, offset, limit) - QueueKind.Starred -> dao.starredSongs(entry.sourceId, offset, limit) - QueueKind.Song -> emptyList() - } + val fresh = resolverFor(entry.kind).rows(dao, entry, offset, limit) if (fresh.isNotEmpty()) remember(ref, offset, fresh) + resolvedRowCount += fresh.size return fresh } @@ -810,7 +994,7 @@ class QueueRepository( limit: Int, cursor: KeyCursor, ): List? { - if (!cursor.key.seekable(entry.kind)) return null + if (!resolverFor(entry.kind).supportsKeyset) return null val from = cursor.ordinal // 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 @@ -836,43 +1020,14 @@ class QueueRepository( key: OrderKey, skip: Long, limit: Int, - ): List = - when (entry.kind) { - QueueKind.Playlist -> dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit) - QueueKind.Album -> dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit) - QueueKind.Artist -> emptyList() - QueueKind.Genre -> emptyList() - QueueKind.Downloaded -> emptyList() - QueueKind.Starred -> emptyList() - QueueKind.Song -> emptyList() - } + ): List = resolverFor(entry.kind).rowsFrom(dao, entry, key, skip, limit) ?: emptyList() private suspend fun rowsBefore( entry: QueueEntry, key: OrderKey, skip: Long, limit: Int, - ): List = - when (entry.kind) { - QueueKind.Playlist -> dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit) - QueueKind.Album -> dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit) - QueueKind.Artist -> emptyList() - QueueKind.Genre -> emptyList() - QueueKind.Downloaded -> emptyList() - QueueKind.Starred -> emptyList() - QueueKind.Song -> emptyList() - } - - private fun OrderKey.seekable(kind: QueueKind): Boolean = - when (kind) { - QueueKind.Playlist -> position != null - QueueKind.Album -> disc != null && track != null - QueueKind.Artist -> false - QueueKind.Genre -> false - QueueKind.Downloaded -> false - QueueKind.Starred -> false - QueueKind.Song -> false - } + ): List = resolverFor(entry.kind).rowsBefore(dao, entry, key, skip, limit) ?: emptyList() private suspend fun lengthOf( entry: QueueEntry, @@ -897,36 +1052,7 @@ class QueueRepository( ) private suspend fun QueueEntry.resolvedLength(): Long { - val total = - when (kind) { - QueueKind.Song -> { - dao.songLength(sourceId, refId) - } - - QueueKind.Playlist -> { - dao.playlistLength(sourceId, refId) - } - - QueueKind.Album -> { - dao.albumLength(sourceId, refId) - } - - QueueKind.Artist -> { - dao.artistLength(sourceId, refId) - } - - QueueKind.Genre -> { - dao.genreLength(sourceId, refId) - } - - QueueKind.Downloaded -> { - dao.downloadedLength(sourceId) - } - - QueueKind.Starred -> { - dao.starredLength(sourceId) - } - } + val total = resolverFor(kind).length(dao, this) val available = (total - offset).coerceAtLeast(0) return count?.coerceAtMost(available) ?: available } diff --git a/app/src/test/kotlin/com/subtracks/data/repo/QueueKeysetTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/QueueKeysetTest.kt index 2edbe94e..f1b28a7f 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/QueueKeysetTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/QueueKeysetTest.kt @@ -260,6 +260,22 @@ class QueueKeysetTest { assertTrue(ids.all { it in expected }) } + @Test + fun thePlaylistWindowQuerySeeksThePositionIndex() = + runTest { + seedLibrary() + val plan = + queryPlan( + "SELECT songs.*, playlist_songs.position AS playlistPosition, albums.coverArt AS coverArt " + + "FROM playlist_songs JOIN songs ON songs.sourceId = playlist_songs.sourceId " + + "AND songs.id = playlist_songs.songId LEFT JOIN albums ON albums.sourceId = songs.sourceId " + + "AND albums.id = songs.albumId WHERE playlist_songs.sourceId = 1 " + + "AND playlist_songs.playlistId = 'pl1' ORDER BY playlist_songs.position LIMIT 51 OFFSET 100000", + ) + + assertTrue(plan.joinToString("\n"), plan.any { it.contains("sqlite_autoindex_playlist_songs_1") }) + } + @Test fun theAlbumKeysetQuerySeeksTheOrderIndex() = runTest { 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 19952949..82ce400f 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt @@ -727,6 +727,32 @@ class QueueRepositoryTest { assertEquals(listOf("s1", "s2", "s3"), resolveAll(restored)) } + @Test + fun shuffledLargeContextWindowOnlyResolvesRequestedRows() = + runTest { + seedLibrary() + db.libraryDao().upsertPlaylists(listOf(playlist("huge", "Huge"))) + db.useWriterConnection { transactor -> + transactor.usePrepared( + "WITH RECURSIVE n(x) AS (VALUES(0) UNION ALL SELECT x + 1 FROM n WHERE x < 199999) " + + "INSERT INTO playlist_songs SELECT 1, 'huge', 's1', x FROM n", + ) { statement -> + statement.step() + } + } + repository.snapshotAfter(listOf(repository.playlistEntry(1, "huge"))) + repository.setShuffle(42L) + val snapshot = repository.snapshot() + + val window = repository.window(snapshot, center = 100_000, radius = 25) + + assertEquals(200_000L, snapshot.contextSize) + assertEquals(51, window.size) + assertEquals(51L, repository.resolvedRowCount) + assertEquals((99_975L..100_025L).toList(), window.map { it.position }) + assertTrue(window.all { it.item.song.id == "s1" }) + } + @Test fun shuffledRangeResolvesEntriesInPositionOrder() = runTest { @@ -851,6 +877,37 @@ class QueueRepositoryTest { } } + @Test + fun contextIndexUsesTheFirstDuplicateOrdinalAcrossSparsePositions() = + 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 snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "duplicates"))) + + assertEquals(0L, repository.combinedContextIndexOf(snapshot, "s2")) + assertEquals(1L, repository.combinedContextIndexOf(snapshot, "s1")) + } + + @Test + fun contextIndexFallsBackToBoundedRowsForGenre() = + runTest { + seedLibrary() + insertGenre("s1", "Rock") + insertGenre("s3", "Rock") + insertGenre("s4", "Rock") + val snapshot = repository.snapshotAfter(listOf(repository.genreEntry(1, "Rock"))) + + assertEquals(2L, repository.combinedContextIndexOf(snapshot, "s4")) + assertTrue(repository.resolvedRowCount <= QUEUE_CHUNK) + } + @Test fun upNextCombinedIndexOfCountsFromTheAnchorIntoTheBlock() = runTest {