159 lines
6.6 KiB
Swift
159 lines
6.6 KiB
Swift
//
|
|
// JellyfinClient.swift
|
|
//
|
|
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
//
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
//
|
|
|
|
import Foundation
|
|
import LuminateAPI
|
|
import LuminateCore
|
|
import OpenAPIRuntime
|
|
import OpenAPIURLSession
|
|
|
|
/// Luminate's Jellyfin service: one configured server, one client identity, one access token.
|
|
///
|
|
/// Construct it once, hand it to the view tree through the `\.client` environment slot, and call
|
|
/// its operations from anywhere:
|
|
///
|
|
/// ```swift
|
|
/// let client = JellyfinClient(
|
|
/// configuration: JellyfinClientConfiguration(serverURL: serverURL)
|
|
/// )
|
|
/// _ = try await client.authenticate(username: "echo", password: secret)
|
|
/// let libraries = try await client.libraries()
|
|
/// ```
|
|
///
|
|
/// ## Isolation
|
|
///
|
|
/// This is an `actor` because the server URL and access token are mutable shared state that any
|
|
/// screen may read while onboarding is writing it. Actor isolation gives that safety without the
|
|
/// unchecked-conformance escape hatches the project bans. It is intentionally not `@Observable`: a
|
|
/// view that needs reactive session state should own a main-actor view model that calls this actor.
|
|
///
|
|
/// ## Errors
|
|
///
|
|
/// Documented HTTP statuses surface as ``JellyfinClientError``. Transport failures, decoding
|
|
/// failures, and oversized response bodies propagate from the OpenAPI runtime unchanged so their
|
|
/// diagnostics survive.
|
|
package actor JellyfinClient: JellyfinService {
|
|
/// The server URL, client identity, and access token in force.
|
|
private var configuration: JellyfinClientConfiguration
|
|
|
|
/// Rebuilds the generated client whenever the configuration changes.
|
|
///
|
|
/// Held as a closure so the production and test initialisers share one mutation path: the
|
|
/// production closure builds a real `Client` around the injected transport, the test closure
|
|
/// hands back the same double every time.
|
|
private let makeAPI: @Sendable (JellyfinClientConfiguration) -> (any JellyfinAPI)?
|
|
|
|
/// The generated client the operations call, rebuilt on every configuration change.
|
|
private var api: (any JellyfinAPI)?
|
|
|
|
/// Creates a client that talks to a real server.
|
|
///
|
|
/// - Parameters:
|
|
/// - configuration: The server URL, client identity, and any persisted access token.
|
|
/// - transport: The HTTP transport; defaults to `URLSession`.
|
|
package init(
|
|
configuration: JellyfinClientConfiguration,
|
|
transport: any ClientTransport = URLSessionTransport()
|
|
) {
|
|
let makeAPI: @Sendable (JellyfinClientConfiguration) -> (any JellyfinAPI)? = { configuration in
|
|
guard let serverURL = configuration.serverURL else { return nil }
|
|
return Client(
|
|
serverURL: serverURL,
|
|
configuration: .init(dateTranscoder: .iso8601WithFractionalSeconds),
|
|
transport: transport,
|
|
middlewares: [
|
|
AuthenticationMiddleware(
|
|
clientName: configuration.clientName,
|
|
deviceName: configuration.deviceName,
|
|
deviceID: configuration.deviceID,
|
|
version: configuration.version,
|
|
token: configuration.accessToken
|
|
)
|
|
]
|
|
)
|
|
}
|
|
self.configuration = configuration
|
|
self.makeAPI = makeAPI
|
|
self.api = makeAPI(configuration)
|
|
}
|
|
|
|
/// Creates a client backed by a supplied API implementation, for tests.
|
|
///
|
|
/// The supplied implementation is reused across configuration changes, so a test can observe
|
|
/// ``setAccessToken(_:)`` without the double being rebuilt.
|
|
///
|
|
/// - Parameters:
|
|
/// - configuration: The server URL, client identity, and any starting access token.
|
|
/// - api: The API implementation to call instead of a real client.
|
|
package init(configuration: JellyfinClientConfiguration, api: any JellyfinAPI) {
|
|
self.configuration = configuration
|
|
self.makeAPI = { _ in api }
|
|
self.api = api
|
|
}
|
|
|
|
/// The server this client currently talks to, or `nil` before configuration.
|
|
package var serverURL: URL? { configuration.serverURL }
|
|
|
|
/// The access token in force, or `nil` before sign-in.
|
|
package var accessToken: String? { configuration.accessToken }
|
|
|
|
/// Whether an access token is present.
|
|
///
|
|
/// Reports only that a token exists, not that the server still honours it; an expired token
|
|
/// surfaces as ``JellyfinClientError/unauthorized`` on the next call.
|
|
package var isAuthenticated: Bool { configuration.accessToken?.isEmpty == false }
|
|
|
|
/// Points the client at a different server and rebuilds the underlying API client.
|
|
///
|
|
/// The access token is left untouched, because a saved profile may already hold a valid token
|
|
/// for the new server. Clear it with ``setAccessToken(_:)`` when switching to a server the
|
|
/// current token is not valid for.
|
|
///
|
|
/// - Parameter serverURL: The new base URL.
|
|
package func setServerURL(_ serverURL: URL) {
|
|
configuration.serverURL = serverURL
|
|
api = makeAPI(configuration)
|
|
}
|
|
|
|
/// Replaces the access token and rebuilds the underlying API client.
|
|
///
|
|
/// An empty string is normalised to `nil` so ``isAuthenticated`` cannot report a token that is
|
|
/// really absent. Pass `nil` to sign out.
|
|
///
|
|
/// - Parameter accessToken: The new token, or `nil` to clear it.
|
|
package func setAccessToken(_ accessToken: String?) {
|
|
let normalized = (accessToken?.isEmpty == true) ? nil : accessToken
|
|
configuration.accessToken = normalized
|
|
api = makeAPI(configuration)
|
|
}
|
|
|
|
/// The generated client the operation extensions call.
|
|
///
|
|
/// Actor-isolated, so every operation reads the client that matches the current configuration.
|
|
///
|
|
/// - Throws: ``JellyfinClientError/notConfigured`` when no server URL has been chosen yet.
|
|
var current: any JellyfinAPI {
|
|
get throws {
|
|
guard let api else { throw JellyfinClientError.notConfigured }
|
|
return api
|
|
}
|
|
}
|
|
}
|