import Foundation // MARK: - Pipeline State /// Represents the current state of the Soulseek acquisition pipeline. /// A-11: All associated values (String, Double) are Equatable — compiler auto-synthesizes conformance. enum SoulseekPipelineState: Equatable { case idle case searching(query: String) case evaluating case downloading(progress: Double) case waitingForImport case complete(albumName: String) case failed(message: String) var isActive: Bool { switch self { case .idle, .complete, .failed: false default: true } } var statusText: String { switch self { case .idle: "Ready" case .searching(let q): "Searching Soulseek for \"\(q)\"..." case .evaluating: "Evaluating sources..." case .downloading(let p): "Downloading... \(Int(p * 100))%" case .waitingForImport: "Waiting for ChadMusic import..." case .complete(let name): "\(name) is ready" case .failed(let msg): "Failed: \(msg)" } } } // MARK: - Orchestrator /// End-to-end pipeline: search slskd -> pick best source -> download -> trigger ChadMusic import. /// Observe `state` from the UI to show progress. Call `acquireAlbum` to start. @MainActor @Observable @available(*, deprecated, message: "Use UnifiedSearchCoordinator instead. SoulseekOrchestrator auto-picks sources without user choice.") final class SoulseekOrchestrator { static let shared = SoulseekOrchestrator() // MARK: - Public State private(set) var state: SoulseekPipelineState = .idle /// The album name that was successfully imported (set on .complete). private(set) var importedAlbum: String? /// The best response chosen during evaluation (for debugging/display). private(set) var chosenSource: SlskdSearchResponse? // MARK: - Private private var activeTask: Task? private var activeSearchId: String? /// H-1: Generation counter to prevent cancelled tasks from clobbering new pipeline state. private var pipelineGeneration: UInt64 = 0 /// C-2: Set of filenames we enqueued for download in this pipeline run. /// Used to filter out stale transfers from the same peer. private var downloadEnqueuedFiles: Set = [] private let slskd = SlskdAPIClient.shared private let chadMusic = ChadMusicAPIClient.shared // Pipeline tuning constants private let searchPollInterval: TimeInterval = 2 private let searchTimeout: TimeInterval = 30 private let downloadPollInterval: TimeInterval = 2 private let downloadTimeout: TimeInterval = 600 // 10 minutes private let importPollInterval: TimeInterval = 10 private let importTimeout: TimeInterval = 300 // 5 minutes // A-3: User-configurable quality threshold (persisted in UserDefaults). // Default 80: FLAC/lossless + completeness. Users wanting 128kbps MP3 can lower it. private var qualityThreshold: Int { let stored = UserDefaults.standard.integer(forKey: "slskd.qualityThreshold") return stored > 0 ? stored : 80 } // MARK: - Public API /// Start the full acquisition pipeline. Cancels any active pipeline first. func acquireAlbum(artist: String, albumName: String, expectedTrackCount: Int? = nil) { cancel() let query = "\(artist) \(albumName)" state = .searching(query: query) importedAlbum = nil chosenSource = nil // H-1: Capture the generation so the catch block can check staleness. pipelineGeneration &+= 1 let myGeneration = pipelineGeneration activeTask = Task { [weak self] in guard let self else { return } do { try await self.runPipeline( query: query, artist: artist, albumName: albumName, expectedTrackCount: expectedTrackCount ) } catch is CancellationError { // H-1: Only reset to .idle if no newer pipeline has started. if self.pipelineGeneration == myGeneration { self.state = .idle } } catch let error as SlskdError { if self.pipelineGeneration == myGeneration { self.state = .failed(message: error.errorDescription ?? "Unknown error") } } catch { if self.pipelineGeneration == myGeneration { self.state = .failed(message: error.localizedDescription) } } } } /// Cancel the active pipeline and clean up. func cancel() { activeTask?.cancel() activeTask = nil // Best-effort: delete active search on slskd if let searchId = activeSearchId { activeSearchId = nil Task { try? await slskd.deleteSearch(id: searchId) } } if state.isActive { state = .idle } } /// Dismiss a completed/failed result (returns to idle). func dismiss() { guard !state.isActive else { return } state = .idle importedAlbum = nil chosenSource = nil } // MARK: - Pipeline Steps private func runPipeline( query: String, artist: String, albumName: String, expectedTrackCount: Int? ) async throws { // Step 1: Search let searchId = try await slskd.startSearch(query: query) activeSearchId = searchId let responses = try await pollSearch(id: searchId) activeSearchId = nil guard !responses.isEmpty else { throw SlskdError.noResults } // Step 2: Evaluate try Task.checkCancellation() state = .evaluating let bestResponse = pickBestSource( responses: responses, expectedTrackCount: expectedTrackCount ) guard let source = bestResponse else { throw SlskdError.noQualityMatch } chosenSource = source // Step 3: Download try Task.checkCancellation() state = .downloading(progress: 0) let audioFiles = source.files.filter(\.isAudioFile) // C-2: Track which files we enqueued so pollDownloads can ignore stale transfers. downloadEnqueuedFiles = Set(audioFiles.map(\.filename)) try await slskd.enqueueDownloads(username: source.username, files: audioFiles) try await pollDownloads( username: source.username, expectedCount: audioFiles.count, enqueuedFiles: downloadEnqueuedFiles ) // Step 4: Trigger ChadMusic import try Task.checkCancellation() state = .waitingForImport try await triggerImportAndWait(artist: artist, albumName: albumName) // Step 5: Complete importedAlbum = albumName state = .complete(albumName: albumName) // Clean up the search try? await slskd.deleteSearch(id: searchId) } // MARK: - Search Polling 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)) } // Timed out — grab whatever we have let finalSearch = try await slskd.getSearch(id: id) if let responses = finalSearch.responses, !responses.isEmpty { return responses } throw SlskdError.searchTimeout } // MARK: - Source Selection private func pickBestSource( responses: [SlskdSearchResponse], expectedTrackCount: Int? ) -> SlskdSearchResponse? { let scored = responses .map { (response: $0, score: $0.qualityScore(expectedTrackCount: expectedTrackCount)) } .filter { $0.score >= qualityThreshold } .sorted { $0.score > $1.score } return scored.first?.response } // MARK: - Download Polling private func pollDownloads(username: String, expectedCount: Int, enqueuedFiles: Set) async throws { let deadline = Date().addingTimeInterval(downloadTimeout) while Date() < deadline { try Task.checkCancellation() let groups = try await slskd.getDownloads() // Find our user's transfer group if let group = groups.first(where: { $0.username == username }) { let allFiles = group.directories?.flatMap { $0.files ?? [] } ?? [] // C-2: Only count transfers for files we actually enqueued this run. // This filters out stale completed/failed transfers from previous downloads. let ourFiles = allFiles.filter { enqueuedFiles.contains($0.filename) } let completed = ourFiles.filter(\.isComplete).count let failed = ourFiles.filter(\.isFailed).count let total = max(expectedCount, ourFiles.count) // Update progress let progress = total > 0 ? Double(completed + failed) / Double(total) : 0 state = .downloading(progress: min(progress, 1.0)) // Check if done if completed + failed >= expectedCount { // H-4: Use ceiling division so 1/2 failures is caught. // For expectedCount=2: threshold=1, so 1 failure throws (1 >= 1). let failThreshold = (expectedCount + 1) / 2 if failed >= failThreshold { throw SlskdError.downloadFailed( "\(failed)/\(expectedCount) files failed" ) } return // Done downloading } } try await Task.sleep(for: .seconds(downloadPollInterval)) } throw SlskdError.downloadFailed("Download timed out after 10 minutes") } // MARK: - ChadMusic Import private func triggerImportAndWait(artist: String, albumName: String) async throws { // Trigger rescan try await chadMusic.triggerRescan() // Poll until the album appears let deadline = Date().addingTimeInterval(importTimeout) let searchLower = albumName.lowercased() while Date() < deadline { try Task.checkCancellation() try await Task.sleep(for: .seconds(importPollInterval)) // Check if album now exists in ChadMusic do { let albums = try await chadMusic.fetchAlbums(filteredBy: "artist", value: artist) // A-6: Use exact match instead of substring (.contains) to avoid // false positives like "I" matching every album title. if albums.contains(where: { $0.title.lowercased() == searchLower }) { return // Album found } } catch { // ChadMusic might be rescanning — ignore transient errors continue } } // C-3: Import timed out — throw so pipeline shows .failed, not .complete. // The files ARE downloaded on the server; the user should rescan manually. throw SlskdError.importTimeout } }