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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9df58ed5a6
commit
24765a5afc
@@ -2,7 +2,7 @@ import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Was beim Live-TV herauskommen kann.
|
||||
public enum LiveTVAvailability: Sendable, Equatable {
|
||||
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.
|
||||
@@ -16,7 +16,7 @@ public enum LiveTVAvailability: Sendable, Equatable {
|
||||
|
||||
extension PlexServer {
|
||||
/// Prüft, ob der Server überhaupt Live-TV kann.
|
||||
public func liveTVAvailability() async -> LiveTVAvailability {
|
||||
public func liveTVAvailability() async -> PlexLiveTVAvailability {
|
||||
do {
|
||||
let container = try await client.send(
|
||||
PlexRequest(path: "/livetv/dvrs", timeout: .seconds(10)),
|
||||
@@ -405,7 +405,7 @@ extension PlexServer {
|
||||
program: PlexProgram? = nil,
|
||||
dvr: PlexDVR? = nil,
|
||||
maximumBitrate: Int?
|
||||
) async -> LiveTuneResult {
|
||||
) async -> PlexTuneResult {
|
||||
let session = UUID().uuidString
|
||||
var attempts = 0
|
||||
|
||||
@@ -448,7 +448,7 @@ extension PlexServer {
|
||||
)
|
||||
trace.append(contentsOf: outcome.probes)
|
||||
if let url = outcome.url {
|
||||
return LiveTuneResult(
|
||||
return PlexTuneResult(
|
||||
url: url,
|
||||
sessionIdentifier: session,
|
||||
subscriptionKey: outcome.subscription,
|
||||
@@ -464,7 +464,7 @@ extension PlexServer {
|
||||
// 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 LiveTuneResult(
|
||||
return PlexTuneResult(
|
||||
url: nil,
|
||||
sessionIdentifier: session,
|
||||
subscriptionKey: nil,
|
||||
@@ -488,7 +488,7 @@ extension PlexServer {
|
||||
)
|
||||
trace.append(outcome.probe)
|
||||
if let url = outcome.url {
|
||||
return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace)
|
||||
return PlexTuneResult(url: url, sessionIdentifier: session, trace: trace)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,7 +515,7 @@ extension PlexServer {
|
||||
)
|
||||
trace.append(outcome.probe)
|
||||
if let url = outcome.url {
|
||||
return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace)
|
||||
return PlexTuneResult(url: url, sessionIdentifier: session, trace: trace)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -540,7 +540,7 @@ extension PlexServer {
|
||||
)
|
||||
trace.append(outcome.probe)
|
||||
if let url = outcome.url {
|
||||
return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace)
|
||||
return PlexTuneResult(url: url, sessionIdentifier: session, trace: trace)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -601,7 +601,7 @@ extension PlexServer {
|
||||
)
|
||||
trace.append(outcome.probe)
|
||||
if let url = outcome.url {
|
||||
return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace)
|
||||
return PlexTuneResult(url: url, sessionIdentifier: session, trace: trace)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -609,7 +609,7 @@ extension PlexServer {
|
||||
// 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)
|
||||
return PlexTuneResult(url: nil, sessionIdentifier: session, trace: trace)
|
||||
}
|
||||
|
||||
/// Schlägt eine Sendung beim Programmanbieter nach.
|
||||
@@ -1442,7 +1442,7 @@ public struct LiveTVGuide: Sendable {
|
||||
}
|
||||
|
||||
/// Was beim Einschalten herauskam.
|
||||
public struct LiveTuneResult: Sendable {
|
||||
public struct PlexTuneResult: Sendable {
|
||||
public let url: URL?
|
||||
public let sessionIdentifier: String
|
||||
/// Womit der Tuner wieder freigegeben wird.
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Plex' Live-TV mit Gedächtnis.
|
||||
///
|
||||
/// Ein Actor und kein Werttyp, und das hat einen einzigen Grund: `PlexBackend`
|
||||
/// ist ein `struct` und kann sich zwischen zwei Aufrufen nichts merken. Zum
|
||||
/// Einschalten braucht Plex aber den **echten** `PlexChannel` und den
|
||||
/// **echten** `PlexDVR` — an letzterem hängt die Sendernummer am Empfänger,
|
||||
/// und an der hängt, ob überhaupt ein Bild kommt.
|
||||
///
|
||||
/// Ohne Gedächtnis müsste das Einschalten die Senderliste neu laden. Das ist
|
||||
/// bei Plex kein Abruf, sondern ein Suchlauf über vier Strategien — der
|
||||
/// mühsamste Teil dieser ganzen Ecke. Er soll einmal laufen, nicht bei jedem
|
||||
/// Antippen.
|
||||
///
|
||||
/// Der Actor liegt als `let` in `PlexBackend`. Ein Actor ist ein Referenztyp;
|
||||
/// Kopien des Struct teilen sich denselben und damit dasselbe Gedächtnis.
|
||||
public actor PlexLiveTVSession {
|
||||
private let server: PlexServer
|
||||
|
||||
/// Der Empfänger, über den eingeschaltet wird.
|
||||
private var dvr: PlexDVR?
|
||||
/// Die zuletzt geladenen Sender nach Kennung — der Weg vom neutralen
|
||||
/// `LiveChannel` zurück zum `PlexChannel`, den `tune` braucht.
|
||||
private var known: [String: PlexChannel] = [:]
|
||||
/// Das zuletzt geladene Programm.
|
||||
///
|
||||
/// Plex liefert Sender und Programm in **einem** Aufruf. Es hier
|
||||
/// festzuhalten spart den zweiten, den der neutrale Vertrag sonst nach
|
||||
/// sich zöge: erst `liveChannels()`, dann `livePrograms()`.
|
||||
private var guide: [String: [PlexProgram]] = [:]
|
||||
|
||||
public init(server: PlexServer) {
|
||||
self.server = server
|
||||
}
|
||||
|
||||
// MARK: - Verfügbarkeit
|
||||
|
||||
func availability() async -> LiveTVAvailability {
|
||||
switch await server.liveTVAvailability() {
|
||||
case .available(let dvrs):
|
||||
// Den ersten merken: alles Weitere — Sender, Programm,
|
||||
// Einschalten — hängt an ihm.
|
||||
dvr = dvrs.first
|
||||
return dvrs.isEmpty ? .notConfigured : .available
|
||||
case .notConfigured:
|
||||
return .notConfigured
|
||||
case .failed(let message):
|
||||
return .failed(message: message)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sender und Programm
|
||||
|
||||
func channels() async -> LiveChannelListing {
|
||||
guard let dvr = await resolvedDVR() else {
|
||||
return LiveChannelListing(channels: [], source: nil, probes: [])
|
||||
}
|
||||
|
||||
let result = await server.liveTVGuide(dvr: dvr, hours: 6)
|
||||
|
||||
known = Dictionary(result.channels.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
|
||||
guide = result.programs
|
||||
|
||||
return LiveChannelListing(
|
||||
channels: result.channels.map { $0.asLiveChannel(running: running(on: $0)) },
|
||||
source: result.source,
|
||||
probes: result.trace.map { LiveChannelProbe(path: $0.path, result: $0.result) }
|
||||
)
|
||||
}
|
||||
|
||||
func programs(channelIDs: [String], from: Date, to: Date) async -> [String: [LiveProgram]] {
|
||||
// Ohne gemerktes Programm erst eines holen. Das passiert nur, wenn
|
||||
// jemand nach dem Programm fragt, ohne vorher die Sender geladen zu
|
||||
// haben — in der Oberfläche kommt das nicht vor, über den Vertrag
|
||||
// könnte es.
|
||||
if guide.isEmpty, let dvr = await resolvedDVR() {
|
||||
let result = await server.liveTVGuide(dvr: dvr, hours: 6)
|
||||
known = Dictionary(result.channels.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
|
||||
guide = result.programs
|
||||
}
|
||||
|
||||
let wanted = Set(channelIDs)
|
||||
var out: [String: [LiveProgram]] = [:]
|
||||
for (channelID, programs) in guide where wanted.isEmpty || wanted.contains(channelID) {
|
||||
let inRange = programs.filter { $0.endsAt > from && $0.startsAt < to }
|
||||
if !inRange.isEmpty {
|
||||
out[channelID] = inRange.map { $0.asLiveProgram(channelID: channelID) }
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: - Einschalten
|
||||
|
||||
func tune(channel: LiveChannel, program: LiveProgram?, maximumBitrate: Int?) async -> LiveTuneResult {
|
||||
// Zurück zum echten Sender. Ohne ihn fehlen Rasterschlüssel und
|
||||
// Sendernummer, und ohne die schaltet nichts ein.
|
||||
guard let plexChannel = known[channel.id] else {
|
||||
return LiveTuneResult(
|
||||
url: nil,
|
||||
sessionID: UUID().uuidString,
|
||||
trace: [LiveChannelProbe(
|
||||
path: channel.id,
|
||||
result: "Sender nicht im geladenen Bestand — erst die Senderliste laden"
|
||||
)]
|
||||
)
|
||||
}
|
||||
|
||||
let plexProgram = program.flatMap { wanted in
|
||||
guide[channel.id]?.first { $0.id == wanted.id }
|
||||
}
|
||||
|
||||
let result = await server.tune(
|
||||
channel: plexChannel,
|
||||
program: plexProgram,
|
||||
dvr: await resolvedDVR(),
|
||||
maximumBitrate: maximumBitrate
|
||||
)
|
||||
|
||||
return LiveTuneResult(
|
||||
url: result.url,
|
||||
sessionID: result.sessionIdentifier,
|
||||
subscriptionKey: result.subscriptionKey,
|
||||
trace: result.trace.map { LiveChannelProbe(path: $0.path, result: $0.result) }
|
||||
)
|
||||
}
|
||||
|
||||
func endSession(subscriptionKey: String) async {
|
||||
await server.endLiveSession(subscriptionKey: subscriptionKey)
|
||||
}
|
||||
|
||||
// MARK: - Hilfen
|
||||
|
||||
/// Der Empfänger — gemerkt, sonst erfragt.
|
||||
private func resolvedDVR() async -> PlexDVR? {
|
||||
if let dvr { return dvr }
|
||||
_ = await availability()
|
||||
return dvr
|
||||
}
|
||||
|
||||
/// Was auf diesem Sender gerade läuft.
|
||||
private func running(on channel: PlexChannel) -> LiveProgram? {
|
||||
let now = Date()
|
||||
guard let match = guide[channel.id]?.first(where: { $0.startsAt <= now && $0.endsAt > now })
|
||||
else { return nil }
|
||||
return match.asLiveProgram(channelID: channel.id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Übersetzung
|
||||
|
||||
extension PlexChannel {
|
||||
/// Der Sender in neutraler Form.
|
||||
///
|
||||
/// Ohne `tuning`: bei Plex merkt sich die Sitzung den echten Sender, und
|
||||
/// ein Griff, der nur wiederholt, was ohnehin dort liegt, wäre eine
|
||||
/// zweite Wahrheit.
|
||||
func asLiveChannel(running: LiveProgram?) -> LiveChannel {
|
||||
LiveChannel(
|
||||
id: id,
|
||||
number: channelIdentifier,
|
||||
title: title,
|
||||
artworkPath: thumb,
|
||||
currentProgram: running
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension PlexProgram {
|
||||
/// Die Sendung in neutraler Form.
|
||||
///
|
||||
/// Die Senderkennung kommt von außen: `PlexProgram` trägt sie optional,
|
||||
/// und im Programmraster steht sie ohnehin schon als Schlüssel darüber.
|
||||
func asLiveProgram(channelID: String) -> LiveProgram {
|
||||
LiveProgram(
|
||||
id: id,
|
||||
channelID: self.channelID ?? channelID,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
summary: summary,
|
||||
startsAt: startsAt,
|
||||
endsAt: endsAt,
|
||||
artworkPath: thumb
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Der Vertrag
|
||||
|
||||
/// Plex als `LiveTVBackend`.
|
||||
///
|
||||
/// Reine Weiterleitung an die Sitzung. Der Suchlauf, der die Senderliste
|
||||
/// findet, und alles, was das Einschalten kostet, bleiben unangetastet in
|
||||
/// `PlexServer+LiveTV` — hier wird nur übersetzt.
|
||||
///
|
||||
/// **Kein** `LiveTVRecordingBackend`: Plex kann in dieser App nichts
|
||||
/// aufnehmen, und das mit werfenden Methoden zu behaupten wäre schlechter,
|
||||
/// als es gar nicht erst zu versprechen.
|
||||
extension PlexBackend: LiveTVBackend {
|
||||
public func liveTVAvailability() async -> LiveTVAvailability {
|
||||
await live.availability()
|
||||
}
|
||||
|
||||
public func liveChannels() async -> LiveChannelListing {
|
||||
await live.channels()
|
||||
}
|
||||
|
||||
public func livePrograms(
|
||||
channelIDs: [String],
|
||||
from: Date,
|
||||
to: Date
|
||||
) async throws(MediaBackendError) -> [String: [LiveProgram]] {
|
||||
await live.programs(channelIDs: channelIDs, from: from, to: to)
|
||||
}
|
||||
|
||||
public func tune(
|
||||
channel: LiveChannel,
|
||||
program: LiveProgram?,
|
||||
maximumBitrate: Int?
|
||||
) async -> LiveTuneResult {
|
||||
await live.tune(channel: channel, program: program, maximumBitrate: maximumBitrate)
|
||||
}
|
||||
|
||||
public func endLiveSession(subscriptionKey: String) async {
|
||||
await live.endSession(subscriptionKey: subscriptionKey)
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,16 @@ public struct PlexBackend: MediaBackend {
|
||||
/// ihm sprechen — diese drei Bereiche wandern als Nächstes.
|
||||
public let server: PlexServer
|
||||
|
||||
/// Live-TV mit Gedächtnis.
|
||||
///
|
||||
/// Ein Actor, also ein Referenztyp: Kopien dieses Struct teilen sich
|
||||
/// denselben und damit die geladene Senderliste. Warum das nötig ist,
|
||||
/// steht bei `PlexLiveTVSession`.
|
||||
let live: PlexLiveTVSession
|
||||
|
||||
public init(server: PlexServer) {
|
||||
self.server = server
|
||||
self.live = PlexLiveTVSession(server: server)
|
||||
}
|
||||
|
||||
public nonisolated var serverName: String { server.name }
|
||||
|
||||
Reference in New Issue
Block a user