luminate/Sources/LuminateServices/Session/JellyfinClient.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
}
}
}