You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
193 lines
7.2 KiB
193 lines
7.2 KiB
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 = ""
|
|
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 .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 stats = try TrackFileStats.compute(for: url)
|
|
|
|
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: stats.fileSize,
|
|
playCount: 0,
|
|
lastPlayedAt: nil,
|
|
rating: 0,
|
|
dateAdded: Date(),
|
|
dateModified: stats.dateModified,
|
|
fileHash: stats.fileHash
|
|
)
|
|
} catch {
|
|
print("Failed to extract metadata from \(url.lastPathComponent): \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|