Files

514 lines
38 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String> = ["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..<i+4],as:UTF8.self)
guard name.count == 4,name.allSatisfy({$0.isASCII && ($0.isUppercase || $0.isNumber)}) else { throw LibraryError.message("Malformed ID3 frame") }
let sizeBytes = tag[i+4..<i+8]
if version == 4 && !sizeBytes.allSatisfy({$0 < 128}) { throw LibraryError.message("Malformed ID3 frame size") }
let size = sizeBytes.reduce(0){($0 << (version == 4 ? 7 : 8))|Int($1)}
guard size >= 0,i+10+size <= tag.count else { throw LibraryError.message("Truncated ID3 frame") }
if !replaced.contains(name) { frames.append(tag[i..<i+10+size]) };i += 10+size
}
}
func sizeData(_ value: Int,sync: Bool) -> 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<Int64>
@State private var patch = MetadataPatch()
@State private var fields = Set<String>()
@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<Bool> { Binding(get:{fields.contains(key)},set:{if $0 { fields.insert(key) } else { fields.remove(key) }}) }
private func fieldRow(_ name: String,key: String,text: Binding<String>) -> 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[..<index].last(where:{$0 != " "}),next = characters.dropFirst(index+1).first(where:{$0 != " "})
pattern += previous == "-" || next == "-" ? "\\s*" : "\\s+"
} else { pattern += NSRegularExpression.escapedPattern(for:String(character)) }
index += 1
}
guard fields.count <= 12,fields.contains(where:{$0 != "I"}),fields.filter({$0 == "T"}).count <= 1 else { throw LibraryError.message("Use up to 12 fields, at least one metadata field, and no more than one T field") }
self.fields = fields
expression = try NSRegularExpression(pattern:(usesPath ? "^(?:.*/)?" : "^")+pattern+"$")
}
func interpret(_ text:String,before:NameMetadata,emDashes:Bool = false,underscores:Bool = true) throws -> NameMetadata {
guard text.count <= 2048 else { throw LibraryError.message("Name is too long to reinterpret") }
var input = text.trimmingCharacters(in:.whitespacesAndNewlines)
if emDashes { for dash in ["—","–","−"] { input = input.replacingOccurrences(of:dash,with:"-") } }
if underscores { input = input.replacingOccurrences(of:"_",with:" ") }
let range = NSRange(input.startIndex...,in:input)
guard let match = expression.firstMatch(in:input,range:range) else { throw LibraryError.message("Does not match this template") }
var values = [Character:[String]]()
for (index,field) in fields.enumerated() where field != "I" {
guard let range = Range(match.range(at:index+1),in:input) else { continue }
let value = String(input[range]).trimmingCharacters(in:.whitespacesAndNewlines)
guard !value.isEmpty else { throw LibraryError.message("A metadata field is empty") }
values[field,default:[]].append(value)
}
var result = before
if let value = values["S"] { result.title = value.joined(separator:" ") }
if let value = values["R"] { result.artist = value.joined(separator:" ") }
if let value = values["L"] { result.album = value.joined(separator:" ") }
if let value = values["V"] { result.rendition = value.joined(separator:" ") }
if let value = values["T"]?.first,let number = Int(value) { result.track = String(format:"%02d",number) }
return result
}
func input(_ song:Song,source:NameSource) throws -> String {
if source == .song { return song.title }
let path = song.originalPath.isEmpty ? song.audio : song.originalPath
let filename = song.originalName.isEmpty ? URL(fileURLWithPath:path).deletingPathExtension().lastPathComponent : song.originalName
if song.originalName.isEmpty,filename.count == 64,filename.allSatisfy(\.isHexDigit) { throw LibraryError.message("Original filename was not recorded. Choose Song Name, or reimport the source to record its original name.") }
return usesPath ? URL(fileURLWithPath:path).deletingPathExtension().path : filename
}
static func preview(_ songs:[Song],template:String,source:NameSource,emDashes:Bool,underscores:Bool) throws -> [NamePreview] {
let parser = try NameTemplate(template)
return try songs.sorted{$0.id < $1.id}.map { song in
try Task.checkCancellation()
let before = NameMetadata(song)
do { let input = try parser.input(song,source:source);return .init(id:song.id,input:input,before:before,after:try parser.interpret(input,before:before,emDashes:emDashes,underscores:underscores)) }
catch { return .init(id:song.id,input:song.title,before:before,issue:error.localizedDescription) }
}
}
}
@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<Model: NameReinterpretationModel>: View {
@ObservedObject var model: Model
let ids: Set<Int64>
@Environment(\.dismiss) private var dismiss
@State private var template = "L-T - R - S"
@State private var templates = NameTemplate.presets
@State private var source = NameSource.song
@State private var emDashes = false
@State private var underscores = true
@State private var matchedOnly = false
@State private var editingTemplates = false
@State private var templateText = ""
@State private var previews = [NamePreview]()
@State private var preparing = true
@State private var issue: String?
private var tracks:[Song] { model.songs.filter{ids.contains($0.id)} }
private var matched:Int { previews.filter{$0.after != nil}.count }
private var changes:[NameChange] { previews.compactMap(\.change) }
private var previewKey:String { template+"|"+source.rawValue+"|\(emDashes)|\(underscores)" }
var body: some View {
VStack(alignment:.leading,spacing:16) {
HStack { VStack(alignment:.leading,spacing:5) { Text("Reinterpret Names").font(.title2.bold());Text("Extract metadata from \(ids.count) selected song names.").foregroundStyle(.secondary) };Spacer();if preparing || model.namesBusy { ProgressView().controlSize(.small) } }
HStack {
Picker("Use",selection:$source) { ForEach(NameSource.allCases,id:\.self) { Text($0.rawValue).tag($0) } }.pickerStyle(.segmented).frame(width:270)
Spacer()
Button("Refresh Preview") { model.perform { try model.refresh() };Task { await preparePreview() } }.disabled(preparing)
Button("Edit Templates…") { templateText = ((try? model.library?.setting(NameTemplate.settingsKey)) ?? "");editingTemplates = true }
}
Picker("Name template",selection:$template) { ForEach(templates,id:\.self) { Text($0).font(.system(.body,design:.monospaced)).tag($0) } }
Text("L Album · T Track · R Artist · S Title · V Rendition · I Ignore. Repeat letters to combine parts. Use / for containing folders.").font(.caption).foregroundStyle(.secondary)
HStack { Toggle("Treat em-dashes as regular dashes",isOn:$emDashes);Toggle("Treat underscores as spaces",isOn:$underscores) }.font(.callout)
previewTable
if previews.count > 200 { Text("Showing the first 200 rows. All \(previews.count) selected songs are checked.").font(.caption).foregroundStyle(.secondary) }
HStack {
Text(preparing ? "Preparing preview…" : "\(matched) of \(previews.count) match · \(changes.count) will change").font(.callout.weight(.medium))
Spacer();Toggle("Apply matching songs only",isOn:$matchedOnly).font(.callout)
}
if let issue { Text(issue).foregroundStyle(.red).font(.callout) }
Text("Preview is in Song ID order; playlist order is preserved. Only library metadata changes. Original files and embedded tags are kept. Undo is available from the Advanced menu.").font(.caption).foregroundStyle(.secondary)
HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction);Button("Apply to \(changes.count) Songs") { apply() }.keyboardShortcut(.defaultAction).buttonStyle(.borderedProminent).disabled(preparing || changes.isEmpty || (!matchedOnly && matched != previews.count) || issue != nil) }
}.padding(24).frame(width:1000).disabled(model.namesBusy)
.onAppear { templates = model.savedNameTemplates();if !tracks.isEmpty,tracks.allSatisfy({!$0.originalName.isEmpty}) { source = .file } }
.task(id:previewKey) { await preparePreview() }
.sheet(isPresented:$editingTemplates) { templatesEditor }
.interactiveDismissDisabled(model.namesBusy)
}
private var previewTable: some View {
Table(Array(previews.prefix(200))) {
TableColumn("ID") { Text("\($0.id)").monospacedDigit().foregroundStyle(.secondary) }.width(40)
TableColumn("Source / Result") { row in VStack(alignment:.leading,spacing:3) { Text(row.input).foregroundStyle(.secondary).lineLimit(1).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) } }
}
}