Serialize playlist mutations and re-resolve on credential change

Two changes to a server-side playlist could overlap: the second planned its
write from the snapshot the first had already invalidated, and on Piped —
where a row is a position — its preflight then passed over whatever had
shifted into the slot. Mutations now hold a per-playlist queue from the write
to the end of the refetch that reconciles it. Creation queues on the account's
list instead: it depends on no playlist's contents, and one queue per account
would only make unrelated changes wait.

The playlist detail view kept the authenticated snapshot in its own state, so
signing in as somebody else — or a change of Apple account — left the previous
login's rows, title and destructive affordances on screen. It now discards them
the moment either lands and loads again, which re-resolves ownership and falls
back to the public endpoint.

Also documents two residuals rather than pretending to close them: equal rows
are indistinguishable to a positional API, so verify-before-remove cannot
protect a particular occurrence of a repeated video; and the credential recheck
is a fence, not a lock, because Keychain state cannot be made atomic with a
remote send.
This commit is contained in:
Arkadiusz Fal
2026-09-03 20:43:21 +02:00
parent 2ba9f57d4c
commit cb858edd34
4 changed files with 236 additions and 31 deletions

View File

@@ -287,6 +287,17 @@ struct UnifiedPlaylistDetailView: View {
.onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in
loadWatchEntries()
}
// Signing in again keeps the account, the source and usually the status,
// so nothing else this view watches would move — but the login behind an
// account-loaded playlist may now be somebody else.
.onReceive(NotificationCenter.default.publisher(for: .accountCredentialDidChange)) { _ in
discardAccountLoadedContentAndReload()
}
// The same, for a change of Apple account: every credential on the
// device belongs to an identity, and this one's are no longer readable.
.onReceive(NotificationCenter.default.publisher(for: .credentialIdentityScopeDidChange)) { _ in
discardAccountLoadedContentAndReload()
}
#if !os(tvOS)
.batchDownload(coordinator: downloadCoordinator)
.onAppear {
@@ -823,6 +834,37 @@ struct UnifiedPlaylistDetailView: View {
loadWatchEntries()
}
/// Throws away everything an account fetched here, and loads again.
///
/// Cleared first and synchronously, because the reload awaits a server:
/// until it answers, a playlist that may be private would otherwise still
/// be on screen — with its rows, its title and its destructive affordances
/// — under a credential that no longer belongs to the person who fetched
/// it. The reload resolves ownership again from scratch, and falls back to
/// the public endpoint when the account can no longer read the playlist.
///
/// Only for a playlist an account loaded: a public one shows nothing that
/// a login could make private, and reloading it would be a flash for
/// nothing.
private func discardAccountLoadedContentAndReload() {
guard case .remote = source, entries != nil || owningAccount != nil else { return }
entries = nil
entriesByRow = [:]
owningAccount = nil
remoteIsListed = nil
remotePlaylist = nil
title = ""
descriptionText = nil
thumbnailURL = nil
videos = []
videoCount = 0
errorMessage = nil
isLoading = true
Task { await loadPlaylist() }
}
private func loadWatchEntries() {
watchEntriesMap = dataManager?.watchEntriesMap() ?? [:]
}