import Foundation import JellyfinKit import MediaKit import Observation /// Führt durch die Anmeldung an einem Jellyfin-Server. /// /// Eigenes Modell statt eines weiteren Falls in `FlixySession.Phase`: die /// Anmeldung besteht aus mehreren Schritten mit eigener Bedienung — Adresse /// eintippen, Code bestätigen lassen, notfalls doch Benutzername und Passwort /// —, und keiner davon interessiert den Rest der App. Was hier ankommt, ist am /// Ende ein einziges Ergebnis: ein Konto und ein Weg dorthin. /// /// **Quick Connect zuerst.** Der sechsstellige Code wird in einem bereits /// angemeldeten Client bestätigt; das Passwort erreicht Flixy dabei nie — /// dieselbe Zusage wie bei Plex, wo die Anmeldemaske von Plex selbst kommt. /// Benutzername und Passwort sind der Rückfall für Server, auf denen Quick /// Connect abgeschaltet ist. Dann hält die App das Passwort für die Dauer /// einer Anfrage und wirft es danach weg. @MainActor @Observable final class JellyfinSignInModel { enum Step: Equatable { /// Die Adresse wird eingetippt. case address /// Die Adresse wird durchprobiert. case checking /// Der Code steht, jetzt wird auf die Bestätigung gewartet. case quickConnect(code: String) /// Der Rückfall: Benutzername und Passwort. case credentials case signingIn } var address = "" var userName = "" var password = "" private(set) var step: Step = .address private(set) var errorMessage: String? /// Wie der gefundene Server heißt — die Bestätigung, dass die Adresse /// stimmt, noch bevor jemand Anmeldedaten eintippt. private(set) var serverName: String? /// Ob der Server die Anmeldung per Code überhaupt anbietet. private(set) var offersQuickConnect = false /// Wird gerufen, sobald ein Konto feststeht. /// /// Der Client kommt mit: er trägt den Token und die Gerätekennung bereits /// und wird von der Sitzung unverändert weiterverwendet. var onAuthenticated: ((JellyfinClient, JellyfinAccount, String) -> Void)? private let identity: JellyfinIdentity private var client: JellyfinClient? private var authenticator: JellyfinAuthenticator? private var pollTask: Task? init(identity: JellyfinIdentity) { self.identity = identity } // MARK: - Schritt 1: den Server finden /// Probiert die plausiblen Ergänzungen der eingetippten Adresse durch. /// /// Nicht eine raten, sondern der Reihe nach fragen: „192.168.1.50" kann /// alles Mögliche heißen, und wer die Adresse vollständig eintippen muss, /// tippt sie dreimal falsch. func findServer() async { pollTask?.cancel() errorMessage = nil let candidates = JellyfinAddress.candidates(for: address) guard !candidates.isEmpty else { errorMessage = String(localized: "signIn.jellyfin.addressMissing") return } step = .checking for candidate in candidates { let client = JellyfinClient(baseURL: candidate, identity: identity) let authenticator = JellyfinAuthenticator(client: client) guard let info = try? await authenticator.serverInfo() else { continue } self.client = client self.authenticator = authenticator serverName = info.serverName address = candidate.absoluteString offersQuickConnect = await authenticator.supportsQuickConnect() if offersQuickConnect { await beginQuickConnect() } else { step = .credentials } return } step = .address errorMessage = String(localized: "signIn.jellyfin.notFound") } // MARK: - Schritt 2a: Code bestätigen lassen func beginQuickConnect() async { guard let authenticator else { return } errorMessage = nil step = .checking do { let request = try await authenticator.beginQuickConnect() step = .quickConnect(code: request.code) startPolling(secret: request.secret, authenticator: authenticator) } catch { // Quick Connect abgeschaltet oder gestört: dann eben der andere // Weg, statt hier stehen zu bleiben. offersQuickConnect = false step = .credentials errorMessage = ErrorText.describe(error) } } /// Fragt im Zweisekundentakt, ob der Code bestätigt wurde. /// /// Fünf Minuten lang — so lange gilt ein Code bei Jellyfin in der /// Voreinstellung. Danach wird nicht weiter gefragt, sondern gesagt, dass /// er abgelaufen ist; eine Schleife, die ewig läuft, sieht aus wie eine /// App, die hängt. private func startPolling(secret: String, authenticator: JellyfinAuthenticator) { pollTask?.cancel() pollTask = Task { [weak self] in for _ in 0..<150 { try? await Task.sleep(for: .seconds(2)) if Task.isCancelled { return } do { if let account = try await authenticator.completeQuickConnect(secret: secret) { self?.finish(with: account) return } } catch MediaBackendError.notFound { // Jellyfin kennt den Code nicht mehr: abgelaufen oder // abgelehnt. self?.expireQuickConnect() return } catch { self?.errorMessage = ErrorText.describe(error) return } } self?.expireQuickConnect() } } private func expireQuickConnect() { errorMessage = String(localized: "signIn.jellyfin.codeExpired") step = .credentials } // MARK: - Schritt 2b: Benutzername und Passwort func signInWithPassword() async { guard let authenticator else { return } pollTask?.cancel() errorMessage = nil step = .signingIn let secret = password // Sofort aus dem Feld nehmen. Es wird nur noch für diese eine Anfrage // gebraucht, und was nicht mehr dasteht, kann auch nicht in einem // Bildschirmfoto landen. password = "" do { let account = try await authenticator.signIn(userName: userName, password: secret) finish(with: account) } catch { errorMessage = ErrorText.describe(error) step = .credentials } } // MARK: - Ende private func finish(with account: JellyfinAccount) { pollTask?.cancel() guard let client else { return } password = "" onAuthenticated?(client, account, serverName ?? address) } /// Von der Code-Anzeige auf Benutzername und Passwort wechseln. /// /// Der Weg ohne Passwort steht vorn, aber er ist kein Zwang: wer keinen /// zweiten angemeldeten Client zur Hand hat, käme sonst nicht weiter. func useCredentials() { pollTask?.cancel() errorMessage = nil step = .credentials } /// Zurück zum Adressfeld — etwa nach einem Vertipper. func startOver() { pollTask?.cancel() client = nil authenticator = nil serverName = nil offersQuickConnect = false password = "" errorMessage = nil step = .address } func cancel() { pollTask?.cancel() password = "" } }