99 lines
4.2 KiB
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
|
|
}
|