129 lines
5.4 KiB
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
|
|
}
|
|
}
|