`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>
295 lines
11 KiB
Swift
295 lines
11 KiB
Swift
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)"))
|
|
}
|
|
}
|
|
}
|