performance updates now qwith progress on import
This commit is contained in:
@@ -184,6 +184,8 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
@Published var songs = [Song]()
|
||||
@Published var playlists = [PersonPlace]()
|
||||
@Published var importProgress: ImportProgress?
|
||||
@Published var lastImportReport: ImportProgress?
|
||||
@Published var importLogVisible = false
|
||||
@Published private(set) var importing = false
|
||||
private var importTask: Task<Void,Never>?
|
||||
private var queuedFileLoads = [(urls:[URL],deck:String,override:Bool,song:Int64?)]()
|
||||
@@ -217,7 +219,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
func dragReference(_ song: Int64) -> SongDragReference { .init(song:song,library:dragSession,singer:nil,venue:nil,fromTonight:false) }
|
||||
func importFiles(_ urls: [URL],loadInto: String? = nil,overridePlaying: Bool = false) {
|
||||
guard !importing,!urls.isEmpty else { return }
|
||||
importing = true;importProgress = .init()
|
||||
importing = true;importProgress = .init();importLogVisible = true
|
||||
let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),replacedSong = loadInto.flatMap{channel($0).song?.id}
|
||||
importTask = Task.detached(priority:.utility) { [weak self] in
|
||||
let access = urls.filter{$0.startAccessingSecurityScopedResource()}
|
||||
@@ -235,8 +237,9 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
latest.finished = true;let result = latest,loadedIDs = importedIDs
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return };self.importing = false;self.importTask = nil;self.importProgress = result
|
||||
self.lastImportReport = result
|
||||
self.perform { try self.refresh() }
|
||||
if let failure = result.failure { self.error = failure }
|
||||
if let failure = result.failure { if loadInto != nil,!self.importLogVisible { self.error = failure } }
|
||||
else if let loadInto,let song = self.songs.first(where:{loadedIDs.contains($0.id)}) { self.load(song,into:loadInto,overridePlaying:overridePlaying && self.channel(loadInto).song?.id == replacedSong) }
|
||||
self.continueFileLoads()
|
||||
}
|
||||
@@ -728,8 +731,8 @@ struct DJConsole: View {
|
||||
HStack {
|
||||
Text(progress.current).lineLimit(1);Text("\(progress.completed)/\(progress.total)").monospacedDigit()
|
||||
Spacer()
|
||||
if system.importing { ProgressView().controlSize(.mini);Button("Cancel") { system.cancelImport() } }
|
||||
else { Button("Dismiss") { system.importProgress = nil } }
|
||||
if system.importing { ProgressView().controlSize(.mini);Button("View Progress") { system.importLogVisible = true };Button("Cancel") { system.cancelImport() } }
|
||||
else { Button("View Log") { system.importLogVisible = true };Button("Dismiss") { system.importProgress = nil } }
|
||||
}.font(.caption).controlSize(.mini)
|
||||
}
|
||||
}.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity,alignment:.top)
|
||||
@@ -739,6 +742,12 @@ struct DJConsole: View {
|
||||
Button("Cancel",role:.cancel) { system.cancelReplacement() }
|
||||
Button("Replace & Load",role:.destructive) { system.confirmReplacement(request) }
|
||||
} message: { request in Text("Deck \(request.deck) is playing. Replacing it with \(request.title) stops the current track. Continue?") }
|
||||
.sheet(isPresented:$system.importLogVisible) {
|
||||
if let progress = system.importing ? system.importProgress : system.lastImportReport {
|
||||
ImportProgressPanel(progress:progress,studio:"DJ Studio",cancel:{ system.cancelImport() },close:{ system.importLogVisible = false },background:{ system.importLogVisible = false })
|
||||
.interactiveDismissDisabled(system.importing)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented:$system.cueEditing) { DJCueEditor(system:system) }
|
||||
.sheet(isPresented:Binding(get:{system.loopEditor != nil},set:{if !$0 { system.loopEditor = nil }})) { let deck = system.channel(system.loopEditor ?? "A");DJLoopEditor(player:deck.player,letter:deck.letter) }
|
||||
.sheet(isPresented:$playlistEditor) { playlistSheet }
|
||||
|
||||
@@ -11,6 +11,14 @@ struct ImportProgress: Sendable {
|
||||
var finished = false
|
||||
var cancelled = false
|
||||
var failure: String?
|
||||
var logLines = [String]()
|
||||
var reportLines: [String] {
|
||||
if let failure { return logLines+["[FAILED] "+current+": "+failure] }
|
||||
return logLines
|
||||
}
|
||||
var logText: String {
|
||||
"Import results: \(completed-skipped) imported, \(skipped) skipped\n"+reportLines.joined(separator:"\n")
|
||||
}
|
||||
}
|
||||
|
||||
struct Importer {
|
||||
@@ -73,6 +81,33 @@ struct Importer {
|
||||
var files = discovered.sorted { $0.path < $1.path }
|
||||
var temporary = [URL]()
|
||||
defer { for url in temporary { try? FileManager.default.removeItem(at: url) } }
|
||||
// Older libraries may contain indexed tracks without stored fingerprints.
|
||||
// Compare only same-size candidates, and hash each legacy file at most once.
|
||||
var legacyBySize = [UInt64:[Song]]()
|
||||
for song in try library.songs() where song.fingerprint.isEmpty {
|
||||
if let info = try? FileManager.default.attributesOfItem(atPath:song.audio),let size = info[.size] as? NSNumber,size.uint64Value > 0 {
|
||||
legacyBySize[size.uint64Value,default:[]].append(song)
|
||||
}
|
||||
}
|
||||
var legacyHashes = [Int64:String]()
|
||||
func duplicateWithFingerprint(_ hash: String,audio: URL) throws -> Song? {
|
||||
if let song = try library.songWithFingerprint(hash) { return song }
|
||||
let size = (try FileManager.default.attributesOfItem(atPath:audio.path)[.size] as? NSNumber)?.uint64Value ?? 0
|
||||
for song in legacyBySize[size] ?? [] {
|
||||
if cancelled() { throw CancellationError() }
|
||||
let candidate: String
|
||||
if let known = legacyHashes[song.id] { candidate = known }
|
||||
else {
|
||||
do { candidate = try audioFingerprint(URL(fileURLWithPath:song.audio),cancelled:cancelled) }
|
||||
catch is CancellationError { throw CancellationError() }
|
||||
catch { continue } // An offline legacy entry must not prevent a new import.
|
||||
legacyHashes[song.id] = candidate
|
||||
try library.execute("UPDATE songs SET fingerprint=? WHERE id=? AND fingerprint=''",[candidate,String(song.id)])
|
||||
}
|
||||
if candidate == hash { return song }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var songIDs = Set<Int64>()
|
||||
var archives = [(url:URL,signature:String,folder:URL)]()
|
||||
var archiveForFile = [String:URL]()
|
||||
@@ -85,13 +120,14 @@ struct Importer {
|
||||
}
|
||||
return try signature(audio)+"|"+(cdg.map { try signature($0) } ?? "no-cdg")+"|copy=\(copyMedia)"
|
||||
}
|
||||
func remember(_ audio:URL,_ signature:String,_ id:Int64) throws {
|
||||
var originalPath = audio.path
|
||||
func sourcePath(_ audio: URL) -> String {
|
||||
if let folder = archiveForFile[audio.resolvingSymlinksInPath().path],let zip = archiveOrigins[folder] {
|
||||
let relative = String(audio.resolvingSymlinksInPath().path.dropFirst(folder.resolvingSymlinksInPath().path.count+1))
|
||||
originalPath = zip.path+"/"+relative
|
||||
return zip.path+"/"+String(audio.resolvingSymlinksInPath().path.dropFirst(folder.resolvingSymlinksInPath().path.count+1))
|
||||
}
|
||||
try library.recordOriginalName(song:id,name:audio.deletingPathExtension().lastPathComponent,path:originalPath)
|
||||
return audio.path
|
||||
}
|
||||
func remember(_ audio:URL,_ signature:String,_ id:Int64) throws {
|
||||
try library.recordOriginalName(song:id,name:audio.deletingPathExtension().lastPathComponent,path:sourcePath(audio))
|
||||
if let archive = archiveForFile[audio.resolvingSymlinksInPath().path] { archiveSongIDs[archive,default:[]].append(id) }
|
||||
else { try library.rememberImport(path:audio.standardizedFileURL.path,signature:signature,songs:[id]) }
|
||||
}
|
||||
@@ -100,6 +136,9 @@ struct Importer {
|
||||
let signature = try importSignature(zip)
|
||||
if let existing = try library.importedSongs(path:zip.standardizedFileURL.path,signature:signature),try library.originalNamesRecorded(existing) {
|
||||
songIDs.formUnion(existing);state.completed += existing.count;state.skipped += existing.count
|
||||
for id in existing {
|
||||
state.logLines.append("[SKIPPED] "+zip.path+" → "+((try library.song(id))?.title ?? String(id))+" (already imported)")
|
||||
}
|
||||
report("Already imported: "+zip.lastPathComponent,force:state.completed == existing.count);continue
|
||||
}
|
||||
report("Extracting "+zip.lastPathComponent,force:archives.isEmpty)
|
||||
@@ -178,20 +217,22 @@ struct Importer {
|
||||
songIDs.formUnion(existing)
|
||||
for id in existing { try remember(audio,signature,id) }
|
||||
state.completed += 1;state.skipped += 1
|
||||
state.logLines.append("[SKIPPED] "+sourcePath(audio)+" (already imported)")
|
||||
report("Already imported: "+audio.lastPathComponent,force:state.completed == 1);continue
|
||||
}
|
||||
guard MediaByteCopy.nonempty(audio) else { throw LibraryError.message("Audio file is empty or unreadable: "+audio.path) }
|
||||
let hash = try audioFingerprint(audio,cancelled:cancelled)
|
||||
let graphicsHash = try cdg.map { try audioFingerprint($0,cancelled:cancelled) }
|
||||
let duplicate = try library.songWithFingerprint(hash)
|
||||
let duplicate = try duplicateWithFingerprint(hash,audio:audio)
|
||||
var sameGraphics = cdg == nil
|
||||
if let graphicsHash,let stored = duplicate?.cdg,FileManager.default.fileExists(atPath:stored) {
|
||||
sameGraphics = try audioFingerprint(URL(fileURLWithPath:stored),cancelled:cancelled) == graphicsHash
|
||||
}
|
||||
if let existing = duplicate,sameGraphics,MediaByteCopy.nonempty(URL(fileURLWithPath:existing.audio)),
|
||||
(!copy || URL(fileURLWithPath:existing.audio).deletingLastPathComponent().standardizedFileURL == media.standardizedFileURL) {
|
||||
(archive == nil || !copy || URL(fileURLWithPath:existing.audio).deletingLastPathComponent().standardizedFileURL == media.standardizedFileURL) {
|
||||
try remember(audio,signature,existing.id);songIDs.insert(existing.id)
|
||||
state.completed += 1;state.skipped += 1
|
||||
state.logLines.append("[SKIPPED] "+sourcePath(audio)+" (identical content already in library)")
|
||||
report("Already in library: "+audio.lastPathComponent,force:state.completed == 1);continue
|
||||
}
|
||||
let dest = copy ? media.appendingPathComponent(hash+"."+audio.pathExtension.lowercased()) : audio.standardizedFileURL
|
||||
@@ -210,6 +251,7 @@ struct Importer {
|
||||
songIDs.insert(id)
|
||||
try remember(audio,signature,id)
|
||||
state.completed += 1
|
||||
state.logLines.append("[IMPORTED] "+sourcePath(audio))
|
||||
report(audio.lastPathComponent,force:state.completed == 1)
|
||||
}
|
||||
for archive in archives {
|
||||
|
||||
@@ -587,6 +587,9 @@ private struct ImportProgressDialog: View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.16).ignoresSafeArea().contentShape(Rectangle()).onTapGesture { }
|
||||
if let progress = storageMode ? model.storageProgress : model.importProgress {
|
||||
if !storageMode {
|
||||
ImportProgressPanel(progress:progress,studio:"Karaoke",cancel:{ model.cancelImport() },close:{ model.dismissImport() })
|
||||
} else {
|
||||
VStack(alignment:.leading,spacing:16) {
|
||||
HStack(spacing:12) {
|
||||
Image(systemName:progress.finished ? (progress.failure == nil ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") : "square.and.arrow.down")
|
||||
@@ -600,12 +603,112 @@ private struct ImportProgressDialog: View {
|
||||
else if !progress.finished { ProgressView().controlSize(.small) }
|
||||
if progress.completed > 0 && !storageMode { Text("\(progress.completed-progress.skipped) imported · \(progress.skipped) already in library").font(.callout.weight(.medium)) }
|
||||
Text(progress.failure ?? progress.current).font(.callout).foregroundStyle(.secondary).lineLimit(3).frame(maxWidth:.infinity,alignment:.leading)
|
||||
if progress.finished,!storageMode { ImportLogView(progress:progress).frame(height:240) }
|
||||
if progress.cancelled || progress.failure != nil { Text(storageMode ? "Original files are kept until their copies and library links are verified." : "Tracks already imported are saved in your library.").font(.caption).foregroundStyle(.secondary) }
|
||||
HStack { Spacer();if progress.finished { Button("Done") { if storageMode { model.dismissMediaMove() } else { model.dismissImport() } }.keyboardShortcut(.defaultAction) } else { Button(storageMode ? "Cancel Move" : "Cancel Import") { if storageMode { model.cancelMediaMove() } else { model.cancelImport() } }.keyboardShortcut(.cancelAction) } }
|
||||
}.padding(24).frame(width:380).background(.regularMaterial,in:RoundedRectangle(cornerRadius:14))
|
||||
}.padding(24).frame(width:storageMode || !progress.finished ? 380 : 660).background(.regularMaterial,in:RoundedRectangle(cornerRadius:14))
|
||||
.overlay(RoundedRectangle(cornerRadius:14).strokeBorder(.primary.opacity(0.08)))
|
||||
.shadow(color:.black.opacity(0.2),radius:24,y:8)
|
||||
.accessibilityElement(children:.contain).accessibilityLabel(storageMode ? "Media move progress" : "Music import progress")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
struct ImportProgressPanel: View {
|
||||
let progress: ImportProgress
|
||||
let studio: String
|
||||
let cancel: () -> Void
|
||||
let close: () -> Void
|
||||
var background: (() -> Void)? = nil
|
||||
private var fraction: Double { progress.total > 0 ? min(1,max(0,Double(progress.completed)/Double(progress.total))) : 0 }
|
||||
private var accent: Color { progress.failure != nil || progress.cancelled ? .orange : progress.finished ? .green : .cyan }
|
||||
private var title: String { progress.finished ? (progress.cancelled ? "Import Cancelled" : progress.failure == nil ? "Import Complete" : "Import Needs Attention") : "Importing Music" }
|
||||
var body: some View {
|
||||
VStack(alignment:.leading,spacing:20) {
|
||||
HStack(spacing:14) {
|
||||
Image(systemName:progress.finished ? (progress.failure != nil || progress.cancelled ? "exclamationmark.circle.fill" : "checkmark.circle.fill") : "square.and.arrow.down.fill")
|
||||
.font(.system(size:26,weight:.medium)).foregroundStyle(accent).frame(width:52,height:52)
|
||||
.background(accent.opacity(0.12),in:RoundedRectangle(cornerRadius:12))
|
||||
VStack(alignment:.leading,spacing:4) {
|
||||
Text(studio.uppercased()+" LIBRARY").font(.system(size:10,weight:.semibold)).tracking(1.4).foregroundStyle(.secondary)
|
||||
Text(title).font(.system(size:22,weight:.semibold))
|
||||
}
|
||||
Spacer()
|
||||
if !progress.finished { ProgressView().controlSize(.small).tint(accent) }
|
||||
}
|
||||
VStack(alignment:.leading,spacing:9) {
|
||||
HStack {
|
||||
Text(progress.total > 0 ? "\(progress.completed.formatted()) of \(progress.total.formatted()) tracks checked" : "Scanning files and folders…").font(.callout)
|
||||
Spacer()
|
||||
Text(progress.total > 0 ? "\(Int(fraction*100))%" : "Preparing").font(.callout.weight(.semibold)).monospacedDigit().foregroundStyle(accent)
|
||||
}
|
||||
GeometryReader { proxy in
|
||||
ZStack(alignment:.leading) {
|
||||
Capsule().fill(.primary.opacity(0.08))
|
||||
Capsule().fill(LinearGradient(colors:[accent.opacity(0.65),accent],startPoint:.leading,endPoint:.trailing))
|
||||
.frame(width:progress.total > 0 ? proxy.size.width*fraction : proxy.size.width*0.08)
|
||||
}
|
||||
}.frame(height:9).accessibilityLabel("Import progress").accessibilityValue("\(Int(fraction*100)) percent")
|
||||
Text(progress.failure ?? progress.current).font(.system(size:11)).foregroundStyle(.secondary).lineLimit(2).frame(height:30,alignment:.topLeading).frame(maxWidth:.infinity,alignment:.leading)
|
||||
}
|
||||
HStack(spacing:12) {
|
||||
count("Imported",value:max(0,progress.completed-progress.skipped),color:.green)
|
||||
count("Skipped",value:progress.skipped,color:.orange)
|
||||
count("Remaining",value:progress.total > 0 ? max(0,progress.total-progress.completed) : -1,color:.cyan)
|
||||
}
|
||||
ImportLogView(progress:progress).frame(height:260)
|
||||
Divider()
|
||||
HStack {
|
||||
Text(progress.finished ? "Review the log or copy it before closing." : "You can cancel safely; completed tracks stay in your library.").font(.caption).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if progress.finished {
|
||||
Button("Done",action:close).buttonStyle(.borderedProminent).tint(accent).keyboardShortcut(.defaultAction)
|
||||
} else {
|
||||
if let background { Button("Run in Background",action:background) }
|
||||
Button("Cancel Import",action:cancel).keyboardShortcut(.cancelAction)
|
||||
}
|
||||
}
|
||||
}.padding(26).frame(width:660)
|
||||
.background(Color(nsColor:.windowBackgroundColor),in:RoundedRectangle(cornerRadius:16))
|
||||
.overlay(RoundedRectangle(cornerRadius:16).strokeBorder(.primary.opacity(0.1)))
|
||||
.shadow(color:.black.opacity(0.2),radius:24,y:8)
|
||||
.accessibilityElement(children:.contain).accessibilityLabel(studio+" import progress")
|
||||
}
|
||||
private func count(_ title: String,value: Int,color: Color) -> some View {
|
||||
VStack(alignment:.leading,spacing:5) {
|
||||
Text(value >= 0 ? value.formatted() : "—").font(.system(size:22,weight:.semibold,design:.rounded)).monospacedDigit().foregroundStyle(color)
|
||||
Text(title).font(.caption).foregroundStyle(.secondary)
|
||||
}.frame(maxWidth:.infinity,alignment:.leading).padding(12).background(color.opacity(0.07),in:RoundedRectangle(cornerRadius:8))
|
||||
}
|
||||
}
|
||||
struct ImportLogView: View {
|
||||
let progress: ImportProgress
|
||||
@State private var follow = true
|
||||
var body: some View {
|
||||
let lines = progress.reportLines
|
||||
VStack(alignment:.leading,spacing:8) {
|
||||
HStack {
|
||||
Text("Import Log").font(.headline)
|
||||
Spacer()
|
||||
if !progress.finished { Toggle("Follow log",isOn:$follow).toggleStyle(.checkbox).controlSize(.small) }
|
||||
Button("Copy Log") { NSPasteboard.general.clearContents();NSPasteboard.general.setString(progress.logText,forType:.string) }
|
||||
}
|
||||
ScrollViewReader { reader in
|
||||
ScrollView {
|
||||
LazyVStack(alignment:.leading,spacing:7) {
|
||||
ForEach(lines.indices,id:\.self) { index in
|
||||
let line = lines[index]
|
||||
HStack(alignment:.top,spacing:8) {
|
||||
Circle().fill(line.hasPrefix("[FAILED]") ? Color.red : line.hasPrefix("[SKIPPED]") ? Color.orange : Color.green).frame(width:5,height:5).padding(.top,5)
|
||||
Text(line).font(.system(size:11,design:.monospaced)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading)
|
||||
}.id(index)
|
||||
}
|
||||
if lines.isEmpty { Text(progress.finished ? "No tracks processed." : "Waiting for the first track…").font(.callout).foregroundStyle(.secondary) }
|
||||
}.padding(12)
|
||||
}.background(Color(nsColor:.textBackgroundColor),in:RoundedRectangle(cornerRadius:8))
|
||||
.overlay(RoundedRectangle(cornerRadius:8).strokeBorder(.primary.opacity(0.06)))
|
||||
.onChange(of:lines.count) { _ in if follow,let last = lines.indices.last { reader.scrollTo(last,anchor:.bottom) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user