Live-TV: jeder Weg wird bis zum Ende geprüft, bevor der Player ihn bekommt

Wieder HTTP 400, und wieder ohne Begründung. Der Grund, dass das dreimal
passieren konnte: eine gebaute Adresse galt schon als Erfolg. Ob der
Server sie überhaupt annimmt, stellte sich erst im Player heraus — und
dort steht nur „HTTP 400 (unhandled)".

Jetzt ruft die App die Adresse einmal selbst ab, ohne Plex-Kopfzeilen,
genau so wie AVPlayer es tut. Nur was mit 200 antwortet, geht an den
Player. Was nicht, steht mit dem Wortlaut des Servers in der Spur.

Weil weder der Schlüssel für die Sitzung noch die anschließend
umzurechnende Adresse für diesen Teil der Plex-Schnittstelle beschrieben
sind, werden die Kombinationen der Reihe nach durchprobiert — Adresse der
Sitzung, Adresse aus der Antwort, Adresse des Teils darin, je mit
mediaIndex -1 und 0, höchstens acht Versuche. Antwortet keiner, wird der
Transcoder wieder angehalten, statt für nichts weiterzurechnen.

Die Spur steht in einem Blatt statt in einem Dialog: die Antwort des
Servers ist mehrzeilig, und ein Dialog schneidet sie ab — dann stünde
dort wieder nur, dass es nicht ging.

Dazu ein Nebeneffekt, der beim zweiten Sender zugeschlagen hätte: der
Player kannte die Sitzungskennung des Live-Stroms nicht und hat den Tuner
beim Schließen nicht freigegeben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-12 14:09:30 +02:00
co-authored by Claude Opus 5
parent 8dba05b4f9
commit a9a45e685d
5 changed files with 235 additions and 101 deletions
+76 -21
View File
@@ -18,8 +18,8 @@ struct LiveTVView: View {
@State private var trace: [ChannelProbe] = [] @State private var trace: [ChannelProbe] = []
@State private var programs: [String: [PlexProgram]] = [:] @State private var programs: [String: [PlexProgram]] = [:]
@State private var isTuning: String? @State private var isTuning: String?
/// Warum das Einschalten nicht geklappt hat. /// Warum das Einschalten nicht geklappt hat — mit jedem versuchten Weg.
@State private var tuneFailure: String? @State private var tuneFailure: TuneFailure?
/// Raster oder Senderliste. /// Raster oder Senderliste.
/// ///
/// Auf dem Handy sind beide Ansichten berechtigt: das Raster zum Stöbern, /// Auf dem Handy sind beide Ansichten berechtigt: das Raster zum Stöbern,
@@ -100,13 +100,11 @@ struct LiveTVView: View {
} }
.task { await load() } .task { await load() }
.refreshable { await load() } .refreshable { await load() }
.alert( // Ein Blatt statt eines Dialogs: die Antwort des Servers ist
Text("liveTV.tuneFailed"), // mehrzeilig, und ein Dialog schneidet sie ab — dann steht dort
isPresented: Binding(get: { tuneFailure != nil }, set: { if !$0 { tuneFailure = nil } }) // wieder nur, dass es nicht ging.
) { .sheet(item: $tuneFailure) { failure in
Button("action.close", role: .cancel) { tuneFailure = nil } TuneFailureSheet(failure: failure)
} message: {
if let tuneFailure { Text(tuneFailure) }
} }
} }
@@ -212,18 +210,11 @@ struct LiveTVView: View {
guard let stream = result.url else { guard let stream = result.url else {
// Vorher verschwand jeder Fehler hier lautlos, und ein Tippen auf // Vorher verschwand jeder Fehler hier lautlos, und ein Tippen auf
// den Sender blieb ohne jede Wirkung — nicht einmal eine Meldung. // den Sender blieb ohne jede Wirkung — nicht einmal eine Meldung.
tuneFailure = ([String(localized: "liveTV.noStream")] tuneFailure = TuneFailure(
+ result.trace.map { "\($0.path) → \($0.result)" }) channel: channel.title,
.joined(separator: "\n") reason: String(localized: "liveTV.noStream"),
return trace: result.trace.map { "\($0.path)\n → \($0.result)" }
} )
// Den Server fragen, bevor der Player es versucht. Lehnt er
// ausdrücklich ab, sagt er im Klartext warum — der Player meldet nur,
// dass es nicht ging. Schweigt der Entscheider dagegen, wird trotzdem
// gespielt: Live-Adressen kennt er womöglich gar nicht.
if case .refused(let reason) = await server.liveTVVerdict(for: stream) {
tuneFailure = reason
return return
} }
@@ -317,3 +308,67 @@ private struct ChannelRow: View {
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
} }
} }
/// Was beim Einschalten schiefging, in voller Länge.
struct TuneFailure: Identifiable {
let id = UUID()
let channel: String
let reason: String
/// Jeder versuchte Weg mit der Antwort des Servers.
let trace: [String]
}
/// Zeigt die Antwort des Servers, statt sie zu verschlucken.
///
/// Die Live-Schnittstelle von Plex ist nirgends beschrieben. Ohne den
/// Wortlaut dessen, was der Server abgelehnt hat, bleibt nur Raten — und
/// jede Runde davon kostet Tage.
private struct TuneFailureSheet: View {
let failure: TuneFailure
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
Text(failure.channel)
.font(.flixyRowHeader)
.foregroundStyle(Color.flixyTextPrimary)
Text(failure.reason)
.font(.flixyBody)
.foregroundStyle(Color.flixyTextSecondary)
ForEach(Array(failure.trace.enumerated()), id: \.offset) { _, line in
Text(line)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Color.flixyTextMuted)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
Button {
UIPasteboard.general.string = failure.trace.joined(separator: "\n")
} label: {
Label("diagnostics.copy", systemImage: "doc.on.doc")
.font(.flixyCaption)
}
.buttonStyle(.bordered)
}
.padding(FlixySpacing.screenMargin)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(Color.flixyCanvas)
.navigationTitle(Text("liveTV.tuneFailed"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("action.close") { dismiss() }
}
}
}
.presentationDetents([.medium, .large])
}
}
+6
View File
@@ -219,6 +219,12 @@ final class PlayerModel {
isLive = true isLive = true
title = request.title title = request.title
// Die Sitzungskennung aus der Adresse übernehmen. Ohne sie bleibt der
// Tuner nach dem Schließen belegt — und beim nächsten Sender hat der
// Server keinen freien mehr.
transcodeSession = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.first { $0.name == "session" }?.value
// Live kommt immer umgerechnet: der Tuner liefert einen // Live kommt immer umgerechnet: der Tuner liefert einen
// Transportstrom, den kein iPhone direkt abspielt. // Transportstrom, den kein iPhone direkt abspielt.
let decision = PlaybackDecision(method: .transcode(maximumBitrate: nil), rationale: []) let decision = PlaybackDecision(method: .transcode(maximumBitrate: nil), rationale: [])
@@ -364,14 +364,15 @@ extension PlexServer {
/// ///
/// Zwei Schritte, und der erste ist der, den ich zuerst übersprungen /// Zwei Schritte, und der erste ist der, den ich zuerst übersprungen
/// hatte: **der Server muss den Tuner belegen**. Erst danach gibt es /// hatte: **der Server muss den Tuner belegen**. Erst danach gibt es
/// etwas umzurechnen. Ohne diesen Schritt begann die Wiedergabe und starb /// etwas umzurechnen.
/// sofort wieder — „die Wiedergabe wurde unterbrochen", ohne dass ein
/// Fehler zu sehen gewesen wäre.
/// ///
/// Unter welchem Schlüssel die Sitzung eröffnet wird, unterscheidet sich /// Weder der Schlüssel für die Sitzung noch die Adresse, die anschließend
/// je nach Anbieter, deshalb werden die in Frage kommenden der Reihe nach /// umzurechnen ist, sind für diesen Teil der Plex-Schnittstelle
/// probiert. Was dabei herauskam, steht in `trace` — scheitert alles, ist /// beschrieben. Deshalb wird jede Kombination **bis zum Ende geprüft**:
/// ohne Rückfrage zu sehen, was der Server geantwortet hat. /// die Adresse wird einmal selbst abgerufen, ohne Kopfzeilen — genau so,
/// wie der Systemplayer es tut. Erst was dabei mit 200 antwortet, geht an
/// den Player. Vorher galt schon eine gebaute Adresse als Erfolg, und der
/// Fehler tauchte erst im Player auf, als „HTTP 400" ohne Begründung.
public func tune( public func tune(
channel: PlexChannel, channel: PlexChannel,
program: PlexProgram? = nil, program: PlexProgram? = nil,
@@ -379,75 +380,131 @@ extension PlexServer {
) async -> LiveTuneResult { ) async -> LiveTuneResult {
let session = UUID().uuidString let session = UUID().uuidString
var trace: [ChannelProbe] = [] var trace: [ChannelProbe] = []
var attempts = 0
for key in Self.sessionKeys(channel: channel, program: program) { for key in Self.sessionKeys(channel: channel, program: program) {
let path = "/livetv/sessions/\(session)/\(key)" let sessionPath = "/livetv/sessions/\(session)"
let openPath = "\(sessionPath)/\(key)"
let item: PlexItem?
do { do {
let container = try await client.send( item = try await client.send(
PlexRequest( PlexRequest(
path: path, path: openPath,
query: [URLQueryItem(name: "X-Plex-Session-Identifier", value: session)], query: [URLQueryItem(name: "X-Plex-Session-Identifier", value: session)],
timeout: .seconds(30) timeout: .seconds(30)
), ),
as: PlexMediaContainer<PlexItem>.self as: PlexMediaContainer<PlexItem>.self
) ).content.first
} catch {
guard let item = container.content.first else { trace.append(ChannelProbe(path: openPath, result: Self.shortDescription(of: error)))
trace.append(ChannelProbe(path: path, result: "leer"))
continue continue
} }
// Die Sitzung hat jetzt einen Tuner. Umgerechnet wird die guard let item else {
// Adresse der **Sitzung** — nicht die des Senders, nicht die trace.append(ChannelProbe(path: openPath, result: "leer"))
// der Sendung und nicht die des Teils darin. Der Transcoder continue
// sucht sich den Teil selbst; deshalb steht unten auch -1. }
let sessionPath = "/livetv/sessions/\(session)" trace.append(ChannelProbe(path: openPath, result: "Sitzung offen"))
let streamPath = item.key.hasPrefix("/livetv/sessions/")
? item.key
: (item.media.first?.parts.first?.key ?? sessionPath)
trace.append(ChannelProbe(path: path, result: "Sitzung: \(streamPath)")) for candidate in Self.streamCandidates(item: item, sessionPath: sessionPath) {
guard attempts < 8 else { break }
attempts += 1
return LiveTuneResult( let request = TranscodeRequest(
url: transcodeURL(for: TranscodeRequest(
ratingKey: item.ratingKey, ratingKey: item.ratingKey,
// -1 heißt „was auch immer die Sitzung liefert". Ein mediaIndex: candidate.mediaIndex,
// laufender Sender hat keine Fassungen zur Auswahl. partIndex: candidate.mediaIndex,
mediaIndex: -1,
partIndex: -1,
maximumBitrate: maximumBitrate, maximumBitrate: maximumBitrate,
isLocal: connection.local, isLocal: connection.local,
sessionIdentifier: session, sessionIdentifier: session,
pathOverride: streamPath pathOverride: candidate.path
)),
sessionIdentifier: session,
trace: trace
) )
} catch { guard let url = transcodeURL(for: request) else { continue }
trace.append(ChannelProbe(path: path, result: Self.shortDescription(of: error)))
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: die Sendung unmittelbar umrechnen lassen. Auf // Letzter Versuch ohne eigene Sitzung: die Sendung unmittelbar
// Servern, die den Tuner selbst belegen, genügt das. // umrechnen lassen. Auf Servern, die den Tuner selbst belegen, genügt
if let metadataKey = program?.metadataKey { // das — und geprüft wird es genauso wie alles andere.
trace.append(ChannelProbe(path: metadataKey, result: "direkt versucht")) if let metadataKey = program?.metadataKey, attempts < 8 {
return LiveTuneResult( let request = TranscodeRequest(
url: transcodeURL(for: TranscodeRequest(
ratingKey: channel.id, ratingKey: channel.id,
maximumBitrate: maximumBitrate, maximumBitrate: maximumBitrate,
isLocal: connection.local, isLocal: connection.local,
sessionIdentifier: session, sessionIdentifier: session,
pathOverride: metadataKey pathOverride: metadataKey
)),
sessionIdentifier: session,
trace: trace
) )
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)
}
}
} }
// Kein Weg hat mit 200 geantwortet. Den Transcoder anhalten, sonst
// rechnet der Server für eine Wiedergabe weiter, die es nicht gibt.
await stopTranscode(sessionIdentifier: session)
return LiveTuneResult(url: nil, sessionIdentifier: session, trace: trace) return LiveTuneResult(url: nil, sessionIdentifier: session, trace: trace)
} }
/// Welche Adresse mit welchem Index umzurechnen ist.
///
/// Die Adresse der Sitzung zuerst — der Transcoder sucht sich den Teil
/// dann selbst, und dafür steht der Index -1. Die übrigen Formen stehen
/// dahinter, weil sich dieser Teil der Schnittstelle zwischen
/// Serverfassungen unterscheidet und nirgends beschrieben ist.
static func streamCandidates(
item: PlexItem,
sessionPath: String
) -> [(path: String, mediaIndex: Int)] {
var paths = [sessionPath]
if item.key.hasPrefix("/livetv/"), !paths.contains(item.key) { paths.append(item.key) }
if let part = item.media.first?.parts.first?.key, !paths.contains(part) { paths.append(part) }
return paths.flatMap { path in
[(path: path, mediaIndex: -1), (path: path, mediaIndex: 0)]
}
}
/// Holt eine Stromadresse einmal selbst ab und sagt, was zurückkam.
///
/// **Ohne Plex-Kopfzeilen**, weil AVPlayer sie ebenfalls nicht schickt.
/// Mit Kopfzeilen zu prüfen hieße, etwas anderes zu prüfen als das, was
/// später wirklich passiert — und dann stünde der Fehler wieder erst im
/// Player, wo niemand mehr sieht, woran es lag.
public func probeStream(url: URL) async -> String {
var request = URLRequest(url: url)
request.timeoutInterval = 20
do {
let (data, response) = try await transport.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
guard status != 200 else { return "HTTP 200" }
// Plex legt dem Fehler eine Begründung bei. Genau die fehlte
// bisher: der Player zeigte „HTTP 400 (unhandled)" und sonst nichts.
let body = String(decoding: data.prefix(300), as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "\n", with: " ")
return body.isEmpty ? "HTTP \(status)" : "HTTP \(status): \(body)"
} catch {
return Self.shortDescription(of: error)
}
}
/// 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
@@ -22,6 +22,8 @@ public actor PlexServer {
/// Systemplayer abruft, müssen die Kennung selbst tragen — AVPlayer /// Systemplayer abruft, müssen die Kennung selbst tragen — AVPlayer
/// schickt keine Kopfzeilen mit. /// schickt keine Kopfzeilen mit.
nonisolated let identity: PlexClientIdentity nonisolated let identity: PlexClientIdentity
/// Für Abrufe ohne Plex-Kopfzeilen — so, wie der Systemplayer sie macht.
nonisolated let transport: any PlexTransport
public init?( public init?(
resource: PlexResource, resource: PlexResource,
@@ -34,6 +36,7 @@ public actor PlexServer {
self.connection = connection self.connection = connection
self.token = token self.token = token
self.identity = identity self.identity = identity
self.transport = transport
self.client = PlexHTTPClient( self.client = PlexHTTPClient(
baseURL: baseURL, baseURL: baseURL,
identity: identity, identity: identity,
@@ -150,27 +150,37 @@ 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("Die Adresse der laufenden Sendung führt zum Strom") @Test("Eine Adresse beim Programmanbieter wird nicht doppelt kodiert")
func tunesThroughTheProgram() async throws { func doesNotDoubleEncode() throws {
// Das Raster nennt keine Teil-Adresse. Der Weg führt über die // Derselbe Fehler wie einst beim Anmelde-Link: `URLComponents` kodiert
// Sendung selbst: Plex behandelt eine laufende Sendung wie einen Film. // ein zweites Mal, aus %3A wird %253A, und der Server findet nichts.
let server = server(routes: [ let request = TranscodeRequest(
"/tv.plex.providers.epg.cloud:2/grid": try fixture("livetv-grid"), ratingKey: "5",
]) isLocal: true,
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 guide = await server.liveTVGuide(dvr: dvr) #expect(path == "/tv.plex.providers.epg.cloud:2/metadata/plex://movie/abc")
let zdf = try #require(guide.channels.last) }
let program = try #require(guide.programs[zdf.id]?.first)
// Hier antwortet keine Sitzung; dann bleibt der direkte Weg über die
// Sendung — auf Servern, die den Tuner selbst belegen, genügt er.
let result = await server.tune(channel: zdf, 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 == "/tv.plex.providers.epg.cloud:2/metadata/plex://movie/6254592919e23c64f6c5605d") @Test("Nur was tatsächlich mit 200 antwortet, geht an den Player")
// Doppelt kodiert fände der Server nichts — aus %3A würde %253A. func rejectsStreamsTheServerRefuses() async throws {
#expect(!url.absoluteString.contains("%253A")) // Vorher galt schon eine gebaute Adresse als Erfolg. Der Fehler
// tauchte dann erst im Player auf — als „HTTP 400" ohne Begründung.
let session = """
{"MediaContainer":{"size":1,"Metadata":[
{"ratingKey":"live-1","key":"/livetv/sessions/abc","type":"clip","title":"ZDF HD"}
]}}
"""
let server = server(routes: ["/gk-5": session])
let channel = PlexChannel(id: "5", title: "ZDF HD", key: "5", gridKey: "gk-5")
let result = await server.tune(channel: channel, maximumBitrate: nil)
#expect(result.url == nil)
// Und die Antwort des Servers steht in der Spur, nicht nur ein Code.
#expect(result.trace.contains { $0.result.hasPrefix("HTTP 404") })
} }
@Test("Erst den Tuner belegen, dann umrechnen lassen") @Test("Erst den Tuner belegen, dann umrechnen lassen")
@@ -183,16 +193,19 @@ struct LiveTVGuideTests {
"Media":[{"id":1,"Part":[{"id":1,"key":"/livetv/sessions/abc/stream"}]}]} "Media":[{"id":1,"Part":[{"id":1,"key":"/livetv/sessions/abc/stream"}]}]}
]}} ]}}
""" """
// Nur unter dem Raster-Schlüssel antwortet eine Sitzung. // Nur unter dem Raster-Schlüssel antwortet eine Sitzung, und der
let server = server(routes: ["/gk-5": session]) // Transcoder liefert erst danach eine Abspielliste.
let server = server(routes: ["/gk-5": session, "/start.m3u8": "#EXTM3U"])
let channel = PlexChannel(id: "5", title: "ZDF HD", key: "5", gridKey: "gk-5") let channel = PlexChannel(id: "5", title: "ZDF HD", key: "5", gridKey: "gk-5")
let result = await server.tune(channel: channel, maximumBitrate: nil) let result = await server.tune(channel: channel, maximumBitrate: nil)
let url = try #require(result.url) let url = try #require(result.url)
let items = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) let items = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems)
// Die Adresse der Sitzung, nicht die des Teils darin. // Die Adresse der Sitzung, nicht die des Senders und nicht die der
#expect(items.first { $0.name == "path" }?.value == "/livetv/sessions/abc") // Sendung — der Transcoder sucht sich den Teil darin selbst.
let path = try #require(items.first { $0.name == "path" }?.value)
#expect(path.hasPrefix("/livetv/sessions/"))
// Ein laufender Sender hat keine Fassungen zur Auswahl. // Ein laufender Sender hat keine Fassungen zur Auswahl.
#expect(items.first { $0.name == "mediaIndex" }?.value == "-1") #expect(items.first { $0.name == "mediaIndex" }?.value == "-1")
// Ohne die Gerätekennung in der Adresse kann der Server die Abholung // Ohne die Gerätekennung in der Adresse kann der Server die Abholung