Procházet zdrojové kódy

fix: slskd 0.25 compat + album-grouped search + review fixes

slskd 0.25.x renamed searchTimeout→timeout (ms) and stopped
returning responses inline. Searches were instantly timing out.

- Fix search API: timeout field in ms, ?includeResponses=true
- Group results by directory (SlskdAlbumSource) — album-centric UX
- Show artist/album from path, format quality (bit depth/sample rate)
- Per-file download progress with transfer status icons
- Restore credentials to Keychain (was UserDefaults — security review)
- Fix nonisolated(unsafe) data race in coordinator deinit
- Split cancel() into cancelSearch()/cancelDownload()
- URL-encode search IDs in API paths
- Fix sample rate display: 44100→"44.1" not "44"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
aldiss před 2 měsíci
rodič
revize
04c006ee0e

+ 317 - 0
Sources/Models/SlskdModels.swift

@@ -0,0 +1,317 @@
+import Foundation
+
+// MARK: - Authentication
+
+struct SlskdLoginRequest: Encodable {
+    let username: String
+    let password: String
+}
+
+struct SlskdLoginResponse: Decodable {
+    let token: String
+}
+
+// MARK: - Search
+
+struct SlskdSearchRequest: Encodable {
+    let searchText: String
+    let responseLimit: Int
+    /// slskd 0.25+ uses "timeout" in milliseconds (was "searchTimeout" in seconds)
+    let timeout: Int
+    let filterResponses: Bool
+    let minimumResponseFileCount: Int
+}
+
+struct SlskdSearch: Decodable, Identifiable {
+    let id: String
+    let searchText: String
+    let isComplete: Bool
+    let responseCount: Int
+    let responses: [SlskdSearchResponse]?
+}
+
+struct SlskdSearchResponse: Decodable, Identifiable {
+    let username: String
+    let files: [SlskdFile]
+    let hasFreeUploadSlot: Bool
+    let uploadSpeed: Int
+    let queueLength: Int
+
+    // A-9: Combine username + first file path to produce unique IDs when the same
+    // user has multiple responses (different directories). Falls back to username alone.
+    var id: String {
+        if let firstFile = files.first {
+            return "\(username):\(firstFile.filename)"
+        }
+        return username
+    }
+}
+
+struct SlskdFile: Decodable {
+    let filename: String
+    let size: Int64
+    let bitRate: Int?
+    let bitDepth: Int?
+    let sampleRate: Int?
+    let length: Int?
+
+    var isAudioFile: Bool {
+        // A-4: Removed "alac" — ALAC uses .m4a container, not .alac extension.
+        let audioExtensions: Set<String> = [
+            "flac", "mp3", "wav", "aiff", "aif", "ogg",
+            "m4a", "aac", "ape", "wv",
+        ]
+        guard let ext = fileExtension else { return false }
+        return audioExtensions.contains(ext)
+    }
+
+    var fileExtension: String? {
+        // slskd paths use backslash: "@@user\Music\Artist\Album\track.flac"
+        let normalized = filename.replacingOccurrences(of: "\\", with: "/")
+        guard let lastComponent = normalized.split(separator: "/").last,
+              lastComponent.contains("."),
+              let ext = lastComponent.split(separator: ".").last else { return nil }
+        return String(ext).lowercased()
+    }
+}
+
+// MARK: - Downloads
+
+struct SlskdDownloadRequest: Encodable {
+    let filename: String
+    let size: Int64
+}
+
+struct SlskdTransfer: Decodable {
+    let username: String
+    let filename: String
+    let state: String       // "Completed", "InProgress", "Queued", "Errored", etc.
+    let size: Int64
+    let bytesTransferred: Int64
+    let percentComplete: Double
+    let averageSpeed: Double?
+
+    var isComplete: Bool {
+        // H-7: isFailed must take priority — "CompletedWithErrors" is a failure, not a success.
+        !isFailed && (state == "Completed" || state.contains("Succeeded"))
+    }
+
+    var isFailed: Bool {
+        state.contains("Errored") || state.contains("Errors")
+            || state.contains("Rejected") || state.contains("Cancelled")
+            || state.contains("TimedOut") || state.contains("Aborted")
+    }
+}
+
+struct SlskdTransferGroup: Decodable {
+    let username: String
+    let directories: [SlskdTransferDirectory]?
+}
+
+struct SlskdTransferDirectory: Decodable {
+    let directory: String
+    let files: [SlskdTransfer]?
+}
+
+// MARK: - Server
+
+struct SlskdServerState: Decodable {
+    let state: String
+}
+
+// MARK: - Quality Scoring
+
+// MARK: Album Source (directory-level grouping)
+
+/// A single directory from a Soulseek user's response — represents one album.
+struct SlskdAlbumSource: Identifiable {
+    let username: String
+    let directory: String
+    let files: [SlskdFile]
+    let hasFreeUploadSlot: Bool
+    let uploadSpeed: Int
+    let queueLength: Int
+
+    var id: String { "\(username):\(directory)" }
+
+    /// Last path component of the directory — the album name.
+    var albumName: String {
+        let normalized = directory.replacingOccurrences(of: "\\", with: "/")
+        return normalized.split(separator: "/").last.map(String.init) ?? directory
+    }
+
+    /// Second-to-last path component — often the artist name.
+    var artistGuess: String? {
+        let normalized = directory.replacingOccurrences(of: "\\", with: "/")
+        let parts = normalized.split(separator: "/")
+        guard parts.count >= 2 else { return nil }
+        return String(parts[parts.count - 2])
+    }
+
+    /// Audio files only.
+    var audioFiles: [SlskdFile] { files.filter(\.isAudioFile) }
+}
+
+extension SlskdSearchResponse {
+    /// Split this response into per-directory album sources.
+    func groupedByDirectory() -> [SlskdAlbumSource] {
+        var directories: [String: [SlskdFile]] = [:]
+        for file in files {
+            // Keep original path separators (backslash from Windows peers)
+            let sep: Character = file.filename.contains("\\") ? "\\" : "/"
+            if let lastSep = file.filename.lastIndex(of: sep) {
+                let dir = String(file.filename[..<lastSep])
+                directories[dir, default: []].append(file)
+            } else {
+                directories["", default: []].append(file)
+            }
+        }
+        return directories.map { dir, files in
+            SlskdAlbumSource(
+                username: username,
+                directory: dir,
+                files: files,
+                hasFreeUploadSlot: hasFreeUploadSlot,
+                uploadSpeed: uploadSpeed,
+                queueLength: queueLength
+            )
+        }
+    }
+}
+
+// MARK: Album Source Scoring
+
+extension SlskdAlbumSource {
+    /// Quality score — same logic as SlskdSearchResponse but scoped to this directory's files.
+    func qualityScore(expectedTrackCount: Int?) -> Int {
+        let audio = audioFiles
+        guard !audio.isEmpty else { return 0 }
+
+        var score = 0
+
+        // Format quality
+        let bestFormatScore = audio.compactMap(\.fileExtension).reduce(0) { best, ext in
+            let s: Int
+            switch ext {
+            case "flac", "wav", "aiff", "aif": s = 100
+            case "ape", "wv": s = 90
+            case "m4a": s = 80
+            case "mp3":
+                let mp3Files = audio.filter { $0.fileExtension == "mp3" }
+                let maxBR = mp3Files.compactMap(\.bitRate).max() ?? 0
+                s = maxBR >= 320 ? 70 : (maxBR >= 256 ? 50 : 30)
+            case "ogg", "aac": s = 60
+            default: s = 20
+            }
+            return max(best, s)
+        }
+        score += bestFormatScore
+
+        // Completeness
+        if let expected = expectedTrackCount, expected > 0 {
+            let hasCueSheet = files.contains { $0.fileExtension == "cue" }
+            if audio.count >= expected {
+                score += 50
+            } else if audio.count >= expected - 1 {
+                score += 30
+            } else if audio.count == 1 && hasCueSheet {
+                score += 35
+            }
+        } else {
+            score += 25
+        }
+
+        if hasFreeUploadSlot { score += 20 }
+
+        if uploadSpeed > 0 {
+            let mbps = Double(uploadSpeed) / 1_000_000.0
+            score += min(10, Int(log2(max(1, mbps)) * 3))
+        }
+
+        switch queueLength {
+        case 0: score += 10
+        case 1..<5: score += 5
+        default: break
+        }
+
+        return score
+    }
+}
+
+extension SlskdSearchResponse {
+
+    /// Score this response for album quality selection. Higher = better.
+    /// Threshold for auto-download: 80 points.
+    ///
+    /// Components:
+    /// - Format quality (max 100): FLAC/WAV=100, lossless compressed=90, 320kbps MP3=70, lower=30
+    /// - Completeness (max 50): audio file count >= expected track count
+    /// - Free upload slot (20)
+    /// - Upload speed (max 10): logarithmic scale
+    /// - Queue length (max 10): 0=10, <5=5, else 0
+    func qualityScore(expectedTrackCount: Int?) -> Int {
+        let audioFiles = files.filter(\.isAudioFile)
+        guard !audioFiles.isEmpty else { return 0 }
+
+        var score = 0
+
+        // Format quality — best format in the response
+        let bestFormatScore = audioFiles.compactMap(\.fileExtension).reduce(0) { best, ext in
+            let s: Int
+            switch ext {
+            case "flac", "wav", "aiff", "aif": s = 100
+            case "ape", "wv": s = 90
+            // A-4: m4a can be ALAC (lossless) — score higher than lossy-only codecs.
+            case "m4a": s = 80
+            case "mp3":
+                // H-2: Only consider bitRate from MP3 files, not FLACs or other formats
+                let mp3Files = audioFiles.filter { $0.fileExtension == "mp3" }
+                let maxBR = mp3Files.compactMap(\.bitRate).max() ?? 0
+                s = maxBR >= 320 ? 70 : (maxBR >= 256 ? 50 : 30)
+            case "ogg", "aac":
+                s = 60
+            default:
+                s = 20
+            }
+            return max(best, s)
+        }
+        score += bestFormatScore
+
+        // Completeness — does file count match expected track count?
+        if let expected = expectedTrackCount, expected > 0 {
+            // A-5: Single-file FLAC+CUE rips contain 1 audio file + .cue sheet for the
+            // whole album. Recognize this pattern and give partial completeness credit
+            // instead of 0, so these high-quality sources aren't systematically rejected.
+            let hasCueSheet = files.contains { $0.fileExtension == "cue" }
+            if audioFiles.count >= expected {
+                score += 50
+            } else if audioFiles.count >= expected - 1 {
+                score += 30  // off by one — might be missing a bonus track
+            } else if audioFiles.count == 1 && hasCueSheet {
+                score += 35  // single-file FLAC+CUE — valid album rip
+            }
+            // else: 0 for incomplete
+        } else {
+            // No expected count — give partial credit
+            score += 25
+        }
+
+        // Free upload slot
+        if hasFreeUploadSlot { score += 20 }
+
+        // Upload speed (0–10, logarithmic)
+        if uploadSpeed > 0 {
+            let mbps = Double(uploadSpeed) / 1_000_000.0
+            score += min(10, Int(log2(max(1, mbps)) * 3))
+        }
+
+        // Queue length
+        switch queueLength {
+        case 0: score += 10
+        case 1..<5: score += 5
+        default: break
+        }
+
+        return score
+    }
+}

+ 20 - 4
Sources/Services/SlskdAPIClient.swift

@@ -66,7 +66,7 @@ enum SlskdKeychainService {
 
 // MARK: - Slskd Credentials
 
-/// Manages slskd username and password stored in Keychain.
+/// Manages slskd Soulseek P2P credentials via Keychain.
 @MainActor
 final class SlskdCredentials {
     static let shared = SlskdCredentials()
@@ -262,7 +262,7 @@ final class SlskdAPIClient {
         let searchReq = SlskdSearchRequest(
             searchText: query,
             responseLimit: responseLimit,
-            searchTimeout: timeout,
+            timeout: timeout * 1000, // slskd 0.25+ expects milliseconds
             filterResponses: true,
             minimumResponseFileCount: 1
         )
@@ -285,14 +285,16 @@ final class SlskdAPIClient {
     /// Poll a search by ID. Returns the search state including responses.
     func getSearch(id: String) async throws -> SlskdSearch {
         try await ensureAuthenticated()
-        return try await get("api/v0/searches/\(id)")
+        let safeId = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
+        return try await get("api/v0/searches/\(safeId)?includeResponses=true")
     }
 
     /// Delete a search to clean up server-side resources.
     func deleteSearch(id: String) async throws {
         try await ensureAuthenticated()
 
-        let url = try buildURL("api/v0/searches/\(id)")
+        let safeId = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
+        let url = try buildURL("api/v0/searches/\(safeId)")
         var request = URLRequest(url: url)
         request.httpMethod = "DELETE"
         try applyAuth(&request)
@@ -337,6 +339,20 @@ final class SlskdAPIClient {
         return try await get("api/v0/server")
     }
 
+    /// Tell slskd to connect to the Soulseek network.
+    func connectToNetwork() async throws {
+        try await ensureAuthenticated()
+        let url = try buildURL("api/v0/server")
+        var request = URLRequest(url: url)
+        request.httpMethod = "PUT"
+        try applyAuth(&request)
+        request.timeoutInterval = 5
+        let (_, response) = try await performRequest(request)
+        if response.statusCode != 200 {
+            throw SlskdError.httpError(response.statusCode)
+        }
+    }
+
     /// Quick connectivity test. Returns nil on success, error on failure.
     func testConnection() async -> SlskdError? {
         do {

+ 65 - 47
Sources/Services/UnifiedSearchCoordinator.swift

@@ -40,44 +40,28 @@ enum SourceDownloadPhase: Equatable {
 // MARK: - Scored Source
 
 struct ScoredSoulseekSource: Identifiable {
-    let response: SlskdSearchResponse
+    let albumSource: SlskdAlbumSource
     let score: Int
 
-    var id: String { response.id }
+    var id: String { albumSource.id }
 
     /// Best audio format in this source.
     var bestFormat: String {
-        let audioFiles = response.files.filter(\.isAudioFile)
-        let extensions = audioFiles.compactMap(\.fileExtension)
-
-        // Priority order
+        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() ?? "?"
     }
 
-    /// Number of audio files.
-    var audioFileCount: Int {
-        response.files.filter(\.isAudioFile).count
-    }
-
-    /// Audio files in this source.
-    var audioFiles: [SlskdFile] {
-        response.files.filter(\.isAudioFile)
-    }
+    var audioFileCount: Int { albumSource.audioFiles.count }
+    var audioFiles: [SlskdFile] { albumSource.audioFiles }
+    var totalSize: Int64 { audioFiles.reduce(0) { $0 + $1.size } }
 
-    /// 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" {
@@ -86,19 +70,42 @@ struct ScoredSoulseekSource: Identifiable {
                 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
     }
 
-    /// Score color: green (excellent), yellow (acceptable), red (poor).
     var scoreGrade: ScoreGrade {
         if score >= 120 { return .excellent }
         if score >= 80 { return .good }
         return .poor
     }
 
-    /// Text representation for drag-and-drop.
+    /// 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 {
-        "\(response.username) — \(formatDisplay) — \(audioFileCount) files — \(formattedTotalSize)"
+        "\(username) — \(formatDisplay) — \(audioFileCount) files — \(formattedTotalSize)"
     }
 
     enum ScoreGrade {
@@ -120,14 +127,16 @@ final class UnifiedSearchCoordinator {
     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 nonisolated(unsafe) var searchTask: Task<Void, Never>?
-    private nonisolated(unsafe) var downloadTask: Task<Void, Never>?
-    private nonisolated(unsafe) var activeSearchId: String?
+    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
@@ -141,22 +150,12 @@ final class UnifiedSearchCoordinator {
         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()
+        cancelSearch()
 
         searchTask = Task { [weak self] in
             guard let self else { return }
@@ -172,10 +171,14 @@ final class UnifiedSearchCoordinator {
 
     /// Cancel any active search or download.
     func cancel() {
+        cancelSearch()
+        cancelDownload()
+    }
+
+    /// Cancel only the active search, preserving download state.
+    func cancelSearch() {
         searchTask?.cancel()
         searchTask = nil
-        downloadTask?.cancel()
-        downloadTask = nil
 
         if let searchId = activeSearchId {
             activeSearchId = nil
@@ -185,12 +188,19 @@ final class UnifiedSearchCoordinator {
         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 source picked by the user.
+    /// Download a specific Soulseek album source picked by the user.
     func downloadSource(
-        _ source: SlskdSearchResponse,
+        _ source: SlskdAlbumSource,
         artist: String,
         albumName: String
     ) {
@@ -250,9 +260,10 @@ final class UnifiedSearchCoordinator {
         let responses = try await pollSearch(id: searchId)
         activeSearchId = nil
 
-        // Score and sort responses
+        // Score and sort by directory (album-level grouping)
         soulseekSources = responses
-            .map { ScoredSoulseekSource(response: $0, score: $0.qualityScore(expectedTrackCount: nil)) }
+            .flatMap { $0.groupedByDirectory() }
+            .map { ScoredSoulseekSource(albumSource: $0, score: $0.qualityScore(expectedTrackCount: nil)) }
             .filter { $0.score > 0 }
             .sorted { $0.score > $1.score }
 
@@ -282,11 +293,11 @@ final class UnifiedSearchCoordinator {
     // MARK: - Private: Download + Import Pipeline
 
     private func runDownloadAndImport(
-        source: SlskdSearchResponse,
+        source: SlskdAlbumSource,
         artist: String,
         albumName: String
     ) async throws {
-        let audioFiles = source.files.filter(\.isAudioFile)
+        let audioFiles = source.audioFiles
         guard !audioFiles.isEmpty else {
             throw SlskdError.noResults
         }
@@ -308,6 +319,13 @@ final class UnifiedSearchCoordinator {
                 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)

+ 98 - 20
Sources/Views/UnifiedSearchResultsView.swift

@@ -9,6 +9,8 @@ struct UnifiedSearchResultsView: View {
     @Binding var navStack: [CloudNavDestination]
 
     @State private var coordinator = UnifiedSearchCoordinator()
+    @State private var editableQuery: String = ""
+    @FocusState private var isSearchFieldFocused: Bool
     @EnvironmentObject private var theme: AppTheme
 
     var body: some View {
@@ -22,7 +24,17 @@ struct UnifiedSearchResultsView: View {
             Group {
                 switch coordinator.phase {
                 case .idle:
-                    EmptyView()
+                    VStack(spacing: 8) {
+                        Spacer()
+                        Image(systemName: "magnifyingglass")
+                            .font(.system(size: 32))
+                            .foregroundStyle(theme.tertiaryText)
+                        Text("Type a query and press Enter")
+                            .font(.system(size: 13))
+                            .foregroundStyle(theme.secondaryText)
+                        Spacer()
+                    }
+                    .frame(maxWidth: .infinity, maxHeight: .infinity)
 
                 case .searchingCloud:
                     searchingView("Searching your library...")
@@ -48,7 +60,12 @@ struct UnifiedSearchResultsView: View {
             }
         }
         .task {
-            coordinator.search(query: query)
+            editableQuery = query
+            if query.trimmingCharacters(in: .whitespacesAndNewlines).count >= 2 {
+                coordinator.search(query: query)
+            } else {
+                isSearchFieldFocused = true
+            }
         }
     }
 
@@ -58,10 +75,16 @@ struct UnifiedSearchResultsView: View {
         HStack(spacing: 8) {
             Image(systemName: "magnifyingglass")
                 .foregroundStyle(theme.secondaryText)
-            Text("\"\(query)\"")
+            TextField("Search library & Soulseek...", text: $editableQuery)
+                .textFieldStyle(.plain)
                 .font(.system(size: 13, weight: .medium))
                 .foregroundStyle(theme.primaryText)
-                .lineLimit(1)
+                .focused($isSearchFieldFocused)
+                .onSubmit {
+                    let q = editableQuery.trimmingCharacters(in: .whitespacesAndNewlines)
+                    guard q.count >= 2 else { return }
+                    coordinator.search(query: q)
+                }
             Spacer()
 
             if coordinator.phase == .searchingCloud || coordinator.phase == .searchingSoulseek {
@@ -99,7 +122,7 @@ struct UnifiedSearchResultsView: View {
                 .multilineTextAlignment(.center)
                 .padding(.horizontal, 20)
             Button("Retry") {
-                coordinator.search(query: query)
+                coordinator.search(query: editableQuery)
             }
             .controlSize(.small)
             Spacer()
@@ -113,7 +136,7 @@ struct UnifiedSearchResultsView: View {
             Image(systemName: "magnifyingglass")
                 .font(.system(size: 32))
                 .foregroundStyle(theme.tertiaryText)
-            Text("No results for \"\(query)\"")
+            Text("No results for \"\(editableQuery)\"")
                 .font(.system(size: 13))
                 .foregroundStyle(theme.secondaryText)
             if !SlskdAPIClient.shared.isConfigured {
@@ -177,11 +200,12 @@ struct UnifiedSearchResultsView: View {
                         SoulseekSourceRow(
                             source: source,
                             isDownloading: coordinator.downloadPhase.isActive,
+                            transfers: coordinator.activeTransfers,
                             onDownload: {
                                 coordinator.downloadSource(
-                                    source.response,
-                                    artist: guessArtist(from: query),
-                                    albumName: query
+                                    source.albumSource,
+                                    artist: source.artistGuess ?? guessArtist(from: editableQuery),
+                                    albumName: source.albumName
                                 )
                             }
                         )
@@ -234,7 +258,7 @@ struct UnifiedSearchResultsView: View {
 
             if coordinator.downloadPhase.isActive {
                 Button("Cancel") {
-                    coordinator.cancel()
+                    coordinator.cancelDownload()
                 }
                 .controlSize(.small)
             } else if coordinator.downloadPhase != .idle {
@@ -299,10 +323,11 @@ struct ScoreBadge: View {
 
 // MARK: - Soulseek Source Row
 
-/// A single Soulseek search result showing quality score, format, file count, and download button.
+/// A single Soulseek search result — one directory (album) from a user.
 struct SoulseekSourceRow: View {
     let source: ScoredSoulseekSource
     var isDownloading: Bool = false
+    var transfers: [String: SlskdTransfer] = [:]
     let onDownload: () -> Void
 
     @EnvironmentObject private var theme: AppTheme
@@ -314,32 +339,55 @@ struct SoulseekSourceRow: View {
                 // Quality score badge
                 ScoreBadge(score: source.score)
 
-                // Source info
+                // Album info
                 VStack(alignment: .leading, spacing: 2) {
+                    // Line 1: Artist — Album
+                    HStack(spacing: 0) {
+                        if let artist = source.artistGuess {
+                            Text(artist)
+                                .font(.system(size: 12, weight: .medium))
+                                .foregroundStyle(theme.secondaryText)
+                                .lineLimit(1)
+                            Text(" — ")
+                                .font(.system(size: 12))
+                                .foregroundStyle(theme.tertiaryText)
+                        }
+                        Text(source.albumName)
+                            .font(.system(size: 12, weight: .semibold))
+                            .foregroundStyle(theme.primaryText)
+                            .lineLimit(1)
+                    }
+
+                    // Line 2: Format · files · size · ⚡
                     HStack(spacing: 6) {
                         Text(source.formatDisplay)
                             .font(.system(size: 11, weight: .bold, design: .monospaced))
                             .foregroundStyle(formatColor)
+                        Text("·")
+                            .foregroundStyle(theme.tertiaryText)
                         Text("\(source.audioFileCount) files")
                             .font(.system(size: 11))
                             .foregroundStyle(theme.secondaryText)
+                        Text("·")
+                            .foregroundStyle(theme.tertiaryText)
                         Text(source.formattedTotalSize)
                             .font(.system(size: 11))
                             .foregroundStyle(theme.secondaryText)
-                        if source.response.hasFreeUploadSlot {
+                        if source.albumSource.hasFreeUploadSlot {
                             Image(systemName: "bolt.fill")
                                 .font(.system(size: 9))
                                 .foregroundStyle(Color(red: 0.2, green: 0.9, blue: 0.4))
                         }
                     }
 
+                    // Line 3: Username · Queue
                     HStack(spacing: 6) {
-                        Text(source.response.username)
+                        Text(source.username)
                             .font(.system(size: 10))
                             .foregroundStyle(theme.tertiaryText)
                             .lineLimit(1)
-                        if source.response.queueLength > 0 {
-                            Text("Queue: \(source.response.queueLength)")
+                        if source.albumSource.queueLength > 0 {
+                            Text("Queue: \(source.albumSource.queueLength)")
                                 .font(.system(size: 10))
                                 .foregroundStyle(theme.tertiaryText)
                         }
@@ -369,7 +417,7 @@ struct SoulseekSourceRow: View {
                 .buttonStyle(.plain)
                 .disabled(isDownloading || source.score < 30)
                 .help(source.score >= 80
-                      ? "Download this source"
+                      ? "Download \(source.albumName)"
                       : "Quality too low (score: \(source.score))")
             }
             .padding(.vertical, 3)
@@ -379,6 +427,11 @@ struct SoulseekSourceRow: View {
                 VStack(alignment: .leading, spacing: 1) {
                     ForEach(source.audioFiles, id: \.filename) { file in
                         HStack(spacing: 8) {
+                            // Transfer status icon
+                            if let transfer = transfers[file.filename] {
+                                transferIcon(transfer)
+                            }
+
                             let name = file.filename
                                 .replacingOccurrences(of: "\\", with: "/")
                                 .split(separator: "/").last.map(String.init) ?? file.filename
@@ -387,9 +440,18 @@ struct SoulseekSourceRow: View {
                                 .foregroundStyle(theme.secondaryText)
                                 .lineLimit(1)
                             Spacer()
-                            Text(ByteCountFormatter.string(fromByteCount: file.size, countStyle: .file))
-                                .font(.system(size: 10, design: .monospaced))
-                                .foregroundStyle(theme.tertiaryText)
+
+                            // Per-file progress or size
+                            if let transfer = transfers[file.filename], !transfer.isComplete, !transfer.isFailed {
+                                Text("\(Int(transfer.percentComplete))%")
+                                    .font(.system(size: 10, weight: .medium, design: .monospaced))
+                                    .foregroundStyle(theme.accent)
+                            } else {
+                                Text(ByteCountFormatter.string(fromByteCount: file.size, countStyle: .file))
+                                    .font(.system(size: 10, design: .monospaced))
+                                    .foregroundStyle(theme.tertiaryText)
+                            }
+
                             if let br = file.bitRate, br > 0 {
                                 Text("\(br)k")
                                     .font(.system(size: 10, design: .monospaced))
@@ -413,4 +475,20 @@ struct SoulseekSourceRow: View {
         default: theme.tertiaryText
         }
     }
+
+    @ViewBuilder
+    private func transferIcon(_ transfer: SlskdTransfer) -> some View {
+        if transfer.isComplete {
+            Image(systemName: "checkmark.circle.fill")
+                .font(.system(size: 9))
+                .foregroundStyle(.green)
+        } else if transfer.isFailed {
+            Image(systemName: "xmark.circle.fill")
+                .font(.system(size: 9))
+                .foregroundStyle(.red)
+        } else {
+            ProgressView()
+                .controlSize(.mini)
+        }
+    }
 }