Vier geratene Adressen, vier 404. Statt ein fünftes Mal zu raten, klopft die App jetzt in einem Durchgang und gleichzeitig ein Dutzend Adressen ab: was /media/providers an Endpunkten angibt, welche Unterverzeichnisse Programmanbieter und Empfänger in ihrer eigenen Antwort führen, die bekannten Formen anderer Serverfassungen, die Senderliste des Tuners selbst — und zuletzt das Programmraster. Dabei zwei Fehlerquellen mitbehoben, die jede einzeln wie „keine Sender" ausgesehen hätten: der Sendername steht je nach Tunertyp in title, name, channelTitle oder callSign, und eine Antwort mit dreißig namenlosen Einträgen sieht aus wie eine Senderliste, ist aber keine. Bleibt es leer, steht jetzt jede abgeklopfte Adresse mit ihrer Antwort in der Ansicht. Das beantwortet „liegt es am Server oder an der App" ohne eine weitere Runde Rückfragen. Dazu drei Sachen bei Serien: - Groß steht, worum es geht — bei einer Folge ihr Titel, bei einer Staffel der Name der Serie. „Staffel 10" als Überschrift sagte nicht, welche Serie gemeint ist. - Die Einordnung steht darunter: Serienname bei Folgen, Staffelnummer bei Staffeln. - Von der Folge führt ein Weg zur Staffel und zur Serie. Wer über „Weiterschauen" bei einer Folge landete, saß dort bisher fest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
423 lines
16 KiB
Swift
423 lines
16 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?
|
|
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
|
|
|
|
/// 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
|
|
|
|
self.channelIdentifier = Self.text(in: values, forKeys: [.channelIdentifier, .vcn, .number])
|
|
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?
|
|
|
|
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
|
|
)
|
|
}
|
|
}
|