Bis hierher konnte man sich bei Jellyfin anmelden und stöbern, aber nichts abspielen. Der Player war die letzte große Stelle, die Plex unmittelbar kannte. Drei Mitglieder kommen dafür in den Vertrag: eine Wiedergabe vorbereiten, sie beenden, den Fortschritt melden. Die Entscheidung **ob** umgerechnet wird, fällt weiterhin vorher im `PlaybackDecider` — der Server führt sie nur aus. Genau diese Trennung fehlte: der Player hat Plex' Transcoder angesprochen und konnte deshalb mit nichts anderem reden. `MediaBackendError` bekommt einen siebten Fall: `refused(reason:)`. Der Server hat verstanden und sagt nein, in eigenen Worten. Bei Plex war genau dieser Satz die einzige brauchbare Spur, und er ging tagelang verloren, weil es dafür keinen Fall gab. Bei Jellyfin kommt die Adresse einer umgerechneten Fassung vom Server selbst (`PlaybackInfo` → `TranscodingUrl`), nicht von uns. Er weiß, welche Spuren er kopieren kann und welche er neu rechnen muss. Drei Dinge, die beim Hinsehen auffielen: - **`startTimeTicks` gehört nicht in die Anfrage.** Sonst entscheiden zwei Stellen über den Startpunkt: der Server ließe den Strom dort beginnen, und der Player spränge noch einmal um dieselbe Strecke vor. Bei einundzwanzig Minuten Fortschritt landet man bei zweiundvierzig. - **Der erste Bericht braucht einen eigenen Endpunkt.** Ohne ihn erscheint der Titel bei Jellyfin nirgends als „läuft gerade". Er fiel zunächst aus, weil er die Laufzeit verlangte — und die steht erst Sekunden nach dem Öffnen fest. Jetzt tut es für die erste Meldung die des Servers. - **In der Download-Adresse standen Titel und Fassung vertauscht.** Bei einem Film mit einer Fassung sind beide Kennungen dieselbe und nichts fällt auf; bei zweien schon. `MediaFile` sagt jetzt, zu welchem Titel sie gehört. Live-TV bleibt bei Plex und wird als Einziges noch unmittelbar durchgereicht. Ein Tuner ist ein Stück Hardware, und die fünfhundert Zeilen, die es gekostet hat, ihn zu bändigen, gehören in keinen gemeinsamen Vertrag. Nachgesehen, nicht nur gebaut: gegen die Attrappe läuft ein echtes Video, der Beginn wird gemeldet, der Fortschritt kommt in Ticks mit Sitzung und Fassung an. Was die Attrappe **nicht** beantworten kann, ist, ob ein echter Jellyfin das Geräteprofil annimmt und eine Umrechnung anbietet — das entscheidet erst ein richtiger Server. 257 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
383 lines
16 KiB
Swift
383 lines
16 KiB
Swift
import Foundation
|
|
import MediaKit
|
|
|
|
/// Jellyfin als `MediaBackend`.
|
|
public struct JellyfinBackend: MediaBackend {
|
|
let client: JellyfinClient
|
|
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 latestItems(limit: 20)
|
|
|
|
// 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? {
|
|
// Im Pfad steht der Titel, in der Abfrage die Fassung. Beides zu
|
|
// vertauschen fällt bei einem Film mit nur einer Fassung nicht auf —
|
|
// die Kennungen sind dann dieselbe — und bei zweien sofort.
|
|
guard let itemID = file.itemID ?? file.key else { return nil }
|
|
return client.url(
|
|
path: "/Videos/\(itemID)/stream",
|
|
query: [
|
|
URLQueryItem(name: "static", value: "true"),
|
|
URLQueryItem(name: "mediaSourceId", value: file.key ?? itemID),
|
|
],
|
|
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
|
|
|
|
/// Die Neuzugänge, wie der Server sie selbst versteht.
|
|
///
|
|
/// Ein eigener Endpunkt statt einer Liste, sortiert nach Anlegedatum —
|
|
/// und das aus zwei Gründen. Der erste ist die Sortierung selbst: sie kam
|
|
/// alphabetisch zurück, nicht nach Datum. Der zweite wiegt schwerer und
|
|
/// bliebe auch bestehen, wenn die Sortierung griffe: das Anlegedatum einer
|
|
/// **Serie** ist der Tag, an dem sie in die Mediathek kam. Eine Serie, die
|
|
/// seit zwei Jahren dort steht und gestern eine neue Folge bekommen hat,
|
|
/// ist neu — nach Anlegedatum wäre sie es nie.
|
|
///
|
|
/// Die Antwort ist hier ausnahmsweise ein nacktes Feld, kein Objekt mit
|
|
/// `Items` darin.
|
|
private func latestItems(limit: Int) async throws(MediaBackendError) -> [MediaItem] {
|
|
let items = try await client.send(
|
|
JellyfinRequest(path: "/Users/\(account.userID)/Items/Latest", query: [
|
|
URLQueryItem(name: "Limit", value: String(limit)),
|
|
URLQueryItem(name: "Fields", value: Self.fields),
|
|
// Neue Folgen erscheinen als ihre Serie statt als zwölf
|
|
// Einzelkacheln, die alle dasselbe Plakat tragen.
|
|
URLQueryItem(name: "GroupItems", value: "true"),
|
|
]),
|
|
as: [JellyfinItem].self
|
|
)
|
|
return items.map { $0.asMediaItem(imageBase: images) }
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|