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