Der Bericht nennt zwei Zeilen, die alles erklären:
Media.protocol = livetv
ratingKey = plex%3A%2F%2Fepisode%2F6a9a1255715fa1c9375df709
Plex führt die Kennung selbst schon kodiert — auch im JSON, wo es dafür
keinen Grund gäbe. Als Abfragewert geschickt dekodiert der Server sie
einmal, bevor er sie als Pfad liest, und bekommt `plex://episode/…`. Das
ist kein gültiger Schlüssel, und die Antwort darauf war die nackte
„400 Bad Request"-Seite.
Für eine Datei in der Mediathek ist das kein Thema: `/library/metadata/123`
enthält nichts zu kodieren. Deshalb ist beides nötig, und welche Form
gilt, hängt daran, woher die Adresse stammt.
Ich habe in diesem Punkt inzwischen jede Variante einmal für die richtige
gehalten — entpackt, einfach, doppelt. Ein Test hält jetzt alle drei
Formen fest, damit die Frage nicht ein viertes Mal aufkommt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
322 lines
14 KiB
Swift
322 lines
14 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 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")
|
|
}
|
|
}
|