import Foundation // 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") } } @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 var memoryCache: [String: SmartDJProfile] = [:] private init() { let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! cacheDir = caches.appendingPathComponent("SmartDJProfiles", isDirectory: true) 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) } } func clearAll() { memoryCache.removeAll() if let files = try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) { for file in files { try? fileManager.removeItem(at: file) } } } var cachedCount: Int { (try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil))?.count ?? 0 } } // 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 } // MARK: - Companion API Service actor 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 } /// 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(url: base.appendingPathComponent("smart-dj/bulk-profiles"), resolvingAgainstBaseURL: false)! 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: - 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) } 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 init() {} func connect() { guard CompanionSettings.shared.isEnabled, CompanionSettings.shared.baseURL != nil else { return } disconnect() let wsURL = URL(string: "ws://\(CompanionSettings.shared.host):\(CompanionSettings.shared.port)/ws/push")! webSocketTask = URLSession.shared.webSocketTask(with: wsURL) webSocketTask?.resume() isConnected = true DebugLogger.shared.log("Push: connecting to \(wsURL)", category: "Companion") 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): 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 DebugLogger.shared.log("Push event: \(msg.event)", category: "Companion") switch msg.event { case "metadata_updated": // Notify the app to refresh library NotificationCenter.default.post(name: .companionMetadataUpdated, object: nil, userInfo: msg.data) case "track_uploaded": NotificationCenter.default.post(name: .companionTrackUploaded, object: nil, userInfo: msg.data) case "profile": // Cache the profile locally 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() { reconnectTask = Task { try? await Task.sleep(for: .seconds(5)) 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") } // 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" } } }