Live-TV: die Adresse der Sendung wird genau einmal kodiert

Die nackte „400 Bad Request"-Seite war keine Plex-Meldung, sondern der
Webserver: er hat die Anfrage selbst nicht verstanden. Grund war mein
eigener Fix von vorhin — ich habe `%3A%2F%2F` entpackt, und damit stand
`plex://episode/…` im Klartext in der Anfrage.

Beide naheliegenden Wege liegen daneben, und ich habe beide durchgemacht:
unverändert durchgereicht kodiert URLComponents ein zweites Mal, aus %3A
wird %253A. Vorher entpackt bricht sich der Server die Adresse. Richtig
ist genau einmal — dafür wird `path` von Hand eingesetzt, nachdem alles
andere kodiert ist.

Dazu aus der Spur gelernt: jede Form von /livetv/sessions/… antwortet auf
diesem Server mit 404. Den Tuner belegt er offenbar selbst, sobald die
Sendung umgerechnet werden soll. Deshalb steht die Adresse der Sendung
jetzt an erster Stelle, und die Sitzungen kommen nur noch dahinter.

Als dritte Form ein Versuch ohne das Geräteprofil: dessen Angaben stecken
voller `&` und `=`, die in einem Abfragewert mehrdeutig sind. Trägt es
ohne, liegt es daran, und das steht dann in der Spur. Ebenso, was der
Programmanbieter überhaupt an Endpunkten angibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-12 14:18:52 +02:00
co-authored by Claude Opus 5
parent a9a45e685d
commit 1803dd670f
4 changed files with 137 additions and 54 deletions
@@ -382,6 +382,45 @@ extension PlexServer {
var trace: [ChannelProbe] = [] var trace: [ChannelProbe] = []
var attempts = 0 var attempts = 0
// Zuerst die Sendung selbst. Auf diesem Server antwortet jede Form von
// `/livetv/sessions/…` mit 404 — den Tuner belegt er offenbar von
// sich aus, sobald die Sendung umgerechnet werden soll.
if let metadataKey = program?.metadataKey {
// Ohne Profil als dritter Versuch: die Profilangaben stecken
// voller `&` und `=`, die in einem Abfragewert mehrdeutig sind.
// Trägt es ohne, liegt es daran — und das steht dann in der Spur.
let variants: [(index: Int, profile: Bool)] = [(0, true), (-1, true), (0, false)]
for variant in variants {
attempts += 1
let outcome = await attemptStream(
path: metadataKey,
ratingKey: channel.id,
mediaIndex: variant.index,
includeProfile: variant.profile,
maximumBitrate: maximumBitrate,
session: session
)
trace.append(outcome.probe)
if let url = outcome.url {
return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace)
}
}
}
// Was der Programmanbieter an Endpunkten angibt — für den Fall, dass
// dieser Server den Tuner doch selbst belegt haben will. Steht mit in
// der Spur, damit auch ein Fehlschlag etwas verrät.
if let epgIdentifier = program?.metadataKey?
.split(separator: "/").first.map(String.init) {
let features = await providerFeatures(for: epgIdentifier)
trace.append(ChannelProbe(
path: "Angebotene Endpunkte",
result: features.isEmpty
? "keine"
: features.map { "\($0.type)=\($0.key ?? "–")" }.joined(separator: ", ")
))
}
for key in Self.sessionKeys(channel: channel, program: program) { for key in Self.sessionKeys(channel: channel, program: program) {
let sessionPath = "/livetv/sessions/\(session)" let sessionPath = "/livetv/sessions/\(session)"
let openPath = "\(sessionPath)/\(key)" let openPath = "\(sessionPath)/\(key)"
@@ -408,47 +447,19 @@ extension PlexServer {
trace.append(ChannelProbe(path: openPath, result: "Sitzung offen")) trace.append(ChannelProbe(path: openPath, result: "Sitzung offen"))
for candidate in Self.streamCandidates(item: item, sessionPath: sessionPath) { for candidate in Self.streamCandidates(item: item, sessionPath: sessionPath) {
guard attempts < 8 else { break } guard attempts < 10 else { break }
attempts += 1 attempts += 1
let request = TranscodeRequest( let outcome = await attemptStream(
path: candidate.path,
ratingKey: item.ratingKey, ratingKey: item.ratingKey,
mediaIndex: candidate.mediaIndex, mediaIndex: candidate.mediaIndex,
partIndex: candidate.mediaIndex, includeProfile: true,
maximumBitrate: maximumBitrate, maximumBitrate: maximumBitrate,
isLocal: connection.local, session: session
sessionIdentifier: session,
pathOverride: candidate.path
) )
guard let url = transcodeURL(for: request) else { continue } trace.append(outcome.probe)
if let url = outcome.url {
let outcome = await probeStream(url: url)
trace.append(ChannelProbe(
path: "\(candidate.path) (mediaIndex \(candidate.mediaIndex))",
result: outcome
))
if outcome.hasPrefix("HTTP 200") {
return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace)
}
}
}
// Letzter Versuch ohne eigene Sitzung: die Sendung unmittelbar
// umrechnen lassen. Auf Servern, die den Tuner selbst belegen, genügt
// das — und geprüft wird es genauso wie alles andere.
if let metadataKey = program?.metadataKey, attempts < 8 {
let request = TranscodeRequest(
ratingKey: channel.id,
maximumBitrate: maximumBitrate,
isLocal: connection.local,
sessionIdentifier: session,
pathOverride: metadataKey
)
if let url = transcodeURL(for: request) {
let outcome = await probeStream(url: url)
trace.append(ChannelProbe(path: metadataKey, result: outcome))
if outcome.hasPrefix("HTTP 200") {
return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace) return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace)
} }
} }
@@ -460,6 +471,39 @@ extension PlexServer {
return LiveTuneResult(url: nil, sessionIdentifier: session, trace: trace) return LiveTuneResult(url: nil, sessionIdentifier: session, trace: trace)
} }
/// Baut eine Stromadresse und ruft sie einmal ab.
///
/// Getrennte Methode statt einer Hilfsfunktion in `tune`: eine solche
/// Funktion greift über jede Unterbrechung hinweg auf die veränderlichen
/// Werte daneben zu, und genau das lässt der Nebenläufigkeitsprüfer von
/// Swift 6 nicht durchgehen.
private func attemptStream(
path: String,
ratingKey: String,
mediaIndex: Int,
includeProfile: Bool,
maximumBitrate: Int?,
session: String
) async -> (url: URL?, probe: ChannelProbe) {
let request = TranscodeRequest(
ratingKey: ratingKey,
mediaIndex: mediaIndex,
partIndex: mediaIndex,
maximumBitrate: maximumBitrate,
isLocal: connection.local,
sessionIdentifier: session,
pathOverride: path
)
guard let url = transcodeURL(for: request, includeProfile: includeProfile) else {
return (nil, ChannelProbe(path: path, result: "Adresse nicht baubar"))
}
let outcome = await probeStream(url: url)
let label = "\(path) (mediaIndex \(mediaIndex)\(includeProfile ? "" : ", ohne Profil"))"
return (outcome.hasPrefix("HTTP 200") ? url : nil, ChannelProbe(path: label, result: outcome))
}
/// Welche Adresse mit welchem Index umzurechnen ist. /// Welche Adresse mit welchem Index umzurechnen ist.
/// ///
/// Die Adresse der Sitzung zuerst — der Transcoder sucht sich den Teil /// Die Adresse der Sitzung zuerst — der Transcoder sucht sich den Teil
@@ -521,6 +565,15 @@ extension PlexServer {
// Volle Adressen taugen hier nicht: an `/livetv/sessions/<uuid>/` // Volle Adressen taugen hier nicht: an `/livetv/sessions/<uuid>/`
// gehört ein Schlüssel, kein zweiter Pfad. // gehört ein Schlüssel, kein zweiter Pfad.
keys = keys.map { $0.split(separator: "/").last.map(String.init) ?? $0 } keys = keys.map { $0.split(separator: "/").last.map(String.init) ?? $0 }
// Und ein Schlüssel, der noch `%3A` enthält, ist als Pfadabschnitt
// keiner — die entpackte Form gehört mit in die Auswahl. Aber nur,
// solange dabei kein Schrägstrich herauskommt: `plex://movie/abc` ist
// als ein Pfadabschnitt genauso wenig brauchbar.
keys += keys.compactMap { key in
guard key.contains("%"), let plain = key.removingPercentEncoding,
!plain.contains("/") else { return nil }
return plain
}
var seen = Set<String>() var seen = Set<String>()
return keys.filter { !$0.isEmpty && seen.insert($0).inserted } return keys.filter { !$0.isEmpty && seen.insert($0).inserted }
@@ -9,7 +9,10 @@ extension PlexServer {
/// Bitrate während der Wiedergabe von selbst, je nachdem was die Leitung /// Bitrate während der Wiedergabe von selbst, je nachdem was die Leitung
/// hergibt. Genau das ist die zugesagte automatische Qualitätsanpassung — /// hergibt. Genau das ist die zugesagte automatische Qualitätsanpassung —
/// sie wird nicht selbst gerechnet, sondern kommt mit dem Format. /// sie wird nicht selbst gerechnet, sondern kommt mit dem Format.
public nonisolated func transcodeURL(for request: TranscodeRequest) -> URL? { public nonisolated func transcodeURL(
for request: TranscodeRequest,
includeProfile: Bool = true
) -> URL? {
guard let baseURL = connection.url, guard let baseURL = connection.url,
var components = URLComponents( var components = URLComponents(
url: baseURL.appending(path: "/video/:/transcode/universal/start.m3u8"), url: baseURL.appending(path: "/video/:/transcode/universal/start.m3u8"),
@@ -19,7 +22,19 @@ extension PlexServer {
components.queryItems = request.queryItems + [ components.queryItems = request.queryItems + [
URLQueryItem(name: "X-Plex-Token", value: token), URLQueryItem(name: "X-Plex-Token", value: token),
] + clientProfileItems + identity.queryItems ] + (includeProfile ? clientProfileItems : []) + identity.queryItems
// `path` wird von Hand eingesetzt, nachdem alles andere kodiert ist.
// Die Adresse kommt beim Programmanbieter bereits kodiert an, und
// beide Automatiken liegen daneben: `URLComponents` kodiert ein
// zweites Mal (%3A wird zu %253A), vorheriges Entpacken schreibt
// `plex://…` im Klartext in die Anfrage, und daran verschluckt sich
// der Server mit einer nackten 400-Seite.
components.percentEncodedQueryItems = components.percentEncodedQueryItems?.map { item in
item.name == "path"
? URLQueryItem(name: "path", value: request.percentEncodedPath)
: item
}
return components.url return components.url
} }
@@ -56,16 +56,24 @@ public struct TranscodeRequest: Sendable, Equatable {
self.pathOverride = pathOverride self.pathOverride = pathOverride
} }
/// Die Adresse, die umgerechnet werden soll — so, wie sie in die Anfrage
/// gehört: **bereits kodiert**.
///
/// Hier lagen beide naheliegenden Wege daneben. Unverändert durch
/// `URLComponents` geschickt, wird sie ein zweites Mal kodiert: aus `%3A`
/// wird `%253A`. Vorher entpackt, steht `plex://episode/…` im Klartext in
/// der Anfrage, und daran verschluckt sich der Server — er antwortet mit
/// einer nackten „400 Bad Request"-Seite, ohne Plex-Meldung.
///
/// Richtig ist: genau einmal kodiert. Darum kümmert sich `transcodeURL`,
/// indem es diesen Wert unangetastet einsetzt.
var percentEncodedPath: String {
pathOverride ?? "/library/metadata/\(ratingKey)"
}
var queryItems: [URLQueryItem] { var queryItems: [URLQueryItem] {
var items = [ var items = [
// Einmal entpackt, weil die Adresse beim Programmanbieter schon URLQueryItem(name: "path", value: percentEncodedPath),
// kodiert ankommt und `URLComponents` sonst ein zweites Mal
// kodiert — aus %3A wird %253A, und der Server findet nichts.
URLQueryItem(
name: "path",
value: pathOverride.map { $0.removingPercentEncoding ?? $0 }
?? "/library/metadata/\(ratingKey)"
),
URLQueryItem(name: "mediaIndex", value: String(mediaIndex)), URLQueryItem(name: "mediaIndex", value: String(mediaIndex)),
URLQueryItem(name: "partIndex", value: String(partIndex)), URLQueryItem(name: "partIndex", value: String(partIndex)),
URLQueryItem(name: "protocol", value: "hls"), URLQueryItem(name: "protocol", value: "hls"),
@@ -150,18 +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("Eine Adresse beim Programmanbieter wird nicht doppelt kodiert") @Test("Die Adresse der Sendung wird genau einmal kodiert")
func doesNotDoubleEncode() throws { func encodesPathExactlyOnce() async throws {
// Derselbe Fehler wie einst beim Anmelde-Link: `URLComponents` kodiert // Beide naheliegenden Wege liegen daneben, und beide habe ich
// ein zweites Mal, aus %3A wird %253A, und der Server findet nichts. // durchgemacht: unverändert durchgereicht kodiert `URLComponents` ein
let request = TranscodeRequest( // 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.
let server = server(routes: [:])
let url = try #require(await server.transcodeURL(for: TranscodeRequest(
ratingKey: "5", ratingKey: "5",
isLocal: true, isLocal: true,
pathOverride: "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2Fabc" pathOverride: "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2Fabc"
) )))
let path = try #require(request.queryItems.first { $0.name == "path" }?.value) let text = url.absoluteString
#expect(path == "/tv.plex.providers.epg.cloud:2/metadata/plex://movie/abc") #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("Nur was tatsächlich mit 200 antwortet, geht an den Player") @Test("Nur was tatsächlich mit 200 antwortet, geht an den Player")
@@ -227,7 +233,8 @@ struct LiveTVGuideTests {
let keys = PlexServer.sessionKeys(channel: channel, program: program) let keys = PlexServer.sessionKeys(channel: channel, program: program)
#expect(keys.first == "gk-5") #expect(keys.first == "gk-5")
// An `/livetv/sessions/<uuid>/` gehört ein Schlüssel, kein zweiter Pfad. // An `/livetv/sessions/<uuid>/` gehört ein Schlüssel, kein zweiter
// Pfad — auch nicht nach dem Entpacken.
#expect(keys.allSatisfy { !$0.contains("/") }) #expect(keys.allSatisfy { !$0.contains("/") })
#expect(keys.contains("5")) #expect(keys.contains("5"))
} }