mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-15 00:42:54 +01:00
sync: make queue reset publication recoverable
This commit is contained in:
@@ -11,8 +11,11 @@ import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.QueueOrigin
|
||||
import com.subtracks.data.model.SongItem
|
||||
import com.subtracks.data.model.UpNextEntry
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -193,6 +196,7 @@ data class QueueResetPreparation(
|
||||
val snapshot: QueueSnapshot,
|
||||
val publicationContext: CoroutineContext = EmptyCoroutineContext,
|
||||
val publish: QueueRepository.QueueMutationScope.(QueueSnapshot) -> Unit = {},
|
||||
val recover: QueueRepository.QueueMutationScope.(QueueSnapshot) -> Unit = {},
|
||||
)
|
||||
|
||||
private class CursorLockContext : AbstractCoroutineContextElement(CursorLockContext) {
|
||||
@@ -633,7 +637,9 @@ class QueueRepository(
|
||||
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 }) {
|
||||
val matchingOrigin =
|
||||
before.entries.any { it.entry.origin in changed } || before.upNext.any { it.entry.origin in changed }
|
||||
if (matchingOrigin) {
|
||||
writeResetLocked(before, entries, upNext, point, pendingOriginSourceId)
|
||||
return
|
||||
}
|
||||
@@ -653,6 +659,8 @@ class QueueRepository(
|
||||
point: QueueResetPoint,
|
||||
pendingOriginSourceId: Long? = null,
|
||||
) {
|
||||
val callerJob = currentCoroutineContext()[Job]
|
||||
withContext(NonCancellable) {
|
||||
val preparation =
|
||||
withCursorLock {
|
||||
withContext(ResetPreparationContext()) {
|
||||
@@ -716,8 +724,8 @@ class QueueRepository(
|
||||
shuffleSize = shuffle?.domain ?: 0L,
|
||||
),
|
||||
)
|
||||
resetListener?.invoke(this@writeResetLocked, QueueReset(point, updated))
|
||||
?: QueueResetPreparation(updated)
|
||||
(resetListener?.invoke(this@writeResetLocked, QueueReset(point, updated)) ?: QueueResetPreparation(updated))
|
||||
.also { callerJob?.ensureActive() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -726,9 +734,15 @@ class QueueRepository(
|
||||
queueVersion.incrementAndGet()
|
||||
resetVersion.incrementAndGet()
|
||||
val published = preparation.snapshot.copy(version = queueVersion.get(), resetVersion = resetVersion.get())
|
||||
withContext(preparation.publicationContext + NonCancellable) {
|
||||
runCatching {
|
||||
withContext(preparation.publicationContext) {
|
||||
preparation.publish(this@writeResetLocked, published)
|
||||
}
|
||||
}.onFailure { failure ->
|
||||
Log.w("queue", "reset publication failed; recovering from committed state", failure)
|
||||
runCatching { preparation.recover(this@writeResetLocked, published) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun remapAnchor(
|
||||
|
||||
@@ -49,6 +49,24 @@ data class QueueItem(
|
||||
val resetItem: QueueResetItem? = null,
|
||||
)
|
||||
|
||||
private data class PlayerGeneration(
|
||||
val itemId: String?,
|
||||
val index: Int,
|
||||
val resetItem: QueueResetItem?,
|
||||
) {
|
||||
companion object {
|
||||
fun from(player: PlayerHandle): PlayerGeneration {
|
||||
val item = player.currentItem
|
||||
return PlayerGeneration(item?.id, player.currentIndex, item?.resetItem)
|
||||
}
|
||||
}
|
||||
|
||||
fun matches(player: PlayerHandle): Boolean {
|
||||
val item = player.currentItem
|
||||
return item?.id == itemId && player.currentIndex == index && item?.resetItem == resetItem
|
||||
}
|
||||
}
|
||||
|
||||
enum class RepeatMode { Off, All, One }
|
||||
|
||||
data class PlaybackState(
|
||||
@@ -1450,6 +1468,7 @@ class PlaybackController(
|
||||
val player = player ?: return QueueResetPreparation(reset.snapshot)
|
||||
val previous = snapshot ?: return QueueResetPreparation(reset.snapshot)
|
||||
if (previous.version > reset.snapshot.version) return QueueResetPreparation(reset.snapshot)
|
||||
val generation = PlayerGeneration.from(player)
|
||||
var current = reset.point.current
|
||||
val actualItem = player.currentItem
|
||||
val actualId = actualItem?.id
|
||||
@@ -1484,7 +1503,7 @@ class PlaybackController(
|
||||
queueRepository.setCursor(position)
|
||||
if (detachedWindow && currentPosition() == null) {
|
||||
val window = prepareDetachedWindow(updated, position)
|
||||
return QueueResetPreparation(updated, playbackDispatcher) { committed ->
|
||||
return resetPreparation(updated, generation) { committed ->
|
||||
beginLibraryResetPublication(committed)
|
||||
publishDetachedSurvivors(player, committed, position, window)
|
||||
refresh(position)
|
||||
@@ -1495,28 +1514,32 @@ class PlaybackController(
|
||||
val index = ordered.indexOfFirst { it.position == position }
|
||||
if (index < 0) {
|
||||
val prepared = prepareWindow(updated, position, player.playWhenReady, player.currentPositionMs)
|
||||
return QueueResetPreparation(updated, playbackDispatcher) { committed ->
|
||||
return resetPreparation(updated, generation) { committed ->
|
||||
beginLibraryResetPublication(committed)
|
||||
if (prepared == null) stopLocked() else publishWindow(player, committed, prepared)
|
||||
refresh(position)
|
||||
}
|
||||
}
|
||||
return QueueResetPreparation(updated, playbackDispatcher) { committed ->
|
||||
return resetPreparation(updated, generation) { committed ->
|
||||
beginLibraryResetPublication(committed)
|
||||
publishRebuiltWindow(player, committed, ordered, index, position)
|
||||
publishWindow(
|
||||
player,
|
||||
committed,
|
||||
PreparedWindow(ordered, position, index, player.playWhenReady, player.currentPositionMs),
|
||||
)
|
||||
refresh(position)
|
||||
}
|
||||
}
|
||||
|
||||
if (player.itemCount == 0) {
|
||||
if (updated.size == 0L) {
|
||||
return QueueResetPreparation(updated, playbackDispatcher) { committed ->
|
||||
return resetPreparation(updated, generation) { committed ->
|
||||
beginLibraryResetPublication(committed)
|
||||
stopLocked()
|
||||
}
|
||||
}
|
||||
val prepared = prepareWindow(updated, 0, autoplay = false, startPositionMs = 0)
|
||||
return QueueResetPreparation(updated, playbackDispatcher) { committed ->
|
||||
return resetPreparation(updated, generation) { committed ->
|
||||
beginLibraryResetPublication(committed)
|
||||
if (prepared == null) stopLocked() else publishWindow(player, committed, prepared)
|
||||
}
|
||||
@@ -1524,13 +1547,46 @@ class PlaybackController(
|
||||
val nearest = (current?.position ?: lastPosition ?: 0L).coerceIn(0, (updated.size - 1).coerceAtLeast(0))
|
||||
val window = prepareDetachedWindow(updated, nearest)
|
||||
queueRepository.setCursor(window.firstOrNull()?.position ?: 0L)
|
||||
return QueueResetPreparation(updated, playbackDispatcher) { committed ->
|
||||
return resetPreparation(updated, generation) { committed ->
|
||||
beginLibraryResetPublication(committed)
|
||||
publishDetachedSurvivors(player, committed, nearest, window)
|
||||
refresh(lastPosition)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetPreparation(
|
||||
updated: QueueSnapshot,
|
||||
generation: PlayerGeneration,
|
||||
publish: QueueMutationScope.(QueueSnapshot) -> Unit,
|
||||
) = QueueResetPreparation(
|
||||
updated,
|
||||
playbackDispatcher,
|
||||
publish = { committed ->
|
||||
if (generation.matches(requireNotNull(player))) {
|
||||
publish(committed)
|
||||
} else {
|
||||
recoverLibraryReset()
|
||||
}
|
||||
},
|
||||
recover = { recoverLibraryReset() },
|
||||
)
|
||||
|
||||
private fun recoverLibraryReset() {
|
||||
scope.launch {
|
||||
queueRepository.withMutationLock {
|
||||
val player = player ?: return@withMutationLock
|
||||
val autoplay = player.playWhenReady
|
||||
val latest = queueRepository.snapshot()
|
||||
stopLocked()
|
||||
this@PlaybackController.snapshot = latest
|
||||
if (latest.size > 0L) {
|
||||
val position = queueRepository.cursor().coerceIn(0, latest.size - 1)
|
||||
loadWindow(position, autoplay = autoplay, startPositionMs = 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun beginLibraryResetPublication(updated: QueueSnapshot) {
|
||||
lastEdit = null
|
||||
windowJob?.cancel()
|
||||
|
||||
@@ -474,6 +474,25 @@ class QueueRepositoryTest {
|
||||
repository.removeAt(snapshot, 0)
|
||||
|
||||
assertEquals(listOf("s2", "s3"), resolveAll(repository.snapshot()))
|
||||
fun aMatchingOriginStillPublishesWhenItsEntriesDoNotChange() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.libraryDao().upsertSongs(listOf(song("s0", "al1", track = 0, album = "First Album")))
|
||||
setPlaylist("p1", "s1", "s2", "s3")
|
||||
repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
|
||||
repository.setCursor(1)
|
||||
val point = repository.captureResetPoint()
|
||||
var resets = 0
|
||||
repository.setResetListener {
|
||||
resets++
|
||||
QueueResetPreparation(it.snapshot)
|
||||
}
|
||||
|
||||
setPlaylist("p1", "s0", "s1", "s2", "s3")
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
|
||||
|
||||
assertEquals(1, resets)
|
||||
assertEquals(2L, repository.cursor())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -387,6 +387,42 @@ class SyncManagerTest {
|
||||
assertTrue(db.queueDao().pendingSyncOrigins(1).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPublicationFailureRecoversFromCommittedReset() =
|
||||
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 recoveries = AtomicInteger()
|
||||
queueRepository.setResetListener { reset ->
|
||||
QueueResetPreparation(
|
||||
reset.snapshot,
|
||||
publish = { throw IllegalStateException("publish failed") },
|
||||
recover = { published ->
|
||||
assertEquals(before.version + 1, published.version)
|
||||
assertEquals(before.resetVersion + 1, published.resetVersion)
|
||||
recoveries.incrementAndGet()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
manager.requestSync()
|
||||
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Success } }
|
||||
|
||||
assertEquals(1, recoveries.get())
|
||||
assertNull(
|
||||
queueRepository
|
||||
.snapshot()
|
||||
.entries
|
||||
.single()
|
||||
.entry.rangeStart,
|
||||
)
|
||||
assertTrue(db.queueDao().pendingSyncOrigins(1).isEmpty())
|
||||
assertEquals(before.version + 1, queueRepository.contentVersion())
|
||||
assertEquals(before.resetVersion + 1, queueRepository.snapshot().resetVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aQueueWriteFailureAfterPendingClearRollsBackWithoutPublishing() =
|
||||
runBlocking {
|
||||
|
||||
@@ -48,6 +48,9 @@ class FakePlayerHandle : PlayerHandle {
|
||||
@Volatile
|
||||
var beforeRemoveFirst: (() -> Unit)? = null
|
||||
|
||||
@Volatile
|
||||
var beforeCurrentItem: (() -> Unit)? = null
|
||||
|
||||
@Volatile
|
||||
var roundTripItemsThroughMedia3 = false
|
||||
|
||||
@@ -86,6 +89,7 @@ class FakePlayerHandle : PlayerHandle {
|
||||
|
||||
override val currentItem: QueueItem?
|
||||
get() {
|
||||
beforeCurrentItem?.invoke()
|
||||
checkThread()
|
||||
return items.getOrNull(index)
|
||||
}
|
||||
|
||||
@@ -3598,6 +3598,36 @@ class PlaybackControllerTest {
|
||||
assertEquals(listOf("s0", "s1", "s2", "s3", "s4", "s5"), handle.items.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTransitionBetweenResetPreparationAndPublicationRebuildsFromCommittedState() {
|
||||
seedPlaylist(5)
|
||||
seedSong("s0", "al1")
|
||||
controller.playPlaylist(1, "p1", 2)
|
||||
await { handle.currentItem?.id == "s3" && controller.state.value.isPlaying }
|
||||
val point = runBlocking { queues.captureResetPoint() }
|
||||
var currentItemReads = 0
|
||||
handle.beforeCurrentItem = {
|
||||
currentItemReads++
|
||||
if (currentItemReads == 2) {
|
||||
handle.beforeCurrentItem = null
|
||||
handle.advanceTo(3)
|
||||
}
|
||||
}
|
||||
handle.beforeRemoveFirst = {
|
||||
handle.beforeRemoveFirst = null
|
||||
handle.advanceTo(3)
|
||||
}
|
||||
|
||||
setPlaylist("s0", "s1", "s2", "s3", "s4", "s5")
|
||||
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
|
||||
|
||||
await {
|
||||
handle.currentItem?.id == "s4" && controller.state.value.position == 3L &&
|
||||
handle.items.size == 5 && handle.items.distinctBy { it.id }.size == 5
|
||||
}
|
||||
assertEquals(listOf("s1", "s2", "s3", "s4", "s5"), handle.items.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSourceResetLetsTheRemovedCurrentFinishBeforeTheClampedTrack() {
|
||||
seedPlaylist(5)
|
||||
|
||||
Reference in New Issue
Block a user