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 eingeschalteten Sender eines Empfängers, mit Namen und Logos. /// /// Zwei Quellen, die zusammengeführt werden müssen — das war der /// eigentliche Stolperstein: /// /// - Der Empfänger kennt nur die **Zuordnung**: welche Sender einer /// Senderliste eingeschaltet sind, als nackte Schlüssel. Bei einer /// Swisscom-Liste sind das 28 von 521. Namen stehen dort keine. /// - Die **Namen, Nummern und Logos** stehen beim Programmanbieter, unter /// der Senderliste. /// /// Zweimal habe ich vorher die Zuordnung für die Senderliste gehalten. /// Beim ersten Mal blieb sie leer, beim zweiten hätten alle Sender „—" /// geheißen und dieselbe Kennung getragen. public func channels(inDVR dvr: PlexDVR) async -> ChannelLookup { let enabled = dvr.enabledChannelKeys var identifier = dvr.epgIdentifier if identifier == nil { identifier = await electronicProgramGuideIdentifier() } guard let epgIdentifier = identifier else { return ChannelLookup(channels: [], source: .noLineup) } let advertised = await providerFeatures(for: epgIdentifier) var lastFailure: String? for path in Self.channelPaths( dvr: dvr, epgIdentifier: epgIdentifier, advertised: advertised ) { do { let container = try await client.send( PlexRequest(path: path, timeout: .seconds(30)), as: PlexMediaContainer.self ) guard !container.content.isEmpty else { continue } // Ohne Zuordnung alles zeigen: besser die vollständige Liste // als gar keine, wenn der Empfänger keine Auswahl meldet. let channels = enabled.isEmpty ? container.content : container.content.filter { $0.matches(anyOf: enabled) } if channels.isEmpty { return ChannelLookup( channels: [], source: .lineupWithoutMatches( available: container.content.count, enabled: enabled.count ) ) } return ChannelLookup( channels: channels, source: .lineup(available: container.content.count, path: path) ) } catch { lastFailure = "\(path): \(error)" } } return ChannelLookup(channels: [], source: .failed(lastFailure ?? "kein Weg lieferte Sender")) } /// Die Adressen, unter denen die Senderliste stehen könnte. /// /// Mehrere, weil dieser Teil der Plex-API keine Beschreibung hat und sich /// die Form zwischen Serverfassungen unterscheidet. Sie werden der Reihe /// nach probiert; welche gegriffen hat, steht in der Diagnose — damit aus /// dem Durchprobieren beim nächsten Mal Wissen wird. /// /// Entscheidend ist `pathIdentifier`: die Kennung der Senderliste kommt /// als vollständige Adresse mit Schema und Anhang. Roh eingesetzt ergibt /// jeder dieser Pfade 404. static func channelPaths( dvr: PlexDVR, epgIdentifier: String, advertised: [PlexProviderFeature] = [] ) -> [String] { var paths: [String] = [] // Zuerst, was der Server selbst angibt. Die Namen der Fähigkeiten sind // nicht dokumentiert, deshalb alles, was nach Sendern klingt. for feature in advertised where ["channels", "lineups", "livetv", "grid"].contains(feature.type) { if let key = feature.key { paths.append(key) } } // Danach die geratenen Formen. Sie haben bei einem Server mit // Swisscom-Liste alle 404 ergeben — sie stehen hier nur noch, weil // andere Serverfassungen sie kennen könnten. if let lineup = dvr.lineups.first { paths.append("/\(epgIdentifier)/lineups/\(lineup.pathIdentifier)/channels") } paths.append("/\(epgIdentifier)/channels") paths.append("/livetv/dvrs/\(dvr.key)/channels") // Doppelte entfernen, Reihenfolge behalten. var seen = Set() return paths.filter { seen.insert($0).inserted } } /// Was der Programmanbieter an Fähigkeiten angibt. public func providerFeatures(for identifier: String) async -> [PlexProviderFeature] { guard let container = try? await client.send( PlexRequest(path: "/media/providers"), as: PlexMediaContainer.self ) else { return [] } return container.content.first { $0.identifier == identifier }?.features ?? [] } /// 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, mit Sendern. /// /// 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) -> [PlexGuideEntry] { 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 // Sender und Sendung stammen aus demselben Eintrag — deshalb // passen die Kennungen zueinander. Getrennt geholt taten sie das // nicht, und das Programm landete in keiner Spalte. let channel = entry.channel guard let program = entry.program(channelID: channel?.id) else { return nil } return PlexGuideEntry(channel: channel, program: program) } } /// Senderliste und Programm in einem Zug. /// /// Der Weg über die Senderliste des Anbieters ist der erste Versuch; wo /// er greift, sind Logos und Nummern vollständiger. Wo er nicht greift — /// und bei einem Server mit Swisscom-Liste tut er das nicht, alle /// bekannten Adressen antworten mit 404 — tragen die Rastereinträge die /// Sender selbst. Die Weboberfläche von Plex zeichnet ihren Guide genau /// daraus. public func liveTVGuide( dvr: PlexDVR, hours: Int = 6 ) async -> LiveTVGuide { let lookup = await channels(inDVR: dvr) var identifier = dvr.epgIdentifier if identifier == nil { identifier = await electronicProgramGuideIdentifier() } guard let epgIdentifier = identifier else { return LiveTVGuide(channels: lookup.channels, programs: [:], source: lookup.description) } let now = Date() let entries = (try? await guide( providerIdentifier: epgIdentifier, from: now.addingTimeInterval(-3_600), to: now.addingTimeInterval(TimeInterval(hours) * 3_600) )) ?? [] let programs = Dictionary(grouping: entries.compactMap { entry in entry.program.channelID.map { (key: $0, program: entry.program) } }, by: \.key).mapValues { pairs in pairs.map(\.program).sorted { $0.startsAt < $1.startsAt } } if !lookup.channels.isEmpty { return LiveTVGuide(channels: lookup.channels, programs: programs, source: lookup.description) } // Aus dem Raster: jeden Sender einmal, in der Reihenfolge, in der das // Raster ihn nennt. Plex sortiert dort bereits nach Sendernummer. var seen = Set() let fromGrid = entries.compactMap(\.channel).filter { seen.insert($0.id).inserted } let enabled = dvr.enabledChannelKeys let filtered = enabled.isEmpty ? fromGrid : fromGrid.filter { $0.matches(anyOf: enabled) } // Passt keiner zur Zuordnung, ist die ganze Liste besser als keine: // die Zuordnung zu erzwingen hieße, bei abweichenden Schlüsseln alles // wegzuwerfen, was der Server gerade geliefert hat. let channels = filtered.isEmpty ? fromGrid : filtered return LiveTVGuide( channels: channels, programs: programs, source: channels.isEmpty ? "Raster: keine Sender (\(lookup.description))" : "Raster: \(channels.count) von \(fromGrid.count) Sendern" ) } /// 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 { var report = ["=== /livetv/dvrs ==="] report.append(await structure(ofPath: "/livetv/dvrs")) // Was der Server an Endpunkten angibt. Nach drei geratenen Adressen // mit 404 ist das die Stelle, an der die Antwort stehen muss. report.append("\n=== /media/providers ===") report.append(await structure(ofPath: "/media/providers")) // Und die Senderliste beim Anbieter — dort stehen die Namen. Ohne sie // im selben Abzug bliebe nach einem Fehlschlag wieder eine Runde // Raten übrig. if let dvr = try? await client.send( PlexRequest(path: "/livetv/dvrs", timeout: .seconds(10)), as: PlexMediaContainer.self ).content.first, let epgIdentifier = dvr.epgIdentifier { let advertised = await providerFeatures(for: epgIdentifier) for path in Self.channelPaths( dvr: dvr, epgIdentifier: epgIdentifier, advertised: advertised ) { report.append("\n=== \(path) ===") report.append(await structure(ofPath: path)) } // Und das Raster: wenn keine Senderliste antwortet, muss die // Antwort hier stehen — die Weboberfläche zeichnet ihren Guide // daraus. let now = Int(Date().timeIntervalSince1970) let gridPath = "/\(epgIdentifier)/grid" report.append("\n=== \(gridPath) ===") report.append(await structure( ofPath: gridPath, query: [ URLQueryItem(name: "type", value: "1"), URLQueryItem(name: "beginsAt>", value: String(now - 3_600)), URLQueryItem(name: "endsAt<", value: String(now + 3_600)), ] )) } else { report.append("\n=== Senderliste ===\nKeine Kennung des Programmanbieters am Empfänger") } return report.joined(separator: "\n") } private func structure(ofPath path: String, query: [URLQueryItem] = []) async -> String { let data: Data do { data = try await client.send(PlexRequest(path: path, query: query, timeout: .seconds(30))) } 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", "type", "key", "identifier", "channelTitle", "channelCallSign", "channelVcn"].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 Senderliste des Programmanbieters, über diesen Pfad. case lineup(available: Int, path: String) /// Die Senderliste kam an, aber keiner passte zur Zuordnung. case lineupWithoutMatches(available: Int, enabled: Int) /// Am Empfänger hängt keine Senderliste oder kein Programmanbieter. case noLineup 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 .lineup(let available, let path): "\(channels.count) von \(available) Sendern · \(path)" case .lineupWithoutMatches(let available, let enabled): "Senderliste hat \(available) Sender, \(enabled) sind zugeordnet — keiner passte zusammen" case .noLineup: "Am Empfänger hängt keine Senderliste" 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? /// Was dieser Anbieter kann, und unter welcher Adresse. /// /// Der Grund, warum hier überhaupt etwas steht: Plex sagt selbst, welche /// Endpunkte es anbietet. Drei Versuche, die Adresse der Senderliste zu /// erraten, endeten in 404 — sie hier abzulesen ist kein Ratespiel mehr. public let features: [PlexProviderFeature] public var id: String { identifier } private enum CodingKeys: String, CodingKey { case identifier, title, types case features = "Feature" } public init(from decoder: any Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) self.identifier = try values.decode(String.self, forKey: .identifier) self.title = try values.decodeIfPresent(String.self, forKey: .title) self.types = try values.decodeIfPresent(String.self, forKey: .types) self.features = try values.decodeIfPresent([PlexProviderFeature].self, forKey: .features) ?? [] } public init(identifier: String, title: String? = nil, types: String? = nil, features: [PlexProviderFeature] = []) { self.identifier = identifier self.title = title self.types = types self.features = features } /// Die Adresse für eine Fähigkeit, falls der Anbieter sie anbietet. public func key(forFeature type: String) -> String? { features.first { $0.type == type }?.key } } /// Eine Fähigkeit eines Anbieters, mit der Adresse dahinter. public struct PlexProviderFeature: Sendable, Equatable, Decodable, Identifiable { public let type: String public let key: String? public var id: String { key ?? type } public init(type: String, key: String? = nil) { self.type = type self.key = key } } /// Ein Rastereintrag: eine Sendung und der Sender, auf dem sie läuft. public struct PlexGuideEntry: Sendable { public let channel: PlexChannel? public let program: PlexProgram } /// Senderliste und Programm, so wie die Live-TV-Ansicht sie braucht. public struct LiveTVGuide: Sendable { public let channels: [PlexChannel] /// Sendungen je Sender, nach Anfangszeit sortiert. public let programs: [String: [PlexProgram]] /// Woher die Sender kamen — steht in der leeren Ansicht und in der Diagnose. public let source: String }