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:
co-authored by
Claude Opus 5
parent
93695dc332
commit
2fcf6821c8
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user