From ed84b5bf22a20f499abcb10941bc1822a089e755 Mon Sep 17 00:00:00 2001 From: Guido Date: Sat, 12 Sep 2026 09:28:21 +0200 Subject: [PATCH] =?UTF-8?q?Live-TV-Sender=20gefunden,=20Film-Schriftz?= =?UTF-8?q?=C3=BCge=20erg=C3=A4nzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Flixy/Views/Detail/PlayableDetailView.swift | 3 +- Flixy/Views/Detail/SeriesDetailView.swift | 3 +- Flixy/Views/Home/HeroSection.swift | 3 +- Flixy/Views/LiveTV/LiveTVView.swift | 19 ++- .../FlixyDesign/Components/HeroBanner.swift | 49 +++++++- .../PlexKit/Endpoints/PlexServer+LiveTV.swift | 66 ++++++++-- .../PlexKit/Endpoints/PlexServer.swift | 19 +++ .../Sources/PlexKit/Models/PlexItem.swift | 13 ++ .../Sources/PlexKit/Models/PlexLiveTV.swift | 83 +++++++++++- .../Sources/PlexKit/Models/PlexTag.swift | 21 ++++ .../Tests/PlexKitTests/ClearLogoTests.swift | 119 ++++++++++++++++++ 11 files changed, 381 insertions(+), 17 deletions(-) create mode 100644 Packages/FlixyKit/Tests/PlexKitTests/ClearLogoTests.swift diff --git a/Flixy/Views/Detail/PlayableDetailView.swift b/Flixy/Views/Detail/PlayableDetailView.swift index 85655e1..9ac7a60 100644 --- a/Flixy/Views/Detail/PlayableDetailView.swift +++ b/Flixy/Views/Detail/PlayableDetailView.swift @@ -67,7 +67,8 @@ struct PlayableDetailView: View { path: item.art ?? item.grandparentArt ?? item.thumb, width: 900, height: 1200 - ) + ), + logoURL: session.server?.rawImageURL(path: item.clearLogoPath) ) { Button { playback.play(item, mediaIndex: selectedVersion) diff --git a/Flixy/Views/Detail/SeriesDetailView.swift b/Flixy/Views/Detail/SeriesDetailView.swift index b810e10..dfaecaa 100644 --- a/Flixy/Views/Detail/SeriesDetailView.swift +++ b/Flixy/Views/Detail/SeriesDetailView.swift @@ -45,7 +45,8 @@ struct SeriesDetailView: View { path: item.art ?? item.thumb, width: 900, height: 1200 - ) + ), + logoURL: session.server?.rawImageURL(path: item.clearLogoPath) ) { DownloadAllButton(container: item) } diff --git a/Flixy/Views/Home/HeroSection.swift b/Flixy/Views/Home/HeroSection.swift index ed7a95a..bc665a3 100644 --- a/Flixy/Views/Home/HeroSection.swift +++ b/Flixy/Views/Home/HeroSection.swift @@ -19,7 +19,8 @@ struct HeroSection: View { path: item.art ?? item.grandparentArt ?? item.thumb, width: 900, height: 1200 - ) + ), + logoURL: session.server?.rawImageURL(path: item.clearLogoPath) ) { Button { playback.play(item) diff --git a/Flixy/Views/LiveTV/LiveTVView.swift b/Flixy/Views/LiveTV/LiveTVView.swift index 4f60b81..ab4d836 100644 --- a/Flixy/Views/LiveTV/LiveTVView.swift +++ b/Flixy/Views/LiveTV/LiveTVView.swift @@ -12,6 +12,8 @@ struct LiveTVView: View { @State private var availability: LiveTVAvailability? @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 isTuning: String? @@ -47,7 +49,17 @@ struct LiveTVView: View { ContentUnavailableView { Label("liveTV.noChannels.title", systemImage: "tv") } 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: { Button("action.retry") { Task { await load() } } } @@ -86,7 +98,10 @@ struct LiveTVView: View { self.availability = availability 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 // deshalb das Umschalten unmöglich wäre. Eine Senderliste ohne diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift index 58af1f4..20a41c2 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift @@ -10,6 +10,10 @@ public struct HeroBanner: 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: 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: 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: 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) diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift index 23c0c81..7ba781e 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift @@ -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.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.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 diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift index 6406248..73af848 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift @@ -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) diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift index 0c702b0..ee78b35 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift @@ -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. diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift index 19e8d1f..56af607 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift @@ -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 } } diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexTag.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexTag.swift index 8ce9cb2..e904ec5 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexTag.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexTag.swift @@ -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 + } +} diff --git a/Packages/FlixyKit/Tests/PlexKitTests/ClearLogoTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/ClearLogoTests.swift new file mode 100644 index 0000000..a173543 --- /dev/null +++ b/Packages/FlixyKit/Tests/PlexKitTests/ClearLogoTests.swift @@ -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.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.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.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.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.self, + from: Data(json.utf8) + ) + #expect(container.content.first?.key == "42") + } +}