Live-TV: die Sitzung des Servers umrechnen, und hasMDE mitschicken

Nachgelesen im Quelltext des Plex-Clients, Funktion `video`:

    path: server.resolveUrl(item.get("key")),   // /livetv/sessions/1268be73-…
    hasMDE: 1, mediaIndex: …, partIndex: …,
    protocol: "hls", …

Zwei Dinge fehlten. Umgerechnet wird die **Sitzung, die der Server beim
Belegen angelegt hat** — nicht die Abspielliste darin, nicht die Sendung
beim Programmführer, und schon gar nicht die Kennung, die ich selbst
erzeuge. Genau die hatte ich mehrfach eingesetzt und mich über 404
gewundert.

Und `hasMDE=1` — „ich verstehe die Entscheidungsantwort". Plex' Client
schickt das bei jeder Wiedergabe mit. Es fehlte bei allen bisherigen
Versuchen, auch bei denen, die sonst richtig waren.

Der Schlüssel wird suchend gelesen und dabei von der Abspielliste
unterschieden: beide beginnen mit /livetv/sessions/, aber nur eine endet
auf .m3u8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-12 15:28:49 +02:00
co-authored by Claude Opus 5
parent a9563be1e1
commit c24938590d
3 changed files with 77 additions and 3 deletions
@@ -687,9 +687,24 @@ extension PlexServer {
var probes = [ChannelProbe(path: "POST \(path)", result: "Tuner belegt: \(part)")] var probes = [ChannelProbe(path: "POST \(path)", result: "Tuner belegt: \(part)")]
// Erst unmittelbar: was beim Belegen herauskommt, ist bereits eine // Der Weg des Plex-Clients: die **Sitzung**, die der Server gerade
// fertige HLS-Abspielliste. Sie an den Transcoder zu geben heißt, ihn // angelegt hat, wird umgerechnet — nicht die Abspielliste darin und
// um die Umrechnung von etwas zu bitten, das schon fertig ist. // nicht meine eigene Kennung. Ihr Schlüssel steht in der Antwort.
if let sessionKey = Self.firstSessionKey(in: data) {
let outcome = await attemptStream(
path: sessionKey,
ratingKey: channel.id,
mediaIndex: 0,
includeProfile: true,
maximumBitrate: maximumBitrate,
session: session
)
probes.append(outcome.probe)
if let url = outcome.url { return (url, subscription, probes) }
}
// Und die Abspielliste unmittelbar. Sie antwortete mit 404 statt 400:
// die Adresse stimmt, die Ressource steht nur nicht bereit.
if let direct = directStreamURL(forPath: part) { if let direct = directStreamURL(forPath: part) {
let outcome = await probeStream(url: direct) let outcome = await probeStream(url: direct)
probes.append(ChannelProbe(path: "\(part) (unmittelbar)", result: outcome)) probes.append(ChannelProbe(path: "\(part) (unmittelbar)", result: outcome))
@@ -708,6 +723,35 @@ extension PlexServer {
return (outcome.url, subscription, probes) return (outcome.url, subscription, probes)
} }
/// Der Schlüssel der Live-Sitzung, die der Server angelegt hat.
///
/// `/livetv/sessions/1268be73-…` — die Sitzung des **Servers**, nicht die
/// Kennung, die der Client für seine Wiedergabe erzeugt. Ich habe
/// mehrfach die eigene eingesetzt und mich über 404 gewundert.
static func firstSessionKey(in data: Data) -> String? {
guard let root = try? JSONSerialization.jsonObject(with: data) else { return nil }
return firstSessionKey(in: root)
}
private static func firstSessionKey(in value: Any) -> String? {
if let dictionary = value as? [String: Any] {
if let key = dictionary["key"] as? String,
key.hasPrefix("/livetv/sessions/"),
!key.hasSuffix(".m3u8") {
return key
}
for nested in dictionary.values {
if let found = firstSessionKey(in: nested) { return found }
}
}
if let array = value as? [Any] {
for nested in array {
if let found = firstSessionKey(in: nested) { return found }
}
}
return nil
}
/// Die Kennung des Aufnahme-/Live-Vorgangs in einer Antwort. /// Die Kennung des Aufnahme-/Live-Vorgangs in einer Antwort.
static func subscriptionKey(in data: Data) -> String? { static func subscriptionKey(in data: Data) -> String? {
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
@@ -74,6 +74,10 @@ public struct TranscodeRequest: Sendable, Equatable {
var queryItems: [URLQueryItem] { var queryItems: [URLQueryItem] {
var items = [ var items = [
URLQueryItem(name: "path", value: percentEncodedPath), URLQueryItem(name: "path", value: percentEncodedPath),
// „Ich verstehe die Entscheidungsantwort." Plex' eigener Client
// schickt das bei jeder Wiedergabe mit. Ohne diese Angabe lehnt
// der Server eine Live-Sitzung mit einer nackten 400 ab.
URLQueryItem(name: "hasMDE", value: "1"),
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"),
@@ -334,6 +334,32 @@ struct LiveTVGuideTests {
#expect(result.subscriptionKey == "77") #expect(result.subscriptionKey == "77")
} }
@Test("Umgerechnet wird die Sitzung des Servers, nicht die eigene Kennung")
func transcodesTheServersSession() throws {
// `/livetv/sessions/1268be73-…` ist die Sitzung, die der Server beim
// Belegen angelegt hat. Ich habe mehrfach meine eigene erzeugte
// Kennung eingesetzt und mich über 404 gewundert.
let json = Data("""
{"MediaContainer":{"MediaSubscription":[{"key":"77","MetadataItem":{
"key":"/livetv/sessions/1268be73-1598-4fb9-ac9e-35825eba3fdd","live":"1",
"Media":[{"id":1,"Part":[{"key":"/livetv/sessions/1268be73/ABC/index.m3u8?offset=-1"}]}]}}]}}
""".utf8)
// Die Abspielliste selbst ist nicht gemeint — sie endet auf .m3u8.
#expect(PlexServer.firstSessionKey(in: json)
== "/livetv/sessions/1268be73-1598-4fb9-ac9e-35825eba3fdd")
}
@Test("Jede Wiedergabe meldet, dass sie die Entscheidungsantwort versteht")
func alwaysSendsHasMDE() {
// Plex' eigener Client schickt `hasMDE=1` bei jeder Wiedergabe mit.
// Ohne diese Angabe lehnt der Server eine Live-Sitzung mit einer
// nackten 400 ab — ohne zu sagen, was ihm fehlt.
let items = TranscodeRequest(ratingKey: "5", isLocal: true).queryItems
#expect(items.contains { $0.name == "hasMDE" && $0.value == "1" })
}
@Test("Die Teil-Adresse wird gefunden, egal wie tief sie liegt") @Test("Die Teil-Adresse wird gefunden, egal wie tief sie liegt")
func findsPartAtAnyDepth() throws { func findsPartAtAnyDepth() throws {
// Der Vorgang liegt je nach Serverfassung unterschiedlich tief // Der Vorgang liegt je nach Serverfassung unterschiedlich tief