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 am Server, jeweils mit ihren Sendern. /// /// Plex liefert sie verschachtelt in der Antwort auf `/livetv/dvrs` mit. /// Der getrennte Endpunkt `/livetv/dvrs/{id}/channels` war die erste /// Vermutung und lieferte nichts — daran hing „Keine Sender" bei einem /// Server, auf dem über dreißig Sender eingerichtet sind. public let devices: [PlexTunerDevice] public var id: String { key } /// Alle Sender aller Empfänger dieses DVR. public var channels: [PlexChannel] { devices.flatMap(\.channels) } private enum CodingKeys: String, CodingKey { case key, uuid, language, lineupTitle, lineup case devices = "Device" } 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) ?? [] } public init( key: String, uuid: String? = nil, lineupTitle: String? = nil, lineup: String? = nil, language: String? = nil, devices: [PlexTunerDevice] = [] ) { self.key = key self.uuid = uuid self.lineupTitle = lineupTitle self.lineup = lineup self.language = language self.devices = devices } } /// 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? public let channels: [PlexChannel] public var id: String { uuid ?? key ?? title ?? "tuner" } /// Plex nennt die Senderliste je nach Fassung `Channel` oder /// `ChannelMapping`. Beide werden gelesen — eine falsche Annahme darüber /// ergibt eine leere Liste, und die sieht aus wie „kein Sender vorhanden". private enum CodingKeys: String, CodingKey { case key, uuid, title case channel = "Channel" 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) if values.contains(.channel) { self.channels = try values.decode([PlexChannel].self, forKey: .channel) } else if values.contains(.channelMapping) { self.channels = try values.decode([PlexChannel].self, forKey: .channelMapping) } else { self.channels = [] } } public init(key: String? = nil, uuid: String? = nil, title: String? = nil, channels: [PlexChannel] = []) { self.key = key self.uuid = uuid self.title = title self.channels = channels } } /// 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 } } 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 ) } }