Wieder HTTP 400, und wieder ohne Begründung. Der Grund, dass das dreimal passieren konnte: eine gebaute Adresse galt schon als Erfolg. Ob der Server sie überhaupt annimmt, stellte sich erst im Player heraus — und dort steht nur „HTTP 400 (unhandled)". Jetzt ruft die App die Adresse einmal selbst ab, ohne Plex-Kopfzeilen, genau so wie AVPlayer es tut. Nur was mit 200 antwortet, geht an den Player. Was nicht, steht mit dem Wortlaut des Servers in der Spur. Weil weder der Schlüssel für die Sitzung noch die anschließend umzurechnende Adresse für diesen Teil der Plex-Schnittstelle beschrieben sind, werden die Kombinationen der Reihe nach durchprobiert — Adresse der Sitzung, Adresse aus der Antwort, Adresse des Teils darin, je mit mediaIndex -1 und 0, höchstens acht Versuche. Antwortet keiner, wird der Transcoder wieder angehalten, statt für nichts weiterzurechnen. Die Spur steht in einem Blatt statt in einem Dialog: die Antwort des Servers ist mehrzeilig, und ein Dialog schneidet sie ab — dann stünde dort wieder nur, dass es nicht ging. Dazu ein Nebeneffekt, der beim zweiten Sender zugeschlagen hätte: der Player kannte die Sitzungskennung des Live-Stroms nicht und hat den Tuner beim Schließen nicht freigegeben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
805 lines
34 KiB
Swift
805 lines
34 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.
|
|
///
|
|
/// Zwei Schritte, und der erste ist der, den ich zuerst übersprungen
|
|
/// hatte: **der Server muss den Tuner belegen**. Erst danach gibt es
|
|
/// etwas umzurechnen.
|
|
///
|
|
/// Weder der Schlüssel für die Sitzung noch die Adresse, die anschließend
|
|
/// umzurechnen ist, sind für diesen Teil der Plex-Schnittstelle
|
|
/// beschrieben. Deshalb wird jede Kombination **bis zum Ende geprüft**:
|
|
/// die Adresse wird einmal selbst abgerufen, ohne Kopfzeilen — genau so,
|
|
/// wie der Systemplayer es tut. Erst was dabei mit 200 antwortet, geht an
|
|
/// den Player. Vorher galt schon eine gebaute Adresse als Erfolg, und der
|
|
/// Fehler tauchte erst im Player auf, als „HTTP 400" ohne Begründung.
|
|
public func tune(
|
|
channel: PlexChannel,
|
|
program: PlexProgram? = nil,
|
|
maximumBitrate: Int?
|
|
) async -> LiveTuneResult {
|
|
let session = UUID().uuidString
|
|
var trace: [ChannelProbe] = []
|
|
var attempts = 0
|
|
|
|
for key in Self.sessionKeys(channel: channel, program: program) {
|
|
let sessionPath = "/livetv/sessions/\(session)"
|
|
let openPath = "\(sessionPath)/\(key)"
|
|
|
|
let item: PlexItem?
|
|
do {
|
|
item = try await client.send(
|
|
PlexRequest(
|
|
path: openPath,
|
|
query: [URLQueryItem(name: "X-Plex-Session-Identifier", value: session)],
|
|
timeout: .seconds(30)
|
|
),
|
|
as: PlexMediaContainer<PlexItem>.self
|
|
).content.first
|
|
} catch {
|
|
trace.append(ChannelProbe(path: openPath, result: Self.shortDescription(of: error)))
|
|
continue
|
|
}
|
|
|
|
guard let item else {
|
|
trace.append(ChannelProbe(path: openPath, result: "leer"))
|
|
continue
|
|
}
|
|
trace.append(ChannelProbe(path: openPath, result: "Sitzung offen"))
|
|
|
|
for candidate in Self.streamCandidates(item: item, sessionPath: sessionPath) {
|
|
guard attempts < 8 else { break }
|
|
attempts += 1
|
|
|
|
let request = TranscodeRequest(
|
|
ratingKey: item.ratingKey,
|
|
mediaIndex: candidate.mediaIndex,
|
|
partIndex: candidate.mediaIndex,
|
|
maximumBitrate: maximumBitrate,
|
|
isLocal: connection.local,
|
|
sessionIdentifier: session,
|
|
pathOverride: candidate.path
|
|
)
|
|
guard let url = transcodeURL(for: request) else { continue }
|
|
|
|
let outcome = await probeStream(url: url)
|
|
trace.append(ChannelProbe(
|
|
path: "\(candidate.path) (mediaIndex \(candidate.mediaIndex))",
|
|
result: outcome
|
|
))
|
|
|
|
if outcome.hasPrefix("HTTP 200") {
|
|
return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Letzter Versuch ohne eigene Sitzung: die Sendung unmittelbar
|
|
// umrechnen lassen. Auf Servern, die den Tuner selbst belegen, genügt
|
|
// das — und geprüft wird es genauso wie alles andere.
|
|
if let metadataKey = program?.metadataKey, attempts < 8 {
|
|
let request = TranscodeRequest(
|
|
ratingKey: channel.id,
|
|
maximumBitrate: maximumBitrate,
|
|
isLocal: connection.local,
|
|
sessionIdentifier: session,
|
|
pathOverride: metadataKey
|
|
)
|
|
if let url = transcodeURL(for: request) {
|
|
let outcome = await probeStream(url: url)
|
|
trace.append(ChannelProbe(path: metadataKey, result: outcome))
|
|
if outcome.hasPrefix("HTTP 200") {
|
|
return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Kein Weg hat mit 200 geantwortet. Den Transcoder anhalten, sonst
|
|
// rechnet der Server für eine Wiedergabe weiter, die es nicht gibt.
|
|
await stopTranscode(sessionIdentifier: session)
|
|
return LiveTuneResult(url: nil, sessionIdentifier: session, trace: trace)
|
|
}
|
|
|
|
/// Welche Adresse mit welchem Index umzurechnen ist.
|
|
///
|
|
/// Die Adresse der Sitzung zuerst — der Transcoder sucht sich den Teil
|
|
/// dann selbst, und dafür steht der Index -1. Die übrigen Formen stehen
|
|
/// dahinter, weil sich dieser Teil der Schnittstelle zwischen
|
|
/// Serverfassungen unterscheidet und nirgends beschrieben ist.
|
|
static func streamCandidates(
|
|
item: PlexItem,
|
|
sessionPath: String
|
|
) -> [(path: String, mediaIndex: Int)] {
|
|
var paths = [sessionPath]
|
|
if item.key.hasPrefix("/livetv/"), !paths.contains(item.key) { paths.append(item.key) }
|
|
if let part = item.media.first?.parts.first?.key, !paths.contains(part) { paths.append(part) }
|
|
|
|
return paths.flatMap { path in
|
|
[(path: path, mediaIndex: -1), (path: path, mediaIndex: 0)]
|
|
}
|
|
}
|
|
|
|
/// Holt eine Stromadresse einmal selbst ab und sagt, was zurückkam.
|
|
///
|
|
/// **Ohne Plex-Kopfzeilen**, weil AVPlayer sie ebenfalls nicht schickt.
|
|
/// Mit Kopfzeilen zu prüfen hieße, etwas anderes zu prüfen als das, was
|
|
/// später wirklich passiert — und dann stünde der Fehler wieder erst im
|
|
/// Player, wo niemand mehr sieht, woran es lag.
|
|
public func probeStream(url: URL) async -> String {
|
|
var request = URLRequest(url: url)
|
|
request.timeoutInterval = 20
|
|
|
|
do {
|
|
let (data, response) = try await transport.data(for: request)
|
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
guard status != 200 else { return "HTTP 200" }
|
|
|
|
// Plex legt dem Fehler eine Begründung bei. Genau die fehlte
|
|
// bisher: der Player zeigte „HTTP 400 (unhandled)" und sonst nichts.
|
|
let body = String(decoding: data.prefix(300), as: UTF8.self)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.replacingOccurrences(of: "\n", with: " ")
|
|
return body.isEmpty ? "HTTP \(status)" : "HTTP \(status): \(body)"
|
|
} catch {
|
|
return Self.shortDescription(of: error)
|
|
}
|
|
}
|
|
|
|
/// Unter welchen Schlüsseln sich eine Live-Sitzung eröffnen lässt.
|
|
///
|
|
/// Der Raster-Schlüssel zuerst: er bezeichnet den Sender genau so, wie
|
|
/// das Programmraster ihn führt, aus dem auch die Senderliste kommt.
|
|
static func sessionKeys(channel: PlexChannel, program: PlexProgram?) -> [String] {
|
|
var keys = [
|
|
channel.gridKey,
|
|
program?.metadataKey.flatMap { $0.split(separator: "/").last.map(String.init) },
|
|
program?.id,
|
|
channel.key,
|
|
channel.id,
|
|
].compactMap { $0 }
|
|
|
|
// Volle Adressen taugen hier nicht: an `/livetv/sessions/<uuid>/`
|
|
// gehört ein Schlüssel, kein zweiter Pfad.
|
|
keys = keys.map { $0.split(separator: "/").last.map(String.init) ?? $0 }
|
|
|
|
var seen = Set<String>()
|
|
return keys.filter { !$0.isEmpty && seen.insert($0).inserted }
|
|
}
|
|
|
|
/// Fragt den Server, ob er diesen Strom zulässt — und warum nicht.
|
|
public func liveTVVerdict(for url: URL) async -> LiveTVVerdict {
|
|
// Dieselben Angaben, nur an den Entscheider statt an den Transcoder.
|
|
// Er antwortet im Klartext, warum er ablehnt — sonst bleibt nur „die
|
|
// Wiedergabe wurde unterbrochen", und das sagt nichts.
|
|
guard let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems
|
|
else { return .unknown("keine Abfrageteile in der Adresse") }
|
|
|
|
do {
|
|
let decision = try await client.send(
|
|
PlexRequest(path: "/video/:/transcode/universal/decision", query: items),
|
|
as: TranscodeDecision.self
|
|
)
|
|
guard decision.isAllowed else {
|
|
return .refused(decision.explanation ?? "ohne Begründung")
|
|
}
|
|
return .allowed
|
|
} catch {
|
|
// Antwortet der Entscheider gar nicht, heißt das **nicht** nein.
|
|
// Live-Adressen kennt er womöglich nicht; darauf die Wiedergabe
|
|
// zu verweigern hieße, eine funktionierende Sendung zu blocken.
|
|
return .unknown(Self.shortDescription(of: error))
|
|
}
|
|
}
|
|
}
|
|
|
|
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]
|
|
}
|
|
|
|
/// Was beim Einschalten herauskam.
|
|
public struct LiveTuneResult: Sendable {
|
|
public let url: URL?
|
|
public let sessionIdentifier: String
|
|
/// Jeder versuchte Weg mit seiner Antwort.
|
|
public let trace: [ChannelProbe]
|
|
}
|
|
|
|
/// Was der Server zu einem Strom sagt.
|
|
public enum LiveTVVerdict: Sendable, Equatable {
|
|
case allowed
|
|
/// Ausdrücklich abgelehnt, mit Begründung.
|
|
case refused(String)
|
|
/// Der Entscheider hat nicht geantwortet — kein Grund, es nicht zu versuchen.
|
|
case unknown(String)
|
|
}
|