Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
404 lines
17 KiB
Swift
404 lines
17 KiB
Swift
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 HaloSession {
|
|
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 = "halo.jellyfin.address"
|
|
static let jellyfinUserID = "halo.jellyfin.userID"
|
|
static let jellyfinUserName = "halo.jellyfin.userName"
|
|
static let jellyfinServerName = "halo.jellyfin.serverName"
|
|
static let jellyfinIsAdministrator = "halo.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 = "halo.migration.plexPurged"
|
|
static let tokenKonten = ["plex.account", "plex.server"]
|
|
static let schlüssel = [
|
|
"halo.backend.kind",
|
|
"halo.server.clientIdentifier",
|
|
"halo.server.connectionURI",
|
|
"halo.server.name",
|
|
"halo.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 Halo 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:
|
|
/// `-HaloStubServer 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: "HaloStubServer"),
|
|
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 }
|
|
}
|