Live-TV: POST, nicht GET — nachgelesen statt geraten

Aus dem Quelltext von Plex' eigenem Web-Client:

    const o = `/livetv/dvrs/${t}/channels/${a}/tune`,
          l = {method:"POST", headers:{"X-Plex-Session-Identifier": …}}

Genau diesen Pfad hatte ich seit mehreren Runden richtig. Jedes Mal per
GET abgefragt, jedes Mal 404 — und ein 404 sieht aus wie „gibt es nicht"
statt wie „nicht so". Also habe ich den Pfad verworfen und weitergesucht,
wieder und wieder, an einer Stelle, die von Anfang an stimmte.

Die Antwort auf das POST trägt den Vorgang mitsamt Metadaten-Eintrag, und
dort steht die abspielbare Teil-Adresse — nicht im Raster und nicht bei
der Sendung. Sie wird suchend gelesen statt an fester Stelle: der Vorgang
liegt je nach Serverfassung unterschiedlich tief, und ein falscher Weg
hinein sähe wieder aus wie „nichts da".

Die Kennung wird wie in `encodeURIComponent` kodiert — insbesondere
bleibt der Bindestrich stehen. `.alphanumerics` hätte aus
`6228f7f2-5fc705fa` ein `6228f7f2%2D5fc705fa` gemacht, und der Server
fände den Sender nicht.

Dass die Antwort in einer öffentlichen Quelle stand, während ich zwei
Tage lang Adressen erfunden habe, ist die eigentliche Lehre dieser Runde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-12 15:22:28 +02:00
co-authored by Claude Opus 5
parent 08e6e14534
commit 562fd758d5
2 changed files with 153 additions and 21 deletions
@@ -401,29 +401,29 @@ extension PlexServer {
].joined(separator: ", ") ].joined(separator: ", ")
)] )]
// Zuerst der Weg über den Empfänger, mit **seiner** Sendernummer. // Der Weg, den Plex' eigener Client geht. Nachgelesen in seinem
// Alles bisher Probierte sprach den Empfänger in der Sprache des // Quelltext, nicht erraten:
// Programmführers an; das musste 404 geben. //
if let deviceChannel, let dvr { // POST /livetv/dvrs/{dvr}/channels/{kennung}/tune
for path in [ // X-Plex-Session-Identifier: <sitzung>
"/livetv/dvrs/\(dvr.key)/channels/\(deviceChannel)", //
"/livetv/sessions/\(session)/\(deviceChannel)", // **POST**, nicht GET. Genau diesen Pfad hatte ich seit mehreren
] { // Runden richtig — und jedes Mal mit der falschen Methode abgefragt.
// Ein GET darauf antwortet mit 404, und das sah aus wie „gibt es
// nicht" statt wie „nicht so".
if let dvr {
attempts += 1 attempts += 1
let outcome = await attemptStream( let outcome = await tuneThroughDVR(
path: path, channel: channel,
ratingKey: channel.id, dvr: dvr,
mediaIndex: -1, session: session,
includeProfile: true, maximumBitrate: maximumBitrate
maximumBitrate: maximumBitrate,
session: session
) )
trace.append(outcome.probe) trace.append(contentsOf: outcome.probes)
if let url = outcome.url { if let url = outcome.url {
return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace) return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace)
} }
} }
}
// Der Raster-Schlüssel als Adresse, unverkürzt. Bisher habe ich nur // Der Raster-Schlüssel als Adresse, unverkürzt. Bisher habe ich nur
// seinen letzten Abschnitt als Sitzungsschlüssel probiert — als // seinen letzten Abschnitt als Sitzungsschlüssel probiert — als
@@ -634,6 +634,83 @@ extension PlexServer {
return lines.joined(separator: "\n") return lines.joined(separator: "\n")
} }
/// Belegt einen Tuner über den Empfänger und liefert den Strom.
///
/// Die Antwort auf das POST trägt den Vorgang mitsamt Metadaten-Eintrag,
/// und **dort** steht die abspielbare Teil-Adresse — nicht im Raster und
/// nicht bei der Sendung. Danach ist es gewöhnliches Umrechnen.
private func tuneThroughDVR(
channel: PlexChannel,
dvr: PlexDVR,
session: String,
maximumBitrate: Int?
) async -> (url: URL?, probes: [ChannelProbe]) {
let identifier = channel.id.addingPercentEncoding(
withAllowedCharacters: .uriComponentAllowed
) ?? channel.id
let path = "/livetv/dvrs/\(dvr.key)/channels/\(identifier)/tune"
let data: Data
do {
data = try await client.send(PlexRequest(
method: .post,
path: path,
additionalHeaders: ["X-Plex-Session-Identifier": session],
timeout: .seconds(30),
isPathEncoded: true
))
} catch {
return (nil, [ChannelProbe(path: "POST \(path)", result: Self.shortDescription(of: error))])
}
guard let part = Self.firstPartKey(in: data) else {
return (nil, [ChannelProbe(
path: "POST \(path)",
result: "belegt, aber ohne Teil-Adresse — \(Self.containerSummary(data))"
)])
}
var probes = [ChannelProbe(path: "POST \(path)", result: "Tuner belegt: \(part)")]
let outcome = await attemptStream(
path: part,
ratingKey: channel.id,
mediaIndex: -1,
includeProfile: true,
maximumBitrate: maximumBitrate,
session: session
)
probes.append(outcome.probe)
return (outcome.url, probes)
}
/// Sucht die erste abspielbare Teil-Adresse irgendwo in einer Antwort.
///
/// Bewusst suchend statt an einer festen Stelle lesend: der Vorgang
/// liegt je nach Serverfassung unterschiedlich tief verschachtelt, und
/// ein falscher Pfad in die Antwort hinein sähe wieder aus wie „nichts da".
static func firstPartKey(in data: Data) -> String? {
guard let root = try? JSONSerialization.jsonObject(with: data) else { return nil }
return firstPartKey(in: root)
}
private static func firstPartKey(in value: Any) -> String? {
if let dictionary = value as? [String: Any] {
if let parts = dictionary["Part"] as? [[String: Any]],
let key = parts.first?["key"] as? String {
return key
}
for nested in dictionary.values {
if let found = firstPartKey(in: nested) { return found }
}
}
if let array = value as? [Any] {
for nested in array {
if let found = firstPartKey(in: nested) { return found }
}
}
return nil
}
/// Baut eine Stromadresse und ruft sie einmal ab. /// Baut eine Stromadresse und ruft sie einmal ab.
/// ///
/// Getrennte Methode statt einer Hilfsfunktion in `tune`: eine solche /// Getrennte Methode statt einer Hilfsfunktion in `tune`: eine solche
@@ -1143,3 +1220,16 @@ extension PlexServer {
text.replacingOccurrences(of: token, with: "«token»") text.replacingOccurrences(of: token, with: "«token»")
} }
} }
extension CharacterSet {
/// Was `encodeURIComponent` unangetastet lässt.
///
/// Nicht `.alphanumerics`: das kodiert auch den Bindestrich, und
/// Senderkennungen bestehen aus zwei Hälften mit einem dazwischen. Aus
/// `6228f7f2-5fc705fa` würde `6228f7f2%2D5fc705fa`, und der Server fände
/// den Sender nicht.
static let uriComponentAllowed = CharacterSet(
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()"
)
}
@@ -295,6 +295,48 @@ struct LiveTVGuideTests {
#expect(path == "/livetv/sessions/xyz/stream") #expect(path == "/livetv/sessions/xyz/stream")
} }
@Test("Der Tuner wird per POST belegt, und die Antwort trägt den Strom")
func tunesThroughDVRWithPost() async throws {
// Nachgelesen im Quelltext von Plex' eigenem Client:
// POST /livetv/dvrs/{dvr}/channels/{kennung}/tune
// Genau diesen Pfad hatte ich seit Runden richtig — und jedes Mal
// mit GET abgefragt. Die Antwort darauf ist 404, und das sah aus wie
// „gibt es nicht" statt wie „nicht so".
let tuned = """
{"MediaContainer":{"size":1,"MediaSubscription":[{"key":"77",
"MetadataItem":{"ratingKey":"live-77","key":"/livetv/sessions/abc","type":"clip",
"title":"blue ZOOM HD",
"Media":[{"id":9,"Part":[{"id":9,"key":"/livetv/sessions/abc/parts/9"}]}]}}]}}
"""
let server = server(routes: [
"/channels/6228f7f2-5fc705fa/tune": tuned,
"/start.m3u8": "#EXTM3U",
])
let channel = PlexChannel(id: "6228f7f2-5fc705fa", title: "blue ZOOM HD", key: "1")
let dvr = PlexDVR(key: "2")
let result = await server.tune(channel: channel, dvr: dvr, maximumBitrate: nil)
let url = try #require(result.url)
let path = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.first { $0.name == "path" }?.value)
#expect(path == "/livetv/sessions/abc/parts/9")
#expect(result.trace.contains { $0.path.hasPrefix("POST ") })
}
@Test("Die Teil-Adresse wird gefunden, egal wie tief sie liegt")
func findsPartAtAnyDepth() throws {
// Der Vorgang liegt je nach Serverfassung unterschiedlich tief
// verschachtelt. Ein fest verdrahteter Weg hinein sähe wieder aus
// wie „nichts da".
let json = Data("""
{"MediaContainer":{"Tief":{"Tiefer":[{"Media":[{"Part":[{"key":"/gefunden"}]}]}]}}}
""".utf8)
#expect(PlexServer.firstPartKey(in: json) == "/gefunden")
#expect(PlexServer.firstPartKey(in: Data("{}".utf8)) == nil)
}
@Test("Die Zuordnung übersetzt zwischen Programmführer und Empfänger") @Test("Die Zuordnung übersetzt zwischen Programmführer und Empfänger")
func translatesChannelKeyToDeviceChannel() { func translatesChannelKeyToDeviceChannel() {
// Der Programmführer kennt den Sender als `6228f7f2…-5fc706…`, der // Der Programmführer kennt den Sender als `6228f7f2…-5fc706…`, der