mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
Compare commits
3
Commits
11abd4afbd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11276c2e1f | ||
|
|
eb55858cc4 | ||
|
|
95ad993996 |
@@ -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
|
||||
}
|
||||
|
||||
@@ -1070,7 +1070,6 @@ class PlaybackController(
|
||||
}
|
||||
lastSavedPositionMs = savedPositionMs
|
||||
loadWindow(anchor.position, autoplay = false, startPositionMs = savedPositionMs)
|
||||
player?.takeIf { it.itemCount > 0 }?.ensurePrepared()
|
||||
}
|
||||
|
||||
private suspend fun stop() = startLock.withLock { stopLocked() }
|
||||
@@ -1452,7 +1451,7 @@ class PlaybackController(
|
||||
}
|
||||
|
||||
override fun onError(message: UiMessage) {
|
||||
if (player?.playWhenReady == true) showMessage(message)
|
||||
showMessage(message)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -343,75 +343,18 @@ class PlaybackControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restoringAPreviousQueuePreparesTheSessionWithoutPlaying() {
|
||||
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() {
|
||||
fun togglingPlayOnARestoredQueuePreparesBeforePlaying() {
|
||||
seedAlbum(5, sourceId = 1)
|
||||
runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"))) }
|
||||
|
||||
controller.connect()
|
||||
await { handle.operations.contains("prepare") }
|
||||
await { handle.items.isNotEmpty() }
|
||||
handle.operations.clear()
|
||||
|
||||
controller.togglePlayPause()
|
||||
await { handle.operations.contains("play") }
|
||||
|
||||
assertEquals(listOf("play"), handle.operations)
|
||||
assertEquals(listOf("prepare", "play"), handle.operations)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user