Files
Flaro/Packages/FlixyKit/Sources/MediaKit/LiveTV.swift
T
ScarriffleandClaude Opus 5 eebe9280cb Die Oberfläche weiß nicht mehr, dass es Plex gibt
Keine Datei unter Views/LiveTV importiert noch PlexKit. Sender, Programm
und Einschalten laufen über den neutralen Vertrag, und supportsLiveTV
fragt endlich die Fähigkeit ab statt "server != nil" — womit die Zusage
in JellyfinBackend.capabilities zum ersten Mal stimmt.

Der Player holt die laufende Sendung jetzt für genau einen Sender nach.
Vorher ging dafür das Raster über alle Sender hinaus, weil der Plex-Weg
keinen schmaleren kannte; im neutralen Vertrag ist die Senderliste ein
Parameter.

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

375 lines
13 KiB
Swift

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
}
/// Wie weit sie fortgeschritten ist, 0…1.
///
/// Gerechnet statt gemeldet: beide Server nennen Anfang und Ende, keiner
/// nennt den Fortschritt. Beim Umschalten ist er die nützlichste Angabe
/// überhaupt — er sagt, ob man noch einsteigt oder nur noch den Abspann
/// sieht.
public func progress(at moment: Date = .now) -> Double {
let total = endsAt.timeIntervalSince(startsAt)
guard total > 0 else { return 0 }
return min(max(moment.timeIntervalSince(startsAt) / total, 0), 1)
}
}
// 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)
}