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
+2
View File
@@ -6,6 +6,7 @@ import UIKit
import FlaroDesign
import FlaroDownloads
import FlaroPlayback
import FlaroServerKit
import JellyfinKit
import MediaKit
@@ -45,6 +46,7 @@ struct FlaroApp: App {
client: Branding.appName,
version: Branding.version
),
flaroIdentity: FlaroIdentity.current(),
tokenStore: KeychainTokenStore(service: Branding.keychainService)
))
+374 -51
View File
@@ -108,6 +108,23 @@
}
}
},
"action.back": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Zurück"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Back"
}
}
}
},
"action.cancel": {
"extractionState": "stale",
"localizations": {
@@ -3854,23 +3871,6 @@
}
}
},
"player.jellyfinPending": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Die Wiedergabe von Jellyfin ist noch nicht fertig — sie ist der nächste Schritt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Playback from Jellyfin isn't finished yet — it's the next step."
}
}
}
},
"player.loadFailed": {
"extractionState": "stale",
"localizations": {
@@ -6254,6 +6254,312 @@
}
}
},
"signIn.flaro.addressHint": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Schema und Port darfst du weglassen — Flaro probiert die üblichen durch."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "You can leave out the scheme and port — Flaro tries the usual ones."
}
}
}
},
"signIn.flaro.addressMissing": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Trag eine Adresse ein."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Enter an address."
}
}
}
},
"signIn.flaro.addressPlaceholder": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "z. B. 192.168.1.50 oder medien.example.com"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "e.g. 192.168.1.50 or media.example.com"
}
}
}
},
"signIn.flaro.addressPrompt": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Wo steht dein Server?"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Where is your server?"
}
}
}
},
"signIn.flaro.changeAddress": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Andere Adresse"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Change address"
}
}
}
},
"signIn.flaro.codeExpired": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Code ist abgelaufen. Fordere einen neuen an."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "The code has expired. Request a new one."
}
}
}
},
"signIn.flaro.codeHint": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Öffne Flaro auf deinem Telefon und gib diesen Code ein."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Open Flaro on your phone and enter this code."
}
}
}
},
"signIn.flaro.credentialsMissing": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Benutzername und Passwort fehlen."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Username and password are missing."
}
}
}
},
"signIn.flaro.findServer": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Server suchen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Find server"
}
}
}
},
"signIn.flaro.needsSetup": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Dieser Server ist noch nicht eingerichtet. Öffne ihn im Browser und lege ein Konto an."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "This server isn't set up yet. Open it in a browser and create an account."
}
}
}
},
"signIn.flaro.notFound": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Unter dieser Adresse antwortet kein Flaro-Server."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "No Flaro server answers at this address."
}
}
}
},
"signIn.flaro.searching": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Server wird gesucht …"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Looking for the server…"
}
}
}
},
"signIn.flaro.signIn": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Anmelden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Sign in"
}
}
}
},
"signIn.flaro.signingIn": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Wird angemeldet …"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Signing in…"
}
}
}
},
"signIn.flaro.title": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Flaro-Server"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Flaro Server"
}
}
}
},
"signIn.flaro.useCode": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Stattdessen Code"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Use a code instead"
}
}
}
},
"signIn.flaro.usePassword": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Stattdessen Passwort"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Use a password instead"
}
}
}
},
"signIn.flaro.waitingForApproval": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Warte auf Bestätigung"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Waiting for approval"
}
}
}
},
"signIn.jellyfin.addressHint": {
"localizations": {
"de": {
@@ -6403,23 +6709,6 @@
}
}
},
"signIn.jellyfin.password": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Passwort"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Password"
}
}
}
},
"signIn.jellyfin.passwordNote": {
"localizations": {
"de": {
@@ -6568,23 +6857,6 @@
}
}
},
"signIn.jellyfin.userName": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Benutzername"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Username"
}
}
}
},
"signIn.jellyfin.waitingForApproval": {
"localizations": {
"de": {
@@ -6601,6 +6873,23 @@
}
}
},
"signIn.password": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Passwort"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Password"
}
}
}
},
"signIn.privacy": {
"localizations": {
"de": {
@@ -6634,6 +6923,40 @@
}
}
},
"signIn.userName": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Benutzername"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Username"
}
}
}
},
"signIn.withFlaro": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Mit Flaro verbinden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Connect to Flaro"
}
}
}
},
"signIn.withJellyfin": {
"localizations": {
"de": {
+19 -1
View File
@@ -122,7 +122,8 @@ struct DownloadService {
// hinterher niemanden mehr, der das nachholen könnte — und seit es
// keine zweite Engine mehr gibt, wäre die Datei schlicht stumm und
// schwarz. Also wird schon beim Herunterladen umgepackt.
let umpacken = quality == .original && !systemPlayerOpens(media: media, part: part)
let umpacken = quality == .original && !kannAlleSpuren
&& !systemPlayerOpens(media: media, part: part)
let url: URL?
switch quality {
@@ -200,6 +201,17 @@ struct DownloadService {
)
}
/// Ob der Server beim Herunterladen alle Spuren mitgibt.
///
/// **Der Unterschied, um dessentwillen Flaro gebaut wurde.** Flaros
/// Download liefert eine MP4 mit jeder Ton- und Textspur, auch wenn dafür
/// umgepackt werden muss. Dann entfällt die Spurwahl, und das Umpacken
/// mit genau einer Spur entfällt gleich mit — es gäbe nichts zu
/// verbessern, nur etwas zu verlieren.
private var kannAlleSpuren: Bool {
backend?.capabilities.contains(.allTracksDownload) == true
}
/// Ob beim Herunterladen eine Tonspur verlorengeht — und deshalb gefragt
/// werden muss.
///
@@ -211,6 +223,12 @@ struct DownloadService {
/// falsche Weg.
func needsAudioChoice(for item: MediaItem, mediaIndex: Int = 0, quality: DownloadQuality) -> Bool {
guard quality == .original else { return false }
// **Kann der Server alles mitgeben, gibt es nichts zu entscheiden.**
// Die Frage ist nur deshalb da, weil Jellyfins Transcoder eine
// Tonspur je Sitzung abbildet. Wo das nicht gilt, wäre sie eine
// Entscheidung, die der Nutzer treffen soll, obwohl beide Antworten
// zum selben Ergebnis führen.
guard !kannAlleSpuren else { return false }
guard let media = mediaIndex < item.versions.count
? item.versions[mediaIndex] : item.preferredVersion,
let part = media.files.first
+180 -17
View File
@@ -1,4 +1,5 @@
import Foundation
import FlaroServerKit
import JellyfinKit
import MediaKit
import Observation
@@ -31,12 +32,41 @@ final class FlaroSession {
// stillschweigend als „ungleich" behandelt.
}
/// Mit welcher Art Server gesprochen wird.
///
/// **Diese Unterscheidung gab es schon einmal** — damals für Plex und
/// Jellyfin — und sie 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.** Zwei gleichzeitig hiessen: zwei
/// Startseiten, zwei Mediatheken, zwei Suchergebnisse — und bei jedem
/// Titel die Frage, woher er stammt.
enum ServerKind: String, CaseIterable, Sendable, Identifiable {
case jellyfin, flaro
var id: String { rawValue }
}
private enum Key {
/// Welche Art zuletzt angemeldet war.
///
/// Nicht `flaro.backend.kind`: so hiess der Schlüssel zu Plex-Zeiten,
/// und der steht in der Aufräumliste weiter unten. Denselben Namen
/// mit anderer Bedeutung zu benutzen wäre eine Falle für den
/// Nächsten, der die Liste liest.
static let activeKind = "flaro.backend.active"
static let jellyfinAddress = "flaro.jellyfin.address"
static let jellyfinUserID = "flaro.jellyfin.userID"
static let jellyfinUserName = "flaro.jellyfin.userName"
static let jellyfinServerName = "flaro.jellyfin.serverName"
static let jellyfinIsAdministrator = "flaro.jellyfin.isAdministrator"
static let flaroAddress = "flaro.flaroServer.address"
static let flaroUserID = "flaro.flaroServer.userID"
static let flaroUserName = "flaro.flaroServer.userName"
static let flaroServerName = "flaro.flaroServer.serverName"
static let flaroIsAdministrator = "flaro.flaroServer.isAdministrator"
}
/// Unter welchem Namen das Server-Token im Schlüsselbund liegt.
@@ -45,6 +75,7 @@ final class FlaroSession {
/// 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"
private static let flaroTokenAccount = "flaro.server"
/// Was die Plex-Zeit auf dem Gerät hinterlassen hat.
///
@@ -90,8 +121,14 @@ final class FlaroSession {
/// Das angemeldete Jellyfin-Konto, solange Jellyfin aktiv ist.
private(set) var jellyfinAccount: JellyfinAccount?
/// Das angemeldete Flaro-Konto, solange Flaro aktiv ist.
private(set) var flaroAccount: FlaroAccount?
/// Mit welcher Art Server gerade gesprochen wird.
private(set) var activeKind: ServerKind?
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
var accountName: String? { jellyfinAccount?.userName }
var accountName: String? { jellyfinAccount?.userName ?? flaroAccount?.userName }
/// Die Mediatheken, die Flaro darstellen kann — Musik und Fotos
/// erscheinen gar nicht erst.
@@ -101,7 +138,12 @@ final class FlaroSession {
var serverName: String { backend?.serverName ?? "" }
/// Unter welcher Adresse er erreicht wird.
var serverAddress: String? { defaults.string(forKey: Key.jellyfinAddress) }
var serverAddress: String? {
switch activeKind {
case .flaro: defaults.string(forKey: Key.flaroAddress)
case .jellyfin, nil: defaults.string(forKey: Key.jellyfinAddress)
}
}
/// Wie die Verbindung zum Server zu beschreiben ist.
///
@@ -136,30 +178,28 @@ final class FlaroSession {
/// 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.
/// **Nur noch ein Fall, und das ist eine Bereinigung.** Hier stand eine
/// Fallunterscheidung aus der Plex-Zeit: war Jellyfin aktiv, hiess es
/// „die Wiedergabe von Jellyfin ist noch nicht fertig". Sie ist längst
/// fertig — und der Satz war ohnehin unerreichbar, denn der Player zeigt
/// diese Meldung ausschliesslich dann, wenn gar kein Server da ist.
var playbackUnavailableMessage: String {
backend != nil
? String(localized: "player.jellyfinPending")
: String(localized: "error.serverUnreachable")
String(localized: "error.serverUnreachable")
}
private let jellyfinIdentity: JellyfinIdentity
private let flaroIdentity: FlaroIdentity
private let tokenStore: any TokenStoring
private let defaults: UserDefaults
init(
jellyfinIdentity: JellyfinIdentity,
flaroIdentity: FlaroIdentity,
tokenStore: any TokenStoring,
defaults: UserDefaults = .standard
) {
self.jellyfinIdentity = jellyfinIdentity
self.flaroIdentity = flaroIdentity
self.tokenStore = tokenStore
self.defaults = defaults
}
@@ -174,7 +214,13 @@ final class FlaroSession {
if await adoptStubServerIfRequested() { return }
#endif
if await restoreJellyfin() { return }
// **Die gespeicherte Art zuerst.** Wer ohne sie startet, war vor
// dieser Fassung angemeldet — damals gab es nur Jellyfin, und genau
// das ist dann gemeint.
switch defaults.string(forKey: Key.activeKind).flatMap(ServerKind.init(rawValue:)) {
case .flaro: if await restoreFlaro() { return }
case .jellyfin, nil: if await restoreJellyfin() { return }
}
phase = .signedOut
}
@@ -212,7 +258,16 @@ final class FlaroSession {
/// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen": nach
/// dem Abmelden soll beim nächsten Start keine Mediathek mehr dastehen.
func signOut() {
// **Dem Flaro-Server sagen, dass dieses Gerät geht.** Jellyfin
// erfährt es über das Verfallen des Tokens; Flaro zieht es auf
// Zuruf zurück, und ein nicht zurückgezogenes Token stünde sonst
// bis zum Ablauf in der Geräteliste.
if let client = flaroClient {
Task { await FlaroSignIn.signOut(client: client) }
}
forgetJellyfin()
forgetFlaro()
defaults.removeObject(forKey: Key.activeKind)
clearActiveServer()
// Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Was
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
@@ -276,14 +331,14 @@ final class FlaroSession {
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
clearActiveServer()
jellyfinAccount = account
activeKind = .jellyfin
self.backend = backend
isLocalConnection = await backend.isInLocalNetwork()
do {
libraries = try await loadLibraries(from: backend)
} catch {
self.backend = nil
jellyfinAccount = nil
clearActiveServer()
phase = .failed(message: ErrorText.describe(error))
return
}
@@ -320,6 +375,7 @@ final class FlaroSession {
clearActiveServer()
jellyfinAccount = account
activeKind = .jellyfin
self.backend = backend
// Auch hier fragen: dasselbe Konto kann heute zuhause und morgen im
// Zug wiederhergestellt werden, und die Antwort gilt je Sitzung.
@@ -341,7 +397,99 @@ final class FlaroSession {
}
}
// MARK: - Verbinden mit Flaro
/// Übernimmt eine fertige Flaro-Anmeldung.
///
/// Dieselbe Form wie bei Jellyfin, und das ist der Beweis für die Naht:
/// was danach kommt — Mediatheken laden, Phase setzen, merken — steht
/// zweimal genau gleich da, weil `MediaBackend` dazwischen liegt.
func adoptFlaro(client: FlaroClient, account: FlaroAccount) async {
phase = .connecting(serverName: account.serverName)
let backend = FlaroBackend(client: client, account: account)
clearActiveServer()
flaroAccount = account
flaroClient = client
activeKind = .flaro
self.backend = backend
// **Flaro fragt danach nicht.** Jellyfin meldet, ob die Verbindung
// im selben Netz liegt; Flaro hat dafür keinen Endpunkt und rechnet
// auch nichts daran fest. Im Zweifel vorsichtig: lieber einmal zu
// stark gedeckelt als eine Wiedergabe, die unterwegs nicht anläuft.
isLocalConnection = false
do {
libraries = try await loadLibraries(from: backend)
} catch {
clearActiveServer()
phase = .failed(message: ErrorText.describe(error))
return
}
rememberFlaro(account: account, token: nil)
phase = .ready
}
/// Dasselbe mit frischem Token — nach einer Anmeldung mit Passwort.
func adoptFlaro(client: FlaroClient, account: FlaroAccount, token: String) async {
await adoptFlaro(client: client, account: account)
guard phase == .ready else { return }
rememberFlaro(account: account, token: token)
}
/// Nimmt eine gespeicherte Flaro-Anmeldung wieder auf.
private func restoreFlaro() async -> Bool {
guard let address = defaults.string(forKey: Key.flaroAddress),
let baseURL = URL(string: address),
let token = tokenStore.token(for: Self.flaroTokenAccount)
else { return false }
phase = .connecting(serverName: defaults.string(forKey: Key.flaroServerName) ?? "")
do {
let (account, client) = try await FlaroSignIn.restore(
baseURL: baseURL, token: token, identity: flaroIdentity)
await adoptFlaro(client: client, account: account)
return true
} catch MediaBackendError.unauthorized {
// Am Server zurückgezogen. Ihn zu behalten erzeugte bei jedem
// Start denselben Fehler.
forgetFlaro()
clearActiveServer()
return false
} catch {
// Nur gerade nicht erreichbar — auf dem Weg zur Arbeit ist jeder
// Server das, und es ist kein Grund, jemanden abzumelden.
//
// **Trotzdem mit Server:** sonst stünde beim Erneut-Versuchen
// kein Konto da, und das Heruntergeladene wäre unerreichbar.
phase = .failed(message: ErrorText.describe(error))
activeKind = .flaro
return true
}
}
private func rememberFlaro(account: FlaroAccount, token: String?) {
defaults.set(ServerKind.flaro.rawValue, forKey: Key.activeKind)
defaults.set(account.baseURL.absoluteString, forKey: Key.flaroAddress)
defaults.set(account.userID, forKey: Key.flaroUserID)
defaults.set(account.userName, forKey: Key.flaroUserName)
defaults.set(account.serverName, forKey: Key.flaroServerName)
defaults.set(account.isAdministrator, forKey: Key.flaroIsAdministrator)
if let token { try? tokenStore.store(token, for: Self.flaroTokenAccount) }
}
private func forgetFlaro() {
for key in [Key.flaroAddress, Key.flaroUserID, Key.flaroUserName,
Key.flaroServerName, Key.flaroIsAdministrator] {
defaults.removeObject(forKey: key)
}
tokenStore.remove(for: Self.flaroTokenAccount)
}
private func rememberJellyfin(account: JellyfinAccount, serverName: String, baseURL: URL) {
defaults.set(ServerKind.jellyfin.rawValue, forKey: Key.activeKind)
defaults.set(baseURL.absoluteString, forKey: Key.jellyfinAddress)
defaults.set(account.userID, forKey: Key.jellyfinUserID)
defaults.set(account.userName, forKey: Key.jellyfinUserName)
@@ -385,12 +533,18 @@ final class FlaroSession {
private func clearActiveServer() {
backend = nil
jellyfinAccount = nil
flaroAccount = nil
flaroClient = nil
activeKind = nil
libraries = []
}
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
func retry() async {
if await restoreJellyfin() { return }
switch defaults.string(forKey: Key.activeKind).flatMap(ServerKind.init(rawValue:)) {
case .flaro: if await restoreFlaro() { return }
case .jellyfin, nil: if await restoreJellyfin() { return }
}
phase = .signedOut
}
@@ -400,4 +554,13 @@ final class FlaroSession {
/// 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 }
/// Dasselbe für Flaro.
var flaroDeviceIdentity: FlaroIdentity { flaroIdentity }
/// Der Client des aktiven Flaro-Servers — zum Abmelden.
///
/// Nicht `@Observable`-sichtbar: keine Ansicht liest ihn, und eine
/// Änderung daran soll kein Neuzeichnen auslösen.
@ObservationIgnored private var flaroClient: FlaroClient?
}
+211
View File
@@ -0,0 +1,211 @@
import Foundation
import FlaroServerKit
import MediaKit
import Observation
/// Führt durch die Anmeldung an einem Flaro-Server.
///
/// Zwei Fragen am Telefon: wo steht der Server, und wer bist du.
///
/// **Am Fernseher andersherum.** Dort steht der sechsstellige Code vorn:
/// es gibt keinen Passwortspeicher, getippt wird mit dem Steuerkreuz, und
/// das Gerät, auf dem man bestätigt, liegt sowieso auf dem Sofa. Am Telefon
/// führt derselbe Weg nirgendwohin — der Code braucht ein zweites, bereits
/// angemeldetes Gerät, und das erste ist gerade dieses hier.
///
/// Welcher Fall gilt, weiss die Oberfläche; dieses Modell bekommt es im
/// Initialisierer gesagt.
@MainActor
@Observable
final class FlaroSignInModel {
enum Step: Equatable {
case address
case checking
/// Der Code steht auf dem Bildschirm, jetzt wird gewartet.
case code(String)
case credentials
case signingIn
}
enum PreferredMethod { case credentials, code }
var address = ""
var userName = ""
var password = ""
private(set) var step: Step = .address
private(set) var errorMessage: String?
/// Wie der gefundene Server heißt — die Bestätigung, dass die Adresse
/// stimmt, noch bevor jemand Anmeldedaten eintippt.
private(set) var serverName: String?
/// Der Pflichthinweis auf die Metadatenquelle, schon vor der Anmeldung.
private(set) var attributions: [FlaroSignIn.MetadataAttribution] = []
var onAuthenticated: ((FlaroClient, FlaroAccount, String) -> Void)?
private let identity: FlaroIdentity
private let preferred: PreferredMethod
private var baseURL: URL?
private var pollTask: Task<Void, Never>?
/// Der Vorgabewert hält iOS unverändert.
init(identity: FlaroIdentity, preferring: PreferredMethod = .credentials) {
self.identity = identity
self.preferred = preferring
}
// Kein `deinit`, der abbricht: `pollTask` gehört dem Hauptakteur, und
// ein `deinit` läuft nicht dort. Abgebrochen wird über `cancel()` beim
// Verschwinden der Ansicht — wie beim Jellyfin-Modell auch.
/// Hält die Abfrageschleife an.
///
/// Ohne das läuft sie nach dem Wegwischen des Sheets noch minutenlang
/// weiter — unsichtbar und für nichts.
func cancel() {
pollTask?.cancel()
pollTask = nil
}
// MARK: - Schritt 1: den Server finden
func findServer() async {
errorMessage = nil
let kandidaten = FlaroAddress.candidates(for: address)
guard !kandidaten.isEmpty else {
errorMessage = String(localized: "signIn.flaro.addressMissing")
return
}
step = .checking
for kandidat in kandidaten {
guard let gruss = try? await FlaroSignIn.greet(
baseURL: kandidat, identity: identity) else { continue }
// **Ein frischer Server ist kein falsches Passwort.** Wer hier
// weitermachte, bekäme „Benutzername oder Passwort stimmt nicht"
// für ein Konto, das es noch gar nicht gibt.
guard !gruss.needsSetup else {
baseURL = kandidat
serverName = gruss.serverName
step = .address
errorMessage = String(localized: "signIn.flaro.needsSetup")
return
}
baseURL = kandidat
address = kandidat.absoluteString
serverName = gruss.serverName
attributions = gruss.attributions
if preferred == .code {
await beginCode()
} else {
step = .credentials
}
return
}
step = .address
errorMessage = String(localized: "signIn.flaro.notFound")
}
/// Zurück zur Adresse — etwa, weil der gefundene Server der falsche ist.
func changeAddress() {
cancel()
baseURL = nil
serverName = nil
errorMessage = nil
step = .address
}
// MARK: - Schritt 2a: der Code
/// Fordert einen Code an und wartet auf die Bestätigung.
func beginCode() async {
guard let baseURL else { return }
cancel()
errorMessage = nil
let offen: FlaroSignIn.PendingCode
do {
offen = try await FlaroSignIn.requestCode(baseURL: baseURL, identity: identity)
} catch {
step = .credentials
errorMessage = ErrorText.describe(error)
return
}
step = .code(offen.code)
// **Nachfragen, nicht offen halten.** Eine Anfrage, die fünf Minuten
// auf eine Bestätigung wartet, läuft hinter jedem Reverse Proxy ins
// Zeitlimit.
pollTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(2))
if Task.isCancelled { return }
guard let self else { return }
guard await self.checkCode(baseURL: baseURL, pending: offen) else { return }
}
}
}
/// Eine Runde der Abfrage. `false` heisst: es gibt nichts mehr zu warten.
private func checkCode(baseURL: URL, pending: FlaroSignIn.PendingCode) async -> Bool {
guard let antwort = try? await FlaroSignIn.pollCode(
baseURL: baseURL, pending: pending, identity: identity)
else { return true } // Ein Aussetzer ist kein Abbruch.
switch antwort.state {
case .pending:
return true
case .expired:
errorMessage = String(localized: "signIn.flaro.codeExpired")
step = .credentials
return false
case .approved(let token, let konto):
guard let client = antwort.client else { return false }
onAuthenticated?(client, konto, token)
return false
}
}
/// Zurück zum Passwort — für Server, auf denen niemand bestätigen kann.
func useCredentials() {
cancel()
errorMessage = nil
step = .credentials
}
// MARK: - Schritt 2b: anmelden
func signIn() async {
guard let baseURL else { return }
cancel()
errorMessage = nil
let name = userName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, !password.isEmpty else {
errorMessage = String(localized: "signIn.flaro.credentialsMissing")
return
}
step = .signingIn
do {
let (token, konto, client) = try await FlaroSignIn.signIn(
baseURL: baseURL, userName: name, password: password, identity: identity)
// **Das Passwort sofort wegwerfen.** Es wird für die Dauer einer
// Anfrage gehalten und danach nicht mehr gebraucht; es stehen zu
// lassen hiesse, es in einem Speicherabbild wiederzufinden.
password = ""
onAuthenticated?(client, konto, token)
} catch {
password = ""
step = .credentials
errorMessage = ErrorText.describe(error)
}
}
}
+176
View File
@@ -0,0 +1,176 @@
import SwiftUI
import FlaroDesign
import FlaroServerKit
/// Die Anmeldung an einem Flaro-Server.
///
/// Zwei Schritte: wo steht er, und wer bist du. Mehr Fragen gibt es nicht —
/// und eine davon beantwortet der Server selbst, indem er seinen Namen nennt.
struct FlaroSignInView: View {
@Environment(FlaroSession.self) private var session
@Environment(\.dismiss) private var dismiss
@Environment(\.flaroAccent) private var accent
@State private var model: FlaroSignInModel?
@FocusState private var focusedField: Field?
private enum Field { case address, userName, password }
var body: some View {
NavigationStack {
ScrollView {
if let model {
content(for: model)
.padding(.horizontal, FlaroSpacing.section)
.padding(.vertical, FlaroSpacing.section)
.frame(maxWidth: 420)
.frame(maxWidth: .infinity)
}
}
.scrollDismissesKeyboard(.interactively)
.background(Color.flaroCanvas)
.navigationTitle(Text("signIn.flaro.title"))
.flaroInlineTitle()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("action.cancel") { dismiss() }
}
}
}
.tint(accent)
.task {
guard model == nil else { return }
let fresh = FlaroSignInModel(identity: session.flaroDeviceIdentity)
fresh.onAuthenticated = { client, konto, token in
dismiss()
Task { await session.adoptFlaro(client: client, account: konto, token: token) }
}
model = fresh
}
}
@ViewBuilder
private func content(for model: FlaroSignInModel) -> some View {
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
switch model.step {
case .address, .checking:
addressStep(model: model)
case .credentials, .signingIn:
credentialsStep(model: model)
case .code:
// **Am Telefon führt der Code nirgendwohin.** Er braucht ein
// zweites, bereits angemeldetes Gerät — und das erste ist
// gerade dieses hier. Das Modell bietet ihn deshalb nur an,
// wenn es mit `preferring: .code` gebaut wurde; am Fernseher
// ist das so, hier nicht. Der Fall steht trotzdem da, weil
// ein `default` die nächste neue Lage verschlucken würde.
EmptyView()
}
if let message = model.errorMessage {
Text(message)
.font(.flaroCaption)
.foregroundStyle(Color.flaroDestructive)
}
}
}
@ViewBuilder
private func addressStep(model: FlaroSignInModel) -> some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
Text("signIn.flaro.addressPrompt")
.font(.flaroRowHeader)
.foregroundStyle(Color.flaroTextPrimary)
TextField("signIn.flaro.addressPlaceholder", text: $model.address)
.textFieldStyle(.roundedBorder)
.textContentType(.URL)
#if os(iOS)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
.focused($focusedField, equals: .address)
.onSubmit { Task { await model.findServer() } }
Text("signIn.flaro.addressHint")
.font(.flaroCaption)
.foregroundStyle(Color.flaroTextMuted)
Button {
Task { await model.findServer() }
} label: {
if model.step == .checking {
ProgressView().frame(maxWidth: .infinity)
} else {
Text("signIn.flaro.findServer").frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.tint(accent)
.disabled(model.step == .checking)
}
.onAppear { focusedField = .address }
}
@ViewBuilder
private func credentialsStep(model: FlaroSignInModel) -> some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
if let name = model.serverName {
// Der Name des Servers steht hier, damit sichtbar ist,
// **wo** man sich gleich anmeldet — eine Adresse allein sagt
// das nicht.
HStack {
Text(name)
.font(.flaroRowHeader)
.foregroundStyle(Color.flaroTextPrimary)
Spacer()
Button("signIn.flaro.changeAddress") { model.changeAddress() }
.font(.flaroCaption)
}
}
TextField("signIn.userName", text: $model.userName)
.textFieldStyle(.roundedBorder)
.textContentType(.username)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
.focused($focusedField, equals: .userName)
.onSubmit { focusedField = .password }
SecureField("signIn.password", text: $model.password)
.textFieldStyle(.roundedBorder)
.textContentType(.password)
.focused($focusedField, equals: .password)
.onSubmit { Task { await model.signIn() } }
Button {
Task { await model.signIn() }
} label: {
if model.step == .signingIn {
ProgressView().frame(maxWidth: .infinity)
} else {
Text("signIn.flaro.signIn").frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.tint(accent)
.disabled(model.step == .signingIn)
// Der Pflichthinweis auf TMDB — Bedingung ihrer Lizenz, und der
// Server liefert ihn ohne Anmeldung genau dafür.
ForEach(model.attributions, id: \.text) { hinweis in
Text(hinweis.text)
.font(.flaroCaption)
.foregroundStyle(Color.flaroTextMuted)
}
}
.onAppear { focusedField = .userName }
}
}
+2 -2
View File
@@ -184,7 +184,7 @@ struct JellyfinSignInView: View {
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
foundServerHeader(model: model)
TextField("signIn.jellyfin.userName", text: $model.userName)
TextField("signIn.userName", text: $model.userName)
.textContentType(.username)
.flaroPlainEntry()
.submitLabel(.next)
@@ -203,7 +203,7 @@ struct JellyfinSignInView: View {
.padding(FlaroSpacing.regular)
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
SecureField("signIn.jellyfin.password", text: $model.password)
SecureField("signIn.password", text: $model.password)
.textContentType(.password)
.submitLabel(.go)
.focused($focusedField, equals: .password)
+29 -9
View File
@@ -3,16 +3,23 @@ import FlaroDesign
/// Der Anmeldebildschirm.
///
/// **Ein Weg, seit es einen Server gibt.** Hier standen zwei Knöpfe
/// nebeneinander, und der obere war der auffälligere — er führte zu Plex.
/// Bleibt einer übrig, erbt er die Hervorhebung: ein Knopf, der aussieht wie
/// die zweite Wahl, lässt den Benutzer nach der ersten suchen.
/// **Wieder zwei Knöpfe, und diesmal zu zwei Servern.** Hier standen schon
/// einmal zwei — Plex oben, Jellyfin darunter —, und nach dem Entfernen von
/// Plex blieb einer übrig. Jetzt gibt es Flaro, und die Frage stellt sich
/// neu: welcher steht oben?
///
/// Jellyfin hat kein Verzeichnis, an dem die eigenen Server hängen. Deshalb
/// wird die Adresse eingetippt und, wann immer der Server es anbietet, ein
/// Code bestätigt statt eines Passworts — das führt `JellyfinSignInView`.
/// **Flaro.** Nicht aus Eitelkeit, sondern weil die App dort mehr kann: der
/// Download bringt alle Tonspuren mit, 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.
///
/// Beide Server haben kein Verzeichnis, an dem die eigenen Instanzen hängen.
/// Die Adresse wird deshalb eingetippt; den Rest führen die beiden
/// Anmeldeansichten.
struct SignInView: View {
@Environment(\.flaroAccent) private var accent
@State private var showsFlaroSignIn = false
@State private var showsJellyfinSignIn = false
var body: some View {
@@ -37,6 +44,17 @@ struct SignInView: View {
Spacer()
VStack(spacing: FlaroSpacing.regular) {
Button {
showsFlaroSignIn = true
} label: {
Text("signIn.withFlaro")
.font(.flaroRowHeader)
.frame(maxWidth: .infinity)
.padding(.vertical, FlaroSpacing.regular)
}
.buttonStyle(.borderedProminent)
.tint(accent)
Button {
showsJellyfinSignIn = true
} label: {
@@ -45,8 +63,7 @@ struct SignInView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, FlaroSpacing.regular)
}
.buttonStyle(.borderedProminent)
.tint(accent)
.buttonStyle(.bordered)
Text("signIn.privacy")
.font(.flaroCaption)
@@ -60,6 +77,9 @@ struct SignInView: View {
.frame(maxWidth: 420)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.flaroCanvas)
.sheet(isPresented: $showsFlaroSignIn) {
FlaroSignInView()
}
.sheet(isPresented: $showsJellyfinSignIn) {
JellyfinSignInView()
}
+2
View File
@@ -2,6 +2,7 @@ import SwiftUI
import FlaroDesign
import FlaroPlayback
import MediaKit
import FlaroServerKit
import JellyfinKit
/// Die App auf dem Fernseher.
@@ -45,6 +46,7 @@ struct FlaroTVApp: App {
client: Branding.appName,
version: Branding.version
),
flaroIdentity: FlaroIdentity.current(),
tokenStore: KeychainTokenStore(service: Branding.keychainService)
))
}
@@ -0,0 +1,130 @@
import SwiftUI
import FlaroDesign
import FlaroServerKit
/// Die Anmeldung an einem Flaro-Server, am Fernseher.
///
/// Dasselbe Modell wie am Telefon, nur mit `preferring: .code`: sechs Ziffern
/// auf dem Bildschirm, bestätigt am Telefon. Das Passwort bleibt der Rückfall
/// — für den Fall, dass gerade kein angemeldetes Gerät zur Hand ist.
struct TVFlaroSignInView: View {
@Environment(FlaroSession.self) private var session
let zurueck: () -> Void
@State private var model: FlaroSignInModel?
var body: some View {
Group {
if let model {
inhalt(for: model)
} else {
ProgressView()
}
}
.task {
guard model == nil else { return }
let frisch = FlaroSignInModel(
identity: session.flaroDeviceIdentity, preferring: .code)
frisch.onAuthenticated = { client, konto, token in
Task { await session.adoptFlaro(client: client, account: konto, token: token) }
}
model = frisch
}
.onDisappear { model?.cancel() }
}
@ViewBuilder
private func inhalt(for model: FlaroSignInModel) -> some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
Text(Branding.appName).font(.flaroHero)
Text("signIn.flaro.title").font(.flaroBody).foregroundStyle(Color.flaroTextMuted)
switch model.step {
case .address:
adresse(model: model)
case .checking:
warten("signIn.flaro.searching")
case .code(let code):
codeAnzeigen(model: model, code: code)
case .credentials:
zugangsdaten(model: model)
case .signingIn:
warten("signIn.flaro.signingIn")
}
if let fehler = model.errorMessage {
Text(fehler).font(.flaroBody).foregroundStyle(Color.flaroDestructive)
}
}
.padding(FlaroSpacing.section)
}
private func adresse(model: FlaroSignInModel) -> some View {
@Bindable var model = model
return VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
Text("signIn.flaro.addressPrompt").font(.flaroRowHeader)
TextField("signIn.flaro.addressPlaceholder", text: $model.address)
.flaroPlainEntry(isAddress: true)
Text("signIn.flaro.addressHint")
.font(.flaroCaption).foregroundStyle(Color.flaroTextMuted)
HStack(spacing: FlaroSpacing.regular) {
Button("signIn.flaro.findServer") { Task { await model.findServer() } }
Button("action.back", action: zurueck)
}
}
}
private func codeAnzeigen(model: FlaroSignInModel, code: String) -> some View {
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
if let name = model.serverName {
Text(name).font(.flaroRowHeader)
}
// **Der Code ist hier der Inhalt, nicht eine Beschriftung.**
// Er wird aus drei Metern abgelesen und in ein Telefon getippt;
// in Fliesstextgrösse ist das eine Zumutung.
Text(code)
.font(.system(size: 96, weight: .bold, design: .monospaced))
.kerning(12)
.foregroundStyle(.white)
Text("signIn.flaro.codeHint").font(.flaroBody)
Label("signIn.flaro.waitingForApproval", systemImage: "hourglass")
.font(.flaroCaption).foregroundStyle(Color.flaroTextMuted)
HStack(spacing: FlaroSpacing.regular) {
Button("signIn.flaro.usePassword") { model.useCredentials() }
Button("signIn.flaro.changeAddress") { model.changeAddress() }
}
}
}
private func zugangsdaten(model: FlaroSignInModel) -> some View {
@Bindable var model = model
return VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
if let name = model.serverName {
Text(name).font(.flaroRowHeader)
}
TextField("signIn.userName", text: $model.userName)
.flaroPlainEntry()
SecureField("signIn.password", text: $model.password)
HStack(spacing: FlaroSpacing.regular) {
Button("signIn.flaro.signIn") { Task { await model.signIn() } }
Button("signIn.flaro.useCode") { Task { await model.beginCode() } }
Button("signIn.flaro.changeAddress") { model.changeAddress() }
}
}
}
private func warten(_ text: LocalizedStringKey) -> some View {
HStack(spacing: FlaroSpacing.regular) {
ProgressView()
Text(text).font(.flaroBody)
}
}
}
+42 -18
View File
@@ -1,40 +1,51 @@
import SwiftUI
import FlaroDesign
import FlaroServerKit
import JellyfinKit
/// Die Anmeldung am Fernseher.
///
/// **Dieselben vier Schritte wie am Telefon, andere Reihenfolge.** Das Modell
/// `JellyfinSignInModel` ist unverändert dasselbe — nur bekommt es hier
/// `preferring: .quickConnect`. Vor einem Fernseher ist der Code der bequemere
/// Weg: es gibt keinen Passwortspeicher, getippt wird mit dem Steuerkreuz, und
/// das Gerät, auf dem man den Code bestätigt, liegt sowieso auf dem Sofa.
/// **Erst die Frage, welcher Server.** Seit es zwei gibt, steht sie vorn —
/// und Flaro oben, weil die App dort mehr kann.
///
/// Der Weg über Benutzername und Passwort bleibt erreichbar — für Server, auf
/// denen Quick Connect abgeschaltet ist.
/// Danach dieselben Schritte wie am Telefon, nur andere Reihenfolge: beide
/// Modelle bekommen hier den Code-Weg voran. Vor einem Fernseher ist er der
/// bequemere: es gibt keinen Passwortspeicher, getippt wird mit dem
/// Steuerkreuz, und das Gerät, auf dem man bestätigt, liegt sowieso auf dem
/// Sofa. Benutzername und Passwort bleiben erreichbar.
struct TVSignInView: View {
@Environment(FlaroSession.self) private var session
@State private var art: FlaroSession.ServerKind?
@State private var model: JellyfinSignInModel?
var body: some View {
ZStack {
Color.flaroCanvas.ignoresSafeArea()
if let model {
inhalt(for: model)
// Eine Spalte, nicht die volle Breite: 1760 Punkte Zeile
// liest sich aus drei Metern nicht mehr als Zeile.
.frame(maxWidth: 900)
} else {
ProgressView()
Group {
switch art {
case nil:
auswahl()
case .flaro:
TVFlaroSignInView(zurueck: { art = nil })
case .jellyfin:
if let model {
inhalt(for: model)
} else {
ProgressView()
}
}
}
// Eine Spalte, nicht die volle Breite: 1760 Punkte Zeile
// liest sich aus drei Metern nicht mehr als Zeile.
.frame(maxWidth: 900)
}
.task {
.onChange(of: art) { _, neu in
guard neu == .jellyfin, model == nil else { return }
// Erst hier, nicht im Eigenschaftswert: das Modell braucht die
// Gerätekennung der Sitzung. Baut es sich eine eigene, steht die
// App nach dem Anmelden ein zweites Mal in der Geräteliste.
guard model == nil else { return }
let frisch = JellyfinSignInModel(
identity: session.jellyfinDeviceIdentity,
preferring: .quickConnect
@@ -51,6 +62,19 @@ struct TVSignInView: View {
.onDisappear { model?.cancel() }
}
private func auswahl() -> some View {
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
Text(Branding.appName).font(.flaroHero)
Text("signIn.tagline").font(.flaroBody).foregroundStyle(Color.flaroTextMuted)
VStack(spacing: FlaroSpacing.regular) {
Button("signIn.withFlaro") { art = .flaro }
Button("signIn.withJellyfin") { art = .jellyfin }
}
}
.padding(FlaroSpacing.section)
}
@ViewBuilder
private func inhalt(for model: JellyfinSignInModel) -> some View {
@Bindable var model = model
@@ -129,9 +153,9 @@ struct TVSignInView: View {
if let name = model.serverName {
Text(name).font(.flaroRowHeader)
}
TextField("signIn.jellyfin.userName", text: $model.userName)
TextField("signIn.userName", text: $model.userName)
.flaroPlainEntry()
SecureField("signIn.jellyfin.password", text: $model.password)
SecureField("signIn.password", text: $model.password)
Text("signIn.jellyfin.passwordNote")
.font(.flaroCaption).foregroundStyle(Color.flaroTextMuted)
+7
View File
@@ -14,6 +14,7 @@ let package = Package(
products: [
.library(name: "MediaKit", targets: ["MediaKit"]),
.library(name: "JellyfinKit", targets: ["JellyfinKit"]),
.library(name: "FlaroServerKit", targets: ["FlaroServerKit"]),
.library(name: "FlaroDesign", targets: ["FlaroDesign"]),
.library(name: "FlaroPlayback", targets: ["FlaroPlayback"]),
.library(name: "FlaroDownloads", targets: ["FlaroDownloads"]),
@@ -39,6 +40,12 @@ let package = Package(
resources: [.copy("Fixtures")]
),
// Flaros eigener Server. Daneben, nicht darin — genau der Steckplatz,
// den Plex geräumt hat. Dass die Naht zwei Server zugleich trägt, war
// schon einmal bewiesen; hier wird es wieder gebraucht.
.target(name: "FlaroServerKit", dependencies: ["MediaKit"], resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "FlaroServerKitTests", dependencies: ["FlaroServerKit"]),
// Ein Werkzeug für die Fragen, die reines HTTP sind — ohne Oberfläche,
// ohne Gerät, ohne Upload. Eine Runde dauert damit Sekunden statt
// eines halben Tages, und das Upload-Kontingent bleibt für Builds.
@@ -125,11 +125,24 @@ public final class AVPlayerEngine: VideoPlayerEngine {
pendingStartTime = source.startTime
// **Cookies muss man AVFoundation ausdrücklich mitgeben.**
//
// `URLSession` liest den gemeinsamen Speicher von allein, `AVURLAsset`
// nicht. Für Jellyfin ist das belanglos — dort steht der Zugang in
// der Adresse. Flaro schreibt ihn bewusst nicht dorthin: er stünde
// sonst in jedem Zugriffsprotokoll und in jedem weitergegebenen Link.
// Ohne diese Zeile bekäme der Spieler dort 401 und zeigte Schwarz.
//
// Serverneutral: wo kein Cookie liegt, ist die Liste leer und es
// ändert sich nichts.
let kekse = HTTPCookieStorage.shared.cookies(for: source.url) ?? []
let asset = AVURLAsset(url: source.url, options: [
// Keine genaue Dauer verlangen. Sonst durchsucht AVFoundation bei
// manchen Formaten die ganze Datei, bevor es überhaupt anfängt —
// und „ganze Datei" heißt bei einem Remux vierzig Gigabyte.
AVURLAssetPreferPreciseDurationAndTimingKey: false,
AVURLAssetHTTPCookiesKey: kekse,
])
let item = AVPlayerItem(asset: asset)
@@ -0,0 +1,19 @@
import Foundation
import MediaKit
/// Die Adressraterei für Flaro.
///
/// Der Inhalt steht in `ServerAddress`. Hier steht nur, was Flaro von
/// Jellyfin unterscheidet: der Port.
public enum FlaroAddress {
/// Der Standardport eines Flaro-Servers.
///
/// 8080, nicht 8096. Die Beispiel-Compose-Datei bildet ihn so ab, und
/// einen fremden Port zu erben hiesse, dass jede Anmeldung einmal ins
/// Leere läuft, bevor sie den richtigen findet.
public static let defaultPort = 8080
public static func candidates(for input: String) -> [URL] {
ServerAddress.candidates(for: input, defaultPort: defaultPort)
}
}
@@ -0,0 +1,417 @@
import Foundation
import MediaKit
/// Ein Flaro-Server, gesehen durch `MediaBackend`.
///
/// Der zweite Server an derselben Naht, an der vorher Plex und Jellyfin
/// nebeneinander hingen. Was hier steht, ist die Übersetzung von Flaros
/// Schnittstelle in den Vertrag — nichts weiter: keine Oberfläche, keine
/// Entscheidung über Wiedergabe, keine Zwischenspeicher.
public actor FlaroBackend: MediaBackend {
private let client: FlaroClient
private let account: FlaroAccount
/// Die Mediatheken, einmal geholt.
///
/// Sie ändern sich nur, wenn jemand in der Verwaltung eine anlegt — und
/// jede Seite der Mediathek bräuchte sie sonst erneut, nur um zu wissen,
/// ob sie nach Filmen oder nach Serien fragt.
private var libraryMemo: [MediaLibrary]?
public init(client: FlaroClient, account: FlaroAccount) {
self.client = client
self.account = account
}
// MARK: - Wer dieser Server ist
public nonisolated var serverName: String { account.serverName }
/// **Die Adresse, nicht der Name.** Zwei Server dürfen „Medien" heissen,
/// und derselbe darf umbenannt werden. Was hier steht, überlebt beides.
public nonisolated var identity: String { "flaro:" + account.baseURL.absoluteString }
public nonisolated var capabilities: Set<BackendCapability> {
[
.favourites,
.ratings,
.libraryScan,
.transcoding,
// **Der Grund, aus dem es diesen Server gibt.** Flaros Download
// bringt jede Ton- und Textspur mit; die App muss vorher nichts
// wählen und kann offline umstellen.
.allTracksDownload,
]
}
// MARK: - Blättern
public func libraries() async throws(MediaBackendError) -> [MediaLibrary] {
if let libraryMemo { return libraryMemo }
let roh = try await client.send(FlaroRequest(path: "/api/v1/libraries"), as: [Wire.Library].self)
let liste = roh.map { $0.neutral() }
libraryMemo = liste
return liste
}
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
// **Vier Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der eine
// fertige Startseite liefert, und das ist Absicht: welche Reihen eine
// App zeigt, entscheidet die App. Der Server liefert die Zutaten.
async let weiter = reihe(pfad: "/api/v1/home/continue")
async let naechste = reihe(pfad: "/api/v1/home/next")
async let filme = seite(pfad: "/api/v1/movies", take: 24, as: Wire.Movie.self)
async let serien = seite(pfad: "/api/v1/series", take: 24, as: Wire.Series.self)
var reihen: [MediaRowGroup] = []
if let eintraege = try? await weiter, !eintraege.isEmpty {
reihen.append(MediaRowGroup(
id: "flaro.continue",
title: String(localized: "flaro.row.continue", bundle: .module),
items: eintraege))
}
if let eintraege = try? await naechste, !eintraege.isEmpty {
reihen.append(MediaRowGroup(
id: "flaro.nextup",
title: String(localized: "flaro.row.nextUp", bundle: .module),
items: eintraege))
}
if let gefunden = try? await filme, !gefunden.items.isEmpty {
reihen.append(MediaRowGroup(
id: "flaro.movies",
title: String(localized: "flaro.row.movies", bundle: .module),
items: gefunden.items.map { $0.neutral() }))
}
if let gefunden = try? await serien, !gefunden.items.isEmpty {
reihen.append(MediaRowGroup(
id: "flaro.series",
title: String(localized: "flaro.row.series", bundle: .module),
items: gefunden.items.map { $0.neutral() }))
}
return reihen
}
public func items(
inLibrary library: MediaLibrary,
query: MediaQuery,
offset: Int,
limit: Int
) async throws(MediaBackendError) -> MediaPage {
var abfrage = [
URLQueryItem(name: "library", value: library.id),
URLQueryItem(name: "skip", value: String(offset)),
URLQueryItem(name: "take", value: String(limit)),
]
if let text = query.searchText, !text.isEmpty {
abfrage.append(URLQueryItem(name: "search", value: text))
}
if library.kind == .show {
let seite = try await client.send(
FlaroRequest(path: "/api/v1/series", query: abfrage),
as: Wire.Page<Wire.Series>.self)
return MediaPage(
items: gesiebt(seite.items.map { $0.neutral() }, query),
offset: offset, totalCount: seite.total)
}
let seite = try await client.send(
FlaroRequest(path: "/api/v1/movies", query: abfrage),
as: Wire.Page<Wire.Movie>.self)
return MediaPage(
items: gesiebt(seite.items.map { $0.neutral() }, query),
offset: offset, totalCount: seite.total)
}
public func item(id: String) async throws(MediaBackendError) -> MediaItem {
// **Erst Film, dann Serie, dann Folge.** Die App reicht eine Kennung
// durch und weiss nicht, was dahintersteckt — sie hat sie aus einer
// Liste. Drei Anfragen im schlechtesten Fall; der Normalfall ist eine.
if let film = try? await client.send(
FlaroRequest(path: "/api/v1/movies/\(id)"), as: Wire.Movie.self) {
return film.neutral()
}
if let serie = try? await client.send(
FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) {
return serie.neutral()
}
throw .notFound
}
public func children(of id: String) async throws(MediaBackendError) -> [MediaItem] {
try await episodes(inSeries: id)
}
/// Alle Folgen einer Serie — in einer Anfrage.
///
/// Der allgemeine Weg über Staffeln kostete bei zwölf Staffeln dreizehn
/// Anfragen. Flaro kann es in einer, also tut es das.
public func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem] {
let serie = try? await client.send(
FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self)
let folgen = try await client.send(
FlaroRequest(path: "/api/v1/series/\(id)/episodes"),
as: Wire.Page<Wire.Episode>.self)
return folgen.items.map { $0.neutral(series: serie) }
}
public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
guard item.kind == .episode else { return nil }
// 204 heisst: es war die letzte. Kein Fehler, nur kein Nachfolger.
let daten = try await client.send(FlaroRequest(path: "/api/v1/episodes/\(item.id)/next"))
guard !daten.isEmpty else { return nil }
guard let folge = try? JSONDecoder().decode(Wire.Episode.self, from: daten) else { return nil }
return folge.neutral()
}
public func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup] {
let abfrage = [
URLQueryItem(name: "search", value: text),
URLQueryItem(name: "take", value: String(limit)),
]
async let filme = client.send(
FlaroRequest(path: "/api/v1/movies", query: abfrage), as: Wire.Page<Wire.Movie>.self)
async let serien = client.send(
FlaroRequest(path: "/api/v1/series", query: abfrage), as: Wire.Page<Wire.Series>.self)
var reihen: [MediaRowGroup] = []
if let gefunden = try? await filme, !gefunden.items.isEmpty {
reihen.append(MediaRowGroup(
id: "flaro.search.movies",
title: String(localized: "flaro.row.movies", bundle: .module),
items: gefunden.items.map { $0.neutral() }))
}
if let gefunden = try? await serien, !gefunden.items.isEmpty {
reihen.append(MediaRowGroup(
id: "flaro.search.series",
title: String(localized: "flaro.row.series", bundle: .module),
items: gefunden.items.map { $0.neutral() }))
}
return reihen
}
/// Flaro kennt noch keine Schlagwörter.
///
/// Eine leere Liste, kein Fehler: die Oberfläche blendet den Filter dann
/// aus, statt einen Knopf zu zeigen, hinter dem nichts ist.
public func filterOptions(
inLibrary library: MediaLibrary,
field: MediaQuery.Field
) async throws(MediaBackendError) -> [MediaFilterOption] { [] }
public func libraryID(for item: MediaItem) async -> String? { nil }
// MARK: - Bilder
/// **Ohne Grössenangabe.** Flaro legt die Bilder so ab, wie TMDB sie
/// liefert, und rechnet nicht um. Ein Plakat wiegt achtzig Kilobyte; ein
/// Server, der jede Kachel einzeln skaliert, spart Leitung und kostet
/// Rechenzeit, die auf einem NAS knapper ist.
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
rawImageURL(path: path)
}
public nonisolated func rawImageURL(path: String?) -> URL? {
guard let path, !path.isEmpty else { return nil }
return client.url(path: "/api/v1/images/\(path)")
}
// MARK: - Herunterladen
/// **Eine Adresse für alles.** Flaro entscheidet selbst, ob die Datei
/// unverändert hinausgeht oder erst umgepackt wird — und packt dabei
/// *alle* Ton- und Textspuren mit ein.
public nonisolated func downloadURL(for file: MediaFile) -> URL? {
client.url(path: "/api/v1/files/\(file.id)/download")
}
/// **Dieselbe Adresse, und das ist der Punkt.**
///
/// Der Vertrag nimmt hier je eine Spurnummer entgegen, weil Jellyfins
/// Transcoder genau eine Tonspur je Sitzung abbildet. Flaro braucht die
/// Wahl nicht: was herauskommt, hat alles. Die Nummern werden deshalb
/// nicht ignoriert, sondern sind gegenstandslos — und die Oberfläche
/// fragt bei `.allTracksDownload` gar nicht erst danach.
public nonisolated func remuxDownloadURL(
itemID: String,
versionIndex: Int,
audioStreamIndex: Int?,
subtitleStreamIndex: Int?
) -> URL? { nil }
/// Noch nicht da: das Herunterrechnen auf eine Bitrate kommt mit Stufe 3.
public nonisolated func compressedDownloadURL(
itemID: String,
versionIndex: Int,
maximumBitrate: Int,
resolution: String?
) -> URL? { nil }
// MARK: - Abspielen
/// Wie lange auf ein Umpacken gewartet wird, bevor aufgegeben wird.
///
/// Grosszügig: ein Dreistünder in 4K braucht auf einem NAS Minuten, und
/// abzubrechen, während die Arbeit läuft, wäre die eine Antwort, die
/// niemandem hilft. Irgendwann muss es trotzdem eine geben — ein Spieler,
/// der unbegrenzt auf einen hängengebliebenen Prozess wartet, zeigt
/// Schwarz ohne Begründung.
private static let packWartezeit = Duration.seconds(600)
public func preparePlayback(
_ intent: PlaybackIntent
) async throws(MediaBackendError) -> PlaybackTarget {
guard let adresse = client.url(path: "/api/v1/files/\(intent.file.id)/download") else {
throw .transport(description: "Adresse nicht baubar")
}
var auskunft = try await client.send(
FlaroRequest(path: "/api/v1/files/\(intent.file.id)/delivery"),
as: Wire.Delivery.self)
if let fehler = auskunft.error { throw .refused(reason: fehler) }
// **Erst warten, dann die Adresse herausgeben.**
//
// 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, mit einem Spinner.
// Genau so sah es beim ersten Lauf im Simulator aus: „Stelle wird
// wiederhergestellt", 0:00, für immer.
//
// Das Anstossen besorgt der Server beim ersten Abruf selbst; hier
// wird nur nachgefragt, bis es so weit ist.
if auskunft.method != "directFile", auskunft.packageState != "ready" {
let bis = ContinuousClock.now + Self.packWartezeit
while ContinuousClock.now < bis {
// Ein HEAD stösst an und fragt zugleich nach. Die Datei dafür
// herunterzuladen wäre absurd.
_ = try? await client.send(FlaroRequest(
method: .head, path: "/api/v1/files/\(intent.file.id)/download"))
auskunft = try await client.send(
FlaroRequest(path: "/api/v1/files/\(intent.file.id)/delivery"),
as: Wire.Delivery.self)
if let fehler = auskunft.error { throw .refused(reason: fehler) }
if auskunft.packageState == "ready" { break }
try? await Task.sleep(for: .seconds(1))
}
guard auskunft.packageState == "ready" else {
throw .refused(reason: String(
localized: "flaro.remux.tooLong", bundle: .module))
}
}
// **Keine Sitzung.** Flaro legt für eine Wiedergabe nichts an, das
// hinterher aufzuräumen wäre: die Datei geht unverändert hinaus oder
// liegt umgepackt im Zwischenspeicher. Es gibt deshalb nichts, was
// weiterrechnet, wenn niemand mehr zusieht.
return PlaybackTarget(url: adresse, sessionID: nil, versionID: intent.file.id)
}
public func endPlayback(sessionID: String) async {}
public func reportProgress(_ report: PlaybackReport) async {
// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
// wäre die schlechtere Hälfte des Tauschs.
//
// Auch `.started` wird gemeldet. Flaro kennt dafür keinen eigenen
// Endpunkt, aber die Stelle ist dieselbe — und sie abzuschneiden
// hiesse, den ersten Bericht wegzuwerfen.
_ = try? await client.send(.json(.post, "/api/v1/items/\(report.itemID)/progress",
Wire.ProgressRequest(
positionSeconds: report.time.seconds,
runtimeSeconds: report.duration > .zero ? report.duration.seconds : nil)))
}
// MARK: - Zustand melden
public func markWatched(id: String) async throws(MediaBackendError) {
try await client.send(.json(.put, "/api/v1/items/\(id)/watched", Wire.WatchedRequest(watched: true)))
}
public func markUnwatched(id: String) async throws(MediaBackendError) {
try await client.send(.json(.put, "/api/v1/items/\(id)/watched", Wire.WatchedRequest(watched: false)))
}
/// Aus „Weiterschauen" nehmen, ohne als gesehen zu gelten.
///
/// **Flaro hat dafür einen eigenen Weg**, und nur deshalb bleibt die
/// Stelle erhalten. Bei Jellyfin wird hier das Benutzerdatenblatt
/// geschrieben, und darin steht auch „gesehen" — wer es nicht mitschickt,
/// löscht es.
public func removeFromContinueWatching(_ item: MediaItem) async throws(MediaBackendError) {
try await client.send(FlaroRequest(method: .post, path: "/api/v1/items/\(item.id)/dismiss"))
}
public func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError) {
try await client.send(.json(.put, "/api/v1/items/\(id)/favourite",
Wire.FavouriteRequest(favourite: isFavourite)))
}
public func rate(id: String, rating: Int) async throws(MediaBackendError) {
try await client.send(.json(.put, "/api/v1/items/\(id)/rating",
Wire.RatingRequest(rating: rating > 0 ? rating : nil)))
}
public func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError) {
try await client.send(FlaroRequest(method: .post, path: "/api/v1/libraries/\(library.id)/scan"))
}
// MARK: - Gemeinsames
private func seite<T: Decodable & Sendable>(
pfad: String, take: Int, as type: T.Type
) async throws(MediaBackendError) -> Wire.Page<T> {
try await client.send(
FlaroRequest(path: pfad, query: [URLQueryItem(name: "take", value: String(take))]),
as: Wire.Page<T>.self)
}
private func reihe(pfad: String) async throws(MediaBackendError) -> [MediaItem] {
let seite = try await client.send(FlaroRequest(path: pfad), as: Wire.Page<Wire.Started>.self)
return seite.items.map { $0.neutral() }
}
/// Was der Server noch nicht kann, siebt die App — aber nur das.
///
/// **Nicht alles.** Gefiltert wird hier über eine *Seite*, nicht über die
/// Mediathek: wer bei fünftausend Titeln „nur ungesehene" wählt, bekommt
/// die ungesehenen der ersten fünfzig. Das ist ein Zwischenstand, kein
/// Entwurf — sobald Flaro die Einschränkung selbst kann, fällt das hier
/// weg. Es jetzt wegzulassen hiesse, einen Schalter anzubieten, der
/// nichts tut.
private func gesiebt(_ titel: [MediaItem], _ query: MediaQuery) -> [MediaItem] {
var liste = titel
if query.unwatchedOnly { liste = liste.filter { !$0.isWatched } }
if query.favouritesOnly { liste = liste.filter(\.isFavourite) }
return liste
}
}
/// Mit wem die App spricht und als wer.
public struct FlaroAccount: Sendable, Equatable {
public let baseURL: URL
public let serverName: String
public let userID: String
public let userName: String
public let isAdministrator: Bool
public init(
baseURL: URL,
serverName: String,
userID: String,
userName: String,
isAdministrator: Bool
) {
self.baseURL = baseURL
self.serverName = serverName
self.userID = userID
self.userName = userName
self.isAdministrator = isAdministrator
}
}
@@ -0,0 +1,294 @@
import Foundation
import MediaKit
/// Wer wir für einen Flaro-Server sind.
///
/// Anders als bei Jellyfin steht davon nichts in einer Kopfzeile: Flaro nimmt
/// den Gerätenamen einmal bei der Anmeldung entgegen und führt das Gerät
/// danach über das Token. Die Kennung muss trotzdem über Starts hinweg
/// gleich bleiben — wechselt sie, meldet sich dasselbe Gerät als neues an,
/// und in der Geräteliste sammelt sich Müll.
public struct FlaroIdentity: Sendable, Equatable {
public let deviceName: String
public let deviceID: String
public init(deviceName: String, deviceID: String) {
self.deviceName = deviceName
self.deviceID = deviceID
}
}
/// Spricht mit einem Flaro-Server.
public actor FlaroClient {
public nonisolated let baseURL: URL
private let identity: FlaroIdentity
private let transport: any FlaroTransport
private let cookieJar: (any FlaroCookieJar)?
private var token: String?
public init(
baseURL: URL,
identity: FlaroIdentity,
token: String? = nil,
transport: any FlaroTransport = URLSession.shared,
cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared
) {
self.baseURL = baseURL
self.identity = identity
self.token = token
self.transport = transport
self.cookieJar = cookieJar
if let token { plant(token) }
}
public func setToken(_ token: String?) {
self.token = token
if let token { plant(token) } else { clearCookie() }
}
public nonisolated var deviceID: String { identity.deviceID }
public nonisolated var deviceName: String { identity.deviceName }
// MARK: - Das Cookie
/// Legt den Zugang als Cookie ab, damit auch fremde Lader ihn mitschicken.
///
/// **Warum überhaupt.** Flaro nimmt kein Token in der Adresse — es stünde
/// sonst in jedem Zugriffsprotokoll, in jedem Verlauf und in jedem
/// weitergegebenen Link. Jellyfin macht das anders, und `artworkURL`
/// heisst deshalb im Vertrag ausdrücklich „der Token steckt in der
/// Adresse selbst".
///
/// Bleibt der Weg, den der Browser längst geht: ein Cookie. Der
/// Bilderlader und die Hintergrundübertragung benutzen `URLSession` mit
/// der Standardkonfiguration und schicken es von sich aus mit; dem
/// Systemplayer wird es ausdrücklich mitgegeben (siehe
/// `playbackCookies`), weil AVFoundation den gemeinsamen Speicher nicht
/// von allein liest.
///
/// **Gesetzt, nicht abgewartet.** Der Server schickt es bei der Anmeldung
/// auch selbst — aber nach einem Neustart hat die App nur das Token aus
/// dem Schlüsselbund und meldet sich gar nicht erst neu an. Dann wäre
/// kein Cookie da, und jedes Plakat bliebe grau.
private nonisolated func plant(_ token: String) {
guard let cookie = Self.cookie(for: baseURL, token: token) else { return }
cookieJar?.store(cookie)
}
private nonisolated func clearCookie() {
guard let host = baseURL.host() else { return }
cookieJar?.removeCookie(named: Self.cookieName, host: host)
}
public static let cookieName = "flaro_session"
nonisolated static func cookie(for baseURL: URL, token: String) -> HTTPCookie? {
guard let host = baseURL.host() else { return nil }
return HTTPCookie(properties: [
.name: cookieName,
.value: token,
.domain: host,
.path: "/",
// Ein Jahr, wie der Server es auch setzt. Kürzer hiesse: das
// Plakat lädt nicht mehr, obwohl das Token noch gilt.
.expires: Date(timeIntervalSinceNow: 365 * 24 * 3600),
])
}
/// Die Cookies, die der Systemplayer für diese Adresse mitschicken soll.
public nonisolated func cookies(for url: URL, token: String?) -> [HTTPCookie] {
guard let token, let cookie = Self.cookie(for: url, token: token) else { return [] }
return [cookie]
}
// MARK: - Adressen
/// Die Adresse eines Endpunkts.
///
/// **Ohne Token.** Das ist der Unterschied zu Jellyfin und kein Versehen —
/// wer die Adresse abruft, bringt das Cookie mit.
public nonisolated func url(path: String, query: [URLQueryItem] = []) -> URL? {
guard var components = URLComponents(
url: baseURL.appending(path: path),
resolvingAgainstBaseURL: false
) else { return nil }
components.queryItems = query.isEmpty ? nil : query
return components.url
}
// MARK: - Anfragen
public func send<Value: Decodable & Sendable>(
_ request: FlaroRequest,
as type: Value.Type
) async throws(MediaBackendError) -> Value {
let data = try await send(request)
do {
return try Self.decoder.decode(Value.self, from: data)
} catch {
throw .decoding(description: String(describing: error))
}
}
@discardableResult
public func send(_ request: FlaroRequest) async throws(MediaBackendError) -> Data {
guard var components = URLComponents(
url: baseURL.appending(path: request.path),
resolvingAgainstBaseURL: false
) else {
throw .transport(description: "Adresse nicht baubar")
}
components.queryItems = request.query.isEmpty ? nil : request.query
guard let url = components.url else {
throw .transport(description: "Adresse nicht baubar")
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method.rawValue
urlRequest.timeoutInterval = request.timeout.seconds
// Die App schickt die Kopfzeile. Das Cookie ist der zweite Weg zum
// selben Token, für Lader, die keine Kopfzeilen können.
if let token { urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
if let body = request.body {
urlRequest.httpBody = body
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
let data: Data
let response: URLResponse
do {
(data, response) = try await transport.data(for: urlRequest)
} catch {
throw .transport(description: error.localizedDescription)
}
guard let http = response as? HTTPURLResponse else {
throw .transport(description: "Keine HTTP-Antwort")
}
switch http.statusCode {
case 200..<300: return data
case 401, 403: throw .unauthorized
case 404: throw .notFound
default:
// **Flaro begründet seine Absagen.** Ein Problem-Dokument nach
// RFC 9457 trägt einen Satz, den der Server selbst formuliert
// hat — und der ist mehr wert als die Zahl davor. Genau daran
// hing beim Live-TV tagelang alles, weil die Antwort weggeworfen
// wurde.
if let grund = Self.begruendung(in: data) { throw .refused(reason: grund) }
throw .server(status: http.statusCode)
}
}
/// Der Satz aus einem Problem-Dokument, falls einer dasteht.
private static func begruendung(in data: Data) -> String? {
struct Problem: Decodable { let detail: String?; let title: String? }
guard let problem = try? JSONDecoder().decode(Problem.self, from: data) else { return nil }
let text = problem.detail ?? problem.title
return text?.isEmpty == false ? text : nil
}
/// Flaro schreibt klein und rechnet in Sekunden.
///
/// Kein `convertFromSnakeCase`: die Felder heissen schon so, wie sie
/// heissen sollen. ASP.NET Core schreibt sie in `camelCase` hinaus, und
/// Swift liest sie genauso.
///
/// Nicht `private`, damit die Prüfungen denselben Dekoder benutzen. Mit
/// einem frisch gebauten `JSONDecoder()` scheitern sie — und zwar an
/// genau dem Datumsformat, für das die Strategie unten da ist.
static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601WithFraction
return decoder
}()
}
/// Eine Anfrage an Flaro.
public struct FlaroRequest: Sendable {
public enum Method: String, Sendable { case get = "GET", head = "HEAD", post = "POST", put = "PUT", delete = "DELETE" }
public let method: Method
public let path: String
public let query: [URLQueryItem]
public let body: Data?
public let timeout: Duration
public init(
method: Method = .get,
path: String,
query: [URLQueryItem] = [],
body: Data? = nil,
timeout: Duration = .seconds(30)
) {
self.method = method
self.path = path
self.query = query
self.body = body
self.timeout = timeout
}
/// Eine Anfrage mit einem Rumpf aus einem beliebigen Wert.
public static func json(
_ method: Method, _ path: String, _ value: some Encodable
) -> FlaroRequest {
FlaroRequest(method: method, path: path, body: try? JSONEncoder().encode(value))
}
}
/// Wie Anfragen tatsächlich verschickt werden.
///
/// Dieselbe Trennung wie bei Jellyfin: Tests brauchen keinen Server, sondern
/// vorbereitete Antworten.
public protocol FlaroTransport: Sendable {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: FlaroTransport {}
/// Wo Cookies landen.
///
/// Eigenes Protokoll, damit ein Test nachsehen kann, ob eines abgelegt wurde,
/// ohne den Cookie-Speicher des ganzen Systems anzufassen.
public protocol FlaroCookieJar: Sendable {
func store(_ cookie: HTTPCookie)
func removeCookie(named name: String, host: String)
}
extension HTTPCookieStorage: FlaroCookieJar {
public func store(_ cookie: HTTPCookie) { setCookie(cookie) }
public func removeCookie(named name: String, host: String) {
cookies?
.filter { $0.name == name && $0.domain.hasSuffix(host) }
.forEach(deleteCookie)
}
}
extension JSONDecoder.DateDecodingStrategy {
/// ASP.NET Core schreibt Bruchteile von Sekunden mit.
///
/// `.iso8601` kommt damit nicht zurecht und wirft — ein Datum, das
/// „2026-10-03T19:25:04.5209555+00:00" lautet, ist für den
/// Standarddekoder kein Datum. Der Fehler fällt erst dort auf, wo ein
/// Feld gelesen wird, das die meisten Antworten leer lassen.
static var iso8601WithFraction: Self {
.custom { decoder in
let text = try decoder.singleValueContainer().decode(String.self)
let mitBruch = ISO8601DateFormatter()
mitBruch.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let datum = mitBruch.date(from: text) { return datum }
let ohne = ISO8601DateFormatter()
ohne.formatOptions = [.withInternetDateTime]
if let datum = ohne.date(from: text) { return datum }
throw DecodingError.dataCorrupted(.init(
codingPath: decoder.codingPath,
debugDescription: "Kein ISO-8601-Datum: \(text)"))
}
}
}
@@ -0,0 +1,175 @@
import Foundation
/// Was Flaro über die Leitung schickt.
///
/// Ein Spiegel der `Contracts.cs` auf der Serverseite, und nur das: hier wird
/// nichts gerechnet und nichts gedeutet. Die Übersetzung in die neutralen
/// Typen steht in `FlaroBackend+Mapping`.
///
/// **Die Felder heissen wie dort.** Keine `CodingKeys`, keine Umbenennung:
/// ASP.NET Core schreibt `camelCase`, Swift liest `camelCase`, und jede
/// Abweichung wäre eine Stelle, an der beim nächsten Serverfeld jemand
/// nachziehen müsste.
enum Wire {
struct Page<T: Decodable & Sendable>: Decodable, Sendable {
let items: [T]
let total: Int
}
struct SystemInfo: Decodable, Sendable {
let version: String?
let serverName: String?
let needsSetup: Bool
let attributions: [Attribution]?
}
struct Attribution: Decodable, Sendable {
let text: String
let logoUrl: String
let url: String
}
struct LoginResponse: Decodable, Sendable {
let token: String
let user: User
}
struct User: Decodable, Sendable {
let id: String
let userName: String
let isAdministrator: Bool
}
struct Library: Decodable, Sendable {
let id: String
let name: String
let kind: String
let path: String
}
struct Stream: Decodable, Sendable {
let index: Int
let kind: String
let codec: String?
let language: String?
let title: String?
let isDefault: Bool
let isForced: Bool
let channels: Int?
let width: Int?
let height: Int?
}
struct File: Decodable, Sendable {
let id: String
let fileName: String
let sizeBytes: Int64
let container: String?
let durationSeconds: Double?
let streams: [Stream]
}
struct ItemState: Decodable, Sendable {
let positionSeconds: Double
let runtimeSeconds: Double?
let watched: Bool
let playCount: Int
let favourite: Bool
let rating: Int?
let dismissed: Bool
let lastPlayedAt: Date?
}
struct Movie: Decodable, Sendable {
let id: String
let title: String
let year: Int?
let overview: String?
let posterImage: String?
let backdropImage: String?
let files: [File]
let state: ItemState?
}
struct Series: Decodable, Sendable {
let id: String
let title: String
let year: Int?
let overview: String?
let posterImage: String?
let backdropImage: String?
let episodeCount: Int
let seasons: [Int]
let unwatchedCount: Int
}
struct Episode: Decodable, Sendable {
let id: String
let season: Int
let episode: Int
let title: String?
let overview: String?
let stillImage: String?
let files: [File]
let state: ItemState?
}
/// Eine Kachel aus „Weiterschauen" oder „Nächste Folge".
struct Started: Decodable, Sendable {
let id: String
let kind: String
let title: String
let subTitle: String?
let season: Int?
let episode: Int?
let year: Int?
let posterImage: String?
let backdropImage: String?
let state: ItemState
}
struct CodeResponse: Decodable, Sendable {
let code: String
let handle: String
let expiresAt: Date
}
/// `state` ist „pending", „approved" oder „expired".
struct CodeState: Decodable, Sendable {
let state: String
let login: LoginResponse?
}
struct CodeRequest: Encodable, Sendable {
let deviceName: String
let deviceId: String
}
struct Delivery: Decodable, Sendable {
let method: String
let reasons: [String]
let audioToEncode: [Int]
let subtitlesDropped: [Int]
let packageState: String
let progress: Double
let error: String?
}
// --- Was hinausgeht --------------------------------------------------
struct LoginRequest: Encodable, Sendable {
let userName: String
let password: String
let deviceName: String
let deviceId: String
}
struct ProgressRequest: Encodable, Sendable {
let positionSeconds: Double
let runtimeSeconds: Double?
}
struct WatchedRequest: Encodable, Sendable { let watched: Bool }
struct FavouriteRequest: Encodable, Sendable { let favourite: Bool }
struct RatingRequest: Encodable, Sendable { let rating: Int? }
}
@@ -0,0 +1,39 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
extension FlaroIdentity {
private static let storageKey = "flaro.flaroServer.deviceID"
/// Baut die Kennung für das laufende Gerät und merkt sie sich.
///
/// **Eine eigene, nicht die von Jellyfin.** Beide Server führen eigene
/// Geräteverzeichnisse; dieselbe Kennung doppelt zu vergeben hiesse, dass
/// ein Widerruf auf dem einen Server im anderen verwirrt — und wer von
/// Jellyfin zu Flaro wechselt, hinterliesse auf dem alten Server ein
/// Gerät, das es dort gar nicht mehr gibt.
///
/// Gleichbleiben muss sie trotzdem: wechselt sie, gilt die App als neues
/// Gerät, und in der Liste steht bei jedem Start ein Eintrag mehr.
///
/// `@MainActor`, weil `UIDevice.current` dort beheimatet ist.
@MainActor
public static func current(defaults: UserDefaults = .standard) -> FlaroIdentity {
let deviceID: String
if let stored = defaults.string(forKey: storageKey) {
deviceID = stored
} else {
deviceID = UUID().uuidString
defaults.set(deviceID, forKey: storageKey)
}
#if canImport(UIKit)
let name = UIDevice.current.name
#else
let name = ProcessInfo.processInfo.hostName
#endif
return FlaroIdentity(deviceName: name, deviceID: deviceID)
}
}
@@ -0,0 +1,179 @@
import Foundation
import MediaKit
/// Aus Flaros Antworten werden neutrale Typen.
///
/// Die einzige Stelle, an der Flaros Schreibweise in die der App übersetzt
/// wird. Alles darüber kennt nur noch `MediaItem`.
extension Wire.Movie {
func neutral() -> MediaItem {
MediaItem(
id: id,
kind: .movie,
title: title,
summary: overview,
year: year,
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
thumb: posterImage,
art: backdropImage,
playCount: state?.playCount,
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
lastPlayedAt: state?.lastPlayedAt,
isFavourite: state?.favourite ?? false,
versions: [Wire.fassung(id: id, files: files)]
)
}
}
extension Wire.Series {
func neutral() -> MediaItem {
MediaItem(
id: id,
kind: .show,
title: title,
summary: overview,
year: year,
thumb: posterImage,
art: backdropImage,
// **Gesehene Folgen, nicht ungesehene.** Der Server zählt, was
// offen ist — die App zeigt daraus die Plakette, rechnet aber
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
// zu derselben Aussage zu haben.
episodeCount: episodeCount,
playedEpisodeCount: episodeCount - unwatchedCount,
childCount: seasons.count
)
}
}
extension Wire.Episode {
func neutral(series: Wire.Series? = nil) -> MediaItem {
MediaItem(
id: id,
kind: .episode,
title: title ?? "Folge \(episode)",
summary: overview,
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
thumb: stillImage,
seriesThumb: series?.posterImage,
seriesArt: series?.backdropImage,
index: episode,
parentIndex: season,
seriesTitle: series?.title,
seriesID: series?.id,
playCount: state?.playCount,
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
lastPlayedAt: state?.lastPlayedAt,
isFavourite: state?.favourite ?? false,
versions: [Wire.fassung(id: id, files: files)]
)
}
}
extension Wire.Started {
/// Die Kachel aus „Weiterschauen" als Titel.
///
/// **Ohne Fassungen.** Die Reihe nennt keine Dateien, und das ist
/// richtig so: eine Kachel zeigt Plakat, Titel und Balken. Wer sie
/// antippt, holt den Titel einzeln — und bekommt dann alles.
func neutral() -> MediaItem {
let folge = kind == "episode"
return MediaItem(
id: id,
kind: folge ? .episode : .movie,
title: folge ? (subTitle ?? "Folge \(episode ?? 0)") : title,
year: year,
duration: state.runtimeSeconds.map { Int($0 * 1000) },
thumb: folge ? backdropImage : posterImage,
art: backdropImage,
seriesThumb: folge ? posterImage : nil,
index: episode,
parentIndex: season,
seriesTitle: folge ? title : nil,
playCount: state.playCount,
resumeMilliseconds: Int(state.positionSeconds * 1000),
lastPlayedAt: state.lastPlayedAt,
isFavourite: state.favourite
)
}
}
extension Wire.Library {
func neutral() -> MediaLibrary {
MediaLibrary(
id: id,
title: name,
// Flaro kennt genau zwei Arten. Was es nicht gibt, als `.other`
// durchzulassen wäre eine Vorsorge für einen Fall, den der
// Server gar nicht erzeugen kann.
kind: kind.lowercased() == "shows" ? .show : .movie)
}
}
extension Wire.File {
func neutral(itemID: String) -> MediaFile {
MediaFile(
id: id,
key: "/api/v1/files/\(id)/download",
itemID: itemID,
container: container,
size: sizeBytes,
duration: durationSeconds.map { Int($0 * 1000) },
file: fileName,
streams: streams.map { $0.neutral() })
}
}
extension Wire.Stream {
func neutral() -> MediaStream {
let art: MediaStream.Kind = switch kind.lowercased() {
case "video": .video
case "subtitle": .subtitle
default: .audio
}
return MediaStream(
// **Die Spurnummer ist die Kennung.** `MediaStream.index` liest
// sie aus `id` zurück, und daran hängt die Spurwahl beim
// Umpacken. Eine eigene Kennung wäre hier eine, die niemand
// zurückrechnen kann.
id: String(index),
kind: art,
codec: codec,
language: language,
languageCode: language,
displayTitle: title ?? language,
channels: channels,
isSelected: isDefault,
isForced: isForced,
title: title,
isDefault: isDefault)
}
}
extension Wire {
/// Eine Fassung aus den Dateien eines Titels.
///
/// **Alle Dateien in einer Fassung**, nicht eine Fassung je Datei: Flaro
/// führt mehrere Dateien zu einem Titel als Fassungen desselben Films —
/// 4K und 1080p nebeneinander — und `MediaVersion.files` ist genau dafür
/// da. Die Eckdaten kommen aus der ersten Datei; welche gespielt wird,
/// entscheidet die App.
static func fassung(id: String, files: [Wire.File]) -> MediaVersion {
let erste = files.first
let bild = erste?.streams.first { $0.kind.lowercased() == "video" }
let ton = erste?.streams.first { $0.kind.lowercased() == "audio" }
return MediaVersion(
id: erste?.id ?? id,
container: erste?.container,
videoCodec: bild?.codec,
audioCodec: ton?.codec,
videoResolution: bild?.height.map(String.init),
width: bild?.width,
height: bild?.height,
audioChannels: ton?.channels,
duration: erste?.durationSeconds.map { Int($0 * 1000) },
files: files.map { $0.neutral(itemID: id) })
}
}
@@ -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)
}
}
@@ -0,0 +1,91 @@
{
"sourceLanguage": "en",
"strings": {
"flaro.remux.tooLong": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Das Umpacken dauert ungewöhnlich lange. Sieh im Serverprotokoll nach."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Remuxing is taking unusually long. Check the server log."
}
}
}
},
"flaro.row.continue": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Weiterschauen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Continue Watching"
}
}
}
},
"flaro.row.movies": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Filme"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Movies"
}
}
}
},
"flaro.row.nextUp": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nächste Folge"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Next Up"
}
}
}
},
"flaro.row.series": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Serien"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Shows"
}
}
}
}
},
"version": "1.0"
}
@@ -1,65 +1,17 @@
import Foundation
import MediaKit
/// Macht aus dem, was jemand eintippt, eine Adresse, die antwortet.
/// Die Adressraterei für Jellyfin.
///
/// Jellyfin hat kein Verzeichnis wie plex.tv — die Adresse kommt vom Nutzer.
/// Und die tippt niemand vollständig: „192.168.1.50" ohne Schema, ohne Port,
/// oder „medien.example.com" ohne „https://". Dieser Typ rät nicht **eine**
/// Ergänzung, sondern nennt die plausiblen in der Reihenfolge, in der sie es
/// wahrscheinlich sind; der Aufrufer probiert sie durch und nimmt die erste,
/// die sich als Jellyfin-Server ausweist.
///
/// Die Reihenfolge ist der ganze Inhalt dieser Datei, und sie ist nicht
/// beliebig: eine IP-Adresse im eigenen Netz spricht fast nie HTTPS, ein
/// Domänenname fast immer.
/// Der Inhalt steht in `ServerAddress` — beide Server haben dasselbe Problem,
/// und es unterscheidet sie nur der Standardport. Dieser Typ bleibt stehen,
/// weil Aufrufer und Prüfungen ihn beim Namen nennen und weil „welcher Port
/// gilt hier" eine Auskunft über Jellyfin ist, nicht über Adressen.
public enum JellyfinAddress {
/// Der Standardport eines Jellyfin-Servers.
public static let defaultPort = 8096
public static func candidates(for input: String) -> [URL] {
var text = input.trimmingCharacters(in: .whitespacesAndNewlines)
while text.hasSuffix("/") { text.removeLast() }
guard !text.isEmpty else { return [] }
// Wer ein Schema hinschreibt, meint es auch so. Daran wird nichts
// ergänzt — sonst landet eine Anfrage unverschlüsselt, obwohl jemand
// ausdrücklich „https" getippt hat.
if text.contains("://") {
return URL(string: text).map { [$0] } ?? []
}
// IPv6 in eckigen Klammern: hier wird nicht geraten, weil die
// Doppelpunkte sonst als Port gelesen würden.
if text.hasPrefix("[") {
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
}
let authority = String(text.split(separator: "/", maxSplits: 1).first ?? "")
let host = String(authority.split(separator: ":").first ?? "")
let hasExplicitPort = authority.contains(":")
&& Int(authority.split(separator: ":").last ?? "") != nil
if hasExplicitPort {
// Port genannt, Schema nicht: erst unverschlüsselt, weil ein
// eigener Port fast immer ein Server ohne Zertifikat ist.
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
}
// Eine reine Zahlenadresse, ein Bonjour-Name oder ein Wort ohne Punkt
// — alles Dinge, die es nur im eigenen Netz gibt.
let isLocalLooking = host.allSatisfy { $0.isNumber || $0 == "." }
|| host.hasSuffix(".local")
|| !host.contains(".")
let forms = isLocalLooking
? ["http://\(withDefaultPort(text))", "http://\(text)", "https://\(text)"]
: ["https://\(text)", "http://\(text)", "http://\(withDefaultPort(text))"]
return forms.compactMap(URL.init(string:))
}
/// Hängt den Standardport an — vor einen etwaigen Pfad, nicht dahinter.
private static func withDefaultPort(_ text: String) -> String {
guard let slash = text.firstIndex(of: "/") else { return "\(text):\(defaultPort)" }
return "\(text[text.startIndex..<slash]):\(defaultPort)\(text[slash...])"
ServerAddress.candidates(for: input, defaultPort: defaultPort)
}
}
@@ -173,6 +173,18 @@ public enum BackendCapability: String, Sendable, Hashable {
/// Die Verwaltung des Servers aus der App heraus.
case administration
case transcoding
/// Ein Download bringt **alle** Ton- und Textspuren mit.
///
/// **Der Unterschied, um dessentwillen Flaro gebaut wurde.** Jellyfins
/// Transcode-Endpunkt bildet eine Tonspur je Sitzung ab: was
/// heruntergeladen wird, ist einspurig, und die Sprachwahl ist offline
/// weg. Einmal hat die App dabei still die Audiodeskription genommen —
/// drei Gigabyte Mobilfunk für eine unbrauchbare Datei.
///
/// Kann der Server es besser, soll die App nicht so tun, als müsste
/// vorher gewählt werden: `remuxDownloadURL` entfällt, die Spurauswahl
/// im Sheet entfällt, und der gewöhnliche Download liefert alles.
case allTracksDownload
}
/// Ein Ausschnitt aus einer Mediathek.
@@ -0,0 +1,68 @@
import Foundation
/// Macht aus dem, was jemand eintippt, eine Adresse, die antwortet.
///
/// Weder Jellyfin noch Flaro haben ein Verzeichnis wie plex.tv — die Adresse
/// kommt vom Nutzer. Und die tippt niemand vollständig: „192.168.1.50" ohne
/// Schema, ohne Port, oder „medien.example.com" ohne „https://". Dieser Typ
/// rät nicht **eine** Ergänzung, sondern nennt die plausiblen in der
/// Reihenfolge, in der sie es wahrscheinlich sind; der Aufrufer probiert sie
/// durch und nimmt die erste, die sich ausweist.
///
/// Die Reihenfolge ist der ganze Inhalt dieser Datei, und sie ist nicht
/// beliebig: eine IP-Adresse im eigenen Netz spricht fast nie HTTPS, ein
/// Domänenname fast immer.
///
/// **In der neutralen Schicht, weil beide Server dasselbe Problem haben.**
/// Nur der Standardport unterscheidet sich, und der ist ein Wert, kein
/// Gedanke. Diese Überlegungen zweimal hinzuschreiben hiesse, dass die eine
/// Fassung beim nächsten Sonderfall nachgezogen wird und die andere nicht.
public enum ServerAddress {
public static func candidates(for input: String, defaultPort: Int) -> [URL] {
var text = input.trimmingCharacters(in: .whitespacesAndNewlines)
while text.hasSuffix("/") { text.removeLast() }
guard !text.isEmpty else { return [] }
// Wer ein Schema hinschreibt, meint es auch so. Daran wird nichts
// ergänzt — sonst landet eine Anfrage unverschlüsselt, obwohl jemand
// ausdrücklich „https" getippt hat.
if text.contains("://") {
return URL(string: text).map { [$0] } ?? []
}
// IPv6 in eckigen Klammern: hier wird nicht geraten, weil die
// Doppelpunkte sonst als Port gelesen würden.
if text.hasPrefix("[") {
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
}
let authority = String(text.split(separator: "/", maxSplits: 1).first ?? "")
let host = String(authority.split(separator: ":").first ?? "")
let hasExplicitPort = authority.contains(":")
&& Int(authority.split(separator: ":").last ?? "") != nil
if hasExplicitPort {
// Port genannt, Schema nicht: erst unverschlüsselt, weil ein
// eigener Port fast immer ein Server ohne Zertifikat ist.
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
}
// Eine reine Zahlenadresse, ein Bonjour-Name oder ein Wort ohne Punkt
// — alles Dinge, die es nur im eigenen Netz gibt.
let isLocalLooking = host.allSatisfy { $0.isNumber || $0 == "." }
|| host.hasSuffix(".local")
|| !host.contains(".")
let mitPort = withDefaultPort(text, port: defaultPort)
let forms = isLocalLooking
? ["http://\(mitPort)", "http://\(text)", "https://\(text)"]
: ["https://\(text)", "http://\(text)", "http://\(mitPort)"]
return forms.compactMap(URL.init(string:))
}
/// Hängt den Standardport an — vor einen etwaigen Pfad, nicht dahinter.
private static func withDefaultPort(_ text: String, port: Int) -> String {
guard let slash = text.firstIndex(of: "/") else { return "\(text):\(port)" }
return "\(text[text.startIndex..<slash]):\(port)\(text[slash...])"
}
}
@@ -0,0 +1,150 @@
import Foundation
import Testing
import MediaKit
@testable import FlaroServerKit
/// Adressen und Zugang — alles, was ohne Netz entsteht.
@Suite("Flaro: Adressen und Zugang")
struct AdressenTests {
private func backend(_ transport: Attrappe, keksglas: Keksglas? = nil) -> FlaroBackend {
FlaroBackend(
client: FlaroClient(
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
transport: transport, cookieJar: keksglas),
account: FlaroAccount(
baseURL: Proben.adresse, serverName: "Wohnzimmer",
userID: "u1", userName: "guido", isAdministrator: true))
}
/// **Kein Token in der Adresse — das ist der Unterschied zu Jellyfin.**
///
/// Dort steckt der Zugang als `?ApiKey=` in jeder Bildadresse, weil eine
/// Bildansicht keine Kopfzeilen schickt. Er steht damit in jedem
/// Zugriffsprotokoll, in jedem Verlauf und in jedem weitergegebenen Link.
/// Flaro geht über ein Cookie.
@Test("Bildadressen tragen keinen Zugang")
func bildAdresseOhneToken() {
let url = backend(Attrappe(rumpf: "{}")).rawImageURL(path: "abc123")
#expect(url?.path == "/api/v1/images/abc123")
#expect(url?.query == nil)
#expect(url?.absoluteString.contains("geheim") == false)
}
@Test("Ein Zugang wird als Cookie abgelegt")
func tokenWirdZuKeks() async {
let glas = Keksglas()
_ = FlaroClient(
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
transport: Attrappe(rumpf: "{}"), cookieJar: glas)
let keks = glas.kekse.first
#expect(keks?.name == "flaro_session")
#expect(keks?.value == "geheim-123")
#expect(keks?.path == "/")
#expect(keks?.domain.hasSuffix("nas") == true)
}
/// **Nach einem Neustart meldet sich niemand neu an.**
///
/// Die App hat dann nur das Token aus dem Schlüsselbund. Käme das Cookie
/// allein aus der Antwort des Servers, wäre keines da — und jedes Plakat
/// bliebe grau, obwohl der Zugang gilt.
@Test("Auch ein wiederaufgenommenes Token legt das Cookie an")
func keksAuchOhneAnmeldung() async throws {
let glas = Keksglas()
let transport = Attrappe([
("/api/v1/auth/me", 200, #"{"id":"u1","userName":"guido","isAdministrator":false}"#),
("/api/v1/system/info", 200, Proben.info),
])
_ = try await FlaroSignIn.restore(
baseURL: Proben.adresse, token: "geheim-123",
identity: Proben.kennung, transport: transport, cookieJar: glas)
#expect(glas.kekse.contains { $0.name == "flaro_session" && $0.value == "geheim-123" })
}
@Test("Die Download-Adresse zeigt auf die Datei, nicht auf den Titel")
func downloadAdresse() {
let url = backend(Attrappe(rumpf: "{}")).downloadURL(
for: MediaFile(id: "datei-1", itemID: "film-1"))
#expect(url?.path == "/api/v1/files/datei-1/download")
}
/// **Der Vertrag fragt nach einer Tonspur, Flaro braucht keine.**
///
/// `remuxDownloadURL` gibt es nur, weil Jellyfins Transcoder genau eine
/// Tonspur je Sitzung abbildet. Hier wäre eine Adresse mit Spurnummer
/// eine Lüge über das, was herauskommt — also gibt es keine, und die
/// Oberfläche erkennt an der Fähigkeit, dass sie nicht fragen muss.
@Test("Kein Umpacken mit Spurwahl — stattdessen die Fähigkeit")
func alleSpuren() {
let b = backend(Attrappe(rumpf: "{}"))
#expect(b.capabilities.contains(.allTracksDownload))
#expect(b.remuxDownloadURL(
itemID: "film-1", versionIndex: 0,
audioStreamIndex: 1, subtitleStreamIndex: nil) == nil)
}
/// Zwei Server dürfen „Medien" heissen; derselbe darf umbenannt werden.
@Test("Die Kennung hängt an der Adresse, nicht am Namen")
func kennung() {
#expect(backend(Attrappe(rumpf: "{}")).identity == "flaro:http://nas:8080")
}
@Test("Anmelden schickt Gerätename und Kennung mit")
func anmeldung() async throws {
let transport = Attrappe([
("/api/v1/system/info", 200, Proben.info),
("/api/v1/auth/login", 200, Proben.anmeldung),
])
let (token, konto, _) = try await FlaroSignIn.signIn(
baseURL: Proben.adresse, userName: "guido", password: "geheim",
identity: Proben.kennung, transport: transport, cookieJar: Keksglas())
let rumpf = try #require(transport.rumpf(von: "/api/v1/auth/login"))
#expect(token == "geheim-123")
#expect(konto.userName == "guido")
#expect(konto.isAdministrator)
#expect(konto.serverName == "Wohnzimmer")
#expect(rumpf["deviceName"] as? String == "Prüfgerät")
#expect(rumpf["deviceId"] as? String == "geraet-1")
}
/// **Ein frischer Server ist kein falsches Passwort.**
@Test("Ein nicht eingerichteter Server sagt das")
func nochNichtEingerichtet() async throws {
let transport = Attrappe(rumpf: #"{"version":"0.1.0","serverName":null,"needsSetup":true}"#)
let gruss = try await FlaroSignIn.greet(
baseURL: Proben.adresse, identity: Proben.kennung, transport: transport)
#expect(gruss.needsSetup)
}
/// **Flaro begründet seine Absagen, und der Satz zählt mehr als die Zahl.**
@Test("Eine Absage mit Begründung kommt als Begründung an")
func begruendeteAbsage() async {
let transport = Attrappe(rumpf: #"{"detail":"Für diese Bibliothek läuft bereits ein Scan."}"#, status: 409)
let b = backend(transport)
await #expect(throws: MediaBackendError.refused(
reason: "Für diese Bibliothek läuft bereits ein Scan.")) {
try await b.refreshLibrary(MediaLibrary(id: "b1", title: "Filme", kind: .movie))
}
}
@Test("Ein abgelaufener Zugang führt zur Abmeldung, nicht zu einer Meldung")
func abgelaufen() async {
let b = backend(Attrappe(rumpf: "", status: 401))
await #expect(throws: MediaBackendError.unauthorized) {
_ = try await b.libraries()
}
}
}
@@ -0,0 +1,104 @@
import Foundation
@testable import FlaroServerKit
/// Ein Transport, der vorbereitete Antworten gibt und mitschreibt.
///
/// Dieselbe Trennung wie bei Jellyfin: Prüfungen brauchen keinen Server,
/// sondern eine Antwort und die Frage, die gestellt wurde.
final class Attrappe: FlaroTransport, @unchecked Sendable {
/// Antworten je Pfad. Der erste Treffer gewinnt — so kann eine Prüfung
/// `/api/v1/movies` und `/api/v1/movies/{id}` getrennt bedienen.
private let antworten: [(pfad: String, status: Int, rumpf: String)]
private let sperre = NSLock()
private var protokoll: [URLRequest] = []
init(_ antworten: [(pfad: String, status: Int, rumpf: String)]) {
self.antworten = antworten
}
convenience init(rumpf: String, status: Int = 200) {
self.init([("", status, rumpf)])
}
var anfragen: [URLRequest] { sperre.withLock { protokoll } }
var letzte: URLRequest? { anfragen.last }
func anfrage(auf pfad: String) -> URLRequest? {
anfragen.last { $0.url?.path == pfad }
}
func rumpf(von pfad: String) -> [String: Any]? {
guard let daten = anfrage(auf: pfad)?.httpBody else { return nil }
return try? JSONSerialization.jsonObject(with: daten) as? [String: Any]
}
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
sperre.withLock { protokoll.append(request) }
let pfad = request.url?.path ?? ""
let treffer = antworten.first { $0.pfad.isEmpty || pfad.hasSuffix($0.pfad) || pfad == $0.pfad }
?? ("", 404, "")
return (
Data(treffer.rumpf.utf8),
HTTPURLResponse(url: request.url!, statusCode: treffer.status,
httpVersion: nil, headerFields: nil)!
)
}
}
/// Ein Keksglas, das nur mitschreibt.
///
/// Der Speicher des Systems anzufassen hiesse, dass eine Prüfung die nächste
/// beeinflusst — und auf einem Entwicklerrechner auch den Browser.
final class Keksglas: FlaroCookieJar, @unchecked Sendable {
private let sperre = NSLock()
private var inhalt: [HTTPCookie] = []
var kekse: [HTTPCookie] { sperre.withLock { inhalt } }
func store(_ cookie: HTTPCookie) { sperre.withLock { inhalt.append(cookie) } }
func removeCookie(named name: String, host: String) {
sperre.withLock { inhalt.removeAll { $0.name == name } }
}
}
enum Proben {
static let adresse = URL(string: "http://nas:8080")!
static let kennung = FlaroIdentity(deviceName: "Prüfgerät", deviceID: "geraet-1")
/// Ein Film mit zwei Tonspuren — der Fall, um den es geht.
static let film = """
{
"id": "film-1", "title": "Der Untergang", "year": 2004,
"overview": "Berlin, April 1945.",
"posterImage": "aaa", "backdropImage": "bbb",
"files": [{
"id": "datei-1", "fileName": "Der Untergang (2004).mkv",
"sizeBytes": 9000000000, "container": "matroska", "durationSeconds": 9360.0,
"streams": [
{"index":0,"kind":"Video","codec":"h264","language":null,"title":null,
"isDefault":true,"isForced":false,"channels":null,"width":1920,"height":1080},
{"index":1,"kind":"Audio","codec":"ac3","language":"deu","title":"Deutsch",
"isDefault":true,"isForced":false,"channels":6,"width":null,"height":null},
{"index":2,"kind":"Audio","codec":"ac3","language":"deu","title":"Audiodeskription",
"isDefault":false,"isForced":false,"channels":2,"width":null,"height":null},
{"index":3,"kind":"Subtitle","codec":"subrip","language":"deu","title":"Deutsch",
"isDefault":false,"isForced":false,"channels":null,"width":null,"height":null}
]
}],
"state": {"positionSeconds":300.0,"runtimeSeconds":9360.0,"watched":false,
"playCount":0,"favourite":false,"rating":null,"dismissed":false,
"lastPlayedAt":"2026-10-03T19:25:04.5209555+00:00"}
}
"""
static let info = """
{"version":"0.1.0","serverName":"Wohnzimmer","needsSetup":false,
"attributions":[{"text":"This product uses TMDB","logoUrl":"https://x/t.svg","url":"https://x"}]}
"""
static let anmeldung = """
{"token":"geheim-123","user":{"id":"u1","userName":"guido","isAdministrator":true}}
"""
}
@@ -0,0 +1,262 @@
import Foundation
import Testing
import MediaKit
@testable import FlaroServerKit
/// Aus Flaros Antworten werden neutrale Typen — ohne Verlust.
@Suite("Flaro: Übersetzung")
struct UebersetzungTests {
private func backend(_ transport: any FlaroTransport) -> FlaroBackend {
FlaroBackend(
client: FlaroClient(
baseURL: Proben.adresse, identity: Proben.kennung, token: "t",
transport: transport, cookieJar: nil),
account: FlaroAccount(
baseURL: Proben.adresse, serverName: "Wohnzimmer",
userID: "u1", userName: "guido", isAdministrator: false))
}
/// **Beide Tonspuren kommen an, mit Namen und Nummer.**
///
/// Das ist der ganze Anlass: eine Datei mit „Deutsch" und
/// „Audiodeskription", und die App muss beide sehen, um nicht blind die
/// falsche zu nehmen.
@Test("Ein Film behält alle Spuren")
func filmMitZweiTonspuren() async throws {
let film = try #require(try? FlaroClient.decoder.decode(Wire.Movie.self, from: Data(Proben.film.utf8)))
let titel = film.neutral()
let datei = try #require(titel.versions.first?.files.first)
#expect(titel.title == "Der Untergang")
#expect(titel.year == 2004)
#expect(datei.audioStreams.count == 2)
#expect(datei.audioStreams.map(\.title) == ["Deutsch", "Audiodeskription"])
// Die Nummer muss zurückrechenbar sein — daran hängt die Spurwahl.
#expect(datei.audioStreams.map(\.index) == [1, 2])
#expect(datei.subtitleStreams.count == 1)
#expect(datei.videoStream?.codec == "h264")
}
/// Fünf Minuten von zwei Stunden sechsunddreissig — die Stelle, die
/// Jellyfin wegwirft.
@Test("Der Stand kommt als Fortschritt an")
func standWirdFortschritt() throws {
let film = try #require(try? FlaroClient.decoder.decode(Wire.Movie.self, from: Data(Proben.film.utf8)))
let titel = film.neutral()
#expect(titel.resumeMilliseconds == 300_000)
#expect(titel.duration == 9_360_000)
#expect(titel.isWatched == false)
let anteil = try #require(titel.watchProgress)
#expect(abs(anteil - 300.0 / 9360.0) < 0.0001)
}
/// ASP.NET Core schreibt Bruchteile von Sekunden mit.
///
/// **Der Standarddekoder scheitert daran**, und zwar stumm bis zur
/// Stelle, an der jemand das Datum liest. Hier stehen beide nebeneinander,
/// damit sichtbar bleibt, dass die eigene Strategie keine Verzierung ist.
@Test("Ein Datum mit Sekundenbruchteilen wird gelesen")
func datumMitBruch() async throws {
let roh = Data(Proben.film.utf8)
#expect((try? JSONDecoder().decode(Wire.Movie.self, from: roh)) == nil,
"2026-10-03T19:25:04.5209555+00:00 ist für .iso8601 kein Datum")
#expect((try? FlaroClient.decoder.decode(Wire.Movie.self, from: roh)) != nil)
let transport = Attrappe(rumpf: #"{"items":[\#(Proben.film)],"total":1}"#)
let seite = try await backend(transport).items(
inLibrary: MediaLibrary(id: "b1", title: "Filme", kind: .movie),
query: MediaQuery(), offset: 0, limit: 50)
#expect(seite.items.first?.lastPlayedAt != nil)
}
/// Der Server zählt, was offen ist; die App rechnet mit der Gegenzahl.
@Test("Offene Folgen werden zu gesehenen")
func serieZaehlt() throws {
let roh = """
{"id":"s1","title":"Dark","year":2017,"overview":null,
"posterImage":"p","backdropImage":"b",
"episodeCount":10,"seasons":[1,2],"unwatchedCount":3}
"""
let serie = try FlaroClient.decoder.decode(Wire.Series.self, from: Data(roh.utf8))
let titel = serie.neutral()
#expect(titel.kind == .show)
#expect(titel.episodeCount == 10)
#expect(titel.playedEpisodeCount == 7)
#expect(titel.childCount == 2)
}
/// **Oben die Serie, darunter die Folge.** Eine Kachel, die nur
/// „Lügen" sagt, hilft beim Wiedererkennen nicht.
@Test("Eine Folge aus Weiterschauen trägt ihre Serie mit")
func weiterschauenFolge() throws {
let roh = """
{"id":"f2","kind":"episode","title":"Dark","subTitle":"Lügen",
"season":1,"episode":2,"year":null,"posterImage":"p","backdropImage":"still",
"state":{"positionSeconds":400.0,"runtimeSeconds":2700.0,"watched":false,
"playCount":0,"favourite":false,"rating":null,"dismissed":false,
"lastPlayedAt":null}}
"""
let eintrag = try FlaroClient.decoder.decode(Wire.Started.self, from: Data(roh.utf8))
let titel = eintrag.neutral()
#expect(titel.kind == .episode)
#expect(titel.title == "Lügen")
#expect(titel.seriesTitle == "Dark")
#expect(titel.parentIndex == 1)
#expect(titel.index == 2)
#expect(titel.resumeMilliseconds == 400_000)
}
@Test("Mediatheken: Shows werden Serien, alles andere Filme")
func mediatheken() async throws {
let transport = Attrappe(rumpf: """
[{"id":"b1","name":"Filme","kind":"Movies","path":"/media/Filme"},
{"id":"b2","name":"Serien","kind":"Shows","path":"/media/Serien"}]
""")
let liste = try await backend(transport).libraries()
#expect(liste.map(\.kind) == [.movie, .show])
#expect(liste.map(\.title) == ["Filme", "Serien"])
}
/// Nach der letzten Folge kommt keine — 204, kein Fehler.
@Test("Hinter der letzten Folge steht nichts")
func keineNaechsteFolge() async throws {
let transport = Attrappe(rumpf: "", status: 204)
let folge = MediaItem(id: "f9", kind: .episode, title: "Letzte")
let naechste = try await backend(transport).nextEpisode(after: folge)
#expect(naechste == nil)
}
@Test("Eine Fortschrittsmeldung geht in Sekunden hinaus")
func fortschrittMelden() async {
let transport = Attrappe(rumpf: "{}")
await backend(transport).reportProgress(PlaybackReport(
itemID: "film-1", state: .playing,
time: .seconds(300), duration: .seconds(9360)))
let rumpf = transport.rumpf(von: "/api/v1/items/film-1/progress")
#expect(transport.letzte?.httpMethod == "POST")
#expect(rumpf?["positionSeconds"] as? Double == 300)
#expect(rumpf?["runtimeSeconds"] as? Double == 9360)
}
/// **Flaro hat einen eigenen Weg dafür — nur deshalb bleibt die Stelle.**
/// Bei Jellyfin wird hier das Benutzerdatenblatt überschrieben.
@Test("Weglegen ist nicht dasselbe wie gesehen")
func weglegen() async throws {
let transport = Attrappe(rumpf: "{}")
try await backend(transport).removeFromContinueWatching(
MediaItem(id: "film-1", title: "Der Untergang"))
#expect(transport.letzte?.url?.path == "/api/v1/items/film-1/dismiss")
#expect(transport.letzte?.httpMethod == "POST")
}
@Test("Gesehen und ungesehen gehen an dieselbe Stelle, mit anderem Wert")
func gesehenMelden() async throws {
let transport = Attrappe(rumpf: "{}")
let b = backend(transport)
try await b.markWatched(id: "film-1")
#expect(transport.rumpf(von: "/api/v1/items/film-1/watched")?["watched"] as? Bool == true)
try await b.markUnwatched(id: "film-1")
#expect(transport.rumpf(von: "/api/v1/items/film-1/watched")?["watched"] as? Bool == false)
#expect(transport.letzte?.httpMethod == "PUT")
}
// --- Abspielen -----------------------------------------------------
/// **Direkt spielbar: sofort los.**
@Test("Eine MP4 geht ohne Umweg hinaus")
func direkteWiedergabe() async throws {
let transport = Attrappe(rumpf: """
{"method":"directFile","reasons":[],"audioToEncode":[],"subtitlesDropped":[],
"packageState":"none","progress":0.0,"error":null}
""")
let ziel = try await backend(transport).preparePlayback(PlaybackIntent(
itemID: "film-1", versionIndex: 0, file: MediaFile(id: "datei-1")))
#expect(ziel.url.path == "/api/v1/files/datei-1/download")
// Flaro legt für eine Wiedergabe nichts an, das aufzuräumen wäre.
#expect(ziel.sessionID == nil)
}
/// **Der Fehler, den erst der Simulator zeigte.**
///
/// 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, mit einem Spinner:
/// „Stelle wird wiederhergestellt", 0:00, für immer.
@Test("Ein Umpacken wird abgewartet, nicht überholt")
func wartetAufsUmpacken() async throws {
let transport = Fortschrittsattrappe()
let ziel = try await backend(transport).preparePlayback(PlaybackIntent(
itemID: "film-1", versionIndex: 0, file: MediaFile(id: "datei-1")))
#expect(ziel.url.path == "/api/v1/files/datei-1/download")
#expect(transport.abfragen >= 2, "es wurde nachgefragt, nicht einmal geschaut")
}
/// Eine Absage des Servers ist eine Absage, kein Weiterwarten.
@Test("Ein gescheitertes Umpacken kommt als Begründung an")
func gescheitertesUmpacken() async {
let transport = Attrappe(rumpf: """
{"method":"remux","reasons":["matroska"],"audioToEncode":[],"subtitlesDropped":[],
"packageState":"failed","progress":0.0,"error":"ffmpeg ist abgestürzt."}
""")
let b = backend(transport)
await #expect(throws: MediaBackendError.refused(reason: "ffmpeg ist abgestürzt.")) {
_ = try await b.preparePlayback(PlaybackIntent(
itemID: "film-1", versionIndex: 0, file: MediaFile(id: "datei-1")))
}
}
/// Flaro kennt noch keine Schlagwörter — eine leere Liste, kein Fehler.
@Test("Ohne Schlagwörter bleibt der Filter leer statt kaputt")
func keineSchlagwoerter() async throws {
let optionen = try await backend(Attrappe(rumpf: "{}")).filterOptions(
inLibrary: MediaLibrary(id: "b1", title: "Filme", kind: .movie), field: .genre)
#expect(optionen.isEmpty)
}
}
/// Antwortet beim ersten Mal „läuft noch", danach „fertig".
///
/// Damit prüfbar ist, dass gewartet wird — eine Attrappe mit immer derselben
/// Antwort könnte das nicht von „einmal geschaut und losgelaufen" trennen.
final class Fortschrittsattrappe: FlaroTransport, @unchecked Sendable {
private let sperre = NSLock()
private var runde = 0
var abfragen: Int { sperre.withLock { runde } }
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
let pfad = request.url?.path ?? ""
let fertig: Bool = sperre.withLock {
if pfad.hasSuffix("/delivery") { runde += 1 }
return runde > 1
}
let rumpf = """
{"method":"remux","reasons":["matroska"],"audioToEncode":[],"subtitlesDropped":[],
"packageState":"\(fertig ? "ready" : "running")","progress":\(fertig ? 1.0 : 0.4),"error":null}
"""
return (Data(rumpf.utf8),
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
}
}
+4
View File
@@ -57,6 +57,8 @@ targets:
# zweiter dazu, steht er daneben.
- package: FlaroKit
product: JellyfinKit
- package: FlaroKit
product: FlaroServerKit
- package: FlaroKit
product: FlaroDesign
- package: FlaroKit
@@ -188,6 +190,8 @@ targets:
product: MediaKit
- package: FlaroKit
product: JellyfinKit
- package: FlaroKit
product: FlaroServerKit
- package: FlaroKit
product: FlaroDesign
- package: FlaroKit