queue: add serialized, origin-aware mutations

This commit is contained in:
austinried
2026-11-14 12:03:42 +00:00
parent f43bc877f0
commit 44e2eb7ae8
16 changed files with 3208 additions and 431 deletions
@@ -161,7 +161,7 @@ interface DownloadDao {
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
@Query(
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
"SELECT d.sourceId AS sourceId, s.id AS songRow, s.albumId AS albumId, s.artistId AS artistId, " +
"al.artistId AS albumArtistId, " +
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
"ar.id AS artistRow, ar.coverArt AS artistCoverArt, " +
@@ -730,6 +730,35 @@ val MIGRATION_37_38 =
}
}
val MIGRATION_38_39 =
object : Migration(38, 39) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `queue_entries` ADD COLUMN `originKind` TEXT")
connection.execSQL("ALTER TABLE `queue_entries` ADD COLUMN `originRefId` TEXT")
connection.execSQL("ALTER TABLE `queue_entries` ADD COLUMN `originOccurrence` INTEGER")
connection.execSQL("ALTER TABLE `up_next_entries` ADD COLUMN `originKind` TEXT")
connection.execSQL("ALTER TABLE `up_next_entries` ADD COLUMN `originRefId` TEXT")
connection.execSQL("ALTER TABLE `up_next_entries` ADD COLUMN `originOccurrence` INTEGER")
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `pending_sync_origins` (`sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, " +
"`refId` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `kind`, `refId`), " +
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
)
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_pending_sync_origins_sourceId` ON `pending_sync_origins` (`sourceId`)")
connection.execSQL(
"CREATE TABLE `song_downloads_new` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, `status` TEXT NOT NULL, " +
"`engineId` INTEGER, `bytes` INTEGER NOT NULL DEFAULT 0, `total` INTEGER NOT NULL DEFAULT 0, `error` TEXT, " +
"`downloadedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `songId`), " +
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
)
connection.execSQL("INSERT INTO `song_downloads_new` SELECT * FROM `song_downloads`")
connection.execSQL("DROP TABLE `song_downloads`")
connection.execSQL("ALTER TABLE `song_downloads_new` RENAME TO `song_downloads`")
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_song_downloads_sourceId` ON `song_downloads` (`sourceId`)")
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_song_downloads_engineId` ON `song_downloads` (`engineId`)")
}
}
internal fun artistSearchTriggersOptimized(connection: SQLiteConnection): Boolean {
val triggers = mutableListOf<String>()
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?)").use { statement ->
@@ -808,4 +837,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_35_36,
MIGRATION_36_37,
MIGRATION_37_38,
MIGRATION_38_39,
)
@@ -5,6 +5,7 @@ import androidx.room3.Insert
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.PendingSyncOrigin
import com.subtracks.data.model.PlaybackCursor
import com.subtracks.data.model.PlaylistSongItem
import com.subtracks.data.model.QueueEntry
@@ -37,6 +38,12 @@ private const val SONG_SQL =
@Dao
interface QueueDao {
@Query("SELECT * FROM pending_sync_origins WHERE sourceId = :sourceId")
suspend fun pendingSyncOrigins(sourceId: Long): List<PendingSyncOrigin>
@Query("DELETE FROM pending_sync_origins WHERE sourceId = :sourceId")
suspend fun clearPendingSyncOrigins(sourceId: Long)
@Query("DELETE FROM queue_entries")
suspend fun clear()
@@ -243,6 +250,44 @@ interface QueueDao {
songId: String,
): Long?
@Query(
"SELECT COUNT(*) 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 playlist_songs.songId = :songId AND playlist_songs.position < :position",
)
suspend fun playlistOccurrenceRank(
sourceId: Long,
playlistId: String,
songId: String,
position: Long,
): Long
@Query(
"SELECT playlist_songs.position 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 playlist_songs.songId = :songId ORDER BY playlist_songs.position LIMIT 1 OFFSET :occurrence",
)
suspend fun playlistPositionForOccurrence(
sourceId: Long,
playlistId: String,
songId: String,
occurrence: Long,
): Long?
@Query(
"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 playlist_songs.position <= :position",
)
suspend fun playlistRankAtPosition(
sourceId: Long,
playlistId: String,
position: Long,
): Long
@Query("SELECT COUNT(*) FROM song_genres WHERE sourceId = :sourceId AND genre = :genre")
suspend fun genreLength(
sourceId: Long,
@@ -263,7 +308,11 @@ interface QueueDao {
limit: Int,
): List<AlbumSongItem>
@Query("SELECT COUNT(*) FROM song_downloads WHERE sourceId = :sourceId AND status = 'Completed'")
@Query(
"SELECT COUNT(*) FROM song_downloads d " +
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
"WHERE d.sourceId = :sourceId AND d.status = 'Completed'",
)
suspend fun downloadedLength(sourceId: Long): Long
@Query("SELECT COUNT(*) FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL")
@@ -16,6 +16,7 @@ import com.subtracks.data.model.DownloadErrorConverter
import com.subtracks.data.model.DownloadStatusConverter
import com.subtracks.data.model.PendingAction
import com.subtracks.data.model.PendingActionKindConverter
import com.subtracks.data.model.PendingSyncOrigin
import com.subtracks.data.model.PlaybackCursor
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSearch
@@ -56,8 +57,9 @@ import com.subtracks.data.model.UpNextEntry
ArtworkSeed::class,
SongDownload::class,
PendingAction::class,
PendingSyncOrigin::class,
],
version = 38,
version = 39,
exportSchema = true,
)
@ColumnTypeConverters(
@@ -38,12 +38,6 @@ enum class DownloadError(
childColumns = ["sourceId"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = Song::class,
parentColumns = ["sourceId", "id"],
childColumns = ["sourceId", "songId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("sourceId"), Index("engineId")],
)
@@ -109,6 +103,7 @@ data class DownloadedSong(
data class DownloadArtwork(
val sourceId: Long,
val songRow: String?,
val albumId: String?,
val artistId: String?,
val albumArtistId: String?,
@@ -119,7 +114,7 @@ data class DownloadArtwork(
val albumArtistCoverArt: String?,
) {
val hasMissingLibraryRow: Boolean
get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
get() = songRow == null || (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
}
data class SourceCoverArt(
@@ -448,6 +448,31 @@ data class DiscKey(
enum class QueueKind { Playlist, Album, Song, Artist, Genre, Downloaded, Starred }
data class QueueOrigin(
val kind: QueueKind,
val sourceId: Long,
val refId: String,
)
@Entity(
tableName = "pending_sync_origins",
primaryKeys = ["sourceId", "kind", "refId"],
foreignKeys = [
ForeignKey(
entity = Source::class,
parentColumns = ["id"],
childColumns = ["sourceId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("sourceId")],
)
data class PendingSyncOrigin(
val sourceId: Long,
val kind: QueueKind,
val refId: String,
)
@Entity(
tableName = "queue_entries",
foreignKeys = [
@@ -468,10 +493,15 @@ data class QueueEntry(
val refId: String,
val rangeStart: Long? = null,
val rangeEnd: Long? = null,
val originKind: QueueKind? = null,
val originRefId: String? = null,
val originOccurrence: Long? = null,
) {
val offset: Long get() = rangeStart ?: 0
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
val origin: QueueOrigin get() = QueueOrigin(originKind ?: kind, sourceId, originRefId ?: refId)
}
@Entity(
@@ -494,16 +524,31 @@ data class UpNextEntry(
val refId: String,
val rangeStart: Long? = null,
val rangeEnd: Long? = null,
val originKind: QueueKind? = null,
val originRefId: String? = null,
val originOccurrence: Long? = null,
) {
val offset: Long get() = rangeStart ?: 0
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
fun toQueueEntry(): QueueEntry = QueueEntry(id, position, sourceId, kind, refId, rangeStart, rangeEnd)
fun toQueueEntry(): QueueEntry =
QueueEntry(id, position, sourceId, kind, refId, rangeStart, rangeEnd, originKind, originRefId, originOccurrence)
companion object {
fun from(entry: QueueEntry) =
UpNextEntry(entry.id, entry.position, entry.sourceId, entry.kind, entry.refId, entry.rangeStart, entry.rangeEnd)
UpNextEntry(
entry.id,
entry.position,
entry.sourceId,
entry.kind,
entry.refId,
entry.rangeStart,
entry.rangeEnd,
entry.originKind,
entry.originRefId,
entry.originOccurrence,
)
}
}
@@ -8,6 +8,7 @@ import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.PlaybackCursor
import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.QueueOrigin
import com.subtracks.data.model.SongItem
import com.subtracks.data.model.UpNextEntry
import kotlinx.coroutines.flow.first
@@ -36,12 +37,6 @@ 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,
@@ -59,58 +54,13 @@ data class ResolvedQueueEntry(
val length: Long,
)
/**
* A shuffled order over a fixed domain of [domain] tracks, plus the domain positions removed since
* the order was created. The permutation stays fixed and removals are subtracted (sparsely, in
* memory) rather than re-derived, so editing a shuffled queue keeps the remaining tracks in place.
* Only the seed and domain are persisted; on restart the removals are gone, so a queue with edits
* falls back to unshuffled rather than guessing.
*/
class ShuffleState(
val seed: Long,
val domain: Long,
private val removedFlats: LongArray = LongArray(0),
private val removedSlots: LongArray = LongArray(0),
) {
fun flatFor(play: Long): Long {
val domainFlat = Shuffle.toFlat(seed, domain, liveSlot(play))
return domainFlat - removedFlats.count { it < domainFlat }
}
fun flatFor(play: Long): Long = Shuffle.toFlat(seed, domain, play)
fun playFor(canonical: Long): Long {
val slot = Shuffle.toSequence(seed, domain, liveFlat(canonical))
return slot - removedSlots.count { it < slot }
}
fun domainFor(canonical: Long): Long = liveFlat(canonical)
val liveSize: Long get() = domain - removedFlats.size.toLong()
fun withRemoved(domainFlat: Long): ShuffleState =
ShuffleState(
seed,
domain,
(removedFlats + domainFlat).sortedArray(),
(removedSlots + Shuffle.toSequence(seed, domain, domainFlat)).sortedArray(),
)
private fun liveSlot(play: Long): Long {
var slot = play
while (true) {
val next = play + removedSlots.count { it <= slot }
if (next <= slot) return slot
slot = next
}
}
private fun liveFlat(canonical: Long): Long {
var flat = canonical
while (true) {
val next = canonical + removedFlats.count { it <= flat }
if (next <= flat) return flat
flat = next
}
}
fun playFor(canonical: Long): Long = Shuffle.toSequence(seed, domain, canonical)
}
data class QueueSnapshot(
@@ -119,6 +69,7 @@ data class QueueSnapshot(
val shuffle: ShuffleState? = null,
val upNextAnchor: Long = 0,
val version: Long = 0,
val resetVersion: Long = 0,
) {
val contextSize: Long = entries.sumOf { it.length }
@@ -202,11 +153,33 @@ data class QueueWindowItem(
val item: SongItem,
val playlistPosition: Long? = null,
val upNext: Boolean = false,
val resetItem: QueueResetItem? = null,
)
data class Reanchored(
val position: Long,
val changed: Boolean,
val stale: Boolean = false,
)
data class QueueResetItem(
val origin: QueueOrigin,
val songId: String,
val position: Long,
val upNext: Boolean,
val entryOccurrence: Int = 0,
val occurrence: Long = 0,
)
data class QueueResetPoint(
val current: QueueResetItem?,
val anchor: QueueResetItem?,
val upNextAnchor: Long? = null,
)
data class QueueReset(
val point: QueueResetPoint,
val snapshot: QueueSnapshot,
)
private interface QueueKindResolver {
@@ -411,13 +384,23 @@ class QueueRepository(
private val dao get() = db.queueDao()
private val queueVersion = AtomicLong()
private val resetVersion = AtomicLong()
private val libraryVersion = AtomicLong()
private val queueMutex = Mutex()
private var resetListener: (suspend QueueMutationScope.(QueueReset) -> Unit)? = null
@Volatile
private var shuffleRemoved: ShuffleState? = null
fun setResetListener(listener: suspend QueueMutationScope.(QueueReset) -> Unit) {
resetListener = listener
}
suspend fun <T> withMutationLock(block: suspend QueueMutationScope.() -> T): T =
queueMutex.withLock {
QueueMutationScope().block()
}
private val lengthCache = ConcurrentHashMap<EntryLengthKey, Long>()
private val keyCache = ConcurrentHashMap<QueueRef, KeyCursor>()
private val keyCache = ConcurrentHashMap<QueueOrigin, KeyCursor>()
internal var keysetSeeks = 0L
private set
@@ -428,7 +411,7 @@ class QueueRepository(
internal var resolvedRowCount = 0L
private set
private val cursorMutex = Mutex()
internal val cursorMutex = Mutex()
fun playlistEntry(
sourceId: Long,
@@ -514,51 +497,46 @@ class QueueRepository(
}
}
suspend fun replace(
private suspend fun replaceLocked(
entries: List<QueueEntry>,
shuffleSeed: Long? = null,
upNext: List<QueueEntry> = emptyList(),
upNextAnchor: Long = 0,
shuffleSeed: Long?,
upNext: List<QueueEntry>,
upNextAnchor: Long,
) {
write(entries)
writeUpNext(upNext)
setShuffle(shuffleSeed)
invalidateLibraryCache()
writeLocked(entries)
writeUpNextLocked(upNext)
setShuffleLocked(shuffleSeed)
cursorMutex.withLock {
dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0, upNextAnchor = upNextAnchor))
}
}
suspend fun snapshot(): QueueSnapshot {
val version = queueVersion.get()
suspend fun snapshot(): QueueSnapshot = withMutationLock { snapshot() }
private suspend fun snapshotLocked(): QueueSnapshot {
val libraryRevision = libraryVersion.get()
val loaded =
db.useReaderConnection { transactor ->
transactor.deferredTransaction {
val entries = dao.entries().map { ResolvedQueueEntry(it, lengthOf(it, version)) }
val entries = dao.entries().map { ResolvedQueueEntry(it, lengthOf(it, libraryRevision)) }
val upNext =
dao.upNextEntries().map {
val entry = it.toQueueEntry()
ResolvedQueueEntry(entry, lengthOf(entry, version))
ResolvedQueueEntry(entry, lengthOf(entry, libraryRevision))
}
val row = dao.cursor()
val size = entries.sumOf { it.length }
val state = shuffleRemoved
val shuffle =
if (row != null && row.shuffleEnabled && size > 0L) {
when {
state != null && state.seed == row.shuffleSeed && state.domain == row.shuffleSize &&
state.liveSize == size -> state
state == null && row.shuffleSize == size -> ShuffleState(row.shuffleSeed, row.shuffleSize)
else -> null
}
if (row != null && row.shuffleEnabled && row.shuffleSize == size && size > 0L) {
ShuffleState(row.shuffleSeed, row.shuffleSize)
} else {
null
}
LoadedSnapshot(entries, upNext, shuffle, row?.upNextAnchor ?: 0L)
}
}
return QueueSnapshot(loaded.entries, loaded.upNext, loaded.shuffle, loaded.anchor, version)
return QueueSnapshot(loaded.entries, loaded.upNext, loaded.shuffle, loaded.anchor, queueVersion.get(), resetVersion.get())
}
suspend fun modes(): QueueModes {
@@ -566,7 +544,7 @@ class QueueRepository(
return QueueModes(row.shuffleEnabled, row.repeatMode)
}
suspend fun setShuffle(seed: Long?) {
private suspend fun setShuffleLocked(seed: Long?) {
val size = if (seed != null) contextSize() else 0L
cursorMutex.withLock {
dao.setCursor(
@@ -577,14 +555,274 @@ class QueueRepository(
),
)
}
shuffleRemoved = seed?.let { ShuffleState(it, size) }
queueVersion.incrementAndGet()
}
fun contentVersion(): Long = queueVersion.get()
fun invalidateLibraryCache() {
lengthCache.clear()
keyCache.clear()
shuffleRemoved = null
libraryVersion.incrementAndGet()
}
suspend fun captureResetPoint(): QueueResetPoint {
val snapshot = snapshot()
val row = cursorRow()
val current =
resetItemAt(snapshot, row.queuePosition)
?: snapshot.size.takeIf { it > 0L }?.let { resetItemAt(snapshot, 0L) }
val anchor = if (snapshot.upNextSize == 0L) null else resetItemAt(snapshot, snapshot.anchorPlay)
return QueueResetPoint(current, anchor, snapshot.upNextAnchor.takeIf { snapshot.upNextSize > 0L })
}
suspend fun resetChangedOrigins(
changed: Set<QueueOrigin>,
point: QueueResetPoint,
) {
if (changed.isEmpty()) return
withMutationLock {
val before = snapshot()
val entries = canonicalEntries(before.entries.map { it.entry }, changed)
val upNext = canonicalEntries(before.upNext.map { it.entry }, changed)
if (entries == before.entries.map { it.entry } && upNext == before.upNext.map { it.entry }) return@withMutationLock
val updated = writeResetLocked(before, entries, upNext, point)
resetListener?.invoke(this, QueueReset(point, updated))
}
}
private suspend fun writeResetLocked(
before: QueueSnapshot,
entries: List<QueueEntry>,
upNext: List<QueueEntry>,
point: QueueResetPoint,
): QueueSnapshot {
invalidateLibraryCache()
val resolvedEntries = entries.map { ResolvedQueueEntry(it, it.resolvedLength()) }
val resolvedUpNext = upNext.map { ResolvedQueueEntry(it, it.resolvedLength()) }
val shuffle = before.shuffleSeed?.let { ShuffleState(it, resolvedEntries.sumOf { it.length }) }
var updated =
QueueSnapshot(
entries = resolvedEntries,
upNext = resolvedUpNext,
shuffle = shuffle,
upNextAnchor = before.upNextAnchor,
version = queueVersion.get(),
resetVersion = resetVersion.get(),
)
val anchorChanged = before.upNextSize > 0L && before.upNextAnchor != point.upNextAnchor
val latestAnchor = if (anchorChanged) resetItemAt(before, before.anchorPlay) else null
val anchor =
if (anchorChanged) {
remapAnchor(updated, latestAnchor) ?: fallbackAnchor(updated, before.anchorPlay)
} else {
remapAnchor(updated, point.anchor)
?: fallbackAnchor(updated, point.anchor?.position ?: before.upNextAnchor)
}
updated = updated.copy(upNextAnchor = anchor)
cursorMutex.withLock {
val row = cursorRow()
val current = point.current ?: resetItemAt(before, row.queuePosition)
val remappedCurrent = current?.let { remapPosition(updated, it) }
val cursor =
when {
updated.size == 0L -> 0L
remappedCurrent != null -> remappedCurrent.coerceIn(0, updated.size - 1)
current != null -> current.position.coerceIn(0, updated.size - 1)
else -> row.queuePosition.coerceIn(0, updated.size - 1)
}
val previousSongId = current?.songId
val updatedSongId = itemAt(updated, cursor)?.song?.id
val positionMs = if (remappedCurrent != null && previousSongId == updatedSongId) row.positionMs else 0L
db.useWriterConnection { connection ->
connection.immediateTransaction {
dao.clear()
dao.insert(entries.mapIndexed { index, entry -> entry.copy(id = 0, position = index.toLong()) })
dao.clearUpNext()
dao.insertUpNext(
upNext.mapIndexed { index, entry -> UpNextEntry.from(entry).copy(id = 0, position = index.toLong()) },
)
dao.setCursor(
row.copy(
queuePosition = cursor,
positionMs = positionMs,
upNextAnchor = anchor,
shuffleEnabled = shuffle != null,
shuffleSeed = shuffle?.seed ?: 0L,
shuffleSize = shuffle?.domain ?: 0L,
),
)
}
}
}
queueVersion.incrementAndGet()
resetVersion.incrementAndGet()
return updated.copy(version = queueVersion.get(), resetVersion = resetVersion.get())
}
private suspend fun remapAnchor(
snapshot: QueueSnapshot,
item: QueueResetItem?,
): Long? {
val target = item?.let { remapPosition(snapshot, it) } ?: return null
return snapshot.flatContext(snapshot.contextPlay(target))
}
private fun fallbackAnchor(
snapshot: QueueSnapshot,
position: Long,
): Long {
val play = position.coerceIn(-1L, snapshot.contextSize - 1L)
return if (play < 0L) play else snapshot.flatContext(play) ?: play
}
suspend fun resetItemAt(
snapshot: QueueSnapshot,
position: Long,
songId: String? = null,
item: SongItem? = null,
): QueueResetItem? {
if (position !in 0 until snapshot.size) return null
val entry = snapshot.locate(position)?.first ?: return null
val upNext = snapshot.isUpNext(position)
val entries = if (upNext) snapshot.upNext else snapshot.entries
val blockPosition =
if (upNext) {
snapshot.upNextIndex(position)
} else {
snapshot.flatContext(snapshot.contextPlay(position))
?: return null
}
var start = 0L
var entryIndex = -1
for (index in entries.indices) {
val end = start + entries[index].length
if (blockPosition >= start && blockPosition < end) {
entryIndex = index
break
}
start = end
}
if (entryIndex < 0) return null
val origin = entry.origin
val entryOccurrence = resetEntryOccurrence(entries, entryIndex)
val resolvedItem = item ?: itemAt(snapshot, position)
val resolvedSongId = songId ?: resolvedItem?.song?.id ?: return null
val occurrence =
entry.originOccurrence
?: if (entry.kind == QueueKind.Playlist && resolvedItem?.playlistPosition != null) {
dao.playlistOccurrenceRank(entry.sourceId, entry.refId, resolvedSongId, resolvedItem.playlistPosition!!)
} else {
0L
}
return QueueResetItem(origin, resolvedSongId, position, upNext, entryOccurrence, occurrence)
}
private fun resetEntryOccurrence(
entries: List<ResolvedQueueEntry>,
targetIndex: Int,
): Int {
val origin = entries[targetIndex].entry.origin
val targetWhole = entries[targetIndex].entry.isWholeOrigin()
var occurrence = 0
var fragmentSeen = false
for (index in 0..targetIndex) {
val entry = entries[index].entry
if (entry.origin != origin) continue
if (entry.isWholeOrigin()) {
if (index == targetIndex) return occurrence
occurrence++
} else if (!fragmentSeen) {
fragmentSeen = true
if (!targetWhole) return occurrence
occurrence++
}
}
return occurrence
}
private fun QueueEntry.isWholeOrigin(): Boolean {
val origin = origin
return kind == origin.kind && refId == origin.refId && rangeStart == null && rangeEnd == null
}
private fun canonicalEntries(
entries: List<QueueEntry>,
changed: Set<QueueOrigin>,
): List<QueueEntry> {
val result = mutableListOf<QueueEntry>()
val reset = mutableSetOf<QueueOrigin>()
for (entry in entries) {
val origin = entry.origin
val whole = entry.isWholeOrigin()
if (origin !in changed) {
result += entry
} else if (whole || reset.add(origin)) {
result += QueueEntry(position = 0, sourceId = origin.sourceId, kind = origin.kind, refId = origin.refId)
}
}
return result
}
private data class OriginEntryGroup(
val starts: List<Long>,
val entries: List<ResolvedQueueEntry>,
)
private fun originEntryGroups(
entries: List<ResolvedQueueEntry>,
origin: QueueOrigin,
): List<OriginEntryGroup> {
val groups = mutableListOf<OriginEntryGroup>()
var fragmentIndex: Int? = null
var start = 0L
for (resolved in entries) {
if (resolved.entry.origin == origin) {
if (resolved.entry.isWholeOrigin()) {
groups += OriginEntryGroup(listOf(start), listOf(resolved))
} else {
val index = fragmentIndex
if (index == null) {
fragmentIndex = groups.size
groups += OriginEntryGroup(listOf(start), listOf(resolved))
} else {
groups[index] =
groups[index].copy(
starts = groups[index].starts + start,
entries = groups[index].entries + resolved,
)
}
}
}
start += resolved.length
}
return groups
}
suspend fun remapPosition(
snapshot: QueueSnapshot,
item: QueueResetItem,
): Long? {
val entries = if (item.upNext) snapshot.upNext else snapshot.entries
val groups = originEntryGroups(entries, item.origin)
val group = groups.getOrNull(item.entryOccurrence) ?: return null
for ((index, resolved) in group.entries.withIndex()) {
val entry = resolved.entry
val rank =
if (entry.kind == QueueKind.Playlist) {
val position = dao.playlistPositionForOccurrence(entry.sourceId, entry.refId, item.songId, item.occurrence)
position?.let { dao.playlistRankAtPosition(entry.sourceId, entry.refId, it) }
} else {
resolverFor(entry.kind).rank(dao, entry, item.songId) ?: fallbackRank(entry, resolved.length, item.songId)
}
if (rank != null && rank >= entry.offset && rank < entry.offset + resolved.length) {
val flat = group.starts[index] + rank - entry.offset
if (item.upNext) return snapshot.anchorPlay + 1 + flat
val play = snapshot.playContext(flat) ?: return null
return snapshot.combined(play)
}
}
return null
}
suspend fun setRepeat(mode: Int) = cursorMutex.withLock { dao.setCursor(cursorRow().copy(repeatMode = mode)) }
@@ -644,17 +882,18 @@ class QueueRepository(
return range(snapshot, (center - radius).coerceAtLeast(0), (center + radius).coerceAtMost(snapshot.size - 1))
}
suspend fun addUpNext(
snapshot: QueueSnapshot,
private suspend fun addUpNextLocked(
currentPosition: Long,
entry: QueueEntry,
playNext: Boolean,
): Long {
val list = snapshot.upNext.map { it.entry }.toMutableList()
invalidateLibraryCache()
val current = snapshotLocked()
val list = current.upNext.map { it.entry }.toMutableList()
val insertIndex =
when {
playNext && snapshot.isUpNext(currentPosition) -> {
entryIndexAfter(snapshot.upNext, snapshot.upNextIndex(currentPosition))
playNext && current.isUpNext(currentPosition) -> {
entryIndexAfter(current.upNext, current.upNextIndex(currentPosition))
}
playNext -> {
@@ -667,75 +906,54 @@ class QueueRepository(
}.coerceIn(0, list.size)
list.add(insertIndex, entry.copy(id = 0, position = 0))
val anchor =
if (snapshot.upNextSize == 0L) {
val play = snapshot.anchorContextPlay(currentPosition)
snapshot.flatContext(play) ?: 0L
if (current.upNextSize == 0L) {
val play = current.anchorContextPlay(currentPosition)
current.flatContext(play) ?: 0L
} else {
snapshot.upNextAnchor
current.upNextAnchor
}
writeUpNext(list)
writeUpNextLocked(list)
cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = anchor)) }
if (snapshot.upNextSize == 0L) return currentPosition
val before = snapshot.upNext.take(insertIndex).sumOf { it.length }
return if (currentPosition >= snapshot.anchorPlay + 1 + before) currentPosition + 1 else currentPosition
if (current.upNextSize == 0L) return currentPosition
val before = current.upNext.take(insertIndex).sumOf { it.length }
return if (currentPosition >= current.anchorPlay + 1 + before) currentPosition + 1 else currentPosition
}
suspend fun reanchorFor(
private suspend fun reanchorForLocked(
snapshot: QueueSnapshot,
target: Long,
): Reanchored {
if (snapshot.version != queueVersion.get()) return Reanchored(target, false, stale = true)
if (snapshot.upNextSize == 0L || snapshot.isUpNext(target)) return Reanchored(target, false)
val play = snapshot.contextPlay(target)
val flat = snapshot.flatContext(play) ?: return Reanchored(target, false)
if (flat == snapshot.upNextAnchor) return Reanchored(target, false)
cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = flat)) }
queueVersion.incrementAndGet()
return Reanchored(play, true)
}
suspend fun setUpNextAnchor(anchor: Long) = cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = anchor)) }
suspend fun clearUpNext() {
writeUpNext(emptyList())
}
suspend fun removeUpNextAt(
private suspend fun removeUpNextAtLocked(
snapshot: QueueSnapshot,
index: Long,
) {
if (index !in 0 until snapshot.upNextSize) return
writeUpNext(compact(removeEntry(snapshot.upNext, index)).map { it.entry })
): Boolean {
if (index !in 0 until snapshot.upNextSize) return false
writeUpNextLocked(compact(removeEntry(snapshot.upNext, index)).map { it.entry })
return true
}
suspend fun removeAt(
private suspend fun removeAtLocked(
snapshot: QueueSnapshot,
position: Long,
) {
if (snapshot.isUpNext(position)) {
removeUpNextAt(snapshot, snapshot.upNextIndex(position))
return
}
): Boolean {
if (snapshot.version != queueVersion.get()) return false
if (snapshot.isUpNext(position)) return removeUpNextAtLocked(snapshot, snapshot.upNextIndex(position))
if (snapshot.shuffled) return false
val play = snapshot.contextPlay(position)
if (snapshot.shuffled) {
// Subtract the track from the fixed shuffle order instead of re-deriving it, so the
// tracks that remain keep their positions.
val order = snapshot.shuffle ?: return
val flat = snapshot.flatContext(play) ?: return
val predecessor = if (play <= 0L) -1L else snapshot.flatContext(play - 1L) ?: -1L
shuffleRemoved = order.withRemoved(order.domainFor(flat))
write(compact(removeEntry(snapshot.entries, flat)).map { it.entry })
if (cursorRow().upNextAnchor == flat) {
// The removed track carried the block; move the anchor to the track before it in
// play order so the block stays in place instead of jumping to the canonical
// predecessor, which in a shuffled queue is an unrelated position.
setUpNextAnchor(if (predecessor > flat) predecessor - 1 else predecessor)
} else {
adjustAnchorOnRemove(flat)
}
} else {
val removed = compact(removeEntry(snapshot.entries, play))
write(removed.map { it.entry })
adjustAnchorOnRemove(play)
}
val removed = compact(removeEntry(snapshot.entries, play))
writeLocked(removed.map { it.entry })
adjustAnchorOnRemove(play)
return true
}
private suspend fun adjustAnchorOnRemove(removedFlat: Long) =
@@ -765,19 +983,21 @@ class QueueRepository(
if (moved != anchor) dao.setCursor(row.copy(upNextAnchor = moved))
}
suspend fun move(
private suspend fun moveLocked(
snapshot: QueueSnapshot,
from: Long,
to: Long,
): Boolean {
if (snapshot.version != queueVersion.get()) return false
val fromUpNext = snapshot.isUpNext(from)
val toUpNext = snapshot.isUpNext(to)
if (fromUpNext && toUpNext) {
val song = itemAt(snapshot, from) ?: return false
val located = snapshot.locate(from) ?: return false
val resetItem = resetItemAt(snapshot, from, item = song)
val removed = removeEntry(snapshot.upNext, snapshot.upNextIndex(from))
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L)
writeUpNext(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry })
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second, resetItem), 1L)
writeUpNextLocked(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry })
return true
}
if (fromUpNext != toUpNext) return false
@@ -789,10 +1009,11 @@ class QueueRepository(
val playTo = snapshot.contextPlay(to)
val located = snapshot.locateContext(playFrom) ?: return false
val song = itemAt(snapshot, from) ?: return false
val resetItem = resetItemAt(snapshot, from, item = song)
val removed = removeEntry(snapshot.entries, playFrom)
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L)
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second, resetItem), 1L)
val insertAt = playTo.coerceIn(0, removed.sumOf { it.length })
write(compact(insertEntry(removed, insertAt, moved)).map { it.entry })
writeLocked(compact(insertEntry(removed, insertAt, moved)).map { it.entry })
adjustAnchorOnMove(playFrom, insertAt)
return true
}
@@ -816,7 +1037,7 @@ class QueueRepository(
private suspend fun contextSize(): Long = dao.entries().sumOf { it.resolvedLength() }
private suspend fun write(entries: List<QueueEntry>) {
private suspend fun writeLocked(entries: List<QueueEntry>) {
db.useWriterConnection { transactor ->
transactor.immediateTransaction {
dao.clear()
@@ -826,7 +1047,7 @@ class QueueRepository(
queueVersion.incrementAndGet()
}
private suspend fun writeUpNext(entries: List<QueueEntry>) {
private suspend fun writeUpNextLocked(entries: List<QueueEntry>) {
db.useWriterConnection { transactor ->
transactor.immediateTransaction {
dao.clearUpNext()
@@ -840,6 +1061,51 @@ class QueueRepository(
queueVersion.incrementAndGet()
}
inner class QueueMutationScope internal constructor() {
suspend fun snapshot(): QueueSnapshot = snapshotLocked()
suspend fun replace(
entries: List<QueueEntry>,
shuffleSeed: Long? = null,
upNext: List<QueueEntry> = emptyList(),
upNextAnchor: Long = 0,
) = replaceLocked(entries, shuffleSeed, upNext, upNextAnchor)
suspend fun setShuffle(seed: Long?) = setShuffleLocked(seed)
suspend fun addUpNext(
currentPosition: Long,
entry: QueueEntry,
playNext: Boolean,
): Long = addUpNextLocked(currentPosition, entry, playNext)
suspend fun reanchorFor(
snapshot: QueueSnapshot,
target: Long,
): Reanchored = reanchorForLocked(snapshot, target)
suspend fun clearUpNext() = writeUpNextLocked(emptyList())
suspend fun removeUpNextAt(
snapshot: QueueSnapshot,
index: Long,
): Boolean {
if (snapshot.version != queueVersion.get()) return false
return removeUpNextAtLocked(snapshot, index)
}
suspend fun removeAt(
snapshot: QueueSnapshot,
position: Long,
): Boolean = removeAtLocked(snapshot, position)
suspend fun move(
snapshot: QueueSnapshot,
from: Long,
to: Long,
): Boolean = moveLocked(snapshot, from, to)
}
private suspend fun flatIndexOf(
entries: List<ResolvedQueueEntry>,
songId: String,
@@ -887,7 +1153,14 @@ class QueueRepository(
for (play in first..last) {
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)
val position = snapshot.combined(play)
items +=
QueueWindowItem(
position,
item,
playlistPosition = item.playlistPosition,
resetItem = resetItemAt(snapshot, position, item = item),
)
}
return items
}
@@ -901,7 +1174,14 @@ class QueueRepository(
val to = minOf(last, end)
val offset = resolved.entry.offset + (from - entryStart)
rows(resolved.entry, offset, (to - from + 1).toInt()).forEachIndexed { index, item ->
items += QueueWindowItem(snapshot.combined(from + index), item, playlistPosition = item.playlistPosition)
val position = snapshot.combined(from + index)
items +=
QueueWindowItem(
position,
item,
playlistPosition = item.playlistPosition,
resetItem = resetItemAt(snapshot, position, item = item),
)
}
}
return items
@@ -924,12 +1204,14 @@ class QueueRepository(
val to = minOf(last, end)
val offset = resolved.entry.offset + (from - entryStart)
rows(resolved.entry, offset, (to - from + 1).toInt()).forEachIndexed { index, item ->
val position = snapshot.anchorPlay + 1 + from + index
items +=
QueueWindowItem(
snapshot.anchorPlay + 1 + from + index,
position,
item,
playlistPosition = item.playlistPosition,
upNext = true,
resetItem = resetItemAt(snapshot, position, item = item),
)
}
}
@@ -945,7 +1227,7 @@ class QueueRepository(
offsetSeeks++
return resolverFor(entry.kind).rows(dao, entry, offset, limit).also { resolvedRowCount += it.size }
}
val ref = QueueRef(entry.kind, entry.sourceId, entry.refId)
val ref = QueueOrigin(entry.kind, entry.sourceId, entry.refId)
val anchored = keyCache[ref]?.let { keyedRows(entry, offset, limit, it) }
if (anchored != null) {
keysetSeeks++
@@ -961,7 +1243,7 @@ class QueueRepository(
}
private fun remember(
ref: QueueRef,
ref: QueueOrigin,
offset: Long,
resolved: List<SongItem>,
) {
@@ -1056,12 +1338,17 @@ class QueueRepository(
source: QueueEntry,
songId: String,
offsetInEntry: Long,
resetItem: QueueResetItem?,
): QueueEntry =
if (source.kind == QueueKind.Album) {
val offset = source.offset + offsetInEntry
source.range(offset, offset)
} else {
songEntry(source.sourceId, songId)
songEntry(source.sourceId, songId).copy(
originKind = source.origin.kind,
originRefId = source.origin.refId,
originOccurrence = resetItem?.occurrence ?: source.originOccurrence,
)
}
private fun QueueEntry.range(
@@ -299,7 +299,7 @@ class PlaybackController(
return@withLock
}
val start = randomIndex(snap.contextSize, avoid)
queueRepository.setShuffle(pinnedSeed(snap.contextSize, start))
queueRepository.withMutationLock { setShuffle(pinnedSeed(snap.contextSize, start)) }
shuffleEnabled = true
queueSourceId = entry.sourceId
queueRepository.setCursor(0)
@@ -314,7 +314,7 @@ class PlaybackController(
windowEnd = -1
lastEdit = null
endedHandled = false
queueRepository.replace(listOf(entry))
queueRepository.withMutationLock { replace(listOf(entry)) }
val snapshot = queueRepository.snapshot()
this.snapshot = snapshot
return snapshot
@@ -350,7 +350,7 @@ class PlaybackController(
val player = player ?: return@withLock
var snapshot = snapshot ?: return@withLock
if (snapshot.size == 0L) return@withLock
val anchor = queueRepository.reanchorFor(snapshot, position)
val anchor = queueRepository.withMutationLock { reanchorFor(snapshot, position) }
if (anchor.changed) {
snapshot = readSnapshot()
this@PlaybackController.snapshot = snapshot
@@ -385,11 +385,12 @@ class PlaybackController(
val snapshot = snapshot ?: return@withLock
if (position !in 0 until snapshot.size) return@withLock
val player = player ?: return@withLock
if (snapshot.shuffled && !snapshot.isUpNext(position)) return@withLock
val cursor = queueRepository.cursor()
val entries = snapshot.entries.map { it.entry }
val upNext = snapshot.upNext.map { it.entry }
if (snapshot.size == 1L) {
queueRepository.removeAt(snapshot, position)
queueRepository.withMutationLock { removeAt(snapshot, position) }
stopLocked()
lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
return@withLock
@@ -398,7 +399,7 @@ class PlaybackController(
val playing = player.playWhenReady
val wasUpNext = snapshot.isUpNext(current)
val removed = if (position == current) null else queueItemAt(snapshot, position)
queueRepository.removeAt(snapshot, position)
queueRepository.withMutationLock { removeAt(snapshot, position) }
var updated = readSnapshot()
this.snapshot = updated
windowJob?.cancel()
@@ -437,7 +438,7 @@ class PlaybackController(
// Keep the up-next block next: after a removal the cursor may have landed on a context
// track that no longer carries the block (its anchor moved), so re-anchor it there.
if (updated.upNextSize > 0L && !updated.isUpNext(target)) {
val reanchored = queueRepository.reanchorFor(updated, target)
val reanchored = queueRepository.withMutationLock { reanchorFor(updated, target) }
if (reanchored.changed) {
updated = readSnapshot()
this.snapshot = updated
@@ -506,7 +507,7 @@ class PlaybackController(
val entries = snapshot.entries.map { it.entry }
val upNext = snapshot.upNext.map { it.entry }
val wasUpNext = snapshot.isUpNext(current)
if (!queueRepository.move(snapshot, from, to)) return@withLock
if (!queueRepository.withMutationLock { move(snapshot, from, to) }) return@withLock
val updated = readSnapshot()
this.snapshot = updated
// The current track still exists, so derive its new position from the edit instead of
@@ -586,7 +587,7 @@ class PlaybackController(
val player = player ?: return@withLock
val playing = player.playWhenReady
val positionMs = player.currentPositionMs
queueRepository.replace(undo.entries, undo.shuffleSeed, undo.upNext, undo.upNextAnchor)
queueRepository.withMutationLock { replace(undo.entries, undo.shuffleSeed, undo.upNext, undo.upNextAnchor) }
shuffleEnabled = undo.shuffleSeed != null
val restored = readSnapshot()
this.snapshot = restored
@@ -725,7 +726,7 @@ class PlaybackController(
val snapshot = snapshot ?: return@withLock
if (!snapshot.isUpNext(position)) return@withLock
if (position >= snapshot.size - 1 && repeatMode == RepeatMode.Off) return@withLock
queueRepository.removeUpNextAt(snapshot, snapshot.upNextIndex(position))
queueRepository.withMutationLock { removeUpNextAt(snapshot, snapshot.upNextIndex(position)) }
val updated = readSnapshot()
this.snapshot = updated
layoutVersion++
@@ -758,7 +759,7 @@ class PlaybackController(
player ?: return@withLock
val current = currentPosition() ?: return@withLock
val wasUpNext = snapshot.isUpNext(current)
queueRepository.clearUpNext()
queueRepository.withMutationLock { clearUpNext() }
val updated = readSnapshot()
this@PlaybackController.snapshot = updated
layoutVersion++
@@ -861,7 +862,7 @@ class PlaybackController(
val player = player ?: return@withLock
if (snapshot.size == 0L) return@withLock
val current = currentPosition() ?: return@withLock
val position = queueRepository.addUpNext(snapshot, current, entry, playNext)
val position = queueRepository.withMutationLock { addUpNext(current, entry, playNext) }
val updated = readSnapshot()
this.snapshot = updated
layoutVersion++
@@ -898,9 +899,9 @@ class PlaybackController(
windowJob?.cancel()
val enabling = !shuffleEnabled
if (enabling) {
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, flat))
queueRepository.withMutationLock { setShuffle(pinnedSeed(snapshot.contextSize, flat)) }
} else {
queueRepository.setShuffle(null)
queueRepository.withMutationLock { setShuffle(null) }
}
shuffleEnabled = enabling
val reordered = readSnapshot()
@@ -911,7 +912,7 @@ class PlaybackController(
enabling -> reordered.combined(0L)
else -> reordered.combined(flat)
}
val anchor = queueRepository.reanchorFor(reordered, position)
val anchor = queueRepository.withMutationLock { reanchorFor(reordered, position) }
val finalSnapshot = if (anchor.changed) readSnapshot() else reordered
if (anchor.changed) layoutVersion++
this@PlaybackController.snapshot = finalSnapshot
@@ -1028,7 +1029,7 @@ class PlaybackController(
val start = startPosition.coerceIn(0, snapshot.contextSize - 1)
val position =
if (shuffle) {
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, start))
queueRepository.withMutationLock { setShuffle(pinnedSeed(snapshot.contextSize, start)) }
0L
} else {
start
@@ -1052,7 +1053,7 @@ class PlaybackController(
if (shuffleEnabled && !snapshot.shuffled && snapshot.contextSize > 0L) {
val start = snapshot.anchorContextPlay(queueRepository.cursor()).coerceIn(0, snapshot.contextSize - 1)
val flat = snapshot.flatContext(start) ?: start
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, flat))
queueRepository.withMutationLock { setShuffle(pinnedSeed(snapshot.contextSize, flat)) }
queueRepository.setCursor(0)
snapshot = queueRepository.snapshot()
} else if (!snapshot.shuffled) {
@@ -1072,7 +1073,7 @@ class PlaybackController(
this.queueSourceId = queueSourceId
val savedPositionMs = queueRepository.cursorPositionMs().coerceAtLeast(0)
val cursor = queueRepository.cursor().coerceIn(0, snapshot.size - 1)
val anchor = queueRepository.reanchorFor(snapshot, cursor)
val anchor = queueRepository.withMutationLock { reanchorFor(snapshot, cursor) }
if (anchor.changed) {
snapshot = queueRepository.snapshot()
this.snapshot = snapshot
@@ -1211,7 +1212,7 @@ class PlaybackController(
val player = player ?: return@withLock
var snapshot = readSnapshot()
if (snapshot.size == 0L) return@withLock
val anchor = queueRepository.reanchorFor(snapshot, target)
val anchor = queueRepository.withMutationLock { reanchorFor(snapshot, target) }
if (anchor.changed) {
snapshot = readSnapshot()
layoutVersion++
@@ -1490,7 +1491,7 @@ class PlaybackController(
private suspend fun readSnapshot(): QueueSnapshot {
val snapshot = queueRepository.snapshot()
if (!snapshot.shuffled) {
if (shuffleEnabled && snapshot.size > 0L) queueRepository.setShuffle(null)
if (shuffleEnabled && snapshot.size > 0L) queueRepository.withMutationLock { setShuffle(null) }
shuffleEnabled = false
}
return snapshot
@@ -313,14 +313,14 @@ class DownloadRepositoryTest {
}
@Test
fun aSongRemovedFromTheServerTakesItsDownloadRowWithIt() =
fun aSongRemovedFromTheServerKeepsItsDownloadRowForRecovery() =
runTest {
seedLibrary()
runBlocking { repository.download(1, "s1") }
runBlocking { db.libraryDao().deleteSongs(1, listOf("s1")) }
assertNull(row(1, "s1"))
assertEquals(DownloadStatus.Queued, row(1, "s1")?.status)
}
@Test
@@ -0,0 +1,49 @@
package com.subtracks.data.repo
import com.subtracks.data.model.QueueEntry
suspend fun QueueRepository.replace(
entries: List<QueueEntry>,
shuffleSeed: Long? = null,
upNext: List<QueueEntry> = emptyList(),
upNextAnchor: Long = 0,
) = withMutationLock {
replace(entries, shuffleSeed, upNext, upNextAnchor)
}
suspend fun QueueRepository.setShuffle(seed: Long?) =
withMutationLock {
setShuffle(seed)
}
suspend fun QueueRepository.addUpNext(
currentPosition: Long,
entry: QueueEntry,
playNext: Boolean,
): Long = withMutationLock { addUpNext(currentPosition, entry, playNext) }
suspend fun QueueRepository.reanchorFor(
snapshot: QueueSnapshot,
target: Long,
): Reanchored = withMutationLock { reanchorFor(snapshot, target) }
suspend fun QueueRepository.clearUpNext() =
withMutationLock {
clearUpNext()
}
suspend fun QueueRepository.removeUpNextAt(
snapshot: QueueSnapshot,
index: Long,
): Boolean = withMutationLock { removeUpNextAt(snapshot, index) }
suspend fun QueueRepository.removeAt(
snapshot: QueueSnapshot,
position: Long,
): Boolean = withMutationLock { removeAt(snapshot, position) }
suspend fun QueueRepository.move(
snapshot: QueueSnapshot,
from: Long,
to: Long,
): Boolean = withMutationLock { move(snapshot, from, to) }
@@ -11,13 +11,18 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.QueueOrigin
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.yield
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -79,6 +84,20 @@ class QueueRepositoryTest {
assertEquals(listOf("s1", "s3"), resolveAll(snapshot))
}
@Test
fun downloadedEntryIgnoresRetainedRowsWhoseSongsArePruned() =
runTest {
seedLibrary()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed))
db.libraryDao().deleteSongs(1, listOf("s2"))
val snapshot = repository.snapshotAfter(listOf(repository.downloadedEntry(1)))
assertEquals(1, snapshot.size)
assertEquals(listOf("s1"), resolveAll(snapshot))
}
@Test
fun starredEntryResolvesStarredTracksNewestFirst() =
runTest {
@@ -416,28 +435,427 @@ class QueueRepositoryTest {
assertEquals(4, repository.snapshot().size)
}
@Test
fun aChangedOriginResetMergesEditsAndRemapsTheCurrentItem() =
runTest {
seedLibrary()
db.libraryDao().upsertSongs(listOf(song("s0", "al1", track = 0, album = "First Album")))
setPlaylist("p1", "s1", "s2", "s3")
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.removeAt(snapshot, 1)
repository.setCursor(1)
val point = repository.captureResetPoint()
setPlaylist("p1", "s0", "s1", "s2", "s3")
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
val updated = repository.snapshot()
val entry = updated.entries.single().entry
assertEquals(QueueKind.Playlist, entry.kind)
assertEquals("p1", entry.refId)
assertNull(entry.rangeStart)
assertNull(entry.rangeEnd)
assertEquals(3L, repository.remapPosition(updated, point.current!!))
}
@Test
fun anUnrelatedOriginResetRemapsWithinTheSecondUnchangedFragment() =
runTest {
seedLibrary()
db.libraryDao().upsertSongs(listOf(song("s0", "al1", track = 0, album = "First Album")))
setPlaylist("p2", "s4")
setPlaylist("p1", "s1", "s2", "s3")
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p2"), repository.playlistEntry(1, "p1")))
repository.removeAt(snapshot, 2)
repository.setCursor(2)
val point = repository.captureResetPoint()
setPlaylist("p2", "s0", "s4")
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p2")), point)
assertEquals(3L, repository.remapPosition(repository.snapshot(), point.current!!))
assertEquals(3L, repository.cursor())
}
@Test
fun anUnrelatedOriginResetPreservesInterleavedFragmentStarts() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.albumEntry(1, "al2")))
repository.move(snapshot, 3, 1)
repository.setCursor(3)
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al2")), point)
assertEquals(4L, repository.remapPosition(repository.snapshot(), point.current!!))
assertEquals(4L, repository.cursor())
}
@Test
fun aMovedPlaylistOccurrenceSurvivesReconstructionAndReset() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s1")
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.move(snapshot, 2, 0)
repository.setCursor(0)
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
assertEquals(2L, repository.remapPosition(repository.snapshot(), point.current!!))
assertEquals(2L, repository.cursor())
}
@Test
fun aRemovedDuplicateOccurrenceDoesNotLeaveItsOffsetOnTheSurvivor() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s1")
repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.setCursor(1)
repository.setPosition(42_000L)
val point = repository.captureResetPoint()
setPlaylist("p1", "s1")
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
assertEquals(0L, repository.cursorPositionMs())
}
@Test
fun anUnrelatedOriginResetKeepsTheEditedQueue() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3")
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.removeAt(snapshot, 1)
val edited = repository.snapshot().entries.map { it.entry }
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p2")), point)
assertEquals(edited, repository.snapshot().entries.map { it.entry })
}
@Test
fun anOriginResetRebuildsOnlyTheChangedUpNextBlock() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3")
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(0, repository.playlistEntry(1, "p1").copy(rangeStart = 0, rangeEnd = 1), playNext = false)
repository.addUpNext(
0,
repository.albumEntry(1, "al2").copy(rangeStart = 1, rangeEnd = 1),
playNext = false,
)
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
val upNext = repository.snapshot().upNext.map { it.entry }
assertEquals(QueueKind.Playlist, upNext[0].kind)
assertNull(upNext[0].rangeStart)
assertEquals(QueueKind.Album, upNext[1].kind)
assertEquals(1L, upNext[1].rangeStart)
assertEquals(1L, upNext[1].rangeEnd)
}
@Test
fun anOriginResetPreservesDuplicateWholeUpNextEntries() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3")
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false)
repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false)
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
val updated = repository.snapshot()
assertEquals(2, updated.upNext.size)
assertEquals(6L, updated.upNextSize)
assertTrue(updated.upNext.all { it.entry.rangeStart == null && it.entry.rangeEnd == null })
}
@Test
fun aResetRemapsCurrentWithinTheSecondDuplicateUpNextEntry() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2")
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false)
repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false)
repository.setCursor(4)
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
assertEquals(4L, repository.cursor())
}
@Test
fun aResetRemapsCurrentAndAnchorWithinTheSecondDuplicateContextEntry() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2")
repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1"), repository.playlistEntry(1, "p1")))
repository.addUpNext(3, repository.songEntry(1, "s4"), playNext = false)
repository.setCursor(3)
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
assertEquals(3L, repository.cursor())
assertEquals(3L, repository.snapshot().upNextAnchor)
}
@Test
fun aResetRemapsTheSecondOccurrenceOfASongWithinAPlaylist() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s1")
repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.setCursor(2)
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
assertEquals(2L, repository.cursor())
}
@Test
fun aRemovedShuffledAnchorFallbackStaysInPlayCoordinates() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3", "s4", "s5")
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.addUpNext(2, repository.songEntry(1, "s4"), playNext = false)
val seed = (1L..100L).first { Shuffle.toSequence(it, 4, 2) != 2L }
repository.setShuffle(seed)
val point = repository.captureResetPoint()
setPlaylist("p1", "s1", "s2", "s4", "s5")
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
repository.setShuffle(seed)
val updated = repository.snapshot()
assertEquals(updated.flatContext(point.anchor!!.position), updated.upNextAnchor)
}
@Test
fun aResetDoesNotRestoreShuffleAfterTheUserDisablesIt() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3")
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.removeAt(snapshot, 1)
repository.setShuffle(42L)
val point = repository.captureResetPoint()
repository.setShuffle(null)
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
assertFalse(repository.snapshot().shuffled)
}
@Test
fun aResetPreservesShuffleEnabledDuringSync() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3")
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.removeAt(snapshot, 1)
val point = repository.captureResetPoint()
repository.setShuffle(99L)
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
assertTrue(repository.snapshot().shuffled)
assertEquals(99L, repository.snapshot().shuffleSeed)
}
@Test
fun aConcurrentUpNextEditSurvivesAReset() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3")
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.removeAt(snapshot, 1)
val point = repository.captureResetPoint()
val reset = launch { repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
val edit = launch { repository.addUpNext(0, repository.songEntry(1, "s4"), playNext = false) }
reset.join()
edit.join()
assertEquals(1L, repository.snapshot().upNextSize)
}
@Test
fun staleContextEditsCannotOverwriteACompletedReset() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3")
val initial = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.removeAt(initial, 1)
val stale = repository.snapshot()
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
val canonical = repository.snapshot().entries.map { it.entry }
repository.removeAt(stale, 0)
assertFalse(repository.move(stale, 0, 1))
assertEquals(canonical, repository.snapshot().entries.map { it.entry })
}
@Test
fun staleUpNextRemovalCannotOverwriteACompletedReset() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3")
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(0, repository.playlistEntry(1, "p1").copy(rangeStart = 0, rangeEnd = 1), playNext = false)
val stale = repository.snapshot()
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
val canonical = repository.snapshot().upNext.map { it.entry }
repository.removeUpNextAt(stale, 0)
assertEquals(canonical, repository.snapshot().upNext.map { it.entry })
}
@Test
fun cacheInvalidationDoesNotAdvanceTheQueueContentVersion() =
runTest {
seedLibrary()
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
val before = repository.snapshot().version
repository.invalidateLibraryCache()
assertEquals(before, repository.snapshot().version)
}
@Test
fun aResetPrefersTheLatestUpNextAnchorOverTheCapturedOne() =
runTest {
seedLibrary()
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(0, repository.albumEntry(1, "al2").copy(rangeStart = 0, rangeEnd = 0), playNext = false)
val point = repository.captureResetPoint()
val navigated = repository.snapshot()
repository.reanchorFor(navigated, 2)
repository.setCursor(repository.snapshot().anchorPlay + 1)
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al2")), point)
assertEquals(1L, repository.snapshot().upNextAnchor)
}
@Test
fun aResetPreservesANewBeforeFirstUpNextAnchor() =
runTest {
seedLibrary()
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(0, repository.albumEntry(1, "al2").copy(rangeStart = 0, rangeEnd = 0), playNext = false)
val point = repository.captureResetPoint()
repository.removeAt(repository.snapshot(), 0)
assertEquals(-1L, repository.snapshot().upNextAnchor)
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al2")), point)
assertEquals(-1L, repository.snapshot().upNextAnchor)
}
@Test
fun aDeletedCurrentSongDoesNotLeaveItsPositionOnTheSuccessor() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3")
repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1").copy(rangeStart = 0, rangeEnd = 2)))
repository.setCursor(0)
repository.setPosition(50_000)
val point = repository.captureResetPoint()
setPlaylist("p1", "s2", "s3")
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
assertEquals(0L, repository.cursorPositionMs())
}
@Test
fun resetWaitsForCursorUpdatesBeforeWritingItsCursor() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3")
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.removeAt(snapshot, 0)
val point = repository.captureResetPoint()
repository.cursorMutex.lock()
val reset = launch { repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
val repeat = launch { repository.setRepeat(2) }
try {
yield()
assertFalse(reset.isCompleted)
assertFalse(repeat.isCompleted)
} finally {
repository.cursorMutex.unlock()
}
reset.join()
repeat.join()
assertEquals(2, repository.modes().repeat)
}
@Test
fun upNextResolvesAfterTheCurrentTrack() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
val updated = repository.snapshot()
assertEquals(4, updated.size)
assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(updated))
}
@Test
fun reAddingUpNextAfterItsLibraryOriginChangedResolvesFreshRows() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2")
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false)
val initial = repository.snapshot()
assertEquals(listOf("s1", "s2"), (1L..2L).map { repository.itemAt(initial, it)?.song?.id })
repository.clearUpNext()
setPlaylist("p1", "s1", "s2", "s3")
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false)
val updated = repository.snapshot()
assertEquals(3L, updated.upNextSize)
assertEquals(listOf("s1", "s2", "s3"), (1L..3L).map { repository.itemAt(updated, it)?.song?.id })
}
@Test
fun playNextGoesToTheFrontOfTheUpNextQueue() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(
repository.snapshot(),
currentPosition = 0,
entry = repository.songEntry(1, "s5"),
playNext = true,
@@ -451,7 +869,7 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.removeAt(repository.snapshot(), 1)
@@ -465,9 +883,8 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(
repository.snapshot(),
currentPosition = 0,
entry = repository.songEntry(1, "s5"),
playNext = false,
@@ -486,7 +903,7 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false)
repository.move(repository.snapshot(), from = 2, to = 1)
@@ -509,9 +926,8 @@ class QueueRepositoryTest {
),
)
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al2")))
repository.addUpNext(snapshot, currentPosition = 0, entry = albumRange(1, 4), playNext = false)
repository.addUpNext(currentPosition = 0, entry = albumRange(1, 4), playNext = false)
repository.addUpNext(
repository.snapshot(),
currentPosition = 0,
entry = repository.songEntry(1, "x1"),
playNext = false,
@@ -562,7 +978,7 @@ class QueueRepositoryTest {
val shuffled = repository.snapshot()
val contextBefore = (0 until shuffled.contextSize).mapNotNull { repository.itemAt(shuffled, it)?.song?.id }
repository.addUpNext(shuffled, currentPosition = 0, entry = repository.songEntry(1, "s5"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s5"), playNext = false)
val updated = repository.snapshot()
assertEquals(4, updated.size)
@@ -580,7 +996,7 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false)
assertEquals(listOf("s1", "s4", "s5", "s2", "s3"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 2)
@@ -593,7 +1009,7 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 0)
@@ -606,7 +1022,7 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 2, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 2, entry = repository.songEntry(1, "s4"), playNext = false)
assertEquals(listOf("s1", "s2", "s3", "s4"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 2)
@@ -621,7 +1037,7 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false)
assertEquals(listOf("s1", "s2", "s4", "s3"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 0)
@@ -634,7 +1050,7 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.replace(listOf(repository.songEntry(1, "s5")))
@@ -783,98 +1199,22 @@ class QueueRepositoryTest {
}
@Test
fun removingAContextTrackWhileShuffledKeepsTheRemainingOrder() =
fun removingAContextTrackWhileShuffledIsRejected() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.setShuffle(42L)
val before = repository.snapshot()
val entriesBefore = before.entries.map { it.entry }
val orderBefore = (0 until before.contextSize).mapNotNull { repository.itemAt(before, it)?.song?.id }
repository.removeAt(before, 1)
val after = repository.snapshot()
assertTrue(after.shuffled)
assertEquals(2L, after.contextSize)
val orderAfter = (0 until after.contextSize).mapNotNull { repository.itemAt(after, it)?.song?.id }
assertEquals(orderBefore.filterIndexed { index, _ -> index != 1 }, orderAfter)
}
@Test
fun severalRemovalsWhileShuffledKeepTheRemainingOrder() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.setShuffle(42L)
val before = repository.snapshot()
val orderBefore = (0 until before.contextSize).mapNotNull { repository.itemAt(before, it)?.song?.id }
repository.removeAt(before, 0)
repository.removeAt(repository.snapshot(), 0)
val after = repository.snapshot()
assertTrue(after.shuffled)
assertEquals(1L, after.contextSize)
val orderAfter = (0 until after.contextSize).mapNotNull { repository.itemAt(after, it)?.song?.id }
assertEquals(orderBefore.drop(2), orderAfter)
}
@Test
fun removingWhileShuffledKeepsTheOrderInverse() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4")))
repository.setShuffle(9L)
repository.removeAt(repository.snapshot(), 2)
val after = repository.snapshot()
assertEquals(3L, after.contextSize)
for (play in 0 until after.contextSize) {
assertEquals(play, after.playContext(after.flatContext(play)!!))
}
assertEquals((0L until after.contextSize).toList(), (0 until after.contextSize).map { after.flatContext(it)!! }.sorted())
}
@Test
fun removingAnyPositionWhileShuffledKeepsTheCoordinatesConsistent() =
runTest {
seedLibrary()
for (seed in listOf(1L, 42L, 999L)) {
for (position in 0L..4L) {
repository.replace(listOf(repository.albumEntry(1, "al1")))
repository.setShuffle(seed)
repository.addUpNext(
repository.snapshot(),
currentPosition = 0,
entry = repository.songEntry(1, "s4"),
playNext = false,
)
val before = repository.snapshot()
if (position >= before.size) continue
repository.removeAt(before, position)
val after = repository.snapshot()
assertTrue("seed=$seed position=$position stopped being shuffled", after.shuffled)
assertEquals(before.size - 1, after.size)
val flats = (0 until after.contextSize).map { after.flatContext(it)!! }
assertEquals((0L until after.contextSize).toList(), flats.sorted())
for (play in 0 until after.contextSize) {
assertEquals(play, after.playContext(after.flatContext(play)!!))
}
for (p in 0 until after.size) {
if (after.isUpNext(p)) continue
assertEquals(p, after.combined(after.contextPlay(p)))
}
val block = (0 until after.size).filter { after.isUpNext(it) }
assertEquals(
(after.anchorPlay + 1..after.anchorPlay + after.upNextSize).toList(),
block,
)
val ids = (0 until after.size).mapNotNull { repository.itemAt(after, it)?.song?.id }
assertEquals(ids.distinct().size, ids.size)
}
}
assertEquals(before.contextSize, after.contextSize)
assertEquals(entriesBefore, after.entries.map { it.entry })
assertEquals(orderBefore, (0 until after.contextSize).mapNotNull { repository.itemAt(after, it)?.song?.id })
}
@Test
@@ -932,7 +1272,7 @@ class QueueRepositoryTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false)
val updated = repository.snapshot()
assertEquals(1L, updated.anchorPlay)
@@ -947,13 +1287,13 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
val before = repository.snapshot()
assertEquals(0L, before.anchorPlay)
assertEquals(3L, repository.combinedContextIndexOf(before, "s3"))
repository.setUpNextAnchor(2)
repository.reanchorFor(before, 3)
val after = repository.snapshot()
assertEquals(2L, after.anchorPlay)
@@ -966,7 +1306,7 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.songEntry(1, "s4")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
val updated = repository.snapshot()
assertEquals(0L, repository.combinedContextIndexOf(updated, "s4"))
@@ -1105,6 +1445,16 @@ class QueueRepositoryTest {
created = 0,
)
private suspend fun setPlaylist(
id: String,
vararg songIds: String,
) {
db.libraryDao().deletePlaylistSongsFrom(1, id, 0)
db.libraryDao().upsertPlaylistSongs(
songIds.mapIndexed { position, songId -> PlaylistSong(1, id, songId, position.toLong()) },
)
}
private suspend fun seedLibrary() {
db.sourcesDao().upsertSource(
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
@@ -101,7 +101,7 @@ class SyncManagerTest {
}
@Test
fun aFailedSyncStillInvalidatesTheLibraryCache() =
fun aFailedSyncDoesNotAdvanceTheQueueContentVersion() =
runBlocking {
val before = queueRepository.snapshot().version
@@ -109,7 +109,7 @@ class SyncManagerTest {
val status = withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertTrue(status is SyncStatus.Failed)
assertEquals(before + 1, queueRepository.snapshot().version)
assertEquals(before, queueRepository.snapshot().version)
}
@Test
@@ -36,6 +36,9 @@ import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.repo.removeAt
import com.subtracks.data.repo.replace
import com.subtracks.data.repo.setShuffle
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
@@ -55,7 +58,6 @@ import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
@@ -2116,40 +2118,28 @@ class PlaybackControllerTest {
}
@Test
fun removingATrackWhileShuffledKeepsTheCurrentTrackPlaying() {
fun removingATrackWhileShuffledIsRejected() {
seedAlbum(6, sourceId = 1)
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
controller.toggleShuffle()
await { controller.state.value.shuffle }
val currentId =
controller.state.value.item!!
.id
val removeAt = (controller.state.value.position!! + 1) % 6
val before = runBlocking { queues.snapshot() }
val itemsBefore = handle.items.map { it.id }
runBlocking { controller.removeAt(removeAt) }
await {
controller.state.value.item
?.id == currentId && controller.state.value.shuffle
}
runBlocking { controller.removeAt((controller.state.value.position!! + 1) % 6) }
assertTrue(handle.items.map { it.id }.contains(currentId))
val remaining =
runBlocking {
val snap = queues.snapshot()
(0 until snap.size).mapNotNull { queues.itemAt(snap, it)?.song?.id }
}
assertEquals(5, remaining.size)
assertEquals(5, remaining.distinct().size)
val after = runBlocking { queues.snapshot() }
assertEquals(before.contextSize, after.contextSize)
assertEquals(before.entries.map { it.entry }, after.entries.map { it.entry })
assertEquals(itemsBefore, handle.items.map { it.id })
}
@Test
fun removingThePlayingTrackWhileShuffledLeavesTheBlockReachable() {
fun removingThePlayingTrackWhileShuffledLeavesTheBlockUnchanged() {
seedAlbum(6, sourceId = 1)
seedSong("x1", "al2")
runBlocking {
@@ -2161,30 +2151,24 @@ class PlaybackControllerTest {
await { controller.state.value.item != null && controller.state.value.shuffle }
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
val before = runBlocking { queues.snapshot() }
val currentId = handle.currentItem?.id
val current = controller.state.value.position!!
runBlocking { controller.removeAt(current) }
runBlocking { controller.removeAt(controller.state.value.position!!) }
// The block was queued behind the removed track, so it takes the slot and plays now.
await {
controller.state.value.item
?.id == "x1"
}
val after = runBlocking { queues.snapshot() }
assertTrue(after.shuffled)
assertEquals(
"x1",
runBlocking { queues.itemAt(after, controller.state.value.position!!)?.song?.id },
)
assertEquals(before.entries.map { it.entry }, after.entries.map { it.entry })
assertEquals(before.upNextAnchor, after.upNextAnchor)
assertEquals(1L, after.upNextSize)
assertEquals(currentId, handle.currentItem?.id)
}
@Test
fun removingTheAnchorWhilePlayingABlockTrackKeepsTheBlockInPlace() {
fun removingTheAnchorWhilePlayingABlockTrackIsRejected() {
seedShuffledQueue(seed = 5L)
seedSong("x1", "al2")
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
controller.playAt(1)
await {
controller.state.value.item
@@ -2195,8 +2179,9 @@ class PlaybackControllerTest {
runBlocking { controller.removeAt(before.anchorPlay) }
val after = runBlocking { queues.snapshot() }
assertEquals(-1L, after.anchorPlay)
assertEquals("x1", runBlocking { queues.itemAt(after, 0)?.song?.id })
assertEquals(before.upNextAnchor, after.upNextAnchor)
assertEquals(before.contextSize, after.contextSize)
assertEquals("x1", runBlocking { queues.itemAt(after, 1)?.song?.id })
assertEquals(
"x1",
controller.state.value.item
@@ -2205,20 +2190,18 @@ class PlaybackControllerTest {
}
@Test
fun removingThePlayingTrackWhileShuffledPlaysTheNextShuffledTrack() {
fun removingThePlayingTrackWhileShuffledDoesNotAdvance() {
seedShuffledQueue(seed = 12345L)
val orderBefore = resolvedIds()
val current = controller.state.value.position!!
val expected = orderBefore.toMutableList().apply { removeAt(current.toInt()) }[current.toInt()]
val currentId = handle.currentItem?.id
val before = runBlocking { queues.snapshot() }
runBlocking { controller.removeAt(current) }
runBlocking { controller.removeAt(controller.state.value.position!!) }
await {
controller.state.value.item
?.id == expected
}
val after = runBlocking { queues.snapshot() }
assertEquals(expected, runBlocking { queues.itemAt(after, controller.state.value.position!!)?.song?.id })
assertEquals(before.entries.map { it.entry }, after.entries.map { it.entry })
assertEquals(currentId, handle.currentItem?.id)
assertEquals(orderBefore, resolvedIds())
}
@Test
@@ -2275,73 +2258,6 @@ class PlaybackControllerTest {
assertEquals("x1", runBlocking { queues.itemAt(after, after.anchorPlay + 2)?.song?.id })
}
@Test
fun undoingARemovalRestoresTheShuffledOrder() {
seedShuffledQueue(seed = 42L)
val orderBefore = resolvedIds()
val currentPosition = controller.state.value.position!!
val before = runBlocking { queues.snapshot() }
val removePosition = (0 until before.size).first { it != currentPosition }
runBlocking { controller.removeAt(removePosition) }
assertNotEquals(orderBefore, resolvedIds())
runBlocking { controller.undo() }
await { controller.state.value.shuffle && resolvedIds() == orderBefore }
assertEquals(orderBefore, resolvedIds())
assertEquals(currentPosition, controller.state.value.position)
}
@Test
fun removingANonCurrentTrackWhileShuffledKeepsTheCursorOnThePlayingTrack() {
seedShuffledQueue(seed = 12345L)
val currentId =
controller.state.value.item!!
.id
val before = runBlocking { queues.snapshot() }
val currentPosition = controller.state.value.position!!
val removePosition = (0 until before.size).first { it != currentPosition }
runBlocking { controller.removeAt(removePosition) }
val after = runBlocking { queues.snapshot() }
assertEquals(
currentId,
controller.state.value.item
?.id,
)
assertEquals(currentId, runBlocking { queues.itemAt(after, controller.state.value.position!!)?.song?.id })
}
@Test
fun removingANonCurrentTrackWhileShuffledKeepsTheBlockNext() {
seedAlbum(6, sourceId = 1)
seedSong("x1", "al2")
runBlocking {
queues.replace(listOf(queues.albumEntry(1, "al1")))
queues.setShuffle(999L)
queues.setCursor(0)
}
controller.connect()
await { controller.state.value.item != null && controller.state.value.shuffle }
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
val currentId =
controller.state.value.item!!
.id
val before = runBlocking { queues.snapshot() }
val currentPosition = controller.state.value.position!!
val removePosition = (0 until before.size).first { !before.isUpNext(it) && it != currentPosition }
runBlocking { controller.removeAt(removePosition) }
val after = runBlocking { queues.snapshot() }
assertEquals(currentId, runBlocking { queues.itemAt(after, controller.state.value.position!!)?.song?.id })
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
}
@Test
fun movingContextTracksWhileShuffledIsIgnored() {
seedAlbum(6, sourceId = 1)
@@ -2799,39 +2715,6 @@ class PlaybackControllerTest {
)
}
@Test
fun removingAContextTrackWhileShuffledKeepsThePlayingTrackAndTheBlock() {
seedAlbum(6, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
controller.toggleShuffle()
await { controller.state.value.shuffle && runBlocking { controller.upcomingItem()?.id } == "x1" }
val currentId =
controller.state.value.item!!
.id
val before = runBlocking { queues.snapshot() }
val currentPosition = controller.state.value.position!!
val removePosition = (0 until before.size).first { !before.isUpNext(it) && it != currentPosition }
runBlocking { controller.removeAt(removePosition) }
await {
val snap = runBlocking { queues.snapshot() }
controller.state.value.item
?.id == currentId &&
runBlocking { queues.itemAt(snap, controller.state.value.position!!)?.song?.id } == currentId
}
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
}
@Test
fun shufflingTheSameAlbumAgainPicksADifferentTrack() {
seedAlbum(5, sourceId = 1)
@@ -21,6 +21,8 @@ import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.repo.replace
import com.subtracks.data.repo.setShuffle
import com.subtracks.playback.FakePlayerConnection
import com.subtracks.playback.FakePlayerHandle
import com.subtracks.playback.PlaybackController