Files
Flaro/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift
T
ScarriffleandClaude Opus 5 12bb49615b Player: die Qualitätsleiter des Servers, Bitrate im Blick, Doppeltipp
Die vier erfundenen Stufen weichen der Leiter, die der Server selbst
meldet: 64, 96, 208, 320, 720, 1500, 2000, 3000, 4000, 8000, 10000,
12000, 20000 kbps mit den Auflösungen, die Plex dafür vorsieht. Eine
eigene Leiter hieße, Stufen anzubieten, die der Transcoder nicht kennt —
er rundet dann still auf die nächste, und die Anzeige log.

Angeboten wird nur, was zur Quelle passt. Eine 720p-Datei auf 1080p
hochzurechnen bringt kein Bild dazu, und die Stufen sähen alle gleich aus.

Oben im Blatt steht, was tatsächlich über die Leitung kommt — gemessene
Bitrate, Auflösung, verworfene Bilder. Der Unterschied zur gewählten
Stufe ist keine Haarspalterei: die eine sagt, was erlaubt ist, die andere,
was ankommt. Ruckelt es trotz hoher Stufe, steht die Antwort dort.

„Nur Ton" gibt es, aber ehrlich beschriftet: Plex kennt für Videos keinen
reinen Tonstrom, also wird die unterste Sprosse geholt und das Bild
ausgeblendet.

Dazu Doppeltipp links und rechts für zehn Sekunden zurück und vor. Die
Reihenfolge der Gesten ist entscheidend — das einfache Tippen verschluckt
sonst das doppelte.

Die Kachel hängt nicht mehr in vier Ecken fest, sondern klebt links oder
rechts und ist in der Höhe frei. Vier Ecken sind schnell gebaut, aber im
Weg: steht sie über einem Knopf, bleiben drei Orte, die ebenso stören.
Größer wird sie durch Zusammen- und Auseinanderziehen; der Griff ist weg,
er saß zwangsläufig auf dem Bild und stritt sich mit dem Verschieben.

Und die Begriffe: „Motor" für engine sagt kein deutscher Entwickler.
Fachwörter, die im Deutschen englisch bleiben, bleiben jetzt englisch.

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

534 lines
24 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.
/// Hält fest, womit tatsächlich gefragt wurde.
///
/// Ein Actor, weil der Transport aus einem anderen Zusammenhang schreibt und
/// Swift 6 das sonst zu Recht verweigert.
private actor QueryRecorder {
private(set) var items: [URLQueryItem] = []
func note(_ new: [URLQueryItem]) { items = new }
}
@Suite("Senderliste aus dem Programmraster")
struct LiveTVGuideTests {
private func server(transport: FakeTransport) -> 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: transport
)!
}
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)
)!
}
// Die Zeiten in `livetv-grid.json` liegen bewusst weit in der Zukunft:
// das Programm wird auf Frische gefiltert, und ein Abzug mit den echten
// Zeitstempeln von damals wäre ab dem Tag nach der Aufnahme leer gewesen.
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("Die laufende Sendung fällt nicht aus dem Fenster")
func keepsTheRunningProgramme() async throws {
// Der Fehler, der das Programm sichtbar falsch machte: gefiltert wurde
// auf „endet vor Fensterende" und „beginnt nach Fensteranfang". Ein
// Zweistünder ab 15:30 war damit um 17:18 unsichtbar, und angezeigt
// wurde die nächste Sendung, die durch den Filter passte.
let body = try fixture("livetv-grid-overlap")
let recorder = QueryRecorder()
let server = server(transport: FakeTransport { request in
await recorder.note(
URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
)
return (Data(body.utf8), FakeTransport.response(for: request, status: 200))
})
// 17:18 — mitten im langen Film.
let now = Date(timeIntervalSince1970: 1_789_211_880)
let entries = try await server.guide(
providerIdentifier: "tv.plex.providers.epg.cloud:2",
from: now,
to: now.addingTimeInterval(6 * 3_600)
)
#expect(entries.map(\.program.title).contains("Der lange Film"))
#expect(entries.first?.program.isRunning(at: now) == true)
// Und der Grund dafür steht in der Abfrage: nur der Anfang wird
// eingegrenzt, mit Vorlauf — keine Grenze auf das Ende.
let seen = await recorder.items
#expect(seen.contains { $0.name == "beginsAt<" })
#expect(!seen.contains { $0.name == "endsAt<" })
}
@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 Stream")
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 nennt ein Basisprofil")
func alwaysNamesBaseProfile() async throws {
// Ohne `X-Plex-Client-Profile-Name` lehnt der Server jede Anfrage an
// den Transcoder mit einer nackten 400 ab. `…-Profile-Extra`
// erweitert ein Profil, es ersetzt keins — ohne Basis gibt es nichts
// zu erweitern. Das hat zwei Tage gekostet.
let server = server(routes: [:])
let url = try #require(
await server.transcodeURL(for: TranscodeRequest(ratingKey: "5", isLocal: true))
).absoluteString
#expect(url.contains("X-Plex-Client-Profile-Name=Chrome"))
}
@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")
}
}