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,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."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user