diff --git a/README.md b/README.md index 67bb864..a07d1ad 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Kleines macOS-SwiftUI-Tool zum Vorbereiten von Hörspielordnern für TonUINO/DFP ## Werkzeuge - `Datenumwandlung`: Schreibt die TonUINO-Ordnerstruktur. MP3-Dateien je Folge können zu `001.mp3` zusammengeführt oder getrennt als `001.mp3`, `002.mp3`, ... exportiert werden. -- `SD-Karte`: Prüft eine ausgewählte TonUINO-SD-Karte und führt `dot_clean` aus, um macOS-Metadaten-Dateien zu bereinigen. +- `SD-Karte`: Prüft eine ausgewählte TonUINO-SD-Karte, führt `dot_clean` aus und kann MP3Gain mit `-r -k -d 4` auf alle MP3-Dateien anwenden. - `ID3-Tags`: Bearbeitet MP3-Tags rekursiv auf einer SD-Karte oder in einem ausgewählten Ordner. ## Erwartete Input-Struktur @@ -58,6 +58,12 @@ Die SD-Karten-Prüfung kontrolliert: - macOS-Metadaten wie `.DS_Store` und `._*` - unerwartete Dateien oder Ordner im Hauptverzeichnis +Die MP3Gain-Funktion setzt voraus, dass `mp3gain` installiert ist, z. B. unter `/opt/homebrew/bin/mp3gain`. Verwendet wird: + +```bash +mp3gain -r -k -d 4 +``` + 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 diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index c1ba410..d8841a7 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -301,6 +301,13 @@ private struct SDCardToolView: View { } .disabled(!canRun) + Button { + normalizeMP3Gain() + } label: { + Label("MP3Gain", systemImage: "waveform") + } + .disabled(!canRun) + Button { cleanSDCard() } label: { @@ -386,6 +393,23 @@ private struct SDCardToolView: View { } } } + + private func normalizeMP3Gain() { + guard let sdCardURL else { return } + + isWorking = true + messages = [.info("MP3Gain gestartet: -r -k -d 4")] + + DispatchQueue.global(qos: .userInitiated).async { + var result = MP3GainNormalizer(fileManager: .default).normalize(rootURL: sdCardURL) + result.append(contentsOf: SDCardValidator(fileManager: .default).validate(rootURL: sdCardURL)) + + DispatchQueue.main.async { + messages = result + isWorking = false + } + } + } } private struct ID3TagToolView: View { diff --git a/Sources/TonUinoLogic.swift b/Sources/TonUinoLogic.swift index 70ee53e..eaa5f0f 100644 --- a/Sources/TonUinoLogic.swift +++ b/Sources/TonUinoLogic.swift @@ -425,6 +425,93 @@ struct SDCardCleaner { } } +struct MP3GainNormalizer { + private let fileManager: FileManager + + init(fileManager: FileManager) { + self.fileManager = fileManager + } + + func normalize(rootURL: URL) -> [ExportMessage] { + guard let mp3gainURL = mp3gainExecutableURL() else { + return [.error("mp3gain wurde nicht gefunden. Erwartet z. B. /opt/homebrew/bin/mp3gain.")] + } + + do { + let mp3Files = try MP3FileFinder(fileManager: fileManager).mp3Files(in: rootURL) + guard !mp3Files.isEmpty else { + return [.warning("Keine MP3-Dateien für MP3Gain gefunden.")] + } + + var messages: [ExportMessage] = [] + var processed = 0 + + for batch in mp3Files.chunked(into: 50) { + let result = runMP3Gain(executableURL: mp3gainURL, files: batch) + messages.append(contentsOf: result.messages) + if result.success { + processed += batch.count + } + } + + messages.insert(.info("MP3Gain abgeschlossen: \(processed) von \(mp3Files.count) Datei(en) verarbeitet."), at: 0) + return messages + } catch { + return [.error("MP3Gain-Dateisuche fehlgeschlagen: \(error.localizedDescription)")] + } + } + + private func runMP3Gain(executableURL: URL, files: [URL]) -> (success: Bool, messages: [ExportMessage]) { + let process = Process() + process.executableURL = executableURL + process.arguments = ["-r", "-k", "-d", "4"] + files.map { $0.path(percentEncoded: false) } + + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + + do { + try process.run() + process.waitUntilExit() + } catch { + return (false, [.error("mp3gain konnte nicht gestartet werden: \(error.localizedDescription)")]) + } + + let output = outputPipe.readString() + let errorOutput = errorPipe.readString() + var messages: [ExportMessage] = [] + + if process.terminationStatus == 0 { + messages.append(.info("MP3Gain Batch verarbeitet: \(files.count) Datei(en)")) + } else { + messages.append(.error("MP3Gain 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 (process.terminationStatus == 0, messages) + } + + private func mp3gainExecutableURL() -> URL? { + let candidates = [ + "/opt/homebrew/bin/mp3gain", + "/usr/local/bin/mp3gain", + "/usr/bin/mp3gain" + ] + + return candidates + .map(URL.init(fileURLWithPath:)) + .first { fileManager.isExecutableFile(atPath: $0.path(percentEncoded: false)) } + } +} + struct SDCardValidator { private let fileManager: FileManager @@ -1253,6 +1340,18 @@ private extension Array where Element == ExportMessage { } } +private extension Array { + func chunked(into size: Int) -> [[Element]] { + guard size > 0 else { + return [self] + } + + return stride(from: 0, to: count, by: size).map { + Array(self[$0.. [URL] { sorted {