import Foundation // ────────────────────────────────────────────────────────────────────── // PendingOperationsQueue.swift // Persistent queue for companion API operations that failed due to // network issues. Retries automatically when the WebSocket reconnects. // Stored as JSON in Documents so it survives app restarts. // ────────────────────────────────────────────────────────────────────── struct PendingOperation: Codable, Identifiable { let id: UUID let type: OperationType let payload: [String: String] let createdAt: Date var retryCount: Int let maxRetries: Int enum OperationType: String, Codable { case metadataEdit // PATCH /batch-edit-metadata case coverArtUpload // POST /library/cover-art-by-path case coverArtDelete // DELETE /library/cover-art/{id} case tagCleanup // POST /tag-cleanup } init(type: OperationType, payload: [String: String], maxRetries: Int = 5) { self.id = UUID() self.type = type self.payload = payload self.createdAt = Date() self.retryCount = 0 self.maxRetries = maxRetries } var isExpired: Bool { retryCount >= maxRetries } var displayDescription: String { switch type { case .metadataEdit: return "Edit: \(payload["album"] ?? payload["artist"] ?? "metadata")" case .coverArtUpload: return "Cover art: \(payload["path"] ?? "upload")" case .coverArtDelete: return "Remove cover: \(payload["songId"] ?? "")" case .tagCleanup: return "Tag cleanup: \(payload["path"] ?? "")" } } } class PendingOperationsQueue: ObservableObject { static let shared = PendingOperationsQueue() @Published private(set) var operations: [PendingOperation] = [] private let fileURL: URL private var isProcessing = false var count: Int { operations.count } var isEmpty: Bool { operations.isEmpty } private init() { let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! fileURL = docs.appendingPathComponent("pending_operations.json") loadFromDisk() } // MARK: - Enqueue func enqueue(_ op: PendingOperation) { operations.append(op) saveToDisk() DebugLogger.shared.log( "Pending op queued: \(op.type.rawValue) (\(operations.count) total)", category: "PendingOps" ) } /// Convenience: enqueue a metadata edit that failed. func enqueueMetadataEdit(paths: [String], tags: [String: String]) { var payload = tags payload["_paths"] = paths.joined(separator: "\n") enqueue(PendingOperation(type: .metadataEdit, payload: payload)) } /// Convenience: enqueue a cover art upload that failed. func enqueueCoverArtUpload(relativePath: String) { enqueue(PendingOperation( type: .coverArtUpload, payload: ["path": relativePath] )) } // MARK: - Process /// Retry all pending operations. Call when WebSocket reconnects /// or when the app returns to foreground with a connection. func processAll() { guard !isProcessing, !operations.isEmpty else { return } isProcessing = true DebugLogger.shared.log( "Processing \(operations.count) pending operations", category: "PendingOps" ) Task { var remaining: [PendingOperation] = [] for var op in operations { if op.isExpired { DebugLogger.shared.log( "Dropping expired op: \(op.displayDescription) (\(op.retryCount) retries)", category: "PendingOps" ) continue } let success = await retryOperation(op) if !success { op.retryCount += 1 remaining.append(op) } } // Copy to let for safe capture in MainActor.run (Swift 6 concurrency) let finalRemaining = remaining await MainActor.run { self.operations = finalRemaining self.saveToDisk() self.isProcessing = false if finalRemaining.isEmpty { DebugLogger.shared.log("All pending ops completed", category: "PendingOps") } else { DebugLogger.shared.log( "\(finalRemaining.count) ops still pending after retry", category: "PendingOps" ) } } } } private func retryOperation(_ op: PendingOperation) async -> Bool { let api = CompanionAPIService.shared switch op.type { case .metadataEdit: let paths = (op.payload["_paths"] ?? "").split(separator: "\n").map(String.init) guard !paths.isEmpty else { return true } // nothing to do = success var tags: [String: String] = op.payload tags.removeValue(forKey: "_paths") let request = BatchMetadataEditRequest( relativePaths: paths, title: tags["title"], artist: tags["artist"], album: tags["album"], albumArtist: tags["album_artist"], genre: tags["genre"], year: tags["year"].flatMap { Int($0) } ) do { _ = try await api.batchEditMetadata(request) return true } catch { return false } case .coverArtUpload: // Cover art data isn't persisted in the queue (too large). // Mark as expired — user will need to re-apply. return false case .coverArtDelete: guard let songId = op.payload["songId"] else { return true } do { try await api.deleteCoverArt(songId: songId) return true } catch { return false } case .tagCleanup: // Tag cleanup is idempotent — safe to retry return false // Not implemented yet } } // MARK: - Remove func remove(_ op: PendingOperation) { operations.removeAll { $0.id == op.id } saveToDisk() } func clearAll() { operations.removeAll() saveToDisk() } // MARK: - Persistence private func saveToDisk() { if let data = try? JSONEncoder().encode(operations) { try? data.write(to: fileURL, options: .atomic) } } func loadFromDisk() { guard let data = try? Data(contentsOf: fileURL), let ops = try? JSONDecoder().decode([PendingOperation].self, from: data) else { return } operations = ops } // MARK: - Export/Import (for backup system) func exportData() -> Data? { try? JSONEncoder().encode(operations) } func importData(_ data: Data) { guard let ops = try? JSONDecoder().decode([PendingOperation].self, from: data) else { return } operations = ops saveToDisk() } }