LibraryManager.swift 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. import Foundation
  2. import SwiftData
  3. /// Manages the music library — scanning directories, importing tracks, and managing files.
  4. @MainActor
  5. final class LibraryManager: ObservableObject {
  6. @Published var isScanning = false
  7. @Published var scanProgress: Double = 0
  8. @Published var lastError: String?
  9. private var modelContext: ModelContext?
  10. func setModelContext(_ context: ModelContext) {
  11. self.modelContext = context
  12. }
  13. // MARK: - Import
  14. /// Import audio files from a directory (recursively).
  15. func importDirectory(_ url: URL) async {
  16. guard let context = modelContext else { return }
  17. isScanning = true
  18. scanProgress = 0
  19. lastError = nil
  20. let fileManager = FileManager.default
  21. var audioURLs: [URL] = []
  22. // Collect all audio files
  23. if let enumerator = fileManager.enumerator(
  24. at: url,
  25. includingPropertiesForKeys: [.isRegularFileKey],
  26. options: [.skipsHiddenFiles]
  27. ) {
  28. for case let fileURL as URL in enumerator {
  29. if MetadataService.isSupportedAudioFile(fileURL) {
  30. audioURLs.append(fileURL)
  31. }
  32. }
  33. }
  34. guard !audioURLs.isEmpty else {
  35. isScanning = false
  36. lastError = "No supported audio files found in directory"
  37. return
  38. }
  39. // Import each file
  40. for (index, fileURL) in audioURLs.enumerated() {
  41. scanProgress = Double(index) / Double(audioURLs.count)
  42. do {
  43. try await importFile(fileURL, context: context)
  44. } catch {
  45. print("LibraryManager: Failed to import \(fileURL.lastPathComponent): \(error)")
  46. }
  47. }
  48. try? context.save()
  49. isScanning = false
  50. scanProgress = 1.0
  51. }
  52. /// Import a single audio file.
  53. func importFile(_ url: URL, context: ModelContext? = nil) async throws {
  54. let ctx = context ?? modelContext
  55. guard let ctx else { return }
  56. // Check if already imported
  57. let path = url.path
  58. let descriptor = FetchDescriptor<Track>(predicate: #Predicate { $0.filePath == path })
  59. let existing = try ctx.fetch(descriptor)
  60. if let existingTrack = existing.first {
  61. // Backfill year if missing (for tracks imported before year extraction was added)
  62. if existingTrack.year == nil {
  63. if let metadata = try? await MetadataService.readMetadata(from: url) {
  64. existingTrack.year = metadata.year
  65. }
  66. }
  67. return
  68. }
  69. // Read metadata
  70. let metadata = try await MetadataService.readMetadata(from: url)
  71. let track = Track(
  72. title: metadata.title,
  73. artist: metadata.artist,
  74. album: metadata.album,
  75. genre: metadata.genre,
  76. filePath: url.path,
  77. duration: metadata.duration,
  78. sampleRate: metadata.sampleRate,
  79. bitDepth: metadata.bitDepth,
  80. channels: metadata.channels,
  81. fileFormat: metadata.fileFormat,
  82. fileSizeBytes: metadata.fileSizeBytes
  83. )
  84. track.year = metadata.year
  85. ctx.insert(track)
  86. }
  87. /// Import files selected via file picker.
  88. func importFiles(_ urls: [URL]) async {
  89. guard let context = modelContext else { return }
  90. isScanning = true
  91. scanProgress = 0
  92. for (index, url) in urls.enumerated() {
  93. scanProgress = Double(index) / Double(urls.count)
  94. do {
  95. try await importFile(url, context: context)
  96. } catch {
  97. print("LibraryManager: Failed to import \(url.lastPathComponent): \(error)")
  98. }
  99. }
  100. try? context.save()
  101. isScanning = false
  102. scanProgress = 1.0
  103. }
  104. // MARK: - Analysis
  105. /// Re-read year metadata from the audio file for a track.
  106. /// Uses a lightweight metadata-only read (no AVAudioFile) so it works for all formats.
  107. func rescanMetadata(_ track: Track) async {
  108. let url = track.fileURL
  109. guard FileManager.default.fileExists(atPath: url.path) else {
  110. print("LibraryManager: File not found for \(track.title): \(url.path)")
  111. return
  112. }
  113. do {
  114. let year = try await MetadataService.readYear(from: url)
  115. track.year = year
  116. } catch {
  117. print("LibraryManager: Year rescan failed for \(track.title): \(error)")
  118. }
  119. }
  120. /// Re-read metadata for all given tracks.
  121. func rescanAllMetadata(tracks: [Track]) async {
  122. isScanning = true
  123. scanProgress = 0
  124. for (index, track) in tracks.enumerated() {
  125. scanProgress = Double(index) / Double(tracks.count)
  126. await rescanMetadata(track)
  127. }
  128. if let context = modelContext {
  129. try? context.save()
  130. }
  131. isScanning = false
  132. scanProgress = 1.0
  133. }
  134. /// Run BPM and key detection on a track.
  135. func analyzeTrack(_ track: Track) async {
  136. do {
  137. async let bpmResult = BPMDetector.detectBPM(for: track)
  138. async let keyResult = KeyDetector.detectKey(for: track)
  139. let bpm = try await bpmResult
  140. let key = try await keyResult
  141. track.bpm = bpm
  142. track.musicalKey = key.shortKey
  143. track.isAnalyzed = true
  144. } catch {
  145. print("LibraryManager: Analysis failed for \(track.title): \(error)")
  146. }
  147. }
  148. /// Analyze all un-analyzed tracks.
  149. func analyzeAllTracks(tracks: [Track]) async {
  150. let unanalyzed = tracks.filter { !$0.isAnalyzed }
  151. for (index, track) in unanalyzed.enumerated() {
  152. scanProgress = Double(index) / Double(unanalyzed.count)
  153. await analyzeTrack(track)
  154. }
  155. scanProgress = 1.0
  156. }
  157. // MARK: - File Operations
  158. /// Copy files to a destination directory (for preparing DAW project).
  159. func copyFiles(_ tracks: [Track], to destinationDir: URL) throws -> [URL] {
  160. let fm = FileManager.default
  161. try fm.createDirectory(at: destinationDir, withIntermediateDirectories: true)
  162. var copiedURLs: [URL] = []
  163. for track in tracks {
  164. let source = track.fileURL
  165. let dest = destinationDir.appendingPathComponent(source.lastPathComponent)
  166. if !fm.fileExists(atPath: dest.path) {
  167. try fm.copyItem(at: source, to: dest)
  168. }
  169. copiedURLs.append(dest)
  170. }
  171. return copiedURLs
  172. }
  173. /// Move files to a new location and update track paths.
  174. func moveFiles(_ tracks: [Track], to destinationDir: URL) throws {
  175. let fm = FileManager.default
  176. try fm.createDirectory(at: destinationDir, withIntermediateDirectories: true)
  177. for track in tracks {
  178. let source = track.fileURL
  179. let dest = destinationDir.appendingPathComponent(source.lastPathComponent)
  180. try fm.moveItem(at: source, to: dest)
  181. track.filePath = dest.path
  182. }
  183. }
  184. /// Rename a file.
  185. func renameFile(_ track: Track, newName: String) throws {
  186. let source = track.fileURL
  187. let ext = source.pathExtension
  188. let dest = source.deletingLastPathComponent()
  189. .appendingPathComponent(newName)
  190. .appendingPathExtension(ext)
  191. try FileManager.default.moveItem(at: source, to: dest)
  192. track.filePath = dest.path
  193. track.title = newName
  194. }
  195. // MARK: - Deletion
  196. func removeTrack(_ track: Track, deleteFile: Bool = false) {
  197. if deleteFile {
  198. try? FileManager.default.removeItem(at: track.fileURL)
  199. }
  200. modelContext?.delete(track)
  201. }
  202. }