Wiedergabe: das Geräteprofil zerfiel in zwei Dutzend eigene Parameter

`URLComponents` kodiert `&` und `=` innerhalb eines Abfragewerts nicht.
Das Geräteprofil steckt voll davon:

    X-Plex-Client-Profile-Extra=add-transcode-target(type=videoProfile&
      context=streaming&protocol=hls&container=mpegts&…)

Der Server hat das nicht als ein Profil gelesen, sondern als zwei Dutzend
eigene Angaben — darunter ein zweites `protocol=`. Eine widersprüchliche
Anfrage, und die Antwort darauf ist eine nackte 400 ohne Hinweis.

Der Fehler steckte in jeder Transcoder-Adresse, seit es sie gibt. Bei
Dateien fiel er nicht auf, weil dort meist DirectPlay greift und der
Transcoder gar nicht gefragt wird.

Kodiert wird der **ursprüngliche** Wert, nicht der bereits von
`URLComponents` behandelte: den ein zweites Mal zu kodieren macht aus
`%3D` ein `%253D` — derselbe Fehler, nur eine Ebene tiefer. Der Test
prüft beides: dass das Profil ein einziger Parameter bleibt und dass
`protocol` genau einmal vorkommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-12 15:32:20 +02:00
co-authored by Claude Opus 5
parent c24938590d
commit 3467f0f690
3 changed files with 73 additions and 20 deletions
@@ -691,17 +691,19 @@ extension PlexServer {
// angelegt hat, wird umgerechnet — nicht die Abspielliste darin und
// nicht meine eigene Kennung. Ihr Schlüssel steht in der Antwort.
if let sessionKey = Self.firstSessionKey(in: data) {
for withProfile in [true, false] {
let outcome = await attemptStream(
path: sessionKey,
ratingKey: channel.id,
mediaIndex: 0,
includeProfile: true,
includeProfile: withProfile,
maximumBitrate: maximumBitrate,
session: session
)
probes.append(outcome.probe)
if let url = outcome.url { return (url, subscription, probes) }
}
}
// Und die Abspielliste unmittelbar. Sie antwortete mit 404 statt 400:
// die Adresse stimmt, die Ressource steht nur nicht bereit.
@@ -32,20 +32,38 @@ extension PlexServer {
)
else { return nil }
components.queryItems = request.queryItems + [
let items = request.queryItems + [
URLQueryItem(name: "X-Plex-Token", value: token),
] + (includeProfile ? clientProfileItems : []) + identity.queryItems
components.queryItems = items
// `path` von Hand einsetzen heißt: genau einmal kodiert. Das ist für
// Dateien richtig. Für eine Sendung beim Programmführer muss die
// Kennung ein zweites Mal kodiert werden — dann übernimmt das
// `URLComponents` von selbst, und hier ist nichts zu tun.
if keepPathEncoding {
components.percentEncodedQueryItems = components.percentEncodedQueryItems?.map { item in
item.name == "path"
? URLQueryItem(name: "path", value: request.percentEncodedPath)
: item
// `path` von Hand einsetzen heißt: genau einmal kodiert. Das ist
// für Dateien richtig. Für eine Sendung beim Programmführer muss
// die Kennung ein zweites Mal kodiert werden — dann übernimmt das
// `URLComponents` von selbst, und hier ist nichts zu tun.
if item.name == "path", keepPathEncoding {
return URLQueryItem(name: "path", value: request.percentEncodedPath)
}
// Das Geräteprofil ist ein Wert voller `&`, `=` und Klammern —
// und `URLComponents` lässt genau die unangetastet. Der Server
// liest sie dann als eigene Parameter: aus einem Profil werden
// zwei Dutzend Angaben, darunter ein zweites `protocol=`. Das
// steckte in **jeder** Transcoder-Adresse, seit es sie gibt.
if item.name.hasSuffix("Profile-Extra"),
// Der **ursprüngliche** Wert, nicht der bereits von
// `URLComponents` behandelte: den ein zweites Mal zu kodieren
// macht aus `%3D` ein `%253D`.
let value = items.first(where: { $0.name == item.name })?.value {
return URLQueryItem(
name: item.name,
value: value.addingPercentEncoding(withAllowedCharacters: .plexParameterAllowed)
?? value
)
}
return item
}
return components.url
}
@@ -298,3 +316,15 @@ extension PlexServer {
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-._~"
)
}
@@ -350,6 +350,27 @@ struct LiveTVGuideTests {
== "/livetv/sessions/1268be73-1598-4fb9-ac9e-35825eba3fdd")
}
@Test("Das Geräteprofil zerfällt nicht in eigene Parameter")
func encodesProfileExtra() async throws {
// `URLComponents` lässt `&` und `=` in einem Wert unangetastet. Das
// Geräteprofil steckt voll davon, und der Server las es als zwei
// Dutzend eigene Angaben — darunter ein zweites `protocol=`. Der
// Fehler steckte in jeder Transcoder-Adresse, seit es sie gibt.
let server = server(routes: [:])
let url = try #require(
await server.transcodeURL(for: TranscodeRequest(ratingKey: "5", isLocal: true))
)
let text = url.absoluteString
let profile = try #require(text.split(separator: "&")
.first { $0.hasPrefix("X-Plex-Client-Profile-Extra=") })
// Das ganze Profil steht in genau einem Parameter.
#expect(profile.contains("%26"))
#expect(profile.contains("add-transcode-target%28"))
// Und `protocol` kommt nur ein einziges Mal vor.
#expect(text.split(separator: "&").filter { $0.hasPrefix("protocol=") }.count == 1)
}
@Test("Jede Wiedergabe meldet, dass sie die Entscheidungsantwort versteht")
func alwaysSendsHasMDE() {
// Plex' eigener Client schickt `hasMDE=1` bei jeder Wiedergabe mit.