Files
Flaro/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift
T
ScarriffleandClaude Opus 5 bbe47b83be Live-TV: das Programm kam nie an, und die Einschalt-Adresse wurde weggeworfen
Drei Fehler, alle im eigenen Code.

Die Sendezeiten stehen beim Programmanbieter im Medien-Block, nicht am
Eintrag darüber. Ich habe sie oben gesucht, nicht gefunden und jede
Sendung verworfen — übrig blieb „Kein Programm verfügbar" auf jedem
Sender, und ohne Sendungen fiel auch die Senderliste auf die Tunerliste
zurück.

Die Senderspalte im Raster fehlte ganz. Die Zeitachse ist mehrere tausend
Punkte breit, und der HStack hat den Nachbarn dafür auf null gedrückt.
Jetzt hat die Spalte Vorrang, und die Achse liegt als Auflage über einer
durchsichtigen Fläche — eine Auflage zählt bei der Größenrechnung nicht
mit. Der Abstand der Achsenbeschriftung gehört in die Spaltenbreite
hinein statt daneben, sonst steht die ganze Achse acht Punkte neben dem
Programm darunter.

Und das Einschalten: die Adresse wurde berechnet und verworfen, dann
bekam der Player eine Senderkennung, zu der er einen Film in der
Mediathek suchte. Den gibt es nicht. Jetzt reicht die Anfrage die fertige
Adresse durch, der Player spielt sie ohne Umweg, und meldet weder
Fortschritt noch sucht er Metadaten. Scheitert es doch, steht ab jetzt
eine Meldung da statt gar nichts.

Geprüft wurde das Raster diesmal mit den Augen: ein Gerüst im Simulator
zeichnet es mit erfundenen Sendern. Dafür bekommt es die Bildadresse als
Abschluss übergeben, statt sie aus der Sitzung zu lesen.

Dazu: Downloads zeigen jetzt an, wenn sie auf WLAN warten. Die
Hintergrund-Sitzung wartet in dem Fall von sich aus — kein Fehler, nur
Stillstand, und das sah bisher aus wie ein hängender Download.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:34:48 +02:00

472 lines
19 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?
/// 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?
/// 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 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.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
)
}
/// 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 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
/// 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
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 = 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 }
}
/// Ein Wert, der als Zahl oder als Zeichenkette kommen kann.
private static func text(
in values: KeyedDecodingContainer<CodingKeys>,
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) {
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?
/// 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
)
}
}