broken changes
This commit is contained in:
@@ -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 ?? "") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Int64>()
|
||||
@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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user