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>
This commit is contained in:
Scarriffle
2026-10-03 22:00:02 +02:00
co-authored by Claude Opus 5
parent 6d5824b889
commit 172b38da06
28 changed files with 3203 additions and 153 deletions
@@ -0,0 +1,195 @@
import Foundation
import MediaKit
/// Die Anmeldung an einem Flaro-Server.
///
/// Getrennt vom Backend, weil sie vorher stattfindet: hier gibt es noch
/// keinen Zugang und nichts, was `MediaBackend` versprechen könnte.
public enum FlaroSignIn {
/// Was der Server über sich sagt, bevor sich jemand anmeldet.
public struct Greeting: Sendable, Equatable {
public let serverName: String
public let version: String?
/// Ob der Server überhaupt schon eingerichtet ist.
///
/// **Ein eigener Fall, keine Fehlermeldung.** Wer einen frischen
/// Server anspricht, hat kein Konto — und „Benutzername oder
/// Passwort stimmt nicht" wäre dann die unbrauchbarste aller
/// Antworten.
public let needsSetup: Bool
/// Der Pflichthinweis auf die Metadatenquelle.
public let attributions: [MetadataAttribution]
}
public struct MetadataAttribution: Sendable, Equatable {
public let text: String
public let logoURL: URL?
public let url: URL?
}
/// Fragt einen Server, wer er ist — ohne Anmeldung.
///
/// Das ist zugleich die Prüfung, ob unter der eingegebenen Adresse
/// überhaupt ein Flaro steht. Ein Jellyfin antwortet hier mit 404, ein
/// Router mit seiner Anmeldeseite, und beides fällt beim Lesen auf.
public static func greet(
baseURL: URL,
identity: FlaroIdentity,
transport: any FlaroTransport = URLSession.shared
) async throws(MediaBackendError) -> Greeting {
let client = FlaroClient(
baseURL: baseURL, identity: identity, transport: transport, cookieJar: nil)
let auskunft = try await client.send(
FlaroRequest(path: "/api/v1/system/info"), as: Wire.SystemInfo.self)
return Greeting(
serverName: auskunft.serverName ?? baseURL.host() ?? "Flaro",
version: auskunft.version,
needsSetup: auskunft.needsSetup,
attributions: (auskunft.attributions ?? []).map {
MetadataAttribution(
text: $0.text, logoURL: URL(string: $0.logoUrl), url: URL(string: $0.url))
})
}
/// Meldet an und liefert den Zugang samt fertigem Backend.
public static func signIn(
baseURL: URL,
userName: String,
password: String,
identity: FlaroIdentity,
transport: any FlaroTransport = URLSession.shared,
cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared
) async throws(MediaBackendError) -> (token: String, account: FlaroAccount, client: FlaroClient) {
let gruss = try await greet(baseURL: baseURL, identity: identity, transport: transport)
let client = FlaroClient(
baseURL: baseURL, identity: identity, transport: transport, cookieJar: cookieJar)
let antwort = try await client.send(
.json(.post, "/api/v1/auth/login", Wire.LoginRequest(
userName: userName,
password: password,
deviceName: identity.deviceName,
deviceId: identity.deviceID)),
as: Wire.LoginResponse.self)
// **Erst jetzt das Token setzen.** Vorher hätte die Anmeldung ihre
// eigene Kopfzeile mitgeschickt — harmlos, aber auch das Cookie wäre
// dann schon gesetzt, bevor feststeht, dass es gilt.
await client.setToken(antwort.token)
return (antwort.token, FlaroAccount(
baseURL: baseURL,
serverName: gruss.serverName,
userID: antwort.user.id,
userName: antwort.user.userName,
isAdministrator: antwort.user.isAdministrator), client)
}
/// Nimmt ein gespeichertes Token wieder auf.
///
/// **Ohne Passwort und ohne neue Anmeldung.** Der Server führt das Gerät
/// über das Token; ein zweiter Anmeldevorgang legte ein zweites Gerät an,
/// und in der Liste stünde dann bei jedem Start eines mehr.
public static func restore(
baseURL: URL,
token: String,
identity: FlaroIdentity,
transport: any FlaroTransport = URLSession.shared,
cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared
) async throws(MediaBackendError) -> (account: FlaroAccount, client: FlaroClient) {
let client = FlaroClient(
baseURL: baseURL, identity: identity, token: token,
transport: transport, cookieJar: cookieJar)
let wer = try await client.send(FlaroRequest(path: "/api/v1/auth/me"), as: Wire.User.self)
let gruss = try? await greet(baseURL: baseURL, identity: identity, transport: transport)
return (FlaroAccount(
baseURL: baseURL,
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
userID: wer.id,
userName: wer.userName,
isAdministrator: wer.isAdministrator), client)
}
// MARK: - Der Weg über einen Code
/// Ein angeforderter Code samt Abholschein.
public struct PendingCode: Sendable, Equatable {
/// Die sechs Ziffern, die auf dem Fernseher stehen.
public let code: String
/// **Der Abholschein, und er ist das eigentliche Geheimnis.**
///
/// Abgeholt wird das Token hiermit, nicht mit dem Code. Täte es der
/// Code allein, liessen sich eine Million Möglichkeiten durchprobieren
/// — und die sechs Ziffern stehen zum Mitlesen auf einem Bildschirm
/// im Wohnzimmer.
public let handle: String
public let expiresAt: Date
}
public enum CodeState: Sendable, Equatable {
case pending
case approved(token: String, account: FlaroAccount)
case expired
}
/// Fordert einen Code an. Der Fernseher zeigt ihn, das Telefon bestätigt.
public static func requestCode(
baseURL: URL,
identity: FlaroIdentity,
transport: any FlaroTransport = URLSession.shared
) async throws(MediaBackendError) -> PendingCode {
let client = FlaroClient(
baseURL: baseURL, identity: identity, transport: transport, cookieJar: nil)
let antwort = try await client.send(
.json(.post, "/api/v1/auth/code", Wire.CodeRequest(
deviceName: identity.deviceName, deviceId: identity.deviceID)),
as: Wire.CodeResponse.self)
return PendingCode(
code: antwort.code, handle: antwort.handle, expiresAt: antwort.expiresAt)
}
/// Fragt nach, ob jemand den Code inzwischen bestätigt hat.
public static func pollCode(
baseURL: URL,
pending: PendingCode,
identity: FlaroIdentity,
transport: any FlaroTransport = URLSession.shared,
cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared
) async throws(MediaBackendError) -> (state: CodeState, client: FlaroClient?) {
let client = FlaroClient(
baseURL: baseURL, identity: identity, transport: transport, cookieJar: cookieJar)
// **Mit dem Abholschein, nicht mit dem Code.** Siehe `PendingCode`.
let zustand = try await client.send(
FlaroRequest(path: "/api/v1/auth/code/\(pending.handle)"), as: Wire.CodeState.self)
switch zustand.state {
case "approved":
guard let anmeldung = zustand.login else { return (.expired, nil) }
await client.setToken(anmeldung.token)
let gruss = try? await greet(
baseURL: baseURL, identity: identity, transport: transport)
return (.approved(token: anmeldung.token, account: FlaroAccount(
baseURL: baseURL,
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
userID: anmeldung.user.id,
userName: anmeldung.user.userName,
isAdministrator: anmeldung.user.isAdministrator)), client)
case "expired":
return (.expired, nil)
default:
return (.pending, nil)
}
}
/// Meldet dieses Gerät ab und widerruft sein Token.
public static func signOut(client: FlaroClient) async {
_ = try? await client.send(FlaroRequest(method: .post, path: "/api/v1/auth/logout"))
await client.setToken(nil)
}
}