Files
Flaro/Packages/FlaroKit/Sources/FlaroServerKit/FlaroSignIn.swift
T
ScarriffleandClaude Opus 5 e30158b84c Eine Sekunde heisst nicht „Weiterschauen"
Die Schwelle aus den Einstellungen wirkt serverseitig, also sofort überall:
`/home/continue` liest die gespeicherte Spalte. Eine Stelle blieb trotzdem
widersprüchlich — die **Beschriftung**.

Web und Apps entschieden „Weiterschauen" gegen „Abspielen" selbst, allein
daran, ob eine Stelle über null steht. Nach einer Sekunde stand der Titel
damit nicht mehr in der Reihe, hiess aber weiter „Weiterschauen", trug
einen Fortschrittsbalken unter der Kachel und bot „Aus Weiterschauen
entfernen" an.

Der Server hatte diese Frage längst beantwortet. `ItemStateResponse` bringt
sie jetzt mit; `MediaItem.isResumable` und `.resumeProgress` sind die
einzigen Stellen, die sie noch stellen. Im Simulator nachgeprüft: nach
einer Sekunde heisst der Knopf „Abspielen", „Von vorn" fehlt, und der
Balken unter der Kachel ist weg.

Kennt ein Server das Feld nicht — Jellyfin, oder ein älterer Flaro —, gilt
wie früher die blosse Stelle.

**Und das Profilbild steht in den Apps.** `FlaroAccount` führt es mit,
`/auth/me` bringt es bei jedem Start frisch, und der Buchstabenkreis tritt
zurück, sobald eines da ist. Jellyfin bleibt beim Buchstaben — dort gibt es
kein Profilbild, und das ist keine Lücke, sondern der Stand der Dinge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 17:30:38 +02:00

199 lines
8.3 KiB
Swift

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,
avatarImage: antwort.user.avatarImage), 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,
avatarImage: wer.avatarImage), 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,
avatarImage: anmeldung.user.avatarImage)), 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)
}
}