From 87cdb42af9beac5c9f6afc46785cea7e035c905e Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Thu, 5 Nov 2026 11:02:22 +0000 Subject: [PATCH] importer chnges --- KJamsClone/Hosting.swift | 21 ++- KJamsClone/Importer.swift | 13 +- KJamsClone/KJMixProApp.swift | 46 ++++-- KJamsClone/Library.swift | 83 +++++++++- KJamsClone/Metadata.swift | 209 +++++++++++++++++++++++++- KJamsCloneTests/KJamsCloneTests.swift | 101 +++++++++++++ 6 files changed, 444 insertions(+), 29 deletions(-) diff --git a/KJamsClone/Hosting.swift b/KJamsClone/Hosting.swift index bd2c409..b9515aa 100644 --- a/KJamsClone/Hosting.swift +++ b/KJamsClone/Hosting.swift @@ -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() + @State private var reinterpreting = false + @State private var reinterpretIDs = Set() @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 } } } diff --git a/KJamsClone/Importer.swift b/KJamsClone/Importer.swift index a530e0a..c2ad249 100644 --- a/KJamsClone/Importer.swift +++ b/KJamsClone/Importer.swift @@ -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 } diff --git a/KJamsClone/KJMixProApp.swift b/KJamsClone/KJMixProApp.swift index f47e165..a2944ae 100644 --- a/KJamsClone/KJMixProApp.swift +++ b/KJamsClone/KJMixProApp.swift @@ -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? private var waveformJobToken: UUID? func generateWaveforms(_ ids: Set? = 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? 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() @State private var waveformDetails = false @State private var batchEditing = false + @State private var reinterpreting = false + @State private var reinterpretIDs = Set() @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? = 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 diff --git a/KJamsClone/Library.swift b/KJamsClone/Library.swift index b19e5a1..2db6de4 100644 --- a/KJamsClone/Library.swift +++ b/KJamsClone/Library.swift @@ -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)]) + } +} diff --git a/KJamsClone/Metadata.swift b/KJamsClone/Metadata.swift index 51432df..692c047 100644 --- a/KJamsClone/Metadata.swift +++ b/KJamsClone/Metadata.swift @@ -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[.. 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 + @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 } } + } +} diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index 9cff0ba..d0b1d74 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -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()