Live-TV-Sender gefunden, Film-Schriftzüge ergänzt

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>
This commit is contained in:
Guido
2026-09-12 09:28:21 +02:00
co-authored by Claude Opus 5
parent 7cfc6e0667
commit ed84b5bf22
11 changed files with 381 additions and 17 deletions
@@ -10,6 +10,10 @@ public struct HeroBanner<Actions: View>: View {
private let metadata: String?
private let summary: String?
private let artworkURL: URL?
/// Der freigestellte Schriftzug des Films. Steht er zur Verfügung, tritt
/// er an die Stelle des gesetzten Titels — „Harry Potter" in seiner
/// eigenen Schrift ist wiedererkennbar, eine Systemschrift nicht.
private let logoURL: URL?
private let aspectRatio: CGFloat
private let actions: Actions
@@ -18,6 +22,7 @@ public struct HeroBanner<Actions: View>: View {
metadata: String? = nil,
summary: String? = nil,
artworkURL: URL?,
logoURL: URL? = nil,
// 16:9, weil Plex genau das als `art` liefert. Vorher stand hier
// 3:4 — mit `scaledToFill` wurde das Bild dadurch links und rechts
// kräftig beschnitten, und vom Aufmacher war nur ein Ausschnitt zu
@@ -29,10 +34,49 @@ public struct HeroBanner<Actions: View>: View {
self.metadata = metadata
self.summary = summary
self.artworkURL = artworkURL
self.logoURL = logoURL
self.aspectRatio = aspectRatio
self.actions = actions()
}
/// Schriftzug, wenn vorhanden — sonst gesetzter Titel.
///
/// Der Schriftzug wird ohne Bild-Transcoder geladen; er ist ein
/// freigestelltes PNG, und der Transcoder liefert JPEG. Aus dem
/// freigestellten Logo würde sonst ein schwarzer Kasten.
@ViewBuilder
private var titleView: some View {
if let logoURL {
AsyncImage(url: logoURL) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFit()
// Nach oben begrenzt: manche Schriftzüge sind fast
// quadratisch, andere sehr breit. Ohne Deckel schöbe
// ein hohes Logo die Knöpfe aus dem Bild.
.frame(maxWidth: 260, maxHeight: 76, alignment: .leading)
default:
// Solange geladen wird — und wenn es scheitert — bleibt es
// beim gesetzten Titel. Ein leerer Platz wäre schlimmer
// als eine Systemschrift.
plainTitle
}
}
.accessibilityLabel(Text(title))
} else {
plainTitle
}
}
private var plainTitle: some View {
Text(title)
.font(.flixyHero)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(2)
}
public var body: some View {
FlixyArtwork(url: artworkURL, aspectRatio: aspectRatio)
.overlay(alignment: .bottom) {
@@ -53,10 +97,7 @@ public struct HeroBanner<Actions: View>: View {
}
.overlay(alignment: .bottomLeading) {
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
Text(title)
.font(.flixyHero)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(2)
titleView
if let metadata {
Text(metadata)
@@ -31,13 +31,34 @@ extension PlexServer {
}
}
/// Die Sender eines Empfängers.
public func channels(inDVR dvr: PlexDVR) async throws(PlexError) -> [PlexChannel] {
let container = try await client.send(
PlexRequest(path: "/livetv/dvrs/\(dvr.key)/channels"),
as: PlexMediaContainer<PlexChannel>.self
)
return container.content
/// Die Sender eines Empfängers, samt Auskunft darüber, woher sie kamen.
///
/// Zwei Quellen, in dieser Reihenfolge. Die erste ist die verlässliche:
/// Plex liefert die Sender bereits verschachtelt in der Antwort auf
/// `/livetv/dvrs` mit. Der getrennte Endpunkt war meine erste Vermutung
/// und lieferte nichts — daran hing „Keine Sender" bei einem Server mit
/// über dreißig eingerichteten Sendern.
///
/// Die Auskunft über die Quelle steht in der Diagnose. Bei einem Bereich
/// ohne offizielle Beschreibung ist „welcher Weg hat gegriffen" die
/// Angabe, mit der sich der nächste Fehler finden lässt.
public func channels(inDVR dvr: PlexDVR) async -> ChannelLookup {
if !dvr.channels.isEmpty {
return ChannelLookup(channels: dvr.channels, source: .embeddedInDVR)
}
do {
let container = try await client.send(
PlexRequest(path: "/livetv/dvrs/\(dvr.key)/channels"),
as: PlexMediaContainer<PlexChannel>.self
)
return ChannelLookup(
channels: container.content,
source: container.content.isEmpty ? .nothingFound : .separateEndpoint
)
} catch {
return ChannelLookup(channels: [], source: .failed(String(describing: error)))
}
}
/// Die Kennung des Programmanbieters.
@@ -116,6 +137,37 @@ extension PlexServer {
}
}
/// Woher die Senderliste kam.
public struct ChannelLookup: Sendable {
public enum Source: Sendable, Equatable {
/// Aus der Antwort auf `/livetv/dvrs` — der übliche Weg.
case embeddedInDVR
/// Über `/livetv/dvrs/{id}/channels`.
case separateEndpoint
/// Beide Wege lieferten nichts.
case nothingFound
case failed(String)
}
public let channels: [PlexChannel]
public let source: Source
public init(channels: [PlexChannel], source: Source) {
self.channels = channels
self.source = source
}
/// Kurzbeschreibung für die Diagnose.
public var description: String {
switch source {
case .embeddedInDVR: "\(channels.count) Sender, aus der Empfängerliste"
case .separateEndpoint: "\(channels.count) Sender, über /livetv/dvrs/…/channels"
case .nothingFound: "Kein Weg lieferte Sender"
case .failed(let reason): "Abfrage gescheitert: \(reason)"
}
}
}
/// Ein Anbieter am Server — Mediathek, Programmführer, Entdecken.
public struct PlexMediaProvider: Sendable, Equatable, Decodable, Identifiable {
public let identifier: String
@@ -156,6 +156,25 @@ public actor PlexServer {
return client.artworkURL(path: path, width: width, height: height, token: token)
}
/// Ein Bild unverändert, ohne den Umweg über den Bild-Transcoder.
///
/// Für freigestellte Schriftzüge zwingend: `/photo/:/transcode` liefert
/// JPEG, und JPEG kennt keine Transparenz. Ein Logo käme als weißer
/// Schriftzug auf schwarzem Kasten heraus statt freigestellt auf dem
/// Hintergrundbild.
public nonisolated func rawImageURL(path: String?) -> URL? {
guard let path, !path.isEmpty,
let baseURL = connection.url,
var components = URLComponents(
url: baseURL.appending(path: path),
resolvingAgainstBaseURL: false
)
else { return nil }
components.queryItems = [URLQueryItem(name: "X-Plex-Token", value: token)]
return components.url
}
/// Die Adresse für DirectPlay: die Datei, unverändert.
public nonisolated func directPlayURL(part: PlexPart) -> URL? {
streamURL(partKey: part.key, download: false)
@@ -69,6 +69,8 @@ public struct PlexItem: Sendable, Equatable, Decodable, Identifiable {
/// Die Besetzung, mit Porträt und Rollennamen.
public let roles: [PlexTag]
public let markers: [PlexMarker]
/// Plakat, Hintergrund und — wenn vorhanden — der Schriftzug des Films.
public let images: [PlexImage]
private enum CodingKeys: String, CodingKey {
case ratingKey, key, guid, type, title, titleSort, summary, year, duration
@@ -84,6 +86,7 @@ public struct PlexItem: Sendable, Equatable, Decodable, Identifiable {
case writers = "Writer"
case roles = "Role"
case markers = "Marker"
case images = "Image"
}
/// Von Hand geschrieben statt erzeugt, weil der erzeugte Decoder einen
@@ -134,6 +137,7 @@ public struct PlexItem: Sendable, Equatable, Decodable, Identifiable {
self.writers = try values.decodeIfPresent([PlexTag].self, forKey: .writers) ?? []
self.roles = try values.decodeIfPresent([PlexTag].self, forKey: .roles) ?? []
self.markers = try values.decodeIfPresent([PlexMarker].self, forKey: .markers) ?? []
self.images = try values.decodeIfPresent([PlexImage].self, forKey: .images) ?? []
}
/// `ratingKey` ist serverweit eindeutig und eignet sich deshalb direkt
@@ -162,6 +166,15 @@ public struct PlexItem: Sendable, Equatable, Decodable, Identifiable {
/// Ob die Detailseite eine Versionsauswahl anbieten muss.
public var hasMultipleVersions: Bool { media.count > 1 }
/// Der Schriftzug des Titels, falls der Server einen hat.
///
/// Nicht jeder Film hat einen — dann bleibt es beim gesetzten Titel. Bei
/// Folgen liefert Plex in der Regel den Schriftzug der Serie mit, weil
/// eine einzelne Folge keinen eigenen hat.
public var clearLogoPath: String? {
images.first(where: \.isClearLogo)?.url
}
}
/// Die Hülle, in die Plex jede Antwort packt.
@@ -14,15 +14,96 @@ public struct PlexDVR: Sendable, Equatable, Decodable, Identifiable {
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 }
public init(key: String, uuid: String? = nil, lineupTitle: String? = nil, lineup: String? = nil, language: String? = nil) {
/// 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
}
}
@@ -61,3 +61,24 @@ public struct PlexMarker: Sendable, Equatable, Decodable, Identifiable {
self.endTimeOffset = endTimeOffset
}
}
/// Ein Bild zu einem Titel.
///
/// Plex liefert neben Plakat und Hintergrund noch das `clearLogo`: den
/// Schriftzug des Films als freigestelltes PNG — „Harry Potter" in seiner
/// eigenen Schrift statt in der der App. Genau dafür ist dieser Typ da.
public struct PlexImage: Sendable, Equatable, Decodable, Identifiable {
public let alt: String?
public let type: String
public let url: String
public var id: String { url }
public var isClearLogo: Bool { type == "clearLogo" }
public init(alt: String? = nil, type: String, url: String) {
self.alt = alt
self.type = type
self.url = url
}
}