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 albumSource: SlskdAlbumSource let score: Int var id: String { albumSource.id } /// Best audio format in this source. var bestFormat: String { let extensions = albumSource.audioFiles.compactMap(\.fileExtension) for fmt in ["flac", "wav", "aiff", "aif", "ape", "wv", "m4a", "ogg", "aac", "mp3"] { if extensions.contains(fmt) { return fmt.uppercased() } } return extensions.first?.uppercased() ?? "?" } var audioFileCount: Int { albumSource.audioFiles.count } var audioFiles: [SlskdFile] { albumSource.audioFiles } var totalSize: Int64 { audioFiles.reduce(0) { $0 + $1.size } } var formattedTotalSize: String { ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file) } 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" } } // Lossless: show bit depth / sample rate if available (e.g. "FLAC 16/48", "FLAC 24/96") if ["FLAC", "WAV", "AIFF", "AIF", "APE", "WV"].contains(fmt) { let depths = audioFiles.compactMap(\.bitDepth) let rates = audioFiles.compactMap(\.sampleRate) if let depth = depths.max(), let rate = rates.max(), depth > 0, rate > 0 { let rateKHz: String if rate >= 1000 { let kHz = Double(rate) / 1000.0 rateKHz = kHz.truncatingRemainder(dividingBy: 1) == 0 ? "\(Int(kHz))" : String(format: "%.1f", kHz) } else { rateKHz = "\(rate)" } return "\(fmt) \(depth)/\(rateKHz)" } } return fmt } var scoreGrade: ScoreGrade { if score >= 120 { return .excellent } if score >= 80 { return .good } return .poor } /// Album name from directory path. var albumName: String { albumSource.albumName } /// Artist guess from directory path. var artistGuess: String? { albumSource.artistGuess } var username: String { albumSource.username } var dragRepresentation: String { "\(username) — \(formatDisplay) — \(audioFileCount) files — \(formattedTotalSize)" } 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 /// Per-file transfer state during download, keyed by filename. private(set) var activeTransfers: [String: SlskdTransfer] = [:] // MARK: - Private private let chadMusic = ChadMusicAPIClient.shared private let slskd = SlskdAPIClient.shared private var searchTask: Task? private var downloadTask: Task? private 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 } // 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) { cancelSearch() 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() { cancelSearch() cancelDownload() } /// Cancel only the active search, preserving download state. func cancelSearch() { searchTask?.cancel() searchTask = nil if let searchId = activeSearchId { activeSearchId = nil Task { try? await slskd.deleteSearch(id: searchId) } } cloudResults = [] soulseekSources = [] phase = .idle } /// Cancel only the active download, preserving search results. func cancelDownload() { downloadTask?.cancel() downloadTask = nil downloadPhase = .idle activeTransfers = [:] } /// Download a specific Soulseek album source picked by the user. func downloadSource( _ source: SlskdAlbumSource, 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 by directory (album-level grouping) soulseekSources = responses .flatMap { $0.groupedByDirectory() } .map { ScoredSoulseekSource(albumSource: $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: SlskdAlbumSource, artist: String, albumName: String ) async throws { let audioFiles = source.audioFiles 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) } // Update per-file transfer state var transfers: [String: SlskdTransfer] = [:] for file in ourFiles { transfers[file.filename] = file } activeTransfers = transfers 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 } }