268 lines
21 KiB
Swift
268 lines
21 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 "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<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 } }
|
||
}
|