| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317 |
- 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
- }
- }
|