import Foundation /// 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)) } 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 { public let channelKey: 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, lineupIdentifier: String? = nil, enabled: String? = "1") { self.channelKey = channelKey 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? /// 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 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.channelMappings = try values .decodeIfPresent([PlexChannelMapping].self, forKey: .channelMapping) ?? [] } public init( key: String? = nil, uuid: String? = nil, title: String? = nil, channelMappings: [PlexChannelMapping] = [] ) { self.key = key self.uuid = uuid self.title = title self.channelMappings = channelMappings } } /// 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? public let id: String private enum CodingKeys: String, CodingKey { case channelIdentifier, title, thumb, key case identifier = "id" case uuid } public init(from decoder: any Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) self.title = try values.decodeIfPresent(String.self, forKey: .title) ?? "—" self.channelIdentifier = try values.decodeIfPresent(String.self, forKey: .channelIdentifier) self.thumb = try values.decodeIfPresent(String.self, forKey: .thumb) self.key = try values.decodeIfPresent(String.self, forKey: .key) // 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 } } public init(id: String, title: String, channelIdentifier: String? = nil, thumb: String? = nil, key: String? = nil) { self.id = id self.title = title self.channelIdentifier = channelIdentifier self.thumb = thumb self.key = key } } /// 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? public init( id: String, title: String, subtitle: String? = nil, summary: String? = nil, startsAt: Date, endsAt: Date, channelID: String? = nil, thumb: 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 } 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? 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: [PlexMedia]? private enum CodingKeys: String, CodingKey { case ratingKey, title, summary, grandparentTitle, thumb, beginsAt, endsAt case media = "Media" } func program(channelID: String?) -> PlexProgram? { 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 ) } }