2 Commits
5 changed files with 375 additions and 159 deletions
@@ -214,14 +214,34 @@ interface QueueDao {
): List<String>
@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<String>,
): List<AlbumSongItem>
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<AlbumSongItem>
@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<String>
@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<AlbumSongItem>
@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<String>
@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<AlbumSongItem>
@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<String>
}
@@ -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,
@@ -209,6 +209,202 @@ data class Reanchored(
val changed: Boolean,
)
private interface QueueKindResolver {
fun supportsKeyset(key: OrderKey): Boolean = 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<SongItem>
suspend fun rowsFrom(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
key: OrderKey,
skip: Long,
limit: Int,
): List<SongItem>? = null
suspend fun rowsBefore(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
key: OrderKey,
skip: Long,
limit: Int,
): List<SongItem>? = null
suspend fun rank(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
songId: String,
): Long? = null
}
private val queueKindResolvers =
mapOf<QueueKind, QueueKindResolver>(
QueueKind.Song to
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 to
object : QueueKindResolver {
override fun supportsKeyset(key: OrderKey) = key.position != null
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 to
object : QueueKindResolver {
override fun supportsKeyset(key: OrderKey) = key.disc != null && key.track != null
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 to
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 to
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 to
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 to
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)
},
)
private fun resolverFor(kind: QueueKind): QueueKindResolver = queueKindResolvers.getValue(kind)
class QueueRepository(
private val db: SubtracksDatabase,
) {
@@ -216,12 +412,6 @@ class QueueRepository(
private val queueVersion = AtomicLong()
@Volatile
private var cachedFlatIds: List<String>? = null
@Volatile
private var cachedFlatVersion = -1L
@Volatile
private var shuffleRemoved: ShuffleState? = null
@@ -235,6 +425,9 @@ class QueueRepository(
internal var offsetSeeks = 0L
private set
internal var resolvedRowCount = 0L
private set
private val cursorMutex = Mutex()
fun playlistEntry(
@@ -388,7 +581,6 @@ class QueueRepository(
}
fun invalidateLibraryCache() {
cachedFlatIds = null
lengthCache.clear()
keyCache.clear()
shuffleRemoved = null
@@ -409,8 +601,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 +610,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 +824,6 @@ class QueueRepository(
}
}
queueVersion.incrementAndGet()
cachedFlatIds = null
}
private suspend fun writeUpNext(entries: List<QueueEntry>) {
@@ -650,34 +840,40 @@ class QueueRepository(
queueVersion.incrementAndGet()
}
private suspend fun flatIds(snapshot: QueueSnapshot): List<String> {
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<ResolvedQueueEntry>,
songId: String,
): Long? {
var start = 0L
for (resolved in entries) {
val entry = resolved.entry
val rank = resolverFor(entry.kind).rank(dao, entry, songId)
if (rank != null && rank >= entry.offset && rank < entry.offset + resolved.length) {
return start + rank - entry.offset
}
val fallback = fallbackRank(entry, resolved.length, songId)
if (fallback != null) return start + fallback - entry.offset
start += resolved.length
}
return null
}
private suspend fun idsFor(resolved: List<ResolvedQueueEntry>): List<String> {
val ids = ArrayList<String>()
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,
length: Long,
songId: String,
): Long? {
var offset = entry.offset
val end = entry.offset + length
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 }
if (index >= 0) return offset + index
if (batch.size < limit) return null
offset += batch.size
}
return ids
return null
}
private suspend fun contextPlayRange(
@@ -688,21 +884,11 @@ class QueueRepository(
if (first > last) return emptyList()
val items = mutableListOf<QueueWindowItem>()
if (snapshot.shuffled) {
val sourceId =
snapshot.entries
.firstOrNull()
?.entry
?.sourceId ?: return emptyList()
val ids = flatIds(snapshot)
val requested = mutableListOf<Pair<Long, String>>()
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 +943,20 @@ class QueueRepository(
): List<SongItem> {
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 +989,7 @@ class QueueRepository(
limit: Int,
cursor: KeyCursor,
): List<SongItem>? {
if (!cursor.key.seekable(entry.kind)) return null
if (!resolverFor(entry.kind).supportsKeyset(cursor.key)) 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 +1015,14 @@ class QueueRepository(
key: OrderKey,
skip: Long,
limit: Int,
): List<SongItem> =
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<SongItem> = resolverFor(entry.kind).rowsFrom(dao, entry, key, skip, limit) ?: emptyList()
private suspend fun rowsBefore(
entry: QueueEntry,
key: OrderKey,
skip: Long,
limit: Int,
): List<SongItem> =
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<SongItem> = resolverFor(entry.kind).rowsBefore(dao, entry, key, skip, limit) ?: emptyList()
private suspend fun lengthOf(
entry: QueueEntry,
@@ -897,36 +1047,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
}
@@ -150,6 +150,18 @@ class QueueKeysetTest {
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
fun scanningAnAlbumWithNullDiscAndTrackBackwardsResolvesEveryRow() =
runTest {
@@ -260,6 +272,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 {
@@ -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,55 @@ 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 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
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 {