NavidromeApp/iOS/Data/OptimisticActionQueue.swift
Dallas Groot 0730fa11f8 bug fixes
2026-04-11 15:09:06 -07:00

182 lines
6.1 KiB
Swift

import Foundation
/// Handles optimistic UI updates mutations happen locally first,
/// then get pushed to the server with automatic retry.
///
/// Example: starring a song updates the local cache immediately,
/// then queues the API call. If the API fails (offline), it retries
/// on next sync or when connectivity returns.
class OptimisticActionQueue: ObservableObject {
static let shared = OptimisticActionQueue()
/// Pending actions that haven't been confirmed by the server
@Published var pendingCount: Int = 0
private var pendingActions: [PendingAction] = []
private let storageKey = "optimistic_pending_actions"
private var retryTask: Task<Void, Never>?
private init() {
loadPending()
}
// MARK: - Star / Unstar
/// Star a song optimistically updates cache immediately, queues API call
func star(songId: String) {
queueAction(.star(id: songId, type: .song))
DebugLogger.shared.log("⭐ Queued: star \(songId)", category: "Sync", level: .info)
}
func unstar(songId: String) {
// Check if there's a pending star for this just remove it instead
if removePending(matching: .star(id: songId, type: .song)) {
DebugLogger.shared.log("⭐ Cancelled pending star for \(songId)", category: "Sync", level: .info)
return
}
queueAction(.unstar(id: songId, type: .song))
DebugLogger.shared.log("⭐ Queued: unstar \(songId)", category: "Sync", level: .info)
}
func starAlbum(albumId: String) {
queueAction(.star(id: albumId, type: .album))
}
func unstarAlbum(albumId: String) {
if removePending(matching: .star(id: albumId, type: .album)) { return }
queueAction(.unstar(id: albumId, type: .album))
}
// MARK: - Scrobble
func scrobble(songId: String) {
queueAction(.scrobble(id: songId, timestamp: Int64(Date().timeIntervalSince1970 * 1000)))
}
// MARK: - Process Queue
/// Try to flush all pending actions to the server.
/// Call this on app launch, on connectivity change, or after sync.
func flush() {
guard !pendingActions.isEmpty else { return }
retryTask?.cancel()
// @MainActor ensures pendingActions is only touched on the main thread
// eliminates the data race between queueAction (main) and the flush Task (pool).
retryTask = Task { @MainActor [weak self] in
guard let self else { return }
let actions = self.pendingActions
var remaining: [PendingAction] = []
for action in actions {
do {
try await self.execute(action)
DebugLogger.shared.log("✓ Flushed: \(action.description)", category: "Sync", level: .info)
} catch {
if action.retryCount < 5 {
var retry = action
retry.retryCount += 1
remaining.append(retry)
DebugLogger.shared.log("↩ Retry \(retry.retryCount)/5: \(action.description)\(error.localizedDescription)", category: "Sync", level: .warning)
} else {
DebugLogger.shared.log("✗ Dropped after 5 retries: \(action.description)", category: "Sync", level: .error)
}
}
}
self.pendingActions = remaining
self.pendingCount = remaining.count
self.savePending()
}
}
// MARK: - Execution
private func execute(_ action: PendingAction) async throws {
let client = ServerManager.shared.client
switch action.kind {
case .star(let id, let type):
switch type {
case .song: try await client.star(id: id)
case .album: try await client.star(albumId: id)
case .artist: try await client.star(artistId: id)
}
case .unstar(let id, let type):
switch type {
case .song: try await client.unstar(id: id)
case .album: try await client.unstar(albumId: id)
case .artist: try await client.unstar(artistId: id)
}
case .scrobble(let id, let timestamp):
try await client.scrobble(id: id, time: timestamp)
}
}
// MARK: - Queue Management
private func queueAction(_ kind: ActionKind) {
let action = PendingAction(kind: kind, createdAt: Date(), retryCount: 0)
pendingActions.append(action)
pendingCount = pendingActions.count
savePending()
// Try immediately
flush()
}
private func removePending(matching kind: ActionKind) -> Bool {
if let idx = pendingActions.firstIndex(where: { $0.kind == kind }) {
pendingActions.remove(at: idx)
pendingCount = pendingActions.count
savePending()
return true
}
return false
}
// MARK: - Persistence
private func savePending() {
if let data = try? JSONEncoder().encode(pendingActions) {
UserDefaults.standard.set(data, forKey: storageKey)
}
}
private func loadPending() {
if let data = UserDefaults.standard.data(forKey: storageKey),
let actions = try? JSONDecoder().decode([PendingAction].self, from: data) {
pendingActions = actions
pendingCount = actions.count
}
}
}
// MARK: - Models
struct PendingAction: Codable {
let kind: ActionKind
let createdAt: Date
var retryCount: Int
var description: String {
switch kind {
case .star(let id, let type): return "star \(type.rawValue) \(id)"
case .unstar(let id, let type): return "unstar \(type.rawValue) \(id)"
case .scrobble(let id, _): return "scrobble \(id)"
}
}
}
enum ActionKind: Codable, Equatable {
case star(id: String, type: ItemType)
case unstar(id: String, type: ItemType)
case scrobble(id: String, timestamp: Int64)
enum ItemType: String, Codable {
case song, album, artist
}
}