SoulseekOrchestrator.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. import Foundation
  2. // MARK: - Pipeline State
  3. /// Represents the current state of the Soulseek acquisition pipeline.
  4. /// A-11: All associated values (String, Double) are Equatable — compiler auto-synthesizes conformance.
  5. enum SoulseekPipelineState: Equatable {
  6. case idle
  7. case searching(query: String)
  8. case evaluating
  9. case downloading(progress: Double)
  10. case waitingForImport
  11. case complete(albumName: String)
  12. case failed(message: String)
  13. var isActive: Bool {
  14. switch self {
  15. case .idle, .complete, .failed: false
  16. default: true
  17. }
  18. }
  19. var statusText: String {
  20. switch self {
  21. case .idle: "Ready"
  22. case .searching(let q): "Searching Soulseek for \"\(q)\"..."
  23. case .evaluating: "Evaluating sources..."
  24. case .downloading(let p): "Downloading... \(Int(p * 100))%"
  25. case .waitingForImport: "Waiting for ChadMusic import..."
  26. case .complete(let name): "\(name) is ready"
  27. case .failed(let msg): "Failed: \(msg)"
  28. }
  29. }
  30. }
  31. // MARK: - Orchestrator
  32. /// End-to-end pipeline: search slskd -> pick best source -> download -> trigger ChadMusic import.
  33. /// Observe `state` from the UI to show progress. Call `acquireAlbum` to start.
  34. @MainActor
  35. @Observable
  36. @available(*, deprecated, message: "Use UnifiedSearchCoordinator instead. SoulseekOrchestrator auto-picks sources without user choice.")
  37. final class SoulseekOrchestrator {
  38. static let shared = SoulseekOrchestrator()
  39. // MARK: - Public State
  40. private(set) var state: SoulseekPipelineState = .idle
  41. /// The album name that was successfully imported (set on .complete).
  42. private(set) var importedAlbum: String?
  43. /// The best response chosen during evaluation (for debugging/display).
  44. private(set) var chosenSource: SlskdSearchResponse?
  45. // MARK: - Private
  46. private var activeTask: Task<Void, Never>?
  47. private var activeSearchId: String?
  48. /// H-1: Generation counter to prevent cancelled tasks from clobbering new pipeline state.
  49. private var pipelineGeneration: UInt64 = 0
  50. /// C-2: Set of filenames we enqueued for download in this pipeline run.
  51. /// Used to filter out stale transfers from the same peer.
  52. private var downloadEnqueuedFiles: Set<String> = []
  53. private let slskd = SlskdAPIClient.shared
  54. private let chadMusic = ChadMusicAPIClient.shared
  55. // Pipeline tuning constants
  56. private let searchPollInterval: TimeInterval = 2
  57. private let searchTimeout: TimeInterval = 30
  58. private let downloadPollInterval: TimeInterval = 2
  59. private let downloadTimeout: TimeInterval = 600 // 10 minutes
  60. private let importPollInterval: TimeInterval = 10
  61. private let importTimeout: TimeInterval = 300 // 5 minutes
  62. // A-3: User-configurable quality threshold (persisted in UserDefaults).
  63. // Default 80: FLAC/lossless + completeness. Users wanting 128kbps MP3 can lower it.
  64. private var qualityThreshold: Int {
  65. let stored = UserDefaults.standard.integer(forKey: "slskd.qualityThreshold")
  66. return stored > 0 ? stored : 80
  67. }
  68. // MARK: - Public API
  69. /// Start the full acquisition pipeline. Cancels any active pipeline first.
  70. func acquireAlbum(artist: String, albumName: String, expectedTrackCount: Int? = nil) {
  71. cancel()
  72. let query = "\(artist) \(albumName)"
  73. state = .searching(query: query)
  74. importedAlbum = nil
  75. chosenSource = nil
  76. // H-1: Capture the generation so the catch block can check staleness.
  77. pipelineGeneration &+= 1
  78. let myGeneration = pipelineGeneration
  79. activeTask = Task { [weak self] in
  80. guard let self else { return }
  81. do {
  82. try await self.runPipeline(
  83. query: query,
  84. artist: artist,
  85. albumName: albumName,
  86. expectedTrackCount: expectedTrackCount
  87. )
  88. } catch is CancellationError {
  89. // H-1: Only reset to .idle if no newer pipeline has started.
  90. if self.pipelineGeneration == myGeneration {
  91. self.state = .idle
  92. }
  93. } catch let error as SlskdError {
  94. if self.pipelineGeneration == myGeneration {
  95. self.state = .failed(message: error.errorDescription ?? "Unknown error")
  96. }
  97. } catch {
  98. if self.pipelineGeneration == myGeneration {
  99. self.state = .failed(message: error.localizedDescription)
  100. }
  101. }
  102. }
  103. }
  104. /// Cancel the active pipeline and clean up.
  105. func cancel() {
  106. activeTask?.cancel()
  107. activeTask = nil
  108. // Best-effort: delete active search on slskd
  109. if let searchId = activeSearchId {
  110. activeSearchId = nil
  111. Task {
  112. try? await slskd.deleteSearch(id: searchId)
  113. }
  114. }
  115. if state.isActive {
  116. state = .idle
  117. }
  118. }
  119. /// Dismiss a completed/failed result (returns to idle).
  120. func dismiss() {
  121. guard !state.isActive else { return }
  122. state = .idle
  123. importedAlbum = nil
  124. chosenSource = nil
  125. }
  126. // MARK: - Pipeline Steps
  127. private func runPipeline(
  128. query: String,
  129. artist: String,
  130. albumName: String,
  131. expectedTrackCount: Int?
  132. ) async throws {
  133. // Step 1: Search
  134. let searchId = try await slskd.startSearch(query: query)
  135. activeSearchId = searchId
  136. let responses = try await pollSearch(id: searchId)
  137. activeSearchId = nil
  138. guard !responses.isEmpty else {
  139. throw SlskdError.noResults
  140. }
  141. // Step 2: Evaluate
  142. try Task.checkCancellation()
  143. state = .evaluating
  144. let bestResponse = pickBestSource(
  145. responses: responses,
  146. expectedTrackCount: expectedTrackCount
  147. )
  148. guard let source = bestResponse else {
  149. throw SlskdError.noQualityMatch
  150. }
  151. chosenSource = source
  152. // Step 3: Download
  153. try Task.checkCancellation()
  154. state = .downloading(progress: 0)
  155. let audioFiles = source.files.filter(\.isAudioFile)
  156. // C-2: Track which files we enqueued so pollDownloads can ignore stale transfers.
  157. downloadEnqueuedFiles = Set(audioFiles.map(\.filename))
  158. try await slskd.enqueueDownloads(username: source.username, files: audioFiles)
  159. try await pollDownloads(
  160. username: source.username,
  161. expectedCount: audioFiles.count,
  162. enqueuedFiles: downloadEnqueuedFiles
  163. )
  164. // Step 4: Trigger ChadMusic import
  165. try Task.checkCancellation()
  166. state = .waitingForImport
  167. try await triggerImportAndWait(artist: artist, albumName: albumName)
  168. // Step 5: Complete
  169. importedAlbum = albumName
  170. state = .complete(albumName: albumName)
  171. // Clean up the search
  172. try? await slskd.deleteSearch(id: searchId)
  173. }
  174. // MARK: - Search Polling
  175. private func pollSearch(id: String) async throws -> [SlskdSearchResponse] {
  176. let deadline = Date().addingTimeInterval(searchTimeout)
  177. while Date() < deadline {
  178. try Task.checkCancellation()
  179. let search = try await slskd.getSearch(id: id)
  180. if search.isComplete {
  181. return search.responses ?? []
  182. }
  183. try await Task.sleep(for: .seconds(searchPollInterval))
  184. }
  185. // Timed out — grab whatever we have
  186. let finalSearch = try await slskd.getSearch(id: id)
  187. if let responses = finalSearch.responses, !responses.isEmpty {
  188. return responses
  189. }
  190. throw SlskdError.searchTimeout
  191. }
  192. // MARK: - Source Selection
  193. private func pickBestSource(
  194. responses: [SlskdSearchResponse],
  195. expectedTrackCount: Int?
  196. ) -> SlskdSearchResponse? {
  197. let scored = responses
  198. .map { (response: $0, score: $0.qualityScore(expectedTrackCount: expectedTrackCount)) }
  199. .filter { $0.score >= qualityThreshold }
  200. .sorted { $0.score > $1.score }
  201. return scored.first?.response
  202. }
  203. // MARK: - Download Polling
  204. private func pollDownloads(username: String, expectedCount: Int, enqueuedFiles: Set<String>) async throws {
  205. let deadline = Date().addingTimeInterval(downloadTimeout)
  206. while Date() < deadline {
  207. try Task.checkCancellation()
  208. let groups = try await slskd.getDownloads()
  209. // Find our user's transfer group
  210. if let group = groups.first(where: { $0.username == username }) {
  211. let allFiles = group.directories?.flatMap { $0.files ?? [] } ?? []
  212. // C-2: Only count transfers for files we actually enqueued this run.
  213. // This filters out stale completed/failed transfers from previous downloads.
  214. let ourFiles = allFiles.filter { enqueuedFiles.contains($0.filename) }
  215. let completed = ourFiles.filter(\.isComplete).count
  216. let failed = ourFiles.filter(\.isFailed).count
  217. let total = max(expectedCount, ourFiles.count)
  218. // Update progress
  219. let progress = total > 0 ? Double(completed + failed) / Double(total) : 0
  220. state = .downloading(progress: min(progress, 1.0))
  221. // Check if done
  222. if completed + failed >= expectedCount {
  223. // H-4: Use ceiling division so 1/2 failures is caught.
  224. // For expectedCount=2: threshold=1, so 1 failure throws (1 >= 1).
  225. let failThreshold = (expectedCount + 1) / 2
  226. if failed >= failThreshold {
  227. throw SlskdError.downloadFailed(
  228. "\(failed)/\(expectedCount) files failed"
  229. )
  230. }
  231. return // Done downloading
  232. }
  233. }
  234. try await Task.sleep(for: .seconds(downloadPollInterval))
  235. }
  236. throw SlskdError.downloadFailed("Download timed out after 10 minutes")
  237. }
  238. // MARK: - ChadMusic Import
  239. private func triggerImportAndWait(artist: String, albumName: String) async throws {
  240. // Trigger rescan
  241. try await chadMusic.triggerRescan()
  242. // Poll until the album appears
  243. let deadline = Date().addingTimeInterval(importTimeout)
  244. let searchLower = albumName.lowercased()
  245. while Date() < deadline {
  246. try Task.checkCancellation()
  247. try await Task.sleep(for: .seconds(importPollInterval))
  248. // Check if album now exists in ChadMusic
  249. do {
  250. let albums = try await chadMusic.fetchAlbums(filteredBy: "artist", value: artist)
  251. // A-6: Use exact match instead of substring (.contains) to avoid
  252. // false positives like "I" matching every album title.
  253. if albums.contains(where: { $0.title.lowercased() == searchLower }) {
  254. return // Album found
  255. }
  256. } catch {
  257. // ChadMusic might be rescanning — ignore transient errors
  258. continue
  259. }
  260. }
  261. // C-3: Import timed out — throw so pipeline shows .failed, not .complete.
  262. // The files ARE downloaded on the server; the user should rescan manually.
  263. throw SlskdError.importTimeout
  264. }
  265. }