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:
Scarriffle
2026-09-12 15:38:31 +02:00
co-authored by Claude Opus 5
parent ef9fd676c8
commit 44dd794e86
3 changed files with 39 additions and 89 deletions
@@ -500,12 +500,7 @@ extension PlexServer {
// Doppelt kodiert zuerst: der Server dekodiert den Abfragewert // Doppelt kodiert zuerst: der Server dekodiert den Abfragewert
// einmal, bevor er ihn als Pfad liest. Nur so kommt bei ihm // einmal, bevor er ihn als Pfad liest. Nur so kommt bei ihm
// wieder `plex%3A%2F%2Fepisode%2F…` an statt `plex://episode/…`. // wieder `plex%3A%2F%2Fepisode%2F…` an statt `plex://episode/…`.
let variants: [(index: Int, profile: Bool, keep: Bool)] = [ let variants: [(index: Int, profile: Bool)] = [(0, true), (-1, true), (0, false)]
(0, true, false),
(-1, true, false),
(0, true, true),
(0, false, false),
]
for variant in variants { for variant in variants {
attempts += 1 attempts += 1
let outcome = await attemptStream( let outcome = await attemptStream(
@@ -513,7 +508,6 @@ extension PlexServer {
ratingKey: channel.id, ratingKey: channel.id,
mediaIndex: variant.index, mediaIndex: variant.index,
includeProfile: variant.profile, includeProfile: variant.profile,
keepPathEncoding: variant.keep,
maximumBitrate: maximumBitrate, maximumBitrate: maximumBitrate,
session: session session: session
) )
@@ -848,7 +842,6 @@ extension PlexServer {
ratingKey: String, ratingKey: String,
mediaIndex: Int, mediaIndex: Int,
includeProfile: Bool, includeProfile: Bool,
keepPathEncoding: Bool = true,
minimal: Bool = false, minimal: Bool = false,
maximumBitrate: Int?, maximumBitrate: Int?,
session: String session: String
@@ -866,7 +859,6 @@ extension PlexServer {
guard let url = transcodeURL( guard let url = transcodeURL(
for: request, for: request,
includeProfile: includeProfile, includeProfile: includeProfile,
keepPathEncoding: keepPathEncoding,
minimal: minimal minimal: minimal
) else { ) else {
return (nil, ChannelProbe(path: path, result: "Adresse nicht baubar")) return (nil, ChannelProbe(path: path, result: "Adresse nicht baubar"))
@@ -875,7 +867,6 @@ extension PlexServer {
let outcome = await probeStream(url: url) let outcome = await probeStream(url: url)
let label = "\(path) (mediaIndex \(mediaIndex)" let label = "\(path) (mediaIndex \(mediaIndex)"
+ "\(includeProfile ? "" : ", ohne Profil")" + "\(includeProfile ? "" : ", ohne Profil")"
+ "\(keepPathEncoding ? "" : ", doppelt kodiert")"
+ "\(minimal ? ", nur das Nötigste" : "") )" + "\(minimal ? ", nur das Nötigste" : "") )"
// Die gebaute Adresse mit in die Spur. Ohne sie diskutiert man über // Die gebaute Adresse mit in die Spur. Ohne sie diskutiert man über
@@ -1304,21 +1295,17 @@ extension PlexServer {
} }
var report: [String] = [] var report: [String] = []
for keepEncoding in [true, false] { do {
let request = TranscodeRequest( let request = TranscodeRequest(
ratingKey: channel.id, ratingKey: channel.id,
isLocal: connection.local, isLocal: connection.local,
pathOverride: metadataKey pathOverride: metadataKey
) )
guard let url = transcodeURL( guard let url = transcodeURL(for: request),
for: request, let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems
keepPathEncoding: keepEncoding else { return "Adresse nicht baubar" }
) else { continue }
guard let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems report.append("--- Entscheider ---")
else { continue }
report.append("--- Entscheider, \(keepEncoding ? "einfach" : "doppelt") kodiert ---")
do { do {
let data = try await client.send(PlexRequest( let data = try await client.send(PlexRequest(
path: "/video/:/transcode/universal/decision", path: "/video/:/transcode/universal/decision",
@@ -23,7 +23,6 @@ extension PlexServer {
public nonisolated func transcodeURL( public nonisolated func transcodeURL(
for request: TranscodeRequest, for request: TranscodeRequest,
includeProfile: Bool = true, includeProfile: Bool = true,
keepPathEncoding: Bool = true,
minimal: Bool = false minimal: Bool = false
) -> URL? { ) -> URL? {
guard let baseURL = connection.url, guard let baseURL = connection.url,
@@ -51,48 +50,34 @@ extension PlexServer {
: request.queryItems + [ : 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
components.percentEncodedQueryItems = components.percentEncodedQueryItems?.map { item in // Jeder Wert streng kodiert, ohne Ausnahme — genauso macht es Plex'
// `path` von Hand einsetzen heißt: genau einmal kodiert. Das ist // eigener Client, der alle Parameter durch `qs.stringify` schickt.
// für Dateien richtig. Für eine Sendung beim Programmführer muss //
// die Kennung ein zweites Mal kodiert werden — dann übernimmt das // `URLComponents` allein tut das nicht: es lässt `/`, `:`, `&`, `=`,
// `URLComponents` von selbst, und hier ist nichts zu tun. // `+` und Klammern in Werten stehen. Damit kam beim Server
if item.name == "path", keepPathEncoding { // `path=/livetv/sessions/…` an statt `path=%2Flivetv%2Fsessions%2F…`,
return URLQueryItem(name: "path", value: request.percentEncodedPath) // 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 return components.url
} }
/// Baut die Adresse eines Stroms, den der Server schon bereitstellt. /// Baut die Adresse eines Stroms, den der Server schon bereitstellt.
/// ///
/// Für Live-TV. Das Belegen eines Tuners liefert eine fertige /// Für Live-TV. Token und Gerätekennung müssen in die Adresse, weil sie
/// HLS-Abspielliste — die gehört abgerufen, nicht umgerechnet. Sie durch /// der Systemplayer abruft und der keine Kopfzeilen mitschickt.
/// 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.
public nonisolated func directStreamURL(forPath path: String) -> URL? { public nonisolated func directStreamURL(forPath path: String) -> URL? {
guard let baseURL = connection.url, guard let baseURL = connection.url,
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) 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") }) #expect(lookup.trace.contains { $0.path == "/livetv/dvrs/2/channels" && $0.result.contains("Channel: 2") })
} }
@Test("Die Adresse der Sendung wird genau einmal kodiert") @Test("Jeder Wert wird streng kodiert, Schrägstriche eingeschlossen")
func encodesPathExactlyOnce() async throws { func encodesEveryValueStrictly() async throws {
// Beide naheliegenden Wege liegen daneben, und beide habe ich // Plex' eigener Client schickt alle Parameter durch `qs.stringify`,
// durchgemacht: unverändert durchgereicht kodiert `URLComponents` ein // und das kodiert alles. `URLComponents` allein lässt `/`, `:`, `&`
// zweites Mal (%3A wird %253A); vorher entpackt steht `plex://…` im // und `=` in Werten stehen — beim Server kam `path=/livetv/…` an
// Klartext in der Anfrage, und daran verschluckt sich der Server mit // statt `path=%2Flivetv%2F…`, und die Antwort war eine nackte 400.
// einer nackten „400 Bad Request"-Seite.
let server = server(routes: [:]) let server = server(routes: [:])
let url = try #require(await server.transcodeURL(for: TranscodeRequest( 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", ratingKey: "5",
isLocal: true, isLocal: true,
pathOverride: "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fepisode%2Fabc" pathOverride: "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fepisode%2Fabc"
) ))).absoluteString
let once = try #require(await server.transcodeURL(for: request)).absoluteString #expect(url.contains("path=%2Ftv.plex.providers.epg.cloud%3A2%2Fmetadata%2F"))
let twice = try #require( // Eine Kennung, die schon `%3A` trägt, wird dabei zu `%253A` — und
await server.transcodeURL(for: request, keepPathEncoding: false) // ist damit genau einmal kodiert.
).absoluteString #expect(url.contains("plex%253A%252F%252Fepisode%252Fabc"))
#expect(!url.contains("plex://"))
#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://"))
} }
@Test("Nur was tatsächlich mit 200 antwortet, geht an den Player") @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. // Das ganze Profil steht in genau einem Parameter.
#expect(profile.contains("%26")) #expect(profile.contains("%26"))
#expect(profile.contains("add-transcode-target%28")) #expect(profile.contains("add-transcode-target%28"))
#expect(profile.contains("%2B"))
// Und `protocol` kommt nur ein einziges Mal vor. // Und `protocol` kommt nur ein einziges Mal vor.
#expect(text.split(separator: "&").filter { $0.hasPrefix("protocol=") }.count == 1) #expect(text.split(separator: "&").filter { $0.hasPrefix("protocol=") }.count == 1)
} }