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>
282 lines
11 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|