luminate/Sources/LuminateCore/Models/JellyfinQuickConnect.swift

129 lines
5.4 KiB
Swift

//
// JellyfinQuickConnect.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
//
/// Drives one Quick Connect pairing attempt as an async event stream.
///
/// Reach it through ``JellyfinService/quickConnect``. Iterating ``connect(pollInterval:maxPolls:)``
/// initiates the request, yields the code to show the user, then polls the server on the caller's
/// behalf until the request is approved from another signed-in client:
///
/// ```swift
/// for try await event in client.quickConnect.connect() {
/// switch event {
/// case let .polling(code: code):
/// showCode(code)
/// case let .authenticated(secret: secret):
/// let authentication = try await client.authenticateWithQuickConnect(secret: secret)
/// accessToken = authentication.accessToken
/// }
/// }
/// ```
///
/// The polling loop is a plain `Task` owned by the stream, cancelled from
/// `Continuation.onTermination` -- ending iteration (breaking out of the loop, or the enclosing
/// `.task` being cancelled on unmount) stops the polling task with it. Nothing keeps requesting
/// once nobody is listening.
package struct JellyfinQuickConnect: Sendable {
/// One step of a Quick Connect pairing attempt.
package enum Event: Equatable, Sendable {
/// The request was created; show the associated code to the user.
case polling(code: String)
/// The request was approved from another client; redeem the secret for a token via
/// ``JellyfinService/authenticateWithQuickConnect(secret:)``.
case authenticated(secret: String)
}
private let service: any JellyfinService
/// Wraps a service for Quick Connect.
///
/// - Parameter service: The service to initiate and poll through. Use
/// ``JellyfinService/quickConnect`` instead of calling this directly.
init(service: any JellyfinService) {
self.service = service
}
/// Starts a Quick Connect pairing attempt when iterated.
///
/// - Parameters:
/// - pollInterval: Time between polls. Defaults to five seconds, matching Jellyfin's web
/// client.
/// - maxPolls: The maximum number of polls before giving up. Defaults to 200, about sixteen
/// minutes at the default interval.
/// - Returns: A stream of one ``Event/polling(code:)`` followed by at most one
/// ``Event/authenticated(secret:)``. The stream throws ``JellyfinClientError/notFound`` if
/// the server expires the request first, or ``JellyfinClientError/quickConnectTimedOut`` if
/// `maxPolls` is exhausted without an answer either way.
package func connect(
pollInterval: Duration = .seconds(5),
maxPolls: Int = 200
) -> AsyncThrowingStream<Event, Error> {
precondition(pollInterval > .zero, "Poll interval must be positive")
precondition(maxPolls > 0, "Maximum poll count must be positive")
return AsyncThrowingStream { continuation in
let task = Task {
do {
try await run(pollInterval: pollInterval, maxPolls: maxPolls, into: continuation)
continuation.finish()
} catch is CancellationError {
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
/// Initiates the request, yields its code, then polls until it is approved or exhausted.
///
/// - Throws: ``JellyfinClientError/missingPayload(operation:)`` if the server omitted the
/// secret or code, whatever ``JellyfinService/quickConnectState(secret:)`` throws (notably
/// ``JellyfinClientError/notFound`` once the server expires the request), or
/// ``JellyfinClientError/quickConnectTimedOut`` after `maxPolls` unanswered polls.
private func run(
pollInterval: Duration,
maxPolls: Int,
into continuation: AsyncThrowingStream<Event, Error>.Continuation
) async throws {
let state = try await service.initiateQuickConnect()
guard let secret = state.secret, let code = state.code else {
throw JellyfinClientError.missingPayload(operation: "InitiateQuickConnect")
}
continuation.yield(.polling(code: code))
for _ in 0..<maxPolls {
try Task.checkCancellation()
if try await service.quickConnectState(secret: secret).authenticated == true {
continuation.yield(.authenticated(secret: secret))
return
}
try await Task.sleep(for: pollInterval)
}
throw JellyfinClientError.quickConnectTimedOut
}
}