Files
Tonuino-Manager/Sources/TonuinoManager/Services/ID3Metadata.swift
T

253 lines
14 KiB
Swift

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<TrackMetadata, String>] = [
"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<TrackMetadata, String>] = [
"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..<position + 3], encoding: .ascii), oldID.utf8.allSatisfy({ $0 >= 0x20 && $0 <= 0x5A }), oldID != "\0\0\0" else { break }
let size = int24(data[position + 3..<position + 6]); position += 6
guard size > 0, position + size <= limit else { break }
let id = v22FrameIDs[oldID] ?? oldID
let frame = data[position..<position + size]
if let keyPath = standardFrames[id], let value = decodeText(frame) { result[keyPath: keyPath] = id == "TCON" ? normalizeGenre(value) : value }
if id == "TXXX", let (description, value) = decodeUserText(frame), let keyPath = managedUserTexts[description] { result[keyPath: keyPath] = value }
position += size
}
} else {
while position + 10 <= limit {
guard let id = String(data: data[position..<position + 4], encoding: .ascii), id.utf8.allSatisfy({ $0 >= 0x20 && $0 <= 0x5A }), id != "\0\0\0\0" else { break }
let size = version == 4 ? syncSafe(data[position + 4..<position + 8]) : int32(data[position + 4..<position + 8]); position += 10
guard size > 0, position + size <= limit else { break }
let frame = data[position..<position + size]
if let keyPath = standardFrames[id], let value = decodeText(frame) { result[keyPath: keyPath] = id == "TCON" ? normalizeGenre(value) : value }
if id == "TXXX", let (description, value) = decodeUserText(frame), let keyPath = managedUserTexts[description] { result[keyPath: keyPath] = value }
position += size
}
}
return result
}
static func write(_ metadata: TrackMetadata, to url: URL) throws {
let original = try Data(contentsOf: url)
var audio = original
var frames = Data()
if original.count >= 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..<original.count)
if version == 2 {
guard original[5] & 0x40 == 0 else { throw ID3MetadataError.unsupportedCompressedV22 }
var index = 10
while index + 6 <= end {
guard let oldID = String(data: original[index..<index + 3], encoding: .ascii), oldID.utf8.allSatisfy({ $0 >= 0x20 && $0 <= 0x5A }), oldID != "\0\0\0" else { break }
let size = int24(original[index + 3..<index + 6]); index += 6
guard size > 0, index + size <= end else { break }
let oldFrame = original[index..<index + size]
if let frameID = v22FrameIDs[oldID] {
let frame = frameID == "APIC" ? convertV22Picture(oldFrame) ?? Data() : Data(oldFrame)
let shouldReplace = managedFrames.contains(frameID) || (frameID == "TXXX" && decodeUserText(frame).map { managedUserTexts[$0.0] != nil } == true)
if !shouldReplace, !frame.isEmpty { appendFrame(id: frameID, content: frame, to: &frames) }
}
index += size
}
} else {
var index = 10
while index + 10 <= end {
guard let frameID = String(data: original[index..<index + 4], encoding: .ascii) else { break }
let size = version == 4 ? syncSafe(original[index + 4..<index + 8]) : int32(original[index + 4..<index + 8])
guard size > 0, index + 10 + size <= end else { break }
let frame = original[index + 10..<index + 10 + size]
let shouldReplace = managedFrames.contains(frameID) || (frameID == "TXXX" && decodeUserText(frame).map { managedUserTexts[$0.0] != nil } == true)
if !shouldReplace { appendFrame(id: frameID, content: Data(frame), to: &frames) }
index += 10 + size
}
}
}
for (id, keyPath) in standardFrames {
let value = metadata[keyPath: keyPath]
guard !value.isEmpty else { continue }
appendFrame(id: id, content: encodeV23Text(value), to: &frames)
}
for (description, keyPath) in managedUserTexts {
let value = metadata[keyPath: keyPath]
guard !value.isEmpty else { continue }
let content = Data([1, 0xFF, 0xFE]) + utf16LE(description) + Data([0, 0, 0xFF, 0xFE]) + utf16LE(value)
appendFrame(id: "TXXX", content: content, to: &frames)
}
var tag = Data("ID3".utf8)
tag.append(contentsOf: [3, 0, 0]); tag.append(contentsOf: syncSafeBytes(frames.count)); tag.append(frames); tag.append(audio)
try tag.write(to: url, options: .atomic)
}
static func verify(_ expected: TrackMetadata, at url: URL) throws {
let actual = read(url)
guard actual.title == expected.title,
actual.artist == expected.artist,
actual.album == expected.album,
actual.albumArtist == expected.albumArtist,
actual.trackNumber == expected.trackNumber,
actual.discNumber == expected.discNumber,
actual.year == expected.year,
actual.genre == expected.genre,
actual.acoustID == expected.acoustID,
actual.musicBrainzRecordingID == expected.musicBrainzRecordingID,
actual.musicBrainzReleaseID == expected.musicBrainzReleaseID else {
throw ID3MetadataError.verificationFailed(url.lastPathComponent)
}
}
private static func decodeText<S: DataProtocol>(_ 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<S: DataProtocol>(_ 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[..<separatorStart], encoding: encoding)
let text = decodeEncoded(value[(separatorStart + separatorLength)...], encoding: encoding)
return (description, text)
}
private static func decodeEncoded<S: DataProtocol>(_ 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<S: DataProtocol>(_ 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<S: Collection>(_ bytes: S) -> Int where S.Element == UInt8 { bytes.reduce(0) { ($0 << 8) | Int($1) } }
private static func int24<S: Collection>(_ bytes: S) -> Int where S.Element == UInt8 { bytes.reduce(0) { ($0 << 8) | Int($1) } }
private static func syncSafe<S: Collection>(_ 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."
}
}
}