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//` 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") } }