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() 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 } } struct WaveformJobProgress: Sendable { var total: Int var processed = 0 var generated = 0 var cached = 0 var skipped = 0 var issues = 0 var current = "Preparing waveforms…" var messages = [String]() var cancelled = false var finished = false } /// Prepares existing library media without importing, playing or modifying track metadata. enum WaveformBatch { static func run(songs: [Song],locations: [MediaLocation],cache: URL,force: Bool = false,cancelled: () -> Bool = { false },progress: (WaveformJobProgress) -> Void = { _ in }) -> WaveformJobProgress { var accessed = [URL]() for location in locations { var url = URL(fileURLWithPath:location.path) if let bookmark = location.bookmark { var stale = false;if let resolved = try? URL(resolvingBookmarkData:bookmark,options:[.withSecurityScope,.withoutUI],relativeTo:nil,bookmarkDataIsStale:&stale) { url = resolved } } if url.startAccessingSecurityScopedResource() { accessed.append(url) } } defer { for url in accessed { url.stopAccessingSecurityScopedResource() } } var ids = Set(),analysedPaths = Set() let tracks = songs.filter{ids.insert($0.id).inserted} var state = WaveformJobProgress(total:tracks.count),lastReport = Date.distantPast for song in tracks { if cancelled() { state.cancelled = true;break } state.current = song.title if song.audio.hasPrefix("showscreen:") { state.skipped += 1;state.processed += 1;continue } let url = URL(fileURLWithPath:song.audio),path = url.standardizedFileURL.path let scoped = url.startAccessingSecurityScopedResource() do { if (!force || analysedPaths.contains(path)),(try? WaveformCache.load(audio:url,directory:cache)) != nil { state.cached += 1 } else { let value = try AudioWaveform.analyse(url:url,cancelled:cancelled) guard value.levels.count == 4096,value.bands.count == 4096 else { throw LibraryError.message("No audio samples available for waveform analysis") } if cancelled() { throw CancellationError() } try WaveformCache.save(value,audio:url,directory:cache);state.generated += 1 } analysedPaths.insert(path) } catch is CancellationError { if scoped { url.stopAccessingSecurityScopedResource() };state.cancelled = true;break } catch { state.issues += 1 if state.messages.count < 100 { state.messages.append(song.title+": "+error.localizedDescription) } } if scoped { url.stopAccessingSecurityScopedResource() } state.processed += 1 if Date().timeIntervalSince(lastReport) >= 0.12 { progress(state);lastReport = Date() } } state.finished = true;state.current = state.cancelled ? "Cancelled · completed waveforms kept" : (state.issues > 0 ? "Waveform analysis finished with issues" : "Waveforms ready") progress(state);return state } }