Der Abzug vom Server zeigt beides schwarz auf weiß. Erstens: /media/grabbers/devices/1/channels antwortet mit 278 Sendern — unter dem Sammelnamen `DeviceChannel`. Die Hülle kannte nur `Channel` und hat die vollständige Liste als leer verworfen. Zweitens: das Programmraster antwortet, aber nicht auf `type=1`. Die 1 steht bei Plex für Film, und im Programm laufen auch Folgen, Sport und Nachrichten — der Filter hat das Raster geleert. Ohne ihn kommen die Sendungen samt Sendername, Nummer, Logo und Kennung. Das Raster hat jetzt Vorrang vor der Tunerliste, auch wenn beide antworten: nur dort gehören Sender und Programm zusammen, es enthält ohnehin nur die eingeschalteten Sender, und die Tunerliste führt alle 278. Einschalten läuft über die Adresse der laufenden Sendung beim Programmanbieter — Plex behandelt eine laufende Sendung wie einen Film. Die Adresse wird einmal entpackt, bevor sie in die Anfrage geht: sonst kodiert URLComponents ein zweites Mal, und aus %3A wird %253A. Die Tests stehen jetzt auf den echten Antworten aus dem Abzug, nicht auf erfundenen: DeviceChannel mit `name` und `key`, Rastereinträge mit channelVcn, channelID und einer Folge neben einem Film. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
643 lines
26 KiB
Swift
643 lines
26 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 Sender eines Empfängers — gefunden, nicht geraten.
|
|
///
|
|
/// 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.
|
|
///
|
|
/// Deshalb wird sie jetzt gesucht statt geraten, in einem Durchgang und
|
|
/// gleichzeitig:
|
|
///
|
|
/// 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 {
|
|
var identifier = dvr.epgIdentifier
|
|
if identifier == nil { identifier = await electronicProgramGuideIdentifier() }
|
|
guard let epgIdentifier = identifier else {
|
|
return ChannelLookup(channels: [], source: .noLineup, trace: [])
|
|
}
|
|
|
|
let paths = await candidatePaths(dvr: dvr, epgIdentifier: epgIdentifier)
|
|
var found: [Int: [PlexChannel]] = [:]
|
|
var trace: [Int: ChannelProbe] = [:]
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
for await (index, channels, probe) in group {
|
|
found[index] = channels
|
|
trace[index] = probe
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/// 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] = []
|
|
|
|
// 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) }
|
|
}
|
|
|
|
// 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)")
|
|
}
|
|
}
|
|
|
|
// 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(
|
|
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: [
|
|
// Ohne Typfilter. `type=1` steht bei Plex für Filme, und
|
|
// im Programm laufen auch Folgen, Sport und Nachrichten —
|
|
// der Filter hat das Raster leer zurückgegeben.
|
|
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,
|
|
trace: lookup.trace
|
|
)
|
|
}
|
|
|
|
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 }
|
|
}
|
|
|
|
// Aus dem Raster: jeden Sender einmal, nach Sendernummer sortiert.
|
|
var seen = Set<String>()
|
|
let fromGrid = entries
|
|
.compactMap(\.channel)
|
|
.filter { seen.insert($0.id).inserted }
|
|
.sorted { ($0.channelIdentifier ?? "") < ($1.channelIdentifier ?? "") }
|
|
|
|
// Das Raster hat Vorrang vor der Senderliste des Tuners, auch wenn
|
|
// beide antworten: nur dort gehören Sender und Programm zusammen,
|
|
// stehen Logos dabei, und es enthält ohnehin nur die eingeschalteten
|
|
// Sender. Die Tunerliste führt alle 278, die meisten davon aus.
|
|
if !fromGrid.isEmpty {
|
|
return LiveTVGuide(
|
|
channels: fromGrid,
|
|
programs: programs,
|
|
source: "Raster: \(fromGrid.count) Sender",
|
|
trace: lookup.trace
|
|
)
|
|
}
|
|
|
|
if !lookup.channels.isEmpty {
|
|
return LiveTVGuide(
|
|
channels: lookup.channels,
|
|
programs: programs,
|
|
source: lookup.description,
|
|
trace: lookup.trace
|
|
)
|
|
}
|
|
|
|
return LiveTVGuide(
|
|
channels: [],
|
|
programs: programs,
|
|
source: "Raster: keine Sender (\(lookup.description))",
|
|
trace: lookup.trace + [ChannelProbe(
|
|
path: "/\(epgIdentifier)/grid",
|
|
result: fromGrid.isEmpty
|
|
? "\(entries.count) Sendungen, kein Sender benannt"
|
|
: "\(fromGrid.count) Sender"
|
|
)]
|
|
)
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// Zwei Wege, je nachdem, woher der Sender kam. Steht die laufende
|
|
/// Sendung im Programmraster, ist ihre Adresse beim Programmanbieter der
|
|
/// direkte Weg: Plex behandelt eine laufende Sendung wie einen Film.
|
|
/// Stammt der Sender dagegen aus der Liste des Tuners, bleibt der Umweg
|
|
/// über eine Live-Sitzung.
|
|
public func tune(
|
|
channel: PlexChannel,
|
|
program: PlexProgram? = nil,
|
|
maximumBitrate: Int?
|
|
) async throws(PlexError) -> URL? {
|
|
let session = UUID().uuidString
|
|
|
|
if let metadataKey = program?.metadataKey {
|
|
return transcodeURL(for: TranscodeRequest(
|
|
ratingKey: channel.id,
|
|
maximumBitrate: maximumBitrate,
|
|
isLocal: connection.local,
|
|
sessionIdentifier: session,
|
|
pathOverride: metadataKey
|
|
))
|
|
}
|
|
|
|
guard let key = channel.key else { return nil }
|
|
|
|
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 {
|
|
for path in await candidatePaths(dvr: dvr, epgIdentifier: epgIdentifier) {
|
|
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))"]
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
/// 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
|
|
|
|
/// 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.
|
|
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
|
|
/// Jede abgeklopfte Adresse mit ihrer Antwort, falls nichts gefunden wurde.
|
|
public let trace: [ChannelProbe]
|
|
}
|