Add separate track export mode

This commit is contained in:
Mikei386
2026-06-07 12:26:14 +02:00
parent 9f4d06ec4c
commit ce47ceb176
3 changed files with 142 additions and 41 deletions
+15 -3
View File
@@ -4,7 +4,7 @@ Kleines macOS-SwiftUI-Tool zum Vorbereiten von Hörspielordnern für TonUINO/DFP
## Werkzeuge ## 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. - `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. - `ID3-Tags`: Bearbeitet MP3-Tags rekursiv auf einer SD-Karte oder in einem ausgewählten Ordner.
@@ -32,9 +32,21 @@ Output/
001.mp3 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. Beim Export können optional ein Zuordnungsbericht (`TonUinoNator-Report.txt`), `dot_clean` und eine anschließende TonUINO-Strukturprüfung ausgeführt werden.
+22 -4
View File
@@ -31,6 +31,7 @@ private struct ConversionToolView: View {
@State private var writeReport = true @State private var writeReport = true
@State private var cleanAfterExport = true @State private var cleanAfterExport = true
@State private var validateAfterExport = true @State private var validateAfterExport = true
@State private var trackMode: TonUinoExportOptions.TrackMode = .merged
@State private var isExporting = false @State private var isExporting = false
private var canExport: Bool { private var canExport: Bool {
@@ -90,6 +91,13 @@ private struct ConversionToolView: View {
Spacer() 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) { HStack(spacing: 12) {
Toggle("Überschreiben", isOn: $overwriteExisting) Toggle("Überschreiben", isOn: $overwriteExisting)
Toggle("Bericht", isOn: $writeReport) Toggle("Bericht", isOn: $writeReport)
@@ -152,11 +160,11 @@ private struct ConversionToolView: View {
} }
.width(64) .width(64)
TableColumn("Zieldatei") { _ in TableColumn("Zieldatei") { episode in
Text("001.mp3") Text(targetFileDescription(for: episode))
.monospacedDigit() .monospacedDigit()
} }
.width(82) .width(108)
TableColumn("Erste Quelle") { episode in TableColumn("Erste Quelle") { episode in
Text(episode.tracks.first?.source.lastPathComponent ?? "-") Text(episode.tracks.first?.source.lastPathComponent ?? "-")
@@ -233,7 +241,8 @@ private struct ConversionToolView: View {
outputURL: outputURL, outputURL: outputURL,
options: TonUinoExportOptions( options: TonUinoExportOptions(
overwriteExisting: overwriteExisting, 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 { private struct SDCardToolView: View {
+105 -34
View File
@@ -15,8 +15,14 @@ struct TrackPlan: Identifiable {
} }
struct TonUinoExportOptions { struct TonUinoExportOptions {
enum TrackMode {
case merged
case separate
}
let overwriteExisting: Bool let overwriteExisting: Bool
let writeReport: Bool let writeReport: Bool
let trackMode: TrackMode
} }
struct TonUinoScanner { struct TonUinoScanner {
@@ -115,41 +121,19 @@ struct TonUinoExporter {
} }
for episode in episodes { 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 { do {
try fileManager.createDirectory(at: targetDirectory, withIntermediateDirectories: true) switch options.trackMode {
case .merged:
if fileManager.fileExists(atPath: destination.path(percentEncoded: false)) { try exportMergedEpisode(episode, outputURL: outputURL, overwriteExisting: options.overwriteExisting)
guard options.overwriteExisting else { messages.append(.info("\(episode.sourceName) -> \(episode.targetFolderName)/001.mp3"))
messages.append(.error("Existiert bereits: \(episode.targetFolderName)/001.mp3")) case .separate:
continue 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 exportedEpisodes += 1
exportedEpisodePlans.append(episode) exportedEpisodePlans.append(episode)
messages.append(.info("\(episode.sourceName) -> \(episode.targetFolderName)/001.mp3"))
} catch { } catch {
if fileManager.fileExists(atPath: temporaryDestination.path(percentEncoded: false)) {
try? fileManager.removeItem(at: temporaryDestination)
}
messages.append(.error("\(episode.sourceName): \(error.localizedDescription)")) messages.append(.error("\(episode.sourceName): \(error.localizedDescription)"))
} }
} }
@@ -157,13 +141,88 @@ struct TonUinoExporter {
if options.writeReport { if options.writeReport {
messages.append(contentsOf: ExportReportWriter(fileManager: fileManager).write( messages.append(contentsOf: ExportReportWriter(fileManager: fileManager).write(
episodes: exportedEpisodePlans, episodes: exportedEpisodePlans,
outputURL: outputURL outputURL: outputURL,
trackMode: options.trackMode
)) ))
} }
messages.insert(.info("\(exportedEpisodes) Folge(n) exportiert."), at: 0) messages.insert(.info("\(exportedEpisodes) Folge(n) exportiert."), at: 0)
return messages 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 { struct ExportReportWriter {
@@ -173,7 +232,11 @@ struct ExportReportWriter {
self.fileManager = fileManager 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") let destination = outputURL.appending(path: "TonUinoNator-Report.txt")
var lines: [String] = [ var lines: [String] = [
"TonUinoNator Exportbericht", "TonUinoNator Exportbericht",
@@ -183,9 +246,17 @@ struct ExportReportWriter {
] ]
for episode in episodes { for episode in episodes {
lines.append("\(episode.targetFolderName)/001.mp3 <- \(episode.sourceName)") switch trackMode {
for track in episode.tracks { case .merged:
lines.append(" - \(track.source.lastPathComponent)") 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)")
}
} }
} }