Diagnose zeigt den Aufbau der Live-TV-Antwort

Die Sender bleiben leer, und ich habe jetzt zweimal geraten, wo sie
stehen könnten — beim ersten Mal ein eigener Endpunkt, beim zweiten die
Verschachtelung unter Device. Beides war eine Vermutung, weil dieser
Teil der Plex-API keine offizielle Beschreibung hat.

Statt ein drittes Mal zu raten: Einstellungen → Diagnose → „Live-TV-
Antwort ansehen" fragt den Server und zeigt den Aufbau der Antwort.
Schlüssel, Anzahl und Sendernamen, keine Werte und keine Zugangsdaten —
genug, um den richtigen Schlüssel zu finden.

Von Listen wird nur der erste Eintrag ausgeklappt; bei dreißig Sendern
wäre die vollständige Ausgabe unlesbar, und alle haben denselben Aufbau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-09-12 10:13:41 +02:00
co-authored by Claude Opus 5
parent ed84b5bf22
commit afccb81792
3 changed files with 2484 additions and 2352 deletions
File diff suppressed because it is too large Load Diff
@@ -11,6 +11,8 @@ struct DiagnosticsView: View {
@Environment(FlixySession.self) private var session
@Environment(\.flixyAccent) private var accent
@Environment(PlaybackDiagnostics.self) private var diagnostics
@State private var liveTVStructure: String?
@State private var isInspectingLiveTV = false
var body: some View {
Form {
@@ -71,6 +73,36 @@ struct DiagnosticsView: View {
}
}
Section {
Button {
Task { await inspectLiveTV() }
} label: {
if isInspectingLiveTV {
ProgressView().controlSize(.small)
} else {
Label("diagnostics.liveTV", systemImage: "antenna.radiowaves.left.and.right")
}
}
.disabled(isInspectingLiveTV)
if let liveTVStructure {
Text(liveTVStructure)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(Color.flixyTextSecondary)
.textSelection(.enabled)
Button {
UIPasteboard.general.string = liveTVStructure
} label: {
Label("diagnostics.copy", systemImage: "doc.on.doc")
}
}
} header: {
Text("sidebar.liveTV")
} footer: {
Text("diagnostics.liveTVHint")
}
Section("settings.app") {
LabeledContent("settings.version", value: Branding.version)
LabeledContent("settings.build", value: Branding.buildStamp)
@@ -89,6 +121,14 @@ struct DiagnosticsView: View {
.navigationBarTitleDisplayMode(.inline)
}
/// Fragt den Server, wie seine Antwort auf `/livetv/dvrs` aufgebaut ist.
private func inspectLiveTV() async {
guard let server = session.server else { return }
isInspectingLiveTV = true
defer { isInspectingLiveTV = false }
liveTVStructure = await server.liveTVStructure()
}
/// Der Bericht als Text zum Weitergeben.
///
/// Enthält bewusst keinen Token: die Quelladresse ist schon ohne
@@ -137,6 +137,66 @@ extension PlexServer {
}
}
extension PlexServer {
/// Der Aufbau der Antwort auf `/livetv/dvrs`, zum Ansehen.
///
/// Nicht zum Anzeigen im Alltag, sondern zum Nachsehen: die
/// Live-TV-Endpunkte von Plex haben keine offizielle Beschreibung, und
/// ohne die tatsächliche Antwort bleibt jede Vermutung darüber, wo die
/// Sender stehen, eine Vermutung. Diese Ausgabe beendet das Raten.
///
/// Werte werden nicht gezeigt, nur Struktur und Anzahl — das genügt, um
/// den richtigen Schlüssel zu finden, und nichts davon ist vertraulich.
public func liveTVStructure() async -> String {
let data: Data
do {
data = try await client.send(PlexRequest(path: "/livetv/dvrs", timeout: .seconds(10)))
} catch {
return "Abfrage gescheitert: \(error)"
}
guard let object = try? JSONSerialization.jsonObject(with: data) else {
return "Antwort ist kein JSON (\(data.count) Bytes)"
}
return Self.outline(object).joined(separator: "\n")
}
/// Beschreibt den Aufbau eines JSON-Werts, ohne die Werte selbst.
///
/// Ausnahme sind Titel und Sendernummern: die stehen ohnehin im
/// Programmführer und sind beim Suchen die nützlichste Angabe.
private static func outline(_ value: Any, indent: Int = 0) -> [String] {
let padding = String(repeating: " ", count: indent)
switch value {
case let dictionary as [String: Any]:
return dictionary.keys.sorted().flatMap { key -> [String] in
let child = dictionary[key]!
if ["title", "channelIdentifier", "lineupTitle"].contains(key),
let text = child as? String {
return ["\(padding)\(key): \(text)"]
}
switch child {
case is [Any], is [String: Any]:
return ["\(padding)\(key):"] + outline(child, indent: indent + 1)
default:
return ["\(padding)\(key): \(type(of: child))"]
}
}
case let array as [Any]:
guard let first = array.first else { return ["\(padding)(leer)"] }
// Nur das erste Element ausklappen — bei dreißig Sendern wäre die
// vollständige Ausgabe unlesbar, und alle haben denselben Aufbau.
return ["\(padding)[\(array.count) Einträge, erster:]"]
+ outline(first, indent: indent + 1)
default:
return ["\(padding)\(type(of: value))"]
}
}
}
/// Woher die Senderliste kam.
public struct ChannelLookup: Sendable {
public enum Source: Sendable, Equatable {