import Foundation // MARK: - Search Phase enum UnifiedSearchPhase: Equatable { case idle case searchingCloud case searchingSoulseek case done case error(String) } // MARK: - Download Phase enum SourceDownloadPhase: Equatable { case idle case downloading(progress: Double) case importing case complete(albumName: String) case failed(String) var isActive: Bool { switch self { case .idle, .complete, .failed: false default: true } } var statusText: String { switch self { case .idle: "Ready" case .downloading(let p): "Downloading... \(Int(p * 100))%" case .importing: "Importing to ChadMusic..." case .complete(let name): "\(name) ready" case .failed(let msg): "Failed: \(msg)" } } } // MARK: - Scored Source struct ScoredSoulseekSource: Identifiable { let response: SlskdSearchResponse let score: Int var id: String { response.id } /// Best audio format in this source. var bestFormat: String { let audioFiles = response.files.filter(\.isAudioFile) let extensions = audioFiles.compactMap(\.fileExtension) // Priority order for fmt in ["flac", "wav", "aiff", "aif", "ape", "wv", "m4a", "ogg", "aac", "mp3"] { if extensions.contains(fmt) { return fmt.uppercased() } } return extensions.first?.uppercased() ?? "?" } /// Number of audio files. var audioFileCount: Int { response.files.filter(\.isAudioFile).count } /// Audio files in this source. var audioFiles: [SlskdFile] { response.files.filter(\.isAudioFile) } /// Total size of audio files in bytes. var totalSize: Int64 { audioFiles.reduce(0) { $0 + $1.size } } /// Human-readable total size (e.g., "847 MB"). var formattedTotalSize: String { ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file) } /// Format + bitrate display (e.g., "FLAC", "MP3 320k"). var formatDisplay: String { let fmt = bestFormat if fmt == "MP3" { let maxBitrate = audioFiles.compactMap(\.bitRate).max() if let br = maxBitrate, br > 0 { return "MP3 \(br)k" } } return fmt } /// Score color: green (excellent), yellow (acceptable), red (poor). var scoreGrade: ScoreGrade { if score >= 120 { return .excellent } if score >= 80 { return .good } return .poor } enum ScoreGrade { case excellent, good, poor } } // MARK: - Coordinator /// Orchestrates parallel cloud + Soulseek search. /// NOT a singleton — create one per search session. @MainActor @Observable final class UnifiedSearchCoordinator { // MARK: - Published State private(set) var phase: UnifiedSearchPhase = .idle private(set) var cloudResults: [ChadAlbum] = [] private(set) var soulseekSources: [ScoredSoulseekSource] = [] private(set) var downloadPhase: SourceDownloadPhase = .idle // MARK: - Private private let chadMusic = ChadMusicAPIClient.shared private let slskd = SlskdAPIClient.shared private nonisolated(unsafe) var searchTask: Task? private nonisolated(unsafe) var downloadTask: Task? private nonisolated(unsafe) var activeSearchId: String? private let searchPollInterval: TimeInterval = 2 private let searchTimeout: TimeInterval = 30 private let downloadPollInterval: TimeInterval = 3 private let downloadTimeout: TimeInterval = 600 private let importTimeout: TimeInterval = 300 /// Quality threshold for display. Sources below this are shown grayed out. private var qualityThreshold: Int { let stored = UserDefaults.standard.integer(forKey: "slskd.qualityThreshold") return stored > 0 ? stored : 80 } deinit { searchTask?.cancel() downloadTask?.cancel() // Best-effort cleanup if let searchId = activeSearchId { let client = SlskdAPIClient.shared Task { try? await client.deleteSearch(id: searchId) } } } // MARK: - Search /// Cloud-first search. If ChadMusic has no results and Soulseek is configured, /// automatically searches Soulseek and returns scored sources for the user to pick. func search(query: String) { cancel() searchTask = Task { [weak self] in guard let self else { return } do { try await self.runSearch(query: query) } catch is CancellationError { // Cancelled — leave state as-is } catch { self.phase = .error(error.localizedDescription) } } } /// Cancel any active search or download. func cancel() { searchTask?.cancel() searchTask = nil downloadTask?.cancel() downloadTask = nil if let searchId = activeSearchId { activeSearchId = nil Task { try? await slskd.deleteSearch(id: searchId) } } cloudResults = [] soulseekSources = [] phase = .idle downloadPhase = .idle } /// Download a specific Soulseek source picked by the user. func downloadSource( _ source: SlskdSearchResponse, artist: String, albumName: String ) { downloadTask?.cancel() downloadPhase = .downloading(progress: 0) downloadTask = Task { [weak self] in guard let self else { return } do { try await self.runDownloadAndImport( source: source, artist: artist, albumName: albumName ) } catch is CancellationError { self.downloadPhase = .idle } catch { self.downloadPhase = .failed(error.localizedDescription) } } } /// Dismiss a completed/failed download state. func dismissDownload() { guard !downloadPhase.isActive else { return } downloadPhase = .idle } // MARK: - Private: Search Pipeline private func runSearch(query: String) async throws { let lowerQuery = query.lowercased() // Step 1: Search ChadMusic (client-side filter) phase = .searchingCloud let allAlbums = try await chadMusic.fetchAlbums() try Task.checkCancellation() cloudResults = allAlbums.filter { album in album.title.lowercased().contains(lowerQuery) || (album.artist?.lowercased().contains(lowerQuery) ?? false) } // Step 2: Always search Soulseek in parallel (not just as fallback) guard slskd.isConfigured else { phase = .done return } try Task.checkCancellation() phase = .searchingSoulseek let searchId = try await slskd.startSearch(query: query) activeSearchId = searchId let responses = try await pollSearch(id: searchId) activeSearchId = nil // Score and sort responses soulseekSources = responses .map { ScoredSoulseekSource(response: $0, score: $0.qualityScore(expectedTrackCount: nil)) } .filter { $0.score > 0 } .sorted { $0.score > $1.score } try Task.checkCancellation() phase = .done } private func pollSearch(id: String) async throws -> [SlskdSearchResponse] { let deadline = Date().addingTimeInterval(searchTimeout) while Date() < deadline { try Task.checkCancellation() let search = try await slskd.getSearch(id: id) if search.isComplete { return search.responses ?? [] } try await Task.sleep(for: .seconds(searchPollInterval)) } // Grab whatever we have let finalSearch = try await slskd.getSearch(id: id) return finalSearch.responses ?? [] } // MARK: - Private: Download + Import Pipeline private func runDownloadAndImport( source: SlskdSearchResponse, artist: String, albumName: String ) async throws { let audioFiles = source.files.filter(\.isAudioFile) guard !audioFiles.isEmpty else { throw SlskdError.noResults } // Step 1: Enqueue downloads downloadPhase = .downloading(progress: 0) let enqueuedFilenames = Set(audioFiles.map(\.filename)) try await slskd.enqueueDownloads(username: source.username, files: audioFiles) // Step 2: Poll downloads let deadline = Date().addingTimeInterval(downloadTimeout) let expectedCount = audioFiles.count while Date() < deadline { try Task.checkCancellation() let groups = try await slskd.getDownloads() if let group = groups.first(where: { $0.username == source.username }) { let allFiles = group.directories?.flatMap { $0.files ?? [] } ?? [] let ourFiles = allFiles.filter { enqueuedFilenames.contains($0.filename) } let completed = ourFiles.filter(\.isComplete).count let failed = ourFiles.filter(\.isFailed).count let total = max(expectedCount, ourFiles.count) let progress = total > 0 ? Double(completed + failed) / Double(total) : 0 downloadPhase = .downloading(progress: min(progress, 1.0)) if completed + failed >= expectedCount { let failThreshold = (expectedCount + 1) / 2 if failed >= failThreshold { throw SlskdError.downloadFailed("\(failed)/\(expectedCount) files failed") } break } } try await Task.sleep(for: .seconds(downloadPollInterval)) } // Step 3: Trigger ChadMusic import (if auto-import enabled) try Task.checkCancellation() let autoImport = UserDefaults.standard.bool(forKey: "slskd.autoImport") // Default to true when key hasn't been set let shouldImport = UserDefaults.standard.object(forKey: "slskd.autoImport") == nil ? true : autoImport guard shouldImport else { downloadPhase = .complete(albumName: albumName) return } downloadPhase = .importing try await chadMusic.triggerRescan() // Poll for album appearance let importDeadline = Date().addingTimeInterval(importTimeout) let searchLower = albumName.lowercased() while Date() < importDeadline { try Task.checkCancellation() try await Task.sleep(for: .seconds(10)) do { let albums = try await chadMusic.fetchAlbums(filteredBy: "artist", value: artist) if albums.contains(where: { $0.title.lowercased() == searchLower }) { downloadPhase = .complete(albumName: albumName) return } } catch { continue } } // Files are downloaded but import timed out throw SlskdError.importTimeout } }