import Foundation import UIKit // MARK: - Companion API Settings class CompanionSettings: ObservableObject { static let shared = CompanionSettings() @Published var host: String { didSet { UserDefaults.standard.set(host, forKey: "companion_host") } } @Published var port: Int { didSet { UserDefaults.standard.set(port, forKey: "companion_port") } } @Published var isEnabled: Bool { didSet { UserDefaults.standard.set(isEnabled, forKey: "companion_enabled") if !isEnabled { CompanionPushClient.shared.disconnect() } } } @Published var smartDJEnabled: Bool { didSet { UserDefaults.standard.set(smartDJEnabled, forKey: "companion_smart_dj") } } var baseURL: URL? { URL(string: "http://\(host):\(port)") } private init() { host = UserDefaults.standard.string(forKey: "companion_host") ?? "192.168.1.100" port = UserDefaults.standard.integer(forKey: "companion_port").nonZero ?? 8000 isEnabled = UserDefaults.standard.bool(forKey: "companion_enabled") smartDJEnabled = UserDefaults.standard.bool(forKey: "companion_smart_dj") } } private extension Int { var nonZero: Int? { self == 0 ? nil : self } } // MARK: - Smart DJ Profile struct SmartDJProfile: Codable, Equatable { let bpm: Double? let silenceStart: Double? let silenceEnd: Double? let loudnessLUFS: Double? enum CodingKeys: String, CodingKey { case bpm case silenceStart = "silence_start" case silenceEnd = "silence_end" case loudnessLUFS = "loudness_lufs" } } // MARK: - Local Smart DJ Cache class SmartDJCache { static let shared = SmartDJCache() private let fileManager = FileManager.default private let cacheDir: URL private let bulkCacheURL: URL private var memoryCache: [String: SmartDJProfile] = [:] private init() { let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! cacheDir = caches.appendingPathComponent("SmartDJProfiles", isDirectory: true) bulkCacheURL = caches.appendingPathComponent("dj_profiles_bulk.json") try? fileManager.createDirectory(at: cacheDir, withIntermediateDirectories: true) } private func cacheURL(for relativePath: String) -> URL { let safe = relativePath .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: " ", with: "_") return cacheDir.appendingPathComponent("\(safe).json") } func get(_ relativePath: String) -> SmartDJProfile? { if let cached = memoryCache[relativePath] { return cached } let url = cacheURL(for: relativePath) guard let data = try? Data(contentsOf: url), let profile = try? JSONDecoder().decode(SmartDJProfile.self, from: data) else { return nil } memoryCache[relativePath] = profile return profile } func store(_ profile: SmartDJProfile, for relativePath: String) { memoryCache[relativePath] = profile let url = cacheURL(for: relativePath) if let data = try? JSONEncoder().encode(profile) { try? data.write(to: url, options: .atomic) } } // MARK: - Bulk Cache (single-file import/export for all profiles) /// Load the bulk cache file into memory. Call on app launch — one disk read, /// populates memoryCache so every subsequent get() is a zero-I/O memory hit. func loadBulkCache() { guard let data = try? Data(contentsOf: bulkCacheURL), let profiles = try? JSONDecoder().decode([String: SmartDJProfile].self, from: data) else { return } // Merge: individual stores (from cache misses) take priority over bulk for (path, profile) in profiles where memoryCache[path] == nil { memoryCache[path] = profile } DebugLogger.shared.log( "DJ bulk cache loaded: \(profiles.count) profiles from disk", category: "SmartDJ" ) } /// Import all profiles from the server export. Updates memory immediately, /// writes a single bulk JSON file to disk for next launch. func bulkImport(_ profiles: [String: SmartDJProfile]) { for (path, profile) in profiles { memoryCache[path] = profile } // Write single bulk file — one atomic write, no per-profile I/O if let data = try? JSONEncoder().encode(profiles) { try? data.write(to: bulkCacheURL, options: .atomic) } DebugLogger.shared.log( "DJ bulk import: \(profiles.count) profiles cached (\(memoryCache.count) total in memory)", category: "SmartDJ" ) } func clearAll() { memoryCache.removeAll() try? fileManager.removeItem(at: bulkCacheURL) if let files = try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) { for file in files { try? fileManager.removeItem(at: file) } } } var cachedCount: Int { memoryCache.count } } // MARK: - Metadata Edit Request (matches Python MetadataUpdate model) struct MetadataEditRequest: Codable { let relativePath: String var title: String? var artist: String? var album: String? var albumArtist: String? var genre: String? var year: Int? var trackNumber: Int? enum CodingKeys: String, CodingKey { case relativePath = "relative_path" case title, artist, album case albumArtist = "album_artist" case genre, year case trackNumber = "track_number" } } /// Request body for PATCH /batch-edit-metadata struct BatchMetadataEditRequest: Codable { let relativePaths: [String] var title: String? var artist: String? var album: String? var albumArtist: String? var genre: String? var year: Int? enum CodingKeys: String, CodingKey { case relativePaths = "relative_paths" case title, artist, album case albumArtist = "album_artist" case genre, year } } // MARK: - Upload Metadata (matches Python upload-track Form fields) struct UploadMetadata { var title: String var artist: String var album: String var albumArtist: String var genre: String var year: String var trackNumber: String var preserveComposer: Bool = false var preserveLyrics: Bool = false } // MARK: - Companion API Service actor CompanionAPIService { /// Shared singleton — use this instead of creating new instances per call-site. /// Each instance allocates its own URLSession; using the singleton ensures /// connection pooling across all Companion API requests. static let shared = CompanionAPIService() private let session: URLSession init() { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 30 config.timeoutIntervalForResource = 600 self.session = URLSession(configuration: config) } private func baseURL() throws -> URL { guard let url = CompanionSettings.shared.baseURL else { throw CompanionError.notConfigured } return url } // MARK: - Health Check (GET /health) func healthCheck() async throws -> Bool { let base = try baseURL() let url = base.appendingPathComponent("health") var req = URLRequest(url: url) req.timeoutInterval = 5 let (_, response) = try await session.data(for: req) if let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) { return true } return false } // MARK: - Smart DJ Profile (GET /smart-dj/profile?relative_path=...) func fetchProfile(relativePath: String) async throws -> SmartDJProfile { if let cached = SmartDJCache.shared.get(relativePath) { return cached } let base = try baseURL() // Use addingPercentEncoding directly instead of URLQueryItem // which double-encodes paths with brackets, unicode, etc. guard let encoded = relativePath.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let url = URL(string: "\(base)/smart-dj/profile?relative_path=\(encoded)") else { throw CompanionError.invalidURL } let (data, response) = try await session.data(from: url) try validateResponse(response) let profile = try JSONDecoder().decode(SmartDJProfile.self, from: data) SmartDJCache.shared.store(profile, for: relativePath) return profile } /// Fetch ALL Smart DJ profiles in one request. Returns a dict keyed by relative path. /// The server compresses with gzip automatically (~200KB → ~30KB). /// Call once on app launch to populate SmartDJCache.bulkImport(). func fetchAllProfiles() async throws -> [String: SmartDJProfile] { let base = try baseURL() let url = URL(string: "\(base)/smart-dj/profiles/export")! let (data, response) = try await session.data(from: url) try validateResponse(response) struct ExportResponse: Codable { let count: Int let profiles: [String: SmartDJProfile] } let export = try JSONDecoder().decode(ExportResponse.self, from: data) DebugLogger.shared.log( "Fetched \(export.count) DJ profiles (\(data.count) bytes)", category: "SmartDJ" ) return export.profiles } /// Prefetch profiles for a batch of songs (e.g. queue, album) func prefetchProfiles(for songs: [Song]) async { await withTaskGroup(of: Void.self) { group in for song in songs { guard let path = song.path else { continue } if SmartDJCache.shared.get(path) != nil { continue } group.addTask { _ = try? await self.fetchProfile(relativePath: path) } } } } // MARK: - Edit Metadata (PATCH /edit-metadata) func editMetadata(_ request: MetadataEditRequest) async throws { let base = try baseURL() let url = base.appendingPathComponent("edit-metadata") var req = URLRequest(url: url) req.httpMethod = "PATCH" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONEncoder().encode(request) let (data, response) = try await session.data(for: req) guard let http = response as? HTTPURLResponse else { throw CompanionError.invalidResponse } if !(200...299).contains(http.statusCode) { // Parse server error detail for better debugging let detail = (try? JSONDecoder().decode([String:String].self, from: data))?["detail"] ?? String(data: data, encoding: .utf8) ?? "Unknown error" throw CompanionError.serverErrorDetail(http.statusCode, detail) } } // MARK: - Batch Edit Metadata (PATCH /batch-edit-metadata) struct BatchEditResult: Codable { let succeeded: [String]? let failed: [BatchEditFailure]? } struct BatchEditFailure: Codable { let path: String let error: String } /// Edit the same tags on multiple files in a single request + single Navidrome scan. func batchEditMetadata(_ request: BatchMetadataEditRequest) async throws -> BatchEditResult { let base = try baseURL() let url = base.appendingPathComponent("batch-edit-metadata") var req = URLRequest(url: url) req.httpMethod = "PATCH" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONEncoder().encode(request) let (data, response) = try await session.data(for: req) guard let http = response as? HTTPURLResponse else { throw CompanionError.invalidResponse } if !(200...299).contains(http.statusCode) { let detail = (try? JSONDecoder().decode([String:String].self, from: data))?["detail"] ?? String(data: data, encoding: .utf8) ?? "Unknown error" throw CompanionError.serverErrorDetail(http.statusCode, detail) } return try JSONDecoder().decode(BatchEditResult.self, from: data) } // MARK: - Upload Track (POST /upload-track) func uploadTrack(fileURL: URL, metadata: UploadMetadata) async throws { let base = try baseURL() let url = base.appendingPathComponent("upload-track") let boundary = "Boundary-\(UUID().uuidString)" var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") req.httpBody = try buildMultipartBody(fileURL: fileURL, metadata: metadata, boundary: boundary) let (_, response) = try await session.data(for: req) try validateResponse(response) } // MARK: - Bulk Fix (POST /bulk-fix) func triggerBulkFix() async throws { let base = try baseURL() let url = base.appendingPathComponent("bulk-fix") var req = URLRequest(url: url) req.httpMethod = "POST" let (_, response) = try await session.data(for: req) try validateResponse(response) } // MARK: - Bulk Profiles (GET /smart-dj/bulk-profiles?paths=...) func fetchBulkProfiles(songs: [Song]) async throws -> [String: SmartDJProfile] { let paths = songs.compactMap { $0.path }.filter { SmartDJCache.shared.get($0) == nil } guard !paths.isEmpty else { return [:] } let base = try baseURL() var components = URLComponents(string: "\(base)/smart-dj/bulk-profiles")! components.queryItems = [URLQueryItem(name: "paths", value: paths.joined(separator: ","))] guard let url = components.url else { throw CompanionError.invalidURL } let (data, response) = try await session.data(from: url) try validateResponse(response) let results = try JSONDecoder().decode([String: SmartDJProfile?].self, from: data) var profiles: [String: SmartDJProfile] = [:] for (path, profile) in results { if let p = profile { SmartDJCache.shared.store(p, for: path) profiles[path] = p } } return profiles } // MARK: - Lyrics /// Fetch lyrics for a song by its file path. Checks embedded tags, .lrc sidecar, and DB cache. func fetchLyricsForPath(relativePath: String) async throws -> LyricsResponse { let base = try baseURL() guard let encoded = relativePath.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let url = URL(string: "\(base)/lyrics/get?relative_path=\(encoded)") else { throw CompanionError.invalidURL } let (data, response) = try await session.data(from: url) try validateResponse(response) return try JSONDecoder().decode(LyricsResponse.self, from: data) } /// Fetch lyrics from LRCLIB via companion API (exact match by artist/title/duration). func fetchLyricsFromLRCLIB(artist: String, title: String, duration: Double) async throws -> LyricsResponse { let base = try baseURL() var components = URLComponents(string: "\(base)/lyrics/fetch")! components.queryItems = [ URLQueryItem(name: "artist", value: artist), URLQueryItem(name: "title", value: title), URLQueryItem(name: "duration", value: String(duration)) ] guard let url = components.url else { throw CompanionError.invalidURL } let (data, response) = try await session.data(from: url) try validateResponse(response) return try JSONDecoder().decode(LyricsResponse.self, from: data) } /// Search LRCLIB for lyrics matching a query. func searchLyrics(query: String) async throws -> [LRCLIBResult] { let base = try baseURL() var components = URLComponents(string: "\(base)/lyrics/search")! components.queryItems = [URLQueryItem(name: "q", value: query)] guard let url = components.url else { throw CompanionError.invalidURL } let (data, response) = try await session.data(from: url) try validateResponse(response) return try JSONDecoder().decode([LRCLIBResult].self, from: data) } /// Embed LRC lyrics into an audio file via Companion API. func embedLyrics(relativePath: String, lrcContent: String) async throws { let base = try baseURL() let url = URL(string: "\(base)/lyrics/embed")! var request = URLRequest(url: url) request.httpMethod = "POST" let boundary = UUID().uuidString request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") var body = Data() func field(_ name: String, _ value: String) { body.append("--\(boundary)\r\n".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!) body.append("\(value)\r\n".data(using: .utf8)!) } field("relative_path", relativePath) field("lrc_content", lrcContent) body.append("--\(boundary)--\r\n".data(using: .utf8)!) request.httpBody = body let (_, response) = try await session.data(for: request) try validateResponse(response) } // MARK: - Visualizer Frames (GET /visualizer/frames?relative_path=...) /// Fetch pre-computed Mitsuha visualizer frames from the server. /// Returns nil if not available. The frames match iOS OfflineAudioAnalyzer format. func fetchVisualizerFrames(relativePath: String) async throws -> [[Float]]? { let base = try baseURL() guard let encoded = relativePath.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let url = URL(string: "\(base)/visualizer/frames?relative_path=\(encoded)") else { throw CompanionError.invalidURL } let (data, response) = try await session.data(from: url) guard let http = response as? HTTPURLResponse else { return nil } guard http.statusCode == 200 else { return nil } struct VisResponse: Codable { let frame_count: Int let fps: Double let points: Int let frames: [[Float]] } let vis = try JSONDecoder().decode(VisResponse.self, from: data) return vis.frames } // MARK: - Background Upload Helpers nonisolated func buildMultipartPayloadFile( fileURL: URL, metadata: UploadMetadata, boundary: String ) throws -> URL { let tempDir = FileManager.default.temporaryDirectory let payloadURL = tempDir.appendingPathComponent("upload_\(UUID().uuidString).multipart") let body = try buildMultipartBody(fileURL: fileURL, metadata: metadata, boundary: boundary) try body.write(to: payloadURL) return payloadURL } nonisolated func uploadURL() throws -> URL { guard let url = CompanionSettings.shared.baseURL else { throw CompanionError.notConfigured } return url.appendingPathComponent("upload-track") } // MARK: - Multipart Builder private nonisolated func buildMultipartBody( fileURL: URL, metadata: UploadMetadata, boundary: String ) throws -> Data { var body = Data() let crlf = "\r\n" func field(_ name: String, _ value: String) { guard !value.isEmpty else { return } body.append("--\(boundary)\(crlf)".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"\(name)\"\(crlf)\(crlf)".data(using: .utf8)!) body.append("\(value)\(crlf)".data(using: .utf8)!) } field("title", metadata.title) field("artist", metadata.artist) field("album", metadata.album) if !metadata.albumArtist.isEmpty { field("album_artist", metadata.albumArtist) } if !metadata.genre.isEmpty { field("genre", metadata.genre) } if !metadata.year.isEmpty { field("year", metadata.year) } if !metadata.trackNumber.isEmpty { field("track_number", metadata.trackNumber) } field("preserve_composer", metadata.preserveComposer ? "true" : "false") field("preserve_lyrics", metadata.preserveLyrics ? "true" : "false") let fileData = try Data(contentsOf: fileURL) let filename = fileURL.lastPathComponent let mime: String = { switch fileURL.pathExtension.lowercased() { case "mp3": return "audio/mpeg" case "flac": return "audio/flac" case "m4a", "aac": return "audio/mp4" case "ogg", "opus": return "audio/ogg" case "wav": return "audio/wav" default: return "application/octet-stream" } }() body.append("--\(boundary)\(crlf)".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\(crlf)".data(using: .utf8)!) body.append("Content-Type: \(mime)\(crlf)\(crlf)".data(using: .utf8)!) body.append(fileData) body.append(crlf.data(using: .utf8)!) body.append("--\(boundary)--\(crlf)".data(using: .utf8)!) return body } private func validateResponse(_ response: URLResponse) throws { guard let http = response as? HTTPURLResponse else { throw CompanionError.invalidResponse } guard (200...299).contains(http.statusCode) else { throw CompanionError.serverError(http.statusCode) } } } // MARK: - WebSocket Push Client /// Connects to the Companion API WebSocket for real-time push events. /// Events: metadata_updated, track_uploaded, analysis_complete, vis_ready class CompanionPushClient: ObservableObject { static let shared = CompanionPushClient() @Published var isConnected = false @Published var lastEvent: String? private var webSocketTask: URLSessionWebSocketTask? private var pingTimer: Timer? private var reconnectTask: Task? private var reconnectDelay: TimeInterval = 5 // starts at 5s private let maxReconnectDelay: TimeInterval = 120 // caps at 2 min private var reconnectAttempts = 0 private init() {} func connect() { guard CompanionSettings.shared.isEnabled, CompanionSettings.shared.baseURL != nil else { return } let wasReconnecting = reconnectAttempts > 0 disconnect() let wsURL = URL(string: "ws://\(CompanionSettings.shared.host):\(CompanionSettings.shared.port)/ws/push")! webSocketTask = URLSession.shared.webSocketTask(with: wsURL) webSocketTask?.resume() isConnected = true if wasReconnecting { DebugLogger.shared.log( "Push: reconnected after \(reconnectAttempts) attempts", category: "Companion" ) } // Reset backoff on fresh connect reconnectDelay = 5 reconnectAttempts = 0 listen() startPing() } func disconnect() { pingTimer?.invalidate() pingTimer = nil reconnectTask?.cancel() reconnectTask = nil webSocketTask?.cancel(with: .goingAway, reason: nil) webSocketTask = nil isConnected = false } private func listen() { webSocketTask?.receive { [weak self] result in guard let self = self else { return } switch result { case .success(let message): switch message { case .string(let text): self.handleMessage(text) default: break } self.listen() // Keep listening case .failure(let error): // Only log and reschedule if Companion is still enabled — // avoids spam when the server is unreachable or disabled guard CompanionSettings.shared.isEnabled else { return } // Suppress the extremely common "Socket is not connected" noise // after the first attempt — it fills the console during backoff if reconnectAttempts == 0 { DebugLogger.shared.log("Push: disconnected — \(error.localizedDescription)", category: "Companion") } DispatchQueue.main.async { self.isConnected = false self.scheduleReconnect() } } } } private func handleMessage(_ text: String) { guard let data = text.data(using: .utf8), let msg = try? JSONDecoder().decode(PushMessage.self, from: data) else { return } DispatchQueue.main.async { self.lastEvent = msg.event // Suppress noisy pong events from log if msg.event != "pong" { DebugLogger.shared.log("Push event: \(msg.event)", category: "Companion") } switch msg.event { case "metadata_updated": NotificationCenter.default.post(name: .companionMetadataUpdated, object: nil, userInfo: msg.data) case "track_uploaded", "tracks_uploaded": NotificationCenter.default.post(name: .companionTrackUploaded, object: nil, userInfo: msg.data) case "batch_metadata_updated": NotificationCenter.default.post(name: .companionMetadataUpdated, object: nil, userInfo: msg.data) case "cover_art_updated": ImageCache.shared.clearAll() NotificationCenter.default.post(name: .companionCoverArtUpdated, object: nil, userInfo: msg.data) case "artist_photo_updated": ImageCache.shared.clearAll() NotificationCenter.default.post(name: .companionArtistPhotoUpdated, object: nil, userInfo: msg.data) case "conflicts_updated": // Notify ConflictManager to refresh — use NotificationCenter to avoid // a cross-module dependency between Foundation and SwiftUI layers. NotificationCenter.default.post(name: .companionConflictsUpdated, object: nil, userInfo: msg.data) case "tags_cleaned": NotificationCenter.default.post(name: .companionTagsCleaned, object: nil, userInfo: msg.data) case "profile": if let path = msg.data?["path"] as? String, let jsonData = try? JSONSerialization.data(withJSONObject: msg.data ?? [:]), let profile = try? JSONDecoder().decode(SmartDJProfile.self, from: jsonData) { SmartDJCache.shared.store(profile, for: path) } default: break } } } func sendAction(_ action: String, data: [String: String] = [:]) { var payload = data payload["action"] = action if let jsonData = try? JSONSerialization.data(withJSONObject: payload), let text = String(data: jsonData, encoding: .utf8) { webSocketTask?.send(.string(text)) { error in if let error = error { DebugLogger.shared.log("Push send failed: \(error.localizedDescription)", category: "Companion") } } } } private func startPing() { pingTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in self?.sendAction("ping") } } private func scheduleReconnect() { guard CompanionSettings.shared.isEnabled else { return } reconnectAttempts += 1 let delay = reconnectDelay // Exponential backoff: 5 → 10 → 20 → 40 → 80 → 120 → 120... reconnectDelay = min(reconnectDelay * 2, maxReconnectDelay) // Silent retries — only log milestones to avoid console spam if reconnectAttempts == 1 || reconnectAttempts == 5 || reconnectAttempts % 20 == 0 { DebugLogger.shared.log( "Push: reconnecting (attempt \(reconnectAttempts), \(Int(delay))s backoff)", category: "Companion" ) } reconnectTask = Task { try? await Task.sleep(for: .seconds(delay)) await MainActor.run { self.connect() } } } } struct PushMessage: Codable { let event: String let data: [String: String]? } extension Notification.Name { static let companionMetadataUpdated = Notification.Name("companionMetadataUpdated") static let companionTrackUploaded = Notification.Name("companionTrackUploaded") static let companionCoverArtUpdated = Notification.Name("companionCoverArtUpdated") static let companionArtistPhotoUpdated = Notification.Name("companionArtistPhotoUpdated") static let companionLibraryChanged = Notification.Name("companionLibraryChanged") static let companionConflictsUpdated = Notification.Name("companionConflictsUpdated") static let companionTagsCleaned = Notification.Name("companionTagsCleaned") } // MARK: - Conflict Models // Defined here so CompanionAPIService can reference them without a separate file. struct AnyCodable: Codable { let value: Any init(_ value: Any) { self.value = value } init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let v = try? container.decode([String].self) { value = v; return } if let v = try? container.decode([String: String].self) { value = v; return } if let v = try? container.decode(String.self) { value = v; return } if let v = try? container.decode(Int.self) { value = v; return } value = "" } func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch value { case let v as [String]: try container.encode(v) case let v as [String: String]: try container.encode(v) case let v as String: try container.encode(v) case let v as Int: try container.encode(v) default: try container.encode("") } } } struct LibraryConflict: Codable, Identifiable { let type: String let severity: String let title: String let detail: String let affected_paths: [String] let fix_action: String? let fix_data: [String: AnyCodable]? var id: String { "\(type)|\(title)" } } struct ConflictsResponse: Codable { let total: Int let errors: Int let warnings: Int let issues: [LibraryConflict] } // MARK: - Conflict Manager // Defined here (in CompanionAPIService.swift) so it compiles before // DownloadsSettingsView.swift and LibraryConflictsView.swift reference it. @MainActor class ConflictManager: ObservableObject { static let shared = ConflictManager() @Published var conflicts: ConflictsResponse? @Published var isLoading = false @Published var lastError: String? @Published var isFixing: String? = nil private let api = CompanionAPIService.shared var badgeCount: Int { conflicts?.errors ?? 0 } var totalCount: Int { conflicts?.total ?? 0 } init() { NotificationCenter.default.addObserver( forName: .companionConflictsUpdated, object: nil, queue: .main ) { [weak self] _ in Task { await self?.refresh() } } } func refresh() async { guard CompanionSettings.shared.isEnabled else { return } isLoading = true lastError = nil do { conflicts = try await api.fetchConflicts() } catch { lastError = error.localizedDescription } isLoading = false } func fix(_ conflict: LibraryConflict) async { guard let action = conflict.fix_action else { return } isFixing = conflict.id do { try await api.fixConflict(action: action, fixData: conflict.fix_data) try? await Task.sleep(for: .seconds(2)) await refresh() } catch { lastError = error.localizedDescription } isFixing = nil } } // MARK: - Library Fetch (Phase 1) extension CompanionAPIService { /// Fetch all songs, paginating until complete. func fetchAllSongs(sort: String = "sort_album,disc_number,track_number") async throws -> [CompanionSong] { let base = try baseURL() var all: [CompanionSong] = [] var page = 0 let perPage = 500 while true { guard var comps = URLComponents(url: base.appendingPathComponent("library/songs"), resolvingAgainstBaseURL: false) else { break } comps.queryItems = [ URLQueryItem(name: "page", value: "\(page)"), URLQueryItem(name: "per_page", value: "\(perPage)"), URLQueryItem(name: "sort", value: sort), ] guard let url = comps.url else { break } let (data, response) = try await session.data(from: url) try validateResponse(response) let result = try JSONDecoder().decode(CompanionLibraryResponse.self, from: data) let batch = result.songs ?? [] all.append(contentsOf: batch) if batch.count < perPage { break } page += 1 } return all } /// Fetch songs for a specific album (used by AlbumDetailView). func fetchAlbumSongs(album: String, albumArtist: String) async throws -> [CompanionSong] { let base = try baseURL() guard var comps = URLComponents(url: base.appendingPathComponent("library/songs"), resolvingAgainstBaseURL: false) else { throw CompanionError.invalidURL } comps.queryItems = [ URLQueryItem(name: "album", value: album), URLQueryItem(name: "album_artist", value: albumArtist), URLQueryItem(name: "sort", value: "disc_number,track_number"), URLQueryItem(name: "per_page", value: "500"), ] guard let url = comps.url else { throw CompanionError.invalidURL } let (data, response) = try await session.data(from: url) try validateResponse(response) let result = try JSONDecoder().decode(CompanionLibraryResponse.self, from: data) return result.songs ?? [] } /// Fetch all albums. func fetchAllAlbums() async throws -> [CompanionAlbum] { let base = try baseURL() let url = base.appendingPathComponent("library/albums") let (data, response) = try await session.data(from: url) try validateResponse(response) let result = try JSONDecoder().decode(CompanionLibraryResponse.self, from: data) return result.albums ?? [] } /// Fetch all artists. func fetchAllArtists() async throws -> [CompanionArtist] { let base = try baseURL() let url = base.appendingPathComponent("library/artists") let (data, response) = try await session.data(from: url) try validateResponse(response) let result = try JSONDecoder().decode(CompanionLibraryResponse.self, from: data) return result.artists ?? [] } /// Full-text search across title/artist/album/genre. func searchLibrary(query: String, limit: Int = 50) async throws -> [CompanionSong] { let base = try baseURL() guard var comps = URLComponents(url: base.appendingPathComponent("library/search"), resolvingAgainstBaseURL: false) else { throw CompanionError.invalidURL } comps.queryItems = [ URLQueryItem(name: "q", value: query), URLQueryItem(name: "limit", value: "\(limit)"), ] guard let url = comps.url else { throw CompanionError.invalidURL } let (data, response) = try await session.data(from: url) try validateResponse(response) let result = try JSONDecoder().decode(CompanionLibraryResponse.self, from: data) return result.songs ?? [] } /// Build a cover art URL for a companion song ID. nonisolated func coverArtURL(companionId: String) -> URL? { CompanionSettings.shared.baseURL? .appendingPathComponent("library/cover-art/\(companionId)") } /// Upload a JPEG image as cover.jpg for the album containing this song. func uploadCoverArt(songId: String, image: UIImage) async throws { let base = try baseURL() let url = base.appendingPathComponent("library/cover-art/\(songId)") let boundary = "Boundary-\(UUID().uuidString)" var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") guard let jpeg = image.jpegData(compressionQuality: 0.92) else { throw CompanionError.invalidResponse } let crlf = "\r\n" var body = Data() body.append("--\(boundary)\(crlf)".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"file\"; filename=\"cover.jpg\"\(crlf)".data(using: .utf8)!) body.append("Content-Type: image/jpeg\(crlf)\(crlf)".data(using: .utf8)!) body.append(jpeg) body.append("\(crlf)--\(boundary)--\(crlf)".data(using: .utf8)!) req.httpBody = body let (_, response) = try await session.data(for: req) try validateResponse(response) } /// Delete cover.jpg from the album directory for the song. func deleteCoverArt(songId: String) async throws { let base = try baseURL() let url = base.appendingPathComponent("library/cover-art/\(songId)") var req = URLRequest(url: url) req.httpMethod = "DELETE" let (_, response) = try await session.data(for: req) try validateResponse(response) } /// Upload cover art using a relative file path (fallback when no companion DB ID). /// Uses POST /library/cover-art-by-path with multipart form containing /// the relative_path as a text field and the image as a file field. func uploadCoverArtByPath(relativePath: String, image: UIImage) async throws { let base = try baseURL() let url = base.appendingPathComponent("library/cover-art-by-path") let boundary = "Boundary-\(UUID().uuidString)" var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") guard let jpeg = image.jpegData(compressionQuality: 0.92) else { throw CompanionError.invalidResponse } let crlf = "\r\n" var body = Data() // Field: relative_path body.append("--\(boundary)\(crlf)".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"relative_path\"\(crlf)\(crlf)".data(using: .utf8)!) body.append(relativePath.data(using: .utf8)!) body.append(crlf.data(using: .utf8)!) // Field: file (image) body.append("--\(boundary)\(crlf)".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"file\"; filename=\"cover.jpg\"\(crlf)".data(using: .utf8)!) body.append("Content-Type: image/jpeg\(crlf)\(crlf)".data(using: .utf8)!) body.append(jpeg) body.append("\(crlf)--\(boundary)--\(crlf)".data(using: .utf8)!) req.httpBody = body DebugLogger.shared.log( "uploadCoverArtByPath: \(relativePath) (\(jpeg.count) bytes JPEG)", category: "Companion" ) let (_, response) = try await session.data(for: req) try validateResponse(response) } /// Build an artist photo URL for an artist name. nonisolated func artistPhotoURL(artistName: String) -> URL? { guard let encoded = artistName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { return nil } return CompanionSettings.shared.baseURL? .appendingPathComponent("library/artist-photo/\(encoded)") } /// Fetch all library conflicts and issues. func fetchConflicts() async throws -> ConflictsResponse { let base = try baseURL() let url = base.appendingPathComponent("library/conflicts") let (data, response) = try await session.data(from: url) try validateResponse(response) return try JSONDecoder().decode(ConflictsResponse.self, from: data) } /// Fix a specific conflict by action type. func fixConflict(action: String, fixData: [String: AnyCodable]?) async throws { let base = try baseURL() let url = base.appendingPathComponent("library/fix-conflict") var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") // Build body — AnyCodable.value can be String, Int, or [String] var bodyFix: [String: Any] = [:] if let fd = fixData { for (k, v) in fd { bodyFix[k] = v.value } } let body: [String: Any] = ["action": action, "fix_data": bodyFix] req.httpBody = try JSONSerialization.data(withJSONObject: body) let (_, response) = try await session.data(for: req) try validateResponse(response) } } // MARK: - Errors enum CompanionError: LocalizedError { case notConfigured, invalidURL, invalidResponse case serverError(Int) case serverErrorDetail(Int, String) case extractionFailed(String) case noAudioFiles var errorDescription: String? { switch self { case .notConfigured: return "Companion API not configured" case .invalidURL: return "Invalid Companion API URL" case .invalidResponse: return "Invalid response from Companion API" case .serverError(let code): return "Companion API error (HTTP \(code))" case .serverErrorDetail(let code, let detail): return "HTTP \(code): \(detail)" case .extractionFailed(let msg): return "Zip extraction failed: \(msg)" case .noAudioFiles: return "No audio files found in archive" } } }