Add separate track export mode
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+92
-21
@@ -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,24 +121,52 @@ struct TonUinoExporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for episode in episodes {
|
for episode in episodes {
|
||||||
|
do {
|
||||||
|
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))"))
|
||||||
|
}
|
||||||
|
|
||||||
|
exportedEpisodes += 1
|
||||||
|
exportedEpisodePlans.append(episode)
|
||||||
|
} catch {
|
||||||
|
messages.append(.error("\(episode.sourceName): \(error.localizedDescription)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.writeReport {
|
||||||
|
messages.append(contentsOf: ExportReportWriter(fileManager: fileManager).write(
|
||||||
|
episodes: exportedEpisodePlans,
|
||||||
|
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 targetDirectory = outputURL.appending(path: episode.targetFolderName, directoryHint: .isDirectory)
|
||||||
let destination = targetDirectory.appending(path: "001.mp3")
|
let destination = targetDirectory.appending(path: "001.mp3")
|
||||||
let temporaryDestination = targetDirectory.appending(path: ".001.mp3.tmp")
|
let temporaryDestination = targetDirectory.appending(path: ".001.mp3.tmp")
|
||||||
|
|
||||||
do {
|
|
||||||
try fileManager.createDirectory(at: targetDirectory, withIntermediateDirectories: true)
|
try fileManager.createDirectory(at: targetDirectory, withIntermediateDirectories: true)
|
||||||
|
try prepareDestination(destination, overwriteExisting: overwriteExisting)
|
||||||
if fileManager.fileExists(atPath: destination.path(percentEncoded: false)) {
|
|
||||||
guard options.overwriteExisting else {
|
|
||||||
messages.append(.error("Existiert bereits: \(episode.targetFolderName)/001.mp3"))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if fileManager.fileExists(atPath: temporaryDestination.path(percentEncoded: false)) {
|
if fileManager.fileExists(atPath: temporaryDestination.path(percentEncoded: false)) {
|
||||||
try fileManager.removeItem(at: temporaryDestination)
|
try fileManager.removeItem(at: temporaryDestination)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
try MP3Concatenator(fileManager: fileManager).concatenate(
|
try MP3Concatenator(fileManager: fileManager).concatenate(
|
||||||
sources: episode.tracks.map(\.source),
|
sources: episode.tracks.map(\.source),
|
||||||
destination: temporaryDestination
|
destination: temporaryDestination
|
||||||
@@ -142,27 +176,52 @@ struct TonUinoExporter {
|
|||||||
try fileManager.removeItem(at: destination)
|
try fileManager.removeItem(at: destination)
|
||||||
}
|
}
|
||||||
try fileManager.moveItem(at: temporaryDestination, to: destination)
|
try fileManager.moveItem(at: temporaryDestination, to: destination)
|
||||||
|
|
||||||
exportedEpisodes += 1
|
|
||||||
exportedEpisodePlans.append(episode)
|
|
||||||
messages.append(.info("\(episode.sourceName) -> \(episode.targetFolderName)/001.mp3"))
|
|
||||||
} catch {
|
} catch {
|
||||||
if fileManager.fileExists(atPath: temporaryDestination.path(percentEncoded: false)) {
|
if fileManager.fileExists(atPath: temporaryDestination.path(percentEncoded: false)) {
|
||||||
try? fileManager.removeItem(at: temporaryDestination)
|
try? fileManager.removeItem(at: temporaryDestination)
|
||||||
}
|
}
|
||||||
messages.append(.error("\(episode.sourceName): \(error.localizedDescription)"))
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.writeReport {
|
private func exportSeparateEpisode(
|
||||||
messages.append(contentsOf: ExportReportWriter(fileManager: fileManager).write(
|
_ episode: EpisodePlan,
|
||||||
episodes: exportedEpisodePlans,
|
outputURL: URL,
|
||||||
outputURL: outputURL
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.insert(.info("\(exportedEpisodes) Folge(n) exportiert."), at: 0)
|
let destination = targetDirectory.appending(path: String(format: "%03d.mp3", index + 1))
|
||||||
return messages
|
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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,10 +246,18 @@ struct ExportReportWriter {
|
|||||||
]
|
]
|
||||||
|
|
||||||
for episode in episodes {
|
for episode in episodes {
|
||||||
|
switch trackMode {
|
||||||
|
case .merged:
|
||||||
lines.append("\(episode.targetFolderName)/001.mp3 <- \(episode.sourceName)")
|
lines.append("\(episode.targetFolderName)/001.mp3 <- \(episode.sourceName)")
|
||||||
for track in episode.tracks {
|
for track in episode.tracks {
|
||||||
lines.append(" - \(track.source.lastPathComponent)")
|
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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
|||||||
Reference in New Issue
Block a user