Files
Flaro/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+LiveTV.swift
T
ScarriffleandClaude Opus 5 cb95e3503f Der Folgentitel stand zweimal da
EpisodeTitle statt Name: hier stand der Reihentitel ein zweites Mal, und
im Sendungsblatt hätte derselbe Satz zweimal untereinander gestanden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 06:27:27 +02:00

282 lines
11 KiB
Swift

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 ?? "—",
// `EpisodeTitle` und nicht `Name`: hier stand der Reihentitel ein
// zweites Mal, und im Blatt hätte derselbe Satz zweimal
// untereinander gestanden.
subtitle: item.episodeTitle,
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
}
}
}