import Foundation /// Was beim Live-TV herauskommen kann. public enum LiveTVAvailability: Sendable, Equatable { /// Es gibt einen Empfänger und Sender. case available(dvrs: [PlexDVR]) /// 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) } extension PlexServer { /// Prüft, ob der Server überhaupt Live-TV kann. public func liveTVAvailability() async -> LiveTVAvailability { do { let container = try await client.send( PlexRequest(path: "/livetv/dvrs", timeout: .seconds(10)), as: PlexMediaContainer.self ) return container.content.isEmpty ? .notConfigured : .available(dvrs: container.content) } catch PlexError.notFound { // Ältere Serverfassungen kennen den Endpunkt gar nicht. Das ist // dasselbe Ergebnis wie „kein Empfänger": es gibt nichts zu sehen. return .notConfigured } catch { return .failed(message: String(describing: error)) } } /// Die Sender eines Empfängers, samt Auskunft darüber, woher sie kamen. /// /// Zwei Quellen, in dieser Reihenfolge. Die erste ist die verlässliche: /// Plex liefert die Sender bereits verschachtelt in der Antwort auf /// `/livetv/dvrs` mit. Der getrennte Endpunkt war meine erste Vermutung /// und lieferte nichts — daran hing „Keine Sender" bei einem Server mit /// über dreißig eingerichteten Sendern. /// /// Die Auskunft über die Quelle steht in der Diagnose. Bei einem Bereich /// ohne offizielle Beschreibung ist „welcher Weg hat gegriffen" die /// Angabe, mit der sich der nächste Fehler finden lässt. public func channels(inDVR dvr: PlexDVR) async -> ChannelLookup { if !dvr.channels.isEmpty { return ChannelLookup(channels: dvr.channels, source: .embeddedInDVR) } do { let container = try await client.send( PlexRequest(path: "/livetv/dvrs/\(dvr.key)/channels"), as: PlexMediaContainer.self ) return ChannelLookup( channels: container.content, source: container.content.isEmpty ? .nothingFound : .separateEndpoint ) } catch { return ChannelLookup(channels: [], source: .failed(String(describing: error))) } } /// Die Kennung des Programmanbieters. /// /// Sie wechselt je nach Server und Einrichtung (etwa /// `tv.plex.providers.epg.cloud:2`) und steckt in der Anbieterliste. Fest /// verdrahtet wäre sie auf der Hälfte der Server falsch. public func electronicProgramGuideIdentifier() async -> String? { guard let container = try? await client.send( PlexRequest(path: "/media/providers"), as: PlexMediaContainer.self ) else { return nil } return container.content.first { $0.identifier.contains("epg") }?.identifier } /// Das Programmraster für einen Zeitraum. /// /// Bewusst über einen Zeitraum statt „alles": ein Tag über hundert Sender /// sind mehrere tausend Sendungen, und mehr als die nächsten Stunden /// braucht niemand beim Umschalten. public func guide( providerIdentifier: String, from start: Date, to end: Date ) async throws(PlexError) -> [PlexProgram] { let container = try await client.send( PlexRequest( path: "/\(providerIdentifier)/grid", query: [ // 1 steht bei Plex für „Sendung". URLQueryItem(name: "type", value: "1"), URLQueryItem(name: "beginsAt>", value: String(Int(start.timeIntervalSince1970))), URLQueryItem(name: "endsAt<", value: String(Int(end.timeIntervalSince1970))), ], timeout: .seconds(30) ), as: PlexMediaContainer.self ) return container.content.compactMap { entry in // Die Senderzuordnung steckt im Medien-Block. Fehlt sie, lässt // sich die Sendung keiner Spalte zuordnen — dann ist sie für ein // Raster wertlos. let channelID = entry.media?.first?.parts.first?.key return entry.program(channelID: channelID) } } /// Schaltet einen Sender ein. /// /// Live-TV kommt immer als umgerechneter Strom: der Tuner liefert einen /// Transportstrom, den kein iPhone direkt abspielt. Deshalb führt der Weg /// hier zwangsläufig über den Transcoder — anders als bei Dateien, wo /// DirectPlay der Normalfall ist. public func tune(channel: PlexChannel, maximumBitrate: Int?) async throws(PlexError) -> URL? { guard let key = channel.key else { return nil } let session = UUID().uuidString let container = try await client.send( PlexRequest( path: "/livetv/sessions/\(session)/\(key)", query: [URLQueryItem(name: "X-Plex-Session-Identifier", value: session)], timeout: .seconds(30) ), as: PlexMediaContainer.self ) guard let item = container.content.first else { throw .notFound } return transcodeURL(for: TranscodeRequest( ratingKey: item.ratingKey, maximumBitrate: maximumBitrate, isLocal: connection.local, sessionIdentifier: session )) } } extension PlexServer { /// Der Aufbau der Antwort auf `/livetv/dvrs`, zum Ansehen. /// /// Nicht zum Anzeigen im Alltag, sondern zum Nachsehen: die /// Live-TV-Endpunkte von Plex haben keine offizielle Beschreibung, und /// ohne die tatsächliche Antwort bleibt jede Vermutung darüber, wo die /// Sender stehen, eine Vermutung. Diese Ausgabe beendet das Raten. /// /// Werte werden nicht gezeigt, nur Struktur und Anzahl — das genügt, um /// den richtigen Schlüssel zu finden, und nichts davon ist vertraulich. public func liveTVStructure() async -> String { let data: Data do { data = try await client.send(PlexRequest(path: "/livetv/dvrs", timeout: .seconds(10))) } catch { return "Abfrage gescheitert: \(error)" } guard let object = try? JSONSerialization.jsonObject(with: data) else { return "Antwort ist kein JSON (\(data.count) Bytes)" } return Self.outline(object).joined(separator: "\n") } /// Beschreibt den Aufbau eines JSON-Werts, ohne die Werte selbst. /// /// Ausnahme sind Titel und Sendernummern: die stehen ohnehin im /// Programmführer und sind beim Suchen die nützlichste Angabe. private static func outline(_ value: Any, indent: Int = 0) -> [String] { let padding = String(repeating: " ", count: indent) switch value { case let dictionary as [String: Any]: return dictionary.keys.sorted().flatMap { key -> [String] in let child = dictionary[key]! if ["title", "channelIdentifier", "lineupTitle"].contains(key), let text = child as? String { return ["\(padding)\(key): \(text)"] } switch child { case is [Any], is [String: Any]: return ["\(padding)\(key):"] + outline(child, indent: indent + 1) default: return ["\(padding)\(key): \(type(of: child))"] } } case let array as [Any]: guard let first = array.first else { return ["\(padding)(leer)"] } // Nur das erste Element ausklappen — bei dreißig Sendern wäre die // vollständige Ausgabe unlesbar, und alle haben denselben Aufbau. return ["\(padding)[\(array.count) Einträge, erster:]"] + outline(first, indent: indent + 1) default: return ["\(padding)\(type(of: value))"] } } } /// Woher die Senderliste kam. public struct ChannelLookup: Sendable { public enum Source: Sendable, Equatable { /// Aus der Antwort auf `/livetv/dvrs` — der übliche Weg. case embeddedInDVR /// Über `/livetv/dvrs/{id}/channels`. case separateEndpoint /// Beide Wege lieferten nichts. case nothingFound case failed(String) } public let channels: [PlexChannel] public let source: Source public init(channels: [PlexChannel], source: Source) { self.channels = channels self.source = source } /// Kurzbeschreibung für die Diagnose. public var description: String { switch source { case .embeddedInDVR: "\(channels.count) Sender, aus der Empfängerliste" case .separateEndpoint: "\(channels.count) Sender, über /livetv/dvrs/…/channels" case .nothingFound: "Kein Weg lieferte Sender" case .failed(let reason): "Abfrage gescheitert: \(reason)" } } } /// Ein Anbieter am Server — Mediathek, Programmführer, Entdecken. public struct PlexMediaProvider: Sendable, Equatable, Decodable, Identifiable { public let identifier: String public let title: String? public let types: String? public var id: String { identifier } }