Live-TV: die Sendung nachschlagen, statt ihre Adresse blind weiterzureichen

Die gebaute Adresse ist sauber — nachgerechnet und ausgedruckt. Also
lehnt Plex selbst ab, und zwar mit Recht: eine Sendung beim
Cloud-Programmführer hat keine Datei, die sich umrechnen ließe. Die
nackte 400 war die Antwort auf eine unsinnige Bitte.

Deshalb wird die Sendung jetzt zuerst nachgeschlagen. Das Raster nennt
keine abspielbare Adresse; die Einzelabfrage beim Anbieter nennt
womöglich eine. Was dabei zurückkam, steht in jedem Fall in der Spur —
auch wenn nichts Abspielbares dabei war.

Dafür musste die Anfrage einen bereits kodierten Pfad durchreichen
können: `appending(path:)` kodiert das Prozent ein zweites Mal, und aus
`plex%3A%2F%2F` wird `plex%253A%252F%252F`.

Und der angebotene Endpunkt-Zettel des Programmanbieters ist eindeutig:
content, metadata, grid, timeline — nichts zum Einschalten. Der Empfänger
muss es also tun, und den kannte `tune` bisher gar nicht. Seine Adressen
stehen jetzt mit im Suchraum.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-12 14:39:35 +02:00
co-authored by Claude Opus 5
parent 1803dd670f
commit 62a913f2cd
4 changed files with 153 additions and 10 deletions
+6
View File
@@ -11,6 +11,8 @@ struct LiveTVView: View {
@Environment(NetworkConditions.self) private var network @Environment(NetworkConditions.self) private var network
@State private var availability: LiveTVAvailability? @State private var availability: LiveTVAvailability?
/// Der Empfänger, über den eingeschaltet wird.
@State private var dvr: PlexDVR?
@State private var channels: [PlexChannel] = [] @State private var channels: [PlexChannel] = []
/// Woher die Senderliste kam — steht in der leeren Ansicht. /// Woher die Senderliste kam — steht in der leeren Ansicht.
@State private var channelSource: String? @State private var channelSource: String?
@@ -175,6 +177,7 @@ struct LiveTVView: View {
self.availability = availability self.availability = availability
guard case .available(let dvrs) = availability, let dvr = dvrs.first else { return } guard case .available(let dvrs) = availability, let dvr = dvrs.first else { return }
self.dvr = dvr
// Sender und Programm kommen zusammen: die Senderliste des Anbieters // Sender und Programm kommen zusammen: die Senderliste des Anbieters
// antwortet auf manchen Servern gar nicht, und dann tragen die // antwortet auf manchen Servern gar nicht, und dann tragen die
@@ -204,6 +207,9 @@ struct LiveTVView: View {
let result = await server.tune( let result = await server.tune(
channel: channel, channel: channel,
program: running, program: running,
// Der Programmanbieter kennt keinen Weg zum Einschalten — der
// Empfänger schon, und ohne ihn bleibt der halbe Suchraum zu.
dvr: dvr,
maximumBitrate: policy.maximumBitrate maximumBitrate: policy.maximumBitrate
) )
@@ -31,18 +31,23 @@ public struct PlexRequest: Sendable, Equatable {
/// summiert es sich über mehrere Adressen. /// summiert es sich über mehrere Adressen.
public var timeout: Duration public var timeout: Duration
/// Ob `path` bereits kodiert ist und unverändert übernommen werden soll.
public let isPathEncoded: Bool
public init( public init(
method: Method = .get, method: Method = .get,
path: String, path: String,
query: [URLQueryItem] = [], query: [URLQueryItem] = [],
additionalHeaders: [String: String] = [:], additionalHeaders: [String: String] = [:],
timeout: Duration = .seconds(20) timeout: Duration = .seconds(20),
isPathEncoded: Bool = false
) { ) {
self.method = method self.method = method
self.path = path self.path = path
self.query = query self.query = query
self.additionalHeaders = additionalHeaders self.additionalHeaders = additionalHeaders
self.timeout = timeout self.timeout = timeout
self.isPathEncoded = isPathEncoded
} }
/// Setzt die Anfrage gegen einen Host zusammen. /// Setzt die Anfrage gegen einen Host zusammen.
@@ -52,11 +57,17 @@ public struct PlexRequest: Sendable, Equatable {
token: String? token: String?
) throws(PlexError) -> URLRequest { ) throws(PlexError) -> URLRequest {
guard var components = URLComponents( guard var components = URLComponents(
url: baseURL.appending(path: path), url: baseURL.appending(path: isPathEncoded ? "/" : path),
resolvingAgainstBaseURL: false resolvingAgainstBaseURL: false
) else { ) else {
throw .transport(description: "Ungültige Basisadresse: \(baseURL)") throw .transport(description: "Ungültige Basisadresse: \(baseURL)")
} }
// Adressen beim Programmanbieter enthalten bereits kodierte Zeichen
// (`plex%3A%2F%2Fmovie%2F…`). `appending(path:)` kodiert das Prozent
// ein zweites Mal, und der Server findet nichts mehr.
if isPathEncoded {
components.percentEncodedPath = path
}
components.queryItems = query.isEmpty ? nil : query components.queryItems = query.isEmpty ? nil : query
guard let url = components.url else { guard let url = components.url else {
@@ -376,15 +376,41 @@ extension PlexServer {
public func tune( public func tune(
channel: PlexChannel, channel: PlexChannel,
program: PlexProgram? = nil, program: PlexProgram? = nil,
dvr: PlexDVR? = nil,
maximumBitrate: Int? maximumBitrate: Int?
) async -> LiveTuneResult { ) async -> LiveTuneResult {
let session = UUID().uuidString let session = UUID().uuidString
var trace: [ChannelProbe] = [] var trace: [ChannelProbe] = []
var attempts = 0 var attempts = 0
// Zuerst die Sendung selbst. Auf diesem Server antwortet jede Form von // Die Sendung erst nachschlagen. Das Raster nennt keine abspielbare
// `/livetv/sessions/…` mit 404 — den Tuner belegt er offenbar von // Adresse — die Einzelabfrage beim Programmanbieter womöglich schon.
// sich aus, sobald die Sendung umgerechnet werden soll. // Ihre Adresse blind an den Transcoder zu geben, hat dreimal eine
// nackte 400 ergeben: eine Sendung beim Cloud-Programmführer hat
// keine Datei, die sich umrechnen ließe.
if let metadataKey = program?.metadataKey {
let lookup = await programMedia(atKey: metadataKey)
trace.append(ChannelProbe(path: "\(metadataKey) (nachgeschlagen)", result: lookup.summary))
for part in lookup.parts {
guard attempts < 14 else { break }
attempts += 1
let outcome = await attemptStream(
path: part,
ratingKey: lookup.ratingKey ?? channel.id,
mediaIndex: 0,
includeProfile: true,
maximumBitrate: maximumBitrate,
session: session
)
trace.append(outcome.probe)
if let url = outcome.url {
return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace)
}
}
}
if let metadataKey = program?.metadataKey { if let metadataKey = program?.metadataKey {
// Ohne Profil als dritter Versuch: die Profilangaben stecken // Ohne Profil als dritter Versuch: die Profilangaben stecken
// voller `&` und `=`, die in einem Abfragewert mehrdeutig sind. // voller `&` und `=`, die in einem Abfragewert mehrdeutig sind.
@@ -421,10 +447,13 @@ extension PlexServer {
)) ))
} }
for key in Self.sessionKeys(channel: channel, program: program) { let sessionPath = "/livetv/sessions/\(session)"
let sessionPath = "/livetv/sessions/\(session)" for openPath in Self.openPaths(
let openPath = "\(sessionPath)/\(key)" channel: channel,
program: program,
dvr: dvr,
sessionPath: sessionPath
) {
let item: PlexItem? let item: PlexItem?
do { do {
item = try await client.send( item = try await client.send(
@@ -447,7 +476,7 @@ 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 < 10 else { break } guard attempts < 14 else { break }
attempts += 1 attempts += 1
let outcome = await attemptStream( let outcome = await attemptStream(
@@ -471,6 +500,39 @@ extension PlexServer {
return LiveTuneResult(url: nil, sessionIdentifier: session, trace: trace) return LiveTuneResult(url: nil, sessionIdentifier: session, trace: trace)
} }
/// Schlägt eine Sendung beim Programmanbieter nach.
///
/// Liefert die abspielbaren Teil-Adressen und eine Kurzbeschreibung
/// dessen, was zurückkam. Die Beschreibung steht auch dann in der Spur,
/// wenn nichts Abspielbares dabei war — sonst bliebe nach einem
/// Fehlschlag wieder nur Raten, wo die Adresse steckt.
private func programMedia(atKey key: String) async -> (parts: [String], ratingKey: String?, summary: String) {
let data: Data
do {
data = try await client.send(PlexRequest(
path: key,
query: [URLQueryItem(name: "includeChildren", value: "1")],
timeout: .seconds(20),
isPathEncoded: true
))
} catch {
return ([], nil, Self.shortDescription(of: error))
}
guard let item = (try? PlexResponse.decode(PlexMediaContainer<PlexItem>.self, from: data))?
.content.first
else {
return ([], nil, Self.containerSummary(data))
}
let parts = item.media.flatMap(\.parts).compactMap(\.key)
let summary = parts.isEmpty
? "\(item.media.count) Fassungen, keine Teil-Adresse — \(Self.containerSummary(data))"
: "\(parts.count) Teil-Adressen"
return (parts, item.ratingKey, summary)
}
/// 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
@@ -549,6 +611,37 @@ extension PlexServer {
} }
} }
/// Wo sich ein Tuner belegen lässt.
///
/// Der Programmanbieter gibt unter `/media/providers` keinen einzigen
/// Endpunkt zum Einschalten an — er kann Inhalt, Metadaten und
/// Programmraster, mehr nicht. Der Empfänger muss also über den DVR
/// belegt werden, und dessen Adressen stehen hier neben den
/// Sitzungsformen, weil dieser Teil der Schnittstelle nirgends
/// beschrieben ist.
static func openPaths(
channel: PlexChannel,
program: PlexProgram?,
dvr: PlexDVR?,
sessionPath: String
) -> [String] {
var paths: [String] = []
if let dvr {
for key in [channel.gridKey, channel.key, channel.channelIdentifier, channel.id]
.compactMap({ $0 })
.map({ $0.split(separator: "/").last.map(String.init) ?? $0 }) {
paths.append("/livetv/dvrs/\(dvr.key)/channels/\(key)/tune")
paths.append("/livetv/dvrs/\(dvr.key)/channels/\(key)")
}
}
paths += sessionKeys(channel: channel, program: program).map { "\(sessionPath)/\($0)" }
var seen = Set<String>()
return paths.filter { seen.insert($0).inserted }
}
/// Unter welchen Schlüsseln sich eine Live-Sitzung eröffnen lässt. /// Unter welchen Schlüsseln sich eine Live-Sitzung eröffnen lässt.
/// ///
/// Der Raster-Schlüssel zuerst: er bezeichnet den Sender genau so, wie /// Der Raster-Schlüssel zuerst: er bezeichnet den Sender genau so, wie
@@ -239,6 +239,39 @@ struct LiveTVGuideTests {
#expect(keys.contains("5")) #expect(keys.contains("5"))
} }
@Test("Die nachgeschlagene Sendung liefert die abspielbare Adresse")
func usesPartFromLookup() async throws {
// Das Raster nennt keine Teil-Adresse, die Einzelabfrage schon. Sie
// blind an den Transcoder zu geben, ergab dreimal eine nackte 400:
// eine Sendung beim Cloud-Programmführer hat keine Datei.
let metadata = """
{"MediaContainer":{"size":1,"Metadata":[
{"ratingKey":"epg-1","key":"/tv.plex.providers.epg.cloud:2/metadata/x","type":"movie",
"title":"Tagesschau",
"Media":[{"id":1,"Part":[{"id":1,"key":"/livetv/sessions/xyz/stream"}]}]}
]}}
"""
let server = server(routes: [
"/metadata/plex%3A%2F%2Fmovie%2Fabc": metadata,
"/start.m3u8": "#EXTM3U",
])
let channel = PlexChannel(id: "5", title: "SRF 1 HD", key: "5")
let program = PlexProgram(
id: "p1",
title: "Tagesschau",
startsAt: .now,
endsAt: .now.addingTimeInterval(1800),
metadataKey: "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2Fabc"
)
let result = await server.tune(channel: channel, program: program, 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/xyz/stream")
}
@Test("Die Sender des Tuners stehen unter DeviceChannel") @Test("Die Sender des Tuners stehen unter DeviceChannel")
func readsDeviceChannelKey() async throws { func readsDeviceChannelKey() async throws {
// Der Server lieferte 278 Sender, die Hülle verwarf sie: sie kannte // Der Server lieferte 278 Sender, die Hülle verwarf sie: sie kannte