diff --git a/KJamsClone/DJWorkspace.swift b/KJamsClone/DJWorkspace.swift index fe9f4a6..92c62dd 100644 --- a/KJamsClone/DJWorkspace.swift +++ b/KJamsClone/DJWorkspace.swift @@ -666,9 +666,6 @@ struct StudioSettingsDialog: View { 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) }.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:category) { _ in model.dj.midi.learning = nil } .onDisappear { model.dj.midi.learning = nil } diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index ea056d5..0cdb26f 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -172,6 +172,16 @@ struct DJAudioDevice: Identifiable, Equatable { } @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 waveformDirectory: URL @Published private(set) var movingStorage = false @@ -199,12 +209,12 @@ struct DJAudioDevice: Identifiable, Equatable { 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 } 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 let access = destination.startAccessingSecurityScopedResource();defer { if access { destination.stopAccessingSecurityScopedResource() } } var latest = ImportProgress() 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 { latest.failure = error.localizedDescription;latest.current = "Move needs attention" } 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 private var graphicSaveTask: Task? @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 } let tracks = songs.filter { ids == nil || ids!.contains($0.id) } 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) 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 } } await MainActor.run { [weak self] in @@ -312,7 +322,7 @@ struct DJAudioDevice: Identifiable, Equatable { }.store(in:&syncObservers) perform { 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 } applyGraphicEQ() 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() } } 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) { var updated = favourites 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) { guard !importing,!generatingWaveforms,!movingStorage,!urls.isEmpty else { return } 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 let access = urls.filter{$0.startAccessingSecurityScopedResource()} defer { for url in access { url.stopAccessingSecurityScopedResource() } } @@ -348,7 +358,14 @@ struct DJAudioDevice: Identifiable, Equatable { var importedIDs = [Int64]() do { 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 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) } 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 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) { guard !movingStorage else { error = "Wait for the DJ storage move to finish.";return } perform { + ensureReferenceAccess(song.audio) let deck = channel(letter) if settings.protectPlayingDeck,deck.player.playing,!overridePlaying { pendingReplacement = .init(deck:letter,song:song,urls:[]);return } if !automated { automix.manualTakeover() } @@ -441,6 +459,7 @@ struct DJAudioDevice: Identifiable, Equatable { try config.validate(devices:devices,defaultID:DJAudioDevice.defaultOutput()) } func previewLibrary(_ song: Song) { + ensureReferenceAccess(song.audio) perform { guard !cueUID.isEmpty else { throw LibraryError.message("Choose a headphone output in DJ Devices first.") } let output = try device(cueUID) @@ -1617,7 +1636,11 @@ struct DJPreferencesPane: View { }.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 { VStack(alignment:.leading,spacing:26) { @@ -1656,6 +1679,12 @@ struct DJPreferencesPane: View { } private var library: some View { 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") { VStack(alignment:.leading,spacing:14) { LabeledContent("Media") { Text(system.mediaDirectory.path).textSelection(.enabled) } diff --git a/KJamsClone/Importer.swift b/KJamsClone/Importer.swift index 64ffc29..2675707 100644 --- a/KJamsClone/Importer.swift +++ b/KJamsClone/Importer.swift @@ -27,7 +27,7 @@ struct Importer { var includeGraphics = true var waveformCache: URL? = nil @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 lastReport = Date.distantPast func report(_ current: String,force: Bool = false) { @@ -213,6 +213,7 @@ struct Importer { if includeGraphics { cdg = try companions(of:audio)["cdg"] } else { cdg = nil } let archive = archiveForFile[audio.resolvingSymlinksInPath().path] let copy = copyMedia || archive != nil + if copy { try ManagedMediaStorage.prepare(media) } let signature = try importSignature(audio,cdg:cdg) if let existing = try library.importedSongs(path:audio.standardizedFileURL.path,signature:signature) { songIDs.formUnion(existing) @@ -360,13 +361,20 @@ enum ManagedMediaStorage { static func save(_ location:MediaStorageLocation,to library:Library) throws { 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() } - var result = SHA256() - while let data = try file.read(upToCount:1_048_576),!data.isEmpty { if cancelled() { throw CancellationError() };result.update(data:data) } + 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);total += Int64(data.count);bytesRead(total) } return result.finalize() } 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) let targetRoot = destination.resolvingSymlinksInPath() 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() var files = Set() for song in songs { + try scan("Finding media: "+song.title) for path in [song.audio,song.cdg].compactMap({$0}) { let file = URL(fileURLWithPath:path).resolvingSymlinksInPath() if sources.contains(file.deletingLastPathComponent()) { files.insert(file) } @@ -381,7 +390,9 @@ enum ManagedMediaStorage { } // Include managed hash-named remnants of previous cancelled imports. 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]) { + try scan("Checking media: "+file.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()) { 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) } 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)" } @@ -400,16 +412,16 @@ enum ManagedMediaStorage { if Date().timeIntervalSince(lastReport) >= 0.1 { progress(state);lastReport = Date() } let target = targetRoot.appendingPathComponent(source.lastPathComponent) 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) { 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 } else { - let temporary = targetRoot.appendingPathComponent(".kjmix-moving-"+UUID().uuidString) - defer { try? FileManager.default.removeItem(at:temporary) } - 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) + try MediaByteCopy.copy(source,to:target,expectedHash:expected.map{String(format:"%02x",$0)}.joined(),cancelled:cancelled,bytesCopied:{ bytes in + if Date().timeIntervalSince(lastReport) >= 0.1 { state.current = "Copying "+source.lastPathComponent+" · "+ByteCountFormatter.string(fromByteCount:bytes,countStyle:.file);progress(state);lastReport = Date() } + }) } 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() } @@ -463,7 +475,7 @@ enum MediaByteCopy { } 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 nonempty(destination),try digest(destination,cancelled:cancelled) == expectedHash { return } let temporary = destination.deletingLastPathComponent().appendingPathComponent(".kjmix-import-"+UUID().uuidString) @@ -476,8 +488,9 @@ enum MediaByteCopy { while true { if cancelled() { throw CancellationError() } 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() 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() } @@ -497,27 +510,44 @@ enum DJStorageMove { 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 { + 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 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") try ManagedMediaStorage.prepare(media);try ManagedMediaStorage.prepare(waves) let oldSongs = try db.songs() var cacheSources = [Int64:URL](),allCaches = Set() - for folder in oldRoots.map({$0.appendingPathComponent("Waveforms")}) { + var cacheFolders = Set() + 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]) { 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]) - 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" 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()}) for song in oldSongs where cacheSources[song.id] == nil { + try reportDiscovery("Checking interrupted transfers: "+song.title) if let path = saved[String(song.id)] { 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) } diff --git a/KJamsClone/KJMixProApp.swift b/KJamsClone/KJMixProApp.swift index d69a98b..846f011 100644 --- a/KJamsClone/KJMixProApp.swift +++ b/KJamsClone/KJMixProApp.swift @@ -712,13 +712,13 @@ struct ImportProgressPanel: View { ImportLogView(progress:progress,title:isMove ? "Transfer Log" : "Import Log").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) + 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() if progress.finished { Button("Done",action:close).buttonStyle(.borderedProminent).tint(accent).keyboardShortcut(.defaultAction) } else { 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) diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index 2687f5b..d24383c 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -2918,4 +2918,40 @@ extension KJamsCloneTests { 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 + } + } diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index 36b78b2..59eb712 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -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. 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.