mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-15 00:42:54 +01:00
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:
@@ -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,27 +643,22 @@ class QueueRepository(
|
|||||||
|
|
||||||
private suspend fun clearPendingSyncOriginsLocked(sourceId: Long) =
|
private suspend fun clearPendingSyncOriginsLocked(sourceId: Long) =
|
||||||
withCursorLock {
|
withCursorLock {
|
||||||
db.useWriterConnection { connection ->
|
|
||||||
connection.immediateTransaction {
|
|
||||||
dao.clearPendingSyncOrigins(sourceId)
|
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 {
|
||||||
|
withContext(ResetPreparationContext()) {
|
||||||
db.useWriterConnection { connection ->
|
db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
if (pendingOriginSourceId != null) dao.clearPendingSyncOrigins(pendingOriginSourceId)
|
if (pendingOriginSourceId != null) dao.clearPendingSyncOrigins(pendingOriginSourceId)
|
||||||
invalidateLibraryCache()
|
|
||||||
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 }) }
|
||||||
@@ -663,8 +668,8 @@ class QueueRepository(
|
|||||||
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 anchorChanged = before.upNextSize > 0L && before.upNextAnchor != point.upNextAnchor
|
||||||
val latestAnchor = if (anchorChanged) resetItemAt(before, before.anchorPlay) else null
|
val latestAnchor = if (anchorChanged) resetItemAt(before, before.anchorPlay) else null
|
||||||
@@ -711,21 +716,18 @@ class QueueRepository(
|
|||||||
shuffleSize = shuffle?.domain ?: 0L,
|
shuffleSize = shuffle?.domain ?: 0L,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (pendingOriginSourceId == null) {
|
resetListener?.invoke(this@writeResetLocked, QueueReset(point, updated))
|
||||||
updated
|
?: QueueResetPreparation(updated)
|
||||||
} else {
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
invalidateLibraryCache()
|
||||||
queueVersion.incrementAndGet()
|
queueVersion.incrementAndGet()
|
||||||
resetVersion.incrementAndGet()
|
resetVersion.incrementAndGet()
|
||||||
onReset(updated.copy(version = queueVersion.get(), resetVersion = resetVersion.get()))
|
val published = preparation.snapshot.copy(version = queueVersion.get(), resetVersion = resetVersion.get())
|
||||||
null
|
withContext(preparation.publicationContext + NonCancellable) {
|
||||||
}
|
preparation.publish(this@writeResetLocked, published)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (resetSnapshot != null) {
|
|
||||||
queueVersion.incrementAndGet()
|
|
||||||
resetVersion.incrementAndGet()
|
|
||||||
onReset(resetSnapshot.copy(version = queueVersion.get(), resetVersion = resetVersion.get()))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 requested =
|
val prepared = prepareWindow(snapshot, position, autoplay, startPositionMs)
|
||||||
if (offline) {
|
if (prepared == null) {
|
||||||
downloadedAtOrAfter(snapshot, position) ?: downloadedAtOrBefore(snapshot, position - 1) ?: run {
|
|
||||||
stopLocked()
|
stopLocked()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
publishWindow(player, snapshot, prepared)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun prepareWindow(
|
||||||
|
snapshot: QueueSnapshot,
|
||||||
|
position: Long,
|
||||||
|
autoplay: Boolean,
|
||||||
|
startPositionMs: Long,
|
||||||
|
): PreparedWindow? {
|
||||||
|
val requested =
|
||||||
|
if (offline) {
|
||||||
|
downloadedAtOrAfter(snapshot, position) ?: downloadedAtOrBefore(snapshot, position - 1) ?: return null
|
||||||
} 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,46 +1472,73 @@ 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)
|
refresh(position)
|
||||||
return
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (player.itemCount == 0) {
|
if (player.itemCount == 0) {
|
||||||
if (updated.size == 0L) {
|
if (updated.size == 0L) {
|
||||||
|
return QueueResetPreparation(updated, playbackDispatcher) { committed ->
|
||||||
|
beginLibraryResetPublication(committed)
|
||||||
stopLocked()
|
stopLocked()
|
||||||
} else {
|
|
||||||
loadWindow(0, autoplay = false)
|
|
||||||
}
|
}
|
||||||
return
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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)
|
||||||
|
return QueueResetPreparation(updated, playbackDispatcher) { committed ->
|
||||||
|
beginLibraryResetPublication(committed)
|
||||||
|
publishDetachedSurvivors(player, committed, nearest, window)
|
||||||
refresh(lastPosition)
|
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) {
|
||||||
updating = true
|
updating = true
|
||||||
@@ -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))
|
val nearest = position.coerceIn(0, (snapshot.size - 1).coerceAtLeast(0))
|
||||||
val window =
|
return queueRepository
|
||||||
queueRepository
|
|
||||||
.range(snapshot, nearest, (nearest + QUEUE_WINDOW_RADIUS).coerceAtMost(snapshot.size - 1))
|
.range(snapshot, nearest, (nearest + QUEUE_WINDOW_RADIUS).coerceAtMost(snapshot.size - 1))
|
||||||
.filter { !offline || downloads.localUri(it.item.song.id) != null }
|
.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))
|
||||||
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 {
|
|
||||||
await {
|
|
||||||
runBlocking {
|
|
||||||
db
|
|
||||||
.queueDao()
|
|
||||||
.entries()
|
|
||||||
.single()
|
|
||||||
.id != initialId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
releaseDispatcher.countDown()
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user