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

249 lines
11 KiB
Swift

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))."
}
}
}