HTTP 400 vom Transcoder. Die Kennung dieses Geräts reist bei jeder Anfrage als Kopfzeile mit — aber die Adresse des Stroms ruft nicht die App ab, sondern AVPlayer, und der schickt keine Kopfzeilen mit. Bei Dateien fällt das nicht auf; bei Live-TV schon: der Server legt die Sitzung unter der Gerätekennung an und kann die Abholung ohne sie keiner Sitzung zuordnen. Also trägt die Transcoder-Adresse die Kennung jetzt selbst. Das gilt für alle Ströme, nicht nur für Live — dort fiel es nur zuerst auf. Dazu: umgerechnet wird die Adresse der Sitzung, nicht die des Teils darin. Der Transcoder sucht sich den Teil selbst, deshalb steht dort -1. Und der Entscheider unterscheidet jetzt zwischen „nein" und „keine Antwort". Ein Schweigen als Ablehnung zu lesen hieße, eine funktionierende Sendung zu blocken, bloß weil er Live-Adressen womöglich gar nicht kennt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
246 lines
11 KiB
Swift
246 lines
11 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 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)
|
|
|
|
// Die Adresse der Sitzung, nicht die des Teils darin.
|
|
#expect(items.first { $0.name == "path" }?.value == "/livetv/sessions/abc")
|
|
// 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.
|
|
#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")
|
|
}
|
|
}
|