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:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
+403
View File
@@ -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 }
}