import SwiftUI import AVFoundation struct MetadataPatch { var title: String?;var artist: String?;var album: String?;var label: String?;var rating: Int? var replaceField = "title";var find: String?;var replacement = "" func applying(to original: Song) throws -> Song { var song = original if let title { song.title = title };if let artist { song.artist = artist };if let album { song.album = album };if let label { song.label = label };if let rating { song.rating = rating } if let find,!find.isEmpty { switch replaceField { case "title":song.title = song.title.replacingOccurrences(of:find,with:replacement,options:.caseInsensitive) case "artist":song.artist = song.artist.replacingOccurrences(of:find,with:replacement,options:.caseInsensitive) case "album":song.album = song.album.replacingOccurrences(of:find,with:replacement,options:.caseInsensitive) case "label":song.label = song.label.replacingOccurrences(of:find,with:replacement,options:.caseInsensitive) default:throw LibraryError.message("Unknown metadata field") } } guard !song.title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty,(0...5).contains(song.rating) else { throw LibraryError.message("Every song needs a title and a rating from 0 to 5") };return song } } enum SongSearch { static func matches(_ song: Song,query: String) -> Bool { if query.isEmpty { return true } if query.allSatisfy(\.isNumber),let id = Int64(query) { return song.id == id } var tokens = [String](),current = "",quoted = false for char in query { if char == "\"" { quoted.toggle() } else if !quoted && (char.isWhitespace || char == ",") { if !current.isEmpty { tokens.append(current);current = "" } } else { current.append(char) } } if !current.isEmpty { tokens.append(current) } return tokens.allSatisfy { token in let escaped = token.replacingOccurrences(of:"::",with:"\u{1f}") let parts = escaped.split(separator:":",maxSplits:1,omittingEmptySubsequences:false).map{String($0).replacingOccurrences(of:"\u{1f}",with:":")} let fields: [String],term: String if parts.count == 2 { term = parts[1] switch parts[0].lowercased() { case "title","song","s":fields = [song.title] 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,song.rendition];return fields.contains{$0.localizedCaseInsensitiveContains(token)} } } 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)} } } } struct MetadataCandidate: Identifiable, Decodable { var trackId: Int64;var trackName: String;var artistName: String;var collectionName: String?;var trackViewUrl: String? var id: Int64 { trackId } } actor OnlineMetadata { static let shared = OnlineMetadata() private var cache = [String:[MetadataCandidate]]() private var nextRequest = Date.distantPast func search(_ text: String) async throws -> [MetadataCandidate] { let query = text.trimmingCharacters(in:.whitespacesAndNewlines) guard query.count >= 2,query.count <= 300 else { throw LibraryError.message("Search with 2–300 characters") } if let cached = cache[query] { return cached } let slot = max(Date(),nextRequest);nextRequest = slot.addingTimeInterval(3.1) let delay = slot.timeIntervalSinceNow;if delay > 0 { try await Task.sleep(nanoseconds:UInt64(delay*1_000_000_000)) };try Task.checkCancellation() var url = URLComponents(string:"https://itunes.apple.com/search")! url.queryItems = [.init(name:"term",value:query),.init(name:"media",value:"music"),.init(name:"entity",value:"song"),.init(name:"country",value:"GB"),.init(name:"limit",value:"25")] var request = URLRequest(url:url.url!);request.timeoutInterval = 20;request.setValue("KJMixPro/0.5.0",forHTTPHeaderField:"User-Agent") let (data,response) = try await URLSession.shared.data(for:request) guard let response = response as? HTTPURLResponse,response.statusCode == 200,data.count < 2_000_000 else { throw LibraryError.message("Metadata service is unavailable. Please try later.") } struct Results: Decodable { var results: [MetadataCandidate] } let result = try JSONDecoder().decode(Results.self,from:data).results cache[query] = result;return result } } /// Keeps MP3 audio bytes and unrelated supported ID3 frames. Unsupported tag layouts fail safely. enum FileTagWriter { static func write(_ song: Song,backupDirectory: URL) async throws { let original = URL(fileURLWithPath:song.audio),ext = original.pathExtension.lowercased() guard ["mp3","m4a","mp4","m4v","mov"].contains(ext),!song.audio.hasPrefix("showscreen:") else { throw LibraryError.message("File tag writing supports MP3, M4A and MP4/MOV; other formats keep database metadata") } try FileManager.default.createDirectory(at:backupDirectory,withIntermediateDirectories:true) let backup = backupDirectory.appendingPathComponent(UUID().uuidString+"-"+original.lastPathComponent) try FileManager.default.copyItem(at:original,to:backup) let temp = original.deletingLastPathComponent().appendingPathComponent(".kjmix-tags-"+UUID().uuidString+"."+ext) defer { try? FileManager.default.removeItem(at:temp) } if ext == "mp3" { try await Task.detached(priority:.utility) { try writeMP3(song,original:original,destination:temp) }.value } else { let asset = AVURLAsset(url:original) guard let exporter = AVAssetExportSession(asset:asset,presetName:AVAssetExportPresetPassthrough) else { throw LibraryError.message("This file cannot be tagged without re-encoding") } let type: AVFileType = ext == "m4a" ? .m4a : ext == "mov" ? .mov : .mp4 guard exporter.supportedFileTypes.contains(type) else { throw LibraryError.message("This container cannot preserve its media during tag export") } exporter.outputURL = temp;exporter.outputFileType = type let keys: [AVMetadataKey] = [.commonKeyTitle,.commonKeyArtist,.commonKeyAlbumName,.commonKeyPublisher] var items = try await asset.load(.metadata).filter{!keys.contains($0.commonKey ?? AVMetadataKey(rawValue:""))} for (identifier,value) in [(AVMetadataIdentifier.commonIdentifierTitle,song.title),(.commonIdentifierArtist,song.artist),(.commonIdentifierAlbumName,song.album),(.commonIdentifierPublisher,song.label)] { let item = AVMutableMetadataItem();item.identifier = identifier;item.value = value as NSString;items.append(item) } exporter.metadata = items await withCheckedContinuation { continuation in exporter.exportAsynchronously { continuation.resume() } } guard exporter.status == .completed else { throw exporter.error ?? LibraryError.message("Tag export failed; original preserved") } } _ = try FileManager.default.replaceItemAt(original,withItemAt:temp) } static func writeMP3(_ song: Song,original: URL,destination: URL) throws { let input = try FileHandle(forReadingFrom:original);defer { try? input.close() } let header = try input.read(upToCount:10) ?? Data() var oldSize = 0,version: UInt8 = 4,frames = Data() let replaced: Set = ["TIT2","TPE1","TALB","TPUB"] if header.count == 10,String(decoding:header.prefix(3),as:UTF8.self) == "ID3" { version = header[3] guard [3,4].contains(version),header[5] == 0,header[6...9].allSatisfy({$0 < 128}) else { throw LibraryError.message("Unsupported ID3 layout; original preserved") } oldSize = header[6...9].reduce(0){($0<<7)|Int($1)} guard oldSize <= 16_777_216 else { throw LibraryError.message("ID3 tag is too large") } let tag = try input.read(upToCount:oldSize) ?? Data();guard tag.count == oldSize else { throw LibraryError.message("Truncated ID3 tag") } var i = 0 while i+10 <= tag.count,tag[i] != 0 { let name = String(decoding:tag[i..= 0,i+10+size <= tag.count else { throw LibraryError.message("Truncated ID3 frame") } if !replaced.contains(name) { frames.append(tag[i.. Data { let shift = sync ? 7 : 8,mask = sync ? 127 : 255;return Data((0..<4).reversed().map{UInt8((value >> ($0*shift)) & mask)}) } for (name,text) in [("TIT2",song.title),("TPE1",song.artist),("TALB",song.album),("TPUB",song.label)] { let payload: Data = version == 4 ? Data([3])+Data(text.utf8) : Data([1])+((text.data(using:.utf16)) ?? Data()) frames.append(Data(name.utf8));frames.append(sizeData(payload.count,sync:version == 4));frames.append(Data([0,0]));frames.append(payload) } guard FileManager.default.createFile(atPath:destination.path,contents:nil) else { throw LibraryError.message("Cannot create tagged file") } let output = try FileHandle(forWritingTo:destination);defer { try? output.close() } try output.write(contentsOf:Data("ID3".utf8)+Data([version,0,0])+sizeData(frames.count,sync:true)+frames) try input.seek(toOffset:UInt64(oldSize > 0 || (header.count >= 3 && String(decoding:header.prefix(3),as:UTF8.self) == "ID3") ? oldSize+10 : 0)) while let data = try input.read(upToCount:1_048_576),!data.isEmpty { try Task.checkCancellation();try output.write(contentsOf:data) } } } struct BatchMetadataSheet: View { @ObservedObject var model: AppModel let ids: Set @State private var patch = MetadataPatch() @State private var fields = Set() @State private var title = "" @State private var artist = "" @State private var album = "" @State private var label = "" @State private var find = "" @State private var replacement = "" @State private var field = "title" @State private var rating = 0 @State private var error: String? @Environment(\.dismiss) private var dismiss private var effective: MetadataPatch { .init(title:fields.contains("title") ? title : nil,artist:fields.contains("artist") ? artist : nil,album:fields.contains("album") ? album : nil,label:fields.contains("label") ? label : nil,rating:fields.contains("rating") ? rating : nil,replaceField:field,find:find.isEmpty ? nil : find,replacement:replacement) } var body: some View { VStack(alignment:.leading,spacing:16) { Text("Edit \(ids.count) Songs").font(.title2.bold()) Text("Only checked fields change. Unchecked fields and singer settings remain unchanged. Changes update your library; use Write File Tags separately to update media files.").font(.callout).foregroundStyle(.secondary) fieldRow("Title",key:"title",text:$title);fieldRow("Artist",key:"artist",text:$artist);fieldRow("Album",key:"album",text:$album);fieldRow("Label",key:"label",text:$label) HStack { Toggle("Rating",isOn:binding("rating")).frame(width:95);Picker("Rating",selection:$rating) { ForEach(0...5,id:\.self) { Text("\($0) stars").tag($0) } }.labelsHidden().disabled(!fields.contains("rating")) } Divider() HStack { Text("Find & replace");Picker("Field",selection:$field) { Text("Title").tag("title");Text("Artist").tag("artist");Text("Album").tag("album");Text("Label").tag("label") }.labelsHidden() } HStack { TextField("Find (case insensitive)",text:$find);TextField("Replace with",text:$replacement) } VStack(alignment:.leading,spacing:5) { Text("Preview").font(.headline) ForEach(model.songs.filter{ids.contains($0.id)}.prefix(4)) { song in let next = try? effective.applying(to:song) Text(next.map{"\(song.title) → \($0.title) · \($0.artist) · \($0.album)"} ?? "Invalid result: a title cannot be empty").font(.caption).lineLimit(2) } }.padding(12).frame(maxWidth:.infinity,alignment:.leading).background(Color.primary.opacity(0.05),in:RoundedRectangle(cornerRadius:8)) if let error { Text(error).foregroundStyle(.red) } HStack { Button("Cancel") { dismiss() };Spacer();Button("Apply to \(ids.count) Songs") { do { try model.batchMetadata(ids,patch:effective);dismiss() } catch { self.error = error.localizedDescription } }.keyboardShortcut(.defaultAction).disabled(fields.isEmpty && find.isEmpty) } }.padding(24).frame(width:590).background(Color(nsColor:.windowBackgroundColor)).textFieldStyle(.roundedBorder) } private func binding(_ key: String) -> Binding { Binding(get:{fields.contains(key)},set:{if $0 { fields.insert(key) } else { fields.remove(key) }}) } private func fieldRow(_ name: String,key: String,text: Binding) -> some View { HStack { Toggle(name,isOn:binding(key)).frame(width:95,alignment:.leading);TextField(name,text:text).disabled(!fields.contains(key)) } } } struct MetadataLookupSheet: View { @ObservedObject var model: AppModel let song: Song @State private var query = "" @State private var candidates = [MetadataCandidate]() @State private var busy = false @State private var error: String? @Environment(\.dismiss) private var dismiss var body: some View { VStack(alignment:.leading,spacing:16) { Text("Find Recording Metadata").font(.title2.bold()) Text("Search Apple’s public catalogue for title, artist and album suggestions. Review the recording carefully: karaoke versions may differ. This does not purchase music or provide karaoke audio.").foregroundStyle(.secondary) HStack { TextField("Title and artist",text:$query).textFieldStyle(.roundedBorder).onSubmit(search);Button(busy ? "Searching…" : "Search",action:search).disabled(busy) } if let error { Text(error).foregroundStyle(.red) } List(candidates) { item in HStack { VStack(alignment:.leading,spacing:4) { Text(item.trackName).fontWeight(.medium);Text(item.artistName+" · "+(item.collectionName ?? "")).font(.caption).foregroundStyle(.secondary) };Spacer() if let value = item.trackViewUrl,let url = URL(string:value),url.scheme == "https" { Link("View",destination:url) } Button("Use These Tags") { do { try model.batchMetadata([song.id],patch:.init(title:item.trackName,artist:item.artistName,album:item.collectionName));dismiss() } catch { self.error = error.localizedDescription } } }.padding(.vertical,5) }.frame(height:300) HStack { Text("Suggestions update library metadata only.").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() } } }.padding(24).frame(width:650).background(Color(nsColor:.windowBackgroundColor)).onAppear { query = song.title+" "+song.artist } } private func search() { guard !busy else { return };busy = true;error = nil;Task { do { candidates = try await OnlineMetadata.shared.search(query) } catch { self.error = error.localizedDescription };busy = false } } } struct StoreCatalogueItem: Identifiable, Codable, Hashable { var id: String var title: String var artist: String var album: String? var format: String? var price: String? var url: String? var previewURL: String? func destination(preview: Bool = false) throws -> URL { guard let value = preview ? previewURL : url,let url = URL(string:value),["http","https"].contains(url.scheme?.lowercased() ?? ""),url.host != nil,url.user == nil,url.password == nil else { throw LibraryError.message("This catalogue entry has no valid website link") };return url } static func decode(_ data: Data) throws -> [Self] { guard data.count <= 10_000_000 else { throw LibraryError.message("Catalogue files are limited to 10 MB") } let items = try JSONDecoder().decode([Self].self,from:data) guard items.count <= 25000,Set(items.map(\.id)).count == items.count,items.allSatisfy({!$0.id.isEmpty && !$0.title.isEmpty && $0.title.count <= 1000}) else { throw LibraryError.message("Use unique catalogue IDs and nonempty song titles; maximum 25,000 entries") } for item in items { if item.url != nil { _ = try item.destination() };if item.previewURL != nil { _ = try item.destination(preview:true) } };return items } } @MainActor extension AppModel { func installStoreProfiles() { perform { let profiles: [(String,String)] = [("Party Tyme Downloads","https://www.partytyme.net/songshop/cat/"),("Party Tyme Pro Streaming","https://player.partytymeplayer.com/"),("KaraokeCloud","https://karaokecloud.com/"),("KaraokeCloud Pro","https://karaokecloud.com/become-a-karaoke-jockey.html")] for (name,url) in profiles where !musicStores.contains(where:{$0.name.caseInsensitiveCompare(name) == .orderedSame}) { try library?.saveStore(.init(id:0,name:name,url:url,searchTemplate:"")) };try refresh() } } } struct StoreCatalogueSheet: View { @ObservedObject var model: AppModel let store: MusicStore @State private var items = [StoreCatalogueItem]() @State private var query = "" @State private var sort = "Title" @State private var message = "" @Environment(\.dismiss) private var dismiss private var rows: [StoreCatalogueItem] { items.filter{query.isEmpty || ($0.title+" "+$0.artist+" "+($0.album ?? "")).localizedCaseInsensitiveContains(query)}.sorted{let a = sort == "Artist" ? $0.artist : sort == "Album" ? ($0.album ?? "") : $0.title,b = sort == "Artist" ? $1.artist : sort == "Album" ? ($1.album ?? "") : $1.title;return a.localizedStandardCompare(b) == .orderedAscending} } var body: some View { VStack(alignment:.leading,spacing:16) { Text(store.name+" · Catalogue").font(.title2.bold()) Text("Import a JSON catalogue supplied by your provider or prepared by you. Website and preview links open in your browser. Direct purchasing, subscription playback, encrypted caching and purchase-history sync are not connected.").foregroundStyle(.secondary) HStack { TextField("Search catalogue",text:$query).textFieldStyle(.roundedBorder);Picker("Sort",selection:$sort) { Text("Title").tag("Title");Text("Artist").tag("Artist");Text("Album").tag("Album") }.frame(width:170);Button("Import / Reload…",action:importCatalogue) } List(rows) { item in HStack { VStack(alignment:.leading,spacing:4) { Text(item.title);Text(item.artist+" · "+(item.album ?? "")).font(.caption).foregroundStyle(.secondary) };Spacer();Text(item.format ?? "").font(.caption);Text(item.price ?? "").font(.caption) if item.previewURL != nil { Button("Preview Website") { open(item,preview:true) } };if item.url != nil { Button("View at Store") { open(item) } } } }.frame(height:310) HStack { Text(message.isEmpty ? "\(items.count) catalogue entries saved locally" : message).font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() } } }.padding(24).frame(width:760).background(Color(nsColor:.windowBackgroundColor)).onAppear { if let value = try? model.library?.setting("store.catalogue.\(store.id)"),let data = value.data(using:.utf8),let result = try? StoreCatalogueItem.decode(data) { items = result } } } private func importCatalogue() { let panel = NSOpenPanel();panel.allowedContentTypes = [.json];panel.allowsMultipleSelection = false;panel.canChooseDirectories = false guard panel.runModal() == .OK,let url = panel.url else { return } do { let scope = url.startAccessingSecurityScopedResource();defer { if scope { url.stopAccessingSecurityScopedResource() } } let size = try url.resourceValues(forKeys:[.fileSizeKey]).fileSize ?? 0;guard size <= 10_000_000 else { throw LibraryError.message("Catalogue exceeds 10 MB") } let data = try Data(contentsOf:url),result = try StoreCatalogueItem.decode(data) try model.library?.setSetting("store.catalogue.\(store.id)",String(decoding:data,as:UTF8.self));items = result;message = "Catalogue reloaded · \(result.count) entries" } catch { message = error.localizedDescription } } 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) } } } } @MainActor protocol NameReinterpretationModel: ObservableObject { var songs: [Song] { get } var library: Library? { get } var namesBusy: Bool { get } func refresh() throws func perform(_ action: () throws -> Void) func applyNameChanges(_ changes: [NameChange]) async throws } extension NameReinterpretationModel { 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")) } } extension AppModel: NameReinterpretationModel { 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: Model 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).help(row.input);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 } } } } struct NameReinterpretationCommands { var perform: () -> Void var enabled: Bool } private struct NameReinterpretationKey: FocusedValueKey { typealias Value = NameReinterpretationCommands } extension FocusedValues { var nameReinterpretation: NameReinterpretationCommands? { get { self[NameReinterpretationKey.self] } set { self[NameReinterpretationKey.self] = newValue } } } extension DJSystem: NameReinterpretationModel { func applyNameChanges(_ changes: [NameChange]) async throws { guard !namesBusy,!importing,!generatingWaveforms else { throw LibraryError.message("Finish the current library job first") } namesBusy = true;defer { namesBusy = false } let database = root.appendingPathComponent("Library.sqlite") _ = try await Task.detached(priority:.utility) { try Library(url:database).reinterpretNames(changes) }.value try refresh() for deck in [a,b] { if let song = songs.first(where:{$0.id == deck.song?.id}) { deck.updateTrackMetadata(song) } } } func undoNameChanges() async throws { guard !namesBusy,!importing,!generatingWaveforms else { throw LibraryError.message("Finish the current library job first") } namesBusy = true;defer { namesBusy = false } let database = root.appendingPathComponent("Library.sqlite") _ = try await Task.detached(priority:.utility) { try Library(url:database).undoNameReinterpretation() }.value try refresh() for deck in [a,b] { if let song = songs.first(where:{$0.id == deck.song?.id}) { deck.updateTrackMetadata(song) } } } }