Files
Flaro/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift
T
ScarriffleandClaude Opus 5 24765a5afc Live-TV bekommt einen neutralen Vertrag
Zwei Protokolle statt eines: LiveTVBackend für Sender, Programm und
Einschalten, LiveTVRecordingBackend für Aufnahmen. Getrennt, weil Plex
das eine kann und das andere nicht — ein as? sagt die Wahrheit, eine
Methode, die bei Plex nur wirft, verspricht etwas und bricht es dann.

Plex zieht um, ohne umgebaut zu werden. Der Suchlauf, der die
Senderliste findet, bleibt Zeile für Zeile, wo er ist; davor liegt jetzt
ein Actor, der sich Empfänger und Sender merkt. Nötig, weil PlexBackend
ein struct ist und tune() den echten PlexChannel braucht — ohne
Gedächtnis liefe der Suchlauf bei jedem Antippen neu.

Jellyfin ist neu und deutlich kürzer, weil es dort nichts zu suchen
gibt. Die Endpunkte stammen aus der OpenAPI-Spec des Servers. Beim
Vormerken wird die Vorlage des Servers roh weitergereicht statt
entschlüsselt und neu zusammengesetzt: welche Felder Pflicht sind, sagt
die Beschreibung nicht, und ein verlorenes fiele erst an einer Aufnahme
auf, die nicht startet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 06:17:10 +02:00

1705 lines
73 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import MediaKit
/// Was beim Live-TV herauskommen kann.
public enum PlexLiveTVAvailability: 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 -> PlexLiveTVAvailability {
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.
///
/// Zurück kommt, was den Zeitraum **berührt** — nicht, was vollständig
/// hineinpasst. Das ist der Unterschied, an dem das Programm vorher falsch
/// war: gefiltert wurde auf `endsAt <` Fensterende und `beginsAt >`
/// Fensteranfang, und damit fiel ausgerechnet die laufende Sendung heraus.
/// Ein Zweistünder, der um halb vier begann, war um Viertel nach fünf
/// unsichtbar, und angezeigt wurde stattdessen die nächste, die durch den
/// Filter passte — sichtbar falsch, und anders als Plex' eigene Anzeige.
public func guide(
providerIdentifier: String,
from start: Date,
to end: Date
) async throws(PlexError) -> [PlexGuideEntry] {
// Nur der **Anfang** wird eingegrenzt, und mit Vorlauf. Eine Grenze
// auf das Ende schließt jede Sendung aus, die über das Fenster
// hinausläuft — also gerade die längsten.
let lead = start.addingTimeInterval(-Self.longestProgramme)
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(lead.timeIntervalSince1970))),
URLQueryItem(name: "beginsAt<", 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)
}
}
/// Wie weit vor dem Fenster gesucht wird.
///
/// Sechs Stunden decken alles ab, was im Fernsehen am Stück läuft — lange
/// Spielfilme, Sportübertragungen, Wahlabende.
static var longestProgramme: TimeInterval { 6 * 3_600 }
/// 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)
)) ?? []
// Der Vorlauf der Abfrage holt auch Abgelaufenes mit — das gehört
// nicht ins Programm. **Nur** die Sendungen werden so gefiltert, nicht
// die Sender: ein Sender ohne frische Programmdaten soll trotzdem in
// der Liste stehen, sonst verschwindet er, statt „kein Programm" zu
// sagen.
let fresh = entries.filter { $0.program.endsAt > now }
let programs = Dictionary(grouping: fresh.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 Stream: 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,
dvr: PlexDVR? = nil,
maximumBitrate: Int?
) async -> PlexTuneResult {
let session = UUID().uuidString
var attempts = 0
// Womit überhaupt gearbeitet wird. Ich habe den Raster-Schlüssel
// benutzt, ohne ihn je gesehen zu haben — und im Suchraum nur seinen
// letzten Abschnitt verwendet.
// Die Sendernummer am Empfänger. Der Programmführer kennt den Sender
// als `6228f7f2…-5fc706…`, der Tuner als `300` — ohne diese
// Übersetzung antwortet der Empfänger auf alles mit 404.
let deviceChannel = dvr?.deviceChannel(forChannelKey: channel.id)
var trace: [ChannelProbe] = [ChannelProbe(
path: "Sender-Schlüssel",
result: [
"gridKey=\(channel.gridKey ?? "–")",
"key=\(channel.key ?? "–")",
"id=\(channel.id)",
"nummer=\(channel.channelIdentifier ?? "–")",
"amEmpfänger=\(deviceChannel ?? "–")",
].joined(separator: ", ")
)]
// Der Weg, den Plex' eigener Client geht. Nachgelesen in seinem
// Quelltext, nicht erraten:
//
// POST /livetv/dvrs/{dvr}/channels/{kennung}/tune
// X-Plex-Session-Identifier: <sitzung>
//
// **POST**, nicht GET. Genau diesen Pfad hatte ich seit mehreren
// Runden richtig — und jedes Mal mit der falschen Methode abgefragt.
// Ein GET darauf antwortet mit 404, und das sah aus wie „gibt es
// nicht" statt wie „nicht so".
if let dvr {
attempts += 1
let outcome = await tuneThroughDVR(
channel: channel,
dvr: dvr,
session: session,
maximumBitrate: maximumBitrate
)
trace.append(contentsOf: outcome.probes)
if let url = outcome.url {
return PlexTuneResult(
url: url,
sessionIdentifier: session,
subscriptionKey: outcome.subscription,
trace: trace
)
}
// Belegt, aber nicht abspielbar: den Tuner sofort wieder
// freigeben, statt ihn für nichts zu halten.
if let subscription = outcome.subscription {
await endLiveSession(subscriptionKey: subscription)
// Und hier aufhören. Dass das Belegen klappt, beantwortet die
// Frage nach dem Weg — die übrigen zwanzig Abfragen sind dann
// nur noch Lärm im Bericht und Last für den Server.
return PlexTuneResult(
url: nil,
sessionIdentifier: session,
subscriptionKey: nil,
trace: trace
)
}
}
// Der Raster-Schlüssel als Adresse, unverkürzt. Bisher habe ich nur
// seinen letzten Abschnitt als Sitzungsschlüssel probiert — als
// vollständige Adresse war er nie an der Reihe.
if let gridKey = channel.gridKey, gridKey.hasPrefix("/") {
attempts += 1
let outcome = await attemptStream(
path: gridKey,
ratingKey: channel.id,
mediaIndex: 0,
includeProfile: true,
maximumBitrate: maximumBitrate,
session: session
)
trace.append(outcome.probe)
if let url = outcome.url {
return PlexTuneResult(url: url, sessionIdentifier: session, trace: trace)
}
}
// Die Sendung erst nachschlagen. Das Raster nennt keine abspielbare
// Adresse — die Einzelabfrage beim Programmanbieter womöglich schon.
// Ihre Adresse blind an den Transcoder zu geben, hat dreimal eine
// nackte 400 ergeben: eine Sendung beim Cloud-Programmführer hat
// keine Datei, die sich umrechnen ließe.
if let metadataKey = program?.metadataKey {
let lookup = await programMedia(atKey: metadataKey)
trace.append(ChannelProbe(path: "\(metadataKey) (nachgeschlagen)", result: lookup.summary))
for part in lookup.parts {
guard attempts < 14 else { break }
attempts += 1
let outcome = await attemptStream(
path: part,
ratingKey: lookup.ratingKey ?? channel.id,
mediaIndex: 0,
includeProfile: true,
maximumBitrate: maximumBitrate,
session: session
)
trace.append(outcome.probe)
if let url = outcome.url {
return PlexTuneResult(url: url, sessionIdentifier: session, trace: trace)
}
}
}
if let metadataKey = program?.metadataKey {
// Ohne Profil als dritter Versuch: die Profilangaben stecken
// voller `&` und `=`, die in einem Abfragewert mehrdeutig sind.
// Trägt es ohne, liegt es daran — und das steht dann in der Spur.
// Doppelt kodiert zuerst: der Server dekodiert den Abfragewert
// einmal, bevor er ihn als Pfad liest. Nur so kommt bei ihm
// wieder `plex%3A%2F%2Fepisode%2F…` an statt `plex://episode/…`.
let variants: [(index: Int, profile: Bool)] = [(0, true), (-1, true), (0, false)]
for variant in variants {
attempts += 1
let outcome = await attemptStream(
path: metadataKey,
ratingKey: channel.id,
mediaIndex: variant.index,
includeProfile: variant.profile,
maximumBitrate: maximumBitrate,
session: session
)
trace.append(outcome.probe)
if let url = outcome.url {
return PlexTuneResult(url: url, sessionIdentifier: session, trace: trace)
}
}
}
// Was der Programmanbieter an Endpunkten angibt — für den Fall, dass
// dieser Server den Tuner doch selbst belegt haben will. Steht mit in
// der Spur, damit auch ein Fehlschlag etwas verrät.
if let epgIdentifier = program?.metadataKey?
.split(separator: "/").first.map(String.init) {
let features = await providerFeatures(for: epgIdentifier)
trace.append(ChannelProbe(
path: "Angebotene Endpunkte",
result: features.isEmpty
? "keine"
: features.map { "\($0.type)=\($0.key ?? "–")" }.joined(separator: ", ")
))
}
let sessionPath = "/livetv/sessions/\(session)"
for openPath in Self.openPaths(
channel: channel,
program: program,
dvr: dvr,
sessionPath: sessionPath
) {
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 < 14 else { break }
attempts += 1
let outcome = await attemptStream(
path: candidate.path,
ratingKey: item.ratingKey,
mediaIndex: candidate.mediaIndex,
includeProfile: true,
maximumBitrate: maximumBitrate,
session: session
)
trace.append(outcome.probe)
if let url = outcome.url {
return PlexTuneResult(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 PlexTuneResult(url: nil, sessionIdentifier: session, trace: trace)
}
/// Schlägt eine Sendung beim Programmanbieter nach.
///
/// Liefert die abspielbaren Teil-Adressen und eine Kurzbeschreibung
/// dessen, was zurückkam. Die Beschreibung steht auch dann in der Spur,
/// wenn nichts Abspielbares dabei war — sonst bliebe nach einem
/// Fehlschlag wieder nur Raten, wo die Adresse steckt.
private func programMedia(atKey key: String) async -> (parts: [String], ratingKey: String?, summary: String) {
let data: Data
do {
data = try await client.send(PlexRequest(
path: key,
query: [URLQueryItem(name: "includeChildren", value: "1")],
timeout: .seconds(20),
isPathEncoded: true
))
} catch {
return ([], nil, Self.shortDescription(of: error))
}
guard let item = (try? PlexResponse.decode(PlexMediaContainer<PlexItem>.self, from: data))?
.content.first
else {
return ([], nil, Self.containerSummary(data))
}
let parts = item.media.flatMap(\.parts).compactMap(\.key)
guard parts.isEmpty else {
return (parts, item.ratingKey, "\(parts.count) Teil-Adressen")
}
// Keine Teil-Adresse — dann zählt, was stattdessen dort steht.
// „1 Fassung, keine Teil-Adresse" beendet nichts; der Aufbau der
// Antwort schon, denn irgendwo muss der Server sagen, wie diese
// Sendung abzuspielen ist.
return ([], item.ratingKey, "ohne Teil-Adresse\n" + Self.mediaOutline(data))
}
/// Der Aufbau des Medien-Blocks einer Antwort, kurz gehalten.
static func mediaOutline(_ data: Data) -> String {
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let container = object["MediaContainer"] as? [String: Any],
let first = (container["Metadata"] as? [[String: Any]])?.first
else { return Self.containerSummary(data) }
var lines: [String] = []
if let media = (first["Media"] as? [[String: Any]])?.first {
lines += media.keys.sorted().map { key in
let value = media[key]!
if let text = value as? String { return " Media.\(key) = \(text)" }
if let number = value as? NSNumber { return " Media.\(key) = \(number)" }
if let array = value as? [Any] { return " Media.\(key): \(array.count) Einträge" }
return " Media.\(key): \(type(of: value))"
}
} else {
lines.append(" kein Media-Block")
}
// Und die Kurzform der Sendung selbst — dort stehen `key` und `guid`.
for key in ["key", "guid", "ratingKey", "type", "live", "onAir"] where first[key] != nil {
lines.append(" \(key) = \(first[key]!)")
}
return lines.joined(separator: "\n")
}
/// Belegt einen Tuner über den Empfänger und liefert den Stream.
///
/// Die Antwort auf das POST trägt den Vorgang mitsamt Metadaten-Eintrag,
/// und **dort** steht die abspielbare Teil-Adresse — nicht im Raster und
/// nicht bei der Sendung. Danach ist es gewöhnliches Umrechnen.
private func tuneThroughDVR(
channel: PlexChannel,
dvr: PlexDVR,
session: String,
maximumBitrate: Int?
) async -> (url: URL?, subscription: String?, probes: [ChannelProbe]) {
let identifier = channel.id.addingPercentEncoding(
withAllowedCharacters: .uriComponentAllowed
) ?? channel.id
let path = "/livetv/dvrs/\(dvr.key)/channels/\(identifier)/tune"
let data: Data
do {
data = try await client.send(PlexRequest(
method: .post,
path: path,
additionalHeaders: ["X-Plex-Session-Identifier": session],
timeout: .seconds(30),
isPathEncoded: true
))
} catch {
return (nil, nil, [ChannelProbe(path: "POST \(path)", result: Self.shortDescription(of: error))])
}
// Die Kennung des Vorgangs. Ohne sie bleibt der Tuner nach dem
// Zusehen belegt, und bei vier Tunern ist der fünfte Sender dann
// nicht mehr einzuschalten.
let subscription = Self.subscriptionKey(in: data)
guard let part = Self.firstPartKey(in: data) else {
return (nil, subscription, [ChannelProbe(
path: "POST \(path)",
result: "belegt, aber ohne Teil-Adresse — \(Self.containerSummary(data))"
)])
}
// Die ganze Antwort, nicht nur das, wonach ich gesucht habe. Ich
// habe sie mehrfach durchsucht und dabei übersehen, was sonst noch
// darin steht — Behälter, Teil-Kennungen, vielleicht eine fertige
// Transcode-Sitzung.
var probes = [
ChannelProbe(path: "POST \(path)", result: "Tuner belegt: \(part)"),
ChannelProbe(
path: "Antwort auf das Belegen",
result: redacted(String(decoding: data.prefix(900), as: UTF8.self))
),
]
// Die Sitzung selbst fragen — und **nur** ihr die Abspieladresse
// glauben. Die Antwort auf das Belegen nennt eine Adresse mit der
// Sitzungskennung darin; die Sitzung nennt dieselbe Adresse mit der
// **Gerätekennung**. Nur die zweite gibt es wirklich. Ich habe die
// erste genommen und mich über 404 gewundert.
if let sessionKey = Self.firstSessionKey(in: data),
let sessionData = try? await client.send(
PlexRequest(path: sessionKey, timeout: .seconds(20))
),
let livePart = Self.firstPartKey(in: sessionData),
let direct = directStreamURL(forPath: livePart) {
let outcome = await probeStream(url: direct)
probes.append(ChannelProbe(path: "\(livePart) (aus der Sitzung)", result: outcome))
if outcome.hasPrefix("HTTP 200") { return (direct, subscription, probes) }
}
// Abwarten, bis der Empfänger abgestimmt hat. Er meldet
// `status: inprogress` und `currentSize: 0` — es gibt in dem Moment
// noch keinen Stream, und jede Frage danach musste scheitern. Ein
// Tuner ist ein Stück Hardware; er braucht Sekunden, keine
// Millisekunden.
if let operation = Self.grabOperationKey(in: data) {
probes.append(ChannelProbe(
path: "Abstimmen abwarten",
result: await waitForGrab(atKey: operation)
))
}
// Und wie die Sitzung danach aussieht — dorthin zeigt der Pfad, den
// der Transcoder bekommt.
if let sessionKey = Self.firstSessionKey(in: data) {
// Großzügig: bisher war genau dort abgeschnitten, wo `Part`
// anfängt — also an der einzigen Stelle, die zählt.
probes.append(ChannelProbe(
path: sessionKey,
result: await rawResponse(forPath: sessionKey, limit: 2_500)
))
}
// Der Weg des Plex-Clients: die **Sitzung**, die der Server gerade
// angelegt hat, wird umgerechnet — nicht die Abspielliste darin und
// nicht meine eigene Kennung. Ihr Schlüssel steht in der Antwort.
if let sessionKey = Self.firstSessionKey(in: data) {
// Voll, ohne Profil, und auf das Nötigste zusammengestrichen.
// Eine nackte 400 sagt nicht, welche Angabe sie stört; die drei
// Formen nebeneinander sagen es.
let variants: [(profile: Bool, minimal: Bool)] = [(true, false), (false, false), (true, true)]
for variant in variants {
let outcome = await attemptStream(
path: sessionKey,
ratingKey: channel.id,
mediaIndex: 0,
includeProfile: variant.profile,
minimal: variant.minimal,
maximumBitrate: maximumBitrate,
session: session
)
probes.append(outcome.probe)
if let url = outcome.url { return (url, subscription, probes) }
}
// Dieselben Angaben an den Entscheider. Der Transcoder antwortet
// mit HTML und schweigt sich aus; der Entscheider antwortet in
// Plex' eigenem Format und nennt den Grund.
let request = TranscodeRequest(
ratingKey: channel.id,
maximumBitrate: maximumBitrate,
isLocal: connection.local,
sessionIdentifier: session,
pathOverride: sessionKey
)
if let url = transcodeURL(for: request),
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.percentEncodedQueryItems {
probes.append(ChannelProbe(
path: "Entscheider zur Sitzung",
result: await rawDecision(percentEncodedItems: items)
))
}
}
// Und die Abspielliste unmittelbar. Sie antwortete mit 404 statt 400:
// die Adresse stimmt, die Ressource steht nur nicht bereit. Ein
// Empfänger ist ein Stück Hardware — er braucht einen Moment, bis er
// abgestimmt hat. Deshalb ein zweiter Versuch nach kurzer Pause.
if let direct = directStreamURL(forPath: part) {
for attempt in 0..<2 {
if attempt > 0 { try? await Task.sleep(for: .seconds(2)) }
let outcome = await probeStream(url: direct)
probes.append(ChannelProbe(
path: "\(part) (unmittelbar\(attempt > 0 ? ", nach 2 s" : ""))",
result: outcome
))
if outcome.hasPrefix("HTTP 200") { return (direct, subscription, probes) }
}
}
let outcome = await attemptStream(
path: part,
ratingKey: channel.id,
mediaIndex: -1,
includeProfile: true,
maximumBitrate: maximumBitrate,
session: session
)
probes.append(outcome.probe)
return (outcome.url, subscription, probes)
}
/// Die Adresse, unter der dem Empfänger beim Abstimmen zuzusehen ist.
static func grabOperationKey(in data: Data) -> String? {
guard let root = try? JSONSerialization.jsonObject(with: data) else { return nil }
return firstKey(in: root, matching: "/media/grabbers/operations/")
}
private static func firstKey(in value: Any, matching prefix: String) -> String? {
if let dictionary = value as? [String: Any] {
if let key = dictionary["key"] as? String, key.hasPrefix(prefix) { return key }
for nested in dictionary.values {
if let found = firstKey(in: nested, matching: prefix) { return found }
}
}
if let array = value as? [Any] {
for nested in array {
if let found = firstKey(in: nested, matching: prefix) { return found }
}
}
return nil
}
/// Wartet, bis der Empfänger Daten liefert.
///
/// Höchstens zwölf Sekunden. Länger zu warten hieße, den Nutzer vor einem
/// schwarzen Bild sitzen zu lassen; gar nicht zu warten hieß bisher, nach
/// einem Stream zu fragen, den es noch nicht gibt.
private func waitForGrab(atKey key: String, limit: Int = 12) async -> String {
for second in 1...limit {
try? await Task.sleep(for: .seconds(1))
guard let data = try? await client.send(
PlexRequest(path: key, timeout: .seconds(10))
) else { continue }
guard let root = try? JSONSerialization.jsonObject(with: data),
let size = Self.firstNumber(in: root, forKey: "currentSize")
else { continue }
if size > 0 { return "nach \(second) s: \(size) Bytes" }
}
return "nach \(limit) s noch keine Daten"
}
private static func firstNumber(in value: Any, forKey name: String) -> Int? {
if let dictionary = value as? [String: Any] {
if let number = dictionary[name] as? NSNumber { return number.intValue }
for nested in dictionary.values {
if let found = firstNumber(in: nested, forKey: name) { return found }
}
}
if let array = value as? [Any] {
for nested in array {
if let found = firstNumber(in: nested, forKey: name) { return found }
}
}
return nil
}
/// Die Antwort des Entscheiders im Wortlaut.
private func rawDecision(percentEncodedItems items: [URLQueryItem]) async -> String {
guard let baseURL = connection.url,
var components = URLComponents(
url: baseURL.appending(path: "/video/:/transcode/universal/decision"),
resolvingAgainstBaseURL: false
)
else { return "Adresse nicht baubar" }
components.percentEncodedQueryItems = items
guard let url = components.url else { return "Adresse nicht baubar" }
var request = URLRequest(url: url)
request.timeoutInterval = 20
for (field, value) in identity.headers { request.setValue(value, forHTTPHeaderField: field) }
request.setValue(token, forHTTPHeaderField: "X-Plex-Token")
do {
let (data, response) = try await transport.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
let body = String(decoding: data.prefix(700), as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
return "HTTP \(status): \(redacted(body))"
} catch {
return Self.shortDescription(of: error)
}
}
/// Der Schlüssel der Live-Sitzung, die der Server angelegt hat.
///
/// `/livetv/sessions/1268be73-…` — die Sitzung des **Servers**, nicht die
/// Kennung, die der Client für seine Wiedergabe erzeugt. Ich habe
/// mehrfach die eigene eingesetzt und mich über 404 gewundert.
static func firstSessionKey(in data: Data) -> String? {
guard let root = try? JSONSerialization.jsonObject(with: data) else { return nil }
return firstSessionKey(in: root)
}
private static func firstSessionKey(in value: Any) -> String? {
if let dictionary = value as? [String: Any] {
if let key = dictionary["key"] as? String,
key.hasPrefix("/livetv/sessions/"),
!key.hasSuffix(".m3u8") {
return key
}
for nested in dictionary.values {
if let found = firstSessionKey(in: nested) { return found }
}
}
if let array = value as? [Any] {
for nested in array {
if let found = firstSessionKey(in: nested) { return found }
}
}
return nil
}
/// Die Kennung des Aufnahme-/Live-Vorgangs in einer Antwort.
static func subscriptionKey(in data: Data) -> String? {
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let container = root["MediaContainer"] as? [String: Any]
else { return nil }
for name in ["MediaSubscription", "MediaGrabOperation"] {
if let entries = container[name] as? [[String: Any]],
let key = entries.first?["key"] {
return String(describing: key)
}
if let entry = container[name] as? [String: Any], let key = entry["key"] {
return String(describing: key)
}
}
return nil
}
/// Gibt den Tuner wieder frei.
///
/// So macht es Plex' eigener Client: `DELETE /media/subscriptions/{id}`.
/// Ohne dieses Signal bleibt der Tuner belegt, bis der Server es von
/// selbst merkt — und solange fehlt er beim nächsten Einschalten.
public func endLiveSession(subscriptionKey: String) async {
_ = try? await client.send(PlexRequest(
method: .delete,
path: "/media/subscriptions/\(subscriptionKey)",
timeout: .seconds(10)
))
}
/// Sucht die erste abspielbare Teil-Adresse irgendwo in einer Antwort.
///
/// Bewusst suchend statt an einer festen Stelle lesend: der Vorgang
/// liegt je nach Serverfassung unterschiedlich tief verschachtelt, und
/// ein falscher Pfad in die Antwort hinein sähe wieder aus wie „nichts da".
static func firstPartKey(in data: Data) -> String? {
guard let root = try? JSONSerialization.jsonObject(with: data) else { return nil }
return firstPartKey(in: root)
}
private static func firstPartKey(in value: Any) -> String? {
if let dictionary = value as? [String: Any] {
if let parts = dictionary["Part"] as? [[String: Any]],
let key = parts.first?["key"] as? String {
return key
}
for nested in dictionary.values {
if let found = firstPartKey(in: nested) { return found }
}
}
if let array = value as? [Any] {
for nested in array {
if let found = firstPartKey(in: nested) { return found }
}
}
return nil
}
/// Baut eine Stromadresse und ruft sie einmal ab.
///
/// Getrennte Methode statt einer Hilfsfunktion in `tune`: eine solche
/// Funktion greift über jede Unterbrechung hinweg auf die veränderlichen
/// Werte daneben zu, und genau das lässt der Nebenläufigkeitsprüfer von
/// Swift 6 nicht durchgehen.
private func attemptStream(
path: String,
ratingKey: String,
mediaIndex: Int,
includeProfile: Bool,
minimal: Bool = false,
maximumBitrate: Int?,
session: String
) async -> (url: URL?, probe: ChannelProbe) {
let request = TranscodeRequest(
ratingKey: ratingKey,
mediaIndex: mediaIndex,
partIndex: mediaIndex,
maximumBitrate: maximumBitrate,
isLocal: connection.local,
sessionIdentifier: session,
pathOverride: path
)
guard let url = transcodeURL(
for: request,
includeProfile: includeProfile,
minimal: minimal
) else {
return (nil, ChannelProbe(path: path, result: "Adresse nicht baubar"))
}
let outcome = await probeStream(url: url)
let label = "\(path) (mediaIndex \(mediaIndex)"
+ "\(includeProfile ? "" : ", ohne Profil")"
+ "\(minimal ? ", nur das Nötigste" : "") )"
// Die gebaute Adresse mit in die Spur. Ohne sie diskutiert man über
// eine Anfrage, die niemand gesehen hat — und genau das habe ich
// mehrere Runden lang getan.
let detail = outcome.hasPrefix("HTTP 200")
? outcome
: "\(outcome)\n \(redacted(url.absoluteString))"
return (outcome.hasPrefix("HTTP 200") ? url : nil, ChannelProbe(path: label, result: detail))
}
/// 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)
}
}
/// Wo sich ein Tuner belegen lässt.
///
/// Der Programmanbieter gibt unter `/media/providers` keinen einzigen
/// Endpunkt zum Einschalten an — er kann Inhalt, Metadaten und
/// Programmraster, mehr nicht. Der Empfänger muss also über den DVR
/// belegt werden, und dessen Adressen stehen hier neben den
/// Sitzungsformen, weil dieser Teil der Schnittstelle nirgends
/// beschrieben ist.
static func openPaths(
channel: PlexChannel,
program: PlexProgram?,
dvr: PlexDVR?,
sessionPath: String
) -> [String] {
var paths: [String] = []
if let dvr {
// Die Nummer am Empfänger zuerst: nur sie spricht seine Sprache.
let keys = ([dvr.deviceChannel(forChannelKey: channel.id)]
+ [channel.gridKey, channel.key, channel.channelIdentifier, channel.id])
.compactMap { $0 }
.map { $0.split(separator: "/").last.map(String.init) ?? $0 }
for key in keys {
paths.append("/livetv/dvrs/\(dvr.key)/channels/\(key)/tune")
paths.append("/livetv/dvrs/\(dvr.key)/channels/\(key)")
}
// Und über das Gerät. Es ist die einzige Stelle, die auf diesem
// Server überhaupt Sender führt — 278 Stück unter `DeviceChannel`.
// Wenn der DVR keine kennt, kennt sie das Gerät.
for device in dvr.devices {
guard let deviceKey = device.key else { continue }
let base = deviceKey.hasPrefix("/") ? deviceKey : "/media/grabbers/devices/\(deviceKey)"
for key in keys.prefix(3) {
paths.append("\(base)/channels/\(key)/tune")
paths.append("\(base)/channels/\(key)")
}
}
}
paths += sessionKeys(channel: channel, program: program).map { "\(sessionPath)/\($0)" }
// Und die Sitzung ohne Schlüssel — bisher nie probiert.
paths.append(sessionPath)
var seen = Set<String>()
return paths.filter { seen.insert($0).inserted }
}
/// 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 }
// Und ein Schlüssel, der noch `%3A` enthält, ist als Pfadabschnitt
// keiner — die entpackte Form gehört mit in die Auswahl. Aber nur,
// solange dabei kein Schrägstrich herauskommt: `plex://movie/abc` ist
// als ein Pfadabschnitt genauso wenig brauchbar.
keys += keys.compactMap { key in
guard key.contains("%"), let plain = key.removingPercentEncoding,
!plain.contains("/") else { return nil }
return plain
}
var seen = Set<String>()
return keys.filter { !$0.isEmpty && seen.insert($0).inserted }
}
/// Fragt den Server, ob er diesen Stream 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 PlexTuneResult: Sendable {
public let url: URL?
public let sessionIdentifier: String
/// Womit der Tuner wieder freigegeben wird.
public var subscriptionKey: String?
/// Jeder versuchte Weg mit seiner Antwort.
public let trace: [ChannelProbe]
}
/// Was der Server zu einem Stream 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)
}
extension PlexServer {
/// Was der Entscheider zu einer Sendung sagt — im Wortlaut.
///
/// Der Transcoder antwortet mit einer nackten „400 Bad Request"-Seite und
/// verrät nichts. Der Entscheider bekommt dieselben Angaben, gibt aber
/// eine Plex-Antwort zurück, in der steht, woran es liegt. Das ist die
/// letzte Stelle, an der dieser Server noch etwas erklärt.
public func decisionDump(channel: PlexChannel, program: PlexProgram?) async -> String {
guard let metadataKey = program?.metadataKey else {
return "Keine Sendung — ohne sie gibt es nichts zu entscheiden."
}
var report: [String] = []
do {
let request = TranscodeRequest(
ratingKey: channel.id,
isLocal: connection.local,
pathOverride: metadataKey
)
guard let url = transcodeURL(for: request),
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems
else { return "Adresse nicht baubar" }
report.append("--- Entscheider ---")
do {
let data = try await client.send(PlexRequest(
path: "/video/:/transcode/universal/decision",
query: items,
timeout: .seconds(20)
))
report.append(redacted(String(decoding: data.prefix(900), as: UTF8.self)))
} catch {
report.append(Self.shortDescription(of: error))
}
}
return report.joined(separator: "\n")
}
/// Die rohe Antwort auf eine Adresse, gekürzt und ohne Token.
///
/// Zum Erkunden gedacht: die Live-Schnittstelle von Plex ist nirgends
/// beschrieben, und die Antwort auf „was steht unter dieser Adresse" ist
/// die einzige verlässliche Quelle. Der Token wird aus der Ausgabe
/// entfernt, falls der Server ihn irgendwo zurückspiegelt.
public func rawResponse(forPath path: String, limit: Int = 1_200) async -> String {
let encoded = path.contains("%")
do {
let data = try await client.send(PlexRequest(
path: path,
timeout: .seconds(20),
isPathEncoded: encoded
))
let text = String(decoding: data.prefix(limit), as: UTF8.self)
return redacted(text)
} catch {
return Self.shortDescription(of: error)
}
}
nonisolated func redacted(_ text: String) -> String {
text.replacingOccurrences(of: token, with: "«token»")
}
}
extension CharacterSet {
/// Was `encodeURIComponent` unangetastet lässt.
///
/// Nicht `.alphanumerics`: das kodiert auch den Bindestrich, und
/// Senderkennungen bestehen aus zwei Hälften mit einem dazwischen. Aus
/// `6228f7f2-5fc705fa` würde `6228f7f2%2D5fc705fa`, und der Server fände
/// den Sender nicht.
static let uriComponentAllowed = CharacterSet(
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()"
)
}
extension PlexServer {
/// Klopft den Transcoder mit verschiedenen Angaben ab.
///
/// Eine gewöhnliche Datei scheitert genauso wie Live-TV: der Transcoder
/// antwortet auf alles mit einer nackten 400. Damit ist es kein
/// Live-Problem, sondern eines der Anfrage selbst — und welche Angabe
/// stört, sagt nur das Halbieren.
public func transcoderBisect(ratingKey: String) async -> [String] {
let session = UUID().uuidString
let path = "/library/metadata/\(ratingKey)"
// Erst prüfen, ob es diesen Eintrag überhaupt gibt. Der Titel für die
// Gegenprobe kam aus den Startseiten-Reihen, und dort stehen auch
// Einträge von plex.tv, deren Kennung kein Mediathek-Schlüssel ist.
// Dann wäre die ganze Gegenprobe wertlos gewesen.
var lines = [" Eintrag \(ratingKey): \(await rawResponse(forPath: path, limit: 150))"]
let encoded = path.addingPercentEncoding(withAllowedCharacters: .plexParameterAllowed) ?? path
let variants: [(String, [URLQueryItem])] = [
("nur Pfad und Token", [
URLQueryItem(name: "path", value: encoded),
URLQueryItem(name: "X-Plex-Token", value: token),
]),
("Pfad unkodiert", [
URLQueryItem(name: "path", value: path),
URLQueryItem(name: "X-Plex-Token", value: token),
]),
("mit Protokoll und Indizes", [
URLQueryItem(name: "path", value: encoded),
URLQueryItem(name: "protocol", value: "hls"),
URLQueryItem(name: "mediaIndex", value: "0"),
URLQueryItem(name: "partIndex", value: "0"),
URLQueryItem(name: "X-Plex-Token", value: token),
]),
("dazu die Gerätekennung", [
URLQueryItem(name: "path", value: encoded),
URLQueryItem(name: "protocol", value: "hls"),
URLQueryItem(name: "mediaIndex", value: "0"),
URLQueryItem(name: "partIndex", value: "0"),
URLQueryItem(name: "X-Plex-Token", value: token),
URLQueryItem(name: "X-Plex-Client-Identifier", value: identity.clientIdentifier),
]),
("dazu hasMDE", [
URLQueryItem(name: "path", value: encoded),
URLQueryItem(name: "protocol", value: "hls"),
URLQueryItem(name: "hasMDE", value: "1"),
URLQueryItem(name: "mediaIndex", value: "0"),
URLQueryItem(name: "partIndex", value: "0"),
URLQueryItem(name: "X-Plex-Token", value: token),
URLQueryItem(name: "X-Plex-Client-Identifier", value: identity.clientIdentifier),
]),
("dazu die Sitzung", [
URLQueryItem(name: "path", value: encoded),
URLQueryItem(name: "protocol", value: "hls"),
URLQueryItem(name: "hasMDE", value: "1"),
URLQueryItem(name: "mediaIndex", value: "0"),
URLQueryItem(name: "partIndex", value: "0"),
URLQueryItem(name: "session", value: session),
URLQueryItem(name: "X-Plex-Session-Identifier", value: session),
URLQueryItem(name: "X-Plex-Token", value: token),
URLQueryItem(name: "X-Plex-Client-Identifier", value: identity.clientIdentifier),
]),
]
for (name, items) in variants {
lines.append(" \(name): \(await probe(items: items, endpoint: "start.m3u8"))")
}
lines.append(" Entscheider: \(await probe(items: variants.last!.1, endpoint: "decision"))")
// Lebt der Transcoder überhaupt? `ping` und `stop` gehören zu
// derselben Familie und brauchen nur eine Sitzungskennung. Wenn auch
// sie mit 400 antworten, ist nicht die Anfrage falsch, sondern der
// ganze Zweig nicht da.
let session2 = [URLQueryItem(name: "session", value: session),
URLQueryItem(name: "X-Plex-Token", value: token)]
lines.append(" ping: \(await probe(items: session2, endpoint: "ping"))")
lines.append(" stop: \(await probe(items: session2, endpoint: "stop"))")
// Und zur Gegenkontrolle etwas, von dem wir wissen, dass es geht:
// die Bilder kommen über denselben `/:/`-Aufbau.
lines.append(" Mediathek: \(await rawResponse(forPath: "/library/sections", limit: 90))")
lines.append(" Server sagt über sich: \(await capabilities())")
return lines
}
/// Was der Server über seine eigenen Fähigkeiten sagt.
///
/// Die Felder stehen in jeder Antwort der Wurzel, und ihre Werte habe ich
/// nie angesehen. Steht dort `transcoderVideo: false`, kann dieser Server
/// kein Video umrechnen — und dann war die ganze Suche der letzten Tage
/// die Suche nach einer Fähigkeit, die es nicht gibt.
public func capabilities() async -> String {
guard let data = try? await client.send(PlexRequest(path: "/", timeout: .seconds(15))),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let container = root["MediaContainer"] as? [String: Any]
else { return "keine Antwort" }
let interesting = [
"transcoderVideo", "transcoderAudio", "transcoderSubtitles",
"transcoderActiveVideoSessions", "transcoderVideoBitrates",
"offlineTranscode", "allowSync", "version", "platform",
]
return interesting
.compactMap { key in container[key].map { "\(key)=\($0)" } }
.joined(separator: ", ")
}
private func probe(items: [URLQueryItem], endpoint: String) async -> String {
guard let baseURL = connection.url,
var components = URLComponents(
url: baseURL.appending(path: "/video/:/transcode/universal/\(endpoint)"),
resolvingAgainstBaseURL: false
)
else { return "Adresse nicht baubar" }
components.percentEncodedQuery = items
.map { "\($0.name)=\($0.value ?? "")" }
.joined(separator: "&")
guard let url = components.url else { return "Adresse nicht baubar" }
return await probeStream(url: url)
}
}
extension PlexServer {
/// Schickt eine beliebige Anfrage und zeigt Status und Antwort.
///
/// Zum Prüfen einzelner Vermutungen. `headers:` entscheidet, ob die
/// Anfrage so aussieht wie die der App oder wie die des Systemplayers —
/// der schickt keine Kopfzeilen mit, und dieser Unterschied war schon
/// einmal die Ursache.
public func attempt(target: String, headers: Bool = true) async -> String {
guard let baseURL = connection.url else { return "keine Verbindung" }
let pieces = target.split(separator: "?", maxSplits: 1)
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
else { return "Adresse nicht baubar" }
components.percentEncodedPath = String(pieces[0])
if pieces.count > 1 {
components.percentEncodedQuery = String(pieces[1])
.replacingOccurrences(of: "{token}", with: token)
}
guard let url = components.url else { return "Adresse nicht baubar" }
var request = URLRequest(url: url)
request.timeoutInterval = 25
if headers {
for (field, value) in identity.headers { request.setValue(value, forHTTPHeaderField: field) }
request.setValue(token, forHTTPHeaderField: "X-Plex-Token")
}
do {
let (data, response) = try await transport.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
let body = String(decoding: data.prefix(500), as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
return "HTTP \(status)\n\(redacted(body))"
} catch {
return Self.shortDescription(of: error)
}
}
}