Files
Flaro/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift
T
ScarriffleandClaude Opus 5 d2237c29cd Fragen statt starten — die Probe überfuhr den Server
Beim letzten Lauf scheiterte auch die Anfrage, die vorher funktioniert
hatte. Kein neuer Befund, sondern mein Messfehler: die Probe warf sechs
Transcode-Sitzungen in Folge an, und danach lehnt Plex alles mit nackten
400ern ab — auch das, was vorher ging.

Jede Anfrage an `start.m3u8` startet einen Transcoder. Für eine
Parameterreihe ist das das falsche Werkzeug. Der **Entscheider** sagt,
was der Server täte, ohne etwas zu tun: Auflösung, Bitrate, und ob
umgerechnet oder umgepackt wird. Die Reihe läuft jetzt darüber.

Die zwei verbleibenden echten Sitzungen bekommen Abstand voneinander.

Der Wert wird aus der Antwort gesucht statt geparst — Plex antwortet je
nach Kopfzeile als XML oder JSON, und es geht um eine Anzeige, nicht um
Daten, mit denen weitergerechnet wird.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 18:15:05 +02:00

364 lines
16 KiB
Swift

import Foundation
import MediaKit
extension PlexServer {
// MARK: - Transcoding
/// Die Adresse des HLS-Streams für diese Wiedergabe.
///
/// HLS ist die Kost, für die AVPlayer gebaut ist — **aber nur, wenn die
/// Playlist mehr als eine Stufe hat.**
///
/// Hier stand, der Player wechsle die Bitrate von selbst, und das sei die
/// zugesagte automatische Anpassung. Das war falsch: umschalten kann er
/// nur zwischen den Varianten, die in der Liste stehen, und diese
/// Anfrage bestellt über `maxVideoBitrate` und `videoQuality` genau eine.
/// Bei einer Variante gibt es nichts zu wechseln — und genau deshalb
/// ruckelt es auf einer Leitung, die einbricht.
///
/// Wie viele Stufen tatsächlich ankommen, steht in
/// `PlaybackStatistics.variantCount`. Das ist der Parser des Players und
/// damit die einzige belastbare Auskunft darüber.
/// - 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
)
}
/// Die Entscheidung im Wortlaut — **ohne** etwas zu starten.
///
/// Der Entscheider sagt, was der Server *täte*: welche Auflösung, welche
/// Bitrate, ob umgerechnet oder umgepackt wird. Das ist das richtige
/// Werkzeug, um Parameter auszuprobieren.
///
/// Der Weg über `start.m3u8` ist es **nicht**: jede Anfrage dort wirft
/// einen Transcoder an, und ein halbes Dutzend davon in Folge bringt den
/// Server dazu, alles Weitere mit nackten 400ern abzulehnen — auch das,
/// was vorher ging. Genau so ist eine Messung schon einmal gekippt.
public func decisionText(for request: TranscodeRequest) async -> String {
let daten = try? await client.send(
PlexRequest(
path: "/video/:/transcode/universal/decision",
query: request.queryItems + clientProfileItems
)
)
guard let daten else { return "nicht erreichbar" }
return String(decoding: daten.prefix(4_000), as: UTF8.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-._~"
)
}