diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index adbb1ae..872995d 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -184,6 +184,8 @@ struct DJAudioDevice: Identifiable, Equatable { @Published var songs = [Song]() @Published var playlists = [PersonPlace]() @Published var importProgress: ImportProgress? + @Published var lastImportReport: ImportProgress? + @Published var importLogVisible = false @Published private(set) var importing = false private var importTask: Task? private var queuedFileLoads = [(urls:[URL],deck:String,override:Bool,song:Int64?)]() @@ -217,7 +219,7 @@ struct DJAudioDevice: Identifiable, Equatable { func dragReference(_ song: Int64) -> SongDragReference { .init(song:song,library:dragSession,singer:nil,venue:nil,fromTonight:false) } func importFiles(_ urls: [URL],loadInto: String? = nil,overridePlaying: Bool = false) { guard !importing,!urls.isEmpty else { return } - importing = true;importProgress = .init() + importing = true;importProgress = .init();importLogVisible = true let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),replacedSong = loadInto.flatMap{channel($0).song?.id} importTask = Task.detached(priority:.utility) { [weak self] in let access = urls.filter{$0.startAccessingSecurityScopedResource()} @@ -235,8 +237,9 @@ struct DJAudioDevice: Identifiable, Equatable { latest.finished = true;let result = latest,loadedIDs = importedIDs await MainActor.run { [weak self] in guard let self else { return };self.importing = false;self.importTask = nil;self.importProgress = result + self.lastImportReport = result self.perform { try self.refresh() } - if let failure = result.failure { self.error = failure } + if let failure = result.failure { if loadInto != nil,!self.importLogVisible { self.error = failure } } else if let loadInto,let song = self.songs.first(where:{loadedIDs.contains($0.id)}) { self.load(song,into:loadInto,overridePlaying:overridePlaying && self.channel(loadInto).song?.id == replacedSong) } self.continueFileLoads() } @@ -728,8 +731,8 @@ struct DJConsole: View { HStack { Text(progress.current).lineLimit(1);Text("\(progress.completed)/\(progress.total)").monospacedDigit() Spacer() - if system.importing { ProgressView().controlSize(.mini);Button("Cancel") { system.cancelImport() } } - else { Button("Dismiss") { system.importProgress = nil } } + if system.importing { ProgressView().controlSize(.mini);Button("View Progress") { system.importLogVisible = true };Button("Cancel") { system.cancelImport() } } + else { Button("View Log") { system.importLogVisible = true };Button("Dismiss") { system.importProgress = nil } } }.font(.caption).controlSize(.mini) } }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity,alignment:.top) @@ -739,6 +742,12 @@ struct DJConsole: View { Button("Cancel",role:.cancel) { system.cancelReplacement() } Button("Replace & Load",role:.destructive) { system.confirmReplacement(request) } } message: { request in Text("Deck \(request.deck) is playing. Replacing it with \(request.title) stops the current track. Continue?") } + .sheet(isPresented:$system.importLogVisible) { + if let progress = system.importing ? system.importProgress : system.lastImportReport { + ImportProgressPanel(progress:progress,studio:"DJ Studio",cancel:{ system.cancelImport() },close:{ system.importLogVisible = false },background:{ system.importLogVisible = false }) + .interactiveDismissDisabled(system.importing) + } + } .sheet(isPresented:$system.cueEditing) { DJCueEditor(system:system) } .sheet(isPresented:Binding(get:{system.loopEditor != nil},set:{if !$0 { system.loopEditor = nil }})) { let deck = system.channel(system.loopEditor ?? "A");DJLoopEditor(player:deck.player,letter:deck.letter) } .sheet(isPresented:$playlistEditor) { playlistSheet } diff --git a/KJamsClone/Importer.swift b/KJamsClone/Importer.swift index d4a3a6b..4017af1 100644 --- a/KJamsClone/Importer.swift +++ b/KJamsClone/Importer.swift @@ -11,6 +11,14 @@ struct ImportProgress: Sendable { var finished = false var cancelled = false var failure: String? + var logLines = [String]() + var reportLines: [String] { + if let failure { return logLines+["[FAILED] "+current+": "+failure] } + return logLines + } + var logText: String { + "Import results: \(completed-skipped) imported, \(skipped) skipped\n"+reportLines.joined(separator:"\n") + } } struct Importer { @@ -73,6 +81,33 @@ struct Importer { var files = discovered.sorted { $0.path < $1.path } var temporary = [URL]() defer { for url in temporary { try? FileManager.default.removeItem(at: url) } } + // Older libraries may contain indexed tracks without stored fingerprints. + // Compare only same-size candidates, and hash each legacy file at most once. + var legacyBySize = [UInt64:[Song]]() + for song in try library.songs() where song.fingerprint.isEmpty { + if let info = try? FileManager.default.attributesOfItem(atPath:song.audio),let size = info[.size] as? NSNumber,size.uint64Value > 0 { + legacyBySize[size.uint64Value,default:[]].append(song) + } + } + var legacyHashes = [Int64:String]() + func duplicateWithFingerprint(_ hash: String,audio: URL) throws -> Song? { + if let song = try library.songWithFingerprint(hash) { return song } + let size = (try FileManager.default.attributesOfItem(atPath:audio.path)[.size] as? NSNumber)?.uint64Value ?? 0 + for song in legacyBySize[size] ?? [] { + if cancelled() { throw CancellationError() } + let candidate: String + if let known = legacyHashes[song.id] { candidate = known } + else { + do { candidate = try audioFingerprint(URL(fileURLWithPath:song.audio),cancelled:cancelled) } + catch is CancellationError { throw CancellationError() } + catch { continue } // An offline legacy entry must not prevent a new import. + legacyHashes[song.id] = candidate + try library.execute("UPDATE songs SET fingerprint=? WHERE id=? AND fingerprint=''",[candidate,String(song.id)]) + } + if candidate == hash { return song } + } + return nil + } var songIDs = Set() var archives = [(url:URL,signature:String,folder:URL)]() var archiveForFile = [String:URL]() @@ -85,13 +120,14 @@ struct Importer { } return try signature(audio)+"|"+(cdg.map { try signature($0) } ?? "no-cdg")+"|copy=\(copyMedia)" } - func remember(_ audio:URL,_ signature:String,_ id:Int64) throws { - var originalPath = audio.path + func sourcePath(_ audio: URL) -> String { if let folder = archiveForFile[audio.resolvingSymlinksInPath().path],let zip = archiveOrigins[folder] { - let relative = String(audio.resolvingSymlinksInPath().path.dropFirst(folder.resolvingSymlinksInPath().path.count+1)) - originalPath = zip.path+"/"+relative + return zip.path+"/"+String(audio.resolvingSymlinksInPath().path.dropFirst(folder.resolvingSymlinksInPath().path.count+1)) } - try library.recordOriginalName(song:id,name:audio.deletingPathExtension().lastPathComponent,path:originalPath) + return audio.path + } + func remember(_ audio:URL,_ signature:String,_ id:Int64) throws { + try library.recordOriginalName(song:id,name:audio.deletingPathExtension().lastPathComponent,path:sourcePath(audio)) if let archive = archiveForFile[audio.resolvingSymlinksInPath().path] { archiveSongIDs[archive,default:[]].append(id) } else { try library.rememberImport(path:audio.standardizedFileURL.path,signature:signature,songs:[id]) } } @@ -100,6 +136,9 @@ struct Importer { let signature = try importSignature(zip) if let existing = try library.importedSongs(path:zip.standardizedFileURL.path,signature:signature),try library.originalNamesRecorded(existing) { songIDs.formUnion(existing);state.completed += existing.count;state.skipped += existing.count + for id in existing { + state.logLines.append("[SKIPPED] "+zip.path+" → "+((try library.song(id))?.title ?? String(id))+" (already imported)") + } report("Already imported: "+zip.lastPathComponent,force:state.completed == existing.count);continue } report("Extracting "+zip.lastPathComponent,force:archives.isEmpty) @@ -178,20 +217,22 @@ struct Importer { songIDs.formUnion(existing) for id in existing { try remember(audio,signature,id) } state.completed += 1;state.skipped += 1 + state.logLines.append("[SKIPPED] "+sourcePath(audio)+" (already imported)") report("Already imported: "+audio.lastPathComponent,force:state.completed == 1);continue } guard MediaByteCopy.nonempty(audio) else { throw LibraryError.message("Audio file is empty or unreadable: "+audio.path) } let hash = try audioFingerprint(audio,cancelled:cancelled) let graphicsHash = try cdg.map { try audioFingerprint($0,cancelled:cancelled) } - let duplicate = try library.songWithFingerprint(hash) + let duplicate = try duplicateWithFingerprint(hash,audio:audio) var sameGraphics = cdg == nil if let graphicsHash,let stored = duplicate?.cdg,FileManager.default.fileExists(atPath:stored) { sameGraphics = try audioFingerprint(URL(fileURLWithPath:stored),cancelled:cancelled) == graphicsHash } if let existing = duplicate,sameGraphics,MediaByteCopy.nonempty(URL(fileURLWithPath:existing.audio)), - (!copy || URL(fileURLWithPath:existing.audio).deletingLastPathComponent().standardizedFileURL == media.standardizedFileURL) { + (archive == nil || !copy || URL(fileURLWithPath:existing.audio).deletingLastPathComponent().standardizedFileURL == media.standardizedFileURL) { try remember(audio,signature,existing.id);songIDs.insert(existing.id) state.completed += 1;state.skipped += 1 + state.logLines.append("[SKIPPED] "+sourcePath(audio)+" (identical content already in library)") report("Already in library: "+audio.lastPathComponent,force:state.completed == 1);continue } let dest = copy ? media.appendingPathComponent(hash+"."+audio.pathExtension.lowercased()) : audio.standardizedFileURL @@ -210,6 +251,7 @@ struct Importer { songIDs.insert(id) try remember(audio,signature,id) state.completed += 1 + state.logLines.append("[IMPORTED] "+sourcePath(audio)) report(audio.lastPathComponent,force:state.completed == 1) } for archive in archives { diff --git a/KJamsClone/KJMixProApp.swift b/KJamsClone/KJMixProApp.swift index 9a085bb..4f49962 100644 --- a/KJamsClone/KJMixProApp.swift +++ b/KJamsClone/KJMixProApp.swift @@ -587,6 +587,9 @@ private struct ImportProgressDialog: View { ZStack { Color.black.opacity(0.16).ignoresSafeArea().contentShape(Rectangle()).onTapGesture { } if let progress = storageMode ? model.storageProgress : model.importProgress { + if !storageMode { + ImportProgressPanel(progress:progress,studio:"Karaoke",cancel:{ model.cancelImport() },close:{ model.dismissImport() }) + } else { VStack(alignment:.leading,spacing:16) { HStack(spacing:12) { Image(systemName:progress.finished ? (progress.failure == nil ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") : "square.and.arrow.down") @@ -600,12 +603,112 @@ private struct ImportProgressDialog: View { else if !progress.finished { ProgressView().controlSize(.small) } if progress.completed > 0 && !storageMode { Text("\(progress.completed-progress.skipped) imported · \(progress.skipped) already in library").font(.callout.weight(.medium)) } Text(progress.failure ?? progress.current).font(.callout).foregroundStyle(.secondary).lineLimit(3).frame(maxWidth:.infinity,alignment:.leading) + if progress.finished,!storageMode { ImportLogView(progress:progress).frame(height:240) } if progress.cancelled || progress.failure != nil { Text(storageMode ? "Original files are kept until their copies and library links are verified." : "Tracks already imported are saved in your library.").font(.caption).foregroundStyle(.secondary) } HStack { Spacer();if progress.finished { Button("Done") { if storageMode { model.dismissMediaMove() } else { model.dismissImport() } }.keyboardShortcut(.defaultAction) } else { Button(storageMode ? "Cancel Move" : "Cancel Import") { if storageMode { model.cancelMediaMove() } else { model.cancelImport() } }.keyboardShortcut(.cancelAction) } } - }.padding(24).frame(width:380).background(.regularMaterial,in:RoundedRectangle(cornerRadius:14)) + }.padding(24).frame(width:storageMode || !progress.finished ? 380 : 660).background(.regularMaterial,in:RoundedRectangle(cornerRadius:14)) .overlay(RoundedRectangle(cornerRadius:14).strokeBorder(.primary.opacity(0.08))) .shadow(color:.black.opacity(0.2),radius:24,y:8) .accessibilityElement(children:.contain).accessibilityLabel(storageMode ? "Media move progress" : "Music import progress") + } + } + } + } +} +struct ImportProgressPanel: View { + let progress: ImportProgress + let studio: String + let cancel: () -> Void + let close: () -> Void + var background: (() -> Void)? = nil + private var fraction: Double { progress.total > 0 ? min(1,max(0,Double(progress.completed)/Double(progress.total))) : 0 } + private var accent: Color { progress.failure != nil || progress.cancelled ? .orange : progress.finished ? .green : .cyan } + private var title: String { progress.finished ? (progress.cancelled ? "Import Cancelled" : progress.failure == nil ? "Import Complete" : "Import Needs Attention") : "Importing Music" } + var body: some View { + VStack(alignment:.leading,spacing:20) { + HStack(spacing:14) { + Image(systemName:progress.finished ? (progress.failure != nil || progress.cancelled ? "exclamationmark.circle.fill" : "checkmark.circle.fill") : "square.and.arrow.down.fill") + .font(.system(size:26,weight:.medium)).foregroundStyle(accent).frame(width:52,height:52) + .background(accent.opacity(0.12),in:RoundedRectangle(cornerRadius:12)) + VStack(alignment:.leading,spacing:4) { + Text(studio.uppercased()+" LIBRARY").font(.system(size:10,weight:.semibold)).tracking(1.4).foregroundStyle(.secondary) + Text(title).font(.system(size:22,weight:.semibold)) + } + Spacer() + if !progress.finished { ProgressView().controlSize(.small).tint(accent) } + } + VStack(alignment:.leading,spacing:9) { + HStack { + Text(progress.total > 0 ? "\(progress.completed.formatted()) of \(progress.total.formatted()) tracks checked" : "Scanning files and folders…").font(.callout) + Spacer() + Text(progress.total > 0 ? "\(Int(fraction*100))%" : "Preparing").font(.callout.weight(.semibold)).monospacedDigit().foregroundStyle(accent) + } + GeometryReader { proxy in + ZStack(alignment:.leading) { + Capsule().fill(.primary.opacity(0.08)) + Capsule().fill(LinearGradient(colors:[accent.opacity(0.65),accent],startPoint:.leading,endPoint:.trailing)) + .frame(width:progress.total > 0 ? proxy.size.width*fraction : proxy.size.width*0.08) + } + }.frame(height:9).accessibilityLabel("Import progress").accessibilityValue("\(Int(fraction*100)) percent") + Text(progress.failure ?? progress.current).font(.system(size:11)).foregroundStyle(.secondary).lineLimit(2).frame(height:30,alignment:.topLeading).frame(maxWidth:.infinity,alignment:.leading) + } + HStack(spacing:12) { + count("Imported",value:max(0,progress.completed-progress.skipped),color:.green) + count("Skipped",value:progress.skipped,color:.orange) + count("Remaining",value:progress.total > 0 ? max(0,progress.total-progress.completed) : -1,color:.cyan) + } + ImportLogView(progress:progress).frame(height:260) + Divider() + HStack { + Text(progress.finished ? "Review the log or copy it before closing." : "You can cancel safely; completed tracks stay in your library.").font(.caption).foregroundStyle(.secondary) + Spacer() + if progress.finished { + Button("Done",action:close).buttonStyle(.borderedProminent).tint(accent).keyboardShortcut(.defaultAction) + } else { + if let background { Button("Run in Background",action:background) } + Button("Cancel Import",action:cancel).keyboardShortcut(.cancelAction) + } + } + }.padding(26).frame(width:660) + .background(Color(nsColor:.windowBackgroundColor),in:RoundedRectangle(cornerRadius:16)) + .overlay(RoundedRectangle(cornerRadius:16).strokeBorder(.primary.opacity(0.1))) + .shadow(color:.black.opacity(0.2),radius:24,y:8) + .accessibilityElement(children:.contain).accessibilityLabel(studio+" import progress") + } + private func count(_ title: String,value: Int,color: Color) -> some View { + VStack(alignment:.leading,spacing:5) { + Text(value >= 0 ? value.formatted() : "—").font(.system(size:22,weight:.semibold,design:.rounded)).monospacedDigit().foregroundStyle(color) + Text(title).font(.caption).foregroundStyle(.secondary) + }.frame(maxWidth:.infinity,alignment:.leading).padding(12).background(color.opacity(0.07),in:RoundedRectangle(cornerRadius:8)) + } +} +struct ImportLogView: View { + let progress: ImportProgress + @State private var follow = true + var body: some View { + let lines = progress.reportLines + VStack(alignment:.leading,spacing:8) { + HStack { + Text("Import Log").font(.headline) + Spacer() + if !progress.finished { Toggle("Follow log",isOn:$follow).toggleStyle(.checkbox).controlSize(.small) } + Button("Copy Log") { NSPasteboard.general.clearContents();NSPasteboard.general.setString(progress.logText,forType:.string) } + } + ScrollViewReader { reader in + ScrollView { + LazyVStack(alignment:.leading,spacing:7) { + ForEach(lines.indices,id:\.self) { index in + let line = lines[index] + HStack(alignment:.top,spacing:8) { + Circle().fill(line.hasPrefix("[FAILED]") ? Color.red : line.hasPrefix("[SKIPPED]") ? Color.orange : Color.green).frame(width:5,height:5).padding(.top,5) + Text(line).font(.system(size:11,design:.monospaced)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) + }.id(index) + } + if lines.isEmpty { Text(progress.finished ? "No tracks processed." : "Waiting for the first track…").font(.callout).foregroundStyle(.secondary) } + }.padding(12) + }.background(Color(nsColor:.textBackgroundColor),in:RoundedRectangle(cornerRadius:8)) + .overlay(RoundedRectangle(cornerRadius:8).strokeBorder(.primary.opacity(0.06))) + .onChange(of:lines.count) { _ in if follow,let last = lines.indices.last { reader.scrollTo(last,anchor:.bottom) } } } } } diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index 28f28d5..194ec71 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -2540,3 +2540,87 @@ extension KJamsCloneTests { XCTAssertLessThan(inside.redComponent*inside.blueComponent,0.99,"Spectrum must still paint inside its bounds") } } + + +extension KJamsCloneTests { + func testImportSkipsRenamedLegacyContentAndLogsEachResultWithoutMatchingTitles() 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 fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let original = root.appendingPathComponent("Original.mp3"),renamed = root.appendingPathComponent("Renamed.mp3"),different = root.appendingPathComponent("Different.mp3") + try FileManager.default.copyItem(at:fixtures.appendingPathComponent("Demo.mp3"),to:original) + try FileManager.default.copyItem(at:original,to:renamed) + try FileManager.default.copyItem(at:fixtures.appendingPathComponent("TestTrack.mp3"),to:different) + let db = try Library(url:root.appendingPathComponent("db.sqlite")),legacy = try db.add(title:"My saved title",audio:original.path,cdg:nil) + try db.rate(legacy,4) + var result = ImportProgress() + let importer = Importer(media:root.appendingPathComponent("Media")) + let ids = try importer.run([renamed,different],library:db,progress:{ result = $0 }) + XCTAssertEqual(ids.count,2);XCTAssertEqual(try db.songs().count,2) + XCTAssertEqual(result.skipped,1);XCTAssertEqual(result.completed,2) + XCTAssertTrue(result.logLines.contains{$0.hasPrefix("[SKIPPED]") && $0.contains("Renamed.mp3")}) + XCTAssertTrue(result.logLines.contains{$0.hasPrefix("[IMPORTED]") && $0.contains("Different.mp3")}) + XCTAssertEqual(try db.song(legacy)?.title,"My saved title");XCTAssertEqual(try db.song(legacy)?.rating,4) + XCTAssertEqual(try db.song(legacy)?.fingerprint.count,64) + _ = try importer.run([renamed,different],library:db,progress:{ result = $0 }) + XCTAssertEqual(result.skipped,2);XCTAssertEqual(result.logLines.count,2) + XCTAssertTrue(result.logLines.allSatisfy{$0.hasPrefix("[SKIPPED]")}) + } + func testReimportFromAnotherFolderSkipsExistingMediaAndReturnsItsID() 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 first = root.appendingPathComponent("First.mp3"),second = root.appendingPathComponent("Second.mp3") + try FileManager.default.copyItem(at:fixture,to:first);try FileManager.default.copyItem(at:fixture,to:second) + let db = try Library(url:root.appendingPathComponent("db.sqlite")) + let ids = try Importer(media:root.appendingPathComponent("MediaA")).run([first],library:db) + var report = ImportProgress() + let repeated = try Importer(media:root.appendingPathComponent("MediaB")).run([second],library:db,progress:{report = $0}) + XCTAssertEqual(repeated,ids);XCTAssertEqual(try db.songs().count,1);XCTAssertEqual(report.skipped,1) + XCTAssertTrue(try FileManager.default.contentsOfDirectory(atPath:root.appendingPathComponent("MediaB").path).isEmpty) + XCTAssertTrue(report.logText.contains("0 imported, 1 skipped"));XCTAssertTrue(report.logText.contains(second.path)) + } + @MainActor func testImportReportIncludesSavedTracksAndFailureAfterPartialImport() async 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 good = root.appendingPathComponent("A Good.mp3"),bad = root.appendingPathComponent("Z Empty.mp3") + try FileManager.default.copyItem(at:fixture,to:good);try Data().write(to:bad) + let model = AppModel(storageURL:root.appendingPathComponent("App")) + model.importFiles([good,bad]) + for _ in 0..<200 where model.isImporting { try await Task.sleep(nanoseconds:20_000_000) } + let report = try XCTUnwrap(model.importProgress) + XCTAssertTrue(report.finished);XCTAssertNotNil(report.failure);XCTAssertEqual(report.completed,1) + XCTAssertTrue(report.reportLines.contains{$0.hasPrefix("[IMPORTED]") && $0.contains("A Good.mp3")}) + XCTAssertTrue(report.reportLines.contains{$0.hasPrefix("[FAILED]") && $0.contains("Z Empty.mp3")}) + } +} + + +extension KJamsCloneTests { + @MainActor func testDJImportShowsLiveModalAndBackgroundChoiceSurvivesCompletion() 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 fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + for i in 0..<48 { try FileManager.default.copyItem(at:fixture,to:input.appendingPathComponent("Track \(i).mp3")) } + let system = DJSystem(storageURL:root.appendingPathComponent("DJ")) + system.importFiles([input]) + XCTAssertTrue(system.importing);XCTAssertTrue(system.importLogVisible) + XCTAssertNotNil(system.importProgress) + // Main-actor controls remain available while the worker scans/copies. + system.importLogVisible = false + system.crossfader = 0.7 + XCTAssertEqual(system.crossfader,0.7) + for _ in 0..<300 where system.importing { try await Task.sleep(nanoseconds:20_000_000) } + XCTAssertFalse(system.importing);XCTAssertFalse(system.importLogVisible,"Run in Background must not force the modal open again at completion") + let report = try XCTUnwrap(system.lastImportReport) + XCTAssertTrue(report.finished);XCTAssertNil(report.failure) + XCTAssertEqual(report.completed,48);XCTAssertEqual(report.skipped,47) + XCTAssertEqual(report.reportLines.count,48);XCTAssertEqual(system.songs.count,1) + } +} diff --git a/README.md b/README.md index 1569466..d0668f9 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Catalogue… supports local JSON import/reload, saved catalogue browsing, search ## Local playback, waveform and external drives -Import MP3/WAV/M4A, same-basename CDG pairs, MP3+G ZIPs or MP4/M4V/MOV with macOS-decodable audio/video. Choose files or a top-level folder in either Import picker; folders are scanned recursively, including nested ZIPs. Keep MP3 and CDG together in the same folder (matching is case-insensitive). Overlapping folder/file selections are checked once. Hidden items, packages, symlinks and the active managed media folder are skipped during folder scans. Scanning and importing run in the background with progress and cancellation. An unreadable input or invalid archive stops the job with an error; completed imports remain saved for a retry. Direct imports copy managed media; registered locations reference regular files in place. Exact content of the same extension is deduplicated. ZIP entries are validated against traversal/symlinks and extracted media is copied for playback. +Import MP3/WAV/M4A, same-basename CDG pairs, MP3+G ZIPs or MP4/M4V/MOV with macOS-decodable audio/video. Choose files or a top-level folder in either Import picker; folders are scanned recursively, including nested ZIPs. Keep MP3 and CDG together in the same folder (matching is case-insensitive). Overlapping folder/file selections are checked once. Hidden items, packages, symlinks and the active managed media folder are skipped during folder scans. Scanning and importing run in the background with progress and cancellation. An unreadable input or invalid archive stops the job with an error; completed imports remain saved for a retry. Direct imports copy managed media; registered locations reference regular files in place. Already imported sources and identical audio content are skipped, including renamed or moved copies. Legacy entries without fingerprints are checked against same-size files and indexed for future duplicate detection. Missing or damaged managed files can still be repaired by reimporting. Finished karaoke imports show a per-track log of imports, skips and failures, with Copy Log; cancelled or failed jobs retain their partial log. ZIP entries are validated against traversal/symlinks and extracted media is copied for playback. Library Locations recursively scans mounted drives/folders, indexes supported media and prepares cached 4096-bin RMS/frequency waveforms in the background. Cancel keeps completed work. Rescan reuses unchanged entries/caches; Rebuild Waveforms forces analysis. Offline sources retain their catalogue; removing a location keeps songs/media. Reconnect the drive to play referenced files. Results refresh at completion; there is no filesystem watcher/scheduled rescan. diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index 7a51bac..b02b8e1 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -75,3 +75,5 @@ Attached-interface checks verify available output devices and AUHAL stereo chann Latest validation: **147 tests executed, zero failures, one attached-interface check skipped because the user confirmed the Numark was unplugged**. Earlier attached-interface runs verified the four-channel AUHAL maps; native menu selection and live binding changes are also tested independently of hardware. Spectrum scrolling caches source-aligned image sections and translates them by fractional pixels as playback advances. On macOS 14+ a view display link schedules refreshes with the current display; macOS 13 uses a timer. Beat and loop markers stay aligned to the same audio-clock position. Cached images are rebuilt after waveform analysis, resize, zoom or colour changes. + +Karaoke and DJ imports show a live progress panel with a coloured progress bar, imported/skipped/remaining totals, the current file and a selectable, copyable log. DJ imports, including Finder drops, offer Run in Background; View Progress reopens the panel, and View Log shows the completed report. Scanning, hashing, copying and metadata reads run on a utility background worker with a separate database connection; interface progress updates are throttled to about ten per second. Cancelling retains completed tracks. Duplicate detection reuses existing track IDs, including identical files renamed or selected from another folder; a repeated drop can still load that track into either deck. Different songs are never considered duplicates solely because their titles match.