feat(*): subsonic client with avfoundation playback and apple music ui

This commit is contained in:
hh committed 2026-09-22 06:44:44 +02:00
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 {}