Initial Commit
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
|
||||
struct SourceScanProgress: Sendable {
|
||||
var source: Int64
|
||||
var files = 0
|
||||
var indexed = 0
|
||||
var generated = 0
|
||||
var cached = 0
|
||||
var issues = 0
|
||||
var current = "Starting…"
|
||||
var messages = [String]()
|
||||
var cancelled = false
|
||||
var finished = false
|
||||
}
|
||||
|
||||
/// One utility-priority worker/SQLite connection. Audio stays on the source volume;
|
||||
/// ZIP members use managed media because temporary extraction cannot be referenced.
|
||||
enum LibraryScanner {
|
||||
static func scan(_ source: MediaLocation,database: URL,media: URL,cache: URL,forceWaveforms: Bool = false,cancelled: () -> Bool = { false },progress: (SourceScanProgress) -> Void = { _ in }) throws -> SourceScanProgress {
|
||||
var root = URL(fileURLWithPath:source.path)
|
||||
if let bookmark = source.bookmark,!bookmark.isEmpty {
|
||||
var stale = false
|
||||
if let resolved = try? URL(resolvingBookmarkData:bookmark,options:[.withSecurityScope,.withoutUI],relativeTo:nil,bookmarkDataIsStale:&stale) { root = resolved }
|
||||
}
|
||||
let scoped = root.startAccessingSecurityScopedResource();defer { if scoped { root.stopAccessingSecurityScopedResource() } }
|
||||
var directory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath:root.path,isDirectory:&directory),directory.boolValue else { throw LibraryError.message("Drive or folder is unavailable. Reconnect it and scan again.") }
|
||||
let db = try Library(url:database),importer = Importer(media:media)
|
||||
let snapshot = try db.sourceSnapshot(source.id)
|
||||
var songs = Dictionary(uniqueKeysWithValues:try db.songs().map { ($0.id,$0) })
|
||||
var state = SourceScanProgress(source:source.id),lastReport = Date.distantPast
|
||||
var seenSongs = Set<Int64>()
|
||||
var enumerationMessages = [String]()
|
||||
guard let enumerator = FileManager.default.enumerator(at:root,includingPropertiesForKeys:[.isRegularFileKey,.isSymbolicLinkKey],options:[.skipsHiddenFiles,.skipsPackageDescendants],errorHandler:{ url,error in
|
||||
if enumerationMessages.count < 20 { enumerationMessages.append(url.lastPathComponent+": "+error.localizedDescription) };return true
|
||||
}) else { throw LibraryError.message("Cannot read this folder") }
|
||||
for case let file as URL in enumerator {
|
||||
if cancelled() { state.cancelled = true;break }
|
||||
guard ["mp3","wav","m4a","mp4","m4v","mov","zip"].contains(file.pathExtension.lowercased()) else { continue }
|
||||
let values = try? file.resourceValues(forKeys:[.isRegularFileKey,.isSymbolicLinkKey])
|
||||
guard values?.isRegularFile == true,values?.isSymbolicLink != true else { continue }
|
||||
state.files += 1;state.current = file.lastPathComponent
|
||||
do {
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath:file.path)
|
||||
let size = (attributes[.size] as? NSNumber)?.int64Value ?? 0,modified = (attributes[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
|
||||
let ids: [Int64]
|
||||
if let old = snapshot[file.path],old.size == size,old.modified == modified,!old.songs.isEmpty,old.songs.allSatisfy({ id in songs[id].map { FileManager.default.fileExists(atPath:$0.audio) } ?? false }) {
|
||||
ids = old.songs
|
||||
} else {
|
||||
ids = try importer.run([file],library:db,copyMedia:false,cancelled:cancelled)
|
||||
try db.recordSourceFile(source:source.id,path:file.path,size:size,modified:modified,songs:ids)
|
||||
// Refresh after deduplication/relocation, without sharing the UI connection.
|
||||
for id in ids { if let song = try db.song(id) { songs[id] = song } }
|
||||
}
|
||||
seenSongs.formUnion(ids);state.indexed = seenSongs.count
|
||||
for id in ids {
|
||||
if cancelled() { throw CancellationError() }
|
||||
guard let song = songs[id] else { continue }
|
||||
let audio = URL(fileURLWithPath:song.audio)
|
||||
if !forceWaveforms,(try? WaveformCache.load(audio:audio,directory:cache)) != nil { state.cached += 1 }
|
||||
else {
|
||||
let analysis = try AudioWaveform.analyse(url:audio,cancelled:cancelled)
|
||||
try WaveformCache.save(analysis,audio:audio,directory:cache);state.generated += 1
|
||||
}
|
||||
}
|
||||
} catch is CancellationError { state.cancelled = true;break }
|
||||
catch {
|
||||
state.issues += 1
|
||||
if state.messages.count < 100 { state.messages.append(file.lastPathComponent+": "+error.localizedDescription) }
|
||||
}
|
||||
if Date().timeIntervalSince(lastReport) >= 0.12 { progress(state);lastReport = Date() }
|
||||
}
|
||||
state.issues += enumerationMessages.count;state.messages += enumerationMessages
|
||||
state.finished = true
|
||||
state.current = state.cancelled ? "Cancelled — completed work kept" : "Scan complete"
|
||||
if !FileManager.default.fileExists(atPath:root.path) { state.issues += 1;state.messages.append("The source became unavailable during the scan") }
|
||||
let message = state.cancelled ? "Cancelled; completed work kept" : state.issues > 0 ? "\(state.issues) file/read issues; see scan details" : ""
|
||||
try db.finishSourceScan(source.id,path:root.path,message:message)
|
||||
progress(state);return state
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user