importer chnges

This commit is contained in:
Mark Gregory
2026-11-05 11:02:22 +00:00
parent 1d6819d99e
commit 87cdb42af9
6 changed files with 444 additions and 29 deletions
+35 -11
View File
@@ -77,6 +77,8 @@ struct SongDragReference: Codable, Sendable {
@Published var hostSettings = HostSettings()
@Published var rotationSingers = [RotationSinger]()
@Published var showScreens = [ShowScreen]()
@Published var namesBusy = false
@Published private(set) var canUndoNames = false
@Published var tagsBusy = false
@Published var tagsStatus = ""
let singerServer = SingerWebServer()
@@ -88,7 +90,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,!isImporting,!movingMedia else { error = "Wait for the current waveform or location scan to finish, or cancel it first.";return }
guard !generatingWaveforms,scanningSource == nil,!isImporting,!movingMedia,!namesBusy 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")
@@ -155,7 +157,7 @@ struct SongDragReference: Codable, Sendable {
rotationSingers = try library.rotation(currentVenue)
hostSettings = try library.setting("host.\(currentVenue)").flatMap { $0.data(using:.utf8) }.flatMap { try? JSONDecoder().decode(HostSettings.self,from:$0) } ?? .init()
} else { rotationSingers = [];hostSettings = .init() }
player.fadeSeconds = hostSettings.fadeSeconds;player.autoFade = hostSettings.autoFade;refreshTuning()
player.fadeSeconds = hostSettings.fadeSeconds;player.autoFade = hostSettings.autoFade;refreshTuning();canUndoNames = try library.hasNameUndo()
}
func chooseVenue(_ id: Int64?) {
perform {
@@ -322,9 +324,9 @@ struct SongDragReference: Codable, Sendable {
}
}
}
func updateMetadata(_ song: Song,title: String,artist: String,album: String,label: String,rating: Int) throws {
func updateMetadata(_ song: Song,title: String,artist: String,album: String,label: String,rating: Int,trackNumber: String? = nil,rendition: String? = nil) throws {
guard let library else { throw LibraryError.message("Library unavailable") }
try library.updateMetadata(song:song.id,title:title,artist:artist,album:album,label:label,rating:rating)
try library.updateMetadata(song:song.id,title:title,artist:artist,album:album,label:label,rating:rating,trackNumber:trackNumber,rendition:rendition)
if player.currentSongID == song.id { player.title = title.trimmingCharacters(in:.whitespacesAndNewlines) }
try refresh()
}
@@ -349,7 +351,7 @@ struct SongDragReference: Codable, Sendable {
try refresh();return id
}
func startSourceScan(_ id: Int64,forceWaveforms: Bool = false) {
guard scanningSource == nil,!generatingWaveforms,!isImporting,!movingMedia else { error = "A waveform or location scan is already running. Cancel it or wait for it to finish.";return }
guard scanningSource == nil,!generatingWaveforms,!isImporting,!movingMedia,!namesBusy 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 = managedMediaDirectory,cache = root.appendingPathComponent("Waveforms")
@@ -392,7 +394,7 @@ struct SongDragReference: Codable, Sendable {
managedMediaDirectory = url
if !mediaAccessURLs.contains(url),url.startAccessingSecurityScopedResource() { mediaAccessURLs.append(url) }
}
private var storageBusy: Bool { isImporting || movingMedia || scanningSource != nil || generatingWaveforms }
private var storageBusy: Bool { isImporting || movingMedia || namesBusy || scanningSource != nil || generatingWaveforms }
func chooseMediaStorage(moveExisting:Bool = false) {
guard !storageBusy else { error = "Finish or cancel the current import, scan or move first.";return }
let panel = NSOpenPanel();panel.canChooseFiles = false;panel.canChooseDirectories = true;panel.canCreateDirectories = true;panel.allowsMultipleSelection = false
@@ -448,7 +450,7 @@ struct SongDragReference: Codable, Sendable {
private var importTask: Task<Void,Never>?
private var importToken: UUID?
func importFiles(_ urls: [URL]) {
guard !isImporting,scanningSource == nil,!generatingWaveforms,!movingMedia else { error = "Wait for the current import, scan or waveform job to finish first.";return }
guard !isImporting,scanningSource == nil,!generatingWaveforms,!movingMedia,!namesBusy 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 = managedMediaDirectory
importToken = token;isImporting = true;importProgress = ImportProgress()
@@ -482,6 +484,8 @@ struct SongDragReference: Codable, Sendable {
}
private struct LibraryCommands {
var importMusic: () -> Void
var reinterpret: () -> Void
var canReinterpret: Bool
var songInfo: () -> Void
var canEdit: Bool
var play: () -> Void
@@ -497,7 +501,7 @@ private extension FocusedValues {
}
}
@main struct KJMixProApp: App {
@StateObject private var model = AppModel()
@StateObject private var model = AppModel(storageURL:ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil ? nil : FileManager.default.temporaryDirectory.appendingPathComponent("KJMix-TestHost-"+String(ProcessInfo.processInfo.processIdentifier)))
@AppStorage("studioAppearance") private var appearance = "system"
@FocusedValue(\.libraryCommands) private var actions
var body: some Scene {
@@ -519,6 +523,10 @@ private extension FocusedValues {
Button("Song Info…") { actions?.songInfo() }.keyboardShortcut("i").disabled(actions?.canEdit != true)
Button("Add Drive or Folder…") { model.chooseMediaLocation() }
}
CommandMenu("Advanced") {
Button("Reinterpret Names…") { actions?.reinterpret() }.disabled(actions?.canReinterpret != true || model.namesBusy)
Button("Undo Last Name Reinterpretation") { Task { do { try await model.undoNameChanges() } catch { model.error = error.localizedDescription } } }.disabled(!model.canUndoNames || model.namesBusy)
}
CommandMenu("Playback") {
Button("Play / Pause") { if model.player.duration > 0 { model.player.toggle() } else { actions?.play() } }.keyboardShortcut(.space,modifiers:[])
Button("Stop Deck (Keep Track Loaded)") { model.player.stopDeck() }.keyboardShortcut(".",modifiers:[.command,.shift])
@@ -646,6 +654,8 @@ struct LibraryView: View {
@State private var tableSelection = Set<Int64>()
@State private var waveformDetails = false
@State private var batchEditing = false
@State private var reinterpreting = false
@State private var reinterpretIDs = Set<Int64>()
@State private var metadataLookup: Song?
@State private var confirmWriteTags = false
@State private var search = ""
@@ -717,7 +727,7 @@ 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"}))
.focusedSceneValue(\.libraryCommands,LibraryCommands(importMusic:{importing = true},reinterpret:{beginReinterpretation()},canReinterpret:["Library","Singer Songs"].contains(source) && (selectedSong != nil || !tableSelection.isEmpty),songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")},settings:{source = "Settings"}))
.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")
@@ -760,6 +770,7 @@ struct LibraryView: View {
}.padding(24).frame(width:560)
}
.sheet(isPresented:$batchEditing) { BatchMetadataSheet(model:model,ids:tableSelection) }
.sheet(isPresented:$reinterpreting) { ReinterpretNamesSheet(model:model,ids:reinterpretIDs) }
.sheet(item:$metadataLookup) { song in MetadataLookupSheet(model:model,song:song) }
.confirmationDialog("Write file tags for \(tableSelection.count) songs? Original files will be backed up in TagBackups before replacement. Unsupported files are reported and left unchanged.",isPresented:$confirmWriteTags) {
Button("Write File Tags") { model.writeFileTags(tableSelection) }
@@ -947,6 +958,8 @@ struct LibraryView: View {
Text(song.title).fontWeight(.medium).lineLimit(1)
}.padding(.vertical,5).onDrag { model.dragReference(song.id).provider() }
}.width(min:180,ideal:220,max:600)
TableColumn("Track",value:\Song.trackOrder) { song in Text(song.trackNumber.isEmpty ? "—" : song.trackNumber).monospacedDigit().foregroundStyle(.secondary) }.width(45)
TableColumn("Rendition",value:\Song.rendition) { song in Text(song.rendition.isEmpty ? "—" : song.rendition).foregroundStyle(.secondary) }.width(min:70,ideal:100,max:220)
TableColumn("Artist",value:\Song.artist) { song in Text(song.artist.isEmpty ? "—" : song.artist).foregroundStyle(.secondary) }.width(min:90,ideal:110,max:240)
TableColumn("Album",value:\Song.album) { song in Text(song.album.isEmpty ? "—" : song.album).foregroundStyle(.secondary) }.width(min:85,ideal:95,max:220)
TableColumn("Time",value:\Song.duration) { song in Text(durationLabel(song.duration)).monospacedDigit().foregroundStyle(.secondary) }.width(50)
@@ -969,6 +982,7 @@ struct LibraryView: View {
if ids.count > 1 {
Button("Generate Waveforms for \(ids.count) Selected Tracks") { model.generateWaveforms(ids) }.disabled(model.generatingWaveforms || model.scanningSource != nil)
Button("Regenerate Selected Waveforms") { model.generateWaveforms(ids,force:true) }.disabled(model.generatingWaveforms || model.scanningSource != nil)
Button("Reinterpret Names…") { beginReinterpretation(ids) }
Button("Edit \(ids.count) Songs…") { tableSelection = ids;batchEditing = true }
}
else if let id = ids.first,let song = model.songs.first(where:{$0.id == id}) { songActions(song) }
@@ -989,6 +1003,7 @@ struct LibraryView: View {
Button("Song Info…") { editingSong = song }
Button("Generate Waveform") { model.generateWaveforms([song.id]) }.disabled(model.generatingWaveforms || model.scanningSource != nil || song.audio.hasPrefix("showscreen:"))
Button("Regenerate Waveform") { model.generateWaveforms([song.id],force:true) }.disabled(model.generatingWaveforms || model.scanningSource != nil || song.audio.hasPrefix("showscreen:"))
Button("Reinterpret Name…") { beginReinterpretation([song.id]) }
Button("Find Metadata Online…") { metadataLookup = song }
Button("Batch Edit Selected…") { if tableSelection.isEmpty { tableSelection = [song.id] };batchEditing = true }
Button("Write File Tags…") { tableSelection = [song.id];confirmWriteTags = true }.disabled(model.tagsBusy || song.audio.hasPrefix("showscreen:"))
@@ -1160,6 +1175,7 @@ struct LibraryView: View {
Text(model.currentVenueName).font(.caption).foregroundStyle(.secondary)
RepeatButton(player:model.player).buttonStyle(.borderless)
Menu {
Button("Reinterpret Selected Names…") { beginReinterpretation() }.disabled(tableSelection.isEmpty && selectedSong == nil)
Button("Batch Edit Selected…") { batchEditing = true }.disabled(tableSelection.isEmpty)
Button("Find Metadata Online…") { metadataLookup = selectedSong }.disabled(selectedSong == nil)
Button("Write File Tags…") { confirmWriteTags = true }.disabled(tableSelection.isEmpty || model.tagsBusy)
@@ -1169,6 +1185,10 @@ struct LibraryView: View {
} label: { Image(systemName:"ellipsis.circle") }.menuStyle(.borderlessButton).frame(width:20).help("Library actions")
}
}
private func beginReinterpretation(_ ids:Set<Int64>? = nil) {
let chosen = ids ?? (source == "Library" && !tableSelection.isEmpty ? tableSelection : Set(selected.map{[$0]} ?? []))
guard !chosen.isEmpty else { return };reinterpretIDs = chosen;reinterpreting = true
}
private func playSelected() {
guard let song = selectedSong else { return }
model.perform { try model.play(song) }
@@ -1275,6 +1295,8 @@ private struct SongMetadataSheet: View {
@State private var artist = ""
@State private var album = ""
@State private var label = ""
@State private var track = ""
@State private var rendition = ""
@State private var rating = 0
@State private var error: String?
var body: some View {
@@ -1285,6 +1307,8 @@ private struct SongMetadataSheet: View {
GridRow { Text("Artist");TextField("Artist",text:$artist) }
GridRow { Text("Album");TextField("Album",text:$album) }
GridRow { Text("Label");TextField("Label / publisher",text:$label) }
GridRow { Text("Track");TextField("Track number",text:$track) }
GridRow { Text("Rendition");TextField("Version / vocals",text:$rendition) }
GridRow { Text("Rating");GoldRating(rating:$rating) }
}.textFieldStyle(.roundedBorder)
Text("Saved to your library. Embedded audio-file tags are not changed, and re-importing will preserve your library edits.").font(.caption).foregroundStyle(.secondary)
@@ -1292,9 +1316,9 @@ private struct SongMetadataSheet: View {
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)
.onAppear { title = song.title;artist = song.artist;album = song.album;label = song.label;rating = song.rating }
.onAppear { title = song.title;artist = song.artist;album = song.album;label = song.label;rating = song.rating;track = song.trackNumber;rendition = song.rendition }
}
private func save() { do { try model.updateMetadata(song,title:title,artist:artist,album:album,label:label,rating:rating);dismiss() } catch { self.error = error.localizedDescription } }
private func save() { do { try model.updateMetadata(song,title:title,artist:artist,album:album,label:label,rating:rating,trackNumber:track,rendition:rendition);dismiss() } catch { self.error = error.localizedDescription } }
}
private struct LibraryLocationsPane: View {
@ObservedObject var model: AppModel