SlskdModels.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. import Foundation
  2. // MARK: - Authentication
  3. struct SlskdLoginRequest: Encodable {
  4. let username: String
  5. let password: String
  6. }
  7. struct SlskdLoginResponse: Decodable {
  8. let token: String
  9. }
  10. // MARK: - Search
  11. struct SlskdSearchRequest: Encodable {
  12. let searchText: String
  13. let responseLimit: Int
  14. /// slskd 0.25+ uses "timeout" in milliseconds (was "searchTimeout" in seconds)
  15. let timeout: Int
  16. let filterResponses: Bool
  17. let minimumResponseFileCount: Int
  18. }
  19. struct SlskdSearch: Decodable, Identifiable {
  20. let id: String
  21. let searchText: String
  22. let isComplete: Bool
  23. let responseCount: Int
  24. let responses: [SlskdSearchResponse]?
  25. }
  26. struct SlskdSearchResponse: Decodable, Identifiable {
  27. let username: String
  28. let files: [SlskdFile]
  29. let hasFreeUploadSlot: Bool
  30. let uploadSpeed: Int
  31. let queueLength: Int
  32. // A-9: Combine username + first file path to produce unique IDs when the same
  33. // user has multiple responses (different directories). Falls back to username alone.
  34. var id: String {
  35. if let firstFile = files.first {
  36. return "\(username):\(firstFile.filename)"
  37. }
  38. return username
  39. }
  40. }
  41. struct SlskdFile: Decodable {
  42. let filename: String
  43. let size: Int64
  44. let bitRate: Int?
  45. let bitDepth: Int?
  46. let sampleRate: Int?
  47. let length: Int?
  48. var isAudioFile: Bool {
  49. // A-4: Removed "alac" — ALAC uses .m4a container, not .alac extension.
  50. let audioExtensions: Set<String> = [
  51. "flac", "mp3", "wav", "aiff", "aif", "ogg",
  52. "m4a", "aac", "ape", "wv",
  53. ]
  54. guard let ext = fileExtension else { return false }
  55. return audioExtensions.contains(ext)
  56. }
  57. var fileExtension: String? {
  58. // slskd paths use backslash: "@@user\Music\Artist\Album\track.flac"
  59. let normalized = filename.replacingOccurrences(of: "\\", with: "/")
  60. guard let lastComponent = normalized.split(separator: "/").last,
  61. lastComponent.contains("."),
  62. let ext = lastComponent.split(separator: ".").last else { return nil }
  63. return String(ext).lowercased()
  64. }
  65. }
  66. // MARK: - Downloads
  67. struct SlskdDownloadRequest: Encodable {
  68. let filename: String
  69. let size: Int64
  70. }
  71. struct SlskdTransfer: Decodable {
  72. let username: String
  73. let filename: String
  74. let state: String // "Completed", "InProgress", "Queued", "Errored", etc.
  75. let size: Int64
  76. let bytesTransferred: Int64
  77. let percentComplete: Double
  78. let averageSpeed: Double?
  79. var isComplete: Bool {
  80. // H-7: isFailed must take priority — "CompletedWithErrors" is a failure, not a success.
  81. !isFailed && (state == "Completed" || state.contains("Succeeded"))
  82. }
  83. var isFailed: Bool {
  84. state.contains("Errored") || state.contains("Errors")
  85. || state.contains("Rejected") || state.contains("Cancelled")
  86. || state.contains("TimedOut") || state.contains("Aborted")
  87. }
  88. }
  89. struct SlskdTransferGroup: Decodable {
  90. let username: String
  91. let directories: [SlskdTransferDirectory]?
  92. }
  93. struct SlskdTransferDirectory: Decodable {
  94. let directory: String
  95. let files: [SlskdTransfer]?
  96. }
  97. // MARK: - Server
  98. struct SlskdServerState: Decodable {
  99. let state: String
  100. }
  101. // MARK: - Quality Scoring
  102. // MARK: Album Source (directory-level grouping)
  103. /// A single directory from a Soulseek user's response — represents one album.
  104. struct SlskdAlbumSource: Identifiable {
  105. let username: String
  106. let directory: String
  107. let files: [SlskdFile]
  108. let hasFreeUploadSlot: Bool
  109. let uploadSpeed: Int
  110. let queueLength: Int
  111. var id: String { "\(username):\(directory)" }
  112. /// Last path component of the directory — the album name.
  113. var albumName: String {
  114. let normalized = directory.replacingOccurrences(of: "\\", with: "/")
  115. return normalized.split(separator: "/").last.map(String.init) ?? directory
  116. }
  117. /// Second-to-last path component — often the artist name.
  118. var artistGuess: String? {
  119. let normalized = directory.replacingOccurrences(of: "\\", with: "/")
  120. let parts = normalized.split(separator: "/")
  121. guard parts.count >= 2 else { return nil }
  122. return String(parts[parts.count - 2])
  123. }
  124. /// Audio files only.
  125. var audioFiles: [SlskdFile] { files.filter(\.isAudioFile) }
  126. }
  127. extension SlskdSearchResponse {
  128. /// Split this response into per-directory album sources.
  129. func groupedByDirectory() -> [SlskdAlbumSource] {
  130. var directories: [String: [SlskdFile]] = [:]
  131. for file in files {
  132. // Keep original path separators (backslash from Windows peers)
  133. let sep: Character = file.filename.contains("\\") ? "\\" : "/"
  134. if let lastSep = file.filename.lastIndex(of: sep) {
  135. let dir = String(file.filename[..<lastSep])
  136. directories[dir, default: []].append(file)
  137. } else {
  138. directories["", default: []].append(file)
  139. }
  140. }
  141. return directories.map { dir, files in
  142. SlskdAlbumSource(
  143. username: username,
  144. directory: dir,
  145. files: files,
  146. hasFreeUploadSlot: hasFreeUploadSlot,
  147. uploadSpeed: uploadSpeed,
  148. queueLength: queueLength
  149. )
  150. }
  151. }
  152. }
  153. // MARK: Album Source Scoring
  154. extension SlskdAlbumSource {
  155. /// Quality score — same logic as SlskdSearchResponse but scoped to this directory's files.
  156. func qualityScore(expectedTrackCount: Int?) -> Int {
  157. let audio = audioFiles
  158. guard !audio.isEmpty else { return 0 }
  159. var score = 0
  160. // Format quality
  161. let bestFormatScore = audio.compactMap(\.fileExtension).reduce(0) { best, ext in
  162. let s: Int
  163. switch ext {
  164. case "flac", "wav", "aiff", "aif": s = 100
  165. case "ape", "wv": s = 90
  166. case "m4a": s = 80
  167. case "mp3":
  168. let mp3Files = audio.filter { $0.fileExtension == "mp3" }
  169. let maxBR = mp3Files.compactMap(\.bitRate).max() ?? 0
  170. s = maxBR >= 320 ? 70 : (maxBR >= 256 ? 50 : 30)
  171. case "ogg", "aac": s = 60
  172. default: s = 20
  173. }
  174. return max(best, s)
  175. }
  176. score += bestFormatScore
  177. // Completeness
  178. if let expected = expectedTrackCount, expected > 0 {
  179. let hasCueSheet = files.contains { $0.fileExtension == "cue" }
  180. if audio.count >= expected {
  181. score += 50
  182. } else if audio.count >= expected - 1 {
  183. score += 30
  184. } else if audio.count == 1 && hasCueSheet {
  185. score += 35
  186. }
  187. } else {
  188. score += 25
  189. }
  190. if hasFreeUploadSlot { score += 20 }
  191. if uploadSpeed > 0 {
  192. let mbps = Double(uploadSpeed) / 1_000_000.0
  193. score += min(10, Int(log2(max(1, mbps)) * 3))
  194. }
  195. switch queueLength {
  196. case 0: score += 10
  197. case 1..<5: score += 5
  198. default: break
  199. }
  200. return score
  201. }
  202. }
  203. extension SlskdSearchResponse {
  204. /// Score this response for album quality selection. Higher = better.
  205. /// Threshold for auto-download: 80 points.
  206. ///
  207. /// Components:
  208. /// - Format quality (max 100): FLAC/WAV=100, lossless compressed=90, 320kbps MP3=70, lower=30
  209. /// - Completeness (max 50): audio file count >= expected track count
  210. /// - Free upload slot (20)
  211. /// - Upload speed (max 10): logarithmic scale
  212. /// - Queue length (max 10): 0=10, <5=5, else 0
  213. func qualityScore(expectedTrackCount: Int?) -> Int {
  214. let audioFiles = files.filter(\.isAudioFile)
  215. guard !audioFiles.isEmpty else { return 0 }
  216. var score = 0
  217. // Format quality — best format in the response
  218. let bestFormatScore = audioFiles.compactMap(\.fileExtension).reduce(0) { best, ext in
  219. let s: Int
  220. switch ext {
  221. case "flac", "wav", "aiff", "aif": s = 100
  222. case "ape", "wv": s = 90
  223. // A-4: m4a can be ALAC (lossless) — score higher than lossy-only codecs.
  224. case "m4a": s = 80
  225. case "mp3":
  226. // H-2: Only consider bitRate from MP3 files, not FLACs or other formats
  227. let mp3Files = audioFiles.filter { $0.fileExtension == "mp3" }
  228. let maxBR = mp3Files.compactMap(\.bitRate).max() ?? 0
  229. s = maxBR >= 320 ? 70 : (maxBR >= 256 ? 50 : 30)
  230. case "ogg", "aac":
  231. s = 60
  232. default:
  233. s = 20
  234. }
  235. return max(best, s)
  236. }
  237. score += bestFormatScore
  238. // Completeness — does file count match expected track count?
  239. if let expected = expectedTrackCount, expected > 0 {
  240. // A-5: Single-file FLAC+CUE rips contain 1 audio file + .cue sheet for the
  241. // whole album. Recognize this pattern and give partial completeness credit
  242. // instead of 0, so these high-quality sources aren't systematically rejected.
  243. let hasCueSheet = files.contains { $0.fileExtension == "cue" }
  244. if audioFiles.count >= expected {
  245. score += 50
  246. } else if audioFiles.count >= expected - 1 {
  247. score += 30 // off by one — might be missing a bonus track
  248. } else if audioFiles.count == 1 && hasCueSheet {
  249. score += 35 // single-file FLAC+CUE — valid album rip
  250. }
  251. // else: 0 for incomplete
  252. } else {
  253. // No expected count — give partial credit
  254. score += 25
  255. }
  256. // Free upload slot
  257. if hasFreeUploadSlot { score += 20 }
  258. // Upload speed (0–10, logarithmic)
  259. if uploadSpeed > 0 {
  260. let mbps = Double(uploadSpeed) / 1_000_000.0
  261. score += min(10, Int(log2(max(1, mbps)) * 3))
  262. }
  263. // Queue length
  264. switch queueLength {
  265. case 0: score += 10
  266. case 1..<5: score += 5
  267. default: break
  268. }
  269. return score
  270. }
  271. }