From 49693e95b48b69ce362712c25eb1a3597fc0f07c Mon Sep 17 00:00:00 2001 From: Laurent Date: Sat, 23 May 2026 23:42:34 +0200 Subject: [PATCH] feat: add ScannerService with file discovery, metadata extraction, and batch import --- Music/Services/ScannerService.swift | 200 +++++++++++++++++++++++++++ MusicTests/ScannerServiceTests.swift | 35 +++++ 2 files changed, 235 insertions(+) create mode 100644 Music/Services/ScannerService.swift create mode 100644 MusicTests/ScannerServiceTests.swift diff --git a/Music/Services/ScannerService.swift b/Music/Services/ScannerService.swift new file mode 100644 index 0000000..76c8b0a --- /dev/null +++ b/Music/Services/ScannerService.swift @@ -0,0 +1,200 @@ +import Foundation +import AVFoundation + +// With the project-wide `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` setting, +// this class is implicitly @MainActor. That's intentional: @Observable properties +// (isScanning, scanProgress) drive the UI and must be updated on the main actor. +// Static utility methods are marked `nonisolated` so they can run off the main actor. +@Observable +final class ScannerService { + var isScanning = false + var scanProgress: (current: Int, total: Int) = (0, 0) + + let db: DatabaseService + + init(db: DatabaseService) { + self.db = db + } + + nonisolated static let audioExtensions: Set = ["mp3", "m4a", "aac", "wav", "aiff", "alac", "flac"] + + nonisolated static func discoverAudioFiles(in folder: URL) -> [URL] { + var results: [URL] = [] + guard let enumerator = FileManager.default.enumerator( + at: folder, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { return results } + + while let url = enumerator.nextObject() as? URL { + if audioExtensions.contains(url.pathExtension.lowercased()) { + results.append(url) + } + } + return results + } + + func scanFolder(_ folder: URL) async { + isScanning = true + defer { isScanning = false } + + let audioFiles = Self.discoverAudioFiles(in: folder) + scanProgress = (0, audioFiles.count) + + let existingURLs = (try? db.allFileURLs()) ?? [] + let batchSize = 50 + + for batchStart in stride(from: 0, to: audioFiles.count, by: batchSize) { + let batchEnd = min(batchStart + batchSize, audioFiles.count) + let batch = Array(audioFiles[batchStart.. Track? { + let asset = AVURLAsset(url: url) + + var title = url.deletingPathExtension().lastPathComponent + var artist = "Unknown" + var albumArtist = "Unknown" + var album = "Unknown" + var genre = "" + var year: Int? + var trackNumber: Int? + var discNumber: Int? + var bpm: Int? + var composer = "" + var artworkData: Data? + + do { + let metadata = try await asset.load(.metadata) + + for item in metadata { + guard let key = item.commonKey else { continue } + switch key { + case .commonKeyTitle: + title = (try? await item.load(.stringValue)) ?? title + case .commonKeyArtist: + let val = (try? await item.load(.stringValue)) ?? "Unknown" + artist = val + if albumArtist == "Unknown" { albumArtist = val } + case .commonKeyAlbumName: + album = (try? await item.load(.stringValue)) ?? "Unknown" + case .commonKeyArtwork: + artworkData = try? await item.load(.dataValue) + case .commonKeyCreator: + composer = (try? await item.load(.stringValue)) ?? "" + default: + break + } + } + + // Format-specific metadata (ID3 / iTunes) + for item in metadata { + if let identifier = item.identifier { + switch identifier { + case .id3MetadataContentType, .iTunesMetadataUserGenre: + genre = (try? await item.load(.stringValue)) ?? genre + case .id3MetadataTrackNumber: + if let val = try? await item.load(.stringValue), + let num = Int(val.split(separator: "/").first ?? "") { + trackNumber = num + } + case .id3MetadataPartOfASet: + if let val = try? await item.load(.stringValue), + let num = Int(val.split(separator: "/").first ?? "") { + discNumber = num + } + case .id3MetadataBeatsPerMinute: + if let val = try? await item.load(.stringValue) { + bpm = Int(val) + } + case .id3MetadataBand: + albumArtist = (try? await item.load(.stringValue)) ?? albumArtist + case .id3MetadataYear: + if let val = try? await item.load(.stringValue) { + year = Int(val) + } + default: + break + } + } + } + + let duration = try await asset.load(.duration) + let durationSeconds = CMTimeGetSeconds(duration) + + var bitrate: Int? + var sampleRate: Int? + if let audioTrack = try await asset.loadTracks(withMediaType: .audio).first { + let estimatedRate = try await audioTrack.load(.estimatedDataRate) + bitrate = Int(estimatedRate / 1000) + let descriptions = try await audioTrack.load(.formatDescriptions) + if let desc = descriptions.first { + if let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) { + sampleRate = Int(asbd.pointee.mSampleRate) + } + } + } + + let attrs = try FileManager.default.attributesOfItem(atPath: url.path) + let fileSize = attrs[.size] as? Int64 ?? 0 + let modDate = attrs[.modificationDate] as? Date ?? Date() + + return Track( + fileURL: url.absoluteString, + title: title, + artist: artist, + albumArtist: albumArtist, + album: album, + genre: genre, + year: year, + trackNumber: trackNumber, + discNumber: discNumber, + duration: durationSeconds.isNaN ? 0 : durationSeconds, + bpm: bpm, + composer: composer, + fileFormat: url.pathExtension.lowercased(), + bitrate: bitrate, + sampleRate: sampleRate, + fileSize: fileSize, + artworkData: artworkData, + playCount: 0, + lastPlayedAt: nil, + rating: 0, + dateAdded: Date(), + dateModified: modDate, + fileHash: Track.computeHash(fileSize: fileSize, modificationDate: modDate) + ) + } catch { + print("Failed to extract metadata from \(url.lastPathComponent): \(error)") + return nil + } + } +} diff --git a/MusicTests/ScannerServiceTests.swift b/MusicTests/ScannerServiceTests.swift new file mode 100644 index 0000000..7a84962 --- /dev/null +++ b/MusicTests/ScannerServiceTests.swift @@ -0,0 +1,35 @@ +import Testing +import Foundation +@testable import Music + +struct ScannerServiceTests { + // Creates a temp directory with audio and non-audio files, verifies only audio files are discovered. + // 1. Creates a unique temporary directory. + // 2. Creates files with audio extensions (mp3, m4a, flac, wav, aiff) and non-audio extensions (txt, png, json). + // 3. Creates a subdirectory with a nested mp3 file to test recursive discovery. + // 4. Calls ScannerService.discoverAudioFiles and checks that exactly 6 audio files are found. + // 5. Verifies all discovered files have audio-only extensions. + // 6. Cleans up the temp directory. + @Test func discoverAudioFiles() throws { + let tmpDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let audioFiles = ["song.mp3", "track.m4a", "audio.flac", "sound.wav", "music.aiff"] + let nonAudioFiles = ["readme.txt", "image.png", "data.json"] + + for name in audioFiles + nonAudioFiles { + FileManager.default.createFile(atPath: tmpDir.appendingPathComponent(name).path, contents: Data()) + } + + let subDir = tmpDir.appendingPathComponent("subfolder") + try FileManager.default.createDirectory(at: subDir, withIntermediateDirectories: true) + FileManager.default.createFile(atPath: subDir.appendingPathComponent("nested.mp3").path, contents: Data()) + + let discovered = ScannerService.discoverAudioFiles(in: tmpDir) + #expect(discovered.count == 6) + + let extensions = Set(discovered.map { $0.pathExtension.lowercased() }) + #expect(extensions.isSubset(of: ["mp3", "m4a", "flac", "wav", "aiff", "aac", "alac"])) + } +}