From 24765a5afc94eb5fe1dda1161f5b5489b8332f1b Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 14 Sep 2026 06:17:10 +0200 Subject: [PATCH] Live-TV bekommt einen neutralen Vertrag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../JellyfinKit/JellyfinBackend+LiveTV.swift | 280 ++++++++++++++ .../JellyfinBackend+Playback.swift | 5 +- .../JellyfinBackend+Recordings.swift | 192 ++++++++++ .../Sources/JellyfinKit/JellyfinBackend.swift | 8 +- .../Sources/JellyfinKit/JellyfinItem.swift | 54 +++ .../FlixyKit/Sources/MediaKit/LiveTV.swift | 362 ++++++++++++++++++ .../Sources/MediaKit/MediaBackend.swift | 7 + .../PlexKit/Endpoints/PlexServer+LiveTV.swift | 22 +- .../Sources/PlexKit/PlexBackend+LiveTV.swift | 229 +++++++++++ .../Sources/PlexKit/PlexBackend.swift | 8 + 10 files changed, 1153 insertions(+), 14 deletions(-) create mode 100644 Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+LiveTV.swift create mode 100644 Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Recordings.swift create mode 100644 Packages/FlixyKit/Sources/MediaKit/LiveTV.swift create mode 100644 Packages/FlixyKit/Sources/PlexKit/PlexBackend+LiveTV.swift diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+LiveTV.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+LiveTV.swift new file mode 100644 index 0000000..464b3aa --- /dev/null +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+LiveTV.swift @@ -0,0 +1,280 @@ +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 ?? "—", + // Der Folgentitel steht bei Jellyfin dort, wo bei einer Serie der + // Seriename stünde — die Sendung selbst trägt den Reihentitel. + subtitle: item.seriesName == nil ? nil : item.name, + 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 + } + } +} diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift index 9cfd297..2955cfd 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift @@ -110,7 +110,10 @@ extension JellyfinBackend { // MARK: - Hilfen - private func playbackInfo( + /// Nicht `private`: Live-TV in `JellyfinBackend+LiveTV` fragt denselben + /// Weg. Ein Sender ist für Jellyfin ein Eintrag wie jeder andere, und + /// eine zweite Fassung dieser Abfrage wäre eine Kopie, die auseinanderläuft. + func playbackInfo( itemID: String, sourceID: String, transcode: TranscodeSettings diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Recordings.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Recordings.swift new file mode 100644 index 0000000..da0fea6 --- /dev/null +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Recordings.swift @@ -0,0 +1,192 @@ +import Foundation +import MediaKit + +/// Jellyfin als `LiveTVRecordingBackend`. +/// +/// Plex erfüllt diesen Vertrag nicht — es kann in dieser App nichts aufnehmen. +/// Deshalb steht er getrennt: wer aufnehmen will, fragt mit `as?` danach und +/// bekommt eine ehrliche Antwort, statt auf Methoden zu treffen, die nur +/// werfen. +extension JellyfinBackend: LiveTVRecordingBackend { + // MARK: - Aufnahmen + + public func recordings() async throws(MediaBackendError) -> [LiveRecording] { + let page = try await client.send( + JellyfinRequest( + path: "/LiveTv/Recordings", + query: [ + URLQueryItem(name: "userId", value: account.userID), + URLQueryItem(name: "enableImages", value: "true"), + ] + ), + as: JellyfinItemPage.self + ) + + let now = Date() + return page.items.map { item in + LiveRecording( + id: item.id, + title: item.name ?? "—", + channelName: item.channelName, + summary: item.overview, + startsAt: item.startDate, + endsAt: item.endDate, + artworkPath: images.primary(itemID: item.id, tag: item.imageTags?["Primary"]), + // Abgeleitet statt erfragt: eine Aufnahme, deren Ende noch + // bevorsteht, läuft gerade. Das spart ein Feld, das sonst nur + // für diese eine Anzeige im Modell stünde. + isInProgress: (item.endDate.map { $0 > now }) ?? false + ) + } + } + + public func deleteRecording(id: String) async throws(MediaBackendError) { + _ = try await client.send(JellyfinRequest( + method: .delete, + path: "/LiveTv/Recordings/\(id)" + )) + } + + // MARK: - Vorgemerkt + + public func timers() async throws(MediaBackendError) -> [LiveTimer] { + let page = try await client.send( + JellyfinRequest(path: "/LiveTv/Timers"), + as: JellyfinTimerPage.self + ) + return page.items.map { timer in + LiveTimer( + id: timer.id ?? "", + programID: timer.programID, + channelID: timer.channelID, + channelName: timer.channelName, + title: timer.name ?? "—", + startsAt: timer.startDate, + endsAt: timer.endDate, + seriesTimerID: timer.seriesTimerID + ) + } + } + + public func scheduleTimer(for program: LiveProgram) async throws(MediaBackendError) { + try await schedule(program: program, path: "/LiveTv/Timers") + } + + public func cancelTimer(id: String) async throws(MediaBackendError) { + _ = try await client.send(JellyfinRequest( + method: .delete, + path: "/LiveTv/Timers/\(id)" + )) + } + + // MARK: - Ganze Reihen + + public func seriesTimers() async throws(MediaBackendError) -> [LiveSeriesTimer] { + let page = try await client.send( + JellyfinRequest(path: "/LiveTv/SeriesTimers"), + as: JellyfinSeriesTimerPage.self + ) + return page.items.map { timer in + LiveSeriesTimer( + id: timer.id ?? "", + name: timer.name ?? "—", + channelID: timer.channelID, + channelName: timer.channelName, + recordsNewOnly: timer.skipEpisodesInLibrary ?? false, + recordsAnyChannel: timer.recordAnyChannel ?? false + ) + } + } + + public func scheduleSeriesTimer(for program: LiveProgram) async throws(MediaBackendError) { + try await schedule(program: program, path: "/LiveTv/SeriesTimers") + } + + public func cancelSeriesTimer(id: String) async throws(MediaBackendError) { + _ = try await client.send(JellyfinRequest( + method: .delete, + path: "/LiveTv/SeriesTimers/\(id)" + )) + } + + // MARK: - Der gemeinsame Weg + + /// Merkt eine Sendung vor — einmalig oder als ganze Reihe. + /// + /// **Zwei Schritte, und der erste ist der wichtige.** Der Server liefert + /// unter `Timers/Defaults` eine für diese Sendung fertig ausgefüllte + /// Vorlage: Vor- und Nachlauf, Priorität, Dienstname, wie lange sie + /// aufgehoben wird. Was davon Pflicht ist, steht nirgends — die Beschreibung + /// nennt kein einziges Feld als erforderlich. + /// + /// Deshalb wird die Vorlage **roh weitergereicht**, so wie sie kam, und + /// nicht entschlüsselt und neu zusammengesetzt. Jedes Feld, das dabei + /// durch die Modellierung fiele, wäre eines, das der Server vermisst — + /// und man erführe es erst an einer Aufnahme, die nicht startet. + private func schedule(program: LiveProgram, path: String) async throws(MediaBackendError) { + let template = try await client.send(JellyfinRequest( + path: "/LiveTv/Timers/Defaults", + query: [URLQueryItem(name: "programId", value: program.id)] + )) + + _ = try await client.send(JellyfinRequest( + method: .post, + path: path, + body: template + )) + } +} + +// MARK: - Was Jellyfin über Vorgemerktes schickt + +/// Ein vorgemerkter Termin, wie Jellyfin ihn meldet. +/// +/// Kein `JellyfinItem`: Timer sind bei Jellyfin ausdrücklich **keine** +/// Einträge, sondern ein eigener Typ mit eigenen Feldern. +struct JellyfinTimer: Decodable, Sendable { + let id: String? + let programID: String? + let channelID: String? + let channelName: String? + let name: String? + let startDate: Date? + let endDate: Date? + let seriesTimerID: String? + + private enum CodingKeys: String, CodingKey { + case id = "Id", programID = "ProgramId", channelID = "ChannelId" + case channelName = "ChannelName", name = "Name" + case startDate = "StartDate", endDate = "EndDate" + case seriesTimerID = "SeriesTimerId" + } +} + +struct JellyfinTimerPage: Decodable, Sendable { + let items: [JellyfinTimer] + + private enum CodingKeys: String, CodingKey { case items = "Items" } +} + +/// Eine stehende Aufnahme für alles, was zu einer Reihe gehört. +struct JellyfinSeriesTimer: Decodable, Sendable { + let id: String? + let channelID: String? + let channelName: String? + let name: String? + /// Ob bereits Vorhandenes übersprungen wird — das, was man umgangssprachlich + /// „nur neue Folgen" nennt. + let skipEpisodesInLibrary: Bool? + let recordAnyChannel: Bool? + + private enum CodingKeys: String, CodingKey { + case id = "Id", channelID = "ChannelId", channelName = "ChannelName" + case name = "Name", skipEpisodesInLibrary = "SkipEpisodesInLibrary" + case recordAnyChannel = "RecordAnyChannel" + } +} + +struct JellyfinSeriesTimerPage: Decodable, Sendable { + let items: [JellyfinSeriesTimer] + + private enum CodingKeys: String, CodingKey { case items = "Items" } +} diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift index f86e611..0a057b6 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift @@ -5,7 +5,9 @@ import MediaKit public struct JellyfinBackend: MediaBackend { let client: JellyfinClient let account: JellyfinAccount - private let images = JellyfinImagePaths() + /// Nicht `private`: Live-TV und Aufnahmen liegen in eigenen Dateien und + /// brauchen dieselben Bildpfade. + let images = JellyfinImagePaths() public let serverName: String public init(client: JellyfinClient, account: JellyfinAccount, serverName: String) { @@ -17,7 +19,9 @@ public struct JellyfinBackend: MediaBackend { public var isAdministrator: Bool { account.isAdministrator } public nonisolated var capabilities: Set { - var capabilities: Set = [.favourites, .liveTV, .libraryScan, .transcoding] + var capabilities: Set = [ + .favourites, .liveTV, .liveTVRecordings, .libraryScan, .transcoding, + ] // Verwaltung nur, wenn das Konto sie auch darf. Den Bereich zu zeigen // und bei jedem Tipp abzulehnen, wäre die schlechtere Antwort. if account.isAdministrator { capabilities.insert(.administration) } diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem.swift index 80418b2..3075cfb 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem.swift @@ -35,6 +35,36 @@ public struct JellyfinItem: Decodable, Sendable { public let mediaSources: [JellyfinMediaSource]? public let collectionType: String? + // MARK: - Live-TV + // + // Sender **und** Sendungen kommen bei Jellyfin als derselbe Typ wie + // Filme und Folgen. Deshalb stehen ihre Felder hier und nicht in einem + // eigenen Modell: ein zweites, das zu neunzig Prozent dasselbe trägt, + // wäre eine Kopie mit Pflegeaufwand. + + /// Zu welchem Sender eine Sendung gehört. + public let channelID: String? + public let channelName: String? + /// Die Nummer, unter der ein Sender im Programm steht — „3" oder „11.1". + public let channelNumber: String? + /// Dasselbe, aber am Sender selbst statt an der Sendung. + public let number: String? + public let startDate: Date? + public let endDate: Date? + /// Was auf diesem Sender gerade läuft. + /// + /// Kommt nur, wenn die Senderliste mit `addCurrentProgram` geholt wurde — + /// dann aber gleich mit, und die Liste braucht keinen zweiten Aufruf. + public let currentProgram: JellyfinCurrentProgram? + /// Ob die Sendung zu einem laufenden Serien-Timer gehört. + public let seriesTimerID: String? + public let isMovie: Bool? + public let isSeries: Bool? + public let isNews: Bool? + public let isSports: Bool? + public let isKids: Bool? + public let isLive: Bool? + public struct Person: Decodable, Sendable { public let id: String? public let name: String? @@ -78,6 +108,30 @@ public struct JellyfinItem: Decodable, Sendable { case parentBackdropItemID = "ParentBackdropItemId" case seriesPrimaryImageTag = "SeriesPrimaryImageTag" case mediaSources = "MediaSources", collectionType = "CollectionType" + case channelID = "ChannelId", channelName = "ChannelName" + case channelNumber = "ChannelNumber", number = "Number" + case startDate = "StartDate", endDate = "EndDate" + case currentProgram = "CurrentProgram", seriesTimerID = "SeriesTimerId" + case isMovie = "IsMovie", isSeries = "IsSeries", isNews = "IsNews" + case isSports = "IsSports", isKids = "IsKids", isLive = "IsLive" + } +} + +/// Was auf einem Sender gerade läuft. +/// +/// Ein eigenes, schmales Modell statt eines verschachtelten `JellyfinItem`: +/// ein Struct darf sich nicht selbst enthalten — seine Größe wäre unendlich. +/// Gebraucht werden hier ohnehin nur fünf Felder. +public struct JellyfinCurrentProgram: Decodable, Sendable { + public let id: String + public let name: String? + public let overview: String? + public let startDate: Date? + public let endDate: Date? + + private enum CodingKeys: String, CodingKey { + case id = "Id", name = "Name", overview = "Overview" + case startDate = "StartDate", endDate = "EndDate" } } diff --git a/Packages/FlixyKit/Sources/MediaKit/LiveTV.swift b/Packages/FlixyKit/Sources/MediaKit/LiveTV.swift new file mode 100644 index 0000000..751a764 --- /dev/null +++ b/Packages/FlixyKit/Sources/MediaKit/LiveTV.swift @@ -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) +} diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift index 294ccf0..febf5ff 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift @@ -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 diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift index 9ce8ca0..3adc640 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift @@ -2,7 +2,7 @@ import Foundation import MediaKit /// Was beim Live-TV herauskommen kann. -public enum LiveTVAvailability: Sendable, Equatable { +public enum PlexLiveTVAvailability: Sendable, Equatable { /// Es gibt einen Empfänger und Sender. case available(dvrs: [PlexDVR]) /// Der Server läuft, hat aber keinen Empfänger eingerichtet. @@ -16,7 +16,7 @@ public enum LiveTVAvailability: Sendable, Equatable { extension PlexServer { /// Prüft, ob der Server überhaupt Live-TV kann. - public func liveTVAvailability() async -> LiveTVAvailability { + public func liveTVAvailability() async -> PlexLiveTVAvailability { do { let container = try await client.send( PlexRequest(path: "/livetv/dvrs", timeout: .seconds(10)), @@ -405,7 +405,7 @@ extension PlexServer { program: PlexProgram? = nil, dvr: PlexDVR? = nil, maximumBitrate: Int? - ) async -> LiveTuneResult { + ) async -> PlexTuneResult { let session = UUID().uuidString var attempts = 0 @@ -448,7 +448,7 @@ extension PlexServer { ) trace.append(contentsOf: outcome.probes) if let url = outcome.url { - return LiveTuneResult( + return PlexTuneResult( url: url, sessionIdentifier: session, subscriptionKey: outcome.subscription, @@ -464,7 +464,7 @@ extension PlexServer { // Und hier aufhören. Dass das Belegen klappt, beantwortet die // Frage nach dem Weg — die übrigen zwanzig Abfragen sind dann // nur noch Lärm im Bericht und Last für den Server. - return LiveTuneResult( + return PlexTuneResult( url: nil, sessionIdentifier: session, subscriptionKey: nil, @@ -488,7 +488,7 @@ extension PlexServer { ) trace.append(outcome.probe) if let url = outcome.url { - return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace) + return PlexTuneResult(url: url, sessionIdentifier: session, trace: trace) } } @@ -515,7 +515,7 @@ extension PlexServer { ) trace.append(outcome.probe) if let url = outcome.url { - return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace) + return PlexTuneResult(url: url, sessionIdentifier: session, trace: trace) } } } @@ -540,7 +540,7 @@ extension PlexServer { ) trace.append(outcome.probe) if let url = outcome.url { - return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace) + return PlexTuneResult(url: url, sessionIdentifier: session, trace: trace) } } } @@ -601,7 +601,7 @@ extension PlexServer { ) trace.append(outcome.probe) if let url = outcome.url { - return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace) + return PlexTuneResult(url: url, sessionIdentifier: session, trace: trace) } } } @@ -609,7 +609,7 @@ extension PlexServer { // Kein Weg hat mit 200 geantwortet. Den Transcoder anhalten, sonst // rechnet der Server für eine Wiedergabe weiter, die es nicht gibt. await stopTranscode(sessionIdentifier: session) - return LiveTuneResult(url: nil, sessionIdentifier: session, trace: trace) + return PlexTuneResult(url: nil, sessionIdentifier: session, trace: trace) } /// Schlägt eine Sendung beim Programmanbieter nach. @@ -1442,7 +1442,7 @@ public struct LiveTVGuide: Sendable { } /// Was beim Einschalten herauskam. -public struct LiveTuneResult: Sendable { +public struct PlexTuneResult: Sendable { public let url: URL? public let sessionIdentifier: String /// Womit der Tuner wieder freigegeben wird. diff --git a/Packages/FlixyKit/Sources/PlexKit/PlexBackend+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/PlexBackend+LiveTV.swift new file mode 100644 index 0000000..8f4c259 --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/PlexBackend+LiveTV.swift @@ -0,0 +1,229 @@ +import Foundation +import MediaKit + +/// Plex' Live-TV mit Gedächtnis. +/// +/// Ein Actor und kein Werttyp, und das hat einen einzigen Grund: `PlexBackend` +/// ist ein `struct` und kann sich zwischen zwei Aufrufen nichts merken. Zum +/// Einschalten braucht Plex aber den **echten** `PlexChannel` und den +/// **echten** `PlexDVR` — an letzterem hängt die Sendernummer am Empfänger, +/// und an der hängt, ob überhaupt ein Bild kommt. +/// +/// Ohne Gedächtnis müsste das Einschalten die Senderliste neu laden. Das ist +/// bei Plex kein Abruf, sondern ein Suchlauf über vier Strategien — der +/// mühsamste Teil dieser ganzen Ecke. Er soll einmal laufen, nicht bei jedem +/// Antippen. +/// +/// Der Actor liegt als `let` in `PlexBackend`. Ein Actor ist ein Referenztyp; +/// Kopien des Struct teilen sich denselben und damit dasselbe Gedächtnis. +public actor PlexLiveTVSession { + private let server: PlexServer + + /// Der Empfänger, über den eingeschaltet wird. + private var dvr: PlexDVR? + /// Die zuletzt geladenen Sender nach Kennung — der Weg vom neutralen + /// `LiveChannel` zurück zum `PlexChannel`, den `tune` braucht. + private var known: [String: PlexChannel] = [:] + /// Das zuletzt geladene Programm. + /// + /// Plex liefert Sender und Programm in **einem** Aufruf. Es hier + /// festzuhalten spart den zweiten, den der neutrale Vertrag sonst nach + /// sich zöge: erst `liveChannels()`, dann `livePrograms()`. + private var guide: [String: [PlexProgram]] = [:] + + public init(server: PlexServer) { + self.server = server + } + + // MARK: - Verfügbarkeit + + func availability() async -> LiveTVAvailability { + switch await server.liveTVAvailability() { + case .available(let dvrs): + // Den ersten merken: alles Weitere — Sender, Programm, + // Einschalten — hängt an ihm. + dvr = dvrs.first + return dvrs.isEmpty ? .notConfigured : .available + case .notConfigured: + return .notConfigured + case .failed(let message): + return .failed(message: message) + } + } + + // MARK: - Sender und Programm + + func channels() async -> LiveChannelListing { + guard let dvr = await resolvedDVR() else { + return LiveChannelListing(channels: [], source: nil, probes: []) + } + + let result = await server.liveTVGuide(dvr: dvr, hours: 6) + + known = Dictionary(result.channels.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + guide = result.programs + + return LiveChannelListing( + channels: result.channels.map { $0.asLiveChannel(running: running(on: $0)) }, + source: result.source, + probes: result.trace.map { LiveChannelProbe(path: $0.path, result: $0.result) } + ) + } + + func programs(channelIDs: [String], from: Date, to: Date) async -> [String: [LiveProgram]] { + // Ohne gemerktes Programm erst eines holen. Das passiert nur, wenn + // jemand nach dem Programm fragt, ohne vorher die Sender geladen zu + // haben — in der Oberfläche kommt das nicht vor, über den Vertrag + // könnte es. + if guide.isEmpty, let dvr = await resolvedDVR() { + let result = await server.liveTVGuide(dvr: dvr, hours: 6) + known = Dictionary(result.channels.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + guide = result.programs + } + + let wanted = Set(channelIDs) + var out: [String: [LiveProgram]] = [:] + for (channelID, programs) in guide where wanted.isEmpty || wanted.contains(channelID) { + let inRange = programs.filter { $0.endsAt > from && $0.startsAt < to } + if !inRange.isEmpty { + out[channelID] = inRange.map { $0.asLiveProgram(channelID: channelID) } + } + } + return out + } + + // MARK: - Einschalten + + func tune(channel: LiveChannel, program: LiveProgram?, maximumBitrate: Int?) async -> LiveTuneResult { + // Zurück zum echten Sender. Ohne ihn fehlen Rasterschlüssel und + // Sendernummer, und ohne die schaltet nichts ein. + guard let plexChannel = known[channel.id] else { + return LiveTuneResult( + url: nil, + sessionID: UUID().uuidString, + trace: [LiveChannelProbe( + path: channel.id, + result: "Sender nicht im geladenen Bestand — erst die Senderliste laden" + )] + ) + } + + let plexProgram = program.flatMap { wanted in + guide[channel.id]?.first { $0.id == wanted.id } + } + + let result = await server.tune( + channel: plexChannel, + program: plexProgram, + dvr: await resolvedDVR(), + maximumBitrate: maximumBitrate + ) + + return LiveTuneResult( + url: result.url, + sessionID: result.sessionIdentifier, + subscriptionKey: result.subscriptionKey, + trace: result.trace.map { LiveChannelProbe(path: $0.path, result: $0.result) } + ) + } + + func endSession(subscriptionKey: String) async { + await server.endLiveSession(subscriptionKey: subscriptionKey) + } + + // MARK: - Hilfen + + /// Der Empfänger — gemerkt, sonst erfragt. + private func resolvedDVR() async -> PlexDVR? { + if let dvr { return dvr } + _ = await availability() + return dvr + } + + /// Was auf diesem Sender gerade läuft. + private func running(on channel: PlexChannel) -> LiveProgram? { + let now = Date() + guard let match = guide[channel.id]?.first(where: { $0.startsAt <= now && $0.endsAt > now }) + else { return nil } + return match.asLiveProgram(channelID: channel.id) + } +} + +// MARK: - Übersetzung + +extension PlexChannel { + /// Der Sender in neutraler Form. + /// + /// Ohne `tuning`: bei Plex merkt sich die Sitzung den echten Sender, und + /// ein Griff, der nur wiederholt, was ohnehin dort liegt, wäre eine + /// zweite Wahrheit. + func asLiveChannel(running: LiveProgram?) -> LiveChannel { + LiveChannel( + id: id, + number: channelIdentifier, + title: title, + artworkPath: thumb, + currentProgram: running + ) + } +} + +extension PlexProgram { + /// Die Sendung in neutraler Form. + /// + /// Die Senderkennung kommt von außen: `PlexProgram` trägt sie optional, + /// und im Programmraster steht sie ohnehin schon als Schlüssel darüber. + func asLiveProgram(channelID: String) -> LiveProgram { + LiveProgram( + id: id, + channelID: self.channelID ?? channelID, + title: title, + subtitle: subtitle, + summary: summary, + startsAt: startsAt, + endsAt: endsAt, + artworkPath: thumb + ) + } +} + +// MARK: - Der Vertrag + +/// Plex als `LiveTVBackend`. +/// +/// Reine Weiterleitung an die Sitzung. Der Suchlauf, der die Senderliste +/// findet, und alles, was das Einschalten kostet, bleiben unangetastet in +/// `PlexServer+LiveTV` — hier wird nur übersetzt. +/// +/// **Kein** `LiveTVRecordingBackend`: Plex kann in dieser App nichts +/// aufnehmen, und das mit werfenden Methoden zu behaupten wäre schlechter, +/// als es gar nicht erst zu versprechen. +extension PlexBackend: LiveTVBackend { + public func liveTVAvailability() async -> LiveTVAvailability { + await live.availability() + } + + public func liveChannels() async -> LiveChannelListing { + await live.channels() + } + + public func livePrograms( + channelIDs: [String], + from: Date, + to: Date + ) async throws(MediaBackendError) -> [String: [LiveProgram]] { + await live.programs(channelIDs: channelIDs, from: from, to: to) + } + + public func tune( + channel: LiveChannel, + program: LiveProgram?, + maximumBitrate: Int? + ) async -> LiveTuneResult { + await live.tune(channel: channel, program: program, maximumBitrate: maximumBitrate) + } + + public func endLiveSession(subscriptionKey: String) async { + await live.endSession(subscriptionKey: subscriptionKey) + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift b/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift index 86620ff..094706d 100644 --- a/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift +++ b/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift @@ -14,8 +14,16 @@ public struct PlexBackend: MediaBackend { /// ihm sprechen — diese drei Bereiche wandern als Nächstes. public let server: PlexServer + /// Live-TV mit Gedächtnis. + /// + /// Ein Actor, also ein Referenztyp: Kopien dieses Struct teilen sich + /// denselben und damit die geladene Senderliste. Warum das nötig ist, + /// steht bei `PlexLiveTVSession`. + let live: PlexLiveTVSession + public init(server: PlexServer) { self.server = server + self.live = PlexLiveTVSession(server: server) } public nonisolated var serverName: String { server.name }