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>
352 lines
16 KiB
Swift
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")
|
|
}
|
|
}
|