resolve queue rows with keyset cursor seeks instead of offsets

This commit is contained in:
austinried
2026-10-04 00:30:01 +00:00
parent d710a3185c
commit 4aaeb4871b
7 changed files with 506 additions and 21 deletions
@@ -16,12 +16,15 @@ import com.subtracks.data.model.Song
import com.subtracks.data.model.SongListItem
import kotlinx.coroutines.flow.Flow
internal const val PLAYLIST_SONGS_SQL =
"SELECT songs.*, albums.coverArt AS coverArt FROM playlist_songs " +
internal const val PLAYLIST_SONGS_SELECT =
"SELECT songs.*, albums.coverArt AS coverArt, playlist_songs.position AS position 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 = :sourceId AND playlist_songs.playlistId = :playlistId " +
"ORDER BY playlist_songs.position"
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId"
internal const val PLAYLIST_SONGS_ORDER = " ORDER BY playlist_songs.position"
internal const val PLAYLIST_SONGS_SQL = PLAYLIST_SONGS_SELECT + PLAYLIST_SONGS_ORDER
internal const val ALBUMS_FILTER =
"FROM albums WHERE sourceId = :sourceId " +
@@ -259,6 +259,16 @@ val MIGRATION_17_18 =
}
}
val MIGRATION_18_19 =
object : Migration(18, 19) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"CREATE INDEX IF NOT EXISTS `index_songs_sourceId_albumId_order` ON `songs` " +
"(`sourceId`, `albumId`, `disc`, `track`, `id`)",
)
}
}
val MIGRATIONS: Array<Migration> =
arrayOf(
MIGRATION_1_2,
@@ -278,4 +288,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_15_16,
MIGRATION_16_17,
MIGRATION_17_18,
MIGRATION_18_19,
)
@@ -9,14 +9,17 @@ import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.SongListItem
import com.subtracks.data.model.UpNextEntry
private const val ALBUM_SONGS_SQL =
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
private const val ALBUM_SONGS_SELECT =
"SELECT songs.*, albums.coverArt AS coverArt, NULL AS position FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.albumId = :albumId " +
"ORDER BY songs.disc, songs.track, songs.id"
"WHERE songs.sourceId = :sourceId AND songs.albumId = :albumId"
private const val ALBUM_SONGS_ORDER = " ORDER BY songs.disc, songs.track, songs.id"
private const val ALBUM_SONGS_SQL = ALBUM_SONGS_SELECT + ALBUM_SONGS_ORDER
private const val SONG_SQL =
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"SELECT songs.*, albums.coverArt AS coverArt, NULL AS position FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.id = :songId"
@@ -76,6 +79,30 @@ interface QueueDao {
limit: Int,
): List<SongListItem>
@Query(
"$PLAYLIST_SONGS_SELECT AND playlist_songs.position >= :position$PLAYLIST_SONGS_ORDER " +
"LIMIT :limit OFFSET :skip",
)
suspend fun playlistSongsFrom(
sourceId: Long,
playlistId: String,
position: Long,
skip: Long,
limit: Int,
): List<SongListItem>
@Query(
"$PLAYLIST_SONGS_SELECT AND playlist_songs.position < :position " +
"ORDER BY playlist_songs.position DESC LIMIT :limit OFFSET :skip",
)
suspend fun playlistSongsBefore(
sourceId: Long,
playlistId: String,
position: Long,
skip: Long,
limit: Int,
): List<SongListItem>
@Query("$ALBUM_SONGS_SQL LIMIT :limit OFFSET :offset")
suspend fun albumSongs(
sourceId: Long,
@@ -84,6 +111,34 @@ interface QueueDao {
limit: Int,
): List<SongListItem>
@Query(
"$ALBUM_SONGS_SELECT AND (songs.disc, songs.track, songs.id) >= (:disc, :track, :id)" +
"$ALBUM_SONGS_ORDER LIMIT :limit OFFSET :skip",
)
suspend fun albumSongsFrom(
sourceId: Long,
albumId: String,
disc: Long,
track: Long,
id: String,
skip: Long,
limit: Int,
): List<SongListItem>
@Query(
"$ALBUM_SONGS_SELECT AND (songs.disc, songs.track, songs.id) < (:disc, :track, :id) " +
"ORDER BY songs.disc DESC, songs.track DESC, songs.id DESC LIMIT :limit OFFSET :skip",
)
suspend fun albumSongsBefore(
sourceId: Long,
albumId: String,
disc: Long,
track: Long,
id: String,
skip: Long,
limit: Int,
): List<SongListItem>
@Query("$SONG_SQL LIMIT :limit OFFSET :offset")
suspend fun song(
sourceId: Long,
@@ -113,7 +168,7 @@ interface QueueDao {
): List<String>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"SELECT songs.*, albums.coverArt AS coverArt, NULL AS position FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.id IN (:ids)",
)
@@ -35,7 +35,7 @@ import com.subtracks.data.model.UpNextEntry
ArtworkSeed::class,
SongDownload::class,
],
version = 18,
version = 19,
exportSchema = true,
)
@ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class)
@@ -209,7 +209,14 @@ data class PlaylistSong(
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("sourceId", "albumId"), Index("sourceId", "artistId")],
indices = [
Index("sourceId", "albumId"),
Index("sourceId", "artistId"),
Index(
name = "index_songs_sourceId_albumId_order",
value = ["sourceId", "albumId", "disc", "track", "id"],
),
],
)
data class Song(
val sourceId: Long,
@@ -230,6 +237,7 @@ data class Song(
data class SongListItem(
@Embedded val song: Song,
val coverArt: String?,
val position: Long? = null,
)
data class DiscKey(
@@ -36,6 +36,24 @@ private data class LoadedSnapshot(
val anchor: Long,
)
private data class QueueRef(
val kind: QueueKind,
val sourceId: Long,
val refId: String,
)
private data class OrderKey(
val id: String,
val disc: Long? = null,
val track: Long? = null,
val position: Long? = null,
)
private data class KeyCursor(
val ordinal: Long,
val key: OrderKey,
)
data class ResolvedQueueEntry(
val entry: QueueEntry,
val length: Long,
@@ -206,6 +224,14 @@ class QueueRepository(
private val lengthCache = ConcurrentHashMap<EntryLengthKey, Long>()
private val keyCache = ConcurrentHashMap<QueueRef, KeyCursor>()
internal var keysetSeeks = 0L
private set
internal var offsetSeeks = 0L
private set
private val cursorMutex = Mutex()
fun playlistEntry(
@@ -327,6 +353,7 @@ class QueueRepository(
fun invalidateLibraryCache() {
cachedFlatIds = null
lengthCache.clear()
keyCache.clear()
shuffleRemoved = null
queueVersion.incrementAndGet()
}
@@ -679,19 +706,99 @@ class QueueRepository(
entry: QueueEntry,
offset: Long,
limit: Int,
): List<SongListItem> {
if (entry.kind == QueueKind.Song) {
offsetSeeks++
return dao.song(entry.sourceId, entry.refId, offset, limit)
}
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)
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.Song -> emptyList()
}
if (fresh.isNotEmpty()) remember(ref, offset, fresh)
return fresh
}
private fun remember(
ref: QueueRef,
offset: Long,
resolved: List<SongListItem>,
) {
if (keyCache.size >= LENGTH_CACHE_LIMIT) keyCache.clear()
keyCache[ref] = KeyCursor(offset + resolved.size - 1, orderKey(ref.kind, resolved.last()))
}
private fun orderKey(
kind: QueueKind,
row: SongListItem,
): OrderKey =
when (kind) {
QueueKind.Album -> OrderKey(row.song.id, disc = row.song.disc, track = row.song.track)
QueueKind.Playlist -> OrderKey(row.song.id, position = row.position)
QueueKind.Song -> OrderKey(row.song.id)
}
private suspend fun keyedRows(
entry: QueueEntry,
offset: Long,
limit: Int,
cursor: KeyCursor,
): List<SongListItem>? {
if (!cursor.key.seekable(entry.kind)) return null
val from = cursor.ordinal
return if (offset >= from) {
rowsFrom(entry, cursor.key, offset - from, limit)
} else {
val end = offset + limit - 1
if (end < from) {
rowsBefore(entry, cursor.key, from - end - 1, limit).asReversed()
} else {
val head = rowsBefore(entry, cursor.key, 0, (from - offset).toInt()).asReversed()
val tail = rowsFrom(entry, cursor.key, 0, (end - from + 1).toInt())
head + tail
}
}
}
private suspend fun rowsFrom(
entry: QueueEntry,
key: OrderKey,
skip: Long,
limit: Int,
): List<SongListItem> =
when (entry.kind) {
QueueKind.Playlist -> {
dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
}
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.Song -> emptyList()
}
QueueKind.Album -> {
dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
}
private suspend fun rowsBefore(
entry: QueueEntry,
key: OrderKey,
skip: Long,
limit: Int,
): List<SongListItem> =
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.Song -> emptyList()
}
QueueKind.Song -> {
dao.song(entry.sourceId, entry.refId, offset, limit)
}
private fun OrderKey.seekable(kind: QueueKind): Boolean =
when (kind) {
QueueKind.Playlist -> position != null
QueueKind.Album -> disc != null && track != null
QueueKind.Song -> false
}
private suspend fun lengthOf(