Der übersprungene Schritt. Ich habe die Sendung direkt an den Transcoder gegeben — ohne dass jemand den Tuner belegt hätte. Der Server fing an, merkte, dass er nichts zu senden hat, und brach ab: „die Wiedergabe wurde unterbrochen", ohne weitere Angabe. Jetzt zwei Schritte. Erst eine Live-Sitzung eröffnen, dann die Adresse dieser Sitzung umrechnen lassen — nicht die des Senders und nicht die der Sendung. mediaIndex und partIndex sind dabei -1: ein laufender Sender hat keine Fassungen zur Auswahl. Unter welchem Schlüssel sich die Sitzung eröffnen lässt, unterscheidet sich je nach Anbieter, deshalb werden die in Frage kommenden der Reihe nach probiert — der Raster-Schlüssel zuerst, weil er den Sender genauso bezeichnet wie die Liste, aus der er stammt. Dazu zwei Stellen, an denen Gründe bisher verschwanden: Vor dem Abspielen wird der Server gefragt, ob er den Strom überhaupt zulässt. Er antwortet im Klartext; der Player meldet nur, dass es nicht ging. Und wenn der Motor scheitert, steht sein Grund ab jetzt auf dem Bildschirm statt nur im Protokoll. „Die Wiedergabe wurde unterbrochen" allein sagt weder, ob der Server ablehnt, noch ob der Motor das Format nicht mag — und genau diese Unterscheidung hat diese Runde gekostet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
487 lines
19 KiB
Swift
487 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?
|
|
/// 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 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?
|
|
/// 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
|
|
)
|
|
}
|
|
}
|