import Foundation import SwiftData /// Manages the music library — scanning directories, importing tracks, and managing files. @MainActor final class LibraryManager: ObservableObject { @Published var isScanning = false @Published var scanProgress: Double = 0 @Published var lastError: String? private var modelContext: ModelContext? func setModelContext(_ context: ModelContext) { self.modelContext = context } // MARK: - Import /// Import audio files from a directory (recursively). func importDirectory(_ url: URL) async { guard let context = modelContext else { return } isScanning = true scanProgress = 0 lastError = nil let fileManager = FileManager.default var audioURLs: [URL] = [] // Collect all audio files if let enumerator = fileManager.enumerator( at: url, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles] ) { for case let fileURL as URL in enumerator { if MetadataService.isSupportedAudioFile(fileURL) { audioURLs.append(fileURL) } } } guard !audioURLs.isEmpty else { isScanning = false lastError = "No supported audio files found in directory" return } // Import each file for (index, fileURL) in audioURLs.enumerated() { scanProgress = Double(index) / Double(audioURLs.count) do { try await importFile(fileURL, context: context) } catch { print("LibraryManager: Failed to import \(fileURL.lastPathComponent): \(error)") } } try? context.save() isScanning = false scanProgress = 1.0 } /// Import a single audio file. func importFile(_ url: URL, context: ModelContext? = nil) async throws { let ctx = context ?? modelContext guard let ctx else { return } // Check if already imported let path = url.path let descriptor = FetchDescriptor(predicate: #Predicate { $0.filePath == path }) let existing = try ctx.fetch(descriptor) if let existingTrack = existing.first { // Backfill year if missing (for tracks imported before year extraction was added) if existingTrack.year == nil { if let metadata = try? await MetadataService.readMetadata(from: url) { existingTrack.year = metadata.year } } return } // Read metadata let metadata = try await MetadataService.readMetadata(from: url) let track = Track( title: metadata.title, artist: metadata.artist, album: metadata.album, genre: metadata.genre, filePath: url.path, duration: metadata.duration, sampleRate: metadata.sampleRate, bitDepth: metadata.bitDepth, channels: metadata.channels, fileFormat: metadata.fileFormat, fileSizeBytes: metadata.fileSizeBytes ) track.year = metadata.year ctx.insert(track) } /// Import files selected via file picker. func importFiles(_ urls: [URL]) async { guard let context = modelContext else { return } isScanning = true scanProgress = 0 for (index, url) in urls.enumerated() { scanProgress = Double(index) / Double(urls.count) do { try await importFile(url, context: context) } catch { print("LibraryManager: Failed to import \(url.lastPathComponent): \(error)") } } try? context.save() isScanning = false scanProgress = 1.0 } // MARK: - Analysis /// Re-read year metadata from the audio file for a track. /// Uses a lightweight metadata-only read (no AVAudioFile) so it works for all formats. func rescanMetadata(_ track: Track) async { let url = track.fileURL guard FileManager.default.fileExists(atPath: url.path) else { print("LibraryManager: File not found for \(track.title): \(url.path)") return } do { let year = try await MetadataService.readYear(from: url) track.year = year } catch { print("LibraryManager: Year rescan failed for \(track.title): \(error)") } } /// Re-read metadata for all given tracks. func rescanAllMetadata(tracks: [Track]) async { isScanning = true scanProgress = 0 for (index, track) in tracks.enumerated() { scanProgress = Double(index) / Double(tracks.count) await rescanMetadata(track) } if let context = modelContext { try? context.save() } isScanning = false scanProgress = 1.0 } /// Run BPM and key detection on a track. func analyzeTrack(_ track: Track) async { do { async let bpmResult = BPMDetector.detectBPM(for: track) async let keyResult = KeyDetector.detectKey(for: track) let bpm = try await bpmResult let key = try await keyResult track.bpm = bpm track.musicalKey = key.shortKey track.isAnalyzed = true } catch { print("LibraryManager: Analysis failed for \(track.title): \(error)") } } /// Analyze all un-analyzed tracks. func analyzeAllTracks(tracks: [Track]) async { let unanalyzed = tracks.filter { !$0.isAnalyzed } for (index, track) in unanalyzed.enumerated() { scanProgress = Double(index) / Double(unanalyzed.count) await analyzeTrack(track) } scanProgress = 1.0 } // MARK: - File Operations /// Copy files to a destination directory (for preparing DAW project). func copyFiles(_ tracks: [Track], to destinationDir: URL) throws -> [URL] { let fm = FileManager.default try fm.createDirectory(at: destinationDir, withIntermediateDirectories: true) var copiedURLs: [URL] = [] for track in tracks { let source = track.fileURL let dest = destinationDir.appendingPathComponent(source.lastPathComponent) if !fm.fileExists(atPath: dest.path) { try fm.copyItem(at: source, to: dest) } copiedURLs.append(dest) } return copiedURLs } /// Move files to a new location and update track paths. func moveFiles(_ tracks: [Track], to destinationDir: URL) throws { let fm = FileManager.default try fm.createDirectory(at: destinationDir, withIntermediateDirectories: true) for track in tracks { let source = track.fileURL let dest = destinationDir.appendingPathComponent(source.lastPathComponent) try fm.moveItem(at: source, to: dest) track.filePath = dest.path } } /// Rename a file. func renameFile(_ track: Track, newName: String) throws { let source = track.fileURL let ext = source.pathExtension let dest = source.deletingLastPathComponent() .appendingPathComponent(newName) .appendingPathExtension(ext) try FileManager.default.moveItem(at: source, to: dest) track.filePath = dest.path track.title = newName } // MARK: - Deletion func removeTrack(_ track: Track, deleteFile: Bool = false) { if deleteFile { try? FileManager.default.removeItem(at: track.fileURL) } modelContext?.delete(track) } }