Files
Flaro/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift
T
ScarriffleandClaude Opus 5 903bfe5f63 Der Player weiß jetzt, wie es ihm geht
Bisher gab es über eine laufende Wiedergabe zwei Zahlen — gemessene
Bitrate und verworfene Bilder —, und die standen nur zur Anzeige da. Die
Frage „trägt die Leitung das gerade" war nicht zu beantworten, weder für
den Nutzer noch für die App.

**Die wichtigste neue Zahl ist `variantCount`.** Sie kommt aus
`AVAsset.variants`, also vom Parser des Players, und beantwortet die
Frage, ob es überhaupt etwas zum Umschalten gibt. Steht dort 1, greift
jedes Nachregeln ins Leere — und genau das ist heute der Fall, weil die
App bei beiden Servern eine Playlist mit einer Stufe bestellt.

Dazu der Pufferstand aus `loadedTimeRanges`: der einzige echte
Frühwarner. Schrumpft er schneller, als die Zeit vergeht, kommt weniger
herein als hinausgeht — zehn bis zwanzig Sekunden, bevor das Bild steht.
Alles andere meldet sich erst danach.

**Verworfene Bilder werden jetzt über alle Protokolleinträge summiert.**
Das Zugriffsprotokoll legt bei jedem Stufenwechsel einen neuen an, dessen
Zähler bei null beginnt; wer nur `last` liest, hält eine Wiedergabe für
makellos, die schon zwanzigmal Bilder verworfen hat.

**Der Wachhund war blind für den häufigsten Fall.** Vierzig Sekunden am
Stück ohne Daten brechen ab — aber er setzte sich bei jedem kurzen
Erholen zurück, und zehn Rückler à dreißig Sekunden lösten ihn nie aus.
`StallLedger` zählt daneben mit, wie viel Zeit im gleitenden Fenster
fürs Warten draufging. Es bricht nichts ab, es stellt nur fest — und ist
ein reiner Typ ohne Uhr und ohne AVFoundation, damit sich eine Folge von
Rücklern als Zahlenreihe hinschreiben und das Urteil prüfen lässt.
Sieben Tests, darunter genau der Fall, an dem der alte Wachhund
vorbeisah.

Gezählt wird nur der Weg vom Spielen ins Warten. Das anfängliche Puffern
ist der Start, kein Rückler; würde es mitgezählt, hätte jede Wiedergabe
eine Vorstrafe.

Und zwei Kommentare sind berichtigt. Sie behaupteten, AVPlayer wechsle
die Bitrate von selbst und das sei „die zugesagte automatische
Qualitätsanpassung". Umschalten kann er nur zwischen Varianten, die in
der Liste stehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 14:59:29 +02:00

343 lines
15 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
)
}
/// 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-._~"
)
}