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) }