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