Flixy heißt ab hier Halo
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,403 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user