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>
239 lines
9.5 KiB
Swift
239 lines
9.5 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, samt Auskunft darüber, woher sie kamen.
|
|
///
|
|
/// Zwei Quellen, in dieser Reihenfolge. Die erste ist die verlässliche:
|
|
/// Plex liefert die Sender bereits verschachtelt in der Antwort auf
|
|
/// `/livetv/dvrs` mit. Der getrennte Endpunkt war meine erste Vermutung
|
|
/// und lieferte nichts — daran hing „Keine Sender" bei einem Server mit
|
|
/// über dreißig eingerichteten Sendern.
|
|
///
|
|
/// Die Auskunft über die Quelle steht in der Diagnose. Bei einem Bereich
|
|
/// ohne offizielle Beschreibung ist „welcher Weg hat gegriffen" die
|
|
/// Angabe, mit der sich der nächste Fehler finden lässt.
|
|
public func channels(inDVR dvr: PlexDVR) async -> ChannelLookup {
|
|
if !dvr.channels.isEmpty {
|
|
return ChannelLookup(channels: dvr.channels, source: .embeddedInDVR)
|
|
}
|
|
|
|
do {
|
|
let container = try await client.send(
|
|
PlexRequest(path: "/livetv/dvrs/\(dvr.key)/channels"),
|
|
as: PlexMediaContainer<PlexChannel>.self
|
|
)
|
|
return ChannelLookup(
|
|
channels: container.content,
|
|
source: container.content.isEmpty ? .nothingFound : .separateEndpoint
|
|
)
|
|
} 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 {
|
|
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 {
|
|
/// Aus der Antwort auf `/livetv/dvrs` — der übliche Weg.
|
|
case embeddedInDVR
|
|
/// Über `/livetv/dvrs/{id}/channels`.
|
|
case separateEndpoint
|
|
/// Beide Wege lieferten nichts.
|
|
case nothingFound
|
|
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 .embeddedInDVR: "\(channels.count) Sender, aus der Empfängerliste"
|
|
case .separateEndpoint: "\(channels.count) Sender, über /livetv/dvrs/…/channels"
|
|
case .nothingFound: "Kein Weg lieferte Sender"
|
|
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 }
|
|
}
|