Plex ist raus — und nahm zwei stille Fehler mit

Vollständig entfernt: das Paket `PlexKit` samt 108 Tests, der
Web-Authenticator, die Anmeldung über plex.tv, die Serverauswahl und alles,
was daran hing. 43 Dateien gelöscht, 68 Stellen in 28 weiteren bereinigt.

**Zwei Dinge hätten beim blossen Streichen still das Falsche getan.**

`playbackUnavailableMessage` deckte im Else-Zweig zwei Lagen gleichzeitig ab
— „es ist Plex" und „niemand ist angemeldet". Wer die Serverart naiv
herausstreicht, bekommt als nicht angemeldeter Nutzer „Jellyfin-Wiedergabe
steht aus" statt „Server nicht erreichbar". Die Unterscheidung hängt jetzt
daran, ob überhaupt ein Server da ist.

Die Schlüsselbund-Einträge `plex.account` und `plex.server` wurden allein
über den Authenticator aus dem gelöschten Paket entfernt. Danach stünde
`plex.account` in keiner Zeile dieses Projekts mehr: wer sich nie abgemeldet
hat, trüge ein gültiges Token am fremden Konto bis in alle Ewigkeit mit sich,
ohne dass irgendein Weg es noch löschen könnte. `purgePlexLeftovers()` räumt
beide Token und fünf Voreinstellungen einmalig ab — und fasst nichts an, wo
nichts liegt, damit kein Jellyfin-Nutzer dafür seinen Mediathekenspeicher
verliert.

**Was Nutzer sehen.** Die Berechtigungsfrage fürs lokale Netz sagte wörtlich,
die App brauche es, „um deinen Plex-Server zu finden" — das stand so im
Systemdialog. Der Anmeldebildschirm hatte zwei Knöpfe, und der hervorgehobene
war der für Plex; der verbliebene erbt die Hervorhebung, denn ein Knopf, der
aussieht wie die zweite Wahl, lässt nach der ersten suchen. 14 verwaiste
Textbausteine sind weg, vier umformuliert.

Darunter einer, der schlicht nicht mehr stimmte: `signIn.privacy` versprach,
das Passwort erreiche diese App nie. Beim Jellyfin-Rückfall auf das Passwort
tut es das sehr wohl. Die Zusage, die weiterhin gilt, ist die über fremde
Dienste — und nur die steht jetzt da.

**Was ausdrücklich bleibt:** `MediaBackend`. Diese Abstraktion hat zwei
Server gleichzeitig getragen, und der Steckplatz, den Plex räumt, ist genau
der, auf den Halo kommt. `ServerKind` fällt dagegen weg — mit einem Fall ist
es ein Bool, das `backend != nil` schon beantwortet.

Nebenbei zog die README nach. Sie beschrieb noch einen „nativen Plex-Client"
mit zwei Playern und VLCKit, beides seit Längerem nicht mehr wahr.

368 Tests grün (von 476 minus die 108 aus PlexKitTests, kein anderer).
iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 11:10:20 +02:00
co-authored by Claude Opus 5
parent 93695dc332
commit 2fcf6821c8
68 changed files with 231 additions and 9425 deletions
+90 -414
View File
@@ -2,7 +2,6 @@ import Foundation
import JellyfinKit
import MediaKit
import Observation
import PlexKit
/// Der Zustand der Verbindung zum Server.
///
@@ -17,54 +16,22 @@ import PlexKit
@MainActor
@Observable
final class FlixySession {
/// Welche Art von Server gerade bedient wird.
enum ServerKind: String, Sendable, CaseIterable {
case plex
case jellyfin
}
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
}
}
// Kein eigener ==-Operator mehr: er war nur nötig, weil `PlexResource`
// nicht `Equatable` war und über die Kennung verglichen werden musste.
// Alle verbliebenen Nutzlasten sind `String`, also leitet der Compiler
// die Gleichheit selbst ab — und ein künftiger Fall wird nicht
// stillschweigend als „ungleich" behandelt.
}
private enum Key {
/// Welche Serverart zuletzt aktiv war.
///
/// Fehlt der Eintrag, aber es liegt ein Plex-Token bereit, war es
/// Plex — so kam die App bis hierher, und niemand soll durch ein
/// Update abgemeldet werden.
static let serverKind = "flixy.backend.kind"
static let preferredServer = "flixy.server.clientIdentifier"
static let cachedConnectionURI = "flixy.server.connectionURI"
static let cachedServerName = "flixy.server.name"
static let cachedConnectionIsLocal = "flixy.server.connectionIsLocal"
static let jellyfinAddress = "flixy.jellyfin.address"
static let jellyfinUserID = "flixy.jellyfin.userID"
static let jellyfinUserName = "flixy.jellyfin.userName"
@@ -72,33 +39,38 @@ final class FlixySession {
static let jellyfinIsAdministrator = "flixy.jellyfin.isAdministrator"
}
/// Unter welchem Namen die Server-Token im Schlüsselbund liegen.
/// Unter welchem Namen das Server-Token im Schlüsselbund liegt.
///
/// Nicht in den Voreinstellungen: sie geben vollen Zugriff auf die
/// Mediathek. Getrennte Konten je Serverart, damit ein späteres Umschalten
/// zwischen beiden nicht jedes Mal eine neue Anmeldung kostet. Das
/// ausdrückliche Abmelden räumt trotzdem beide ab — siehe `signOut()`.
private static let plexTokenAccount = "plex.server"
/// Nicht in den Voreinstellungen: es gibt vollen Zugriff auf die
/// Mediathek. Der Name trägt die Serverart, damit ein zweiter Server
/// später daneben passt, ohne diesen zu verdrängen.
private static let jellyfinTokenAccount = "jellyfin.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.
/// Was die Plex-Zeit auf dem Gerät hinterlassen hat.
///
/// 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
/// **Diese Namen stehen sonst nirgends mehr.** Mit dem Plex-Code ging auch
/// das Einzige, was die beiden Token je wieder entfernt hätte — wer sich
/// nie abgemeldet hat, trüge ein gültiges plex.tv-Token bis in alle
/// Ewigkeit im Schlüsselbund, ohne dass irgendein Weg es noch löschen
/// könnte. Deshalb wird einmalig aufgeräumt, bevor die Namen endgültig
/// verschwinden.
private enum Altlast {
static let merker = "flixy.migration.plexPurged"
static let tokenKonten = ["plex.account", "plex.server"]
static let schlüssel = [
"flixy.backend.kind",
"flixy.server.clientIdentifier",
"flixy.server.connectionURI",
"flixy.server.name",
"flixy.server.connectionIsLocal",
]
}
/// Ob der Server in **demselben** Netz steht wie wir.
///
/// Entscheidet, ob „Auto" gedeckelt anfängt: zuhause trägt die Leitung
/// das Original, unterwegs nicht. Bei Plex steht es im gewählten Weg; bei
/// Jellyfin wird der Server gefragt, weil ein Blick auf die Adresse nicht
/// genügt — wer über einen Reverse Proxy geht, erreicht seinen Server
/// das Original, unterwegs nicht. Der Server wird danach gefragt, weil
/// ein Blick auf die Adresse nicht genügt — wer über einen Reverse Proxy geht, erreicht seinen Server
/// auch von der Couch aus unter einem öffentlichen Namen.
///
/// Im Zweifel `false`: lieber einmal zu vorsichtig gedeckelt als eine
@@ -106,32 +78,19 @@ final class FlixySession {
private(set) var isLocalConnection = false
private(set) var phase: Phase = .launching
/// Welche Serverart gerade bedient wird — `nil`, solange niemand
/// angemeldet ist.
private(set) var kind: ServerKind?
/// Der aktive Server hinter dem neutralen Vertrag.
///
/// Die Ansichten sprechen ausschließlich hierüber. Ob dahinter Plex oder
/// Jellyfin steckt, sehen sie nicht — und genau deshalb musste für
/// Jellyfin keine einzige Ansicht neu geschrieben werden.
/// Die Ansichten sprechen ausschließlich hierüber und sehen nicht, wer
/// dahintersteckt. Dass dieser Vertrag einmal zwei Server gleichzeitig
/// getragen hat, ist kein Zufall, sondern der Grund, warum für einen
/// weiteren keine einzige Ansicht neu geschrieben werden muss.
private(set) var backend: (any MediaBackend)?
/// Der Plex-Server, solange Plex aktiv ist.
///
/// Player, Live-TV und die Diagnose sprechen noch unmittelbar mit ihm.
/// Bei Jellyfin ist er `nil` — diese drei Bereiche wandern als Nächstes
/// auf den neutralen Vertrag.
private(set) var server: PlexServer?
/// Das angemeldete Jellyfin-Konto, solange Jellyfin aktiv ist.
private(set) var jellyfinAccount: JellyfinAccount?
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
///
/// Plex führt den Kontonamen am Konto, nicht am Server; dort steht er hier
/// noch nicht zur Verfügung, und deshalb bleibt die Zeile leer, statt
/// etwas Falsches zu behaupten.
var accountName: String? { jellyfinAccount?.userName }
/// Die Mediatheken, die Flixy darstellen kann — Musik und Fotos
@@ -142,46 +101,31 @@ final class FlixySession {
var serverName: String { backend?.serverName ?? "" }
/// Unter welcher Adresse er erreicht wird.
var serverAddress: String? {
switch kind {
case .plex: server?.connection.uri
case .jellyfin: defaults.string(forKey: Key.jellyfinAddress)
case nil: nil
}
}
var serverAddress: String? { defaults.string(forKey: Key.jellyfinAddress) }
/// Wie die Verbindung zum Server zu beschreiben ist.
///
/// Für die Diagnose, und deshalb hier: welcher Weg zum Server führt, weiß
/// die Sitzung. Bei Plex ist es die entscheidende Angabe — über die
/// Vermittlung von plex.tv ist alles gedrosselt, und das erklärt die
/// Hälfte aller Ruckler, bevor man weitersucht.
/// die Sitzung. Nah oder fern ist dabei die Angabe, die etwas erklärt —
/// an ihr hängt, ob „Auto" gedeckelt anfängt, und damit die Hälfte aller
/// Fragen nach Rucklern. Die blosse Adresse steht schon eine Zeile höher.
var connectionDescription: String? {
switch kind {
case .plex:
guard let connection = server?.connection else { return nil }
if connection.local { return "Lokal · \(connection.uri)" }
if connection.relay { return "Plex-Vermittlung (gedrosselt)" }
return "Direkt von außen · \(connection.uri)"
case .jellyfin:
return serverAddress
case nil:
return nil
}
guard let serverAddress else { return nil }
return isLocalConnection
? "Lokal · \(serverAddress)"
: "Von außen · \(serverAddress)"
}
/// Ob der aktive Server Live-TV anbietet.
///
/// Endlich die Fähigkeit selbst. Hier stand `server != nil` — also „nur
/// Plex" —, weil der Programmführer unmittelbar mit Plex sprach; einen
/// Die Fähigkeit selbst, nicht der Umweg über die Serverart. Einen
/// Eintrag zu zeigen, der zu einem leeren Bildschirm führt, wäre eine
/// Zusage gewesen, die niemand einlöst. Seit Live-TV über den neutralen
/// Vertrag läuft, löst sie jeder ein, der sie gibt.
/// Zusage, die niemand einlöst — so gibt sie nur, wer sie auch einlöst.
var supportsLiveTV: Bool { backend?.capabilities.contains(.liveTV) == true }
/// Ob er darüber hinaus aufnehmen kann.
///
/// Getrennt gefragt, weil Plex das eine kann und das andere nicht.
/// Getrennt gefragt: ein Server kann empfangen, ohne aufnehmen zu können.
var supportsRecordings: Bool { backend?.capabilities.contains(.liveTVRecordings) == true }
/// Der Server als Live-TV-Server — sofern er einer ist.
@@ -192,229 +136,83 @@ final class FlixySession {
/// Warum gerade nicht abgespielt werden kann.
///
/// Bei Jellyfin ist die Anmeldung fertig, die Wiedergabe noch nicht — sie
/// ist der nächste Schritt. „Server nicht erreichbar" wäre an dieser
/// Stelle schlicht gelogen: er ist erreichbar, die App kann nur noch
/// nichts von ihm abspielen.
/// Zwei verschiedene Lagen, und sie dürfen nicht verwechselt werden: ist
/// ein Server da, ist die Anmeldung fertig und nur die Wiedergabe noch
/// nicht — „nicht erreichbar" wäre dann gelogen. Ist **keiner** da, ist
/// niemand angemeldet, und „Wiedergabe steht aus" wäre es.
///
/// Die Unterscheidung hing früher an der Serverart, und deren `nil`-Fall
/// lief stillschweigend im selben Zweig mit. Jetzt steht sie da, wo sie
/// hingehört: an der Frage, ob überhaupt ein Server da ist.
var playbackUnavailableMessage: String {
kind == .jellyfin
backend != nil
? String(localized: "player.jellyfinPending")
: String(localized: "error.serverUnreachable")
}
private let identity: PlexClientIdentity
private let jellyfinIdentity: JellyfinIdentity
private let tokenStore: any TokenStoring
private let authenticator: PlexAuthenticator
private let webAuthenticator = PlexWebAuthenticator()
private let defaults: UserDefaults
private var authorizationTask: Task<Void, Never>?
init(
identity: PlexClientIdentity,
jellyfinIdentity: JellyfinIdentity,
tokenStore: any TokenStoring,
defaults: UserDefaults = .standard
) {
self.identity = identity
self.jellyfinIdentity = jellyfinIdentity
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 {
purgePlexLeftovers()
#if DEBUG
if await adoptStubServerIfRequested() { return }
#endif
switch storedKind {
case .jellyfin:
if await restoreJellyfin() { return }
phase = .signedOut
case .plex, nil:
guard let token = authenticator.storedToken else {
phase = .signedOut
return
}
// Wer vor dieser Version angemeldet war, hat keinen Eintrag über
// die Serverart — nachtragen, damit „nicht eingetragen" von hier
// an eindeutig „abgemeldet" heißt.
defaults.set(ServerKind.plex.rawValue, forKey: Key.serverKind)
// 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)
}
}
private var storedKind: ServerKind? {
defaults.string(forKey: Key.serverKind).flatMap(ServerKind.init(rawValue:))
}
/// 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.plexTokenAccount)
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 }
let plex = adopt(server: server)
do {
libraries = try await loadLibraries(from: plex)
phase = .ready
return true
} catch {
clearActiveServer()
return false
}
}
// MARK: - Anmeldung bei Plex
/// Ö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: ErrorText.describe(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()
if await restoreJellyfin() { return }
phase = .signedOut
}
/// Meldet ab — von allen Servern, nicht nur vom gerade aktiven.
/// Räumt einmal weg, was die Plex-Zeit hinterlassen hat.
///
/// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen". Nur den
/// aktiven zu vergessen hieße, dass nach dem Abmelden beim nächsten Start
/// wieder eine Mediathek dasteht — die des anderen Servers.
/// **Der einzige Weg, auf dem das je wieder passiert.** Vorher entfernte
/// `signOut()` die Plex-Token über den Authenticator; den gibt es nicht
/// mehr, und `plex.account` stünde sonst in keiner Zeile dieses Projekts.
/// Wer bis heute angemeldet war und nie auf „Abmelden" gedrückt hat,
/// behielte ein gültiges Token am fremden Konto — unsichtbar und
/// unlöschbar.
///
/// Der Merker wird **vor** der Prüfung gesetzt: ein Aufräumen, das
/// scheitert, soll nicht bei jedem Start wiederkehren.
private func purgePlexLeftovers() {
guard !defaults.bool(forKey: Altlast.merker) else { return }
defaults.set(true, forKey: Altlast.merker)
// Nur anfassen, wo auch etwas liegt. Sonst verlöre jeder
// Jellyfin-Nutzer beim ersten Start seinen Mediatheken-Speicher für
// nichts.
let hattePlex = Altlast.tokenKonten.contains { tokenStore.token(for: $0) != nil }
|| Altlast.schlüssel.contains { defaults.object(forKey: $0) != nil }
guard hattePlex else { return }
for konto in Altlast.tokenKonten { tokenStore.remove(for: konto) }
for schlüssel in Altlast.schlüssel { defaults.removeObject(forKey: schlüssel) }
// Titel und Sehfortschritt gehörten zu einem Konto, das es hier nicht
// mehr gibt. Gelesen würden sie ohnehin nie wieder.
Task { await LibraryCache.shared.clearAll() }
}
/// Meldet ab und räumt dabei alles ab, was zur Anmeldung gehörte.
///
/// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen": nach
/// dem Abmelden soll beim nächsten Start keine Mediathek mehr dastehen.
func signOut() {
authorizationTask?.cancel()
authenticator.signOut()
forgetRememberedConnection()
forgetJellyfin()
defaults.removeObject(forKey: Key.serverKind)
clearActiveServer()
// Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Was
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
@@ -423,91 +221,6 @@ final class FlixySession {
phase = .signedOut
}
// MARK: - Verbinden mit Plex
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: ErrorText.describe(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
}
let plex = adopt(server: server)
remember(resource: resource, connection: connection)
do {
libraries = try await loadLibraries(from: plex)
phase = .ready
} catch {
phase = .failed(message: ErrorText.describe(error))
}
}
/// Merkt sich, worüber es funktioniert hat.
private func remember(resource: PlexResource, connection: PlexConnection) {
defaults.set(ServerKind.plex.rawValue, forKey: Key.serverKind)
defaults.set(resource.clientIdentifier, forKey: Key.preferredServer)
defaults.set(connection.uri, forKey: Key.cachedConnectionURI)
defaults.set(connection.local, forKey: Key.cachedConnectionIsLocal)
isLocalConnection = connection.local
defaults.set(resource.name, forKey: Key.cachedServerName)
if let token = resource.accessToken {
try? tokenStore.store(token, for: Self.plexTokenAccount)
}
}
/// 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.plexTokenAccount)
}
// MARK: - Verbinden mit Jellyfin
/// Übernimmt eine fertige Jellyfin-Anmeldung.
@@ -540,7 +253,6 @@ final class FlixySession {
let client = JellyfinClient(baseURL: baseURL, identity: jellyfinIdentity, token: account.token)
let backend = JellyfinBackend(client: client, account: account, serverName: "Attrappe")
kind = .jellyfin
jellyfinAccount = account
self.backend = backend
@@ -563,7 +275,6 @@ final class FlixySession {
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
clearActiveServer()
kind = .jellyfin
jellyfinAccount = account
self.backend = backend
isLocalConnection = await backend.isInLocalNetwork()
@@ -573,7 +284,6 @@ final class FlixySession {
} catch {
self.backend = nil
jellyfinAccount = nil
kind = nil
phase = .failed(message: ErrorText.describe(error))
return
}
@@ -609,7 +319,6 @@ final class FlixySession {
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
clearActiveServer()
kind = .jellyfin
jellyfinAccount = account
self.backend = backend
// Auch hier fragen: dasselbe Konto kann heute zuhause und morgen im
@@ -633,7 +342,6 @@ final class FlixySession {
}
private func rememberJellyfin(account: JellyfinAccount, serverName: String, baseURL: URL) {
defaults.set(ServerKind.jellyfin.rawValue, forKey: Key.serverKind)
defaults.set(baseURL.absoluteString, forKey: Key.jellyfinAddress)
defaults.set(account.userID, forKey: Key.jellyfinUserID)
defaults.set(account.userName, forKey: Key.jellyfinUserName)
@@ -652,22 +360,6 @@ final class FlixySession {
// MARK: - Der aktive Server
/// Setzt Plex als aktiven Server und gibt die Hülle zurück.
///
/// Zurückgegeben, damit der Aufrufer sie benutzen kann, ohne sich ein
/// zweites Mal eine zu bauen — die trüge eine eigene Live-TV-Sitzung mit
/// sich, und dann wären zwei im Umlauf, die sich nichts voneinander
/// erzählen.
@discardableResult
private func adopt(server: PlexServer) -> PlexBackend {
let hülle = PlexBackend(server: server)
kind = .plex
self.server = server
backend = hülle
jellyfinAccount = nil
return hülle
}
/// Holt die Mediathekenliste und merkt sie sich.
///
/// **Der Speicher ist hier nur ein Rettungsanker**, kein Beschleuniger:
@@ -691,26 +383,15 @@ final class FlixySession {
}
private func clearActiveServer() {
server = nil
backend = nil
jellyfinAccount = nil
kind = nil
libraries = []
}
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
func retry() async {
switch storedKind {
case .jellyfin:
if await restoreJellyfin() { return }
phase = .signedOut
case .plex, nil:
if let token = authenticator.storedToken {
await connect(accountToken: token)
} else {
phase = .signedOut
}
}
if await restoreJellyfin() { return }
phase = .signedOut
}
/// Die Kennung für Jellyfin-Anmeldungen.
@@ -720,8 +401,3 @@ final class FlixySession {
/// nach dem Anmelden ein zweites Mal in der Geräteliste des Servers.
var jellyfinDeviceIdentity: JellyfinIdentity { jellyfinIdentity }
}
extension Array {
/// Das einzige Element — oder `nil`, wenn es keines oder mehrere gibt.
var singleElement: Element? { count == 1 ? first : nil }
}