sync: publish queue resets after commit

Prepare queue and playback reconciliation inside the reset transaction without publishing listener, cache, or revision state. Publish through a non-suspending, non-cancellable post-commit step so committed resets are never retried over later edits.

Keep the pending-origin clear under the cursor lock without a redundant single-statement transaction, and cover listener and post-clear write failures.
This commit is contained in:
austinried
2026-11-14 12:05:28 +00:00
parent ffc61bdbd2
commit 705c93053f
4 changed files with 322 additions and 170 deletions
@@ -11,6 +11,7 @@ import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.QueueOrigin 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.NonCancellable
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -20,6 +21,7 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
const val QUEUE_CHUNK = 60 const val QUEUE_CHUNK = 60
@@ -187,10 +189,20 @@ data class QueueReset(
val snapshot: QueueSnapshot, val snapshot: QueueSnapshot,
) )
data class QueueResetPreparation(
val snapshot: QueueSnapshot,
val publicationContext: CoroutineContext = EmptyCoroutineContext,
val publish: QueueRepository.QueueMutationScope.(QueueSnapshot) -> Unit = {},
)
private class CursorLockContext : AbstractCoroutineContextElement(CursorLockContext) { private class CursorLockContext : AbstractCoroutineContextElement(CursorLockContext) {
companion object Key : CoroutineContext.Key<CursorLockContext> companion object Key : CoroutineContext.Key<CursorLockContext>
} }
private class ResetPreparationContext : AbstractCoroutineContextElement(ResetPreparationContext) {
companion object Key : CoroutineContext.Key<ResetPreparationContext>
}
private interface QueueKindResolver { private interface QueueKindResolver {
fun supportsKeyset(key: OrderKey): Boolean = false fun supportsKeyset(key: OrderKey): Boolean = false
@@ -396,9 +408,9 @@ class QueueRepository(
private val resetVersion = AtomicLong() private val resetVersion = AtomicLong()
private val libraryVersion = AtomicLong() private val libraryVersion = AtomicLong()
private val queueMutex = Mutex() private val queueMutex = Mutex()
private var resetListener: (suspend QueueMutationScope.(QueueReset) -> Unit)? = null private var resetListener: (suspend QueueMutationScope.(QueueReset) -> QueueResetPreparation)? = null
fun setResetListener(listener: suspend QueueMutationScope.(QueueReset) -> Unit) { fun setResetListener(listener: suspend QueueMutationScope.(QueueReset) -> QueueResetPreparation) {
resetListener = listener resetListener = listener
} }
@@ -622,9 +634,7 @@ class QueueRepository(
val entries = canonicalEntries(before.entries.map { it.entry }, changed) val entries = canonicalEntries(before.entries.map { it.entry }, changed)
val upNext = canonicalEntries(before.upNext.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 }) { if (entries != before.entries.map { it.entry } || upNext != before.upNext.map { it.entry }) {
writeResetLocked(before, entries, upNext, point, pendingOriginSourceId) { updated -> writeResetLocked(before, entries, upNext, point, pendingOriginSourceId)
resetListener?.invoke(this, QueueReset(point, updated))
}
return return
} }
} }
@@ -633,99 +643,91 @@ class QueueRepository(
private suspend fun clearPendingSyncOriginsLocked(sourceId: Long) = private suspend fun clearPendingSyncOriginsLocked(sourceId: Long) =
withCursorLock { withCursorLock {
db.useWriterConnection { connection -> dao.clearPendingSyncOrigins(sourceId)
connection.immediateTransaction {
dao.clearPendingSyncOrigins(sourceId)
}
}
} }
private suspend fun writeResetLocked( private suspend fun QueueMutationScope.writeResetLocked(
before: QueueSnapshot, before: QueueSnapshot,
entries: List<QueueEntry>, entries: List<QueueEntry>,
upNext: List<QueueEntry>, upNext: List<QueueEntry>,
point: QueueResetPoint, point: QueueResetPoint,
pendingOriginSourceId: Long? = null, pendingOriginSourceId: Long? = null,
onReset: suspend (QueueSnapshot) -> Unit,
) { ) {
val resetSnapshot = val preparation =
withCursorLock { withCursorLock {
db.useWriterConnection { connection -> withContext(ResetPreparationContext()) {
connection.immediateTransaction { db.useWriterConnection { connection ->
if (pendingOriginSourceId != null) dao.clearPendingSyncOrigins(pendingOriginSourceId) connection.immediateTransaction {
invalidateLibraryCache() if (pendingOriginSourceId != null) dao.clearPendingSyncOrigins(pendingOriginSourceId)
val resolvedEntries = entries.map { ResolvedQueueEntry(it, it.resolvedLength()) } val resolvedEntries = entries.map { ResolvedQueueEntry(it, it.resolvedLength()) }
val resolvedUpNext = upNext.map { ResolvedQueueEntry(it, it.resolvedLength()) } val resolvedUpNext = upNext.map { ResolvedQueueEntry(it, it.resolvedLength()) }
val shuffle = before.shuffleSeed?.let { ShuffleState(it, resolvedEntries.sumOf { it.length }) } val shuffle = before.shuffleSeed?.let { ShuffleState(it, resolvedEntries.sumOf { it.length }) }
var updated = var updated =
QueueSnapshot( QueueSnapshot(
entries = resolvedEntries, entries = resolvedEntries,
upNext = resolvedUpNext, upNext = resolvedUpNext,
shuffle = shuffle, shuffle = shuffle,
upNextAnchor = before.upNextAnchor, upNextAnchor = before.upNextAnchor,
version = queueVersion.get(), version = queueVersion.get() + 1,
resetVersion = resetVersion.get(), resetVersion = resetVersion.get() + 1,
)
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)
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
dao.clear()
dao.insert(
withOriginEntryIds(entries).mapIndexed { index, entry ->
entry.copy(id = 0, position = index.toLong())
},
) )
val anchorChanged = before.upNextSize > 0L && before.upNextAnchor != point.upNextAnchor dao.clearUpNext()
val latestAnchor = if (anchorChanged) resetItemAt(before, before.anchorPlay) else null dao.insertUpNext(
val anchor = withOriginEntryIds(upNext).mapIndexed { index, entry ->
if (anchorChanged) { UpNextEntry.from(entry).copy(id = 0, position = index.toLong())
remapAnchor(updated, latestAnchor) ?: fallbackAnchor(updated, before.anchorPlay) },
} else { )
remapAnchor(updated, point.anchor) dao.setCursor(
?: fallbackAnchor(updated, point.anchor?.position ?: before.upNextAnchor) row.copy(
} queuePosition = cursor,
updated = updated.copy(upNextAnchor = anchor) positionMs = positionMs,
val row = cursorRow() upNextAnchor = anchor,
val current = point.current ?: resetItemAt(before, row.queuePosition) shuffleEnabled = shuffle != null,
val remappedCurrent = current?.let { remapPosition(updated, it) } shuffleSeed = shuffle?.seed ?: 0L,
val cursor = shuffleSize = shuffle?.domain ?: 0L,
when { ),
updated.size == 0L -> 0L )
remappedCurrent != null -> remappedCurrent.coerceIn(0, updated.size - 1) resetListener?.invoke(this@writeResetLocked, QueueReset(point, updated))
current != null -> current.position.coerceIn(0, updated.size - 1) ?: QueueResetPreparation(updated)
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
dao.clear()
dao.insert(
withOriginEntryIds(entries).mapIndexed { index, entry ->
entry.copy(id = 0, position = index.toLong())
},
)
dao.clearUpNext()
dao.insertUpNext(
withOriginEntryIds(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,
),
)
if (pendingOriginSourceId == null) {
updated
} else {
queueVersion.incrementAndGet()
resetVersion.incrementAndGet()
onReset(updated.copy(version = queueVersion.get(), resetVersion = resetVersion.get()))
null
} }
} }
} }
} }
if (resetSnapshot != null) { invalidateLibraryCache()
queueVersion.incrementAndGet() queueVersion.incrementAndGet()
resetVersion.incrementAndGet() resetVersion.incrementAndGet()
onReset(resetSnapshot.copy(version = queueVersion.get(), resetVersion = resetVersion.get())) val published = preparation.snapshot.copy(version = queueVersion.get(), resetVersion = resetVersion.get())
withContext(preparation.publicationContext + NonCancellable) {
preparation.publish(this@writeResetLocked, published)
} }
} }
@@ -1033,6 +1035,18 @@ class QueueRepository(
return if (currentPosition >= current.anchorPlay + 1 + before) currentPosition + 1 else currentPosition return if (currentPosition >= current.anchorPlay + 1 + before) currentPosition + 1 else currentPosition
} }
private suspend fun reanchorForResetLocked(
snapshot: QueueSnapshot,
target: Long,
): Reanchored {
if (snapshot.upNextSize == 0L || snapshot.isUpNext(target)) return Reanchored(target, false)
val play = snapshot.contextPlay(target)
val flat = snapshot.flatContext(play) ?: return Reanchored(target, false)
if (flat == snapshot.upNextAnchor) return Reanchored(target, false)
withCursorLock { dao.setCursor(cursorRow().copy(upNextAnchor = flat)) }
return Reanchored(play, true)
}
private suspend fun reanchorForLocked( private suspend fun reanchorForLocked(
snapshot: QueueSnapshot, snapshot: QueueSnapshot,
target: Long, target: Long,
@@ -1198,6 +1212,11 @@ class QueueRepository(
target: Long, target: Long,
): Reanchored = reanchorForLocked(snapshot, target) ): Reanchored = reanchorForLocked(snapshot, target)
suspend fun reanchorForReset(
snapshot: QueueSnapshot,
target: Long,
): Reanchored = reanchorForResetLocked(snapshot, target)
suspend fun clearUpNext() = writeUpNextLocked(emptyList()) suspend fun clearUpNext() = writeUpNextLocked(emptyList())
suspend fun removeUpNextAt( suspend fun removeUpNextAt(
@@ -1342,7 +1361,8 @@ class QueueRepository(
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 = QueueOrigin(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 preparingReset = currentCoroutineContext()[ResetPreparationContext] != null
val anchored = if (preparingReset) null else keyCache[ref]?.let { keyedRows(entry, offset, limit, it) }
if (anchored != null) { if (anchored != null) {
keysetSeeks++ keysetSeeks++
if (anchored.isNotEmpty()) remember(ref, offset, anchored) if (anchored.isNotEmpty()) remember(ref, offset, anchored)
@@ -1351,7 +1371,7 @@ class QueueRepository(
} }
offsetSeeks++ offsetSeeks++
val fresh = resolverFor(entry.kind).rows(dao, entry, offset, limit) val fresh = resolverFor(entry.kind).rows(dao, entry, offset, limit)
if (fresh.isNotEmpty()) remember(ref, offset, fresh) if (fresh.isNotEmpty() && !preparingReset) remember(ref, offset, fresh)
resolvedRowCount += fresh.size resolvedRowCount += fresh.size
return fresh return fresh
} }
@@ -1424,6 +1444,7 @@ class QueueRepository(
entry: QueueEntry, entry: QueueEntry,
version: Long, version: Long,
): Long { ): Long {
if (currentCoroutineContext()[ResetPreparationContext] != null) return entry.resolvedLength()
val key = entry.lengthKey(version) val key = entry.lengthKey(version)
lengthCache[key]?.let { return it } lengthCache[key]?.let { return it }
val length = entry.resolvedLength() val length = entry.resolvedLength()
@@ -14,6 +14,7 @@ import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.QueueRepository.QueueMutationScope import com.subtracks.data.repo.QueueRepository.QueueMutationScope
import com.subtracks.data.repo.QueueReset import com.subtracks.data.repo.QueueReset
import com.subtracks.data.repo.QueueResetItem import com.subtracks.data.repo.QueueResetItem
import com.subtracks.data.repo.QueueResetPreparation
import com.subtracks.data.repo.QueueSnapshot import com.subtracks.data.repo.QueueSnapshot
import com.subtracks.data.repo.QueueWindowItem import com.subtracks.data.repo.QueueWindowItem
import com.subtracks.data.repo.Shuffle import com.subtracks.data.repo.Shuffle
@@ -115,6 +116,14 @@ private data class PendingPlay(
val behavior: SongTapBehavior?, val behavior: SongTapBehavior?,
) )
private data class PreparedWindow(
val items: List<QueueWindowItem>,
val actual: Long,
val startIndex: Int,
val autoplay: Boolean,
val startPositionMs: Long,
)
class PlaybackController( class PlaybackController(
private val sourceRepository: SourceRepository, private val sourceRepository: SourceRepository,
private val queueRepository: QueueRepository, private val queueRepository: QueueRepository,
@@ -166,7 +175,7 @@ class PlaybackController(
init { init {
queueRepository.setResetListener { reset -> queueRepository.setResetListener { reset ->
withContext(playbackDispatcher) { withContext(playbackDispatcher) {
reconcileLibraryResetLocked(reset) prepareLibraryResetLocked(reset)
} }
} }
scope.launch { scope.launch {
@@ -1209,42 +1218,58 @@ class PlaybackController(
) { ) {
val player = player ?: return val player = player ?: return
val snapshot = snapshot ?: return val snapshot = snapshot ?: return
val prepared = prepareWindow(snapshot, position, autoplay, startPositionMs)
if (prepared == null) {
stopLocked()
return
}
publishWindow(player, snapshot, prepared)
}
private suspend fun prepareWindow(
snapshot: QueueSnapshot,
position: Long,
autoplay: Boolean,
startPositionMs: Long,
): PreparedWindow? {
val requested = val requested =
if (offline) { if (offline) {
downloadedAtOrAfter(snapshot, position) ?: downloadedAtOrBefore(snapshot, position - 1) ?: run { downloadedAtOrAfter(snapshot, position) ?: downloadedAtOrBefore(snapshot, position - 1) ?: return null
stopLocked()
return
}
} else { } else {
position position
} }
val raw = queueRepository.window(snapshot, requested, QUEUE_WINDOW_RADIUS) val raw = queueRepository.window(snapshot, requested, QUEUE_WINDOW_RADIUS)
val window = if (offline) raw.filter { downloads.localUri(it.item.song.id) != null } else raw val window = if (offline) raw.filter { downloads.localUri(it.item.song.id) != null } else raw
if (window.isEmpty()) { if (window.isEmpty()) return null
stopLocked()
return
}
var startIndex = window.indexOfFirst { it.position == requested } var startIndex = window.indexOfFirst { it.position == requested }
if (startIndex < 0) { if (startIndex < 0) {
startIndex = window.indexOfFirst { it.position > requested } startIndex = window.indexOfFirst { it.position > requested }
if (startIndex < 0) startIndex = window.lastIndex if (startIndex < 0) startIndex = window.lastIndex
} }
val actual = window[startIndex].position val actual = window[startIndex].position
clearDetached()
windowPositions = LongArray(window.size) { window[it].position }
windowFiltered = offline
windowStart = window.first().position
windowEnd = window.last().position
lastPosition = actual
if (actual != requested) queueRepository.setCursor(actual) if (actual != requested) queueRepository.setCursor(actual)
return PreparedWindow(window, actual, startIndex, autoplay, startPositionMs)
}
private fun publishWindow(
player: PlayerHandle,
snapshot: QueueSnapshot,
prepared: PreparedWindow,
) {
clearDetached()
windowPositions = LongArray(prepared.items.size) { prepared.items[it].position }
windowFiltered = offline
windowStart = prepared.items.first().position
windowEnd = prepared.items.last().position
lastPosition = prepared.actual
updating = true updating = true
player.setWindow(window.map { it.playerItem(snapshot) }, startIndex, startPositionMs) player.setWindow(prepared.items.map { it.playerItem(snapshot) }, prepared.startIndex, prepared.startPositionMs)
updating = false updating = false
if (autoplay) { if (prepared.autoplay) {
player.prepare() player.prepare()
player.play() player.play()
} }
refresh(actual) refresh(prepared.actual)
} }
private suspend fun reloadQuality() = private suspend fun reloadQuality() =
@@ -1421,10 +1446,10 @@ class PlaybackController(
lastPosition = target lastPosition = target
} }
private suspend fun QueueMutationScope.reconcileLibraryResetLocked(reset: QueueReset) { private suspend fun QueueMutationScope.prepareLibraryResetLocked(reset: QueueReset): QueueResetPreparation {
val player = player ?: return val player = player ?: return QueueResetPreparation(reset.snapshot)
val previous = snapshot ?: return val previous = snapshot ?: return QueueResetPreparation(reset.snapshot)
if (previous.version > reset.snapshot.version) return if (previous.version > reset.snapshot.version) return QueueResetPreparation(reset.snapshot)
var current = reset.point.current var current = reset.point.current
val actualItem = player.currentItem val actualItem = player.currentItem
val actualId = actualItem?.id val actualId = actualItem?.id
@@ -1447,45 +1472,72 @@ class PlaybackController(
current?.copy(songId = actualId, position = actualPosition, upNext = upNext) current?.copy(songId = actualId, position = actualPosition, upNext = upNext)
} }
} }
lastEdit = null
windowJob?.cancel()
endedHandled = false
var updated = reset.snapshot var updated = reset.snapshot
shuffleEnabled = updated.shuffled
layoutVersion++
this@PlaybackController.snapshot = updated
val target = current?.let { queueRepository.remapPosition(updated, it) } val target = current?.let { queueRepository.remapPosition(updated, it) }
if (target != null) { if (target != null) {
val anchor = reanchorFor(updated, target) val anchor = reanchorForReset(updated, target)
if (anchor.stale) return
if (anchor.changed) { if (anchor.changed) {
updated = snapshot() val flat = updated.flatContext(updated.contextPlay(target)) ?: updated.upNextAnchor
this@PlaybackController.snapshot = updated updated = updated.copy(upNextAnchor = flat)
} }
val position = anchor.position.coerceIn(0, updated.size - 1) val position = anchor.position.coerceIn(0, updated.size - 1)
queueRepository.setCursor(position) queueRepository.setCursor(position)
if (detachedWindow && currentPosition() == null) { if (detachedWindow && currentPosition() == null) {
rebuildDetachedSurvivors(updated, position) val window = prepareDetachedWindow(updated, position)
} else { return QueueResetPreparation(updated, playbackDispatcher) { committed ->
clearDetached() beginLibraryResetPublication(committed)
rebuildWindow(queueRepository.window(updated, position, QUEUE_WINDOW_RADIUS), position) publishDetachedSurvivors(player, committed, position, window)
refresh(position)
}
}
val raw = queueRepository.window(updated, position, QUEUE_WINDOW_RADIUS)
val ordered = if (offline) raw.filter { downloads.localUri(it.item.song.id) != null } else raw
val index = ordered.indexOfFirst { it.position == position }
if (index < 0) {
val prepared = prepareWindow(updated, position, player.playWhenReady, player.currentPositionMs)
return QueueResetPreparation(updated, playbackDispatcher) { committed ->
beginLibraryResetPublication(committed)
if (prepared == null) stopLocked() else publishWindow(player, committed, prepared)
refresh(position)
}
}
return QueueResetPreparation(updated, playbackDispatcher) { committed ->
beginLibraryResetPublication(committed)
publishRebuiltWindow(player, committed, ordered, index, position)
refresh(position)
} }
refresh(position)
return
} }
if (player.itemCount == 0) { if (player.itemCount == 0) {
if (updated.size == 0L) { if (updated.size == 0L) {
stopLocked() return QueueResetPreparation(updated, playbackDispatcher) { committed ->
} else { beginLibraryResetPublication(committed)
loadWindow(0, autoplay = false) stopLocked()
}
}
val prepared = prepareWindow(updated, 0, autoplay = false, startPositionMs = 0)
return QueueResetPreparation(updated, playbackDispatcher) { committed ->
beginLibraryResetPublication(committed)
if (prepared == null) stopLocked() else publishWindow(player, committed, prepared)
} }
return
} }
val nearest = (current?.position ?: lastPosition ?: 0L).coerceIn(0, (updated.size - 1).coerceAtLeast(0)) val nearest = (current?.position ?: lastPosition ?: 0L).coerceIn(0, (updated.size - 1).coerceAtLeast(0))
rebuildDetachedSurvivors(updated, nearest) val window = prepareDetachedWindow(updated, nearest)
queueRepository.setCursor(lastPosition ?: 0L) queueRepository.setCursor(window.firstOrNull()?.position ?: 0L)
refresh(lastPosition) return QueueResetPreparation(updated, playbackDispatcher) { committed ->
beginLibraryResetPublication(committed)
publishDetachedSurvivors(player, committed, nearest, window)
refresh(lastPosition)
}
}
private fun beginLibraryResetPublication(updated: QueueSnapshot) {
lastEdit = null
windowJob?.cancel()
endedHandled = false
shuffleEnabled = updated.shuffled
layoutVersion++
snapshot = updated
} }
private fun trimToCurrent(player: PlayerHandle) { private fun trimToCurrent(player: PlayerHandle) {
@@ -1554,6 +1606,16 @@ class PlaybackController(
loadWindow(position, autoplay = player.playWhenReady, startPositionMs = player.currentPositionMs) loadWindow(position, autoplay = player.playWhenReady, startPositionMs = player.currentPositionMs)
return return
} }
publishRebuiltWindow(player, snapshot, ordered, index, position)
}
private fun publishRebuiltWindow(
player: PlayerHandle,
snapshot: QueueSnapshot,
ordered: List<QueueWindowItem>,
index: Int,
position: Long,
) {
// A track downloaded while it was streaming still holds a stream URL; fix the current item's // A track downloaded while it was streaming still holds a stream URL; fix the current item's
// source in place (without resetting the window) so an offline switch can play the local file. // source in place (without resetting the window) so an offline switch can play the local file.
val current = ordered[index] val current = ordered[index]
@@ -1594,11 +1656,27 @@ class PlaybackController(
position: Long, position: Long,
) { ) {
val player = player ?: return val player = player ?: return
val window = prepareDetachedWindow(snapshot, position)
publishDetachedSurvivors(player, snapshot, position, window)
}
private suspend fun prepareDetachedWindow(
snapshot: QueueSnapshot,
position: Long,
): List<QueueWindowItem> {
val nearest = position.coerceIn(0, (snapshot.size - 1).coerceAtLeast(0))
return queueRepository
.range(snapshot, nearest, (nearest + QUEUE_WINDOW_RADIUS).coerceAtMost(snapshot.size - 1))
.filter { !offline || downloads.localUri(it.item.song.id) != null }
}
private fun publishDetachedSurvivors(
player: PlayerHandle,
snapshot: QueueSnapshot,
position: Long,
window: List<QueueWindowItem>,
) {
val nearest = position.coerceIn(0, (snapshot.size - 1).coerceAtLeast(0)) val nearest = position.coerceIn(0, (snapshot.size - 1).coerceAtLeast(0))
val window =
queueRepository
.range(snapshot, nearest, (nearest + QUEUE_WINDOW_RADIUS).coerceAtMost(snapshot.size - 1))
.filter { !offline || downloads.localUri(it.item.song.id) != null }
trimToCurrent(player) trimToCurrent(player)
updating = true updating = true
try { try {
@@ -20,6 +20,7 @@ import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.NetworkServerActionSink import com.subtracks.data.repo.NetworkServerActionSink
import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.QueueResetPreparation
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.repo.replace import com.subtracks.data.repo.replace
import com.subtracks.data.repo.setShuffle import com.subtracks.data.repo.setShuffle
@@ -288,7 +289,10 @@ class SyncManagerTest {
val before = queueRepository.snapshot() val before = queueRepository.snapshot()
val cacheVersion = queueRepository.libraryCacheVersion() val cacheVersion = queueRepository.libraryCacheVersion()
val resetAttempts = AtomicInteger() val resetAttempts = AtomicInteger()
queueRepository.setResetListener { resetAttempts.incrementAndGet() } queueRepository.setResetListener { reset ->
resetAttempts.incrementAndGet()
QueueResetPreparation(reset.snapshot)
}
failPendingOriginClear() failPendingOriginClear()
manager.requestSync() manager.requestSync()
@@ -334,12 +338,15 @@ class SyncManagerTest {
server.dispatcher = healthyDispatcher() server.dispatcher = healthyDispatcher()
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false) sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
queueRepository.replace(listOf(queueRepository.albumEntry(1, "al1").copy(rangeStart = 0, rangeEnd = 0))) queueRepository.replace(listOf(queueRepository.albumEntry(1, "al1").copy(rangeStart = 0, rangeEnd = 0)))
val before = queueRepository.snapshot()
val cacheVersion = queueRepository.libraryCacheVersion()
val resetAttempted = CountDownLatch(1) val resetAttempted = CountDownLatch(1)
val resetAttempts = AtomicInteger() val resetAttempts = AtomicInteger()
queueRepository.setResetListener { queueRepository.setResetListener { reset ->
queueRepository.setPosition(42_000L) queueRepository.setPosition(42_000L)
resetAttempted.countDown() resetAttempted.countDown()
if (resetAttempts.incrementAndGet() == 1) throw IllegalStateException("reconciliation failed") if (resetAttempts.incrementAndGet() == 1) throw IllegalStateException("reconciliation failed")
QueueResetPreparation(reset.snapshot)
} }
manager.requestSync() manager.requestSync()
@@ -356,6 +363,15 @@ class SyncManagerTest {
.entry.rangeStart, .entry.rangeStart,
) )
assertTrue(db.queueDao().pendingSyncOrigins(1).isNotEmpty()) assertTrue(db.queueDao().pendingSyncOrigins(1).isNotEmpty())
assertEquals(
listOf(before.version, before.resetVersion, cacheVersion, 0L),
listOf(
queueRepository.contentVersion(),
queueRepository.snapshot().resetVersion,
queueRepository.libraryCacheVersion(),
queueRepository.cursorPositionMs(),
),
)
manager.requestSync() manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.drop(1).first { it is SyncStatus.Success } } withTimeout(TEST_TIMEOUT_MS) { manager.status.drop(1).first { it is SyncStatus.Success } }
@@ -371,6 +387,58 @@ class SyncManagerTest {
assertTrue(db.queueDao().pendingSyncOrigins(1).isEmpty()) assertTrue(db.queueDao().pendingSyncOrigins(1).isEmpty())
} }
@Test
fun aQueueWriteFailureAfterPendingClearRollsBackWithoutPublishing() =
runBlocking {
server.dispatcher = healthyDispatcher()
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
queueRepository.replace(listOf(queueRepository.albumEntry(1, "al1").copy(rangeStart = 0, rangeEnd = 0)))
val before = queueRepository.snapshot()
val cacheVersion = queueRepository.libraryCacheVersion()
val resetAttempts = AtomicInteger()
queueRepository.setResetListener { reset ->
resetAttempts.incrementAndGet()
QueueResetPreparation(reset.snapshot)
}
failQueueResetInsert()
manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertEquals(
0L,
queueRepository
.snapshot()
.entries
.single()
.entry.rangeStart,
)
assertTrue(db.queueDao().pendingSyncOrigins(1).isNotEmpty())
assertEquals(
listOf(0L, before.version, before.resetVersion, cacheVersion),
listOf(
resetAttempts.get().toLong(),
queueRepository.contentVersion(),
queueRepository.snapshot().resetVersion,
queueRepository.libraryCacheVersion(),
),
)
allowQueueResetInsert()
manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.drop(1).first { it is SyncStatus.Success } }
assertEquals(1, resetAttempts.get())
assertNull(
queueRepository
.snapshot()
.entries
.single()
.entry.rangeStart,
)
assertTrue(db.queueDao().pendingSyncOrigins(1).isEmpty())
}
@Test @Test
fun aFailedSyncPreservesResolvedQueueShuffle() = fun aFailedSyncPreservesResolvedQueueShuffle() =
runBlocking { runBlocking {
@@ -527,6 +595,21 @@ class SyncManagerTest {
} }
} }
private suspend fun failQueueResetInsert() {
db.useWriterConnection { connection ->
connection.usePrepared(
"CREATE TRIGGER fail_queue_reset_insert BEFORE INSERT ON queue_entries " +
"BEGIN SELECT RAISE(FAIL, 'queue reset insert failed'); END",
) { it.step() }
}
}
private suspend fun allowQueueResetInsert() {
db.useWriterConnection { connection ->
connection.usePrepared("DROP TRIGGER fail_queue_reset_insert") { it.step() }
}
}
private fun healthyDispatcher() = private fun healthyDispatcher() =
object : Dispatcher() { object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse = override fun dispatch(request: RecordedRequest): MockResponse =
@@ -3726,14 +3726,6 @@ class PlaybackControllerTest {
controller.playPlaylist(1, "p1", 2) controller.playPlaylist(1, "p1", 2)
await { handle.currentItem?.id == "s3" && controller.state.value.isPlaying } await { handle.currentItem?.id == "s3" && controller.state.value.isPlaying }
val point = runBlocking { queues.captureResetPoint() } val point = runBlocking { queues.captureResetPoint() }
val initialId =
runBlocking {
db
.queueDao()
.entries()
.single()
.id
}
val dispatcherBlocked = CountDownLatch(1) val dispatcherBlocked = CountDownLatch(1)
val releaseDispatcher = CountDownLatch(1) val releaseDispatcher = CountDownLatch(1)
controllerScope.launch { controllerScope.launch {
@@ -3745,22 +3737,10 @@ class PlaybackControllerTest {
controller.seekTo(42_000L) controller.seekTo(42_000L)
setPlaylist("s1", "s2", "s4", "s5") setPlaylist("s1", "s2", "s4", "s5")
val reset = val reset =
repoScope.launch { repoScope.launch(start = CoroutineStart.UNDISPATCHED) {
queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
} }
try { releaseDispatcher.countDown()
await {
runBlocking {
db
.queueDao()
.entries()
.single()
.id != initialId
}
}
} finally {
releaseDispatcher.countDown()
}
runBlocking { withTimeout(TEST_TIMEOUT_MS) { reset.join() } } runBlocking { withTimeout(TEST_TIMEOUT_MS) { reset.join() } }
Thread.sleep(200) Thread.sleep(200)
@@ -3998,19 +3978,9 @@ class PlaybackControllerTest {
assertTrue(dispatcherBlocked.await(TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)) assertTrue(dispatcherBlocked.await(TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS))
handle.advanceTo(handle.currentIndex + 1) handle.advanceTo(handle.currentIndex + 1)
val reset = val reset =
repoScope.launch { repoScope.launch(start = CoroutineStart.UNDISPATCHED) {
queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
} }
await {
runBlocking {
db
.queueDao()
.upNextEntries()
.single()
.rangeStart == null
}
}
releaseDispatcher.countDown() releaseDispatcher.countDown()
runBlocking { withTimeout(TEST_TIMEOUT_MS) { reset.join() } } runBlocking { withTimeout(TEST_TIMEOUT_MS) { reset.join() } }
Thread.sleep(200) Thread.sleep(200)