import Foundation 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 FlixySession { 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. } private enum Key { static let jellyfinAddress = "flixy.jellyfin.address" static let jellyfinUserID = "flixy.jellyfin.userID" static let jellyfinUserName = "flixy.jellyfin.userName" static let jellyfinServerName = "flixy.jellyfin.serverName" static let jellyfinIsAdministrator = "flixy.jellyfin.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" /// 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 = "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. 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? /// Wer angemeldet ist — sofern der Server einen Namen dafür kennt. var accountName: String? { jellyfinAccount?.userName } /// Die Mediatheken, die Flixy 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? { 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. /// /// 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 { backend != nil ? String(localized: "player.jellyfinPending") : String(localized: "error.serverUnreachable") } private let jellyfinIdentity: JellyfinIdentity private let tokenStore: any TokenStoring private let defaults: UserDefaults init( jellyfinIdentity: JellyfinIdentity, tokenStore: any TokenStoring, defaults: UserDefaults = .standard ) { self.jellyfinIdentity = jellyfinIdentity 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 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() { forgetJellyfin() 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: /// `-FlixyStubServer 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: "FlixyStubServer"), 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 self.backend = backend isLocalConnection = await backend.isInLocalNetwork() do { libraries = try await loadLibraries(from: backend) } catch { self.backend = nil jellyfinAccount = nil 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 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 } } private func rememberJellyfin(account: JellyfinAccount, serverName: String, baseURL: URL) { 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 libraries = [] } /// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren. func retry() async { 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 } }