Files
Flaro/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift
T
ScarriffleandClaude Opus 5 5b2b86b66b MediaKit: die Grundschicht, auf der Plex und Jellyfin dasselbe meinen
Erster Schritt zu Jellyfin, und er besteht vor allem aus Wegnehmen.

`PlaybackDecider` nahm bisher Plex-Modelle entgegen und las daraus sechs
Felder. Das band die Entscheidung an einen bestimmten Server, obwohl an
der Frage „kann iOS das öffnen?" nichts serverspezifisch ist. Jetzt nimmt
er ein `MediaProfile` — Container, Codecs, Bitrate, ob die Datei
untersucht ist.

Wie ernst die Kopplung war, zeigte der Player selbst: für die
Offline-Wiedergabe baute er sich **erfundene** Plex-Werte zusammen — eine
Media mit Kennung 0, ein Part mit dem Dateinamen als Schlüssel,
zusammengesetzte Spurlisten —, nur damit die Signatur passte. Für eine
Datei, die längst auf dem Gerät lag und mit Plex nichts mehr zu tun
hatte. Dieser Hack ist weg.

Neu ist das Target `MediaKit`: Typen und Rechnungen, die für jeden Server
gelten, ohne eine einzige Abhängigkeit. Darin `MediaProfile` und die
`Duration`-Brücke, die bisher in PlexKit stand — „weil es die unterste
Schicht ist". Das stimmte, solange es nur einen Server gab.

Damit kennen Wiedergabe und Downloads Plex nicht mehr. Zwei der
Abhängigkeiten waren ohnehin tot; die dritte trug nur diese Brücke.

Die wichtigste Testtabelle des Projekts ist mitgewandert und dabei
kürzer geworden: dort steht jetzt, worauf es ankommt, statt vollständiger
Modelle mit Kennungen und Dateipfaden, von denen niemand etwas las.

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

516 lines
20 KiB
Swift

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<String> {
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<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,
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
)
}
}