LibraryManager.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. import Foundation
  2. import os
  3. import SwiftData
  4. private let logger = Logger(subsystem: "com.mixboard.MixBoardiOS", category: "LibraryManager")
  5. /// Manages the music library — importing tracks from the document picker and managing files.
  6. @MainActor
  7. final class LibraryManager: ObservableObject {
  8. @Published var isScanning = false
  9. @Published var scanProgress: Double = 0
  10. @Published var scanStatus: String = ""
  11. @Published var lastError: String?
  12. private var modelContext: ModelContext?
  13. /// App documents directory where imported music files are stored.
  14. static var musicDirectory: URL {
  15. let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
  16. let musicDir = docs.appendingPathComponent("Music", isDirectory: true)
  17. try? FileManager.default.createDirectory(at: musicDir, withIntermediateDirectories: true)
  18. return musicDir
  19. }
  20. func setModelContext(_ context: ModelContext) {
  21. self.modelContext = context
  22. }
  23. // MARK: - Import from URLs (files and/or folders)
  24. /// Import audio files or folders selected from a document picker.
  25. /// Files get copied into the app's Music directory.
  26. func importFiles(_ urls: [URL]) async {
  27. guard let context = modelContext else { return }
  28. isScanning = true
  29. scanProgress = 0
  30. scanStatus = "Collecting files..."
  31. lastError = nil
  32. // Resolve folders into individual audio file URLs
  33. var audioURLs: [URL] = []
  34. for url in urls {
  35. let accessing = url.startAccessingSecurityScopedResource()
  36. defer { if accessing { url.stopAccessingSecurityScopedResource() } }
  37. var isDir: ObjCBool = false
  38. if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue {
  39. // It's a folder — enumerate audio files recursively
  40. let folderFiles = collectAudioFiles(in: url)
  41. audioURLs.append(contentsOf: folderFiles)
  42. } else if MetadataService.isSupportedAudioFile(url) {
  43. audioURLs.append(url)
  44. }
  45. }
  46. guard !audioURLs.isEmpty else {
  47. isScanning = false
  48. scanStatus = ""
  49. lastError = "No supported audio files found"
  50. return
  51. }
  52. scanStatus = "Importing \(audioURLs.count) files..."
  53. for (index, url) in audioURLs.enumerated() {
  54. scanProgress = Double(index) / Double(audioURLs.count)
  55. scanStatus = "Importing \(index + 1)/\(audioURLs.count): \(url.lastPathComponent)"
  56. let accessing = url.startAccessingSecurityScopedResource()
  57. defer { if accessing { url.stopAccessingSecurityScopedResource() } }
  58. do {
  59. try await importFile(url, context: context)
  60. } catch {
  61. print("LibraryManager: Failed to import \(url.lastPathComponent): \(error)")
  62. }
  63. }
  64. try? context.save()
  65. isScanning = false
  66. scanProgress = 1.0
  67. scanStatus = ""
  68. }
  69. /// Recursively collect audio files from a directory.
  70. private func collectAudioFiles(in folderURL: URL) -> [URL] {
  71. var result: [URL] = []
  72. let fm = FileManager.default
  73. guard let enumerator = fm.enumerator(
  74. at: folderURL,
  75. includingPropertiesForKeys: [.isRegularFileKey],
  76. options: [.skipsHiddenFiles]
  77. ) else { return result }
  78. for case let fileURL as URL in enumerator {
  79. if MetadataService.isSupportedAudioFile(fileURL) {
  80. result.append(fileURL)
  81. }
  82. }
  83. return result
  84. }
  85. // MARK: - Auto-scan Documents folder
  86. /// Scan the app's entire Documents directory (recursively) for any audio files
  87. /// that aren't yet in the library. This picks up files placed via the Files app.
  88. func scanMusicDirectory() async {
  89. guard let context = modelContext else {
  90. logger.error("scanMusicDirectory: No model context")
  91. return
  92. }
  93. let fm = FileManager.default
  94. let docsDir = fm.urls(for: .documentDirectory, in: .userDomainMask).first!.standardizedFileURL
  95. logger.notice("scanMusicDirectory: Scanning \(docsDir.path)")
  96. // Collect audio files from the entire Documents directory recursively
  97. var audioFiles: [URL] = []
  98. if let enumerator = fm.enumerator(
  99. at: docsDir,
  100. includingPropertiesForKeys: [.isRegularFileKey],
  101. options: [.skipsHiddenFiles]
  102. ) {
  103. for case let fileURL as URL in enumerator {
  104. if MetadataService.isSupportedAudioFile(fileURL) {
  105. audioFiles.append(fileURL)
  106. }
  107. }
  108. }
  109. logger.notice("scanMusicDirectory: Found \(audioFiles.count) audio files on disk")
  110. guard !audioFiles.isEmpty else { return }
  111. var newCount = 0
  112. isScanning = true
  113. scanStatus = "Scanning for music files..."
  114. for (index, fileURL) in audioFiles.enumerated() {
  115. scanProgress = Double(index) / Double(audioFiles.count)
  116. let fileName = fileURL.lastPathComponent
  117. // Check by relative path (not just filename) so same-named files in different folders are all imported
  118. let standardizedFile = fileURL.standardizedFileURL.path
  119. let standardizedDocs = docsDir.path
  120. let checkPath = standardizedFile.hasPrefix(standardizedDocs + "/")
  121. ? String(standardizedFile.dropFirst(standardizedDocs.count + 1))
  122. : fileName
  123. let descriptor = FetchDescriptor<Track>(predicate: #Predicate { $0.filePath == checkPath })
  124. let existing = (try? context.fetch(descriptor)) ?? []
  125. if existing.isEmpty {
  126. do {
  127. let metadata = try await MetadataService.readMetadata(from: fileURL)
  128. // Store the path relative to Documents (standardize to handle /private/var vs /var)
  129. let standardizedFile = fileURL.standardizedFileURL.path
  130. let standardizedDocs = docsDir.path
  131. let relativePath = standardizedFile.hasPrefix(standardizedDocs + "/")
  132. ? String(standardizedFile.dropFirst(standardizedDocs.count + 1))
  133. : fileName
  134. logger.notice("scanMusicDirectory: Importing \(fileName) → relativePath: \(relativePath)")
  135. let track = Track(
  136. title: metadata.title,
  137. artist: metadata.artist,
  138. album: metadata.album,
  139. genre: metadata.genre,
  140. filePath: relativePath,
  141. fileName: fileName,
  142. duration: metadata.duration,
  143. sampleRate: metadata.sampleRate,
  144. bitDepth: metadata.bitDepth,
  145. channels: metadata.channels,
  146. fileFormat: metadata.fileFormat,
  147. fileSizeBytes: metadata.fileSizeBytes
  148. )
  149. track.year = metadata.year
  150. context.insert(track)
  151. newCount += 1
  152. scanStatus = "Found: \(fileName)"
  153. } catch {
  154. logger.error("scanMusicDirectory: Failed to scan \(fileName): \(error)")
  155. }
  156. }
  157. }
  158. if newCount > 0 {
  159. try? context.save()
  160. }
  161. isScanning = false
  162. scanProgress = 1.0
  163. scanStatus = newCount > 0 ? "Found \(newCount) new tracks" : ""
  164. }
  165. /// Fix tracks with bad paths. Call once at startup, not during scans.
  166. func fixBadPathsIfNeeded() {
  167. guard let context = modelContext else { return }
  168. let hasRun = UserDefaults.standard.bool(forKey: "pathFixV1")
  169. guard !hasRun else { return }
  170. fixBadPaths(context: context)
  171. UserDefaults.standard.set(true, forKey: "pathFixV1")
  172. }
  173. /// Fix tracks that have absolute or /private-prefixed paths from a previous scan bug.
  174. private func fixBadPaths(context: ModelContext) {
  175. let descriptor = FetchDescriptor<Track>()
  176. guard let allTracks = try? context.fetch(descriptor) else { return }
  177. let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.standardizedFileURL
  178. var fixed = 0
  179. for track in allTracks {
  180. var path = track.filePath
  181. // Remove leading /private prefix
  182. if path.hasPrefix("/private") {
  183. path = String(path.dropFirst("/private".count))
  184. }
  185. // Remove absolute Documents path prefix if present
  186. if path.hasPrefix(docsDir.path + "/") {
  187. path = String(path.dropFirst(docsDir.path.count + 1))
  188. }
  189. // Remove leading / if still absolute
  190. if path.hasPrefix("/") && !path.hasPrefix(docsDir.path) {
  191. // Check if the file actually exists relative to Documents
  192. let candidateURL = docsDir.appendingPathComponent(String(path.dropFirst()))
  193. if FileManager.default.fileExists(atPath: candidateURL.path) {
  194. path = String(path.dropFirst())
  195. }
  196. }
  197. if path != track.filePath {
  198. logger.notice("fixBadPaths: \(track.filePath) → \(path)")
  199. track.filePath = path
  200. fixed += 1
  201. }
  202. }
  203. if fixed > 0 {
  204. try? context.save()
  205. logger.notice("fixBadPaths: Fixed \(fixed) tracks")
  206. }
  207. }
  208. // MARK: - Import single file
  209. /// Import a single audio file. Copies it to the app's Music directory.
  210. func importFile(_ url: URL, context: ModelContext? = nil) async throws {
  211. let ctx = context ?? modelContext
  212. guard let ctx else { return }
  213. let fileName = url.lastPathComponent
  214. // Check if already imported by filename
  215. let descriptor = FetchDescriptor<Track>(predicate: #Predicate { $0.fileName == fileName })
  216. let existing = try ctx.fetch(descriptor)
  217. guard existing.isEmpty else { return }
  218. // Copy file to app's Music directory (unless it's already there)
  219. let destURL = Self.musicDirectory.appendingPathComponent(fileName)
  220. if !FileManager.default.fileExists(atPath: destURL.path) {
  221. try FileManager.default.copyItem(at: url, to: destURL)
  222. }
  223. // Read metadata
  224. let metadata = try await MetadataService.readMetadata(from: destURL)
  225. let relativePath = "Music/\(fileName)"
  226. let track = Track(
  227. title: metadata.title,
  228. artist: metadata.artist,
  229. album: metadata.album,
  230. genre: metadata.genre,
  231. filePath: relativePath,
  232. fileName: fileName,
  233. duration: metadata.duration,
  234. sampleRate: metadata.sampleRate,
  235. bitDepth: metadata.bitDepth,
  236. channels: metadata.channels,
  237. fileFormat: metadata.fileFormat,
  238. fileSizeBytes: metadata.fileSizeBytes
  239. )
  240. track.year = metadata.year
  241. ctx.insert(track)
  242. }
  243. // MARK: - Analysis
  244. func analyzeTrack(_ track: Track) async {
  245. do {
  246. async let bpmResult = BPMDetector.detectBPM(for: track)
  247. async let keyResult = KeyDetector.detectKey(for: track)
  248. let bpm = try await bpmResult
  249. let key = try await keyResult
  250. track.bpm = bpm
  251. track.musicalKey = key.shortKey
  252. track.isAnalyzed = true
  253. } catch {
  254. print("LibraryManager: Analysis failed for \(track.title): \(error)")
  255. }
  256. }
  257. func analyzeAllTracks(tracks: [Track]) async {
  258. let unanalyzed = tracks.filter { !$0.isAnalyzed }
  259. for (index, track) in unanalyzed.enumerated() {
  260. scanProgress = Double(index) / Double(unanalyzed.count)
  261. await analyzeTrack(track)
  262. }
  263. scanProgress = 1.0
  264. }
  265. // MARK: - Deletion
  266. func removeTrack(_ track: Track, deleteFile: Bool = true) {
  267. if deleteFile {
  268. try? FileManager.default.removeItem(at: track.fileURL)
  269. }
  270. modelContext?.delete(track)
  271. }
  272. // MARK: - Rescan Metadata
  273. /// Re-read metadata from disk for all tracks in the library.
  274. /// Updates title, artist, album, genre, year, duration, and audio properties.
  275. /// Useful after fixing metadata readers (e.g. OGG/Opus tag reading).
  276. func rescanMetadata() async {
  277. guard let context = modelContext else { return }
  278. let descriptor = FetchDescriptor<Track>()
  279. guard let allTracks = try? context.fetch(descriptor) else { return }
  280. guard !allTracks.isEmpty else { return }
  281. isScanning = true
  282. scanStatus = "Rescanning metadata..."
  283. scanProgress = 0
  284. var updated = 0
  285. for (index, track) in allTracks.enumerated() {
  286. scanProgress = Double(index) / Double(allTracks.count)
  287. scanStatus = "Rescanning \(index + 1)/\(allTracks.count): \(track.fileName)"
  288. let fileURL = track.fileURL
  289. guard FileManager.default.fileExists(atPath: fileURL.path) else { continue }
  290. do {
  291. let metadata = try await MetadataService.readMetadata(from: fileURL)
  292. // Always update metadata fields
  293. track.title = metadata.title
  294. track.artist = metadata.artist
  295. track.album = metadata.album
  296. track.genre = metadata.genre
  297. track.year = metadata.year
  298. track.duration = metadata.duration
  299. track.sampleRate = metadata.sampleRate
  300. track.bitDepth = metadata.bitDepth
  301. track.channels = metadata.channels
  302. track.fileFormat = metadata.fileFormat
  303. track.fileSizeBytes = metadata.fileSizeBytes
  304. updated += 1
  305. } catch {
  306. logger.error("rescanMetadata: Failed for \(track.fileName): \(error)")
  307. }
  308. }
  309. if updated > 0 {
  310. try? context.save()
  311. }
  312. isScanning = false
  313. scanProgress = 1.0
  314. scanStatus = updated > 0 ? "Updated \(updated) tracks" : "No updates needed"
  315. logger.notice("rescanMetadata: Updated \(updated) of \(allTracks.count) tracks")
  316. }
  317. }