commit 5a02abf26e2b25669f210581cb503281aaec3a13 Author: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Sun Jun 7 10:53:55 2026 +0200 Initial TonUinoNator app diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9bf047 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +.build/ +Package.resolved +*.xcodeproj/ +*.xcworkspace/ +DerivedData/ diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..52e237a --- /dev/null +++ b/Package.swift @@ -0,0 +1,19 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "TonUinoNator", + platforms: [ + .macOS(.v13) + ], + products: [ + .executable(name: "TonUinoNator", targets: ["TonUinoNator"]) + ], + targets: [ + .executableTarget( + name: "TonUinoNator", + path: "Sources" + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..9db48c5 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# TonUinoNator + +Kleines macOS-SwiftUI-Tool zum Vorbereiten von Hörspielordnern für TonUINO/DFPlayer-SD-Karten. + +## Erwartete Input-Struktur + +```text +Input/ + Folge 1/ + teil 1.mp3 + teil 2.mp3 + Folge 2/ + 01.mp3 + 02.mp3 +``` + +Die App nimmt jeden Unterordner mit Audiodateien als eine Folge. Dateien werden natürlich sortiert, also z. B. `Teil 2` vor `Teil 10`. + +## Output-Struktur + +```text +Output/ + 01/ + 001.mp3 + 02/ + 001.mp3 +``` + +Das entspricht dem gewünschten TonUINO/DFPlayer-Schema: 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. + +## Starten + +```bash +cd TonUinoNator +swift run +``` + +Für einen schnellen Build: + +```bash +swift build +``` diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift new file mode 100644 index 0000000..f12f248 --- /dev/null +++ b/Sources/ContentView.swift @@ -0,0 +1,286 @@ +import AppKit +import SwiftUI + +struct ContentView: 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 isExporting = false + + private var canExport: Bool { + inputURL != nil && outputURL != nil && !episodes.isEmpty && !isExporting + } + + var body: some View { + VStack(spacing: 0) { + toolbar + + Divider() + + HSplitView { + VStack(alignment: .leading, spacing: 12) { + header + episodeTable + } + .frame(minWidth: 520) + + VStack(alignment: .leading, spacing: 12) { + Text("Protokoll") + .font(.headline) + messageList + } + .frame(minWidth: 280) + .padding(16) + } + } + .onChange(of: inputURL) { _ in reloadPreview() } + .onAppear(perform: reloadPreview) + } + + private var toolbar: some View { + HStack(spacing: 10) { + Button { + chooseFolder(title: "Input-Ordner wählen") { url in + inputURL = url + } + } label: { + Label("Input", systemImage: "folder") + } + + Button { + chooseFolder(title: "Output-Ordner wählen") { url in + outputURL = url + } + } label: { + Label("Output", systemImage: "externaldrive") + } + + Button { + reloadPreview() + } label: { + Label("Aktualisieren", systemImage: "arrow.clockwise") + } + .disabled(inputURL == nil) + + Spacer() + + Toggle("Überschreiben", isOn: $overwriteExisting) + .toggleStyle(.checkbox) + + Button { + export() + } label: { + Label("Exportieren", systemImage: "square.and.arrow.down") + } + .buttonStyle(.borderedProminent) + .disabled(!canExport) + } + .padding(12) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 8) { + Text("TonUINO Export") + .font(.title2.weight(.semibold)) + + VStack(alignment: .leading, spacing: 4) { + PathRow(label: "Input", url: inputURL) + PathRow(label: "Output", url: outputURL) + } + + Text("\(episodes.count) Folgen, \(episodes.reduce(0) { $0 + $1.tracks.count }) Quelldateien") + .foregroundStyle(.secondary) + } + .padding([.horizontal, .top], 16) + } + + private var episodeTable: some View { + Table(episodes) { + TableColumn("Ziel") { episode in + Text(episode.targetFolderName) + .monospacedDigit() + } + .width(48) + + TableColumn("Folge") { episode in + VStack(alignment: .leading, spacing: 2) { + Text(episode.sourceName) + .lineLimit(1) + if !episode.warnings.isEmpty { + Text(episode.warnings.joined(separator: " · ")) + .font(.caption) + .foregroundStyle(.orange) + .lineLimit(2) + } + } + } + + TableColumn("Quellen") { episode in + Text("\(episode.tracks.count)") + .monospacedDigit() + } + .width(64) + + TableColumn("Zieldatei") { _ in + Text("001.mp3") + .monospacedDigit() + } + .width(82) + + TableColumn("Erste Quelle") { episode in + Text(episode.tracks.first?.source.lastPathComponent ?? "-") + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + + private var messageList: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + if messages.isEmpty { + Text("Noch kein Export ausgeführt.") + .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) + } + } + } + } + } + } + + 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 = [] + return + } + + do { + let scanner = TonUinoScanner(fileManager: .default) + episodes = try scanner.scan(inputURL: inputURL) + messages = scannerMessages(for: episodes) + } catch { + episodes = [] + messages = [.error("Vorschau fehlgeschlagen: \(error.localizedDescription)")] + } + } + + private func scannerMessages(for episodes: [EpisodePlan]) -> [ExportMessage] { + var result: [ExportMessage] = [] + + if episodes.isEmpty { + result.append(.warning("Keine Unterordner mit Audiodateien gefunden.")) + } + + for episode in episodes where !episode.warnings.isEmpty { + result.append(.warning("\(episode.sourceName): \(episode.warnings.joined(separator: ", "))")) + } + + return result + } + + private func export() { + guard let outputURL else { return } + + isExporting = true + messages = [.info("Export gestartet.")] + + DispatchQueue.global(qos: .userInitiated).async { + let exporter = TonUinoExporter(fileManager: .default) + let result = exporter.export( + episodes: episodes, + outputURL: outputURL, + overwriteExisting: overwriteExisting + ) + + DispatchQueue.main.async { + messages = result + isExporting = false + } + } + } +} + +private struct PathRow: View { + let label: String + let url: URL? + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(label) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(width: 48, alignment: .leading) + Text(url?.path(percentEncoded: false) ?? "Nicht gewählt") + .font(.caption) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + } +} + +struct ExportMessage: Identifiable { + enum Kind { + case info + case warning + case error + } + + let id = UUID() + let kind: Kind + let text: String + + var iconName: String { + switch kind { + case .info: "checkmark.circle" + case .warning: "exclamationmark.triangle" + case .error: "xmark.octagon" + } + } + + var color: Color { + switch kind { + case .info: .green + case .warning: .orange + case .error: .red + } + } + + static func info(_ text: String) -> ExportMessage { + ExportMessage(kind: .info, text: text) + } + + static func warning(_ text: String) -> ExportMessage { + ExportMessage(kind: .warning, text: text) + } + + static func error(_ text: String) -> ExportMessage { + ExportMessage(kind: .error, text: text) + } +} diff --git a/Sources/TonUinoLogic.swift b/Sources/TonUinoLogic.swift new file mode 100644 index 0000000..b5a3ca9 --- /dev/null +++ b/Sources/TonUinoLogic.swift @@ -0,0 +1,262 @@ +import Foundation + +struct EpisodePlan: Identifiable { + let id = UUID() + let source: URL + let sourceName: String + let targetFolderName: String + let tracks: [TrackPlan] + let warnings: [String] +} + +struct TrackPlan: Identifiable { + let id = UUID() + let source: URL +} + +struct TonUinoScanner { + private let fileManager: FileManager + + init(fileManager: FileManager) { + self.fileManager = fileManager + } + + func scan(inputURL: URL) throws -> [EpisodePlan] { + let directoryURLs = try fileManager + .contentsOfDirectory( + at: inputURL, + includingPropertiesForKeys: [.isDirectoryKey, .isHiddenKey], + options: [.skipsHiddenFiles] + ) + .filter { url in + (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + } + .sortedByLocalizedName() + + let playableDirectories = directoryURLs.compactMap { directoryURL -> (URL, [URL])? in + let trackURLs = (try? audioFiles(in: directoryURL)) ?? [] + guard !trackURLs.isEmpty else { return nil } + return (directoryURL, trackURLs) + } + + return playableDirectories.enumerated().map { index, item in + let directoryURL = item.0 + let trackURLs = item.1 + let targetFolderName = String(format: "%02d", index + 1) + var warnings: [String] = [] + + if index >= 99 { + warnings.append("TonUINO nutzt üblicherweise maximal 99 Ordner") + } + + if trackURLs.count > 255 { + warnings.append("Mehr als 255 Tracks in einem Ordner") + } + + let nonMP3Count = trackURLs.filter { $0.pathExtension.lowercased() != "mp3" }.count + if nonMP3Count > 0 { + warnings.append("\(nonMP3Count) Datei(en) sind nicht MP3 und werden nicht exportiert") + } + + let tracks = trackURLs.map { trackURL in + TrackPlan(source: trackURL) + } + + return EpisodePlan( + source: directoryURL, + sourceName: directoryURL.lastPathComponent, + targetFolderName: targetFolderName, + tracks: tracks, + warnings: warnings + ) + } + } + + private func audioFiles(in directoryURL: URL) throws -> [URL] { + try fileManager + .contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: [.isRegularFileKey, .isHiddenKey], + options: [.skipsHiddenFiles] + ) + .filter { url in + let isFile = (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true + return isFile && SupportedAudioExtensions.contains(url.pathExtension) + } + .sortedByLocalizedName() + } +} + +struct TonUinoExporter { + private let fileManager: FileManager + + init(fileManager: FileManager) { + self.fileManager = fileManager + } + + func export( + episodes: [EpisodePlan], + outputURL: URL, + overwriteExisting: Bool + ) -> [ExportMessage] { + var messages: [ExportMessage] = [] + var exportedEpisodes = 0 + + do { + try fileManager.createDirectory(at: outputURL, withIntermediateDirectories: true) + } catch { + return [.error("Output-Ordner konnte nicht erstellt werden: \(error.localizedDescription)")] + } + + 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 overwriteExisting else { + messages.append(.error("Existiert bereits: \(episode.targetFolderName)/001.mp3")) + continue + } + + } + + 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 + 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)")) + } + } + + messages.insert(.info("\(exportedEpisodes) Folge(n) exportiert."), at: 0) + return messages + } +} + +enum SupportedAudioExtensions { + private static let values: Set = ["mp3", "m4a", "aac", "wav", "flac", "ogg"] + + static func contains(_ pathExtension: String) -> Bool { + values.contains(pathExtension.lowercased()) + } +} + +struct MP3Concatenator { + enum ConcatenationError: LocalizedError { + case noSources + case unsupportedFormat(String) + case unreadableFile(String) + + var errorDescription: String? { + switch self { + case .noSources: + "Keine MP3-Dateien zum Zusammenfügen gefunden." + case .unsupportedFormat(let fileName): + "\(fileName) ist keine MP3-Datei." + case .unreadableFile(let fileName): + "\(fileName) konnte nicht gelesen werden." + } + } + } + + private let fileManager: FileManager + + init(fileManager: FileManager) { + self.fileManager = fileManager + } + + func concatenate(sources: [URL], destination: URL) throws { + guard !sources.isEmpty else { + throw ConcatenationError.noSources + } + + for source in sources where source.pathExtension.lowercased() != "mp3" { + throw ConcatenationError.unsupportedFormat(source.lastPathComponent) + } + + fileManager.createFile(atPath: destination.path(percentEncoded: false), contents: nil) + guard let output = try? FileHandle(forWritingTo: destination) else { + throw ConcatenationError.unreadableFile(destination.lastPathComponent) + } + + defer { + try? output.close() + } + + for source in sources { + guard let data = try? Data(contentsOf: source) else { + throw ConcatenationError.unreadableFile(source.lastPathComponent) + } + + let payload = stripMP3Metadata(from: data) + try output.write(contentsOf: payload) + } + } + + private func stripMP3Metadata(from data: Data) -> Data { + guard !data.isEmpty else { return data } + + let start = id3v2EndOffset(in: data) + let end = id3v1StartOffset(in: data) + + guard start < end else { return Data() } + return data.subdata(in: start.. Int { + guard data.count >= 10 else { return 0 } + + let header = [UInt8](data.prefix(10)) + guard header[0] == 0x49, header[1] == 0x44, header[2] == 0x33 else { + return 0 + } + + let size = (Int(header[6] & 0x7F) << 21) + | (Int(header[7] & 0x7F) << 14) + | (Int(header[8] & 0x7F) << 7) + | Int(header[9] & 0x7F) + + let footerSize = (header[5] & 0x10) == 0x10 ? 10 : 0 + return min(data.count, 10 + size + footerSize) + } + + 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)] + if tag.elementsEqual([0x54, 0x41, 0x47]) { + return start + } + + return data.count + } +} + +private extension Array where Element == URL { + func sortedByLocalizedName() -> [URL] { + sorted { + $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending + } + } +} diff --git a/Sources/TonUinoNatorApp.swift b/Sources/TonUinoNatorApp.swift new file mode 100644 index 0000000..23d5f23 --- /dev/null +++ b/Sources/TonUinoNatorApp.swift @@ -0,0 +1,12 @@ +import SwiftUI + +@main +struct TonUinoNatorApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .frame(minWidth: 900, minHeight: 620) + } + .windowStyle(.titleBar) + } +}