luminate/Sources/LuminateServices/Discovery/ServerConnectionProbe.swift

99 lines
4.2 KiB
Swift

//
// ServerConnectionProbe.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
#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
}