Live-TV: die Abspielliste ist fertig — sie gehört abgerufen, nicht umgerechnet

Das Belegen des Tuners klappt, und heraus kommt:

    /livetv/sessions/1b5bb998…/DBACCA6D…/index.m3u8?offset=-1.000000

Eine fertige HLS-Abspielliste. Ich habe sie an den Transcoder gegeben —
also darum gebeten, etwas umzurechnen, das schon fertig ist. Daher die
nackte 400. Sie wird jetzt unmittelbar abgerufen, mit Token und
Gerätekennung in der Adresse, weil der Systemplayer keine Kopfzeilen
mitschickt. Die eigene Abfrage der Sitzung bleibt dabei erhalten.

Dazu das Gegenstück, ebenfalls aus dem Quelltext des Plex-Clients:
`DELETE /media/subscriptions/{id}` gibt den Tuner wieder frei. Er ist ein
Stück Hardware — vier Stück gibt es, und ein nicht freigegebener fehlt
beim nächsten Einschalten. Das passiert jetzt beim Schließen des Players
und auch dann, wenn das Belegen zwar klappt, aber nichts Abspielbares
dabei herauskommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-12 15:25:56 +02:00
co-authored by Claude Opus 5
parent 562fd758d5
commit a9563be1e1
6 changed files with 120 additions and 11 deletions
@@ -421,7 +421,17 @@ extension PlexServer {
)
trace.append(contentsOf: outcome.probes)
if let url = outcome.url {
return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace)
return LiveTuneResult(
url: url,
sessionIdentifier: session,
subscriptionKey: outcome.subscription,
trace: trace
)
}
// Belegt, aber nicht abspielbar: den Tuner sofort wieder
// freigeben, statt ihn für nichts zu halten.
if let subscription = outcome.subscription {
await endLiveSession(subscriptionKey: subscription)
}
}
@@ -644,7 +654,7 @@ extension PlexServer {
dvr: PlexDVR,
session: String,
maximumBitrate: Int?
) async -> (url: URL?, probes: [ChannelProbe]) {
) async -> (url: URL?, subscription: String?, probes: [ChannelProbe]) {
let identifier = channel.id.addingPercentEncoding(
withAllowedCharacters: .uriComponentAllowed
) ?? channel.id
@@ -660,17 +670,32 @@ extension PlexServer {
isPathEncoded: true
))
} catch {
return (nil, [ChannelProbe(path: "POST \(path)", result: Self.shortDescription(of: error))])
return (nil, nil, [ChannelProbe(path: "POST \(path)", result: Self.shortDescription(of: error))])
}
// Die Kennung des Vorgangs. Ohne sie bleibt der Tuner nach dem
// Zusehen belegt, und bei vier Tunern ist der fünfte Sender dann
// nicht mehr einzuschalten.
let subscription = Self.subscriptionKey(in: data)
guard let part = Self.firstPartKey(in: data) else {
return (nil, [ChannelProbe(
return (nil, subscription, [ChannelProbe(
path: "POST \(path)",
result: "belegt, aber ohne Teil-Adresse — \(Self.containerSummary(data))"
)])
}
var probes = [ChannelProbe(path: "POST \(path)", result: "Tuner belegt: \(part)")]
// Erst unmittelbar: was beim Belegen herauskommt, ist bereits eine
// fertige HLS-Abspielliste. Sie an den Transcoder zu geben heißt, ihn
// um die Umrechnung von etwas zu bitten, das schon fertig ist.
if let direct = directStreamURL(forPath: part) {
let outcome = await probeStream(url: direct)
probes.append(ChannelProbe(path: "\(part) (unmittelbar)", result: outcome))
if outcome.hasPrefix("HTTP 200") { return (direct, subscription, probes) }
}
let outcome = await attemptStream(
path: part,
ratingKey: channel.id,
@@ -680,7 +705,38 @@ extension PlexServer {
session: session
)
probes.append(outcome.probe)
return (outcome.url, probes)
return (outcome.url, subscription, probes)
}
/// Die Kennung des Aufnahme-/Live-Vorgangs in einer Antwort.
static func subscriptionKey(in data: Data) -> String? {
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let container = root["MediaContainer"] as? [String: Any]
else { return nil }
for name in ["MediaSubscription", "MediaGrabOperation"] {
if let entries = container[name] as? [[String: Any]],
let key = entries.first?["key"] {
return String(describing: key)
}
if let entry = container[name] as? [String: Any], let key = entry["key"] {
return String(describing: key)
}
}
return nil
}
/// Gibt den Tuner wieder frei.
///
/// So macht es Plex' eigener Client: `DELETE /media/subscriptions/{id}`.
/// Ohne dieses Signal bleibt der Tuner belegt, bis der Server es von
/// selbst merkt — und solange fehlt er beim nächsten Einschalten.
public func endLiveSession(subscriptionKey: String) async {
_ = try? await client.send(PlexRequest(
method: .delete,
path: "/media/subscriptions/\(subscriptionKey)",
timeout: .seconds(10)
))
}
/// Sucht die erste abspielbare Teil-Adresse irgendwo in einer Antwort.
@@ -1139,6 +1195,8 @@ public struct LiveTVGuide: Sendable {
public struct LiveTuneResult: Sendable {
public let url: URL?
public let sessionIdentifier: String
/// Womit der Tuner wieder freigegeben wird.
public var subscriptionKey: String?
/// Jeder versuchte Weg mit seiner Antwort.
public let trace: [ChannelProbe]
}
@@ -50,6 +50,35 @@ extension PlexServer {
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.
public nonisolated func directStreamURL(forPath path: String) -> URL? {
guard let baseURL = connection.url,
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
else { return nil }
// Die Adresse aus der Sitzung bringt ihre eigene Abfrage mit
// (`?offset=-1.000000`); die darf nicht verlorengehen.
let pieces = path.split(separator: "?", maxSplits: 1)
components.percentEncodedPath = String(pieces[0])
var items = pieces.count > 1
? (URLComponents(string: "?\(pieces[1])")?.queryItems ?? [])
: []
items.append(URLQueryItem(name: "X-Plex-Token", value: token))
items += identity.queryItems
components.queryItems = items
return components.url
}
/// Fragt den Server, ob er diese Wiedergabe zulässt.
public func transcodeDecision(for request: TranscodeRequest) async throws(PlexError) -> TranscodeDecision {
try await client.send(
@@ -306,22 +306,32 @@ struct LiveTVGuideTests {
{"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"}]}]}}]}}
"Media":[{"id":9,"Part":[{"id":9,
"key":"/livetv/sessions/abc/SITZUNG/index.m3u8?offset=-1.000000"}]}]}}]}}
"""
let server = server(routes: [
"/channels/6228f7f2-5fc705fa/tune": tuned,
"/start.m3u8": "#EXTM3U",
"/index.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")
// Unmittelbar abgerufen, nicht umgerechnet: was beim Belegen
// herauskommt, ist bereits eine fertige HLS-Abspielliste.
#expect(url.path() == "/livetv/sessions/abc/SITZUNG/index.m3u8")
#expect(!url.absoluteString.contains("/transcode/"))
// Die eigene Abfrage der Sitzung darf nicht verlorengehen.
let items = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems)
#expect(items.contains { $0.name == "offset" && $0.value == "-1.000000" })
// Und der Systemplayer schickt keine Kopfzeilen mit.
#expect(items.contains { $0.name == "X-Plex-Token" })
#expect(items.contains { $0.name == "X-Plex-Client-Identifier" })
#expect(result.trace.contains { $0.path.hasPrefix("POST ") })
// Womit der Tuner hinterher wieder freigegeben wird.
#expect(result.subscriptionKey == "77")
}
@Test("Die Teil-Adresse wird gefunden, egal wie tief sie liegt")