| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376 |
- 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<Track>(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<Track>()
- 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<Track>(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<Track>()
- 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")
- }
- }
|