183 lines
5.9 KiB
Swift
183 lines
5.9 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("Optimistic: starred song \(songId)", category: "Sync")
|
||
|
|
}
|
||
|
|
|
||
|
|
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("Optimistic: cancelled pending star for \(songId)", category: "Sync")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
queueAction(.unstar(id: songId, type: .song))
|
||
|
|
DebugLogger.shared.log("Optimistic: unstarred song \(songId)", category: "Sync")
|
||
|
|
}
|
||
|
|
|
||
|
|
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()
|
||
|
|
retryTask = Task { [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")
|
||
|
|
} catch {
|
||
|
|
if action.retryCount < 5 {
|
||
|
|
var retry = action
|
||
|
|
retry.retryCount += 1
|
||
|
|
remaining.append(retry)
|
||
|
|
} else {
|
||
|
|
DebugLogger.shared.log("Dropped after 5 retries: \(action.description)", category: "Sync")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let final = remaining
|
||
|
|
await MainActor.run {
|
||
|
|
self.pendingActions = final
|
||
|
|
self.pendingCount = final.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
|
||
|
|
}
|
||
|
|
}
|