import CryptoKit import Foundation public enum SubsonicError: Error, Equatable, Sendable { case server(code: Int, message: String) case http(Int) case malformed } typealias SubsonicTransport = @Sendable (URLRequest) async throws -> (Data, URLResponse) struct SubsonicClient: Sendable { let baseURL: URL let username: String let password: String let transport: SubsonicTransport init(baseURL: URL, username: String, password: String, transport: @escaping SubsonicTransport) { self.baseURL = baseURL self.username = username self.password = password self.transport = transport } init(baseURL: URL, username: String, password: String, session: URLSession) { self.init(baseURL: baseURL, username: username, password: password) { request in try await session.data(for: request) } } func url(_ method: String, _ params: [String: String] = [:]) -> URL { let endpoint = baseURL.appending(path: "rest/\(method).view") var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) ?? URLComponents() components.queryItems = (params.sorted { $0.key < $1.key } + authParams()).map { URLQueryItem(name: $0.key, value: $0.value) } return components.url ?? endpoint } func call(_ method: String, _ params: [String: String] = [:], as _: Payload.Type) async throws -> Payload { let request = URLRequest(url: url(method, params)) let (data, response) = try await transport(request) if let http = response as? HTTPURLResponse, http.statusCode >= 400 { throw SubsonicError.http(http.statusCode) } return try Self.decode(data, as: Payload.self) } func call(_ method: String, _ params: [String: String] = [:]) async throws { _ = try await call(method, params, as: EmptyPayload.self) } static func decode(_ data: Data, as _: Payload.Type) throws -> Payload { let decoder = JSONDecoder() let status: Wrapper do { status = try decoder.decode(Wrapper.self, from: data) } catch { throw SubsonicError.malformed } if status.response.status != "ok" { let error = status.response.error throw SubsonicError.server(code: error?.code ?? 0, message: error?.message ?? "") } do { return try decoder.decode(Wrapper.self, from: data).response } catch { throw SubsonicError.malformed } } private func authParams() -> [(key: String, value: String)] { let salt = Self.salt() let token = Insecure.MD5.hash(data: Data((password + salt).utf8)).map { String(format: "%02x", $0) }.joined() return [("u", username), ("t", token), ("s", salt), ("v", "1.16.1"), ("c", "Amphlify"), ("f", "json")] } private static func salt() -> String { let alphabet = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") return String((0 ..< 12).compactMap { _ in alphabet.randomElement() }) } } private struct Wrapper: Decodable { let response: Payload enum CodingKeys: String, CodingKey { case response = "subsonic-response" } } private struct EnvelopeStatus: Decodable { struct Failure: Decodable { let code: Int let message: String? } let status: String let error: Failure? } struct EmptyPayload: Decodable {}