3 Commits
Author SHA1 Message Date
austinried 11276c2e1f playback: revert the restored session prepare
Preparing the restored window on launch was meant to make the paused
session reachable from Bluetooth and Automate. The follow-up on the issue
shows the notification appears, but the car's play command still does not
reach the app until the phone is unlocked, and the speculative prepare
buffered up to 50 seconds of the restored track on every launch while
claiming the media controls whenever the app opened.

Removing the prepare also removes the error gate it needed, which could
hide genuine playback errors while paused.

Reverts #230.
2026-11-05 13:13:22 +00:00
austinried eb55858cc4 playback: preserve null keys and ranged duplicate ranks 2026-11-04 13:15:54 +00:00
austinried 95ad993996 playback: resolve shuffled windows without flat ID lists 2026-11-04 13:06:45 +00:00
7 changed files with 379 additions and 221 deletions
@@ -214,14 +214,34 @@ interface QueueDao {
): List<String> ): List<String>
@Query( @Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "SELECT CASE WHEN EXISTS (SELECT 1 FROM songs WHERE sourceId = :sourceId AND albumId = :albumId AND id = :songId) " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "THEN (SELECT COUNT(*) FROM songs ranked CROSS JOIN songs target " +
"WHERE songs.sourceId = :sourceId AND songs.id IN (:ids)", "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, sourceId: Long,
ids: List<String>, albumId: String,
): List<AlbumSongItem> 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") @Query("SELECT COUNT(*) FROM song_genres WHERE sourceId = :sourceId AND genre = :genre")
suspend fun genreLength( suspend fun genreLength(
@@ -243,17 +263,6 @@ interface QueueDao {
limit: Int, limit: Int,
): List<AlbumSongItem> ): 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'") @Query("SELECT COUNT(*) FROM song_downloads WHERE sourceId = :sourceId AND status = 'Completed'")
suspend fun downloadedLength(sourceId: Long): Long suspend fun downloadedLength(sourceId: Long): Long
@@ -272,12 +281,6 @@ interface QueueDao {
limit: Int, limit: Int,
): List<AlbumSongItem> ): 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( @Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM song_downloads d " + "SELECT songs.*, albums.coverArt AS coverArt FROM song_downloads d " +
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " + "JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
@@ -290,12 +293,4 @@ interface QueueDao {
offset: Long, offset: Long,
limit: Int, limit: Int,
): List<AlbumSongItem> ): 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, sourceId: Long,
albumId: String, albumId: String,
songId: String, songId: String,
): Long { ): Long = db.queueDao().albumRank(sourceId, albumId, songId) ?: 0L
val index = db.queueDao().albumSongIds(sourceId, albumId).indexOf(songId)
return if (index < 0) 0L else index.toLong()
}
fun playlist( fun playlist(
sourceId: Long, sourceId: Long,
@@ -209,6 +209,202 @@ data class Reanchored(
val changed: Boolean, 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( class QueueRepository(
private val db: SubtracksDatabase, private val db: SubtracksDatabase,
) { ) {
@@ -216,12 +412,6 @@ class QueueRepository(
private val queueVersion = AtomicLong() private val queueVersion = AtomicLong()
@Volatile
private var cachedFlatIds: List<String>? = null
@Volatile
private var cachedFlatVersion = -1L
@Volatile @Volatile
private var shuffleRemoved: ShuffleState? = null private var shuffleRemoved: ShuffleState? = null
@@ -235,6 +425,9 @@ class QueueRepository(
internal var offsetSeeks = 0L internal var offsetSeeks = 0L
private set private set
internal var resolvedRowCount = 0L
private set
private val cursorMutex = Mutex() private val cursorMutex = Mutex()
fun playlistEntry( fun playlistEntry(
@@ -388,7 +581,6 @@ class QueueRepository(
} }
fun invalidateLibraryCache() { fun invalidateLibraryCache() {
cachedFlatIds = null
lengthCache.clear() lengthCache.clear()
keyCache.clear() keyCache.clear()
shuffleRemoved = null shuffleRemoved = null
@@ -409,8 +601,8 @@ class QueueRepository(
snapshot: QueueSnapshot, snapshot: QueueSnapshot,
songId: String, songId: String,
): Long? { ): Long? {
val upIndex = idsFor(snapshot.upNext).indexOf(songId) val upIndex = flatIndexOf(snapshot.upNext, songId)
if (upIndex >= 0) return snapshot.anchorPlay + 1 + upIndex if (upIndex != null) return snapshot.anchorPlay + 1 + upIndex
return combinedContextIndexOf(snapshot, songId) return combinedContextIndexOf(snapshot, songId)
} }
@@ -418,9 +610,8 @@ class QueueRepository(
snapshot: QueueSnapshot, snapshot: QueueSnapshot,
songId: String, songId: String,
): Long? { ): Long? {
val flat = flatIds(snapshot).indexOf(songId) val flat = flatIndexOf(snapshot.entries, songId) ?: return null
if (flat < 0) return null return snapshot.playContext(flat)?.let { snapshot.combined(it) }
return snapshot.playContext(flat.toLong())?.let { snapshot.combined(it) }
} }
suspend fun range( suspend fun range(
@@ -633,7 +824,6 @@ class QueueRepository(
} }
} }
queueVersion.incrementAndGet() queueVersion.incrementAndGet()
cachedFlatIds = null
} }
private suspend fun writeUpNext(entries: List<QueueEntry>) { private suspend fun writeUpNext(entries: List<QueueEntry>) {
@@ -650,34 +840,40 @@ class QueueRepository(
queueVersion.incrementAndGet() queueVersion.incrementAndGet()
} }
private suspend fun flatIds(snapshot: QueueSnapshot): List<String> { private suspend fun flatIndexOf(
val cached = cachedFlatIds entries: List<ResolvedQueueEntry>,
if (cached != null && cachedFlatVersion == snapshot.version && cached.size.toLong() == snapshot.contextSize) return cached songId: String,
val ids = idsFor(snapshot.entries) ): Long? {
cachedFlatIds = ids var start = 0L
cachedFlatVersion = snapshot.version for (resolved in entries) {
return ids 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> { private suspend fun fallbackRank(
val ids = ArrayList<String>() entry: QueueEntry,
for (entry in resolved) { length: Long,
val ref = entry.entry songId: String,
val entryIds = ): Long? {
when (ref.kind) { var offset = entry.offset
QueueKind.Song -> listOf(ref.refId) val end = entry.offset + length
QueueKind.Album -> dao.albumSongIds(ref.sourceId, ref.refId) while (offset < end) {
QueueKind.Playlist -> dao.playlistSongIds(ref.sourceId, ref.refId) val limit = minOf(QUEUE_CHUNK.toLong(), end - offset).toInt()
QueueKind.Artist -> dao.artistSongIds(ref.sourceId, ref.refId) val batch = rows(entry, offset, limit)
QueueKind.Genre -> dao.genreSongIds(ref.sourceId, ref.refId) val index = batch.indexOfFirst { it.song.id == songId }
QueueKind.Downloaded -> dao.downloadedSongIds(ref.sourceId) if (index >= 0) return offset + index
QueueKind.Starred -> dao.starredSongIds(ref.sourceId) if (batch.size < limit) return null
} offset += batch.size
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))
} }
return ids return null
} }
private suspend fun contextPlayRange( private suspend fun contextPlayRange(
@@ -688,21 +884,11 @@ class QueueRepository(
if (first > last) return emptyList() if (first > last) return emptyList()
val items = mutableListOf<QueueWindowItem>() val items = mutableListOf<QueueWindowItem>()
if (snapshot.shuffled) { 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) { for (play in first..last) {
val flat = snapshot.flatContext(play) ?: continue val located = snapshot.locateContext(play) ?: continue
val id = ids.getOrNull(flat.toInt()) ?: continue val item = rows(located.first, located.first.offset + located.second, 1).firstOrNull() ?: continue
requested += play to id 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 return items
} }
var start = 0L var start = 0L
@@ -757,27 +943,20 @@ class QueueRepository(
): List<SongItem> { ): List<SongItem> {
if (entry.kind == QueueKind.Song) { if (entry.kind == QueueKind.Song) {
offsetSeeks++ 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 ref = QueueRef(entry.kind, entry.sourceId, entry.refId)
val anchored = keyCache[ref]?.let { keyedRows(entry, offset, limit, it) } val anchored = keyCache[ref]?.let { keyedRows(entry, offset, limit, it) }
if (anchored != null) { if (anchored != null) {
keysetSeeks++ keysetSeeks++
if (anchored.isNotEmpty()) remember(ref, offset, anchored) if (anchored.isNotEmpty()) remember(ref, offset, anchored)
resolvedRowCount += anchored.size
return anchored return anchored
} }
offsetSeeks++ offsetSeeks++
val fresh = val fresh = resolverFor(entry.kind).rows(dao, entry, offset, limit)
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()
}
if (fresh.isNotEmpty()) remember(ref, offset, fresh) if (fresh.isNotEmpty()) remember(ref, offset, fresh)
resolvedRowCount += fresh.size
return fresh return fresh
} }
@@ -810,7 +989,7 @@ class QueueRepository(
limit: Int, limit: Int,
cursor: KeyCursor, cursor: KeyCursor,
): List<SongItem>? { ): List<SongItem>? {
if (!cursor.key.seekable(entry.kind)) 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
@@ -836,43 +1015,14 @@ class QueueRepository(
key: OrderKey, key: OrderKey,
skip: Long, skip: Long,
limit: Int, limit: Int,
): List<SongItem> = ): List<SongItem> = resolverFor(entry.kind).rowsFrom(dao, entry, key, skip, limit) ?: emptyList()
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()
}
private suspend fun rowsBefore( private suspend fun rowsBefore(
entry: QueueEntry, entry: QueueEntry,
key: OrderKey, key: OrderKey,
skip: Long, skip: Long,
limit: Int, limit: Int,
): List<SongItem> = ): List<SongItem> = resolverFor(entry.kind).rowsBefore(dao, entry, key, skip, limit) ?: emptyList()
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
}
private suspend fun lengthOf( private suspend fun lengthOf(
entry: QueueEntry, entry: QueueEntry,
@@ -897,36 +1047,7 @@ class QueueRepository(
) )
private suspend fun QueueEntry.resolvedLength(): Long { private suspend fun QueueEntry.resolvedLength(): Long {
val total = val total = resolverFor(kind).length(dao, this)
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 available = (total - offset).coerceAtLeast(0) val available = (total - offset).coerceAtLeast(0)
return count?.coerceAtMost(available) ?: available return count?.coerceAtMost(available) ?: available
} }
@@ -1070,7 +1070,6 @@ class PlaybackController(
} }
lastSavedPositionMs = savedPositionMs lastSavedPositionMs = savedPositionMs
loadWindow(anchor.position, autoplay = false, startPositionMs = savedPositionMs) loadWindow(anchor.position, autoplay = false, startPositionMs = savedPositionMs)
player?.takeIf { it.itemCount > 0 }?.ensurePrepared()
} }
private suspend fun stop() = startLock.withLock { stopLocked() } private suspend fun stop() = startLock.withLock { stopLocked() }
@@ -1452,7 +1451,7 @@ class PlaybackController(
} }
override fun onError(message: UiMessage) { override fun onError(message: UiMessage) {
if (player?.playWhenReady == true) showMessage(message) showMessage(message)
refresh() refresh()
} }
} }
@@ -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 {
@@ -260,6 +272,22 @@ class QueueKeysetTest {
assertTrue(ids.all { it in expected }) 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 @Test
fun theAlbumKeysetQuerySeeksTheOrderIndex() = fun theAlbumKeysetQuerySeeksTheOrderIndex() =
runTest { runTest {
@@ -727,6 +727,32 @@ class QueueRepositoryTest {
assertEquals(listOf("s1", "s2", "s3"), resolveAll(restored)) 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 @Test
fun shuffledRangeResolvesEntriesInPositionOrder() = fun shuffledRangeResolvesEntriesInPositionOrder() =
runTest { 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 @Test
fun upNextCombinedIndexOfCountsFromTheAnchorIntoTheBlock() = fun upNextCombinedIndexOfCountsFromTheAnchorIntoTheBlock() =
runTest { runTest {
@@ -343,75 +343,18 @@ class PlaybackControllerTest {
} }
@Test @Test
fun restoringAPreviousQueuePreparesTheSessionWithoutPlaying() { fun togglingPlayOnARestoredQueuePreparesBeforePlaying() {
seedAlbum(3, sourceId = 1)
runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"))) }
controller.connect()
await { handle.operations.contains("prepare") }
assertFalse(handle.operations.contains("play"))
assertFalse(handle.isIdle)
assertFalse(handle.playWhenReady)
assertFalse(controller.state.value.isPlaying)
}
@Test
fun aPrepareFailureBeforePlaybackIsNotReported() {
seedAlbum(3, sourceId = 1)
runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"))) }
controller.connect()
await { handle.operations.contains("prepare") }
handle.fail(UiMessage(R.string.playback_error_network))
assertTrue(messages.isEmpty())
controller.togglePlayPause()
await { handle.operations.contains("play") }
handle.fail(UiMessage(R.string.playback_error_network))
await { messages.isNotEmpty() }
assertEquals(1, messages.size)
}
@Test
fun restoringAnEmptyQueueDoesNotPrepare() {
seedAlbum(3, sourceId = 1)
controller.connect()
await { controller.ready.value }
assertTrue(handle.items.isEmpty())
assertFalse(handle.operations.contains("prepare"))
}
@Test
fun restoringAnOfflineQueueWithoutDownloadsDoesNotPrepare() {
seedAlbum(3, sourceId = 1)
runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"))) }
setOffline()
controller.connect()
await { controller.ready.value }
assertTrue(handle.items.isEmpty())
assertFalse(handle.operations.contains("prepare"))
}
@Test
fun togglingPlayOnARestoredQueuePlaysThePreparedWindow() {
seedAlbum(5, sourceId = 1) seedAlbum(5, sourceId = 1)
runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"))) } runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"))) }
controller.connect() controller.connect()
await { handle.operations.contains("prepare") } await { handle.items.isNotEmpty() }
handle.operations.clear() handle.operations.clear()
controller.togglePlayPause() controller.togglePlayPause()
await { handle.operations.contains("play") } await { handle.operations.contains("play") }
assertEquals(listOf("play"), handle.operations) assertEquals(listOf("prepare", "play"), handle.operations)
} }
@Test @Test