import Foundation import Observation import PlexKit /// Der Zustand der Verbindung zum Server. /// /// Alles, was die App über „bin ich angemeldet und mit wem spreche ich" weiß, /// steht hier. Die Views lesen nur `phase` — dadurch gibt es genau einen Ort, /// an dem sich entscheidet, was auf dem Bildschirm erscheint. @MainActor @Observable final class FlixySession { enum Phase: Equatable { /// Beim Start: liegt ein Token im Schlüsselbund? case launching case signedOut /// Das Anmeldefenster von Plex ist offen. case authenticating /// Mehrere Server am Konto — einer muss gewählt werden. case choosingServer([PlexResource]) /// Ein Weg zum Server wird gesucht. case connecting(serverName: String) case ready case failed(message: String) static func == (lhs: Phase, rhs: Phase) -> Bool { switch (lhs, rhs) { case (.launching, .launching), (.signedOut, .signedOut), (.ready, .ready): true case (.authenticating, .authenticating): true case (.choosingServer(let l), .choosingServer(let r)): l.map(\.clientIdentifier) == r.map(\.clientIdentifier) case (.connecting(let l), .connecting(let r)): l == r case (.failed(let l), .failed(let r)): l == r default: false } } } private enum Key { static let preferredServer = "flixy.server.clientIdentifier" } /// Wird für die Anmelde-Adresse gebraucht: plex.tv ordnet den /// bestätigten Code darüber diesem Gerät zu. var clientIdentifier: String { identity.clientIdentifier } private(set) var phase: Phase = .launching private(set) var server: PlexServer? /// Nur die Mediatheken, die Flixy darstellen kann — Musik und Fotos /// erscheinen gar nicht erst. private(set) var libraries: [PlexLibrary] = [] private let identity: PlexClientIdentity private let tokenStore: any TokenStoring private let authenticator: PlexAuthenticator private let webAuthenticator = PlexWebAuthenticator() private let defaults: UserDefaults private var authorizationTask: Task? init( identity: PlexClientIdentity, tokenStore: any TokenStoring, defaults: UserDefaults = .standard ) { self.identity = identity self.tokenStore = tokenStore self.defaults = defaults self.authenticator = PlexAuthenticator(identity: identity, tokenStore: tokenStore) } // MARK: - Start /// Versucht, mit einem bereits gespeicherten Token weiterzumachen. func restore() async { guard let token = authenticator.storedToken else { phase = .signedOut return } await connect(accountToken: token) } // MARK: - Anmeldung /// Öffnet die Anmeldeseite von Plex. /// /// Der PIN steckt weiterhin darunter — er ist der einzige Weg, auf dem /// Plex einen Token herausgibt. Sichtbar ist er nicht mehr: Plex liefert /// seine eigene Anmeldemaske aus und schickt die App danach über /// `forwardUrl` zurück. func beginSignIn() async { phase = .authenticating do { let pin = try await authenticator.requestPin() guard let url = pin.authorizationURL( clientIdentifier: identity.clientIdentifier, product: Branding.plexProduct, forwardURL: Branding.authCallbackURL ) else { phase = .failed(message: String(localized: "signIn.windowFailed")) return } try await webAuthenticator.authenticate( url: url, callbackScheme: Branding.urlScheme ) // Das Fenster ist zu, jetzt liegt der Token bereit. Kurz // nachgefragt statt sofort erwartet: zwischen dem Rücksprung und // dem Eintrag bei Plex liegen manchmal ein, zwei Sekunden. let token = try await authenticator.waitForAuthorization( pin: pin, pollInterval: .milliseconds(500), timeout: .seconds(20) ) await connect(accountToken: token) } catch PlexWebAuthenticator.Failure.cancelled { // Fenster geschlossen. Kein Fehler — kommentarlos zurück. phase = .signedOut } catch let error as PlexWebAuthenticator.Failure { phase = .failed(message: webAuthenticationMessage(for: error)) } catch { phase = .failed(message: message(for: error)) } } func cancelSignIn() { authorizationTask?.cancel() authorizationTask = nil phase = .signedOut } private func webAuthenticationMessage(for failure: PlexWebAuthenticator.Failure) -> String { switch failure { case .cancelled: String(localized: "signIn.cancelled") case .presentationFailed(let description): description } } func signOut() { authorizationTask?.cancel() authenticator.signOut() defaults.removeObject(forKey: Key.preferredServer) server = nil libraries = [] phase = .signedOut } // MARK: - Verbinden private func connect(accountToken: String) async { phase = .connecting(serverName: "") let directory = PlexServerDirectory(identity: identity, token: accountToken) let servers: [PlexResource] do { servers = try await directory.servers() } catch PlexError.unauthorized { // Der Token wurde am Konto zurückgezogen. Ihn zu behalten würde // bei jedem Start denselben Fehler erzeugen. authenticator.signOut() phase = .signedOut return } catch { phase = .failed(message: message(for: error)) return } guard !servers.isEmpty else { phase = .failed(message: String(localized: "error.noServers")) return } // Beim zweiten Start soll niemand erneut wählen müssen. let remembered = defaults.string(forKey: Key.preferredServer) if let chosen = servers.first(where: { $0.clientIdentifier == remembered }) ?? servers.singleElement { await connect(to: chosen) } else { phase = .choosingServer(servers) } } func connect(to resource: PlexResource) async { phase = .connecting(serverName: resource.name) let selector = PlexConnectionSelector(identity: identity) guard let connection = await selector.selectConnection(for: resource) else { phase = .failed(message: String(localized: "error.serverUnreachable")) return } guard let server = PlexServer( resource: resource, connection: connection, identity: identity ) else { phase = .failed(message: String(localized: "error.serverUnreachable")) return } self.server = server defaults.set(resource.clientIdentifier, forKey: Key.preferredServer) do { libraries = try await server.libraries().filter(\.isSupported) phase = .ready } catch { phase = .failed(message: message(for: error)) } } /// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren. func retry() async { if let token = authenticator.storedToken { await connect(accountToken: token) } else { phase = .signedOut } } private func message(for error: any Error) -> String { guard let plexError = error as? PlexError else { return error.localizedDescription } return switch plexError { case .unauthorized: String(localized: "error.unauthorized") case .notFound: String(localized: "error.notFound") case .noReachableConnection: String(localized: "error.serverUnreachable") case .server(let status): String(localized: "error.server \(status)") case .transport(let description): description case .decoding: String(localized: "error.unexpectedResponse") } } } extension Array { /// Das einzige Element — oder `nil`, wenn es keines oder mehrere gibt. var singleElement: Element? { count == 1 ? first : nil } }