| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330 |
- 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<Void, Never>?
- 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<String> = []
- 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<String>) 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
- }
- }
|