feat(*): subsonic client with avfoundation playback and apple music ui
This commit is contained in:
commit
e5bb559d7f
93 files changed
+7517
No files matched your search
@@ -0,0 +1,100 @@
|
||||
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 {}
|
||||
Reference in new issue
Block a user