import Foundation import MediaKit /// Wer wir für einen Jellyfin-Server sind. /// /// Jellyfin erwartet all das in **einer** Kopfzeile, in einer eigenen /// Schreibweise. Die Gerätekennung muss über Starts hinweg gleich bleiben: /// wechselt sie, gilt die App als neues Gerät, und in der Geräteliste des /// Servers sammelt sich Müll an. public struct JellyfinIdentity: Sendable, Equatable { public let client: String public let device: String public let deviceID: String public let version: String public init(client: String, device: String, deviceID: String, version: String) { self.client = client self.device = device self.deviceID = deviceID self.version = version } /// Die Kopfzeile, mit oder ohne Token. /// /// Anführungszeichen sind Pflicht, auch um Werte ohne Leerzeichen — /// Jellyfin zerlegt die Zeile sonst falsch und meldet, man sei nicht /// angemeldet, obwohl der Token stimmt. func authorizationHeader(token: String?) -> String { var fields = [ "Client=\"\(client)\"", "Device=\"\(device)\"", "DeviceId=\"\(deviceID)\"", "Version=\"\(version)\"", ] if let token { fields.append("Token=\"\(token)\"") } return "MediaBrowser " + fields.joined(separator: ", ") } } /// Spricht mit einem Jellyfin-Server. public actor JellyfinClient { public nonisolated let baseURL: URL private let identity: JellyfinIdentity private let transport: any JellyfinTransport private var token: String? public init( baseURL: URL, identity: JellyfinIdentity, token: String? = nil, transport: any JellyfinTransport = URLSession.shared ) { self.baseURL = baseURL self.identity = identity self.token = token self.transport = transport } public func setToken(_ token: String?) { self.token = token } /// Unter welcher Kennung der Server dieses Gerät führt. /// /// Wird zum Abräumen gebraucht: eine laufende Umrechnung wird über Gerät /// **und** Sitzung angehalten, nicht über die Sitzung allein. public nonisolated var deviceID: String { identity.deviceID } public nonisolated func authorization(token: String?) -> String { identity.authorizationHeader(token: token) } /// Die Adresse eines Endpunkts, mit Token in der Abfrage. /// /// Für alles, was nicht die App abruft, sondern der Systemplayer oder eine /// Bildansicht: die schicken keine Kopfzeilen mit. Bei Plex hat genau das /// tagelang gekostet — hier steht es von Anfang an. public nonisolated func url( path: String, query: [URLQueryItem] = [], includingToken token: String? = nil ) -> URL? { guard var components = URLComponents( url: baseURL.appending(path: path), resolvingAgainstBaseURL: false ) else { return nil } var items = query if let token { items.append(URLQueryItem(name: "ApiKey", value: token)) } components.queryItems = items.isEmpty ? nil : items return components.url } public nonisolated var currentTokenForURLs: String? { nil } // MARK: - Anfragen public func send( _ request: JellyfinRequest, 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: JellyfinRequest) 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 urlRequest.setValue(identity.authorizationHeader(token: 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: throw .server(status: http.statusCode) } } /// Jellyfin schreibt seine Felder groß und rechnet Zeiten in Ticks. /// /// Kein `convertFromPascalCase` — das gibt es nicht, und die Felder /// tragen ihre Schreibweise in den `CodingKeys` der Modelle. private static let decoder: JSONDecoder = { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return decoder }() } /// Eine Anfrage an Jellyfin. public struct JellyfinRequest: Sendable { public enum Method: String, Sendable { case get = "GET", post = "POST", 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 } } /// Wie Anfragen tatsächlich verschickt werden. /// /// Dieselbe Trennung wie bei Plex: Tests brauchen keinen Server, sondern /// vorbereitete Antworten. public protocol JellyfinTransport: Sendable { func data(for request: URLRequest) async throws -> (Data, URLResponse) } extension URLSession: JellyfinTransport {}