// // JellyfinClient.swift // // Copyright 2026 Brendan Szymanski // // 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 . // // 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, 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 } } }