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