Der Strukturabzug vom Server hat es entschieden. ChannelMapping enthält genau vier Felder — channelKey, deviceIdentifier, enabled, lineupIdentifier — und keinen einzigen Namen. Es ist keine Senderliste, sondern ein Verweis darauf: welche 28 von 521 Sendern der Swisscom-Liste eingeschaltet sind. Zweimal habe ich die Zuordnung für die Senderliste gehalten. Beim ersten Mal blieb sie leer, beim zweiten hätten alle 28 Sender „—" geheißen und dieselbe Kennung getragen — SwiftUI hätte bei doppelten Kennungen ohnehin nur einen gezeigt. Jetzt werden beide Quellen zusammengeführt: die Namen, Nummern und Logos kommen von der Senderliste beim Programmanbieter, die Auswahl aus der Zuordnung. Die Kennung des Anbieters steht als `epgIdentifier` direkt am Empfänger — verlässlicher, als sie aus der Anbieterliste zu suchen. Der Abgleich ist nachsichtig: je nach Anbieter entspricht der channelKey der Kennung, dem Pfad oder der Sendernummer. Ein strenger Vergleich fände in zwei von drei Fällen nichts, und „keiner passte zusammen" sieht aus wie „keine Sender". Genau das unterscheidet die Meldung jetzt auch. Der Strukturabzug in der Diagnose deckt jetzt beide Endpunkte ab — nach einem Fehlschlag bleibt keine weitere Runde Raten übrig. 166 Tests, neun davon zu Zuordnung und Abgleich. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
289 lines
12 KiB
Swift
289 lines
12 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
|
|
|
|
// `??` mit einem asynchronen Aufruf rechts geht nicht — die
|
|
// Kurzschlussauswertung ist nicht nebenläufigkeitsfähig.
|
|
var identifier = dvr.epgIdentifier
|
|
if identifier == nil {
|
|
identifier = await electronicProgramGuideIdentifier()
|
|
}
|
|
|
|
guard let epgIdentifier = identifier, let lineup = dvr.lineups.first else {
|
|
return ChannelLookup(channels: [], source: .noLineup)
|
|
}
|
|
|
|
do {
|
|
let container = try await client.send(
|
|
PlexRequest(
|
|
path: "/\(epgIdentifier)/lineups/\(lineup.id)/channels",
|
|
timeout: .seconds(30)
|
|
),
|
|
as: PlexMediaContainer<PlexChannel>.self
|
|
)
|
|
|
|
// 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) }
|
|
|
|
return ChannelLookup(
|
|
channels: channels,
|
|
source: channels.isEmpty
|
|
? .lineupWithoutMatches(available: container.content.count, enabled: enabled.count)
|
|
: .lineup(available: container.content.count)
|
|
)
|
|
} catch {
|
|
return ChannelLookup(channels: [], source: .failed(String(describing: error)))
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// 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) -> [PlexProgram] {
|
|
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
|
|
// Die Senderzuordnung steckt im Medien-Block. Fehlt sie, lässt
|
|
// sich die Sendung keiner Spalte zuordnen — dann ist sie für ein
|
|
// Raster wertlos.
|
|
let channelID = entry.media?.first?.parts.first?.key
|
|
return entry.program(channelID: channelID)
|
|
}
|
|
}
|
|
|
|
/// 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"))
|
|
|
|
// 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 lineup = dvr.lineups.first {
|
|
let path = "/\(epgIdentifier)/lineups/\(lineup.id)/channels"
|
|
report.append("\n=== \(path) ===")
|
|
report.append(await structure(ofPath: path))
|
|
} else {
|
|
report.append("\n=== Senderliste ===\nKeine Kennung oder Senderliste am Empfänger")
|
|
}
|
|
|
|
return report.joined(separator: "\n")
|
|
}
|
|
|
|
private func structure(ofPath path: String) async -> String {
|
|
let data: Data
|
|
do {
|
|
data = try await client.send(PlexRequest(path: path, 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"].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.
|
|
case lineup(available: Int)
|
|
/// 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):
|
|
"\(channels.count) von \(available) Sendern eingeschaltet"
|
|
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?
|
|
|
|
public var id: String { identifier }
|
|
}
|