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,189 @@
import Foundation
import MediaKit
/// Wer angemeldet ist.
public struct JellyfinAccount: Sendable, Equatable {
public let userID: String
public let userName: String
public let token: String
/// Ob dieses Konto den Server verwalten darf.
///
/// Entscheidet, ob der Verwaltungsbereich überhaupt erscheint. Ihn zu
/// zeigen und bei jedem Tipp „keine Berechtigung" zu melden, wäre die
/// schlechtere Antwort.
public let isAdministrator: Bool
public init(userID: String, userName: String, token: String, isAdministrator: Bool) {
self.userID = userID
self.userName = userName
self.token = token
self.isAdministrator = isAdministrator
}
}
/// Was ein Server über sich verrät, bevor man angemeldet ist.
public struct JellyfinServerInfo: Sendable, Equatable, Decodable {
public let serverName: String
public let version: String?
public let id: String?
/// Ob die Einrichtung abgeschlossen ist.
public let startupWizardCompleted: Bool?
private enum CodingKeys: String, CodingKey {
case serverName = "ServerName"
case version = "Version"
case id = "Id"
case startupWizardCompleted = "StartupWizardCompleted"
}
}
/// Der Code, mit dem sich die App ohne Passwort anmeldet.
public struct QuickConnectRequest: Sendable, Equatable, Decodable {
public let secret: String
public let code: String
private enum CodingKeys: String, CodingKey {
case secret = "Secret"
case code = "Code"
}
}
/// Meldet an einem Jellyfin-Server an.
///
/// Zwei Wege, und die Reihenfolge ist Absicht.
///
/// **Quick Connect** ist der erste: die App zeigt einen sechsstelligen Code,
/// den man in einem bereits angemeldeten Client bestätigt. Das Passwort
/// erreicht Flixy dabei nie — dieselbe Zusage, die bei Plex gilt, wo die
/// Anmeldemaske von Plex selbst kommt.
///
/// **Benutzername und Passwort** sind der Rückfall, falls der Server Quick
/// Connect abgeschaltet hat. Dann hält die App das Passwort für die Dauer
/// einer Anfrage — sie speichert es nicht, protokolliert es nicht und legt es
/// nirgends ab. Gespeichert wird nur der Token, und der im Schlüsselbund.
public actor JellyfinAuthenticator {
private let client: JellyfinClient
public init(client: JellyfinClient) {
self.client = client
}
/// Fragt den Server, ob er überhaupt einer ist.
public func serverInfo() async throws(MediaBackendError) -> JellyfinServerInfo {
try await client.send(
JellyfinRequest(path: "/System/Info/Public", timeout: .seconds(10)),
as: JellyfinServerInfo.self
)
}
/// Ob der Server die Anmeldung per Code anbietet.
public func supportsQuickConnect() async -> Bool {
// Schweigt er oder antwortet mit einem Fehler, gilt es als
// abgeschaltet. Ein Code, den niemand bestätigen kann, wäre eine
// Sackgasse mit Wartezeit.
guard let data = try? await client.send(JellyfinRequest(path: "/QuickConnect/Enabled")) else {
return false
}
let text = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
return text.lowercased() == "true"
}
public func beginQuickConnect() async throws(MediaBackendError) -> QuickConnectRequest {
try await client.send(
JellyfinRequest(method: .post, path: "/QuickConnect/Initiate"),
as: QuickConnectRequest.self
)
}
/// Sieht nach, ob der Code inzwischen bestätigt wurde.
///
/// Gibt `nil` zurück, solange er es nicht ist — der Aufrufer führt seine
/// eigene Schleife, weil er nebenher auf Abbruch achten muss.
public func completeQuickConnect(
secret: String
) async throws(MediaBackendError) -> JellyfinAccount? {
let state = try await client.send(
JellyfinRequest(
path: "/QuickConnect/Connect",
query: [URLQueryItem(name: "secret", value: secret)]
),
as: QuickConnectState.self
)
guard state.authenticated else { return nil }
let body = try encode(["Secret": secret])
return try await authenticate(
JellyfinRequest(method: .post, path: "/Users/AuthenticateWithQuickConnect", body: body)
)
}
/// Der Rückfall, wenn Quick Connect abgeschaltet ist.
public func signIn(
userName: String,
password: String
) async throws(MediaBackendError) -> JellyfinAccount {
let body = try encode(["Username": userName, "Pw": password])
return try await authenticate(
JellyfinRequest(method: .post, path: "/Users/AuthenticateByName", body: body)
)
}
private func authenticate(
_ request: JellyfinRequest
) async throws(MediaBackendError) -> JellyfinAccount {
let result = try await client.send(request, as: AuthenticationResult.self)
await client.setToken(result.accessToken)
return JellyfinAccount(
userID: result.user.id,
userName: result.user.name,
token: result.accessToken,
isAdministrator: result.user.policy?.isAdministrator ?? false
)
}
private func encode(_ fields: [String: String]) throws(MediaBackendError) -> Data {
do {
return try JSONSerialization.data(withJSONObject: fields)
} catch {
throw .decoding(description: String(describing: error))
}
}
}
private struct QuickConnectState: Decodable, Sendable {
let authenticated: Bool
private enum CodingKeys: String, CodingKey {
case authenticated = "Authenticated"
}
}
private struct AuthenticationResult: Decodable, Sendable {
let accessToken: String
let user: User
struct User: Decodable, Sendable {
let id: String
let name: String
let policy: Policy?
private enum CodingKeys: String, CodingKey {
case id = "Id"
case name = "Name"
case policy = "Policy"
}
}
struct Policy: Decodable, Sendable {
let isAdministrator: Bool?
private enum CodingKeys: String, CodingKey {
case isAdministrator = "IsAdministrator"
}
}
private enum CodingKeys: String, CodingKey {
case accessToken = "AccessToken"
case user = "User"
}
}
@@ -0,0 +1,360 @@
import Foundation
import MediaKit
/// Jellyfin als `MediaBackend`.
public struct JellyfinBackend: MediaBackend {
private let client: JellyfinClient
private let account: JellyfinAccount
private let images = JellyfinImagePaths()
public let serverName: String
public init(client: JellyfinClient, account: JellyfinAccount, serverName: String) {
self.client = client
self.account = account
self.serverName = serverName
}
public var isAdministrator: Bool { account.isAdministrator }
public nonisolated var capabilities: Set<BackendCapability> {
var capabilities: Set<BackendCapability> = [.favourites, .liveTV, .libraryScan, .transcoding]
// Verwaltung nur, wenn das Konto sie auch darf. Den Bereich zu zeigen
// und bei jedem Tipp abzulehnen, wäre die schlechtere Antwort.
if account.isAdministrator { capabilities.insert(.administration) }
return capabilities
}
// MARK: - Blättern
public func libraries() async throws(MediaBackendError) -> [MediaLibrary] {
let page = try await client.send(
JellyfinRequest(path: "/Users/\(account.userID)/Views"),
as: JellyfinItemPage.self
)
return page.items.map { view in
let kind: MediaLibrary.Kind = switch view.collectionType {
case "movies": .movie
case "tvshows": .show
case "music": .music
case "homevideos", "photos": .photo
case "livetv": .liveTV
default: .other
}
return MediaLibrary(
id: view.id,
title: view.name ?? "—",
kind: kind,
thumb: images.primary(itemID: view.id, tag: nil)
)
}
}
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
// Jellyfin hat keine fertigen Reihen wie Plex' Hubs — die Startseite
// wird aus einzelnen Abfragen zusammengesetzt. Das ist kein Mangel:
// so steht hier, was die Reihen bedeuten, statt dass es der Server
// entscheidet.
// „Zuletzt hinzugefügt" wird streng geholt: scheitert es, ist der
// Server nicht erreichbar, und dann gehört eine Fehlermeldung hin.
let latest = try await rowItems(path: "/Users/\(account.userID)/Items", query: [
URLQueryItem(name: "SortBy", value: "DateCreated"),
URLQueryItem(name: "SortOrder", value: "Descending"),
URLQueryItem(name: "IncludeItemTypes", value: "Movie,Series"),
URLQueryItem(name: "Recursive", value: "true"),
URLQueryItem(name: "Limit", value: "20"),
URLQueryItem(name: "Fields", value: Self.fields),
])
// Die beiden anderen nachsichtig: fällt „Als Nächstes" aus, soll
// deshalb nicht die ganze Startseite leer bleiben.
let resuming = try? await rowItems(path: "/Users/\(account.userID)/Items/Resume", query: [
URLQueryItem(name: "Limit", value: "20"),
URLQueryItem(name: "MediaTypes", value: "Video"),
URLQueryItem(name: "Fields", value: Self.fields),
])
let nextUp = try? await rowItems(path: "/Shows/NextUp", query: [
URLQueryItem(name: "userId", value: account.userID),
URLQueryItem(name: "Limit", value: "20"),
URLQueryItem(name: "Fields", value: Self.fields),
])
let rows = [
MediaRowGroup(
id: "resume",
title: String(localized: "row.continueWatching", bundle: .module),
items: resuming ?? []
),
MediaRowGroup(
id: "nextup",
title: String(localized: "row.nextUp", bundle: .module),
items: nextUp ?? []
),
MediaRowGroup(
id: "latest",
title: String(localized: "row.recentlyAdded", bundle: .module),
items: latest
),
]
// Leere Reihen wären auf der Startseite nur Überschriften ohne Inhalt.
return rows.filter { !$0.items.isEmpty }
}
public func items(
inLibrary library: MediaLibrary,
query: MediaQuery,
offset: Int,
limit: Int
) async throws(MediaBackendError) -> MediaPage {
var items: [URLQueryItem] = [
URLQueryItem(name: "ParentId", value: library.id),
URLQueryItem(name: "Recursive", value: "true"),
URLQueryItem(name: "StartIndex", value: String(offset)),
URLQueryItem(name: "Limit", value: String(limit)),
URLQueryItem(name: "Fields", value: Self.fields),
URLQueryItem(name: "SortBy", value: query.sort.jellyfinField),
URLQueryItem(name: "SortOrder", value: query.sort.isDescending ? "Descending" : "Ascending"),
]
// Ohne diese Angabe liefert eine Serienmediathek alle Folgen einzeln
// statt der Serien — dieselbe Falle wie bei Plex.
switch library.kind {
case .movie: items.append(URLQueryItem(name: "IncludeItemTypes", value: "Movie"))
case .show: items.append(URLQueryItem(name: "IncludeItemTypes", value: "Series"))
default: break
}
if query.unwatchedOnly { items.append(URLQueryItem(name: "Filters", value: "IsUnplayed")) }
if query.favouritesOnly { items.append(URLQueryItem(name: "Filters", value: "IsFavorite")) }
if let genre = query.genre { items.append(URLQueryItem(name: "Genres", value: genre)) }
if let year = query.year { items.append(URLQueryItem(name: "Years", value: String(year))) }
if let rating = query.contentRating {
items.append(URLQueryItem(name: "OfficialRatings", value: rating))
}
if let text = query.searchText { items.append(URLQueryItem(name: "searchTerm", value: text)) }
// Auflösung filtert Jellyfin nicht selbst; „4K" wird unten aussortiert.
let page = try await client.send(
JellyfinRequest(path: "/Users/\(account.userID)/Items", query: items),
as: JellyfinItemPage.self
)
var mapped = page.items.map { $0.asMediaItem(imageBase: images) }
var total = page.totalRecordCount ?? mapped.count
if query.resolution?.lowercased() == "4k" {
mapped = mapped.filter { ($0.preferredVersion?.pixelHeight ?? 0) >= 1_600 }
// Die Gesamtzahl stimmt dann nicht mehr — lieber die gefilterte
// Menge als eine Zahl, die zum Nachladen auffordert, obwohl nichts
// mehr kommt.
total = mapped.count + offset
}
return MediaPage(items: mapped, offset: offset, totalCount: total)
}
public func item(id: String) async throws(MediaBackendError) -> MediaItem {
let item = try await client.send(
JellyfinRequest(
path: "/Users/\(account.userID)/Items/\(id)",
query: [URLQueryItem(name: "Fields", value: Self.fields)]
),
as: JellyfinItem.self
)
return item.asMediaItem(imageBase: images)
}
public func children(of id: String) async throws(MediaBackendError) -> [MediaItem] {
try await rowItems(path: "/Users/\(account.userID)/Items", query: [
URLQueryItem(name: "ParentId", value: id),
URLQueryItem(name: "SortBy", value: "SortName"),
URLQueryItem(name: "Fields", value: Self.fields),
])
}
public func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup] {
let page = try await client.send(
JellyfinRequest(path: "/Users/\(account.userID)/Items", query: [
URLQueryItem(name: "searchTerm", value: text),
URLQueryItem(name: "Recursive", value: "true"),
URLQueryItem(name: "IncludeItemTypes", value: "Movie,Series,Episode"),
URLQueryItem(name: "Limit", value: String(limit)),
URLQueryItem(name: "Fields", value: Self.fields),
]),
as: JellyfinItemPage.self
)
// Nach Art gruppiert, weil Plex' Suche gruppiert zurückkommt und die
// Ergebnisansicht darauf ausgelegt ist.
let items = page.items.map { $0.asMediaItem(imageBase: images) }
let groups: [(MediaItem.Kind, String)] = [
(.movie, "search.movies"), (.show, "search.shows"), (.episode, "search.episodes"),
]
return groups.compactMap { kind, key in
let matching = items.filter { $0.kind == kind }
guard !matching.isEmpty else { return nil }
return MediaRowGroup(
id: kind.rawValue,
title: String(localized: String.LocalizationValue(key), bundle: .module),
items: matching
)
}
}
public func filterOptions(
inLibrary library: MediaLibrary,
field: MediaQuery.Field
) async throws(MediaBackendError) -> [MediaFilterOption] {
guard field == .genre else { return [] }
let page = try await client.send(
JellyfinRequest(path: "/Genres", query: [
URLQueryItem(name: "ParentId", value: library.id),
URLQueryItem(name: "userId", value: account.userID),
]),
as: JellyfinItemPage.self
)
return page.items.map { MediaFilterOption(id: $0.name ?? $0.id, title: $0.name ?? "—") }
}
public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
guard let seriesID = item.seriesID else { return nil }
let next = try await rowItems(path: "/Shows/NextUp", query: [
URLQueryItem(name: "userId", value: account.userID),
URLQueryItem(name: "SeriesId", value: seriesID),
URLQueryItem(name: "Limit", value: "1"),
URLQueryItem(name: "Fields", value: Self.fields),
])
// Die laufende Folge selbst ist nicht die nächste.
return next.first { $0.id != item.id }
}
// MARK: - Bilder
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
guard let path else { return nil }
return imageURL(path: path, extra: [
URLQueryItem(name: "fillWidth", value: String(width)),
URLQueryItem(name: "fillHeight", value: String(height)),
URLQueryItem(name: "quality", value: "90"),
])
}
public nonisolated func rawImageURL(path: String?) -> URL? {
guard let path else { return nil }
return imageURL(path: path, extra: [])
}
/// Setzt Pfad, mitgelieferte Marke und Größenangaben zusammen.
private nonisolated func imageURL(path: String, extra: [URLQueryItem]) -> URL? {
let pieces = path.split(separator: "?", maxSplits: 1)
var query = extra
if pieces.count > 1 {
query += URLComponents(string: "?\(pieces[1])")?.queryItems ?? []
}
// Bilder holt kein angemeldeter Client, sondern eine Bildansicht — sie
// schickt keine Kopfzeilen mit, also muss der Schlüssel in die Adresse.
return client.url(path: String(pieces[0]), query: query, includingToken: account.token)
}
// MARK: - Herunterladen
public nonisolated func downloadURL(for file: MediaFile) -> URL? {
guard let sourceID = file.key else { return nil }
return client.url(
path: "/Videos/\(sourceID)/stream",
query: [
URLQueryItem(name: "static", value: "true"),
URLQueryItem(name: "mediaSourceId", value: sourceID),
],
includingToken: account.token
)
}
public nonisolated func compressedDownloadURL(
itemID: String,
versionIndex: Int,
maximumBitrate: Int,
resolution: String?
) -> URL? {
// Jellyfin liefert die Adresse für eine umgerechnete Fassung erst nach
// einer Anfrage an `/PlaybackInfo` — das geht hier nicht, weil diese
// Methode keine Anfrage stellen darf. `nil` heißt: nur Original
// anbieten, statt eine Adresse zu erfinden, die nicht antwortet.
nil
}
// MARK: - Zustand melden
public func markWatched(id: String) async throws(MediaBackendError) {
try await client.send(JellyfinRequest(
method: .post,
path: "/Users/\(account.userID)/PlayedItems/\(id)"
))
}
public func markUnwatched(id: String) async throws(MediaBackendError) {
try await client.send(JellyfinRequest(
method: .delete,
path: "/Users/\(account.userID)/PlayedItems/\(id)"
))
}
public func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError) {
try await client.send(JellyfinRequest(
method: isFavourite ? .post : .delete,
path: "/Users/\(account.userID)/FavoriteItems/\(id)"
))
}
public func rate(id: String, rating: Int) async throws(MediaBackendError) {
// Jellyfin kennt nur Daumen hoch und runter, keine Sterne. Eine Zahl
// von eins bis zehn hier hineinzupressen hieße, etwas zu behaupten,
// was der Server nicht speichert.
throw .notFound
}
public func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError) {
try await client.send(JellyfinRequest(
method: .post,
path: "/Items/\(library.id)/Refresh",
query: [URLQueryItem(name: "Recursive", value: "true")]
))
}
// MARK: - Hilfen
private func rowItems(
path: String,
query: [URLQueryItem]
) async throws(MediaBackendError) -> [MediaItem] {
let page = try await client.send(
JellyfinRequest(path: path, query: query),
as: JellyfinItemPage.self
)
return page.items.map { $0.asMediaItem(imageBase: images) }
}
/// Was Jellyfin zusätzlich mitschicken soll.
///
/// Ohne diese Liste fehlen Beschreibung, Besetzung und Fassungen — und
/// jede Detailseite bräuchte eine zweite Anfrage.
private static let fields = "Overview,Genres,People,MediaSources,DateCreated,ParentId"
}
extension MediaQuery.Sort {
var jellyfinField: String {
switch self {
case .titleAscending, .titleDescending: "SortName"
case .recentlyAdded: "DateCreated"
case .releaseDate: "PremiereDate"
case .rating: "CommunityRating"
case .lastPlayed: "DatePlayed"
}
}
var isDescending: Bool {
switch self {
case .titleAscending: false
case .titleDescending, .recentlyAdded, .releaseDate, .rating, .lastPlayed: true
}
}
}
@@ -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 {}
@@ -0,0 +1,176 @@
import Foundation
import MediaKit
/// Die Übersetzung der Jellyfin-Modelle in die neutrale Form.
///
/// Das Gegenstück zu `PlexItem+Media`. Beide Dateien machen dasselbe, und dass
/// es sie zweimal gibt, ist der Preis dafür, dass es die Ansichten nur einmal
/// gibt.
extension JellyfinItem {
public func asMediaItem(imageBase: JellyfinImagePaths) -> MediaItem {
MediaItem(
id: id,
path: nil,
kind: Self.kind(type),
title: name ?? "—",
summary: overview,
year: productionYear,
duration: runTimeTicks?.millisecondsFromTicks,
contentRating: officialRating,
// Jellyfin führt beide auf einer Zehnerskala, wie Plex auch.
rating: criticRating.map { $0 / 10 } ?? communityRating,
audienceRating: communityRating,
thumb: imageBase.primary(itemID: id, tag: imageTags?["Primary"]),
art: imageBase.backdrop(itemID: id, tag: backdropImageTags?.first)
?? parentBackdropItemID.map { imageBase.backdrop(itemID: $0, tag: nil) } ?? nil,
parentThumb: seasonID.map { imageBase.primary(itemID: $0, tag: nil) } ?? nil,
seriesThumb: seriesID.map { imageBase.primary(itemID: $0, tag: seriesPrimaryImageTag) } ?? nil,
seriesArt: seriesID.map { imageBase.backdrop(itemID: $0, tag: nil) } ?? nil,
index: indexNumber,
parentIndex: parentIndexNumber,
parentTitle: seasonName,
parentID: seasonID,
seriesTitle: seriesName,
seriesID: seriesID,
playCount: userData?.playCount,
resumeMilliseconds: userData?.playbackPositionTicks?.millisecondsFromTicks,
lastPlayedAt: nil,
addedAt: dateCreated,
// Das Feld, das es bei Plex nicht gibt — hier kommt es frei mit.
isFavourite: userData?.isFavorite ?? false,
episodeCount: recursiveItemCount,
playedEpisodeCount: playedCount,
childCount: childCount,
versions: (mediaSources ?? []).map { $0.asMediaVersion(itemID: id) },
genres: genres ?? [],
directors: (people ?? []).filter { $0.type == "Director" }.compactMap(\.name),
writers: (people ?? []).filter { $0.type == "Writer" }.compactMap(\.name),
cast: (people ?? []).filter { $0.type == "Actor" }.map { person in
MediaPerson(
id: person.id ?? person.name ?? UUID().uuidString,
name: person.name ?? "—",
role: person.role,
thumb: person.id.map { imageBase.primary(itemID: $0, tag: person.primaryImageTag) } ?? nil
)
},
markers: [],
artwork: logoArtwork(imageBase: imageBase)
)
}
/// Wie viele Folgen gesehen sind.
///
/// Jellyfin meldet die **ungesehenen**. Die Zahl, die die Oberfläche
/// zeigt, ist die andere — und sie auszurechnen ist ehrlicher, als das
/// Gegenteil anzuzeigen und es „Fortschritt" zu nennen.
private var playedCount: Int? {
guard let total = recursiveItemCount, let unplayed = userData?.unplayedItemCount else {
return nil
}
return max(0, total - unplayed)
}
private func logoArtwork(imageBase: JellyfinImagePaths) -> [MediaArtwork] {
guard let tag = imageTags?["Logo"] else { return [] }
return [MediaArtwork(kind: .clearLogo, path: imageBase.logo(itemID: id, tag: tag))]
}
private static func kind(_ type: String?) -> MediaItem.Kind? {
switch type {
case "Movie": .movie
case "Series": .show
case "Season": .season
case "Episode": .episode
case "BoxSet": .collection
case "Trailer", "Video": .clip
default: nil
}
}
}
extension JellyfinMediaSource {
public func asMediaVersion(itemID: String) -> MediaVersion {
let streams = mediaStreams ?? []
let video = streams.first { $0.type == "Video" }
let audio = streams.first { $0.type == "Audio" && $0.isDefault == true }
?? streams.first { $0.type == "Audio" }
return MediaVersion(
id: id ?? itemID,
container: container,
videoCodec: video?.codec,
audioCodec: audio?.codec,
videoResolution: video?.height.map(String.init),
// Jellyfin rechnet in Bit je Sekunde, die App in Kilobit.
bitrate: bitrate.map { $0 / 1_000 },
width: video?.width,
height: video?.height,
audioChannels: audio?.channels,
duration: runTimeTicks?.millisecondsFromTicks,
files: [
// Jellyfin trennt Fassung und Datei nicht: eine Quelle ist
// beides. Die neutrale Form kennt beide Ebenen, weil Plex sie
// trennt — hier ist die zweite eben einelementig.
MediaFile(
id: id ?? itemID,
key: id,
container: container,
size: size,
duration: runTimeTicks?.millisecondsFromTicks,
streams: streams.compactMap(\.asMediaStream)
)
]
)
}
}
extension JellyfinMediaStream {
public var asMediaStream: MediaStream? {
let mapped: MediaStream.Kind
switch type {
case "Video": mapped = .video
case "Audio": mapped = .audio
case "Subtitle": mapped = .subtitle
default: return nil
}
return MediaStream(
id: String(index ?? 0),
kind: mapped,
codec: codec,
language: language,
languageCode: language,
displayTitle: displayTitle,
channels: channels,
isSelected: isDefault ?? false,
isForced: isForced ?? false
)
}
}
/// Baut die Adressen der Bilder.
///
/// Eigener Typ, weil jedes Bild eine eigene Adresse ist und nicht ein Pfad wie
/// bei Plex. Die Marke (`tag`) gehört dazu: ohne sie liefert der Server zwar
/// ein Bild, aber der Zwischenspeicher merkt nicht, wenn es sich ändert.
public struct JellyfinImagePaths: Sendable {
public init() {}
public func primary(itemID: String, tag: String?) -> String {
path(itemID: itemID, kind: "Primary", tag: tag)
}
public func backdrop(itemID: String, tag: String?) -> String {
path(itemID: itemID, kind: "Backdrop", tag: tag)
}
public func logo(itemID: String, tag: String?) -> String {
path(itemID: itemID, kind: "Logo", tag: tag)
}
private func path(itemID: String, kind: String, tag: String?) -> String {
var path = "/Items/\(itemID)/Images/\(kind)"
if let tag { path += "?tag=\(tag)" }
return path
}
}
@@ -0,0 +1,152 @@
import Foundation
import MediaKit
/// Ein Eintrag, wie Jellyfin ihn über die Leitung schickt.
///
/// Die Felder heißen groß und die Zeiten sind Ticks zu hundert Nanosekunden —
/// beides bleibt hier unten. Nach oben geht nur `MediaItem`.
public struct JellyfinItem: Decodable, Sendable {
public let id: String
public let name: String?
public let type: String?
public let overview: String?
public let productionYear: Int?
/// Laufzeit in Ticks zu 100 ns.
public let runTimeTicks: Int64?
public let indexNumber: Int?
public let parentIndexNumber: Int?
public let seriesName: String?
public let seriesID: String?
public let seasonID: String?
public let seasonName: String?
public let officialRating: String?
public let communityRating: Double?
public let criticRating: Double?
public let dateCreated: Date?
public let childCount: Int?
public let recursiveItemCount: Int?
public let genres: [String]?
public let people: [Person]?
public let userData: UserData?
public let imageTags: [String: String]?
public let backdropImageTags: [String]?
public let parentBackdropItemID: String?
public let seriesPrimaryImageTag: String?
public let mediaSources: [JellyfinMediaSource]?
public let collectionType: String?
public struct Person: Decodable, Sendable {
public let id: String?
public let name: String?
public let role: String?
public let type: String?
public let primaryImageTag: String?
private enum CodingKeys: String, CodingKey {
case id = "Id", name = "Name", role = "Role", type = "Type"
case primaryImageTag = "PrimaryImageTag"
}
}
public struct UserData: Decodable, Sendable {
public let playbackPositionTicks: Int64?
public let playCount: Int?
public let played: Bool?
public let isFavorite: Bool?
public let unplayedItemCount: Int?
private enum CodingKeys: String, CodingKey {
case playbackPositionTicks = "PlaybackPositionTicks"
case playCount = "PlayCount"
case played = "Played"
case isFavorite = "IsFavorite"
case unplayedItemCount = "UnplayedItemCount"
}
}
private enum CodingKeys: String, CodingKey {
case id = "Id", name = "Name", type = "Type", overview = "Overview"
case productionYear = "ProductionYear", runTimeTicks = "RunTimeTicks"
case indexNumber = "IndexNumber", parentIndexNumber = "ParentIndexNumber"
case seriesName = "SeriesName", seriesID = "SeriesId"
case seasonID = "SeasonId", seasonName = "SeasonName"
case officialRating = "OfficialRating", communityRating = "CommunityRating"
case criticRating = "CriticRating", dateCreated = "DateCreated"
case childCount = "ChildCount", recursiveItemCount = "RecursiveItemCount"
case genres = "Genres", people = "People", userData = "UserData"
case imageTags = "ImageTags", backdropImageTags = "BackdropImageTags"
case parentBackdropItemID = "ParentBackdropItemId"
case seriesPrimaryImageTag = "SeriesPrimaryImageTag"
case mediaSources = "MediaSources", collectionType = "CollectionType"
}
}
/// Eine Fassung, wie Jellyfin sie meldet.
public struct JellyfinMediaSource: Decodable, Sendable {
public let id: String?
public let container: String?
public let size: Int64?
public let bitrate: Int?
public let runTimeTicks: Int64?
public let mediaStreams: [JellyfinMediaStream]?
public let supportsDirectPlay: Bool?
public let supportsDirectStream: Bool?
public let supportsTranscoding: Bool?
public let transcodingURL: String?
private enum CodingKeys: String, CodingKey {
case id = "Id", container = "Container", size = "Size", bitrate = "Bitrate"
case runTimeTicks = "RunTimeTicks", mediaStreams = "MediaStreams"
case supportsDirectPlay = "SupportsDirectPlay"
case supportsDirectStream = "SupportsDirectStream"
case supportsTranscoding = "SupportsTranscoding"
case transcodingURL = "TranscodingUrl"
}
}
/// Eine Spur, wie Jellyfin sie meldet.
public struct JellyfinMediaStream: Decodable, Sendable {
public let index: Int?
public let type: String?
public let codec: String?
public let language: String?
public let displayTitle: String?
public let channels: Int?
public let isDefault: Bool?
public let isForced: Bool?
public let height: Int?
public let width: Int?
private enum CodingKeys: String, CodingKey {
case index = "Index", type = "Type", codec = "Codec", language = "Language"
case displayTitle = "DisplayTitle", channels = "Channels"
case isDefault = "IsDefault", isForced = "IsForced"
case height = "Height", width = "Width"
}
}
/// Die Hülle, in der Jellyfin Listen schickt.
public struct JellyfinItemPage: Decodable, Sendable {
public let items: [JellyfinItem]
public let totalRecordCount: Int?
public let startIndex: Int?
private enum CodingKeys: String, CodingKey {
case items = "Items"
case totalRecordCount = "TotalRecordCount"
case startIndex = "StartIndex"
}
}
extension Int64 {
/// Ticks zu hundert Nanosekunden in Millisekunden.
///
/// Jellyfins Einheit. Zehntausend Ticks sind eine Millisekunde — eine
/// Zahl, die man beim Lesen zweimal prüft und deshalb genau einmal
/// aufschreibt.
var millisecondsFromTicks: Int { Int(self / 10_000) }
}
extension Int {
var ticksFromMilliseconds: Int64 { Int64(self) * 10_000 }
}
@@ -0,0 +1,102 @@
{
"sourceLanguage": "en",
"version": "1.0",
"strings": {
"row.continueWatching": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Weiterschauen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Continue watching"
}
}
}
},
"row.nextUp": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Als Nächstes"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Next up"
}
}
}
},
"row.recentlyAdded": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Zuletzt hinzugefügt"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Recently added"
}
}
}
},
"search.movies": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Filme"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Movies"
}
}
}
},
"search.shows": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Serien"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Shows"
}
}
}
},
"search.episodes": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Folgen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Episodes"
}
}
}
}
}
}