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
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,
sourceID: String,
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 {
let client: JellyfinClient
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 init(client: JellyfinClient, account: JellyfinAccount, serverName: String) {
@@ -17,7 +19,9 @@ public struct JellyfinBackend: MediaBackend {
public var isAdministrator: Bool { account.isAdministrator }
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
// und bei jedem Tipp abzulehnen, wäre die schlechtere Antwort.
if account.isAdministrator { capabilities.insert(.administration) }
@@ -35,6 +35,36 @@ public struct JellyfinItem: Decodable, Sendable {
public let mediaSources: [JellyfinMediaSource]?
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 let id: String?
public let name: String?
@@ -78,6 +108,30 @@ public struct JellyfinItem: Decodable, Sendable {
case parentBackdropItemID = "ParentBackdropItemId"
case seriesPrimaryImageTag = "SeriesPrimaryImageTag"
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"
}
}