Files
Flaro/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift
T
ScarriffleandClaude Opus 5 08e6e14534 Live-TV: der Empfänger spricht eine andere Sprache als der Programmführer
Aus der Antwort von /media/grabbers/devices/1:

    {"channelKey":"6228f7f2364e02fa5ec8e51c-5fc706063f45c6002eab58a3",
     "deviceIdentifier":"300",
     "lineupIdentifier":"002"}

Der Programmführer kennt den Sender als `6228f7f2…-5fc706…`, der Tuner
als `300`. Die Zuordnung im Empfänger übersetzt zwischen beidem — und
genau diese Übersetzung habe ich nie benutzt. Jede Anfrage an den
Empfänger ging in der Sprache des Programmführers hinaus, und darauf gibt
es nur eine Antwort: 404.

Die Nummer am Empfänger steht jetzt an erster Stelle im Suchraum und im
Bericht. Der Empfänger ist ein HDHomeRun FLEX QUATRO mit vier Tunern; er
kennt Sender ausschließlich unter dieser Nummer.

Nebenbei aus derselben Ausgabe: /livetv/sessions antwortet mit size 0.
Die Route gibt es also — sie ist eine Liste. /livetv/sessions/<uuid> gibt
es nur, wenn die Sitzung vorher angelegt wurde, und deshalb war jeder
Abruf dort ein 404 ohne Aussagekraft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 15:18:12 +02:00

352 lines
16 KiB
Swift

import Foundation
import Testing
@testable import PlexKit
/// Woher die Senderliste kommt.
///
/// Drei Adressen, unter denen die Senderliste stehen sollte, antworteten auf
/// einem echten Server mit 404 — geraten war jede. Das Programmraster ist
/// keine Vermutung: die Weboberfläche von Plex zeichnet ihren Guide daraus,
/// und jeder Eintrag trägt den Sender mit sich. Diese Tests halten fest, dass
/// die Sender dort auch dann herauskommen, wenn sonst nichts antwortet.
@Suite("Senderliste aus dem Programmraster")
struct LiveTVGuideTests {
private func server(routes: [String: String]) -> PlexServer {
PlexServer(
resource: PlexResource(
name: "Test",
clientIdentifier: "srv",
accessToken: "token",
connections: [PlexConnection(uri: "http://server:32400", local: true)]
),
connection: PlexConnection(uri: "http://server:32400", local: true),
identity: .test,
transport: FakeTransport.routes(routes)
)!
}
private func fixture(_ name: String) throws -> String {
let url = try #require(Bundle.module.url(forResource: "Fixtures/\(name)", withExtension: "json"))
return try String(contentsOf: url, encoding: .utf8)
}
private var dvr: PlexDVR {
PlexDVR(key: "2", epgIdentifier: "tv.plex.providers.epg.cloud:2")
}
@Test("Jeder Sender kommt genau einmal, obwohl er in mehreren Sendungen steht")
func derivesChannelsFromGrid() async throws {
let server = server(routes: [
"/tv.plex.providers.epg.cloud:2/grid": try fixture("livetv-grid"),
])
let guide = await server.liveTVGuide(dvr: dvr)
#expect(guide.channels.count == 2)
// 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")
func fallsBackToCallSign() async throws {
let server = server(routes: [
"/tv.plex.providers.epg.cloud:2/grid": try fixture("livetv-grid"),
])
let guide = await server.liveTVGuide(dvr: dvr)
let callSignOnly = try #require(guide.channels.first)
#expect(callSignOnly.title == "SRF2")
#expect(callSignOnly.channelIdentifier == "002")
}
@Test("Sendungen landen beim richtigen Sender, nach Anfangszeit sortiert")
func groupsProgramsByChannel() async throws {
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 programs = try #require(guide.programs[zdf.id])
// 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)
}
@Test("Ein Sender ohne Namen taucht nicht als Strich auf")
func skipsNamelessChannels() async throws {
let json = """
{"MediaContainer":{"size":1,"Metadata":[
{"ratingKey":"x","title":"Irgendwas","beginsAt":1757682000,"endsAt":1757685600,
"Media":[{"channelIdentifier":"nur-eine-kennung","Part":[{"key":"/livetv/channels/9"}]}]}
]}}
"""
let server = server(routes: ["/tv.plex.providers.epg.cloud:2/grid": json])
let guide = await server.liveTVGuide(dvr: dvr)
#expect(guide.channels.isEmpty)
}
@Test("Die Senderliste des Empfängers wird gefunden, ohne dass sie geraten wird")
func sweepsUntilSomethingAnswers() async throws {
// Der Fall, der viermal danebenging: die Senderliste steht weder beim
// Programmanbieter noch unter dem Empfänger, sondern beim Gerät — und
// sie schreibt den Namen in `name` statt in `title`.
let device = """
{"MediaContainer":{"size":2,"Channel":[
{"name":"SRF 1 HD","vcn":1,"key":"/livetv/channels/11","enabled":"1"},
{"name":"SRF zwei HD","vcn":2,"key":"/livetv/channels/12","enabled":"1"}
]}}
"""
let server = server(routes: ["/media/grabbers/devices/tuner-7/channels": device])
let withTuner = PlexDVR(
key: "2",
devices: [PlexTunerDevice(key: "tuner-7")],
epgIdentifier: "tv.plex.providers.epg.cloud:2"
)
let lookup = await server.channels(inDVR: withTuner)
#expect(lookup.channels.map(\.title) == ["SRF 1 HD", "SRF zwei HD"])
#expect(lookup.channels.first?.channelIdentifier == "1")
}
@Test("Jede abgeklopfte Adresse steht mit ihrer Antwort in der Spur")
func recordsWhatEveryPathAnswered() async throws {
// Ohne diese Spur ist „keine Sender" eine Sackgasse: sie sagt nicht,
// ob der Server nichts hergibt oder die App am falschen Ort sucht.
let server = server(routes: [:])
let withTuner = PlexDVR(key: "2", epgIdentifier: "tv.plex.providers.epg.cloud:2")
let lookup = await server.channels(inDVR: withTuner)
#expect(lookup.channels.isEmpty)
#expect(lookup.trace.count >= 3)
#expect(lookup.trace.allSatisfy { !$0.result.isEmpty })
#expect(lookup.trace.contains { $0.path == "/livetv/dvrs/2/channels" })
}
@Test("Eine Liste aus Strichen zählt nicht als Senderliste")
func rejectsNamelessChannels() async throws {
// Genau das ist einmal durchgegangen: dreißig Einträge ohne Namen
// sahen aus wie eine Senderliste und verdrängten die richtige Quelle.
let nameless = """
{"MediaContainer":{"size":2,"Channel":[
{"channelKey":"a","enabled":"1"},{"channelKey":"b","enabled":"1"}
]}}
"""
let server = server(routes: ["/livetv/dvrs/2/channels": nameless])
let withTuner = PlexDVR(key: "2", epgIdentifier: "tv.plex.providers.epg.cloud:2")
let lookup = await server.channels(inDVR: withTuner)
#expect(lookup.channels.isEmpty)
#expect(lookup.trace.contains { $0.path == "/livetv/dvrs/2/channels" && $0.result.contains("Channel: 2") })
}
@Test("Die Adresse der Sendung wird genau einmal kodiert")
func encodesPathExactlyOnce() async throws {
// Beide naheliegenden Wege liegen daneben, und beide habe ich
// durchgemacht: unverändert durchgereicht kodiert `URLComponents` ein
// zweites Mal (%3A wird %253A); vorher entpackt steht `plex://…` im
// Klartext in der Anfrage, und daran verschluckt sich der Server mit
// einer nackten „400 Bad Request"-Seite.
let server = server(routes: [:])
let url = try #require(await server.transcodeURL(for: TranscodeRequest(
ratingKey: "5",
isLocal: true,
pathOverride: "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2Fabc"
)))
let text = url.absoluteString
#expect(text.contains("path=/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2Fabc"))
#expect(!text.contains("%253A"))
#expect(!text.contains("plex://"))
}
@Test("Für eine Sendung wird die Kennung zweimal kodiert")
func doubleEncodesForLive() async throws {
// Der Server dekodiert den Abfragewert einmal, bevor er ihn als Pfad
// liest. Genau einmal kodiert kommt bei ihm `plex://episode/…` an —
// kein gültiger Schlüssel, und die Antwort ist eine nackte 400.
let server = server(routes: [:])
let request = TranscodeRequest(
ratingKey: "5",
isLocal: true,
pathOverride: "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fepisode%2Fabc"
)
let once = try #require(await server.transcodeURL(for: request)).absoluteString
let twice = try #require(
await server.transcodeURL(for: request, keepPathEncoding: false)
).absoluteString
#expect(once.contains("plex%3A%2F%2Fepisode%2Fabc"))
#expect(twice.contains("plex%253A%252F%252Fepisode%252Fabc"))
// Und niemals im Klartext: daran verschluckt sich der Server.
#expect(!twice.contains("plex://"))
}
@Test("Nur was tatsächlich mit 200 antwortet, geht an den Player")
func rejectsStreamsTheServerRefuses() async throws {
// Vorher galt schon eine gebaute Adresse als Erfolg. Der Fehler
// tauchte dann erst im Player auf — als „HTTP 400" ohne Begründung.
let session = """
{"MediaContainer":{"size":1,"Metadata":[
{"ratingKey":"live-1","key":"/livetv/sessions/abc","type":"clip","title":"ZDF HD"}
]}}
"""
let server = server(routes: ["/gk-5": session])
let channel = PlexChannel(id: "5", title: "ZDF HD", key: "5", gridKey: "gk-5")
let result = await server.tune(channel: channel, maximumBitrate: nil)
#expect(result.url == nil)
// Und die Antwort des Servers steht in der Spur, nicht nur ein Code.
#expect(result.trace.contains { $0.result.hasPrefix("HTTP 404") })
}
@Test("Erst den Tuner belegen, dann umrechnen lassen")
func opensSessionBeforeTranscoding() async throws {
// Der übersprungene Schritt: ohne belegten Tuner beginnt die
// Wiedergabe und stirbt sofort — „die Wiedergabe wurde unterbrochen".
let session = """
{"MediaContainer":{"size":1,"Metadata":[
{"ratingKey":"live-1","key":"/livetv/sessions/abc","type":"clip","title":"ZDF HD",
"Media":[{"id":1,"Part":[{"id":1,"key":"/livetv/sessions/abc/stream"}]}]}
]}}
"""
// Nur unter dem Raster-Schlüssel antwortet eine Sitzung, und der
// Transcoder liefert erst danach eine Abspielliste.
let server = server(routes: ["/gk-5": session, "/start.m3u8": "#EXTM3U"])
let channel = PlexChannel(id: "5", title: "ZDF HD", key: "5", gridKey: "gk-5")
let result = await server.tune(channel: channel, maximumBitrate: nil)
let url = try #require(result.url)
let items = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems)
// Die Adresse der Sitzung, nicht die des Senders und nicht die der
// Sendung — der Transcoder sucht sich den Teil darin selbst.
let path = try #require(items.first { $0.name == "path" }?.value)
#expect(path.hasPrefix("/livetv/sessions/"))
// Ein laufender Sender hat keine Fassungen zur Auswahl.
#expect(items.first { $0.name == "mediaIndex" }?.value == "-1")
// Ohne die Gerätekennung in der Adresse kann der Server die Abholung
// keiner Sitzung zuordnen: AVPlayer schickt keine Kopfzeilen mit.
#expect(items.contains { $0.name == "X-Plex-Client-Identifier" })
}
@Test("Der Raster-Schlüssel wird zuerst versucht, und nie ein ganzer Pfad")
func sessionKeyOrder() {
let channel = PlexChannel(id: "hash-5", title: "ZDF HD", key: "5", gridKey: "gk-5")
let program = PlexProgram(
id: "p1",
title: "heute",
startsAt: .now,
endsAt: .now.addingTimeInterval(1800),
metadataKey: "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2Fabc"
)
let keys = PlexServer.sessionKeys(channel: channel, program: program)
#expect(keys.first == "gk-5")
// An `/livetv/sessions/<uuid>/` gehört ein Schlüssel, kein zweiter
// Pfad — auch nicht nach dem Entpacken.
#expect(keys.allSatisfy { !$0.contains("/") })
#expect(keys.contains("5"))
}
@Test("Die nachgeschlagene Sendung liefert die abspielbare Adresse")
func usesPartFromLookup() async throws {
// Das Raster nennt keine Teil-Adresse, die Einzelabfrage schon. Sie
// blind an den Transcoder zu geben, ergab dreimal eine nackte 400:
// eine Sendung beim Cloud-Programmführer hat keine Datei.
let metadata = """
{"MediaContainer":{"size":1,"Metadata":[
{"ratingKey":"epg-1","key":"/tv.plex.providers.epg.cloud:2/metadata/x","type":"movie",
"title":"Tagesschau",
"Media":[{"id":1,"Part":[{"id":1,"key":"/livetv/sessions/xyz/stream"}]}]}
]}}
"""
let server = server(routes: [
"/metadata/plex%3A%2F%2Fmovie%2Fabc": metadata,
"/start.m3u8": "#EXTM3U",
])
let channel = PlexChannel(id: "5", title: "SRF 1 HD", key: "5")
let program = PlexProgram(
id: "p1",
title: "Tagesschau",
startsAt: .now,
endsAt: .now.addingTimeInterval(1800),
metadataKey: "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2Fabc"
)
let result = await server.tune(channel: channel, program: program, maximumBitrate: nil)
let url = try #require(result.url)
let path = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.first { $0.name == "path" }?.value)
#expect(path == "/livetv/sessions/xyz/stream")
}
@Test("Die Zuordnung übersetzt zwischen Programmführer und Empfänger")
func translatesChannelKeyToDeviceChannel() {
// Der Programmführer kennt den Sender als `6228f7f2…-5fc706…`, der
// Tuner als `300`. Ohne diese Übersetzung spricht man den Empfänger
// in einer Sprache an, die er nicht versteht — und bekommt 404 auf
// alles. Genau daran ist das Einschalten reihenweise gescheitert.
let dvr = PlexDVR(
key: "2",
devices: [PlexTunerDevice(key: "1", channelMappings: [
PlexChannelMapping(
channelKey: "6228f7f2364e02fa5ec8e51c-5fc706063f45c6002eab58a3",
deviceIdentifier: "300",
lineupIdentifier: "002"
),
PlexChannelMapping(
channelKey: "6228f7f2364e02fa5ec8e51c-5fc705fa6b022a002d77f212",
deviceIdentifier: "299",
lineupIdentifier: "001"
),
])]
)
#expect(dvr.deviceChannel(forChannelKey: "6228f7f2364e02fa5ec8e51c-5fc706063f45c6002eab58a3") == "300")
#expect(dvr.deviceChannel(forChannelKey: "6228f7f2364e02fa5ec8e51c-5fc705fa6b022a002d77f212") == "299")
// Auch ohne den Vorsatz der Senderliste, denn die Kennung kommt je
// nach Quelle mit oder ohne ihn.
#expect(dvr.deviceChannel(forChannelKey: "5fc706063f45c6002eab58a3") == "300")
#expect(dvr.deviceChannel(forChannelKey: "unbekannt") == nil)
}
@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")
}
}