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 } } }