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
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -71,7 +71,7 @@ struct LiveTVView: View {
channels: channels, channels: channels,
programs: programs, programs: programs,
tuning: isTuning, tuning: isTuning,
onTune: { channel in Task { await tune(channel) } } onTune: { channel, program in Task { await tune(channel, program: program) } }
) )
} else { } else {
channelList channelList
@@ -180,13 +180,20 @@ struct LiveTVView: View {
trace = guide.trace 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 } guard let server = session.server, isTuning == nil else { return }
isTuning = channel.id isTuning = channel.id
defer { isTuning = nil } 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) 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 } else { return }
// Live-TV läuft über denselben Player wie alles andere. Der Schlüssel // 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 channels: [PlexChannel]
let programs: [String: [PlexProgram]] let programs: [String: [PlexProgram]]
let tuning: String? let tuning: String?
let onTune: (PlexChannel) -> Void let onTune: (PlexChannel, PlexProgram?) -> Void
@Environment(FlixySession.self) private var session @Environment(FlixySession.self) private var session
@Environment(\.flixyAccent) private var accent @Environment(\.flixyAccent) private var accent
@@ -53,7 +53,7 @@ struct ProgramGuide: View {
channel: selection.channel, channel: selection.channel,
program: selection.program, program: selection.program,
isRunning: selection.program.isRunning(at: now), 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 // Die Fortschrittsbalken und die Jetzt-Linie stimmen sonst nach ein
@@ -105,10 +105,15 @@ struct ProgramGuide: View {
.background(Color.flixyCanvas) .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 { private var channelColumn: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
ForEach(channels) { channel in ForEach(channels) { channel in
Button { onTune(channel) } label: { Button { onTune(channel, running(on: channel)) } label: {
HStack(spacing: FlixySpacing.snug) { HStack(spacing: FlixySpacing.snug) {
FlixyArtwork( FlixyArtwork(
url: session.server?.artworkURL(path: channel.thumb, width: 120, height: 120), url: session.server?.artworkURL(path: channel.thumb, width: 120, height: 120),
@@ -256,8 +256,9 @@ extension PlexServer {
PlexRequest( PlexRequest(
path: "/\(providerIdentifier)/grid", path: "/\(providerIdentifier)/grid",
query: [ query: [
// 1 steht bei Plex für „Sendung". // Ohne Typfilter. `type=1` steht bei Plex für Filme, und
URLQueryItem(name: "type", value: "1"), // 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: "beginsAt>", value: String(Int(start.timeIntervalSince1970))),
URLQueryItem(name: "endsAt<", value: String(Int(end.timeIntervalSince1970))), URLQueryItem(name: "endsAt<", value: String(Int(end.timeIntervalSince1970))),
], ],
@@ -314,6 +315,26 @@ extension PlexServer {
pairs.map(\.program).sorted { $0.startsAt < $1.startsAt } 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 { if !lookup.channels.isEmpty {
return LiveTVGuide( return LiveTVGuide(
channels: lookup.channels, 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( return LiveTVGuide(
channels: channels, channels: [],
programs: programs, programs: programs,
source: channels.isEmpty source: "Raster: keine Sender (\(lookup.description))",
? "Raster: keine Sender (\(lookup.description))"
: "Raster: \(channels.count) von \(fromGrid.count) Sendern",
trace: lookup.trace + [ChannelProbe( trace: lookup.trace + [ChannelProbe(
path: "/\(epgIdentifier)/grid", path: "/\(epgIdentifier)/grid",
result: fromGrid.isEmpty result: fromGrid.isEmpty
@@ -356,10 +363,31 @@ extension PlexServer {
/// Transportstrom, den kein iPhone direkt abspielt. Deshalb führt der Weg /// Transportstrom, den kein iPhone direkt abspielt. Deshalb führt der Weg
/// hier zwangsläufig über den Transcoder — anders als bei Dateien, wo /// hier zwangsläufig über den Transcoder — anders als bei Dateien, wo
/// DirectPlay der Normalfall ist. /// 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 } guard let key = channel.key else { return nil }
let session = UUID().uuidString
let container = try await client.send( let container = try await client.send(
PlexRequest( PlexRequest(
path: "/livetv/sessions/\(session)/\(key)", path: "/livetv/sessions/\(session)/\(key)",
@@ -25,6 +25,12 @@ public struct TranscodeRequest: Sendable, Equatable {
/// Welche Untertitelspur eingebrannt werden soll. /// Welche Untertitelspur eingebrannt werden soll.
public let subtitleStreamID: Int? public let subtitleStreamID: Int?
public let audioStreamID: 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( public init(
ratingKey: String, ratingKey: String,
@@ -35,7 +41,8 @@ public struct TranscodeRequest: Sendable, Equatable {
isLocal: Bool, isLocal: Bool,
sessionIdentifier: String = UUID().uuidString, sessionIdentifier: String = UUID().uuidString,
subtitleStreamID: Int? = nil, subtitleStreamID: Int? = nil,
audioStreamID: Int? = nil audioStreamID: Int? = nil,
pathOverride: String? = nil
) { ) {
self.ratingKey = ratingKey self.ratingKey = ratingKey
self.mediaIndex = mediaIndex self.mediaIndex = mediaIndex
@@ -46,11 +53,19 @@ public struct TranscodeRequest: Sendable, Equatable {
self.sessionIdentifier = sessionIdentifier self.sessionIdentifier = sessionIdentifier
self.subtitleStreamID = subtitleStreamID self.subtitleStreamID = subtitleStreamID
self.audioStreamID = audioStreamID self.audioStreamID = audioStreamID
self.pathOverride = pathOverride
} }
var queryItems: [URLQueryItem] { var queryItems: [URLQueryItem] {
var items = [ 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: "mediaIndex", value: String(mediaIndex)),
URLQueryItem(name: "partIndex", value: String(partIndex)), URLQueryItem(name: "partIndex", value: String(partIndex)),
URLQueryItem(name: "protocol", value: "hls"), 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 /// Die Namen schließen sich gegenseitig aus, deshalb genügt es, sie der
/// Reihe nach zu probieren. /// Reihe nach zu probieren.
private static var contentKeys: [String] { 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 { private struct DynamicKey: CodingKey {
@@ -187,10 +187,12 @@ struct PlexGridMedia: Decodable {
let channelCallSign: String? let channelCallSign: String?
let channelThumb: String? let channelThumb: String?
let channelVcn: String? let channelVcn: String?
/// Die laufende Nummer des Senders am Server.
let channelID: String?
let parts: [PlexGridPart] let parts: [PlexGridPart]
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case channelIdentifier, channelTitle, channelCallSign, channelThumb, channelVcn case channelIdentifier, channelTitle, channelCallSign, channelThumb, channelVcn, channelID
case part = "Part" case part = "Part"
} }
@@ -208,6 +210,13 @@ struct PlexGridMedia: Decodable {
} else { } else {
self.channelVcn = nil 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) ?? [] 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. /// Senderliste wertlos und stünde bei jedem Eintrag doppelt.
var channel: PlexChannel? { var channel: PlexChannel? {
guard let name = channelTitle ?? channelCallSign else { return nil } 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( return PlexChannel(
id: identity, id: identity,
title: name, title: Self.withoutLeadingNumber(name, number: channelVcn),
channelIdentifier: channelVcn ?? channelIdentifier, channelIdentifier: channelVcn,
thumb: channelThumb, 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. /// Der Strom hinter einer Rastersendung.
@@ -268,7 +289,12 @@ public struct PlexChannel: Sendable, Equatable, Decodable, Identifiable {
.compactMap { try? values.decodeIfPresent(String.self, forKey: $0) } .compactMap { try? values.decodeIfPresent(String.self, forKey: $0) }
.first { !$0.isEmpty } ?? Self.placeholder .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.thumb = try values.decodeIfPresent(String.self, forKey: .thumb)
self.key = try values.decodeIfPresent(String.self, forKey: .key) 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 endsAt: Date
public let channelID: String? public let channelID: String?
public let thumb: String? public let thumb: String?
/// Die Adresse dieser Sendung beim Programmanbieter.
public let metadataKey: String?
public init( public init(
id: String, id: String,
@@ -350,7 +378,8 @@ public struct PlexProgram: Sendable, Equatable, Identifiable {
startsAt: Date, startsAt: Date,
endsAt: Date, endsAt: Date,
channelID: String? = nil, channelID: String? = nil,
thumb: String? = nil thumb: String? = nil,
metadataKey: String? = nil
) { ) {
self.id = id self.id = id
self.title = title self.title = title
@@ -360,6 +389,7 @@ public struct PlexProgram: Sendable, Equatable, Identifiable {
self.endsAt = endsAt self.endsAt = endsAt
self.channelID = channelID self.channelID = channelID
self.thumb = thumb self.thumb = thumb
self.metadataKey = metadataKey
} }
public var duration: Duration { public var duration: Duration {
@@ -385,6 +415,9 @@ public struct PlexProgram: Sendable, Equatable, Identifiable {
/// die Oberfläche keine Zeitstempel rechnen muss. /// die Oberfläche keine Zeitstempel rechnen muss.
struct PlexGridEntry: Decodable { struct PlexGridEntry: Decodable {
let ratingKey: String? 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 title: String?
let summary: String? let summary: String?
let grandparentTitle: String? let grandparentTitle: String?
@@ -396,7 +429,7 @@ struct PlexGridEntry: Decodable {
let media: [PlexGridMedia]? let media: [PlexGridMedia]?
private enum CodingKeys: String, CodingKey { 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" case media = "Media"
} }
@@ -416,7 +449,8 @@ struct PlexGridEntry: Decodable {
startsAt: Date(timeIntervalSince1970: TimeInterval(beginsAt)), startsAt: Date(timeIntervalSince1970: TimeInterval(beginsAt)),
endsAt: Date(timeIntervalSince1970: TimeInterval(endsAt)), endsAt: Date(timeIntervalSince1970: TimeInterval(endsAt)),
channelID: channelID, channelID: channelID,
thumb: thumb thumb: thumb,
metadataKey: key
) )
} }
} }
@@ -3,47 +3,53 @@
"size": 3, "size": 3,
"Metadata": [ "Metadata": [
{ {
"ratingKey": "epg://1", "ratingKey": "5f1",
"title": "Tagesschau", "key": "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2F6254592919e23c64f6c5605d",
"summary": "Nachrichten", "title": "Endlich Witwer",
"summary": "Komödie",
"type": "movie",
"beginsAt": 1757682000, "beginsAt": 1757682000,
"endsAt": 1757682900, "endsAt": 1757685600,
"Media": [ "Media": [
{ {
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-11", "channelCallSign": "ZDFGERH",
"channelTitle": "SRF 1 HD", "channelID": 5,
"channelCallSign": "SRF1", "channelIdentifier": "6228f7f2364e02fa5ec8e51c-5fc705f9c8d56d002e06f849",
"channelThumb": "/library/metadata/11/thumb", "channelThumb": "/library/metadata/5/thumb",
"channelVcn": "1", "channelTitle": "006 ZDFGERH (ZDF HD)",
"Part": [{ "key": "/livetv/channels/11" }] "channelVcn": "006",
"onAir": true
} }
] ]
}, },
{ {
"ratingKey": "epg://2", "ratingKey": "5f2",
"title": "Meteo", "key": "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fepisode%2Fabc",
"beginsAt": 1757682900, "title": "heute journal",
"endsAt": 1757683200, "type": "episode",
"beginsAt": 1757685600,
"endsAt": 1757687400,
"Media": [ "Media": [
{ {
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-11", "channelID": 5,
"channelTitle": "SRF 1 HD", "channelIdentifier": "6228f7f2364e02fa5ec8e51c-5fc705f9c8d56d002e06f849",
"channelVcn": 1, "channelTitle": "006 ZDFGERH (ZDF HD)",
"Part": [{ "key": "/livetv/channels/11" }] "channelVcn": "006"
} }
] ]
}, },
{ {
"ratingKey": "epg://3", "ratingKey": "5f3",
"key": "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fshow%2Fxyz",
"title": "Sport aktuell", "title": "Sport aktuell",
"beginsAt": 1757682000, "beginsAt": 1757682000,
"endsAt": 1757685600, "endsAt": 1757685600,
"Media": [ "Media": [
{ {
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-12",
"channelCallSign": "SRF2", "channelCallSign": "SRF2",
"channelVcn": "2", "channelID": 12,
"Part": [{ "key": "/livetv/channels/12" }] "channelIdentifier": "6228f7f2364e02fa5ec8e51c-12",
"channelVcn": "002"
} }
] ]
} }
@@ -44,7 +44,9 @@ struct LiveTVGuideTests {
let guide = await server.liveTVGuide(dvr: dvr) let guide = await server.liveTVGuide(dvr: dvr)
#expect(guide.channels.count == 2) #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") @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 guide = await server.liveTVGuide(dvr: dvr)
let second = try #require(guide.channels.last) let callSignOnly = try #require(guide.channels.first)
#expect(second.title == "SRF2") #expect(callSignOnly.title == "SRF2")
// Die Sendernummer kommt mal als Zahl, mal als Zeichenkette — beides #expect(callSignOnly.channelIdentifier == "002")
// muss lesbar sein, sonst fehlt sie bei der Hälfte der Sender.
#expect(second.channelIdentifier == "2")
} }
@Test("Sendungen landen beim richtigen Sender, nach Anfangszeit sortiert") @Test("Sendungen landen beim richtigen Sender, nach Anfangszeit sortiert")
@@ -69,10 +69,12 @@ struct LiveTVGuideTests {
]) ])
let guide = await server.liveTVGuide(dvr: dvr) let guide = await server.liveTVGuide(dvr: dvr)
let first = try #require(guide.channels.first) let zdf = try #require(guide.channels.last)
let programs = try #require(guide.programs[first.id]) 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) #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") }) #expect(lookup.trace.contains { $0.path == "/livetv/dvrs/2/channels" && $0.result.contains("Channel: 2") })
} }
@Test("Die Einschaltadresse reist mit dem Sender mit") @Test("Die Adresse der laufenden Sendung führt zum Strom")
func keepsTuneKey() async throws { 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: [ let server = server(routes: [
"/tv.plex.providers.epg.cloud:2/grid": try fixture("livetv-grid"), "/tv.plex.providers.epg.cloud:2/grid": try fixture("livetv-grid"),
]) ])
let guide = await server.liveTVGuide(dvr: dvr) 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")
} }
} }