267 lines
16 KiB
Swift
267 lines
16 KiB
Swift
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
|
|
}
|
|
}
|
|
|
|
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<Int64>(),analysedPaths = Set<String>()
|
|
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
|
|
}
|
|
}
|
|
|
|
/// Blocking decoders and admission waits must not occupy Swift's cooperative executor.
|
|
/// Cancellation is captured explicitly because GCD blocks have no Swift task context.
|
|
enum WaveformWorker {
|
|
private final class Cancellation: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var value = false
|
|
var isCancelled: Bool { lock.lock();defer { lock.unlock() };return value }
|
|
func cancel() { lock.lock();value = true;lock.unlock() }
|
|
}
|
|
static func run<T: Sendable>(_ operation: @escaping @Sendable (@escaping @Sendable () -> Bool) throws -> T) async throws -> T {
|
|
let cancellation = Cancellation()
|
|
return try await withTaskCancellationHandler(operation: {
|
|
try Task.checkCancellation()
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
DispatchQueue.global(qos:.utility).async {
|
|
do {
|
|
if cancellation.isCancelled { throw CancellationError() }
|
|
let result = try operation { cancellation.isCancelled }
|
|
if cancellation.isCancelled { throw CancellationError() }
|
|
continuation.resume(returning:result)
|
|
} catch { continuation.resume(throwing:error) }
|
|
}
|
|
}
|
|
},onCancel:{ cancellation.cancel() })
|
|
}
|
|
}
|
|
|
|
/// Admission control is read on workers; starting playback reduces future admissions.
|
|
final class WaveformWorkload: @unchecked Sendable {
|
|
static let shared = WaveformWorkload()
|
|
private let lock = NSCondition()
|
|
private var playing = Set<UUID>()
|
|
private var active = 0
|
|
private var allowed: Int { playing.isEmpty ? min(2,max(1,ProcessInfo.processInfo.activeProcessorCount/2)) : 1 }
|
|
func setPlaying(_ active: Bool,id: UUID) { lock.lock();defer { lock.unlock() };if active { playing.insert(id) } else { playing.remove(id) };lock.broadcast() }
|
|
var workers: Int { lock.lock();defer { lock.unlock() };return allowed }
|
|
var playbackActive: Bool { lock.lock();defer { lock.unlock() };return !playing.isEmpty }
|
|
func acquire(cancelled: () -> Bool) throws {
|
|
lock.lock();defer { lock.unlock() }
|
|
while active >= allowed {
|
|
if cancelled() { throw CancellationError() }
|
|
_ = lock.wait(until:Date().addingTimeInterval(0.02))
|
|
}
|
|
if cancelled() { throw CancellationError() };active += 1
|
|
}
|
|
func release() { lock.lock();active -= 1;lock.broadcast();lock.unlock() }
|
|
}
|
|
extension WaveformBatch {
|
|
static func runConcurrent(songs: [Song],locations: [MediaLocation],cache: URL,force: Bool = false,limit: @escaping @Sendable () -> Int = { WaveformWorkload.shared.workers },progress: (WaveformJobProgress) -> Void = { _ in }) async -> WaveformJobProgress {
|
|
var ids = Set<Int64>(),groups = [[Song]](),indices = [String:Int]()
|
|
for song in songs where ids.insert(song.id).inserted {
|
|
let key = URL(fileURLWithPath:song.audio).standardizedFileURL.path
|
|
if let index = indices[key] { groups[index].append(song) }
|
|
else { indices[key] = groups.count;groups.append([song]) }
|
|
}
|
|
var state = WaveformJobProgress(total:ids.count),next = 0,active = 0,lastReport = Date.distantPast
|
|
await withTaskGroup(of:WaveformJobProgress.self) { group in
|
|
while next < groups.count || active > 0 {
|
|
if Task.isCancelled { group.cancelAll();state.cancelled = true }
|
|
let allowed = min(2,max(1,limit()))
|
|
while !Task.isCancelled,active < allowed,next < groups.count {
|
|
let tracks = groups[next];next += 1;active += 1
|
|
state.current = tracks.first?.title ?? "Preparing waveforms…"
|
|
group.addTask(priority:.utility) {
|
|
do {
|
|
return try await WaveformWorker.run { cancelled in
|
|
run(songs:tracks,locations:locations,cache:cache,force:force,cancelled:cancelled)
|
|
}
|
|
} catch {
|
|
var result = WaveformJobProgress(total:tracks.count)
|
|
result.cancelled = true;result.finished = true
|
|
return result
|
|
}
|
|
}
|
|
}
|
|
// Publish admission before awaiting a decoder: otherwise a long first track
|
|
// leaves the interface at “Preparing waveforms” with no visible activity.
|
|
if active > 0 { progress(state) }
|
|
guard active > 0,let result = await group.next() else { break }
|
|
active -= 1
|
|
state.processed += result.processed;state.generated += result.generated;state.cached += result.cached
|
|
state.skipped += result.skipped;state.issues += result.issues;state.cancelled = state.cancelled || result.cancelled
|
|
state.messages.append(contentsOf:result.messages.prefix(max(0,100-state.messages.count)))
|
|
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
|
|
}
|
|
}
|
|
|
|
/// Serial batches of background analysis, independent of import completion.
|
|
@MainActor final class PostImportWaveformQueue {
|
|
private var pending = [Int64:Song]()
|
|
private var locations = [MediaLocation]()
|
|
private var cache: URL?
|
|
private var task: Task<Void,Never>?
|
|
private var token = UUID()
|
|
private var update: ((WaveformJobProgress) -> Void)?
|
|
private var completed: (([Song]) -> Void)?
|
|
private var latest: WaveformJobProgress?
|
|
var isRunning: Bool { task != nil }
|
|
func enqueue(_ songs: [Song],locations: [MediaLocation],cache: URL,update: @escaping (WaveformJobProgress) -> Void,completed: @escaping ([Song]) -> Void) {
|
|
for song in songs { pending[song.id] = song }
|
|
self.locations = locations;self.cache = cache;self.update = update;self.completed = completed
|
|
if task == nil { startNext() }
|
|
}
|
|
private func startNext() {
|
|
guard !pending.isEmpty,let cache else { return }
|
|
let songs = pending.values.sorted{$0.id < $1.id},locations = self.locations,id = UUID()
|
|
pending.removeAll();token = id;latest = .init(total:songs.count);update?(latest!)
|
|
task = Task.detached(priority:.utility) { [weak self] in
|
|
let result = await WaveformBatch.runConcurrent(songs:songs,locations:locations,cache:cache,progress: { value in
|
|
Task { @MainActor [weak self] in guard let self,self.token == id,self.task != nil else { return };self.latest = value;self.update?(value) }
|
|
})
|
|
await MainActor.run { [weak self] in
|
|
guard let self,self.token == id else { return }
|
|
self.task = nil;self.latest = result;self.update?(result);self.completed?(songs)
|
|
self.startNext()
|
|
}
|
|
}
|
|
}
|
|
func cancel() {
|
|
token = UUID();pending.removeAll();task?.cancel();task = nil
|
|
if var value = latest { value.finished = true;value.cancelled = true;value.current = "Background waveform job cancelled";latest = value;update?(value) }
|
|
}
|
|
deinit { task?.cancel() }
|
|
}
|