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
+2 -1
View File
@@ -67,7 +67,8 @@ struct PlayableDetailView: View {
path: item.art ?? item.grandparentArt ?? item.thumb, path: item.art ?? item.grandparentArt ?? item.thumb,
width: 900, width: 900,
height: 1200 height: 1200
) ),
logoURL: session.server?.rawImageURL(path: item.clearLogoPath)
) { ) {
Button { Button {
playback.play(item, mediaIndex: selectedVersion) playback.play(item, mediaIndex: selectedVersion)
+2 -1
View File
@@ -45,7 +45,8 @@ struct SeriesDetailView: View {
path: item.art ?? item.thumb, path: item.art ?? item.thumb,
width: 900, width: 900,
height: 1200 height: 1200
) ),
logoURL: session.server?.rawImageURL(path: item.clearLogoPath)
) { ) {
DownloadAllButton(container: item) DownloadAllButton(container: item)
} }
+2 -1
View File
@@ -19,7 +19,8 @@ struct HeroSection: View {
path: item.art ?? item.grandparentArt ?? item.thumb, path: item.art ?? item.grandparentArt ?? item.thumb,
width: 900, width: 900,
height: 1200 height: 1200
) ),
logoURL: session.server?.rawImageURL(path: item.clearLogoPath)
) { ) {
Button { Button {
playback.play(item) playback.play(item)
+17 -2
View File
@@ -12,6 +12,8 @@ struct LiveTVView: View {
@State private var availability: LiveTVAvailability? @State private var availability: LiveTVAvailability?
@State private var channels: [PlexChannel] = [] @State private var channels: [PlexChannel] = []
/// Woher die Senderliste kam — steht in der leeren Ansicht.
@State private var channelSource: String?
@State private var programs: [String: [PlexProgram]] = [:] @State private var programs: [String: [PlexProgram]] = [:]
@State private var isTuning: String? @State private var isTuning: String?
@@ -47,7 +49,17 @@ struct LiveTVView: View {
ContentUnavailableView { ContentUnavailableView {
Label("liveTV.noChannels.title", systemImage: "tv") Label("liveTV.noChannels.title", systemImage: "tv")
} description: { } description: {
Text("liveTV.noChannels.description") VStack(spacing: FlixySpacing.snug) {
Text("liveTV.noChannels.description")
if let channelSource {
// Ohne diese Angabe ist „keine Sender" eine
// Sackgasse: es sagt nicht, ob der Server nichts
// hergibt oder ob die App am falschen Ort sucht.
Text(channelSource)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(Color.flixyTextMuted)
}
}
} actions: { } actions: {
Button("action.retry") { Task { await load() } } Button("action.retry") { Task { await load() } }
} }
@@ -86,7 +98,10 @@ struct LiveTVView: View {
self.availability = availability self.availability = availability
guard case .available(let dvrs) = availability, let dvr = dvrs.first else { return } guard case .available(let dvrs) = availability, let dvr = dvrs.first else { return }
channels = (try? await server.channels(inDVR: dvr)) ?? []
let lookup = await server.channels(inDVR: dvr)
channels = lookup.channels
channelSource = lookup.description
// Der Programmführer kommt getrennt: er kann ausfallen, ohne dass // Der Programmführer kommt getrennt: er kann ausfallen, ohne dass
// deshalb das Umschalten unmöglich wäre. Eine Senderliste ohne // deshalb das Umschalten unmöglich wäre. Eine Senderliste ohne
@@ -10,6 +10,10 @@ public struct HeroBanner<Actions: View>: View {
private let metadata: String? private let metadata: String?
private let summary: String? private let summary: String?
private let artworkURL: URL? 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 aspectRatio: CGFloat
private let actions: Actions private let actions: Actions
@@ -18,6 +22,7 @@ public struct HeroBanner<Actions: View>: View {
metadata: String? = nil, metadata: String? = nil,
summary: String? = nil, summary: String? = nil,
artworkURL: URL?, artworkURL: URL?,
logoURL: URL? = nil,
// 16:9, weil Plex genau das als `art` liefert. Vorher stand hier // 16:9, weil Plex genau das als `art` liefert. Vorher stand hier
// 3:4 — mit `scaledToFill` wurde das Bild dadurch links und rechts // 3:4 — mit `scaledToFill` wurde das Bild dadurch links und rechts
// kräftig beschnitten, und vom Aufmacher war nur ein Ausschnitt zu // 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.metadata = metadata
self.summary = summary self.summary = summary
self.artworkURL = artworkURL self.artworkURL = artworkURL
self.logoURL = logoURL
self.aspectRatio = aspectRatio self.aspectRatio = aspectRatio
self.actions = actions() 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 { public var body: some View {
FlixyArtwork(url: artworkURL, aspectRatio: aspectRatio) FlixyArtwork(url: artworkURL, aspectRatio: aspectRatio)
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
@@ -53,10 +97,7 @@ public struct HeroBanner<Actions: View>: View {
} }
.overlay(alignment: .bottomLeading) { .overlay(alignment: .bottomLeading) {
VStack(alignment: .leading, spacing: FlixySpacing.snug) { VStack(alignment: .leading, spacing: FlixySpacing.snug) {
Text(title) titleView
.font(.flixyHero)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(2)
if let metadata { if let metadata {
Text(metadata) Text(metadata)
@@ -31,13 +31,34 @@ extension PlexServer {
} }
} }
/// Die Sender eines Empfängers. /// Die Sender eines Empfängers, samt Auskunft darüber, woher sie kamen.
public func channels(inDVR dvr: PlexDVR) async throws(PlexError) -> [PlexChannel] { ///
let container = try await client.send( /// Zwei Quellen, in dieser Reihenfolge. Die erste ist die verlässliche:
PlexRequest(path: "/livetv/dvrs/\(dvr.key)/channels"), /// Plex liefert die Sender bereits verschachtelt in der Antwort auf
as: PlexMediaContainer<PlexChannel>.self /// `/livetv/dvrs` mit. Der getrennte Endpunkt war meine erste Vermutung
) /// und lieferte nichts — daran hing „Keine Sender" bei einem Server mit
return container.content /// ü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. /// 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. /// Ein Anbieter am Server — Mediathek, Programmführer, Entdecken.
public struct PlexMediaProvider: Sendable, Equatable, Decodable, Identifiable { public struct PlexMediaProvider: Sendable, Equatable, Decodable, Identifiable {
public let identifier: String public let identifier: String
@@ -156,6 +156,25 @@ public actor PlexServer {
return client.artworkURL(path: path, width: width, height: height, token: token) 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. /// Die Adresse für DirectPlay: die Datei, unverändert.
public nonisolated func directPlayURL(part: PlexPart) -> URL? { public nonisolated func directPlayURL(part: PlexPart) -> URL? {
streamURL(partKey: part.key, download: false) streamURL(partKey: part.key, download: false)
@@ -69,6 +69,8 @@ public struct PlexItem: Sendable, Equatable, Decodable, Identifiable {
/// Die Besetzung, mit Porträt und Rollennamen. /// Die Besetzung, mit Porträt und Rollennamen.
public let roles: [PlexTag] public let roles: [PlexTag]
public let markers: [PlexMarker] public let markers: [PlexMarker]
/// Plakat, Hintergrund und — wenn vorhanden — der Schriftzug des Films.
public let images: [PlexImage]
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case ratingKey, key, guid, type, title, titleSort, summary, year, duration 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 writers = "Writer"
case roles = "Role" case roles = "Role"
case markers = "Marker" case markers = "Marker"
case images = "Image"
} }
/// Von Hand geschrieben statt erzeugt, weil der erzeugte Decoder einen /// 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.writers = try values.decodeIfPresent([PlexTag].self, forKey: .writers) ?? []
self.roles = try values.decodeIfPresent([PlexTag].self, forKey: .roles) ?? [] self.roles = try values.decodeIfPresent([PlexTag].self, forKey: .roles) ?? []
self.markers = try values.decodeIfPresent([PlexMarker].self, forKey: .markers) ?? [] 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 /// `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. /// Ob die Detailseite eine Versionsauswahl anbieten muss.
public var hasMultipleVersions: Bool { media.count > 1 } 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. /// 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 language: String?
public let lineupTitle: String? public let lineupTitle: String?
public let lineup: 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 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.key = key
self.uuid = uuid self.uuid = uuid
self.lineupTitle = lineupTitle self.lineupTitle = lineupTitle
self.lineup = lineup self.lineup = lineup
self.language = language 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 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
}
}
@@ -0,0 +1,119 @@
import Foundation
import Testing
@testable import PlexKit
@Suite("Schriftzüge und Bildliste")
struct ClearLogoTests {
private func item(from json: String) throws -> PlexItem {
let data = Data(#"{"MediaContainer":{"Metadata":[\#(json)]}}"#.utf8)
return try #require(
try PlexResponse.decode(PlexMediaContainer<PlexItem>.self, from: data).content.first
)
}
@Test("Der Schriftzug wird aus der Bildliste gelesen")
func findsClearLogo() throws {
let decoded = try item(from: """
{
"ratingKey": "1", "type": "movie", "title": "Harry Potter",
"Image": [
{"alt": "Harry Potter", "type": "coverPoster", "url": "/library/metadata/1/thumb/1"},
{"alt": "Harry Potter", "type": "background", "url": "/library/metadata/1/art/1"},
{"alt": "Harry Potter", "type": "clearLogo", "url": "/library/metadata/1/clearLogo/1"}
]
}
""")
#expect(decoded.clearLogoPath == "/library/metadata/1/clearLogo/1")
}
@Test("Ohne Schriftzug bleibt es beim Titel")
func withoutClearLogo() throws {
// Der Normalfall bei selbst zusammengestellten Mediatheken.
let decoded = try item(from: """
{
"ratingKey": "1", "type": "movie", "title": "Ein Film",
"Image": [{"type": "coverPoster", "url": "/library/metadata/1/thumb/1"}]
}
""")
#expect(decoded.clearLogoPath == nil)
}
@Test("Eine fehlende Bildliste ist kein Fehler")
func missingImageList() throws {
// Listen liefern keine Bildliste mit — nur die Einzelabfrage tut das.
let decoded = try item(from: #"{"ratingKey": "1", "type": "movie", "title": "Ein Film"}"#)
#expect(decoded.images.isEmpty)
#expect(decoded.clearLogoPath == nil)
}
}
@Suite("Empfänger und Sender")
struct TunerDecodingTests {
@Test("Sender werden aus dem Empfänger gelesen")
func readsChannelsFromDevice() throws {
// Der Fall, an dem „Keine Sender" hing: Plex liefert sie verschachtelt
// in der Antwort auf /livetv/dvrs mit, nicht über einen eigenen
// Endpunkt.
let json = """
{"MediaContainer":{"Dvr":[{
"key": "1", "lineupTitle": "Kabel",
"Device": [{
"key": "2", "uuid": "tuner-1", "title": "HDHomeRun",
"Channel": [
{"id": 10, "channelIdentifier": "1.1", "title": "SRF 1 HD", "key": "/livetv/channel/1"},
{"id": 11, "channelIdentifier": "1.2", "title": "SRF zwei"}
]
}]
}]}}
"""
let container = try PlexResponse.decode(
PlexMediaContainer<PlexDVR>.self,
from: Data(json.utf8)
)
let dvr = try #require(container.content.first)
#expect(dvr.devices.count == 1)
#expect(dvr.channels.count == 2)
#expect(dvr.channels.first?.title == "SRF 1 HD")
#expect(dvr.channels.first?.channelIdentifier == "1.1")
}
@Test("Auch die ältere Schreibweise ChannelMapping wird gelesen")
func readsChannelMapping() throws {
let json = """
{"MediaContainer":{"Dvr":[{
"key": "1",
"Device": [{"uuid": "t", "ChannelMapping": [{"id": "a", "title": "3sat"}]}]
}]}}
"""
let container = try PlexResponse.decode(
PlexMediaContainer<PlexDVR>.self,
from: Data(json.utf8)
)
#expect(container.content.first?.channels.first?.title == "3sat")
}
@Test("Ein Empfänger ohne Sender ergibt eine leere Liste, keinen Fehler")
func deviceWithoutChannels() throws {
let json = #"{"MediaContainer":{"Dvr":[{"key":"1","Device":[{"uuid":"t"}]}]}}"#
let container = try PlexResponse.decode(
PlexMediaContainer<PlexDVR>.self,
from: Data(json.utf8)
)
#expect(container.content.first?.channels.isEmpty == true)
}
@Test("Ein Empfänger-Schlüssel als Zahl wird gelesen")
func numericDVRKey() throws {
// Je nach Serverfassung kommt er als Zahl oder als Zeichenkette. Ein
// fester Typ hätte die ganze Empfängerliste unlesbar gemacht.
let json = #"{"MediaContainer":{"Dvr":[{"key":42}]}}"#
let container = try PlexResponse.decode(
PlexMediaContainer<PlexDVR>.self,
from: Data(json.utf8)
)
#expect(container.content.first?.key == "42")
}
}