101 lines
3.5 KiB
Swift
101 lines
3.5 KiB
Swift
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<Payload: Decodable>(_ 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<Payload: Decodable>(_ data: Data, as _: Payload.Type) throws -> Payload {
|
|
let decoder = JSONDecoder()
|
|
let status: Wrapper<EnvelopeStatus>
|
|
do {
|
|
status = try decoder.decode(Wrapper<EnvelopeStatus>.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<Payload>.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<Payload: Decodable>: 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 {}
|