Track.swift 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. import Foundation
  2. import SwiftData
  3. /// Download state for cloud tracks.
  4. enum DownloadState: String, Codable {
  5. case none
  6. case downloading
  7. case downloaded
  8. case error
  9. }
  10. /// Upload state for local tracks.
  11. enum UploadState: String, Codable {
  12. case none
  13. case uploading
  14. case uploaded
  15. case error
  16. }
  17. /// Represents a single audio track in the library.
  18. @Model
  19. final class Track {
  20. var id: UUID = UUID()
  21. var title: String = ""
  22. var artist: String = ""
  23. var album: String = ""
  24. var genre: String = ""
  25. var filePath: String = ""
  26. var duration: TimeInterval = 0
  27. var bpm: Double?
  28. var musicalKey: String?
  29. var sampleRate: Double = 44100
  30. var bitDepth: Int = 16
  31. var channels: Int = 2
  32. var fileFormat: String = ""
  33. var fileSizeBytes: Int64 = 0
  34. var dateAdded: Date = Date()
  35. var lastPlayed: Date?
  36. var playCount: Int = 0
  37. var rating: Int = 0 // 0-5 stars
  38. var color: String? // user-assigned color tag
  39. var year: Int? // release year from metadata
  40. var notes: String = ""
  41. // MARK: - Cloud (Chad Music) fields
  42. /// If true, this track is from Chad Music cloud — streamed via AVPlayer, not local file.
  43. var isCloud: Bool = false
  44. /// Chad Music server URL for streaming (e.g., "/music/Artist/Album/track.mp3").
  45. var cloudStreamPath: String?
  46. /// Chad Music track ID (hex string from server).
  47. var cloudTrackId: String?
  48. /// Local cache path for downloaded cloud tracks (persistent offline storage).
  49. var localCachePath: String?
  50. /// Download state for cloud tracks — stored as raw string value of DownloadState enum.
  51. var downloadStateRaw: String = DownloadState.none.rawValue
  52. /// Upload state for local tracks — stored as raw string value of UploadState enum.
  53. var uploadStateRaw: String = UploadState.none.rawValue
  54. /// Cached waveform samples (downsampled min/max pairs), stored as Data for efficiency.
  55. var waveformData: Data?
  56. /// Whether BPM/key analysis has been performed.
  57. var isAnalyzed: Bool = false
  58. @Relationship(deleteRule: .cascade, inverse: \CuePoint.track)
  59. var cuePoints: [CuePoint]
  60. var fileURL: URL {
  61. URL(fileURLWithPath: filePath.isEmpty ? "/dev/null" : filePath)
  62. }
  63. /// True if this track has a valid local file (not a cloud-only track).
  64. var hasLocalFile: Bool {
  65. !filePath.isEmpty && !isCloud
  66. }
  67. /// Download state as typed enum (read/write through downloadStateRaw).
  68. var downloadState: DownloadState {
  69. get { DownloadState(rawValue: downloadStateRaw) ?? .none }
  70. set { downloadStateRaw = newValue.rawValue }
  71. }
  72. /// Upload state as typed enum (read/write through uploadStateRaw).
  73. var uploadState: UploadState {
  74. get { UploadState(rawValue: uploadStateRaw) ?? .none }
  75. set { uploadStateRaw = newValue.rawValue }
  76. }
  77. /// True if this track has a playable local file — either a local library track or a downloaded cloud track.
  78. /// Performs stale file recovery: if localCachePath is set but file is missing, resets download state.
  79. var hasPlayableLocalFile: Bool {
  80. // Local library track
  81. if !filePath.isEmpty && FileManager.default.fileExists(atPath: filePath) {
  82. return true
  83. }
  84. // Downloaded cloud track
  85. if let cachePath = localCachePath {
  86. if FileManager.default.fileExists(atPath: cachePath) {
  87. return true
  88. }
  89. // Stale file recovery — file missing, reset state
  90. localCachePath = nil
  91. downloadState = .none
  92. }
  93. return false
  94. }
  95. /// URL for the best available local file (prefers filePath, falls back to localCachePath).
  96. var playableFileURL: URL {
  97. if !filePath.isEmpty && FileManager.default.fileExists(atPath: filePath) {
  98. return URL(fileURLWithPath: filePath)
  99. }
  100. if let cachePath = localCachePath, FileManager.default.fileExists(atPath: cachePath) {
  101. return URL(fileURLWithPath: cachePath)
  102. }
  103. return URL(fileURLWithPath: "/dev/null")
  104. }
  105. var formattedDuration: String {
  106. let minutes = Int(duration) / 60
  107. let seconds = Int(duration) % 60
  108. return String(format: "%d:%02d", minutes, seconds)
  109. }
  110. var formattedBPM: String {
  111. guard let bpm else { return "—" }
  112. return String(format: "%.1f", bpm)
  113. }
  114. var formattedFileSize: String {
  115. ByteCountFormatter.string(fromByteCount: fileSizeBytes, countStyle: .file)
  116. }
  117. init(
  118. title: String,
  119. artist: String = "",
  120. album: String = "",
  121. genre: String = "",
  122. filePath: String,
  123. duration: TimeInterval = 0,
  124. sampleRate: Double = 44100,
  125. bitDepth: Int = 16,
  126. channels: Int = 2,
  127. fileFormat: String = "",
  128. fileSizeBytes: Int64 = 0
  129. ) {
  130. self.id = UUID()
  131. self.title = title
  132. self.artist = artist
  133. self.album = album
  134. self.genre = genre
  135. self.filePath = filePath
  136. self.duration = duration
  137. self.bpm = nil
  138. self.musicalKey = nil
  139. self.sampleRate = sampleRate
  140. self.bitDepth = bitDepth
  141. self.channels = channels
  142. self.fileFormat = fileFormat
  143. self.fileSizeBytes = fileSizeBytes
  144. self.dateAdded = Date()
  145. self.lastPlayed = nil
  146. self.playCount = 0
  147. self.rating = 0
  148. self.color = nil
  149. self.year = nil
  150. self.notes = ""
  151. self.waveformData = nil
  152. self.isAnalyzed = false
  153. self.cuePoints = []
  154. }
  155. /// Create a Track from a Chad Music cloud track.
  156. static func fromCloud(_ chadTrack: ChadTrack) -> Track {
  157. let track = Track(
  158. title: chadTrack.title,
  159. artist: chadTrack.artist ?? "",
  160. album: chadTrack.album ?? "",
  161. filePath: "",
  162. duration: chadTrack.duration ?? 0
  163. )
  164. track.isCloud = true
  165. track.cloudStreamPath = chadTrack.url
  166. track.cloudTrackId = chadTrack.id
  167. track.year = chadTrack.year
  168. return track
  169. }
  170. }