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 laufenden Sendung führt zum Strom") func tunesThroughTheProgram() async throws { // Das Raster nennt keine Teil-Adresse. Der Weg führt über die // Sendung selbst: Plex behandelt eine laufende Sendung wie einen Film. 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 program = try #require(guide.programs[zdf.id]?.first) // Hier antwortet keine Sitzung; dann bleibt der direkte Weg über die // Sendung — auf Servern, die den Tuner selbst belegen, genügt er. let result = await server.tune(channel: zdf, 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 == "/tv.plex.providers.epg.cloud:2/metadata/plex://movie/6254592919e23c64f6c5605d") // Doppelt kodiert fände der Server nichts — aus %3A würde %253A. #expect(!url.absoluteString.contains("%253A")) } @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. 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) let url = try #require(result.url) let items = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) #expect(items.first { $0.name == "path" }?.value == "/livetv/sessions/abc/stream") // Ein laufender Sender hat keine Fassungen zur Auswahl. #expect(items.first { $0.name == "mediaIndex" }?.value == "-1") } @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. #expect(keys.allSatisfy { !$0.contains("/") }) #expect(keys.contains("5")) } @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") } }