JellyfinKit: anmelden, stöbern, übersetzen

Der zweite Server. Eigenes Target neben PlexKit, nicht darin — die beiden
teilen die neutrale Schicht und sonst nichts.

Die Anmeldung geht zuerst über **Quick Connect**: die App zeigt einen
sechsstelligen Code, den man in einem bereits angemeldeten Client
bestätigt. Damit gilt hier dieselbe Zusage wie bei Plex — das Passwort
erreicht Flixy nie. Benutzername und Passwort bleiben als Rückfall, falls
der Server Quick Connect abgeschaltet hat; dann hält die App das Passwort
für die Dauer einer Anfrage und speichert es nirgends.

Zwei Stellen, an denen sich eine Übersetzung verrechnet, und die deshalb
Tests haben:

Jellyfin zählt Zeiten in Ticks zu hundert Nanosekunden. Eine Null zu viel,
und aus 44 Minuten werden siebeneinhalb Stunden.

Und es meldet die **ungesehenen** Folgen, nicht die gesehenen. Ungeprüft
übernommen stünde bei einer fast fertigen Serie „9 von 24" statt „15 von
24".

Dass die Adressen den Token tragen, steht hier von Anfang an — bei Plex
hat genau das tagelang gekostet, weil AVPlayer keine Kopfzeilen mitschickt.

Zwei Fähigkeiten sind ausdrücklich nicht da: Sterne-Bewertungen kennt
Jellyfin nicht, nur Daumen hoch und runter. Und die Adresse einer
verkleinerten Fassung gibt es erst nach einer Anfrage — sie zu erfinden
hieße, eine Adresse anzubieten, die nicht antwortet.

Die Startseite ist nachsichtig gebaut: fällt „Als Nächstes" aus, bleibt
deshalb nicht die ganze Seite leer.

Die App bietet Jellyfin noch nicht an — das ist der nächste Schritt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-13 12:59:02 +02:00
co-authored by Claude Opus 5
parent fe2d730c00
commit 9540f26dd8
10 changed files with 1422 additions and 0 deletions
@@ -0,0 +1,192 @@
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
}
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<Value: Decodable & Sendable>(
_ 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 {}