removed unwanted bar now with waveforms, major updates

This commit is contained in:
Mark Gregory
2026-11-06 13:19:04 +00:00
parent f6368e4ae7
commit e67acb8210
6 changed files with 127 additions and 33 deletions
-3
View File
@@ -666,9 +666,6 @@ struct StudioSettingsDialog: View {
Divider() Divider()
HStack { Text(scope == "DJ" && category == "Devices" ? "DJ routing · Changes apply immediately" : (scope == "DJ" ? "DJ Studio" : "Karaoke Studio")+" settings · Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }.padding(.horizontal,24).padding(.vertical,16) HStack { Text(scope == "DJ" && category == "Devices" ? "DJ routing · Changes apply immediately" : (scope == "DJ" ? "DJ Studio" : "Karaoke Studio")+" settings · Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }.padding(.horizontal,24).padding(.vertical,16)
}.frame(width:850,height:700).background(Color(nsColor:.windowBackgroundColor)) }.frame(width:850,height:700).background(Color(nsColor:.windowBackgroundColor))
.sheet(isPresented:Binding(get:{model.dj.storageProgress != nil},set:{if !$0,!model.dj.movingStorage { model.dj.storageProgress = nil }})) {
if let progress = model.dj.storageProgress { ImportProgressPanel(progress:progress,studio:"DJ Studio Storage",cancel:{model.dj.cancelStorageMove()},close:{model.dj.storageProgress = nil},isMove:true).interactiveDismissDisabled(model.dj.movingStorage) }
}
.onChange(of:scope) { _ in model.dj.midi.learning = nil;if !categories.contains(category) { category = "General" } } .onChange(of:scope) { _ in model.dj.midi.learning = nil;if !categories.contains(category) { category = "General" } }
.onChange(of:category) { _ in model.dj.midi.learning = nil } .onChange(of:category) { _ in model.dj.midi.learning = nil }
.onDisappear { model.dj.midi.learning = nil } .onDisappear { model.dj.midi.learning = nil }
+40 -11
View File
@@ -172,6 +172,16 @@ struct DJAudioDevice: Identifiable, Equatable {
} }
@MainActor final class DJSystem: ObservableObject { @MainActor final class DJSystem: ObservableObject {
@Published var copyImportedMedia = false { didSet { perform { try library?.setSetting("DJ.CopyImportedMedia.v1",copyImportedMedia ? "1" : "0") } } }
private var referenceAccess = [String:URL]()
private var referenceLocations = [MediaLocation]()
private func ensureReferenceAccess(_ path: String) {
for location in referenceLocations where path == location.path || path.hasPrefix(location.path+"/") {
guard referenceAccess[location.path] == nil,let bookmark = location.bookmark else { continue }
var stale = false
if let url = try? URL(resolvingBookmarkData:bookmark,options:[.withSecurityScope,.withoutUI],relativeTo:nil,bookmarkDataIsStale:&stale),url.startAccessingSecurityScopedResource() { referenceAccess[location.path] = url }
}
}
@Published private(set) var mediaDirectory: URL @Published private(set) var mediaDirectory: URL
@Published private(set) var waveformDirectory: URL @Published private(set) var waveformDirectory: URL
@Published private(set) var movingStorage = false @Published private(set) var movingStorage = false
@@ -199,12 +209,12 @@ struct DJAudioDevice: Identifiable, Equatable {
func moveStorage(to destination: URL) { func moveStorage(to destination: URL) {
guard !movingStorage,!importing,!generatingWaveforms,!namesBusy,!a.player.playing,!b.player.playing,!preview.playing else { error = "Stop playback and finish the current library job before moving DJ files.";return } guard !movingStorage,!importing,!generatingWaveforms,!namesBusy,!a.player.playing,!b.player.playing,!preview.playing else { error = "Stop playback and finish the current library job before moving DJ files.";return }
let loadedA = a.song?.id,loadedB = b.song?.id,database = root.appendingPathComponent("Library.sqlite"),localRoot = root let loadedA = a.song?.id,loadedB = b.song?.id,database = root.appendingPathComponent("Library.sqlite"),localRoot = root
a.unload();b.unload();stopPreview();movingStorage = true;storageProgress = .init() a.unload();b.unload();stopPreview();movingStorage = true;storageProgress = .init(current:"Preparing DJ storage move…")
storageTask = Task.detached(priority:.utility) { [weak self] in storageTask = Task.detached(priority:.utility) { [weak self] in
let access = destination.startAccessingSecurityScopedResource();defer { if access { destination.stopAccessingSecurityScopedResource() } } let access = destination.startAccessingSecurityScopedResource();defer { if access { destination.stopAccessingSecurityScopedResource() } }
var latest = ImportProgress() var latest = ImportProgress()
do { latest = try DJStorageMove.run(database:database,defaultRoot:localRoot,destination:destination,cancelled:{Task.isCancelled}) { value in do { latest = try DJStorageMove.run(database:database,defaultRoot:localRoot,destination:destination,cancelled:{Task.isCancelled}) { value in
latest = value;Task { @MainActor [weak self] in self?.storageProgress = value } latest = value;Task { @MainActor [weak self] in guard let self,self.movingStorage,self.storageTask?.isCancelled != true else { return };self.storageProgress = value }
} } catch is CancellationError { latest.cancelled = true;latest.current = "Move cancelled; verified files kept" } } } catch is CancellationError { latest.cancelled = true;latest.current = "Move cancelled; verified files kept" }
catch { latest.failure = error.localizedDescription;latest.current = "Move needs attention" } catch { latest.failure = error.localizedDescription;latest.current = "Move needs attention" }
latest.finished = true;let result = latest latest.finished = true;let result = latest
@@ -216,7 +226,7 @@ struct DJAudioDevice: Identifiable, Equatable {
} }
} }
} }
func cancelStorageMove() { storageTask?.cancel() } func cancelStorageMove() { storageTask?.cancel();storageProgress?.current = "Cancelling… Originals and verified copies will be kept." }
@Published var namesBusy = false @Published var namesBusy = false
private var graphicSaveTask: Task<Void,Never>? private var graphicSaveTask: Task<Void,Never>?
@Published var graphicEQ = DJGraphicEQSettings() { didSet { @Published var graphicEQ = DJGraphicEQSettings() { didSet {
@@ -241,10 +251,10 @@ struct DJAudioDevice: Identifiable, Equatable {
guard !generatingWaveforms,!importing,!movingStorage else { error = "Wait for the current import or waveform job to finish first.";return } guard !generatingWaveforms,!importing,!movingStorage else { error = "Wait for the current import or waveform job to finish first.";return }
let tracks = songs.filter { ids == nil || ids!.contains($0.id) } let tracks = songs.filter { ids == nil || ids!.contains($0.id) }
guard !tracks.isEmpty else { error = "Choose a DJ library track first.";return } guard !tracks.isEmpty else { error = "Choose a DJ library track first.";return }
let cache = waveformDirectory let cache = waveformDirectory,locations = referenceLocations
generatingWaveforms = true;waveformJob = .init(total:tracks.count) generatingWaveforms = true;waveformJob = .init(total:tracks.count)
waveformTask = Task.detached(priority:.utility) { [weak self] in waveformTask = Task.detached(priority:.utility) { [weak self] in
let result = await WaveformBatch.runConcurrent(songs:tracks,locations:[],cache:cache,force:force) { update in let result = await WaveformBatch.runConcurrent(songs:tracks,locations:locations,cache:cache,force:force) { update in
Task { @MainActor [weak self] in self?.waveformJob = update } Task { @MainActor [weak self] in self?.waveformJob = update }
} }
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
@@ -312,7 +322,7 @@ struct DJAudioDevice: Identifiable, Equatable {
}.store(in:&syncObservers) }.store(in:&syncObservers)
perform { perform {
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
library = try Library(url:root.appendingPathComponent("Library.sqlite"));try loadStorage();try refresh() library = try Library(url:root.appendingPathComponent("Library.sqlite"));copyImportedMedia = try library?.setting("DJ.CopyImportedMedia.v1") == "1";try loadStorage();try refresh()
if let data = try? Data(contentsOf:root.appendingPathComponent("DJGraphicEQ.json")),let saved = try? JSONDecoder().decode(DJGraphicEQSettings.self,from:data) { graphicEQ = saved.sanitized } if let data = try? Data(contentsOf:root.appendingPathComponent("DJGraphicEQ.json")),let saved = try? JSONDecoder().decode(DJGraphicEQSettings.self,from:data) { graphicEQ = saved.sanitized }
applyGraphicEQ() applyGraphicEQ()
favourites = Set((try library?.setting("DJ.Favourites.v1") ?? "").split(separator:",").compactMap{Int64($0)}) favourites = Set((try library?.setting("DJ.Favourites.v1") ?? "").split(separator:",").compactMap{Int64($0)})
@@ -323,7 +333,7 @@ struct DJAudioDevice: Identifiable, Equatable {
audioDeviceMonitor = DJAudioDeviceMonitor { [weak self] in self?.refreshDevices() } audioDeviceMonitor = DJAudioDeviceMonitor { [weak self] in self?.refreshDevices() }
} }
private func saveSettings() { perform { let data = try JSONEncoder().encode(settings.sanitized);try data.write(to:root.appendingPathComponent("DJSettings.json"),options:.atomic) } } private func saveSettings() { perform { let data = try JSONEncoder().encode(settings.sanitized);try data.write(to:root.appendingPathComponent("DJSettings.json"),options:.atomic) } }
func refresh() throws { songs = try library?.songs() ?? [];playlists = try library?.entities("playlists") ?? [] } func refresh() throws { songs = try library?.songs() ?? [];playlists = try library?.entities("playlists") ?? [];referenceLocations = try library?.mediaLocations() ?? [] }
func toggleFavourite(_ song: Int64) { func toggleFavourite(_ song: Int64) {
var updated = favourites var updated = favourites
if !updated.insert(song).inserted { updated.remove(song) } if !updated.insert(song).inserted { updated.remove(song) }
@@ -340,7 +350,7 @@ struct DJAudioDevice: Identifiable, Equatable {
func importFiles(_ urls: [URL],loadInto: String? = nil,overridePlaying: Bool = false) { func importFiles(_ urls: [URL],loadInto: String? = nil,overridePlaying: Bool = false) {
guard !importing,!generatingWaveforms,!movingStorage,!urls.isEmpty else { return } guard !importing,!generatingWaveforms,!movingStorage,!urls.isEmpty else { return }
importing = true;importProgress = .init();importLogVisible = true importing = true;importProgress = .init();importLogVisible = true
let database = root.appendingPathComponent("Library.sqlite"),media = mediaDirectory,cache = waveformDirectory,replacedSong = loadInto.flatMap{channel($0).song?.id} let database = root.appendingPathComponent("Library.sqlite"),media = mediaDirectory,cache = waveformDirectory,copyMedia = copyImportedMedia,replacedSong = loadInto.flatMap{channel($0).song?.id}
importTask = Task.detached(priority:.utility) { [weak self] in importTask = Task.detached(priority:.utility) { [weak self] in
let access = urls.filter{$0.startAccessingSecurityScopedResource()} let access = urls.filter{$0.startAccessingSecurityScopedResource()}
defer { for url in access { url.stopAccessingSecurityScopedResource() } } defer { for url in access { url.stopAccessingSecurityScopedResource() } }
@@ -348,7 +358,14 @@ struct DJAudioDevice: Identifiable, Equatable {
var importedIDs = [Int64]() var importedIDs = [Int64]()
do { do {
let db = try Library(url:database) let db = try Library(url:database)
importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false,waveformCache:cache).run(urls,library:db,cancelled:{Task.isCancelled}) { value in if !copyMedia {
for url in urls {
if Task.isCancelled { throw CancellationError() }
let bookmark = (try? url.bookmarkData(options:.withSecurityScope,includingResourceValuesForKeys:nil,relativeTo:nil)) ?? (try? url.bookmarkData(options:.minimalBookmark,includingResourceValuesForKeys:nil,relativeTo:nil))
_ = try db.saveMediaLocation(name:url.lastPathComponent,url:url,bookmark:bookmark,external:true)
}
}
importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false,waveformCache:cache).run(urls,library:db,copyMedia:copyMedia,cancelled:{Task.isCancelled}) { value in
latest = value latest = value
Task { @MainActor [weak self] in if self?.importing == true { self?.importProgress = value } } Task { @MainActor [weak self] in if self?.importing == true { self?.importProgress = value } }
} }
@@ -372,7 +389,7 @@ struct DJAudioDevice: Identifiable, Equatable {
requestFileLoad(next.urls,into:next.deck,override:next.override && channel(next.deck).song?.id == next.song) requestFileLoad(next.urls,into:next.deck,override:next.override && channel(next.deck).song?.id == next.song)
} }
func cancelReplacement() { pendingReplacement = nil;continueFileLoads() } func cancelReplacement() { pendingReplacement = nil;continueFileLoads() }
deinit { importTask?.cancel();waveformTask?.cancel();storageTask?.cancel();storageAccess?.stopAccessingSecurityScopedResource() } deinit { importTask?.cancel();waveformTask?.cancel();storageTask?.cancel();storageAccess?.stopAccessingSecurityScopedResource();for url in referenceAccess.values { url.stopAccessingSecurityScopedResource() } }
func perform(_ action: () throws -> Void) { do { try action() } catch { self.error = error.localizedDescription } } func perform(_ action: () throws -> Void) { do { try action() } catch { self.error = error.localizedDescription } }
func applyMix() { let weights = DJMix.weights(crossfader,curve:settings.crossfaderCurve);let output = master*pow(10,-settings.headroomDB/20);a.master = output;b.master = output;a.crossWeight = routing.mode == "External" ? 1 : weights.0;b.crossWeight = routing.mode == "External" ? 1 : weights.1 } func applyMix() { let weights = DJMix.weights(crossfader,curve:settings.crossfaderCurve);let output = master*pow(10,-settings.headroomDB/20);a.master = output;b.master = output;a.crossWeight = routing.mode == "External" ? 1 : weights.0;b.crossWeight = routing.mode == "External" ? 1 : weights.1 }
@@ -389,6 +406,7 @@ struct DJAudioDevice: Identifiable, Equatable {
func load(_ song: Song,into letter: String,overridePlaying: Bool = false,automated: Bool = false) { func load(_ song: Song,into letter: String,overridePlaying: Bool = false,automated: Bool = false) {
guard !movingStorage else { error = "Wait for the DJ storage move to finish.";return } guard !movingStorage else { error = "Wait for the DJ storage move to finish.";return }
perform { perform {
ensureReferenceAccess(song.audio)
let deck = channel(letter) let deck = channel(letter)
if settings.protectPlayingDeck,deck.player.playing,!overridePlaying { pendingReplacement = .init(deck:letter,song:song,urls:[]);return } if settings.protectPlayingDeck,deck.player.playing,!overridePlaying { pendingReplacement = .init(deck:letter,song:song,urls:[]);return }
if !automated { automix.manualTakeover() } if !automated { automix.manualTakeover() }
@@ -441,6 +459,7 @@ struct DJAudioDevice: Identifiable, Equatable {
try config.validate(devices:devices,defaultID:DJAudioDevice.defaultOutput()) try config.validate(devices:devices,defaultID:DJAudioDevice.defaultOutput())
} }
func previewLibrary(_ song: Song) { func previewLibrary(_ song: Song) {
ensureReferenceAccess(song.audio)
perform { perform {
guard !cueUID.isEmpty else { throw LibraryError.message("Choose a headphone output in DJ Devices first.") } guard !cueUID.isEmpty else { throw LibraryError.message("Choose a headphone output in DJ Devices first.") }
let output = try device(cueUID) let output = try device(cueUID)
@@ -1617,7 +1636,11 @@ struct DJPreferencesPane: View {
}.padding(26).frame(maxWidth:.infinity,alignment:.leading) }.padding(26).frame(maxWidth:.infinity,alignment:.leading)
} }
} }
}.alert("DJ settings",isPresented:Binding(get:{system.error != nil},set:{if !$0 { system.error = nil }})) { Button("OK") { system.error = nil } } message: { Text(system.error ?? "") } }
.sheet(isPresented:Binding(get:{system.storageProgress != nil},set:{if !$0,!system.movingStorage { system.storageProgress = nil }})) {
if let progress = system.storageProgress { ImportProgressPanel(progress:progress,studio:"DJ Studio Storage",cancel:{system.cancelStorageMove()},close:{system.storageProgress = nil},isMove:true).interactiveDismissDisabled(system.movingStorage) }
}
.alert("DJ settings",isPresented:Binding(get:{system.error != nil},set:{if !$0 { system.error = nil }})) { Button("OK") { system.error = nil } } message: { Text(system.error ?? "") }
} }
private var general: some View { private var general: some View {
VStack(alignment:.leading,spacing:26) { VStack(alignment:.leading,spacing:26) {
@@ -1656,6 +1679,12 @@ struct DJPreferencesPane: View {
} }
private var library: some View { private var library: some View {
VStack(alignment:.leading,spacing:26) { VStack(alignment:.leading,spacing:26) {
PreferencesSection("Import Mode") {
VStack(alignment:.leading,spacing:12) {
Picker("Media files",selection:$system.copyImportedMedia) { Text("Reference files in their original location").tag(false);Text("Copy files into DJ storage").tag(true) }.pickerStyle(.radioGroup)
Text("Referencing avoids duplicate media. Keep the source drive connected. Saved waveforms stay in your selected waveform folder. Audio inside ZIP archives is extracted into DJ storage.").font(.caption).foregroundStyle(.secondary)
}.padding(12)
}
PreferencesSection("Media and Waveform Storage") { PreferencesSection("Media and Waveform Storage") {
VStack(alignment:.leading,spacing:14) { VStack(alignment:.leading,spacing:14) {
LabeledContent("Media") { Text(system.mediaDirectory.path).textSelection(.enabled) } LabeledContent("Media") { Text(system.mediaDirectory.path).textSelection(.enabled) }
+47 -17
View File
@@ -27,7 +27,7 @@ struct Importer {
var includeGraphics = true var includeGraphics = true
var waveformCache: URL? = nil var waveformCache: URL? = nil
@discardableResult func run(_ urls: [URL],library: Library,copyMedia: Bool = true,cancelled: () -> Bool = { false },progress: (ImportProgress) -> Void = { _ in }) throws -> [Int64] { @discardableResult func run(_ urls: [URL],library: Library,copyMedia: Bool = true,cancelled: () -> Bool = { false },progress: (ImportProgress) -> Void = { _ in }) throws -> [Int64] {
try ManagedMediaStorage.prepare(media) if copyMedia { try ManagedMediaStorage.prepare(media) }
var state = ImportProgress() var state = ImportProgress()
var lastReport = Date.distantPast var lastReport = Date.distantPast
func report(_ current: String,force: Bool = false) { func report(_ current: String,force: Bool = false) {
@@ -213,6 +213,7 @@ struct Importer {
if includeGraphics { cdg = try companions(of:audio)["cdg"] } else { cdg = nil } if includeGraphics { cdg = try companions(of:audio)["cdg"] } else { cdg = nil }
let archive = archiveForFile[audio.resolvingSymlinksInPath().path] let archive = archiveForFile[audio.resolvingSymlinksInPath().path]
let copy = copyMedia || archive != nil let copy = copyMedia || archive != nil
if copy { try ManagedMediaStorage.prepare(media) }
let signature = try importSignature(audio,cdg:cdg) let signature = try importSignature(audio,cdg:cdg)
if let existing = try library.importedSongs(path:audio.standardizedFileURL.path,signature:signature) { if let existing = try library.importedSongs(path:audio.standardizedFileURL.path,signature:signature) {
songIDs.formUnion(existing) songIDs.formUnion(existing)
@@ -360,13 +361,20 @@ enum ManagedMediaStorage {
static func save(_ location:MediaStorageLocation,to library:Library) throws { static func save(_ location:MediaStorageLocation,to library:Library) throws {
try library.setSetting(MediaStorageLocation.key,String(decoding:try JSONEncoder().encode(location),as:UTF8.self)) try library.setSetting(MediaStorageLocation.key,String(decoding:try JSONEncoder().encode(location),as:UTF8.self))
} }
static func hash(_ url:URL,cancelled:()->Bool) throws -> SHA256.Digest { static func hash(_ url:URL,cancelled:()->Bool,bytesRead:(Int64)->Void = {_ in}) throws -> SHA256.Digest {
let file = try FileHandle(forReadingFrom:url);defer { try? file.close() } let file = try FileHandle(forReadingFrom:url);defer { try? file.close() }
var result = SHA256() var result = SHA256(),total: Int64 = 0
while let data = try file.read(upToCount:1_048_576),!data.isEmpty { if cancelled() { throw CancellationError() };result.update(data:data) } while let data = try file.read(upToCount:1_048_576),!data.isEmpty { if cancelled() { throw CancellationError() };result.update(data:data);total += Int64(data.count);bytesRead(total) }
return result.finalize() return result.finalize()
} }
static func move(database:URL,roots:[URL],destination:URL,cancelled:()->Bool = {false},progress:(ImportProgress)->Void = {_ in}) throws -> ImportProgress { static func move(database:URL,roots:[URL],destination:URL,cancelled:()->Bool = {false},progress:(ImportProgress)->Void = {_ in}) throws -> ImportProgress {
var state = ImportProgress(current:"Checking media locations…"),lastReport = Date.distantPast
func scan(_ text: String) throws {
if cancelled() { throw CancellationError() }
if Date().timeIntervalSince(lastReport) >= 0.1 { state.current = text;progress(state);lastReport = Date() }
if cancelled() { throw CancellationError() }
}
try scan("Checking media locations…")
try prepare(destination) try prepare(destination)
let targetRoot = destination.resolvingSymlinksInPath() let targetRoot = destination.resolvingSymlinksInPath()
let sources = Set(roots.map{$0.resolvingSymlinksInPath()}).filter{$0 != targetRoot} let sources = Set(roots.map{$0.resolvingSymlinksInPath()}).filter{$0 != targetRoot}
@@ -374,6 +382,7 @@ enum ManagedMediaStorage {
let db = try Library(url:database),songs = try db.songs() let db = try Library(url:database),songs = try db.songs()
var files = Set<URL>() var files = Set<URL>()
for song in songs { for song in songs {
try scan("Finding media: "+song.title)
for path in [song.audio,song.cdg].compactMap({$0}) { for path in [song.audio,song.cdg].compactMap({$0}) {
let file = URL(fileURLWithPath:path).resolvingSymlinksInPath() let file = URL(fileURLWithPath:path).resolvingSymlinksInPath()
if sources.contains(file.deletingLastPathComponent()) { files.insert(file) } if sources.contains(file.deletingLastPathComponent()) { files.insert(file) }
@@ -381,7 +390,9 @@ enum ManagedMediaStorage {
} }
// Include managed hash-named remnants of previous cancelled imports. // Include managed hash-named remnants of previous cancelled imports.
for root in sources where FileManager.default.fileExists(atPath:root.path) { for root in sources where FileManager.default.fileExists(atPath:root.path) {
try scan("Checking folder: "+root.path)
for file in try FileManager.default.contentsOfDirectory(at:root,includingPropertiesForKeys:[.isRegularFileKey,.isSymbolicLinkKey]) { for file in try FileManager.default.contentsOfDirectory(at:root,includingPropertiesForKeys:[.isRegularFileKey,.isSymbolicLinkKey]) {
try scan("Checking media: "+file.lastPathComponent)
let stem = file.deletingPathExtension().lastPathComponent let stem = file.deletingPathExtension().lastPathComponent
if [64,129].contains(stem.count),stem.allSatisfy({$0.isHexDigit || $0 == "-"}),["mp3","wav","m4a","mp4","m4v","mov","cdg"].contains(file.pathExtension.lowercased()) { if [64,129].contains(stem.count),stem.allSatisfy({$0.isHexDigit || $0 == "-"}),["mp3","wav","m4a","mp4","m4v","mov","cdg"].contains(file.pathExtension.lowercased()) {
let values = try file.resourceValues(forKeys:[.isRegularFileKey,.isSymbolicLinkKey]) let values = try file.resourceValues(forKeys:[.isRegularFileKey,.isSymbolicLinkKey])
@@ -389,7 +400,8 @@ enum ManagedMediaStorage {
} }
} }
} }
var state = ImportProgress(total:files.count),verified = [(URL,URL)](),lastReport = Date.distantPast state.total = files.count
var verified = [(URL,URL)]()
defer { progress(state) } defer { progress(state) }
var sourceStamps = [URL:String]() var sourceStamps = [URL:String]()
func stamp(_ url:URL) throws -> String { let info = try FileManager.default.attributesOfItem(atPath:url.path);return "\(info[.size] ?? 0)|\(info[.modificationDate] ?? "")|\(info[.systemFileNumber] ?? 0)" } func stamp(_ url:URL) throws -> String { let info = try FileManager.default.attributesOfItem(atPath:url.path);return "\(info[.size] ?? 0)|\(info[.modificationDate] ?? "")|\(info[.systemFileNumber] ?? 0)" }
@@ -400,16 +412,16 @@ enum ManagedMediaStorage {
if Date().timeIntervalSince(lastReport) >= 0.1 { progress(state);lastReport = Date() } if Date().timeIntervalSince(lastReport) >= 0.1 { progress(state);lastReport = Date() }
let target = targetRoot.appendingPathComponent(source.lastPathComponent) let target = targetRoot.appendingPathComponent(source.lastPathComponent)
sourceStamps[source] = try stamp(source) sourceStamps[source] = try stamp(source)
let expected = try hash(source,cancelled:cancelled) let expected = try hash(source,cancelled:cancelled,bytesRead:{ bytes in
if Date().timeIntervalSince(lastReport) >= 0.1 { state.current = "Verifying "+source.lastPathComponent+" · "+ByteCountFormatter.string(fromByteCount:bytes,countStyle:.file);progress(state);lastReport = Date() }
})
if FileManager.default.fileExists(atPath:target.path) { if FileManager.default.fileExists(atPath:target.path) {
guard try hash(target,cancelled:cancelled) == expected else { throw LibraryError.message("A different file already exists at \(target.path). Choose an empty folder or resolve the conflict first.") } guard try hash(target,cancelled:cancelled) == expected else { throw LibraryError.message("A different file already exists at \(target.path). Choose an empty folder or resolve the conflict first.") }
state.skipped += 1 state.skipped += 1
} else { } else {
let temporary = targetRoot.appendingPathComponent(".kjmix-moving-"+UUID().uuidString) try MediaByteCopy.copy(source,to:target,expectedHash:expected.map{String(format:"%02x",$0)}.joined(),cancelled:cancelled,bytesCopied:{ bytes in
defer { try? FileManager.default.removeItem(at:temporary) } if Date().timeIntervalSince(lastReport) >= 0.1 { state.current = "Copying "+source.lastPathComponent+" · "+ByteCountFormatter.string(fromByteCount:bytes,countStyle:.file);progress(state);lastReport = Date() }
try FileManager.default.copyItem(at:source,to:temporary) })
guard try hash(temporary,cancelled:cancelled) == expected else { throw LibraryError.message("Verification failed for \(source.lastPathComponent). The original has been kept.") }
try FileManager.default.moveItem(at:temporary,to:target)
} }
verified.append((source,target));state.completed += 1;state.logLines.append("[COPIED] "+source.path) verified.append((source,target));state.completed += 1;state.logLines.append("[COPIED] "+source.path)
if Date().timeIntervalSince(lastReport) >= 0.1 || state.completed == 1 || state.completed == state.total { progress(state);lastReport = Date() } if Date().timeIntervalSince(lastReport) >= 0.1 || state.completed == 1 || state.completed == state.total { progress(state);lastReport = Date() }
@@ -463,7 +475,7 @@ enum MediaByteCopy {
} }
return hash.finalize().map{String(format:"%02x",$0)}.joined() return hash.finalize().map{String(format:"%02x",$0)}.joined()
} }
static func copy(_ source: URL,to destination: URL,expectedHash: String,cancelled: () -> Bool = { false }) throws { static func copy(_ source: URL,to destination: URL,expectedHash: String,cancelled: () -> Bool = { false },bytesCopied: (Int64) -> Void = {_ in}) throws {
if cancelled() { throw CancellationError() } if cancelled() { throw CancellationError() }
if nonempty(destination),try digest(destination,cancelled:cancelled) == expectedHash { return } if nonempty(destination),try digest(destination,cancelled:cancelled) == expectedHash { return }
let temporary = destination.deletingLastPathComponent().appendingPathComponent(".kjmix-import-"+UUID().uuidString) let temporary = destination.deletingLastPathComponent().appendingPathComponent(".kjmix-import-"+UUID().uuidString)
@@ -476,8 +488,9 @@ enum MediaByteCopy {
while true { while true {
if cancelled() { throw CancellationError() } if cancelled() { throw CancellationError() }
guard let data = try input.read(upToCount:1_048_576),!data.isEmpty else { break } guard let data = try input.read(upToCount:1_048_576),!data.isEmpty else { break }
try output.write(contentsOf:data);bytesWritten += data.count try output.write(contentsOf:data);bytesWritten += data.count;bytesCopied(Int64(bytesWritten))
} }
if cancelled() { throw CancellationError() }
try output.synchronize();try output.close() try output.synchronize();try output.close()
guard bytesWritten > 0,try digest(temporary,cancelled:cancelled) == expectedHash else { throw LibraryError.message("Audio data was empty or changed during copying. Retry the import.") } guard bytesWritten > 0,try digest(temporary,cancelled:cancelled) == expectedHash else { throw LibraryError.message("Audio data was empty or changed during copying. Retry the import.") }
if cancelled() { throw CancellationError() } if cancelled() { throw CancellationError() }
@@ -497,27 +510,44 @@ enum DJStorageMove {
return try JSONDecoder().decode(MediaStorageLocation.self,from:Data(text.utf8)) return try JSONDecoder().decode(MediaStorageLocation.self,from:Data(text.utf8))
} }
static func run(database: URL,defaultRoot: URL,destination: URL,cancelled: () -> Bool = {false},progress: (ImportProgress) -> Void = {_ in}) throws -> ImportProgress { static func run(database: URL,defaultRoot: URL,destination: URL,cancelled: () -> Bool = {false},progress: (ImportProgress) -> Void = {_ in}) throws -> ImportProgress {
var discovery = ImportProgress(current:"Checking DJ storage…"),lastDiscovery = Date.distantPast
func reportDiscovery(_ text: String,force: Bool = false) throws {
if cancelled() { throw CancellationError() }
if force || Date().timeIntervalSince(lastDiscovery) >= 0.1 { discovery.current = text;progress(discovery);lastDiscovery = Date() }
if cancelled() { throw CancellationError() }
}
try reportDiscovery("Checking DJ storage…",force:true)
let db = try Library(url:database),old = try location(in:db) let db = try Library(url:database),old = try location(in:db)
let oldRoots = Array(Set((old?.roots ?? [])+[defaultRoot.path,old?.path ?? defaultRoot.path])).map{URL(fileURLWithPath:$0)} let oldRoots = Array(Set((old?.roots ?? [])+[defaultRoot.path,old?.path ?? defaultRoot.path])).map{URL(fileURLWithPath:$0)}
let target = destination.resolvingSymlinksInPath(),media = target.appendingPathComponent("Media"),waves = target.appendingPathComponent("Waveforms") let target = destination.resolvingSymlinksInPath(),media = target.appendingPathComponent("Media"),waves = target.appendingPathComponent("Waveforms")
try ManagedMediaStorage.prepare(media);try ManagedMediaStorage.prepare(waves) try ManagedMediaStorage.prepare(media);try ManagedMediaStorage.prepare(waves)
let oldSongs = try db.songs() let oldSongs = try db.songs()
var cacheSources = [Int64:URL](),allCaches = Set<URL>() var cacheSources = [Int64:URL](),allCaches = Set<URL>()
for folder in oldRoots.map({$0.appendingPathComponent("Waveforms")}) { var cacheFolders = Set<URL>()
for folder in Set(oldRoots.map({$0.appendingPathComponent("Waveforms").resolvingSymlinksInPath()})) {
try reportDiscovery("Scanning saved waveforms: "+folder.path)
if let files = try? FileManager.default.contentsOfDirectory(at:folder,includingPropertiesForKeys:[.isRegularFileKey,.isSymbolicLinkKey]) { if let files = try? FileManager.default.contentsOfDirectory(at:folder,includingPropertiesForKeys:[.isRegularFileKey,.isSymbolicLinkKey]) {
for file in files where file.pathExtension == "waveform" { for file in files where file.pathExtension == "waveform" {
try reportDiscovery("Checking saved waveforms: \(allCaches.count.formatted()) found")
let attributes = try file.resourceValues(forKeys:[.isRegularFileKey,.isSymbolicLinkKey]) let attributes = try file.resourceValues(forKeys:[.isRegularFileKey,.isSymbolicLinkKey])
if attributes.isRegularFile == true,attributes.isSymbolicLink != true { allCaches.insert(file.resolvingSymlinksInPath()) } if attributes.isRegularFile == true,attributes.isSymbolicLink != true { allCaches.insert(file.resolvingSymlinksInPath());cacheFolders.insert(folder) }
} }
} }
for song in oldSongs where cacheSources[song.id] == nil { }
if let file = try? WaveformCache.location(audio:URL(fileURLWithPath:song.audio),directory:folder),FileManager.default.fileExists(atPath:file.path) { cacheSources[song.id] = file.resolvingSymlinksInPath() } if !cacheFolders.isEmpty {
let filesByName = Dictionary(grouping:allCaches,by: \.lastPathComponent)
for (index,song) in oldSongs.enumerated() {
try reportDiscovery("Matching waveform \(index+1) of \(oldSongs.count): "+song.title)
// The key filename does not depend on its cache directory.
if let key = try? WaveformCache.location(audio:URL(fileURLWithPath:song.audio),directory:waves).lastPathComponent,
let file = filesByName[key]?.first { cacheSources[song.id] = file }
} }
} }
let manifestKey = "dj.storage.waveform.sources.v1" let manifestKey = "dj.storage.waveform.sources.v1"
if let json = try db.setting(manifestKey),let saved = try? JSONDecoder().decode([String:String].self,from:Data(json.utf8)) { if let json = try db.setting(manifestKey),let saved = try? JSONDecoder().decode([String:String].self,from:Data(json.utf8)) {
let folders = Set(oldRoots.map{$0.appendingPathComponent("Waveforms").resolvingSymlinksInPath()}) let folders = Set(oldRoots.map{$0.appendingPathComponent("Waveforms").resolvingSymlinksInPath()})
for song in oldSongs where cacheSources[song.id] == nil { for song in oldSongs where cacheSources[song.id] == nil {
try reportDiscovery("Checking interrupted transfers: "+song.title)
if let path = saved[String(song.id)] { if let path = saved[String(song.id)] {
let file = URL(fileURLWithPath:path).resolvingSymlinksInPath() let file = URL(fileURLWithPath:path).resolvingSymlinksInPath()
if folders.contains(file.deletingLastPathComponent()),file.pathExtension == "waveform",FileManager.default.fileExists(atPath:file.path) { cacheSources[song.id] = file;allCaches.insert(file) } if folders.contains(file.deletingLastPathComponent()),file.pathExtension == "waveform",FileManager.default.fileExists(atPath:file.path) { cacheSources[song.id] = file;allCaches.insert(file) }
+2 -2
View File
@@ -712,13 +712,13 @@ struct ImportProgressPanel: View {
ImportLogView(progress:progress,title:isMove ? "Transfer Log" : "Import Log").frame(height:260) ImportLogView(progress:progress,title:isMove ? "Transfer Log" : "Import Log").frame(height:260)
Divider() Divider()
HStack { 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) Text(progress.finished ? "Review the log or copy it before closing." : isMove ? "Copies are verified before originals are removed." : "You can cancel safely; completed tracks stay in your library.").font(.caption).foregroundStyle(.secondary)
Spacer() Spacer()
if progress.finished { if progress.finished {
Button("Done",action:close).buttonStyle(.borderedProminent).tint(accent).keyboardShortcut(.defaultAction) Button("Done",action:close).buttonStyle(.borderedProminent).tint(accent).keyboardShortcut(.defaultAction)
} else { } else {
if let background { Button("Run in Background",action:background) } if let background { Button("Run in Background",action:background) }
Button(isMove ? "Cancel Move" : "Cancel Import",action:cancel).keyboardShortcut(.cancelAction) Button(progress.current.hasPrefix("Cancelling") ? "Cancelling…" : isMove ? "Cancel Move" : "Cancel Import",action:cancel).keyboardShortcut(.cancelAction).disabled(progress.current.hasPrefix("Cancelling"))
} }
} }
}.padding(26).frame(width:660) }.padding(26).frame(width:660)
+36
View File
@@ -2918,4 +2918,40 @@ extension KJamsCloneTests {
XCTAssertNotNil(try WaveformCache.load(audio:URL(fileURLWithPath:song.audio),directory:target.appendingPathComponent("Waveforms"))) XCTAssertNotNil(try WaveformCache.load(audio:URL(fileURLWithPath:song.audio),directory:target.appendingPathComponent("Waveforms")))
} }
func testStorageCopyCancellationBetweenChunksKeepsOriginalAndRemovesStaging() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
let source = root.appendingPathComponent("Source.bin"),destination = root.appendingPathComponent("Destination.bin")
try Data(repeating:0x5A,count:4*1024*1024).write(to:source)
let hash = try ManagedMediaStorage.hash(source,cancelled:{false}).map{String(format:"%02x",$0)}.joined()
var cancelled = false,bytes: Int64 = 0
XCTAssertThrowsError(try MediaByteCopy.copy(source,to:destination,expectedHash:hash,cancelled:{cancelled},bytesCopied:{ count in bytes = count;cancelled = true })) { error in XCTAssertTrue(error is CancellationError) }
XCTAssertEqual(bytes,1_048_576)
XCTAssertTrue(FileManager.default.fileExists(atPath:source.path));XCTAssertFalse(FileManager.default.fileExists(atPath:destination.path))
XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath:root.path),["Source.bin"])
}
func testDJStorageReportsAndCancelsBeforeDiscovery() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
var cancelled = false,updates = [String]()
XCTAssertThrowsError(try DJStorageMove.run(database:root.appendingPathComponent("Library.sqlite"),defaultRoot:root,destination:root.appendingPathComponent("Destination"),cancelled:{cancelled},progress:{ updates.append($0.current);cancelled = true })) { error in XCTAssertTrue(error is CancellationError) }
XCTAssertEqual(updates.first,"Checking DJ storage…")
XCTAssertFalse(FileManager.default.fileExists(atPath:root.appendingPathComponent("Destination").path))
}
@MainActor func testDJStorageDialogAppearsWithoutParentModelChange() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root)
let host = NSHostingView(rootView:StudioSettingsDialog(model:model,scope:"DJ",category:"Library"))
let window = NSWindow(contentRect:NSRect(x:0,y:0,width:900,height:750),styleMask:[.titled],backing:.buffered,defer:false)
window.contentView = host;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil)
defer { model.dj.storageProgress = nil;window.orderOut(nil);window.contentView = nil }
try await Task.sleep(nanoseconds:100_000_000)
model.dj.storageProgress = .init(current:"Preparing DJ storage move…")
for _ in 0..<100 where window.sheets.isEmpty { try await Task.sleep(nanoseconds:20_000_000) }
XCTAssertFalse(window.sheets.isEmpty,"The DJ child model must present progress immediately without an unrelated AppModel refresh")
model.dj.storageProgress = nil
}
} }
+2
View File
@@ -117,3 +117,5 @@ Loop Off cancels scheduled repeating audio immediately and resumes normal playba
DJ Settings → Library → Move Media and Waveforms selects a destination folder, including an external drive. Imported audio is stored in its Media subfolder and saved waveforms in Waveforms. Transfers run on a utility worker with progress and cancellation; both decks and headphone preview must be stopped. Every copied file is verified before its original is removed. Waveform keys are updated for relocated audio, so saved analysis remains reusable. Folder access is remembered across launches; missing external drives are reported instead of silently creating substitute folders. The database, playlists, favourites, ratings, MIDI setup and settings stay on this Mac. Cancellation retains verified transfers and remaining originals; rerun the move to finish an interrupted transfer. DJ Settings → Library → Move Media and Waveforms selects a destination folder, including an external drive. Imported audio is stored in its Media subfolder and saved waveforms in Waveforms. Transfers run on a utility worker with progress and cancellation; both decks and headphone preview must be stopped. Every copied file is verified before its original is removed. Waveform keys are updated for relocated audio, so saved analysis remains reusable. Folder access is remembered across launches; missing external drives are reported instead of silently creating substitute folders. The database, playlists, favourites, ratings, MIDI setup and settings stay on this Mac. Cancellation retains verified transfers and remaining originals; rerun the move to finish an interrupted transfer.
Changes to a playing loop's length are queued with the audio node at a loop boundary, avoiding a node restart. A three-millisecond seam blend reduces edge clicks without changing the loop's frame count. Loop Off still cancels repetition immediately. Changes to a playing loop's length are queued with the audio node at a loop boundary, avoiding a node restart. A three-millisecond seam blend reduces edge clicks without changing the loop's frame count. Loop Off still cancels repetition immediately.
Move progress is presented by the DJ settings pane that observes the move directly. Discovery reports progress before file matching begins, skips empty cache folders and checks each audio stamp once. Cancel responds during scanning, hashing and streaming copy; copies use 1 MB chunks and remove incomplete staging files. Large file verification/copy phases report byte counts instead of leaving the interface unchanged.