Live-TV: die Sender waren da, zwei eigene Fehler haben sie verschluckt

Der Abzug vom Server zeigt beides schwarz auf weiß.

Erstens: /media/grabbers/devices/1/channels antwortet mit 278 Sendern —
unter dem Sammelnamen `DeviceChannel`. Die Hülle kannte nur `Channel` und
hat die vollständige Liste als leer verworfen.

Zweitens: das Programmraster antwortet, aber nicht auf `type=1`. Die 1
steht bei Plex für Film, und im Programm laufen auch Folgen, Sport und
Nachrichten — der Filter hat das Raster geleert. Ohne ihn kommen die
Sendungen samt Sendername, Nummer, Logo und Kennung.

Das Raster hat jetzt Vorrang vor der Tunerliste, auch wenn beide
antworten: nur dort gehören Sender und Programm zusammen, es enthält
ohnehin nur die eingeschalteten Sender, und die Tunerliste führt alle 278.

Einschalten läuft über die Adresse der laufenden Sendung beim
Programmanbieter — Plex behandelt eine laufende Sendung wie einen Film.
Die Adresse wird einmal entpackt, bevor sie in die Anfrage geht: sonst
kodiert URLComponents ein zweites Mal, und aus %3A wird %253A.

Die Tests stehen jetzt auf den echten Antworten aus dem Abzug, nicht auf
erfundenen: DeviceChannel mit `name` und `key`, Rastereinträge mit
channelVcn, channelID und einer Folge neben einem Film.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-12 13:08:15 +02:00
co-authored by Claude Opus 5
parent 7e36f37506
commit 2362865ba9
9 changed files with 2726 additions and 2578 deletions
+38 -23
View File
@@ -1,14 +1,22 @@
{
"sourceLanguage" : "en",
"strings" : {
"%@ → %@" : {
"comment" : "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$@ → %2$@"
}
}
}
},
"%@ GB" : {
"comment" : "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.",
"isCommentAutoGenerated" : true
},
"1080p": {
"comment": "A label for a 1080p video.",
"isCommentAutoGenerated": true
},
"4K" : {
"comment" : "A label for a 4K video.",
"isCommentAutoGenerated" : true
@@ -17,8 +25,8 @@
"comment" : "A label for a 720p video.",
"isCommentAutoGenerated" : true
},
"SD": {
"comment": "A label for a 720p movie.",
"1080p" : {
"comment" : "A label for a 1080p video.",
"isCommentAutoGenerated" : true
},
"action.apply" : {
@@ -443,6 +451,7 @@
}
},
"detail.toSeason" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -459,6 +468,7 @@
}
},
"detail.toSeries" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2318,23 +2328,6 @@
}
}
},
"player.subtitleTracks": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Untertitel"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Subtitles"
}
}
}
},
"player.subtitlesOff" : {
"comment" : "Eintrag, der die Untertitel abschaltet",
"extractionState" : "stale",
@@ -2353,6 +2346,23 @@
}
}
},
"player.subtitleTracks" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Untertitel"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Subtitles"
}
}
}
},
"player.tracks" : {
"localizations" : {
"de" : {
@@ -2575,6 +2585,10 @@
}
}
},
"SD" : {
"comment" : "A label for a 720p movie.",
"isCommentAutoGenerated" : true
},
"search.hint" : {
"localizations" : {
"de" : {
@@ -2625,6 +2639,7 @@
},
"season.short %lld" : {
"comment" : "Platzhalter: Staffelnummer",
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
+10 -3
View File
@@ -71,7 +71,7 @@ struct LiveTVView: View {
channels: channels,
programs: programs,
tuning: isTuning,
onTune: { channel in Task { await tune(channel) } }
onTune: { channel, program in Task { await tune(channel, program: program) } }
)
} else {
channelList
@@ -180,13 +180,20 @@ struct LiveTVView: View {
trace = guide.trace
}
private func tune(_ channel: PlexChannel) async {
private func tune(_ channel: PlexChannel, program: PlexProgram? = nil) async {
guard let server = session.server, isTuning == nil else { return }
isTuning = channel.id
defer { isTuning = nil }
// Die laufende Sendung mitgeben: über ihre Adresse beim
// Programmanbieter führt der direkte Weg zum Strom.
let running = program ?? programs[channel.id]?.first { $0.isRunning() }
let policy = settings.policy(for: network)
guard (try? await server.tune(channel: channel, maximumBitrate: policy.maximumBitrate)) != nil
guard (try? await server.tune(
channel: channel,
program: running,
maximumBitrate: policy.maximumBitrate
)) != nil
else { return }
// Live-TV läuft über denselben Player wie alles andere. Der Schlüssel
+8 -3
View File
@@ -18,7 +18,7 @@ struct ProgramGuide: View {
let channels: [PlexChannel]
let programs: [String: [PlexProgram]]
let tuning: String?
let onTune: (PlexChannel) -> Void
let onTune: (PlexChannel, PlexProgram?) -> Void
@Environment(FlixySession.self) private var session
@Environment(\.flixyAccent) private var accent
@@ -53,7 +53,7 @@ struct ProgramGuide: View {
channel: selection.channel,
program: selection.program,
isRunning: selection.program.isRunning(at: now),
onTune: { onTune(selection.channel) }
onTune: { onTune(selection.channel, selection.program) }
)
}
// Die Fortschrittsbalken und die Jetzt-Linie stimmen sonst nach ein
@@ -105,10 +105,15 @@ struct ProgramGuide: View {
.background(Color.flixyCanvas)
}
/// Was auf diesem Sender gerade läuft.
private func running(on channel: PlexChannel) -> PlexProgram? {
programs[channel.id]?.first { $0.isRunning(at: now) }
}
private var channelColumn: some View {
VStack(spacing: 0) {
ForEach(channels) { channel in
Button { onTune(channel) } label: {
Button { onTune(channel, running(on: channel)) } label: {
HStack(spacing: FlixySpacing.snug) {
FlixyArtwork(
url: session.server?.artworkURL(path: channel.thumb, width: 120, height: 120),
@@ -256,8 +256,9 @@ extension PlexServer {
PlexRequest(
path: "/\(providerIdentifier)/grid",
query: [
// 1 steht bei Plex für „Sendung".
URLQueryItem(name: "type", value: "1"),
// Ohne Typfilter. `type=1` steht bei Plex für Filme, und
// im Programm laufen auch Folgen, Sport und Nachrichten —
// der Filter hat das Raster leer zurückgegeben.
URLQueryItem(name: "beginsAt>", value: String(Int(start.timeIntervalSince1970))),
URLQueryItem(name: "endsAt<", value: String(Int(end.timeIntervalSince1970))),
],
@@ -314,6 +315,26 @@ extension PlexServer {
pairs.map(\.program).sorted { $0.startsAt < $1.startsAt }
}
// Aus dem Raster: jeden Sender einmal, nach Sendernummer sortiert.
var seen = Set<String>()
let fromGrid = entries
.compactMap(\.channel)
.filter { seen.insert($0.id).inserted }
.sorted { ($0.channelIdentifier ?? "") < ($1.channelIdentifier ?? "") }
// Das Raster hat Vorrang vor der Senderliste des Tuners, auch wenn
// beide antworten: nur dort gehören Sender und Programm zusammen,
// stehen Logos dabei, und es enthält ohnehin nur die eingeschalteten
// Sender. Die Tunerliste führt alle 278, die meisten davon aus.
if !fromGrid.isEmpty {
return LiveTVGuide(
channels: fromGrid,
programs: programs,
source: "Raster: \(fromGrid.count) Sender",
trace: lookup.trace
)
}
if !lookup.channels.isEmpty {
return LiveTVGuide(
channels: lookup.channels,
@@ -323,24 +344,10 @@ extension PlexServer {
)
}
// Aus dem Raster: jeden Sender einmal, in der Reihenfolge, in der das
// Raster ihn nennt. Plex sortiert dort bereits nach Sendernummer.
var seen = Set<String>()
let fromGrid = entries.compactMap(\.channel).filter { seen.insert($0.id).inserted }
let enabled = dvr.enabledChannelKeys
let filtered = enabled.isEmpty ? fromGrid : fromGrid.filter { $0.matches(anyOf: enabled) }
// Passt keiner zur Zuordnung, ist die ganze Liste besser als keine:
// die Zuordnung zu erzwingen hieße, bei abweichenden Schlüsseln alles
// wegzuwerfen, was der Server gerade geliefert hat.
let channels = filtered.isEmpty ? fromGrid : filtered
return LiveTVGuide(
channels: channels,
channels: [],
programs: programs,
source: channels.isEmpty
? "Raster: keine Sender (\(lookup.description))"
: "Raster: \(channels.count) von \(fromGrid.count) Sendern",
source: "Raster: keine Sender (\(lookup.description))",
trace: lookup.trace + [ChannelProbe(
path: "/\(epgIdentifier)/grid",
result: fromGrid.isEmpty
@@ -356,10 +363,31 @@ extension PlexServer {
/// Transportstrom, den kein iPhone direkt abspielt. Deshalb führt der Weg
/// hier zwangsläufig über den Transcoder — anders als bei Dateien, wo
/// DirectPlay der Normalfall ist.
public func tune(channel: PlexChannel, maximumBitrate: Int?) async throws(PlexError) -> URL? {
///
/// Zwei Wege, je nachdem, woher der Sender kam. Steht die laufende
/// Sendung im Programmraster, ist ihre Adresse beim Programmanbieter der
/// direkte Weg: Plex behandelt eine laufende Sendung wie einen Film.
/// Stammt der Sender dagegen aus der Liste des Tuners, bleibt der Umweg
/// über eine Live-Sitzung.
public func tune(
channel: PlexChannel,
program: PlexProgram? = nil,
maximumBitrate: Int?
) async throws(PlexError) -> URL? {
let session = UUID().uuidString
if let metadataKey = program?.metadataKey {
return transcodeURL(for: TranscodeRequest(
ratingKey: channel.id,
maximumBitrate: maximumBitrate,
isLocal: connection.local,
sessionIdentifier: session,
pathOverride: metadataKey
))
}
guard let key = channel.key else { return nil }
let session = UUID().uuidString
let container = try await client.send(
PlexRequest(
path: "/livetv/sessions/\(session)/\(key)",
@@ -25,6 +25,12 @@ public struct TranscodeRequest: Sendable, Equatable {
/// Welche Untertitelspur eingebrannt werden soll.
public let subtitleStreamID: Int?
public let audioStreamID: Int?
/// Eine andere Adresse als die der Mediathek.
///
/// Live-TV liegt nicht unter `/library/metadata`, sondern beim
/// Programmanbieter. Der Transcoder nimmt beides — aber nur, wenn man
/// ihm die richtige Adresse nennt.
public let pathOverride: String?
public init(
ratingKey: String,
@@ -35,7 +41,8 @@ public struct TranscodeRequest: Sendable, Equatable {
isLocal: Bool,
sessionIdentifier: String = UUID().uuidString,
subtitleStreamID: Int? = nil,
audioStreamID: Int? = nil
audioStreamID: Int? = nil,
pathOverride: String? = nil
) {
self.ratingKey = ratingKey
self.mediaIndex = mediaIndex
@@ -46,11 +53,19 @@ public struct TranscodeRequest: Sendable, Equatable {
self.sessionIdentifier = sessionIdentifier
self.subtitleStreamID = subtitleStreamID
self.audioStreamID = audioStreamID
self.pathOverride = pathOverride
}
var queryItems: [URLQueryItem] {
var items = [
URLQueryItem(name: "path", value: "/library/metadata/\(ratingKey)"),
// Einmal entpackt, weil die Adresse beim Programmanbieter schon
// kodiert ankommt und `URLComponents` sonst ein zweites Mal
// kodiert — aus %3A wird %253A, und der Server findet nichts.
URLQueryItem(
name: "path",
value: pathOverride.map { $0.removingPercentEncoding ?? $0 }
?? "/library/metadata/\(ratingKey)"
),
URLQueryItem(name: "mediaIndex", value: String(mediaIndex)),
URLQueryItem(name: "partIndex", value: String(partIndex)),
URLQueryItem(name: "protocol", value: "hls"),
@@ -205,7 +205,10 @@ public struct PlexMediaContainer<Content: Decodable & Sendable>: Sendable, Decod
/// Die Namen schließen sich gegenseitig aus, deshalb genügt es, sie der
/// Reihe nach zu probieren.
private static var contentKeys: [String] {
["Metadata", "Directory", "Hub", "Dvr", "MediaProvider", "Channel", "ChannelMapping", "Device"]
["Metadata", "Directory", "Hub", "Dvr", "MediaProvider",
// Der Tuner führt seine Sender unter `DeviceChannel`, nicht unter
// `Channel`. Die 278 Sender waren da, die Hülle hat sie verworfen.
"Channel", "DeviceChannel", "ChannelMapping", "Device"]
}
private struct DynamicKey: CodingKey {
@@ -187,10 +187,12 @@ struct PlexGridMedia: Decodable {
let channelCallSign: String?
let channelThumb: String?
let channelVcn: String?
/// Die laufende Nummer des Senders am Server.
let channelID: String?
let parts: [PlexGridPart]
private enum CodingKeys: String, CodingKey {
case channelIdentifier, channelTitle, channelCallSign, channelThumb, channelVcn
case channelIdentifier, channelTitle, channelCallSign, channelThumb, channelVcn, channelID
case part = "Part"
}
@@ -208,6 +210,13 @@ struct PlexGridMedia: Decodable {
} 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.parts = try values.decodeIfPresent([PlexGridPart].self, forKey: .part) ?? []
}
@@ -217,15 +226,27 @@ struct PlexGridMedia: Decodable {
/// Senderliste wertlos und stünde bei jedem Eintrag doppelt.
var channel: PlexChannel? {
guard let name = channelTitle ?? channelCallSign else { return nil }
let identity = channelIdentifier ?? parts.first?.key ?? name
let identity = channelIdentifier ?? channelID ?? parts.first?.key ?? name
return PlexChannel(
id: identity,
title: name,
channelIdentifier: channelVcn ?? channelIdentifier,
title: Self.withoutLeadingNumber(name, number: channelVcn),
channelIdentifier: channelVcn,
thumb: channelThumb,
key: parts.first?.key
key: parts.first?.key ?? channelID
)
}
/// 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.
@@ -268,7 +289,12 @@ public struct PlexChannel: Sendable, Equatable, Decodable, Identifiable {
.compactMap { try? values.decodeIfPresent(String.self, forKey: $0) }
.first { !$0.isEmpty } ?? Self.placeholder
self.channelIdentifier = Self.text(in: values, forKeys: [.channelIdentifier, .vcn, .number])
// 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 = try values.decodeIfPresent(String.self, forKey: .key)
@@ -341,6 +367,8 @@ public struct PlexProgram: Sendable, Equatable, Identifiable {
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,
@@ -350,7 +378,8 @@ public struct PlexProgram: Sendable, Equatable, Identifiable {
startsAt: Date,
endsAt: Date,
channelID: String? = nil,
thumb: String? = nil
thumb: String? = nil,
metadataKey: String? = nil
) {
self.id = id
self.title = title
@@ -360,6 +389,7 @@ public struct PlexProgram: Sendable, Equatable, Identifiable {
self.endsAt = endsAt
self.channelID = channelID
self.thumb = thumb
self.metadataKey = metadataKey
}
public var duration: Duration {
@@ -385,6 +415,9 @@ public struct PlexProgram: Sendable, Equatable, Identifiable {
/// 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?
@@ -396,7 +429,7 @@ struct PlexGridEntry: Decodable {
let media: [PlexGridMedia]?
private enum CodingKeys: String, CodingKey {
case ratingKey, title, summary, grandparentTitle, thumb, beginsAt, endsAt
case ratingKey, key, title, summary, grandparentTitle, thumb, beginsAt, endsAt
case media = "Media"
}
@@ -416,7 +449,8 @@ struct PlexGridEntry: Decodable {
startsAt: Date(timeIntervalSince1970: TimeInterval(beginsAt)),
endsAt: Date(timeIntervalSince1970: TimeInterval(endsAt)),
channelID: channelID,
thumb: thumb
thumb: thumb,
metadataKey: key
)
}
}
@@ -3,47 +3,53 @@
"size": 3,
"Metadata": [
{
"ratingKey": "epg://1",
"title": "Tagesschau",
"summary": "Nachrichten",
"ratingKey": "5f1",
"key": "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2F6254592919e23c64f6c5605d",
"title": "Endlich Witwer",
"summary": "Komödie",
"type": "movie",
"beginsAt": 1757682000,
"endsAt": 1757682900,
"endsAt": 1757685600,
"Media": [
{
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-11",
"channelTitle": "SRF 1 HD",
"channelCallSign": "SRF1",
"channelThumb": "/library/metadata/11/thumb",
"channelVcn": "1",
"Part": [{ "key": "/livetv/channels/11" }]
"channelCallSign": "ZDFGERH",
"channelID": 5,
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-5fc705f9c8d56d002e06f849",
"channelThumb": "/library/metadata/5/thumb",
"channelTitle": "006 ZDFGERH (ZDF HD)",
"channelVcn": "006",
"onAir": true
}
]
},
{
"ratingKey": "epg://2",
"title": "Meteo",
"beginsAt": 1757682900,
"endsAt": 1757683200,
"ratingKey": "5f2",
"key": "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fepisode%2Fabc",
"title": "heute journal",
"type": "episode",
"beginsAt": 1757685600,
"endsAt": 1757687400,
"Media": [
{
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-11",
"channelTitle": "SRF 1 HD",
"channelVcn": 1,
"Part": [{ "key": "/livetv/channels/11" }]
"channelID": 5,
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-5fc705f9c8d56d002e06f849",
"channelTitle": "006 ZDFGERH (ZDF HD)",
"channelVcn": "006"
}
]
},
{
"ratingKey": "epg://3",
"ratingKey": "5f3",
"key": "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fshow%2Fxyz",
"title": "Sport aktuell",
"beginsAt": 1757682000,
"endsAt": 1757685600,
"Media": [
{
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-12",
"channelCallSign": "SRF2",
"channelVcn": "2",
"Part": [{ "key": "/livetv/channels/12" }]
"channelID": 12,
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-12",
"channelVcn": "002"
}
]
}
@@ -44,7 +44,9 @@ struct LiveTVGuideTests {
let guide = await server.liveTVGuide(dvr: dvr)
#expect(guide.channels.count == 2)
#expect(guide.channels.map(\.title) == ["SRF 1 HD", "SRF2"])
// Nach Sendernummer, und ohne die Nummer noch einmal im Namen:
// Plex schreibt sie dort mit hinein.
#expect(guide.channels.map(\.title) == ["SRF2", "ZDFGERH (ZDF HD)"])
}
@Test("Fehlt der Sendername, trägt das Kürzel den Sender")
@@ -54,12 +56,10 @@ struct LiveTVGuideTests {
])
let guide = await server.liveTVGuide(dvr: dvr)
let second = try #require(guide.channels.last)
let callSignOnly = try #require(guide.channels.first)
#expect(second.title == "SRF2")
// Die Sendernummer kommt mal als Zahl, mal als Zeichenkette — beides
// muss lesbar sein, sonst fehlt sie bei der Hälfte der Sender.
#expect(second.channelIdentifier == "2")
#expect(callSignOnly.title == "SRF2")
#expect(callSignOnly.channelIdentifier == "002")
}
@Test("Sendungen landen beim richtigen Sender, nach Anfangszeit sortiert")
@@ -69,10 +69,12 @@ struct LiveTVGuideTests {
])
let guide = await server.liveTVGuide(dvr: dvr)
let first = try #require(guide.channels.first)
let programs = try #require(guide.programs[first.id])
let zdf = try #require(guide.channels.last)
let programs = try #require(guide.programs[zdf.id])
#expect(programs.map(\.title) == ["Tagesschau", "Meteo"])
// Auch die Folge ist dabei: mit `type=1` kam nur „Film" durch, und
// das Raster blieb leer.
#expect(programs.map(\.title) == ["Endlich Witwer", "heute journal"])
#expect(guide.programs.count == 2)
}
@@ -148,14 +150,47 @@ struct LiveTVGuideTests {
#expect(lookup.trace.contains { $0.path == "/livetv/dvrs/2/channels" && $0.result.contains("Channel: 2") })
}
@Test("Die Einschaltadresse reist mit dem Sender mit")
func keepsTuneKey() async throws {
@Test("Die Adresse der laufenden Sendung führt zum Strom")
func tunesThroughTheProgram() async throws {
// Das Raster nennt keine Teil-Adresse. Der Weg führt über die
// Sendung selbst: Plex behandelt eine laufende Sendung wie einen Film.
let server = server(routes: [
"/tv.plex.providers.epg.cloud:2/grid": try fixture("livetv-grid"),
])
let guide = await server.liveTVGuide(dvr: dvr)
let zdf = try #require(guide.channels.last)
let program = try #require(guide.programs[zdf.id]?.first)
let url = try #require(await server.tune(channel: zdf, program: program, maximumBitrate: nil))
let path = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.first { $0.name == "path" }?.value)
#expect(guide.channels.first?.key == "/livetv/channels/11")
#expect(path == "/tv.plex.providers.epg.cloud:2/metadata/plex://movie/6254592919e23c64f6c5605d")
// Doppelt kodiert fände der Server nichts — aus %3A würde %253A.
#expect(!url.absoluteString.contains("%253A"))
}
@Test("Die Sender des Tuners stehen unter DeviceChannel")
func readsDeviceChannelKey() async throws {
// Der Server lieferte 278 Sender, die Hülle verwarf sie: sie kannte
// nur `Channel`. Der Tuner schreibt `DeviceChannel`.
let json = """
{"MediaContainer":{"size":2,"DeviceChannel":[
{"drm":false,"favorite":false,"hd":true,"identifier":"300","key":"300","name":"ZDF HD"},
{"hd":true,"identifier":"301","key":"301","name":"SRF 1 HD"}
]}}
"""
let server = server(routes: ["/media/grabbers/devices/1/channels": json])
let withTuner = PlexDVR(
key: "2",
devices: [PlexTunerDevice(key: "1")],
epgIdentifier: "tv.plex.providers.epg.cloud:2"
)
let lookup = await server.channels(inDVR: withTuner)
#expect(lookup.channels.map(\.title) == ["ZDF HD", "SRF 1 HD"])
// Die Nummer steht beim Tuner schlicht im Schlüssel.
#expect(lookup.channels.first?.channelIdentifier == "300")
}
}