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 }