From d5b24bff2987beb3073a50e2441ac2f5a8a28c4f Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Thu, 5 Nov 2026 13:01:47 +0000 Subject: [PATCH] broken changes --- KJamsClone/DJWorkspace.swift | 25 ++++++++++++- KJamsClone/KJMixProApp.swift | 17 +++++++-- KJamsClone/Metadata.swift | 2 +- KJamsCloneTests/KJamsCloneTests.swift | 53 +++++++++++++++++++++++++++ docs/DJ_WORKSTATION.md | 10 +++++ work/repair-empty-dj-media.py | 48 ++++++++++++++++++++++++ 6 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 work/repair-empty-dj-media.py diff --git a/KJamsClone/DJWorkspace.swift b/KJamsClone/DJWorkspace.swift index b31dd19..27d8e5d 100644 --- a/KJamsClone/DJWorkspace.swift +++ b/KJamsClone/DJWorkspace.swift @@ -268,13 +268,14 @@ struct DJDeckControls: View { } struct StudioSettingsPane: View { + var showHeading = true @AppStorage("studioAppearance") private var appearance = "system" @ObservedObject var model: AppModel @State private var port = "" var body: some View { ScrollView { VStack(alignment:.leading,spacing:22) { - HStack { VStack(alignment:.leading,spacing:4) { Text("Studio Settings").font(.title2.bold());Text("Changes save automatically on this Mac.").foregroundStyle(.secondary) };Spacer() } + if showHeading { HStack { VStack(alignment:.leading,spacing:4) { Text("Studio Settings").font(.title2.bold());Text("Changes save automatically on this Mac.").foregroundStyle(.secondary) };Spacer() } } GroupBox("Appearance") { HStack { Picker("Appearance",selection:$appearance) { @@ -608,3 +609,25 @@ struct RotationSongBrowser: View { .onAppear { if model.selectedSinger == nil { model.selectedSinger = model.venueSingers.first?.id } } } } + +struct StudioSettingsDialog: View { + @ObservedObject var model: AppModel + @Environment(\.dismiss) private var dismiss + var body: some View { + VStack(spacing:0) { + HStack(spacing:14) { + Image(systemName:"slider.horizontal.3").font(.title2).foregroundStyle(Color.accentColor) + VStack(alignment:.leading,spacing:4) { + Text("Studio Settings").font(.title2.bold()) + Text("Karaoke playback, background music and hosting").font(.callout).foregroundStyle(.secondary) + } + Spacer() + }.padding(24) + Divider() + StudioSettingsPane(showHeading:false,model:model) + Divider() + HStack { Text("Changes save automatically on this Mac").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }.padding(.horizontal,24).padding(.vertical,16) + }.frame(width:760,height:680).background(Color(nsColor:.windowBackgroundColor)) + .alert("Settings",isPresented:Binding(get:{model.error != nil},set:{if !$0 { model.error = nil }})) { Button("OK") { model.error = nil } } message: { Text(model.error ?? "") } + } +} diff --git a/KJamsClone/KJMixProApp.swift b/KJamsClone/KJMixProApp.swift index 6e0f186..f51684b 100644 --- a/KJamsClone/KJMixProApp.swift +++ b/KJamsClone/KJMixProApp.swift @@ -674,6 +674,7 @@ struct LibraryView: View { @ObservedObject var model: AppModel @State var showDJ = true @State private var djSongSettings = false + @State private var studioSettingsModal = false @State private var selected: Int64? @State private var tableSelection = Set() @State private var waveformDetails = false @@ -738,7 +739,15 @@ struct LibraryView: View { Spacer() if showDJ,["Library","Singer Songs","Playlists"].contains(source) { Button("Song Settings") { djSongSettings = true } - .popover(isPresented:$djSongSettings) { detailPanel.frame(width:900,height:135).padding(12) } + .sheet(isPresented:$djSongSettings) { + VStack(spacing:0) { + HStack { VStack(alignment:.leading,spacing:4) { Text("Song Settings").font(.title2.bold());Text(selectedSong?.title ?? "Select a karaoke track to adjust its settings").font(.callout).foregroundStyle(.secondary) };Spacer() }.padding(24) + Divider() + detailPanel.frame(height:150).padding(16) + Divider() + HStack { Text("Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { djSongSettings = false }.keyboardShortcut(.defaultAction) }.padding(20) + }.frame(width:920).background(Color(nsColor:.windowBackgroundColor)) + } } if source == "Library" { Button { importing = true } label: { Label("Import Music",systemImage:"plus") }.buttonStyle(.bordered) @@ -774,12 +783,14 @@ struct LibraryView: View { .background(StudioTheme.frame) .tint(.accentColor) .focusedSceneValue(\.nameReinterpretation,NameReinterpretationCommands(perform:{beginReinterpretation()},enabled:["Library","Singer Songs"].contains(source) && (selectedSong != nil || !tableSelection.isEmpty))) - .focusedSceneValue(\.libraryCommands,LibraryCommands(importMusic:{importing = true},songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")},settings:{source = "Settings"})) + .focusedSceneValue(\.libraryCommands,LibraryCommands(importMusic:{importing = true},songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")},settings:{studioSettingsModal = true})) + .sheet(isPresented:$studioSettingsModal) { StudioSettingsDialog(model:model) } .navigationTitle(source == "Singer Songs" ? (model.singers.first { $0.id == model.selectedSinger }?.name ?? "Singer") : source) .navigationSubtitle(model.currentVenueName) .searchable(text:$search,placement:.toolbar,prompt:"Search Music") .toolbar { ToolbarItemGroup { + Button { studioSettingsModal = true } label: { Label("Settings",systemImage:"gearshape") } Button { showDJ.toggle() } label: { Label(showDJ ? "Hide DJ" : "Show DJ",systemImage:"waveform.path") }.help("Show or hide the DJ workspace; playback continues") Menu { Button("Import Files or Folder…") { importing = true } @@ -924,7 +935,7 @@ struct LibraryView: View { navigationRow("ShowScreens",symbol:"rectangle.on.rectangle") navigationRow("Singer Web",symbol:"network") navigationRow("Music Stores",symbol:"bag") - navigationRow("Settings",symbol:"gearshape") + Button { studioSettingsModal = true } label: { Label("Settings…",systemImage:"gearshape").frame(maxWidth:.infinity,alignment:.leading).padding(9) }.buttonStyle(.plain) }.font(.body).listRowSeparator(.hidden).listRowInsets(EdgeInsets(top:1,leading:8,bottom:1,trailing:8)) }.listStyle(.sidebar) Divider().padding(.horizontal,14) diff --git a/KJamsClone/Metadata.swift b/KJamsClone/Metadata.swift index b82b85a..dc51026 100644 --- a/KJamsClone/Metadata.swift +++ b/KJamsClone/Metadata.swift @@ -96,7 +96,7 @@ enum FileTagWriter { guard exporter.supportedFileTypes.contains(type) else { throw LibraryError.message("This container cannot preserve its media during tag export") } exporter.outputURL = temp;exporter.outputFileType = type let keys: [AVMetadataKey] = [.commonKeyTitle,.commonKeyArtist,.commonKeyAlbumName,.commonKeyPublisher] - var items = asset.metadata.filter{!keys.contains($0.commonKey ?? AVMetadataKey(rawValue:""))} + var items = try await asset.load(.metadata).filter{!keys.contains($0.commonKey ?? AVMetadataKey(rawValue:""))} for (identifier,value) in [(AVMetadataIdentifier.commonIdentifierTitle,song.title),(.commonIdentifierArtist,song.artist),(.commonIdentifierAlbumName,song.album),(.commonIdentifierPublisher,song.label)] { let item = AVMutableMetadataItem();item.identifier = identifier;item.value = value as NSString;items.append(item) } diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index b0d366a..febd7ca 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -1733,6 +1733,14 @@ extension KJamsCloneTests { if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:"/private/tmp/kjmix-dj-native.png")) } XCTAssertEqual(model.dj.a.player.currentSongID,id);XCTAssertEqual(model.dj.b.player.currentSongID,id) XCTAssertFalse(model.dj.a.player.playing);XCTAssertNil(model.player.currentSongID) + let settings = NSHostingView(rootView:StudioSettingsDialog(model:model).preferredColorScheme(.dark)) + settings.frame = NSRect(x:0,y:0,width:760,height:680) + let settingsWindow = NSWindow(contentRect:settings.frame,styleMask:[.borderless],backing:.buffered,defer:false) + settingsWindow.appearance = NSAppearance(named:.darkAqua);settingsWindow.contentView = settings + settingsWindow.setFrameOrigin(NSPoint(x:-10000,y:-10000));settingsWindow.orderFront(nil) + defer { settingsWindow.orderOut(nil);settingsWindow.contentView = nil } + try await Task.sleep(nanoseconds:200_000_000);settings.layoutSubtreeIfNeeded() + if let rep = settings.bitmapImageRepForCachingDisplay(in:settings.bounds) { settings.cacheDisplay(in:settings.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:"/private/tmp/kjmix-settings-native.png")) } } } @@ -1799,3 +1807,48 @@ extension KJamsCloneTests { XCTAssertNotEqual(model.dragReference(track.id).library,model.dj.dragReference(track.id).library) } } + +extension KJamsCloneTests { + func testMediaByteCopyRepairsPartialFileWithoutCopyingSourcePermissions() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let source = root.appendingPathComponent("Read-only original.mp3"),destination = root.appendingPathComponent("Managed.mp3") + let bytes = Data(repeating:0x42,count:2_200_000);try bytes.write(to:source) + try FileManager.default.setAttributes([.posixPermissions:0o444],ofItemAtPath:source.path) + try Data().write(to:destination) + let hash = try MediaByteCopy.digest(source) + try MediaByteCopy.copy(source,to:destination,expectedHash:hash) + XCTAssertEqual(try Data(contentsOf:destination),bytes) + XCTAssertTrue(FileManager.default.isWritableFile(atPath:destination.path)) + XCTAssertEqual(try FileManager.default.attributesOfItem(atPath:source.path)[.posixPermissions] as? NSNumber,NSNumber(value:0o444)) + XCTAssertThrowsError(try MediaByteCopy.copy(source,to:destination,expectedHash:"wrong")) + XCTAssertEqual(try Data(contentsOf:destination),bytes,"A failed verification must preserve the existing destination") + XCTAssertThrowsError(try MediaByteCopy.copy(source,to:root.appendingPathComponent("Cancelled.mp3"),expectedHash:hash,cancelled:{true})) + XCTAssertFalse(FileManager.default.fileExists(atPath:root.appendingPathComponent("Cancelled.mp3").path)) + XCTAssertFalse(try FileManager.default.contentsOfDirectory(atPath:root.path).contains{$0.hasPrefix(".kjmix-import-")}) + } + func testRetryImportRepairsEmptyRegisteredAudioAndGraphicsCopies() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let db = try Library(url:root.appendingPathComponent("Library.sqlite")),importer = Importer(media:root.appendingPathComponent("Media")) + let ids = try importer.run([fixture],library:db),song = try XCTUnwrap(try db.songs().first) + try Data().write(to:URL(fileURLWithPath:song.audio)) + if let graphics = song.cdg { try Data().write(to:URL(fileURLWithPath:graphics)) } + XCTAssertEqual(try importer.run([fixture],library:db),ids) + XCTAssertEqual(try db.songs().count,1) + XCTAssertEqual(try MediaByteCopy.digest(URL(fileURLWithPath:song.audio)),try MediaByteCopy.digest(fixture)) + if let graphics = song.cdg { XCTAssertTrue(MediaByteCopy.nonempty(URL(fileURLWithPath:graphics))) } + } + @MainActor func testEmptyAudioShowsRepairInstructionInsteadOfCoreAudioError() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let file = root.appendingPathComponent("Empty.mp3");try Data().write(to:file) + let player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms"),persistsMix:false) + XCTAssertThrowsError(try player.load(.init(id:1,title:"Empty",audio:file.path,cdg:nil,rating:0,plays:0),startPlaying:false)) { error in XCTAssertTrue(error.localizedDescription.contains("Reimport the original media")) } + XCTAssertFalse(player.playing);XCTAssertNil(player.currentSongID) + } +} diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index 370869e..794ed41 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -48,3 +48,13 @@ Select the controller’s CoreMIDI input in MIDI Mapping, choose an action, clic - Existing karaoke rotation, singer navigation, recursive CDG/audio import, background handoff/fades and offline EQ processing tests passed. Fixed-coordinate rotation UI tests were updated for the background strip’s new position. - Captured and visually inspected the actual NSHostingView macOS interface at 1320 × 1100 points, with separate karaoke and DJ fixture records and loaded background audio. This is a native app capture, not the HTML design preview. - Physical Numark controls, controller LEDs and separate physical audio outputs have not been exercised. Same-device master/cue channel pairs are not implemented. BPM/beat grids are not automatically analysed, and loops/jogs use seek scheduling rather than sample-accurate scratching. + +## Import repair and settings modal + +Media imports now stream audio/CDG content into a private staging file, verify its SHA-256, and atomically rename it into place. Source permissions, ACLs and protected security-scope metadata are not copied. Failed or cancelled transfers remove staging files and never publish a partial destination. Existing empty destinations are not accepted as completed imports; retrying repairs them. Empty playback files show a reimport instruction instead of the raw AVAudioFile error. + +The three zero-byte files observed in the local DJ media directory were repaired from checksum-matched originals. Their sources and library records were preserved; macOS afinfo successfully recognised all three repaired MP3s. The interrupted, unregistered item will be registered when its folder is imported again. + +Studio settings open as a dedicated modal from the toolbar, sidebar or Command-comma. Song Settings also opens as a titled modal with a Done action. The deprecated AVAsset metadata read in FileTagWriter now uses asynchronous loading. + +Verification after these changes: Debug build succeeds; **116 tests pass with zero failures**, including partial-copy repair, source permission preservation, failed-verification/cancellation cleanup and readable empty-audio errors. Native settings modal capture was inspected. diff --git a/work/repair-empty-dj-media.py b/work/repair-empty-dj-media.py new file mode 100644 index 0000000..b2c4d4a --- /dev/null +++ b/work/repair-empty-dj-media.py @@ -0,0 +1,48 @@ +import hashlib +import os +from pathlib import Path +import sqlite3 +import sys +import tempfile + +root = Path.home() / 'Library/Application Support/KJamsClone/DJ' +media = root / 'Media' +def digest(path): + h = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + h.update(chunk) + return h.hexdigest() +with sqlite3.connect(f'file:{root / "Library.sqlite"}?mode=ro', uri=True) as db: + originals = [Path(row[0]) for row in db.execute('select original_path from songs') if row[0]] +# Include the failed, unregistered source only within the same explicitly imported folder. +folders = {p.parent for p in originals} +sources = set(originals) +for folder in folders: + sources.update(p for p in folder.iterdir() if p.is_file() and p.suffix.lower() == '.mp3') +repairs = [] +for source in sorted(sources): + if not source.is_file() or source.stat().st_size == 0: + continue + expected = digest(source) + target = media / (expected + source.suffix.lower()) + if target.is_file() and target.stat().st_size == 0: + repairs.append((source, target, expected)) +print(f'Verified {len(repairs)} empty DJ copies with matching original audio.') +if '--apply' in sys.argv: + for source, target, expected in repairs: + descriptor, temporary = tempfile.mkstemp(prefix='.kjmix-repair-', dir=media) + try: + with os.fdopen(descriptor, 'wb') as output, source.open('rb') as input_stream: + for chunk in iter(lambda: input_stream.read(1024 * 1024), b''): + output.write(chunk) + output.flush() + os.fsync(output.fileno()) + assert digest(Path(temporary)) == expected + assert target.stat().st_size == 0, 'Destination changed; refusing to replace it' + os.chmod(temporary, 0o644) + os.replace(temporary, target) + print(f'Repaired verified audio: {target.name} ({target.stat().st_size} bytes)') + finally: + if os.path.exists(temporary): + os.unlink(temporary)