514 lines
38 KiB
Swift
514 lines
38 KiB
Swift
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) } }
|
||
}
|
||
}
|