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 programs: [String: [PlexProgram]] = [:]
@State private var isTuning: String?
/// Warum das Einschalten nicht geklappt hat.
@State private var tuneFailure: String?
/// Warum das Einschalten nicht geklappt hat — mit jedem versuchten Weg.
@State private var tuneFailure: TuneFailure?
/// Raster oder Senderliste.
///
/// Auf dem Handy sind beide Ansichten berechtigt: das Raster zum Stöbern,
@@ -100,13 +100,11 @@ struct LiveTVView: View {
}
.task { await load() }
.refreshable { await load() }
.alert(
Text("liveTV.tuneFailed"),
isPresented: Binding(get: { tuneFailure != nil }, set: { if !$0 { tuneFailure = nil } })
) {
Button("action.close", role: .cancel) { tuneFailure = nil }
} message: {
if let tuneFailure { Text(tuneFailure) }
// Ein Blatt statt eines Dialogs: die Antwort des Servers ist
// mehrzeilig, und ein Dialog schneidet sie ab — dann steht dort
// wieder nur, dass es nicht ging.
.sheet(item: $tuneFailure) { failure in
TuneFailureSheet(failure: failure)
}
}
@@ -212,18 +210,11 @@ struct LiveTVView: View {
guard let stream = result.url else {
// Vorher verschwand jeder Fehler hier lautlos, und ein Tippen auf
// den Sender blieb ohne jede Wirkung — nicht einmal eine Meldung.
tuneFailure = ([String(localized: "liveTV.noStream")]
+ result.trace.map { "\($0.path) → \($0.result)" })
.joined(separator: "\n")
return
}
// 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
tuneFailure = TuneFailure(
channel: channel.title,
reason: String(localized: "liveTV.noStream"),
trace: result.trace.map { "\($0.path)\n → \($0.result)" }
)
return
}
@@ -317,3 +308,67 @@ private struct ChannelRow: View {
.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])
}
}