Files
Flaro/Flaro/Session/FlaroSession.swift
T
ScarriffleandClaude Opus 5 172b38da06 Die Apps können jetzt beides: Jellyfin oder Flaro
`FlaroServerKit` steht neben `JellyfinKit`, nicht darin — genau auf dem
Steckplatz, den Plex geräumt hat. Dass `MediaBackend` zwei Server zugleich
trägt, war schon einmal bewiesen; die Naht hat gehalten, und keine einzige
Ansicht musste dafür neu geschrieben werden.

`ServerKind` ist zurück. Es wurde beim Entfernen von Plex gestrichen, weil
ein Aufzählungstyp mit einem Fall ein umständliches Bool ist. Jetzt gibt es
wieder zwei, und es ist wieder eine Unterscheidung — einer zur Zeit, nicht
beide nebeneinander.

**Flaro steht oben auf dem Anmeldebildschirm.** Nicht aus Eitelkeit: dort
bringt ein Download alle Tonspuren mit und der Fortschritt wird ab der
ersten Sekunde gemerkt. Wer Jellyfin benutzt, sucht gezielt danach — wer neu
anfängt, soll nicht in der schwächeren Hälfte landen, weil ihr Knopf
auffälliger war. Am Fernseher steht bei beiden der sechsstellige Code voran.

---

**Der Zugang geht als Cookie, nicht in der Adresse.** Jellyfin hängt sein
Token an jede Bildadresse, weil eine Bildansicht keine Kopfzeilen schickt;
es steht damit in jedem Zugriffsprotokoll und jedem weitergegebenen Link.
Flaro tut das nicht, und deshalb legt der Client sein Token selbst im
gemeinsamen Keksspeicher ab — auch beim Wiederaufnehmen aus dem
Schlüsselbund, wo gar keine Anmeldung stattfindet und der Server es nie
setzen könnte.

`URLSession` schickt es von allein mit; **AVFoundation nicht**. Der
Systemplayer bekommt die Kekse jetzt ausdrücklich. Serverneutral: wo keine
liegen, ist die Liste leer.

**`BackendCapability.allTracksDownload`** statt einer Spurwahl, die ins
Leere führt. Die Frage „welche Tonspur?" gibt es nur, weil Jellyfins
Transcoder eine je Sitzung abbildet. Bei Flaro führen beide Antworten zum
selben Ergebnis — also wird nicht gefragt, und das Umpacken mit einer
einzigen Spur entfällt gleich mit.

**Die Adressraterei wandert nach `MediaKit`.** Sechzig Zeilen sorgfältig
begründeter Reihenfolge — IP-Adressen sprechen selten HTTPS, Domänennamen
fast immer — unterscheiden sich zwischen beiden Servern nur im Standardport.
Zweimal hingeschrieben wäre die eine Fassung beim nächsten Sonderfall
nachgezogen worden und die andere nicht. Jellyfins Prüfungen laufen
unverändert gegen die verschobene Fassung; das ist der Beweis.

**Nebenbei zwei Altlasten:** `player.jellyfinPending` war unerreichbar — der
Player zeigt diese Meldung nur, wenn gar kein Server da ist, dann aber nie
die mit Jellyfin. Und `signIn.jellyfin.userName` hiess „Benutzername"; der
Schlüssel heisst jetzt so neutral wie der Text.

---

**Der Fehler, den erst der Simulator zeigte:** „Stelle wird
wiederhergestellt", 0:00, für immer.

Muss der Server umpacken, antwortet er auf den Download mit 202 und fängt
an. AVPlayer bekommt darauf kein Video, sondern eine leere Antwort — und
wartet, unbegrenzt, ohne Fehler. Die Weboberfläche fragt in einer Schleife
nach; `preparePlayback` tat es nicht. Jetzt wartet es, mit Obergrenze und
einer Begründung, wenn sie reisst. Mit Prüfung, die zwei Runden erzwingt.

413 Tests grün. Im Simulator gegen den echten Server nachgewiesen: Adresse
eintippen, Server gefunden, angemeldet, Mediathek mit Plakaten, Neustart
ohne erneute Anmeldung — und eine MKV abgespielt, die der Server dafür erst
umpacken musste. Danach stand auf dem Server: Matrix, 2,0 s von 2,0 s,
gesehen, ein Durchlauf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 22:00:02 +02:00

567 lines
24 KiB
Swift

import Foundation
import FlaroServerKit
import JellyfinKit
import MediaKit
import Observation
/// 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.
///
/// Seit es zwei Serverarten gibt, hält diese Klasse **einen** aktiven Server,
/// nicht zwei. Zwei gleichzeitig hießen: zwei Startseiten, zwei Mediatheken,
/// zwei Suchergebnisse — und bei jedem Titel die Frage, woher er stammt.
/// Umschalten ja, nebeneinander nein.
@MainActor
@Observable
final class FlaroSession {
enum Phase: Equatable {
/// Beim Start: liegt ein Token im Schlüsselbund?
case launching
case signedOut
/// Ein Weg zum Server wird gesucht.
case connecting(serverName: String)
case ready
case failed(message: String)
// 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.
}
/// Mit welcher Art Server gesprochen wird.
///
/// **Diese Unterscheidung gab es schon einmal** — damals für Plex und
/// Jellyfin — und sie wurde beim Entfernen von Plex gestrichen, weil ein
/// Aufzählungstyp mit einem Fall ein umständliches Bool ist. Jetzt gibt
/// es wieder zwei, und es ist wieder eine Unterscheidung.
///
/// **Einer zur Zeit, nicht beide.** Zwei gleichzeitig hiessen: zwei
/// Startseiten, zwei Mediatheken, zwei Suchergebnisse — und bei jedem
/// Titel die Frage, woher er stammt.
enum ServerKind: String, CaseIterable, Sendable, Identifiable {
case jellyfin, flaro
var id: String { rawValue }
}
private enum Key {
/// Welche Art zuletzt angemeldet war.
///
/// Nicht `flaro.backend.kind`: so hiess der Schlüssel zu Plex-Zeiten,
/// und der steht in der Aufräumliste weiter unten. Denselben Namen
/// mit anderer Bedeutung zu benutzen wäre eine Falle für den
/// Nächsten, der die Liste liest.
static let activeKind = "flaro.backend.active"
static let jellyfinAddress = "flaro.jellyfin.address"
static let jellyfinUserID = "flaro.jellyfin.userID"
static let jellyfinUserName = "flaro.jellyfin.userName"
static let jellyfinServerName = "flaro.jellyfin.serverName"
static let jellyfinIsAdministrator = "flaro.jellyfin.isAdministrator"
static let flaroAddress = "flaro.flaroServer.address"
static let flaroUserID = "flaro.flaroServer.userID"
static let flaroUserName = "flaro.flaroServer.userName"
static let flaroServerName = "flaro.flaroServer.serverName"
static let flaroIsAdministrator = "flaro.flaroServer.isAdministrator"
}
/// Unter welchem Namen das Server-Token im Schlüsselbund liegt.
///
/// 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"
private static let flaroTokenAccount = "flaro.server"
/// Was die Plex-Zeit auf dem Gerät hinterlassen hat.
///
/// **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 = "flaro.migration.plexPurged"
static let tokenKonten = ["plex.account", "plex.server"]
static let schlüssel = [
"flaro.backend.kind",
"flaro.server.clientIdentifier",
"flaro.server.connectionURI",
"flaro.server.name",
"flaro.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. 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
/// Wiedergabe, die über eine Fernverbindung gar nicht erst anläuft.
private(set) var isLocalConnection = false
private(set) var phase: Phase = .launching
/// Der aktive Server hinter dem neutralen Vertrag.
///
/// 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)?
/// Das angemeldete Jellyfin-Konto, solange Jellyfin aktiv ist.
private(set) var jellyfinAccount: JellyfinAccount?
/// Das angemeldete Flaro-Konto, solange Flaro aktiv ist.
private(set) var flaroAccount: FlaroAccount?
/// Mit welcher Art Server gerade gesprochen wird.
private(set) var activeKind: ServerKind?
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
var accountName: String? { jellyfinAccount?.userName ?? flaroAccount?.userName }
/// Die Mediatheken, die Flaro darstellen kann — Musik und Fotos
/// erscheinen gar nicht erst.
private(set) var libraries: [MediaLibrary] = []
/// Wie der Server heißt, so wie er sich selbst nennt.
var serverName: String { backend?.serverName ?? "" }
/// Unter welcher Adresse er erreicht wird.
var serverAddress: String? {
switch activeKind {
case .flaro: defaults.string(forKey: Key.flaroAddress)
case .jellyfin, nil: 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. 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? {
guard let serverAddress else { return nil }
return isLocalConnection
? "Lokal · \(serverAddress)"
: "Von außen · \(serverAddress)"
}
/// Ob der aktive Server Live-TV anbietet.
///
/// 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, 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: 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.
var liveTV: (any LiveTVBackend)? { backend as? any LiveTVBackend }
/// Der Server als Aufnahme-Server — sofern er einer ist.
var recorder: (any LiveTVRecordingBackend)? { backend as? any LiveTVRecordingBackend }
/// Warum gerade nicht abgespielt werden kann.
///
/// **Nur noch ein Fall, und das ist eine Bereinigung.** Hier stand eine
/// Fallunterscheidung aus der Plex-Zeit: war Jellyfin aktiv, hiess es
/// „die Wiedergabe von Jellyfin ist noch nicht fertig". Sie ist längst
/// fertig — und der Satz war ohnehin unerreichbar, denn der Player zeigt
/// diese Meldung ausschliesslich dann, wenn gar kein Server da ist.
var playbackUnavailableMessage: String {
String(localized: "error.serverUnreachable")
}
private let jellyfinIdentity: JellyfinIdentity
private let flaroIdentity: FlaroIdentity
private let tokenStore: any TokenStoring
private let defaults: UserDefaults
init(
jellyfinIdentity: JellyfinIdentity,
flaroIdentity: FlaroIdentity,
tokenStore: any TokenStoring,
defaults: UserDefaults = .standard
) {
self.jellyfinIdentity = jellyfinIdentity
self.flaroIdentity = flaroIdentity
self.tokenStore = tokenStore
self.defaults = defaults
}
// MARK: - Start
/// Versucht, mit einem bereits gespeicherten Token weiterzumachen.
func restore() async {
purgePlexLeftovers()
#if DEBUG
if await adoptStubServerIfRequested() { return }
#endif
// **Die gespeicherte Art zuerst.** Wer ohne sie startet, war vor
// dieser Fassung angemeldet — damals gab es nur Jellyfin, und genau
// das ist dann gemeint.
switch defaults.string(forKey: Key.activeKind).flatMap(ServerKind.init(rawValue:)) {
case .flaro: if await restoreFlaro() { return }
case .jellyfin, nil: if await restoreJellyfin() { return }
}
phase = .signedOut
}
/// Räumt einmal weg, was die Plex-Zeit hinterlassen hat.
///
/// **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() {
// **Dem Flaro-Server sagen, dass dieses Gerät geht.** Jellyfin
// erfährt es über das Verfallen des Tokens; Flaro zieht es auf
// Zuruf zurück, und ein nicht zurückgezogenes Token stünde sonst
// bis zum Ablauf in der Geräteliste.
if let client = flaroClient {
Task { await FlaroSignIn.signOut(client: client) }
}
forgetJellyfin()
forgetFlaro()
defaults.removeObject(forKey: Key.activeKind)
clearActiveServer()
// Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Was
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
// verloren.
Task { await LibraryCache.shared.clearAll() }
phase = .signedOut
}
// MARK: - Verbinden mit Jellyfin
/// Übernimmt eine fertige Jellyfin-Anmeldung.
///
/// Die Anmeldung selbst führt `JellyfinSignInModel` — sie besteht aus
/// mehreren Schritten mit eigener Bedienung (Adresse, Code, Rückfall auf
/// Passwort) und gehört deshalb in ein eigenes Modell. Hier kommt nur das
/// Ergebnis an: ein Konto und ein Weg dorthin.
#if DEBUG
/// Meldet sich an der Attrappe aus `Tools/JellyfinStub` an.
///
/// Nur in der Entwicklung und nur auf ausdrückliches Kommando:
/// `-FlaroStubServer http://127.0.0.1:8096` als Startargument. Der Weg
/// führt bewusst **am Schlüsselbund vorbei** und schreibt nichts fort —
/// so lässt sich die Oberfläche ansehen, prüfen und fotografieren, ohne
/// dass je ein echter Server, ein echtes Konto oder ein echter Token im
/// Spiel wäre. Genau daran hat es bisher gefehlt: Aussagen über das
/// Aussehen waren Vermutungen, weil niemand hinsehen konnte.
private func adoptStubServerIfRequested() async -> Bool {
guard let raw = defaults.string(forKey: "FlaroStubServer"),
let baseURL = URL(string: raw)
else { return false }
let account = JellyfinAccount(
userID: "user-1",
userName: "Attrappe",
token: "stub-token",
isAdministrator: true
)
let client = JellyfinClient(baseURL: baseURL, identity: jellyfinIdentity, token: account.token)
let backend = JellyfinBackend(client: client, account: account, serverName: "Attrappe")
jellyfinAccount = account
self.backend = backend
do {
libraries = try await loadLibraries(from: backend)
phase = .ready
} catch {
phase = .failed(message: ErrorText.describe(error))
}
return true
}
#endif
func adoptJellyfin(
client: JellyfinClient,
account: JellyfinAccount,
serverName: String
) async {
phase = .connecting(serverName: serverName)
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
clearActiveServer()
jellyfinAccount = account
activeKind = .jellyfin
self.backend = backend
isLocalConnection = await backend.isInLocalNetwork()
do {
libraries = try await loadLibraries(from: backend)
} catch {
clearActiveServer()
phase = .failed(message: ErrorText.describe(error))
return
}
rememberJellyfin(account: account, serverName: serverName, baseURL: client.baseURL)
phase = .ready
}
/// Nimmt eine gespeicherte Jellyfin-Anmeldung wieder auf.
///
/// Gibt `false` zurück, wenn nichts gespeichert ist oder der Token nicht
/// mehr gilt — dann steht die Anmeldung wieder am Anfang. Ist der Server
/// bloß gerade nicht erreichbar, bleibt die Anmeldung dagegen bestehen und
/// es gibt einen Fehler mit „Erneut versuchen": auf dem Weg zur Arbeit ist
/// jeder Server unerreichbar, und das ist kein Grund, jemanden abzumelden.
private func restoreJellyfin() async -> Bool {
guard let address = defaults.string(forKey: Key.jellyfinAddress),
let baseURL = URL(string: address),
let userID = defaults.string(forKey: Key.jellyfinUserID),
let token = tokenStore.token(for: Self.jellyfinTokenAccount)
else { return false }
let serverName = defaults.string(forKey: Key.jellyfinServerName) ?? ""
phase = .connecting(serverName: serverName)
let account = JellyfinAccount(
userID: userID,
userName: defaults.string(forKey: Key.jellyfinUserName) ?? "",
token: token,
isAdministrator: defaults.bool(forKey: Key.jellyfinIsAdministrator)
)
let client = JellyfinClient(baseURL: baseURL, identity: jellyfinIdentity, token: token)
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
clearActiveServer()
jellyfinAccount = account
activeKind = .jellyfin
self.backend = backend
// Auch hier fragen: dasselbe Konto kann heute zuhause und morgen im
// Zug wiederhergestellt werden, und die Antwort gilt je Sitzung.
isLocalConnection = await backend.isInLocalNetwork()
do {
libraries = try await loadLibraries(from: backend)
phase = .ready
return true
} catch MediaBackendError.unauthorized {
// Der Token wurde am Server zurückgezogen. Ihn zu behalten würde
// bei jedem Start denselben Fehler erzeugen.
forgetJellyfin()
clearActiveServer()
return false
} catch {
phase = .failed(message: ErrorText.describe(error))
return true
}
}
// MARK: - Verbinden mit Flaro
/// Übernimmt eine fertige Flaro-Anmeldung.
///
/// Dieselbe Form wie bei Jellyfin, und das ist der Beweis für die Naht:
/// was danach kommt — Mediatheken laden, Phase setzen, merken — steht
/// zweimal genau gleich da, weil `MediaBackend` dazwischen liegt.
func adoptFlaro(client: FlaroClient, account: FlaroAccount) async {
phase = .connecting(serverName: account.serverName)
let backend = FlaroBackend(client: client, account: account)
clearActiveServer()
flaroAccount = account
flaroClient = client
activeKind = .flaro
self.backend = backend
// **Flaro fragt danach nicht.** Jellyfin meldet, ob die Verbindung
// im selben Netz liegt; Flaro hat dafür keinen Endpunkt und rechnet
// auch nichts daran fest. Im Zweifel vorsichtig: lieber einmal zu
// stark gedeckelt als eine Wiedergabe, die unterwegs nicht anläuft.
isLocalConnection = false
do {
libraries = try await loadLibraries(from: backend)
} catch {
clearActiveServer()
phase = .failed(message: ErrorText.describe(error))
return
}
rememberFlaro(account: account, token: nil)
phase = .ready
}
/// Dasselbe mit frischem Token — nach einer Anmeldung mit Passwort.
func adoptFlaro(client: FlaroClient, account: FlaroAccount, token: String) async {
await adoptFlaro(client: client, account: account)
guard phase == .ready else { return }
rememberFlaro(account: account, token: token)
}
/// Nimmt eine gespeicherte Flaro-Anmeldung wieder auf.
private func restoreFlaro() async -> Bool {
guard let address = defaults.string(forKey: Key.flaroAddress),
let baseURL = URL(string: address),
let token = tokenStore.token(for: Self.flaroTokenAccount)
else { return false }
phase = .connecting(serverName: defaults.string(forKey: Key.flaroServerName) ?? "")
do {
let (account, client) = try await FlaroSignIn.restore(
baseURL: baseURL, token: token, identity: flaroIdentity)
await adoptFlaro(client: client, account: account)
return true
} catch MediaBackendError.unauthorized {
// Am Server zurückgezogen. Ihn zu behalten erzeugte bei jedem
// Start denselben Fehler.
forgetFlaro()
clearActiveServer()
return false
} catch {
// Nur gerade nicht erreichbar — auf dem Weg zur Arbeit ist jeder
// Server das, und es ist kein Grund, jemanden abzumelden.
//
// **Trotzdem mit Server:** sonst stünde beim Erneut-Versuchen
// kein Konto da, und das Heruntergeladene wäre unerreichbar.
phase = .failed(message: ErrorText.describe(error))
activeKind = .flaro
return true
}
}
private func rememberFlaro(account: FlaroAccount, token: String?) {
defaults.set(ServerKind.flaro.rawValue, forKey: Key.activeKind)
defaults.set(account.baseURL.absoluteString, forKey: Key.flaroAddress)
defaults.set(account.userID, forKey: Key.flaroUserID)
defaults.set(account.userName, forKey: Key.flaroUserName)
defaults.set(account.serverName, forKey: Key.flaroServerName)
defaults.set(account.isAdministrator, forKey: Key.flaroIsAdministrator)
if let token { try? tokenStore.store(token, for: Self.flaroTokenAccount) }
}
private func forgetFlaro() {
for key in [Key.flaroAddress, Key.flaroUserID, Key.flaroUserName,
Key.flaroServerName, Key.flaroIsAdministrator] {
defaults.removeObject(forKey: key)
}
tokenStore.remove(for: Self.flaroTokenAccount)
}
private func rememberJellyfin(account: JellyfinAccount, serverName: String, baseURL: URL) {
defaults.set(ServerKind.jellyfin.rawValue, forKey: Key.activeKind)
defaults.set(baseURL.absoluteString, forKey: Key.jellyfinAddress)
defaults.set(account.userID, forKey: Key.jellyfinUserID)
defaults.set(account.userName, forKey: Key.jellyfinUserName)
defaults.set(serverName, forKey: Key.jellyfinServerName)
defaults.set(account.isAdministrator, forKey: Key.jellyfinIsAdministrator)
try? tokenStore.store(account.token, for: Self.jellyfinTokenAccount)
}
private func forgetJellyfin() {
for key in [Key.jellyfinAddress, Key.jellyfinUserID, Key.jellyfinUserName,
Key.jellyfinServerName, Key.jellyfinIsAdministrator] {
defaults.removeObject(forKey: key)
}
tokenStore.remove(for: Self.jellyfinTokenAccount)
}
// MARK: - Der aktive Server
/// Holt die Mediathekenliste und merkt sie sich.
///
/// **Der Speicher ist hier nur ein Rettungsanker**, kein Beschleuniger:
/// die Liste ist eine einzige kleine Anfrage, und sie sofort zu zeigen
/// hiesse, eine Sitzung für fertig zu erklären, bevor feststeht, dass der
/// Token noch gilt. Antwortet der Server dagegen **nicht**, ist eine
/// Seitenleiste von gestern besser als eine leere — im Zug ist das der
/// Unterschied zwischen „das Heruntergeladene ansehen" und „gar nichts".
private func loadLibraries(from backend: any MediaBackend) async throws -> [MediaLibrary] {
let kennung = backend.identity
do {
let geladen = try await backend.libraries().filter(\.isSupported)
await LibraryCache.shared.storeLibraries(geladen, server: kennung)
return geladen
} catch {
guard let gemerkt = await LibraryCache.shared.libraries(server: kennung),
!gemerkt.isEmpty
else { throw error }
return gemerkt
}
}
private func clearActiveServer() {
backend = nil
jellyfinAccount = nil
flaroAccount = nil
flaroClient = nil
activeKind = nil
libraries = []
}
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
func retry() async {
switch defaults.string(forKey: Key.activeKind).flatMap(ServerKind.init(rawValue:)) {
case .flaro: if await restoreFlaro() { return }
case .jellyfin, nil: if await restoreJellyfin() { return }
}
phase = .signedOut
}
/// Die Kennung für Jellyfin-Anmeldungen.
///
/// Der Anmeldebildschirm baut sich seinen eigenen Client, bevor es eine
/// Sitzung gibt — er braucht dieselbe Gerätekennung, sonst steht die App
/// nach dem Anmelden ein zweites Mal in der Geräteliste des Servers.
var jellyfinDeviceIdentity: JellyfinIdentity { jellyfinIdentity }
/// Dasselbe für Flaro.
var flaroDeviceIdentity: FlaroIdentity { flaroIdentity }
/// Der Client des aktiven Flaro-Servers — zum Abmelden.
///
/// Nicht `@Observable`-sichtbar: keine Ansicht liest ihn, und eine
/// Änderung daran soll kein Neuzeichnen auslösen.
@ObservationIgnored private var flaroClient: FlaroClient?
}