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
@@ -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")
}
}