diff --git a/README.md b/README.md index 9db48c5..30bbf10 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ Kleines macOS-SwiftUI-Tool zum Vorbereiten von Hörspielordnern für TonUINO/DFPlayer-SD-Karten. +## Werkzeuge + +- `Datenumwandlung`: Fügt die MP3-Dateien je Folge zusammen und schreibt die TonUINO-Ordnerstruktur. +- `SD-Karte`: Prüft eine ausgewählte TonUINO-SD-Karte und führt `dot_clean` aus, um macOS-Metadaten-Dateien zu bereinigen. +- `ID3-Tags`: Bearbeitet MP3-Tags rekursiv auf einer SD-Karte oder in einem ausgewählten Ordner. + ## Erwartete Input-Struktur ```text @@ -30,6 +36,18 @@ Das entspricht dem gewünschten TonUINO/DFPlayer-Schema: ein nummerierter Ordner Die App fügt die MP3-Dateien einer Folge in natürlicher Sortierung zusammen. Andere Audioformate werden in der Vorschau gewarnt und beim Export nicht in MP3 konvertiert. +Beim Export können optional ein Zuordnungsbericht (`TonUinoNator-Report.txt`), `dot_clean` und eine anschließende TonUINO-Strukturprüfung ausgeführt werden. + +Die SD-Karten-Prüfung kontrolliert: + +- Folgenordner `01` bis `99` +- Dateien in Folgenordnern mit dreistelligem Prefix `001.mp3` bis `255.mp3` +- Spezialordner `mp3` und `advert` mit vierstelligem Prefix `0001.mp3` bis `9999.mp3` +- macOS-Metadaten wie `.DS_Store` und `._*` +- unerwartete Dateien oder Ordner im Hauptverzeichnis + +Das ID3-Tag-Werkzeug zeigt vorhandene Tags im Protokoll an. Es kann alle ID3-Tags löschen oder gezielt Titel, Interpret, Album, Tracknummer, Jahr, Genre, Kommentar und eingebettete Cover/Bilder entfernen. Optional wird anschließend ein neuer Kommentar in die MP3-Dateien geschrieben. Die Tag-Suche findet MP3-Dateien über ID3v2-/ID3v1-Titel, Album und Künstler und zeigt den Ordner der Datei im Protokoll. + ## Starten ```bash diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index f12f248..b538e9a 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2,11 +2,35 @@ import AppKit import SwiftUI struct ContentView: View { + var body: some View { + TabView { + ConversionToolView() + .tabItem { + Label("Datenumwandlung", systemImage: "arrow.triangle.2.circlepath") + } + + SDCardToolView() + .tabItem { + Label("SD-Karte", systemImage: "sdcard") + } + + ID3TagToolView() + .tabItem { + Label("ID3-Tags", systemImage: "tag") + } + } + } +} + +private struct ConversionToolView: View { @State private var inputURL: URL? @State private var outputURL: URL? @State private var episodes: [EpisodePlan] = [] @State private var messages: [ExportMessage] = [] @State private var overwriteExisting = false + @State private var writeReport = true + @State private var cleanAfterExport = true + @State private var validateAfterExport = true @State private var isExporting = false private var canExport: Bool { @@ -66,8 +90,13 @@ struct ContentView: View { Spacer() - Toggle("Überschreiben", isOn: $overwriteExisting) - .toggleStyle(.checkbox) + HStack(spacing: 12) { + Toggle("Überschreiben", isOn: $overwriteExisting) + Toggle("Bericht", isOn: $writeReport) + Toggle("dot_clean", isOn: $cleanAfterExport) + Toggle("Prüfen", isOn: $validateAfterExport) + } + .toggleStyle(.checkbox) Button { export() @@ -161,19 +190,6 @@ struct ContentView: View { } } - private func chooseFolder(title: String, completion: (URL) -> Void) { - let panel = NSOpenPanel() - panel.title = title - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.canCreateDirectories = true - - if panel.runModal() == .OK, let url = panel.url { - completion(url) - } - } - private func reloadPreview() { guard let inputURL else { episodes = [] @@ -212,12 +228,23 @@ struct ContentView: View { DispatchQueue.global(qos: .userInitiated).async { let exporter = TonUinoExporter(fileManager: .default) - let result = exporter.export( + var result = exporter.export( episodes: episodes, outputURL: outputURL, - overwriteExisting: overwriteExisting + options: TonUinoExportOptions( + overwriteExisting: overwriteExisting, + writeReport: writeReport + ) ) + if cleanAfterExport { + result.append(contentsOf: SDCardCleaner().clean(url: outputURL)) + } + + if validateAfterExport { + result.append(contentsOf: SDCardValidator(fileManager: .default).validate(rootURL: outputURL)) + } + DispatchQueue.main.async { messages = result isExporting = false @@ -226,6 +253,432 @@ struct ContentView: View { } } +private struct SDCardToolView: View { + @State private var sdCardURL: URL? + @State private var messages: [ExportMessage] = [] + @State private var isWorking = false + + private var canRun: Bool { + sdCardURL != nil && !isWorking + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + Button { + chooseFolder(title: "TonUINO SD-Karte wählen") { url in + sdCardURL = url + messages = [] + } + } label: { + Label("SD-Karte", systemImage: "sdcard") + } + + Spacer() + + Button { + validateSDCard() + } label: { + Label("Prüfen", systemImage: "checklist") + } + .disabled(!canRun) + + Button { + cleanSDCard() + } label: { + Label("dot_clean ausführen", systemImage: "sparkles") + } + .buttonStyle(.borderedProminent) + .disabled(!canRun) + } + .padding(12) + + Divider() + + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("SD-Karte bereinigen") + .font(.title2.weight(.semibold)) + + PathRow(label: "Karte", url: sdCardURL) + } + + VStack(alignment: .leading, spacing: 10) { + Text("Protokoll") + .font(.headline) + + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + if messages.isEmpty { + Text("Noch keine SD-Karte geprüft oder bereinigt.") + .foregroundStyle(.secondary) + } else { + ForEach(messages) { message in + HStack(alignment: .top, spacing: 8) { + Image(systemName: message.iconName) + .foregroundStyle(message.color) + .frame(width: 18) + Text(message.text) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + Spacer(minLength: 0) + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + + private func cleanSDCard() { + guard let sdCardURL else { return } + + isWorking = true + messages = [.info("dot_clean gestartet.")] + + DispatchQueue.global(qos: .userInitiated).async { + var result = SDCardCleaner().clean(url: sdCardURL) + result.append(contentsOf: SDCardValidator(fileManager: .default).validate(rootURL: sdCardURL)) + + DispatchQueue.main.async { + messages = result + isWorking = false + } + } + } + + private func validateSDCard() { + guard let sdCardURL else { return } + + isWorking = true + messages = [.info("Prüfung gestartet.")] + + DispatchQueue.global(qos: .userInitiated).async { + let result = SDCardValidator(fileManager: .default).validate(rootURL: sdCardURL) + + DispatchQueue.main.async { + messages = result + isWorking = false + } + } + } +} + +private struct ID3TagToolView: View { + @State private var targetURL: URL? + @State private var messages: [ExportMessage] = [] + @State private var isWorking = false + @State private var removeAllTags = false + @State private var removeTitle = true + @State private var removeArtist = true + @State private var removeAlbum = true + @State private var removeTrack = true + @State private var removeYear = true + @State private var removeGenre = true + @State private var removeComment = true + @State private var removeArtwork = true + @State private var writeComment = false + @State private var commentText = "" + @State private var tagSearchText = "" + + private var canApply: Bool { + targetURL != nil && !isWorking && (removeAllTags || selectedFields.isEmpty == false || writeComment) + } + + private var canSearchTags: Bool { + targetURL != nil && !isWorking && !tagSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var selectedFields: Set { + var fields = Set() + if removeTitle { fields.insert(.title) } + if removeArtist { fields.insert(.artist) } + if removeAlbum { fields.insert(.album) } + if removeTrack { fields.insert(.track) } + if removeYear { fields.insert(.year) } + if removeGenre { fields.insert(.genre) } + if removeComment && !writeComment { fields.insert(.comment) } + if removeArtwork { fields.insert(.artwork) } + return fields + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + Button { + chooseFolder(title: "SD-Karte oder Ordner wählen") { url in + targetURL = url + scanTags(for: url) + } + } label: { + Label("Ordner wählen", systemImage: "folder") + } + + Spacer() + + Button { + if let targetURL { + scanTags(for: targetURL) + } + } label: { + Label("Tags anzeigen", systemImage: "list.bullet.rectangle") + } + .disabled(targetURL == nil || isWorking) + + Button { + applyChanges() + } label: { + Label("Tags anwenden", systemImage: "tag.slash") + } + .buttonStyle(.borderedProminent) + .disabled(!canApply) + } + .padding(12) + + Divider() + + HSplitView { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("ID3-Tags bearbeiten") + .font(.title2.weight(.semibold)) + PathRow(label: "Ordner", url: targetURL) + } + + VStack(alignment: .leading, spacing: 10) { + Toggle("Alle ID3-Tags löschen", isOn: $removeAllTags) + .toggleStyle(.checkbox) + + Divider() + + Text("Felder leeren/löschen") + .font(.headline) + + Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 8) { + GridRow { + Toggle("Titel", isOn: $removeTitle) + Toggle("Interpret", isOn: $removeArtist) + } + GridRow { + Toggle("Album", isOn: $removeAlbum) + Toggle("Tracknummer", isOn: $removeTrack) + } + GridRow { + Toggle("Jahr", isOn: $removeYear) + Toggle("Genre", isOn: $removeGenre) + } + GridRow { + Toggle("Kommentar", isOn: $removeComment) + .disabled(writeComment) + Toggle("Cover/Bilder", isOn: $removeArtwork) + } + } + .toggleStyle(.checkbox) + .disabled(removeAllTags) + + Divider() + + Toggle("Kommentar eintragen", isOn: $writeComment) + .toggleStyle(.checkbox) + .onChange(of: writeComment) { newValue in + if newValue { + removeComment = false + } + } + + HStack(spacing: 8) { + TextField("", text: $commentText) + .textFieldStyle(.roundedBorder) + .frame(width: 240) + + Button { + useClipboardAsComment() + } label: { + Label("Aus Zwischenablage", systemImage: "doc.on.clipboard") + } + + Button { + useSelectedFolderNameAsComment() + } label: { + Label("Ordnername", systemImage: "folder") + } + + Button { + clearComment() + } label: { + Label("Leeren", systemImage: "xmark.circle") + } + } + + Divider() + + Text("Tag-Suche") + .font(.headline) + + HStack(spacing: 8) { + TextField("", text: $tagSearchText) + .textFieldStyle(.roundedBorder) + .frame(width: 240) + + Button { + searchTags() + } label: { + Label("Tags suchen", systemImage: "magnifyingglass") + } + .disabled(!canSearchTags) + } + } + + Spacer(minLength: 0) + } + .padding(16) + .frame(minWidth: 420, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + VStack(alignment: .leading, spacing: 12) { + Text("Protokoll") + .font(.headline) + MessageList(messages: messages, emptyText: "Noch keine ID3-Tags angezeigt oder bearbeitet.") + } + .frame(minWidth: 300) + .padding(16) + } + } + } + + private func applyChanges() { + guard let targetURL else { return } + + isWorking = true + messages = [.info("ID3-Bearbeitung gestartet.")] + + let options = ID3TagEditOptions( + removeAllTags: removeAllTags, + fieldsToRemove: selectedFields, + commentToWrite: writeComment ? commentText.trimmingCharacters(in: .whitespacesAndNewlines) : nil + ) + + DispatchQueue.global(qos: .userInitiated).async { + var result = ID3TagEditor(fileManager: .default).apply(to: targetURL, options: options) + result.append(contentsOf: ID3TagInspector(fileManager: .default).inspect(rootURL: targetURL)) + + DispatchQueue.main.async { + messages = result + isWorking = false + } + } + } + + private func scanTags(for url: URL) { + isWorking = true + messages = [.info("ID3-Tags werden gelesen.")] + + DispatchQueue.global(qos: .userInitiated).async { + let result = ID3TagInspector(fileManager: .default).inspect(rootURL: url) + + DispatchQueue.main.async { + messages = result + isWorking = false + } + } + } + + private func searchTags() { + guard let targetURL else { return } + + isWorking = true + messages = [.info("Tag-Suche gestartet.")] + + let query = tagSearchText.trimmingCharacters(in: .whitespacesAndNewlines) + + DispatchQueue.global(qos: .userInitiated).async { + let result = ID3TagSearcher(fileManager: .default).search(rootURL: targetURL, query: query) + + DispatchQueue.main.async { + messages = result + isWorking = false + } + } + } + + private func useClipboardAsComment() { + guard let text = NSPasteboard.general.string(forType: .string)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty else { + messages = [.warning("Zwischenablage enthält keinen Text.")] + return + } + + setComment(text) + } + + private func useSelectedFolderNameAsComment() { + guard let targetURL else { + messages = [.warning("Kein Ordner gewählt.")] + return + } + + setComment(targetURL.lastPathComponent) + } + + private func clearComment() { + commentText = "" + writeComment = false + } + + private func setComment(_ text: String) { + commentText = text + writeComment = true + removeComment = false + } +} + +private struct MessageList: View { + let messages: [ExportMessage] + let emptyText: String + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + if messages.isEmpty { + Text(emptyText) + .foregroundStyle(.secondary) + } else { + ForEach(messages) { message in + HStack(alignment: .top, spacing: 8) { + Image(systemName: message.iconName) + .foregroundStyle(message.color) + .frame(width: 18) + Text(message.text) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +private func chooseFolder(title: String, completion: (URL) -> Void) { + let panel = NSOpenPanel() + panel.title = title + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.canCreateDirectories = true + + if panel.runModal() == .OK, let url = panel.url { + completion(url) + } +} + private struct PathRow: View { let label: String let url: URL? diff --git a/Sources/TonUinoLogic.swift b/Sources/TonUinoLogic.swift index b5a3ca9..688a552 100644 --- a/Sources/TonUinoLogic.swift +++ b/Sources/TonUinoLogic.swift @@ -14,6 +14,11 @@ struct TrackPlan: Identifiable { let source: URL } +struct TonUinoExportOptions { + let overwriteExisting: Bool + let writeReport: Bool +} + struct TonUinoScanner { private let fileManager: FileManager @@ -97,10 +102,11 @@ struct TonUinoExporter { func export( episodes: [EpisodePlan], outputURL: URL, - overwriteExisting: Bool + options: TonUinoExportOptions ) -> [ExportMessage] { var messages: [ExportMessage] = [] var exportedEpisodes = 0 + var exportedEpisodePlans: [EpisodePlan] = [] do { try fileManager.createDirectory(at: outputURL, withIntermediateDirectories: true) @@ -117,11 +123,10 @@ struct TonUinoExporter { try fileManager.createDirectory(at: targetDirectory, withIntermediateDirectories: true) if fileManager.fileExists(atPath: destination.path(percentEncoded: false)) { - guard overwriteExisting else { + guard options.overwriteExisting else { messages.append(.error("Existiert bereits: \(episode.targetFolderName)/001.mp3")) continue } - } if fileManager.fileExists(atPath: temporaryDestination.path(percentEncoded: false)) { @@ -139,6 +144,7 @@ struct TonUinoExporter { try fileManager.moveItem(at: temporaryDestination, to: destination) exportedEpisodes += 1 + exportedEpisodePlans.append(episode) messages.append(.info("\(episode.sourceName) -> \(episode.targetFolderName)/001.mp3")) } catch { if fileManager.fileExists(atPath: temporaryDestination.path(percentEncoded: false)) { @@ -148,11 +154,51 @@ struct TonUinoExporter { } } + if options.writeReport { + messages.append(contentsOf: ExportReportWriter(fileManager: fileManager).write( + episodes: exportedEpisodePlans, + outputURL: outputURL + )) + } + messages.insert(.info("\(exportedEpisodes) Folge(n) exportiert."), at: 0) return messages } } +struct ExportReportWriter { + private let fileManager: FileManager + + init(fileManager: FileManager) { + self.fileManager = fileManager + } + + func write(episodes: [EpisodePlan], outputURL: URL) -> [ExportMessage] { + let destination = outputURL.appending(path: "TonUinoNator-Report.txt") + var lines: [String] = [ + "TonUinoNator Exportbericht", + "Erstellt: \(DateFormatter.reportDateTime.string(from: Date()))", + "", + "Folgen:" + ] + + for episode in episodes { + lines.append("\(episode.targetFolderName)/001.mp3 <- \(episode.sourceName)") + for track in episode.tracks { + lines.append(" - \(track.source.lastPathComponent)") + } + } + + do { + try fileManager.createDirectory(at: outputURL, withIntermediateDirectories: true) + try lines.joined(separator: "\n").write(to: destination, atomically: true, encoding: .utf8) + return [.info("Exportbericht geschrieben: TonUinoNator-Report.txt")] + } catch { + return [.error("Exportbericht konnte nicht geschrieben werden: \(error.localizedDescription)")] + } + } +} + enum SupportedAudioExtensions { private static let values: Set = ["mp3", "m4a", "aac", "wav", "flac", "ogg"] @@ -253,6 +299,889 @@ struct MP3Concatenator { } } +struct SDCardCleaner { + func clean(url: URL) -> [ExportMessage] { + guard let dotCleanURL = dotCleanExecutableURL() else { + return [.error("dot_clean wurde auf diesem Mac nicht gefunden.")] + } + + let process = Process() + process.executableURL = dotCleanURL + process.arguments = [url.path(percentEncoded: false)] + + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + + do { + try process.run() + process.waitUntilExit() + } catch { + return [.error("dot_clean konnte nicht gestartet werden: \(error.localizedDescription)")] + } + + let output = outputPipe.readString() + let errorOutput = errorPipe.readString() + var messages: [ExportMessage] = [] + + if process.terminationStatus == 0 { + messages.append(.info("SD-Karte bereinigt: \(url.lastPathComponent)")) + } else { + messages.append(.error("dot_clean beendet mit Code \(process.terminationStatus).")) + } + + if !output.isEmpty { + messages.append(.info(output)) + } + + if !errorOutput.isEmpty { + messages.append(process.terminationStatus == 0 ? .warning(errorOutput) : .error(errorOutput)) + } + + return messages + } + + private func dotCleanExecutableURL() -> URL? { + let candidates = [ + "/usr/sbin/dot_clean", + "/usr/bin/dot_clean" + ] + + return candidates + .map(URL.init(fileURLWithPath:)) + .first { FileManager.default.isExecutableFile(atPath: $0.path(percentEncoded: false)) } + } +} + +struct SDCardValidator { + private let fileManager: FileManager + + init(fileManager: FileManager) { + self.fileManager = fileManager + } + + func validate(rootURL: URL) -> [ExportMessage] { + var messages: [ExportMessage] = [] + + do { + let items = try fileManager.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey, .isHiddenKey], + options: [] + ).sortedByLocalizedName() + + let visibleItems = items.filter { !$0.lastPathComponent.hasPrefix(".") } + let metadataItems = items.filter { isMacMetadataFile($0.lastPathComponent) } + if !metadataItems.isEmpty { + messages.append(.warning("\(metadataItems.count) macOS-Metadaten-Datei(en) gefunden. dot_clean ausführen.")) + } + + let numericFolders = visibleItems.filter { url in + (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + && isNumericTonUinoFolder(url.lastPathComponent) + } + + if numericFolders.isEmpty { + messages.append(.warning("Keine TonUINO-Folgenordner 01 bis 99 gefunden.")) + } + + for item in visibleItems { + let values = try item.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) + let name = item.lastPathComponent + + if values.isDirectory == true { + validateRootDirectory(name: name, url: item, messages: &messages) + } else if values.isRegularFile == true && name != "TonUinoNator-Report.txt" { + messages.append(.warning("Unerwartete Datei im Hauptordner: \(name)")) + } + } + } catch { + return [.error("SD-Karte konnte nicht gelesen werden: \(error.localizedDescription)")] + } + + if messages.containsErrors { + messages.insert(.error("Prüfung abgeschlossen: Fehler gefunden."), at: 0) + } else if messages.containsWarnings { + messages.insert(.warning("Prüfung abgeschlossen: Warnungen gefunden."), at: 0) + } else { + messages.insert(.info("Prüfung abgeschlossen: TonUINO-Struktur sieht gut aus."), at: 0) + } + + return messages + } + + private func validateRootDirectory(name: String, url: URL, messages: inout [ExportMessage]) { + if isNumericTonUinoFolder(name) { + validateEpisodeFolder(url: url, messages: &messages) + return + } + + if name.lowercased() == "mp3" || name.lowercased() == "advert" { + validateFourDigitFolder(url: url, messages: &messages) + return + } + + messages.append(.warning("Unerwarteter Ordner im Hauptordner: \(name)")) + } + + private func validateEpisodeFolder(url: URL, messages: inout [ExportMessage]) { + do { + let files = try fileManager.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [.isRegularFileKey], + options: [] + ).sortedByLocalizedName() + + let visibleFiles = files.filter { !$0.lastPathComponent.hasPrefix(".") } + if visibleFiles.isEmpty { + messages.append(.warning("\(url.lastPathComponent): leerer Ordner")) + } + + for file in files where isMacMetadataFile(file.lastPathComponent) { + messages.append(.warning("\(url.lastPathComponent): macOS-Metadaten-Datei \(file.lastPathComponent)")) + } + + for file in visibleFiles { + let name = file.lastPathComponent + let isFile = (try? file.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true + guard isFile else { + messages.append(.warning("\(url.lastPathComponent): unerwarteter Unterordner \(name)")) + continue + } + + guard isThreeDigitMP3(name) else { + messages.append(.error("\(url.lastPathComponent): ungültiger Dateiname \(name), erwartet 001.mp3 bis 255.mp3")) + continue + } + } + + if visibleFiles.count > 255 { + messages.append(.error("\(url.lastPathComponent): mehr als 255 Dateien")) + } + } catch { + messages.append(.error("\(url.lastPathComponent): konnte nicht gelesen werden")) + } + } + + private func validateFourDigitFolder(url: URL, messages: inout [ExportMessage]) { + do { + let files = try fileManager.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [.isRegularFileKey], + options: [] + ).sortedByLocalizedName() + + for file in files where isMacMetadataFile(file.lastPathComponent) { + messages.append(.warning("\(url.lastPathComponent): macOS-Metadaten-Datei \(file.lastPathComponent)")) + } + + for file in files where !file.lastPathComponent.hasPrefix(".") { + let name = file.lastPathComponent + let isFile = (try? file.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true + guard isFile else { + messages.append(.warning("\(url.lastPathComponent): unerwarteter Unterordner \(name)")) + continue + } + + if !isFourDigitMP3(name) { + messages.append(.error("\(url.lastPathComponent): ungültiger Dateiname \(name), erwartet 0001.mp3 bis 9999.mp3")) + } + } + } catch { + messages.append(.error("\(url.lastPathComponent): konnte nicht gelesen werden")) + } + } + + private func isMacMetadataFile(_ name: String) -> Bool { + name == ".DS_Store" || name.hasPrefix("._") + } + + private func isNumericTonUinoFolder(_ name: String) -> Bool { + guard name.count == 2, let number = Int(name), number >= 1, number <= 99 else { + return false + } + + return name.allSatisfy(\.isNumber) + } + + private func isThreeDigitMP3(_ name: String) -> Bool { + guard name.lowercased().hasSuffix(".mp3"), name.count >= 7 else { return false } + let prefix = String(name.prefix(3)) + guard prefix.allSatisfy(\.isNumber), let number = Int(prefix) else { + return false + } + + return number >= 1 && number <= 255 + } + + private func isFourDigitMP3(_ name: String) -> Bool { + guard name.lowercased().hasSuffix(".mp3"), name.count >= 8 else { return false } + let prefix = String(name.prefix(4)) + guard prefix.allSatisfy(\.isNumber), let number = Int(prefix) else { + return false + } + + return number >= 1 && number <= 9999 + } +} + +enum ID3TagField: Hashable { + case title + case artist + case album + case track + case year + case genre + case comment + case artwork + + var frameIDs: Set { + switch self { + case .title: ["TIT2"] + case .artist: ["TPE1", "TPE2", "TCOM"] + case .album: ["TALB"] + case .track: ["TRCK", "TPOS"] + case .year: ["TYER", "TDRC", "TDAT", "TIME"] + case .genre: ["TCON"] + case .comment: ["COMM"] + case .artwork: ["APIC", "PIC"] + } + } +} + +struct ID3TagEditOptions { + let removeAllTags: Bool + let fieldsToRemove: Set + let commentToWrite: String? + + var frameIDsToRemove: Set { + fieldsToRemove.reduce(into: Set()) { result, field in + result.formUnion(field.frameIDs) + } + } +} + +struct ID3TagEditor { + private let fileManager: FileManager + + init(fileManager: FileManager) { + self.fileManager = fileManager + } + + func apply(to rootURL: URL, options: ID3TagEditOptions) -> [ExportMessage] { + do { + let mp3Files = try MP3FileFinder(fileManager: fileManager).mp3Files(in: rootURL) + guard !mp3Files.isEmpty else { + return [.warning("Keine MP3-Dateien gefunden.")] + } + + var changed = 0 + var messages: [ExportMessage] = [] + + for file in mp3Files { + do { + let didChange = try edit(fileURL: file, options: options) + if didChange { + changed += 1 + } + } catch { + messages.append(.error("\(file.lastPathComponent): \(error.localizedDescription)")) + } + } + + messages.insert(.info("\(changed) von \(mp3Files.count) MP3-Datei(en) bearbeitet."), at: 0) + return messages + } catch { + return [.error("Ordner konnte nicht gelesen werden: \(error.localizedDescription)")] + } + } + + private func edit(fileURL: URL, options: ID3TagEditOptions) throws -> Bool { + let original = try Data(contentsOf: fileURL) + let edited = try ID3TagDataEditor(options: options).edit(data: original) + + guard edited != original else { + return false + } + + let temporaryURL = fileURL.deletingLastPathComponent() + .appending(path: ".\(fileURL.lastPathComponent).tmp") + + if fileManager.fileExists(atPath: temporaryURL.path(percentEncoded: false)) { + try fileManager.removeItem(at: temporaryURL) + } + + try edited.write(to: temporaryURL, options: .atomic) + try fileManager.removeItem(at: fileURL) + try fileManager.moveItem(at: temporaryURL, to: fileURL) + return true + } +} + +struct ID3TagInspector { + private let fileManager: FileManager + + init(fileManager: FileManager) { + self.fileManager = fileManager + } + + func inspect(rootURL: URL) -> [ExportMessage] { + do { + let mp3Files = try MP3FileFinder(fileManager: fileManager).mp3Files(in: rootURL) + guard !mp3Files.isEmpty else { + return [.warning("Keine MP3-Dateien gefunden.")] + } + + var messages: [ExportMessage] = [.info("\(mp3Files.count) MP3-Datei(en) gefunden.")] + + for file in mp3Files { + do { + let summary = try inspect(fileURL: file) + messages.append(.info(summary)) + } catch { + messages.append(.error("\(file.lastPathComponent): \(error.localizedDescription)")) + } + } + + return messages + } catch { + return [.error("Ordner konnte nicht gelesen werden: \(error.localizedDescription)")] + } + } + + private func inspect(fileURL: URL) throws -> String { + let data = try Data(contentsOf: fileURL) + let id3v2 = ID3v2Tag(data: data) + let id3v1 = ID3v1Tag(data: data) + + var parts: [String] = [fileURL.lastPathComponent] + + if let id3v2 { + parts.append("ID3v2.\(id3v2.majorVersion)") + let values = ID3ReadableTags(frames: id3v2.frames) + parts.append(contentsOf: values.summaryParts) + } + + if let id3v1 { + parts.append("ID3v1") + parts.append(contentsOf: id3v1.summaryParts) + } + + if parts.count == 1 { + parts.append("keine ID3-Tags") + } + + return parts.joined(separator: " | ") + } +} + +struct ID3TagSearcher { + private let fileManager: FileManager + + init(fileManager: FileManager) { + self.fileManager = fileManager + } + + func search(rootURL: URL, query: String) -> [ExportMessage] { + let normalizedQuery = query.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + + do { + let mp3Files = try MP3FileFinder(fileManager: fileManager).mp3Files(in: rootURL) + guard !mp3Files.isEmpty else { + return [.warning("Keine MP3-Dateien gefunden.")] + } + + var matches: [ExportMessage] = [] + + for file in mp3Files { + do { + let searchableValues = try searchableTags(in: file) + let matchingValues = searchableValues.filter { value in + value.text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + .contains(normalizedQuery) + } + + if !matchingValues.isEmpty { + let fields = matchingValues + .map { "\($0.label): \($0.text)" } + .joined(separator: " | ") + matches.append(.info("\(fields) | Datei: \(file.lastPathComponent) | Ordner: \(file.deletingLastPathComponent().path(percentEncoded: false))")) + } + } catch { + matches.append(.error("\(file.lastPathComponent): \(error.localizedDescription)")) + } + } + + if matches.isEmpty { + return [.warning("Keine Titel, Alben oder Künstler gefunden für: \(query)")] + } + + matches.insert(.info("\(matches.count) Treffer für: \(query)"), at: 0) + return matches + } catch { + return [.error("Ordner konnte nicht gelesen werden: \(error.localizedDescription)")] + } + } + + private func searchableTags(in fileURL: URL) throws -> [(label: String, text: String)] { + let data = try Data(contentsOf: fileURL) + var values: [(label: String, text: String)] = [] + + if let id3v2 = ID3v2Tag(data: data) { + let readableTags = ID3ReadableTags(frames: id3v2.frames) + values.append(contentsOf: readableTags.searchableValues) + } + + if let id3v1 = ID3v1Tag(data: data) { + if !id3v1.title.isEmpty { + values.append(("Titel", id3v1.title)) + } + if !id3v1.album.isEmpty { + values.append(("Album", id3v1.album)) + } + if !id3v1.artist.isEmpty { + values.append(("Künstler", id3v1.artist)) + } + } + + return values + } +} + +private struct MP3FileFinder { + let fileManager: FileManager + + func mp3Files(in rootURL: URL) throws -> [URL] { + let values = try rootURL.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) + + if values.isRegularFile == true { + return rootURL.pathExtension.lowercased() == "mp3" ? [rootURL] : [] + } + + guard values.isDirectory == true else { + return [] + } + + guard let enumerator = fileManager.enumerator( + at: rootURL, + includingPropertiesForKeys: [.isRegularFileKey, .isHiddenKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } + + return enumerator.compactMap { item in + guard let url = item as? URL, url.pathExtension.lowercased() == "mp3" else { + return nil + } + + let isFile = (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true + return isFile ? url : nil + } + .sortedByLocalizedName() + } +} + +private struct ID3TagDataEditor { + enum EditError: LocalizedError { + case unsupportedID3Version(Int) + + var errorDescription: String? { + switch self { + case .unsupportedID3Version(let version): + "ID3v2.\(version) wird nicht unterstützt." + } + } + } + + let options: ID3TagEditOptions + + func edit(data: Data) throws -> Data { + let id3v2 = ID3v2Tag(data: data) + let audioStart = id3v2?.endOffset ?? 0 + let audioEnd = id3v1StartOffset(in: data) + let audioData = data.subdata(in: audioStart.. Data { + guard !frames.isEmpty else { + return audioData + } + + var frameData = Data() + for frame in frames { + frameData.append(frame.encoded(majorVersion: majorVersion)) + } + + var result = Data() + result.append(contentsOf: [0x49, 0x44, 0x33]) + result.append(UInt8(majorVersion)) + result.append(0x00) + result.append(0x00) + result.append(contentsOf: syncsafeBytes(frameData.count)) + result.append(frameData) + result.append(audioData) + return result + } + + private func id3v1StartOffset(in data: Data) -> Int { + guard data.count >= 128 else { return data.count } + + let start = data.count - 128 + let tag = data[start..<(start + 3)] + return tag.elementsEqual([0x54, 0x41, 0x47]) ? start : data.count + } +} + +private struct ID3v2Tag { + let majorVersion: Int + let endOffset: Int + let frames: [ID3Frame] + + init?(data: Data) { + guard data.count >= 10 else { return nil } + + let header = [UInt8](data.prefix(10)) + guard header[0] == 0x49, header[1] == 0x44, header[2] == 0x33 else { + return nil + } + + majorVersion = Int(header[3]) + let size = syncsafeInteger(header[6...9]) + let footerSize = (header[5] & 0x10) == 0x10 ? 10 : 0 + endOffset = min(data.count, 10 + size + footerSize) + + guard majorVersion == 3 || majorVersion == 4, endOffset >= 10 else { + frames = [] + return + } + + frames = ID3Frame.parseFrames( + data: data.subdata(in: 10.. [ID3Frame] { + var frames: [ID3Frame] = [] + var offset = 0 + + while offset + 10 <= data.count { + let header = [UInt8](data[offset..<(offset + 10)]) + if header[0] == 0 { + break + } + + guard let id = String(bytes: header[0..<4], encoding: .ascii), + id.allSatisfy({ $0.isLetter || $0.isNumber }) else { + break + } + + let size: Int + if majorVersion == 4 { + size = syncsafeInteger(header[4...7]) + } else { + size = bigEndianInteger(header[4...7]) + } + + guard size >= 0, offset + 10 + size <= data.count else { + break + } + + let payload = data.subdata(in: (offset + 10)..<(offset + 10 + size)) + frames.append(ID3Frame(id: id, payload: payload, flags: Array(header[8...9]))) + offset += 10 + size + } + + return frames + } + + static func comment(text: String, majorVersion: Int) -> ID3Frame { + var payload = Data() + payload.append(0x03) + payload.append(contentsOf: [0x64, 0x65, 0x75]) + payload.append(0x00) + payload.append(Data(text.utf8)) + return ID3Frame(id: "COMM", payload: payload, flags: [0x00, 0x00]) + } + + func encoded(majorVersion: Int) -> Data { + var data = Data() + data.append(Data(id.utf8.prefix(4))) + if majorVersion == 4 { + data.append(contentsOf: syncsafeBytes(payload.count)) + } else { + data.append(contentsOf: bigEndianBytes(payload.count)) + } + data.append(contentsOf: flags.prefix(2)) + if flags.count < 2 { + data.append(contentsOf: Array(repeating: UInt8(0), count: 2 - flags.count)) + } + data.append(payload) + return data + } +} + +private struct ID3ReadableTags { + let frames: [ID3Frame] + + var searchableValues: [(label: String, text: String)] { + var values: [(label: String, text: String)] = [] + appendSearchValue("Titel", ids: ["TIT2"], to: &values) + appendSearchValue("Album", ids: ["TALB"], to: &values) + appendSearchValue("Künstler", ids: ["TPE1", "TPE2", "TCOM"], to: &values) + return values + } + + var summaryParts: [String] { + var parts: [String] = [] + + appendText("Titel", ids: ["TIT2"], to: &parts) + appendText("Interpret", ids: ["TPE1"], to: &parts) + appendText("Album", ids: ["TALB"], to: &parts) + appendText("Track", ids: ["TRCK"], to: &parts) + appendText("Jahr", ids: ["TDRC", "TYER"], to: &parts) + appendText("Genre", ids: ["TCON"], to: &parts) + appendComment(to: &parts) + appendArtwork(to: &parts) + + let shownIDs = Set(["TIT2", "TPE1", "TALB", "TRCK", "TDRC", "TYER", "TCON", "COMM", "APIC", "PIC"]) + let otherCount = frames.filter { !shownIDs.contains($0.id) }.count + if otherCount > 0 { + parts.append("\(otherCount) weitere Frame(s)") + } + + if parts.isEmpty && !frames.isEmpty { + parts.append("\(frames.count) nicht lesbare Frame(s)") + } + + return parts + } + + private func appendText(_ label: String, ids: Set, to parts: inout [String]) { + guard let text = textValue(ids: ids), !text.isEmpty else { + return + } + + parts.append("\(label): \(text)") + } + + private func textValue(ids: Set) -> String? { + frames.first(where: { ids.contains($0.id) })?.textValue + } + + private func appendSearchValue(_ label: String, ids: Set, to values: inout [(label: String, text: String)]) { + guard let text = textValue(ids: ids), !text.isEmpty else { + return + } + + values.append((label, text)) + } + + private func appendComment(to parts: inout [String]) { + guard let comment = frames.first(where: { $0.id == "COMM" })?.commentValue, !comment.isEmpty else { + return + } + + parts.append("Kommentar: \(comment)") + } + + private func appendArtwork(to parts: inout [String]) { + let artworkCount = frames.filter { $0.id == "APIC" || $0.id == "PIC" }.count + guard artworkCount > 0 else { + return + } + + parts.append("\(artworkCount) eingebettete(s) Bild(er)") + } +} + +private struct ID3v1Tag { + let title: String + let artist: String + let album: String + let year: String + let comment: String + let track: UInt8? + let genre: UInt8 + + init?(data: Data) { + guard data.count >= 128 else { return nil } + + let start = data.count - 128 + let tag = data[start..<(start + 3)] + guard tag.elementsEqual([0x54, 0x41, 0x47]) else { + return nil + } + + title = ID3v1Tag.trimmedString(data[(start + 3)..<(start + 33)]) + artist = ID3v1Tag.trimmedString(data[(start + 33)..<(start + 63)]) + album = ID3v1Tag.trimmedString(data[(start + 63)..<(start + 93)]) + year = ID3v1Tag.trimmedString(data[(start + 93)..<(start + 97)]) + + let commentRange = data[(start + 97)..<(start + 127)] + let commentBytes = [UInt8](commentRange) + if commentBytes.count == 30, commentBytes[28] == 0, commentBytes[29] != 0 { + comment = ID3v1Tag.trimmedString(commentRange.dropLast(2)) + track = commentBytes[29] + } else { + comment = ID3v1Tag.trimmedString(commentRange) + track = nil + } + + genre = data[start + 127] + } + + var summaryParts: [String] { + var parts: [String] = [] + if !title.isEmpty { parts.append("Titel: \(title)") } + if !artist.isEmpty { parts.append("Interpret: \(artist)") } + if !album.isEmpty { parts.append("Album: \(album)") } + if !year.isEmpty { parts.append("Jahr: \(year)") } + if !comment.isEmpty { parts.append("Kommentar: \(comment)") } + if let track { parts.append("Track: \(track)") } + return parts + } + + private static func trimmedString(_ bytes: S) -> String where S.Element == UInt8 { + let data = Data(bytes) + return (String(data: data, encoding: .isoLatin1) ?? "") + .trimmingCharacters(in: CharacterSet(charactersIn: "\0 ").union(.newlines)) + } +} + +private extension ID3Frame { + var textValue: String? { + guard payload.count >= 1 else { return nil } + return decodeID3Text(payload.dropFirst()) + } + + var commentValue: String? { + guard payload.count >= 4 else { return nil } + + let body = payload.dropFirst(4) + let bytes = [UInt8](body) + let textStart: Int + if let zeroIndex = bytes.firstIndex(of: 0) { + textStart = zeroIndex + 1 + } else { + textStart = 0 + } + + guard textStart <= bytes.count else { return nil } + return decodeID3Text(bytes[textStart...]) + } + + private func decodeID3Text(_ bytes: S) -> String? where S.Element == UInt8 { + guard let encodingByte = payload.first else { return nil } + let data = Data(bytes) + + switch encodingByte { + case 0x00: + return String(data: data, encoding: .isoLatin1)?.trimmedForID3 + case 0x01: + return String(data: data, encoding: .utf16)?.trimmedForID3 + case 0x02: + return String(data: data, encoding: .utf16BigEndian)?.trimmedForID3 + case 0x03: + return String(data: data, encoding: .utf8)?.trimmedForID3 + default: + return String(data: data, encoding: .utf8)?.trimmedForID3 + } + } +} + +private extension String { + var trimmedForID3: String { + trimmingCharacters(in: CharacterSet(charactersIn: "\0 ").union(.newlines)) + } +} + +private func syncsafeInteger(_ bytes: S) -> Int where S.Element == UInt8 { + bytes.reduce(0) { ($0 << 7) | Int($1 & 0x7F) } +} + +private func bigEndianInteger(_ bytes: S) -> Int where S.Element == UInt8 { + bytes.reduce(0) { ($0 << 8) | Int($1) } +} + +private func syncsafeBytes(_ value: Int) -> [UInt8] { + [ + UInt8((value >> 21) & 0x7F), + UInt8((value >> 14) & 0x7F), + UInt8((value >> 7) & 0x7F), + UInt8(value & 0x7F) + ] +} + +private func bigEndianBytes(_ value: Int) -> [UInt8] { + [ + UInt8((value >> 24) & 0xFF), + UInt8((value >> 16) & 0xFF), + UInt8((value >> 8) & 0xFF), + UInt8(value & 0xFF) + ] +} + +private extension Pipe { + func readString() -> String { + let data = fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } +} + +private extension DateFormatter { + static let reportDateTime: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .medium + return formatter + }() +} + +private extension Array where Element == ExportMessage { + var containsErrors: Bool { + contains { $0.kind == .error } + } + + var containsWarnings: Bool { + contains { $0.kind == .warning } + } +} + private extension Array where Element == URL { func sortedByLocalizedName() -> [URL] { sorted { diff --git a/Sources/TonUinoNatorApp.swift b/Sources/TonUinoNatorApp.swift index 23d5f23..0e8c287 100644 --- a/Sources/TonUinoNatorApp.swift +++ b/Sources/TonUinoNatorApp.swift @@ -1,7 +1,10 @@ +import AppKit import SwiftUI @main struct TonUinoNatorApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + var body: some Scene { WindowGroup { ContentView() @@ -10,3 +13,14 @@ struct TonUinoNatorApp: App { .windowStyle(.titleBar) } } + +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.regular) + + DispatchQueue.main.async { + NSApp.activate(ignoringOtherApps: true) + NSApp.windows.first?.makeKeyAndOrderFront(nil) + } + } +}