Files
Flaro/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift
T
GuidoandClaude Opus 5 ed84b5bf22 Live-TV-Sender gefunden, Film-Schriftzüge ergänzt
Senderliste
  Plex liefert die Sender verschachtelt in der Antwort auf /livetv/dvrs
  mit — jeder Empfänger bringt seine eigenen unter `Device` → `Channel`.
  Mein getrennter Endpunkt /livetv/dvrs/{id}/channels war eine Vermutung
  und lieferte nichts. Bei einem Server mit über dreißig eingerichteten
  Sendern stand deshalb „Keine Sender".

  Die verschachtelte Liste hat jetzt Vorrang, der Endpunkt bleibt als
  Rückfalllinie. `ChannelMapping` wird als ältere Schreibweise ebenfalls
  gelesen, und der Empfänger-Schlüssel kommt je nach Serverfassung als
  Zahl oder als Zeichenkette.

  Bleibt die Liste leer, sagt die Ansicht jetzt welcher Weg was ergeben
  hat. In einem Bereich ohne offizielle Beschreibung ist das die Angabe,
  mit der sich der nächste Fehler finden lässt.

Schriftzüge
  Plex liefert bei vielen Filmen ein `clearLogo` — den Schriftzug in
  seiner eigenen Type, freigestellt. Der tritt jetzt im Aufmacher an die
  Stelle des gesetzten Titels.

  Dabei ein Fallstrick: /photo/:/transcode liefert JPEG, und JPEG kennt
  keine Transparenz. Über den Bild-Transcoder geladen wäre aus dem
  freigestellten Logo ein schwarzer Kasten geworden. Es wird deshalb
  unverändert geladen. Fehlt der Schriftzug oder scheitert das Laden,
  bleibt es beim Titel.

161 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:28:21 +02:00

179 lines
7.0 KiB
Swift

import Foundation
/// Was beim Live-TV herauskommen kann.
public enum LiveTVAvailability: Sendable, Equatable {
/// Es gibt einen Empfänger und Sender.
case available(dvrs: [PlexDVR])
/// Der Server läuft, hat aber keinen Empfänger eingerichtet.
///
/// Der mit Abstand häufigste Fall — Live-TV verlangt eine Fernsehkarte
/// oder einen Netzwerk-Tuner. Die Oberfläche soll das erklären, statt
/// einen Fehler zu zeigen.
case notConfigured
case failed(message: String)
}
extension PlexServer {
/// Prüft, ob der Server überhaupt Live-TV kann.
public func liveTVAvailability() async -> LiveTVAvailability {
do {
let container = try await client.send(
PlexRequest(path: "/livetv/dvrs", timeout: .seconds(10)),
as: PlexMediaContainer<PlexDVR>.self
)
return container.content.isEmpty ? .notConfigured : .available(dvrs: container.content)
} catch PlexError.notFound {
// Ältere Serverfassungen kennen den Endpunkt gar nicht. Das ist
// dasselbe Ergebnis wie „kein Empfänger": es gibt nichts zu sehen.
return .notConfigured
} catch {
return .failed(message: String(describing: error))
}
}
/// Die Sender eines Empfängers, samt Auskunft darüber, woher sie kamen.
///
/// Zwei Quellen, in dieser Reihenfolge. Die erste ist die verlässliche:
/// Plex liefert die Sender bereits verschachtelt in der Antwort auf
/// `/livetv/dvrs` mit. Der getrennte Endpunkt war meine erste Vermutung
/// und lieferte nichts — daran hing „Keine Sender" bei einem Server mit
/// über dreißig eingerichteten Sendern.
///
/// Die Auskunft über die Quelle steht in der Diagnose. Bei einem Bereich
/// ohne offizielle Beschreibung ist „welcher Weg hat gegriffen" die
/// Angabe, mit der sich der nächste Fehler finden lässt.
public func channels(inDVR dvr: PlexDVR) async -> ChannelLookup {
if !dvr.channels.isEmpty {
return ChannelLookup(channels: dvr.channels, source: .embeddedInDVR)
}
do {
let container = try await client.send(
PlexRequest(path: "/livetv/dvrs/\(dvr.key)/channels"),
as: PlexMediaContainer<PlexChannel>.self
)
return ChannelLookup(
channels: container.content,
source: container.content.isEmpty ? .nothingFound : .separateEndpoint
)
} catch {
return ChannelLookup(channels: [], source: .failed(String(describing: error)))
}
}
/// Die Kennung des Programmanbieters.
///
/// Sie wechselt je nach Server und Einrichtung (etwa
/// `tv.plex.providers.epg.cloud:2`) und steckt in der Anbieterliste. Fest
/// verdrahtet wäre sie auf der Hälfte der Server falsch.
public func electronicProgramGuideIdentifier() async -> String? {
guard let container = try? await client.send(
PlexRequest(path: "/media/providers"),
as: PlexMediaContainer<PlexMediaProvider>.self
) else { return nil }
return container.content.first { $0.identifier.contains("epg") }?.identifier
}
/// Das Programmraster für einen Zeitraum.
///
/// Bewusst über einen Zeitraum statt „alles": ein Tag über hundert Sender
/// sind mehrere tausend Sendungen, und mehr als die nächsten Stunden
/// braucht niemand beim Umschalten.
public func guide(
providerIdentifier: String,
from start: Date,
to end: Date
) async throws(PlexError) -> [PlexProgram] {
let container = try await client.send(
PlexRequest(
path: "/\(providerIdentifier)/grid",
query: [
// 1 steht bei Plex für „Sendung".
URLQueryItem(name: "type", value: "1"),
URLQueryItem(name: "beginsAt>", value: String(Int(start.timeIntervalSince1970))),
URLQueryItem(name: "endsAt<", value: String(Int(end.timeIntervalSince1970))),
],
timeout: .seconds(30)
),
as: PlexMediaContainer<PlexGridEntry>.self
)
return container.content.compactMap { entry in
// Die Senderzuordnung steckt im Medien-Block. Fehlt sie, lässt
// sich die Sendung keiner Spalte zuordnen — dann ist sie für ein
// Raster wertlos.
let channelID = entry.media?.first?.parts.first?.key
return entry.program(channelID: channelID)
}
}
/// Schaltet einen Sender ein.
///
/// Live-TV kommt immer als umgerechneter Strom: der Tuner liefert einen
/// Transportstrom, den kein iPhone direkt abspielt. Deshalb führt der Weg
/// hier zwangsläufig über den Transcoder — anders als bei Dateien, wo
/// DirectPlay der Normalfall ist.
public func tune(channel: PlexChannel, maximumBitrate: Int?) async throws(PlexError) -> URL? {
guard let key = channel.key else { return nil }
let session = UUID().uuidString
let container = try await client.send(
PlexRequest(
path: "/livetv/sessions/\(session)/\(key)",
query: [URLQueryItem(name: "X-Plex-Session-Identifier", value: session)],
timeout: .seconds(30)
),
as: PlexMediaContainer<PlexItem>.self
)
guard let item = container.content.first else { throw .notFound }
return transcodeURL(for: TranscodeRequest(
ratingKey: item.ratingKey,
maximumBitrate: maximumBitrate,
isLocal: connection.local,
sessionIdentifier: session
))
}
}
/// Woher die Senderliste kam.
public struct ChannelLookup: Sendable {
public enum Source: Sendable, Equatable {
/// Aus der Antwort auf `/livetv/dvrs` — der übliche Weg.
case embeddedInDVR
/// Über `/livetv/dvrs/{id}/channels`.
case separateEndpoint
/// Beide Wege lieferten nichts.
case nothingFound
case failed(String)
}
public let channels: [PlexChannel]
public let source: Source
public init(channels: [PlexChannel], source: Source) {
self.channels = channels
self.source = source
}
/// Kurzbeschreibung für die Diagnose.
public var description: String {
switch source {
case .embeddedInDVR: "\(channels.count) Sender, aus der Empfängerliste"
case .separateEndpoint: "\(channels.count) Sender, über /livetv/dvrs/…/channels"
case .nothingFound: "Kein Weg lieferte Sender"
case .failed(let reason): "Abfrage gescheitert: \(reason)"
}
}
}
/// Ein Anbieter am Server — Mediathek, Programmführer, Entdecken.
public struct PlexMediaProvider: Sendable, Equatable, Decodable, Identifiable {
public let identifier: String
public let title: String?
public let types: String?
public var id: String { identifier }
}