249 lines
11 KiB
Swift
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))."
|
|
}
|
|
}
|
|
}
|