feat: add ScannerService with file discovery, metadata extraction, and batch import

feat/music-streaming
Laurent 1 month ago
parent e0e03daf3f
commit 49693e95b4
  1. 200
      Music/Services/ScannerService.swift
  2. 35
      MusicTests/ScannerServiceTests.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<String> = ["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..<batchEnd])
var tracks: [Track] = []
for fileURL in batch {
let urlString = fileURL.absoluteString
if existingURLs.contains(urlString) { continue }
if let track = await Self.extractMetadata(from: fileURL) {
tracks.append(track)
}
}
if !tracks.isEmpty {
try? db.insertBatch(tracks)
}
scanProgress = (batchEnd, audioFiles.count)
}
}
func rescan(_ folder: URL) async {
let audioFiles = Self.discoverAudioFiles(in: folder)
let currentFileURLs = Set(audioFiles.map { $0.absoluteString })
let storedURLs = (try? db.allFileURLs()) ?? []
let removedURLs = storedURLs.subtracting(currentFileURLs)
if !removedURLs.isEmpty {
try? db.deleteTracksWithURLs(removedURLs)
}
await scanFolder(folder)
}
nonisolated static func extractMetadata(from url: URL) async -> 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
}
}
}

@ -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"]))
}
}
Loading…
Cancel
Save