importer chnges

This commit is contained in:
Mark Gregory
2026-11-04 16:10:32 +00:00
parent c35dd4b20e
commit 1f397685dd
10 changed files with 399 additions and 53 deletions
+4 -4
View File
@@ -245,7 +245,7 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 15;
CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = 39R6U3437D;
ENABLE_APP_SANDBOX = NO;
ENABLE_TESTABILITY = YES;
@@ -255,7 +255,7 @@
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Singers on your local network can search the library and manage their own song lists when you enable the Singer Web server.";
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.6.4;
MARKETING_VERSION = 0.6.6;
OTHER_LDFLAGS = "-lsqlite3";
PRODUCT_BUNDLE_IDENTIFIER = com.kjamsclone.app;
PRODUCT_MODULE_NAME = KJamsClone;
@@ -273,7 +273,7 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 15;
CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = 39R6U3437D;
ENABLE_APP_SANDBOX = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -282,7 +282,7 @@
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Singers on your local network can search the library and manage their own song lists when you enable the Singer Web server.";
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.6.4;
MARKETING_VERSION = 0.6.6;
OTHER_LDFLAGS = "-lsqlite3";
PRODUCT_BUNDLE_IDENTIFIER = com.kjamsclone.app;
PRODUCT_MODULE_NAME = KJamsClone;
+14 -2
View File
@@ -270,12 +270,24 @@ struct DJDeckControls: View {
}
struct StudioSettingsPane: View {
@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() }
GroupBox("Appearance") {
HStack {
Picker("Appearance",selection:$appearance) {
Text("System").tag("system")
Text("Light").tag("light")
Text("Dark").tag("dark")
}.pickerStyle(.segmented).frame(maxWidth:360)
Spacer()
}.padding(10)
Text("System follows your Mac. The waveform and video output remain dark for clear monitoring.").font(.caption).foregroundStyle(.secondary).padding([.horizontal,.bottom],10)
}
GroupBox("Playback & Jog") {
VStack(alignment:.leading,spacing:12) {
Toggle("Automatically play the next queued singer",isOn:$model.studio.autoAdvance)
@@ -478,7 +490,7 @@ struct RotationDashboard: View {
}
}.padding(12).frame(minWidth:360).background(Color.primary.opacity(0.025),in:RoundedRectangle(cornerRadius:10))
}
}.padding(.horizontal,18).padding(.bottom,16).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark)
}.padding(.horizontal,18).padding(.bottom,16).background(Color(nsColor:.windowBackgroundColor))
.onChange(of:model.hostSettings.smart) { _ in model.saveHostSettings() }
.onChange(of:model.rotationPlaybackActive) { active in if active { addingSongs = false } }
}
@@ -531,7 +543,7 @@ private struct RotationTonightSheet: View {
}
}.frame(height:330)
HStack { Text("Adding a song marks this singer Here and puts it in the rotation.").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:570).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark)
}.padding(24).frame(width:570).background(Color(nsColor:.windowBackgroundColor))
}
}
+3 -3
View File
@@ -104,7 +104,7 @@ struct ShowScreensPane: View {
List(model.showScreens) { screen in
HStack { Image(systemName:"rectangle.on.rectangle").foregroundStyle(.tint);VStack(alignment:.leading) { Text(screen.title);Text("\(Int(screen.seconds)) seconds").font(.caption).foregroundStyle(.secondary) };Spacer();Button("Edit") { edit = screen };Button("Show Now") { model.perform { try model.playScreen(screen) } };Button("Queue") { model.enqueueScreen(screen.id) }.disabled(model.currentVenue == nil) }
}
}.padding(22).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).sheet(item:$edit) { screen in ShowScreenEditor(model:model,screen:screen) }
}.padding(22).background(Color(nsColor:.windowBackgroundColor)).sheet(item:$edit) { screen in ShowScreenEditor(model:model,screen:screen) }
}
}
private struct ShowScreenEditor: View {
@@ -120,7 +120,7 @@ private struct ShowScreenEditor: View {
Text("Placeholders: {venue}, {singer}, {next}, {song}").font(.caption).foregroundStyle(.secondary)
HStack { Picker("Colour",selection:$screen.tint) { Text("Indigo").tag("indigo");Text("Teal").tag("teal");Text("Rose").tag("rose") };Stepper("\(Int(screen.seconds)) seconds",value:$screen.seconds,in:3...300,step:1) }
HStack { Button("Cancel") { dismiss() };Spacer();Button("Save") { model.saveScreen(screen);if model.error == nil { dismiss() } }.keyboardShortcut(.defaultAction).disabled(screen.title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) }
}.padding(24).frame(width:530).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).textFieldStyle(.roundedBorder)
}.padding(24).frame(width:530).background(Color(nsColor:.windowBackgroundColor)).textFieldStyle(.roundedBorder)
}
}
@@ -195,7 +195,7 @@ struct PlaylistWorkspace: View {
}.padding(.vertical,5).onDrag { model.dragReference(song.id).provider() }
}.listStyle(.inset)
Text("Use toolbar search for this playlist. Quoted phrases, artist:, album:, title:, id: and exact = searches are supported. Saved order can be changed with the arrow buttons when search is empty.").font(.caption).foregroundStyle(.secondary)
}.padding(22).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).sheet(isPresented:$adding) { addSheet }
}.padding(22).background(Color(nsColor:.windowBackgroundColor)).sheet(isPresented:$adding) { addSheet }
.confirmationDialog("Delete this playlist? Library songs are kept.",isPresented:$delete) { Button("Delete Playlist",role:.destructive) { if let selected { model.delete("playlists",id:selected);self.selected = nil } } }
.onChange(of:sort) { _ in saveSort() }.onChange(of:ascending) { _ in saveSort() }
.onChange(of:selected) { id in
+96 -10
View File
@@ -2,19 +2,73 @@ import Foundation
import CryptoKit
import AVFoundation
struct ImportProgress: Sendable {
var completed = 0
var skipped = 0
var total = 0
var current = "Preparing import…"
var finished = false
var cancelled = false
var failure: String?
}
struct Importer {
let media: URL
@discardableResult func run(_ urls: [URL],library: Library,copyMedia: Bool = true,cancelled: () -> Bool = { false }) throws -> [Int64] {
@discardableResult func run(_ urls: [URL],library: Library,copyMedia: Bool = true,cancelled: () -> Bool = { false },progress: (ImportProgress) -> Void = { _ in }) throws -> [Int64] {
try FileManager.default.createDirectory(at: media, withIntermediateDirectories: true)
var state = ImportProgress()
var lastReport = Date.distantPast
func report(_ current: String,force: Bool = false) {
state.current = current
if force || Date().timeIntervalSince(lastReport) >= 0.1 { progress(state);lastReport = Date() }
}
report("Preparing import…",force:true)
// Index each directory once: a large folder must not be searched for every song.
var siblingCache = [URL:[String:[String:URL]]]()
func companions(of url: URL) throws -> [String:URL] {
let directory = url.deletingLastPathComponent()
if siblingCache[directory] == nil {
let entries = try FileManager.default.contentsOfDirectory(at:directory,includingPropertiesForKeys:nil)
var index = [String:[String:URL]]()
for entry in entries {
if cancelled() { throw CancellationError() }
index[entry.deletingPathExtension().lastPathComponent.lowercased(),default:[:]][entry.pathExtension.lowercased()] = entry
}
siblingCache[directory] = index
}
return siblingCache[directory]?[url.deletingPathExtension().lastPathComponent.lowercased()] ?? [:]
}
defer { state.total = max(state.total,state.completed);progress(state) }
var files = urls
var temporary = [URL]()
defer { for url in temporary { try? FileManager.default.removeItem(at: url) } }
var songIDs = Set<Int64>()
var archives = [(url:URL,signature:String,folder:URL)]()
var archiveForFile = [String:URL]()
var archiveSongIDs = [URL:[Int64]]()
func importSignature(_ audio: URL,cdg: URL? = nil) throws -> String {
func signature(_ url: URL) throws -> String {
let attrs = try FileManager.default.attributesOfItem(atPath:url.path)
return "\(url.standardizedFileURL.path)|\(attrs[.size] ?? 0)|\((attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0)|\((attrs[.creationDate] as? Date)?.timeIntervalSince1970 ?? 0)|\(attrs[.systemFileNumber] ?? 0)"
}
return try signature(audio)+"|"+(cdg.map { try signature($0) } ?? "no-cdg")+"|copy=\(copyMedia)"
}
func remember(_ audio:URL,_ signature:String,_ id:Int64) throws {
if let archive = archiveForFile[audio.resolvingSymlinksInPath().path] { archiveSongIDs[archive,default:[]].append(id) }
else { try library.rememberImport(path:audio.standardizedFileURL.path,signature:signature,songs:[id]) }
}
for zip in urls where zip.pathExtension.lowercased() == "zip" {
if cancelled() { throw CancellationError() }
let signature = try importSignature(zip)
if let existing = try library.importedSongs(path:zip.standardizedFileURL.path,signature:signature) {
songIDs.formUnion(existing);state.completed += existing.count;state.skipped += existing.count
report("Already imported: "+zip.lastPathComponent,force:state.completed == existing.count);continue
}
report("Extracting "+zip.lastPathComponent,force:archives.isEmpty)
let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
temporary.append(folder)
archives.append((zip,signature,folder))
// Validate names before extraction; reject path traversal and symlinks afterwards.
let details = try process(["-Z", "-l", zip.path])
guard !details.split(separator: "\n").contains(where: { $0.hasPrefix("l") }) else { throw LibraryError.message("ZIP symlinks are unsupported") }
@@ -26,25 +80,49 @@ struct Importer {
for case let url as URL in extracted {
let values = try url.resourceValues(forKeys: [.isSymbolicLinkKey,.isRegularFileKey])
guard values.isSymbolicLink != true else { throw LibraryError.message("ZIP symlinks are unsupported") }
if values.isRegularFile == true { files.append(url) }
if values.isRegularFile == true { files.append(url);archiveForFile[url.resolvingSymlinksInPath().path] = folder }
}
}
for cdg in files.filter({ $0.pathExtension.lowercased() == "cdg" }) {
let siblings = try FileManager.default.contentsOfDirectory(at:cdg.deletingLastPathComponent(),includingPropertiesForKeys:nil)
if let audio = siblings.first(where: { ["mp3","wav","m4a","mp4","m4v","mov"].contains($0.pathExtension.lowercased()) && $0.deletingPathExtension().lastPathComponent.caseInsensitiveCompare(cdg.deletingPathExtension().lastPathComponent) == .orderedSame }) { files.append(audio) }
if cancelled() { throw CancellationError() }
let matching = try companions(of:cdg)
if let audio = ["mp3","wav","m4a","mp4","m4v","mov"].compactMap({ matching[$0] }).first { files.append(audio) }
}
var imported = 0
for audio in Set(files) where ["mp3","wav","m4a","mp4","m4v","mov"].contains(audio.pathExtension.lowercased()) {
let audioFiles = Set(files).filter { ["mp3","wav","m4a","mp4","m4v","mov"].contains($0.pathExtension.lowercased()) }.sorted { $0.path < $1.path }
state.total = audioFiles.count+state.completed
report("Importing tracks…",force:true)
for audio in audioFiles {
if cancelled() { throw CancellationError() }
let siblings = try FileManager.default.contentsOfDirectory(at: audio.deletingLastPathComponent(), includingPropertiesForKeys: nil)
let cdg = siblings.first { $0.pathExtension.lowercased() == "cdg" && $0.deletingPathExtension().lastPathComponent.caseInsensitiveCompare(audio.deletingPathExtension().lastPathComponent) == .orderedSame }
report(audio.lastPathComponent)
let cdg = try companions(of:audio)["cdg"]
let archive = archiveForFile[audio.resolvingSymlinksInPath().path]
let copy = copyMedia || archive != nil
let signature = try importSignature(audio,cdg:cdg)
if let existing = try library.importedSongs(path:audio.standardizedFileURL.path,signature:signature) {
songIDs.formUnion(existing)
if let archive { archiveSongIDs[archive,default:[]].append(contentsOf:existing) }
state.completed += 1;state.skipped += 1
report("Already imported: "+audio.lastPathComponent,force:state.completed == 1);continue
}
let hash = try audioFingerprint(audio,cancelled:cancelled)
let copy = copyMedia || temporary.contains { audio.resolvingSymlinksInPath().path.hasPrefix($0.resolvingSymlinksInPath().path+"/") }
let graphicsHash = try cdg.map { try audioFingerprint($0,cancelled:cancelled) }
let duplicate = try library.songWithFingerprint(hash)
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,FileManager.default.fileExists(atPath:existing.audio),
(!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
report("Already in library: "+audio.lastPathComponent,force:state.completed == 1);continue
}
let dest = copy ? media.appendingPathComponent(hash+"."+audio.pathExtension.lowercased()) : audio.standardizedFileURL
if copy && !FileManager.default.fileExists(atPath:dest.path) { try FileManager.default.copyItem(at:audio,to:dest) }
var graphics: String?
if let cdg {
let target = copy ? media.appendingPathComponent(hash+".cdg") : cdg.standardizedFileURL
let target = copy ? media.appendingPathComponent(hash+"-"+(graphicsHash ?? "graphics")+".cdg") : cdg.standardizedFileURL
if copy && !FileManager.default.fileExists(atPath:target.path) { try FileManager.default.copyItem(at:cdg,to:target) }
graphics = target.path
}
@@ -54,8 +132,16 @@ struct Importer {
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)
songIDs.insert(id)
try remember(audio,signature,id)
state.completed += 1
report(audio.lastPathComponent,force:state.completed == 1)
}
guard imported > 0 else { throw LibraryError.message("No audio found. Keep the MP3 and matching CDG together, or import an MP3+G ZIP.") }
for archive in archives {
let ids = archiveSongIDs[archive.folder] ?? []
try library.rememberImport(path:archive.url.standardizedFileURL.path,signature:archive.signature,songs:ids)
}
guard imported > 0 || state.skipped > 0 else { throw LibraryError.message("No audio found. Keep the MP3 and matching CDG together, or import an MP3+G ZIP.") }
state.finished = true;report("Import complete",force:true)
return Array(songIDs)
}
private func audioFingerprint(_ url: URL,cancelled: () -> Bool) throws -> String {
+112 -29
View File
@@ -88,7 +88,7 @@ struct SongDragReference: Codable, Sendable {
private var waveformJobTask: Task<Void,Never>?
private var waveformJobToken: UUID?
func generateWaveforms(_ ids: Set<Int64>? = nil,force: Bool = false) {
guard !generatingWaveforms,scanningSource == nil else { error = "Wait for the current waveform or location scan to finish, or cancel it first.";return }
guard !generatingWaveforms,scanningSource == nil,!isImporting else { error = "Wait for the current waveform or location scan to finish, or cancel it first.";return }
let tracks = songs.filter{ids == nil || ids!.contains($0.id)}
guard !tracks.isEmpty else { error = "Select a library track first.";return }
let token = UUID(),locations = mediaLocations,cache = root.appendingPathComponent("Waveforms")
@@ -342,7 +342,7 @@ struct SongDragReference: Codable, Sendable {
try refresh();return id
}
func startSourceScan(_ id: Int64,forceWaveforms: Bool = false) {
guard scanningSource == nil,!generatingWaveforms else { error = "A waveform or location scan is already running. Cancel it or wait for it to finish.";return }
guard scanningSource == nil,!generatingWaveforms,!isImporting else { error = "A waveform or location scan is already running. Cancel it or wait for it to finish.";return }
guard let source = mediaLocations.first(where:{ $0.id == id }) else { return }
let token = UUID();scanToken = token;scanningSource = id;scanProgress = SourceScanProgress(source:id)
let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),cache = root.appendingPathComponent("Waveforms")
@@ -370,7 +370,42 @@ struct SongDragReference: Codable, Sendable {
guard scanningSource == nil else { error = "Finish or cancel the scan before removing a location";return }
perform { try library?.removeMediaLocation(id);try refresh() }
}
func importFiles(_ urls: [URL]) { perform { guard let library else { throw LibraryError.message("Library unavailable") }; try Importer(media:root.appendingPathComponent("Media")).run(urls,library:library); try refresh() } }
@Published private(set) var importProgress: ImportProgress?
@Published private(set) var isImporting = false
private var importTask: Task<Void,Never>?
private var importToken: UUID?
func importFiles(_ urls: [URL]) {
guard !isImporting,scanningSource == nil,!generatingWaveforms else { error = "Wait for the current import, scan or waveform job to finish first.";return }
guard !urls.isEmpty else { return }
let token = UUID(),database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media")
importToken = token;isImporting = true;importProgress = ImportProgress()
importTask = Task.detached(priority:.utility) { [weak self] in
let accessed = urls.filter { $0.startAccessingSecurityScopedResource() }
defer { for url in accessed { url.stopAccessingSecurityScopedResource() } }
var latest = ImportProgress()
do {
let database = try Library(url:database)
try Importer(media:media).run(urls,library:database,cancelled:{Task.isCancelled},progress:{ update in
latest = update
Task { @MainActor [weak self] in
guard let self,self.importToken == token else { return };self.importProgress = update
}
})
} catch is CancellationError { latest.cancelled = true;latest.current = "Import cancelled" }
catch { latest.failure = error.localizedDescription;latest.current = "Import stopped" }
latest.finished = true
let result = latest
await MainActor.run { [weak self] in
guard let self,self.importToken == token else { return }
self.importToken = nil;self.importTask = nil;self.isImporting = false
self.perform { try self.refresh() }
self.importProgress = result
}
}
}
func cancelImport() { importTask?.cancel();importProgress?.current = "Cancelling after the current file…" }
func dismissImport() { if !isImporting { importProgress = nil } }
}
private struct LibraryCommands {
var importMusic: () -> Void
@@ -390,10 +425,12 @@ private extension FocusedValues {
}
@main struct KJMixProApp: App {
@StateObject private var model = AppModel()
@AppStorage("studioAppearance") private var appearance = "system"
@FocusedValue(\.libraryCommands) private var actions
var body: some Scene {
Window("KJMix Pro", id: "main") {
LibraryView(model: model).frame(minWidth: 1060, minHeight: 760).preferredColorScheme(.dark)
LibraryView(model: model).frame(minWidth: 1060, minHeight: 760)
.preferredColorScheme(appearance == "dark" ? .dark : appearance == "light" ? .light : nil)
}.defaultSize(width: 1320, height: 900).windowToolbarStyle(.unified)
.commands {
CommandGroup(replacing: .newItem) {
@@ -433,6 +470,35 @@ private extension FocusedValues {
}.defaultSize(width: 600, height: 400)
}
}
private struct ImportProgressDialog: View {
@ObservedObject var model: AppModel
var body: some View {
ZStack {
Color.black.opacity(0.16).ignoresSafeArea().contentShape(Rectangle()).onTapGesture { }
if let progress = model.importProgress {
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")
.font(.system(size:28)).foregroundStyle(Color.accentColor)
VStack(alignment:.leading,spacing:3) {
Text(progress.finished ? (progress.cancelled ? "Import Cancelled" : progress.failure == nil ? "Import Complete" : "Import Stopped") : "Importing Music").font(.headline)
Text(progress.total > 0 ? "\(progress.completed) of \(progress.total) tracks checked" : "Preparing files and archives…").font(.subheadline).foregroundStyle(.secondary)
}
}
if progress.total > 0 { ProgressView(value:Double(progress.completed),total:Double(progress.total)) }
else if !progress.finished { ProgressView().controlSize(.small) }
if progress.completed > 0 { 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.cancelled || progress.failure != nil { Text("Tracks already imported are saved in your library.").font(.caption).foregroundStyle(.secondary) }
HStack { Spacer();if progress.finished { Button("Done") { model.dismissImport() }.keyboardShortcut(.defaultAction) } else { Button("Cancel Import") { model.cancelImport() }.keyboardShortcut(.cancelAction) } }
}.padding(24).frame(width:380).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("Music import progress")
}
}
}
}
private enum StudioTheme {
static let frame = Color(nsColor:.windowBackgroundColor)
static let navy = Color(nsColor:.textBackgroundColor)
@@ -443,8 +509,8 @@ private enum StudioTheme {
private struct StudioSidebarLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(spacing:9) {
configuration.icon.foregroundStyle(Color(white:0.66)).frame(width:18)
configuration.title.foregroundStyle(Color(white:0.88))
configuration.icon.foregroundStyle(.secondary).frame(width:18)
configuration.title.foregroundStyle(.primary)
}
}
}
@@ -466,7 +532,7 @@ private struct SidebarVideoMonitor: View {
var body: some View {
VStack(alignment: .leading, spacing: 7) {
Label("Video Monitor", systemImage: "tv")
.font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.7))
.font(.caption.weight(.medium)).foregroundStyle(.secondary)
LyricsView(player: player)
.frame(height:140)
.clipped()
@@ -576,7 +642,8 @@ struct LibraryView: View {
.background(StudioTheme.frame)
.tint(.accentColor)
.focusedSceneValue(\.libraryCommands,LibraryCommands(importMusic:{importing = true},songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")},settings:{source = "Settings"}))
.navigationTitle("KJMix Pro")
.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 {
@@ -589,12 +656,14 @@ struct LibraryView: View {
} label: { Label("Add",systemImage:"plus") }
Button { editingSong = selectedSong } label: { Label("Song Info",systemImage:"info.circle") }.disabled(selectedSong == nil)
Button { openWindow(id:"lyrics") } label: { Label("Video Output",systemImage:"rectangle.on.rectangle") }
Button { source = "Settings" } label: { Label("Settings",systemImage:"gearshape") }
}
}
}
var body: some View {
workspace
.disabled(model.importProgress != nil)
.overlay { if model.importProgress != nil { ImportProgressDialog(model:model) } }
.onAppear { updateLibraryRows() }
.onChange(of:model.songs) { _ in updateLibraryRows() }
.onChange(of:model.tuningCache) { _ in updateLibraryRows() }
@@ -609,7 +678,7 @@ struct LibraryView: View {
Text("Waveform Analysis").font(.title2.bold())
ScrollView { Text(model.waveformJob?.messages.joined(separator:"\n\n") ?? "").font(.callout).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }.frame(height:300)
HStack { Spacer();Button("Done") { waveformDetails = false }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:560).preferredColorScheme(.dark)
}.padding(24).frame(width:560)
}
.sheet(isPresented:$batchEditing) { BatchMetadataSheet(model:model,ids:tableSelection) }
.sheet(item:$metadataLookup) { song in MetadataLookupSheet(model:model,song:song) }
@@ -676,8 +745,8 @@ struct LibraryView: View {
Image("BrandMark").resizable().scaledToFit().frame(width:32,height:32)
VStack(alignment:.leading,spacing:2) { Text("KJMix Pro").font(.headline);Text("Karaoke Studio").font(.caption).foregroundStyle(.secondary) };Spacer()
}.padding(16)
ScrollView {
LazyVStack(alignment:.leading,spacing:3) {
List {
Group {
sidebarHeading("Workspace")
navigationRow("Library",symbol:"music.note.list",count:model.songs.count)
navigationRow("Rotation",symbol:"person.3.sequence",count:model.rotationPlan.count)
@@ -714,8 +783,8 @@ struct LibraryView: View {
navigationRow("Singer Web",symbol:"network")
navigationRow("Music Stores",symbol:"bag")
navigationRow("Settings",symbol:"gearshape")
}.font(.system(size:13)).padding(.horizontal,10).padding(.bottom,12)
}
}.font(.body).listRowSeparator(.hidden).listRowInsets(EdgeInsets(top:1,leading:8,bottom:1,trailing:8))
}.listStyle(.sidebar)
Divider().padding(.horizontal,14)
SidebarVideoMonitor(player:model.player).padding(14)
}.background(.ultraThinMaterial)
@@ -754,7 +823,7 @@ struct LibraryView: View {
Button { model.moveTonight(song.id,singer:singer.id,by:-1) } label: { Image(systemName:"arrow.up") }.disabled(index == 0).help("Move earlier")
Button { model.moveTonight(song.id,singer:singer.id,by:1) } label: { Image(systemName:"arrow.down") }.disabled(index == model.tonightSongs(for:singer.id).count-1).help("Move later")
Button("Remove") { model.removeFromTonight(song.id,singer:singer.id) }
}.padding(8).background(.white.opacity(0.035)).contentShape(Rectangle())
}.padding(8).background(Color.primary.opacity(0.035)).contentShape(Rectangle())
.onDrag { model.dragReference(song.id,singer:singer.id,tonight:true).provider() }
.modifier(TonightSongDropTarget(model:model,singer:singer.id,venue:model.currentVenue,before:song.id,enabled:true))
}
@@ -774,7 +843,7 @@ struct LibraryView: View {
Button("Play") { model.selectedSinger = singer.id;model.perform { try model.play(song,singer:singer.id) } }
Button("Add to Tonight") { model.addToTonight(song.id,singer:singer.id) }.disabled(model.currentVenue == nil)
Button("Remove") { model.assign(song.id,to:singer.id,included:false) }
}.padding(8).background(.white.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = song.id }
}.padding(8).background(Color.primary.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = song.id }
.onDrag { model.dragReference(song.id).provider() }
}
if model.songs(for:singer.id).isEmpty { Text("Use Add Songs… or the + beside this singer in the sidebar.").foregroundStyle(.secondary).padding(.top,25) }
@@ -892,7 +961,7 @@ struct LibraryView: View {
TextField(source == "Singers" ? "Singer name" : "Venue name",text:$name).textFieldStyle(.roundedBorder).onSubmit(addEntity)
Button(source == "Singers" ? "Add Singer" : "Add Venue",action:addEntity).disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty)
}.padding(12)
}.foregroundStyle(.white).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line))
}.foregroundStyle(.primary).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line))
.sheet(isPresented:$managingRoster) { rosterSheet }
.sheet(item:$editingEntity) { entity in
VStack(alignment:.leading,spacing:16) {
@@ -1139,7 +1208,7 @@ private struct SongMetadataSheet: View {
Text(URL(fileURLWithPath:song.audio).lastPathComponent).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle).help(song.audio)
if let error { Text(error).foregroundStyle(.red).font(.callout) }
HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction);Button("Save") { save() }.keyboardShortcut(.defaultAction).disabled(title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) }
}.padding(24).frame(width:500).background(StudioTheme.frame).preferredColorScheme(.dark)
}.padding(24).frame(width:500).background(StudioTheme.frame)
.onAppear { title = song.title;artist = song.artist;album = song.album;label = song.label;rating = song.rating }
}
private func save() { do { try model.updateMetadata(song,title:title,artist:artist,album:album,label:label,rating:rating);dismiss() } catch { self.error = error.localizedDescription } }
@@ -1170,7 +1239,7 @@ private struct LibraryLocationsPane: View {
}
if !location.lastScan.isEmpty { Text("Last scan: \(location.lastScan)").font(.caption).foregroundStyle(.secondary) }
if !location.lastError.isEmpty { Text(location.lastError).font(.caption).foregroundStyle(.orange) }
}.padding(12).background(selected == location.id ? Color.blue.opacity(0.13) : Color.white.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = location.id }
}.padding(12).background(selected == location.id ? Color.blue.opacity(0.13) : Color.primary.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = location.id }
}
if model.mediaLocations.isEmpty { Text("No locations added. Choose Add Drive / Folder… to index an external drive or music folder.").foregroundStyle(.secondary).padding(.vertical,25) }
}
@@ -1213,7 +1282,7 @@ private struct MusicStoresPane: View {
Button("Delete",role:.destructive) { deleting = store.id }
}
Text(store.url).font(.caption).foregroundStyle(.secondary).textSelection(.enabled)
}.padding(12).background(selected == store.id ? Color.blue.opacity(0.15) : Color.white.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = store.id }
}.padding(12).background(selected == store.id ? Color.blue.opacity(0.15) : Color.primary.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = store.id }
}
if model.musicStores.isEmpty { Text("No stores configured. Add a store name and website address to get started.").foregroundStyle(.secondary).padding(.top,20) }
}
@@ -1246,7 +1315,7 @@ private struct MusicStoreEditor: View {
Text("Use {query} where the search words belong. Only http:// and https:// websites are accepted.").font(.caption).foregroundStyle(.secondary)
if let error { Text(error).foregroundStyle(.red).font(.callout) }
HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction);Button("Save") { save() }.keyboardShortcut(.defaultAction).disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty || url.isEmpty) }
}.padding(24).frame(width:500).background(StudioTheme.frame).preferredColorScheme(.dark)
}.padding(24).frame(width:500).background(StudioTheme.frame)
.onAppear { name = store?.name ?? "";url = store?.url ?? "";search = store?.searchTemplate ?? "" }
}
private func save() { do { try model.saveStore(.init(id:store?.id ?? 0,name:name,url:url,searchTemplate:search));dismiss() } catch { self.error = error.localizedDescription } }
@@ -1279,7 +1348,9 @@ private struct SingerSongsSheet: View {
}.frame(maxWidth:.infinity,alignment:.leading)
}.frame(height:270)
HStack { Button("Import Songs…") { importing = true };Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:520).background(StudioTheme.frame).preferredColorScheme(.dark)
}.padding(24).frame(width:520).background(StudioTheme.frame)
.disabled(model.importProgress != nil)
.overlay { if model.importProgress != nil { ImportProgressDialog(model:model) } }
.fileImporter(isPresented:$importing,allowedContentTypes:[.audio,.movie,.zip,UTType(filenameExtension:"cdg") ?? .data],allowsMultipleSelection:true) { result in
switch result { case .success(let urls):model.importFiles(urls);case .failure(let error):model.error = error.localizedDescription }
}
@@ -1319,7 +1390,7 @@ private struct AddEntitySheet: View {
Button(kind == .singer ? "Add Singer & Songs" : "Add Venue",action:save).keyboardShortcut(.defaultAction)
.disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty || (kind == .singer && venue == nil))
}
}.padding(24).frame(width:kind == .singer ? 500 : 360).background(StudioTheme.frame).preferredColorScheme(.dark)
}.padding(24).frame(width:kind == .singer ? 500 : 360).background(StudioTheme.frame)
.onAppear { focused = true;venue = model.currentVenue ?? model.venues.first?.id;songs = model.newSingerSongs }
}
private func save() {
@@ -1416,9 +1487,6 @@ private struct NowPlayingWaveform: View {
let path = Path(CGRect(x:x,y:mid-height/2,width:max(0.7,step-0.7),height:height))
context.fill(path,with:.color(bandColor(start).opacity(x <= size.width*progress ? 1 : 0.88)))
}
let x = size.width*progress
var head = Path();head.move(to:CGPoint(x:x,y:top));head.addLine(to:CGPoint(x:x,y:size.height))
context.stroke(head,with:.color(.red),lineWidth:1)
if let hoverX {
let hx = min(size.width,max(0,hoverX))
var hover = Path();hover.move(to:CGPoint(x:hx,y:top));hover.addLine(to:CGPoint(x:hx,y:size.height))
@@ -1429,6 +1497,19 @@ private struct NowPlayingWaveform: View {
} else if player.waveformLoading {
context.draw(Text("Analysing waveform…").font(.system(size:9)).foregroundColor(.gray),at:CGPoint(x:size.width/2,y:mid))
}
if player.duration > 0 {
let x = min(size.width-2,max(2,size.width*progress))
context.fill(Path(CGRect(x:0,y:top,width:x,height:plotHeight)),with:.color(.black.opacity(0.18)))
var head = Path();head.move(to:CGPoint(x:x,y:top));head.addLine(to:CGPoint(x:x,y:size.height))
context.stroke(head,with:.color(.black.opacity(0.9)),lineWidth:6)
context.stroke(head,with:.color(.cyan.opacity(0.3)),lineWidth:5)
context.stroke(head,with:.color(.white),lineWidth:2)
var pointer = Path();pointer.move(to:CGPoint(x:x-5,y:top));pointer.addLine(to:CGPoint(x:x+5,y:top));pointer.addLine(to:CGPoint(x:x,y:top+7));pointer.closeSubpath()
context.fill(pointer,with:.color(.cyan))
let labelX = min(size.width-30,max(30,x))
context.fill(Path(roundedRect:CGRect(x:labelX-29,y:0,width:58,height:13),cornerRadius:3),with:.color(.cyan))
context.draw(Text(preciseClock(player.time)).font(.system(size:9,weight:.bold,design:.monospaced)).foregroundColor(.black),at:CGPoint(x:labelX,y:6))
}
}.contentShape(Rectangle()).background(WaveformJogWheel(player:player))
.onContinuousHover { phase in switch phase { case .active(let point):hoverX = point.x;case .ended:hoverX = nil } }
.gesture(DragGesture(minimumDistance:0).onChanged { value in
@@ -1473,8 +1554,10 @@ private struct NowPlayingWaveform: View {
Text("\(clock(player.time)) / \(clock(player.duration))").monospacedDigit().padding(.leading,5)
}.font(.system(size:9,weight:.medium)).foregroundStyle(Color(white:0.6))
DJDeckControls(player:player)
}.padding(.horizontal,12).padding(.vertical,9).frame(maxWidth:.infinity).frame(height:174)
.background(Color.black.opacity(0.22),in:RoundedRectangle(cornerRadius:12)).overlay(RoundedRectangle(cornerRadius:12).strokeBorder(.white.opacity(0.07),lineWidth:1))
}.padding(.horizontal,14).padding(.vertical,10).frame(maxWidth:.infinity).frame(height:174)
.background(Color(.sRGB,red:0.055,green:0.065,blue:0.08,opacity:1),in:RoundedRectangle(cornerRadius:10))
.overlay(RoundedRectangle(cornerRadius:10).strokeBorder(.white.opacity(0.1),lineWidth:1))
.environment(\.colorScheme,.dark)
}
}
private struct ToneKnob: View {
@@ -1598,7 +1681,7 @@ private struct SeekBar: View {
Slider(value: Binding(get: { player.time }, set: { player.seek($0) }), in: 0...max(1, player.duration))
.controlSize(.mini).tint(.gray).disabled(player.duration == 0).accessibilityLabel("Playback position")
Text(clock(player.duration))
}.font(.system(size: 10, design: .monospaced)).foregroundStyle(.white.opacity(0.65))
}.font(.system(size: 10, design: .monospaced)).foregroundStyle(.secondary)
}
}
private struct RepeatButton: View {
+23 -1
View File
@@ -120,7 +120,8 @@ final class Library {
try execute("CREATE TABLE IF NOT EXISTS singer_rotation(venue INTEGER NOT NULL,singer INTEGER NOT NULL,position INTEGER NOT NULL,here INTEGER NOT NULL DEFAULT 1,PRIMARY KEY(venue,singer),FOREIGN KEY(venue,singer) REFERENCES venue_singers(venue,singer) ON DELETE CASCADE)")
try execute("CREATE TABLE IF NOT EXISTS show_screens(song INTEGER PRIMARY KEY REFERENCES songs(id) ON DELETE CASCADE,body TEXT NOT NULL,footer TEXT NOT NULL,tint TEXT NOT NULL)")
try execute("CREATE TABLE IF NOT EXISTS singer_access(venue INTEGER NOT NULL,singer INTEGER NOT NULL,token TEXT NOT NULL UNIQUE,PRIMARY KEY(venue,singer),FOREIGN KEY(venue,singer) REFERENCES venue_singers(venue,singer) ON DELETE CASCADE)")
try execute("PRAGMA user_version=9")
try execute("CREATE TABLE IF NOT EXISTS import_history(path TEXT PRIMARY KEY,signature TEXT NOT NULL,songs TEXT NOT NULL,imported_at TEXT NOT NULL)")
try execute("PRAGMA user_version=10")
}
deinit { sqlite3_close(db) }
private func statement(_ sql: String, _ values: [String?]) throws -> OpaquePointer {
@@ -536,3 +537,24 @@ extension Library {
do { for (i,id) in ids.enumerated() { try execute("UPDATE singer_rotation SET position=? WHERE venue=? AND singer=?",[String(i),String(venue),String(id)]) };try execute("COMMIT") } catch { try? execute("ROLLBACK");throw error }
}
}
extension Library {
func importedSongs(path: String,signature: String) throws -> [Int64]? {
let query = try statement("SELECT songs FROM import_history WHERE path=? AND signature=?",[path,signature]);defer { sqlite3_finalize(query) }
guard sqlite3_step(query) == SQLITE_ROW,let data = text(query,0).data(using:.utf8),let ids = try? JSONDecoder().decode([Int64].self,from:data),!ids.isEmpty else { return nil }
for id in ids {
guard let track = try song(id),FileManager.default.fileExists(atPath:track.audio) else { return nil }
if let cdg = track.cdg,!FileManager.default.fileExists(atPath:cdg) { return nil }
}
return ids
}
func rememberImport(path: String,signature: String,songs: [Int64]) throws {
guard !songs.isEmpty else { return }
let json = String(decoding:try JSONEncoder().encode(Array(Set(songs)).sorted()),as:UTF8.self)
try execute("INSERT INTO import_history(path,signature,songs,imported_at) VALUES(?,?,?,?) ON CONFLICT(path) DO UPDATE SET signature=excluded.signature,songs=excluded.songs,imported_at=excluded.imported_at",[path,signature,json,ISO8601DateFormatter().string(from:Date())])
}
func songWithFingerprint(_ fingerprint: String) throws -> Song? {
let query = try statement("SELECT id FROM songs WHERE fingerprint=? ORDER BY id LIMIT 1",[fingerprint]);defer { sqlite3_finalize(query) }
return sqlite3_step(query) == SQLITE_ROW ? try song(sqlite3_column_int64(query,0)) : nil
}
}
+3 -3
View File
@@ -173,7 +173,7 @@ struct BatchMetadataSheet: View {
}.padding(12).frame(maxWidth:.infinity,alignment:.leading).background(Color.primary.opacity(0.05),in:RoundedRectangle(cornerRadius:8))
if let error { Text(error).foregroundStyle(.red) }
HStack { Button("Cancel") { dismiss() };Spacer();Button("Apply to \(ids.count) Songs") { do { try model.batchMetadata(ids,patch:effective);dismiss() } catch { self.error = error.localizedDescription } }.keyboardShortcut(.defaultAction).disabled(fields.isEmpty && find.isEmpty) }
}.padding(24).frame(width:590).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).textFieldStyle(.roundedBorder)
}.padding(24).frame(width:590).background(Color(nsColor:.windowBackgroundColor)).textFieldStyle(.roundedBorder)
}
private func binding(_ key: String) -> Binding<Bool> { Binding(get:{fields.contains(key)},set:{if $0 { fields.insert(key) } else { fields.remove(key) }}) }
private func fieldRow(_ name: String,key: String,text: Binding<String>) -> some View { HStack { Toggle(name,isOn:binding(key)).frame(width:95,alignment:.leading);TextField(name,text:text).disabled(!fields.contains(key)) } }
@@ -201,7 +201,7 @@ struct MetadataLookupSheet: View {
}.padding(.vertical,5)
}.frame(height:300)
HStack { Text("Suggestions update library metadata only.").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() } }
}.padding(24).frame(width:650).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).onAppear { query = song.title+" "+song.artist }
}.padding(24).frame(width:650).background(Color(nsColor:.windowBackgroundColor)).onAppear { query = song.title+" "+song.artist }
}
private func search() { guard !busy else { return };busy = true;error = nil;Task { do { candidates = try await OnlineMetadata.shared.search(query) } catch { self.error = error.localizedDescription };busy = false } }
}
@@ -251,7 +251,7 @@ struct StoreCatalogueSheet: View {
if item.previewURL != nil { Button("Preview Website") { open(item,preview:true) } };if item.url != nil { Button("View at Store") { open(item) } }
} }.frame(height:310)
HStack { Text(message.isEmpty ? "\(items.count) catalogue entries saved locally" : message).font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() } }
}.padding(24).frame(width:760).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).onAppear { if let value = try? model.library?.setting("store.catalogue.\(store.id)"),let data = value.data(using:.utf8),let result = try? StoreCatalogueItem.decode(data) { items = result } }
}.padding(24).frame(width:760).background(Color(nsColor:.windowBackgroundColor)).onAppear { if let value = try? model.library?.setting("store.catalogue.\(store.id)"),let data = value.data(using:.utf8),let result = try? StoreCatalogueItem.decode(data) { items = result } }
}
private func importCatalogue() {
let panel = NSOpenPanel();panel.allowedContentTypes = [.json];panel.allowsMultipleSelection = false;panel.canChooseDirectories = false
+1 -1
View File
@@ -206,6 +206,6 @@ struct SingerServerPane: View {
}.padding(.vertical,4)
}
if let invitation { Text(invitation).font(.callout).foregroundStyle(.secondary) }
}.padding(22).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).onAppear { port = String(server.running ? Int(server.port) : model.studio.serverPort);address = server.localURL }.onChange(of:server.running) { running in if running { address = server.localURL } }
}.padding(22).background(Color(nsColor:.windowBackgroundColor)).onAppear { port = String(server.running ? Int(server.port) : model.studio.serverPort);address = server.localURL }.onChange(of:server.running) { running in if running { address = server.localURL } }
}
}
+114
View File
@@ -6,6 +6,118 @@ import UniformTypeIdentifiers
@testable import KJamsClone
final class KJamsCloneTests: XCTestCase {
@MainActor func testLargeImportReturnsImmediatelyAndReportsCompletion() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let inputs = root.appendingPathComponent("Inputs");try FileManager.default.createDirectory(at:inputs,withIntermediateDirectories:true)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
var urls = [URL]()
for index in 0..<48 { let url = inputs.appendingPathComponent("Track \(index).mp3");try FileManager.default.copyItem(at:fixture,to:url);urls.append(url) }
let model = AppModel(storageURL:root.appendingPathComponent("Library"))
model.importFiles(urls)
XCTAssertTrue(model.isImporting,"Import must return to the UI before processing the files")
XCTAssertTrue(model.songs.isEmpty)
var heartbeat = false
Task { @MainActor in heartbeat = true }
for _ in 0..<500 { if !model.isImporting { break };try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertTrue(heartbeat);XCTAssertFalse(model.isImporting)
XCTAssertEqual(model.importProgress?.completed,48);XCTAssertEqual(model.importProgress?.total,48)
XCTAssertEqual(model.importProgress?.finished,true);XCTAssertNil(model.importProgress?.failure)
XCTAssertEqual(model.songs.count,1,"Identical audio still deduplicates")
model.dismissImport();XCTAssertNil(model.importProgress)
}
func testImportCancellationReportsSavedPartialWork() 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 db = try Library(url:root.appendingPathComponent("Library.sqlite"))
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
var cancel = false,latest = ImportProgress()
XCTAssertThrowsError(try Importer(media:root.appendingPathComponent("Media")).run([fixtures.appendingPathComponent("Demo.mp3"),fixtures.appendingPathComponent("TestTrack.mp3")],library:db,cancelled:{cancel},progress:{ latest = $0;if $0.completed == 1 { cancel = true } })) { XCTAssertTrue($0 is CancellationError) }
XCTAssertEqual(latest.completed,1);XCTAssertEqual(latest.total,2);XCTAssertEqual(try db.songs().count,1)
}
@MainActor func testImportFailureAndCancellationFinishTheDialog() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root)
model.importFiles([root.appendingPathComponent("missing.mp3")])
for _ in 0..<200 { if !model.isImporting { break };try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertFalse(model.isImporting);XCTAssertEqual(model.importProgress?.finished,true);XCTAssertNotNil(model.importProgress?.failure)
model.dismissImport()
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
model.importFiles([fixture]);model.cancelImport()
for _ in 0..<200 { if !model.isImporting { break };try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertFalse(model.isImporting);XCTAssertEqual(model.importProgress?.cancelled,true)
model.dismissImport();XCTAssertNil(model.importProgress)
}
func testImportHistoryPersistsAndSkipsUnchangedTracksAndRenamedDuplicates() 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 source = root.appendingPathComponent("Original.mp3");try FileManager.default.copyItem(at:fixture,to:source)
let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),importer = Importer(media:media)
let db = try Library(url:database)
let first = try importer.run([source],library:db)
let id = try XCTUnwrap(first.first);try db.rate(id,5)
let stored = try XCTUnwrap(db.song(id));let modified = try FileManager.default.attributesOfItem(atPath:stored.audio)[.modificationDate] as? Date
let reopened = try Library(url:database);var progress = ImportProgress()
XCTAssertEqual(try importer.run([source],library:reopened,progress:{progress = $0}),first)
XCTAssertEqual(progress.completed,1);XCTAssertEqual(progress.skipped,1)
let renamed = root.appendingPathComponent("Renamed.mp3");try FileManager.default.copyItem(at:source,to:renamed)
XCTAssertEqual(try importer.run([renamed],library:reopened,progress:{progress = $0}),first)
XCTAssertEqual(progress.skipped,1);XCTAssertEqual(try reopened.songs().count,1)
XCTAssertEqual(try reopened.song(id)?.rating,5)
XCTAssertEqual(try FileManager.default.attributesOfItem(atPath:stored.audio)[.modificationDate] as? Date,modified)
try FileManager.default.removeItem(at:URL(fileURLWithPath:stored.audio))
_ = try importer.run([source],library:reopened,progress:{progress = $0})
XCTAssertEqual(progress.skipped,0);XCTAssertTrue(FileManager.default.fileExists(atPath:stored.audio))
}
func testUnchangedArchiveSkipsExtractionAndMissingGraphicsCanBeRestored() 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.zip"))
let importer = Importer(media:root.appendingPathComponent("Media")),db = try Library(url:root.appendingPathComponent("Library.sqlite"))
let ids = try importer.run([fixture],library:db);var updates = [ImportProgress]()
XCTAssertEqual(try importer.run([fixture],library:db,progress:{updates.append($0)}),ids)
XCTAssertFalse(updates.contains{$0.current.hasPrefix("Extracting ")});XCTAssertEqual(updates.last?.skipped,1)
let cdg = try XCTUnwrap(try db.songs().first?.cdg);try FileManager.default.removeItem(atPath:cdg)
updates = [];_ = try importer.run([fixture],library:db,progress:{updates.append($0)})
XCTAssertTrue(updates.contains{$0.current.hasPrefix("Extracting ")});XCTAssertEqual(updates.last?.skipped,0)
XCTAssertTrue(FileManager.default.fileExists(atPath:try XCTUnwrap(try db.songs().first?.cdg)))
}
func testChangedAudioAndGraphicsAreRechecked() 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 fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
let audio = root.appendingPathComponent("Track.mp3"),cdg = root.appendingPathComponent("Track.cdg")
try FileManager.default.copyItem(at:fixtures.appendingPathComponent("Demo.mp3"),to:audio)
try FileManager.default.copyItem(at:fixtures.appendingPathComponent("Demo.cdg"),to:cdg)
let db = try Library(url:root.appendingPathComponent("Library.sqlite")),importer = Importer(media:root.appendingPathComponent("Media"))
let ids = try importer.run([audio],library:db)
let originalGraphics = try XCTUnwrap(try db.song(ids[0])?.cdg)
let changedGraphics = try Data(contentsOf:fixtures.appendingPathComponent("TestTrack.cdg"));try changedGraphics.write(to:cdg)
var progress = ImportProgress()
XCTAssertEqual(try importer.run([audio],library:db,progress:{progress = $0}),ids);XCTAssertEqual(progress.skipped,0)
let newGraphics = try XCTUnwrap(try db.song(ids[0])?.cdg)
XCTAssertNotEqual(originalGraphics,newGraphics);XCTAssertEqual(try Data(contentsOf:URL(fileURLWithPath:newGraphics)),changedGraphics)
try Data(contentsOf:fixtures.appendingPathComponent("TestTrack.mp3")).write(to:audio)
_ = try importer.run([audio],library:db,progress:{progress = $0});XCTAssertEqual(progress.skipped,0);XCTAssertEqual(try db.songs().count,2)
}
func testArchiveDeduplicationStillCreatesManagedMediaForReferencedAudio() 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 fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
let db = try Library(url:root.appendingPathComponent("Library.sqlite")),media = root.appendingPathComponent("Media"),importer = Importer(media:media)
let ids = try importer.run([fixtures.appendingPathComponent("Demo.mp3")],library:db,copyMedia:false)
_ = try importer.run([fixtures.appendingPathComponent("Demo.zip")],library:db,copyMedia:false)
XCTAssertEqual(try db.songs().count,1)
let song = try XCTUnwrap(try db.song(ids[0]));XCTAssertEqual(URL(fileURLWithPath:song.audio).deletingLastPathComponent().standardizedFileURL,media.standardizedFileURL)
XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio));XCTAssertNotNil(song.cdg)
}
func packet(_ instruction: UInt8, _ data: [UInt8]) -> [UInt8] { [9,instruction,0,0] + Array((data + Array(repeating: 0,count:16)).prefix(16)) + [0,0,0,0] }
func testPresetTileXORAndInvalidCoordinates() {
var d = CDGDecoder()
@@ -219,6 +331,8 @@ final class KJamsCloneTests: XCTestCase {
_ = try model.createEntity(.singer,name:"Alex")
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
model.importFiles([fixtures.appendingPathComponent("Demo.zip")])
for _ in 0..<200 { if !model.isImporting { break };try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertFalse(model.isImporting);XCTAssertNil(model.importProgress?.failure)
let song = try XCTUnwrap(model.songs.first)
model.enqueue(song.id); model.enqueue(song.id); model.playQueueNext()
XCTAssertEqual(model.queue.count,1); XCTAssertEqual(model.history.count,1)
+29
View File
@@ -1,3 +1,32 @@
# KJMix Pro 0.6.6 (build 17) — responsive imports and import history
Verified on 4 November 2026.
- File copying, fingerprints, archive extraction and metadata reading now run on a utility worker with its own SQLite connection. Imports show a progress dialog with current file, completed count, imported/skipped totals, cancellation and completion/error feedback. Completed tracks survive cancellation or errors.
- Each source directory is indexed once per batch for companion CDG matching. Archive-to-track history mapping uses direct lookup; progress updates are throttled.
- SQLite schema 10 adds persistent import history. Unchanged sources and archives are skipped on later imports. Audio fingerprints recognise renamed/copied tracks, preserving existing metadata and singer preferences. Changed audio/graphics are rechecked; missing managed copies are restored. Archive history is recorded only after successful processing; partial archives are retried safely.
- Existing libraries get audio-fingerprint deduplication on their next import; source-history shortcuts are populated as files are encountered. ZIP shortcuts start after the first successful import using this version.
- The waveform playhead now has a bright white line with a dark outline/cyan glow, cyan pointer and moving precise-time label, plus shading over elapsed audio. The marker remains visible during waveform analysis.
- 80 XCTest cases passed, zero failures (`/tmp/kjmix-import-final-80-tests.log`). New cases cover async import return/UI scheduling, progress, cancellation/partial saves, errors, history persistence, unchanged ZIP extraction bypass, renamed audio, changed audio/CDG, missing media recovery and managed archive copies. Existing playback, singer and rotation tests also passed.
- Import dialog and playing waveform rendered with isolated original test media at 1320 × 900. No personal media used in previews or distributions.
- The release script records signing, Apple notarization, stapling and Gatekeeper results under `build-release/release-verification/KJMixPro-0.6.6-macOS/`. Only the current DMG is retained in `dist`.
---
# KJMix Pro 0.6.5 (build 16) — native macOS appearance
Verified on 3 November 2026.
- Native SwiftUI sidebar list, contextual window title and venue subtitle, simplified toolbar.
- System appearance by default, with persisted System / Light / Dark selection in Settings. Editors and workspaces inherit the choice; waveform, video and tone mixer use dedicated dark monitoring surfaces.
- 73 XCTest cases passed with zero failures, including actual singer disclosure/navigation and rotation Add to Tonight, reorder and Start clicks. Log: `/tmp/kjmix-native-ui-tests-final.log`.
- Light-mode library, rotation and settings rendered using isolated original demo data; dark-mode rotation inspected from the native UI test capture.
- Final contrast-only change to playback-position labels compiled in the universal Release build.
- Distribution contains only the current DMG. Signing, notarization, stapling and Gatekeeper results are recorded by the release script under `build-release/release-verification/KJMixPro-0.6.5-macOS/`.
- This is a native macOS styling update, not a claim of Apple certification or an exhaustive accessibility audit.
---
# KJMix Pro 0.6.4 (build 15) verification
Verified on 3 November 2026.