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:
Scarriffle
2026-09-14 06:17:10 +02:00
co-authored by Claude Opus 5
parent 9df58ed5a6
commit 24765a5afc
10 changed files with 1153 additions and 14 deletions
@@ -0,0 +1,280 @@
import Foundation
import MediaKit
/// Jellyfin als `LiveTVBackend`.
///
/// Deutlich kürzer als das Plex-Gegenstück, und das liegt nicht an weniger
/// Sorgfalt: Jellyfin hat feste, beschriebene Endpunkte. Es gibt hier nichts
/// zu suchen und darum auch nichts zu berichten, wenn die Suche scheitert.
///
/// Deshalb bleiben `source` und `probes` in der Senderliste leer. Die
/// Oberfläche zeigt den Diagnoseblock dann gar nicht erst — er ist die
/// Antwort auf ein Problem, das es hier nicht gibt.
extension JellyfinBackend: LiveTVBackend {
// MARK: - Ob es etwas zu sehen gibt
public func liveTVAvailability() async -> LiveTVAvailability {
do {
let info = try await client.send(
JellyfinRequest(path: "/LiveTv/Info"),
as: JellyfinLiveTVInfo.self
)
// `IsEnabled` allein genügt nicht: der Server meldet es auch dann,
// wenn zwar der Dienst läuft, aber kein Empfänger daran hängt.
// Erst die Dienstliste sagt, ob tatsächlich etwas anliegt.
guard info.isEnabled == true, !(info.services ?? []).isEmpty else {
return .notConfigured
}
return .available
} catch {
// Ein Server ohne Live-TV-Unterstützung antwortet hier mit 404.
// Das ist kein Fehler, sondern dieselbe Auskunft wie „nichts
// eingerichtet" — und die Oberfläche soll sie erklären, nicht
// einen Fehlerdialog zeigen.
if case .notFound = error { return .notConfigured }
return .failed(message: error.probeDescription)
}
}
// MARK: - Sender
public func liveChannels() async -> LiveChannelListing {
do {
let page = try await client.send(
JellyfinRequest(
path: "/LiveTv/Channels",
query: [
URLQueryItem(name: "userId", value: account.userID),
// Die laufende Sendung gleich mit. Ohne das bräuchte
// die Senderliste einen zweiten Aufruf, nur um zu
// zeigen, was gerade läuft.
URLQueryItem(name: "addCurrentProgram", value: "true"),
URLQueryItem(name: "enableImages", value: "true"),
URLQueryItem(name: "enableUserData", value: "false"),
]
),
as: JellyfinItemPage.self
)
return LiveChannelListing(channels: page.items.map(liveChannel(from:)))
} catch {
return LiveChannelListing(channels: [])
}
}
// MARK: - Programm
public func livePrograms(
channelIDs: [String],
from: Date,
to: Date
) async throws(MediaBackendError) -> [String: [LiveProgram]] {
var query = [
URLQueryItem(name: "userId", value: account.userID),
URLQueryItem(name: "minEndDate", value: Self.timestamp(from)),
URLQueryItem(name: "maxStartDate", value: Self.timestamp(to)),
URLQueryItem(name: "enableImages", value: "true"),
URLQueryItem(name: "enableUserData", value: "false"),
URLQueryItem(name: "sortBy", value: "StartDate"),
]
// Leer heißt: alle. Eine leere Liste mitzuschicken hieße dagegen
// „keiner", und dann käme nichts zurück.
if !channelIDs.isEmpty {
query.append(URLQueryItem(name: "channelIds", value: channelIDs.joined(separator: ",")))
}
let page = try await client.send(
JellyfinRequest(path: "/LiveTv/Programs", query: query),
as: JellyfinItemPage.self
)
// Nach Sender gebündelt, weil das Raster genau so danach fragt.
var out: [String: [LiveProgram]] = [:]
for item in page.items {
guard let program = liveProgram(from: item) else { continue }
out[program.channelID, default: []].append(program)
}
for (channel, programs) in out {
out[channel] = programs.sorted { $0.startsAt < $1.startsAt }
}
return out
}
// MARK: - Einschalten
public func tune(
channel: LiveChannel,
program: LiveProgram?,
maximumBitrate: Int?
) async -> LiveTuneResult {
let session = UUID().uuidString
// Ein Sender ist für Jellyfin ein Eintrag wie jeder andere: derselbe
// Weg wie beim Film, nur dass die Quelle nie endet. Der Server nennt
// die Adresse; sie selbst zusammenzubauen wäre eine Vermutung darüber,
// was er kopieren kann und was er rechnen muss.
do {
let info = try await playbackInfo(
itemID: channel.id,
sourceID: channel.id,
transcode: TranscodeSettings(maximumBitrate: maximumBitrate, resolution: nil)
)
let source = info.mediaSources?.first
if let path = source?.transcodingURL,
let url = URL(string: path, relativeTo: client.baseURL)?.absoluteURL {
return LiveTuneResult(
url: url,
sessionID: info.playSessionID ?? session,
subscriptionKey: info.playSessionID
)
}
// Kein Transcoder-Weg: dann unverändert. Manche Empfänger liefern
// bereits einen Strom, den das Gerät ohne Umrechnung versteht.
if let url = client.url(
path: "/Videos/\(channel.id)/stream",
query: [
URLQueryItem(name: "static", value: "true"),
URLQueryItem(name: "mediaSourceId", value: source?.id ?? channel.id),
],
includingToken: account.token
) {
return LiveTuneResult(
url: url,
sessionID: info.playSessionID ?? session,
subscriptionKey: info.playSessionID
)
}
return LiveTuneResult(
url: nil,
sessionID: session,
trace: [LiveChannelProbe(
path: "/Items/\(channel.id)/PlaybackInfo",
result: "Der Server nannte weder eine Transcoder- noch eine Direktadresse"
)]
)
} catch {
return LiveTuneResult(
url: nil,
sessionID: session,
trace: [LiveChannelProbe(
path: "/Items/\(channel.id)/PlaybackInfo",
result: error.probeDescription
)]
)
}
}
public func endLiveSession(subscriptionKey: String) async {
// Dieselbe Freigabe wie am Ende eines Films: der Transcoder rechnet
// sonst weiter, und beim Live-TV bleibt dabei auch der Empfänger
// belegt — davon gibt es weniger als von allem anderen.
await endPlayback(sessionID: subscriptionKey)
}
// MARK: - Übersetzung
private func liveChannel(from item: JellyfinItem) -> LiveChannel {
LiveChannel(
id: item.id,
// `Number` steht am Sender, `ChannelNumber` an der Sendung.
number: item.number ?? item.channelNumber,
title: item.name ?? "—",
artworkPath: images.primary(itemID: item.id, tag: item.imageTags?["Primary"]),
currentProgram: item.currentProgram.flatMap { current in
guard let start = current.startDate, let end = current.endDate else { return nil }
return LiveProgram(
id: current.id,
channelID: item.id,
title: current.name ?? "—",
summary: current.overview,
startsAt: start,
endsAt: end
)
}
)
}
/// Eine Sendung — oder `nil`, wenn sie keine Zeit hat.
///
/// Ohne Anfang und Ende lässt sie sich nicht ins Raster legen, und ein
/// Block ohne Ausdehnung wäre in der Anzeige ein unsichtbarer Streifen,
/// den man nicht treffen kann.
private func liveProgram(from item: JellyfinItem) -> LiveProgram? {
guard let channelID = item.channelID,
let start = item.startDate,
let end = item.endDate
else { return nil }
var kind: LiveProgram.Kind = []
if item.isMovie == true { kind.insert(.movie) }
if item.isSeries == true { kind.insert(.series) }
if item.isNews == true { kind.insert(.news) }
if item.isSports == true { kind.insert(.sports) }
if item.isKids == true { kind.insert(.kids) }
if item.isLive == true { kind.insert(.live) }
return LiveProgram(
id: item.id,
channelID: channelID,
title: item.name ?? "—",
// Der Folgentitel steht bei Jellyfin dort, wo bei einer Serie der
// Seriename stünde — die Sendung selbst trägt den Reihentitel.
subtitle: item.seriesName == nil ? nil : item.name,
summary: item.overview,
startsAt: start,
endsAt: end,
artworkPath: images.primary(itemID: item.id, tag: item.imageTags?["Primary"]),
seriesTimerID: item.seriesTimerID,
kind: kind
)
}
/// Ein Zeitpunkt, wie Jellyfin ihn in der Adresse erwartet.
static func timestamp(_ date: Date) -> String {
ISO8601DateFormatter().string(from: date)
}
}
// MARK: - Was der Server über Live-TV sagt
/// Die Auskunft unter `/LiveTv/Info`.
struct JellyfinLiveTVInfo: Decodable, Sendable {
let isEnabled: Bool?
/// Die eingerichteten Dienste. Leer heißt: kein Empfänger.
let services: [Service]?
struct Service: Decodable, Sendable {
let name: String?
private enum CodingKeys: String, CodingKey { case name = "Name" }
}
private enum CodingKeys: String, CodingKey {
case isEnabled = "IsEnabled"
case services = "Services"
}
}
// MARK: - Fehler in der Spur
extension MediaBackendError {
/// Knapp und technisch — für die Diagnosespur, nicht für den Nutzer.
///
/// Bewusst nicht dasselbe wie die Meldung, die in der Oberfläche steht:
/// die soll erklären, was zu tun ist, diese soll festhalten, was passiert
/// ist. Sie wird nicht übersetzt, weil sie in einem Bericht landet, den
/// man weitergibt.
var probeDescription: String {
switch self {
case .unauthorized: "nicht angemeldet"
case .notFound: "404"
case .noReachableConnection: "keine Verbindung"
case .server(let status): "HTTP \(status)"
case .transport(let description): description
case .decoding(let description): "unerwartete Antwort: \(description)"
case .refused(let reason): reason
}
}
}
@@ -110,7 +110,10 @@ extension JellyfinBackend {
// MARK: - Hilfen // MARK: - Hilfen
private func playbackInfo( /// Nicht `private`: Live-TV in `JellyfinBackend+LiveTV` fragt denselben
/// Weg. Ein Sender ist für Jellyfin ein Eintrag wie jeder andere, und
/// eine zweite Fassung dieser Abfrage wäre eine Kopie, die auseinanderläuft.
func playbackInfo(
itemID: String, itemID: String,
sourceID: String, sourceID: String,
transcode: TranscodeSettings transcode: TranscodeSettings
@@ -0,0 +1,192 @@
import Foundation
import MediaKit
/// Jellyfin als `LiveTVRecordingBackend`.
///
/// Plex erfüllt diesen Vertrag nicht — es kann in dieser App nichts aufnehmen.
/// Deshalb steht er getrennt: wer aufnehmen will, fragt mit `as?` danach und
/// bekommt eine ehrliche Antwort, statt auf Methoden zu treffen, die nur
/// werfen.
extension JellyfinBackend: LiveTVRecordingBackend {
// MARK: - Aufnahmen
public func recordings() async throws(MediaBackendError) -> [LiveRecording] {
let page = try await client.send(
JellyfinRequest(
path: "/LiveTv/Recordings",
query: [
URLQueryItem(name: "userId", value: account.userID),
URLQueryItem(name: "enableImages", value: "true"),
]
),
as: JellyfinItemPage.self
)
let now = Date()
return page.items.map { item in
LiveRecording(
id: item.id,
title: item.name ?? "—",
channelName: item.channelName,
summary: item.overview,
startsAt: item.startDate,
endsAt: item.endDate,
artworkPath: images.primary(itemID: item.id, tag: item.imageTags?["Primary"]),
// Abgeleitet statt erfragt: eine Aufnahme, deren Ende noch
// bevorsteht, läuft gerade. Das spart ein Feld, das sonst nur
// für diese eine Anzeige im Modell stünde.
isInProgress: (item.endDate.map { $0 > now }) ?? false
)
}
}
public func deleteRecording(id: String) async throws(MediaBackendError) {
_ = try await client.send(JellyfinRequest(
method: .delete,
path: "/LiveTv/Recordings/\(id)"
))
}
// MARK: - Vorgemerkt
public func timers() async throws(MediaBackendError) -> [LiveTimer] {
let page = try await client.send(
JellyfinRequest(path: "/LiveTv/Timers"),
as: JellyfinTimerPage.self
)
return page.items.map { timer in
LiveTimer(
id: timer.id ?? "",
programID: timer.programID,
channelID: timer.channelID,
channelName: timer.channelName,
title: timer.name ?? "—",
startsAt: timer.startDate,
endsAt: timer.endDate,
seriesTimerID: timer.seriesTimerID
)
}
}
public func scheduleTimer(for program: LiveProgram) async throws(MediaBackendError) {
try await schedule(program: program, path: "/LiveTv/Timers")
}
public func cancelTimer(id: String) async throws(MediaBackendError) {
_ = try await client.send(JellyfinRequest(
method: .delete,
path: "/LiveTv/Timers/\(id)"
))
}
// MARK: - Ganze Reihen
public func seriesTimers() async throws(MediaBackendError) -> [LiveSeriesTimer] {
let page = try await client.send(
JellyfinRequest(path: "/LiveTv/SeriesTimers"),
as: JellyfinSeriesTimerPage.self
)
return page.items.map { timer in
LiveSeriesTimer(
id: timer.id ?? "",
name: timer.name ?? "—",
channelID: timer.channelID,
channelName: timer.channelName,
recordsNewOnly: timer.skipEpisodesInLibrary ?? false,
recordsAnyChannel: timer.recordAnyChannel ?? false
)
}
}
public func scheduleSeriesTimer(for program: LiveProgram) async throws(MediaBackendError) {
try await schedule(program: program, path: "/LiveTv/SeriesTimers")
}
public func cancelSeriesTimer(id: String) async throws(MediaBackendError) {
_ = try await client.send(JellyfinRequest(
method: .delete,
path: "/LiveTv/SeriesTimers/\(id)"
))
}
// MARK: - Der gemeinsame Weg
/// Merkt eine Sendung vor — einmalig oder als ganze Reihe.
///
/// **Zwei Schritte, und der erste ist der wichtige.** Der Server liefert
/// unter `Timers/Defaults` eine für diese Sendung fertig ausgefüllte
/// Vorlage: Vor- und Nachlauf, Priorität, Dienstname, wie lange sie
/// aufgehoben wird. Was davon Pflicht ist, steht nirgends — die Beschreibung
/// nennt kein einziges Feld als erforderlich.
///
/// Deshalb wird die Vorlage **roh weitergereicht**, so wie sie kam, und
/// nicht entschlüsselt und neu zusammengesetzt. Jedes Feld, das dabei
/// durch die Modellierung fiele, wäre eines, das der Server vermisst —
/// und man erführe es erst an einer Aufnahme, die nicht startet.
private func schedule(program: LiveProgram, path: String) async throws(MediaBackendError) {
let template = try await client.send(JellyfinRequest(
path: "/LiveTv/Timers/Defaults",
query: [URLQueryItem(name: "programId", value: program.id)]
))
_ = try await client.send(JellyfinRequest(
method: .post,
path: path,
body: template
))
}
}
// MARK: - Was Jellyfin über Vorgemerktes schickt
/// Ein vorgemerkter Termin, wie Jellyfin ihn meldet.
///
/// Kein `JellyfinItem`: Timer sind bei Jellyfin ausdrücklich **keine**
/// Einträge, sondern ein eigener Typ mit eigenen Feldern.
struct JellyfinTimer: Decodable, Sendable {
let id: String?
let programID: String?
let channelID: String?
let channelName: String?
let name: String?
let startDate: Date?
let endDate: Date?
let seriesTimerID: String?
private enum CodingKeys: String, CodingKey {
case id = "Id", programID = "ProgramId", channelID = "ChannelId"
case channelName = "ChannelName", name = "Name"
case startDate = "StartDate", endDate = "EndDate"
case seriesTimerID = "SeriesTimerId"
}
}
struct JellyfinTimerPage: Decodable, Sendable {
let items: [JellyfinTimer]
private enum CodingKeys: String, CodingKey { case items = "Items" }
}
/// Eine stehende Aufnahme für alles, was zu einer Reihe gehört.
struct JellyfinSeriesTimer: Decodable, Sendable {
let id: String?
let channelID: String?
let channelName: String?
let name: String?
/// Ob bereits Vorhandenes übersprungen wird — das, was man umgangssprachlich
/// „nur neue Folgen" nennt.
let skipEpisodesInLibrary: Bool?
let recordAnyChannel: Bool?
private enum CodingKeys: String, CodingKey {
case id = "Id", channelID = "ChannelId", channelName = "ChannelName"
case name = "Name", skipEpisodesInLibrary = "SkipEpisodesInLibrary"
case recordAnyChannel = "RecordAnyChannel"
}
}
struct JellyfinSeriesTimerPage: Decodable, Sendable {
let items: [JellyfinSeriesTimer]
private enum CodingKeys: String, CodingKey { case items = "Items" }
}
@@ -5,7 +5,9 @@ import MediaKit
public struct JellyfinBackend: MediaBackend { public struct JellyfinBackend: MediaBackend {
let client: JellyfinClient let client: JellyfinClient
let account: JellyfinAccount let account: JellyfinAccount
private let images = JellyfinImagePaths() /// Nicht `private`: Live-TV und Aufnahmen liegen in eigenen Dateien und
/// brauchen dieselben Bildpfade.
let images = JellyfinImagePaths()
public let serverName: String public let serverName: String
public init(client: JellyfinClient, account: JellyfinAccount, serverName: String) { public init(client: JellyfinClient, account: JellyfinAccount, serverName: String) {
@@ -17,7 +19,9 @@ public struct JellyfinBackend: MediaBackend {
public var isAdministrator: Bool { account.isAdministrator } public var isAdministrator: Bool { account.isAdministrator }
public nonisolated var capabilities: Set<BackendCapability> { public nonisolated var capabilities: Set<BackendCapability> {
var capabilities: Set<BackendCapability> = [.favourites, .liveTV, .libraryScan, .transcoding] var capabilities: Set<BackendCapability> = [
.favourites, .liveTV, .liveTVRecordings, .libraryScan, .transcoding,
]
// Verwaltung nur, wenn das Konto sie auch darf. Den Bereich zu zeigen // Verwaltung nur, wenn das Konto sie auch darf. Den Bereich zu zeigen
// und bei jedem Tipp abzulehnen, wäre die schlechtere Antwort. // und bei jedem Tipp abzulehnen, wäre die schlechtere Antwort.
if account.isAdministrator { capabilities.insert(.administration) } if account.isAdministrator { capabilities.insert(.administration) }
@@ -35,6 +35,36 @@ public struct JellyfinItem: Decodable, Sendable {
public let mediaSources: [JellyfinMediaSource]? public let mediaSources: [JellyfinMediaSource]?
public let collectionType: String? public let collectionType: String?
// MARK: - Live-TV
//
// Sender **und** Sendungen kommen bei Jellyfin als derselbe Typ wie
// Filme und Folgen. Deshalb stehen ihre Felder hier und nicht in einem
// eigenen Modell: ein zweites, das zu neunzig Prozent dasselbe trägt,
// wäre eine Kopie mit Pflegeaufwand.
/// Zu welchem Sender eine Sendung gehört.
public let channelID: String?
public let channelName: String?
/// Die Nummer, unter der ein Sender im Programm steht — „3" oder „11.1".
public let channelNumber: String?
/// Dasselbe, aber am Sender selbst statt an der Sendung.
public let number: String?
public let startDate: Date?
public let endDate: Date?
/// Was auf diesem Sender gerade läuft.
///
/// Kommt nur, wenn die Senderliste mit `addCurrentProgram` geholt wurde —
/// dann aber gleich mit, und die Liste braucht keinen zweiten Aufruf.
public let currentProgram: JellyfinCurrentProgram?
/// Ob die Sendung zu einem laufenden Serien-Timer gehört.
public let seriesTimerID: String?
public let isMovie: Bool?
public let isSeries: Bool?
public let isNews: Bool?
public let isSports: Bool?
public let isKids: Bool?
public let isLive: Bool?
public struct Person: Decodable, Sendable { public struct Person: Decodable, Sendable {
public let id: String? public let id: String?
public let name: String? public let name: String?
@@ -78,6 +108,30 @@ public struct JellyfinItem: Decodable, Sendable {
case parentBackdropItemID = "ParentBackdropItemId" case parentBackdropItemID = "ParentBackdropItemId"
case seriesPrimaryImageTag = "SeriesPrimaryImageTag" case seriesPrimaryImageTag = "SeriesPrimaryImageTag"
case mediaSources = "MediaSources", collectionType = "CollectionType" case mediaSources = "MediaSources", collectionType = "CollectionType"
case channelID = "ChannelId", channelName = "ChannelName"
case channelNumber = "ChannelNumber", number = "Number"
case startDate = "StartDate", endDate = "EndDate"
case currentProgram = "CurrentProgram", seriesTimerID = "SeriesTimerId"
case isMovie = "IsMovie", isSeries = "IsSeries", isNews = "IsNews"
case isSports = "IsSports", isKids = "IsKids", isLive = "IsLive"
}
}
/// Was auf einem Sender gerade läuft.
///
/// Ein eigenes, schmales Modell statt eines verschachtelten `JellyfinItem`:
/// ein Struct darf sich nicht selbst enthalten — seine Größe wäre unendlich.
/// Gebraucht werden hier ohnehin nur fünf Felder.
public struct JellyfinCurrentProgram: Decodable, Sendable {
public let id: String
public let name: String?
public let overview: String?
public let startDate: Date?
public let endDate: Date?
private enum CodingKeys: String, CodingKey {
case id = "Id", name = "Name", overview = "Overview"
case startDate = "StartDate", endDate = "EndDate"
} }
} }
@@ -0,0 +1,362 @@
import Foundation
// MARK: - Was ein Sender ist
/// Der Griff, mit dem eine Implementierung ihren Sender wiederfindet.
///
/// Undurchsichtig mit Absicht. Bei Plex stecken darin die Schlüssel, die der
/// Suchlauf gefunden hat — Rasterschlüssel, Sendernummer am Empfänger, der
/// Empfänger selbst. Bei Jellyfin genügt die Kennung, und er bleibt leer.
///
/// Er wird **weitergereicht, nicht gelesen**. Wer einen Sender anzeigt, geht
/// sein Inhalt nichts an; wer ihn einschaltet, hat ihn selbst gefüllt.
///
/// Der Umweg ist nötig, weil beide Backends Werttypen sind und sich zwischen
/// zwei Aufrufen nichts merken können. Ohne ihn müsste das Einschalten die
/// Senderliste neu laden — bei Plex hieße das, den ganzen Suchlauf noch
/// einmal zu gehen.
public struct LiveTuningHandle: Sendable, Equatable, Hashable {
private let fields: [String: String]
public init(_ fields: [String: String] = [:]) {
self.fields = fields
}
public subscript(_ key: String) -> String? { fields[key] }
public var isEmpty: Bool { fields.isEmpty }
}
/// Ein Fernsehsender.
public struct LiveChannel: Sendable, Equatable, Identifiable {
public let id: String
/// Die Nummer, unter der er im Programm steht — „3" oder „11.1".
///
/// Optional, weil nicht jeder Server sie führt. Steht sie, gehört sie vor
/// den Namen: danach sucht man.
public let number: String?
public let title: String
public let artworkPath: String?
/// Was gerade läuft, sofern der Server es gleich mitliefert.
///
/// Jellyfin tut das auf Wunsch (`addCurrentProgram`), und dann spart es
/// einen zweiten Aufruf für die Senderliste.
public let currentProgram: LiveProgram?
public let tuning: LiveTuningHandle
public init(
id: String,
number: String? = nil,
title: String,
artworkPath: String? = nil,
currentProgram: LiveProgram? = nil,
tuning: LiveTuningHandle = LiveTuningHandle()
) {
self.id = id
self.number = number
self.title = title
self.artworkPath = artworkPath
self.currentProgram = currentProgram
self.tuning = tuning
}
}
// MARK: - Was eine Sendung ist
/// Eine Sendung im Programm.
public struct LiveProgram: Sendable, Equatable, Identifiable {
public let id: String
public let channelID: String
public let title: String
/// Der Folgentitel, wenn es einer ist.
public let subtitle: String?
public let summary: String?
public let startsAt: Date
public let endsAt: Date
public let artworkPath: String?
/// Ob sie zu einem laufenden Serien-Timer gehört.
///
/// Steht sie, ist die Sendung bereits zur Aufnahme vorgemerkt, und die
/// Oberfläche soll „Aufnehmen" nicht noch einmal anbieten.
public let seriesTimerID: String?
public let kind: Kind
/// Wovon eine Sendung handelt — soweit der Server es sagt.
///
/// Als Menge und nicht als einzelner Wert: eine Sportübertragung kann
/// zugleich eine Live-Sendung sein, und Jellyfin meldet beide Kennzeichen
/// nebeneinander.
public struct Kind: OptionSet, Sendable, Equatable, Hashable {
public let rawValue: Int
public init(rawValue: Int) { self.rawValue = rawValue }
public static let movie = Kind(rawValue: 1 << 0)
public static let series = Kind(rawValue: 1 << 1)
public static let news = Kind(rawValue: 1 << 2)
public static let sports = Kind(rawValue: 1 << 3)
public static let kids = Kind(rawValue: 1 << 4)
public static let live = Kind(rawValue: 1 << 5)
}
public init(
id: String,
channelID: String,
title: String,
subtitle: String? = nil,
summary: String? = nil,
startsAt: Date,
endsAt: Date,
artworkPath: String? = nil,
seriesTimerID: String? = nil,
kind: Kind = []
) {
self.id = id
self.channelID = channelID
self.title = title
self.subtitle = subtitle
self.summary = summary
self.startsAt = startsAt
self.endsAt = endsAt
self.artworkPath = artworkPath
self.seriesTimerID = seriesTimerID
self.kind = kind
}
/// Ob sie zu diesem Zeitpunkt läuft.
public func isRunning(at moment: Date = .now) -> Bool {
startsAt <= moment && endsAt > moment
}
}
// MARK: - Ob es überhaupt etwas zu sehen gibt
/// Was beim Live-TV herauskommen kann.
public enum LiveTVAvailability: Sendable, Equatable {
/// Es gibt einen Empfänger und Sender.
///
/// Ohne Angabe, **welchen** Empfänger: das ist Sache der Implementierung.
/// Vorher stand hier die Liste der Plex-Empfänger, und damit trug die
/// neutrale Form einen fremden Typ durch die halbe App.
case available
/// 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)
}
/// Eine abgeklopfte Adresse mit ihrer Antwort.
///
/// Nur Plex füllt das: dort wird die Senderliste gesucht, weil ihre Adresse
/// sich zwischen Serverfassungen unterscheidet. Jellyfin hat einen festen
/// Endpunkt und liefert nichts — die Oberfläche zeigt den Block dann gar
/// nicht erst.
public struct LiveChannelProbe: Sendable, Equatable, Identifiable {
public let path: String
public let result: String
public var id: String { path }
public init(path: String, result: String) {
self.path = path
self.result = result
}
}
/// Die Senderliste samt dem, was ihr Zustandekommen erklärt.
public struct LiveChannelListing: Sendable, Equatable {
public let channels: [LiveChannel]
/// Woher sie kam. `nil`, wo es nichts zu erklären gibt.
public let source: String?
/// Jeder versuchte Weg. Leer, wo nicht gesucht werden musste.
public let probes: [LiveChannelProbe]
public init(channels: [LiveChannel], source: String? = nil, probes: [LiveChannelProbe] = []) {
self.channels = channels
self.source = source
self.probes = probes
}
}
// MARK: - Einschalten
/// Was beim Einschalten herauskam.
public struct LiveTuneResult: Sendable {
public let url: URL?
public let sessionID: String
/// Womit der Empfänger wieder freigegeben wird.
///
/// Ohne dieses Signal bleibt ein Tuner belegt, bis der Server von selbst
/// merkt, dass niemand zusieht — und vier Stück gibt es davon.
public let subscriptionKey: String?
/// Jeder versuchte Weg mit seiner Antwort.
public let trace: [LiveChannelProbe]
public init(
url: URL?,
sessionID: String,
subscriptionKey: String? = nil,
trace: [LiveChannelProbe] = []
) {
self.url = url
self.sessionID = sessionID
self.subscriptionKey = subscriptionKey
self.trace = trace
}
}
// MARK: - Aufnehmen
/// Eine fertige Aufnahme auf dem Server.
public struct LiveRecording: Sendable, Equatable, Identifiable {
public let id: String
public let title: String
public let channelName: String?
public let summary: String?
public let startsAt: Date?
public let endsAt: Date?
public let artworkPath: String?
/// Ob gerade noch aufgenommen wird.
public let isInProgress: Bool
public init(
id: String,
title: String,
channelName: String? = nil,
summary: String? = nil,
startsAt: Date? = nil,
endsAt: Date? = nil,
artworkPath: String? = nil,
isInProgress: Bool = false
) {
self.id = id
self.title = title
self.channelName = channelName
self.summary = summary
self.startsAt = startsAt
self.endsAt = endsAt
self.artworkPath = artworkPath
self.isInProgress = isInProgress
}
}
/// Eine vorgemerkte Aufnahme.
public struct LiveTimer: Sendable, Equatable, Identifiable {
public let id: String
public let programID: String?
public let channelID: String?
public let channelName: String?
public let title: String
public let startsAt: Date?
public let endsAt: Date?
/// Ob sie zu einem Serien-Timer gehört.
public let seriesTimerID: String?
public init(
id: String,
programID: String? = nil,
channelID: String? = nil,
channelName: String? = nil,
title: String,
startsAt: Date? = nil,
endsAt: Date? = nil,
seriesTimerID: String? = nil
) {
self.id = id
self.programID = programID
self.channelID = channelID
self.channelName = channelName
self.title = title
self.startsAt = startsAt
self.endsAt = endsAt
self.seriesTimerID = seriesTimerID
}
}
/// Eine stehende Aufnahme für alles, was zu einer Serie gehört.
public struct LiveSeriesTimer: Sendable, Equatable, Identifiable {
public let id: String
public let name: String
public let channelID: String?
public let channelName: String?
/// Ob nur Erstausstrahlungen aufgenommen werden.
public let recordsNewOnly: Bool
/// Ob auf allen Sendern aufgenommen wird, nicht nur auf einem.
public let recordsAnyChannel: Bool
public init(
id: String,
name: String,
channelID: String? = nil,
channelName: String? = nil,
recordsNewOnly: Bool = false,
recordsAnyChannel: Bool = false
) {
self.id = id
self.name = name
self.channelID = channelID
self.channelName = channelName
self.recordsNewOnly = recordsNewOnly
self.recordsAnyChannel = recordsAnyChannel
}
}
// MARK: - Die Verträge
/// Was ein Server können muss, damit Live-TV funktioniert.
///
/// Bewusst **außerhalb** von `MediaBackend`: nicht jeder Server kann das, und
/// ein Vertrag, den die Hälfte der Erfüller nur mit Ausnahmen erfüllt, ist
/// keiner. Wer Live-TV will, fragt mit `as?` danach.
public protocol LiveTVBackend: Sendable {
/// Ob es einen Empfänger gibt.
///
/// Wirft nicht: „kein Empfänger eingerichtet" ist kein Fehler, sondern
/// eine Antwort, und die Oberfläche soll sie erklären können.
func liveTVAvailability() async -> LiveTVAvailability
/// Die Sender — und, wo gesucht werden musste, wie sie gefunden wurden.
func liveChannels() async -> LiveChannelListing
/// Das Programm für die genannten Sender in einem Zeitraum.
///
/// Nach Senderkennung gebündelt, weil das Raster genau so danach fragt.
func livePrograms(
channelIDs: [String],
from: Date,
to: Date
) async throws(MediaBackendError) -> [String: [LiveProgram]]
/// Schaltet ein.
///
/// Wirft nicht, sondern berichtet: scheitert es, ist die Spur der einzige
/// Weg zu sehen, woran. Genau daran hing beim Live-TV tagelang alles.
func tune(
channel: LiveChannel,
program: LiveProgram?,
maximumBitrate: Int?
) async -> LiveTuneResult
/// Gibt den Empfänger wieder frei.
func endLiveSession(subscriptionKey: String) async
}
/// Was ein Server darüber hinaus können muss, damit Aufnehmen funktioniert.
///
/// Getrennt vom Rest, weil Plex das eine kann und das andere nicht. Ein
/// `as? LiveTVRecordingBackend` sagt die Wahrheit; eine Methode, die bei Plex
/// nur wirft, verspricht etwas und bricht es dann.
public protocol LiveTVRecordingBackend: Sendable {
func recordings() async throws(MediaBackendError) -> [LiveRecording]
func deleteRecording(id: String) async throws(MediaBackendError)
func timers() async throws(MediaBackendError) -> [LiveTimer]
func scheduleTimer(for program: LiveProgram) async throws(MediaBackendError)
func cancelTimer(id: String) async throws(MediaBackendError)
func seriesTimers() async throws(MediaBackendError) -> [LiveSeriesTimer]
func scheduleSeriesTimer(for program: LiveProgram) async throws(MediaBackendError)
func cancelSeriesTimer(id: String) async throws(MediaBackendError)
}
@@ -97,6 +97,13 @@ public protocol MediaBackend: Sendable {
/// Server nicht kann, ist schlimmer als eine, die sie weglässt. /// Server nicht kann, ist schlimmer als eine, die sie weglässt.
public enum BackendCapability: String, Sendable, Hashable { public enum BackendCapability: String, Sendable, Hashable {
case liveTV case liveTV
/// Aufnehmen und Vormerken — nicht dasselbe wie `liveTV`.
///
/// Plex kann Sender zeigen und einschalten, aber in dieser App nichts
/// aufnehmen; Jellyfin kann beides. Eine Fähigkeit für beides zusammen
/// hieße, entweder Plex Live-TV wegzunehmen oder einen Knopf zu zeigen,
/// der dort nichts tut.
case liveTVRecordings
case favourites case favourites
case ratings case ratings
case libraryScan case libraryScan
@@ -2,7 +2,7 @@ import Foundation
import MediaKit import MediaKit
/// Was beim Live-TV herauskommen kann. /// Was beim Live-TV herauskommen kann.
public enum LiveTVAvailability: Sendable, Equatable { public enum PlexLiveTVAvailability: Sendable, Equatable {
/// Es gibt einen Empfänger und Sender. /// Es gibt einen Empfänger und Sender.
case available(dvrs: [PlexDVR]) case available(dvrs: [PlexDVR])
/// Der Server läuft, hat aber keinen Empfänger eingerichtet. /// Der Server läuft, hat aber keinen Empfänger eingerichtet.
@@ -16,7 +16,7 @@ public enum LiveTVAvailability: Sendable, Equatable {
extension PlexServer { extension PlexServer {
/// Prüft, ob der Server überhaupt Live-TV kann. /// Prüft, ob der Server überhaupt Live-TV kann.
public func liveTVAvailability() async -> LiveTVAvailability { public func liveTVAvailability() async -> PlexLiveTVAvailability {
do { do {
let container = try await client.send( let container = try await client.send(
PlexRequest(path: "/livetv/dvrs", timeout: .seconds(10)), PlexRequest(path: "/livetv/dvrs", timeout: .seconds(10)),
@@ -405,7 +405,7 @@ extension PlexServer {
program: PlexProgram? = nil, program: PlexProgram? = nil,
dvr: PlexDVR? = nil, dvr: PlexDVR? = nil,
maximumBitrate: Int? maximumBitrate: Int?
) async -> LiveTuneResult { ) async -> PlexTuneResult {
let session = UUID().uuidString let session = UUID().uuidString
var attempts = 0 var attempts = 0
@@ -448,7 +448,7 @@ extension PlexServer {
) )
trace.append(contentsOf: outcome.probes) trace.append(contentsOf: outcome.probes)
if let url = outcome.url { if let url = outcome.url {
return LiveTuneResult( return PlexTuneResult(
url: url, url: url,
sessionIdentifier: session, sessionIdentifier: session,
subscriptionKey: outcome.subscription, subscriptionKey: outcome.subscription,
@@ -464,7 +464,7 @@ extension PlexServer {
// Und hier aufhören. Dass das Belegen klappt, beantwortet die // Und hier aufhören. Dass das Belegen klappt, beantwortet die
// Frage nach dem Weg — die übrigen zwanzig Abfragen sind dann // Frage nach dem Weg — die übrigen zwanzig Abfragen sind dann
// nur noch Lärm im Bericht und Last für den Server. // nur noch Lärm im Bericht und Last für den Server.
return LiveTuneResult( return PlexTuneResult(
url: nil, url: nil,
sessionIdentifier: session, sessionIdentifier: session,
subscriptionKey: nil, subscriptionKey: nil,
@@ -488,7 +488,7 @@ extension PlexServer {
) )
trace.append(outcome.probe) trace.append(outcome.probe)
if let url = outcome.url { 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) trace.append(outcome.probe)
if let url = outcome.url { 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) trace.append(outcome.probe)
if let url = outcome.url { 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) trace.append(outcome.probe)
if let url = outcome.url { 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 // Kein Weg hat mit 200 geantwortet. Den Transcoder anhalten, sonst
// rechnet der Server für eine Wiedergabe weiter, die es nicht gibt. // rechnet der Server für eine Wiedergabe weiter, die es nicht gibt.
await stopTranscode(sessionIdentifier: session) 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. /// Schlägt eine Sendung beim Programmanbieter nach.
@@ -1442,7 +1442,7 @@ public struct LiveTVGuide: Sendable {
} }
/// Was beim Einschalten herauskam. /// Was beim Einschalten herauskam.
public struct LiveTuneResult: Sendable { public struct PlexTuneResult: Sendable {
public let url: URL? public let url: URL?
public let sessionIdentifier: String public let sessionIdentifier: String
/// Womit der Tuner wieder freigegeben wird. /// 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. /// ihm sprechen — diese drei Bereiche wandern als Nächstes.
public let server: PlexServer 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) { public init(server: PlexServer) {
self.server = server self.server = server
self.live = PlexLiveTVSession(server: server)
} }
public nonisolated var serverName: String { server.name } public nonisolated var serverName: String { server.name }