// // ServerConnectionProbe.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 #if canImport(FoundationNetworking) import FoundationNetworking #endif /// Probes normalized server URLs and accepts only reachable Jellyfin identities. package struct ServerConnectionProbe: Sendable { /// Asks one candidate URL to identify itself. package typealias Request = @Sendable (URL) async throws -> JellyfinServerInfo private let request: Request /// Creates a probe using the supplied request seam or the live HTTP implementation. /// /// - Parameter request: The request closure used for each candidate URL. package init(request: @escaping Request = ServerConnectionProbe.liveRequest) { self.request = request } /// Tries the candidates in order and returns the first accepted Jellyfin connection. /// /// - Parameter address: The normalized address whose candidates should be probed. /// - Returns: The candidate URL and server identity that answered successfully. /// - Throws: ``ServerConnectionError`` for unreachable, rejected, or non-Jellyfin servers; /// cancellation is propagated as `CancellationError`. package func connect(to address: ServerAddress) async throws -> ServerConnection { for url in address.probeCandidates { if Task.isCancelled { throw CancellationError() } do { let info = try await request(url) guard isJellyfin(info) else { throw ServerConnectionError.notJellyfin(url: url, productName: info.productName) } return ServerConnection(url: url, serverInfo: info) } catch let error as ServerConnectionError { throw error } catch { let underlying = (error as? ClientError)?.underlyingError ?? error if underlying is URLError { continue } throw ServerConnectionError.rejected(url: url, detail: String(describing: underlying)) } } throw ServerConnectionError.unreachable(attempted: address.probeCandidates) } /// Performs the public Jellyfin system-info request against one candidate URL. /// /// - Parameter url: The normalized base URL to probe. /// - Returns: The identifying information returned by Jellyfin. /// - Throws: Transport, OpenAPI runtime, or mapped Jellyfin client errors from the request. package static let liveRequest: Request = { url in let configuration = URLSessionConfiguration.ephemeral configuration.timeoutIntervalForRequest = 10 configuration.timeoutIntervalForResource = 15 configuration.requestCachePolicy = .reloadIgnoringLocalCacheData let session = URLSession(configuration: configuration, delegate: HTTPSDowngradeGuard(), delegateQueue: nil) let client = JellyfinClient( configuration: JellyfinClientConfiguration(serverURL: url), makeTransport: { URLSessionTransport(configuration: .init(session: session)) } ) return try await client.publicServerInfo() } } private func isJellyfin(_ info: JellyfinServerInfo) -> Bool { guard let productName = info.productName else { return true } return productName.range(of: "jellyfin", options: .caseInsensitive) != nil }