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>
334 lines
15 KiB
Swift
334 lines
15 KiB
Swift
import Foundation
|
|
import MediaKit
|
|
|
|
extension PlexServer {
|
|
// MARK: - Transcoding
|
|
|
|
/// Die Adresse des HLS-Streams für diese Wiedergabe.
|
|
///
|
|
/// HLS ist genau die Kost, für die AVPlayer gebaut ist: er wechselt die
|
|
/// Bitrate während der Wiedergabe von selbst, je nachdem was die Leitung
|
|
/// hergibt. Genau das ist die zugesagte automatische Qualitätsanpassung —
|
|
/// sie wird nicht selbst gerechnet, sondern kommt mit dem Format.
|
|
/// - Parameter keepPathEncoding: Ob `path` unverändert eingesetzt wird.
|
|
///
|
|
/// Hier steckt der Unterschied zwischen einer laufenden Sendung und
|
|
/// „400 Bad Request". Eine Datei in der Mediathek liegt unter
|
|
/// `/library/metadata/123` — da gibt es nichts zu kodieren. Eine Sendung
|
|
/// beim Programmführer liegt unter
|
|
/// `/…/metadata/plex%3A%2F%2Fepisode%2F…`, und der Server dekodiert den
|
|
/// Abfragewert einmal, bevor er ihn als Pfad liest. Unverändert
|
|
/// eingesetzt kommt bei ihm `plex://episode/…` an — kein gültiger
|
|
/// Schlüssel. Er braucht die Kennung ein zweites Mal kodiert, damit nach
|
|
/// seinem Dekodieren wieder `%3A%2F%2F` dasteht.
|
|
public nonisolated func transcodeURL(
|
|
for request: TranscodeRequest,
|
|
includeProfile: Bool = true,
|
|
minimal: Bool = false
|
|
) -> URL? {
|
|
guard let baseURL = connection.url,
|
|
var components = URLComponents(
|
|
url: baseURL.appending(path: "/video/:/transcode/universal/start.m3u8"),
|
|
resolvingAgainstBaseURL: false
|
|
)
|
|
else { return nil }
|
|
|
|
// Das Nötigste, um herauszufinden, an welcher Angabe der Server
|
|
// Anstoß nimmt. Eine nackte 400 sagt nicht, welcher Parameter ihr
|
|
// nicht passt — halbieren sagt es.
|
|
let items = minimal
|
|
? [
|
|
URLQueryItem(name: "path", value: request.percentEncodedPath),
|
|
URLQueryItem(name: "protocol", value: "hls"),
|
|
URLQueryItem(name: "hasMDE", value: "1"),
|
|
URLQueryItem(name: "mediaIndex", value: String(request.mediaIndex)),
|
|
URLQueryItem(name: "partIndex", value: String(request.partIndex)),
|
|
URLQueryItem(name: "session", value: request.sessionIdentifier),
|
|
URLQueryItem(name: "X-Plex-Session-Identifier", value: request.sessionIdentifier),
|
|
URLQueryItem(name: "X-Plex-Token", value: token),
|
|
URLQueryItem(name: "X-Plex-Client-Identifier", value: identity.clientIdentifier),
|
|
]
|
|
: request.queryItems + [
|
|
URLQueryItem(name: "X-Plex-Token", value: token),
|
|
] + (includeProfile ? clientProfileItems : []) + identity.queryItems
|
|
|
|
// Jeder Wert streng kodiert, ohne Ausnahme — genauso macht es Plex'
|
|
// eigener Client, der alle Parameter durch `qs.stringify` schickt.
|
|
//
|
|
// `URLComponents` allein tut das nicht: es lässt `/`, `:`, `&`, `=`,
|
|
// `+` und Klammern in Werten stehen. Damit kam beim Server
|
|
// `path=/livetv/sessions/…` an statt `path=%2Flivetv%2Fsessions%2F…`,
|
|
// und das Geräteprofil zerfiel an seinen `&` in zwei Dutzend eigene
|
|
// Angaben. Beides beantwortet er mit einer nackten 400.
|
|
//
|
|
// Nebenbei löst diese eine Regel die Kodierfrage, die mich mehrere
|
|
// Anläufe gekostet hat: eine Kennung, die bereits `%3A` enthält, wird
|
|
// hier zu `%253A` — und ist damit genau einmal richtig kodiert.
|
|
components.percentEncodedQuery = items
|
|
.map { item in
|
|
let value = item.value?
|
|
.addingPercentEncoding(withAllowedCharacters: .plexParameterAllowed) ?? ""
|
|
return "\(item.name)=\(value)"
|
|
}
|
|
.joined(separator: "&")
|
|
|
|
return components.url
|
|
}
|
|
|
|
/// Baut die Adresse eines Streams, den der Server schon bereitstellt.
|
|
///
|
|
/// Für Live-TV. Token und Gerätekennung müssen in die Adresse, weil sie
|
|
/// der Systemplayer abruft und der keine Kopfzeilen mitschickt.
|
|
public nonisolated func directStreamURL(forPath path: String) -> URL? {
|
|
guard let baseURL = connection.url,
|
|
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
|
|
else { return nil }
|
|
|
|
// Die Adresse aus der Sitzung bringt ihre eigene Abfrage mit
|
|
// (`?offset=-1.000000`); die darf nicht verlorengehen.
|
|
let pieces = path.split(separator: "?", maxSplits: 1)
|
|
components.percentEncodedPath = String(pieces[0])
|
|
|
|
var items = pieces.count > 1
|
|
? (URLComponents(string: "?\(pieces[1])")?.queryItems ?? [])
|
|
: []
|
|
items.append(URLQueryItem(name: "X-Plex-Token", value: token))
|
|
items += identity.queryItems
|
|
components.queryItems = items
|
|
|
|
return components.url
|
|
}
|
|
|
|
/// Fragt den Server, ob er diese Wiedergabe zulässt.
|
|
public func transcodeDecision(for request: TranscodeRequest) async throws(PlexError) -> TranscodeDecision {
|
|
try await client.send(
|
|
PlexRequest(
|
|
path: "/video/:/transcode/universal/decision",
|
|
query: request.queryItems + clientProfileItems
|
|
),
|
|
as: TranscodeDecision.self
|
|
)
|
|
}
|
|
|
|
/// Hält den Transcoder an.
|
|
///
|
|
/// Muss beim Verlassen des Players laufen. Ohne dieses Signal rechnet der
|
|
/// Server weiter, bis er von selbst merkt, dass niemand mehr abruft — das
|
|
/// dauert Minuten und kostet die ganze Zeit über Rechenleistung.
|
|
public func stopTranscode(sessionIdentifier: String) async {
|
|
_ = try? await client.send(
|
|
PlexRequest(
|
|
path: "/video/:/transcode/universal/stop",
|
|
query: [URLQueryItem(name: "session", value: sessionIdentifier)],
|
|
timeout: .seconds(5)
|
|
)
|
|
)
|
|
}
|
|
|
|
/// Was dieses Gerät abspielen kann, in der Schreibweise von Plex.
|
|
///
|
|
/// Der Server richtet seine Entscheidung danach aus. Die Angaben stammen
|
|
/// aus `DeviceCapabilities` — wären sie großzügiger als das, was iOS
|
|
/// wirklich kann, schickte Plex einen Stream, der hier stumm bliebe oder
|
|
/// schwarz.
|
|
private nonisolated var clientProfileItems: [URLQueryItem] {
|
|
[
|
|
// Das **Basisprofil**. Ohne diese eine Angabe lehnt der Server
|
|
// jede Anfrage an den Transcoder mit einer nackten 400 ab — und
|
|
// zwar jede, auch die an eine gewöhnliche Datei.
|
|
//
|
|
// Der Grund ist banal und war zwei Tage lang unsichtbar:
|
|
// `X-Plex-Client-Profile-Extra` **erweitert** ein Profil, es
|
|
// ersetzt keins. Ohne Basis gibt es nichts zu erweitern, und der
|
|
// Server kann keine Entscheidung bilden.
|
|
//
|
|
// „Chrome", weil dieses Profil auf jedem Server vorhanden ist und
|
|
// genau das kann, was auch AVPlayer kann: HLS mit H.264 und AAC.
|
|
// Die Feinheiten für iOS stehen ohnehin im Zusatz darunter.
|
|
URLQueryItem(name: "X-Plex-Client-Profile-Name", value: "Chrome"),
|
|
URLQueryItem(
|
|
name: "X-Plex-Client-Profile-Extra",
|
|
value: [
|
|
// Was ohne Umrechnung durchgereicht werden darf.
|
|
"add-transcode-target(type=videoProfile&context=streaming&protocol=hls&container=mpegts&videoCodec=h264&audioCodec=aac)",
|
|
// DTS und TrueHD kann iOS nicht — für sie ausdrücklich
|
|
// eine Umrechnung nach AAC verlangen, statt zu hoffen,
|
|
// dass Plex es von selbst merkt.
|
|
"add-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=aac)",
|
|
"add-limitation(scope=videoCodec&scopeName=h264&type=upperBound&name=video.level&value=52)",
|
|
].joined(separator: "+")
|
|
),
|
|
]
|
|
}
|
|
|
|
// MARK: - Fortschritt melden
|
|
|
|
/// Meldet die aktuelle Stelle an den Server.
|
|
///
|
|
/// Grundlage für das geräteübergreifende Weiterschauen: was hier
|
|
/// ankommt, sieht der Fernseher im Wohnzimmer. Fehler werden verschluckt —
|
|
/// eine Fehlermeldung mitten im Film wäre störender als ein verlorener
|
|
/// Fortschrittswert, und der nächste Bericht kommt in Sekunden.
|
|
public func reportProgress(
|
|
ratingKey: String,
|
|
state: PlaybackReportState,
|
|
time: Duration,
|
|
duration: Duration
|
|
) async {
|
|
_ = try? await client.send(
|
|
PlexRequest(
|
|
path: "/:/timeline",
|
|
query: [
|
|
URLQueryItem(name: "ratingKey", value: ratingKey),
|
|
URLQueryItem(name: "key", value: "/library/metadata/\(ratingKey)"),
|
|
// Plex kennt keinen eigenen Zustand für den Beginn.
|
|
URLQueryItem(
|
|
name: "state",
|
|
value: state == .started ? "playing" : state.rawValue
|
|
),
|
|
URLQueryItem(name: "time", value: String(time.milliseconds)),
|
|
URLQueryItem(name: "duration", value: String(duration.milliseconds)),
|
|
],
|
|
timeout: .seconds(10)
|
|
)
|
|
)
|
|
}
|
|
|
|
/// Markiert einen Titel als gesehen.
|
|
public func markWatched(ratingKey: String) async throws(PlexError) {
|
|
_ = try await client.send(scrobbleRequest(path: "/:/scrobble", ratingKey: ratingKey))
|
|
}
|
|
|
|
/// Nimmt die Markierung zurück.
|
|
public func markUnwatched(ratingKey: String) async throws(PlexError) {
|
|
_ = try await client.send(scrobbleRequest(path: "/:/unscrobble", ratingKey: ratingKey))
|
|
}
|
|
|
|
/// Setzt eine Bewertung. `rating` von 0 bis 10; 0 entfernt sie.
|
|
public func rate(ratingKey: String, rating: Int) async throws(PlexError) {
|
|
_ = try await client.send(
|
|
PlexRequest(path: "/:/rate", query: [
|
|
URLQueryItem(name: "key", value: ratingKey),
|
|
URLQueryItem(name: "identifier", value: "com.plexapp.plugins.library"),
|
|
URLQueryItem(name: "rating", value: String(max(0, min(10, rating)))),
|
|
])
|
|
)
|
|
}
|
|
|
|
/// Stößt einen Suchlauf in einer Mediathek an.
|
|
public func refreshLibrary(_ library: PlexLibrary) async throws(PlexError) {
|
|
_ = try await client.send(PlexRequest(path: "/library/sections/\(library.key)/refresh"))
|
|
}
|
|
|
|
private nonisolated func scrobbleRequest(path: String, ratingKey: String) -> PlexRequest {
|
|
PlexRequest(path: path, query: [
|
|
URLQueryItem(name: "key", value: ratingKey),
|
|
// Ohne diese Kennung antwortet Plex mit 400. Sie benennt die
|
|
// Bibliotheks-Erweiterung, an die sich die Meldung richtet.
|
|
URLQueryItem(name: "identifier", value: "com.plexapp.plugins.library"),
|
|
])
|
|
}
|
|
}
|
|
|
|
/// In welchem Zustand sich die Wiedergabe beim Melden befindet.
|
|
extension PlexServer {
|
|
/// Die nächste Folge nach der angegebenen.
|
|
///
|
|
/// Plex kennt keinen Endpunkt dafür. Der Weg führt über die Geschwister:
|
|
/// die Folgen derselben Staffel holen und die mit der nächsthöheren Nummer
|
|
/// nehmen. Ist die Staffel zu Ende, wird in der folgenden weitergesucht —
|
|
/// sonst bräche das Weiterschauen jedes Mal am Staffelende ab.
|
|
public func nextEpisode(after episode: PlexItem) async throws(PlexError) -> PlexItem? {
|
|
guard episode.kind == .episode,
|
|
let seasonKey = episode.parentRatingKey,
|
|
let currentIndex = episode.index
|
|
else { return nil }
|
|
|
|
let siblings = try await children(of: seasonKey)
|
|
if let next = siblings
|
|
.filter({ ($0.index ?? 0) > currentIndex })
|
|
.min(by: { ($0.index ?? 0) < ($1.index ?? 0) }) {
|
|
return next
|
|
}
|
|
|
|
// Staffelende: in der nächsten Staffel weitersuchen.
|
|
guard let showKey = episode.grandparentRatingKey,
|
|
let seasonNumber = episode.parentIndex
|
|
else { return nil }
|
|
|
|
let seasons = try await children(of: showKey)
|
|
guard let nextSeason = seasons
|
|
.filter({ ($0.index ?? 0) > seasonNumber })
|
|
.min(by: { ($0.index ?? 0) < ($1.index ?? 0) })
|
|
else { return nil }
|
|
|
|
return try await children(of: nextSeason.ratingKey)
|
|
.min(by: { ($0.index ?? 0) < ($1.index ?? 0) })
|
|
}
|
|
}
|
|
|
|
extension PlexServer {
|
|
/// Die Adresse für einen heruntergerechneten Download.
|
|
///
|
|
/// **Noch nicht an einem echten Server geprüft.** Der Weg nutzt den
|
|
/// Universal-Transcoder mit `protocol=http`, der einen fortlaufenden Stream
|
|
/// statt HLS liefert — anders als der offizielle Sync-Weg von Plex, der
|
|
/// umständlich und schlecht dokumentiert ist.
|
|
///
|
|
/// Zwei Dinge sind dabei offen und gehören am eigenen Server geprüft,
|
|
/// bevor die Einstellung angeboten wird:
|
|
/// 1. Ob Plex eine `Content-Length` mitschickt. Ohne sie gibt es keinen
|
|
/// Fortschrittsbalken und kein Fortsetzen nach dem Pausieren.
|
|
/// 2. Ob die entstehende Datei durchsuchbar ist. Ein Stream, der von vorn
|
|
/// gedacht ist, muss es nicht sein — dann ließe sich im Download nicht
|
|
/// springen.
|
|
///
|
|
/// Bis das geklärt ist, bleibt der Download in Originalqualität die
|
|
/// Voreinstellung.
|
|
public nonisolated func transcodedDownloadURL(
|
|
ratingKey: String,
|
|
mediaIndex: Int = 0,
|
|
maximumBitrate: Int,
|
|
resolution: String?
|
|
) -> URL? {
|
|
guard let baseURL = connection.url,
|
|
var components = URLComponents(
|
|
url: baseURL.appending(path: "/video/:/transcode/universal/start"),
|
|
resolvingAgainstBaseURL: false
|
|
)
|
|
else { return nil }
|
|
|
|
let request = TranscodeRequest(
|
|
ratingKey: ratingKey,
|
|
mediaIndex: mediaIndex,
|
|
maximumBitrate: maximumBitrate,
|
|
resolution: resolution,
|
|
// Ein Download ist kein Abspielen im Heimnetz: `wan` lässt Plex
|
|
// konservativer puffern, was für eine Datei egal ist.
|
|
isLocal: false
|
|
)
|
|
|
|
components.queryItems = request.queryItems
|
|
// Überschreibt `protocol=hls` aus der Anfrage: für eine Datei
|
|
// brauchen wir einen fortlaufenden Stream, keine Segmentliste.
|
|
.filter { $0.name != "protocol" && $0.name != "fastSeek" && $0.name != "copyts" }
|
|
+ [
|
|
URLQueryItem(name: "protocol", value: "http"),
|
|
URLQueryItem(name: "X-Plex-Token", value: token),
|
|
]
|
|
return components.url
|
|
}
|
|
}
|
|
|
|
|
|
extension CharacterSet {
|
|
/// Was in einem Abfragewert unangetastet bleiben darf.
|
|
///
|
|
/// Streng gehalten: alles andere wird kodiert. `&` und `=` sind hier die
|
|
/// eigentlichen Übeltäter — sie trennen Parameter, und `URLComponents`
|
|
/// kodiert sie in Werten nicht von selbst.
|
|
static let plexParameterAllowed = CharacterSet(
|
|
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
|
)
|
|
}
|