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:
co-authored by
Claude Opus 5
parent
c24938590d
commit
3467f0f690
@@ -691,16 +691,18 @@ extension PlexServer {
|
|||||||
// angelegt hat, wird umgerechnet — nicht die Abspielliste darin und
|
// angelegt hat, wird umgerechnet — nicht die Abspielliste darin und
|
||||||
// nicht meine eigene Kennung. Ihr Schlüssel steht in der Antwort.
|
// nicht meine eigene Kennung. Ihr Schlüssel steht in der Antwort.
|
||||||
if let sessionKey = Self.firstSessionKey(in: data) {
|
if let sessionKey = Self.firstSessionKey(in: data) {
|
||||||
let outcome = await attemptStream(
|
for withProfile in [true, false] {
|
||||||
path: sessionKey,
|
let outcome = await attemptStream(
|
||||||
ratingKey: channel.id,
|
path: sessionKey,
|
||||||
mediaIndex: 0,
|
ratingKey: channel.id,
|
||||||
includeProfile: true,
|
mediaIndex: 0,
|
||||||
maximumBitrate: maximumBitrate,
|
includeProfile: withProfile,
|
||||||
session: session
|
maximumBitrate: maximumBitrate,
|
||||||
)
|
session: session
|
||||||
probes.append(outcome.probe)
|
)
|
||||||
if let url = outcome.url { return (url, subscription, probes) }
|
probes.append(outcome.probe)
|
||||||
|
if let url = outcome.url { return (url, subscription, probes) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Und die Abspielliste unmittelbar. Sie antwortete mit 404 statt 400:
|
// Und die Abspielliste unmittelbar. Sie antwortete mit 404 statt 400:
|
||||||
|
|||||||
@@ -32,20 +32,38 @@ extension PlexServer {
|
|||||||
)
|
)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
|
|
||||||
components.queryItems = request.queryItems + [
|
let items = request.queryItems + [
|
||||||
URLQueryItem(name: "X-Plex-Token", value: token),
|
URLQueryItem(name: "X-Plex-Token", value: token),
|
||||||
] + (includeProfile ? clientProfileItems : []) + identity.queryItems
|
] + (includeProfile ? clientProfileItems : []) + identity.queryItems
|
||||||
|
components.queryItems = items
|
||||||
|
|
||||||
// `path` von Hand einsetzen heißt: genau einmal kodiert. Das ist für
|
components.percentEncodedQueryItems = components.percentEncodedQueryItems?.map { item in
|
||||||
// Dateien richtig. Für eine Sendung beim Programmführer muss die
|
// `path` von Hand einsetzen heißt: genau einmal kodiert. Das ist
|
||||||
// Kennung ein zweites Mal kodiert werden — dann übernimmt das
|
// für Dateien richtig. Für eine Sendung beim Programmführer muss
|
||||||
// `URLComponents` von selbst, und hier ist nichts zu tun.
|
// die Kennung ein zweites Mal kodiert werden — dann übernimmt das
|
||||||
if keepPathEncoding {
|
// `URLComponents` von selbst, und hier ist nichts zu tun.
|
||||||
components.percentEncodedQueryItems = components.percentEncodedQueryItems?.map { item in
|
if item.name == "path", keepPathEncoding {
|
||||||
item.name == "path"
|
return URLQueryItem(name: "path", value: request.percentEncodedPath)
|
||||||
? URLQueryItem(name: "path", value: request.percentEncodedPath)
|
|
||||||
: item
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
return components.url
|
||||||
}
|
}
|
||||||
@@ -298,3 +316,15 @@ extension PlexServer {
|
|||||||
return components.url
|
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")
|
== "/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")
|
@Test("Jede Wiedergabe meldet, dass sie die Entscheidungsantwort versteht")
|
||||||
func alwaysSendsHasMDE() {
|
func alwaysSendsHasMDE() {
|
||||||
// Plex' eigener Client schickt `hasMDE=1` bei jeder Wiedergabe mit.
|
// Plex' eigener Client schickt `hasMDE=1` bei jeder Wiedergabe mit.
|
||||||
|
|||||||
Reference in New Issue
Block a user