Track.swift 5.5 KB

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