Drei geratene Adressen für die Senderliste antworteten auf einem echten Server alle mit 404. Statt ein viertes Mal zu raten, liest die App jetzt zwei Dinge: was /media/providers an Endpunkten angibt, und das Programmraster selbst — die Weboberfläche von Plex zeichnet ihren Guide daraus, und jeder Rastereintrag trägt Sendername, Nummer, Logo und Einschaltweg mit sich. Damit kommen Sender und Programm aus einer Abfrage, und ihre Kennungen passen zueinander. Getrennt geholt taten sie das nicht: das Programm landete in keiner Zeile. Dazu die Ansicht, für die das alles da ist — ein Programmraster mit stehender Senderspalte, mitlaufender Zeitachse und einer Jetzt-Linie. Antippen öffnet die Sendung; läuft sie gerade, schaltet ein Knopf um. Die Geometrie steht in GuideLayout und ist geprüft: in einer View wäre sie unsichtbar falsch. Die Trennlinien gehören in die Zeile statt zwischen die Zeilen — Divider ist auf 3x-Schirmen keinen ganzen Punkt hoch, und der Bruchteil summiert sich, bis die angetippte Zeile zum Nachbarsender gehört. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
384 lines
15 KiB
Swift
384 lines
15 KiB
Swift
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<String> {
|
|
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
|
|
}
|
|
}
|
|
|
|
/// 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?
|
|
let parts: [PlexGridPart]
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case channelIdentifier, channelTitle, channelCallSign, channelThumb, channelVcn
|
|
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
|
|
}
|
|
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 ?? parts.first?.key ?? name
|
|
return PlexChannel(
|
|
id: identity,
|
|
title: name,
|
|
channelIdentifier: channelVcn ?? channelIdentifier,
|
|
thumb: channelThumb,
|
|
key: parts.first?.key
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Der Strom 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?
|
|
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<String>) -> 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: [PlexGridMedia]?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case ratingKey, 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? {
|
|
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
|
|
)
|
|
}
|
|
}
|