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( _ 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)")) } } }