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 "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)} } } else { fields = [song.title,song.artist,song.album,song.label];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 = asset.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)).preferredColorScheme(.dark).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)).preferredColorScheme(.dark).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)).preferredColorScheme(.dark).onAppear { if let value = try? model.library?.setting("store.catalogue.\(store.id)"),let data = value.data(using:.utf8),let result = try? StoreCatalogueItem.decode(data) { items = result } } } 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 } } }