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