Wiedergabe: jeden Parameter streng kodieren, so wie Plex es selbst tut
Die gebaute Adresse anzusehen hat es in einem Blick beantwortet:
path=/livetv/sessions/0043d829-… ← meine
path=%2Flivetv%2Fsessions%2F0043d829-… ← Plex
Plex' Client schickt alle Parameter durch `qs.stringify`, und das kodiert
alles. `URLComponents` lässt `/`, `:`, `&`, `=`, `+` und Klammern in
Werten stehen. Beim Server kam damit ein Pfad mit echten Schrägstrichen
an — und ein Geräteprofil, das an seinen `&` in zwei Dutzend eigene
Angaben zerfiel. Beides beantwortet er mit einer nackten 400.
Eine Regel für alle Werte, keine Ausnahmen. Damit löst sich nebenbei die
Kodierfrage, die mich mehrere Anläufe gekostet hat: eine Kennung, die
schon `%3A` enthält, wird dabei zu `%253A` — und ist genau einmal
kodiert. Die Sonderbehandlung für `path` und das Profil entfällt, und mit
ihr die Schalter, mit denen ich zwischen falschen Formen hin- und
hergeschaltet habe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ef9fd676c8
commit
44dd794e86
@@ -500,12 +500,7 @@ extension PlexServer {
|
||||
// Doppelt kodiert zuerst: der Server dekodiert den Abfragewert
|
||||
// einmal, bevor er ihn als Pfad liest. Nur so kommt bei ihm
|
||||
// wieder `plex%3A%2F%2Fepisode%2F…` an statt `plex://episode/…`.
|
||||
let variants: [(index: Int, profile: Bool, keep: Bool)] = [
|
||||
(0, true, false),
|
||||
(-1, true, false),
|
||||
(0, true, true),
|
||||
(0, false, false),
|
||||
]
|
||||
let variants: [(index: Int, profile: Bool)] = [(0, true), (-1, true), (0, false)]
|
||||
for variant in variants {
|
||||
attempts += 1
|
||||
let outcome = await attemptStream(
|
||||
@@ -513,7 +508,6 @@ extension PlexServer {
|
||||
ratingKey: channel.id,
|
||||
mediaIndex: variant.index,
|
||||
includeProfile: variant.profile,
|
||||
keepPathEncoding: variant.keep,
|
||||
maximumBitrate: maximumBitrate,
|
||||
session: session
|
||||
)
|
||||
@@ -848,7 +842,6 @@ extension PlexServer {
|
||||
ratingKey: String,
|
||||
mediaIndex: Int,
|
||||
includeProfile: Bool,
|
||||
keepPathEncoding: Bool = true,
|
||||
minimal: Bool = false,
|
||||
maximumBitrate: Int?,
|
||||
session: String
|
||||
@@ -866,7 +859,6 @@ extension PlexServer {
|
||||
guard let url = transcodeURL(
|
||||
for: request,
|
||||
includeProfile: includeProfile,
|
||||
keepPathEncoding: keepPathEncoding,
|
||||
minimal: minimal
|
||||
) else {
|
||||
return (nil, ChannelProbe(path: path, result: "Adresse nicht baubar"))
|
||||
@@ -875,7 +867,6 @@ extension PlexServer {
|
||||
let outcome = await probeStream(url: url)
|
||||
let label = "\(path) (mediaIndex \(mediaIndex)"
|
||||
+ "\(includeProfile ? "" : ", ohne Profil")"
|
||||
+ "\(keepPathEncoding ? "" : ", doppelt kodiert")"
|
||||
+ "\(minimal ? ", nur das Nötigste" : "") )"
|
||||
|
||||
// Die gebaute Adresse mit in die Spur. Ohne sie diskutiert man über
|
||||
@@ -1304,21 +1295,17 @@ extension PlexServer {
|
||||
}
|
||||
|
||||
var report: [String] = []
|
||||
for keepEncoding in [true, false] {
|
||||
do {
|
||||
let request = TranscodeRequest(
|
||||
ratingKey: channel.id,
|
||||
isLocal: connection.local,
|
||||
pathOverride: metadataKey
|
||||
)
|
||||
guard let url = transcodeURL(
|
||||
for: request,
|
||||
keepPathEncoding: keepEncoding
|
||||
) else { continue }
|
||||
guard let url = transcodeURL(for: request),
|
||||
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems
|
||||
else { return "Adresse nicht baubar" }
|
||||
|
||||
guard let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems
|
||||
else { continue }
|
||||
|
||||
report.append("--- Entscheider, \(keepEncoding ? "einfach" : "doppelt") kodiert ---")
|
||||
report.append("--- Entscheider ---")
|
||||
do {
|
||||
let data = try await client.send(PlexRequest(
|
||||
path: "/video/:/transcode/universal/decision",
|
||||
|
||||
@@ -23,7 +23,6 @@ extension PlexServer {
|
||||
public nonisolated func transcodeURL(
|
||||
for request: TranscodeRequest,
|
||||
includeProfile: Bool = true,
|
||||
keepPathEncoding: Bool = true,
|
||||
minimal: Bool = false
|
||||
) -> URL? {
|
||||
guard let baseURL = connection.url,
|
||||
@@ -51,48 +50,34 @@ extension PlexServer {
|
||||
: request.queryItems + [
|
||||
URLQueryItem(name: "X-Plex-Token", value: token),
|
||||
] + (includeProfile ? clientProfileItems : []) + identity.queryItems
|
||||
components.queryItems = items
|
||||
|
||||
components.percentEncodedQueryItems = components.percentEncodedQueryItems?.map { item in
|
||||
// `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)
|
||||
// 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: "&")
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/// Baut die Adresse eines Stroms, den der Server schon bereitstellt.
|
||||
///
|
||||
/// Für Live-TV. Das Belegen eines Tuners liefert eine fertige
|
||||
/// HLS-Abspielliste — die gehört abgerufen, nicht umgerechnet. Sie durch
|
||||
/// den Transcoder zu schicken heißt, ihn um die Umrechnung von etwas zu
|
||||
/// bitten, das bereits fertig ist; er antwortet mit einer nackten 400.
|
||||
///
|
||||
/// Token und Gerätekennung müssen in die Adresse, weil sie der
|
||||
/// Systemplayer abruft und der keine Kopfzeilen mitschickt.
|
||||
/// 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)
|
||||
|
||||
@@ -150,47 +150,24 @@ struct LiveTVGuideTests {
|
||||
#expect(lookup.trace.contains { $0.path == "/livetv/dvrs/2/channels" && $0.result.contains("Channel: 2") })
|
||||
}
|
||||
|
||||
@Test("Die Adresse der Sendung wird genau einmal kodiert")
|
||||
func encodesPathExactlyOnce() async throws {
|
||||
// Beide naheliegenden Wege liegen daneben, und beide habe ich
|
||||
// durchgemacht: unverändert durchgereicht kodiert `URLComponents` ein
|
||||
// zweites Mal (%3A wird %253A); vorher entpackt steht `plex://…` im
|
||||
// Klartext in der Anfrage, und daran verschluckt sich der Server mit
|
||||
// einer nackten „400 Bad Request"-Seite.
|
||||
@Test("Jeder Wert wird streng kodiert, Schrägstriche eingeschlossen")
|
||||
func encodesEveryValueStrictly() async throws {
|
||||
// Plex' eigener Client schickt alle Parameter durch `qs.stringify`,
|
||||
// und das kodiert alles. `URLComponents` allein lässt `/`, `:`, `&`
|
||||
// und `=` in Werten stehen — beim Server kam `path=/livetv/…` an
|
||||
// statt `path=%2Flivetv%2F…`, und die Antwort war eine nackte 400.
|
||||
let server = server(routes: [:])
|
||||
let url = try #require(await server.transcodeURL(for: TranscodeRequest(
|
||||
ratingKey: "5",
|
||||
isLocal: true,
|
||||
pathOverride: "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2Fabc"
|
||||
)))
|
||||
let text = url.absoluteString
|
||||
|
||||
#expect(text.contains("path=/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2Fabc"))
|
||||
#expect(!text.contains("%253A"))
|
||||
#expect(!text.contains("plex://"))
|
||||
}
|
||||
|
||||
@Test("Für eine Sendung wird die Kennung zweimal kodiert")
|
||||
func doubleEncodesForLive() async throws {
|
||||
// Der Server dekodiert den Abfragewert einmal, bevor er ihn als Pfad
|
||||
// liest. Genau einmal kodiert kommt bei ihm `plex://episode/…` an —
|
||||
// kein gültiger Schlüssel, und die Antwort ist eine nackte 400.
|
||||
let server = server(routes: [:])
|
||||
let request = TranscodeRequest(
|
||||
ratingKey: "5",
|
||||
isLocal: true,
|
||||
pathOverride: "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fepisode%2Fabc"
|
||||
)
|
||||
))).absoluteString
|
||||
|
||||
let once = try #require(await server.transcodeURL(for: request)).absoluteString
|
||||
let twice = try #require(
|
||||
await server.transcodeURL(for: request, keepPathEncoding: false)
|
||||
).absoluteString
|
||||
|
||||
#expect(once.contains("plex%3A%2F%2Fepisode%2Fabc"))
|
||||
#expect(twice.contains("plex%253A%252F%252Fepisode%252Fabc"))
|
||||
// Und niemals im Klartext: daran verschluckt sich der Server.
|
||||
#expect(!twice.contains("plex://"))
|
||||
#expect(url.contains("path=%2Ftv.plex.providers.epg.cloud%3A2%2Fmetadata%2F"))
|
||||
// Eine Kennung, die schon `%3A` trägt, wird dabei zu `%253A` — und
|
||||
// ist damit genau einmal kodiert.
|
||||
#expect(url.contains("plex%253A%252F%252Fepisode%252Fabc"))
|
||||
#expect(!url.contains("plex://"))
|
||||
}
|
||||
|
||||
@Test("Nur was tatsächlich mit 200 antwortet, geht an den Player")
|
||||
@@ -367,6 +344,7 @@ struct LiveTVGuideTests {
|
||||
// Das ganze Profil steht in genau einem Parameter.
|
||||
#expect(profile.contains("%26"))
|
||||
#expect(profile.contains("add-transcode-target%28"))
|
||||
#expect(profile.contains("%2B"))
|
||||
// Und `protocol` kommt nur ein einziges Mal vor.
|
||||
#expect(text.split(separator: "&").filter { $0.hasPrefix("protocol=") }.count == 1)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user