diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index e607bec..98b8fc2 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -373,6 +373,7 @@ struct DJAudioDevice: Identifiable, Equatable { @Published var importLogVisible = false @Published private(set) var importing = false private var importTask: Task? + private var importGeneration = UUID() private var queuedFileLoads = [(urls:[URL],deck:String,override:Bool,song:Int64?)]() init(storageURL: URL? = nil) { root = storageURL ?? FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("KJamsClone/DJ") @@ -418,6 +419,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 importID = UUID();importGeneration = importID let database = root.appendingPathComponent("Library.sqlite"),media = mediaDirectory,beforeIDs = Set(songs.map(\.id)),copyMedia = copyImportedMedia,replacedSong = loadInto.flatMap{channel($0).song?.id} importTask = Task.detached(priority:.utility) { [weak self] in let access = urls.filter{$0.startAccessingSecurityScopedResource()} @@ -433,15 +435,15 @@ struct DJAudioDevice: Identifiable, Equatable { _ = 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).run(urls,library:db,copyMedia:copyMedia,cancelled:{Task.isCancelled}) { value in + importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false,continueOnUnreadableAudio:true).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 } } + Task { @MainActor [weak self] in if self?.importing == true,self?.importGeneration == importID { self?.importProgress = value } } } } catch is CancellationError { latest.cancelled = true;latest.current = "DJ import cancelled" } catch { latest.failure = error.localizedDescription;latest.current = "DJ import stopped" } 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 + guard let self,self.importGeneration == importID else { return };self.importing = false;self.importTask = nil;self.importProgress = result self.lastImportReport = result self.perform { try self.refresh() } if let failure = result.failure { if loadInto != nil,!self.importLogVisible { self.error = failure } } @@ -451,7 +453,7 @@ struct DJAudioDevice: Identifiable, Equatable { } } } - func cancelImport() { queuedFileLoads = [];importTask?.cancel() } + func cancelImport() { queuedFileLoads = [];importTask?.cancel();if importing { importProgress?.current = "Cancelling…" } } func continueFileLoads() { guard !importing,pendingReplacement == nil,!queuedFileLoads.isEmpty else { return } let next = queuedFileLoads.removeFirst() @@ -1121,17 +1123,6 @@ struct DJConsole: View { DJLibrarySnapshot(revision:libraryRevision,selection:selection,selected:selected,page:page,busy:system.generatingWaveforms || system.importing,font:system.settings.libraryFont,height:system.settings.libraryRowHeight) { browser }.equatable().frame(maxWidth:.infinity,maxHeight:.infinity) } } - if let progress = system.automaticWaveformProgress { - BackgroundWaveformStatus(progress:progress,cancel:{system.automaticWaveforms.cancel()},dismiss:{system.automaticWaveformProgress = nil}) - } - if let progress = system.importProgress { - HStack { - Text(progress.current).lineLimit(1);Text("\(progress.completed)/\(progress.total)").monospacedDigit() - Spacer() - 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(minWidth:900,maxWidth:.infinity,minHeight:706,maxHeight:.infinity,alignment:.top) .background(DJStyle.background).foregroundStyle(.white) .preferredColorScheme(.dark) @@ -1141,7 +1132,7 @@ struct DJConsole: View { } 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 }) + ImportProgressPanel(progress:progress,studio:"DJ Studio",cancel:{ system.cancelImport() },close:{ system.importLogVisible = false },background:{ system.importLogVisible = false },waveformStatus:system.automaticWaveformProgress.map { progress in BackgroundWaveformStatus(progress:progress,cancel:{system.automaticWaveforms.cancel()},dismiss:{system.automaticWaveformProgress = nil}) }) .interactiveDismissDisabled(system.importing) } } @@ -1205,6 +1196,7 @@ struct DJConsole: View { Spacer() HStack(spacing:8) { Text("MASTER").font(.system(size:9,weight:.semibold)).foregroundStyle(.secondary);DJHardwareFader(value:$system.master,label:"DJ master level").frame(width:95,height:28).help("Adjust the overall DJ output level for both decks") } Button { sidebarVisible.toggle() } label: { Image(systemName:"sidebar.left") }.help("Show or hide collections").buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a)) + Button { system.importLogVisible = true } label: { Image(systemName:"doc.text") }.buttonStyle(LibraryActionButtonStyle(tint:.gray)).disabled(system.importProgress == nil).help("Open the import log and background waveform progress") Button { graphicEQVisible = true } label: { Label("Equaliser",systemImage:"slider.vertical.3") }.buttonStyle(LibraryActionButtonStyle(tint:system.graphicEQ.enabled ? DJStyle.a : .gray)).help("Open the ten-band graphic equaliser and sound presets for both decks") DJQueueButton(automix:system.automix,open:{queueVisible = true}) if system.previewDeck != nil { Button("Stop Preview") { system.stopPreview() }.tint(.green).help("Stop independent headphone preview") } diff --git a/KJamsClone/Importer.swift b/KJamsClone/Importer.swift index 97be71a..65a5c96 100644 --- a/KJamsClone/Importer.swift +++ b/KJamsClone/Importer.swift @@ -26,6 +26,9 @@ struct Importer { var audioExtensions: Set = ["mp3","wav","m4a","mp4","m4v","mov"] var includeGraphics = true var waveformCache: URL? = nil + var continueOnUnreadableAudio = false + var metadataTimeout: TimeInterval = 2 + var metadataLoader: (@Sendable (URL) async throws -> [AVMetadataKey:String])? = nil @discardableResult func run(_ urls: [URL],library: Library,copyMedia: Bool = true,cancelled: () -> Bool = { false },progress: (ImportProgress) -> Void = { _ in }) throws -> [Int64] { if copyMedia { try ManagedMediaStorage.prepare(media) } var state = ImportProgress() @@ -73,6 +76,7 @@ struct Importer { } } else if values.isRegularFile == true,supported.contains(url.pathExtension.lowercased()) { discovered.insert(url) + report("Scanning · \(discovered.count.formatted()) supported files found · \(url.lastPathComponent)") } } catch is CancellationError { throw CancellationError() } catch { throw LibraryError.message("Could not scan \(url.path): \(error.localizedDescription)") } @@ -85,7 +89,10 @@ struct Importer { // 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]]() + var existingChecked = 0 for song in try library.songs() where song.fingerprint.isEmpty { + if cancelled() { throw CancellationError() } + existingChecked += 1;report("Checking existing library · \(existingChecked.formatted()) tracks · \(song.title)") 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) } @@ -222,9 +229,24 @@ struct Importer { 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 hash: String + let graphicsHash: String? + do { + guard MediaByteCopy.nonempty(audio) else { throw LibraryError.message("Audio file is empty or unreadable: "+audio.path) } + report("Checking audio: "+audio.lastPathComponent) + if continueOnUnreadableAudio { _ = try AVAudioFile(forReading:audio) } + hash = try audioFingerprint(audio,cancelled:cancelled,progress:{ bytes in + report("Reading "+audio.lastPathComponent+" · "+ByteCountFormatter.string(fromByteCount:bytes,countStyle:.file)) + }) + graphicsHash = try cdg.map { try audioFingerprint($0,cancelled:cancelled) } + } catch is CancellationError { throw CancellationError() } + catch { + guard continueOnUnreadableAudio else { throw error } + state.completed += 1;state.skipped += 1 + state.logLines.append("[SKIPPED] "+sourcePath(audio)+" (unreadable audio: "+error.localizedDescription+")") + report("Skipped unreadable file: "+audio.lastPathComponent,force:state.completed == 1) + continue + } let duplicate = try duplicateWithFingerprint(hash,audio:audio) var sameGraphics = cdg == nil if let graphicsHash,let stored = duplicate?.cdg,FileManager.default.fileExists(atPath:stored) { @@ -238,7 +260,10 @@ struct Importer { report("Already in library: "+audio.lastPathComponent,force:state.completed == 1);continue } let dest = copy ? media.appendingPathComponent(hash+"."+audio.pathExtension.lowercased()) : audio.standardizedFileURL - if copy { try MediaByteCopy.copy(audio,to:dest,expectedHash:hash,cancelled:cancelled) } + if copy { + report("Copying: "+audio.lastPathComponent) + try MediaByteCopy.copy(audio,to:dest,expectedHash:hash,cancelled:cancelled) + } var graphics: String? if let cdg { let target = copy ? media.appendingPathComponent(hash+"-"+(graphicsHash ?? "graphics")+".cdg") : cdg.standardizedFileURL @@ -246,12 +271,25 @@ struct Importer { graphics = target.path } imported += 1 + report("Reading tags: "+audio.lastPathComponent,force:imported == 1) let metadata = try audioMetadata(audio,cancelled:cancelled) - func tag(_ key: AVMetadataKey) -> String { metadata[key] ?? "" } + if metadata == nil { state.logLines.append("[WARNING] "+sourcePath(audio)+" (tag read timed out; imported using its filename)") } + func tag(_ key: AVMetadataKey) -> String { metadata?[key] ?? "" } let taggedTitle = tag(.commonKeyTitle) - let id = try library.add(title:taggedTitle.isEmpty ? audio.deletingPathExtension().lastPathComponent : taggedTitle,audio:dest.path,cdg:graphics,artist:tag(.commonKeyArtist),album:tag(.commonKeyAlbumName),label:tag(.commonKeyPublisher),duration:audioDuration(audio),fingerprint:hash,preferIncomingFile:copy) + let duration = audioDuration(audio) + report("Saving track: "+audio.lastPathComponent) + try library.execute("SAVEPOINT kjmix_import_track") + let id: Int64 + do { + id = try library.add(title:taggedTitle.isEmpty ? audio.deletingPathExtension().lastPathComponent : taggedTitle,audio:dest.path,cdg:graphics,artist:tag(.commonKeyArtist),album:tag(.commonKeyAlbumName),label:tag(.commonKeyPublisher),duration:duration,fingerprint:hash,preferIncomingFile:copy) + try remember(audio,signature,id) + try library.execute("RELEASE SAVEPOINT kjmix_import_track") + } catch { + try? library.execute("ROLLBACK TO SAVEPOINT kjmix_import_track") + try? library.execute("RELEASE SAVEPOINT kjmix_import_track") + throw error + } songIDs.insert(id) - try remember(audio,signature,id) state.completed += 1 state.logLines.append("[IMPORTED] "+sourcePath(audio)) if let waveformCache { @@ -274,29 +312,40 @@ struct Importer { state.finished = true;report("Import complete",force:true) return Array(songIDs) } - private func audioFingerprint(_ url: URL,cancelled: () -> Bool) throws -> String { + private func audioFingerprint(_ url: URL,cancelled: () -> Bool,progress: (Int64) -> Void = {_ in}) throws -> String { let file = try FileHandle(forReadingFrom:url);defer { try? file.close() } var hasher = SHA256() - while let data = try file.read(upToCount:1_048_576),!data.isEmpty { if cancelled() { throw CancellationError() };hasher.update(data:data) } + var bytes: Int64 = 0 + while true { + if cancelled() { throw CancellationError() } + guard let data = try file.read(upToCount:1_048_576),!data.isEmpty else { break } + hasher.update(data:data);bytes += Int64(data.count);progress(bytes) + } return hasher.finalize().map { String(format:"%02x",$0) }.joined() } /// The importer runs on its worker queue. Bridge asynchronous tag loading here /// so the existing synchronous transaction can retain its cancellation semantics. - private func audioMetadata(_ url: URL,cancelled: () -> Bool) throws -> [AVMetadataKey:String] { + private func audioMetadata(_ url: URL,cancelled: () -> Bool) throws -> [AVMetadataKey:String]? { + if cancelled() { throw CancellationError() } let result = ImportMetadataResult(),ready = DispatchSemaphore(value:0) + let asset = AVURLAsset(url:url),loader = metadataLoader let task = Task.detached(priority:.utility) { + defer { ready.signal() } + if let loader { result.set((try? await loader(url)) ?? [:]);return } var tags = [AVMetadataKey:String]() - let items = (try? await AVURLAsset(url:url).load(.commonMetadata)) ?? [] + let items = (try? await asset.load(.commonMetadata)) ?? [] for item in items { if Task.isCancelled { break } guard let key = item.commonKey,[AVMetadataKey.commonKeyTitle,.commonKeyArtist,.commonKeyAlbumName,.commonKeyPublisher].contains(key),tags[key] == nil else { continue } if let value = try? await item.load(.stringValue) { tags[key] = value } } - result.set(tags);ready.signal() + result.set(tags) } - defer { task.cancel() } - while ready.wait(timeout:.now()+0.05) == .timedOut { + defer { task.cancel();asset.cancelLoading() } + let deadline = ProcessInfo.processInfo.systemUptime+max(0.01,metadataTimeout) + while ready.wait(timeout:.now()+0.02) == .timedOut { if cancelled() { throw CancellationError() } + if ProcessInfo.processInfo.systemUptime >= deadline { return nil } } if cancelled() { throw CancellationError() } return result.get() diff --git a/KJamsClone/KJMixProApp.swift b/KJamsClone/KJMixProApp.swift index cd57f96..e75f057 100644 --- a/KJamsClone/KJMixProApp.swift +++ b/KJamsClone/KJMixProApp.swift @@ -719,6 +719,7 @@ struct ImportProgressPanel: View { var background: (() -> Void)? = nil var isMove = false var activity: String? = nil + var waveformStatus: BackgroundWaveformStatus? = 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 { if let activity { return progress.finished ? (progress.cancelled ? "Cancelled" : progress.failure == nil ? "Library Updated" : "Library Needs Attention") : activity };if isMove { return progress.finished ? (progress.cancelled ? "Move Cancelled" : progress.failure == nil ? "Move Complete" : "Move Needs Attention") : "Moving Media and Waveforms" };return progress.finished ? (progress.cancelled ? "Import Cancelled" : progress.failure == nil ? "Import Complete" : "Import Needs Attention") : "Importing Music" } @@ -755,7 +756,8 @@ struct ImportProgressPanel: View { 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,title:activity != nil ? "Library Log" : isMove ? "Transfer Log" : "Import Log").frame(height:260) + if let waveformStatus { waveformStatus } + ImportLogView(progress:progress,title:activity != nil ? "Library Log" : isMove ? "Transfer Log" : "Import Log").frame(height:waveformStatus == nil ? 260 : 200) Divider() HStack { 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) @@ -785,7 +787,8 @@ struct ImportLogView: View { var title = "Import Log" @State private var follow = true var body: some View { - let lines = progress.reportLines + let allLines = progress.reportLines + let lines = Array(allLines.suffix(300)) VStack(alignment:.leading,spacing:8) { HStack { Text(title).font(.headline) @@ -793,13 +796,14 @@ struct ImportLogView: View { 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) } } + if allLines.count > lines.count { Text("Showing the latest \(lines.count.formatted()) of \(allLines.count.formatted()) entries. Copy Log includes the full report.").font(.caption).foregroundStyle(.secondary) } 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) + Circle().fill(line.hasPrefix("[FAILED]") ? Color.red : (line.hasPrefix("[SKIPPED]") || line.hasPrefix("[WARNING]")) ? 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) } @@ -807,7 +811,7 @@ struct ImportLogView: View { }.padding(12) }.background(Color(nsColor:.textBackgroundColor),in:RoundedRectangle(cornerRadius:8)) .overlay(RoundedRectangle(cornerRadius:8).strokeBorder(.primary.opacity(0.06))) - .onStudioChange(of:lines.count) { _ in if follow,let last = lines.indices.last { reader.scrollTo(last,anchor:.bottom) } } + .onStudioChange(of:allLines.count) { _ in if follow,let last = lines.indices.last { reader.scrollTo(last,anchor:.bottom) } } } } } diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index a62a750..2742725 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -3294,3 +3294,65 @@ extension KJamsCloneTests { XCTAssertGreaterThan(player.displayTime,position+0.08) } } + +extension KJamsCloneTests { + func testDJImportContinuesAfterUnreadableFilesAndStalledTags() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let input = root.appendingPathComponent("Input");try FileManager.default.createDirectory(at:input,withIntermediateDirectories:true) + let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:1)) + for (index,name) in ["A Slow Tags","B Good","C Good","D Good"].enumerated() { + let samples = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:1024));samples.frameLength = 1024 + for frame in 0..<1024 { samples.floatChannelData![0][frame] = Float(sin(Double(frame)*Double(index+1)*0.1))*0.2 } + let file = try AVAudioFile(forWriting:input.appendingPathComponent(name+".wav"),settings:format.settings);try file.write(from:samples) + } + try Data().write(to:input.appendingPathComponent("AA Empty.wav")) + let library = try Library(url:root.appendingPathComponent("Library.sqlite")) + let importer = Importer(media:root.appendingPathComponent("Media"),audioExtensions:["wav"],includeGraphics:false,continueOnUnreadableAudio:true,metadataTimeout:0.08,metadataLoader:{ url in + if url.lastPathComponent.hasPrefix("A Slow") { try await Task.sleep(nanoseconds:10_000_000_000) } + return [.commonKeyTitle:"Tagged "+url.deletingPathExtension().lastPathComponent] + }) + var report = ImportProgress();let began = ProcessInfo.processInfo.systemUptime + let ids = try importer.run([input],library:library,copyMedia:false,progress:{report = $0}) + XCTAssertLessThan(ProcessInfo.processInfo.systemUptime-began,3,"A stalled tag read must not hold up later files") + XCTAssertEqual(ids.count,4);XCTAssertEqual(try library.songs().count,4) + XCTAssertEqual(report.total,5);XCTAssertEqual(report.completed,5);XCTAssertEqual(report.skipped,1);XCTAssertTrue(report.finished) + XCTAssertTrue(report.logLines.contains{$0.contains("tag read timed out")}) + XCTAssertTrue(report.logLines.contains{$0.contains("AA Empty.wav") && $0.hasPrefix("[SKIPPED]")}) + XCTAssertTrue(try library.songs().contains{$0.title == "A Slow Tags"});XCTAssertTrue(try library.songs().contains{$0.title == "Tagged D Good"}) + } + func testImportCancellationInterruptsStalledMetadataWait() 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 fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let library = try Library(url:root.appendingPathComponent("Library.sqlite")),began = ProcessInfo.processInfo.systemUptime + let importer = Importer(media:root.appendingPathComponent("Media"),metadataTimeout:20,metadataLoader:{ _ in try await Task.sleep(nanoseconds:10_000_000_000);return [:] }) + XCTAssertThrowsError(try importer.run([fixture],library:library,copyMedia:false,cancelled:{ProcessInfo.processInfo.systemUptime-began > 0.12})) { XCTAssertTrue($0 is CancellationError) } + XCTAssertLessThan(ProcessInfo.processInfo.systemUptime-began,1) + XCTAssertTrue(try library.songs().isEmpty) + } + @MainActor func testLargeDJImportKeepsUIResponsiveAndCompletesEveryInput() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let input = root.appendingPathComponent("Input");try FileManager.default.createDirectory(at:input,withIntermediateDirectories:true) + let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:1)) + for index in 0..<128 { + let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:1024));buffer.frameLength = 1024 + for frame in 0..<1024 { buffer.floatChannelData![0][frame] = Float(sin(Double(frame)*0.1+Double(index)))*0.2 } + let file = try AVAudioFile(forWriting:input.appendingPathComponent(String(format:"Unique %04d.wav",index)),settings:format.settings);try file.write(from:buffer) + } + for index in 0..<512 { try FileManager.default.copyItem(at:input.appendingPathComponent("Unique 0000.wav"),to:input.appendingPathComponent(String(format:"Duplicate %04d.wav",index))) } + let system = DJSystem(storageURL:root.appendingPathComponent("DJ")) + system.importFiles([input]);XCTAssertTrue(system.importing);XCTAssertTrue(system.importLogVisible) + var heartbeat = false;Task { @MainActor in heartbeat = true } + system.crossfader = 0.75 + for _ in 0..<1000 where system.importing { try await Task.sleep(nanoseconds:10_000_000) } + XCTAssertTrue(heartbeat);XCTAssertFalse(system.importing);XCTAssertEqual(system.crossfader,0.75) + let report = try XCTUnwrap(system.lastImportReport) + XCTAssertEqual(report.completed,640);XCTAssertEqual(report.total,640);XCTAssertEqual(report.skipped,512) + XCTAssertNil(report.failure);XCTAssertEqual(system.songs.count,128,"Every distinct track is saved, while repeated audio remains deduplicated") + system.automaticWaveforms.cancel() + for _ in 0..<200 where system.automaticWaveforms.isRunning { try await Task.sleep(nanoseconds:5_000_000) } + } +} diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index 502b9db..ae6c414 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -140,3 +140,7 @@ The library sidebar provides All Music, Favourites, Top Rated (four or five star EQ and filter knobs use native mouse-event click counting, so double-clicking either the knob or its label restores its defined default (0 dB for EQ and centred filter). Dragging remains vertical; arrow keys adjust the focused control and Space resets it. Resize handle positions and collection-sidebar visibility are remembered between launches. The eraser menu beside IN/OUT clears individual hot cues, all hot cues, the main cue, loop markers, or every cue/loop marker on that deck. Hot cues also support Option-click and right-click to clear. Clearing the main cue resets it to track start without seeking; clearing markers retains the loaded track and playback. Hover descriptions cover the performance controls, mixer, library actions, queue controls and cue/loop editors. + +Large DJ imports show scanning, existing-library checks, file reading, tag reading and saving stages in the import modal. Tag reads have a two-second deadline: a stalled read is cancelled, the track is imported using its filename, and the warning is retained in the log. DJ imports skip unreadable audio and continue with later tracks; destination/storage and database failures still stop the operation. Each track’s database rows and import-history records commit together, preserving completed work on cancellation. + +Import and automatic-waveform feedback is shown only in the import modal, reopened using the document icon in the DJ Studio header. The workspace no longer shows import banners or background waveform feedback. Large logs display only their latest 300 entries for responsive scrolling; Copy Log retains the complete report. Waveforms remain a separate background job after track import.