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 TonUinoExportOptions { enum TrackMode { case merged case separate } let overwriteExisting: Bool let writeReport: Bool let trackMode: TrackMode } 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, options: TonUinoExportOptions ) -> [ExportMessage] { var messages: [ExportMessage] = [] var exportedEpisodes = 0 var exportedEpisodePlans: [EpisodePlan] = [] do { try fileManager.createDirectory(at: outputURL, withIntermediateDirectories: true) } catch { return [.error("Output-Ordner konnte nicht erstellt werden: \(error.localizedDescription)")] } 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 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 { private let fileManager: FileManager init(fileManager: FileManager) { self.fileManager = fileManager } func write( episodes: [EpisodePlan], outputURL: URL, trackMode: TonUinoExportOptions.TrackMode ) -> [ExportMessage] { let destination = outputURL.appending(path: "TonUinoNator-Report.txt") var lines: [String] = [ "TonUinoNator Exportbericht", "Erstellt: \(DateFormatter.reportDateTime.string(from: Date()))", "", "Folgen:" ] for episode in episodes { 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)") } } } do { try fileManager.createDirectory(at: outputURL, withIntermediateDirectories: true) try lines.joined(separator: "\n").write(to: destination, atomically: true, encoding: .utf8) return [.info("Exportbericht geschrieben: TonUinoNator-Report.txt")] } catch { return [.error("Exportbericht konnte nicht geschrieben werden: \(error.localizedDescription)")] } } } 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 } } struct SDCardCleaner { func clean(url: URL) -> [ExportMessage] { guard let dotCleanURL = dotCleanExecutableURL() else { return [.error("dot_clean wurde auf diesem Mac nicht gefunden.")] } let process = Process() process.executableURL = dotCleanURL process.arguments = [url.path(percentEncoded: false)] let outputPipe = Pipe() let errorPipe = Pipe() process.standardOutput = outputPipe process.standardError = errorPipe do { try process.run() process.waitUntilExit() } catch { return [.error("dot_clean konnte nicht gestartet werden: \(error.localizedDescription)")] } let output = outputPipe.readString() let errorOutput = errorPipe.readString() var messages: [ExportMessage] = [] if process.terminationStatus == 0 { messages.append(.info("SD-Karte bereinigt: \(url.lastPathComponent)")) } else { messages.append(.error("dot_clean beendet mit Code \(process.terminationStatus).")) } if !output.isEmpty { messages.append(.info(output)) } if !errorOutput.isEmpty { messages.append(process.terminationStatus == 0 ? .warning(errorOutput) : .error(errorOutput)) } return messages } private func dotCleanExecutableURL() -> URL? { let candidates = [ "/usr/sbin/dot_clean", "/usr/bin/dot_clean" ] return candidates .map(URL.init(fileURLWithPath:)) .first { FileManager.default.isExecutableFile(atPath: $0.path(percentEncoded: false)) } } } struct MP3GainNormalizer { private let fileManager: FileManager init(fileManager: FileManager) { self.fileManager = fileManager } func normalize(rootURL: URL) -> [ExportMessage] { guard let mp3gainURL = mp3gainExecutableURL() else { return [.error("mp3gain wurde nicht gefunden. Erwartet z. B. /opt/homebrew/bin/mp3gain.")] } do { let mp3Files = try MP3FileFinder(fileManager: fileManager).mp3Files(in: rootURL) guard !mp3Files.isEmpty else { return [.warning("Keine MP3-Dateien für MP3Gain gefunden.")] } var messages: [ExportMessage] = [] var processed = 0 for batch in mp3Files.chunked(into: 50) { let result = runMP3Gain(executableURL: mp3gainURL, files: batch) messages.append(contentsOf: result.messages) if result.success { processed += batch.count } } messages.insert(.info("MP3Gain abgeschlossen: \(processed) von \(mp3Files.count) Datei(en) verarbeitet."), at: 0) return messages } catch { return [.error("MP3Gain-Dateisuche fehlgeschlagen: \(error.localizedDescription)")] } } private func runMP3Gain(executableURL: URL, files: [URL]) -> (success: Bool, messages: [ExportMessage]) { let process = Process() process.executableURL = executableURL process.arguments = ["-r", "-k", "-d", "4"] + files.map { $0.path(percentEncoded: false) } let outputPipe = Pipe() let errorPipe = Pipe() process.standardOutput = outputPipe process.standardError = errorPipe do { try process.run() process.waitUntilExit() } catch { return (false, [.error("mp3gain konnte nicht gestartet werden: \(error.localizedDescription)")]) } let output = outputPipe.readString() let errorOutput = errorPipe.readString() var messages: [ExportMessage] = [] if process.terminationStatus == 0 { messages.append(.info("MP3Gain Batch verarbeitet: \(files.count) Datei(en)")) } else { messages.append(.error("MP3Gain beendet mit Code \(process.terminationStatus).")) } if !output.isEmpty { messages.append(.info(output)) } if !errorOutput.isEmpty { messages.append(process.terminationStatus == 0 ? .warning(errorOutput) : .error(errorOutput)) } return (process.terminationStatus == 0, messages) } private func mp3gainExecutableURL() -> URL? { let candidates = [ "/opt/homebrew/bin/mp3gain", "/usr/local/bin/mp3gain", "/usr/bin/mp3gain" ] return candidates .map(URL.init(fileURLWithPath:)) .first { fileManager.isExecutableFile(atPath: $0.path(percentEncoded: false)) } } } struct SDCardValidator { private let fileManager: FileManager init(fileManager: FileManager) { self.fileManager = fileManager } func validate(rootURL: URL) -> [ExportMessage] { var messages: [ExportMessage] = [] do { let items = try fileManager.contentsOfDirectory( at: rootURL, includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey, .isHiddenKey], options: [] ).sortedByLocalizedName() let visibleItems = items.filter { !$0.lastPathComponent.hasPrefix(".") } let metadataItems = items.filter { isMacMetadataFile($0.lastPathComponent) } if !metadataItems.isEmpty { messages.append(.warning("\(metadataItems.count) macOS-Metadaten-Datei(en) gefunden. dot_clean ausführen.")) } let numericFolders = visibleItems.filter { url in (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true && isNumericTonUinoFolder(url.lastPathComponent) } if numericFolders.isEmpty { messages.append(.warning("Keine TonUINO-Folgenordner 01 bis 99 gefunden.")) } for item in visibleItems { let values = try item.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) let name = item.lastPathComponent if values.isDirectory == true { validateRootDirectory(name: name, url: item, messages: &messages) } else if values.isRegularFile == true && name != "TonUinoNator-Report.txt" { messages.append(.warning("Unerwartete Datei im Hauptordner: \(name)")) } } } catch { return [.error("SD-Karte konnte nicht gelesen werden: \(error.localizedDescription)")] } if messages.containsErrors { messages.insert(.error("Prüfung abgeschlossen: Fehler gefunden."), at: 0) } else if messages.containsWarnings { messages.insert(.warning("Prüfung abgeschlossen: Warnungen gefunden."), at: 0) } else { messages.insert(.info("Prüfung abgeschlossen: TonUINO-Struktur sieht gut aus."), at: 0) } return messages } private func validateRootDirectory(name: String, url: URL, messages: inout [ExportMessage]) { if isNumericTonUinoFolder(name) { validateEpisodeFolder(url: url, messages: &messages) return } if name.lowercased() == "mp3" || name.lowercased() == "advert" { validateFourDigitFolder(url: url, messages: &messages) return } messages.append(.warning("Unerwarteter Ordner im Hauptordner: \(name)")) } private func validateEpisodeFolder(url: URL, messages: inout [ExportMessage]) { do { let files = try fileManager.contentsOfDirectory( at: url, includingPropertiesForKeys: [.isRegularFileKey], options: [] ).sortedByLocalizedName() let visibleFiles = files.filter { !$0.lastPathComponent.hasPrefix(".") } if visibleFiles.isEmpty { messages.append(.warning("\(url.lastPathComponent): leerer Ordner")) } for file in files where isMacMetadataFile(file.lastPathComponent) { messages.append(.warning("\(url.lastPathComponent): macOS-Metadaten-Datei \(file.lastPathComponent)")) } for file in visibleFiles { let name = file.lastPathComponent let isFile = (try? file.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true guard isFile else { messages.append(.warning("\(url.lastPathComponent): unerwarteter Unterordner \(name)")) continue } guard isThreeDigitMP3(name) else { messages.append(.error("\(url.lastPathComponent): ungültiger Dateiname \(name), erwartet 001.mp3 bis 255.mp3")) continue } } if visibleFiles.count > 255 { messages.append(.error("\(url.lastPathComponent): mehr als 255 Dateien")) } } catch { messages.append(.error("\(url.lastPathComponent): konnte nicht gelesen werden")) } } private func validateFourDigitFolder(url: URL, messages: inout [ExportMessage]) { do { let files = try fileManager.contentsOfDirectory( at: url, includingPropertiesForKeys: [.isRegularFileKey], options: [] ).sortedByLocalizedName() for file in files where isMacMetadataFile(file.lastPathComponent) { messages.append(.warning("\(url.lastPathComponent): macOS-Metadaten-Datei \(file.lastPathComponent)")) } for file in files where !file.lastPathComponent.hasPrefix(".") { let name = file.lastPathComponent let isFile = (try? file.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true guard isFile else { messages.append(.warning("\(url.lastPathComponent): unerwarteter Unterordner \(name)")) continue } if !isFourDigitMP3(name) { messages.append(.error("\(url.lastPathComponent): ungültiger Dateiname \(name), erwartet 0001.mp3 bis 9999.mp3")) } } } catch { messages.append(.error("\(url.lastPathComponent): konnte nicht gelesen werden")) } } private func isMacMetadataFile(_ name: String) -> Bool { name == ".DS_Store" || name.hasPrefix("._") } private func isNumericTonUinoFolder(_ name: String) -> Bool { guard name.count == 2, let number = Int(name), number >= 1, number <= 99 else { return false } return name.allSatisfy(\.isNumber) } private func isThreeDigitMP3(_ name: String) -> Bool { guard name.lowercased().hasSuffix(".mp3"), name.count >= 7 else { return false } let prefix = String(name.prefix(3)) guard prefix.allSatisfy(\.isNumber), let number = Int(prefix) else { return false } return number >= 1 && number <= 255 } private func isFourDigitMP3(_ name: String) -> Bool { guard name.lowercased().hasSuffix(".mp3"), name.count >= 8 else { return false } let prefix = String(name.prefix(4)) guard prefix.allSatisfy(\.isNumber), let number = Int(prefix) else { return false } return number >= 1 && number <= 9999 } } enum ID3TagField: Hashable { case title case artist case album case track case year case genre case comment case artwork var frameIDs: Set { switch self { case .title: ["TIT2"] case .artist: ["TPE1", "TPE2", "TCOM"] case .album: ["TALB"] case .track: ["TRCK", "TPOS"] case .year: ["TYER", "TDRC", "TDAT", "TIME"] case .genre: ["TCON"] case .comment: ["COMM"] case .artwork: ["APIC", "PIC"] } } } struct ID3TagEditOptions { let removeAllTags: Bool let fieldsToRemove: Set let commentToWrite: String? var frameIDsToRemove: Set { fieldsToRemove.reduce(into: Set()) { result, field in result.formUnion(field.frameIDs) } } } struct ID3TagEditor { private let fileManager: FileManager init(fileManager: FileManager) { self.fileManager = fileManager } func apply(to rootURL: URL, options: ID3TagEditOptions) -> [ExportMessage] { do { let mp3Files = try MP3FileFinder(fileManager: fileManager).mp3Files(in: rootURL) guard !mp3Files.isEmpty else { return [.warning("Keine MP3-Dateien gefunden.")] } var changed = 0 var messages: [ExportMessage] = [] for file in mp3Files { do { let didChange = try edit(fileURL: file, options: options) if didChange { changed += 1 } } catch { messages.append(.error("\(file.lastPathComponent): \(error.localizedDescription)")) } } messages.insert(.info("\(changed) von \(mp3Files.count) MP3-Datei(en) bearbeitet."), at: 0) return messages } catch { return [.error("Ordner konnte nicht gelesen werden: \(error.localizedDescription)")] } } private func edit(fileURL: URL, options: ID3TagEditOptions) throws -> Bool { let original = try Data(contentsOf: fileURL) let edited = try ID3TagDataEditor(options: options).edit(data: original) guard edited != original else { return false } let temporaryURL = fileURL.deletingLastPathComponent() .appending(path: ".\(fileURL.lastPathComponent).tmp") if fileManager.fileExists(atPath: temporaryURL.path(percentEncoded: false)) { try fileManager.removeItem(at: temporaryURL) } try edited.write(to: temporaryURL, options: .atomic) try fileManager.removeItem(at: fileURL) try fileManager.moveItem(at: temporaryURL, to: fileURL) return true } } struct ID3TagInspector { private let fileManager: FileManager init(fileManager: FileManager) { self.fileManager = fileManager } func inspect(rootURL: URL) -> [ExportMessage] { do { let mp3Files = try MP3FileFinder(fileManager: fileManager).mp3Files(in: rootURL) guard !mp3Files.isEmpty else { return [.warning("Keine MP3-Dateien gefunden.")] } var messages: [ExportMessage] = [.info("\(mp3Files.count) MP3-Datei(en) gefunden.")] for file in mp3Files { do { let summary = try inspect(fileURL: file) messages.append(.info(summary)) } catch { messages.append(.error("\(file.lastPathComponent): \(error.localizedDescription)")) } } return messages } catch { return [.error("Ordner konnte nicht gelesen werden: \(error.localizedDescription)")] } } private func inspect(fileURL: URL) throws -> String { let data = try Data(contentsOf: fileURL) let id3v2 = ID3v2Tag(data: data) let id3v1 = ID3v1Tag(data: data) var parts: [String] = [fileURL.lastPathComponent] if let id3v2 { parts.append("ID3v2.\(id3v2.majorVersion)") let values = ID3ReadableTags(frames: id3v2.frames) parts.append(contentsOf: values.summaryParts) } if let id3v1 { parts.append("ID3v1") parts.append(contentsOf: id3v1.summaryParts) } if parts.count == 1 { parts.append("keine ID3-Tags") } return parts.joined(separator: " | ") } } struct ID3TagSearcher { private let fileManager: FileManager init(fileManager: FileManager) { self.fileManager = fileManager } func search(rootURL: URL, query: String) -> [ExportMessage] { let normalizedQuery = query.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) do { let mp3Files = try MP3FileFinder(fileManager: fileManager).mp3Files(in: rootURL) guard !mp3Files.isEmpty else { return [.warning("Keine MP3-Dateien gefunden.")] } var matches: [ExportMessage] = [] for file in mp3Files { do { let searchableValues = try searchableTags(in: file) let matchingValues = searchableValues.filter { value in value.text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) .contains(normalizedQuery) } if !matchingValues.isEmpty { let fields = matchingValues .map { "\($0.label): \($0.text)" } .joined(separator: " | ") matches.append(.info("\(fields) | Datei: \(file.lastPathComponent) | Ordner: \(file.deletingLastPathComponent().path(percentEncoded: false))")) } } catch { matches.append(.error("\(file.lastPathComponent): \(error.localizedDescription)")) } } if matches.isEmpty { return [.warning("Keine Titel, Alben oder Künstler gefunden für: \(query)")] } matches.insert(.info("\(matches.count) Treffer für: \(query)"), at: 0) return matches } catch { return [.error("Ordner konnte nicht gelesen werden: \(error.localizedDescription)")] } } private func searchableTags(in fileURL: URL) throws -> [(label: String, text: String)] { let data = try Data(contentsOf: fileURL) var values: [(label: String, text: String)] = [] if let id3v2 = ID3v2Tag(data: data) { let readableTags = ID3ReadableTags(frames: id3v2.frames) values.append(contentsOf: readableTags.searchableValues) } if let id3v1 = ID3v1Tag(data: data) { if !id3v1.title.isEmpty { values.append(("Titel", id3v1.title)) } if !id3v1.album.isEmpty { values.append(("Album", id3v1.album)) } if !id3v1.artist.isEmpty { values.append(("Künstler", id3v1.artist)) } } return values } } private struct MP3FileFinder { let fileManager: FileManager func mp3Files(in rootURL: URL) throws -> [URL] { let values = try rootURL.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) if values.isRegularFile == true { return rootURL.pathExtension.lowercased() == "mp3" ? [rootURL] : [] } guard values.isDirectory == true else { return [] } guard let enumerator = fileManager.enumerator( at: rootURL, includingPropertiesForKeys: [.isRegularFileKey, .isHiddenKey], options: [.skipsHiddenFiles] ) else { return [] } return enumerator.compactMap { item in guard let url = item as? URL, url.pathExtension.lowercased() == "mp3" else { return nil } let isFile = (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true return isFile ? url : nil } .sortedByLocalizedName() } } private struct ID3TagDataEditor { enum EditError: LocalizedError { case unsupportedID3Version(Int) var errorDescription: String? { switch self { case .unsupportedID3Version(let version): "ID3v2.\(version) wird nicht unterstützt." } } } let options: ID3TagEditOptions func edit(data: Data) throws -> Data { let id3v2 = ID3v2Tag(data: data) let audioStart = id3v2?.endOffset ?? 0 let audioEnd = id3v1StartOffset(in: data) let audioData = data.subdata(in: audioStart.. Data { guard !frames.isEmpty else { return audioData } var frameData = Data() for frame in frames { frameData.append(frame.encoded(majorVersion: majorVersion)) } var result = Data() result.append(contentsOf: [0x49, 0x44, 0x33]) result.append(UInt8(majorVersion)) result.append(0x00) result.append(0x00) result.append(contentsOf: syncsafeBytes(frameData.count)) result.append(frameData) result.append(audioData) return result } 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)] return tag.elementsEqual([0x54, 0x41, 0x47]) ? start : data.count } } private struct ID3v2Tag { let majorVersion: Int let endOffset: Int let frames: [ID3Frame] init?(data: Data) { guard data.count >= 10 else { return nil } let header = [UInt8](data.prefix(10)) guard header[0] == 0x49, header[1] == 0x44, header[2] == 0x33 else { return nil } majorVersion = Int(header[3]) let size = syncsafeInteger(header[6...9]) let footerSize = (header[5] & 0x10) == 0x10 ? 10 : 0 endOffset = min(data.count, 10 + size + footerSize) guard majorVersion == 3 || majorVersion == 4, endOffset >= 10 else { frames = [] return } frames = ID3Frame.parseFrames( data: data.subdata(in: 10.. [ID3Frame] { var frames: [ID3Frame] = [] var offset = 0 while offset + 10 <= data.count { let header = [UInt8](data[offset..<(offset + 10)]) if header[0] == 0 { break } guard let id = String(bytes: header[0..<4], encoding: .ascii), id.allSatisfy({ $0.isLetter || $0.isNumber }) else { break } let size: Int if majorVersion == 4 { size = syncsafeInteger(header[4...7]) } else { size = bigEndianInteger(header[4...7]) } guard size >= 0, offset + 10 + size <= data.count else { break } let payload = data.subdata(in: (offset + 10)..<(offset + 10 + size)) frames.append(ID3Frame(id: id, payload: payload, flags: Array(header[8...9]))) offset += 10 + size } return frames } static func comment(text: String, majorVersion: Int) -> ID3Frame { var payload = Data() payload.append(0x03) payload.append(contentsOf: [0x64, 0x65, 0x75]) payload.append(0x00) payload.append(Data(text.utf8)) return ID3Frame(id: "COMM", payload: payload, flags: [0x00, 0x00]) } func encoded(majorVersion: Int) -> Data { var data = Data() data.append(Data(id.utf8.prefix(4))) if majorVersion == 4 { data.append(contentsOf: syncsafeBytes(payload.count)) } else { data.append(contentsOf: bigEndianBytes(payload.count)) } data.append(contentsOf: flags.prefix(2)) if flags.count < 2 { data.append(contentsOf: Array(repeating: UInt8(0), count: 2 - flags.count)) } data.append(payload) return data } } private struct ID3ReadableTags { let frames: [ID3Frame] var searchableValues: [(label: String, text: String)] { var values: [(label: String, text: String)] = [] appendSearchValue("Titel", ids: ["TIT2"], to: &values) appendSearchValue("Album", ids: ["TALB"], to: &values) appendSearchValue("Künstler", ids: ["TPE1", "TPE2", "TCOM"], to: &values) return values } var summaryParts: [String] { var parts: [String] = [] appendText("Titel", ids: ["TIT2"], to: &parts) appendText("Interpret", ids: ["TPE1"], to: &parts) appendText("Album", ids: ["TALB"], to: &parts) appendText("Track", ids: ["TRCK"], to: &parts) appendText("Jahr", ids: ["TDRC", "TYER"], to: &parts) appendText("Genre", ids: ["TCON"], to: &parts) appendComment(to: &parts) appendArtwork(to: &parts) let shownIDs = Set(["TIT2", "TPE1", "TALB", "TRCK", "TDRC", "TYER", "TCON", "COMM", "APIC", "PIC"]) let otherCount = frames.filter { !shownIDs.contains($0.id) }.count if otherCount > 0 { parts.append("\(otherCount) weitere Frame(s)") } if parts.isEmpty && !frames.isEmpty { parts.append("\(frames.count) nicht lesbare Frame(s)") } return parts } private func appendText(_ label: String, ids: Set, to parts: inout [String]) { guard let text = textValue(ids: ids), !text.isEmpty else { return } parts.append("\(label): \(text)") } private func textValue(ids: Set) -> String? { frames.first(where: { ids.contains($0.id) })?.textValue } private func appendSearchValue(_ label: String, ids: Set, to values: inout [(label: String, text: String)]) { guard let text = textValue(ids: ids), !text.isEmpty else { return } values.append((label, text)) } private func appendComment(to parts: inout [String]) { guard let comment = frames.first(where: { $0.id == "COMM" })?.commentValue, !comment.isEmpty else { return } parts.append("Kommentar: \(comment)") } private func appendArtwork(to parts: inout [String]) { let artworkCount = frames.filter { $0.id == "APIC" || $0.id == "PIC" }.count guard artworkCount > 0 else { return } parts.append("\(artworkCount) eingebettete(s) Bild(er)") } } private struct ID3v1Tag { let title: String let artist: String let album: String let year: String let comment: String let track: UInt8? let genre: UInt8 init?(data: Data) { guard data.count >= 128 else { return nil } let start = data.count - 128 let tag = data[start..<(start + 3)] guard tag.elementsEqual([0x54, 0x41, 0x47]) else { return nil } title = ID3v1Tag.trimmedString(data[(start + 3)..<(start + 33)]) artist = ID3v1Tag.trimmedString(data[(start + 33)..<(start + 63)]) album = ID3v1Tag.trimmedString(data[(start + 63)..<(start + 93)]) year = ID3v1Tag.trimmedString(data[(start + 93)..<(start + 97)]) let commentRange = data[(start + 97)..<(start + 127)] let commentBytes = [UInt8](commentRange) if commentBytes.count == 30, commentBytes[28] == 0, commentBytes[29] != 0 { comment = ID3v1Tag.trimmedString(commentRange.dropLast(2)) track = commentBytes[29] } else { comment = ID3v1Tag.trimmedString(commentRange) track = nil } genre = data[start + 127] } var summaryParts: [String] { var parts: [String] = [] if !title.isEmpty { parts.append("Titel: \(title)") } if !artist.isEmpty { parts.append("Interpret: \(artist)") } if !album.isEmpty { parts.append("Album: \(album)") } if !year.isEmpty { parts.append("Jahr: \(year)") } if !comment.isEmpty { parts.append("Kommentar: \(comment)") } if let track { parts.append("Track: \(track)") } return parts } private static func trimmedString(_ bytes: S) -> String where S.Element == UInt8 { let data = Data(bytes) return (String(data: data, encoding: .isoLatin1) ?? "") .trimmingCharacters(in: CharacterSet(charactersIn: "\0 ").union(.newlines)) } } private extension ID3Frame { var textValue: String? { guard payload.count >= 1 else { return nil } return decodeID3Text(payload.dropFirst()) } var commentValue: String? { guard payload.count >= 4 else { return nil } let body = payload.dropFirst(4) let bytes = [UInt8](body) let textStart: Int if let zeroIndex = bytes.firstIndex(of: 0) { textStart = zeroIndex + 1 } else { textStart = 0 } guard textStart <= bytes.count else { return nil } return decodeID3Text(bytes[textStart...]) } private func decodeID3Text(_ bytes: S) -> String? where S.Element == UInt8 { guard let encodingByte = payload.first else { return nil } let data = Data(bytes) switch encodingByte { case 0x00: return String(data: data, encoding: .isoLatin1)?.trimmedForID3 case 0x01: return String(data: data, encoding: .utf16)?.trimmedForID3 case 0x02: return String(data: data, encoding: .utf16BigEndian)?.trimmedForID3 case 0x03: return String(data: data, encoding: .utf8)?.trimmedForID3 default: return String(data: data, encoding: .utf8)?.trimmedForID3 } } } private extension String { var trimmedForID3: String { trimmingCharacters(in: CharacterSet(charactersIn: "\0 ").union(.newlines)) } } private func syncsafeInteger(_ bytes: S) -> Int where S.Element == UInt8 { bytes.reduce(0) { ($0 << 7) | Int($1 & 0x7F) } } private func bigEndianInteger(_ bytes: S) -> Int where S.Element == UInt8 { bytes.reduce(0) { ($0 << 8) | Int($1) } } private func syncsafeBytes(_ value: Int) -> [UInt8] { [ UInt8((value >> 21) & 0x7F), UInt8((value >> 14) & 0x7F), UInt8((value >> 7) & 0x7F), UInt8(value & 0x7F) ] } private func bigEndianBytes(_ value: Int) -> [UInt8] { [ UInt8((value >> 24) & 0xFF), UInt8((value >> 16) & 0xFF), UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF) ] } private extension Pipe { func readString() -> String { let data = fileHandleForReading.readDataToEndOfFile() return String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } } private extension DateFormatter { static let reportDateTime: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .medium return formatter }() } private extension Array where Element == ExportMessage { var containsErrors: Bool { contains { $0.kind == .error } } var containsWarnings: Bool { contains { $0.kind == .warning } } } private extension Array { func chunked(into size: Int) -> [[Element]] { guard size > 0 else { return [self] } return stride(from: 0, to: count, by: size).map { Array(self[$0.. [URL] { sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } } }