Live-TV: die Senderliste wird gesucht, nicht geraten — und Serien richtig beschriftet

Vier geratene Adressen, vier 404. Statt ein fünftes Mal zu raten, klopft
die App jetzt in einem Durchgang und gleichzeitig ein Dutzend Adressen ab:
was /media/providers an Endpunkten angibt, welche Unterverzeichnisse
Programmanbieter und Empfänger in ihrer eigenen Antwort führen, die
bekannten Formen anderer Serverfassungen, die Senderliste des Tuners
selbst — und zuletzt das Programmraster.

Dabei zwei Fehlerquellen mitbehoben, die jede einzeln wie „keine Sender"
ausgesehen hätten: der Sendername steht je nach Tunertyp in title, name,
channelTitle oder callSign, und eine Antwort mit dreißig namenlosen
Einträgen sieht aus wie eine Senderliste, ist aber keine.

Bleibt es leer, steht jetzt jede abgeklopfte Adresse mit ihrer Antwort in
der Ansicht. Das beantwortet „liegt es am Server oder an der App" ohne
eine weitere Runde Rückfragen.

Dazu drei Sachen bei Serien:
- Groß steht, worum es geht — bei einer Folge ihr Titel, bei einer
  Staffel der Name der Serie. „Staffel 10" als Überschrift sagte nicht,
  welche Serie gemeint ist.
- Die Einordnung steht darunter: Serienname bei Folgen, Staffelnummer bei
  Staffeln.
- Von der Folge führt ein Weg zur Staffel und zur Serie. Wer über
  „Weiterschauen" bei einer Folge landete, saß dort bisher fest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-12 12:21:29 +02:00
co-authored by Claude Opus 5
parent 591cba73a7
commit 7e36f37506
11 changed files with 565 additions and 124 deletions
@@ -31,110 +31,193 @@ extension PlexServer {
}
}
/// Die eingeschalteten Sender eines Empfängers, mit Namen und Logos.
/// Die Sender eines Empfängers — gefunden, nicht geraten.
///
/// Zwei Quellen, die zusammengeführt werden müssen — das war der
/// eigentliche Stolperstein:
/// Die Live-TV-Schnittstelle von Plex hat keine Beschreibung, und die
/// Adresse der Senderliste unterscheidet sich zwischen Serverfassungen,
/// Tunertypen und Programmanbietern. Drei Versuche, sie zu erraten,
/// endeten auf einem echten Server alle mit 404.
///
/// - Der Empfänger kennt nur die **Zuordnung**: welche Sender einer
/// Senderliste eingeschaltet sind, als nackte Schlüssel. Bei einer
/// Swisscom-Liste sind das 28 von 521. Namen stehen dort keine.
/// - Die **Namen, Nummern und Logos** stehen beim Programmanbieter, unter
/// der Senderliste.
/// Deshalb wird sie jetzt gesucht statt geraten, in einem Durchgang und
/// gleichzeitig:
///
/// Zweimal habe ich vorher die Zuordnung für die Senderliste gehalten.
/// Beim ersten Mal blieb sie leer, beim zweiten hätten alle Sender „—"
/// geheißen und dieselbe Kennung getragen.
/// 1. Der Server sagt unter `/media/providers` selbst, welche Endpunkte
/// er anbietet.
/// 2. Der Programmanbieter und der Empfänger führen Unterverzeichnisse,
/// deren Adressen in ihrer eigenen Antwort stehen.
/// 3. Dazu die bekannten Formen, die andere Server verwenden.
/// 4. Und zuletzt das Programmraster, in dem jeder Eintrag seinen Sender
/// mitträgt.
///
/// Was dabei herauskam, steht in `trace` — jede Adresse mit ihrer
/// Antwort. Schlägt alles fehl, ist damit ohne Rückfrage zu sehen, was
/// der Server tatsächlich geantwortet hat.
public func channels(inDVR dvr: PlexDVR) async -> ChannelLookup {
let enabled = dvr.enabledChannelKeys
var identifier = dvr.epgIdentifier
if identifier == nil {
identifier = await electronicProgramGuideIdentifier()
}
if identifier == nil { identifier = await electronicProgramGuideIdentifier() }
guard let epgIdentifier = identifier else {
return ChannelLookup(channels: [], source: .noLineup)
return ChannelLookup(channels: [], source: .noLineup, trace: [])
}
let advertised = await providerFeatures(for: epgIdentifier)
let paths = await candidatePaths(dvr: dvr, epgIdentifier: epgIdentifier)
var found: [Int: [PlexChannel]] = [:]
var trace: [Int: ChannelProbe] = [:]
var lastFailure: String?
for path in Self.channelPaths(
dvr: dvr,
epgIdentifier: epgIdentifier,
advertised: advertised
) {
do {
let container = try await client.send(
PlexRequest(path: path, timeout: .seconds(30)),
as: PlexMediaContainer<PlexChannel>.self
)
guard !container.content.isEmpty else { continue }
// Ohne Zuordnung alles zeigen: besser die vollständige Liste
// als gar keine, wenn der Empfänger keine Auswahl meldet.
let channels = enabled.isEmpty
? container.content
: container.content.filter { $0.matches(anyOf: enabled) }
if channels.isEmpty {
return ChannelLookup(
channels: [],
source: .lineupWithoutMatches(
available: container.content.count,
enabled: enabled.count
)
)
// Gleichzeitig, nicht nacheinander: ein Dutzend Adressen einzeln
// abzuklopfen dauert bei einem Server, der schweigt statt abzulehnen,
// eine Minute. Der Actor gibt an jedem `await` frei, deshalb laufen
// die Abfragen wirklich nebeneinander.
await withTaskGroup(of: (Int, [PlexChannel], ChannelProbe).self) { group in
for (index, path) in paths.enumerated() {
group.addTask {
let outcome = await self.probe(path: path)
return (index, outcome.channels, outcome.probe)
}
return ChannelLookup(
channels: channels,
source: .lineup(available: container.content.count, path: path)
)
} catch {
lastFailure = "\(path): \(error)"
}
for await (index, channels, probe) in group {
found[index] = channels
trace[index] = probe
}
}
return ChannelLookup(channels: [], source: .failed(lastFailure ?? "kein Weg lieferte Sender"))
let ordered = paths.indices.compactMap { trace[$0] }
let enabled = dvr.enabledChannelKeys
// Die Reihenfolge der Kandidaten ist die Rangfolge: was der Server
// selbst angibt, zählt mehr als eine bekannte Form.
for index in paths.indices {
guard let channels = found[index], !channels.isEmpty else { continue }
// Ohne Zuordnung alles zeigen, und auch dann, wenn keiner passt:
// eine vollständige Liste ist besser als eine leere Ansicht, und
// die Schlüssel der Zuordnung weichen je nach Anbieter ab.
let matching = enabled.isEmpty ? channels : channels.filter { $0.matches(anyOf: enabled) }
let result = matching.isEmpty ? channels : matching
return ChannelLookup(
channels: result,
source: .lineup(available: channels.count, path: paths[index]),
trace: ordered
)
}
return ChannelLookup(channels: [], source: .failed("keine der \(paths.count) Adressen lieferte Sender"), trace: ordered)
}
/// Die Adressen, unter denen die Senderliste stehen könnte.
///
/// Mehrere, weil dieser Teil der Plex-API keine Beschreibung hat und sich
/// die Form zwischen Serverfassungen unterscheidet. Sie werden der Reihe
/// nach probiert; welche gegriffen hat, steht in der Diagnose — damit aus
/// dem Durchprobieren beim nächsten Mal Wissen wird.
///
/// Entscheidend ist `pathIdentifier`: die Kennung der Senderliste kommt
/// als vollständige Adresse mit Schema und Anhang. Roh eingesetzt ergibt
/// jeder dieser Pfade 404.
static func channelPaths(
dvr: PlexDVR,
epgIdentifier: String,
advertised: [PlexProviderFeature] = []
) -> [String] {
/// Eine Adresse abklopfen: was steht dort, und sind es Sender?
private func probe(path: String) async -> (channels: [PlexChannel], probe: ChannelProbe) {
let data: Data
do {
data = try await client.send(PlexRequest(path: path, timeout: .seconds(12)))
} catch {
return ([], ChannelProbe(path: path, result: Self.shortDescription(of: error)))
}
let channels = (try? PlexResponse.decode(PlexMediaContainer<PlexChannel>.self, from: data))?.content ?? []
// Namenlose zählen nicht: eine Liste aus dreißig Strichen sieht aus
// wie eine Senderliste, ist aber keine — dieser Fehler ist hier schon
// einmal passiert.
let named = channels.filter(\.hasName)
return (
named,
ChannelProbe(
path: path,
result: named.isEmpty ? Self.containerSummary(data) : "\(named.count) Sender"
)
)
}
/// Alle Adressen, an denen die Senderliste stehen könnte — erfragt und bekannt.
private func candidatePaths(dvr: PlexDVR, epgIdentifier: String) async -> [String] {
var paths: [String] = []
// Zuerst, was der Server selbst angibt. Die Namen der Fähigkeiten sind
// nicht dokumentiert, deshalb alles, was nach Sendern klingt.
for feature in advertised where ["channels", "lineups", "livetv", "grid"].contains(feature.type) {
// 1. Was der Server selbst angibt.
for feature in await providerFeatures(for: epgIdentifier)
where ["channels", "lineups", "livetv", "grid", "tuner"].contains(feature.type) {
if let key = feature.key { paths.append(key) }
}
// Danach die geratenen Formen. Sie haben bei einem Server mit
// Swisscom-Liste alle 404 ergeben — sie stehen hier nur noch, weil
// andere Serverfassungen sie kennen könnten.
if let lineup = dvr.lineups.first {
paths.append("/\(epgIdentifier)/lineups/\(lineup.pathIdentifier)/channels")
// 2. Was Anbieter und Empfänger an Unterverzeichnissen führen.
for root in ["/\(epgIdentifier)", "/livetv/dvrs/\(dvr.key)"] {
for entry in await directories(at: root) {
guard let key = entry.key else { continue }
let haystack = (key + " " + (entry.title ?? "")).lowercased()
guard ["channel", "sender", "lineup", "grid", "guide"].contains(where: haystack.contains)
else { continue }
paths.append(key.hasPrefix("/") ? key : "\(root)/\(key)")
}
}
paths.append("/\(epgIdentifier)/channels")
paths.append("/livetv/dvrs/\(dvr.key)/channels")
// Doppelte entfernen, Reihenfolge behalten.
// 3. Die bekannten Formen.
paths.append(contentsOf: Self.knownPaths(dvr: dvr, epgIdentifier: epgIdentifier))
var seen = Set<String>()
return paths.filter { seen.insert($0).inserted }
}
/// Die Adressen, die andere Serverfassungen verwenden.
///
/// Entscheidend ist `pathIdentifier`: die Kennung der Senderliste kommt
/// als vollständige Adresse mit Schema und Anhang. Roh eingesetzt ergibt
/// jeder dieser Pfade 404 — das war der erste Fehlschlag von vieren.
static func knownPaths(dvr: PlexDVR, epgIdentifier: String) -> [String] {
var paths = ["/livetv/dvrs/\(dvr.key)/channels"]
if let lineup = dvr.lineups.first {
paths.append("/\(epgIdentifier)/lineups/\(lineup.pathIdentifier)/channels")
paths.append("/livetv/dvrs/\(dvr.key)/lineups/\(lineup.pathIdentifier)/channels")
}
paths.append("/\(epgIdentifier)/channels")
paths.append("/\(epgIdentifier)/lineups")
// Der Empfänger selbst führt bei Netzwerk-Tunern seine eigene
// Senderliste — die vom Gerät gefundenen Sender, mit Namen.
for device in dvr.devices {
for key in [device.key, device.deviceIdentifier].compactMap({ $0 }) {
let base = key.hasPrefix("/") ? key : "/media/grabbers/devices/\(key)"
paths.append("\(base)/channels")
paths.append("/livetv/dvrs/\(dvr.key)/devices/\(key)/channels")
}
}
var seen = Set<String>()
return paths.filter { seen.insert($0).inserted }
}
/// Die Unterverzeichnisse an einer Adresse.
private func directories(at path: String) async -> [PlexNavigationEntry] {
let container = try? await client.send(
PlexRequest(path: path, timeout: .seconds(12)),
as: PlexMediaContainer<PlexNavigationEntry>.self
)
return container?.content ?? []
}
/// Welche Sammlungen eine Antwort enthält, mit Anzahl.
///
/// Beantwortet die Frage, die nach einem Fehlschlag als Nächstes kommt:
/// war dort etwas, und unter welchem Namen?
static func containerSummary(_ data: Data) -> String {
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let container = object["MediaContainer"] as? [String: Any]
else { return "keine Plex-Antwort (\(data.count) Bytes)" }
let collections = container
.compactMap { key, value -> String? in
guard let array = value as? [Any] else { return nil }
return "\(key): \(array.count)"
}
.sorted()
return collections.isEmpty ? "leer" : collections.joined(separator: ", ")
}
static func shortDescription(of error: any Error) -> String {
if let plex = error as? PlexError { return String(describing: plex) }
return (error as? URLError).map { "URLError \($0.errorCode)" } ?? "Fehler"
}
/// Was der Programmanbieter an Fähigkeiten angibt.
public func providerFeatures(for identifier: String) async -> [PlexProviderFeature] {
guard let container = try? await client.send(
@@ -210,7 +293,12 @@ extension PlexServer {
var identifier = dvr.epgIdentifier
if identifier == nil { identifier = await electronicProgramGuideIdentifier() }
guard let epgIdentifier = identifier else {
return LiveTVGuide(channels: lookup.channels, programs: [:], source: lookup.description)
return LiveTVGuide(
channels: lookup.channels,
programs: [:],
source: lookup.description,
trace: lookup.trace
)
}
let now = Date()
@@ -227,7 +315,12 @@ extension PlexServer {
}
if !lookup.channels.isEmpty {
return LiveTVGuide(channels: lookup.channels, programs: programs, source: lookup.description)
return LiveTVGuide(
channels: lookup.channels,
programs: programs,
source: lookup.description,
trace: lookup.trace
)
}
// Aus dem Raster: jeden Sender einmal, in der Reihenfolge, in der das
@@ -247,7 +340,13 @@ extension PlexServer {
programs: programs,
source: channels.isEmpty
? "Raster: keine Sender (\(lookup.description))"
: "Raster: \(channels.count) von \(fromGrid.count) Sendern"
: "Raster: \(channels.count) von \(fromGrid.count) Sendern",
trace: lookup.trace + [ChannelProbe(
path: "/\(epgIdentifier)/grid",
result: fromGrid.isEmpty
? "\(entries.count) Sendungen, kein Sender benannt"
: "\(fromGrid.count) Sender"
)]
)
}
@@ -307,12 +406,7 @@ extension PlexServer {
as: PlexMediaContainer<PlexDVR>.self
).content.first,
let epgIdentifier = dvr.epgIdentifier {
let advertised = await providerFeatures(for: epgIdentifier)
for path in Self.channelPaths(
dvr: dvr,
epgIdentifier: epgIdentifier,
advertised: advertised
) {
for path in await candidatePaths(dvr: dvr, epgIdentifier: epgIdentifier) {
report.append("\n=== \(path) ===")
report.append(await structure(ofPath: path))
}
@@ -387,6 +481,24 @@ extension PlexServer {
}
}
/// Eine abgeklopfte Adresse und was sie geantwortet hat.
public struct ChannelProbe: Sendable, Equatable, Identifiable {
public let path: String
/// „28 Sender", „notFound", „Channel: 0" — kurz genug für eine Zeile.
public let result: String
public var id: String { path }
}
/// Ein Verzeichniseintrag, wie ihn Plex in Wurzelantworten führt.
///
/// Nur Adresse und Beschriftung: mehr braucht es nicht, um zu erkennen, wo
/// die Sender stehen, und mehr steht dort oft auch nicht.
struct PlexNavigationEntry: Decodable {
let key: String?
let title: String?
}
/// Woher die Senderliste kam.
public struct ChannelLookup: Sendable {
public enum Source: Sendable, Equatable {
@@ -402,9 +514,18 @@ public struct ChannelLookup: Sendable {
public let channels: [PlexChannel]
public let source: Source
public init(channels: [PlexChannel], source: Source) {
/// Jede abgeklopfte Adresse mit ihrer Antwort.
///
/// Steht in der Ansicht, wenn nichts gefunden wurde. Der Grund ist
/// unbequem, aber nützlich: bei einer Schnittstelle ohne Beschreibung ist
/// „keine Sender" allein eine Sackgasse, und jede Runde Rückfragen
/// kostet Tage.
public let trace: [ChannelProbe]
public init(channels: [PlexChannel], source: Source, trace: [ChannelProbe] = []) {
self.channels = channels
self.source = source
self.trace = trace
}
/// Kurzbeschreibung für die Diagnose.
@@ -488,4 +609,6 @@ public struct LiveTVGuide: Sendable {
public let programs: [String: [PlexProgram]]
/// Woher die Sender kamen — steht in der leeren Ansicht und in der Diagnose.
public let source: String
/// Jede abgeklopfte Adresse mit ihrer Antwort, falls nichts gefunden wurde.
public let trace: [ChannelProbe]
}