import Foundation import MediaKit 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" static let cachedConnectionURI = "flixy.server.connectionURI" static let cachedServerName = "flixy.server.name" static let cachedConnectionIsLocal = "flixy.server.connectionIsLocal" } /// Unter welchem Namen der Server-Token im Schlüsselbund liegt. /// /// Nicht in den Voreinstellungen: er gibt vollen Zugriff auf die /// Mediathek, genau wie der Konto-Token. private static let serverTokenAccount = "plex.server" /// 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 } /// Der Code der laufenden Anmeldung. /// /// Wird nach kurzer Zeit im Anmeldebildschirm eingeblendet — als Ausweg, /// falls die Browser-Seite den Code nicht verknüpft. Das passiert, wenn /// man dort schon angemeldet ist, und ohne diesen Ausweg säße man fest. private(set) var pendingCode: String? private(set) var showsManualCode = false private(set) var phase: Phase = .launching private(set) var server: PlexServer? /// Derselbe Server, aber hinter dem neutralen Vertrag. /// /// Die Ansichten sprechen hierüber, nicht mehr mit Plex unmittelbar. Der /// Weg darunter bleibt offen, solange Player, Downloads und Live-TV noch /// nicht umgestellt sind — die drei folgen als Nächstes. var backend: PlexBackend? { server.map(PlexBackend.init) } /// Die Mediatheken in neutraler Form. var mediaLibraries: [MediaLibrary] { libraries.map(\.asMediaLibrary) } /// 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 } // Erst der kurze Weg: die Verbindung, die beim letzten Mal // funktioniert hat. Ohne ihn kostet jeder Start eine Anfrage an // plex.tv plus eine neue Verbindungssuche — zusammen mehrere // Sekunden, in denen nur ein Ladekreis zu sehen ist. if await restoreFromRememberedConnection() { return } await connect(accountToken: token) } /// Nimmt die zuletzt funktionierende Verbindung wieder auf. /// /// Gibt `false` zurück, wenn nichts gespeichert ist oder der Server nicht /// mehr darüber erreichbar ist — dann übernimmt die vollständige Suche. /// Das ist der Fall, wenn man von zuhause weggeht: die lokale Adresse /// antwortet nicht mehr, und der direkte Weg muss neu gefunden werden. private func restoreFromRememberedConnection() async -> Bool { guard let identifier = defaults.string(forKey: Key.preferredServer), let uri = defaults.string(forKey: Key.cachedConnectionURI), let serverToken = tokenStore.token(for: Self.serverTokenAccount) else { return false } let name = defaults.string(forKey: Key.cachedServerName) ?? "" phase = .connecting(serverName: name) let connection = PlexConnection( uri: uri, local: defaults.bool(forKey: Key.cachedConnectionIsLocal) ) let resource = PlexResource( name: name, clientIdentifier: identifier, accessToken: serverToken, connections: [connection] ) // Kurze Zeitgrenze: wenn die gemerkte Adresse nicht sofort antwortet, // ist die vollständige Suche schneller als weiteres Warten. let selector = PlexConnectionSelector(identity: identity, probeTimeout: .seconds(2)) guard await selector.selectConnection(for: resource) != nil, let server = PlexServer(resource: resource, connection: connection, identity: identity) else { return false } self.server = server do { libraries = try await server.libraries().filter(\.isSupported) phase = .ready return true } catch { self.server = nil return false } } // 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. /// /// Das Fenster wird geöffnet, aber **nicht abgewartet**: Plex führt den /// Rücksprung über `forwardUrl` nicht zuverlässig aus. Auf dem iPad landet /// der Browser nach dem Anmelden auf watch.plex.tv und bleibt dort stehen /// — der Token liegt längst bereit, aber die App wartete auf einen /// Rückruf, der nie kam. Stattdessen wird hier im Sekundentakt beim Server /// nachgefragt und nebenbei geprüft, ob das Fenster geschlossen wurde. func beginSignIn() async { phase = .authenticating let pin: PlexPin do { pin = try await authenticator.requestPin() } catch { phase = .failed(message: message(for: error)) return } guard let url = pin.linkURL else { phase = .failed(message: String(localized: "signIn.windowFailed")) return } pendingCode = pin.code showsManualCode = false webAuthenticator.present(url: url, callbackScheme: Branding.urlScheme) // Die Frist entspricht der Gültigkeit des Codes bei Plex. for second in 0..<900 { try? await Task.sleep(for: .seconds(1)) // Nach einer Viertelminute den Code zeigen. Wer bis dahin nicht // durch ist, hat vermutlich den Fall erwischt, in dem die Seite // nicht verknüpft — dann hilft nur noch das Abtippen. if second == 15 { showsManualCode = true } if let token = try? await authenticator.token(for: pin) { webAuthenticator.dismiss() clearPendingCode() await connect(accountToken: token) return } // Fenster geschlossen, ohne dass ein Token da ist. if webAuthenticator.didReturn { switch webAuthenticator.outcome { case .cancelled, nil: // Abgebrochen oder zurückgesprungen, ohne dass der Token // schon bereitlag. Noch ein paar Sekunden nachfragen, // bevor aufgegeben wird — zwischen Rücksprung und Eintrag // bei Plex liegt manchmal eine Sekunde. if let token = try? await graceTokenLookup(pin: pin) { clearPendingCode() await connect(accountToken: token) return } // Fenster zu, kein Token: nicht abmelden, sondern den Code // stehen lassen. Er gilt weiter, und das Abtippen bei // plex.tv/link ist jetzt der schnellste Weg. showsManualCode = true case .presentationFailed(let description): phase = .failed(message: description) } return } } webAuthenticator.dismiss() clearPendingCode() phase = .failed(message: String(localized: "signIn.timedOut")) } private func clearPendingCode() { pendingCode = nil showsManualCode = false } /// Fragt nach dem Schließen des Fensters noch ein paar Sekunden nach. private func graceTokenLookup(pin: PlexPin) async throws -> String? { for _ in 0..<5 { if let token = try? await authenticator.token(for: pin) { return token } try? await Task.sleep(for: .milliseconds(700)) } return nil } func cancelSignIn() { authorizationTask?.cancel() authorizationTask = nil webAuthenticator.dismiss() clearPendingCode() phase = .signedOut } func signOut() { authorizationTask?.cancel() authenticator.signOut() forgetRememberedConnection() 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 remember(resource: resource, connection: connection) do { libraries = try await server.libraries().filter(\.isSupported) phase = .ready } catch { phase = .failed(message: message(for: error)) } } /// Merkt sich, worüber es funktioniert hat. private func remember(resource: PlexResource, connection: PlexConnection) { defaults.set(resource.clientIdentifier, forKey: Key.preferredServer) defaults.set(connection.uri, forKey: Key.cachedConnectionURI) defaults.set(connection.local, forKey: Key.cachedConnectionIsLocal) defaults.set(resource.name, forKey: Key.cachedServerName) if let token = resource.accessToken { try? tokenStore.store(token, for: Self.serverTokenAccount) } } /// Vergisst die gemerkte Verbindung. private func forgetRememberedConnection() { for key in [Key.preferredServer, Key.cachedConnectionURI, Key.cachedConnectionIsLocal, Key.cachedServerName] { defaults.removeObject(forKey: key) } tokenStore.remove(for: Self.serverTokenAccount) } /// 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 } }