diff --git a/README.md b/README.md index 30bbf10..67bb864 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Kleines macOS-SwiftUI-Tool zum Vorbereiten von Hörspielordnern für TonUINO/DFP ## Werkzeuge -- `Datenumwandlung`: Fügt die MP3-Dateien je Folge zusammen und schreibt die TonUINO-Ordnerstruktur. +- `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. - `ID3-Tags`: Bearbeitet MP3-Tags rekursiv auf einer SD-Karte oder in einem ausgewählten Ordner. @@ -32,9 +32,21 @@ Output/ 001.mp3 ``` -Das entspricht dem gewünschten TonUINO/DFPlayer-Schema: ein nummerierter Ordner pro Folge und darin genau eine Datei `001.mp3`. +Im Modus `Zusammenführen` entsteht ein nummerierter Ordner pro Folge und darin genau eine Datei `001.mp3`. -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. +Im Modus `Getrennt` bleiben die Dateien einer Folge getrennt: + +```text +Output/ + 01/ + 001.mp3 + 002.mp3 + 02/ + 001.mp3 + 002.mp3 +``` + +Die App verarbeitet die MP3-Dateien einer Folge in natürlicher Sortierung. 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. diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b538e9a..c1ba410 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -31,6 +31,7 @@ private struct ConversionToolView: View { @State private var writeReport = true @State private var cleanAfterExport = true @State private var validateAfterExport = true + @State private var trackMode: TonUinoExportOptions.TrackMode = .merged @State private var isExporting = false private var canExport: Bool { @@ -90,6 +91,13 @@ private struct ConversionToolView: View { Spacer() + Picker("Modus", selection: $trackMode) { + Text("Zusammenführen").tag(TonUinoExportOptions.TrackMode.merged) + Text("Getrennt").tag(TonUinoExportOptions.TrackMode.separate) + } + .pickerStyle(.segmented) + .frame(width: 230) + HStack(spacing: 12) { Toggle("Überschreiben", isOn: $overwriteExisting) Toggle("Bericht", isOn: $writeReport) @@ -152,11 +160,11 @@ private struct ConversionToolView: View { } .width(64) - TableColumn("Zieldatei") { _ in - Text("001.mp3") + TableColumn("Zieldatei") { episode in + Text(targetFileDescription(for: episode)) .monospacedDigit() } - .width(82) + .width(108) TableColumn("Erste Quelle") { episode in Text(episode.tracks.first?.source.lastPathComponent ?? "-") @@ -233,7 +241,8 @@ private struct ConversionToolView: View { outputURL: outputURL, options: TonUinoExportOptions( overwriteExisting: overwriteExisting, - writeReport: writeReport + writeReport: writeReport, + trackMode: trackMode ) ) @@ -251,6 +260,15 @@ private struct ConversionToolView: View { } } } + + private func targetFileDescription(for episode: EpisodePlan) -> String { + switch trackMode { + case .merged: + "001.mp3" + case .separate: + episode.tracks.count <= 1 ? "001.mp3" : "001...\(String(format: "%03d", episode.tracks.count)).mp3" + } + } } private struct SDCardToolView: View { diff --git a/Sources/TonUinoLogic.swift b/Sources/TonUinoLogic.swift index 688a552..70ee53e 100644 --- a/Sources/TonUinoLogic.swift +++ b/Sources/TonUinoLogic.swift @@ -15,8 +15,14 @@ struct TrackPlan: Identifiable { } struct TonUinoExportOptions { + enum TrackMode { + case merged + case separate + } + let overwriteExisting: Bool let writeReport: Bool + let trackMode: TrackMode } struct TonUinoScanner { @@ -115,41 +121,19 @@ struct TonUinoExporter { } for episode in episodes { - let targetDirectory = outputURL.appending(path: episode.targetFolderName, directoryHint: .isDirectory) - let destination = targetDirectory.appending(path: "001.mp3") - let temporaryDestination = targetDirectory.appending(path: ".001.mp3.tmp") - do { - try fileManager.createDirectory(at: targetDirectory, withIntermediateDirectories: true) - - if fileManager.fileExists(atPath: destination.path(percentEncoded: false)) { - guard options.overwriteExisting else { - messages.append(.error("Existiert bereits: \(episode.targetFolderName)/001.mp3")) - continue - } + switch options.trackMode { + case .merged: + try exportMergedEpisode(episode, outputURL: outputURL, overwriteExisting: options.overwriteExisting) + messages.append(.info("\(episode.sourceName) -> \(episode.targetFolderName)/001.mp3")) + case .separate: + try exportSeparateEpisode(episode, outputURL: outputURL, overwriteExisting: options.overwriteExisting) + messages.append(.info("\(episode.sourceName) -> \(episode.targetFolderName)/001.mp3...\(String(format: "%03d.mp3", episode.tracks.count))")) } - if fileManager.fileExists(atPath: temporaryDestination.path(percentEncoded: false)) { - try fileManager.removeItem(at: temporaryDestination) - } - - try MP3Concatenator(fileManager: fileManager).concatenate( - sources: episode.tracks.map(\.source), - destination: temporaryDestination - ) - - if fileManager.fileExists(atPath: destination.path(percentEncoded: false)) { - try fileManager.removeItem(at: destination) - } - 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)) { - try? fileManager.removeItem(at: temporaryDestination) - } messages.append(.error("\(episode.sourceName): \(error.localizedDescription)")) } } @@ -157,13 +141,88 @@ struct TonUinoExporter { if options.writeReport { messages.append(contentsOf: ExportReportWriter(fileManager: fileManager).write( episodes: exportedEpisodePlans, - outputURL: outputURL + outputURL: outputURL, + trackMode: options.trackMode )) } messages.insert(.info("\(exportedEpisodes) Folge(n) exportiert."), at: 0) return messages } + + private func exportMergedEpisode( + _ episode: EpisodePlan, + outputURL: URL, + overwriteExisting: Bool + ) throws { + let targetDirectory = outputURL.appending(path: episode.targetFolderName, directoryHint: .isDirectory) + let destination = targetDirectory.appending(path: "001.mp3") + let temporaryDestination = targetDirectory.appending(path: ".001.mp3.tmp") + + try fileManager.createDirectory(at: targetDirectory, withIntermediateDirectories: true) + try prepareDestination(destination, overwriteExisting: overwriteExisting) + + if fileManager.fileExists(atPath: temporaryDestination.path(percentEncoded: false)) { + try fileManager.removeItem(at: temporaryDestination) + } + + do { + try MP3Concatenator(fileManager: fileManager).concatenate( + sources: episode.tracks.map(\.source), + destination: temporaryDestination + ) + + if fileManager.fileExists(atPath: destination.path(percentEncoded: false)) { + try fileManager.removeItem(at: destination) + } + try fileManager.moveItem(at: temporaryDestination, to: destination) + } catch { + if fileManager.fileExists(atPath: temporaryDestination.path(percentEncoded: false)) { + try? fileManager.removeItem(at: temporaryDestination) + } + throw error + } + } + + private func exportSeparateEpisode( + _ episode: EpisodePlan, + outputURL: URL, + overwriteExisting: Bool + ) throws { + let targetDirectory = outputURL.appending(path: episode.targetFolderName, directoryHint: .isDirectory) + try fileManager.createDirectory(at: targetDirectory, withIntermediateDirectories: true) + + for (index, track) in episode.tracks.enumerated() { + guard track.source.pathExtension.lowercased() == "mp3" else { + throw MP3Concatenator.ConcatenationError.unsupportedFormat(track.source.lastPathComponent) + } + + let destination = targetDirectory.appending(path: String(format: "%03d.mp3", index + 1)) + try prepareDestination(destination, overwriteExisting: overwriteExisting) + try fileManager.copyItem(at: track.source, to: destination) + } + } + + private func prepareDestination(_ destination: URL, overwriteExisting: Bool) throws { + if fileManager.fileExists(atPath: destination.path(percentEncoded: false)) { + guard overwriteExisting else { + throw ExportError.destinationExists(destination.lastPathComponent) + } + + try fileManager.removeItem(at: destination) + } + } + + enum ExportError: LocalizedError { + case destinationExists(String) + + var errorDescription: String? { + switch self { + case .destinationExists(let fileName): + "Existiert bereits: \(fileName)" + } + } + } } struct ExportReportWriter { @@ -173,7 +232,11 @@ struct ExportReportWriter { self.fileManager = fileManager } - func write(episodes: [EpisodePlan], outputURL: URL) -> [ExportMessage] { + func write( + episodes: [EpisodePlan], + outputURL: URL, + trackMode: TonUinoExportOptions.TrackMode + ) -> [ExportMessage] { let destination = outputURL.appending(path: "TonUinoNator-Report.txt") var lines: [String] = [ "TonUinoNator Exportbericht", @@ -183,9 +246,17 @@ struct ExportReportWriter { ] for episode in episodes { - lines.append("\(episode.targetFolderName)/001.mp3 <- \(episode.sourceName)") - for track in episode.tracks { - lines.append(" - \(track.source.lastPathComponent)") + switch trackMode { + case .merged: + lines.append("\(episode.targetFolderName)/001.mp3 <- \(episode.sourceName)") + for track in episode.tracks { + lines.append(" - \(track.source.lastPathComponent)") + } + case .separate: + lines.append("\(episode.targetFolderName)/ <- \(episode.sourceName)") + for (index, track) in episode.tracks.enumerated() { + lines.append(" - \(String(format: "%03d.mp3", index + 1)) <- \(track.source.lastPathComponent)") + } } }