// // JellyfinQuickConnect.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 // /// 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 { 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.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..