„1 Fassung, keine Teil-Adresse" beendet nichts. Wenn dort kein abspielbarer Pfad steht, zählt, was stattdessen dort steht — irgendwo muss der Server sagen, wie diese Sendung abzuspielen ist. Der Bericht zeigt den Medien-Block jetzt Feld für Feld. Dazu zwei Dinge, die ich benutzt habe, ohne sie je gesehen zu haben: der Raster-Schlüssel des Senders und seine übrigen Kennungen stehen jetzt im Bericht. Und der Raster-Schlüssel wird als vollständige Adresse probiert — bisher war immer nur sein letzter Abschnitt als Sitzungsschlüssel an der Reihe. Der Empfänger kommt ebenfalls dazu: /livetv/dvrs/2/channels antwortet auf diesem Server gar nicht, aber /media/grabbers/devices/1/channels führt 278 Sender. Wenn der DVR keine kennt, kennt sie das Gerät. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1027 lines
43 KiB
Swift
1027 lines
43 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,
|
||
dvr: PlexDVR? = nil,
|
||
maximumBitrate: Int?
|
||
) async -> LiveTuneResult {
|
||
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.
|
||
var trace: [ChannelProbe] = [ChannelProbe(
|
||
path: "Sender-Schlüssel",
|
||
result: [
|
||
"gridKey=\(channel.gridKey ?? "–")",
|
||
"key=\(channel.key ?? "–")",
|
||
"id=\(channel.id)",
|
||
"nummer=\(channel.channelIdentifier ?? "–")",
|
||
].joined(separator: ", ")
|
||
)]
|
||
|
||
// 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 LiveTuneResult(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 LiveTuneResult(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.
|
||
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 LiveTuneResult(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 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)
|
||
}
|
||
|
||
/// 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")
|
||
}
|
||
|
||
/// 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,
|
||
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) else {
|
||
return (nil, ChannelProbe(path: path, result: "Adresse nicht baubar"))
|
||
}
|
||
|
||
let outcome = await probeStream(url: url)
|
||
let label = "\(path) (mediaIndex \(mediaIndex)\(includeProfile ? "" : ", ohne Profil"))"
|
||
return (outcome.hasPrefix("HTTP 200") ? url : nil, ChannelProbe(path: label, result: outcome))
|
||
}
|
||
|
||
/// 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 {
|
||
let keys = [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(2) {
|
||
paths.append("\(base)/channels/\(key)/tune")
|
||
paths.append("\(base)/channels/\(key)")
|
||
}
|
||
}
|
||
}
|
||
|
||
paths += sessionKeys(channel: channel, program: program).map { "\(sessionPath)/\($0)" }
|
||
|
||
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 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)
|
||
}
|