import Foundation import MediaKit /// Ein Fernsehempfänger am Server. /// /// **Dieser ganze Bereich ist an keinem echten Server geprüft.** Die /// Live-TV-Endpunkte sind der am schlechtesten dokumentierte Teil der /// Plex-API — es gibt keine offizielle Beschreibung, nur das, was sich aus den /// Anfragen der offiziellen Apps ablesen lässt. Die Modelle sind deshalb /// durchgehend nachsichtig: fehlende Felder führen zu leeren Listen, nicht zu /// Fehlern. public struct PlexDVR: Sendable, Equatable, Decodable, Identifiable { public let key: String public let uuid: String? public let language: String? public let lineupTitle: String? public let lineup: String? /// Die Empfänger, jeweils mit ihrer Senderzuordnung. public let devices: [PlexTunerDevice] /// Die Kennung des Programmanbieters, etwa `tv.plex.providers.epg.cloud:2`. /// /// Steht direkt am Empfänger — verlässlicher, als sie aus der /// Anbieterliste zu suchen. public let epgIdentifier: String? /// Die Senderlisten, aus denen ausgewählt wurde. public let lineups: [PlexLineup] public var id: String { key } /// Welche Sender eingeschaltet sind, als Schlüssel. /// /// Die Zuordnung enthält **keine Namen** — nur, welche Sender der /// Senderliste aktiv sind. Die Namen stehen beim Programmanbieter. Das /// war der Grund, warum die Senderansicht leer blieb: die Zuordnung sah /// nach einer Senderliste aus, war aber nur ein Verweis darauf. public var enabledChannelKeys: Set { Set(devices.flatMap(\.channelMappings).filter(\.isEnabled).map(\.channelKey)) } /// Die Sendernummer am Empfänger für eine Kennung des Programmführers. /// /// Nachsichtig verglichen, weil die Kennung je nach Quelle mit oder ohne /// Vorsatz der Senderliste kommt. public func deviceChannel(forChannelKey key: String) -> String? { let tail = key.split(separator: "-").last.map(String.init) for mapping in devices.flatMap(\.channelMappings) { guard let identifier = mapping.deviceIdentifier else { continue } if mapping.channelKey == key { return identifier } if let tail, mapping.channelKey.hasSuffix(tail) { return identifier } } return nil } private enum CodingKeys: String, CodingKey { case key, uuid, language, lineupTitle, lineup, epgIdentifier case devices = "Device" case lineups = "Lineup" } public init(from decoder: any Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) // Als Zahl oder Zeichenkette — je nach Serverfassung. if let text = try? values.decode(String.self, forKey: .key) { self.key = text } else { self.key = String(try values.decode(Int.self, forKey: .key)) } self.uuid = try values.decodeIfPresent(String.self, forKey: .uuid) self.language = try values.decodeIfPresent(String.self, forKey: .language) self.lineupTitle = try values.decodeIfPresent(String.self, forKey: .lineupTitle) self.lineup = try values.decodeIfPresent(String.self, forKey: .lineup) self.devices = try values.decodeIfPresent([PlexTunerDevice].self, forKey: .devices) ?? [] self.epgIdentifier = try values.decodeIfPresent(String.self, forKey: .epgIdentifier) self.lineups = try values.decodeIfPresent([PlexLineup].self, forKey: .lineups) ?? [] } public init( key: String, uuid: String? = nil, lineupTitle: String? = nil, lineup: String? = nil, language: String? = nil, devices: [PlexTunerDevice] = [], epgIdentifier: String? = nil, lineups: [PlexLineup] = [] ) { self.key = key self.uuid = uuid self.lineupTitle = lineupTitle self.lineup = lineup self.language = language self.devices = devices self.epgIdentifier = epgIdentifier self.lineups = lineups } } /// Eine Senderliste des Anbieters — bei Kabel oder IPTV oft mehrere hundert. public struct PlexLineup: Sendable, Equatable, Decodable, Identifiable { /// Die Kennung, wie Plex sie liefert. /// /// Trotz des Namens keine Kennung, sondern eine vollständige Adresse: /// /// lineup://tv.plex.providers.epg.cloud/6228f7f2364e02fa5ec8e51c#Swisscom%20Blue%20TV /// /// Mit Schema, Doppelschrägstrich, Prozentzeichen und einem Anhang hinter /// der Raute. Roh in einen Pfad gesetzt ergibt das zwangsläufig 404 — /// genau daran scheiterte die Senderabfrage. public let id: String public let title: String? /// Der nackte Teil in der Mitte, als Pfadabschnitt verwendbar. /// /// Aus der Adresse oben wird `6228f7f2364e02fa5ec8e51c`. public var pathIdentifier: String { // Der Anhang hinter der Raute ist der Anzeigename und gehört nicht in // die Adresse. let withoutFragment = id.split(separator: "#", maxSplits: 1).first.map(String.init) ?? id return withoutFragment.split(separator: "/").last.map(String.init) ?? withoutFragment } public init(id: String, title: String? = nil) { self.id = id self.title = title } } /// Ein Eintrag der Senderzuordnung. /// /// Sagt, welcher Sender der Senderliste am Empfänger eingeschaltet ist — /// mehr nicht. Namen, Nummern und Logos stehen beim Programmanbieter. public struct PlexChannelMapping: Sendable, Equatable, Decodable { /// Die Kennung des Senders beim Programmführer. public let channelKey: String /// Die Sendernummer **am Empfänger** — etwa `"300"`. /// /// Der Stein von Rosetta dieser Schnittstelle: der Programmführer kennt /// den Sender als `6228f7f2…-5fc706…`, der Tuner als `300`. Ohne diese /// Übersetzung redet man mit dem Empfänger in einer Sprache, die er nicht /// spricht — und bekommt auf jede Anfrage 404. public let deviceIdentifier: String? public let lineupIdentifier: String? private let enabled: String? /// Plex schreibt hier `"1"` oder `"0"` als Zeichenkette, nicht als /// Wahrheitswert. public var isEnabled: Bool { enabled != "0" } public init( channelKey: String, deviceIdentifier: String? = nil, lineupIdentifier: String? = nil, enabled: String? = "1" ) { self.channelKey = channelKey self.deviceIdentifier = deviceIdentifier self.lineupIdentifier = lineupIdentifier self.enabled = enabled } } /// Ein Empfänger — eine Fernsehkarte oder ein Netzwerk-Tuner. public struct PlexTunerDevice: Sendable, Equatable, Decodable, Identifiable { public let key: String? public let uuid: String? public let title: String? /// Die Gerätekennung — unter ihr führt der Server die gefundenen Sender. public let deviceIdentifier: String? /// Welche Sender der Senderliste hier eingeschaltet sind. public let channelMappings: [PlexChannelMapping] public var id: String { uuid ?? key ?? title ?? "tuner" } private enum CodingKeys: String, CodingKey { case key, uuid, title, deviceId, deviceIdentifier case channelMapping = "ChannelMapping" } public init(from decoder: any Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) self.key = try? values.decodeIfPresent(String.self, forKey: .key) self.uuid = try values.decodeIfPresent(String.self, forKey: .uuid) self.title = try values.decodeIfPresent(String.self, forKey: .title) self.deviceIdentifier = (try? values.decodeIfPresent(String.self, forKey: .deviceId)) ?? (try? values.decodeIfPresent(String.self, forKey: .deviceIdentifier)) ?? nil self.channelMappings = try values .decodeIfPresent([PlexChannelMapping].self, forKey: .channelMapping) ?? [] } public init( key: String? = nil, uuid: String? = nil, title: String? = nil, deviceIdentifier: String? = nil, channelMappings: [PlexChannelMapping] = [] ) { self.key = key self.uuid = uuid self.title = title self.deviceIdentifier = deviceIdentifier self.channelMappings = channelMappings } } /// Der Medien-Block einer Sendung im Raster. /// /// Er trägt die Angaben zum Sender mit: das Raster ist bei Plex nicht nur /// eine Liste von Sendungen, sondern die einzige Antwort, in der Sendername, /// Nummer, Logo und Einschaltweg zusammen stehen. Drei geratene /// Senderlisten-Adressen ergaben 404 — diese hier liefert die /// Weboberfläche selbst aus. struct PlexGridMedia: Decodable { let channelIdentifier: String? let channelTitle: String? let channelCallSign: String? let channelThumb: String? let channelVcn: String? /// Die laufende Nummer des Senders am Server. let channelID: String? /// Der Schlüssel des Senders im Raster — ein Kandidat fürs Einschalten. let gridKey: String? /// Anfang und Ende der Sendung. /// /// Sie stehen hier und **nicht** am Eintrag darüber. Genau daran ist das /// Raster gescheitert: oben gesucht, nicht gefunden, jede Sendung /// verworfen — und übrig blieb „Kein Programm verfügbar" auf jedem Sender. let beginsAt: Int? let endsAt: Int? let parts: [PlexGridPart] private enum CodingKeys: String, CodingKey { case channelIdentifier, channelTitle, channelCallSign, channelThumb, channelVcn, channelID case gridKey, beginsAt, endsAt case part = "Part" } init(from decoder: any Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) self.channelIdentifier = try? values.decodeIfPresent(String.self, forKey: .channelIdentifier) self.channelTitle = try values.decodeIfPresent(String.self, forKey: .channelTitle) self.channelCallSign = try values.decodeIfPresent(String.self, forKey: .channelCallSign) self.channelThumb = try values.decodeIfPresent(String.self, forKey: .channelThumb) // Die Sendernummer kommt mal als Zahl, mal als Zeichenkette. if let text = try? values.decodeIfPresent(String.self, forKey: .channelVcn) { self.channelVcn = text } else if let number = try? values.decodeIfPresent(Int.self, forKey: .channelVcn) { self.channelVcn = String(number) } else { self.channelVcn = nil } if let text = try? values.decodeIfPresent(String.self, forKey: .channelID) { self.channelID = text } else if let number = try? values.decodeIfPresent(Int.self, forKey: .channelID) { self.channelID = String(number) } else { self.channelID = nil } self.gridKey = try? values.decodeIfPresent(String.self, forKey: .gridKey) self.beginsAt = try? values.decodeIfPresent(Int.self, forKey: .beginsAt) self.endsAt = try? values.decodeIfPresent(Int.self, forKey: .endsAt) self.parts = try values.decodeIfPresent([PlexGridPart].self, forKey: .part) ?? [] } /// Der Sender, sofern dieser Block einen benennt. /// /// Ohne Namen kein Sender: eine Zeile, die nur „—" hieße, ist für die /// Senderliste wertlos und stünde bei jedem Eintrag doppelt. var channel: PlexChannel? { guard let name = channelTitle ?? channelCallSign else { return nil } let identity = channelIdentifier ?? channelID ?? parts.first?.key ?? name return PlexChannel( id: identity, title: Self.withoutLeadingNumber(name, number: channelVcn), channelIdentifier: channelVcn, thumb: channelThumb, key: parts.first?.key ?? channelID, gridKey: gridKey ) } /// Entfernt die Sendernummer aus dem Namen. /// /// Plex schreibt sie dort mit hinein — „006 ZDFGERH (ZDF HD)". Da die /// Nummer in der Senderspalte ohnehin eigens steht, stünde sie sonst /// zweimal nebeneinander. static func withoutLeadingNumber(_ name: String, number: String?) -> String { guard let number, !number.isEmpty else { return name } let prefix = number + " " guard name.hasPrefix(prefix) else { return name } return String(name.dropFirst(prefix.count)) } } /// Der Stream hinter einer Rastersendung. struct PlexGridPart: Decodable { let key: String? } /// Ein Sender. public struct PlexChannel: Sendable, Equatable, Decodable, Identifiable { /// Die Nummer, unter der der Sender läuft — etwa `"3.1"`. public let channelIdentifier: String? public let title: String public let thumb: String? /// Der Schlüssel, über den sich der Sender einschalten lässt. public let key: String? /// Der Schlüssel des Senders im Programmraster. public let gridKey: String? public let id: String /// Ob dieser Sender einen Namen trägt. /// /// Eine Liste aus dreißig Strichen sieht aus wie eine Senderliste, ist /// aber keine — dieser Irrtum hat hier schon einmal eine falsche Quelle /// als die richtige durchgehen lassen. public var hasName: Bool { title != Self.placeholder } static let placeholder = "—" private enum CodingKeys: String, CodingKey { case channelIdentifier, title, thumb, key, gridKey case identifier = "id" case uuid, name, callSign, vcn, number, channelTitle, channelCallSign } public init(from decoder: any Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) // Vier Schreibweisen für denselben Namen, je nach Tunertyp und // Anbieter. Nur `title` zu lesen hieße, die halbe Senderliste als // namenlos zu verwerfen. self.title = [CodingKeys.title, .name, .channelTitle, .callSign, .channelCallSign] .lazy .compactMap { try? values.decodeIfPresent(String.self, forKey: $0) } .first { !$0.isEmpty } ?? Self.placeholder // Beim Tuner heißt die Nummer schlicht `key` — „300". Ohne diesen // Rückfall stünde die halbe Senderliste ohne Nummer da. let number = Self.text(in: values, forKeys: [.channelIdentifier, .vcn, .number]) let keyValue = Self.text(in: values, forKeys: [.key]) self.channelIdentifier = number ?? keyValue.flatMap { $0.allSatisfy { character in character.isNumber || character == "." } ? $0 : nil } self.thumb = try values.decodeIfPresent(String.self, forKey: .thumb) self.key = Self.text(in: values, forKeys: [.key]) self.gridKey = try? values.decodeIfPresent(String.self, forKey: .gridKey) // Plex liefert die Kennung mal als Zahl, mal als Zeichenkette, mal als // `uuid`. Ein einzelner erwarteter Typ hätte hier bei manchen // Serverfassungen die ganze Senderliste unlesbar gemacht. if let text = try? values.decodeIfPresent(String.self, forKey: .identifier) { self.id = text } else if let number = try? values.decodeIfPresent(Int.self, forKey: .identifier) { self.id = String(number) } else if let uuid = try values.decodeIfPresent(String.self, forKey: .uuid) { self.id = uuid } else { self.id = channelIdentifier ?? title } } /// Ob dieser Sender in der Zuordnung des Empfängers steht. /// /// Nachsichtig verglichen: die Zuordnung führt einen `channelKey`, und /// je nach Anbieter entspricht der der Kennung, dem Pfad oder der /// Sendernummer. Ein strenger Vergleich fände in zwei von drei Fällen /// nichts — und „keiner passte zusammen" sieht aus wie „keine Sender". public func matches(anyOf keys: Set) -> Bool { if keys.contains(id) { return true } if let channelIdentifier, keys.contains(channelIdentifier) { return true } if let key { if keys.contains(key) { return true } if let last = key.split(separator: "/").last, keys.contains(String(last)) { return true } } // Umgekehrt: manche Zuordnungen führen den vollen Pfad, der Sender // aber nur die Kennung. return keys.contains { $0.split(separator: "/").last.map(String.init) == id } } /// Ein Wert, der als Zahl oder als Zeichenkette kommen kann. private static func text( in values: KeyedDecodingContainer, forKeys keys: [CodingKeys] ) -> String? { for key in keys { if let text = try? values.decodeIfPresent(String.self, forKey: key), !text.isEmpty { return text } if let number = try? values.decodeIfPresent(Int.self, forKey: key) { return String(number) } } return nil } public init( id: String, title: String, channelIdentifier: String? = nil, thumb: String? = nil, key: String? = nil, gridKey: String? = nil ) { self.id = id self.title = title self.channelIdentifier = channelIdentifier self.thumb = thumb self.key = key self.gridKey = gridKey } } /// Eine Sendung im Programm. public struct PlexProgram: Sendable, Equatable, Identifiable { public let id: String public let title: String public let subtitle: String? public let summary: String? public let startsAt: Date public let endsAt: Date public let channelID: String? public let thumb: String? /// Die Adresse dieser Sendung beim Programmanbieter. public let metadataKey: String? public init( id: String, title: String, subtitle: String? = nil, summary: String? = nil, startsAt: Date, endsAt: Date, channelID: String? = nil, thumb: String? = nil, metadataKey: String? = nil ) { self.id = id self.title = title self.subtitle = subtitle self.summary = summary self.startsAt = startsAt self.endsAt = endsAt self.channelID = channelID self.thumb = thumb self.metadataKey = metadataKey } public var duration: Duration { .seconds(endsAt.timeIntervalSince(startsAt)) } public func isRunning(at moment: Date = Date()) -> Bool { moment >= startsAt && moment < endsAt } /// Wie weit die Sendung fortgeschritten ist, 0…1. public func progress(at moment: Date = Date()) -> Double { let total = endsAt.timeIntervalSince(startsAt) guard total > 0 else { return 0 } return min(max(moment.timeIntervalSince(startsAt) / total, 0), 1) } } /// Ein Eintrag aus dem Programmraster. /// /// Plex liefert das Raster als gewöhnliche Metadaten mit den Zeitangaben in /// Sekunden seit 1970. Die Übersetzung in `PlexProgram` passiert hier, damit /// die Oberfläche keine Zeitstempel rechnen muss. struct PlexGridEntry: Decodable { let ratingKey: String? /// Die Adresse der Sendung beim Programmanbieter — über sie läuft das /// Einschalten, denn Plex behandelt eine laufende Sendung wie einen Film. let key: String? let title: String? let summary: String? let grandparentTitle: String? let thumb: String? let beginsAt: Int? let endsAt: Int? /// Die Sendermedien; darin steht, zu welchem Sender die Sendung gehört. let media: [PlexGridMedia]? private enum CodingKeys: String, CodingKey { case ratingKey, key, title, summary, grandparentTitle, thumb, beginsAt, endsAt case media = "Media" } /// Der Sender, auf dem diese Sendung läuft. /// /// Hier steht der Sender vollständig: Name, Nummer, Logo, Einschaltweg. /// Das ist der Grund, warum das Raster die Senderliste ersetzen kann. var channel: PlexChannel? { media?.compactMap(\.channel).first } func program(channelID: String?) -> PlexProgram? { // Die Zeiten stehen beim Programmanbieter im Medien-Block, nicht am // Eintrag. Beide Stellen lesen, damit es auf beiden Serverfassungen // trägt. let beginsAt = self.beginsAt ?? media?.first?.beginsAt let endsAt = self.endsAt ?? media?.first?.endsAt guard let beginsAt, let endsAt, let title else { return nil } return PlexProgram( id: ratingKey ?? "\(channelID ?? "")-\(beginsAt)", title: title, subtitle: grandparentTitle, summary: summary, startsAt: Date(timeIntervalSince1970: TimeInterval(beginsAt)), endsAt: Date(timeIntervalSince1970: TimeInterval(endsAt)), channelID: channelID, thumb: thumb, metadataKey: key ) } }