|
|
@@ -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
|
|
|
+ }
|
|
|
+}
|