Add Tonuino Manager website and Sparkle updates
This commit is contained in:
commit
1cac427ff2
44 files changed
+5861
No files matched your search
@@ -0,0 +1,252 @@
|
||||
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."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user