Senderliste
Plex liefert die Sender verschachtelt in der Antwort auf /livetv/dvrs
mit — jeder Empfänger bringt seine eigenen unter `Device` → `Channel`.
Mein getrennter Endpunkt /livetv/dvrs/{id}/channels war eine Vermutung
und lieferte nichts. Bei einem Server mit über dreißig eingerichteten
Sendern stand deshalb „Keine Sender".
Die verschachtelte Liste hat jetzt Vorrang, der Endpunkt bleibt als
Rückfalllinie. `ChannelMapping` wird als ältere Schreibweise ebenfalls
gelesen, und der Empfänger-Schlüssel kommt je nach Serverfassung als
Zahl oder als Zeichenkette.
Bleibt die Liste leer, sagt die Ansicht jetzt welcher Weg was ergeben
hat. In einem Bereich ohne offizielle Beschreibung ist das die Angabe,
mit der sich der nächste Fehler finden lässt.
Schriftzüge
Plex liefert bei vielen Filmen ein `clearLogo` — den Schriftzug in
seiner eigenen Type, freigestellt. Der tritt jetzt im Aufmacher an die
Stelle des gesetzten Titels.
Dabei ein Fallstrick: /photo/:/transcode liefert JPEG, und JPEG kennt
keine Transparenz. Über den Bild-Transcoder geladen wäre aus dem
freigestellten Logo ein schwarzer Kasten geworden. Es wird deshalb
unverändert geladen. Fehlt der Schriftzug oder scheitert das Laden,
bleibt es beim Titel.
161 Tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
239 lines
8.6 KiB
Swift
239 lines
8.6 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 am Server, jeweils mit ihren Sendern.
|
|
///
|
|
/// Plex liefert sie verschachtelt in der Antwort auf `/livetv/dvrs` mit.
|
|
/// Der getrennte Endpunkt `/livetv/dvrs/{id}/channels` war die erste
|
|
/// Vermutung und lieferte nichts — daran hing „Keine Sender" bei einem
|
|
/// Server, auf dem über dreißig Sender eingerichtet sind.
|
|
public let devices: [PlexTunerDevice]
|
|
|
|
public var id: String { key }
|
|
|
|
/// Alle Sender aller Empfänger dieses DVR.
|
|
public var channels: [PlexChannel] {
|
|
devices.flatMap(\.channels)
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case key, uuid, language, lineupTitle, lineup
|
|
case devices = "Device"
|
|
}
|
|
|
|
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) ?? []
|
|
}
|
|
|
|
public init(
|
|
key: String,
|
|
uuid: String? = nil,
|
|
lineupTitle: String? = nil,
|
|
lineup: String? = nil,
|
|
language: String? = nil,
|
|
devices: [PlexTunerDevice] = []
|
|
) {
|
|
self.key = key
|
|
self.uuid = uuid
|
|
self.lineupTitle = lineupTitle
|
|
self.lineup = lineup
|
|
self.language = language
|
|
self.devices = devices
|
|
}
|
|
}
|
|
|
|
/// 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?
|
|
public let channels: [PlexChannel]
|
|
|
|
public var id: String { uuid ?? key ?? title ?? "tuner" }
|
|
|
|
/// Plex nennt die Senderliste je nach Fassung `Channel` oder
|
|
/// `ChannelMapping`. Beide werden gelesen — eine falsche Annahme darüber
|
|
/// ergibt eine leere Liste, und die sieht aus wie „kein Sender vorhanden".
|
|
private enum CodingKeys: String, CodingKey {
|
|
case key, uuid, title
|
|
case channel = "Channel"
|
|
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)
|
|
|
|
if values.contains(.channel) {
|
|
self.channels = try values.decode([PlexChannel].self, forKey: .channel)
|
|
} else if values.contains(.channelMapping) {
|
|
self.channels = try values.decode([PlexChannel].self, forKey: .channelMapping)
|
|
} else {
|
|
self.channels = []
|
|
}
|
|
}
|
|
|
|
public init(key: String? = nil, uuid: String? = nil, title: String? = nil, channels: [PlexChannel] = []) {
|
|
self.key = key
|
|
self.uuid = uuid
|
|
self.title = title
|
|
self.channels = channels
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
|
|
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: [PlexMedia]?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case ratingKey, title, summary, grandparentTitle, thumb, beginsAt, endsAt
|
|
case media = "Media"
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
}
|