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>
269 lines
9.5 KiB
Swift
269 lines
9.5 KiB
Swift
import Foundation
|
|
import MediaKit
|
|
|
|
/// Plex als `MediaBackend`.
|
|
///
|
|
/// Eine Hülle um `PlexServer`, keine Umschreibung: die Endpunkte bleiben, wo
|
|
/// sie sind, und werden hier nur in die neutrale Form gebracht. Das ist der
|
|
/// ganze Zweck — die Oberfläche fragt nach `MediaItem`, und was dafür über die
|
|
/// Leitung geht, ist ihre Sache nicht.
|
|
public struct PlexBackend: MediaBackend {
|
|
/// Der darunterliegende Server.
|
|
///
|
|
/// Noch öffentlich, weil Player, Downloads und Live-TV vorerst direkt mit
|
|
/// ihm sprechen — diese drei Bereiche wandern als Nächstes.
|
|
public let server: PlexServer
|
|
|
|
public init(server: PlexServer) {
|
|
self.server = server
|
|
}
|
|
|
|
public nonisolated var serverName: String { server.name }
|
|
|
|
public nonisolated var capabilities: Set<BackendCapability> {
|
|
// Keine Favoriten: Plex kennt sie nicht, und ein Knopf, der nichts
|
|
// bewirkt, ist schlimmer als keiner.
|
|
[.liveTV, .ratings, .libraryScan, .transcoding]
|
|
}
|
|
|
|
// MARK: - Blättern
|
|
|
|
public func libraries() async throws(MediaBackendError) -> [MediaLibrary] {
|
|
try await mapping { try await server.libraries().map(\.asMediaLibrary) }
|
|
}
|
|
|
|
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
|
|
try await mapping { try await server.homeHubs().map(\.asMediaRowGroup) }
|
|
}
|
|
|
|
public func items(
|
|
inLibrary library: MediaLibrary,
|
|
query: MediaQuery,
|
|
offset: Int,
|
|
limit: Int
|
|
) async throws(MediaBackendError) -> MediaPage {
|
|
try await mapping {
|
|
let page = try await server.items(
|
|
inLibrary: plexLibrary(library),
|
|
query: LibraryQuery(query),
|
|
offset: offset,
|
|
limit: limit
|
|
)
|
|
return MediaPage(
|
|
items: page.items.map(\.asMediaItem),
|
|
offset: page.offset,
|
|
totalCount: page.totalSize
|
|
)
|
|
}
|
|
}
|
|
|
|
public func item(id: String) async throws(MediaBackendError) -> MediaItem {
|
|
try await mapping { try await server.metadata(ratingKey: id).asMediaItem }
|
|
}
|
|
|
|
public func children(of id: String) async throws(MediaBackendError) -> [MediaItem] {
|
|
try await mapping { try await server.children(of: id).map(\.asMediaItem) }
|
|
}
|
|
|
|
public func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup] {
|
|
try await mapping { try await server.search(text, limit: limit).map(\.asMediaRowGroup) }
|
|
}
|
|
|
|
public func filterOptions(
|
|
inLibrary library: MediaLibrary,
|
|
field: MediaQuery.Field
|
|
) async throws(MediaBackendError) -> [MediaFilterOption] {
|
|
try await mapping {
|
|
try await server
|
|
.filterOptions(inLibrary: plexLibrary(library), field: field.rawValue)
|
|
.map { MediaFilterOption(id: $0.key, title: $0.title) }
|
|
}
|
|
}
|
|
|
|
public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
|
|
try await mapping {
|
|
// Der Weg führt über die Geschwister; dafür braucht es den
|
|
// vollständigen Plex-Eintrag, nicht nur die Kennung.
|
|
let plex = try await server.metadata(ratingKey: item.id)
|
|
return try await server.nextEpisode(after: plex)?.asMediaItem
|
|
}
|
|
}
|
|
|
|
// MARK: - Bilder
|
|
|
|
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
|
|
server.artworkURL(path: path, width: width, height: height)
|
|
}
|
|
|
|
public nonisolated func rawImageURL(path: String?) -> URL? {
|
|
server.rawImageURL(path: path)
|
|
}
|
|
|
|
// MARK: - Herunterladen
|
|
|
|
public nonisolated func downloadURL(for file: MediaFile) -> URL? {
|
|
guard let key = file.key else { return nil }
|
|
// Nur der Schlüssel wird gebraucht; alles andere steckt schon in der
|
|
// Adresse oder ist für den Abruf ohne Belang.
|
|
return server.downloadURL(part: PlexPart(id: 0, key: key))
|
|
}
|
|
|
|
public nonisolated func compressedDownloadURL(
|
|
itemID: String,
|
|
versionIndex: Int,
|
|
maximumBitrate: Int,
|
|
resolution: String?
|
|
) -> URL? {
|
|
server.transcodedDownloadURL(
|
|
ratingKey: itemID,
|
|
mediaIndex: versionIndex,
|
|
maximumBitrate: maximumBitrate,
|
|
resolution: resolution
|
|
)
|
|
}
|
|
|
|
// MARK: - Abspielen
|
|
|
|
public func preparePlayback(
|
|
_ intent: PlaybackIntent
|
|
) async throws(MediaBackendError) -> PlaybackTarget {
|
|
guard let transcode = intent.transcode else {
|
|
// Unverändert übertragen: die Datei selbst, ohne Sitzung — es gibt
|
|
// nichts anzuhalten, was der Server angelegt hätte.
|
|
guard let key = intent.file.key,
|
|
let url = server.directPlayURL(part: PlexPart(id: 0, key: key))
|
|
else { throw .notFound }
|
|
return PlaybackTarget(url: url)
|
|
}
|
|
|
|
let request = TranscodeRequest(
|
|
ratingKey: intent.itemID,
|
|
mediaIndex: intent.versionIndex,
|
|
maximumBitrate: transcode.maximumBitrate,
|
|
resolution: transcode.resolution,
|
|
isLocal: intent.isLocalConnection
|
|
)
|
|
|
|
// Vorher fragen, ob der Server überhaupt mitspielt. Sagt er hier schon
|
|
// nein, steht der Grund im Klartext da — statt eines schwarzen Bildes
|
|
// nach zwanzig Sekunden Warten.
|
|
let decision = try await mapping { try await server.transcodeDecision(for: request) }
|
|
guard decision.isAllowed else {
|
|
throw .refused(reason: decision.explanation ?? "")
|
|
}
|
|
|
|
guard let url = server.transcodeURL(for: request) else { throw .notFound }
|
|
return PlaybackTarget(url: url, sessionID: request.sessionIdentifier)
|
|
}
|
|
|
|
public func endPlayback(sessionID: String) async {
|
|
await server.stopTranscode(sessionIdentifier: sessionID)
|
|
}
|
|
|
|
public func reportProgress(_ report: PlaybackReport) async {
|
|
await server.reportProgress(
|
|
ratingKey: report.itemID,
|
|
state: report.state,
|
|
time: report.time,
|
|
duration: report.duration
|
|
)
|
|
}
|
|
|
|
// MARK: - Zustand melden
|
|
|
|
public func markWatched(id: String) async throws(MediaBackendError) {
|
|
try await mapping { try await server.markWatched(ratingKey: id) }
|
|
}
|
|
|
|
public func markUnwatched(id: String) async throws(MediaBackendError) {
|
|
try await mapping { try await server.markUnwatched(ratingKey: id) }
|
|
}
|
|
|
|
public func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError) {
|
|
// Plex kennt keine Favoriten. Stillschweigend nichts zu tun wäre
|
|
// schlimmer als zu sagen, dass es das hier nicht gibt.
|
|
throw .notFound
|
|
}
|
|
|
|
public func rate(id: String, rating: Int) async throws(MediaBackendError) {
|
|
try await mapping { try await server.rate(ratingKey: id, rating: rating) }
|
|
}
|
|
|
|
public func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError) {
|
|
try await mapping { try await server.refreshLibrary(plexLibrary(library)) }
|
|
}
|
|
|
|
// MARK: - Hilfen
|
|
|
|
/// Baut aus der neutralen Mediathek die Plex-Form zurück.
|
|
///
|
|
/// Nur `key` und `type` werden gebraucht; alles andere hat der Server
|
|
/// ohnehin. Ein zweiter Abruf, nur um ein Modell vollständig zu machen,
|
|
/// das gleich wieder zu einer Adresse wird, wäre verschwendet.
|
|
private func plexLibrary(_ library: MediaLibrary) -> PlexLibrary {
|
|
let type: String = switch library.kind {
|
|
case .movie: "movie"
|
|
case .show: "show"
|
|
case .music: "artist"
|
|
case .photo: "photo"
|
|
case .liveTV: "livetv"
|
|
case .other: "unknown"
|
|
}
|
|
return PlexLibrary(key: library.id, title: library.title, type: type)
|
|
}
|
|
|
|
/// Führt einen Plex-Aufruf aus und übersetzt seinen Fehler.
|
|
///
|
|
/// Der Block wirft ungetypt, weil Swift den getippten Wurf durch eine
|
|
/// Closure hindurch nicht ableiten kann. Der zweite `catch` ist deshalb
|
|
/// nicht Zierde: gäbe es ihn nicht, fiele ein unerwarteter Fehler durch
|
|
/// und die Signatur wäre eine Lüge.
|
|
private func mapping<Value>(
|
|
_ work: () async throws -> Value
|
|
) async throws(MediaBackendError) -> Value {
|
|
do {
|
|
return try await work()
|
|
} catch let error as PlexError {
|
|
throw error.asMediaBackendError
|
|
} catch {
|
|
throw .transport(description: String(describing: error))
|
|
}
|
|
}
|
|
}
|
|
|
|
extension PlexError {
|
|
public var asMediaBackendError: MediaBackendError {
|
|
switch self {
|
|
case .unauthorized: .unauthorized
|
|
case .notFound: .notFound
|
|
case .noReachableConnection: .noReachableConnection
|
|
case .server(let status): .server(status: status)
|
|
case .transport(let description): .transport(description: description)
|
|
case .decoding(let description): .decoding(description: description)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension LibraryQuery {
|
|
/// Übersetzt die neutrale Abfrage in Plex' Schreibweise.
|
|
init(_ query: MediaQuery) {
|
|
let sort: Sort = switch query.sort {
|
|
case .titleAscending: .titleAscending
|
|
case .titleDescending: .titleDescending
|
|
case .recentlyAdded: .recentlyAdded
|
|
case .releaseDate: .releaseDate
|
|
case .rating: .rating
|
|
case .lastPlayed: .lastViewed
|
|
}
|
|
|
|
self.init(
|
|
sort: sort,
|
|
unwatchedOnly: query.unwatchedOnly,
|
|
genre: query.genre,
|
|
year: query.year,
|
|
contentRating: query.contentRating,
|
|
resolution: query.resolution
|
|
)
|
|
}
|
|
}
|