importer chnges
This commit is contained in:
+207
-2
@@ -41,11 +41,13 @@ enum SongSearch {
|
||||
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];return fields.contains{$0.localizedCaseInsensitiveContains(token)}
|
||||
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];term = parts[0] }
|
||||
} 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)}
|
||||
}
|
||||
@@ -265,3 +267,206 @@ struct StoreCatalogueSheet: View {
|
||||
}
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppModel {
|
||||
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"))
|
||||
}
|
||||
func applyNameChanges(_ changes:[NameChange]) async throws {
|
||||
guard !namesBusy,!isImporting,!movingMedia,scanningSource == nil,!generatingWaveforms,!tagsBusy else { throw LibraryError.message("Finish the current library job first") }
|
||||
namesBusy = true;defer { namesBusy = false }
|
||||
let database = root.appendingPathComponent("Library.sqlite")
|
||||
let count = try await Task.detached(priority:.userInitiated) { try Library(url:database).reinterpretNames(changes) }.value
|
||||
try refresh();tagsStatus = "Reinterpreted \(count) song names"
|
||||
if let id = player.currentSongID,let song = songs.first(where:{$0.id == id}) { player.title = song.title }
|
||||
}
|
||||
func undoNameChanges() async throws {
|
||||
guard !namesBusy,!isImporting,!movingMedia,scanningSource == nil,!generatingWaveforms,!tagsBusy else { throw LibraryError.message("Finish the current library job first") }
|
||||
namesBusy = true;defer { namesBusy = false }
|
||||
let database = root.appendingPathComponent("Library.sqlite")
|
||||
let count = try await Task.detached(priority:.userInitiated) { try Library(url:database).undoNameReinterpretation() }.value
|
||||
try refresh();tagsStatus = "Restored \(count) song names"
|
||||
if let id = player.currentSongID,let song = songs.first(where:{$0.id == id}) { player.title = song.title }
|
||||
}
|
||||
}
|
||||
|
||||
struct ReinterpretNamesSheet: View {
|
||||
@ObservedObject var model: AppModel
|
||||
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);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 } }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user