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 = [ "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[.. 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 } }