Drei geratene Adressen für die Senderliste antworteten auf einem echten Server alle mit 404. Statt ein viertes Mal zu raten, liest die App jetzt zwei Dinge: was /media/providers an Endpunkten angibt, und das Programmraster selbst — die Weboberfläche von Plex zeichnet ihren Guide daraus, und jeder Rastereintrag trägt Sendername, Nummer, Logo und Einschaltweg mit sich. Damit kommen Sender und Programm aus einer Abfrage, und ihre Kennungen passen zueinander. Getrennt geholt taten sie das nicht: das Programm landete in keiner Zeile. Dazu die Ansicht, für die das alles da ist — ein Programmraster mit stehender Senderspalte, mitlaufender Zeitachse und einer Jetzt-Linie. Antippen öffnet die Sendung; läuft sie gerade, schaltet ein Knopf um. Die Geometrie steht in GuideLayout und ist geprüft: in einer View wäre sie unsichtbar falsch. Die Trennlinien gehören in die Zeile statt zwischen die Zeilen — Divider ist auf 3x-Schirmen keinen ganzen Punkt hoch, und der Bruchteil summiert sich, bis die angetippte Zeile zum Nachbarsender gehört. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
492 lines
20 KiB
Swift
492 lines
20 KiB
Swift
import Foundation
|
|
|
|
/// Was beim Live-TV herauskommen kann.
|
|
public enum LiveTVAvailability: Sendable, Equatable {
|
|
/// Es gibt einen Empfänger und Sender.
|
|
case available(dvrs: [PlexDVR])
|
|
/// Der Server läuft, hat aber keinen Empfänger eingerichtet.
|
|
///
|
|
/// Der mit Abstand häufigste Fall — Live-TV verlangt eine Fernsehkarte
|
|
/// oder einen Netzwerk-Tuner. Die Oberfläche soll das erklären, statt
|
|
/// einen Fehler zu zeigen.
|
|
case notConfigured
|
|
case failed(message: String)
|
|
}
|
|
|
|
extension PlexServer {
|
|
/// Prüft, ob der Server überhaupt Live-TV kann.
|
|
public func liveTVAvailability() async -> LiveTVAvailability {
|
|
do {
|
|
let container = try await client.send(
|
|
PlexRequest(path: "/livetv/dvrs", timeout: .seconds(10)),
|
|
as: PlexMediaContainer<PlexDVR>.self
|
|
)
|
|
return container.content.isEmpty ? .notConfigured : .available(dvrs: container.content)
|
|
} catch PlexError.notFound {
|
|
// Ältere Serverfassungen kennen den Endpunkt gar nicht. Das ist
|
|
// dasselbe Ergebnis wie „kein Empfänger": es gibt nichts zu sehen.
|
|
return .notConfigured
|
|
} catch {
|
|
return .failed(message: String(describing: error))
|
|
}
|
|
}
|
|
|
|
/// Die eingeschalteten Sender eines Empfängers, mit Namen und Logos.
|
|
///
|
|
/// Zwei Quellen, die zusammengeführt werden müssen — das war der
|
|
/// eigentliche Stolperstein:
|
|
///
|
|
/// - 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.
|
|
///
|
|
/// 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.
|
|
public func channels(inDVR dvr: PlexDVR) async -> ChannelLookup {
|
|
let enabled = dvr.enabledChannelKeys
|
|
|
|
var identifier = dvr.epgIdentifier
|
|
if identifier == nil {
|
|
identifier = await electronicProgramGuideIdentifier()
|
|
}
|
|
guard let epgIdentifier = identifier else {
|
|
return ChannelLookup(channels: [], source: .noLineup)
|
|
}
|
|
|
|
let advertised = await providerFeatures(for: epgIdentifier)
|
|
|
|
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
|
|
)
|
|
)
|
|
}
|
|
return ChannelLookup(
|
|
channels: channels,
|
|
source: .lineup(available: container.content.count, path: path)
|
|
)
|
|
} catch {
|
|
lastFailure = "\(path): \(error)"
|
|
}
|
|
}
|
|
|
|
return ChannelLookup(channels: [], source: .failed(lastFailure ?? "kein Weg lieferte Sender"))
|
|
}
|
|
|
|
/// 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] {
|
|
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) {
|
|
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")
|
|
}
|
|
paths.append("/\(epgIdentifier)/channels")
|
|
paths.append("/livetv/dvrs/\(dvr.key)/channels")
|
|
|
|
// Doppelte entfernen, Reihenfolge behalten.
|
|
var seen = Set<String>()
|
|
return paths.filter { seen.insert($0).inserted }
|
|
}
|
|
|
|
/// Was der Programmanbieter an Fähigkeiten angibt.
|
|
public func providerFeatures(for identifier: String) async -> [PlexProviderFeature] {
|
|
guard let container = try? await client.send(
|
|
PlexRequest(path: "/media/providers"),
|
|
as: PlexMediaContainer<PlexMediaProvider>.self
|
|
) else { return [] }
|
|
|
|
return container.content.first { $0.identifier == identifier }?.features ?? []
|
|
}
|
|
|
|
/// Die Kennung des Programmanbieters.
|
|
///
|
|
/// Sie wechselt je nach Server und Einrichtung (etwa
|
|
/// `tv.plex.providers.epg.cloud:2`) und steckt in der Anbieterliste. Fest
|
|
/// verdrahtet wäre sie auf der Hälfte der Server falsch.
|
|
public func electronicProgramGuideIdentifier() async -> String? {
|
|
guard let container = try? await client.send(
|
|
PlexRequest(path: "/media/providers"),
|
|
as: PlexMediaContainer<PlexMediaProvider>.self
|
|
) else { return nil }
|
|
|
|
return container.content.first { $0.identifier.contains("epg") }?.identifier
|
|
}
|
|
|
|
/// Das Programmraster für einen Zeitraum, mit Sendern.
|
|
///
|
|
/// Bewusst über einen Zeitraum statt „alles": ein Tag über hundert Sender
|
|
/// sind mehrere tausend Sendungen, und mehr als die nächsten Stunden
|
|
/// braucht niemand beim Umschalten.
|
|
public func guide(
|
|
providerIdentifier: String,
|
|
from start: Date,
|
|
to end: Date
|
|
) async throws(PlexError) -> [PlexGuideEntry] {
|
|
let container = try await client.send(
|
|
PlexRequest(
|
|
path: "/\(providerIdentifier)/grid",
|
|
query: [
|
|
// 1 steht bei Plex für „Sendung".
|
|
URLQueryItem(name: "type", value: "1"),
|
|
URLQueryItem(name: "beginsAt>", value: String(Int(start.timeIntervalSince1970))),
|
|
URLQueryItem(name: "endsAt<", value: String(Int(end.timeIntervalSince1970))),
|
|
],
|
|
timeout: .seconds(30)
|
|
),
|
|
as: PlexMediaContainer<PlexGridEntry>.self
|
|
)
|
|
|
|
return container.content.compactMap { entry in
|
|
// Sender und Sendung stammen aus demselben Eintrag — deshalb
|
|
// passen die Kennungen zueinander. Getrennt geholt taten sie das
|
|
// nicht, und das Programm landete in keiner Spalte.
|
|
let channel = entry.channel
|
|
guard let program = entry.program(channelID: channel?.id) else { return nil }
|
|
return PlexGuideEntry(channel: channel, program: program)
|
|
}
|
|
}
|
|
|
|
/// Senderliste und Programm in einem Zug.
|
|
///
|
|
/// Der Weg über die Senderliste des Anbieters ist der erste Versuch; wo
|
|
/// er greift, sind Logos und Nummern vollständiger. Wo er nicht greift —
|
|
/// und bei einem Server mit Swisscom-Liste tut er das nicht, alle
|
|
/// bekannten Adressen antworten mit 404 — tragen die Rastereinträge die
|
|
/// Sender selbst. Die Weboberfläche von Plex zeichnet ihren Guide genau
|
|
/// daraus.
|
|
public func liveTVGuide(
|
|
dvr: PlexDVR,
|
|
hours: Int = 6
|
|
) async -> LiveTVGuide {
|
|
let lookup = await channels(inDVR: dvr)
|
|
|
|
var identifier = dvr.epgIdentifier
|
|
if identifier == nil { identifier = await electronicProgramGuideIdentifier() }
|
|
guard let epgIdentifier = identifier else {
|
|
return LiveTVGuide(channels: lookup.channels, programs: [:], source: lookup.description)
|
|
}
|
|
|
|
let now = Date()
|
|
let entries = (try? await guide(
|
|
providerIdentifier: epgIdentifier,
|
|
from: now.addingTimeInterval(-3_600),
|
|
to: now.addingTimeInterval(TimeInterval(hours) * 3_600)
|
|
)) ?? []
|
|
|
|
let programs = Dictionary(grouping: entries.compactMap { entry in
|
|
entry.program.channelID.map { (key: $0, program: entry.program) }
|
|
}, by: \.key).mapValues { pairs in
|
|
pairs.map(\.program).sorted { $0.startsAt < $1.startsAt }
|
|
}
|
|
|
|
if !lookup.channels.isEmpty {
|
|
return LiveTVGuide(channels: lookup.channels, programs: programs, source: lookup.description)
|
|
}
|
|
|
|
// Aus dem Raster: jeden Sender einmal, in der Reihenfolge, in der das
|
|
// Raster ihn nennt. Plex sortiert dort bereits nach Sendernummer.
|
|
var seen = Set<String>()
|
|
let fromGrid = entries.compactMap(\.channel).filter { seen.insert($0.id).inserted }
|
|
|
|
let enabled = dvr.enabledChannelKeys
|
|
let filtered = enabled.isEmpty ? fromGrid : fromGrid.filter { $0.matches(anyOf: enabled) }
|
|
// Passt keiner zur Zuordnung, ist die ganze Liste besser als keine:
|
|
// die Zuordnung zu erzwingen hieße, bei abweichenden Schlüsseln alles
|
|
// wegzuwerfen, was der Server gerade geliefert hat.
|
|
let channels = filtered.isEmpty ? fromGrid : filtered
|
|
|
|
return LiveTVGuide(
|
|
channels: channels,
|
|
programs: programs,
|
|
source: channels.isEmpty
|
|
? "Raster: keine Sender (\(lookup.description))"
|
|
: "Raster: \(channels.count) von \(fromGrid.count) Sendern"
|
|
)
|
|
}
|
|
|
|
/// Schaltet einen Sender ein.
|
|
///
|
|
/// Live-TV kommt immer als umgerechneter Strom: der Tuner liefert einen
|
|
/// Transportstrom, den kein iPhone direkt abspielt. Deshalb führt der Weg
|
|
/// hier zwangsläufig über den Transcoder — anders als bei Dateien, wo
|
|
/// DirectPlay der Normalfall ist.
|
|
public func tune(channel: PlexChannel, maximumBitrate: Int?) async throws(PlexError) -> URL? {
|
|
guard let key = channel.key else { return nil }
|
|
|
|
let session = UUID().uuidString
|
|
let container = try await client.send(
|
|
PlexRequest(
|
|
path: "/livetv/sessions/\(session)/\(key)",
|
|
query: [URLQueryItem(name: "X-Plex-Session-Identifier", value: session)],
|
|
timeout: .seconds(30)
|
|
),
|
|
as: PlexMediaContainer<PlexItem>.self
|
|
)
|
|
|
|
guard let item = container.content.first else { throw .notFound }
|
|
return transcodeURL(for: TranscodeRequest(
|
|
ratingKey: item.ratingKey,
|
|
maximumBitrate: maximumBitrate,
|
|
isLocal: connection.local,
|
|
sessionIdentifier: session
|
|
))
|
|
}
|
|
}
|
|
|
|
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 {
|
|
var report = ["=== /livetv/dvrs ==="]
|
|
report.append(await structure(ofPath: "/livetv/dvrs"))
|
|
|
|
// Was der Server an Endpunkten angibt. Nach drei geratenen Adressen
|
|
// mit 404 ist das die Stelle, an der die Antwort stehen muss.
|
|
report.append("\n=== /media/providers ===")
|
|
report.append(await structure(ofPath: "/media/providers"))
|
|
|
|
// Und die Senderliste beim Anbieter — dort stehen die Namen. Ohne sie
|
|
// im selben Abzug bliebe nach einem Fehlschlag wieder eine Runde
|
|
// Raten übrig.
|
|
if let dvr = try? await client.send(
|
|
PlexRequest(path: "/livetv/dvrs", timeout: .seconds(10)),
|
|
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
|
|
) {
|
|
report.append("\n=== \(path) ===")
|
|
report.append(await structure(ofPath: path))
|
|
}
|
|
// Und das Raster: wenn keine Senderliste antwortet, muss die
|
|
// Antwort hier stehen — die Weboberfläche zeichnet ihren Guide
|
|
// daraus.
|
|
let now = Int(Date().timeIntervalSince1970)
|
|
let gridPath = "/\(epgIdentifier)/grid"
|
|
report.append("\n=== \(gridPath) ===")
|
|
report.append(await structure(
|
|
ofPath: gridPath,
|
|
query: [
|
|
URLQueryItem(name: "type", value: "1"),
|
|
URLQueryItem(name: "beginsAt>", value: String(now - 3_600)),
|
|
URLQueryItem(name: "endsAt<", value: String(now + 3_600)),
|
|
]
|
|
))
|
|
} else {
|
|
report.append("\n=== Senderliste ===\nKeine Kennung des Programmanbieters am Empfänger")
|
|
}
|
|
|
|
return report.joined(separator: "\n")
|
|
}
|
|
|
|
private func structure(ofPath path: String, query: [URLQueryItem] = []) async -> String {
|
|
let data: Data
|
|
do {
|
|
data = try await client.send(PlexRequest(path: path, query: query, timeout: .seconds(30)))
|
|
} 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", "type", "key", "identifier",
|
|
"channelTitle", "channelCallSign", "channelVcn"].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 {
|
|
/// Aus der Senderliste des Programmanbieters, über diesen Pfad.
|
|
case lineup(available: Int, path: String)
|
|
/// Die Senderliste kam an, aber keiner passte zur Zuordnung.
|
|
case lineupWithoutMatches(available: Int, enabled: Int)
|
|
/// Am Empfänger hängt keine Senderliste oder kein Programmanbieter.
|
|
case noLineup
|
|
case failed(String)
|
|
}
|
|
|
|
public let channels: [PlexChannel]
|
|
public let source: Source
|
|
|
|
public init(channels: [PlexChannel], source: Source) {
|
|
self.channels = channels
|
|
self.source = source
|
|
}
|
|
|
|
/// Kurzbeschreibung für die Diagnose.
|
|
public var description: String {
|
|
switch source {
|
|
case .lineup(let available, let path):
|
|
"\(channels.count) von \(available) Sendern · \(path)"
|
|
case .lineupWithoutMatches(let available, let enabled):
|
|
"Senderliste hat \(available) Sender, \(enabled) sind zugeordnet — keiner passte zusammen"
|
|
case .noLineup:
|
|
"Am Empfänger hängt keine Senderliste"
|
|
case .failed(let reason):
|
|
"Abfrage gescheitert: \(reason)"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ein Anbieter am Server — Mediathek, Programmführer, Entdecken.
|
|
public struct PlexMediaProvider: Sendable, Equatable, Decodable, Identifiable {
|
|
public let identifier: String
|
|
public let title: String?
|
|
public let types: String?
|
|
/// Was dieser Anbieter kann, und unter welcher Adresse.
|
|
///
|
|
/// Der Grund, warum hier überhaupt etwas steht: Plex sagt selbst, welche
|
|
/// Endpunkte es anbietet. Drei Versuche, die Adresse der Senderliste zu
|
|
/// erraten, endeten in 404 — sie hier abzulesen ist kein Ratespiel mehr.
|
|
public let features: [PlexProviderFeature]
|
|
|
|
public var id: String { identifier }
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case identifier, title, types
|
|
case features = "Feature"
|
|
}
|
|
|
|
public init(from decoder: any Decoder) throws {
|
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.identifier = try values.decode(String.self, forKey: .identifier)
|
|
self.title = try values.decodeIfPresent(String.self, forKey: .title)
|
|
self.types = try values.decodeIfPresent(String.self, forKey: .types)
|
|
self.features = try values.decodeIfPresent([PlexProviderFeature].self, forKey: .features) ?? []
|
|
}
|
|
|
|
public init(identifier: String, title: String? = nil, types: String? = nil, features: [PlexProviderFeature] = []) {
|
|
self.identifier = identifier
|
|
self.title = title
|
|
self.types = types
|
|
self.features = features
|
|
}
|
|
|
|
/// Die Adresse für eine Fähigkeit, falls der Anbieter sie anbietet.
|
|
public func key(forFeature type: String) -> String? {
|
|
features.first { $0.type == type }?.key
|
|
}
|
|
}
|
|
|
|
/// Eine Fähigkeit eines Anbieters, mit der Adresse dahinter.
|
|
public struct PlexProviderFeature: Sendable, Equatable, Decodable, Identifiable {
|
|
public let type: String
|
|
public let key: String?
|
|
|
|
public var id: String { key ?? type }
|
|
|
|
public init(type: String, key: String? = nil) {
|
|
self.type = type
|
|
self.key = key
|
|
}
|
|
}
|
|
|
|
/// Ein Rastereintrag: eine Sendung und der Sender, auf dem sie läuft.
|
|
public struct PlexGuideEntry: Sendable {
|
|
public let channel: PlexChannel?
|
|
public let program: PlexProgram
|
|
}
|
|
|
|
/// Senderliste und Programm, so wie die Live-TV-Ansicht sie braucht.
|
|
public struct LiveTVGuide: Sendable {
|
|
public let channels: [PlexChannel]
|
|
/// Sendungen je Sender, nach Anfangszeit sortiert.
|
|
public let programs: [String: [PlexProgram]]
|
|
/// Woher die Sender kamen — steht in der leeren Ansicht und in der Diagnose.
|
|
public let source: String
|
|
}
|