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
File diff suppressed because it is too large Load Diff
@@ -161,7 +161,7 @@ interface DownloadDao {
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>> fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
@Query( @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.artistId AS albumArtistId, " +
"al.id AS albumRow, al.coverArt AS albumCoverArt, " + "al.id AS albumRow, al.coverArt AS albumCoverArt, " +
"ar.id AS artistRow, ar.coverArt AS artistCoverArt, " + "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 { internal fun artistSearchTriggersOptimized(connection: SQLiteConnection): Boolean {
val triggers = mutableListOf<String>() val triggers = mutableListOf<String>()
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?)").use { statement -> 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_35_36,
MIGRATION_36_37, MIGRATION_36_37,
MIGRATION_37_38, MIGRATION_37_38,
MIGRATION_38_39,
) )
@@ -5,6 +5,7 @@ import androidx.room3.Insert
import androidx.room3.OnConflictStrategy import androidx.room3.OnConflictStrategy
import androidx.room3.Query import androidx.room3.Query
import com.subtracks.data.model.AlbumSongItem import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.PendingSyncOrigin
import com.subtracks.data.model.PlaybackCursor import com.subtracks.data.model.PlaybackCursor
import com.subtracks.data.model.PlaylistSongItem import com.subtracks.data.model.PlaylistSongItem
import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueEntry
@@ -37,6 +38,12 @@ private const val SONG_SQL =
@Dao @Dao
interface QueueDao { 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") @Query("DELETE FROM queue_entries")
suspend fun clear() suspend fun clear()
@@ -243,6 +250,44 @@ interface QueueDao {
songId: String, songId: String,
): Long? ): 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") @Query("SELECT COUNT(*) FROM song_genres WHERE sourceId = :sourceId AND genre = :genre")
suspend fun genreLength( suspend fun genreLength(
sourceId: Long, sourceId: Long,
@@ -263,7 +308,11 @@ interface QueueDao {
limit: Int, limit: Int,
): List<AlbumSongItem> ): 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 suspend fun downloadedLength(sourceId: Long): Long
@Query("SELECT COUNT(*) FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL") @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.DownloadStatusConverter
import com.subtracks.data.model.PendingAction import com.subtracks.data.model.PendingAction
import com.subtracks.data.model.PendingActionKindConverter import com.subtracks.data.model.PendingActionKindConverter
import com.subtracks.data.model.PendingSyncOrigin
import com.subtracks.data.model.PlaybackCursor import com.subtracks.data.model.PlaybackCursor
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSearch import com.subtracks.data.model.PlaylistSearch
@@ -56,8 +57,9 @@ import com.subtracks.data.model.UpNextEntry
ArtworkSeed::class, ArtworkSeed::class,
SongDownload::class, SongDownload::class,
PendingAction::class, PendingAction::class,
PendingSyncOrigin::class,
], ],
version = 38, version = 39,
exportSchema = true, exportSchema = true,
) )
@ColumnTypeConverters( @ColumnTypeConverters(
@@ -38,12 +38,6 @@ enum class DownloadError(
childColumns = ["sourceId"], childColumns = ["sourceId"],
onDelete = ForeignKey.CASCADE, onDelete = ForeignKey.CASCADE,
), ),
ForeignKey(
entity = Song::class,
parentColumns = ["sourceId", "id"],
childColumns = ["sourceId", "songId"],
onDelete = ForeignKey.CASCADE,
),
], ],
indices = [Index("sourceId"), Index("engineId")], indices = [Index("sourceId"), Index("engineId")],
) )
@@ -109,6 +103,7 @@ data class DownloadedSong(
data class DownloadArtwork( data class DownloadArtwork(
val sourceId: Long, val sourceId: Long,
val songRow: String?,
val albumId: String?, val albumId: String?,
val artistId: String?, val artistId: String?,
val albumArtistId: String?, val albumArtistId: String?,
@@ -119,7 +114,7 @@ data class DownloadArtwork(
val albumArtistCoverArt: String?, val albumArtistCoverArt: String?,
) { ) {
val hasMissingLibraryRow: Boolean 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( data class SourceCoverArt(
@@ -448,6 +448,31 @@ data class DiscKey(
enum class QueueKind { Playlist, Album, Song, Artist, Genre, Downloaded, Starred } 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( @Entity(
tableName = "queue_entries", tableName = "queue_entries",
foreignKeys = [ foreignKeys = [
@@ -468,10 +493,15 @@ data class QueueEntry(
val refId: String, val refId: String,
val rangeStart: Long? = null, val rangeStart: Long? = null,
val rangeEnd: 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 offset: Long get() = rangeStart ?: 0
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) } val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
val origin: QueueOrigin get() = QueueOrigin(originKind ?: kind, sourceId, originRefId ?: refId)
} }
@Entity( @Entity(
@@ -494,16 +524,31 @@ data class UpNextEntry(
val refId: String, val refId: String,
val rangeStart: Long? = null, val rangeStart: Long? = null,
val rangeEnd: 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 offset: Long get() = rangeStart ?: 0
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(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 { companion object {
fun from(entry: QueueEntry) = 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.PlaybackCursor
import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.QueueOrigin
import com.subtracks.data.model.SongItem import com.subtracks.data.model.SongItem
import com.subtracks.data.model.UpNextEntry import com.subtracks.data.model.UpNextEntry
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -36,12 +37,6 @@ private data class LoadedSnapshot(
val anchor: Long, val anchor: Long,
) )
private data class QueueRef(
val kind: QueueKind,
val sourceId: Long,
val refId: String,
)
private data class OrderKey( private data class OrderKey(
val id: String, val id: String,
val disc: Long? = null, val disc: Long? = null,
@@ -59,58 +54,13 @@ data class ResolvedQueueEntry(
val length: Long, 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( class ShuffleState(
val seed: Long, val seed: Long,
val domain: Long, val domain: Long,
private val removedFlats: LongArray = LongArray(0),
private val removedSlots: LongArray = LongArray(0),
) { ) {
fun flatFor(play: Long): Long { fun flatFor(play: Long): Long = Shuffle.toFlat(seed, domain, play)
val domainFlat = Shuffle.toFlat(seed, domain, liveSlot(play))
return domainFlat - removedFlats.count { it < domainFlat }
}
fun playFor(canonical: Long): Long { fun playFor(canonical: Long): Long = Shuffle.toSequence(seed, domain, canonical)
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
}
}
} }
data class QueueSnapshot( data class QueueSnapshot(
@@ -119,6 +69,7 @@ data class QueueSnapshot(
val shuffle: ShuffleState? = null, val shuffle: ShuffleState? = null,
val upNextAnchor: Long = 0, val upNextAnchor: Long = 0,
val version: Long = 0, val version: Long = 0,
val resetVersion: Long = 0,
) { ) {
val contextSize: Long = entries.sumOf { it.length } val contextSize: Long = entries.sumOf { it.length }
@@ -202,11 +153,33 @@ data class QueueWindowItem(
val item: SongItem, val item: SongItem,
val playlistPosition: Long? = null, val playlistPosition: Long? = null,
val upNext: Boolean = false, val upNext: Boolean = false,
val resetItem: QueueResetItem? = null,
) )
data class Reanchored( data class Reanchored(
val position: Long, val position: Long,
val changed: Boolean, 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 { private interface QueueKindResolver {
@@ -411,13 +384,23 @@ class QueueRepository(
private val dao get() = db.queueDao() private val dao get() = db.queueDao()
private val queueVersion = AtomicLong() 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 fun setResetListener(listener: suspend QueueMutationScope.(QueueReset) -> Unit) {
private var shuffleRemoved: ShuffleState? = null resetListener = listener
}
suspend fun <T> withMutationLock(block: suspend QueueMutationScope.() -> T): T =
queueMutex.withLock {
QueueMutationScope().block()
}
private val lengthCache = ConcurrentHashMap<EntryLengthKey, Long>() private val lengthCache = ConcurrentHashMap<EntryLengthKey, Long>()
private val keyCache = ConcurrentHashMap<QueueRef, KeyCursor>() private val keyCache = ConcurrentHashMap<QueueOrigin, KeyCursor>()
internal var keysetSeeks = 0L internal var keysetSeeks = 0L
private set private set
@@ -428,7 +411,7 @@ class QueueRepository(
internal var resolvedRowCount = 0L internal var resolvedRowCount = 0L
private set private set
private val cursorMutex = Mutex() internal val cursorMutex = Mutex()
fun playlistEntry( fun playlistEntry(
sourceId: Long, sourceId: Long,
@@ -514,51 +497,46 @@ class QueueRepository(
} }
} }
suspend fun replace( private suspend fun replaceLocked(
entries: List<QueueEntry>, entries: List<QueueEntry>,
shuffleSeed: Long? = null, shuffleSeed: Long?,
upNext: List<QueueEntry> = emptyList(), upNext: List<QueueEntry>,
upNextAnchor: Long = 0, upNextAnchor: Long,
) { ) {
write(entries) invalidateLibraryCache()
writeUpNext(upNext) writeLocked(entries)
setShuffle(shuffleSeed) writeUpNextLocked(upNext)
setShuffleLocked(shuffleSeed)
cursorMutex.withLock { cursorMutex.withLock {
dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0, upNextAnchor = upNextAnchor)) dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0, upNextAnchor = upNextAnchor))
} }
} }
suspend fun snapshot(): QueueSnapshot { suspend fun snapshot(): QueueSnapshot = withMutationLock { snapshot() }
val version = queueVersion.get()
private suspend fun snapshotLocked(): QueueSnapshot {
val libraryRevision = libraryVersion.get()
val loaded = val loaded =
db.useReaderConnection { transactor -> db.useReaderConnection { transactor ->
transactor.deferredTransaction { transactor.deferredTransaction {
val entries = dao.entries().map { ResolvedQueueEntry(it, lengthOf(it, version)) } val entries = dao.entries().map { ResolvedQueueEntry(it, lengthOf(it, libraryRevision)) }
val upNext = val upNext =
dao.upNextEntries().map { dao.upNextEntries().map {
val entry = it.toQueueEntry() val entry = it.toQueueEntry()
ResolvedQueueEntry(entry, lengthOf(entry, version)) ResolvedQueueEntry(entry, lengthOf(entry, libraryRevision))
} }
val row = dao.cursor() val row = dao.cursor()
val size = entries.sumOf { it.length } val size = entries.sumOf { it.length }
val state = shuffleRemoved
val shuffle = val shuffle =
if (row != null && row.shuffleEnabled && size > 0L) { if (row != null && row.shuffleEnabled && row.shuffleSize == size && size > 0L) {
when { ShuffleState(row.shuffleSeed, row.shuffleSize)
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
}
} else { } else {
null null
} }
LoadedSnapshot(entries, upNext, shuffle, row?.upNextAnchor ?: 0L) 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 { suspend fun modes(): QueueModes {
@@ -566,7 +544,7 @@ class QueueRepository(
return QueueModes(row.shuffleEnabled, row.repeatMode) 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 val size = if (seed != null) contextSize() else 0L
cursorMutex.withLock { cursorMutex.withLock {
dao.setCursor( dao.setCursor(
@@ -577,14 +555,274 @@ class QueueRepository(
), ),
) )
} }
shuffleRemoved = seed?.let { ShuffleState(it, size) } queueVersion.incrementAndGet()
} }
fun contentVersion(): Long = queueVersion.get()
fun invalidateLibraryCache() { fun invalidateLibraryCache() {
lengthCache.clear() lengthCache.clear()
keyCache.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() 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)) } 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)) return range(snapshot, (center - radius).coerceAtLeast(0), (center + radius).coerceAtMost(snapshot.size - 1))
} }
suspend fun addUpNext( private suspend fun addUpNextLocked(
snapshot: QueueSnapshot,
currentPosition: Long, currentPosition: Long,
entry: QueueEntry, entry: QueueEntry,
playNext: Boolean, playNext: Boolean,
): Long { ): Long {
val list = snapshot.upNext.map { it.entry }.toMutableList() invalidateLibraryCache()
val current = snapshotLocked()
val list = current.upNext.map { it.entry }.toMutableList()
val insertIndex = val insertIndex =
when { when {
playNext && snapshot.isUpNext(currentPosition) -> { playNext && current.isUpNext(currentPosition) -> {
entryIndexAfter(snapshot.upNext, snapshot.upNextIndex(currentPosition)) entryIndexAfter(current.upNext, current.upNextIndex(currentPosition))
} }
playNext -> { playNext -> {
@@ -667,75 +906,54 @@ class QueueRepository(
}.coerceIn(0, list.size) }.coerceIn(0, list.size)
list.add(insertIndex, entry.copy(id = 0, position = 0)) list.add(insertIndex, entry.copy(id = 0, position = 0))
val anchor = val anchor =
if (snapshot.upNextSize == 0L) { if (current.upNextSize == 0L) {
val play = snapshot.anchorContextPlay(currentPosition) val play = current.anchorContextPlay(currentPosition)
snapshot.flatContext(play) ?: 0L current.flatContext(play) ?: 0L
} else { } else {
snapshot.upNextAnchor current.upNextAnchor
} }
writeUpNext(list) writeUpNextLocked(list)
cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = anchor)) } cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = anchor)) }
if (snapshot.upNextSize == 0L) return currentPosition if (current.upNextSize == 0L) return currentPosition
val before = snapshot.upNext.take(insertIndex).sumOf { it.length } val before = current.upNext.take(insertIndex).sumOf { it.length }
return if (currentPosition >= snapshot.anchorPlay + 1 + before) currentPosition + 1 else currentPosition return if (currentPosition >= current.anchorPlay + 1 + before) currentPosition + 1 else currentPosition
} }
suspend fun reanchorFor( private suspend fun reanchorForLocked(
snapshot: QueueSnapshot, snapshot: QueueSnapshot,
target: Long, target: Long,
): Reanchored { ): Reanchored {
if (snapshot.version != queueVersion.get()) return Reanchored(target, false, stale = true)
if (snapshot.upNextSize == 0L || snapshot.isUpNext(target)) return Reanchored(target, false) if (snapshot.upNextSize == 0L || snapshot.isUpNext(target)) return Reanchored(target, false)
val play = snapshot.contextPlay(target) val play = snapshot.contextPlay(target)
val flat = snapshot.flatContext(play) ?: return Reanchored(target, false) val flat = snapshot.flatContext(play) ?: return Reanchored(target, false)
if (flat == snapshot.upNextAnchor) return Reanchored(target, false) if (flat == snapshot.upNextAnchor) return Reanchored(target, false)
cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = flat)) } cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = flat)) }
queueVersion.incrementAndGet()
return Reanchored(play, true) return Reanchored(play, true)
} }
suspend fun setUpNextAnchor(anchor: Long) = cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = anchor)) } private suspend fun removeUpNextAtLocked(
suspend fun clearUpNext() {
writeUpNext(emptyList())
}
suspend fun removeUpNextAt(
snapshot: QueueSnapshot, snapshot: QueueSnapshot,
index: Long, index: Long,
) { ): Boolean {
if (index !in 0 until snapshot.upNextSize) return if (index !in 0 until snapshot.upNextSize) return false
writeUpNext(compact(removeEntry(snapshot.upNext, index)).map { it.entry }) writeUpNextLocked(compact(removeEntry(snapshot.upNext, index)).map { it.entry })
return true
} }
suspend fun removeAt( private suspend fun removeAtLocked(
snapshot: QueueSnapshot, snapshot: QueueSnapshot,
position: Long, position: Long,
) { ): Boolean {
if (snapshot.isUpNext(position)) { if (snapshot.version != queueVersion.get()) return false
removeUpNextAt(snapshot, snapshot.upNextIndex(position)) if (snapshot.isUpNext(position)) return removeUpNextAtLocked(snapshot, snapshot.upNextIndex(position))
return if (snapshot.shuffled) return false
}
val play = snapshot.contextPlay(position) 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)) val removed = compact(removeEntry(snapshot.entries, play))
write(removed.map { it.entry }) writeLocked(removed.map { it.entry })
adjustAnchorOnRemove(play) adjustAnchorOnRemove(play)
} return true
} }
private suspend fun adjustAnchorOnRemove(removedFlat: Long) = private suspend fun adjustAnchorOnRemove(removedFlat: Long) =
@@ -765,19 +983,21 @@ class QueueRepository(
if (moved != anchor) dao.setCursor(row.copy(upNextAnchor = moved)) if (moved != anchor) dao.setCursor(row.copy(upNextAnchor = moved))
} }
suspend fun move( private suspend fun moveLocked(
snapshot: QueueSnapshot, snapshot: QueueSnapshot,
from: Long, from: Long,
to: Long, to: Long,
): Boolean { ): Boolean {
if (snapshot.version != queueVersion.get()) return false
val fromUpNext = snapshot.isUpNext(from) val fromUpNext = snapshot.isUpNext(from)
val toUpNext = snapshot.isUpNext(to) val toUpNext = snapshot.isUpNext(to)
if (fromUpNext && toUpNext) { if (fromUpNext && toUpNext) {
val song = itemAt(snapshot, from) ?: return false val song = itemAt(snapshot, from) ?: return false
val located = snapshot.locate(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 removed = removeEntry(snapshot.upNext, snapshot.upNextIndex(from))
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)
writeUpNext(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry }) writeUpNextLocked(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry })
return true return true
} }
if (fromUpNext != toUpNext) return false if (fromUpNext != toUpNext) return false
@@ -789,10 +1009,11 @@ class QueueRepository(
val playTo = snapshot.contextPlay(to) val playTo = snapshot.contextPlay(to)
val located = snapshot.locateContext(playFrom) ?: return false val located = snapshot.locateContext(playFrom) ?: return false
val song = itemAt(snapshot, from) ?: return false val song = itemAt(snapshot, from) ?: return false
val resetItem = resetItemAt(snapshot, from, item = song)
val removed = removeEntry(snapshot.entries, playFrom) 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 }) 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) adjustAnchorOnMove(playFrom, insertAt)
return true return true
} }
@@ -816,7 +1037,7 @@ class QueueRepository(
private suspend fun contextSize(): Long = dao.entries().sumOf { it.resolvedLength() } 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 -> db.useWriterConnection { transactor ->
transactor.immediateTransaction { transactor.immediateTransaction {
dao.clear() dao.clear()
@@ -826,7 +1047,7 @@ class QueueRepository(
queueVersion.incrementAndGet() queueVersion.incrementAndGet()
} }
private suspend fun writeUpNext(entries: List<QueueEntry>) { private suspend fun writeUpNextLocked(entries: List<QueueEntry>) {
db.useWriterConnection { transactor -> db.useWriterConnection { transactor ->
transactor.immediateTransaction { transactor.immediateTransaction {
dao.clearUpNext() dao.clearUpNext()
@@ -840,6 +1061,51 @@ class QueueRepository(
queueVersion.incrementAndGet() 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( private suspend fun flatIndexOf(
entries: List<ResolvedQueueEntry>, entries: List<ResolvedQueueEntry>,
songId: String, songId: String,
@@ -887,7 +1153,14 @@ class QueueRepository(
for (play in first..last) { for (play in first..last) {
val located = snapshot.locateContext(play) ?: continue val located = snapshot.locateContext(play) ?: continue
val item = rows(located.first, located.first.offset + located.second, 1).firstOrNull() ?: 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 return items
} }
@@ -901,7 +1174,14 @@ class QueueRepository(
val to = minOf(last, end) val to = minOf(last, end)
val offset = resolved.entry.offset + (from - entryStart) val offset = resolved.entry.offset + (from - entryStart)
rows(resolved.entry, offset, (to - from + 1).toInt()).forEachIndexed { index, item -> 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 return items
@@ -924,12 +1204,14 @@ class QueueRepository(
val to = minOf(last, end) val to = minOf(last, end)
val offset = resolved.entry.offset + (from - entryStart) val offset = resolved.entry.offset + (from - entryStart)
rows(resolved.entry, offset, (to - from + 1).toInt()).forEachIndexed { index, item -> rows(resolved.entry, offset, (to - from + 1).toInt()).forEachIndexed { index, item ->
val position = snapshot.anchorPlay + 1 + from + index
items += items +=
QueueWindowItem( QueueWindowItem(
snapshot.anchorPlay + 1 + from + index, position,
item, item,
playlistPosition = item.playlistPosition, playlistPosition = item.playlistPosition,
upNext = true, upNext = true,
resetItem = resetItemAt(snapshot, position, item = item),
) )
} }
} }
@@ -945,7 +1227,7 @@ class QueueRepository(
offsetSeeks++ offsetSeeks++
return resolverFor(entry.kind).rows(dao, entry, offset, limit).also { resolvedRowCount += it.size } 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) } val anchored = keyCache[ref]?.let { keyedRows(entry, offset, limit, it) }
if (anchored != null) { if (anchored != null) {
keysetSeeks++ keysetSeeks++
@@ -961,7 +1243,7 @@ class QueueRepository(
} }
private fun remember( private fun remember(
ref: QueueRef, ref: QueueOrigin,
offset: Long, offset: Long,
resolved: List<SongItem>, resolved: List<SongItem>,
) { ) {
@@ -1056,12 +1338,17 @@ class QueueRepository(
source: QueueEntry, source: QueueEntry,
songId: String, songId: String,
offsetInEntry: Long, offsetInEntry: Long,
resetItem: QueueResetItem?,
): QueueEntry = ): QueueEntry =
if (source.kind == QueueKind.Album) { if (source.kind == QueueKind.Album) {
val offset = source.offset + offsetInEntry val offset = source.offset + offsetInEntry
source.range(offset, offset) source.range(offset, offset)
} else { } 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( private fun QueueEntry.range(
@@ -299,7 +299,7 @@ class PlaybackController(
return@withLock return@withLock
} }
val start = randomIndex(snap.contextSize, avoid) val start = randomIndex(snap.contextSize, avoid)
queueRepository.setShuffle(pinnedSeed(snap.contextSize, start)) queueRepository.withMutationLock { setShuffle(pinnedSeed(snap.contextSize, start)) }
shuffleEnabled = true shuffleEnabled = true
queueSourceId = entry.sourceId queueSourceId = entry.sourceId
queueRepository.setCursor(0) queueRepository.setCursor(0)
@@ -314,7 +314,7 @@ class PlaybackController(
windowEnd = -1 windowEnd = -1
lastEdit = null lastEdit = null
endedHandled = false endedHandled = false
queueRepository.replace(listOf(entry)) queueRepository.withMutationLock { replace(listOf(entry)) }
val snapshot = queueRepository.snapshot() val snapshot = queueRepository.snapshot()
this.snapshot = snapshot this.snapshot = snapshot
return snapshot return snapshot
@@ -350,7 +350,7 @@ class PlaybackController(
val player = player ?: return@withLock val player = player ?: return@withLock
var snapshot = snapshot ?: return@withLock var snapshot = snapshot ?: return@withLock
if (snapshot.size == 0L) return@withLock if (snapshot.size == 0L) return@withLock
val anchor = queueRepository.reanchorFor(snapshot, position) val anchor = queueRepository.withMutationLock { reanchorFor(snapshot, position) }
if (anchor.changed) { if (anchor.changed) {
snapshot = readSnapshot() snapshot = readSnapshot()
this@PlaybackController.snapshot = snapshot this@PlaybackController.snapshot = snapshot
@@ -385,11 +385,12 @@ class PlaybackController(
val snapshot = snapshot ?: return@withLock val snapshot = snapshot ?: return@withLock
if (position !in 0 until snapshot.size) return@withLock if (position !in 0 until snapshot.size) return@withLock
val player = player ?: return@withLock val player = player ?: return@withLock
if (snapshot.shuffled && !snapshot.isUpNext(position)) return@withLock
val cursor = queueRepository.cursor() val cursor = queueRepository.cursor()
val entries = snapshot.entries.map { it.entry } val entries = snapshot.entries.map { it.entry }
val upNext = snapshot.upNext.map { it.entry } val upNext = snapshot.upNext.map { it.entry }
if (snapshot.size == 1L) { if (snapshot.size == 1L) {
queueRepository.removeAt(snapshot, position) queueRepository.withMutationLock { removeAt(snapshot, position) }
stopLocked() stopLocked()
lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor) lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
return@withLock return@withLock
@@ -398,7 +399,7 @@ class PlaybackController(
val playing = player.playWhenReady val playing = player.playWhenReady
val wasUpNext = snapshot.isUpNext(current) val wasUpNext = snapshot.isUpNext(current)
val removed = if (position == current) null else queueItemAt(snapshot, position) val removed = if (position == current) null else queueItemAt(snapshot, position)
queueRepository.removeAt(snapshot, position) queueRepository.withMutationLock { removeAt(snapshot, position) }
var updated = readSnapshot() var updated = readSnapshot()
this.snapshot = updated this.snapshot = updated
windowJob?.cancel() 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 // 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. // track that no longer carries the block (its anchor moved), so re-anchor it there.
if (updated.upNextSize > 0L && !updated.isUpNext(target)) { if (updated.upNextSize > 0L && !updated.isUpNext(target)) {
val reanchored = queueRepository.reanchorFor(updated, target) val reanchored = queueRepository.withMutationLock { reanchorFor(updated, target) }
if (reanchored.changed) { if (reanchored.changed) {
updated = readSnapshot() updated = readSnapshot()
this.snapshot = updated this.snapshot = updated
@@ -506,7 +507,7 @@ class PlaybackController(
val entries = snapshot.entries.map { it.entry } val entries = snapshot.entries.map { it.entry }
val upNext = snapshot.upNext.map { it.entry } val upNext = snapshot.upNext.map { it.entry }
val wasUpNext = snapshot.isUpNext(current) 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() val updated = readSnapshot()
this.snapshot = updated this.snapshot = updated
// The current track still exists, so derive its new position from the edit instead of // 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 player = player ?: return@withLock
val playing = player.playWhenReady val playing = player.playWhenReady
val positionMs = player.currentPositionMs 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 shuffleEnabled = undo.shuffleSeed != null
val restored = readSnapshot() val restored = readSnapshot()
this.snapshot = restored this.snapshot = restored
@@ -725,7 +726,7 @@ class PlaybackController(
val snapshot = snapshot ?: return@withLock val snapshot = snapshot ?: return@withLock
if (!snapshot.isUpNext(position)) return@withLock if (!snapshot.isUpNext(position)) return@withLock
if (position >= snapshot.size - 1 && repeatMode == RepeatMode.Off) 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() val updated = readSnapshot()
this.snapshot = updated this.snapshot = updated
layoutVersion++ layoutVersion++
@@ -758,7 +759,7 @@ class PlaybackController(
player ?: return@withLock player ?: return@withLock
val current = currentPosition() ?: return@withLock val current = currentPosition() ?: return@withLock
val wasUpNext = snapshot.isUpNext(current) val wasUpNext = snapshot.isUpNext(current)
queueRepository.clearUpNext() queueRepository.withMutationLock { clearUpNext() }
val updated = readSnapshot() val updated = readSnapshot()
this@PlaybackController.snapshot = updated this@PlaybackController.snapshot = updated
layoutVersion++ layoutVersion++
@@ -861,7 +862,7 @@ class PlaybackController(
val player = player ?: return@withLock val player = player ?: return@withLock
if (snapshot.size == 0L) return@withLock if (snapshot.size == 0L) return@withLock
val current = currentPosition() ?: 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() val updated = readSnapshot()
this.snapshot = updated this.snapshot = updated
layoutVersion++ layoutVersion++
@@ -898,9 +899,9 @@ class PlaybackController(
windowJob?.cancel() windowJob?.cancel()
val enabling = !shuffleEnabled val enabling = !shuffleEnabled
if (enabling) { if (enabling) {
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, flat)) queueRepository.withMutationLock { setShuffle(pinnedSeed(snapshot.contextSize, flat)) }
} else { } else {
queueRepository.setShuffle(null) queueRepository.withMutationLock { setShuffle(null) }
} }
shuffleEnabled = enabling shuffleEnabled = enabling
val reordered = readSnapshot() val reordered = readSnapshot()
@@ -911,7 +912,7 @@ class PlaybackController(
enabling -> reordered.combined(0L) enabling -> reordered.combined(0L)
else -> reordered.combined(flat) 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 val finalSnapshot = if (anchor.changed) readSnapshot() else reordered
if (anchor.changed) layoutVersion++ if (anchor.changed) layoutVersion++
this@PlaybackController.snapshot = finalSnapshot this@PlaybackController.snapshot = finalSnapshot
@@ -1028,7 +1029,7 @@ class PlaybackController(
val start = startPosition.coerceIn(0, snapshot.contextSize - 1) val start = startPosition.coerceIn(0, snapshot.contextSize - 1)
val position = val position =
if (shuffle) { if (shuffle) {
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, start)) queueRepository.withMutationLock { setShuffle(pinnedSeed(snapshot.contextSize, start)) }
0L 0L
} else { } else {
start start
@@ -1052,7 +1053,7 @@ class PlaybackController(
if (shuffleEnabled && !snapshot.shuffled && snapshot.contextSize > 0L) { if (shuffleEnabled && !snapshot.shuffled && snapshot.contextSize > 0L) {
val start = snapshot.anchorContextPlay(queueRepository.cursor()).coerceIn(0, snapshot.contextSize - 1) val start = snapshot.anchorContextPlay(queueRepository.cursor()).coerceIn(0, snapshot.contextSize - 1)
val flat = snapshot.flatContext(start) ?: start val flat = snapshot.flatContext(start) ?: start
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, flat)) queueRepository.withMutationLock { setShuffle(pinnedSeed(snapshot.contextSize, flat)) }
queueRepository.setCursor(0) queueRepository.setCursor(0)
snapshot = queueRepository.snapshot() snapshot = queueRepository.snapshot()
} else if (!snapshot.shuffled) { } else if (!snapshot.shuffled) {
@@ -1072,7 +1073,7 @@ class PlaybackController(
this.queueSourceId = queueSourceId this.queueSourceId = queueSourceId
val savedPositionMs = queueRepository.cursorPositionMs().coerceAtLeast(0) val savedPositionMs = queueRepository.cursorPositionMs().coerceAtLeast(0)
val cursor = queueRepository.cursor().coerceIn(0, snapshot.size - 1) 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) { if (anchor.changed) {
snapshot = queueRepository.snapshot() snapshot = queueRepository.snapshot()
this.snapshot = snapshot this.snapshot = snapshot
@@ -1211,7 +1212,7 @@ class PlaybackController(
val player = player ?: return@withLock val player = player ?: return@withLock
var snapshot = readSnapshot() var snapshot = readSnapshot()
if (snapshot.size == 0L) return@withLock if (snapshot.size == 0L) return@withLock
val anchor = queueRepository.reanchorFor(snapshot, target) val anchor = queueRepository.withMutationLock { reanchorFor(snapshot, target) }
if (anchor.changed) { if (anchor.changed) {
snapshot = readSnapshot() snapshot = readSnapshot()
layoutVersion++ layoutVersion++
@@ -1490,7 +1491,7 @@ class PlaybackController(
private suspend fun readSnapshot(): QueueSnapshot { private suspend fun readSnapshot(): QueueSnapshot {
val snapshot = queueRepository.snapshot() val snapshot = queueRepository.snapshot()
if (!snapshot.shuffled) { if (!snapshot.shuffled) {
if (shuffleEnabled && snapshot.size > 0L) queueRepository.setShuffle(null) if (shuffleEnabled && snapshot.size > 0L) queueRepository.withMutationLock { setShuffle(null) }
shuffleEnabled = false shuffleEnabled = false
} }
return snapshot return snapshot
@@ -313,14 +313,14 @@ class DownloadRepositoryTest {
} }
@Test @Test
fun aSongRemovedFromTheServerTakesItsDownloadRowWithIt() = fun aSongRemovedFromTheServerKeepsItsDownloadRowForRecovery() =
runTest { runTest {
seedLibrary() seedLibrary()
runBlocking { repository.download(1, "s1") } runBlocking { repository.download(1, "s1") }
runBlocking { db.libraryDao().deleteSongs(1, listOf("s1")) } runBlocking { db.libraryDao().deleteSongs(1, listOf("s1")) }
assertNull(row(1, "s1")) assertEquals(DownloadStatus.Queued, row(1, "s1")?.status)
} }
@Test @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.PlaylistSong
import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.QueueOrigin
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.yield
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@@ -79,6 +84,20 @@ class QueueRepositoryTest {
assertEquals(listOf("s1", "s3"), resolveAll(snapshot)) 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 @Test
fun starredEntryResolvesStarredTracksNewestFirst() = fun starredEntryResolvesStarredTracksNewestFirst() =
runTest { runTest {
@@ -416,28 +435,427 @@ class QueueRepositoryTest {
assertEquals(4, repository.snapshot().size) 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 @Test
fun upNextResolvesAfterTheCurrentTrack() = fun upNextResolvesAfterTheCurrentTrack() =
runTest { runTest {
seedLibrary() seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) 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() val updated = repository.snapshot()
assertEquals(4, updated.size) assertEquals(4, updated.size)
assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(updated)) 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 @Test
fun playNextGoesToTheFrontOfTheUpNextQueue() = fun playNextGoesToTheFrontOfTheUpNextQueue() =
runTest { runTest {
seedLibrary() seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) 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.addUpNext(
repository.snapshot(),
currentPosition = 0, currentPosition = 0,
entry = repository.songEntry(1, "s5"), entry = repository.songEntry(1, "s5"),
playNext = true, playNext = true,
@@ -451,7 +869,7 @@ class QueueRepositoryTest {
runTest { runTest {
seedLibrary() seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) 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) repository.removeAt(repository.snapshot(), 1)
@@ -465,9 +883,8 @@ class QueueRepositoryTest {
runTest { runTest {
seedLibrary() seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) 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.addUpNext(
repository.snapshot(),
currentPosition = 0, currentPosition = 0,
entry = repository.songEntry(1, "s5"), entry = repository.songEntry(1, "s5"),
playNext = false, playNext = false,
@@ -486,7 +903,7 @@ class QueueRepositoryTest {
runTest { runTest {
seedLibrary() seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) 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) repository.move(repository.snapshot(), from = 2, to = 1)
@@ -509,9 +926,8 @@ class QueueRepositoryTest {
), ),
) )
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al2"))) 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.addUpNext(
repository.snapshot(),
currentPosition = 0, currentPosition = 0,
entry = repository.songEntry(1, "x1"), entry = repository.songEntry(1, "x1"),
playNext = false, playNext = false,
@@ -562,7 +978,7 @@ class QueueRepositoryTest {
val shuffled = repository.snapshot() val shuffled = repository.snapshot()
val contextBefore = (0 until shuffled.contextSize).mapNotNull { repository.itemAt(shuffled, it)?.song?.id } 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() val updated = repository.snapshot()
assertEquals(4, updated.size) assertEquals(4, updated.size)
@@ -580,7 +996,7 @@ class QueueRepositoryTest {
runTest { runTest {
seedLibrary() seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) 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())) assertEquals(listOf("s1", "s4", "s5", "s2", "s3"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 2) repository.removeAt(repository.snapshot(), 2)
@@ -593,7 +1009,7 @@ class QueueRepositoryTest {
runTest { runTest {
seedLibrary() seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) 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())) assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 0) repository.removeAt(repository.snapshot(), 0)
@@ -606,7 +1022,7 @@ class QueueRepositoryTest {
runTest { runTest {
seedLibrary() seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) 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())) assertEquals(listOf("s1", "s2", "s3", "s4"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 2) repository.removeAt(repository.snapshot(), 2)
@@ -621,7 +1037,7 @@ class QueueRepositoryTest {
runTest { runTest {
seedLibrary() seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) 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())) assertEquals(listOf("s1", "s2", "s4", "s3"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 0) repository.removeAt(repository.snapshot(), 0)
@@ -634,7 +1050,7 @@ class QueueRepositoryTest {
runTest { runTest {
seedLibrary() seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) 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"))) repository.replace(listOf(repository.songEntry(1, "s5")))
@@ -783,98 +1199,22 @@ class QueueRepositoryTest {
} }
@Test @Test
fun removingAContextTrackWhileShuffledKeepsTheRemainingOrder() = fun removingAContextTrackWhileShuffledIsRejected() =
runTest { runTest {
seedLibrary() seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.setShuffle(42L) repository.setShuffle(42L)
val before = repository.snapshot() val before = repository.snapshot()
val entriesBefore = before.entries.map { it.entry }
val orderBefore = (0 until before.contextSize).mapNotNull { repository.itemAt(before, it)?.song?.id } val orderBefore = (0 until before.contextSize).mapNotNull { repository.itemAt(before, it)?.song?.id }
repository.removeAt(before, 1) repository.removeAt(before, 1)
val after = repository.snapshot() val after = repository.snapshot()
assertTrue(after.shuffled) assertTrue(after.shuffled)
assertEquals(2L, after.contextSize) assertEquals(before.contextSize, after.contextSize)
val orderAfter = (0 until after.contextSize).mapNotNull { repository.itemAt(after, it)?.song?.id } assertEquals(entriesBefore, after.entries.map { it.entry })
assertEquals(orderBefore.filterIndexed { index, _ -> index != 1 }, orderAfter) assertEquals(orderBefore, (0 until after.contextSize).mapNotNull { repository.itemAt(after, it)?.song?.id })
}
@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)
}
}
} }
@Test @Test
@@ -932,7 +1272,7 @@ class QueueRepositoryTest {
seedLibrary() seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) 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() val updated = repository.snapshot()
assertEquals(1L, updated.anchorPlay) assertEquals(1L, updated.anchorPlay)
@@ -947,13 +1287,13 @@ class QueueRepositoryTest {
runTest { runTest {
seedLibrary() seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) 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() val before = repository.snapshot()
assertEquals(0L, before.anchorPlay) assertEquals(0L, before.anchorPlay)
assertEquals(3L, repository.combinedContextIndexOf(before, "s3")) assertEquals(3L, repository.combinedContextIndexOf(before, "s3"))
repository.setUpNextAnchor(2) repository.reanchorFor(before, 3)
val after = repository.snapshot() val after = repository.snapshot()
assertEquals(2L, after.anchorPlay) assertEquals(2L, after.anchorPlay)
@@ -966,7 +1306,7 @@ class QueueRepositoryTest {
runTest { runTest {
seedLibrary() seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.songEntry(1, "s4"))) 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() val updated = repository.snapshot()
assertEquals(0L, repository.combinedContextIndexOf(updated, "s4")) assertEquals(0L, repository.combinedContextIndexOf(updated, "s4"))
@@ -1105,6 +1445,16 @@ class QueueRepositoryTest {
created = 0, 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() { private suspend fun seedLibrary() {
db.sourcesDao().upsertSource( db.sourcesDao().upsertSource(
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0), Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
@@ -101,7 +101,7 @@ class SyncManagerTest {
} }
@Test @Test
fun aFailedSyncStillInvalidatesTheLibraryCache() = fun aFailedSyncDoesNotAdvanceTheQueueContentVersion() =
runBlocking { runBlocking {
val before = queueRepository.snapshot().version val before = queueRepository.snapshot().version
@@ -109,7 +109,7 @@ class SyncManagerTest {
val status = withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } } val status = withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertTrue(status is SyncStatus.Failed) assertTrue(status is SyncStatus.Failed)
assertEquals(before + 1, queueRepository.snapshot().version) assertEquals(before, queueRepository.snapshot().version)
} }
@Test @Test
@@ -36,6 +36,9 @@ import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository 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.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -55,7 +58,6 @@ import okhttp3.OkHttpClient
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@@ -2116,40 +2118,28 @@ class PlaybackControllerTest {
} }
@Test @Test
fun removingATrackWhileShuffledKeepsTheCurrentTrackPlaying() { fun removingATrackWhileShuffledIsRejected() {
seedAlbum(6, sourceId = 1) seedAlbum(6, sourceId = 1)
controller.playAlbum(1, "al1", 2) controller.playAlbum(1, "al1", 2)
await { await {
controller.state.value.item controller.state.value.item
?.id == "s3" ?.id == "s3"
} }
controller.toggleShuffle() controller.toggleShuffle()
await { controller.state.value.shuffle } await { controller.state.value.shuffle }
val currentId = val before = runBlocking { queues.snapshot() }
controller.state.value.item!! val itemsBefore = handle.items.map { it.id }
.id
val removeAt = (controller.state.value.position!! + 1) % 6
runBlocking { controller.removeAt(removeAt) } runBlocking { controller.removeAt((controller.state.value.position!! + 1) % 6) }
await {
controller.state.value.item
?.id == currentId && controller.state.value.shuffle
}
assertTrue(handle.items.map { it.id }.contains(currentId)) val after = runBlocking { queues.snapshot() }
val remaining = assertEquals(before.contextSize, after.contextSize)
runBlocking { assertEquals(before.entries.map { it.entry }, after.entries.map { it.entry })
val snap = queues.snapshot() assertEquals(itemsBefore, handle.items.map { it.id })
(0 until snap.size).mapNotNull { queues.itemAt(snap, it)?.song?.id }
}
assertEquals(5, remaining.size)
assertEquals(5, remaining.distinct().size)
} }
@Test @Test
fun removingThePlayingTrackWhileShuffledLeavesTheBlockReachable() { fun removingThePlayingTrackWhileShuffledLeavesTheBlockUnchanged() {
seedAlbum(6, sourceId = 1) seedAlbum(6, sourceId = 1)
seedSong("x1", "al2") seedSong("x1", "al2")
runBlocking { runBlocking {
@@ -2161,30 +2151,24 @@ class PlaybackControllerTest {
await { controller.state.value.item != null && controller.state.value.shuffle } await { controller.state.value.item != null && controller.state.value.shuffle }
controller.addToQueue(1, QueueKind.Song, "x1") controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "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(controller.state.value.position!!) }
runBlocking { controller.removeAt(current) }
// 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() } val after = runBlocking { queues.snapshot() }
assertTrue(after.shuffled) assertEquals(before.entries.map { it.entry }, after.entries.map { it.entry })
assertEquals( assertEquals(before.upNextAnchor, after.upNextAnchor)
"x1", assertEquals(1L, after.upNextSize)
runBlocking { queues.itemAt(after, controller.state.value.position!!)?.song?.id }, assertEquals(currentId, handle.currentItem?.id)
)
} }
@Test @Test
fun removingTheAnchorWhilePlayingABlockTrackKeepsTheBlockInPlace() { fun removingTheAnchorWhilePlayingABlockTrackIsRejected() {
seedShuffledQueue(seed = 5L) seedShuffledQueue(seed = 5L)
seedSong("x1", "al2") seedSong("x1", "al2")
controller.addToQueue(1, QueueKind.Song, "x1") controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" } await { runBlocking { controller.upcomingItem()?.id } == "x1" }
controller.playAt(1) controller.playAt(1)
await { await {
controller.state.value.item controller.state.value.item
@@ -2195,8 +2179,9 @@ class PlaybackControllerTest {
runBlocking { controller.removeAt(before.anchorPlay) } runBlocking { controller.removeAt(before.anchorPlay) }
val after = runBlocking { queues.snapshot() } val after = runBlocking { queues.snapshot() }
assertEquals(-1L, after.anchorPlay) assertEquals(before.upNextAnchor, after.upNextAnchor)
assertEquals("x1", runBlocking { queues.itemAt(after, 0)?.song?.id }) assertEquals(before.contextSize, after.contextSize)
assertEquals("x1", runBlocking { queues.itemAt(after, 1)?.song?.id })
assertEquals( assertEquals(
"x1", "x1",
controller.state.value.item controller.state.value.item
@@ -2205,20 +2190,18 @@ class PlaybackControllerTest {
} }
@Test @Test
fun removingThePlayingTrackWhileShuffledPlaysTheNextShuffledTrack() { fun removingThePlayingTrackWhileShuffledDoesNotAdvance() {
seedShuffledQueue(seed = 12345L) seedShuffledQueue(seed = 12345L)
val orderBefore = resolvedIds() val orderBefore = resolvedIds()
val current = controller.state.value.position!! val currentId = handle.currentItem?.id
val expected = orderBefore.toMutableList().apply { removeAt(current.toInt()) }[current.toInt()] 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() } 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 @Test
@@ -2275,73 +2258,6 @@ class PlaybackControllerTest {
assertEquals("x1", runBlocking { queues.itemAt(after, after.anchorPlay + 2)?.song?.id }) 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 @Test
fun movingContextTracksWhileShuffledIsIgnored() { fun movingContextTracksWhileShuffledIsIgnored() {
seedAlbum(6, sourceId = 1) 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 @Test
fun shufflingTheSameAlbumAgainPicksADifferentTrack() { fun shufflingTheSameAlbumAgainPicksADifferentTrack() {
seedAlbum(5, sourceId = 1) 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.DownloadRepository
import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository 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.FakePlayerConnection
import com.subtracks.playback.FakePlayerHandle import com.subtracks.playback.FakePlayerHandle
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
+5 -3
View File
@@ -84,13 +84,15 @@ The queue lives in SQL, not memory. `queue_entries` is an ordered list of *refer
`QueueRepository` resolves a queue position to a song lazily: it walks the entries summing their lengths (a `COUNT(*)` per entry) and then fetches one row with `LIMIT 1 OFFSET`. `PlaybackController` keeps only the resolved entry lengths and the cursor in memory — never the songs — and hands the player a bounded window (currently `2 * 25 + 1` items) centred on the cursor. A player index maps back to a queue position as `windowStart + index`. The window is loaded once with `setMediaItems` and then shifted incrementally with `addMediaItems`/`removeMediaItem` as playback advances; a shift never replaces the playlist, so the current item and its prepared next source survive and transitions stay gapless. `hasNext`/`hasPrevious` are intentionally always true rather than derived from the SQL queue size: the previous/next buttons stay usable at both ends of the queue so repeat mode and restart-the-track keep working, and `next()`/`previous()` decide the actual end-of-queue action (advance, wrap when repeating, restart, or no-op). The snapshot is re-read whenever the window shifts and on skip, so a sync that shrinks the referenced album or playlist cannot strand the window on stale lengths; if the active source changes under a loaded queue, playback stops rather than resolving the old queue's stream URLs against the new server. `QueueRepository` resolves a queue position to a song lazily: it walks the entries summing their lengths (a `COUNT(*)` per entry) and then fetches one row with `LIMIT 1 OFFSET`. `PlaybackController` keeps only the resolved entry lengths and the cursor in memory — never the songs — and hands the player a bounded window (currently `2 * 25 + 1` items) centred on the cursor. A player index maps back to a queue position as `windowStart + index`. The window is loaded once with `setMediaItems` and then shifted incrementally with `addMediaItems`/`removeMediaItem` as playback advances; a shift never replaces the playlist, so the current item and its prepared next source survive and transitions stay gapless. `hasNext`/`hasPrevious` are intentionally always true rather than derived from the SQL queue size: the previous/next buttons stay usable at both ends of the queue so repeat mode and restart-the-track keep working, and `next()`/`previous()` decide the actual end-of-queue action (advance, wrap when repeating, restart, or no-op). The snapshot is re-read whenever the window shifts and on skip, so a sync that shrinks the referenced album or playlist cannot strand the window on stale lengths; if the active source changes under a loaded queue, playback stops rather than resolving the old queue's stream URLs against the new server.
Playback is a *context* plus a manual *up next* list. `queue_entries` is the context — the album, playlist or song you started, and the only thing shuffle permutes. `up_next_entries` is the manual queue: the same reference-plus-range rows, ordered, never shuffled. A `playback_cursor.upNextAnchor` holds the context track the block sits behind; the controller re-anchors it whenever playback lands on a different context track, so the block always plays next. `QueueRepository` resolves a combined position by mapping it through the context order and splicing the block after the anchor, so the controller's window and next/previous logic keep working. Add to queue appends to the block and play next inserts at its front; both reuse the context edit machinery (range split, insert, compact). Advancing out of a block track removes it — consume as played, with no history — and a new context clears the block, so a manual edit never disturbs the shuffle permutation. Shuffle itself stores no per-track order: `playback_cursor.shuffleSeed`/`shuffleSize` are the whole state, and a position maps through a seekable Feistel permutation of `[0, size)`, so a very large album cannot materialise an order list. Reordering the context while shuffled would need that order back, so the context is playback-only while shuffled and manual ordering goes through the up-next block. All queue and player-window mutations run through one `QueueMutationScope`. Snapshot reads, edits, sync/download resets, Undo and playback reconciliation share that lock, and reset reconciliation is invoked synchronously before the reset operation returns; there is no asynchronous reset collector. Content revisions reject stale writes, a separate reset generation rejects row operations created before a canonical reset, and a library revision only controls resolution caches. Navigation computes its target inside the mutation operation, while position-based UI edits carry their reset generation from operation creation. External initialization waits, including download-state loading, stay outside the mutation lock.
Playback is a *context* plus a manual *up next* list. `queue_entries` is the context — the album, playlist or song you started, and the only thing shuffle permutes. `up_next_entries` is the manual queue: the same reference-plus-range rows, ordered, never shuffled. Every entry also retains its origin context, so explicit songs created by rearranges can later be merged back into the album, playlist, artist or genre they came from. A `playback_cursor.upNextAnchor` holds the context track the block sits behind; the controller re-anchors it whenever playback lands on a different context track, so the block always plays next. `QueueRepository` resolves a combined position by mapping it through the context order and splicing the block after the anchor, so the controller's window and next/previous logic keep working. Add to queue appends to the block and play next inserts at its front; both reuse the context edit machinery (range split, insert, compact). Advancing out of a block track removes it — consume as played, with no history — and a new context clears the block, so a manual edit never disturbs the shuffle permutation. Shuffle itself stores no per-track order: `playback_cursor.shuffleSeed`/`shuffleSize` are the whole state, and a position maps through a seekable Feistel permutation of `[0, size)`, so a very large album cannot materialise an order list. Context removal and reordering are disabled while shuffled; manual ordering and deletion still work in the up-next block and in non-shuffled contexts.
Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from. Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from.
Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. Some streams stay length-unknown anyway: an Opus/Ogg/Vorbis transcode (a length estimate breaks the end-seek they rely on), a transcode of a track with no library duration, and a direct play the server does not give a `Content-Length`. Media3 marks an unknown-length, unknown-duration progressive stream live, and a live item makes the media session publish no playback position, so the notification would show no progress even though the item carries a real duration; `KnownDurationPlayer` wraps the `ExoPlayer` and reports an item with a library duration as finite, restoring the position and progress the session publishes without making the stream seekable. Streamed bytes also pass through a bounded `SimpleCache` (`CacheDataSource` sits under `DefaultDataSource`, so downloads bypass it): a headerless MP3's duration is only estimated, and when the extractor rewrites it at end of stream Media3 drops and re-opens the prefetched next period. The cache serves the bytes that prefetch already buffered from disk, so the re-open does not re-fetch them (only bytes the cancelled prefetch never committed are fetched again). Keys are the salted stream URLs, so nothing is reused across a service restart; it is a bounded scratch buffer, not a persistent download. Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. Some streams stay length-unknown anyway: an Opus/Ogg/Vorbis transcode (a length estimate breaks the end-seek they rely on), a transcode of a track with no library duration, and a direct play the server does not give a `Content-Length`. Media3 marks an unknown-length, unknown-duration progressive stream live, and a live item makes the media session publish no playback position, so the notification would show no progress even though the item carries a real duration; `KnownDurationPlayer` wraps the `ExoPlayer` and reports an item with a library duration as finite, restoring the position and progress the session publishes without making the stream seekable. Streamed bytes also pass through a bounded `SimpleCache` (`CacheDataSource` sits under `DefaultDataSource`, so downloads bypass it): a headerless MP3's duration is only estimated, and when the extractor rewrites it at end of stream Media3 drops and re-opens the prefetched next period. The cache serves the bytes that prefetch already buffered from disk, so the re-open does not re-fetch them (only bytes the cancelled prefetch never committed are fetched again). Keys are the salted stream URLs, so nothing is reused across a service restart; it is a bounded scratch buffer, not a persistent download.
The window is also the queue the media notification browses — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics are not wired yet. The window is also the queue the media notification browses — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index while retaining its origin context, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics are not wired yet.
Scrobbling is a `Scrobbler` that collects the controller's `state` and `positionMs` and drives the Subsonic `scrobble` endpoint: a now-playing notification (`submission=false`) when a track starts playing, and one submission (`submission=true`, with the playback start time) when the track passes half its duration or four minutes, whichever comes first. That split is the endpoint's own `submission` flag, and the threshold is the Last.fm rule the server forwards to; servers such as Navidrome trust the client and apply no threshold themselves. Tracks under 30 seconds are never submitted, and only time actually played counts: forward and backward seeks are ignored, and a rewind only starts a fresh listen when it lands in the track's first few seconds (a repeat or an explicit restart). The duration is the player's resolved value, falling back to the library row when the player has not reported one. Sending is decoupled from the collector so a slow or offline write cannot stall position tracking, while a lock keeps the writes in order. The policy is a pure state machine with no Android dependencies. A Settings toggle (`UserPreferences.scrobbling`, on by default) gates both calls. A submission also bumps the played song's local `playCount`/`played` and its album and artist ranks, so the play sorts move before the next sync; the sync then overwrites them with the server's own numbers, so a play the server never accepted is rolled back at that point. Scrobbling is a `Scrobbler` that collects the controller's `state` and `positionMs` and drives the Subsonic `scrobble` endpoint: a now-playing notification (`submission=false`) when a track starts playing, and one submission (`submission=true`, with the playback start time) when the track passes half its duration or four minutes, whichever comes first. That split is the endpoint's own `submission` flag, and the threshold is the Last.fm rule the server forwards to; servers such as Navidrome trust the client and apply no threshold themselves. Tracks under 30 seconds are never submitted, and only time actually played counts: forward and backward seeks are ignored, and a rewind only starts a fresh listen when it lands in the track's first few seconds (a repeat or an explicit restart). The duration is the player's resolved value, falling back to the library row when the player has not reported one. Sending is decoupled from the collector so a slow or offline write cannot stall position tracking, while a lock keeps the writes in order. The policy is a pure state machine with no Android dependencies. A Settings toggle (`UserPreferences.scrobbling`, on by default) gates both calls. A submission also bumps the played song's local `playCount`/`played` and its album and artist ranks, so the play sorts move before the next sync; the sync then overwrites them with the server's own numbers, so a play the server never accepted is rolled back at that point.
@@ -126,7 +128,7 @@ Songs have a primary and a fallback fetch. The primary probes `search3` with an
Cleanup is the "everything I had, minus everything the server just sent" step described above: artists, albums, songs and playlists delete the leftover IDs in 500-row chunks, disc titles are deleted per `(album, disc)` for albums that still exist, and playlist entries beyond the new count are removed along with entries for playlists that disappeared. Cleanup is the "everything I had, minus everything the server just sent" step described above: artists, albums, songs and playlists delete the leftover IDs in 500-row chunks, disc titles are deleted per `(album, disc)` for albums that still exist, and playlist entries beyond the new count are removed along with entries for playlists that disappeared.
Sync is not atomic. Each batch commits on its own, so a failure leaves the batches already written in place and skips that entity's prune; stale rows can linger until the next successful sync, which also invalidates the queue's cached library. The one hazard is a scan that stops early — a server returning a short page by mistake, or a library larger than the page cap — because the prune trusts that the stream actually reached the end. Each batch commit also makes Room notify paged observers once per batch (a few per second, bounded by the network round-trips), which is cheap but can make an on-screen list churn during a sync; gate UI observation on `SyncStatus.Running` if that is ever noticeable. Sync is not atomic. Each batch commits on its own, so a failure leaves the batches already written in place and skips that entity's prune; stale rows can linger until the next successful sync, which also invalidates the queue's cached library. Changed origins are merged only after their batch transaction commits and are persisted per source after failures, so the next successful sync includes them even after a process restart. A successful sync reports the origins it actually changed and collapses only those context and up-next blocks back to their canonical references; unrelated origins and failed syncs leave local edits alone. The current item and up-next anchor are remapped by origin, song ID and position as a best effort, without promising exact duplicate-occurrence identity. If the current song disappeared it finishes in a detached slot before the refreshed queue continues at a deterministic clamped position. Each batch commit also makes Room notify paged observers once per batch (a few per second, bounded by the network round-trips), which is cheap but can make an on-screen list churn during a sync; gate UI observation on `SyncStatus.Running` if that is ever noticeable.
## Search ## Search