Files
Flaro/Flixy/Session/FlixySession.swift
T
GuidoandClaude Opus 5 9a69df4635 Phase 1: Anmeldung, Bibliothek, Detailseiten, Hybrid-Player
Die Datenschicht:
- plex.tv-Anmeldung über das PIN-Verfahren, Token im Schlüsselbund
- Serversuche und Verbindungsauswahl nach Rang (lokal vor direkt vor
  Vermittlung), mit Abgleich der Server-Kennung gegen veraltete
  plex.direct-Adressen
- PlexServer als einzige Stelle, an der die App Endpunkte kennt:
  Mediatheken, Hubs, seitenweises Blättern mit Filtern, Metadaten,
  Staffeln/Folgen, Suche

Die Oberfläche:
- Startseite mit Aufmacher und den Reihen vom Server
- Mediathek als Raster oder Liste, mit Filter- und Sortierblatt
- Detailseiten für Filme, Folgen, Serien und Staffeln inkl. Besetzung,
  Fassungsauswahl und aufklappbaren technischen Angaben
- Suche mit gemerkten Anfragen
- Vollbildplayer mit eigenem Chrome

Der Player:
- AVPlayerEngine und VLCPlayerEngine hinter einem gemeinsamen Protokoll
- VLCKit liegt in einem eigenen Package (FlixyVLC), damit `swift test`
  in FlixyKit nicht das 920-MB-Binärpaket mitzieht

Am echten plex.tv geprüft: der Anmeldecode kommt an. Dabei fiel auf,
dass `strong=true` einen dreißigstelligen Code liefert, der sich bei
plex.tv/link nicht eintippen lässt — jetzt der kurze.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:46:28 +02:00

216 lines
7.2 KiB
Swift

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
/// Der Code steht bereit, der Nutzer bestätigt ihn im Browser.
case awaitingAuthorization(PlexPin)
/// 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 (.awaitingAuthorization(let l), .awaitingAuthorization(let r)):
l.id == r.id
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 defaults: UserDefaults
private var authorizationTask: Task<Void, Never>?
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
func beginSignIn() async {
do {
let pin = try await authenticator.requestPin()
phase = .awaitingAuthorization(pin)
waitForAuthorization(of: pin)
} catch {
phase = .failed(message: message(for: error))
}
}
func cancelSignIn() {
authorizationTask?.cancel()
authorizationTask = nil
phase = .signedOut
}
private func waitForAuthorization(of pin: PlexPin) {
authorizationTask?.cancel()
authorizationTask = Task { [authenticator] in
do {
let token = try await authenticator.waitForAuthorization(pin: pin)
guard !Task.isCancelled else { return }
await connect(accountToken: token)
} catch {
guard !Task.isCancelled else { return }
phase = .failed(message: message(for: error))
}
}
}
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 }
}