import Foundation struct TrackMetadata: Sendable { var title = "" var artist = "" var album = "" var albumArtist = "" var trackNumber = "" var discNumber = "" var year = "" var genre = "" var acoustID = "" var musicBrainzRecordingID = "" var musicBrainzReleaseID = "" } enum ID3Metadata { private static let standardFrames: [String: WritableKeyPath] = [ "TIT2": \.title, "TPE1": \.artist, "TALB": \.album, "TPE2": \.albumArtist, "TRCK": \.trackNumber, "TPOS": \.discNumber, "TDRC": \.year, "TYER": \.year, "TCON": \.genre ] private static let managedFrames = Set(standardFrames.keys) private static let managedUserTexts: [String: WritableKeyPath] = [ "Acoustid Id": \.acoustID, "MusicBrainz Recording Id": \.musicBrainzRecordingID, "MusicBrainz Album Id": \.musicBrainzReleaseID ] private static let v22FrameIDs: [String: String] = [ "BUF": "RBUF", "CNT": "PCNT", "COM": "COMM", "CRA": "AENC", "ETC": "ETCO", "GEO": "GEOB", "IPL": "TIPL", "MCI": "MCDI", "MLL": "MLLT", "PIC": "APIC", "POP": "POPM", "REV": "RVRB", "RVA": "RVAD", "SLT": "SYLT", "STC": "SYTC", "TAL": "TALB", "TBP": "TBPM", "TCM": "TCOM", "TCO": "TCON", "TCR": "TCOP", "TDA": "TDAT", "TDY": "TDLY", "TEN": "TENC", "TFT": "TFLT", "TIM": "TIME", "TKE": "TKEY", "TLA": "TLAN", "TLE": "TLEN", "TMT": "TMED", "TOA": "TOPE", "TOF": "TOFN", "TOL": "TOLY", "TOR": "TORY", "TOT": "TOAL", "TP1": "TPE1", "TP2": "TPE2", "TP3": "TPE3", "TP4": "TPE4", "TPA": "TPOS", "TPB": "TPUB", "TRC": "TSRC", "TRD": "TRDA", "TRK": "TRCK", "TSI": "TSIZ", "TSS": "TSSE", "TT1": "TIT1", "TT2": "TIT2", "TT3": "TIT3", "TXT": "TEXT", "TXX": "TXXX", "TYE": "TYER", "UFI": "UFID", "ULT": "USLT", "WAF": "WOAF", "WAM": "WOAM", "WAS": "WOAS", "WCM": "WCOM", "WCP": "WCOP", "WPB": "WPUB", "WXX": "WXXX" ] static func read(_ url: URL) -> TrackMetadata { guard let handle = try? FileHandle(forReadingFrom: url) else { return TrackMetadata() } defer { try? handle.close() } guard let header = try? handle.read(upToCount: 10), header.count == 10, String(data: header.prefix(3), encoding: .ascii) == "ID3" else { return TrackMetadata() } let tagSize = syncSafe(header[6..<10]) guard tagSize > 0, let tagBody = try? handle.read(upToCount: tagSize) else { return TrackMetadata() } var data = header data.append(tagBody) let version = header[3] let limit = min(data.count, 10 + syncSafe(data[6..<10])) var position = 10 var result = TrackMetadata() if version == 2 { guard header[5] & 0x40 == 0 else { return result } while position + 6 <= limit { guard let oldID = String(data: data[position..= 0x20 && $0 <= 0x5A }), oldID != "\0\0\0" else { break } let size = int24(data[position + 3.. 0, position + size <= limit else { break } let id = v22FrameIDs[oldID] ?? oldID let frame = data[position..= 0x20 && $0 <= 0x5A }), id != "\0\0\0\0" else { break } let size = version == 4 ? syncSafe(data[position + 4.. 0, position + size <= limit else { break } let frame = data[position..= 10, String(data: original.prefix(3), encoding: .ascii) == "ID3" { let version = original[3] let end = min(original.count, 10 + syncSafe(original[6..<10])) audio = original.subdata(in: end..= 0x20 && $0 <= 0x5A }), oldID != "\0\0\0" else { break } let size = int24(original[index + 3.. 0, index + size <= end else { break } let oldFrame = original[index.. 0, index + 10 + size <= end else { break } let frame = original[index + 10..(_ data: S) -> String? { guard let encoding = data.first else { return nil } let value = Data(data.dropFirst()) let decoded: String? switch encoding { case 0: decoded = String(data: value, encoding: .isoLatin1) case 1: decoded = String(data: value, encoding: .utf16) case 2: decoded = String(data: value, encoding: .utf16BigEndian) default: decoded = String(data: value, encoding: .utf8) } return decoded?.trimmingCharacters(in: .controlCharacters) } private static func decodeUserText(_ data: S) -> (String, String)? { guard let encoding = data.first else { return nil } let value = Data(data.dropFirst()) let separatorStart: Int let separatorLength: Int if encoding == 1 || encoding == 2 { let start = value.starts(with: [0xFF, 0xFE]) || value.starts(with: [0xFE, 0xFF]) ? 2 : 0 var cursor = start var found: Int? while cursor + 1 < value.count { if value[cursor] == 0 && value[cursor + 1] == 0 { found = cursor; break } cursor += 2 } guard let found else { return nil } separatorStart = found; separatorLength = 2 } else { guard let found = value.firstIndex(of: 0) else { return nil } separatorStart = found; separatorLength = 1 } let description = decodeEncoded(value[..(_ data: S, encoding: UInt8) -> String { let value = Data(data) switch encoding { case 0: return String(data: value, encoding: .isoLatin1) ?? "" case 1: return String(data: value, encoding: .utf16) ?? "" case 2: return String(data: value, encoding: .utf16BigEndian) ?? "" default: return String(data: value, encoding: .utf8) ?? "" } } private static func encodeV23Text(_ value: String) -> Data { Data([1, 0xFF, 0xFE]) + utf16LE(value) } private static func normalizeGenre(_ value: String) -> String { let genres = [ "Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", "Rap", "Reggae", "Rock", "Techno", "Industrial", "Alternative", "Ska", "Death Metal", "Pranks", "Soundtrack", "Euro-Techno", "Ambient", "Trip-Hop", "Vocal", "Jazz+Funk", "Fusion", "Trance", "Classical", "Instrumental", "Acid", "House", "Game", "Sound Clip", "Gospel", "Noise", "AlternRock", "Bass", "Soul", "Punk", "Space", "Meditative", "Instrumental Pop", "Instrumental Rock", "Ethnic", "Gothic", "Darkwave", "Techno-Industrial", "Electronic", "Pop-Folk", "Eurodance", "Dream", "Southern Rock", "Comedy", "Cult", "Gangsta", "Top 40", "Christian Rap", "Pop/Funk", "Jungle", "Native American", "Cabaret", "New Wave", "Psychedelic", "Rave", "Showtunes", "Trailer", "Lo-Fi", "Tribal", "Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll", "Hard Rock" ] let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) let pattern = #"^\((\d{1,3})\)\s*"# if let range = trimmed.range(of: pattern, options: .regularExpression), let numberRange = trimmed.range(of: #"\d{1,3}"#, options: .regularExpression), let index = Int(trimmed[numberRange]), genres.indices.contains(index) { let suffix = String(trimmed[range.upperBound...]).trimmingCharacters(in: .whitespaces) return suffix.isEmpty ? genres[index] : suffix } if let index = Int(trimmed), genres.indices.contains(index) { return genres[index] } return value } private static func convertV22Picture(_ frame: S) -> Data? { let bytes = Data(frame) guard bytes.count > 5, let format = String(data: bytes[1..<4], encoding: .ascii) else { return nil } let normalized = format.uppercased() let mime = normalized == "JPG" || normalized == "JPEG" ? "image/jpeg" : "image/\(normalized.lowercased())" return Data([bytes[0]]) + Data(mime.utf8) + Data([0]) + bytes[4...] } private static func utf16LE(_ value: String) -> Data { var data = Data(capacity: value.utf16.count * 2) for unit in value.utf16 { data.append(UInt8(unit & 0xFF)); data.append(UInt8(unit >> 8)) } return data } private static func appendFrame(id: String, content: Data, to frames: inout Data) { frames.append(Data(id.utf8)); frames.append(contentsOf: [UInt8((content.count >> 24) & 255), UInt8((content.count >> 16) & 255), UInt8((content.count >> 8) & 255), UInt8(content.count & 255), 0, 0]); frames.append(content) } private static func int32(_ bytes: S) -> Int where S.Element == UInt8 { bytes.reduce(0) { ($0 << 8) | Int($1) } } private static func int24(_ bytes: S) -> Int where S.Element == UInt8 { bytes.reduce(0) { ($0 << 8) | Int($1) } } private static func syncSafe(_ bytes: S) -> Int where S.Element == UInt8 { bytes.reduce(0) { ($0 << 7) | Int($1 & 0x7f) } } private static func syncSafeBytes(_ number: Int) -> [UInt8] { [UInt8((number >> 21) & 0x7f), UInt8((number >> 14) & 0x7f), UInt8((number >> 7) & 0x7f), UInt8(number & 0x7f)] } } enum ID3MetadataError: LocalizedError { case verificationFailed(String) case unsupportedCompressedV22 var errorDescription: String? { switch self { case .verificationFailed(let file): "Die geschriebenen Tags in \(file) konnten nicht überprüft werden." case .unsupportedCompressedV22: "Diese komprimierten ID3v2.2-Tags können nicht sicher bearbeitet werden. Die Datei wurde nicht geändert." } } }