Die vollständige Sitzung zeigt es nebeneinander:
Tune-Antwort: …/sessions/df16c509…/B5080949-E767-…/index.m3u8
Sitzung selbst: …/sessions/df16c509…/2F3939E2-6196-…/index.m3u8
Dieselbe Adresse, aber mit verschiedenen Kennungen darin: die
Tune-Antwort setzt die Sitzungskennung ein, die Sitzung selbst die
Gerätekennung. Nur die zweite gibt es wirklich. Ich habe die erste
genommen — daher jedes Mal 404.
Gesehen habe ich das erst, als die Antwort nicht mehr bei 700 Zeichen
abgeschnitten war. Genau dort fing `Part` an.
Dieselbe Antwort trägt auch `TranscodeSession` — der Strom läuft bereits.
Es gab nie etwas umzurechnen, und der Transcoder hatte recht, jede dieser
Anfragen abzulehnen.
Die Abspieladresse kommt jetzt ausschließlich aus der Sitzung.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
461 lines
21 KiB
Swift
461 lines
21 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("Jeder Wert wird streng kodiert, Schrägstriche eingeschlossen")
|
|
func encodesEveryValueStrictly() async throws {
|
|
// Plex' eigener Client schickt alle Parameter durch `qs.stringify`,
|
|
// und das kodiert alles. `URLComponents` allein lässt `/`, `:`, `&`
|
|
// und `=` in Werten stehen — beim Server kam `path=/livetv/…` an
|
|
// statt `path=%2Flivetv%2F…`, und die Antwort war eine nackte 400.
|
|
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%2Fepisode%2Fabc"
|
|
))).absoluteString
|
|
|
|
#expect(url.contains("path=%2Ftv.plex.providers.epg.cloud%3A2%2Fmetadata%2F"))
|
|
// Eine Kennung, die schon `%3A` trägt, wird dabei zu `%253A` — und
|
|
// ist damit genau einmal kodiert.
|
|
#expect(url.contains("plex%253A%252F%252Fepisode%252Fabc"))
|
|
#expect(!url.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("Der Tuner wird per POST belegt, und die Antwort trägt den Strom")
|
|
func tunesThroughDVRWithPost() async throws {
|
|
// Nachgelesen im Quelltext von Plex' eigenem Client:
|
|
// POST /livetv/dvrs/{dvr}/channels/{kennung}/tune
|
|
// Genau diesen Pfad hatte ich seit Runden richtig — und jedes Mal
|
|
// mit GET abgefragt. Die Antwort darauf ist 404, und das sah aus wie
|
|
// „gibt es nicht" statt wie „nicht so".
|
|
let tuned = """
|
|
{"MediaContainer":{"size":1,"MediaSubscription":[{"key":"77",
|
|
"MetadataItem":{"ratingKey":"live-77","key":"/livetv/sessions/abc","type":"clip",
|
|
"title":"blue ZOOM HD",
|
|
"Media":[{"id":9,"Part":[{"id":9,
|
|
"key":"/livetv/sessions/abc/SITZUNG/index.m3u8?offset=-1.000000"}]}]}}]}}
|
|
"""
|
|
let server = server(routes: [
|
|
"/channels/6228f7f2-5fc705fa/tune": tuned,
|
|
"/index.m3u8": "#EXTM3U",
|
|
])
|
|
let channel = PlexChannel(id: "6228f7f2-5fc705fa", title: "blue ZOOM HD", key: "1")
|
|
let dvr = PlexDVR(key: "2")
|
|
|
|
let result = await server.tune(channel: channel, dvr: dvr, maximumBitrate: nil)
|
|
let url = try #require(result.url)
|
|
|
|
// Unmittelbar abgerufen, nicht umgerechnet: was beim Belegen
|
|
// herauskommt, ist bereits eine fertige HLS-Abspielliste.
|
|
#expect(url.path() == "/livetv/sessions/abc/SITZUNG/index.m3u8")
|
|
#expect(!url.absoluteString.contains("/transcode/"))
|
|
// Die eigene Abfrage der Sitzung darf nicht verlorengehen.
|
|
let items = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems)
|
|
#expect(items.contains { $0.name == "offset" && $0.value == "-1.000000" })
|
|
// Und der Systemplayer schickt keine Kopfzeilen mit.
|
|
#expect(items.contains { $0.name == "X-Plex-Token" })
|
|
#expect(items.contains { $0.name == "X-Plex-Client-Identifier" })
|
|
#expect(result.trace.contains { $0.path.hasPrefix("POST ") })
|
|
// Womit der Tuner hinterher wieder freigegeben wird.
|
|
#expect(result.subscriptionKey == "77")
|
|
}
|
|
|
|
@Test("Umgerechnet wird die Sitzung des Servers, nicht die eigene Kennung")
|
|
func transcodesTheServersSession() throws {
|
|
// `/livetv/sessions/1268be73-…` ist die Sitzung, die der Server beim
|
|
// Belegen angelegt hat. Ich habe mehrfach meine eigene erzeugte
|
|
// Kennung eingesetzt und mich über 404 gewundert.
|
|
let json = Data("""
|
|
{"MediaContainer":{"MediaSubscription":[{"key":"77","MetadataItem":{
|
|
"key":"/livetv/sessions/1268be73-1598-4fb9-ac9e-35825eba3fdd","live":"1",
|
|
"Media":[{"id":1,"Part":[{"key":"/livetv/sessions/1268be73/ABC/index.m3u8?offset=-1"}]}]}}]}}
|
|
""".utf8)
|
|
|
|
// Die Abspielliste selbst ist nicht gemeint — sie endet auf .m3u8.
|
|
#expect(PlexServer.firstSessionKey(in: json)
|
|
== "/livetv/sessions/1268be73-1598-4fb9-ac9e-35825eba3fdd")
|
|
}
|
|
|
|
@Test("Das Geräteprofil zerfällt nicht in eigene Parameter")
|
|
func encodesProfileExtra() async throws {
|
|
// `URLComponents` lässt `&` und `=` in einem Wert unangetastet. Das
|
|
// Geräteprofil steckt voll davon, und der Server las es als zwei
|
|
// Dutzend eigene Angaben — darunter ein zweites `protocol=`. Der
|
|
// Fehler steckte in jeder Transcoder-Adresse, seit es sie gibt.
|
|
let server = server(routes: [:])
|
|
let url = try #require(
|
|
await server.transcodeURL(for: TranscodeRequest(ratingKey: "5", isLocal: true))
|
|
)
|
|
let text = url.absoluteString
|
|
let profile = try #require(text.split(separator: "&")
|
|
.first { $0.hasPrefix("X-Plex-Client-Profile-Extra=") })
|
|
|
|
// Das ganze Profil steht in genau einem Parameter.
|
|
#expect(profile.contains("%26"))
|
|
#expect(profile.contains("add-transcode-target%28"))
|
|
#expect(profile.contains("%2B"))
|
|
// Und `protocol` kommt nur ein einziges Mal vor.
|
|
#expect(text.split(separator: "&").filter { $0.hasPrefix("protocol=") }.count == 1)
|
|
}
|
|
|
|
@Test("Jede Wiedergabe meldet, dass sie die Entscheidungsantwort versteht")
|
|
func alwaysSendsHasMDE() {
|
|
// Plex' eigener Client schickt `hasMDE=1` bei jeder Wiedergabe mit.
|
|
// Ohne diese Angabe lehnt der Server eine Live-Sitzung mit einer
|
|
// nackten 400 ab — ohne zu sagen, was ihm fehlt.
|
|
let items = TranscodeRequest(ratingKey: "5", isLocal: true).queryItems
|
|
|
|
#expect(items.contains { $0.name == "hasMDE" && $0.value == "1" })
|
|
}
|
|
|
|
@Test("Die Abspieladresse kommt aus der Sitzung, nicht aus der Tune-Antwort")
|
|
func prefersPartFromSession() async throws {
|
|
// Beide Antworten nennen eine Adresse derselben Form, aber mit
|
|
// verschiedenen Kennungen darin: die Tune-Antwort setzt die
|
|
// Sitzungskennung ein, die Sitzung selbst die Gerätekennung. Nur die
|
|
// zweite gibt es wirklich — die erste ergab jedes Mal 404.
|
|
let tuned = """
|
|
{"MediaContainer":{"size":1,"MediaSubscription":[{"key":"22",
|
|
"MetadataItem":{"key":"/livetv/sessions/abc","live":"1",
|
|
"Media":[{"id":1,"Part":[{"key":"/livetv/sessions/abc/SITZUNGSKENNUNG/index.m3u8"}]}]}}]}}
|
|
"""
|
|
let session = """
|
|
{"MediaContainer":{"size":1,"Metadata":[{"key":"/livetv/sessions/abc","live":"1",
|
|
"Media":[{"id":1,"Part":[{"key":"/livetv/sessions/abc/GERAETEKENNUNG/index.m3u8?offset=-1.000000"}]}]}]}}
|
|
"""
|
|
let server = server(routes: [
|
|
"/channels/kanal-7/tune": tuned,
|
|
"/livetv/sessions/abc": session,
|
|
// Nur unter der Gerätekennung liefert der Server eine
|
|
// Abspielliste; die andere Form gibt es nicht.
|
|
"/GERAETEKENNUNG/index.m3u8": "#EXTM3U",
|
|
])
|
|
let channel = PlexChannel(id: "kanal-7", title: "SRF 1 HD", key: "2")
|
|
|
|
let result = await server.tune(channel: channel, dvr: PlexDVR(key: "2"), maximumBitrate: nil)
|
|
let url = try #require(result.url)
|
|
|
|
#expect(url.path() == "/livetv/sessions/abc/GERAETEKENNUNG/index.m3u8")
|
|
#expect(!url.absoluteString.contains("SITZUNGSKENNUNG"))
|
|
#expect(!url.absoluteString.contains("/transcode/"))
|
|
}
|
|
|
|
@Test("Die Teil-Adresse wird gefunden, egal wie tief sie liegt")
|
|
func findsPartAtAnyDepth() throws {
|
|
// Der Vorgang liegt je nach Serverfassung unterschiedlich tief
|
|
// verschachtelt. Ein fest verdrahteter Weg hinein sähe wieder aus
|
|
// wie „nichts da".
|
|
let json = Data("""
|
|
{"MediaContainer":{"Tief":{"Tiefer":[{"Media":[{"Part":[{"key":"/gefunden"}]}]}]}}}
|
|
""".utf8)
|
|
|
|
#expect(PlexServer.firstPartKey(in: json) == "/gefunden")
|
|
#expect(PlexServer.firstPartKey(in: Data("{}".utf8)) == nil)
|
|
}
|
|
|
|
@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")
|
|
}
|
|
}
|