| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386 |
- 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<Void, Never>?
- private var downloadTask: Task<Void, Never>?
- 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
- }
- }
|