Add Tonuino Manager website and Sparkle updates

This commit is contained in:
maddin committed 2026-10-31 16:40:48 +01:00
commit 1cac427ff2
44 files changed
+5861

No files matched your search

@@ -0,0 +1,248 @@
import Foundation
import Security
struct AcoustIDRelease: Decodable, Identifiable, Hashable, Sendable {
let id: String
let title: String
let date: String?
let artists: [AcoustIDArtist]?
var artist: String { artists?.map(\.name).filter { !$0.isEmpty }.joined(separator: ", ") ?? "" }
var year: String { String((date ?? "").prefix(4)) }
private enum CodingKeys: String, CodingKey { case id, title, date, artists }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
title = try values.decode(String.self, forKey: .title)
artists = try values.decodeIfPresent([AcoustIDArtist].self, forKey: .artists)
if let stringDate = try? values.decode(String.self, forKey: .date) {
date = stringDate
} else if let parts = try? values.decode(DateParts.self, forKey: .date) {
let year = parts.year?.value
let month = parts.month?.value
let day = parts.day?.value
if let year {
date = [year, month.map { String(format: "%02d", Int($0) ?? 0) }, day.map { String(format: "%02d", Int($0) ?? 0) }]
.compactMap { $0 }
.joined(separator: "-")
} else {
date = nil
}
} else {
date = nil
}
}
private struct DateParts: Decodable {
let year: DatePart?
let month: DatePart?
let day: DatePart?
}
private struct DatePart: Decodable {
let value: String
init(from decoder: Decoder) throws {
let value = try decoder.singleValueContainer()
if let string = try? value.decode(String.self) {
self.value = string
} else if let integer = try? value.decode(Int.self) {
self.value = String(integer)
} else {
throw DecodingError.typeMismatch(String.self, .init(codingPath: decoder.codingPath, debugDescription: "Expected a date component as a string or integer."))
}
}
}
}
struct AcoustIDArtist: Decodable, Hashable, Sendable {
let id: String?
let name: String
}
struct AcoustIDMatch: Identifiable, Hashable, Sendable {
let acoustID: String
let score: Double
let recordingID: String
let title: String
let artist: String
let releases: [AcoustIDRelease]
var id: String { "\(acoustID):\(recordingID)" }
}
enum AcoustIDCredentials {
private static let service = Bundle.main.bundleIdentifier ?? "app.maddin.tonuino"
private static let account = "acoustid-client-key"
static func loadClientKey() -> String? {
var query = baseQuery
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data,
let value = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty else { return nil }
return value
}
static func saveClientKey(_ value: String) throws {
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else {
let status = SecItemDelete(baseQuery as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else { throw AcoustIDError.keychain(status) }
return
}
let data = Data(normalized.utf8)
let update = [kSecValueData as String: data]
let updateStatus = SecItemUpdate(baseQuery as CFDictionary, update as CFDictionary)
if updateStatus == errSecItemNotFound {
var query = baseQuery
query[kSecValueData as String] = data
query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked
let addStatus = SecItemAdd(query as CFDictionary, nil)
guard addStatus == errSecSuccess else { throw AcoustIDError.keychain(addStatus) }
} else if updateStatus != errSecSuccess {
throw AcoustIDError.keychain(updateStatus)
}
}
private static var baseQuery: [String: Any] {
[kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account]
}
}
enum AcoustIDService {
private static let endpoint = URL(string: "https://api.acoustid.org/v2/lookup")!
private static let session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 30
return URLSession(configuration: configuration)
}()
static func identify(_ file: URL, clientKey: String) async throws -> [AcoustIDMatch] {
guard !clientKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw AcoustIDError.missingClientKey }
guard let fpcalc = MediaToolLocator.executable(named: "fpcalc") else { throw MediaToolError.missing("Chromaprint fpcalc") }
let fingerprintData = try await Task.detached(priority: .userInitiated) {
try MediaProcess.run(fpcalc, arguments: ["-json", "-length", "120", file.path])
}.value
let fingerprint: FingerprintOutput
do { fingerprint = try JSONDecoder().decode(FingerprintOutput.self, from: fingerprintData) }
catch { throw AcoustIDError.invalidFingerprint(file.lastPathComponent) }
guard fingerprint.duration > 0, !fingerprint.fingerprint.isEmpty else { throw AcoustIDError.invalidFingerprint(file.lastPathComponent) }
await LookupRateGate.shared.wait()
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = formBody([
"client": clientKey,
"duration": String(Int(fingerprint.duration.rounded())),
"fingerprint": fingerprint.fingerprint,
"format": "json",
"meta": "recordings+releases"
])
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw AcoustIDError.http((response as? HTTPURLResponse)?.statusCode ?? -1)
}
let payload: LookupResponse
do { payload = try JSONDecoder().decode(LookupResponse.self, from: data) }
catch { throw AcoustIDError.invalidResponse(Self.responseDecodingFailure(error, data: data, response: response)) }
guard payload.status == "ok" else { throw AcoustIDError.service(payload.error?.message ?? "Unbekannter AcoustID-Fehler") }
return (payload.results ?? []).flatMap { result in
(result.recordings ?? []).map { recording in
let artist = recording.artists?.map(\.name).filter { !$0.isEmpty }.joined(separator: ", ") ?? ""
return AcoustIDMatch(
acoustID: result.id,
score: result.score,
recordingID: recording.id,
title: recording.title,
artist: artist,
releases: recording.releases ?? []
)
}
}.sorted { $0.score > $1.score }
}
private static func formBody(_ values: [String: String]) -> Data {
var components = URLComponents()
components.queryItems = values.keys.sorted().map { URLQueryItem(name: $0, value: values[$0]) }
return Data((components.percentEncodedQuery ?? "").utf8)
}
private static func responseDecodingFailure(_ error: Error, data: Data, response: URLResponse) -> String {
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
let contentType = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Type") ?? "unbekannt"
let body = String(data: data.prefix(500), encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "(keine lesbare Antwort)"
let detail: String
if let decodingError = error as? DecodingError {
switch decodingError {
case .keyNotFound(let key, let context):
detail = "Feld '\(key.stringValue)' fehlt (\(context.codingPath.map(\.stringValue).joined(separator: ".")))."
case .typeMismatch(let type, let context):
detail = "Unerwarteter Datentyp für \(context.codingPath.map(\.stringValue).joined(separator: ".")) (\(type))."
case .valueNotFound(let type, let context):
detail = "Leerer Wert für \(context.codingPath.map(\.stringValue).joined(separator: ".")) (\(type))."
case .dataCorrupted(let context):
detail = context.debugDescription
@unknown default:
detail = error.localizedDescription
}
} else {
detail = error.localizedDescription
}
return "HTTP \(status), Content-Type \(contentType): \(detail) Antwort: \(body)"
}
private struct FingerprintOutput: Decodable { let duration: Double; let fingerprint: String }
private struct LookupResponse: Decodable {
let status: String
let error: ServiceError?
let results: [LookupResult]?
struct ServiceError: Decodable { let message: String? }
}
private struct LookupResult: Decodable { let id: String; let score: Double; let recordings: [Recording]?
struct Recording: Decodable {
let id: String
let title: String
let artists: [AcoustIDArtist]?
let releases: [AcoustIDRelease]?
}
}
private actor LookupRateGate {
static let shared = LookupRateGate()
private var lastRequest = Date.distantPast
func wait() async {
let delay = max(0, 0.36 - Date().timeIntervalSince(lastRequest))
lastRequest = Date().addingTimeInterval(delay)
if delay > 0 { try? await Task.sleep(for: .seconds(delay)) }
}
}
}
enum AcoustIDError: LocalizedError {
case missingClientKey, invalidFingerprint(String), invalidResponse(String), http(Int), service(String), keychain(OSStatus)
var errorDescription: String? {
switch self {
case .missingClientKey: "Trage zuerst den AcoustID-Anwendungsschlüssel in den Einstellungen ein."
case .invalidFingerprint(let file): "Für \(file) konnte kein Audiofingerabdruck erstellt werden."
case .invalidResponse(let detail): "Die AcoustID-Antwort konnte nicht gelesen werden: \(detail)"
case .http(let status): "AcoustID ist mit HTTP-Status \(status) fehlgeschlagen."
case .service(let message): "AcoustID-Fehler: \(message)"
case .keychain(let status): "Der AcoustID-Schlüssel konnte nicht im Schlüsselbund gespeichert werden (\(status))."
}
}
}
@@ -0,0 +1,153 @@
import AVFoundation
import Combine
import Foundation
import MediaPlayer
@MainActor
final class AudioPlayerModel: ObservableObject {
@Published private(set) var title = ""
@Published private(set) var artist = ""
@Published private(set) var duration: Double = 0
@Published private(set) var position: Double = 0
@Published private(set) var isPlaying = false
@Published private(set) var isLoading = false
@Published private(set) var statusMessage = "Bereit"
@Published private(set) var currentURL: URL?
@Published var volume: Double = 0.8 { didSet { player.volume = Float(volume) } }
private let player = AVPlayer()
private let commandCenter = MPRemoteCommandCenter.shared()
private var observer: Any?
private var itemObservation: NSKeyValueObservation?
private var endObserver: NSObjectProtocol?
private var remoteCommandTargets: [(MPRemoteCommand, Any)] = []
private var queue: [MusicTrack] = []
private var currentIndex = 0
init() {
player.automaticallyWaitsToMinimizeStalling = true
installRemoteCommands()
observer = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.25, preferredTimescale: 600), queue: .main) { [weak self] time in
Task { @MainActor [weak self] in
guard let self else { return }
self.position = time.seconds.isFinite ? time.seconds : 0
let seconds = self.player.currentItem?.duration.seconds ?? 0
if seconds.isFinite { self.duration = seconds }
self.isPlaying = self.player.rate > 0
self.updateNowPlayingInfo()
}
}
}
deinit {
if let observer { player.removeTimeObserver(observer) }
if let endObserver { NotificationCenter.default.removeObserver(endObserver) }
for (command, target) in remoteCommandTargets { command.removeTarget(target) }
}
func play(_ tracks: [MusicTrack], startingAt index: Int) {
guard tracks.indices.contains(index) else { return }
queue = tracks; currentIndex = index
loadCurrent(autoplay: true)
}
func isCurrentTrack(in tracks: [MusicTrack]) -> Bool {
guard let currentURL else { return false }
return tracks.contains { $0.url.standardizedFileURL == currentURL.standardizedFileURL }
}
func toggle() {
guard currentURL != nil else { statusMessage = "Wähle zuerst einen Track aus."; return }
if isPlaying { player.pause(); isPlaying = false; statusMessage = "Pausiert" }
else if player.currentItem?.status == .failed { loadCurrent(autoplay: true) }
else { player.play(); isPlaying = true; statusMessage = "Wiedergabe wird gestartet…" }
updateNowPlayingInfo()
}
func seek(_ value: Double) { player.seek(to: CMTime(seconds: value, preferredTimescale: 600)) }
func next() { guard currentIndex + 1 < queue.count else { return }; currentIndex += 1; loadCurrent(autoplay: true) }
func previous() { guard currentIndex > 0 else { return }; currentIndex -= 1; loadCurrent(autoplay: true) }
private func loadCurrent(autoplay: Bool) {
let track = queue[currentIndex]
title = track.displayTitle; artist = track.artist; currentURL = track.url
duration = 0; position = 0; isPlaying = false; isLoading = true
statusMessage = "Lade \(track.url.lastPathComponent)…"
updateNowPlayingInfo()
if let endObserver { NotificationCenter.default.removeObserver(endObserver); self.endObserver = nil }
itemObservation = nil
let item = AVPlayerItem(url: track.url)
itemObservation = item.observe(\.status, options: [.initial, .new]) { [weak self] item, _ in
Task { @MainActor in
guard let self, self.currentURL == track.url else { return }
switch item.status {
case .readyToPlay:
self.isLoading = false
let seconds = item.duration.seconds
if seconds.isFinite { self.duration = seconds }
self.statusMessage = autoplay ? "Wiedergabe" : "Bereit zum Abspielen"
if autoplay { self.player.play(); self.isPlaying = true }
self.updateNowPlayingInfo()
case .failed:
self.isLoading = false; self.isPlaying = false
self.statusMessage = "Wiedergabe fehlgeschlagen: \(item.error?.localizedDescription ?? "Unbekannter Fehler")"
self.updateNowPlayingInfo()
case .unknown: break
@unknown default: break
}
}
}
player.replaceCurrentItem(with: item)
endObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main) { [weak self] _ in
Task { @MainActor in self?.next() }
}
}
private func installRemoteCommands() {
let toggleTarget = commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
Task { @MainActor [weak self] in self?.toggle() }
return .success
}
let playTarget = commandCenter.playCommand.addTarget { [weak self] _ in
Task { @MainActor [weak self] in
guard let self, self.currentURL != nil, !self.isPlaying else { return }
self.toggle()
}
return .success
}
let pauseTarget = commandCenter.pauseCommand.addTarget { [weak self] _ in
Task { @MainActor [weak self] in
guard let self, self.isPlaying else { return }
self.toggle()
}
return .success
}
let nextTarget = commandCenter.nextTrackCommand.addTarget { [weak self] _ in
Task { @MainActor [weak self] in self?.next() }
return .success
}
let previousTarget = commandCenter.previousTrackCommand.addTarget { [weak self] _ in
Task { @MainActor [weak self] in self?.previous() }
return .success
}
remoteCommandTargets = [
(commandCenter.togglePlayPauseCommand, toggleTarget),
(commandCenter.playCommand, playTarget),
(commandCenter.pauseCommand, pauseTarget),
(commandCenter.nextTrackCommand, nextTarget),
(commandCenter.previousTrackCommand, previousTarget)
]
}
private func updateNowPlayingInfo() {
var info: [String: Any] = [
MPMediaItemPropertyTitle: title,
MPMediaItemPropertyArtist: artist,
MPNowPlayingInfoPropertyElapsedPlaybackTime: position,
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0
]
if duration > 0 { info[MPMediaItemPropertyPlaybackDuration] = duration }
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
}
@@ -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."
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,63 @@
import Foundation
enum MediaToolLocator {
static func executable(named name: String) -> URL? {
guard let bundled = Bundle.main.resourceURL?.appendingPathComponent(name),
FileManager.default.isExecutableFile(atPath: bundled.path) else {
return nil
}
return bundled
}
static func javascriptRuntimeArguments() -> [String] {
guard let runtime = executable(named: "deno") else { return [] }
return ["--js-runtimes", "deno:\(runtime.path)"]
}
}
enum MediaToolError: LocalizedError {
case missing(String)
case failed(String, String)
var errorDescription: String? {
switch self {
case .missing(let tool): "\(tool) fehlt im App-Paket. Installiere eine App-Version mit gebündelten Werkzeugen."
case .failed(let tool, let detail): "\(tool) ist fehlgeschlagen: \(detail)"
}
}
}
enum MediaProcess {
static func run(_ executable: URL, arguments: [String]) throws -> Data {
let id = UUID().uuidString
let outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("tonuino-tool-\(id).out")
let errorURL = FileManager.default.temporaryDirectory.appendingPathComponent("tonuino-tool-\(id).err")
FileManager.default.createFile(atPath: outputURL.path, contents: nil)
FileManager.default.createFile(atPath: errorURL.path, contents: nil)
defer {
try? FileManager.default.removeItem(at: outputURL)
try? FileManager.default.removeItem(at: errorURL)
}
let outputHandle = try FileHandle(forWritingTo: outputURL)
let errorHandle = try FileHandle(forWritingTo: errorURL)
defer { try? outputHandle.close(); try? errorHandle.close() }
let process = Process()
process.executableURL = executable
process.arguments = arguments
process.standardOutput = outputHandle
process.standardError = errorHandle
do { try process.run() }
catch { throw MediaToolError.failed(executable.lastPathComponent, error.localizedDescription) }
process.waitUntilExit()
let output = try Data(contentsOf: outputURL)
let errorOutput = String(data: try Data(contentsOf: errorURL), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard process.terminationStatus == 0 else {
let detail = errorOutput.isEmpty ? "Prozesscode \(process.terminationStatus)" : String(errorOutput.suffix(1800))
throw MediaToolError.failed(executable.lastPathComponent, detail)
}
return output
}
}
@@ -0,0 +1,92 @@
import Foundation
struct MusicBrainzRelease: Decodable, Identifiable, Hashable {
let id: String
let title: String
let date: String?
let artistCredit: [ArtistCredit]?
let media: [Medium]?
let genres: [Genre]?
var artist: String { artistCredit?.map { ($0.name ?? $0.artist?.name ?? "") + ($0.joinphrase ?? "") }.joined() ?? "" }
var year: String { String((date ?? "").prefix(4)) }
var genre: String { genres?.map(\.name).joined(separator: ", ") ?? "" }
var tracks: [MusicBrainzRecording] { media?.flatMap { medium in (medium.tracks ?? []).map { $0.withDisc(medium.position.map(String.init) ?? medium.number ?? "1") } } ?? [] }
enum CodingKeys: String, CodingKey { case id, title, date, media, genres; case artistCredit = "artist-credit" }
}
struct MusicBrainzRecording: Decodable, Identifiable, Hashable {
let recording: RecordingID?
let title: String
let number: String?
let position: Int?
let length: Int?
let artistCredit: [ArtistCredit]?
var id: String { recording?.id ?? "\(discNumber)-\(displayNumber)-\(title)" }
private(set) var discNumber = "1"
enum CodingKeys: String, CodingKey { case recording, title, number, position, length; case artistCredit = "artist-credit" }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
recording = try values.decodeIfPresent(RecordingID.self, forKey: .recording)
title = try values.decode(String.self, forKey: .title)
number = try values.decodeIfPresent(String.self, forKey: .number)
position = try values.decodeIfPresent(Int.self, forKey: .position)
length = try values.decodeIfPresent(Int.self, forKey: .length)
artistCredit = try values.decodeIfPresent([ArtistCredit].self, forKey: .artistCredit)
}
var artist: String { artistCredit?.map { ($0.name ?? $0.artist?.name ?? "") + ($0.joinphrase ?? "") }.joined() ?? "" }
var displayNumber: String { number ?? position.map(String.init) ?? "—" }
func withDisc(_ value: String) -> MusicBrainzRecording { var copy = self; copy.discNumber = value; return copy }
}
struct RecordingID: Decodable, Hashable { let id: String? }
struct ArtistCredit: Decodable, Hashable {
let name: String?
let artist: CreditArtist?
let joinphrase: String?
}
struct CreditArtist: Decodable, Hashable { let name: String? }
struct Medium: Decodable, Hashable { let position: Int?; let number: String?; let tracks: [MusicBrainzRecording]? }
struct Genre: Decodable, Hashable { let name: String }
enum MusicBrainzService {
private static let base = URL(string: "https://musicbrainz.org/ws/2")!
private static let session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 25
configuration.httpAdditionalHeaders = ["User-Agent": "TonuinoManager/1.0 (macOS music library manager)"]
return URLSession(configuration: configuration)
}()
static func searchReleases(artist: String, album: String) async throws -> [MusicBrainzRelease] {
await RateGate.shared.wait()
var components = URLComponents(url: base.appendingPathComponent("release"), resolvingAgainstBaseURL: false)!
let query = [album.isEmpty ? nil : "release:\"\(escape(album))\"", artist.isEmpty ? nil : "artist:\"\(escape(artist))\""].compactMap { $0 }.joined(separator: " AND ")
components.queryItems = [URLQueryItem(name: "query", value: query.isEmpty ? "*" : query), URLQueryItem(name: "limit", value: "15"), URLQueryItem(name: "fmt", value: "json")]
let (data, _) = try await session.data(from: components.url!)
return try JSONDecoder().decode(ReleaseSearch.self, from: data).releases
}
static func loadRelease(id: String) async throws -> MusicBrainzRelease {
await RateGate.shared.wait()
var components = URLComponents(url: base.appendingPathComponent("release/\(id)"), resolvingAgainstBaseURL: false)!
components.queryItems = [URLQueryItem(name: "inc", value: "recordings+artist-credits+genres"), URLQueryItem(name: "fmt", value: "json")]
let (data, _) = try await session.data(from: components.url!)
return try JSONDecoder().decode(MusicBrainzRelease.self, from: data)
}
private static func escape(_ value: String) -> String { value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") }
private struct ReleaseSearch: Decodable { let releases: [MusicBrainzRelease] }
private actor RateGate {
static let shared = RateGate()
private var lastRequest = Date.distantPast
func wait() async {
let delay = max(0, 1.1 - Date().timeIntervalSince(lastRequest))
lastRequest = Date().addingTimeInterval(delay)
if delay > 0 { try? await Task.sleep(for: .seconds(delay)) }
}
}
}
@@ -0,0 +1,161 @@
import CryptoTokenKit
import Combine
import Foundation
@MainActor
final class CardModel: ObservableObject {
@Published private(set) var readerNames: [String] = []
@Published var selectedReader = ""
@Published private(set) var cardStatus = "Kein Reader verbunden"
@Published private(set) var currentUID = ""
@Published private(set) var currentCard: TonuinoCard?
@Published private(set) var serialPorts = TonuinoSerialService.ports
@Published var selectedSerialPort = ""
@Published private(set) var serialConnected = false
@Published private(set) var firmwareVersion: String?
@Published private(set) var tonuinoSettings: TonuinoDeviceSettings?
@Published var writing = false
@Published var configuringSettings = false
@Published private(set) var serialAdminMenuActive = false
private let serial = TonuinoSerialService()
private let reader = PCSCReader()
func refreshReaders() { readerNames = reader.slotNames; if !readerNames.contains(selectedReader) { selectedReader = readerNames.first ?? "" } }
func connectSerial() async throws {
guard !selectedSerialPort.isEmpty else { throw SerialError.disconnected }
let port = selectedSerialPort
let service = serial
let connection = try await Task.detached(priority: .userInitiated) { try service.connect(port) }.value
firmwareVersion = connection.firmwareVersion
tonuinoSettings = connection.settings
serialConnected = true
}
func disconnectSerial() { serial.disconnect(); serialConnected = false; firmwareVersion = nil; tonuinoSettings = nil; serialAdminMenuActive = false }
func refreshSerialPorts() { serialPorts = TonuinoSerialService.ports; if !serialPorts.contains(selectedSerialPort) { selectedSerialPort = serialPorts.first ?? "" } }
func writeViaTonuino(_ card: TonuinoCard) async throws {
guard serialConnected else { throw SerialError.disconnected }; writing = true
defer { writing = false }
let service = serial
try await Task.detached(priority: .userInitiated) { try await service.writeCard(card) }.value
serialAdminMenuActive = true
}
func cancelWrite() throws { try serial.cancelWrite(); serialAdminMenuActive = false }
func exitTonuinoAdminMenu() throws { try serial.exitAdminMenu(); serialAdminMenuActive = false }
func applyTonuinoSetting(menuItem: Int, option: Int, currentPIN: String = "", newPIN: String = "") async throws {
guard serialConnected else { throw SerialError.disconnected }
configuringSettings = true
defer { configuringSettings = false }
let service = serial
try await Task.detached(priority: .userInitiated) {
try await service.applyAdminSetting(menuItem: menuItem, option: option, currentPIN: currentPIN, newPIN: newPIN)
}.value
serialAdminMenuActive = true
}
func applyTonuinoVolume(minimum: Int, maximum: Int, initial: Int, currentPIN: String = "") async throws {
guard serialConnected else { throw SerialError.disconnected }
configuringSettings = true
defer { configuringSettings = false }
let service = serial
try await Task.detached(priority: .userInitiated) {
try await service.applyVolumeSettings(minimum: minimum, maximum: maximum, initial: initial, currentPIN: currentPIN)
}.value
serialAdminMenuActive = true
}
func resetTonuinoSettings(currentPIN: String = "") async throws {
guard serialConnected else { throw SerialError.disconnected }
configuringSettings = true
defer { configuringSettings = false }
let service = serial
try await Task.detached(priority: .userInitiated) {
try await service.resetAdminSettings(currentPIN: currentPIN)
}.value
serialAdminMenuActive = false
}
func readViaReader() async throws -> (String, TonuinoCard?) { try await reader.readCard(slotName: selectedReader) }
func writeViaReader(_ card: TonuinoCard) async throws { try await reader.writeCard(card, slotName: selectedReader) }
func eraseViaReader() async throws { try await reader.writeCard(nil, slotName: selectedReader) }
}
final class PCSCReader {
private var manager: TKSmartCardSlotManager? { TKSmartCardSlotManager.default }
var slotNames: [String] { manager?.slotNames ?? [] }
func readCard(slotName: String) async throws -> (String, TonuinoCard?) {
let (card, _) = try await connectedCard(slotName)
defer { card.endSession() }
let uidData = try await transmit(card, [0xFF, 0xCA, 0x00, 0x00, 0x00])
let uid = uidData.map { String(format: "%02X", $0) }.joined()
let bytes = try await readTonuinoData(card)
return (uid, TonuinoCard.decode(bytes))
}
func writeCard(_ value: TonuinoCard?, slotName: String) async throws {
let (card, atr) = try await connectedCard(slotName)
defer { card.endSession() }
let type = CardType(atr: atr)
guard type != .unknown else { throw PCSCError.unsupportedCard }
let payload = value?.payload() ?? Data(repeating: 0, count: 16)
if type == .ultralight {
for page in 0..<4 {
let chunk = payload.subdata(in: page * 4..<(page + 1) * 4)
_ = try await transmit(card, [0xFF, 0xD6, 0x00, UInt8(8 + page), 0x04] + chunk)
}
} else {
try await authenticate(card, block: 4)
_ = try await transmit(card, [0xFF, 0xD6, 0x00, 0x04, 0x10] + payload)
}
}
private func readTonuinoData(_ card: TKSmartCard) async throws -> Data {
let atr = Array(card.slot.atr?.bytes ?? Data())
if CardType(atr: atr) == .ultralight {
return try await transmit(card, [0xFF, 0xB0, 0x00, 0x08, 0x10])
}
try await authenticate(card, block: 4)
return try await transmit(card, [0xFF, 0xB0, 0x00, 0x04, 0x10])
}
private func authenticate(_ card: TKSmartCard, block: UInt8) async throws {
_ = try await transmit(card, [0xFF, 0x82, 0x00, 0x00, 0x06, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF])
_ = try await transmit(card, [0xFF, 0x86, 0x00, 0x00, 0x05, 0x01, 0x00, block, 0x60, 0x00])
}
private func connectedCard(_ name: String) async throws -> (TKSmartCard, [UInt8]) {
guard let slot = manager?.slotNamed(name), let card = slot.makeSmartCard() else { throw PCSCError.noCard }
try await card.beginSession()
return (card, Array(slot.atr?.bytes ?? Data()))
}
private func transmit(_ card: TKSmartCard, _ command: [UInt8]) async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
card.transmit(Data(command)) { response, error in
if let error { continuation.resume(throwing: error); return }
guard let response, response.count >= 2 else { continuation.resume(throwing: PCSCError.communication); return }
let status = response.suffix(2)
guard status.elementsEqual([0x90, 0x00]) else { continuation.resume(throwing: PCSCError.status(UInt8(status.first ?? 0), UInt8(status.last ?? 0))); return }
continuation.resume(returning: response.dropLast(2))
}
}
}
}
enum CardType: Equatable {
case classic, ultralight, unknown
init(atr: [UInt8]) {
let marker: [UInt8] = [0xA0, 0x00, 0x00, 0x03, 0x06, 0x03, 0x00]
guard let i = atr.indices.dropLast(marker.count).first(where: { Array(atr[$0..<$0 + marker.count]) == marker }), atr.count > i + marker.count else { self = .unknown; return }
switch atr[i + marker.count] { case 0x01, 0x02, 0x26: self = .classic; case 0x03: self = .ultralight; default: self = .unknown }
}
}
enum PCSCError: LocalizedError {
case noCard, unsupportedCard, communication, status(UInt8, UInt8)
var errorDescription: String? {
switch self {
case .noCard: "Lege eine Karte auf den ausgewählten Reader."
case .unsupportedCard: "Dieser Kartentyp wird nicht unterstützt."
case .communication: "Keine gültige Antwort vom Kartenleser."
case .status(let a, let b): "Kartenleser meldet Fehlerstatus %02X%02X.".replacingOccurrences(of: "%02X%02X", with: String(format: "%02X%02X", a, b))
}
}
}
@@ -0,0 +1,303 @@
import Darwin
import Foundation
final class TonuinoSerialService: @unchecked Sendable {
private let lock = NSLock()
private var descriptor: Int32 = -1
private var buffer = Data()
private var outputMonitor: Task<Void, Never>?
private(set) var firmwareVersion: String?
static var ports: [String] {
((try? FileManager.default.contentsOfDirectory(atPath: "/dev")) ?? [])
.filter { $0.hasPrefix("cu.") }.map { "/dev/\($0)" }.sorted()
}
var isConnected: Bool { lock.lock(); defer { lock.unlock() }; return descriptor >= 0 }
func connect(_ path: String) throws -> (firmwareVersion: String?, settings: TonuinoDeviceSettings?) {
disconnect()
let fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK)
guard fd >= 0 else { throw SerialError.open(path, String(cString: strerror(errno))) }
var termiosConfig = termios()
guard tcgetattr(fd, &termiosConfig) == 0 else { close(fd); throw SerialError.configure }
cfmakeraw(&termiosConfig); cfsetispeed(&termiosConfig, speed_t(B115200)); cfsetospeed(&termiosConfig, speed_t(B115200))
termiosConfig.c_cflag |= tcflag_t(CLOCAL | CREAD)
termiosConfig.c_cflag &= ~tcflag_t(CSTOPB | PARENB | CSIZE)
termiosConfig.c_cflag |= tcflag_t(CS8)
guard tcsetattr(fd, TCSANOW, &termiosConfig) == 0 else { close(fd); throw SerialError.configure }
tcflush(fd, TCIOFLUSH)
lock.lock(); descriptor = fd; buffer.removeAll(); lock.unlock()
firmwareVersion = nil
var parsedSettings = TonuinoDeviceSettings()
let startupDeadline = Date().addingTimeInterval(3) // many TonUINO boards reset when serial is opened
while Date() < startupDeadline {
let remaining = startupDeadline.timeIntervalSinceNow
guard let line = try readLine(timeout: min(0.15, max(remaining, 0))) else { continue }
if let range = line.range(of: #"V\d+\.\d+\.\d+[^\r\n]*"#, options: .regularExpression) {
firmwareVersion = String(line[range])
}
parsedSettings.readLogLine(line)
}
let capturedSettings = parsedSettings.hasValues ? parsedSettings : nil
outputMonitor = Task.detached(priority: .utility) { [weak self] in
while !Task.isCancelled {
guard let self else { return }
do { _ = try self.readLine(timeout: 0.3) }
catch { return }
}
}
return (firmwareVersion, capturedSettings)
}
func disconnect() {
outputMonitor?.cancel()
outputMonitor = nil
lock.lock(); let old = descriptor; descriptor = -1; buffer.removeAll(); lock.unlock()
if old >= 0 { close(old) }
}
func writeCard(_ card: TonuinoCard) async throws {
try card.validate()
guard card.modifier == nil else {
throw SerialError.invalid("Sonderkarten können mit diesem seriellen Schreibassistenten nicht programmiert werden. Verwende einen PC/SC-Reader oder exportiere einen Dump.")
}
// TonUINO-TNG has no WRITECARD serial command. With SerialInputAsCommand
// enabled, its documented numeric commands can drive the built-in
// Admin > New card voice menu instead.
try write("-4\n") // allLong: open the admin menu while TonUINO is idle
try await Task.sleep(for: .milliseconds(500))
try await selectMenuItem(1) // Admin: create new card
let mode = card.mode == .admin ? 6 : card.mode.rawValue
try await selectMenuItem(mode)
if card.mode.needsFolder, ![.admin, .repeatLast, .bluetooth].contains(card.mode) {
try await selectMenuItem(Int(card.folder))
}
switch card.mode {
case .single:
try await selectMenuItem(Int(card.special))
case .hoerspielRange, .albumRange, .partyRange, .audiobookRange:
try await selectMenuItem(Int(card.special))
try await selectMenuItem(Int(card.special2))
case .audiobook:
try await selectMenuItem(Int(card.folder) + Int(card.special2))
case .audiobookSingle:
try await selectMenuItem(Int(card.folder) + Int(card.special2))
try await selectMenuItem(Int(card.special) + 1)
case .quiz:
let option: Int
switch (card.special, card.special2) {
case (2, 0): option = 1
case (4, 0): option = 2
case (2, 1): option = 3
case (4, 1): option = 4
case (0, 1): option = 5
default: throw SerialError.invalid("Ungültige Quiz-Optionen.")
}
try await selectMenuItem(option)
default:
break
}
}
func cancelWrite() throws { try exitAdminMenu() }
func exitAdminMenu() throws { try write("-6\n") }
func applyAdminSetting(menuItem: Int, option: Int, currentPIN: String = "", newPIN: String = "") async throws {
guard (2...14).contains(menuItem), (1...99).contains(option) else {
throw SerialError.invalid("Ungültige TonUINO-Menüauswahl.")
}
if menuItem == 12, option == 3 { try validatePIN(newPIN) }
try await enterAdmin(currentPIN: currentPIN)
try await selectMenuItem(menuItem)
try await selectMenuItem(option)
if menuItem == 12, option == 3 {
try await sendPIN(newPIN)
}
}
func applyVolumeSettings(minimum: Int, maximum: Int, initial: Int, currentPIN: String = "") async throws {
guard (1...29).contains(minimum), (2...30).contains(maximum), minimum < maximum,
(minimum...maximum).contains(initial) else {
throw SerialError.invalid("Lautstärke: Minimum muss unter dem Maximum liegen; Startlautstärke muss dazwischen liegen.")
}
try await enterAdmin(currentPIN: currentPIN)
// Set a known intermediate range so subsequent voice-menu indices are deterministic.
try await selectMenuItem(3) // Minimum volume
try await selectMenuItem(1)
try await selectMenuItem(2) // Maximum volume
try await selectMenuItem(29) // 30, now that minimum is 1
try await selectMenuItem(3)
try await selectMenuItem(minimum)
try await selectMenuItem(2)
try await selectMenuItem(maximum - minimum)
try await selectMenuItem(4)
try await selectMenuItem(initial - minimum + 1)
}
func resetAdminSettings(currentPIN: String = "") async throws {
try await enterAdmin(currentPIN: currentPIN)
try await selectMenuItem(11)
// The reset state consumes one command to return to Admin_Entry.
try await Task.sleep(for: .milliseconds(250))
try write("-6\n")
try await Task.sleep(for: .milliseconds(250))
try write("-6\n")
try await Task.sleep(for: .milliseconds(250))
}
private func enterAdmin(currentPIN: String) async throws {
if !currentPIN.isEmpty { try validatePIN(currentPIN) }
try write("-4\n") // allLong: enter Admin while idle
try await Task.sleep(for: .milliseconds(500))
if !currentPIN.isEmpty { try await sendPIN(currentPIN) }
}
private func sendPIN(_ pin: String) async throws {
let commands: [Character: Int] = ["1": -5, "2": -8, "3": -2]
for digit in pin {
try Task.checkCancellation()
try write("\(commands[digit]!)\n")
try await Task.sleep(for: .milliseconds(220))
}
try await Task.sleep(for: .milliseconds(250))
}
private func validatePIN(_ pin: String) throws {
guard pin.count == 4, pin.allSatisfy({ ["1", "2", "3"].contains(String($0)) }) else {
throw SerialError.invalid("Die TonUINO-PIN muss aus vier Ziffern 1, 2 oder 3 bestehen.")
}
}
private func selectMenuItem(_ value: Int) async throws {
try Task.checkCancellation()
try write("\(value)\n")
try await Task.sleep(for: .milliseconds(180))
try write("-5\n") // pause: select the current voice-menu item
try await Task.sleep(for: .milliseconds(250))
}
private func write(_ text: String) throws {
lock.lock(); defer { lock.unlock() }
guard descriptor >= 0 else { throw SerialError.disconnected }
let bytes = Array(text.utf8)
let result = Darwin.write(descriptor, bytes, bytes.count)
guard result == bytes.count else { throw SerialError.io(String(cString: strerror(errno))) }
}
private func readLine(timeout: TimeInterval) throws -> String? {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
lock.lock(); let fd = descriptor; lock.unlock()
guard fd >= 0 else { throw SerialError.disconnected }
var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
if Darwin.poll(&pollDescriptor, 1, 100) > 0 {
var bytes = [UInt8](repeating: 0, count: 256)
let count = Darwin.read(fd, &bytes, bytes.count)
if count > 0 {
lock.lock(); buffer.append(contentsOf: bytes.prefix(count))
if let newline = buffer.firstIndex(of: 10) {
let line = Data(buffer[..<newline]); buffer.removeSubrange(...newline); lock.unlock()
return String(data: line, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
}
lock.unlock()
}
}
}
return nil
}
}
/// Values printed by current TonUINO-TNG AiO Plus firmware during startup.
/// Older AiO firmware may not print these lines, so every field is optional.
struct TonuinoDeviceSettings: Equatable {
var firmwareSettingsVersion: Int?
var speakerMaximum: Int?
var speakerMinimum: Int?
var speakerInitial: Int?
var headphoneMaximum: Int?
var headphoneMinimum: Int?
var headphoneInitial: Int?
var equalizer: Int?
var standbyTimer: Int?
var invertedVolumeButtons: Int?
var adminMenuLocked: Int?
var pauseWhenCardRemoved: Int?
var hasValues: Bool {
firmwareSettingsVersion != nil || speakerMaximum != nil || speakerMinimum != nil || speakerInitial != nil ||
headphoneMaximum != nil || headphoneMinimum != nil || headphoneInitial != nil || equalizer != nil ||
standbyTimer != nil || invertedVolumeButtons != nil || adminMenuLocked != nil || pauseWhenCardRemoved != nil
}
mutating func readLogLine(_ line: String) {
if let values = values(after: "Ver:", in: line), let first = values.first { firmwareSettingsVersion = first }
if let values = values(after: "Vol:", in: line) {
if values.indices.contains(0) { speakerMaximum = values[0] }
if values.indices.contains(1) { speakerMinimum = values[1] }
if values.indices.contains(2) { speakerInitial = values[2] }
if values.indices.contains(3) { headphoneMaximum = values[3] }
if values.indices.contains(4) { headphoneMinimum = values[4] }
if values.indices.contains(5) { headphoneInitial = values[5] }
}
if let values = values(after: "EQ:", in: line), let first = values.first { equalizer = first }
if let values = values(after: "ST:", in: line), let first = values.first { standbyTimer = first }
if let values = values(after: "IB:", in: line), let first = values.first { invertedVolumeButtons = first }
if let values = values(after: "AL:", in: line), let first = values.first { adminMenuLocked = first }
if let values = values(after: "PCR:", in: line), let first = values.first { pauseWhenCardRemoved = first }
// AP (the admin PIN) is intentionally neither parsed nor retained.
}
private func values(after marker: String, in line: String) -> [Int]? {
guard let range = line.range(of: NSRegularExpression.escapedPattern(for: marker), options: [.regularExpression, .caseInsensitive]) else { return nil }
let remainder = line[range.upperBound...]
let text = String(remainder)
let pattern = #"-?\d+"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let nsRange = NSRange(text.startIndex..., in: text)
return regex.matches(in: text, range: nsRange).compactMap { match in
guard let matchRange = Range(match.range, in: text) else { return nil }
return Int(text[matchRange])
}
}
}
extension TonuinoCard {
func validate() throws {
if let modifier {
switch modifier {
case .sleepTimer:
guard special > 0 else { throw SerialError.invalid("Der Einschlaf-Timer muss mindestens eine Minute dauern.") }
case .freezeDance, .fireWaterAir:
guard special <= 2 else { throw SerialError.invalid("Die Pausenstufe muss zwischen 0 und 2 liegen.") }
default:
break
}
return
}
if mode.needsFolder, mode != .admin, !(1...99).contains(Int(folder)) { throw SerialError.invalid("Ordner muss zwischen 01 und 99 liegen.") }
if mode == .single && special == 0 { throw SerialError.invalid("Bitte einen Track größer 0 auswählen.") }
if mode == .audiobookSingle && special >= 30 { throw SerialError.invalid("Hörbuch einzeln akzeptiert maximal 29 Zusatztracks.") }
if [.hoerspielRange, .albumRange, .partyRange, .audiobookRange].contains(mode), special == 0 || special > special2 {
throw SerialError.invalid("Der Trackbereich muss von 1 bis zum Endtrack reichen.")
}
if mode == .quiz && !([0, 2, 4].contains(Int(special)) && [0, 1].contains(Int(special2))) { throw SerialError.invalid("Ungültige Quiz-Optionen.") }
}
}
enum SerialError: LocalizedError {
case open(String, String), configure, disconnected, io(String), timeout, firmware(String), invalid(String)
var errorDescription: String? {
switch self {
case .open(let p, let e): "Serieller Port \(p) kann nicht geöffnet werden: \(e)"
case .configure: "Der serielle Port konnte nicht auf 115200 Baud konfiguriert werden."
case .disconnected: "Keine Verbindung zum TonUINO."
case .io(let e): "Serielle Kommunikation fehlgeschlagen: \(e)"
case .timeout: "Zeitüberschreitung. Prüfe, ob keine Karte auf dem TonUINO-Leser liegt und die Firmware SerialInputAsCommand aktiviert hat."
case .firmware(let text): text
case .invalid(let text): text
}
}
}
@@ -0,0 +1,133 @@
import Foundation
struct YouTubeChapter: Decodable, Identifiable, Sendable {
let start: Double
let end: Double?
let title: String?
var id: Int { Int(start * 1000) }
enum CodingKeys: String, CodingKey { case title; case start = "start_time"; case end = "end_time" }
}
struct YouTubeVideoInfo: Decodable, Sendable {
let id: String
let webpageURL: String?
let title: String
let uploader: String?
let channel: String?
let duration: Double?
let chapters: [YouTubeChapter]?
enum CodingKeys: String, CodingKey {
case id, title, uploader, channel, duration, chapters
case webpageURL = "webpage_url"
}
var artist: String { channel?.isEmpty == false ? channel! : (uploader ?? "") }
var usableChapters: [YouTubeChapter] {
guard let chapters else { return [] }
return chapters.filter { $0.start.isFinite && $0.start >= 0 && ($0.end ?? duration ?? 0) > $0.start }
}
}
struct PreparedYouTubeImport: Sendable {
let directory: URL
let files: [URL]
}
enum YouTubeImportService {
static func inspect(_ rawURL: String) throws -> YouTubeVideoInfo {
guard let url = validatedURL(rawURL) else { throw YouTubeImportError.invalidURL }
guard let ytDLP = MediaToolLocator.executable(named: "yt-dlp") else {
throw MediaToolError.missing("yt-dlp")
}
guard MediaToolLocator.executable(named: "deno") != nil else { throw MediaToolError.missing("Deno") }
let arguments = ["--dump-single-json", "--skip-download", "--no-playlist", "--no-warnings", "--quiet", "--no-cache-dir"]
+ MediaToolLocator.javascriptRuntimeArguments() + [url.absoluteString]
let data = try MediaProcess.run(ytDLP, arguments: arguments)
do { return try JSONDecoder().decode(YouTubeVideoInfo.self, from: data) }
catch { throw YouTubeImportError.invalidVideoInfo(error.localizedDescription) }
}
static func prepare(_ video: YouTubeVideoInfo, splitByChapters: Bool) throws -> PreparedYouTubeImport {
guard let sourceURL = validatedURL(video.webpageURL ?? "https://www.youtube.com/watch?v=\(video.id)") else { throw YouTubeImportError.invalidURL }
guard let ytDLP = MediaToolLocator.executable(named: "yt-dlp") else { throw MediaToolError.missing("yt-dlp") }
guard let ffmpeg = MediaToolLocator.executable(named: "ffmpeg") else { throw MediaToolError.missing("FFmpeg") }
guard MediaToolLocator.executable(named: "deno") != nil else { throw MediaToolError.missing("Deno") }
let chapters = video.usableChapters
guard !splitByChapters || !chapters.isEmpty else { throw YouTubeImportError.noChapters }
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("tonuino-youtube-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
do {
let outputTemplate = directory.appendingPathComponent("source.%(ext)s").path
let downloadArguments = ["--format", "bestaudio/best", "--output", outputTemplate, "--no-playlist", "--no-warnings", "--no-progress", "--quiet", "--no-cache-dir"]
+ MediaToolLocator.javascriptRuntimeArguments() + [sourceURL.absoluteString]
_ = try MediaProcess.run(ytDLP, arguments: downloadArguments)
let sourceFiles = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])
.filter { $0.lastPathComponent.hasPrefix("source.") && !$0.pathExtension.lowercased().hasSuffix("part") }
guard let audioURL = sourceFiles.first else { throw YouTubeImportError.downloadOutputMissing }
let outputs: [URL]
if splitByChapters {
outputs = try chapters.enumerated().map { index, chapter in
let end = chapter.end ?? video.duration ?? 0
let length = end - chapter.start
guard length > 0 else { throw YouTubeImportError.invalidChapter }
let output = directory.appendingPathComponent(String(format: "chapter-%03d.mp3", index + 1))
let displayTitle = chapter.title?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "Kapitel \(index + 1)"
let args = [
"-nostdin", "-hide_banner", "-loglevel", "error", "-y",
"-ss", String(format: "%.3f", chapter.start), "-i", audioURL.path, "-t", String(format: "%.3f", length),
"-map", "0:a:0", "-vn", "-codec:a", "libmp3lame", "-q:a", "2", "-id3v2_version", "3",
"-metadata", "title=\(displayTitle)", "-metadata", "artist=\(video.artist)", "-metadata", "album=\(video.title)",
"-metadata", "track=\(index + 1)/\(chapters.count)", output.path
]
_ = try MediaProcess.run(ffmpeg, arguments: args)
return output
}
} else {
let output = directory.appendingPathComponent("audio.mp3")
let args = [
"-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", audioURL.path,
"-map", "0:a:0", "-vn", "-codec:a", "libmp3lame", "-q:a", "2", "-id3v2_version", "3",
"-metadata", "title=\(video.title)", "-metadata", "artist=\(video.artist)", "-metadata", "album=\(video.title)", output.path
]
_ = try MediaProcess.run(ffmpeg, arguments: args)
outputs = [output]
}
return PreparedYouTubeImport(directory: directory, files: outputs)
} catch {
try? FileManager.default.removeItem(at: directory)
throw error
}
}
private static func validatedURL(_ raw: String) -> URL? {
guard let components = URLComponents(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)),
components.scheme?.lowercased() == "https",
let host = components.host?.lowercased(),
["youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com", "youtu.be", "www.youtube-nocookie.com"].contains(host),
let url = components.url else { return nil }
return url
}
}
private extension String {
var nonEmpty: String? { isEmpty ? nil : self }
}
enum YouTubeImportError: LocalizedError {
case invalidURL, invalidVideoInfo(String), noChapters, invalidChapter, downloadOutputMissing
var errorDescription: String? {
switch self {
case .invalidURL: "Bitte eine HTTPS-URL zu einem einzelnen YouTube-Video eingeben. Playlists werden nicht importiert."
case .invalidVideoInfo(let detail): "Videoinformationen konnten nicht gelesen werden: \(detail)"
case .noChapters: "Dieses Video enthält keine verwendbaren Kapitel. Importiere es als eine Datei."
case .invalidChapter: "Mindestens ein Kapitel hat eine ungültige Zeitspanne."
case .downloadOutputMissing: "yt-dlp hat keine Audiodatei erzeugt."
}
}
}