| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353 |
- 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<Void, Never>?
- private nonisolated(unsafe) var downloadTask: Task<Void, Never>?
- 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
- try Task.checkCancellation()
- 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
- }
- }
|