Plex ist raus — und nahm zwei stille Fehler mit

Vollständig entfernt: das Paket `PlexKit` samt 108 Tests, der
Web-Authenticator, die Anmeldung über plex.tv, die Serverauswahl und alles,
was daran hing. 43 Dateien gelöscht, 68 Stellen in 28 weiteren bereinigt.

**Zwei Dinge hätten beim blossen Streichen still das Falsche getan.**

`playbackUnavailableMessage` deckte im Else-Zweig zwei Lagen gleichzeitig ab
— „es ist Plex" und „niemand ist angemeldet". Wer die Serverart naiv
herausstreicht, bekommt als nicht angemeldeter Nutzer „Jellyfin-Wiedergabe
steht aus" statt „Server nicht erreichbar". Die Unterscheidung hängt jetzt
daran, ob überhaupt ein Server da ist.

Die Schlüsselbund-Einträge `plex.account` und `plex.server` wurden allein
über den Authenticator aus dem gelöschten Paket entfernt. Danach stünde
`plex.account` in keiner Zeile dieses Projekts mehr: wer sich nie abgemeldet
hat, trüge ein gültiges Token am fremden Konto bis in alle Ewigkeit mit sich,
ohne dass irgendein Weg es noch löschen könnte. `purgePlexLeftovers()` räumt
beide Token und fünf Voreinstellungen einmalig ab — und fasst nichts an, wo
nichts liegt, damit kein Jellyfin-Nutzer dafür seinen Mediathekenspeicher
verliert.

**Was Nutzer sehen.** Die Berechtigungsfrage fürs lokale Netz sagte wörtlich,
die App brauche es, „um deinen Plex-Server zu finden" — das stand so im
Systemdialog. Der Anmeldebildschirm hatte zwei Knöpfe, und der hervorgehobene
war der für Plex; der verbliebene erbt die Hervorhebung, denn ein Knopf, der
aussieht wie die zweite Wahl, lässt nach der ersten suchen. 14 verwaiste
Textbausteine sind weg, vier umformuliert.

Darunter einer, der schlicht nicht mehr stimmte: `signIn.privacy` versprach,
das Passwort erreiche diese App nie. Beim Jellyfin-Rückfall auf das Passwort
tut es das sehr wohl. Die Zusage, die weiterhin gilt, ist die über fremde
Dienste — und nur die steht jetzt da.

**Was ausdrücklich bleibt:** `MediaBackend`. Diese Abstraktion hat zwei
Server gleichzeitig getragen, und der Steckplatz, den Plex räumt, ist genau
der, auf den Halo kommt. `ServerKind` fällt dagegen weg — mit einem Fall ist
es ein Bool, das `backend != nil` schon beantwortet.

Nebenbei zog die README nach. Sie beschrieb noch einen „nativen Plex-Client"
mit zwei Playern und VLCKit, beides seit Längerem nicht mehr wahr.

368 Tests grün (von 476 minus die 108 aus PlexKitTests, kein anderer).
iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 11:10:20 +02:00
co-authored by Claude Opus 5
parent 93695dc332
commit 2fcf6821c8
68 changed files with 231 additions and 9425 deletions
@@ -1,5 +1,5 @@
import Foundation
import PlexKit
import MediaKit
import Testing
@testable import FlixyDownloads
@@ -1,101 +0,0 @@
import Foundation
import Testing
@testable import PlexKit
@Suite("Anmelde-Adresse")
struct AuthorizationURLTests {
private let pin = PlexPin(id: 42, code: "5474")
private func url() throws -> URL {
try #require(pin.authorizationURL(clientIdentifier: "abc-123", product: "Flixy"))
}
@Test("Die Klammern der Kontext-Parameter werden nicht doppelt kodiert")
func bracketsAreEncodedExactlyOnce() throws {
// Der Fehler, der plex.tv „Diese Anfrage konnte nicht abgeschlossen
// werden" antworten ließ: eine bereits kodierte Klammer (%5B) wurde
// beim Setzen des Fragments ein zweites Mal kodiert (%255B). Plex sah
// dann einen Parameter namens „context%5Bdevice%5D…" statt
// „context[device]…" und konnte nichts damit anfangen.
let text = try url().absoluteString
#expect(!text.contains("%25"), "doppelt kodiert: \(text)")
#expect(text.contains("context%5Bdevice%5D%5Bproduct%5D=Flixy"))
}
@Test("Code und Client-Kennung stehen im Fragment")
func carriesCodeAndClient() throws {
let text = try url().absoluteString
#expect(text.hasPrefix("https://app.plex.tv/auth#?"))
#expect(text.contains("clientID=abc-123"))
#expect(text.contains("code=5474"))
}
@Test("Produktnamen mit Leerzeichen werden kodiert")
func productNamesWithSpacesAreEncoded() throws {
// Der Name steht noch nicht fest. Wird daraus „Flixy Player", darf
// das Leerzeichen die Adresse nicht zerreißen.
let url = try #require(pin.authorizationURL(clientIdentifier: "abc", product: "Flixy Player"))
#expect(url.absoluteString.contains("Flixy%20Player"))
#expect(!url.absoluteString.contains("Flixy Player"))
}
}
@Suite("Anmelde-Adresse mit Rücksprung")
struct AuthorizationForwardURLTests {
private let pin = PlexPin(id: 42, code: "5474")
@Test("Ohne Rückadresse bleibt die Adresse wie bisher")
func withoutForwardURL() throws {
let url = try #require(pin.authorizationURL(clientIdentifier: "abc", product: "Flixy"))
#expect(!url.absoluteString.contains("forwardUrl"))
}
@Test("Die Rückadresse wird angehängt und kodiert")
func forwardURLIsAppendedAndEncoded() throws {
// Das Schema enthält Doppelpunkte und Schrägstriche. Unkodiert
// zerrissen sie die Adresse, und Plex führte den Rücksprung nie aus —
// das Anmeldefenster bliebe offen stehen.
let url = try #require(pin.authorizationURL(
clientIdentifier: "abc",
product: "Flixy",
forwardURL: URL(string: "com.scarriffleservices.flixy://auth")!
))
let text = url.absoluteString
#expect(text.contains("forwardUrl=com.scarriffleservices.flixy%3A%2F%2Fauth"))
#expect(!text.contains("%25"))
}
@Test("Die übrigen Parameter bleiben unversehrt")
func otherParametersSurvive() throws {
let url = try #require(pin.authorizationURL(
clientIdentifier: "abc",
product: "Flixy",
forwardURL: URL(string: "flixy://auth")!
))
let text = url.absoluteString
#expect(text.contains("clientID=abc"))
#expect(text.contains("code=5474"))
#expect(text.contains("context%5Bdevice%5D%5Bproduct%5D=Flixy"))
}
}
@Suite("Verknüpfungsseite")
struct LinkURLTests {
@Test("Der Code steht in der Adresse")
func carriesTheCode() throws {
let pin = PlexPin(id: 1, code: "S6J9")
let url = try #require(pin.linkURL)
#expect(url.absoluteString == "https://plex.tv/link/?pin=S6J9")
}
@Test("Sonderzeichen im Code werden kodiert")
func encodesUnusualCodes() throws {
// Kurze Codes sind alphanumerisch, aber darauf verlassen möchte ich
// mich nicht — ein ungeschütztes `&` würde die Adresse zerreißen.
let pin = PlexPin(id: 1, code: "A&B=C")
let url = try #require(pin.linkURL)
#expect(!url.absoluteString.contains("&B"))
#expect(url.absoluteString.contains("A%26B%3DC"))
}
}
@@ -1,228 +0,0 @@
import Foundation
import Testing
@testable import PlexKit
@Suite("Schriftzüge und Bildliste")
struct ClearLogoTests {
private func item(from json: String) throws -> PlexItem {
let data = Data(#"{"MediaContainer":{"Metadata":[\#(json)]}}"#.utf8)
return try #require(
try PlexResponse.decode(PlexMediaContainer<PlexItem>.self, from: data).content.first
)
}
@Test("Der Schriftzug wird aus der Bildliste gelesen")
func findsClearLogo() throws {
let decoded = try item(from: """
{
"ratingKey": "1", "type": "movie", "title": "Harry Potter",
"Image": [
{"alt": "Harry Potter", "type": "coverPoster", "url": "/library/metadata/1/thumb/1"},
{"alt": "Harry Potter", "type": "background", "url": "/library/metadata/1/art/1"},
{"alt": "Harry Potter", "type": "clearLogo", "url": "/library/metadata/1/clearLogo/1"}
]
}
""")
#expect(decoded.clearLogoPath == "/library/metadata/1/clearLogo/1")
}
@Test("Ohne Schriftzug bleibt es beim Titel")
func withoutClearLogo() throws {
// Der Normalfall bei selbst zusammengestellten Mediatheken.
let decoded = try item(from: """
{
"ratingKey": "1", "type": "movie", "title": "Ein Film",
"Image": [{"type": "coverPoster", "url": "/library/metadata/1/thumb/1"}]
}
""")
#expect(decoded.clearLogoPath == nil)
}
@Test("Eine fehlende Bildliste ist kein Fehler")
func missingImageList() throws {
// Listen liefern keine Bildliste mit — nur die Einzelabfrage tut das.
let decoded = try item(from: #"{"ratingKey": "1", "type": "movie", "title": "Ein Film"}"#)
#expect(decoded.images.isEmpty)
#expect(decoded.clearLogoPath == nil)
}
}
@Suite("Empfänger, Zuordnung und Senderliste")
struct TunerDecodingTests {
/// Die Antwort, wie sie ein echter Server liefert.
///
/// Nachgebildet aus dem Strukturabzug eines Servers mit Swisscom-Liste:
/// 28 zugeordnete Sender von 521 in der Liste. Entscheidend daran ist,
/// was NICHT drinsteht — die Zuordnung führt keine Namen.
private let dvrJSON = """
{"MediaContainer":{"size":1,"Dvr":[{
"key": "1",
"uuid": "dvr-uuid",
"epgIdentifier": "tv.plex.providers.epg.cloud:2",
"lineupTitle": "Swisscom Blue TV (Deutschschweiz)",
"lineup": "lineup://tv.plex.providers.epg.cloud/swisscom",
"Lineup": [{"id": "swisscom-de", "title": "Swisscom Blue TV (Deutschschweiz)"}],
"Device": [{
"key": "2", "uuid": "tuner-1", "title": "Swisscom Blue TV",
"ChannelMapping": [
{"channelKey": "srf1", "lineupIdentifier": "swisscom-de", "enabled": "1"},
{"channelKey": "srf2", "lineupIdentifier": "swisscom-de", "enabled": "1"},
{"channelKey": "abgeschaltet", "lineupIdentifier": "swisscom-de", "enabled": "0"}
]
}]
}]}}
"""
private func dvr() throws -> PlexDVR {
let container = try PlexResponse.decode(
PlexMediaContainer<PlexDVR>.self,
from: Data(dvrJSON.utf8)
)
return try #require(container.content.first)
}
@Test("Die Kennung des Programmanbieters steht am Empfänger")
func readsEPGIdentifier() throws {
// Verlässlicher, als sie aus der Anbieterliste zu suchen.
#expect(try dvr().epgIdentifier == "tv.plex.providers.epg.cloud:2")
}
@Test("Die Senderliste wird gelesen")
func readsLineup() throws {
#expect(try dvr().lineups.first?.id == "swisscom-de")
}
@Test("Nur eingeschaltete Sender zählen zur Zuordnung")
func onlyEnabledMappings() throws {
// `enabled` kommt als Zeichenkette „1" oder „0", nicht als
// Wahrheitswert.
let keys = try dvr().enabledChannelKeys
#expect(keys == ["srf1", "srf2"])
#expect(!keys.contains("abgeschaltet"))
}
@Test("Ein Empfänger-Schlüssel als Zahl wird gelesen")
func numericDVRKey() throws {
// Je nach Serverfassung kommt er als Zahl oder als Zeichenkette.
let json = #"{"MediaContainer":{"Dvr":[{"key":42}]}}"#
let container = try PlexResponse.decode(
PlexMediaContainer<PlexDVR>.self,
from: Data(json.utf8)
)
#expect(container.content.first?.key == "42")
}
}
@Suite("Abgleich von Zuordnung und Senderliste")
struct ChannelMatchingTests {
@Test("Übereinstimmung über die Kennung")
func matchesByIdentifier() {
let channel = PlexChannel(id: "srf1", title: "SRF 1")
#expect(channel.matches(anyOf: ["srf1", "srf2"]))
}
@Test("Übereinstimmung über die Sendernummer")
func matchesByChannelNumber() {
let channel = PlexChannel(id: "x", title: "SRF 1", channelIdentifier: "1.1")
#expect(channel.matches(anyOf: ["1.1"]))
}
@Test("Übereinstimmung über den Pfad")
func matchesByPath() {
// Je nach Anbieter führt die Zuordnung den vollen Pfad, der Sender
// aber nur die Kennung — oder umgekehrt.
let channel = PlexChannel(id: "x", title: "SRF 1", key: "/livetv/channels/srf1")
#expect(channel.matches(anyOf: ["srf1"]))
#expect(channel.matches(anyOf: ["/livetv/channels/srf1"]))
}
@Test("Umgekehrt: Zuordnung führt den Pfad, Sender die Kennung")
func matchesReversed() {
let channel = PlexChannel(id: "srf1", title: "SRF 1")
#expect(channel.matches(anyOf: ["/lineups/swisscom/channels/srf1"]))
}
@Test("Nicht zugeordnete Sender fallen heraus")
func nonMatchingIsExcluded() {
// Der Punkt der ganzen Übung: von 521 Sendern der Liste sollen die 28
// übrig bleiben, die eingeschaltet sind.
let channel = PlexChannel(id: "orf1", title: "ORF 1")
#expect(!channel.matches(anyOf: ["srf1", "srf2"]))
}
}
@Suite("Kennung der Senderliste")
struct LineupIdentifierTests {
@Test("Aus der vollen Adresse wird der nackte Teil")
func extractsBareIdentifier() {
// Genau der Wert von einem echten Server. Roh in den Pfad gesetzt
// ergab er 404: Schema, Doppelschrägstrich, Prozentzeichen und ein
// Anhang hinter der Raute.
let lineup = PlexLineup(
id: "lineup://tv.plex.providers.epg.cloud/6228f7f2364e02fa5ec8e51c#Swisscom%20Blue%20TV%20%28521%20channels%29"
)
#expect(lineup.pathIdentifier == "6228f7f2364e02fa5ec8e51c")
}
@Test("Eine Adresse ohne Anhang funktioniert auch")
func withoutFragment() {
let lineup = PlexLineup(id: "lineup://tv.plex.providers.epg.cloud/abc123")
#expect(lineup.pathIdentifier == "abc123")
}
@Test("Eine bereits nackte Kennung bleibt unverändert")
func alreadyBare() {
// Nicht jeder Anbieter liefert eine Adresse — manche geben die
// Kennung direkt.
#expect(PlexLineup(id: "abc123").pathIdentifier == "abc123")
}
@Test("Ein leerer Anhang stört nicht")
func emptyFragment() {
#expect(PlexLineup(id: "lineup://host/abc123#").pathIdentifier == "abc123")
}
}
@Suite("Adressen der Senderliste")
struct ChannelPathTests {
private let dvr = PlexDVR(
key: "1",
lineups: [PlexLineup(
id: "lineup://tv.plex.providers.epg.cloud/6228f7f2364e02fa5ec8e51c#Swisscom%20Blue%20TV"
)]
)
@Test("Die Kennung der Senderliste wird ausgepackt, nicht roh eingesetzt")
func usesBareIdentifier() throws {
// Der Fehler, der 404 ergab: die volle Adresse mit Schema, Anhang und
// Prozentzeichen landete unverändert im Pfad.
let paths = PlexServer.knownPaths(dvr: dvr, epgIdentifier: "epg:2")
let lineupPath = try #require(paths.first { $0.contains("/lineups/") })
#expect(lineupPath == "/epg:2/lineups/6228f7f2364e02fa5ec8e51c/channels")
#expect(!lineupPath.contains("lineup://"))
#expect(!lineupPath.contains("#"))
#expect(!lineupPath.contains("%"))
}
@Test("Es gibt Rückfalllinien")
func offersFallbacks() {
// Die Form unterscheidet sich zwischen Serverfassungen, und dieser
// Teil der API hat keine Beschreibung.
let paths = PlexServer.knownPaths(dvr: dvr, epgIdentifier: "epg:2")
#expect(paths.count >= 2)
#expect(paths.contains("/livetv/dvrs/1/channels"))
}
@Test("Ohne Senderliste bleiben die übrigen Wege")
func withoutLineup() {
let bare = PlexDVR(key: "7")
let paths = PlexServer.knownPaths(dvr: bare, epgIdentifier: "epg:2")
#expect(!paths.isEmpty)
#expect(paths.allSatisfy { !$0.contains("lineups//") })
}
}
@@ -1,257 +0,0 @@
import Foundation
import Testing
@testable import PlexKit
@Suite("Verbindungsauswahl")
struct ConnectionSelectorTests {
private let serverID = "server-abc-123"
private func identityResponse(_ machineIdentifier: String) -> String {
#"{"MediaContainer":{"machineIdentifier":"\#(machineIdentifier)","version":"1.42.0"}}"#
}
private func resource(connections: [PlexConnection]) -> PlexResource {
PlexResource(
name: "Wohnzimmer",
clientIdentifier: serverID,
accessToken: "server-token",
connections: connections
)
}
@Test("Der lokale Weg gewinnt, auch wenn die Vermittlung erreichbar ist")
func localWinsOverRelay() async {
// Der eigentliche Zweck der Rangordnung: die Vermittlung von Plex
// antwortet im Zweifel schneller, ist aber gedrosselt. Ein Rennen ohne
// Rang würde ausgerechnet den langsamsten Weg krönen.
let transport = FakeTransport.routes(["/identity": identityResponse(serverID)])
let selector = PlexConnectionSelector(identity: .test, transport: transport)
let chosen = await selector.selectConnection(for: resource(connections: [
PlexConnection(uri: "https://relay.plex.direct:443", relay: true),
PlexConnection(uri: "http://192.168.1.10:32400", local: true),
PlexConnection(uri: "https://1-2-3-4.abc.plex.direct:32400"),
]))
#expect(chosen?.uri == "http://192.168.1.10:32400")
}
@Test("Der direkte Weg von außen kommt vor der Vermittlung")
func directRemoteBeatsRelay() async {
let transport = FakeTransport.routes(["/identity": identityResponse(serverID)])
let selector = PlexConnectionSelector(identity: .test, transport: transport)
let chosen = await selector.selectConnection(for: resource(connections: [
PlexConnection(uri: "https://relay.plex.direct:443", relay: true),
PlexConnection(uri: "https://1-2-3-4.abc.plex.direct:32400"),
]))
#expect(chosen?.uri == "https://1-2-3-4.abc.plex.direct:32400")
}
@Test("Ein Server mit fremder Kennung wird abgelehnt")
func rejectsWrongServer() async {
// Eine veraltete plex.direct-Adresse kann auf einen fremden Server
// zeigen. Ohne Abgleich der Kennung zeigte die App fremde Mediatheken.
let transport = FakeTransport.routes(["/identity": identityResponse("ein-anderer-server")])
let selector = PlexConnectionSelector(identity: .test, transport: transport)
let chosen = await selector.selectConnection(for: resource(connections: [
PlexConnection(uri: "http://192.168.1.10:32400", local: true),
]))
#expect(chosen == nil)
}
@Test("Ohne erreichbaren Weg gibt es kein Ergebnis")
func noReachableConnection() async {
let selector = PlexConnectionSelector(identity: .test, transport: FakeTransport.unreachable())
let chosen = await selector.selectConnection(for: resource(connections: [
PlexConnection(uri: "http://192.168.1.10:32400", local: true),
PlexConnection(uri: "https://relay.plex.direct:443", relay: true),
]))
#expect(chosen == nil)
}
@Test("Ohne Server-Token wird gar nicht erst probiert")
func missingAccessTokenFailsFast() async {
// Bei geteilten Servern liefert plex.tv gelegentlich keinen Token —
// etwa wenn die Freigabe zurückgezogen wurde. Anfragen ohne ihn
// beantwortet der Server wortlos mit 401.
let selector = PlexConnectionSelector(identity: .test, transport: FakeTransport.unreachable())
let resource = PlexResource(
name: "Geteilt",
clientIdentifier: serverID,
accessToken: nil,
connections: [PlexConnection(uri: "http://192.168.1.10:32400", local: true)]
)
#expect(await selector.selectConnection(for: resource) == nil)
}
@Test("Die Rangordnung der Wege stimmt")
func tierOrdering() {
#expect(PlexConnection(uri: "x", local: true).tier == 0)
#expect(PlexConnection(uri: "x").tier == 1)
#expect(PlexConnection(uri: "x", relay: true).tier == 2)
}
}
@Suite("Serverliste")
struct ServerDirectoryTests {
private let resourcesJSON = """
[
{
"name": "Wohnzimmer",
"product": "Plex Media Server",
"clientIdentifier": "server-abc",
"provides": "server",
"owned": true,
"accessToken": "token-eigen",
"connections": [
{ "uri": "http://192.168.1.10:32400", "address": "192.168.1.10", "port": 32400, "local": true, "relay": false, "protocol": "http", "IPv6": false },
{ "uri": "https://1-2-3-4.abc.plex.direct:32400", "local": false, "relay": false, "protocol": "https" }
]
},
{
"name": "iPhone von Guido",
"clientIdentifier": "client-xyz",
"provides": "client,player",
"owned": true,
"connections": []
},
{
"name": "Server eines Freundes",
"clientIdentifier": "server-def",
"provides": "server",
"owned": false,
"accessToken": "token-geteilt",
"connections": [
{ "uri": "https://5-6-7-8.def.plex.direct:32400", "local": false, "relay": false }
]
}
]
"""
@Test("Nur Server erscheinen in der Liste, keine Abspielgeräte")
func filtersOutNonServers() async throws {
// Das Konto listet auch jedes iPhone und jeden Fernseher, der je
// angemeldet war. Ungefiltert stünden sie alle in der Serverauswahl.
let directory = PlexServerDirectory(
identity: .test,
token: "konto-token",
transport: FakeTransport.routes(["/api/v2/resources": resourcesJSON])
)
let servers = try await directory.servers()
#expect(servers.count == 2)
#expect(!servers.contains { $0.clientIdentifier == "client-xyz" })
}
@Test("Geteilte Server behalten ihren eigenen Token")
func sharedServersKeepTheirOwnToken() async throws {
let directory = PlexServerDirectory(
identity: .test,
token: "konto-token",
transport: FakeTransport.routes(["/api/v2/resources": resourcesJSON])
)
let servers = try await directory.servers()
let shared = try #require(servers.first { !$0.owned })
#expect(shared.accessToken == "token-geteilt")
#expect(shared.accessToken != "konto-token")
}
}
@Suite("Verbindungsauswahl unter Zeitdruck")
struct ConnectionSelectorTimeoutTests {
private let serverID = "server-abc-123"
private func identityResponse(_ machineIdentifier: String) -> String {
#"{"MediaContainer":{"machineIdentifier":"\#(machineIdentifier)","version":"1.42.0"}}"#
}
private var resource: PlexResource {
PlexResource(
name: "Wohnzimmer",
clientIdentifier: serverID,
accessToken: "server-token",
connections: [
PlexConnection(uri: "http://192.168.1.10:32400", local: true),
PlexConnection(uri: "https://1-2-3-4.abc.plex.direct:32400"),
PlexConnection(uri: "https://relay.plex.direct:443", relay: true),
]
)
}
@Test("Eine schweigende lokale Adresse blockiert die Anmeldung nicht", .timeLimit(.minutes(1)))
func hangingLocalDoesNotBlock() async {
// Der Fehler aus der Praxis: die Ränge wurden nacheinander geprüft,
// also kostete eine schweigende lokale Adresse erst die volle
// Zeitgrenze, bevor der direkte Weg überhaupt begann. Ergebnis war
// eine Anmeldung, die anderthalb Minuten hing.
let transport = FakeTransport.hosts(
answering: [
"1-2-3-4.abc.plex.direct": identityResponse(serverID),
"relay.plex.direct": identityResponse(serverID),
],
hanging: ["192.168.1.10"]
)
let selector = PlexConnectionSelector(
identity: .test,
transport: transport,
probeTimeout: .milliseconds(200)
)
let started = ContinuousClock.now
let chosen = await selector.selectConnection(for: resource)
let elapsed = ContinuousClock.now - started
// Der direkte Weg gewinnt, weil lokal nicht antwortet.
#expect(chosen?.uri == "https://1-2-3-4.abc.plex.direct:32400")
// Und zwar in der Größenordnung der Zeitgrenze, nicht in deren
// Vielfachem.
#expect(elapsed < .seconds(2), "dauerte \(elapsed)")
}
@Test("Die Vermittlung kommt nur zum Zug, wenn sonst nichts geht", .timeLimit(.minutes(1)))
func relayOnlyWhenNothingElseAnswers() async {
let transport = FakeTransport.hosts(
answering: ["relay.plex.direct": identityResponse(serverID)],
hanging: ["192.168.1.10"]
)
let selector = PlexConnectionSelector(
identity: .test,
transport: transport,
probeTimeout: .milliseconds(200)
)
let chosen = await selector.selectConnection(for: resource)
#expect(chosen?.relay == true)
}
@Test("Eine erreichbare lokale Adresse gewinnt sofort", .timeLimit(.minutes(1)))
func reachableLocalWinsImmediately() async {
// Hier hängen die beiden anderen. Würde die Auswahl auf alle Antworten
// warten, bevor sie entscheidet, wäre auch der gute Fall langsam.
let transport = FakeTransport.hosts(
answering: ["192.168.1.10": identityResponse(serverID)],
hanging: ["1-2-3-4.abc.plex.direct", "relay.plex.direct"]
)
let selector = PlexConnectionSelector(
identity: .test,
transport: transport,
probeTimeout: .seconds(30)
)
let started = ContinuousClock.now
let chosen = await selector.selectConnection(for: resource)
let elapsed = ContinuousClock.now - started
#expect(chosen?.local == true)
#expect(elapsed < .seconds(2), "dauerte \(elapsed)")
}
}
@@ -1,77 +0,0 @@
import Foundation
import Testing
@testable import PlexKit
/// Unter welchen Namen Plex seine Sammlungen ablegt.
///
/// Es gibt keinen einheitlichen, und das ist keine Kleinigkeit: Live-TV galt
/// als „nicht eingerichtet", weil die Hülle `Dvr` nicht kannte, eine leere
/// Liste las und daraus die falsche Schlussfolgerung zog — auf einem Server,
/// auf dem ein Empfänger eingerichtet war.
@Suite("Sammlungsnamen in der Hülle")
struct ContainerKeysTests {
@Test("Fernsehempfänger stehen unter Dvr")
func readsDvrKey() throws {
let json = """
{"MediaContainer":{"size":1,"Dvr":[
{"key":"1","uuid":"abc","lineupTitle":"Kabel","language":"deu"}
]}}
"""
let container = try PlexResponse.decode(
PlexMediaContainer<PlexDVR>.self,
from: Data(json.utf8)
)
#expect(container.content.count == 1)
#expect(container.content.first?.key == "1")
}
@Test("Die Anbieterliste steht unter MediaProvider")
func readsMediaProviderKey() throws {
// Darüber wird die Kennung des Programmführers gefunden. Ohne sie gibt
// es kein Programm, auch wenn die Sender da sind.
let json = """
{"MediaContainer":{"size":2,"MediaProvider":[
{"identifier":"com.plexapp.plugins.library","title":"Mediathek"},
{"identifier":"tv.plex.providers.epg.cloud:2","title":"Programm"}
]}}
"""
let container = try PlexResponse.decode(
PlexMediaContainer<PlexMediaProvider>.self,
from: Data(json.utf8)
)
#expect(container.content.count == 2)
#expect(container.content.contains { $0.identifier.contains("epg") })
}
@Test("Titel stehen weiterhin unter Metadata")
func stillReadsMetadata() throws {
let json = #"{"MediaContainer":{"Metadata":[{"ratingKey":"1","type":"movie","title":"Film"}]}}"#
let container = try PlexResponse.decode(
PlexMediaContainer<PlexItem>.self,
from: Data(json.utf8)
)
#expect(container.content.first?.title == "Film")
}
@Test("Eine fehlende Sammlung ist kein Fehler")
func missingCollectionIsEmpty() throws {
// Eine leere Mediathek liefert gar kein Feld.
let json = #"{"MediaContainer":{"size":0}}"#
let container = try PlexResponse.decode(
PlexMediaContainer<PlexItem>.self,
from: Data(json.utf8)
)
#expect(container.content.isEmpty)
}
@Test("Eine vorhandene, aber kaputte Sammlung meldet den Fehler")
func malformedCollectionThrows() {
// Der Unterschied, der beim Umbau beinahe verloren ging: aus „ein Feld
// heißt jetzt anders" darf nicht „die Mediathek ist leer" werden.
let json = #"{"MediaContainer":{"Metadata":[{"title":"ohne ratingKey"}]}}"#
#expect(throws: PlexError.self) {
try PlexResponse.decode(PlexMediaContainer<PlexItem>.self, from: Data(json.utf8))
}
}
}
@@ -1,16 +0,0 @@
# Antwort-Vorlagen
Diese Dateien bilden das Antwortformat von Plex nach. Sie sind **von Hand
geschrieben**, nicht von einem laufenden Server mitgeschnitten — es stand bei
der Entstehung keiner zur Verfügung.
Sie decken das Format ab, nicht die Wirklichkeit eines gewachsenen Bestands.
Sobald ein Server erreichbar ist, gehören sie durch echte Mitschnitte ersetzt:
curl -H "Accept: application/json" \
"http://<server>:32400/library/sections/1/all?X-Plex-Token=<token>" \
> movies-section.json
Erfahrungsgemäß tauchen dabei Fälle auf, die man sich nicht ausdenkt — fehlende
`Media`-Blöcke bei noch nicht analysierten Dateien, Titel ohne `year`, Folgen
ohne `grandparentThumb`. Genau dafür sind die Vorlagen da.
@@ -1,44 +0,0 @@
{
"MediaContainer": {
"size": 2,
"title1": "Serien",
"title2": "Staffel 1",
"Metadata": [
{
"ratingKey": "77801",
"key": "/library/metadata/77801",
"type": "episode",
"title": "Die erste Folge",
"index": 1,
"parentIndex": 1,
"parentTitle": "Staffel 1",
"parentRatingKey": "77800",
"grandparentTitle": "Eine Serie",
"grandparentRatingKey": "77700",
"grandparentThumb": "/library/metadata/77700/thumb/1699000000",
"duration": 2760000,
"addedAt": 1699000000,
"Media": [
{
"id": 90001,
"container": "mkv",
"Part": [
{ "id": 95001, "key": "/library/parts/95001/1699000000/file.mkv", "container": "mkv" }
]
}
]
},
{
"ratingKey": "77802",
"key": "/library/metadata/77802",
"type": "episode",
"title": "Die zweite Folge",
"index": 2,
"parentIndex": 1,
"grandparentTitle": "Eine Serie",
"duration": 2820000,
"addedAt": 1699000100
}
]
}
}
@@ -1,43 +0,0 @@
{
"MediaContainer": {
"size": 2,
"Metadata": [
{
"ratingKey": "long",
"title": "Der lange Film",
"Media": [
{
"channelIdentifier": "ch-1",
"channelTitle": "SRF 1 HD",
"channelVcn": "002",
"beginsAt": 1789205400,
"endsAt": 1789212600,
"Part": [
{
"key": "/livetv/channels/2"
}
]
}
]
},
{
"ratingKey": "after",
"title": "Was danach kommt",
"Media": [
{
"channelIdentifier": "ch-1",
"channelTitle": "SRF 1 HD",
"channelVcn": "002",
"beginsAt": 1789212600,
"endsAt": 1789216200,
"Part": [
{
"key": "/livetv/channels/2"
}
]
}
]
}
]
}
}
@@ -1,58 +0,0 @@
{
"MediaContainer": {
"size": 3,
"Metadata": [
{
"ratingKey": "5f1",
"key": "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2F6254592919e23c64f6c5605d",
"title": "Endlich Witwer",
"summary": "Komödie",
"type": "movie",
"Media": [
{
"channelCallSign": "ZDFGERH",
"channelID": 5,
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-5fc705f9c8d56d002e06f849",
"channelThumb": "/library/metadata/5/thumb",
"channelTitle": "006 ZDFGERH (ZDF HD)",
"channelVcn": "006",
"onAir": true,
"beginsAt": 4102444800,
"endsAt": 4102445700
}
]
},
{
"ratingKey": "5f2",
"key": "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fepisode%2Fabc",
"title": "heute journal",
"type": "episode",
"Media": [
{
"channelID": 5,
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-5fc705f9c8d56d002e06f849",
"channelTitle": "006 ZDFGERH (ZDF HD)",
"channelVcn": "006",
"beginsAt": 4102445700,
"endsAt": 4102446000
}
]
},
{
"ratingKey": "5f3",
"key": "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fshow%2Fxyz",
"title": "Sport aktuell",
"Media": [
{
"channelCallSign": "SRF2",
"channelID": 12,
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-12",
"channelVcn": "002",
"beginsAt": 4102444800,
"endsAt": 4102448400
}
]
}
]
}
}
@@ -1,89 +0,0 @@
{
"MediaContainer": {
"size": 1,
"totalSize": 1,
"offset": 0,
"title1": "Filme",
"title2": "Zuletzt hinzugefügt",
"Metadata": [
{
"ratingKey": "45231",
"key": "/library/metadata/45231",
"guid": "plex://movie/5d776b59ad5437001f79c36d",
"type": "movie",
"title": "Blade Runner 2049",
"titleSort": "Blade Runner 2049",
"summary": "Dreißig Jahre nach den Ereignissen des ersten Films.",
"year": 2017,
"duration": 9784000,
"contentRating": "de/16",
"rating": 8.0,
"audienceRating": 8.1,
"thumb": "/library/metadata/45231/thumb/1699887654",
"art": "/library/metadata/45231/art/1699887654",
"viewCount": 1,
"viewOffset": 4892000,
"lastViewedAt": 1699900000,
"addedAt": 1699887654,
"Media": [
{
"id": 88121,
"container": "mkv",
"videoCodec": "hevc",
"audioCodec": "truehd",
"videoResolution": "4k",
"videoProfile": "main 10",
"bitrate": 58420,
"width": 3840,
"height": 2160,
"audioChannels": 8,
"duration": 9784000,
"Part": [
{
"id": 91002,
"key": "/library/parts/91002/1699887654/file.mkv",
"container": "mkv",
"size": 71453298112,
"duration": 9784000,
"file": "/media/Filme/Blade Runner 2049 (2017)/Blade Runner 2049 2160p.mkv",
"Stream": [
{ "id": 310021, "streamType": 1, "codec": "hevc", "bitDepth": 10 },
{ "id": 310022, "streamType": 2, "codec": "truehd", "language": "Deutsch", "languageCode": "deu", "channels": 8, "displayTitle": "Deutsch (TrueHD 7.1)", "selected": true },
{ "id": 310023, "streamType": 2, "codec": "eac3", "language": "English", "languageCode": "eng", "channels": 6, "displayTitle": "English (EAC3 5.1)" },
{ "id": 310024, "streamType": 3, "codec": "subrip", "language": "Deutsch", "languageCode": "deu", "key": "/library/streams/310024", "displayTitle": "Deutsch (SRT)" },
{ "id": 310025, "streamType": 3, "codec": "pgs", "language": "English", "languageCode": "eng", "forced": true, "displayTitle": "English (Forced)" }
]
}
]
},
{
"id": 88122,
"container": "mp4",
"videoCodec": "h264",
"audioCodec": "aac",
"videoResolution": "1080",
"bitrate": 8240,
"width": 1920,
"height": 1080,
"audioChannels": 2,
"duration": 9784000,
"Part": [
{
"id": 91003,
"key": "/library/parts/91003/1699887655/file.mp4",
"container": "mp4",
"size": 10083446784,
"duration": 9784000,
"file": "/media/Filme/Blade Runner 2049 (2017)/Blade Runner 2049 1080p.mp4",
"Stream": [
{ "id": 310031, "streamType": 1, "codec": "h264" },
{ "id": 310032, "streamType": 2, "codec": "aac", "language": "Deutsch", "languageCode": "deu", "channels": 2, "selected": true }
]
}
]
}
]
}
]
}
}
@@ -1,90 +0,0 @@
import Foundation
import Testing
@testable import PlexKit
/// Welche Reihe welche Kachelform bekommt.
///
/// Weiterschauen lebt vom Szenenbild — man erkennt die Stelle wieder, an der
/// man aufgehört hat. Überall sonst ist das Plakat richtig, denn danach sucht
/// man einen Film aus.
@Suite("Form der Reihen")
struct HubShapeTests {
@Test(
"Angefangenes wird als Szenenbild gezeigt",
arguments: ["home.continue", "continueWatching", "tv.ondeck", "home.ondeck"]
)
func continueWatchingUsesLandscape(identifier: String) {
#expect(PlexHub(hubIdentifier: identifier, title: "Weiterschauen").isContinueWatching)
}
@Test(
"Alles andere bekommt Plakate",
arguments: ["home.movies.recent", "movie.recentlyadded", "home.watchlist",
"movie.collection", "tv.recentlyaired"]
)
func everythingElseUsesPosters(identifier: String) {
#expect(!PlexHub(hubIdentifier: identifier, title: "Irgendeine Reihe").isContinueWatching)
}
@Test("Die Entscheidung hängt nicht am übersetzten Titel")
func titleDoesNotDecide() {
// Der Titel kommt in der Sprache des Servers. Würde er die Form
// bestimmen, sähe die Startseite auf Englisch anders aus als auf
// Deutsch — und niemand käme auf die Ursache.
let english = PlexHub(hubIdentifier: "home.continue", title: "Continue Watching")
let german = PlexHub(hubIdentifier: "home.continue", title: "Weiterschauen")
#expect(english.isContinueWatching == german.isContinueWatching)
let misleading = PlexHub(hubIdentifier: "home.movies.recent", title: "Weiterschauen")
#expect(!misleading.isContinueWatching)
}
@Test("Groß- und Kleinschreibung spielt keine Rolle")
func caseInsensitive() {
#expect(PlexHub(hubIdentifier: "Home.ContinueWatching", title: "x").isContinueWatching)
}
}
/// Aus welcher Mediathek eine Reihe stammt.
///
/// **Der Fehler, der es hierher gebracht hat.** Ohne diese Angabe kann die
/// App eine Reihe keiner Mediathek zuordnen — und die Einstellung „welche
/// Mediatheken speisen den Aufmacher" wirft dann jede Reihe weg, fällt auf
/// „alles" zurück und sieht aus wie ein Schalter, der nichts tut.
@Suite("Mediathek einer Reihe")
struct HubLibraryTests {
private func hub(_ json: String) throws -> PlexHub {
try JSONDecoder().decode(PlexHub.self, from: Data(json.utf8))
}
/// Plex liefert die Abschnittsnummer je nach Endpunkt als Zahl oder als
/// Zeichenkette. Nur eines zu lesen hiesse: geht auf manchen Servern und
/// auf anderen nicht, und zwar lautlos.
@Test("Die Nummer kommt als Zahl")
func readsNumber() throws {
let reihe = try hub(#"{"hubIdentifier":"movie.recentlyadded","title":"Neu","librarySectionID":3}"#)
#expect(reihe.librarySectionID == "3")
}
@Test("Die Nummer kommt als Zeichenkette")
func readsString() throws {
let reihe = try hub(#"{"hubIdentifier":"movie.recentlyadded","title":"Neu","librarySectionID":"3"}"#)
#expect(reihe.librarySectionID == "3")
}
/// „Fortsetzen" geht quer durch alle Mediatheken; Plex nennt dort keine
/// Nummer. Das ist kein Fehler, sondern die richtige Auskunft.
@Test("Übergreifende Reihen nennen keine")
func crossLibraryHasNone() throws {
let reihe = try hub(#"{"hubIdentifier":"home.continue","title":"Fortsetzen"}"#)
#expect(reihe.librarySectionID == nil)
}
@Test("Die Reihe reicht sie weiter")
func rowCarriesIt() throws {
let reihe = try hub(#"{"hubIdentifier":"movie.recentlyadded","title":"Neu","librarySectionID":7}"#)
#expect(reihe.asMediaRowGroup.libraryID == "7")
}
}
@@ -1,100 +0,0 @@
import Foundation
import Testing
@testable import PlexKit
@Suite("Mediatheks-Abfragen")
struct LibraryQueryTests {
private func value(_ items: [URLQueryItem], _ name: String) -> String? {
items.first { $0.name == name }?.value
}
@Test("Eine Serienmediathek liefert Serien, nicht einzelne Folgen")
func showLibraryRequestsShows() {
// Ohne `type=2` schüttet Plex alle Folgen einzeln aus — bei einer
// Serienmediathek sind das schnell zehntausend Einträge statt dreißig
// Serien.
let items = LibraryQuery().queryItems(libraryKind: .show)
#expect(value(items, "type") == "2")
}
@Test("Eine Filmmediathek fragt nach Filmen")
func movieLibraryRequestsMovies() {
#expect(value(LibraryQuery().queryItems(libraryKind: .movie), "type") == "1")
}
@Test("Unbekannte Mediathekenarten bekommen keine Artangabe")
func unknownKindOmitsType() {
#expect(value(LibraryQuery().queryItems(libraryKind: nil), "type") == nil)
}
@Test("Sortierung landet immer in der Abfrage")
func sortIsAlwaysPresent() {
for sort in LibraryQuery.Sort.allCases {
let items = LibraryQuery(sort: sort).queryItems(libraryKind: .movie)
#expect(value(items, "sort") == sort.rawValue)
}
}
@Test("Ohne Filter stehen keine Filterparameter in der Abfrage")
func unsetFiltersAreOmitted() {
// Ein leerer Parameter ist für Plex nicht dasselbe wie ein fehlender:
// `genre=` filtert auf ein Genre ohne Namen und liefert nichts.
let items = LibraryQuery().queryItems(libraryKind: .movie)
#expect(value(items, "genre") == nil)
#expect(value(items, "year") == nil)
#expect(value(items, "unwatched") == nil)
#expect(value(items, "resolution") == nil)
}
@Test("Gesetzte Filter landen vollständig in der Abfrage")
func setFiltersAppear() {
let query = LibraryQuery(
sort: .recentlyAdded,
unwatchedOnly: true,
genre: "18",
year: 2017,
resolution: "4k"
)
let items = query.queryItems(libraryKind: .movie)
#expect(value(items, "sort") == "addedAt:desc")
#expect(value(items, "unwatched") == "1")
#expect(value(items, "genre") == "18")
#expect(value(items, "year") == "2017")
#expect(value(items, "resolution") == "4k")
}
@Test("Der Filterknopf weiß, ob gefiltert wird")
func filteringFlagReflectsState() {
#expect(!LibraryQuery().isFiltering)
// Die Sortierung allein zählt nicht als Filter — sonst wäre der Knopf
// dauerhaft hervorgehoben.
#expect(!LibraryQuery(sort: .rating).isFiltering)
#expect(LibraryQuery(unwatchedOnly: true).isFiltering)
#expect(LibraryQuery(genre: "18").isFiltering)
}
}
@Suite("Seitenweises Nachladen")
struct PlexPageTests {
@Test("Eine volle Seite kündigt weitere Titel an")
func fullPageHasMore() {
let page = PlexPage(items: Array(0..<60), offset: 0, totalSize: 250)
#expect(page.hasMore)
#expect(page.nextOffset == 60)
}
@Test("Die letzte Seite kündigt keine weiteren Titel an")
func lastPageHasNoMore() {
// Genau die Bedingung, die sonst zu einer Endlosschleife führt: die
// Ansicht lädt nach, bekommt nichts, lädt wieder nach.
let page = PlexPage(items: Array(0..<10), offset: 240, totalSize: 250)
#expect(!page.hasMore)
}
@Test("Eine leere Mediathek verlangt kein Nachladen")
func emptyLibraryHasNoMore() {
let page = PlexPage(items: [Int](), offset: 0, totalSize: 0)
#expect(!page.hasMore)
}
}
@@ -1,533 +0,0 @@
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")
}
}
@@ -1,79 +0,0 @@
import Foundation
import Testing
import MediaKit
@testable import PlexKit
/// Die Übersetzung der Plex-Modelle in die neutrale Form.
///
/// Gegen eine echte Server-Antwort, nicht gegen erfundene Werte: die
/// Eigenheiten, an denen eine Übersetzung scheitert, stehen genau dort —
/// „grandparent" für die Serie, Sekunden seit 1970 als Zahl, Fassungen und
/// Dateien ineinander verschachtelt.
@Suite("Übersetzung nach MediaKit")
struct MediaTranslationTests {
private func item(_ fixture: String) throws -> PlexItem {
let url = try #require(Bundle.module.url(forResource: "Fixtures/\(fixture)", withExtension: "json"))
let container = try PlexResponse.decode(
PlexMediaContainer<PlexItem>.self,
from: try Data(contentsOf: url)
)
return try #require(container.content.first)
}
@Test("Aus dem Großelternteil wird die Serie")
func translatesTheGrandparent() throws {
let episode = try item("episodes-unanalyzed").asMediaItem
// Der Name war von Anfang an irreführend: gemeint ist die Serie, und
// in jeder Ansicht musste man ihn im Kopf übersetzen.
#expect(episode.kind == .episode)
#expect(episode.title == "Die erste Folge")
#expect(episode.seriesTitle == "Eine Serie")
#expect(episode.index == 1)
#expect(episode.parentIndex == 1)
}
@Test("Fassungen und Dateien kommen vollständig mit")
func translatesVersions() throws {
let movie = try item("movie-with-two-versions").asMediaItem
#expect(movie.versions.count == 2)
#expect(movie.hasMultipleVersions)
let first = try #require(movie.versions.first)
#expect(first.files.isEmpty == false)
#expect(first.container != nil)
}
@Test("Die Wiedergabe-Entscheidung bekommt dasselbe Profil wie vorher")
func buildsTheSameProfile() throws {
// Beide Wege müssen dasselbe sagen — sonst spielte ein Titel nach dem
// Umbau plötzlich über einen anderen Player.
let plex = try item("movie-with-two-versions")
let media = try #require(plex.media.first)
let part = try #require(media.parts.first)
let viaPlex = media.profile(part: part)
let viaMedia = try #require(plex.asMediaItem.versions.first)
.profile(file: plex.asMediaItem.versions.first?.files.first)
#expect(viaPlex == viaMedia)
}
@Test("Ohne Fortschritt gibt es keinen Balken")
func leavesUnstartedItemsAlone() throws {
let movie = try item("movie-with-two-versions").asMediaItem
// `watchProgress` ist bewusst `nil` statt 0: ein Balken bei null
// Prozent sieht aus wie ein Fehler, nicht wie „noch nicht begonnen".
if movie.resumeMilliseconds == nil || movie.resumeMilliseconds == 0 {
#expect(movie.watchProgress == nil)
}
}
@Test("Plex kennt keine Favoriten und behauptet auch keine")
func doesNotInventFavourites() throws {
#expect(try item("movie-with-two-versions").asMediaItem.isFavourite == false)
}
}
@@ -1,158 +0,0 @@
import Foundation
import Testing
@testable import PlexKit
/// Die Suche nach der nächsten Folge.
///
/// Plex kennt dafür keinen Endpunkt — der Weg führt über die Geschwister, und
/// am Staffelende über die nächste Staffel. Genau dort bricht es ohne diese
/// Logik ab, und zwar an der Stelle, an der man es am wenigsten gebrauchen
/// kann: mitten in einer Serie.
@Suite("Nächste Folge")
struct NextEpisodeTests {
private func episode(
ratingKey: String,
index: Int,
season: Int,
seasonKey: String,
showKey: String = "show-1"
) -> String {
"""
{
"ratingKey": "\(ratingKey)", "type": "episode", "title": "Folge \(index)",
"index": \(index), "parentIndex": \(season),
"parentRatingKey": "\(seasonKey)", "grandparentRatingKey": "\(showKey)",
"grandparentTitle": "Eine Serie"
}
"""
}
private func container(_ items: [String]) -> String {
"""
{"MediaContainer":{"size":\(items.count),"Metadata":[\(items.joined(separator: ","))]}}
"""
}
private func season(ratingKey: String, index: Int) -> String {
"""
{"ratingKey":"\(ratingKey)","type":"season","title":"Staffel \(index)","index":\(index)}
"""
}
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 current(index: Int, season: Int = 1, seasonKey: String = "season-1") throws -> PlexItem {
let data = Data(container([episode(ratingKey: "e\(index)", index: index, season: season, seasonKey: seasonKey)]).utf8)
let decoded = try PlexResponse.decode(PlexMediaContainer<PlexItem>.self, from: data)
return try #require(decoded.content.first)
}
@Test("Innerhalb einer Staffel kommt die nächsthöhere Folge")
func nextWithinSeason() async throws {
let server = server(routes: [
"/library/metadata/season-1/children": container([
episode(ratingKey: "e1", index: 1, season: 1, seasonKey: "season-1"),
episode(ratingKey: "e2", index: 2, season: 1, seasonKey: "season-1"),
episode(ratingKey: "e3", index: 3, season: 1, seasonKey: "season-1"),
]),
])
let next = try await server.nextEpisode(after: try current(index: 1))
#expect(next?.ratingKey == "e2")
}
@Test("Lücken in der Nummerierung überspringen nichts Falsches")
func handlesGapsInNumbering() async throws {
// Fehlt eine Folge auf der Platte, darf nicht die Folge mit der
// höchsten Nummer kommen, sondern die nächsterreichbare.
let server = server(routes: [
"/library/metadata/season-1/children": container([
episode(ratingKey: "e1", index: 1, season: 1, seasonKey: "season-1"),
episode(ratingKey: "e5", index: 5, season: 1, seasonKey: "season-1"),
episode(ratingKey: "e9", index: 9, season: 1, seasonKey: "season-1"),
]),
])
let next = try await server.nextEpisode(after: try current(index: 1))
#expect(next?.ratingKey == "e5")
}
@Test("Am Staffelende geht es in der nächsten Staffel weiter")
func rollsOverToNextSeason() async throws {
let server = server(routes: [
"/library/metadata/season-1/children": container([
episode(ratingKey: "e1", index: 1, season: 1, seasonKey: "season-1"),
episode(ratingKey: "e2", index: 2, season: 1, seasonKey: "season-1"),
]),
"/library/metadata/show-1/children": container([
season(ratingKey: "season-1", index: 1),
season(ratingKey: "season-2", index: 2),
]),
"/library/metadata/season-2/children": container([
episode(ratingKey: "s2e1", index: 1, season: 2, seasonKey: "season-2"),
episode(ratingKey: "s2e2", index: 2, season: 2, seasonKey: "season-2"),
]),
])
let next = try await server.nextEpisode(after: try current(index: 2))
#expect(next?.ratingKey == "s2e1")
}
@Test("Nach der letzten Folge der letzten Staffel kommt nichts mehr")
func stopsAtTheEnd() async throws {
let server = server(routes: [
"/library/metadata/season-1/children": container([
episode(ratingKey: "e1", index: 1, season: 1, seasonKey: "season-1"),
]),
"/library/metadata/show-1/children": container([season(ratingKey: "season-1", index: 1)]),
])
let next = try await server.nextEpisode(after: try current(index: 1))
#expect(next == nil)
}
@Test("Ein Film hat keine nächste Folge")
func moviesHaveNoNext() async throws {
let data = Data(#"{"MediaContainer":{"Metadata":[{"ratingKey":"m1","type":"movie","title":"Ein Film"}]}}"#.utf8)
let movie = try #require(try PlexResponse.decode(PlexMediaContainer<PlexItem>.self, from: data).content.first)
let next = try await server(routes: [:]).nextEpisode(after: movie)
#expect(next == nil)
}
@Test("Staffel 0 mit Specials wird nicht vorgezogen")
func specialsDoNotJumpAhead() async throws {
// Plex führt Specials als Staffel 0. Sie stehen in der Liste vor der
// ersten regulären Staffel — würde einfach die erste genommen, landete
// man nach dem Staffelfinale bei einem Weihnachtsspecial.
let server = server(routes: [
"/library/metadata/season-1/children": container([
episode(ratingKey: "e1", index: 1, season: 1, seasonKey: "season-1"),
]),
"/library/metadata/show-1/children": container([
season(ratingKey: "season-0", index: 0),
season(ratingKey: "season-1", index: 1),
season(ratingKey: "season-2", index: 2),
]),
"/library/metadata/season-2/children": container([
episode(ratingKey: "s2e1", index: 1, season: 2, seasonKey: "season-2"),
]),
])
let next = try await server.nextEpisode(after: try current(index: 1))
#expect(next?.ratingKey == "s2e1")
}
}
@@ -1,203 +0,0 @@
import Foundation
import Testing
@testable import PlexKit
/// Lädt eine Antwort-Vorlage aus dem Test-Bundle.
private func fixture(_ name: String) throws -> Data {
let url = try #require(
Bundle.module.url(forResource: "Fixtures/\(name)", withExtension: "json"),
"Vorlage \(name).json nicht im Bundle — steht sie in Package.swift unter resources?"
)
return try Data(contentsOf: url)
}
@Suite("Antworten lesen")
struct PlexDecodingTests {
@Test("Ein Film mit zwei Fassungen wird vollständig gelesen")
func decodesMovieWithTwoVersions() throws {
let container = try PlexResponse.decode(
PlexMediaContainer<PlexItem>.self,
from: try fixture("movie-with-two-versions")
)
let movie = try #require(container.content.first)
#expect(movie.ratingKey == "45231")
#expect(movie.kind == .movie)
#expect(movie.title == "Blade Runner 2049")
#expect(movie.year == 2017)
#expect(movie.hasMultipleVersions)
#expect(movie.media.count == 2)
}
@Test("Die Fassung mit der höchsten Bitrate wird vorgewählt")
func prefersHighestBitrateVersion() throws {
let container = try PlexResponse.decode(
PlexMediaContainer<PlexItem>.self,
from: try fixture("movie-with-two-versions")
)
let movie = try #require(container.content.first)
let preferred = try #require(movie.preferredMedia)
#expect(preferred.videoResolution == "4k")
#expect(preferred.bitrate == 58420)
}
@Test("Der Fortschritt wird als Anteil berechnet")
func computesWatchProgress() throws {
let container = try PlexResponse.decode(
PlexMediaContainer<PlexItem>.self,
from: try fixture("movie-with-two-versions")
)
let movie = try #require(container.content.first)
let progress = try #require(movie.watchProgress)
// 4 892 000 von 9 784 000 Millisekunden — genau die Hälfte.
#expect(abs(progress - 0.5) < 0.001)
}
@Test("Spuren werden nach Art getrennt")
func separatesStreamsByKind() throws {
let container = try PlexResponse.decode(
PlexMediaContainer<PlexItem>.self,
from: try fixture("movie-with-two-versions")
)
let part = try #require(container.content.first?.preferredMedia?.parts.first)
#expect(part.videoStream?.codec == "hevc")
#expect(part.audioStreams.count == 2)
#expect(part.subtitleStreams.count == 2)
#expect(part.selectedAudioStream?.language == "Deutsch")
}
@Test("Untertitel als eigene Datei werden erkannt")
func detectsExternalSubtitles() throws {
let container = try PlexResponse.decode(
PlexMediaContainer<PlexItem>.self,
from: try fixture("movie-with-two-versions")
)
let part = try #require(container.content.first?.preferredMedia?.parts.first)
let subtitles = part.subtitleStreams
// Die SRT-Spur liegt als Datei daneben und lässt sich deshalb auch bei
// DirectPlay dazuschalten; die PGS-Spur steckt im Container.
#expect(subtitles.first { $0.codec == "subrip" }?.isExternal == true)
#expect(subtitles.first { $0.codec == "pgs" }?.isExternal == false)
#expect(subtitles.first { $0.codec == "pgs" }?.isForced == true)
}
@Test("Folgen ohne analysierte Datei brechen das Lesen nicht ab")
func toleratesUnanalyzedEpisodes() throws {
// Der wichtigste Fall aus der Praxis: Plex liefert Einträge aus, bevor
// es die Dateien durchgesehen hat. Ein nicht-optionales Feld würde
// hier die ganze Staffel unlesbar machen statt nur eine Angabe.
let container = try PlexResponse.decode(
PlexMediaContainer<PlexItem>.self,
from: try fixture("episodes-unanalyzed")
)
#expect(container.content.count == 2)
let first = try #require(container.content.first)
#expect(first.media.first?.parts.first?.streams.isEmpty == true)
// Die zweite Folge hat gar keinen `Media`-Block.
let second = try #require(container.content.last)
#expect(second.media.isEmpty)
#expect(second.title == "Die zweite Folge")
#expect(second.index == 2)
}
@Test("Serienzugehörigkeit von Folgen wird gelesen")
func decodesEpisodeHierarchy() throws {
let container = try PlexResponse.decode(
PlexMediaContainer<PlexItem>.self,
from: try fixture("episodes-unanalyzed")
)
let episode = try #require(container.content.first)
#expect(episode.kind == .episode)
#expect(episode.index == 1)
#expect(episode.parentIndex == 1)
#expect(episode.grandparentTitle == "Eine Serie")
#expect(episode.grandparentRatingKey == "77700")
}
@Test("Ungesehene Titel haben keinen Fortschritt")
func unwatchedItemsHaveNoProgress() throws {
let container = try PlexResponse.decode(
PlexMediaContainer<PlexItem>.self,
from: try fixture("episodes-unanalyzed")
)
// Ohne diese Unterscheidung bekäme jede ungesehene Kachel einen
// Fortschrittsbalken der Breite null — sichtbarer Strich, keine Aussage.
#expect(container.content.allSatisfy { $0.watchProgress == nil })
#expect(container.content.allSatisfy { !$0.isWatched })
}
@Test("Kaputte Antworten melden, woran es lag")
func decodingErrorsCarryDetail() {
let garbage = Data(#"{"MediaContainer":{"Metadata":[{"title":"ohne ratingKey"}]}}"#.utf8)
#expect(throws: PlexError.self) {
try PlexResponse.decode(PlexMediaContainer<PlexItem>.self, from: garbage)
}
do {
_ = try PlexResponse.decode(PlexMediaContainer<PlexItem>.self, from: garbage)
Issue.record("Hätte scheitern müssen")
} catch let error as PlexError {
guard case .decoding(let description) = error else {
Issue.record("Falsche Fehlerart: \(error)")
return
}
// Der Schlüsselpfad muss drinstehen — sonst ist beim Nachziehen
// einer geänderten Plex-Version nicht zu erkennen, welches Feld
// gewandert ist.
#expect(description.contains("ratingKey"))
} catch {
Issue.record("Unerwarteter Fehler: \(error)")
}
}
}
@Suite("HTTP-Status übersetzen")
struct PlexResponseValidationTests {
private func response(_ status: Int) -> HTTPURLResponse {
HTTPURLResponse(
url: URL(string: "http://server:32400/library/sections")!,
statusCode: status,
httpVersion: nil,
headerFields: nil
)!
}
@Test("Erfolgsstatus geben die Daten durch", arguments: [200, 201, 204])
func successPassesDataThrough(status: Int) throws {
let payload = Data("inhalt".utf8)
#expect(try PlexResponse.validate(data: payload, response: response(status)) == payload)
}
@Test("401 und 403 führen zurück zur Anmeldung", arguments: [401, 403])
func authenticationFailuresMapToUnauthorized(status: Int) {
#expect(throws: PlexError.unauthorized) {
try PlexResponse.validate(data: Data(), response: response(status))
}
}
@Test("404 ist ein fehlender Eintrag")
func notFoundMaps() {
#expect(throws: PlexError.notFound) {
try PlexResponse.validate(data: Data(), response: response(404))
}
}
@Test("Serverfehler dürfen wiederholt werden, Anfragefehler nicht")
func retryabilityFollowsStatusClass() {
#expect(PlexError.server(status: 503).isRetryable)
#expect(PlexError.server(status: 500).isRetryable)
#expect(!PlexError.server(status: 400).isRetryable)
#expect(!PlexError.unauthorized.isRetryable)
#expect(PlexError.transport(description: "Zeitüberschreitung").isRetryable)
}
}
@@ -1,81 +0,0 @@
import Foundation
@testable import PlexKit
/// Ein Transport, der vorbereitete Antworten liefert.
///
/// Statt `URLProtocol` zu unterwandern: das ist global und bringt bei parallel
/// laufenden Tests die Antworten durcheinander. Hier hat jeder Test seinen
/// eigenen Transport.
struct FakeTransport: PlexTransport {
let handler: @Sendable (URLRequest) async throws -> (Data, URLResponse)
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
try await handler(request)
}
/// Antwortet auf jeden Pfad, der als Schlüssel hinterlegt ist, und mit 404
/// auf alle übrigen.
static func routes(
_ routes: [String: String],
status: Int = 200
) -> FakeTransport {
FakeTransport { request in
let path = request.url?.path() ?? ""
guard let body = routes.first(where: { path.hasSuffix($0.key) })?.value else {
return (Data(), response(for: request, status: 404))
}
return (Data(body.utf8), response(for: request, status: status))
}
}
/// Verweigert jede Verbindung — für die Wege, die im Test nicht gewinnen
/// sollen.
static func unreachable() -> FakeTransport {
FakeTransport { _ in throw URLError(.cannotConnectToHost) }
}
/// Antwortet je nach Adresse unterschiedlich — und lässt einzelne Adressen
/// ins Leere laufen.
///
/// Bildet den Fall nach, der in der Wirklichkeit am meisten weh tut: eine
/// lokale Adresse, die nicht ablehnt, sondern schweigt. Ein abgelehnter
/// Verbindungsversuch ist sofort vorbei; ein schweigender läuft bis zur
/// Zeitgrenze.
static func hosts(
answering answers: [String: String],
hanging: Set<String> = []
) -> FakeTransport {
FakeTransport { request in
let host = request.url?.host() ?? ""
if hanging.contains(host) {
// Länger als jede Zeitgrenze, die ein Test setzen würde.
try await Task.sleep(for: .seconds(3600))
}
guard let body = answers[host] else {
throw URLError(.cannotConnectToHost)
}
return (Data(body.utf8), response(for: request, status: 200))
}
}
static func response(for request: URLRequest, status: Int) -> HTTPURLResponse {
HTTPURLResponse(
url: request.url ?? URL(string: "http://example.invalid")!,
statusCode: status,
httpVersion: nil,
headerFields: nil
)!
}
}
extension PlexClientIdentity {
static let test = PlexClientIdentity(
product: "FlixyTests",
version: "0",
clientIdentifier: "test-client",
platform: "iOS",
platformVersion: "26.0",
device: "iPhone",
deviceName: "Testgerät"
)
}
@@ -1,186 +0,0 @@
import Foundation
import Testing
@testable import PlexKit
@Suite("Transcode-Anfrage")
struct TranscodeRequestTests {
private func value(_ request: TranscodeRequest, _ name: String) -> String? {
request.queryItems.first { $0.name == name }?.value
}
@Test("Der Pfad zeigt auf den Titel, nicht auf die Datei")
func pathPointsAtMetadata() {
// Plex erwartet hier die Metadaten-Adresse. Gibt man den Dateipfad an,
// antwortet der Transcoder mit 404.
let request = TranscodeRequest(ratingKey: "45231", isLocal: true)
#expect(value(request, "path") == "/library/metadata/45231")
}
@Test("Umpacken ist erlaubt, DirectPlay ausdrücklich nicht")
func allowsDirectStreamButNotDirectPlay() {
// directStream=1 lässt Plex nur den Container wechseln, wenn der
// Bildcodec passt — das spart die Neukodierung. directPlay=0, weil
// diese Frage der PlaybackDecider bereits entschieden hat.
let request = TranscodeRequest(ratingKey: "1", isLocal: true)
#expect(value(request, "directStream") == "1")
#expect(value(request, "directPlay") == "0")
}
@Test("Ohne fastSeek müsste der Transcoder beim Springen von vorn beginnen")
func enablesFastSeek() {
#expect(value(TranscodeRequest(ratingKey: "1", isLocal: true), "fastSeek") == "1")
}
@Test("Die Bitratengrenze bringt eine passende Qualitätsstufe mit")
func bitrateCarriesQuality() {
// Ohne videoQuality ignoriert Plex die Grenze bei manchen Fassungen.
let request = TranscodeRequest(ratingKey: "1", maximumBitrate: 4_000, isLocal: false)
#expect(value(request, "maxVideoBitrate") == "4000")
#expect(value(request, "videoQuality") != nil)
}
@Test("Höhere Bitraten ergeben höhere Qualitätsstufen")
func qualityRisesWithBitrate() {
func quality(_ bitrate: Int) -> Int {
Int(value(TranscodeRequest(ratingKey: "1", maximumBitrate: bitrate, isLocal: true), "videoQuality") ?? "0")!
}
#expect(quality(2_000) < quality(8_000))
#expect(quality(8_000) < quality(20_000))
#expect(quality(20_000) <= 100)
}
@Test("Ohne Grenze steht keine Bitrate in der Anfrage")
func noBitrateWhenUnlimited() {
// Ein leerer Wert ist für Plex nicht dasselbe wie ein fehlender — er
// würde als Grenze von null gelesen.
let request = TranscodeRequest(ratingKey: "1", isLocal: true)
#expect(value(request, "maxVideoBitrate") == nil)
#expect(value(request, "videoQuality") == nil)
}
@Test("Lokal und außer Haus werden unterschieden")
func distinguishesLocation() {
#expect(value(TranscodeRequest(ratingKey: "1", isLocal: true), "location") == "lan")
#expect(value(TranscodeRequest(ratingKey: "1", isLocal: false), "location") == "wan")
}
@Test("Die Sitzungskennung steht in beiden Feldern")
func sessionAppearsTwice() {
// Plex erwartet sie sowohl als `session` als auch als
// `X-Plex-Session-Identifier`. Fehlt eines, lässt sich der Transcoder
// später nicht gezielt anhalten — und er rechnet minutenlang weiter.
let request = TranscodeRequest(ratingKey: "1", isLocal: true, sessionIdentifier: "sitzung-1")
#expect(value(request, "session") == "sitzung-1")
#expect(value(request, "X-Plex-Session-Identifier") == "sitzung-1")
}
@Test("Untertitel werden nur eingebrannt, wenn eine Spur gewählt ist")
func subtitlesOnlyWhenRequested() {
let without = TranscodeRequest(ratingKey: "1", isLocal: true)
#expect(value(without, "subtitles") == nil)
let with = TranscodeRequest(ratingKey: "1", isLocal: true, subtitleStreamID: 310_024)
#expect(value(with, "subtitles") == "burn")
#expect(value(with, "subtitleStreamID") == "310024")
}
}
@Suite("Dauer in Plex-Einheiten")
struct DurationConversionTests {
@Test("Millisekunden werden ganzzahlig gerechnet")
func millisecondsAreWholeNumbers() {
#expect(Duration.seconds(90).milliseconds == 90_000)
#expect(Duration.milliseconds(4_892_000).milliseconds == 4_892_000)
#expect(Duration.zero.milliseconds == 0)
}
@Test("Sekunden als Gleitkommazahl")
func secondsAsDouble() {
#expect(abs(Duration.milliseconds(1_500).seconds - 1.5) < 0.0001)
}
}
/// Was der Server mit seinen Codes meint.
@Suite("Entscheidung des Servers")
struct TranscodeDecisionTests {
private func decision(code: Int, text: String) throws -> TranscodeDecision {
let json = """
{"MediaContainer":{"generalDecisionCode":\(code),"generalDecisionText":"\(text)"}}
"""
return try PlexResponse.decode(TranscodeDecision.self, from: Data(json.utf8))
}
@Test("Umwandlung erlaubt ist eine Zusage, keine Absage")
func treatsConversionAsAllowed() throws {
// Der Fehler, an dem nichts mehr abspielte: die App fordert eine
// Umrechnung an, der Server sagt „direkte Wiedergabe nicht verfügbar;
// Umwandlung ok — und die App las das als Nein.
#expect(try decision(code: 1_001, text: "Direct play not available").isAllowed)
#expect(try decision(code: 2_000, text: "Conversion OK").isAllowed)
}
@Test("Direkte Wiedergabe bleibt eine Zusage")
func treatsDirectPlayAsAllowed() throws {
#expect(try decision(code: 1_000, text: "Direct play OK").isAllowed)
}
@Test("Ab dreitausend ist es eine Absage")
func treatsHighCodesAsRefusal() throws {
#expect(try decision(code: 3_000, text: "Not allowed").isAllowed == false)
#expect(try decision(code: 6_000, text: "Transcoder busy").isAllowed == false)
}
@Test("Ohne Code keine Absage")
func allowsWhenSilent() throws {
let json = #"{"MediaContainer":{}}"#
let decision = try PlexResponse.decode(TranscodeDecision.self, from: Data(json.utf8))
#expect(decision.isAllowed)
}
}
@Suite("Leiter bei Plex")
struct PlexLadderTests {
private func werte(_ request: TranscodeRequest, _ name: String) -> [String] {
request.queryItems
.filter { $0.name == name }
.compactMap(\.value)
}
@Test("Ohne Wunsch keine Leiter")
func noLadderByDefault() {
let request = TranscodeRequest(ratingKey: "1", isLocal: false)
#expect(werte(request, "autoAdjustQuality").isEmpty)
}
@Test("Mit Wunsch steht der Wert genau einmal da")
func ladderIsRequested() {
let request = TranscodeRequest(ratingKey: "1", isLocal: false, adaptive: true)
#expect(werte(request, "autoAdjustQuality") == ["1"])
}
/// Die Bitrate bleibt die **oberste** Sprosse, nicht die einzige — beide
/// Angaben gehören zusammen in die Anfrage.
@Test("Die Obergrenze bleibt neben der Leiter stehen")
func ceilingSurvives() {
let request = TranscodeRequest(
ratingKey: "1",
maximumBitrate: 8_000,
isLocal: false,
adaptive: true
)
#expect(werte(request, "autoAdjustQuality") == ["1"])
#expect(werte(request, "maxVideoBitrate") == ["8000"])
}
/// Dieselbe Wiedergabe, nicht zwei: sonst bliebe beim Aufräumen ein
/// Transcoder stehen, weil die Kennung nicht mehr passt.
@Test("Die Sitzungskennung bleibt dieselbe")
func sessionSurvives() {
let original = TranscodeRequest(ratingKey: "1", isLocal: false)
let mitLeiter = original.askingForLadder()
#expect(mitLeiter.sessionIdentifier == original.sessionIdentifier)
#expect(mitLeiter.adaptive)
}
}