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:
co-authored by
Claude Opus 5
parent
9df58ed5a6
commit
24765a5afc
@@ -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.
|
||||
public enum BackendCapability: String, Sendable, Hashable {
|
||||
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 ratings
|
||||
case libraryScan
|
||||
|
||||
Reference in New Issue
Block a user