importer chnges
This commit is contained in:
@@ -150,6 +150,9 @@ struct PlaylistWorkspace: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@Binding var query: String
|
||||
@State private var selected: Int64?
|
||||
@State private var nameSelection = Set<Int64>()
|
||||
@State private var reinterpreting = false
|
||||
@State private var reinterpretIDs = Set<Int64>()
|
||||
@State private var name = ""
|
||||
@State private var sort = "Saved Order"
|
||||
@State private var ascending = true
|
||||
@@ -164,9 +167,11 @@ struct PlaylistWorkspace: View {
|
||||
guard sort != "Saved Order" else { return songs }
|
||||
return songs.sorted { a,b in
|
||||
let first: Bool
|
||||
if sort == "Duration" { first = a.duration == b.duration ? a.id < b.id : a.duration < b.duration }
|
||||
if sort == "Song ID" { first = a.id < b.id }
|
||||
else if sort == "Track" { first = a.trackOrder == b.trackOrder ? a.id < b.id : a.trackOrder < b.trackOrder }
|
||||
else if sort == "Duration" { first = a.duration == b.duration ? a.id < b.id : a.duration < b.duration }
|
||||
else if sort == "Rating" { first = model.displayedRating(a) == model.displayedRating(b) ? a.id < b.id : model.displayedRating(a) < model.displayedRating(b) }
|
||||
else { let av = sort == "Artist" ? a.artist : sort == "Album" ? a.album : a.title,bv = sort == "Artist" ? b.artist : sort == "Album" ? b.album : b.title;let comparison = av.localizedStandardCompare(bv);first = comparison == .orderedSame ? a.id < b.id : comparison == .orderedAscending }
|
||||
else { let av = sort == "Artist" ? a.artist : sort == "Album" ? a.album : sort == "Rendition" ? a.rendition : a.title,bv = sort == "Artist" ? b.artist : sort == "Album" ? b.album : sort == "Rendition" ? b.rendition : b.title;let comparison = av.localizedStandardCompare(bv);first = comparison == .orderedSame ? a.id < b.id : comparison == .orderedAscending }
|
||||
return ascending ? first : !first
|
||||
}
|
||||
}
|
||||
@@ -179,11 +184,11 @@ struct PlaylistWorkspace: View {
|
||||
Menu { Button("Rename") { if let selected { model.rename("playlists",id:selected,name:name) } }.disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty);Button("Delete…",role:.destructive) { delete = true } } label: { Image(systemName:"ellipsis.circle") }.disabled(selected == nil)
|
||||
}
|
||||
HStack {
|
||||
Picker("Sort",selection:$sort) { ForEach(["Saved Order","Title","Artist","Album","Duration","Rating"],id:\.self) { Text($0) } }.frame(width:200)
|
||||
Picker("Sort",selection:$sort) { ForEach(["Saved Order","Song ID","Title","Artist","Album","Track","Rendition","Duration","Rating"],id:\.self) { Text($0) } }.frame(width:200)
|
||||
Button { ascending.toggle() } label: { Image(systemName:ascending ? "arrow.up" : "arrow.down") }.disabled(sort == "Saved Order").help("Reverse sort")
|
||||
Text("\(rows.count) songs").font(.caption).foregroundStyle(.secondary);Spacer();Button("Add Songs…") { adding = true }.disabled(selected == nil)
|
||||
Text("\(rows.count) songs").font(.caption).foregroundStyle(.secondary);Spacer();Button(nameSelection.isEmpty ? "Reinterpret Visible Songs…" : "Reinterpret Selected…") { reinterpretIDs = nameSelection.isEmpty ? Set(rows.map(\.id)) : nameSelection.intersection(Set(rows.map(\.id)));reinterpreting = true }.disabled(rows.isEmpty);Button("Add Songs…") { adding = true }.disabled(selected == nil)
|
||||
}
|
||||
List(Array(rows.enumerated()),id:\.element.id) { index,song in
|
||||
List(Array(rows.enumerated()),id:\.element.id,selection:$nameSelection) { index,song in
|
||||
HStack {
|
||||
Text("\(index+1)").font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width:22)
|
||||
VStack(alignment:.leading,spacing:4) { Text(song.title);Text(song.artist+" · "+song.album).font(.caption).foregroundStyle(.secondary) };Spacer()
|
||||
@@ -192,13 +197,17 @@ struct PlaylistWorkspace: View {
|
||||
Button { move(song.id,-1) } label: { Image(systemName:"chevron.up") }.disabled(sort != "Saved Order" || !query.isEmpty || index == 0)
|
||||
Button { move(song.id,1) } label: { Image(systemName:"chevron.down") }.disabled(sort != "Saved Order" || !query.isEmpty || index == rows.count-1)
|
||||
Button { model.perform { if let selected { try model.library?.removeFromPlaylist(selected,song:song.id);try model.refresh() } } } label: { Image(systemName:"minus.circle") }.help("Remove from playlist")
|
||||
}.padding(.vertical,5).onDrag { model.dragReference(song.id).provider() }
|
||||
}.padding(.vertical,5).tag(song.id).onDrag { model.dragReference(song.id).provider() }
|
||||
.contextMenu { Button("Reinterpret Name…") { reinterpretIDs = nameSelection.contains(song.id) ? nameSelection : [song.id];reinterpreting = true } }
|
||||
}.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)).sheet(isPresented:$adding) { addSheet }
|
||||
.sheet(isPresented:$reinterpreting) { ReinterpretNamesSheet(model:model,ids:reinterpretIDs) }
|
||||
.onChange(of:query) { _ in nameSelection = [] }
|
||||
.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
|
||||
nameSelection = []
|
||||
if let id,let value = try? model.library?.setting("playlist.sort.\(id)") { let parts = value.components(separatedBy:"|");sort = parts[0];ascending = parts.count < 2 || parts[1] == "asc" } else { sort = "Saved Order";ascending = true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ struct Importer {
|
||||
var archives = [(url:URL,signature:String,folder:URL)]()
|
||||
var archiveForFile = [String:URL]()
|
||||
var archiveSongIDs = [URL:[Int64]]()
|
||||
var archiveOrigins = [URL:URL]()
|
||||
func importSignature(_ audio: URL,cdg: URL? = nil) throws -> String {
|
||||
func signature(_ url: URL) throws -> String {
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath:url.path)
|
||||
@@ -82,13 +83,19 @@ struct Importer {
|
||||
return try signature(audio)+"|"+(cdg.map { try signature($0) } ?? "no-cdg")+"|copy=\(copyMedia)"
|
||||
}
|
||||
func remember(_ audio:URL,_ signature:String,_ id:Int64) throws {
|
||||
var originalPath = audio.path
|
||||
if let folder = archiveForFile[audio.resolvingSymlinksInPath().path],let zip = archiveOrigins[folder] {
|
||||
let relative = String(audio.resolvingSymlinksInPath().path.dropFirst(folder.resolvingSymlinksInPath().path.count+1))
|
||||
originalPath = zip.path+"/"+relative
|
||||
}
|
||||
try library.recordOriginalName(song:id,name:audio.deletingPathExtension().lastPathComponent,path:originalPath)
|
||||
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 files 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) {
|
||||
if let existing = try library.importedSongs(path:zip.standardizedFileURL.path,signature:signature),try library.originalNamesRecorded(existing) {
|
||||
songIDs.formUnion(existing);state.completed += existing.count;state.skipped += existing.count
|
||||
report("Already imported: "+zip.lastPathComponent,force:state.completed == existing.count);continue
|
||||
}
|
||||
@@ -96,7 +103,7 @@ struct Importer {
|
||||
let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
temporary.append(folder)
|
||||
archives.append((zip,signature,folder))
|
||||
archives.append((zip,signature,folder));archiveOrigins[folder] = zip
|
||||
// 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") }
|
||||
@@ -165,7 +172,7 @@ struct Importer {
|
||||
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) }
|
||||
for id in existing { try remember(audio,signature,id) }
|
||||
state.completed += 1;state.skipped += 1
|
||||
report("Already imported: "+audio.lastPathComponent,force:state.completed == 1);continue
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
struct Song: Identifiable, Hashable {
|
||||
struct Song: Identifiable, Hashable, Sendable {
|
||||
var id: Int64
|
||||
var title: String
|
||||
var audio: String
|
||||
@@ -13,6 +13,11 @@ struct Song: Identifiable, Hashable {
|
||||
var label: String = ""
|
||||
var duration: Double = 0
|
||||
var fingerprint: String = ""
|
||||
var trackNumber: String = ""
|
||||
var rendition: String = ""
|
||||
var originalName: String = ""
|
||||
var originalPath: String = ""
|
||||
var trackOrder: Int { Int(trackNumber) ?? 0 }
|
||||
}
|
||||
struct MediaLocation: Identifiable, Hashable, Sendable {
|
||||
var id: Int64
|
||||
@@ -80,6 +85,9 @@ final class Library {
|
||||
try execute("CREATE TABLE IF NOT EXISTS singers(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)")
|
||||
try execute("CREATE TABLE IF NOT EXISTS venues(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)")
|
||||
try execute("CREATE TABLE IF NOT EXISTS history(id INTEGER PRIMARY KEY,song INTEGER REFERENCES songs(id),singer INTEGER REFERENCES singers(id),venue INTEGER REFERENCES venues(id),played TEXT DEFAULT CURRENT_TIMESTAMP)")
|
||||
for column in ["track_number","rendition","original_name","original_path"] {
|
||||
if try !columns("songs").contains(column) { try execute("ALTER TABLE songs ADD COLUMN \(column) TEXT NOT NULL DEFAULT ''") }
|
||||
}
|
||||
// Additive migration: preserve older libraries and infer existing memberships from history.
|
||||
for column in ["artist", "album", "label"] {
|
||||
if try !columns("songs").contains(column) { try execute("ALTER TABLE songs ADD COLUMN \(column) TEXT NOT NULL DEFAULT ''") }
|
||||
@@ -121,7 +129,8 @@ final class Library {
|
||||
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("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")
|
||||
try execute("CREATE TABLE IF NOT EXISTS reinterpret_undo(batch TEXT NOT NULL,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,before_json TEXT NOT NULL,after_json TEXT NOT NULL,edited INTEGER NOT NULL,created TEXT NOT NULL,PRIMARY KEY(batch,song))")
|
||||
try execute("PRAGMA user_version=11")
|
||||
}
|
||||
deinit { sqlite3_close(db) }
|
||||
private func statement(_ sql: String, _ values: [String?]) throws -> OpaquePointer {
|
||||
@@ -139,11 +148,11 @@ final class Library {
|
||||
guard sqlite3_step(s) == SQLITE_DONE else { throw error() }
|
||||
}
|
||||
func songs() throws -> [Song] {
|
||||
let s = try statement("SELECT id,title,audio,cdg,rating,plays,artist,album,label,duration,fingerprint FROM songs ORDER BY title COLLATE NOCASE", []); defer { sqlite3_finalize(s) }
|
||||
let s = try statement("SELECT id,title,audio,cdg,rating,plays,artist,album,label,duration,fingerprint,track_number,rendition,original_name,original_path FROM songs ORDER BY title COLLATE NOCASE", []); defer { sqlite3_finalize(s) }
|
||||
var result = [Song]()
|
||||
while true {
|
||||
let code = sqlite3_step(s); if code == SQLITE_DONE { break }; guard code == SQLITE_ROW else { throw error() }
|
||||
result.append(Song(id: sqlite3_column_int64(s,0), title: String(cString: sqlite3_column_text(s,1)), audio: String(cString: sqlite3_column_text(s,2)), cdg: sqlite3_column_text(s,3).map { String(cString:$0) }, rating: Int(sqlite3_column_int(s,4)), plays: Int(sqlite3_column_int(s,5)), artist: text(s,6), album: text(s,7), label: text(s,8),duration:sqlite3_column_double(s,9),fingerprint:text(s,10)))
|
||||
result.append(Song(id: sqlite3_column_int64(s,0), title: String(cString: sqlite3_column_text(s,1)), audio: String(cString: sqlite3_column_text(s,2)), cdg: sqlite3_column_text(s,3).map { String(cString:$0) }, rating: Int(sqlite3_column_int(s,4)), plays: Int(sqlite3_column_int(s,5)), artist: text(s,6), album: text(s,7), label: text(s,8),duration:sqlite3_column_double(s,9),fingerprint:text(s,10),trackNumber:text(s,11),rendition:text(s,12),originalName:text(s,13),originalPath:text(s,14)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -339,11 +348,14 @@ final class Library {
|
||||
else { try execute("UPDATE music_stores SET name=?,url=?,search_template=? WHERE id=?",[name,url,search,String(store.id)]) }
|
||||
}
|
||||
func deleteStore(_ id: Int64) throws { try execute("DELETE FROM music_stores WHERE id=?",[String(id)]) }
|
||||
func updateMetadata(song: Int64,title: String,artist: String,album: String,label: String,rating: Int) throws {
|
||||
func updateMetadata(song: Int64,title: String,artist: String,album: String,label: String,rating: Int,trackNumber: String? = nil,rendition: String? = nil) throws {
|
||||
let title = title.trimmingCharacters(in:.whitespacesAndNewlines)
|
||||
guard !title.isEmpty else { throw LibraryError.message("Song title cannot be empty") }
|
||||
if let trackNumber,!trackNumber.isEmpty,trackNumber.count > 3 || !trackNumber.allSatisfy({$0.isASCII && $0.isNumber}) { throw LibraryError.message("Use a track number from 0 to 999, or leave it empty") }
|
||||
try execute("UPDATE songs SET title=?,artist=?,album=?,label=?,rating=?,metadata_edited=1 WHERE id=?",[title,artist.trimmingCharacters(in:.whitespacesAndNewlines),album.trimmingCharacters(in:.whitespacesAndNewlines),label.trimmingCharacters(in:.whitespacesAndNewlines),String(rating),String(song)])
|
||||
guard sqlite3_changes(db) == 1 else { throw LibraryError.message("Song no longer exists") }
|
||||
if let trackNumber { try execute("UPDATE songs SET track_number=? WHERE id=?",[trackNumber,String(song)]) }
|
||||
if let rendition { try execute("UPDATE songs SET rendition=? WHERE id=?",[rendition,String(song)]) }
|
||||
}
|
||||
func tonightSongIDs(venue: Int64?) throws -> [Int64:[Int64]] {
|
||||
guard let venue else { return [:] }
|
||||
@@ -432,9 +444,9 @@ final class Library {
|
||||
try execute("UPDATE media_sources SET path=?,last_scan=?,last_error=? WHERE id=?",[path,ISO8601DateFormatter().string(from:Date()),message,String(source)])
|
||||
}
|
||||
func song(_ id: Int64) throws -> Song? {
|
||||
let s = try statement("SELECT id,title,audio,cdg,rating,plays,artist,album,label,duration,fingerprint FROM songs WHERE id=?",[String(id)]);defer { sqlite3_finalize(s) }
|
||||
let s = try statement("SELECT id,title,audio,cdg,rating,plays,artist,album,label,duration,fingerprint,track_number,rendition,original_name,original_path FROM songs WHERE id=?",[String(id)]);defer { sqlite3_finalize(s) }
|
||||
guard sqlite3_step(s) == SQLITE_ROW else { return nil }
|
||||
return .init(id:sqlite3_column_int64(s,0),title:text(s,1),audio:text(s,2),cdg:sqlite3_column_type(s,3) == SQLITE_NULL ? nil : text(s,3),rating:Int(sqlite3_column_int(s,4)),plays:Int(sqlite3_column_int(s,5)),artist:text(s,6),album:text(s,7),label:text(s,8),duration:sqlite3_column_double(s,9),fingerprint:text(s,10))
|
||||
return .init(id:sqlite3_column_int64(s,0),title:text(s,1),audio:text(s,2),cdg:sqlite3_column_type(s,3) == SQLITE_NULL ? nil : text(s,3),rating:Int(sqlite3_column_int(s,4)),plays:Int(sqlite3_column_int(s,5)),artist:text(s,6),album:text(s,7),label:text(s,8),duration:sqlite3_column_double(s,9),fingerprint:text(s,10),trackNumber:text(s,11),rendition:text(s,12),originalName:text(s,13),originalPath:text(s,14))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,3 +570,60 @@ extension Library {
|
||||
return sqlite3_step(query) == SQLITE_ROW ? try song(sqlite3_column_int64(query,0)) : nil
|
||||
}
|
||||
}
|
||||
|
||||
extension Library {
|
||||
func recordOriginalName(song:Int64,name:String,path:String) throws {
|
||||
try execute("UPDATE songs SET original_name=?,original_path=? WHERE id=? AND original_name=''",[name,path,String(song)])
|
||||
}
|
||||
func originalNamesRecorded(_ ids:[Int64]) throws -> Bool {
|
||||
for id in ids { if try song(id)?.originalName.isEmpty != false { return false } };return true
|
||||
}
|
||||
func hasNameUndo() throws -> Bool {
|
||||
let query = try statement("SELECT 1 FROM reinterpret_undo LIMIT 1",[]);defer { sqlite3_finalize(query) };return sqlite3_step(query) == SQLITE_ROW
|
||||
}
|
||||
@discardableResult func reinterpretNames(_ changes:[NameChange]) throws -> Int {
|
||||
let changes = changes.filter{$0.before != $0.after}
|
||||
guard !changes.isEmpty else { return 0 }
|
||||
guard Set(changes.map(\.id)).count == changes.count else { throw LibraryError.message("A song appears more than once in this batch") }
|
||||
let batch = UUID().uuidString,created = ISO8601DateFormatter().string(from:Date())
|
||||
try execute("BEGIN IMMEDIATE")
|
||||
do {
|
||||
for change in changes {
|
||||
guard let current = try song(change.id),NameMetadata(current) == change.before else { throw LibraryError.message("A song changed after the preview. Refresh the preview before applying.") }
|
||||
let query = try statement("SELECT metadata_edited FROM songs WHERE id=?",[String(change.id)])
|
||||
let edited = sqlite3_step(query) == SQLITE_ROW ? sqlite3_column_int(query,0) : 0;sqlite3_finalize(query)
|
||||
let before = String(decoding:try JSONEncoder().encode(change.before),as:UTF8.self),after = String(decoding:try JSONEncoder().encode(change.after),as:UTF8.self)
|
||||
try execute("INSERT INTO reinterpret_undo(batch,song,before_json,after_json,edited,created) VALUES(?,?,?,?,?,?)",[batch,String(change.id),before,after,String(edited),created])
|
||||
try setNameMetadata(change.after,id:change.id,edited:1)
|
||||
}
|
||||
try execute("DELETE FROM reinterpret_undo WHERE batch NOT IN (SELECT batch FROM reinterpret_undo GROUP BY batch ORDER BY MAX(rowid) DESC LIMIT 10)")
|
||||
try execute("COMMIT");return changes.count
|
||||
} catch { try? execute("ROLLBACK");throw error }
|
||||
}
|
||||
@discardableResult func undoNameReinterpretation() throws -> Int {
|
||||
try execute("BEGIN IMMEDIATE")
|
||||
do {
|
||||
let latest = try statement("SELECT batch FROM reinterpret_undo ORDER BY rowid DESC LIMIT 1",[])
|
||||
guard sqlite3_step(latest) == SQLITE_ROW else { sqlite3_finalize(latest);throw LibraryError.message("There is no name reinterpretation to undo") }
|
||||
let batch = text(latest,0);sqlite3_finalize(latest)
|
||||
let query = try statement("SELECT song,before_json,after_json,edited FROM reinterpret_undo WHERE batch=? ORDER BY song",[batch])
|
||||
var rows = [(Int64,NameMetadata,NameMetadata,Int32)]()
|
||||
do {
|
||||
defer { sqlite3_finalize(query) }
|
||||
while sqlite3_step(query) == SQLITE_ROW {
|
||||
let before = try JSONDecoder().decode(NameMetadata.self,from:Data(text(query,1).utf8)),after = try JSONDecoder().decode(NameMetadata.self,from:Data(text(query,2).utf8))
|
||||
rows.append((sqlite3_column_int64(query,0),before,after,sqlite3_column_int(query,3)))
|
||||
}
|
||||
}
|
||||
for (id,before,after,edited) in rows {
|
||||
guard let current = try song(id),NameMetadata(current) == after else { throw LibraryError.message("A song was edited after reinterpretation. Undo would overwrite that edit, so no songs were changed.") }
|
||||
try setNameMetadata(before,id:id,edited:edited)
|
||||
}
|
||||
try execute("DELETE FROM reinterpret_undo WHERE batch=?",[batch]);try execute("COMMIT");return rows.count
|
||||
} catch { try? execute("ROLLBACK");throw error }
|
||||
}
|
||||
private func setNameMetadata(_ value:NameMetadata,id:Int64,edited:Int32) throws {
|
||||
guard !value.title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty else { throw LibraryError.message("Song title cannot be empty") }
|
||||
try execute("UPDATE songs SET title=?,artist=?,album=?,track_number=?,rendition=?,metadata_edited=? WHERE id=?",[value.title,value.artist,value.album,value.track,value.rendition,String(edited),String(id)])
|
||||
}
|
||||
}
|
||||
|
||||
+207
-2
@@ -41,11 +41,13 @@ enum SongSearch {
|
||||
case "artist","a":fields = [song.artist]
|
||||
case "album":fields = [song.album]
|
||||
case "label":fields = [song.label]
|
||||
case "track","t":fields = [song.trackNumber]
|
||||
case "rendition","version","v":fields = [song.rendition]
|
||||
case "id":fields = [String(song.id)]
|
||||
case "rating":fields = [String(song.rating)]
|
||||
default:fields = [song.title,song.artist,song.album,song.label];return fields.contains{$0.localizedCaseInsensitiveContains(token)}
|
||||
default:fields = [song.title,song.artist,song.album,song.label,song.rendition];return fields.contains{$0.localizedCaseInsensitiveContains(token)}
|
||||
}
|
||||
} else { fields = [song.title,song.artist,song.album,song.label];term = parts[0] }
|
||||
} else { fields = [song.title,song.artist,song.album,song.label,song.rendition];term = parts[0] }
|
||||
if term.hasPrefix("=") { return fields.contains{$0.compare(String(term.dropFirst()),options:[.caseInsensitive,.diacriticInsensitive]) == .orderedSame} }
|
||||
return fields.contains{$0.localizedCaseInsensitiveContains(term)}
|
||||
}
|
||||
@@ -265,3 +267,206 @@ struct StoreCatalogueSheet: View {
|
||||
}
|
||||
private func open(_ item: StoreCatalogueItem,preview: Bool = false) { do { NSWorkspace.shared.open(try item.destination(preview:preview)) } catch { message = error.localizedDescription } }
|
||||
}
|
||||
|
||||
struct NameMetadata: Codable, Equatable, Sendable {
|
||||
var title: String
|
||||
var artist: String
|
||||
var album: String
|
||||
var track: String
|
||||
var rendition: String
|
||||
init(_ song:Song) { title = song.title;artist = song.artist;album = song.album;track = song.trackNumber;rendition = song.rendition }
|
||||
}
|
||||
struct NameChange: Sendable {
|
||||
var id: Int64
|
||||
var before: NameMetadata
|
||||
var after: NameMetadata
|
||||
}
|
||||
enum NameSource: String, CaseIterable, Sendable { case song = "Song Name",file = "File Name" }
|
||||
struct NamePreview: Identifiable, Sendable {
|
||||
var id: Int64
|
||||
var input: String
|
||||
var before: NameMetadata
|
||||
var after: NameMetadata?
|
||||
var issue: String?
|
||||
var change: NameChange? { guard let after,after != before else { return nil };return .init(id:id,before:before,after:after) }
|
||||
}
|
||||
struct NameTemplate {
|
||||
static let presets = ["L-T - R - S","L-T - S - R","R - S","S - R","L - R - S","L-T - R - S (V)","L-T - R - S [V]","T. R - S","/R/S","/L/R/S","L-L-T-R-S","L-T - R-R - S"]
|
||||
static let settingsKey = "names.templates.v1"
|
||||
private let fields: [Character]
|
||||
private let expression: NSRegularExpression
|
||||
let usesPath: Bool
|
||||
init(_ template:String) throws {
|
||||
let allowed = Set("LTRSIV-|()[]{}./ ")
|
||||
guard !template.isEmpty,template.count <= 160,template.allSatisfy({allowed.contains($0)}) else { throw LibraryError.message("Use L, T, R, S, I, V and the separators - | ( ) [ ] { } . / or spaces. Maximum 160 characters.") }
|
||||
let tokens = Set("LTRSIV"),characters = Array(template)
|
||||
var fields = [Character](),pattern = ""
|
||||
usesPath = template.contains("/")
|
||||
var index = template.hasPrefix("/") ? 1 : 0
|
||||
while index < characters.count {
|
||||
let character = characters[index]
|
||||
if tokens.contains(character) {
|
||||
if index > 0,tokens.contains(characters[index-1]) { throw LibraryError.message("Separate field letters with a dash, space or another literal separator") }
|
||||
fields.append(character)
|
||||
pattern += character == "T" ? "([0-9]{1,3})" : usesPath ? "([^/]+?)" : "(.+?)"
|
||||
} else if character == "-" { pattern += "\\s*-\\s*" }
|
||||
else if character == " " {
|
||||
let previous = characters[..<index].last(where:{$0 != " "}),next = characters.dropFirst(index+1).first(where:{$0 != " "})
|
||||
pattern += previous == "-" || next == "-" ? "\\s*" : "\\s+"
|
||||
} else { pattern += NSRegularExpression.escapedPattern(for:String(character)) }
|
||||
index += 1
|
||||
}
|
||||
guard fields.count <= 12,fields.contains(where:{$0 != "I"}),fields.filter({$0 == "T"}).count <= 1 else { throw LibraryError.message("Use up to 12 fields, at least one metadata field, and no more than one T field") }
|
||||
self.fields = fields
|
||||
expression = try NSRegularExpression(pattern:(usesPath ? "^(?:.*/)?" : "^")+pattern+"$")
|
||||
}
|
||||
func interpret(_ text:String,before:NameMetadata,emDashes:Bool = false,underscores:Bool = true) throws -> NameMetadata {
|
||||
guard text.count <= 2048 else { throw LibraryError.message("Name is too long to reinterpret") }
|
||||
var input = text.trimmingCharacters(in:.whitespacesAndNewlines)
|
||||
if emDashes { for dash in ["—","–","−"] { input = input.replacingOccurrences(of:dash,with:"-") } }
|
||||
if underscores { input = input.replacingOccurrences(of:"_",with:" ") }
|
||||
let range = NSRange(input.startIndex...,in:input)
|
||||
guard let match = expression.firstMatch(in:input,range:range) else { throw LibraryError.message("Does not match this template") }
|
||||
var values = [Character:[String]]()
|
||||
for (index,field) in fields.enumerated() where field != "I" {
|
||||
guard let range = Range(match.range(at:index+1),in:input) else { continue }
|
||||
let value = String(input[range]).trimmingCharacters(in:.whitespacesAndNewlines)
|
||||
guard !value.isEmpty else { throw LibraryError.message("A metadata field is empty") }
|
||||
values[field,default:[]].append(value)
|
||||
}
|
||||
var result = before
|
||||
if let value = values["S"] { result.title = value.joined(separator:" ") }
|
||||
if let value = values["R"] { result.artist = value.joined(separator:" ") }
|
||||
if let value = values["L"] { result.album = value.joined(separator:" ") }
|
||||
if let value = values["V"] { result.rendition = value.joined(separator:" ") }
|
||||
if let value = values["T"]?.first,let number = Int(value) { result.track = String(format:"%02d",number) }
|
||||
return result
|
||||
}
|
||||
func input(_ song:Song,source:NameSource) throws -> String {
|
||||
if source == .song { return song.title }
|
||||
let path = song.originalPath.isEmpty ? song.audio : song.originalPath
|
||||
let filename = song.originalName.isEmpty ? URL(fileURLWithPath:path).deletingPathExtension().lastPathComponent : song.originalName
|
||||
if song.originalName.isEmpty,filename.count == 64,filename.allSatisfy(\.isHexDigit) { throw LibraryError.message("Original filename was not recorded. Choose Song Name, or reimport the source to record its original name.") }
|
||||
return usesPath ? URL(fileURLWithPath:path).deletingPathExtension().path : filename
|
||||
}
|
||||
static func preview(_ songs:[Song],template:String,source:NameSource,emDashes:Bool,underscores:Bool) throws -> [NamePreview] {
|
||||
let parser = try NameTemplate(template)
|
||||
return try songs.sorted{$0.id < $1.id}.map { song in
|
||||
try Task.checkCancellation()
|
||||
let before = NameMetadata(song)
|
||||
do { let input = try parser.input(song,source:source);return .init(id:song.id,input:input,before:before,after:try parser.interpret(input,before:before,emDashes:emDashes,underscores:underscores)) }
|
||||
catch { return .init(id:song.id,input:song.title,before:before,issue:error.localizedDescription) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppModel {
|
||||
func savedNameTemplates() -> [String] {
|
||||
let saved = ((try? library?.setting(NameTemplate.settingsKey)) ?? "").components(separatedBy:"\n").filter{!$0.trimmingCharacters(in:.whitespaces).isEmpty}
|
||||
return Array(Set(NameTemplate.presets+saved)).sorted()
|
||||
}
|
||||
func saveNameTemplates(_ text:String) throws {
|
||||
let templates = text.components(separatedBy:.newlines).filter{!$0.trimmingCharacters(in:.whitespaces).isEmpty}
|
||||
guard templates.count <= 100 else { throw LibraryError.message("Save up to 100 custom templates") }
|
||||
for (index,template) in templates.enumerated() { do { _ = try NameTemplate(template) } catch { throw LibraryError.message("Line \(index+1): \(error.localizedDescription)") } }
|
||||
try library?.setSetting(NameTemplate.settingsKey,templates.joined(separator:"\n"))
|
||||
}
|
||||
func applyNameChanges(_ changes:[NameChange]) async throws {
|
||||
guard !namesBusy,!isImporting,!movingMedia,scanningSource == nil,!generatingWaveforms,!tagsBusy else { throw LibraryError.message("Finish the current library job first") }
|
||||
namesBusy = true;defer { namesBusy = false }
|
||||
let database = root.appendingPathComponent("Library.sqlite")
|
||||
let count = try await Task.detached(priority:.userInitiated) { try Library(url:database).reinterpretNames(changes) }.value
|
||||
try refresh();tagsStatus = "Reinterpreted \(count) song names"
|
||||
if let id = player.currentSongID,let song = songs.first(where:{$0.id == id}) { player.title = song.title }
|
||||
}
|
||||
func undoNameChanges() async throws {
|
||||
guard !namesBusy,!isImporting,!movingMedia,scanningSource == nil,!generatingWaveforms,!tagsBusy else { throw LibraryError.message("Finish the current library job first") }
|
||||
namesBusy = true;defer { namesBusy = false }
|
||||
let database = root.appendingPathComponent("Library.sqlite")
|
||||
let count = try await Task.detached(priority:.userInitiated) { try Library(url:database).undoNameReinterpretation() }.value
|
||||
try refresh();tagsStatus = "Restored \(count) song names"
|
||||
if let id = player.currentSongID,let song = songs.first(where:{$0.id == id}) { player.title = song.title }
|
||||
}
|
||||
}
|
||||
|
||||
struct ReinterpretNamesSheet: View {
|
||||
@ObservedObject var model: AppModel
|
||||
let ids: Set<Int64>
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var template = "L-T - R - S"
|
||||
@State private var templates = NameTemplate.presets
|
||||
@State private var source = NameSource.song
|
||||
@State private var emDashes = false
|
||||
@State private var underscores = true
|
||||
@State private var matchedOnly = false
|
||||
@State private var editingTemplates = false
|
||||
@State private var templateText = ""
|
||||
@State private var previews = [NamePreview]()
|
||||
@State private var preparing = true
|
||||
@State private var issue: String?
|
||||
private var tracks:[Song] { model.songs.filter{ids.contains($0.id)} }
|
||||
private var matched:Int { previews.filter{$0.after != nil}.count }
|
||||
private var changes:[NameChange] { previews.compactMap(\.change) }
|
||||
private var previewKey:String { template+"|"+source.rawValue+"|\(emDashes)|\(underscores)" }
|
||||
var body: some View {
|
||||
VStack(alignment:.leading,spacing:16) {
|
||||
HStack { VStack(alignment:.leading,spacing:5) { Text("Reinterpret Names").font(.title2.bold());Text("Extract metadata from \(ids.count) selected song names.").foregroundStyle(.secondary) };Spacer();if preparing || model.namesBusy { ProgressView().controlSize(.small) } }
|
||||
HStack {
|
||||
Picker("Use",selection:$source) { ForEach(NameSource.allCases,id:\.self) { Text($0.rawValue).tag($0) } }.pickerStyle(.segmented).frame(width:270)
|
||||
Spacer()
|
||||
Button("Refresh Preview") { model.perform { try model.refresh() };Task { await preparePreview() } }.disabled(preparing)
|
||||
Button("Edit Templates…") { templateText = ((try? model.library?.setting(NameTemplate.settingsKey)) ?? "");editingTemplates = true }
|
||||
}
|
||||
Picker("Name template",selection:$template) { ForEach(templates,id:\.self) { Text($0).font(.system(.body,design:.monospaced)).tag($0) } }
|
||||
Text("L Album · T Track · R Artist · S Title · V Rendition · I Ignore. Repeat letters to combine parts. Use / for containing folders.").font(.caption).foregroundStyle(.secondary)
|
||||
HStack { Toggle("Treat em-dashes as regular dashes",isOn:$emDashes);Toggle("Treat underscores as spaces",isOn:$underscores) }.font(.callout)
|
||||
previewTable
|
||||
if previews.count > 200 { Text("Showing the first 200 rows. All \(previews.count) selected songs are checked.").font(.caption).foregroundStyle(.secondary) }
|
||||
HStack {
|
||||
Text(preparing ? "Preparing preview…" : "\(matched) of \(previews.count) match · \(changes.count) will change").font(.callout.weight(.medium))
|
||||
Spacer();Toggle("Apply matching songs only",isOn:$matchedOnly).font(.callout)
|
||||
}
|
||||
if let issue { Text(issue).foregroundStyle(.red).font(.callout) }
|
||||
Text("Preview is in Song ID order; playlist order is preserved. Only library metadata changes. Original files and embedded tags are kept. Undo is available from the Advanced menu.").font(.caption).foregroundStyle(.secondary)
|
||||
HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction);Button("Apply to \(changes.count) Songs") { apply() }.keyboardShortcut(.defaultAction).buttonStyle(.borderedProminent).disabled(preparing || changes.isEmpty || (!matchedOnly && matched != previews.count) || issue != nil) }
|
||||
}.padding(24).frame(width:1000).disabled(model.namesBusy)
|
||||
.onAppear { templates = model.savedNameTemplates();if !tracks.isEmpty,tracks.allSatisfy({!$0.originalName.isEmpty}) { source = .file } }
|
||||
.task(id:previewKey) { await preparePreview() }
|
||||
.sheet(isPresented:$editingTemplates) { templatesEditor }
|
||||
.interactiveDismissDisabled(model.namesBusy)
|
||||
}
|
||||
private var previewTable: some View {
|
||||
Table(Array(previews.prefix(200))) {
|
||||
TableColumn("ID") { Text("\($0.id)").monospacedDigit().foregroundStyle(.secondary) }.width(40)
|
||||
TableColumn("Source / Result") { row in VStack(alignment:.leading,spacing:3) { Text(row.input).foregroundStyle(.secondary).lineLimit(1);Text(row.after?.title ?? row.issue ?? "No match").foregroundStyle(row.after == nil ? Color.orange : Color.primary).lineLimit(2) } }.width(min:220,ideal:300)
|
||||
TableColumn("Artist") { Text($0.after?.artist ?? "—") }.width(min:110,ideal:170)
|
||||
TableColumn("Album") { Text($0.after?.album ?? "—") }.width(min:75,ideal:110)
|
||||
TableColumn("Track") { Text($0.after?.track ?? "—").monospacedDigit() }.width(45)
|
||||
TableColumn("Rendition") { Text($0.after?.rendition ?? "—") }.width(min:90,ideal:140)
|
||||
}.frame(height:280).overlay { if preparing { ProgressView() } }
|
||||
.accessibilityLabel("Name reinterpretation preview")
|
||||
}
|
||||
private var templatesEditor: some View {
|
||||
VStack(alignment:.leading,spacing:14) {
|
||||
Text("Custom Name Templates").font(.title2.bold())
|
||||
Text("One template per line. Built-in templates are always available. Spaces around dash separators are optional when matching.").foregroundStyle(.secondary)
|
||||
TextEditor(text:$templateText).font(.system(.body,design:.monospaced)).frame(height:220).border(.secondary.opacity(0.2))
|
||||
if let issue { Text(issue).foregroundStyle(.red).font(.caption) }
|
||||
HStack { Spacer();Button("Cancel") { issue = nil;editingTemplates = false };Button("Save Templates") { do { try model.saveNameTemplates(templateText);templates = model.savedNameTemplates();if !templates.contains(template) { template = NameTemplate.presets[0] };issue = nil;editingTemplates = false } catch { issue = error.localizedDescription } }.keyboardShortcut(.defaultAction) }
|
||||
}.padding(24).frame(width:600)
|
||||
}
|
||||
private func preparePreview() async {
|
||||
preparing = true;issue = nil
|
||||
let songs = tracks,chosen = template,input = source,dashes = emDashes,spaces = underscores
|
||||
do {
|
||||
let worker = Task.detached(priority:.userInitiated) { try NameTemplate.preview(songs,template:chosen,source:input,emDashes:dashes,underscores:spaces) }
|
||||
let result = try await withTaskCancellationHandler(operation:{try await worker.value},onCancel:{worker.cancel()})
|
||||
try Task.checkCancellation();previews = result;preparing = false
|
||||
} catch is CancellationError { }
|
||||
catch { if !Task.isCancelled { previews = [];issue = error.localizedDescription;preparing = false } }
|
||||
}
|
||||
private func apply() {
|
||||
let plan = changes
|
||||
Task { do { try await model.applyNameChanges(plan);dismiss() } catch { issue = error.localizedDescription } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +286,107 @@ final class KJamsCloneTests: XCTestCase {
|
||||
XCTAssertTrue(model.songs.allSatisfy{$0.audio.hasPrefix(b.resolvingSymlinksInPath().path+"/")})
|
||||
XCTAssertEqual(model.managedMediaDirectory.resolvingSymlinksInPath().path,b.resolvingSymlinksInPath().path)
|
||||
}
|
||||
func testNameTemplateExtractsAlbumTrackArtistAndTitle() throws {
|
||||
let song = Song(id:1,title:"SF285-16 - Michael Bublé - Haven't Met You Yet",audio:"/audio.mp3",cdg:nil,rating:4,plays:7,label:"Sunfly",duration:210)
|
||||
let parsed = try NameTemplate("L-T - R - S").interpret(song.title,before:NameMetadata(song))
|
||||
XCTAssertEqual(parsed.album,"SF285");XCTAssertEqual(parsed.track,"16");XCTAssertEqual(parsed.artist,"Michael Bublé");XCTAssertEqual(parsed.title,"Haven't Met You Yet")
|
||||
let compact = try NameTemplate("L-T-R-S").interpret("SF285 - 01 - Björk - Jóga",before:NameMetadata(song))
|
||||
XCTAssertEqual(compact.track,"01");XCTAssertEqual(compact.artist,"Björk");XCTAssertEqual(compact.title,"Jóga")
|
||||
}
|
||||
func testNameTemplatesRepeatIgnoreAndExtractRendition() throws {
|
||||
let before = NameMetadata(Song(id:1,title:"Original",audio:"/a",cdg:nil,rating:0,plays:0))
|
||||
let artist = try NameTemplate("L-T - R-R - S").interpret("SC1234-04 - Olivia Newton-John - Physical",before:before)
|
||||
XCTAssertEqual(artist.artist,"Olivia Newton John");XCTAssertEqual(artist.title,"Physical")
|
||||
let disc = try NameTemplate("L-L-T-R-S").interpret("SC1234-5-04-The Foobars-The Song",before:before)
|
||||
XCTAssertEqual(disc.album,"SC1234 5");XCTAssertEqual(disc.track,"04")
|
||||
for (template,text) in [("I | T. R - S [V]","unused | 03. Artist - Title [Backing vocals]"),("I | T. R - S (V)","unused | 03. Artist - Title (Backing vocals)"),("I | T. R - S {V}","unused | 03. Artist - Title {Backing vocals}")] {
|
||||
let parsed = try NameTemplate(template).interpret(text,before:before)
|
||||
XCTAssertEqual(parsed.artist,"Artist");XCTAssertEqual(parsed.title,"Title");XCTAssertEqual(parsed.track,"03");XCTAssertEqual(parsed.rendition,"Backing vocals")
|
||||
}
|
||||
}
|
||||
func testNameTemplateDashAndUnderscoreOptionsAreExplicit() throws {
|
||||
let before = NameMetadata(Song(id:1,title:"Original",audio:"/a",cdg:nil,rating:0,plays:0)),parser = try NameTemplate("R - S")
|
||||
XCTAssertThrowsError(try parser.interpret("Artist — Title",before:before))
|
||||
XCTAssertEqual(try parser.interpret("Artist — Title",before:before,emDashes:true).title,"Title")
|
||||
XCTAssertEqual(try parser.interpret("The_Artist - The_Title",before:before).artist,"The Artist")
|
||||
XCTAssertEqual(try parser.interpret("The_Artist - The_Title",before:before,underscores:false).title,"The_Title")
|
||||
}
|
||||
func testInvalidNameTemplatesAndUnmatchedSongsFailSafely() throws {
|
||||
for text in ["","A - S","R_*S","RS","I","T-T-S","R+$S"] { XCTAssertThrowsError(try NameTemplate(text),text) }
|
||||
let songs = [Song(id:2,title:"Artist - Title",audio:"/a",cdg:nil,rating:0,plays:0),Song(id:1,title:"Unmatched",audio:"/b",cdg:nil,rating:0,plays:0)]
|
||||
let rows = try NameTemplate.preview(songs,template:"R - S",source:.song,emDashes:false,underscores:true)
|
||||
XCTAssertEqual(rows.map(\.id),[1,2]);XCTAssertNil(rows[0].after);XCTAssertNotNil(rows[0].issue);XCTAssertEqual(rows.compactMap(\.change).count,1)
|
||||
XCTAssertEqual(rows[1].after?.title,"Title")
|
||||
}
|
||||
func testNameTemplateUsesOriginalFolderMetadataAndRejectsManagedHashFallback() throws {
|
||||
let song = Song(id:1,title:"01",audio:"/Media/"+String(repeating:"a",count:64)+".mp3",cdg:nil,rating:0,plays:0,originalName:"Jóga",originalPath:"/Original/Album/Björk/Jóga.mp3")
|
||||
let parser = try NameTemplate("/L/R/S"),input = try parser.input(song,source:.file)
|
||||
let parsed = try parser.interpret(input,before:NameMetadata(song))
|
||||
XCTAssertEqual(parsed.album,"Album");XCTAssertEqual(parsed.artist,"Björk");XCTAssertEqual(parsed.title,"Jóga")
|
||||
var legacy = song;legacy.originalName = "";legacy.originalPath = ""
|
||||
XCTAssertThrowsError(try parser.input(legacy,source:.file));XCTAssertEqual(try parser.input(legacy,source:.song),"01")
|
||||
}
|
||||
func testImportRecordsOriginalNamesAndBackfillsSkippedArchives() 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")),importer = Importer(media:root.appendingPathComponent("Media"))
|
||||
let archive = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.zip"))
|
||||
let ids = try importer.run([archive],library:db),id = try XCTUnwrap(ids.first)
|
||||
let first = try XCTUnwrap(try db.song(id));XCTAssertFalse(first.originalName.isEmpty);XCTAssertTrue(first.originalPath.hasPrefix(archive.path+"/"));XCTAssertFalse(first.originalName == URL(fileURLWithPath:first.audio).deletingPathExtension().lastPathComponent)
|
||||
try db.updateMetadata(song:id,title:"My Edited Title",artist:"Artist",album:"Album",label:"",rating:5)
|
||||
try db.execute("UPDATE songs SET original_name='',original_path='' WHERE id=?",[String(id)])
|
||||
var progress = ImportProgress();_ = try importer.run([archive],library:db,progress:{progress = $0})
|
||||
let after = try XCTUnwrap(try db.song(id));XCTAssertEqual(after.title,"My Edited Title");XCTAssertEqual(after.rating,5);XCTAssertFalse(after.originalName.isEmpty);XCTAssertEqual(progress.skipped,1);XCTAssertEqual(try db.songs().count,1)
|
||||
}
|
||||
func testReinterpretationPersistsFieldsPreservesRelationshipsAndCanUndo() 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 database = root.appendingPathComponent("Library.sqlite"),db = try Library(url:database)
|
||||
let a = try db.add(title:"SF100-01 - Artist A - Title A (Explicit)",audio:"/a.mp3",cdg:"/a.cdg"),b = try db.add(title:"SF100-02 - Artist B - Title B (Clean)",audio:"/b.mp3",cdg:nil)
|
||||
try db.rate(a,5);try db.addEntity("singers",name:"Alex");let singer = try XCTUnwrap(try db.entities("singers").first?.id)
|
||||
try db.assignSong(a,singer:singer);try db.savePreference(song:a,singer:singer,value:.init(pitch:4,tempo:0.9,rating:3))
|
||||
try db.addEntity("playlists",name:"Set");let playlist = try XCTUnwrap(try db.entities("playlists").first?.id)
|
||||
try db.addToPlaylist(playlist,song:b);try db.addToPlaylist(playlist,song:a)
|
||||
let before = try db.songs(),plan = try NameTemplate.preview(before,template:"L-T - R - S (V)",source:.song,emDashes:false,underscores:true).compactMap(\.change)
|
||||
XCTAssertEqual(try db.reinterpretNames(plan),2)
|
||||
let reopened = try Library(url:database),after = try XCTUnwrap(try reopened.song(a))
|
||||
XCTAssertEqual(after.title,"Title A");XCTAssertEqual(after.artist,"Artist A");XCTAssertEqual(after.album,"SF100");XCTAssertEqual(after.trackNumber,"01");XCTAssertEqual(after.rendition,"Explicit")
|
||||
XCTAssertEqual(after.audio,"/a.mp3");XCTAssertEqual(after.cdg,"/a.cdg");XCTAssertEqual(after.rating,5)
|
||||
XCTAssertEqual(try reopened.preference(song:a,singer:singer),.init(pitch:4,tempo:0.9,rating:3));XCTAssertEqual(try reopened.playlistSongIDs(playlist),[b,a])
|
||||
XCTAssertTrue(try reopened.hasNameUndo());XCTAssertEqual(try reopened.undoNameReinterpretation(),2)
|
||||
XCTAssertEqual(try reopened.songs(),before);XCTAssertFalse(try reopened.hasNameUndo());XCTAssertEqual(try reopened.playlistSongIDs(playlist),[b,a])
|
||||
}
|
||||
func testStaleReinterpretationPreviewRollsBackEntireBatch() 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 a = try db.add(title:"Artist - First",audio:"/a",cdg:nil),b = try db.add(title:"Artist - Second",audio:"/b",cdg:nil)
|
||||
let plan = try NameTemplate.preview(db.songs(),template:"R - S",source:.song,emDashes:false,underscores:true).compactMap(\.change)
|
||||
try db.updateMetadata(song:b,title:"Later Edit",artist:"",album:"",label:"",rating:0)
|
||||
XCTAssertThrowsError(try db.reinterpretNames(plan));XCTAssertEqual(try db.song(a)?.title,"Artist - First");XCTAssertEqual(try db.song(b)?.title,"Later Edit");XCTAssertFalse(try db.hasNameUndo())
|
||||
}
|
||||
func testUndoReinterpretationDoesNotOverwriteLaterEdits() 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 id = try db.add(title:"Artist - Title",audio:"/a",cdg:nil)
|
||||
_ = try db.reinterpretNames(NameTemplate.preview(db.songs(),template:"R - S",source:.song,emDashes:false,underscores:true).compactMap(\.change))
|
||||
try db.updateMetadata(song:id,title:"Later Edit",artist:"Artist",album:"",label:"",rating:0)
|
||||
XCTAssertThrowsError(try db.undoNameReinterpretation());XCTAssertEqual(try db.song(id)?.title,"Later Edit");XCTAssertTrue(try db.hasNameUndo())
|
||||
}
|
||||
@MainActor func testSavedCustomTemplatesAndAsyncReinterpretUndo() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
|
||||
let model = AppModel(storageURL:root)
|
||||
try model.saveNameTemplates("R | S [V]\nT. S")
|
||||
XCTAssertTrue(model.savedNameTemplates().contains("R | S [V]"));XCTAssertThrowsError(try model.saveNameTemplates("A - S"))
|
||||
let id = try XCTUnwrap(model.library).add(title:"Artist | Title [Clean]",audio:"/a",cdg:nil);try model.refresh()
|
||||
let changes = try NameTemplate.preview(model.songs,template:"R | S [V]",source:.song,emDashes:false,underscores:true).compactMap(\.change)
|
||||
try await model.applyNameChanges(changes)
|
||||
XCTAssertTrue(model.canUndoNames);XCTAssertFalse(model.namesBusy);XCTAssertEqual(model.songs.first?.rendition,"Clean")
|
||||
XCTAssertTrue(SongSearch.matches(try XCTUnwrap(model.songs.first),query:"version:Clean"))
|
||||
try await model.undoNameChanges();XCTAssertFalse(model.canUndoNames);XCTAssertEqual(model.songs.first?.title,"Artist | Title [Clean]")
|
||||
XCTAssertEqual(model.songs.first?.id,id)
|
||||
}
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user