import Foundation import os import SwiftData private let logger = Logger(subsystem: "com.mixboard.MixBoardiOS", category: "LibraryManager") /// Manages the music library — importing tracks from the document picker and managing files. @MainActor final class LibraryManager: ObservableObject { @Published var isScanning = false @Published var scanProgress: Double = 0 @Published var scanStatus: String = "" @Published var lastError: String? private var modelContext: ModelContext? /// App documents directory where imported music files are stored. static var musicDirectory: URL { let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! let musicDir = docs.appendingPathComponent("Music", isDirectory: true) try? FileManager.default.createDirectory(at: musicDir, withIntermediateDirectories: true) return musicDir } func setModelContext(_ context: ModelContext) { self.modelContext = context } // MARK: - Import from URLs (files and/or folders) /// Import audio files or folders selected from a document picker. /// Files get copied into the app's Music directory. func importFiles(_ urls: [URL]) async { guard let context = modelContext else { return } isScanning = true scanProgress = 0 scanStatus = "Collecting files..." lastError = nil // Resolve folders into individual audio file URLs var audioURLs: [URL] = [] for url in urls { let accessing = url.startAccessingSecurityScopedResource() defer { if accessing { url.stopAccessingSecurityScopedResource() } } var isDir: ObjCBool = false if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue { // It's a folder — enumerate audio files recursively let folderFiles = collectAudioFiles(in: url) audioURLs.append(contentsOf: folderFiles) } else if MetadataService.isSupportedAudioFile(url) { audioURLs.append(url) } } guard !audioURLs.isEmpty else { isScanning = false scanStatus = "" lastError = "No supported audio files found" return } scanStatus = "Importing \(audioURLs.count) files..." for (index, url) in audioURLs.enumerated() { scanProgress = Double(index) / Double(audioURLs.count) scanStatus = "Importing \(index + 1)/\(audioURLs.count): \(url.lastPathComponent)" let accessing = url.startAccessingSecurityScopedResource() defer { if accessing { url.stopAccessingSecurityScopedResource() } } do { try await importFile(url, context: context) } catch { print("LibraryManager: Failed to import \(url.lastPathComponent): \(error)") } } try? context.save() isScanning = false scanProgress = 1.0 scanStatus = "" } /// Recursively collect audio files from a directory. private func collectAudioFiles(in folderURL: URL) -> [URL] { var result: [URL] = [] let fm = FileManager.default guard let enumerator = fm.enumerator( at: folderURL, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles] ) else { return result } for case let fileURL as URL in enumerator { if MetadataService.isSupportedAudioFile(fileURL) { result.append(fileURL) } } return result } // MARK: - Auto-scan Documents folder /// Scan the app's entire Documents directory (recursively) for any audio files /// that aren't yet in the library. This picks up files placed via the Files app. func scanMusicDirectory() async { guard let context = modelContext else { logger.error("scanMusicDirectory: No model context") return } let fm = FileManager.default let docsDir = fm.urls(for: .documentDirectory, in: .userDomainMask).first!.standardizedFileURL logger.notice("scanMusicDirectory: Scanning \(docsDir.path)") // Collect audio files from the entire Documents directory recursively var audioFiles: [URL] = [] if let enumerator = fm.enumerator( at: docsDir, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles] ) { for case let fileURL as URL in enumerator { if MetadataService.isSupportedAudioFile(fileURL) { audioFiles.append(fileURL) } } } logger.notice("scanMusicDirectory: Found \(audioFiles.count) audio files on disk") guard !audioFiles.isEmpty else { return } var newCount = 0 isScanning = true scanStatus = "Scanning for music files..." for (index, fileURL) in audioFiles.enumerated() { scanProgress = Double(index) / Double(audioFiles.count) let fileName = fileURL.lastPathComponent // Check by relative path (not just filename) so same-named files in different folders are all imported let standardizedFile = fileURL.standardizedFileURL.path let standardizedDocs = docsDir.path let checkPath = standardizedFile.hasPrefix(standardizedDocs + "/") ? String(standardizedFile.dropFirst(standardizedDocs.count + 1)) : fileName let descriptor = FetchDescriptor(predicate: #Predicate { $0.filePath == checkPath }) let existing = (try? context.fetch(descriptor)) ?? [] if existing.isEmpty { do { let metadata = try await MetadataService.readMetadata(from: fileURL) // Store the path relative to Documents (standardize to handle /private/var vs /var) let standardizedFile = fileURL.standardizedFileURL.path let standardizedDocs = docsDir.path let relativePath = standardizedFile.hasPrefix(standardizedDocs + "/") ? String(standardizedFile.dropFirst(standardizedDocs.count + 1)) : fileName logger.notice("scanMusicDirectory: Importing \(fileName) → relativePath: \(relativePath)") let track = Track( title: metadata.title, artist: metadata.artist, album: metadata.album, genre: metadata.genre, filePath: relativePath, fileName: fileName, duration: metadata.duration, sampleRate: metadata.sampleRate, bitDepth: metadata.bitDepth, channels: metadata.channels, fileFormat: metadata.fileFormat, fileSizeBytes: metadata.fileSizeBytes ) track.year = metadata.year context.insert(track) newCount += 1 scanStatus = "Found: \(fileName)" } catch { logger.error("scanMusicDirectory: Failed to scan \(fileName): \(error)") } } } if newCount > 0 { try? context.save() } isScanning = false scanProgress = 1.0 scanStatus = newCount > 0 ? "Found \(newCount) new tracks" : "" } /// Fix tracks with bad paths. Call once at startup, not during scans. func fixBadPathsIfNeeded() { guard let context = modelContext else { return } let hasRun = UserDefaults.standard.bool(forKey: "pathFixV1") guard !hasRun else { return } fixBadPaths(context: context) UserDefaults.standard.set(true, forKey: "pathFixV1") } /// Fix tracks that have absolute or /private-prefixed paths from a previous scan bug. private func fixBadPaths(context: ModelContext) { let descriptor = FetchDescriptor() guard let allTracks = try? context.fetch(descriptor) else { return } let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.standardizedFileURL var fixed = 0 for track in allTracks { var path = track.filePath // Remove leading /private prefix if path.hasPrefix("/private") { path = String(path.dropFirst("/private".count)) } // Remove absolute Documents path prefix if present if path.hasPrefix(docsDir.path + "/") { path = String(path.dropFirst(docsDir.path.count + 1)) } // Remove leading / if still absolute if path.hasPrefix("/") && !path.hasPrefix(docsDir.path) { // Check if the file actually exists relative to Documents let candidateURL = docsDir.appendingPathComponent(String(path.dropFirst())) if FileManager.default.fileExists(atPath: candidateURL.path) { path = String(path.dropFirst()) } } if path != track.filePath { logger.notice("fixBadPaths: \(track.filePath) → \(path)") track.filePath = path fixed += 1 } } if fixed > 0 { try? context.save() logger.notice("fixBadPaths: Fixed \(fixed) tracks") } } // MARK: - Import single file /// Import a single audio file. Copies it to the app's Music directory. func importFile(_ url: URL, context: ModelContext? = nil) async throws { let ctx = context ?? modelContext guard let ctx else { return } let fileName = url.lastPathComponent // Check if already imported by filename let descriptor = FetchDescriptor(predicate: #Predicate { $0.fileName == fileName }) let existing = try ctx.fetch(descriptor) guard existing.isEmpty else { return } // Copy file to app's Music directory (unless it's already there) let destURL = Self.musicDirectory.appendingPathComponent(fileName) if !FileManager.default.fileExists(atPath: destURL.path) { try FileManager.default.copyItem(at: url, to: destURL) } // Read metadata let metadata = try await MetadataService.readMetadata(from: destURL) let relativePath = "Music/\(fileName)" let track = Track( title: metadata.title, artist: metadata.artist, album: metadata.album, genre: metadata.genre, filePath: relativePath, fileName: fileName, 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) } // MARK: - Analysis 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)") } } 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: - Deletion func removeTrack(_ track: Track, deleteFile: Bool = true) { if deleteFile { try? FileManager.default.removeItem(at: track.fileURL) } modelContext?.delete(track) } // MARK: - Rescan Metadata /// Re-read metadata from disk for all tracks in the library. /// Updates title, artist, album, genre, year, duration, and audio properties. /// Useful after fixing metadata readers (e.g. OGG/Opus tag reading). func rescanMetadata() async { guard let context = modelContext else { return } let descriptor = FetchDescriptor() guard let allTracks = try? context.fetch(descriptor) else { return } guard !allTracks.isEmpty else { return } isScanning = true scanStatus = "Rescanning metadata..." scanProgress = 0 var updated = 0 for (index, track) in allTracks.enumerated() { scanProgress = Double(index) / Double(allTracks.count) scanStatus = "Rescanning \(index + 1)/\(allTracks.count): \(track.fileName)" let fileURL = track.fileURL guard FileManager.default.fileExists(atPath: fileURL.path) else { continue } do { let metadata = try await MetadataService.readMetadata(from: fileURL) // Always update metadata fields track.title = metadata.title track.artist = metadata.artist track.album = metadata.album track.genre = metadata.genre track.year = metadata.year track.duration = metadata.duration track.sampleRate = metadata.sampleRate track.bitDepth = metadata.bitDepth track.channels = metadata.channels track.fileFormat = metadata.fileFormat track.fileSizeBytes = metadata.fileSizeBytes updated += 1 } catch { logger.error("rescanMetadata: Failed for \(track.fileName): \(error)") } } if updated > 0 { try? context.save() } isScanning = false scanProgress = 1.0 scanStatus = updated > 0 ? "Updated \(updated) tracks" : "No updates needed" logger.notice("rescanMetadata: Updated \(updated) of \(allTracks.count) tracks") } }