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
+5 -15
View File
@@ -13,7 +13,6 @@ let package = Package(
platforms: [.iOS(.v26), .macOS(.v26), .tvOS(.v26)],
products: [
.library(name: "MediaKit", targets: ["MediaKit"]),
.library(name: "PlexKit", targets: ["PlexKit"]),
.library(name: "JellyfinKit", targets: ["JellyfinKit"]),
.library(name: "FlixyDesign", targets: ["FlixyDesign"]),
.library(name: "FlixyPlayback", targets: ["FlixyPlayback"]),
@@ -24,16 +23,15 @@ let package = Package(
// damit die gesamte Protokoll-Schicht ohne Oberfläche testbar bleibt.
// Die neutrale Grundschicht: Typen und Rechnungen, die für jeden
// Server gelten. Hängt von nichts ab — genau das macht sie zur
// Grundlage, auf der Plex, Jellyfin und später das Web-Projekt
// Grundlage, auf der jeder Server und später das Web-Projekt
// dasselbe meinen.
.target(name: "MediaKit"),
.testTarget(name: "MediaKitTests", dependencies: ["MediaKit"]),
.target(name: "PlexKit", dependencies: ["MediaKit"]),
// Jellyfin. Eigenes Target neben PlexKit, nicht darin: die beiden
// teilen die neutrale Schicht und sonst nichts. Wer später Plex
// herausnimmt, löscht ein Verzeichnis.
// Jellyfin. Eigenes Target auf der neutralen Schicht — ein Server,
// eine Schachtel. Kommt ein zweiter dazu, steht er daneben und
// nicht darin; herausnehmen heisst dann, ein Verzeichnis zu löschen.
.target(name: "JellyfinKit", dependencies: ["MediaKit"], resources: [.process("Localizable.xcstrings")]),
.testTarget(
name: "JellyfinKitTests",
@@ -44,15 +42,7 @@ let package = Package(
// Ein Werkzeug für die Fragen, die reines HTTP sind — ohne Oberfläche,
// ohne Gerät, ohne Upload. Eine Runde dauert damit Sekunden statt
// eines halben Tages, und das Upload-Kontingent bleibt für Builds.
.executableTarget(name: "FlixyProbe", dependencies: ["MediaKit", "PlexKit", "JellyfinKit"]),
.testTarget(
name: "PlexKitTests",
dependencies: ["PlexKit"],
// Echte Server-Antworten als Dateien. `copy` statt `process`, weil
// sie unverändert im Bundle liegen sollen — sonst deutet Xcode die
// JSON-Dateien um.
resources: [.copy("Fixtures")]
),
.executableTarget(name: "FlixyProbe", dependencies: ["MediaKit", "JellyfinKit"]),
// Farben, Typografie, wiederkehrende Bausteine. Hängt bewusst von
// nichts ab: dadurch bleibt das Umstellen der Akzentfarbe ein
+20 -355
View File
@@ -1,34 +1,27 @@
// **Nur für den Rechner, auf dem entwickelt wird.**
//
// Das hier ist ein Kommandozeilenwerkzeug: es fragt nach einem Passwort über
// `getpass`, löst Rechnernamen über `Host` auf und druckt in ein Terminal.
// Nichts davon gibt es auf iOS, und auf tvOS erst recht nicht — die App
// hängt auch nicht daran. Ohne diese Bedingung scheitert aber jeder Versuch,
// **das ganze Paket** für eine andere Plattform zu übersetzen, an einem
// Werkzeug, das dort nie laufen sollte. Das kostete eine halbe Stunde
// Fehlersuche an der falschen Stelle.
// `getpass` und druckt in ein Terminal. Nichts davon gibt es auf iOS, und auf
// tvOS erst recht nicht — die App hängt auch nicht daran. Ohne diese Bedingung
// scheitert aber jeder Versuch, **das ganze Paket** für eine andere Plattform
// zu übersetzen, an einem Werkzeug, das dort nie laufen sollte. Das kostete
// eine halbe Stunde Fehlersuche an der falschen Stelle.
#if os(macOS)
import Foundation
import MediaKit
import PlexKit
// Ein Werkzeug für die Fragen, die reines HTTP sind.
//
// Der Grund, dass es das gibt: die Live-TV-Schnittstelle von Plex ist
// nirgends beschrieben, und jede Vermutung darüber ging bisher als
// TestFlight-Build zum Nutzer und kam einen halben Tag später als Screenshot
// zurück. Irgendwann war das Upload-Kontingent des Tages aufgebraucht — für
// eine Frage, die keine Oberfläche braucht.
// Der Grund, dass es das gibt: eine Vermutung über die Schnittstelle eines
// Servers ging bisher als TestFlight-Build zum Nutzer und kam einen halben Tag
// später als Screenshot zurück. Irgendwann war das Upload-Kontingent des Tages
// aufgebraucht — für eine Frage, die keine Oberfläche braucht. Hier dauert
// eine Runde Sekunden.
//
// Der Token wird über die übliche Anmeldung geholt und liegt im Schlüsselbund
// dieses Rechners. Er steht nirgends in der Ausgabe: gedruckt werden Pfade,
// Kennungen und Antworten, keine vollständigen Adressen.
// MARK: - Die Leiter-Frage
//
// Beide Modi laufen **vor** der Plex-Anmeldung: die eine Frage hat mit Plex
// nichts zu tun, die andere bringt ihre eigene Anmeldung mit.
if let index = CommandLine.arguments.firstIndex(of: "--playlist"),
let roh = CommandLine.arguments.dropFirst(index + 1).first,
let url = URL(string: roh) {
@@ -50,344 +43,16 @@ if let index = CommandLine.arguments.firstIndex(of: "--jellyfin"),
exit(0)
}
let identity = PlexClientIdentity(
product: "Flixy-Probe",
version: "1.0",
clientIdentifier: storedClientIdentifier(),
platform: "macOS",
platformVersion: ProcessInfo.processInfo.operatingSystemVersionString,
device: "Mac",
deviceName: Host.current().localizedName ?? "Mac"
)
// Kein Modus erkannt. Ohne diesen Hinweis endete das Werkzeug wortlos — und
// ein Tippfehler im Argument sähe aus wie ein Server, der nichts liefert.
print("""
FlixyProbe — fragt Server über reines HTTP, ohne Oberfläche und ohne Gerät.
let store = KeychainTokenStore(service: "com.scarriffleservices.flixy.probe")
let authenticator = PlexAuthenticator(identity: identity, tokenStore: store)
let token: String
if let existing = authenticator.storedToken {
print("Angemeldet (Token aus dem Schlüsselbund).")
token = existing
} else {
let pin = try await authenticator.requestPin()
print("""
Anmelden: https://plex.tv/link
Code: \(pin.code)
Warte …
--playlist <url> Eine HLS-Leiter prüfen: Varianten, Tonspuren,
erster Schnipsel
--jellyfin <host> Die Leiter holen, die Jellyfin für einen Titel
baut, und begründen, warum sie so aussieht
--spuren <host> <suche> Die Tonspuren eines Titels auflisten
""")
var found: String?
for _ in 0..<150 where found == nil {
try await Task.sleep(for: .seconds(2))
found = try await authenticator.token(for: pin)
}
guard let found else { fatalError("Anmeldung abgelaufen.") }
token = found
print("Angemeldet.\n")
}
let directory = PlexServerDirectory(identity: identity, token: token)
let servers = try await directory.servers()
guard let resource = servers.first else { fatalError("Kein Server am Konto.") }
// Welche Wege es zu diesem Server gibt. Die Fehlerseiten sehen nicht nach
// Plex aus, sondern nach einem Webserver davor — dann macht es einen
// Unterschied, ob die Anfrage durch die Portweiterleitung geht oder direkt.
print("Wege zu \(resource.name):")
for candidate in resource.connections {
print(" \(candidate.local ? "lokal " : "entfernt") \(candidate.uri)")
}
let forceLocal = CommandLine.arguments.contains("--local")
let selector = PlexConnectionSelector(identity: identity)
let chosen: PlexConnection?
if forceLocal {
chosen = resource.connections.first { $0.local }
} else if let index = CommandLine.arguments.firstIndex(of: "--weg"),
let number = CommandLine.arguments.dropFirst(index + 1).first.flatMap(Int.init),
resource.connections.indices.contains(number) {
// Ein bestimmter Weg, zum Vergleich: die Vermittlung von Plex nimmt
// einen anderen Weg als die Portweiterleitung nach Hause.
chosen = resource.connections[number]
} else {
chosen = await selector.selectConnection(for: resource)
}
guard let connection = chosen,
let server = PlexServer(resource: resource, connection: connection, identity: identity)
else { fatalError("Kein Weg zu \(resource.name) erreichbar.") }
print("\nGenommen: \(connection.local ? "lokal" : "entfernt") — \(connection.uri)\n")
// MARK: - Freie Abfrage
// `swift run FlixyProbe /pfad /noch-ein-pfad …` fragt genau diese Adressen ab
// und zeigt die Antwort. Dafür ist das Werkzeug da: eine Schnittstelle ohne
// Beschreibung erkundet man, indem man sie fragt, nicht indem man rät.
// Was hinter `--try` steht, ist der Einzelversuch und keine freie Abfrage.
let tryTarget = CommandLine.arguments.firstIndex(of: "--try")
.flatMap { CommandLine.arguments.dropFirst($0 + 1).first }
if let tryTarget {
print(await server.attempt(target: tryTarget, headers: !CommandLine.arguments.contains("--bare")))
exit(0)
}
var requested = CommandLine.arguments.dropFirst().filter { !$0.hasPrefix("--") }
// Was hinter `--channel` steht, ist keine Adresse.
if let wanted = CommandLine.arguments.drop(while: { $0 != "--channel" }).dropFirst().first {
requested.removeAll { $0 == wanted }
}
if !requested.isEmpty {
for path in requested {
print("=== \(path) ===")
print(await server.rawResponse(forPath: path))
print()
}
exit(0)
}
// MARK: - Gegenprobe
// Bevor weiter an Live-TV gesucht wird: tut der Transcoder überhaupt etwas?
// Jede Anfrage an ihn endet mit einer nackten 400 — auch die an den
// Entscheider. Das ist eine Annahme, die ich die ganze Zeit ungeprüft
// mitgeschleppt habe, und sie gehört als Erstes geprüft: läuft eine
// gewöhnliche Datei durch, liegt es an Live-TV. Läuft sie nicht durch, habe
// ich tagelang am falschen Ende gesucht.
// Aus einer echten Mediathek, nicht aus den Startseiten-Reihen: dort stehen
// auch Einträge von plex.tv, deren Kennung kein Schlüssel dieses Servers ist.
// Mit so einem Eintrag prüft die Gegenprobe nichts.
let libraries = (try? await server.libraries()) ?? []
let movieLibrary = libraries.first { $0.kind == .movie } ?? libraries.first
let firstMovie = await { () async -> PlexItem? in
guard let movieLibrary else { return nil }
return try? await server.items(inLibrary: movieLibrary, limit: 1).items.first
}()
/// Der **größte** Titel aus einer Stichprobe — nicht der erste im Alphabet.
///
/// Für die Frage „wie gross liefert der Server" taugt eine kleine Datei
/// nicht: Plex rechnet nichts hoch, und dann misst man die Quelle statt die
/// Einstellungen. Genau daran ist eine Messung schon gescheitert — „2 Fast 2
/// Furious" steht alphabetisch vorn und ist ein 540p-Rip.
let biggestMovie = await { () async -> PlexItem? in
guard let movieLibrary else { return nil }
let stichprobe = (try? await server.items(inLibrary: movieLibrary, limit: 60).items) ?? []
return stichprobe.max { links, rechts in
(links.media.first?.bitrate ?? 0) < (rechts.media.first?.bitrate ?? 0)
}
}()
// MARK: - Die Leiter bei Plex
// Dieselbe Frage wie bei Jellyfin, und aus demselben Grund gestellt: bei Plex
// steht `autoAdjustQuality=1` im Code, und ob sein Transcoder daraufhin
// mehrere Stufen liefert, war nie geprüft. Eine ungeprüfte Annahme über
// Serververhalten hat uns bei Jellyfin vier Runden gekostet.
if CommandLine.arguments.contains("--leiter") {
guard let movie = biggestMovie ?? firstMovie else {
print("Keine Datei zum Prüfen gefunden.")
exit(0)
}
// **Die Quelle zuerst nennen.** Ohne sie misst man womöglich die Datei
// statt die Einstellungen: Plex rechnet nichts hoch, und eine kleine
// Quelle liefert klein, ganz gleich was man anfragt.
let quelle = movie.media.first
print("Leiter-Probe mit: \(movie.title)")
print(" Quelle: \(quelle?.width ?? 0)x\(quelle?.height ?? 0), "
+ "\((quelle?.bitrate ?? 0)) kbit, \(quelle?.videoCodec ?? "–")\n")
// **Zwei getrennte Sitzungen, und das ist der Kern des Vergleichs.**
//
// `askingForLadder()` behält die Sitzungskennung bei — richtig für die
// App, weil sonst beim Aufräumen ein Transcoder stehen bliebe. Für eine
// Gegenüberstellung ist es tödlich: die zweite Anfrage bekommt dann die
// laufende Sitzung der ersten zurück, die Antwort ist zwangsläufig
// identisch, und man hält das für ein Ergebnis. Genau das ist hier
// passiert.
let ohne = TranscodeRequest(
ratingKey: movie.ratingKey,
maximumBitrate: 8_000,
isLocal: connection.local
)
let mit = TranscodeRequest(
ratingKey: movie.ratingKey,
maximumBitrate: 8_000,
isLocal: connection.local,
adaptive: true
)
print("Sitzungen: \(ohne.sessionIdentifier.prefix(8)) / \(mit.sessionIdentifier.prefix(8))\n")
// **Warum kommt so wenig zurück?**
//
// Die App schickt bei „automatisch" `maxVideoBitrate` und `videoQuality`,
// aber **kein** `videoResolution` — dort steht `nil`. Verdacht: Plex nimmt
// dann seine eigene Voreinstellung für die Qualitätsstufe, und die ist
// klein. Das wäre kein Servereinstellungs-Problem, sondern unser eigenes.
print("=== Woran hängt die gelieferte Größe? ===")
let proben: [(String, Int, String?)] = [
("wie die App heute (ohne Auflösung)", 8_000, nil),
("mit videoResolution=1920x1080", 8_000, "1920x1080"),
("20 Mbit, ohne Auflösung", 20_000, nil),
("20 Mbit + 1920x1080", 20_000, "1920x1080"),
]
for (name, bitrate, auflösung) in proben {
let probe = TranscodeRequest(
ratingKey: movie.ratingKey,
maximumBitrate: bitrate,
resolution: auflösung,
isLocal: connection.local
)
// **Über den Entscheider, nicht über start.m3u8.** Jede Anfrage dort
// wirft einen Transcoder an; sechs in Folge bringen den Server dazu,
// auch das abzulehnen, was vorher ging. Der Entscheider sagt, was er
// täte, ohne etwas zu tun.
let wortlaut = await server.decisionText(for: probe)
let auflösung = herausziehen("videoResolution", aus: wortlaut)
?? herausziehen("height", aus: wortlaut)
let bitrate = herausziehen("bitrate", aus: wortlaut)
let entscheidung = herausziehen("transcodeDecisionText", aus: wortlaut)
?? herausziehen("generalDecisionText", aus: wortlaut)
print(" \(name):")
print(" Auflösung \(auflösung ?? "–") · Bitrate \(bitrate ?? "–")")
if let entscheidung { print(" \(entscheidung)") }
}
print()
// Zwischen den beiden echten Sitzungen Luft lassen: Plex nimmt eine
// schnelle Folge von Transcode-Starts übel und lehnt danach alles ab.
for (name, anfrage) in [("ohne autoAdjustQuality", ohne), ("mit autoAdjustQuality=1", mit)] {
try? await Task.sleep(for: .seconds(3))
print("=== \(name) ===")
guard let url = server.transcodeURL(for: anfrage) else {
print(" Keine Adresse zu bauen.\n")
continue
}
print(await prüfePlaylist(url))
print()
}
// Aufräumen: **beide** Anfragen haben je einen Transcoder angeworfen.
await server.stopTranscode(sessionIdentifier: ohne.sessionIdentifier)
await server.stopTranscode(sessionIdentifier: mit.sessionIdentifier)
print("Beide Transcoder angehalten.")
exit(0)
}
if let movie = firstMovie {
let request = TranscodeRequest(
ratingKey: movie.ratingKey,
maximumBitrate: 4_000,
isLocal: connection.local
)
if let url = server.transcodeURL(for: request) {
print("Gegenprobe — Transcoder mit: \(movie.title)")
print(" volle Anfrage: \(await server.probeStream(url: url))")
for line in await server.transcoderBisect(ratingKey: movie.ratingKey) {
print(line)
}
print()
}
} else {
print("Gegenprobe: keine Datei zum Prüfen gefunden.\n")
}
// MARK: - Live-TV
let availability = await server.liveTVAvailability()
guard case .available(let dvrs) = availability, let dvr = dvrs.first else {
print("Live-TV: \(availability)")
exit(0)
}
print("Empfänger: \(dvr.key), Programmführer: \(dvr.epgIdentifier ?? "–")")
let guide = await server.liveTVGuide(dvr: dvr, hours: 2)
print("Sender: \(guide.channels.count) — \(guide.source)\n")
// `--channel 002` wählt einen bestimmten Sender. Sonst der erste, auf dem
// überhaupt etwas läuft — ein Sender ohne Sendung sagt über das
// Einschalten wenig aus.
let wanted = CommandLine.arguments.drop(while: { $0 != "--channel" }).dropFirst().first
let channel = guide.channels.first { candidate in
guard let wanted else { return false }
return candidate.channelIdentifier == wanted
|| candidate.channelIdentifier == wanted.trimmingCharacters(in: CharacterSet(charactersIn: "0"))
|| candidate.title.localizedCaseInsensitiveContains(wanted)
}
?? guide.channels.first { guide.programs[$0.id]?.contains { $0.isRunning() } == true }
?? guide.channels.first
guard let channel else { exit(0) }
let program = guide.programs[channel.id]?.first { $0.isRunning() }
print("Einschalten: \(channel.title) (\(channel.channelIdentifier ?? "–"))")
print("Läuft: \(program?.title ?? "–")\n")
let result = await server.tune(
channel: channel,
program: program,
dvr: dvr,
maximumBitrate: nil
)
for probe in result.trace {
print(probe.path)
print(" → \(probe.result)")
}
print("\n" + (result.url == nil ? "Kein Weg trug." : "Erfolg."))
if result.url == nil {
// Der Transcoder antwortet mit einer nackten 400-Seite. Der Entscheider
// bekommt dieselben Angaben und erklärt sich — die letzte Stelle, an der
// dieser Server noch sagt, woran es liegt.
print("\n=== Was der Entscheider dazu sagt ===")
print(await server.decisionDump(channel: channel, program: program))
print("\n=== Empfänger und Gerät ===")
for path in ["/livetv/dvrs/\(dvr.key)", "/media/grabbers/devices/1", "/livetv/sessions"] {
print("--- \(path) ---")
print(await server.rawResponse(forPath: path, limit: 700))
}
}
/// Eine Kennung, die über Läufe hinweg gleich bleibt.
///
/// Wechselt sie, gilt die Probe am Konto als neues Gerät, und in der
/// Geräteliste sammelt sich Müll an.
func storedClientIdentifier() -> String {
let key = "flixy.probe.clientIdentifier"
if let stored = UserDefaults.standard.string(forKey: key) { return stored }
let created = UUID().uuidString
UserDefaults.standard.set(created, forKey: key)
return created
}
/// Zieht einen Wert aus der Antwort des Entscheiders.
///
/// Plex antwortet je nach Kopfzeile als XML oder JSON; beide Formen tragen
/// den Wert als `name="wert"` bzw. `"name":"wert"`. Statt zwei Parser zu
/// bauen, wird hier gesucht — es geht um eine Anzeige, nicht um Daten, mit
/// denen weitergerechnet wird.
func herausziehen(_ name: String, aus text: String) -> String? {
for muster in ["\(name)=\"", "\"\(name)\":\""] {
guard let bereich = text.range(of: muster) else { continue }
let rest = text[bereich.upperBound...]
let wert = String(rest.prefix { $0 != "\"" })
if !wert.isEmpty { return wert }
}
// JSON-Zahlen stehen ohne Anführungszeichen.
if let bereich = text.range(of: "\"\(name)\":") {
let rest = text[bereich.upperBound...].drop { $0 == " " }
let wert = String(rest.prefix { $0.isNumber })
if !wert.isEmpty { return wert }
}
return nil
}
exit(1)
#endif
@@ -3,9 +3,10 @@ import MediaKit
/// Die Übersetzung der Jellyfin-Modelle in die neutrale Form.
///
/// Das Gegenstück zu `PlexItem+Media`. Beide Dateien machen dasselbe, und dass
/// es sie zweimal gibt, ist der Preis dafür, dass es die Ansichten nur einmal
/// gibt.
/// **Hier und nicht in den Ansichten.** Jeder Server bringt seine eigenen
/// Felder mit; sie genau einmal an der Modulgrenze zu übersetzen ist der
/// Preis dafür, dass es die Ansichten nur einmal gibt. Kommt ein zweiter
/// Server dazu, bekommt er seine eigene Datei dieser Art — und sonst nichts.
extension JellyfinItem {
/// `markers` kommen von aussen, weil Jellyfin sie nicht am Titel
/// liefert: sie stehen hinter `/MediaSegments/{id}` und damit hinter
@@ -6,19 +6,18 @@ extension Duration {
/// Foundation und AVFoundation rechnen in `TimeInterval`; `Duration` ist
/// der genauere Typ, den der übrige Code verwendet.
///
/// Diese Brücke stand bis eben in `PlexKit` — „weil es die unterste
/// Schicht ist". Das stimmte, solange es nur einen Server gab. Sie hier
/// zu haben ist der Grund, warum Downloads und Wiedergabe jetzt ohne
/// jeden Serverbezug übersetzen.
/// Sie liegt in der neutralen Schicht und nicht beim Server — das ist
/// der Grund, warum Downloads und Wiedergabe ohne jeden Serverbezug
/// übersetzen. In einem Servermodul stand sie einmal, „weil es die
/// unterste Schicht ist"; das stimmte nur, solange es einen gab.
public var seconds: Double {
Double(components.seconds) + Double(components.attoseconds) / 1e18
}
/// Die Dauer in ganzen Millisekunden.
///
/// Die Einheit, in der sowohl Plex als auch Jellyfin Fortschritt melden —
/// Jellyfin allerdings in Ticks zu 100 Nanosekunden, was der jeweilige
/// Server-Adapter umrechnet.
/// Die übliche Einheit, in der Server Fortschritt melden — Jellyfin
/// allerdings in Ticks zu 100 Nanosekunden, was sein Adapter umrechnet.
public var milliseconds: Int {
Int(components.seconds * 1000 + components.attoseconds / 1_000_000_000_000_000)
}
@@ -1,131 +0,0 @@
import Foundation
import MediaKit
/// Führt die Anmeldung bei plex.tv durch.
public actor PlexAuthenticator {
/// Wo das Konto-Token im Schlüsselbund steht.
public static let accountTokenAccount = "plex.account"
private let client: PlexHTTPClient
private let identity: PlexClientIdentity
private let tokenStore: any TokenStoring
private let clock: any Clock<Duration>
public init(
identity: PlexClientIdentity,
tokenStore: any TokenStoring,
transport: any PlexTransport = URLSession.shared,
clock: any Clock<Duration> = ContinuousClock()
) {
self.identity = identity
self.tokenStore = tokenStore
self.clock = clock
self.client = PlexHTTPClient(
baseURL: URL(string: "https://plex.tv")!,
identity: identity,
transport: transport
)
}
/// Der bereits gespeicherte Token, falls vorhanden.
public nonisolated var storedToken: String? {
tokenStore.token(for: Self.accountTokenAccount)
}
/// Fordert einen neuen Anmeldecode an.
///
/// Bewusst ohne `strong=true`: das liefert zwar einen schwerer zu
/// erratenden Code, aber einen dreißigstelligen. Bei plex.tv/link
/// abzutippen ist der nicht — und genau das ist der Weg, den Flixy
/// anbietet. Der kurze Code ist vier Zeichen lang und läuft nach wenigen
/// Minuten ab, was das Raten ausreichend aussichtslos macht.
public func requestPin() async throws(PlexError) -> PlexPin {
try await client.send(
PlexRequest(method: .post, path: "/api/v2/pins"),
as: PlexPin.self
)
}
/// Fragt einmal nach, ob der Code inzwischen bestätigt wurde.
///
/// Gibt `nil` zurück, solange er es nicht ist. Für Aufrufer, die ihre
/// eigene Schleife führen — etwa weil sie nebenher noch auf das Schließen
/// eines Anmeldefensters achten müssen.
public func token(for pin: PlexPin) async throws(PlexError) -> String? {
let current = try await client.send(
PlexRequest(path: "/api/v2/pins/\(pin.id)"),
as: PlexPin.self
)
guard let token = current.authToken, !token.isEmpty else { return nil }
try? tokenStore.store(token, for: Self.accountTokenAccount)
await client.setToken(token)
return token
}
/// Wartet darauf, dass der Nutzer den Code im Browser bestätigt.
///
/// Plex bietet keinen Rückkanal — es bleibt nur regelmäßiges Nachfragen.
/// Das Intervall ist bewusst großzügig: der Weg über den Browser dauert
/// ohnehin Sekunden, und sekündliches Anklopfen brächte nichts außer Last.
///
/// Die Wartezeit ist an die Gültigkeit des Codes angelehnt. Kürzer wäre
/// verkehrt herum: die App gäbe auf, obwohl der Code noch gilt, und wer
/// zwischendurch etwas anderes gemacht hat, stünde vor „Zeit abgelaufen"
/// bei einem Code, der funktioniert hätte.
public func waitForAuthorization(
pin: PlexPin,
pollInterval: Duration = .seconds(2),
timeout: Duration = .seconds(900)
) async throws(PlexError) -> String {
// Über die Anzahl der Versuche statt über einen Zeitpunkt: eine
// existenzielle `any Clock` gibt ihren Instant-Typ nicht preis, und
// deshalb lassen sich zwei ihrer Zeitpunkte nicht vergleichen. Das
// Ergebnis ist dasselbe, und in Tests reicht eine Uhr, die sofort
// zurückkehrt.
//
// Nach oben begrenzt, damit ein versehentlich winziges Intervall nicht
// in einen Überlauf oder Millionen Anfragen läuft.
let attempts = pollInterval > .zero
? min(1000, max(1, Int(timeout / pollInterval)))
: 1
for _ in 0..<attempts {
do {
try await clock.sleep(for: pollInterval)
} catch {
// Abbruch durch die Oberfläche, etwa weil das Anmelde-Sheet
// geschlossen wurde. Kein Fehlerfall.
throw .transport(description: "Anmeldung abgebrochen")
}
let current = try await client.send(
PlexRequest(path: "/api/v2/pins/\(pin.id)"),
as: PlexPin.self
)
if let token = current.authToken, !token.isEmpty {
try? tokenStore.store(token, for: Self.accountTokenAccount)
await client.setToken(token)
return token
}
if let expiresAt = current.expiresAt, expiresAt < Date() {
throw .transport(description: "Der Anmeldecode ist abgelaufen")
}
}
throw .transport(description: "Zeit für die Anmeldung abgelaufen")
}
/// Meldet das Gerät ab. Der Token verschwindet aus dem Schlüsselbund;
/// am Konto bleibt der Eintrag stehen, bis er dort entfernt wird.
///
/// `nonisolated`, weil nur der Schlüsselbund angefasst wird — der ist
/// selbst abgesichert. Die Oberfläche soll beim Abmelden nicht auf den
/// Actor warten müssen, denn das kann mitten in einer laufenden Abfrage
/// passieren.
public nonisolated func signOut() {
tokenStore.remove(for: Self.accountTokenAccount)
}
}
@@ -1,115 +0,0 @@
import Foundation
/// Der Anmeldecode aus dem PIN-Verfahren.
///
/// Plex legt auf Anfrage einen vierstelligen Code an. Der Nutzer bestätigt ihn
/// im Browser bei plex.tv, danach trägt derselbe Datensatz den Token. Die App
/// selbst bekommt nie Benutzername oder Passwort zu sehen — deshalb ist dieser
/// Weg dem manuellen Eintippen eines Tokens vorzuziehen.
public struct PlexPin: Sendable, Equatable, Decodable {
public let id: Int
public let code: String
public let authToken: String?
public let expiresAt: Date?
private enum CodingKeys: String, CodingKey {
case id, code, authToken, expiresAt
}
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.id = try values.decode(Int.self, forKey: .id)
self.code = try values.decode(String.self, forKey: .code)
self.authToken = try values.decodeIfPresent(String.self, forKey: .authToken)
// Plex liefert ISO-8601 mit Zeitzone. Ein eigener Formatierer statt
// einer Decoder-Strategie, weil sonst alle anderen Datumsfelder in
// dieser Antwort mitbetroffen wären.
if let text = try values.decodeIfPresent(String.self, forKey: .expiresAt) {
self.expiresAt = ISO8601DateFormatter().date(from: text)
} else {
self.expiresAt = nil
}
}
public init(id: Int, code: String, authToken: String? = nil, expiresAt: Date? = nil) {
self.id = id
self.code = code
self.authToken = authToken
self.expiresAt = expiresAt
}
/// Die Verknüpfungsseite von Plex, mit bereits eingetragenem Code.
///
/// Gegenüber `app.plex.tv/auth` der verlässlichere Weg — und zwar genau in
/// dem Fall, der sonst stillschweigend scheitert: Wer im Browser schon bei
/// Plex angemeldet ist, wird von der Auth-Seite sofort auf watch.plex.tv
/// weitergeschickt, **ohne dass der Code verknüpft wird**. Man sieht sich
/// angemeldet, die App wartet vergeblich.
///
/// Diese Seite tut nur eine Sache: den Code bestätigen. Die Kennung des
/// Geräts steckt bereits im PIN — sie kam beim Anlegen über die
/// `X-Plex-*`-Kopfzeilen mit.
public var linkURL: URL? {
let allowed = CharacterSet.alphanumerics
guard let code = code.addingPercentEncoding(withAllowedCharacters: allowed) else {
return nil
}
return URL(string: "https://plex.tv/link/?pin=\(code)")
}
/// Die Adresse, unter der der Nutzer den Code bestätigt.
///
/// Von Hand zusammengesetzt statt über `URLComponents`. Der Grund ist die
/// Form, die Plex verlangt: die Parameter stehen im Fragment (hinter dem
/// `#`), nicht in der Abfrage, und einer davon heißt buchstäblich
/// `context[device][product]`. Die eckigen Klammern müssen genau einmal
/// kodiert sein.
///
/// `URLComponents.fragment` kodiert beim Setzen ein zweites Mal: aus `%5B`
/// wird `%255B`. Plex sieht dann einen Parameter, den es nicht kennt, und
/// antwortet mit „Diese Anfrage konnte nicht abgeschlossen werden".
/// Festgehalten in `AuthorizationURLTests`.
///
/// `forwardUrl` schickt den Browser nach der Anmeldung auf eine Adresse
/// mit eigenem Schema zurück. Genau darauf wartet
/// `ASWebAuthenticationSession`: das Anmeldefenster schließt sich von
/// selbst, und die App weiß, dass sie den Token abholen kann. Ohne diese
/// Rückadresse bliebe die Seite offen und der Nutzer müsste raten, wann er
/// fertig ist.
public func authorizationURL(
clientIdentifier: String,
product: String,
forwardURL: URL? = nil
) -> URL? {
// `&`, `=` und `+` müssen raus: sie trennen Parameter bzw. stehen für
// ein Leerzeichen. In einem Wert würden sie die Adresse zerreißen.
let allowed = CharacterSet.urlQueryAllowed.subtracting(CharacterSet(charactersIn: "&=+"))
func encode(_ value: String) -> String? {
value.addingPercentEncoding(withAllowedCharacters: allowed)
}
guard let client = encode(clientIdentifier),
let code = encode(code),
let product = encode(product)
else { return nil }
var text = """
https://app.plex.tv/auth#?clientID=\(client)&code=\(code)\
&context%5Bdevice%5D%5Bproduct%5D=\(product)
"""
// Strenger kodiert als die übrigen Werte: die Rückadresse enthält
// `:` und `/`, und die lässt `urlQueryAllowed` durch. Als Wert
// innerhalb einer Adresse, die Plex selbst noch einmal zerlegt, müssen
// sie kodiert sein — sonst endet der Wert für Plex beim ersten
// Schrägstrich.
let unreserved = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~"))
if let forwardURL,
let forward = forwardURL.absoluteString.addingPercentEncoding(withAllowedCharacters: unreserved) {
text += "&forwardUrl=\(forward)"
}
return URL(string: text)
}
}
@@ -1,113 +0,0 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
/// Wer wir gegenüber Plex sind.
///
/// Plex verlangt bei jeder Anfrage eine Handvoll `X-Plex-*`-Kopfzeilen. Die
/// wichtigste ist `X-Plex-Client-Identifier`: darüber erkennt der Server das
/// Gerät wieder. Ändert sie sich, gilt die App als neues Gerät — die
/// Anmeldung ist futsch und in der Geräteliste des Kontos sammelt sich Müll
/// an. Deshalb wird sie einmal erzeugt und dann festgehalten.
public struct PlexClientIdentity: Sendable, Equatable {
public let product: String
public let version: String
public let clientIdentifier: String
public let platform: String
public let platformVersion: String
public let device: String
public let deviceName: String
public init(
product: String,
version: String,
clientIdentifier: String,
platform: String,
platformVersion: String,
device: String,
deviceName: String
) {
self.product = product
self.version = version
self.clientIdentifier = clientIdentifier
self.platform = platform
self.platformVersion = platformVersion
self.device = device
self.deviceName = deviceName
}
/// Die Kopfzeilen für eine Anfrage — ohne Token, das kommt je nach Ziel
/// (plex.tv oder Server) aus einer anderen Quelle.
public var headers: [String: String] {
[
"X-Plex-Product": product,
"X-Plex-Version": version,
"X-Plex-Client-Identifier": clientIdentifier,
"X-Plex-Platform": platform,
"X-Plex-Platform-Version": platformVersion,
"X-Plex-Device": device,
"X-Plex-Device-Name": deviceName,
// Ohne diese Zeile antwortet Plex in XML. Die Modelle hier sind auf
// JSON ausgelegt, also fragen wir es ausdrücklich an.
"Accept": "application/json",
]
}
/// Dieselben Angaben als Abfrageteile der Adresse.
///
/// Für Adressen, die nicht die App abruft, sondern der Systemplayer:
/// AVPlayer schickt keine Plex-Kopfzeilen mit. Bei Live-TV ist das
/// entscheidend — der Server legt die Sitzung unter der Gerätekennung an
/// und lehnt die Abholung ohne sie mit „HTTP 400" ab.
public var queryItems: [URLQueryItem] {
headers
.filter { $0.key.hasPrefix("X-Plex-") }
.map { URLQueryItem(name: $0.key, value: $0.value) }
.sorted { $0.name < $1.name }
}
}
extension PlexClientIdentity {
private static let storageKey = "flixy.plex.clientIdentifier"
/// Baut die Kennung für das laufende Gerät und merkt sich die Client-ID.
///
/// `@MainActor`, weil `UIDevice.current` dort beheimatet ist. Das mit
/// `assumeIsolated` zu umgehen wäre ein Absturz mit Anlauf — die Kennung
/// wird ohnehin einmal beim Start gebaut.
@MainActor
public static func current(
product: String,
version: String,
defaults: UserDefaults = .standard
) -> PlexClientIdentity {
let identifier: String
if let stored = defaults.string(forKey: storageKey) {
identifier = stored
} else {
identifier = UUID().uuidString
defaults.set(identifier, forKey: storageKey)
}
#if canImport(UIKit)
let systemVersion = UIDevice.current.systemVersion
let model = UIDevice.current.model
let name = UIDevice.current.name
#else
let systemVersion = ProcessInfo.processInfo.operatingSystemVersionString
let model = "Unknown"
let name = ProcessInfo.processInfo.hostName
#endif
return PlexClientIdentity(
product: product,
version: version,
clientIdentifier: identifier,
platform: "iOS",
platformVersion: systemVersion,
device: model,
deviceName: name
)
}
}
@@ -1,39 +0,0 @@
import Foundation
/// Was beim Sprechen mit Plex schiefgehen kann.
///
/// Bewusst grob geschnitten: die Oberfläche muss unterscheiden können, ob sie
/// zur Anmeldung zurückschicken (`unauthorized`), einen Wiederholen-Knopf
/// zeigen (`transport`) oder einen Fehler melden soll. Feinere Abstufungen
/// hätten kein anderes Verhalten zur Folge.
public enum PlexError: Error, Sendable, Equatable {
/// Token fehlt oder wurde am Server zurückgezogen.
case unauthorized
/// Angefragte Ressource gibt es nicht — etwa ein gelöschter Titel.
case notFound
/// Der Server hat geantwortet, aber mit einem Fehlerstatus.
case server(status: Int)
/// Verbindung kam nicht zustande. Der Grund steht als Text drin, damit die
/// Fehlermeldung konkret werden kann statt „Etwas ist schiefgelaufen".
case transport(description: String)
/// Antwort kam an, ließ sich aber nicht lesen. Fast immer ein Zeichen
/// dafür, dass ein Feld in einer neueren Plex-Version anders heißt.
case decoding(description: String)
/// Es war kein Weg zum Server erreichbar — weder lokal noch über plex.tv.
case noReachableConnection
/// Ob ein erneuter Versuch überhaupt Aussicht auf Erfolg hat. Steuert, ob
/// die Oberfläche einen Wiederholen-Knopf anbietet.
public var isRetryable: Bool {
switch self {
case .transport, .noReachableConnection:
true
case .server(let status):
// 5xx ist ein Serverproblem und kann vorübergehen; 4xx liegt an
// der Anfrage und wird beim zweiten Mal genauso scheitern.
status >= 500
case .unauthorized, .notFound, .decoding:
false
}
}
}
@@ -1,88 +0,0 @@
import Foundation
/// Führt Anfragen gegen einen Host aus.
///
/// Es gibt zwei davon: einen für plex.tv (Anmeldung, Serverliste) und einen je
/// Server. Sie unterscheiden sich nur in Basisadresse und Token — deshalb
/// dieselbe Klasse statt zweier ähnlicher.
public actor PlexHTTPClient {
private let baseURL: URL
private let identity: PlexClientIdentity
private let transport: any PlexTransport
private var token: String?
public init(
baseURL: URL,
identity: PlexClientIdentity,
token: String? = nil,
transport: any PlexTransport = URLSession.shared
) {
self.baseURL = baseURL
self.identity = identity
self.token = token
self.transport = transport
}
public func setToken(_ token: String?) {
self.token = token
}
public var currentToken: String? { token }
public func send(_ request: PlexRequest) async throws(PlexError) -> Data {
let urlRequest = try request.urlRequest(baseURL: baseURL, identity: identity, token: token)
let data: Data
let response: URLResponse
do {
(data, response) = try await transport.data(for: urlRequest)
} catch let error as URLError {
// Der Klartext von URLError nennt Zeitüberschreitung, fehlende
// Verbindung oder Zertifikatsprobleme beim Namen. Das ist der
// Unterschied zwischen einer brauchbaren Fehlermeldung und
// „Etwas ist schiefgelaufen".
throw .transport(description: error.localizedDescription)
} catch {
throw .transport(description: String(describing: error))
}
return try PlexResponse.validate(data: data, response: response)
}
public func send<T: Decodable & Sendable>(
_ request: PlexRequest,
as type: T.Type
) async throws(PlexError) -> T {
let data = try await send(request)
return try PlexResponse.decode(type, from: data)
}
/// Baut die Adresse eines Bildes, ohne es zu laden.
///
/// Plex kann Bilder serverseitig verkleinern — ein Poster in Kachelgröße
/// statt in Originalauflösung spart bei einer Reihe aus zwanzig Titeln ein
/// Vielfaches an Übertragung.
public nonisolated func artworkURL(
path: String,
width: Int,
height: Int,
token: String
) -> URL? {
guard var components = URLComponents(
url: baseURL.appending(path: "/photo/:/transcode"),
resolvingAgainstBaseURL: false
) else { return nil }
components.queryItems = [
URLQueryItem(name: "width", value: String(width)),
URLQueryItem(name: "height", value: String(height)),
URLQueryItem(name: "minSize", value: "1"),
URLQueryItem(name: "upscale", value: "1"),
URLQueryItem(name: "url", value: path),
// Hier muss der Token ausnahmsweise in die Adresse: das Bild lädt
// AsyncImage selbst, und dort lassen sich keine Kopfzeilen setzen.
URLQueryItem(name: "X-Plex-Token", value: token),
]
return components.url
}
}
@@ -1,38 +0,0 @@
import Foundation
/// Übersetzt eine HTTP-Antwort in entweder Nutzdaten oder einen `PlexError`.
public enum PlexResponse {
public static func validate(
data: Data,
response: URLResponse
) throws(PlexError) -> Data {
guard let http = response as? HTTPURLResponse else {
throw .transport(description: "Antwort war keine HTTP-Antwort")
}
switch http.statusCode {
case 200..<300:
return data
case 401, 403:
throw .unauthorized
case 404:
throw .notFound
default:
throw .server(status: http.statusCode)
}
}
public static func decode<T: Decodable>(
_ type: T.Type,
from data: Data
) throws(PlexError) -> T {
do {
return try JSONDecoder().decode(type, from: data)
} catch {
// Der Fehlertext von `DecodingError` nennt den Schlüsselpfad. Das
// ist beim Nachziehen einer geänderten Plex-Version Gold wert,
// deshalb wird er mitgeschleppt statt verschluckt.
throw .decoding(description: String(describing: error))
}
}
}
@@ -1,92 +0,0 @@
import Foundation
import MediaKit
/// Der schmale Spalt, durch den PlexKit ans Netz kommt.
///
/// Alles darüber kennt nur dieses Protokoll. Dadurch laufen die Tests gegen
/// aufgezeichnete Antworten statt gegen einen echten Server — und zwar ohne
/// `URLProtocol`-Verrenkungen, die bei paralleler Testausführung erfahrungs-
/// gemäß ins Rutschen kommen.
public protocol PlexTransport: Sendable {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: PlexTransport {}
/// Eine Anfrage an Plex, unabhängig davon, an welchen Host sie geht.
///
/// Plex nimmt den Token wahlweise als Kopfzeile oder als Abfrageparameter.
/// Hier steht er in der Kopfzeile — sonst landet er in jedem Protokoll und in
/// jedem Absturzbericht, der URLs mitschreibt.
public struct PlexRequest: Sendable, Equatable {
public enum Method: String, Sendable { case get = "GET", post = "POST", put = "PUT", delete = "DELETE" }
public var method: Method
public var path: String
public var query: [URLQueryItem]
public var additionalHeaders: [String: String]
/// Wie lange auf Antwort gewartet wird.
///
/// Der Standardwert von URLSession sind 60 Sekunden. Für einen Server, der
/// nicht antwortet, ist das eine Ewigkeit — und bei der Verbindungssuche
/// summiert es sich über mehrere Adressen.
public var timeout: Duration
/// Ob `path` bereits kodiert ist und unverändert übernommen werden soll.
public let isPathEncoded: Bool
public init(
method: Method = .get,
path: String,
query: [URLQueryItem] = [],
additionalHeaders: [String: String] = [:],
timeout: Duration = .seconds(20),
isPathEncoded: Bool = false
) {
self.method = method
self.path = path
self.query = query
self.additionalHeaders = additionalHeaders
self.timeout = timeout
self.isPathEncoded = isPathEncoded
}
/// Setzt die Anfrage gegen einen Host zusammen.
public func urlRequest(
baseURL: URL,
identity: PlexClientIdentity,
token: String?
) throws(PlexError) -> URLRequest {
guard var components = URLComponents(
url: baseURL.appending(path: isPathEncoded ? "/" : path),
resolvingAgainstBaseURL: false
) else {
throw .transport(description: "Ungültige Basisadresse: \(baseURL)")
}
// Adressen beim Programmanbieter enthalten bereits kodierte Zeichen
// (`plex%3A%2F%2Fmovie%2F…`). `appending(path:)` kodiert das Prozent
// ein zweites Mal, und der Server findet nichts mehr.
if isPathEncoded {
components.percentEncodedPath = path
}
components.queryItems = query.isEmpty ? nil : query
guard let url = components.url else {
throw .transport(description: "Anfrage ließ sich nicht zusammensetzen: \(path)")
}
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.timeoutInterval = timeout.seconds
for (field, value) in identity.headers {
request.setValue(value, forHTTPHeaderField: field)
}
for (field, value) in additionalHeaders {
request.setValue(value, forHTTPHeaderField: field)
}
if let token {
request.setValue(token, forHTTPHeaderField: "X-Plex-Token")
}
return request
}
}
@@ -1,128 +0,0 @@
import Foundation
import MediaKit
/// Sucht den besten erreichbaren Weg zu einem Server.
///
/// Alle Adressen werden **gleichzeitig** geprüft, nicht Rang für Rang. Der
/// Unterschied ist erheblich: geht man die Ränge nacheinander durch, kostet
/// eine nicht erreichbare lokale Adresse erst die volle Zeitgrenze, bevor der
/// direkte Weg überhaupt beginnt. Aus zwei Wartezeiten wird so eine.
///
/// Die Rangordnung bleibt trotzdem: gewinnt eine lokale Adresse, wird sofort
/// abgebrochen — besser kann es nicht werden. Sonst wird gesammelt und am Ende
/// die beste erreichbare genommen. Ein reines „wer zuerst antwortet" wäre
/// falsch, weil die Vermittlung von Plex sich oft am schnellsten meldet und
/// zugleich die langsamste Leitung hat.
public struct PlexConnectionSelector: Sendable {
private let identity: PlexClientIdentity
private let transport: any PlexTransport
private let probeTimeout: Duration
public init(
identity: PlexClientIdentity,
transport: any PlexTransport = URLSession.shared,
probeTimeout: Duration = .seconds(4)
) {
self.identity = identity
self.transport = transport
self.probeTimeout = probeTimeout
}
public func selectConnection(for resource: PlexResource) async -> PlexConnection? {
guard let token = resource.accessToken, !resource.connections.isEmpty else { return nil }
return await withTaskGroup(of: PlexConnection?.self) { group in
for connection in resource.connections {
group.addTask {
await reachable(connection, token: token, expecting: resource.clientIdentifier)
? connection
: nil
}
}
var best: PlexConnection?
for await candidate in group {
guard let candidate else { continue }
if best.map({ candidate.tier < $0.tier }) ?? true {
best = candidate
}
// Eine lokale Adresse ist nicht zu schlagen. Auf die übrigen
// zu warten würde die Anmeldung nur verzögern.
if best?.tier == 0 {
group.cancelAll()
break
}
}
return best
}
}
/// Fragt `/identity` ab und gleicht die Server-Kennung ab.
private func reachable(
_ connection: PlexConnection,
token: String,
expecting clientIdentifier: String
) async -> Bool {
guard let baseURL = connection.url else { return false }
let client = PlexHTTPClient(
baseURL: baseURL,
identity: identity,
token: token,
transport: transport
)
do {
let response = try await withTimeout(probeTimeout) {
try await client.send(
PlexRequest(path: "/identity", timeout: probeTimeout),
as: PlexIdentity.self
)
}
// Der Abgleich ist keine Förmlichkeit: eine veraltete
// plex.direct-Adresse kann auf einen fremden Server zeigen, und
// ohne Prüfung würde die App fremde Mediatheken anzeigen.
return response?.machineIdentifier == clientIdentifier
} catch {
return false
}
}
/// Bricht ab, wenn die Arbeit zu lange dauert.
///
/// Zusätzlich zur Zeitgrenze der Anfrage selbst, damit die Grenze auch bei
/// einem Transport greift, der sie nicht beachtet — in den Tests etwa.
/// Liefert `nil`, wenn die Zeit abgelaufen ist.
private func withTimeout<T: Sendable>(
_ duration: Duration,
_ operation: @escaping @Sendable () async throws -> T
) async throws -> T? {
try await withThrowingTaskGroup(of: T?.self) { group in
group.addTask { try await operation() }
group.addTask {
try? await Task.sleep(for: duration)
return nil
}
let first = try await group.next() ?? nil
group.cancelAll()
return first
}
}
}
/// Die Antwort von `/identity`.
public struct PlexIdentity: Sendable, Decodable {
public let machineIdentifier: String
public let version: String?
private enum OuterKeys: String, CodingKey { case mediaContainer = "MediaContainer" }
private enum InnerKeys: String, CodingKey { case machineIdentifier, version }
public init(from decoder: any Decoder) throws {
let outer = try decoder.container(keyedBy: OuterKeys.self)
let inner = try outer.nestedContainer(keyedBy: InnerKeys.self, forKey: .mediaContainer)
self.machineIdentifier = try inner.decode(String.self, forKey: .machineIdentifier)
self.version = try inner.decodeIfPresent(String.self, forKey: .version)
}
}
@@ -1,131 +0,0 @@
import Foundation
/// Ein Gerät, das am Plex-Konto hängt — für uns nur Server interessant.
public struct PlexResource: Sendable, Equatable, Decodable, Identifiable {
public let name: String
public let product: String?
public let productVersion: String?
public let platform: String?
public let clientIdentifier: String
public let provides: String
public let owned: Bool
/// Der Token für genau diesen Server.
///
/// Nicht derselbe wie der Konto-Token: bei geteilten Servern bekommt man
/// einen eigenen, eingeschränkten. Wer stattdessen den Konto-Token
/// verwendet, bekommt bei fremden Servern wortlos 401.
public let accessToken: String?
public let connections: [PlexConnection]
public var id: String { clientIdentifier }
/// Ob es sich um einen Medienserver handelt — `provides` kann auch
/// „client", „player" oder eine Kombination sein.
public var isServer: Bool {
provides.split(separator: ",").contains("server")
}
private enum CodingKeys: String, CodingKey {
case name, product, productVersion, platform, clientIdentifier
case provides, owned, accessToken, connections
}
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.name = try values.decode(String.self, forKey: .name)
self.product = try values.decodeIfPresent(String.self, forKey: .product)
self.productVersion = try values.decodeIfPresent(String.self, forKey: .productVersion)
self.platform = try values.decodeIfPresent(String.self, forKey: .platform)
self.clientIdentifier = try values.decode(String.self, forKey: .clientIdentifier)
self.provides = try values.decodeIfPresent(String.self, forKey: .provides) ?? ""
self.owned = try values.decodeIfPresent(Bool.self, forKey: .owned) ?? false
self.accessToken = try values.decodeIfPresent(String.self, forKey: .accessToken)
self.connections = try values.decodeIfPresent([PlexConnection].self, forKey: .connections) ?? []
}
public init(
name: String,
clientIdentifier: String,
provides: String = "server",
owned: Bool = true,
accessToken: String? = nil,
connections: [PlexConnection] = [],
product: String? = nil,
productVersion: String? = nil,
platform: String? = nil
) {
self.name = name
self.product = product
self.productVersion = productVersion
self.platform = platform
self.clientIdentifier = clientIdentifier
self.provides = provides
self.owned = owned
self.accessToken = accessToken
self.connections = connections
}
}
/// Ein Weg zu einem Server.
public struct PlexConnection: Sendable, Equatable, Decodable, Identifiable {
public let uri: String
public let address: String?
public let port: Int?
public let local: Bool
/// Ob der Weg über die Vermittlung von Plex läuft. Der ist immer
/// erreichbar, aber auf wenige Mbit gedrosselt — taugt für Metadaten,
/// nicht für 4K.
public let relay: Bool
public let `protocol`: String?
public let iPv6: Bool
public var id: String { uri }
public var url: URL? { URL(string: uri) }
private enum CodingKeys: String, CodingKey {
case uri, address, port, local, relay
case `protocol`
case iPv6 = "IPv6"
}
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.uri = try values.decode(String.self, forKey: .uri)
self.address = try values.decodeIfPresent(String.self, forKey: .address)
self.port = try values.decodeIfPresent(Int.self, forKey: .port)
self.local = try values.decodeIfPresent(Bool.self, forKey: .local) ?? false
self.relay = try values.decodeIfPresent(Bool.self, forKey: .relay) ?? false
self.protocol = try values.decodeIfPresent(String.self, forKey: .protocol)
self.iPv6 = try values.decodeIfPresent(Bool.self, forKey: .iPv6) ?? false
}
public init(
uri: String,
local: Bool = false,
relay: Bool = false,
address: String? = nil,
port: Int? = nil,
protocol: String? = nil,
iPv6: Bool = false
) {
self.uri = uri
self.local = local
self.relay = relay
self.address = address
self.port = port
self.protocol = `protocol`
self.iPv6 = iPv6
}
/// In welcher Reihenfolge die Wege probiert werden.
///
/// Nicht „wer zuerst antwortet, gewinnt": die Vermittlung von Plex meldet
/// sich oft schneller als der Server im eigenen Netz, ist aber gedrosselt.
/// Ein Rennen ohne Rangordnung würde also ausgerechnet den langsamsten Weg
/// prämieren.
public var tier: Int {
if local { return 0 }
if relay { return 2 }
return 1
}
}
@@ -1,34 +0,0 @@
import Foundation
/// Holt die Serverliste vom Konto.
public actor PlexServerDirectory {
private let client: PlexHTTPClient
public init(
identity: PlexClientIdentity,
token: String,
transport: any PlexTransport = URLSession.shared
) {
self.client = PlexHTTPClient(
baseURL: URL(string: "https://plex.tv")!,
identity: identity,
token: token,
transport: transport
)
}
/// Alle Medienserver, auf die das Konto Zugriff hat — eigene und geteilte.
public func servers() async throws(PlexError) -> [PlexResource] {
let resources = try await client.send(
PlexRequest(path: "/api/v2/resources", query: [
// Ohne diese beiden Schalter fehlen in der Antwort die
// plex.direct-Adressen und die Vermittlung — übrig bliebe nur
// die lokale IP, und außer Haus ginge gar nichts.
URLQueryItem(name: "includeHttps", value: "1"),
URLQueryItem(name: "includeRelay", value: "1"),
]),
as: [PlexResource].self
)
return resources.filter(\.isServer)
}
}
@@ -1,85 +0,0 @@
import Foundation
/// Filter und Sortierung einer Mediatheksansicht.
///
/// Wird zu Abfrageparametern für Plex — die Filterung passiert also auf dem
/// Server. Bei zehntausend Titeln wäre alles andere aussichtslos: die Liste
/// erst vollständig zu laden, um sie dann lokal zu durchsuchen, dauert
/// Minuten und belegt entsprechend Speicher.
public struct LibraryQuery: Sendable, Equatable {
public enum Sort: String, Sendable, Equatable, CaseIterable {
case titleAscending = "titleSort:asc"
case titleDescending = "titleSort:desc"
case recentlyAdded = "addedAt:desc"
case releaseDate = "originallyAvailableAt:desc"
case rating = "rating:desc"
case lastViewed = "lastViewedAt:desc"
}
public var sort: Sort
public var unwatchedOnly: Bool
public var genre: String?
public var year: Int?
public var contentRating: String?
public var resolution: String?
public init(
sort: Sort = .titleAscending,
unwatchedOnly: Bool = false,
genre: String? = nil,
year: Int? = nil,
contentRating: String? = nil,
resolution: String? = nil
) {
self.sort = sort
self.unwatchedOnly = unwatchedOnly
self.genre = genre
self.year = year
self.contentRating = contentRating
self.resolution = resolution
}
/// Ob überhaupt gefiltert wird — steuert, ob die Oberfläche den
/// Filterknopf hervorhebt.
public var isFiltering: Bool {
unwatchedOnly || genre != nil || year != nil || contentRating != nil || resolution != nil
}
func queryItems(libraryKind: PlexLibrary.Kind?) -> [URLQueryItem] {
var items: [URLQueryItem] = [URLQueryItem(name: "sort", value: sort.rawValue)]
// Plex nummeriert die Arten: 1 Film, 2 Serie, 4 Folge. Ohne diese
// Angabe liefert eine Serienmediathek alle Folgen einzeln statt der
// Serien.
switch libraryKind {
case .movie: items.append(URLQueryItem(name: "type", value: "1"))
case .show: items.append(URLQueryItem(name: "type", value: "2"))
default: break
}
if unwatchedOnly { items.append(URLQueryItem(name: "unwatched", value: "1")) }
if let genre { items.append(URLQueryItem(name: "genre", value: genre)) }
if let year { items.append(URLQueryItem(name: "year", value: String(year))) }
if let contentRating { items.append(URLQueryItem(name: "contentRating", value: contentRating)) }
if let resolution { items.append(URLQueryItem(name: "resolution", value: resolution)) }
return items
}
}
/// Ein Ausschnitt aus einer längeren Liste.
public struct PlexPage<Element: Sendable>: Sendable {
public let items: [Element]
public let offset: Int
/// Wie viele Einträge es insgesamt gibt.
public let totalSize: Int
public init(items: [Element], offset: Int, totalSize: Int) {
self.items = items
self.offset = offset
self.totalSize = totalSize
}
public var hasMore: Bool { offset + items.count < totalSize }
public var nextOffset: Int { offset + items.count }
}
File diff suppressed because it is too large Load Diff
@@ -1,379 +0,0 @@
import Foundation
import MediaKit
extension PlexServer {
// MARK: - Transcoding
/// Die Adresse des HLS-Streams für diese Wiedergabe.
///
/// HLS ist die Kost, für die AVPlayer gebaut ist — **aber nur, wenn die
/// Playlist mehr als eine Stufe hat.**
///
/// Hier stand, der Player wechsle die Bitrate von selbst, und das sei die
/// zugesagte automatische Anpassung. Das war falsch: umschalten kann er
/// nur zwischen den Varianten, die in der Liste stehen, und diese
/// Anfrage bestellt über `maxVideoBitrate` und `videoQuality` genau eine.
/// Bei einer Variante gibt es nichts zu wechseln — und genau deshalb
/// ruckelt es auf einer Leitung, die einbricht.
///
/// Wie viele Stufen tatsächlich ankommen, steht in
/// `PlaybackStatistics.variantCount`. Das ist der Parser des Players und
/// damit die einzige belastbare Auskunft darüber.
/// - Parameter keepPathEncoding: Ob `path` unverändert eingesetzt wird.
///
/// Hier steckt der Unterschied zwischen einer laufenden Sendung und
/// „400 Bad Request". Eine Datei in der Mediathek liegt unter
/// `/library/metadata/123` — da gibt es nichts zu kodieren. Eine Sendung
/// beim Programmführer liegt unter
/// `/…/metadata/plex%3A%2F%2Fepisode%2F…`, und der Server dekodiert den
/// Abfragewert einmal, bevor er ihn als Pfad liest. Unverändert
/// eingesetzt kommt bei ihm `plex://episode/…` an — kein gültiger
/// Schlüssel. Er braucht die Kennung ein zweites Mal kodiert, damit nach
/// seinem Dekodieren wieder `%3A%2F%2F` dasteht.
public nonisolated func transcodeURL(
for request: TranscodeRequest,
includeProfile: Bool = true,
minimal: Bool = false
) -> URL? {
guard let baseURL = connection.url,
var components = URLComponents(
url: baseURL.appending(path: "/video/:/transcode/universal/start.m3u8"),
resolvingAgainstBaseURL: false
)
else { return nil }
// Das Nötigste, um herauszufinden, an welcher Angabe der Server
// Anstoß nimmt. Eine nackte 400 sagt nicht, welcher Parameter ihr
// nicht passt — halbieren sagt es.
let items = minimal
? [
URLQueryItem(name: "path", value: request.percentEncodedPath),
URLQueryItem(name: "protocol", value: "hls"),
URLQueryItem(name: "hasMDE", value: "1"),
URLQueryItem(name: "mediaIndex", value: String(request.mediaIndex)),
URLQueryItem(name: "partIndex", value: String(request.partIndex)),
URLQueryItem(name: "session", value: request.sessionIdentifier),
URLQueryItem(name: "X-Plex-Session-Identifier", value: request.sessionIdentifier),
URLQueryItem(name: "X-Plex-Token", value: token),
URLQueryItem(name: "X-Plex-Client-Identifier", value: identity.clientIdentifier),
]
: request.queryItems + [
URLQueryItem(name: "X-Plex-Token", value: token),
] + (includeProfile ? clientProfileItems : []) + identity.queryItems
// Jeder Wert streng kodiert, ohne Ausnahme — genauso macht es Plex'
// eigener Client, der alle Parameter durch `qs.stringify` schickt.
//
// `URLComponents` allein tut das nicht: es lässt `/`, `:`, `&`, `=`,
// `+` und Klammern in Werten stehen. Damit kam beim Server
// `path=/livetv/sessions/…` an statt `path=%2Flivetv%2Fsessions%2F…`,
// und das Geräteprofil zerfiel an seinen `&` in zwei Dutzend eigene
// Angaben. Beides beantwortet er mit einer nackten 400.
//
// Nebenbei löst diese eine Regel die Kodierfrage, die mich mehrere
// Anläufe gekostet hat: eine Kennung, die bereits `%3A` enthält, wird
// hier zu `%253A` — und ist damit genau einmal richtig kodiert.
components.percentEncodedQuery = items
.map { item in
let value = item.value?
.addingPercentEncoding(withAllowedCharacters: .plexParameterAllowed) ?? ""
return "\(item.name)=\(value)"
}
.joined(separator: "&")
return components.url
}
/// Baut die Adresse eines Streams, den der Server schon bereitstellt.
///
/// Für Live-TV. Token und Gerätekennung müssen in die Adresse, weil sie
/// der Systemplayer abruft und der keine Kopfzeilen mitschickt.
public nonisolated func directStreamURL(forPath path: String) -> URL? {
guard let baseURL = connection.url,
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
else { return nil }
// Die Adresse aus der Sitzung bringt ihre eigene Abfrage mit
// (`?offset=-1.000000`); die darf nicht verlorengehen.
let pieces = path.split(separator: "?", maxSplits: 1)
components.percentEncodedPath = String(pieces[0])
var items = pieces.count > 1
? (URLComponents(string: "?\(pieces[1])")?.queryItems ?? [])
: []
items.append(URLQueryItem(name: "X-Plex-Token", value: token))
items += identity.queryItems
components.queryItems = items
return components.url
}
/// Fragt den Server, ob er diese Wiedergabe zulässt.
public func transcodeDecision(for request: TranscodeRequest) async throws(PlexError) -> TranscodeDecision {
try await client.send(
PlexRequest(
path: "/video/:/transcode/universal/decision",
query: request.queryItems + clientProfileItems
),
as: TranscodeDecision.self
)
}
/// Die Entscheidung im Wortlaut — **ohne** etwas zu starten.
///
/// Der Entscheider sagt, was der Server *täte*: welche Auflösung, welche
/// Bitrate, ob umgerechnet oder umgepackt wird. Das ist das richtige
/// Werkzeug, um Parameter auszuprobieren.
///
/// Der Weg über `start.m3u8` ist es **nicht**: jede Anfrage dort wirft
/// einen Transcoder an, und ein halbes Dutzend davon in Folge bringt den
/// Server dazu, alles Weitere mit nackten 400ern abzulehnen — auch das,
/// was vorher ging. Genau so ist eine Messung schon einmal gekippt.
public func decisionText(for request: TranscodeRequest) async -> String {
let daten = try? await client.send(
PlexRequest(
path: "/video/:/transcode/universal/decision",
query: request.queryItems + clientProfileItems
)
)
guard let daten else { return "nicht erreichbar" }
return String(decoding: daten.prefix(4_000), as: UTF8.self)
}
/// Hält den Transcoder an.
///
/// Muss beim Verlassen des Players laufen. Ohne dieses Signal rechnet der
/// Server weiter, bis er von selbst merkt, dass niemand mehr abruft — das
/// dauert Minuten und kostet die ganze Zeit über Rechenleistung.
public func stopTranscode(sessionIdentifier: String) async {
_ = try? await client.send(
PlexRequest(
path: "/video/:/transcode/universal/stop",
query: [URLQueryItem(name: "session", value: sessionIdentifier)],
timeout: .seconds(5)
)
)
}
/// Was dieses Gerät abspielen kann, in der Schreibweise von Plex.
///
/// Der Server richtet seine Entscheidung danach aus. Die Angaben stammen
/// aus `DeviceCapabilities` — wären sie großzügiger als das, was iOS
/// wirklich kann, schickte Plex einen Stream, der hier stumm bliebe oder
/// schwarz.
private nonisolated var clientProfileItems: [URLQueryItem] {
[
// Das **Basisprofil**. Ohne diese eine Angabe lehnt der Server
// jede Anfrage an den Transcoder mit einer nackten 400 ab — und
// zwar jede, auch die an eine gewöhnliche Datei.
//
// Der Grund ist banal und war zwei Tage lang unsichtbar:
// `X-Plex-Client-Profile-Extra` **erweitert** ein Profil, es
// ersetzt keins. Ohne Basis gibt es nichts zu erweitern, und der
// Server kann keine Entscheidung bilden.
//
// „Chrome", weil dieses Profil auf jedem Server vorhanden ist und
// genau das kann, was auch AVPlayer kann: HLS mit H.264 und AAC.
// Die Feinheiten für iOS stehen ohnehin im Zusatz darunter.
URLQueryItem(name: "X-Plex-Client-Profile-Name", value: "Chrome"),
URLQueryItem(
name: "X-Plex-Client-Profile-Extra",
value: [
// Was ohne Umrechnung durchgereicht werden darf.
"add-transcode-target(type=videoProfile&context=streaming&protocol=hls&container=mpegts&videoCodec=h264&audioCodec=aac)",
// DTS und TrueHD kann iOS nicht — für sie ausdrücklich
// eine Umrechnung nach AAC verlangen, statt zu hoffen,
// dass Plex es von selbst merkt.
"add-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=aac)",
"add-limitation(scope=videoCodec&scopeName=h264&type=upperBound&name=video.level&value=52)",
].joined(separator: "+")
),
]
}
// MARK: - Fortschritt melden
/// Meldet die aktuelle Stelle an den Server.
///
/// Grundlage für das geräteübergreifende Weiterschauen: was hier
/// ankommt, sieht der Fernseher im Wohnzimmer. Fehler werden verschluckt —
/// eine Fehlermeldung mitten im Film wäre störender als ein verlorener
/// Fortschrittswert, und der nächste Bericht kommt in Sekunden.
public func reportProgress(
ratingKey: String,
state: PlaybackReportState,
time: Duration,
duration: Duration
) async {
_ = try? await client.send(
PlexRequest(
path: "/:/timeline",
query: [
URLQueryItem(name: "ratingKey", value: ratingKey),
URLQueryItem(name: "key", value: "/library/metadata/\(ratingKey)"),
// Plex kennt keinen eigenen Zustand für den Beginn.
URLQueryItem(
name: "state",
value: state == .started ? "playing" : state.rawValue
),
URLQueryItem(name: "time", value: String(time.milliseconds)),
URLQueryItem(name: "duration", value: String(duration.milliseconds)),
],
timeout: .seconds(10)
)
)
}
/// Markiert einen Titel als gesehen.
public func markWatched(ratingKey: String) async throws(PlexError) {
_ = try await client.send(scrobbleRequest(path: "/:/scrobble", ratingKey: ratingKey))
}
/// Nimmt die Markierung zurück.
public func markUnwatched(ratingKey: String) async throws(PlexError) {
_ = try await client.send(scrobbleRequest(path: "/:/unscrobble", ratingKey: ratingKey))
}
/// Nimmt einen Titel aus „Weiterschauen".
///
/// **Plex hat dafür einen eigenen Weg** — anders als Jellyfin, wo man
/// die gemerkte Stelle wegnehmen muss. `/:/unscrobble` wäre hier falsch:
/// das setzt zugleich auf ungesehen, und beides zu vermischen war
/// ausdrücklich nicht gewollt.
public func removeFromContinueWatching(ratingKey: String) async throws(PlexError) {
_ = try await client.send(
PlexRequest(
method: .put,
path: "/actions/removeFromContinueWatching",
query: [URLQueryItem(name: "ratingKey", value: ratingKey)]
)
)
}
/// Setzt eine Bewertung. `rating` von 0 bis 10; 0 entfernt sie.
public func rate(ratingKey: String, rating: Int) async throws(PlexError) {
_ = try await client.send(
PlexRequest(path: "/:/rate", query: [
URLQueryItem(name: "key", value: ratingKey),
URLQueryItem(name: "identifier", value: "com.plexapp.plugins.library"),
URLQueryItem(name: "rating", value: String(max(0, min(10, rating)))),
])
)
}
/// Stößt einen Suchlauf in einer Mediathek an.
public func refreshLibrary(_ library: PlexLibrary) async throws(PlexError) {
_ = try await client.send(PlexRequest(path: "/library/sections/\(library.key)/refresh"))
}
private nonisolated func scrobbleRequest(path: String, ratingKey: String) -> PlexRequest {
PlexRequest(path: path, query: [
URLQueryItem(name: "key", value: ratingKey),
// Ohne diese Kennung antwortet Plex mit 400. Sie benennt die
// Bibliotheks-Erweiterung, an die sich die Meldung richtet.
URLQueryItem(name: "identifier", value: "com.plexapp.plugins.library"),
])
}
}
/// In welchem Zustand sich die Wiedergabe beim Melden befindet.
extension PlexServer {
/// Die nächste Folge nach der angegebenen.
///
/// Plex kennt keinen Endpunkt dafür. Der Weg führt über die Geschwister:
/// die Folgen derselben Staffel holen und die mit der nächsthöheren Nummer
/// nehmen. Ist die Staffel zu Ende, wird in der folgenden weitergesucht —
/// sonst bräche das Weiterschauen jedes Mal am Staffelende ab.
public func nextEpisode(after episode: PlexItem) async throws(PlexError) -> PlexItem? {
guard episode.kind == .episode,
let seasonKey = episode.parentRatingKey,
let currentIndex = episode.index
else { return nil }
let siblings = try await children(of: seasonKey)
if let next = siblings
.filter({ ($0.index ?? 0) > currentIndex })
.min(by: { ($0.index ?? 0) < ($1.index ?? 0) }) {
return next
}
// Staffelende: in der nächsten Staffel weitersuchen.
guard let showKey = episode.grandparentRatingKey,
let seasonNumber = episode.parentIndex
else { return nil }
let seasons = try await children(of: showKey)
guard let nextSeason = seasons
.filter({ ($0.index ?? 0) > seasonNumber })
.min(by: { ($0.index ?? 0) < ($1.index ?? 0) })
else { return nil }
return try await children(of: nextSeason.ratingKey)
.min(by: { ($0.index ?? 0) < ($1.index ?? 0) })
}
}
extension PlexServer {
/// Die Adresse für einen heruntergerechneten Download.
///
/// **Noch nicht an einem echten Server geprüft.** Der Weg nutzt den
/// Universal-Transcoder mit `protocol=http`, der einen fortlaufenden Stream
/// statt HLS liefert — anders als der offizielle Sync-Weg von Plex, der
/// umständlich und schlecht dokumentiert ist.
///
/// Zwei Dinge sind dabei offen und gehören am eigenen Server geprüft,
/// bevor die Einstellung angeboten wird:
/// 1. Ob Plex eine `Content-Length` mitschickt. Ohne sie gibt es keinen
/// Fortschrittsbalken und kein Fortsetzen nach dem Pausieren.
/// 2. Ob die entstehende Datei durchsuchbar ist. Ein Stream, der von vorn
/// gedacht ist, muss es nicht sein — dann ließe sich im Download nicht
/// springen.
///
/// Bis das geklärt ist, bleibt der Download in Originalqualität die
/// Voreinstellung.
public nonisolated func transcodedDownloadURL(
ratingKey: String,
mediaIndex: Int = 0,
maximumBitrate: Int,
resolution: String?
) -> URL? {
guard let baseURL = connection.url,
var components = URLComponents(
url: baseURL.appending(path: "/video/:/transcode/universal/start"),
resolvingAgainstBaseURL: false
)
else { return nil }
let request = TranscodeRequest(
ratingKey: ratingKey,
mediaIndex: mediaIndex,
maximumBitrate: maximumBitrate,
resolution: resolution,
// Ein Download ist kein Abspielen im Heimnetz: `wan` lässt Plex
// konservativer puffern, was für eine Datei egal ist.
isLocal: false
)
components.queryItems = request.queryItems
// Überschreibt `protocol=hls` aus der Anfrage: für eine Datei
// brauchen wir einen fortlaufenden Stream, keine Segmentliste.
.filter { $0.name != "protocol" && $0.name != "fastSeek" && $0.name != "copyts" }
+ [
URLQueryItem(name: "protocol", value: "http"),
URLQueryItem(name: "X-Plex-Token", value: token),
]
return components.url
}
}
extension CharacterSet {
/// Was in einem Abfragewert unangetastet bleiben darf.
///
/// Streng gehalten: alles andere wird kodiert. `&` und `=` sind hier die
/// eigentlichen Übeltäter — sie trennen Parameter, und `URLComponents`
/// kodiert sie in Werten nicht von selbst.
static let plexParameterAllowed = CharacterSet(
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
)
}
@@ -1,247 +0,0 @@
import Foundation
/// Ein verbundener Plex-Server.
///
/// Die eine Stelle, an der die App mit ihrem Server spricht. Die Views kennen
/// weder Pfade noch Abfrageparameter — sie rufen Methoden auf.
public actor PlexServer {
// `nonisolated`, weil beide unveränderlich und Sendable sind: die
// Oberfläche muss für „welche Verbindung läuft gerade" nicht auf den
// Actor warten. Über Modulgrenzen hinweg leitet der Compiler das nicht
// von selbst her.
public nonisolated let resource: PlexResource
public nonisolated let connection: PlexConnection
/// Modulweit sichtbar, weil die Endpunkte auf mehrere Dateien verteilt
/// sind — Blättern hier, Wiedergabe in PlexServer+Playback. Nach außen
/// bleibt er verborgen: die App soll Methoden aufrufen, keine Pfade bauen.
let client: PlexHTTPClient
nonisolated let token: String
/// Wer wir für den Server sind.
///
/// Auch hier festgehalten, nicht nur im Client: Adressen, die der
/// Systemplayer abruft, müssen die Kennung selbst tragen — AVPlayer
/// schickt keine Kopfzeilen mit.
nonisolated let identity: PlexClientIdentity
/// Für Abrufe ohne Plex-Kopfzeilen — so, wie der Systemplayer sie macht.
nonisolated let transport: any PlexTransport
public init?(
resource: PlexResource,
connection: PlexConnection,
identity: PlexClientIdentity,
transport: any PlexTransport = URLSession.shared
) {
guard let baseURL = connection.url, let token = resource.accessToken else { return nil }
self.resource = resource
self.connection = connection
self.token = token
self.identity = identity
self.transport = transport
self.client = PlexHTTPClient(
baseURL: baseURL,
identity: identity,
token: token,
transport: transport
)
}
public nonisolated var name: String { resource.name }
// MARK: - Mediatheken
public func libraries() async throws(PlexError) -> [PlexLibrary] {
let container = try await client.send(
PlexRequest(path: "/library/sections"),
as: PlexMediaContainer<PlexLibrary>.self
)
return container.content
}
// MARK: - Startseite
/// Die fertig zusammengestellten Reihen für die Startseite.
public func homeHubs() async throws(PlexError) -> [PlexHub] {
let container = try await client.send(
PlexRequest(path: "/hubs", query: [
// Leere Reihen wären auf der Startseite nur Überschriften ohne
// Inhalt.
URLQueryItem(name: "includeEmpty", value: "0"),
URLQueryItem(name: "count", value: "20"),
]),
as: PlexMediaContainer<PlexHub>.self
)
return container.content.filter { !$0.items.isEmpty }
}
/// Was als Nächstes dran ist: angefangene Filme und die nächste ungesehene
/// Folge je Serie.
public func continueWatching() async throws(PlexError) -> [PlexItem] {
let container = try await client.send(
PlexRequest(path: "/hubs/continueWatching/items"),
as: PlexMediaContainer<PlexItem>.self
)
return container.content
}
// MARK: - Blättern
public func items(
inLibrary library: PlexLibrary,
query: LibraryQuery = LibraryQuery(),
offset: Int = 0,
limit: Int = 60
) async throws(PlexError) -> PlexPage<PlexItem> {
let container = try await client.send(
PlexRequest(
path: "/library/sections/\(library.key)/all",
query: query.queryItems(libraryKind: library.kind),
// Die Seitengröße steht in Kopfzeilen, nicht in der Adresse —
// eine Eigenheit von Plex.
additionalHeaders: [
"X-Plex-Container-Start": String(offset),
"X-Plex-Container-Size": String(limit),
]
),
as: PlexMediaContainer<PlexItem>.self
)
return PlexPage(
items: container.content,
offset: container.offset ?? offset,
// `totalSize` fehlt, wenn alles auf eine Seite passt. Dann ist die
// Gesamtzahl gleich der Anzahl der gelieferten Einträge.
totalSize: container.totalSize ?? (offset + container.content.count)
)
}
/// Ein einzelner Titel mit allem, was die Detailseite braucht.
public func metadata(ratingKey: String) async throws(PlexError) -> PlexItem {
let container = try await client.send(
PlexRequest(path: "/library/metadata/\(ratingKey)", query: [
// Marker für Intro und Abspann. Sie werden schon jetzt
// mitgeholt, obwohl sie erst in Phase 2 ausgewertet werden —
// sie kosten nichts und ersparen später eine zweite Anfrage.
URLQueryItem(name: "includeMarkers", value: "1"),
URLQueryItem(name: "includeChapters", value: "1"),
URLQueryItem(name: "includeRelated", value: "1"),
]),
as: PlexMediaContainer<PlexItem>.self
)
guard let item = container.content.first else { throw .notFound }
return item
}
/// Die Staffeln einer Serie bzw. die Folgen einer Staffel.
public func children(of ratingKey: String) async throws(PlexError) -> [PlexItem] {
let container = try await client.send(
PlexRequest(path: "/library/metadata/\(ratingKey)/children"),
as: PlexMediaContainer<PlexItem>.self
)
return container.content
}
// MARK: - Suche
/// Sucht über alle Mediatheken. Plex gruppiert das Ergebnis selbst nach
/// Filmen, Serien, Folgen und Personen.
public func search(_ query: String, limit: Int = 30) async throws(PlexError) -> [PlexHub] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
let container = try await client.send(
PlexRequest(path: "/hubs/search", query: [
URLQueryItem(name: "query", value: trimmed),
URLQueryItem(name: "limit", value: String(limit)),
]),
as: PlexMediaContainer<PlexHub>.self
)
return container.content.filter { !$0.items.isEmpty }
}
// MARK: - Adressen
/// Die Adresse eines Bildes in der gewünschten Größe.
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
guard let path, !path.isEmpty else { return nil }
return client.artworkURL(path: path, width: width, height: height, token: token)
}
/// Ein Bild unverändert, ohne den Umweg über den Bild-Transcoder.
///
/// Für freigestellte Schriftzüge zwingend: `/photo/:/transcode` liefert
/// JPEG, und JPEG kennt keine Transparenz. Ein Logo käme als weißer
/// Schriftzug auf schwarzem Kasten heraus statt freigestellt auf dem
/// Hintergrundbild.
public nonisolated func rawImageURL(path: String?) -> URL? {
guard let path, !path.isEmpty,
let baseURL = connection.url,
var components = URLComponents(
url: baseURL.appending(path: path),
resolvingAgainstBaseURL: false
)
else { return nil }
components.queryItems = [URLQueryItem(name: "X-Plex-Token", value: token)]
return components.url
}
/// Die Adresse für DirectPlay: die Datei, unverändert.
public nonisolated func directPlayURL(part: PlexPart) -> URL? {
streamURL(partKey: part.key, download: false)
}
/// Dieselbe Datei, aber als Download angefordert. Der Unterschied liegt
/// nur im Schalter — Plex setzt daraufhin Kopfzeilen, die das Weiterladen
/// nach einem Abbruch erlauben.
public nonisolated func downloadURL(part: PlexPart) -> URL? {
streamURL(partKey: part.key, download: true)
}
private nonisolated func streamURL(partKey: String, download: Bool) -> URL? {
guard let baseURL = connection.url,
var components = URLComponents(
url: baseURL.appending(path: partKey),
resolvingAgainstBaseURL: false
)
else { return nil }
var query = [URLQueryItem(name: "X-Plex-Token", value: token)]
if download { query.append(URLQueryItem(name: "download", value: "1")) }
components.queryItems = query
return components.url
}
}
// MARK: - Filterwerte
/// Ein Wert, nach dem gefiltert werden kann — ein Genre, ein Jahr, eine
/// Altersfreigabe.
public struct PlexFilterOption: Sendable, Equatable, Decodable, Identifiable {
public let key: String
public let title: String
public var id: String { key }
public init(key: String, title: String) {
self.key = key
self.title = title
}
}
extension PlexServer {
/// Die Werte, die es in dieser Mediathek für ein Filterfeld gibt.
///
/// Wird vom Server berechnet, nicht aus der Titelliste abgeleitet: die
/// Liste ist seitenweise geladen und kennt die Genres der übrigen
/// Tausenden Titel gar nicht.
public func filterOptions(
inLibrary library: PlexLibrary,
field: String
) async throws(PlexError) -> [PlexFilterOption] {
let container = try await client.send(
PlexRequest(path: "/library/sections/\(library.key)/\(field)"),
as: PlexMediaContainer<PlexFilterOption>.self
)
return container.content
}
}
@@ -1,229 +0,0 @@
import Foundation
/// Die Anfrage an den Transcoder von Plex.
///
/// Baut die Parameter für `/video/:/transcode/universal/…`. Plex entscheidet
/// daraufhin selbst, wie viel es umrechnen muss: passt der Bildcodec, aber
/// nicht der Container, wird nur umgepackt („direct stream"); passt nichts,
/// wird neu kodiert. Das ist deutlich schonender für den Server, als pauschal
/// alles neu zu rechnen — deshalb steht `directStream` auf 1.
public struct TranscodeRequest: Sendable, Equatable {
public let ratingKey: String
public let mediaIndex: Int
public let partIndex: Int
/// Obergrenze in Kilobit pro Sekunde.
public let maximumBitrate: Int?
/// Zielauflösung als `"1920x1080"`. `nil` behält die des Originals.
public let resolution: String?
/// Ob der Server im selben Netz steht. Plex wählt danach andere
/// Voreinstellungen für Puffergrößen.
public let isLocal: Bool
/// Kennung dieser Wiedergabe. Über sie lässt sich der Transcoder später
/// wieder anhalten — ohne sie rechnet der Server weiter, auch wenn längst
/// niemand mehr zusieht.
public let sessionIdentifier: String
/// Welche Untertitelspur eingebrannt werden soll.
public let subtitleStreamID: Int?
public let audioStreamID: Int?
/// Eine andere Adresse als die der Mediathek.
///
/// Live-TV liegt nicht unter `/library/metadata`, sondern beim
/// Programmanbieter. Der Transcoder nimmt beides — aber nur, wenn man
/// ihm die richtige Adresse nennt.
public let pathOverride: String?
/// Ob Plex die Qualität selbst nachregeln soll.
public let adaptive: Bool
public init(
ratingKey: String,
mediaIndex: Int = 0,
partIndex: Int = 0,
maximumBitrate: Int? = nil,
resolution: String? = nil,
isLocal: Bool,
sessionIdentifier: String = UUID().uuidString,
subtitleStreamID: Int? = nil,
audioStreamID: Int? = nil,
pathOverride: String? = nil,
adaptive: Bool = false
) {
self.ratingKey = ratingKey
self.mediaIndex = mediaIndex
self.partIndex = partIndex
self.maximumBitrate = maximumBitrate
self.resolution = resolution
self.isLocal = isLocal
self.sessionIdentifier = sessionIdentifier
self.subtitleStreamID = subtitleStreamID
self.audioStreamID = audioStreamID
self.pathOverride = pathOverride
self.adaptive = adaptive
}
/// Dieselbe Anfrage, aber mit Leiter.
///
/// Dieselbe Sitzungskennung: es ist **eine** Wiedergabe, und der Server
/// soll sie nicht für zwei halten — sonst bliebe beim Aufräumen ein
/// Transcoder stehen.
public func askingForLadder() -> TranscodeRequest {
TranscodeRequest(
ratingKey: ratingKey,
mediaIndex: mediaIndex,
partIndex: partIndex,
maximumBitrate: maximumBitrate,
resolution: resolution,
isLocal: isLocal,
sessionIdentifier: sessionIdentifier,
subtitleStreamID: subtitleStreamID,
audioStreamID: audioStreamID,
pathOverride: pathOverride,
adaptive: true
)
}
/// Die Adresse, die umgerechnet werden soll — so, wie sie in die Anfrage
/// gehört: **bereits kodiert**.
///
/// Hier lagen beide naheliegenden Wege daneben. Unverändert durch
/// `URLComponents` geschickt, wird sie ein zweites Mal kodiert: aus `%3A`
/// wird `%253A`. Vorher entpackt, steht `plex://episode/…` im Klartext in
/// der Anfrage, und daran verschluckt sich der Server — er antwortet mit
/// einer nackten „400 Bad Request"-Seite, ohne Plex-Meldung.
///
/// Richtig ist: genau einmal kodiert. Darum kümmert sich `transcodeURL`,
/// indem es diesen Wert unangetastet einsetzt.
var percentEncodedPath: String {
pathOverride ?? "/library/metadata/\(ratingKey)"
}
var queryItems: [URLQueryItem] {
var items = [
URLQueryItem(name: "path", value: percentEncodedPath),
// „Ich verstehe die Entscheidungsantwort." Plex' eigener Client
// schickt das bei jeder Wiedergabe mit. Ohne diese Angabe lehnt
// der Server eine Live-Sitzung mit einer nackten 400 ab.
URLQueryItem(name: "hasMDE", value: "1"),
URLQueryItem(name: "mediaIndex", value: String(mediaIndex)),
URLQueryItem(name: "partIndex", value: String(partIndex)),
URLQueryItem(name: "protocol", value: "hls"),
// Ohne fastSeek muss der Transcoder beim Springen von vorn
// beginnen — bei einem Zweistünder dauert das ewig.
URLQueryItem(name: "fastSeek", value: "1"),
// 0 heißt: wir fragen hier ausdrücklich nach einem Stream. Ob
// DirectPlay möglich wäre, hat der PlaybackDecider schon geprüft.
URLQueryItem(name: "directPlay", value: "0"),
// 1 erlaubt das bloße Umpacken, wenn der Bildcodec passt. Das
// spart dem Server die Neukodierung.
URLQueryItem(name: "directStream", value: "1"),
URLQueryItem(name: "copyts", value: "1"),
URLQueryItem(name: "location", value: isLocal ? "lan" : "wan"),
URLQueryItem(name: "session", value: sessionIdentifier),
URLQueryItem(name: "X-Plex-Session-Identifier", value: sessionIdentifier),
]
// **Eine Leiter statt einer Stufe.**
//
// Plex' Universal Transcoder liefert dann eine Playlist mit mehreren
// Varianten; die bestellte Bitrate bleibt die oberste. Ohne diesen
// Wert gibt es genau eine, und AVPlayer hat nichts zum Umschalten.
//
// Als Eigenschaft und nicht als feste Zeile, weil `queryItems` auch
// die Adresse zum **Herunterladen** speist — dort ergäbe eine Leiter
// keinen Sinn, und mit einer Eigenschaft muss dort nichts
// herausgefiltert werden.
if adaptive {
items.append(URLQueryItem(name: "autoAdjustQuality", value: "1"))
}
if let maximumBitrate {
items.append(URLQueryItem(name: "maxVideoBitrate", value: String(maximumBitrate)))
// `videoQuality` skaliert mit: ohne sie ignoriert Plex die
// Bitratengrenze bei manchen Fassungen.
items.append(URLQueryItem(name: "videoQuality", value: String(quality(for: maximumBitrate))))
}
if let resolution {
items.append(URLQueryItem(name: "videoResolution", value: resolution))
}
if let subtitleStreamID {
items.append(URLQueryItem(name: "subtitles", value: "burn"))
items.append(URLQueryItem(name: "subtitleStreamID", value: String(subtitleStreamID)))
items.append(URLQueryItem(name: "subtitleSize", value: "100"))
}
if let audioStreamID {
items.append(URLQueryItem(name: "audioStreamID", value: String(audioStreamID)))
}
return items
}
/// Übersetzt eine Bitrate in Plex' Qualitätsskala von 10 bis 100.
///
/// Die Werte stammen aus der Staffelung, die Plex in seinen eigenen Apps
/// anbietet. Zwischen den Stufen wird aufgerundet — lieber etwas zu gut
/// als sichtbar zu schlecht.
private func quality(for bitrate: Int) -> Int {
switch bitrate {
case ..<1_000: 20
case ..<2_000: 40
case ..<4_000: 60
case ..<8_000: 75
case ..<12_000: 90
default: 100
}
}
}
/// Was der Server zur geplanten Wiedergabe sagt.
///
/// Wird vor dem Start abgefragt. Der Wert liegt weniger in der Entscheidung
/// selbst — die trifft der `PlaybackDecider` — als im Fehlerfall: sagt Plex
/// hier schon nein, erfährt man den Grund im Klartext, statt später vor einem
/// schwarzen Bild zu sitzen.
public struct TranscodeDecision: Sendable, Decodable {
public let generalDecisionCode: Int?
public let generalDecisionText: String?
public let transcodeDecisionCode: Int?
public let transcodeDecisionText: String?
public let mediaDecisionCode: Int?
/// Ob der Server die Wiedergabe grundsätzlich zulässt.
///
/// `1000` heißt „direkt abspielbar", `1001` „direkt nicht, umrechnen
/// schon", `2000` „Umwandlung in Ordnung". Das sind alles **Zusagen** —
/// erst ab `3000` wird abgelehnt, etwa bei fehlender Berechtigung oder
/// überlastetem Transcoder.
///
/// Vorher galt nur `1000` als Zusage. Damit wurde ausgerechnet der Fall
/// blockiert, den die App selbst angefordert hatte: wer im Player eine
/// Stufe wählt, will umgerechnet bekommen, und der Server antwortete
/// „direkte Wiedergabe nicht verfügbar; Umwandlung ok" — eine Zusage, die
/// hier als Absage ankam. Es spielte dann gar nichts.
public var isAllowed: Bool {
guard let generalDecisionCode else { return true }
return generalDecisionCode < Self.firstRefusalCode
}
/// Ab hier lehnt Plex ab.
static let firstRefusalCode = 3_000
public var explanation: String? {
generalDecisionText ?? transcodeDecisionText
}
private enum OuterKeys: String, CodingKey { case mediaContainer = "MediaContainer" }
private enum InnerKeys: String, CodingKey {
case generalDecisionCode, generalDecisionText
case transcodeDecisionCode, transcodeDecisionText
case mediaDecisionCode
}
public init(from decoder: any Decoder) throws {
let outer = try decoder.container(keyedBy: OuterKeys.self)
let inner = try outer.nestedContainer(keyedBy: InnerKeys.self, forKey: .mediaContainer)
self.generalDecisionCode = try inner.decodeIfPresent(Int.self, forKey: .generalDecisionCode)
self.generalDecisionText = try inner.decodeIfPresent(String.self, forKey: .generalDecisionText)
self.transcodeDecisionCode = try inner.decodeIfPresent(Int.self, forKey: .transcodeDecisionCode)
self.transcodeDecisionText = try inner.decodeIfPresent(String.self, forKey: .transcodeDecisionText)
self.mediaDecisionCode = try inner.decodeIfPresent(Int.self, forKey: .mediaDecisionCode)
}
}
@@ -1,187 +0,0 @@
import Foundation
import MediaKit
/// Die Übersetzung der Plex-Drahtmodelle in die neutrale Form.
///
/// Eine Datei, eine Richtung. Danach ist unerheblich, von welchem Server ein
/// Titel stammt — und genau das war das Ziel: die Oberfläche soll nicht an
/// dreißig Stellen wissen müssen, wie Plex seine Felder nennt.
///
/// Die Drahtmodelle bleiben, wo sie sind. Sie tragen Eigenheiten, die nur
/// Plex hat (dynamische Schlüssel in der Hülle, `grandparent` für die Serie,
/// Sekunden seit 1970 als Zahl), und die gehören nicht nach oben.
extension PlexItem {
public var asMediaItem: MediaItem {
MediaItem(
id: ratingKey,
path: key,
kind: kind.map(Self.kind) ?? nil,
title: title,
sortTitle: titleSort,
summary: summary,
year: year,
duration: duration,
contentRating: contentRating,
rating: rating,
audienceRating: audienceRating,
thumb: thumb,
art: art,
parentThumb: parentThumb,
seriesThumb: grandparentThumb,
seriesArt: grandparentArt,
index: index,
parentIndex: parentIndex,
parentTitle: parentTitle,
parentID: parentRatingKey,
seriesTitle: grandparentTitle,
seriesID: grandparentRatingKey,
playCount: viewCount,
resumeMilliseconds: viewOffset,
lastPlayedAt: lastViewedAt.map { Date(timeIntervalSince1970: TimeInterval($0)) },
addedAt: addedAt.map { Date(timeIntervalSince1970: TimeInterval($0)) },
// Plex kennt keine Favoriten. Ehrlicher als eine erfundene
// Markierung, die sich nirgends setzen ließe.
isFavourite: false,
episodeCount: leafCount,
playedEpisodeCount: viewedLeafCount,
childCount: childCount,
versions: media.map(\.asMediaVersion),
genres: genres.map(\.tag),
directors: directors.map(\.tag),
writers: writers.map(\.tag),
cast: roles.map(\.asMediaPerson),
markers: markers.map(\.asMediaMarker),
artwork: images.map(\.asMediaArtwork)
)
}
private static func kind(_ plex: PlexItem.Kind) -> MediaItem.Kind {
switch plex {
case .movie: .movie
case .show: .show
case .season: .season
case .episode: .episode
case .clip: .clip
case .collection: .collection
}
}
}
extension PlexMedia {
public var asMediaVersion: MediaVersion {
MediaVersion(
id: String(id),
container: container,
videoCodec: videoCodec,
audioCodec: audioCodec,
videoResolution: videoResolution,
bitrate: bitrate,
width: width,
height: height,
audioChannels: audioChannels,
duration: duration,
files: parts.map(\.asMediaFile)
)
}
}
extension PlexPart {
public var asMediaFile: MediaFile {
MediaFile(
id: String(id),
key: key,
container: container,
size: size,
duration: duration,
file: file,
streams: streams.compactMap(\.asMediaStream)
)
}
}
extension PlexStream {
/// `nil`, wenn Plex eine Spurart meldet, mit der die App nichts anfängt.
public var asMediaStream: MediaStream? {
let mapped: MediaStream.Kind
switch kind {
case .video: mapped = .video
case .audio: mapped = .audio
case .subtitle: mapped = .subtitle
case nil: return nil
}
return MediaStream(
id: String(id),
kind: mapped,
codec: codec,
language: language,
languageCode: languageCode,
displayTitle: displayTitle ?? title,
channels: channels,
isSelected: isSelected,
isForced: isForced
)
}
}
extension PlexTag {
public var asMediaPerson: MediaPerson {
MediaPerson(id: id, name: tag, role: role, thumb: thumb)
}
}
extension PlexMarker {
public var asMediaMarker: MediaMarker {
MediaMarker(
kind: MediaMarker.Kind(rawValue: type) ?? .unknown,
startMilliseconds: startTimeOffset,
endMilliseconds: endTimeOffset
)
}
}
extension PlexImage {
public var asMediaArtwork: MediaArtwork {
MediaArtwork(
kind: MediaArtwork.Kind(rawValue: type) ?? .unknown,
path: url
)
}
}
extension PlexLibrary {
public var asMediaLibrary: MediaLibrary {
MediaLibrary(
id: key,
title: title,
kind: mediaKind,
thumb: thumb,
art: art
)
}
}
extension PlexLibrary {
private var mediaKind: MediaLibrary.Kind {
switch kind {
case .movie: .movie
case .show: .show
case .artist: .music
case .photo: .photo
case .livetv: .liveTV
case nil: .other
}
}
}
extension PlexHub {
public var asMediaRowGroup: MediaRowGroup {
MediaRowGroup(
id: hubIdentifier,
title: title,
moreKey: more == true ? key : nil,
libraryID: librarySectionID,
items: items.map(\.asMediaItem)
)
}
}
@@ -1,249 +0,0 @@
import Foundation
/// Ein Eintrag in der Mediathek — Film, Serie, Staffel oder Folge.
///
/// Plex verwendet für alle vier denselben Datensatz und unterscheidet über
/// `type`. Das ist ungewohnt, aber praktisch: Suchergebnisse und Reihen auf
/// der Startseite mischen die Arten, und ein gemeinsamer Typ erspart das
/// Aufteilen bei jedem Abruf.
/// Nur `Decodable`: geschrieben wird dieser Typ nirgends. Der Offline-Bestand
/// in Phase 3 bekommt ein eigenes SwiftData-Modell, weil er andere Felder
/// braucht — lokaler Dateipfad, Ladezeitpunkt — und andere nicht.
public struct PlexItem: Sendable, Equatable, Decodable, Identifiable {
public enum Kind: String, Sendable, Codable {
case movie, show, season, episode, clip, collection
}
/// Die Kennung, unter der der Server den Eintrag führt. Kommt als
/// Zeichenkette, obwohl es eine Zahl ist.
public let ratingKey: String
public let key: String
public let guid: String?
public let type: String
public let title: String
public let titleSort: String?
public let summary: String?
public let year: Int?
/// Laufzeit in Millisekunden.
public let duration: Int?
public let contentRating: String?
public let rating: Double?
public let audienceRating: Double?
// Bildpfade. Relativ zum Server — die vollständige Adresse entsteht erst
// beim Abruf, weil sie Token und Größenangaben braucht.
public let thumb: String?
public let art: String?
public let parentThumb: String?
public let grandparentThumb: String?
public let grandparentArt: String?
// Einordnung bei Folgen: `index` ist die Folgennummer, `parentIndex` die
// Staffel, `grandparentTitle` der Serientitel.
public let index: Int?
public let parentIndex: Int?
public let parentTitle: String?
public let parentRatingKey: String?
public let grandparentTitle: String?
public let grandparentRatingKey: String?
// Fortschritt.
public let viewCount: Int?
/// Wie weit gesehen wurde, in Millisekunden.
public let viewOffset: Int?
public let lastViewedAt: Int?
public let addedAt: Int?
// Zählwerte bei Serien und Staffeln.
public let leafCount: Int?
public let viewedLeafCount: Int?
public let childCount: Int?
public let media: [PlexMedia]
// Schlagwörter. Plex liefert sie nur bei der Einzelabfrage eines Titels
// mit, nicht in Listen — deshalb sind sie dort leer.
public let genres: [PlexTag]
public let directors: [PlexTag]
public let writers: [PlexTag]
/// Die Besetzung, mit Porträt und Rollennamen.
public let roles: [PlexTag]
public let markers: [PlexMarker]
/// Plakat, Hintergrund und — wenn vorhanden — der Schriftzug des Films.
public let images: [PlexImage]
private enum CodingKeys: String, CodingKey {
case ratingKey, key, guid, type, title, titleSort, summary, year, duration
case contentRating, rating, audienceRating
case thumb, art, parentThumb, grandparentThumb, grandparentArt
case index, parentIndex, parentTitle, parentRatingKey
case grandparentTitle, grandparentRatingKey
case viewCount, viewOffset, lastViewedAt, addedAt
case leafCount, viewedLeafCount, childCount
case media = "Media"
case genres = "Genre"
case directors = "Director"
case writers = "Writer"
case roles = "Role"
case markers = "Marker"
case images = "Image"
}
/// Von Hand geschrieben statt erzeugt, weil der erzeugte Decoder einen
/// `Media`-Block verlangen würde. Plex liefert Einträge aber schon aus,
/// bevor es die Dateien analysiert hat — ohne diese Nachsicht wäre eine
/// frisch hinzugefügte Staffel als Ganzes unlesbar statt nur unvollständig.
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.ratingKey = try values.decode(String.self, forKey: .ratingKey)
self.key = try values.decodeIfPresent(String.self, forKey: .key) ?? "/library/metadata/\(ratingKey)"
self.guid = try values.decodeIfPresent(String.self, forKey: .guid)
self.type = try values.decode(String.self, forKey: .type)
self.title = try values.decode(String.self, forKey: .title)
self.titleSort = try values.decodeIfPresent(String.self, forKey: .titleSort)
self.summary = try values.decodeIfPresent(String.self, forKey: .summary)
self.year = try values.decodeIfPresent(Int.self, forKey: .year)
self.duration = try values.decodeIfPresent(Int.self, forKey: .duration)
self.contentRating = try values.decodeIfPresent(String.self, forKey: .contentRating)
self.rating = try values.decodeIfPresent(Double.self, forKey: .rating)
self.audienceRating = try values.decodeIfPresent(Double.self, forKey: .audienceRating)
self.thumb = try values.decodeIfPresent(String.self, forKey: .thumb)
self.art = try values.decodeIfPresent(String.self, forKey: .art)
self.parentThumb = try values.decodeIfPresent(String.self, forKey: .parentThumb)
self.grandparentThumb = try values.decodeIfPresent(String.self, forKey: .grandparentThumb)
self.grandparentArt = try values.decodeIfPresent(String.self, forKey: .grandparentArt)
self.index = try values.decodeIfPresent(Int.self, forKey: .index)
self.parentIndex = try values.decodeIfPresent(Int.self, forKey: .parentIndex)
self.parentTitle = try values.decodeIfPresent(String.self, forKey: .parentTitle)
self.parentRatingKey = try values.decodeIfPresent(String.self, forKey: .parentRatingKey)
self.grandparentTitle = try values.decodeIfPresent(String.self, forKey: .grandparentTitle)
self.grandparentRatingKey = try values.decodeIfPresent(String.self, forKey: .grandparentRatingKey)
self.viewCount = try values.decodeIfPresent(Int.self, forKey: .viewCount)
self.viewOffset = try values.decodeIfPresent(Int.self, forKey: .viewOffset)
self.lastViewedAt = try values.decodeIfPresent(Int.self, forKey: .lastViewedAt)
self.addedAt = try values.decodeIfPresent(Int.self, forKey: .addedAt)
self.leafCount = try values.decodeIfPresent(Int.self, forKey: .leafCount)
self.viewedLeafCount = try values.decodeIfPresent(Int.self, forKey: .viewedLeafCount)
self.childCount = try values.decodeIfPresent(Int.self, forKey: .childCount)
self.media = try values.decodeIfPresent([PlexMedia].self, forKey: .media) ?? []
self.genres = try values.decodeIfPresent([PlexTag].self, forKey: .genres) ?? []
self.directors = try values.decodeIfPresent([PlexTag].self, forKey: .directors) ?? []
self.writers = try values.decodeIfPresent([PlexTag].self, forKey: .writers) ?? []
self.roles = try values.decodeIfPresent([PlexTag].self, forKey: .roles) ?? []
self.markers = try values.decodeIfPresent([PlexMarker].self, forKey: .markers) ?? []
self.images = try values.decodeIfPresent([PlexImage].self, forKey: .images) ?? []
}
/// `ratingKey` ist serverweit eindeutig und eignet sich deshalb direkt
/// als Kennung für `ForEach`.
public var id: String { ratingKey }
public var kind: Kind? { Kind(rawValue: type) }
/// Wie weit der Titel gesehen ist, als Anteil von 0 bis 1.
///
/// `nil`, wenn noch gar nicht angefangen wurde — die Kachel zeigt dann
/// keinen Balken statt eines leeren.
public var watchProgress: Double? {
guard let viewOffset, let duration, duration > 0, viewOffset > 0 else { return nil }
return min(Double(viewOffset) / Double(duration), 1)
}
public var isWatched: Bool { (viewCount ?? 0) > 0 }
/// Die Fassung, die abgespielt werden soll, wenn der Nutzer nichts anderes
/// wählt: die mit der höchsten Bitrate. Plex sortiert nicht zuverlässig,
/// deshalb wird hier selbst ausgewählt.
public var preferredMedia: PlexMedia? {
media.max { ($0.bitrate ?? 0) < ($1.bitrate ?? 0) }
}
/// Ob die Detailseite eine Versionsauswahl anbieten muss.
public var hasMultipleVersions: Bool { media.count > 1 }
/// Der Schriftzug des Titels, falls der Server einen hat.
///
/// Nicht jeder Film hat einen — dann bleibt es beim gesetzten Titel. Bei
/// Folgen liefert Plex in der Regel den Schriftzug der Serie mit, weil
/// eine einzelne Folge keinen eigenen hat.
public var clearLogoPath: String? {
images.first(where: \.isClearLogo)?.url
}
}
/// Die Hülle, in die Plex jede Antwort packt.
///
/// Der Name des Inhaltsfelds wechselt je nach Endpunkt (`Metadata`,
/// `Directory`, `Hub`), die Hülle selbst bleibt gleich. Deshalb ist sie
/// generisch über den Inhalt.
public struct PlexMediaContainer<Content: Decodable & Sendable>: Sendable, Decodable {
public let size: Int?
/// Wie viele Einträge es insgesamt gibt — wichtig fürs seitenweise
/// Nachladen, weil `size` nur die aktuelle Seite zählt.
public let totalSize: Int?
public let offset: Int?
public let title1: String?
public let title2: String?
public let content: [Content]
/// Unter welchen Namen Plex seine Sammlungen ablegt.
///
/// Es gibt keinen einheitlichen: Titel stehen unter `Metadata`,
/// Mediatheken unter `Directory`, die Reihen der Startseite unter `Hub`,
/// Fernsehempfänger unter `Dvr`, die Anbieterliste unter `MediaProvider`.
///
/// Genau daran war Live-TV gescheitert: die Hülle kannte `Dvr` nicht, las
/// eine leere Liste und meldete „kein Empfänger eingerichtet" — bei einem
/// Server, auf dem einer eingerichtet war.
///
/// Die Namen schließen sich gegenseitig aus, deshalb genügt es, sie der
/// Reihe nach zu probieren.
private static var contentKeys: [String] {
["Metadata", "Directory", "Hub", "Dvr", "MediaProvider",
// Der Tuner führt seine Sender unter `DeviceChannel`, nicht unter
// `Channel`. Die 278 Sender waren da, die Hülle hat sie verworfen.
"Channel", "DeviceChannel", "ChannelMapping", "Device"]
}
private struct DynamicKey: CodingKey {
let stringValue: String
var intValue: Int? { nil }
init(_ value: String) { stringValue = value }
init?(stringValue: String) { self.stringValue = stringValue }
init?(intValue: Int) { nil }
}
private enum OuterKeys: String, CodingKey { case mediaContainer = "MediaContainer" }
public init(from decoder: any Decoder) throws {
let outer = try decoder.container(keyedBy: OuterKeys.self)
let inner = try outer.nestedContainer(keyedBy: DynamicKey.self, forKey: .mediaContainer)
self.size = try inner.decodeIfPresent(Int.self, forKey: DynamicKey("size"))
self.totalSize = try inner.decodeIfPresent(Int.self, forKey: DynamicKey("totalSize"))
self.offset = try inner.decodeIfPresent(Int.self, forKey: DynamicKey("offset"))
self.title1 = try inner.decodeIfPresent(String.self, forKey: DynamicKey("title1"))
self.title2 = try inner.decodeIfPresent(String.self, forKey: DynamicKey("title2"))
// Erst nachsehen, ob der Schlüssel überhaupt da ist, und dann ohne
// `try?` dekodieren.
//
// Mit `try?` verschwände auch ein echter Dekodierfehler und ergäbe
// stillschweigend eine leere Liste — aus „ein Feld heißt jetzt anders"
// würde „die Mediathek ist leer". Genau diese Verwechslung hat bei
// Live-TV schon einmal auf die falsche Fährte geführt.
for name in Self.contentKeys where inner.contains(DynamicKey(name)) {
self.content = try inner.decode([Content].self, forKey: DynamicKey(name))
return
}
// Kein Schlüssel vorhanden: eine leere Mediathek liefert gar kein Feld.
// Das ist kein Fehler, sondern bedeutet null Einträge.
self.content = []
}
}
@@ -1,144 +0,0 @@
import Foundation
/// Eine Mediathek auf dem Server.
public struct PlexLibrary: Sendable, Equatable, Decodable, Identifiable {
public enum Kind: String, Sendable, Codable {
case movie, show, artist, photo
/// Live-TV und Aufnahmen.
case livetv
}
/// Die Nummer des Abschnitts, als Zeichenkette. Steckt in fast jedem
/// weiteren Pfad.
public let key: String
public let title: String
public let type: String
public let uuid: String?
public let art: String?
public let thumb: String?
public let updatedAt: Int?
public var id: String { key }
public var kind: Kind? { Kind(rawValue: type) }
/// Ob Flixy diese Mediathek darstellen kann. Musik und Fotos sind laut
/// Plan nicht vorgesehen — sie erscheinen deshalb gar nicht erst in der
/// Seitenleiste, statt dort auf einen leeren Bildschirm zu führen.
public var isSupported: Bool {
kind == .movie || kind == .show
}
public init(
key: String,
title: String,
type: String,
uuid: String? = nil,
art: String? = nil,
thumb: String? = nil,
updatedAt: Int? = nil
) {
self.key = key
self.title = title
self.type = type
self.uuid = uuid
self.art = art
self.thumb = thumb
self.updatedAt = updatedAt
}
}
/// Eine Reihe auf der Startseite.
///
/// Plex nennt sie „Hub" und liefert sie fertig zusammengestellt — Weiterschauen,
/// Zuletzt hinzugefügt, Sammlungen. Die App muss diese Reihen also nicht selbst
/// bauen, sondern nur darstellen.
public struct PlexHub: Sendable, Equatable, Decodable, Identifiable {
/// Stabiler Bezeichner wie `home.continue` oder `movie.recentlyadded`.
/// Danach richtet sich die Sortierung in den Einstellungen.
public let hubIdentifier: String
public let title: String
public let type: String?
public let key: String?
public let size: Int?
/// Ob es mehr Einträge gibt, als die Reihe zeigt — Grundlage für ein
/// „Alle anzeigen" am Ende.
public let more: Bool
/// Aus welcher Mediathek diese Reihe stammt.
///
/// `nil` bei den übergreifenden Reihen — „Fortsetzen" geht quer durch
/// alle, und Plex nennt dort keine Nummer.
///
/// **Warum es überhaupt gelesen wird.** Ohne diese Angabe kann die App
/// eine Reihe keiner Mediathek zuordnen, und die Auswahl „welche
/// Mediatheken speisen den Aufmacher" greift ins Leere: sie wirft dann
/// jede Reihe weg, fällt auf „alles" zurück und sieht aus wie ein
/// Schalter, der nichts tut. Genau so war es.
public let librarySectionID: String?
public let items: [PlexItem]
public var id: String { hubIdentifier }
/// Ob diese Reihe Angefangenes zeigt.
///
/// Entscheidet über die Form der Kacheln: 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.
///
/// Geprüft wird die Kennung, nicht der Titel: der ist übersetzt und hieße
/// auf Deutsch „Weiterschauen", auf Englisch „Continue Watching".
public var isContinueWatching: Bool {
let identifier = hubIdentifier.lowercased()
// `ondeck` ist die ältere Bezeichnung; Server unterschiedlicher
// Fassungen liefern die eine oder die andere.
return identifier.contains("continue") || identifier.contains("ondeck")
}
private enum CodingKeys: String, CodingKey {
case hubIdentifier, title, type, key, size, more, librarySectionID
case items = "Metadata"
}
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.title = try values.decode(String.self, forKey: .title)
// Ältere Serverfassungen liefern keinen `hubIdentifier`. Der Titel ist
// dann der einzige verfügbare Schlüssel — schlechter, aber besser als
// eine Antwort, die gar nicht lesbar ist.
self.hubIdentifier = try values.decodeIfPresent(String.self, forKey: .hubIdentifier) ?? title
self.type = try values.decodeIfPresent(String.self, forKey: .type)
self.key = try values.decodeIfPresent(String.self, forKey: .key)
self.size = try values.decodeIfPresent(Int.self, forKey: .size)
self.more = try values.decodeIfPresent(Bool.self, forKey: .more) ?? false
// **Zahl oder Zeichenkette.** Plex liefert die Abschnittsnummer je
// nach Endpunkt mal als `3`, mal als `"3"`. Nur eines davon zu lesen
// hiesse, dass es auf manchen Servern geht und auf anderen nicht —
// und zwar lautlos, denn eine fehlende Nummer ist ein gültiger Fall.
if let zahl = try? values.decodeIfPresent(Int.self, forKey: .librarySectionID) {
self.librarySectionID = String(zahl)
} else {
self.librarySectionID = try values.decodeIfPresent(String.self, forKey: .librarySectionID)
}
self.items = try values.decodeIfPresent([PlexItem].self, forKey: .items) ?? []
}
public init(
hubIdentifier: String,
title: String,
type: String? = nil,
key: String? = nil,
size: Int? = nil,
more: Bool = false,
librarySectionID: String? = nil,
items: [PlexItem] = []
) {
self.hubIdentifier = hubIdentifier
self.librarySectionID = librarySectionID
self.title = title
self.type = type
self.key = key
self.size = size
self.more = more
self.items = items
}
}
@@ -1,515 +0,0 @@
import Foundation
import MediaKit
/// Ein Fernsehempfänger am Server.
///
/// **Dieser ganze Bereich ist an keinem echten Server geprüft.** Die
/// Live-TV-Endpunkte sind der am schlechtesten dokumentierte Teil der
/// Plex-API — es gibt keine offizielle Beschreibung, nur das, was sich aus den
/// Anfragen der offiziellen Apps ablesen lässt. Die Modelle sind deshalb
/// durchgehend nachsichtig: fehlende Felder führen zu leeren Listen, nicht zu
/// Fehlern.
public struct PlexDVR: Sendable, Equatable, Decodable, Identifiable {
public let key: String
public let uuid: String?
public let language: String?
public let lineupTitle: String?
public let lineup: String?
/// Die Empfänger, jeweils mit ihrer Senderzuordnung.
public let devices: [PlexTunerDevice]
/// Die Kennung des Programmanbieters, etwa `tv.plex.providers.epg.cloud:2`.
///
/// Steht direkt am Empfänger — verlässlicher, als sie aus der
/// Anbieterliste zu suchen.
public let epgIdentifier: String?
/// Die Senderlisten, aus denen ausgewählt wurde.
public let lineups: [PlexLineup]
public var id: String { key }
/// Welche Sender eingeschaltet sind, als Schlüssel.
///
/// Die Zuordnung enthält **keine Namen** — nur, welche Sender der
/// Senderliste aktiv sind. Die Namen stehen beim Programmanbieter. Das
/// war der Grund, warum die Senderansicht leer blieb: die Zuordnung sah
/// nach einer Senderliste aus, war aber nur ein Verweis darauf.
public var enabledChannelKeys: Set<String> {
Set(devices.flatMap(\.channelMappings).filter(\.isEnabled).map(\.channelKey))
}
/// Die Sendernummer am Empfänger für eine Kennung des Programmführers.
///
/// Nachsichtig verglichen, weil die Kennung je nach Quelle mit oder ohne
/// Vorsatz der Senderliste kommt.
public func deviceChannel(forChannelKey key: String) -> String? {
let tail = key.split(separator: "-").last.map(String.init)
for mapping in devices.flatMap(\.channelMappings) {
guard let identifier = mapping.deviceIdentifier else { continue }
if mapping.channelKey == key { return identifier }
if let tail, mapping.channelKey.hasSuffix(tail) { return identifier }
}
return nil
}
private enum CodingKeys: String, CodingKey {
case key, uuid, language, lineupTitle, lineup, epgIdentifier
case devices = "Device"
case lineups = "Lineup"
}
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
// Als Zahl oder Zeichenkette — je nach Serverfassung.
if let text = try? values.decode(String.self, forKey: .key) {
self.key = text
} else {
self.key = String(try values.decode(Int.self, forKey: .key))
}
self.uuid = try values.decodeIfPresent(String.self, forKey: .uuid)
self.language = try values.decodeIfPresent(String.self, forKey: .language)
self.lineupTitle = try values.decodeIfPresent(String.self, forKey: .lineupTitle)
self.lineup = try values.decodeIfPresent(String.self, forKey: .lineup)
self.devices = try values.decodeIfPresent([PlexTunerDevice].self, forKey: .devices) ?? []
self.epgIdentifier = try values.decodeIfPresent(String.self, forKey: .epgIdentifier)
self.lineups = try values.decodeIfPresent([PlexLineup].self, forKey: .lineups) ?? []
}
public init(
key: String,
uuid: String? = nil,
lineupTitle: String? = nil,
lineup: String? = nil,
language: String? = nil,
devices: [PlexTunerDevice] = [],
epgIdentifier: String? = nil,
lineups: [PlexLineup] = []
) {
self.key = key
self.uuid = uuid
self.lineupTitle = lineupTitle
self.lineup = lineup
self.language = language
self.devices = devices
self.epgIdentifier = epgIdentifier
self.lineups = lineups
}
}
/// Eine Senderliste des Anbieters — bei Kabel oder IPTV oft mehrere hundert.
public struct PlexLineup: Sendable, Equatable, Decodable, Identifiable {
/// Die Kennung, wie Plex sie liefert.
///
/// Trotz des Namens keine Kennung, sondern eine vollständige Adresse:
///
/// lineup://tv.plex.providers.epg.cloud/6228f7f2364e02fa5ec8e51c#Swisscom%20Blue%20TV
///
/// Mit Schema, Doppelschrägstrich, Prozentzeichen und einem Anhang hinter
/// der Raute. Roh in einen Pfad gesetzt ergibt das zwangsläufig 404 —
/// genau daran scheiterte die Senderabfrage.
public let id: String
public let title: String?
/// Der nackte Teil in der Mitte, als Pfadabschnitt verwendbar.
///
/// Aus der Adresse oben wird `6228f7f2364e02fa5ec8e51c`.
public var pathIdentifier: String {
// Der Anhang hinter der Raute ist der Anzeigename und gehört nicht in
// die Adresse.
let withoutFragment = id.split(separator: "#", maxSplits: 1).first.map(String.init) ?? id
return withoutFragment.split(separator: "/").last.map(String.init) ?? withoutFragment
}
public init(id: String, title: String? = nil) {
self.id = id
self.title = title
}
}
/// Ein Eintrag der Senderzuordnung.
///
/// Sagt, welcher Sender der Senderliste am Empfänger eingeschaltet ist —
/// mehr nicht. Namen, Nummern und Logos stehen beim Programmanbieter.
public struct PlexChannelMapping: Sendable, Equatable, Decodable {
/// Die Kennung des Senders beim Programmführer.
public let channelKey: String
/// Die Sendernummer **am Empfänger** — etwa `"300"`.
///
/// Der Stein von Rosetta dieser Schnittstelle: der Programmführer kennt
/// den Sender als `6228f7f2…-5fc706…`, der Tuner als `300`. Ohne diese
/// Übersetzung redet man mit dem Empfänger in einer Sprache, die er nicht
/// spricht — und bekommt auf jede Anfrage 404.
public let deviceIdentifier: String?
public let lineupIdentifier: String?
private let enabled: String?
/// Plex schreibt hier `"1"` oder `"0"` als Zeichenkette, nicht als
/// Wahrheitswert.
public var isEnabled: Bool { enabled != "0" }
public init(
channelKey: String,
deviceIdentifier: String? = nil,
lineupIdentifier: String? = nil,
enabled: String? = "1"
) {
self.channelKey = channelKey
self.deviceIdentifier = deviceIdentifier
self.lineupIdentifier = lineupIdentifier
self.enabled = enabled
}
}
/// Ein Empfänger — eine Fernsehkarte oder ein Netzwerk-Tuner.
public struct PlexTunerDevice: Sendable, Equatable, Decodable, Identifiable {
public let key: String?
public let uuid: String?
public let title: String?
/// Die Gerätekennung — unter ihr führt der Server die gefundenen Sender.
public let deviceIdentifier: String?
/// Welche Sender der Senderliste hier eingeschaltet sind.
public let channelMappings: [PlexChannelMapping]
public var id: String { uuid ?? key ?? title ?? "tuner" }
private enum CodingKeys: String, CodingKey {
case key, uuid, title, deviceId, deviceIdentifier
case channelMapping = "ChannelMapping"
}
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.key = try? values.decodeIfPresent(String.self, forKey: .key)
self.uuid = try values.decodeIfPresent(String.self, forKey: .uuid)
self.title = try values.decodeIfPresent(String.self, forKey: .title)
self.deviceIdentifier = (try? values.decodeIfPresent(String.self, forKey: .deviceId))
?? (try? values.decodeIfPresent(String.self, forKey: .deviceIdentifier))
?? nil
self.channelMappings = try values
.decodeIfPresent([PlexChannelMapping].self, forKey: .channelMapping) ?? []
}
public init(
key: String? = nil,
uuid: String? = nil,
title: String? = nil,
deviceIdentifier: String? = nil,
channelMappings: [PlexChannelMapping] = []
) {
self.key = key
self.uuid = uuid
self.title = title
self.deviceIdentifier = deviceIdentifier
self.channelMappings = channelMappings
}
}
/// Der Medien-Block einer Sendung im Raster.
///
/// Er trägt die Angaben zum Sender mit: das Raster ist bei Plex nicht nur
/// eine Liste von Sendungen, sondern die einzige Antwort, in der Sendername,
/// Nummer, Logo und Einschaltweg zusammen stehen. Drei geratene
/// Senderlisten-Adressen ergaben 404 — diese hier liefert die
/// Weboberfläche selbst aus.
struct PlexGridMedia: Decodable {
let channelIdentifier: String?
let channelTitle: String?
let channelCallSign: String?
let channelThumb: String?
let channelVcn: String?
/// Die laufende Nummer des Senders am Server.
let channelID: String?
/// Der Schlüssel des Senders im Raster — ein Kandidat fürs Einschalten.
let gridKey: String?
/// Anfang und Ende der Sendung.
///
/// Sie stehen hier und **nicht** am Eintrag darüber. Genau daran ist das
/// Raster gescheitert: oben gesucht, nicht gefunden, jede Sendung
/// verworfen — und übrig blieb „Kein Programm verfügbar" auf jedem Sender.
let beginsAt: Int?
let endsAt: Int?
let parts: [PlexGridPart]
private enum CodingKeys: String, CodingKey {
case channelIdentifier, channelTitle, channelCallSign, channelThumb, channelVcn, channelID
case gridKey, beginsAt, endsAt
case part = "Part"
}
init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.channelIdentifier = try? values.decodeIfPresent(String.self, forKey: .channelIdentifier)
self.channelTitle = try values.decodeIfPresent(String.self, forKey: .channelTitle)
self.channelCallSign = try values.decodeIfPresent(String.self, forKey: .channelCallSign)
self.channelThumb = try values.decodeIfPresent(String.self, forKey: .channelThumb)
// Die Sendernummer kommt mal als Zahl, mal als Zeichenkette.
if let text = try? values.decodeIfPresent(String.self, forKey: .channelVcn) {
self.channelVcn = text
} else if let number = try? values.decodeIfPresent(Int.self, forKey: .channelVcn) {
self.channelVcn = String(number)
} else {
self.channelVcn = nil
}
if let text = try? values.decodeIfPresent(String.self, forKey: .channelID) {
self.channelID = text
} else if let number = try? values.decodeIfPresent(Int.self, forKey: .channelID) {
self.channelID = String(number)
} else {
self.channelID = nil
}
self.gridKey = try? values.decodeIfPresent(String.self, forKey: .gridKey)
self.beginsAt = try? values.decodeIfPresent(Int.self, forKey: .beginsAt)
self.endsAt = try? values.decodeIfPresent(Int.self, forKey: .endsAt)
self.parts = try values.decodeIfPresent([PlexGridPart].self, forKey: .part) ?? []
}
/// Der Sender, sofern dieser Block einen benennt.
///
/// Ohne Namen kein Sender: eine Zeile, die nur „—" hieße, ist für die
/// Senderliste wertlos und stünde bei jedem Eintrag doppelt.
var channel: PlexChannel? {
guard let name = channelTitle ?? channelCallSign else { return nil }
let identity = channelIdentifier ?? channelID ?? parts.first?.key ?? name
return PlexChannel(
id: identity,
title: Self.withoutLeadingNumber(name, number: channelVcn),
channelIdentifier: channelVcn,
thumb: channelThumb,
key: parts.first?.key ?? channelID,
gridKey: gridKey
)
}
/// Entfernt die Sendernummer aus dem Namen.
///
/// Plex schreibt sie dort mit hinein — „006 ZDFGERH (ZDF HD)". Da die
/// Nummer in der Senderspalte ohnehin eigens steht, stünde sie sonst
/// zweimal nebeneinander.
static func withoutLeadingNumber(_ name: String, number: String?) -> String {
guard let number, !number.isEmpty else { return name }
let prefix = number + " "
guard name.hasPrefix(prefix) else { return name }
return String(name.dropFirst(prefix.count))
}
}
/// Der Stream hinter einer Rastersendung.
struct PlexGridPart: Decodable {
let key: String?
}
/// Ein Sender.
public struct PlexChannel: Sendable, Equatable, Decodable, Identifiable {
/// Die Nummer, unter der der Sender läuft — etwa `"3.1"`.
public let channelIdentifier: String?
public let title: String
public let thumb: String?
/// Der Schlüssel, über den sich der Sender einschalten lässt.
public let key: String?
/// Der Schlüssel des Senders im Programmraster.
public let gridKey: String?
public let id: String
/// Ob dieser Sender einen Namen trägt.
///
/// Eine Liste aus dreißig Strichen sieht aus wie eine Senderliste, ist
/// aber keine — dieser Irrtum hat hier schon einmal eine falsche Quelle
/// als die richtige durchgehen lassen.
public var hasName: Bool { title != Self.placeholder }
static let placeholder = "—"
private enum CodingKeys: String, CodingKey {
case channelIdentifier, title, thumb, key, gridKey
case identifier = "id"
case uuid, name, callSign, vcn, number, channelTitle, channelCallSign
}
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
// Vier Schreibweisen für denselben Namen, je nach Tunertyp und
// Anbieter. Nur `title` zu lesen hieße, die halbe Senderliste als
// namenlos zu verwerfen.
self.title = [CodingKeys.title, .name, .channelTitle, .callSign, .channelCallSign]
.lazy
.compactMap { try? values.decodeIfPresent(String.self, forKey: $0) }
.first { !$0.isEmpty } ?? Self.placeholder
// Beim Tuner heißt die Nummer schlicht `key` — „300". Ohne diesen
// Rückfall stünde die halbe Senderliste ohne Nummer da.
let number = Self.text(in: values, forKeys: [.channelIdentifier, .vcn, .number])
let keyValue = Self.text(in: values, forKeys: [.key])
self.channelIdentifier = number
?? keyValue.flatMap { $0.allSatisfy { character in character.isNumber || character == "." } ? $0 : nil }
self.thumb = try values.decodeIfPresent(String.self, forKey: .thumb)
self.key = Self.text(in: values, forKeys: [.key])
self.gridKey = try? values.decodeIfPresent(String.self, forKey: .gridKey)
// Plex liefert die Kennung mal als Zahl, mal als Zeichenkette, mal als
// `uuid`. Ein einzelner erwarteter Typ hätte hier bei manchen
// Serverfassungen die ganze Senderliste unlesbar gemacht.
if let text = try? values.decodeIfPresent(String.self, forKey: .identifier) {
self.id = text
} else if let number = try? values.decodeIfPresent(Int.self, forKey: .identifier) {
self.id = String(number)
} else if let uuid = try values.decodeIfPresent(String.self, forKey: .uuid) {
self.id = uuid
} else {
self.id = channelIdentifier ?? title
}
}
/// Ob dieser Sender in der Zuordnung des Empfängers steht.
///
/// Nachsichtig verglichen: die Zuordnung führt einen `channelKey`, und
/// je nach Anbieter entspricht der der Kennung, dem Pfad oder der
/// Sendernummer. Ein strenger Vergleich fände in zwei von drei Fällen
/// nichts — und „keiner passte zusammen" sieht aus wie „keine Sender".
public func matches(anyOf keys: Set<String>) -> Bool {
if keys.contains(id) { return true }
if let channelIdentifier, keys.contains(channelIdentifier) { return true }
if let key {
if keys.contains(key) { return true }
if let last = key.split(separator: "/").last, keys.contains(String(last)) {
return true
}
}
// Umgekehrt: manche Zuordnungen führen den vollen Pfad, der Sender
// aber nur die Kennung.
return keys.contains { $0.split(separator: "/").last.map(String.init) == id }
}
/// Ein Wert, der als Zahl oder als Zeichenkette kommen kann.
private static func text(
in values: KeyedDecodingContainer<CodingKeys>,
forKeys keys: [CodingKeys]
) -> String? {
for key in keys {
if let text = try? values.decodeIfPresent(String.self, forKey: key), !text.isEmpty {
return text
}
if let number = try? values.decodeIfPresent(Int.self, forKey: key) {
return String(number)
}
}
return nil
}
public init(
id: String,
title: String,
channelIdentifier: String? = nil,
thumb: String? = nil,
key: String? = nil,
gridKey: String? = nil
) {
self.id = id
self.title = title
self.channelIdentifier = channelIdentifier
self.thumb = thumb
self.key = key
self.gridKey = gridKey
}
}
/// Eine Sendung im Programm.
public struct PlexProgram: Sendable, Equatable, Identifiable {
public let id: String
public let title: String
public let subtitle: String?
public let summary: String?
public let startsAt: Date
public let endsAt: Date
public let channelID: String?
public let thumb: String?
/// Die Adresse dieser Sendung beim Programmanbieter.
public let metadataKey: String?
public init(
id: String,
title: String,
subtitle: String? = nil,
summary: String? = nil,
startsAt: Date,
endsAt: Date,
channelID: String? = nil,
thumb: String? = nil,
metadataKey: String? = nil
) {
self.id = id
self.title = title
self.subtitle = subtitle
self.summary = summary
self.startsAt = startsAt
self.endsAt = endsAt
self.channelID = channelID
self.thumb = thumb
self.metadataKey = metadataKey
}
public var duration: Duration {
.seconds(endsAt.timeIntervalSince(startsAt))
}
public func isRunning(at moment: Date = Date()) -> Bool {
moment >= startsAt && moment < endsAt
}
/// Wie weit die Sendung fortgeschritten ist, 0…1.
public func progress(at moment: Date = Date()) -> Double {
let total = endsAt.timeIntervalSince(startsAt)
guard total > 0 else { return 0 }
return min(max(moment.timeIntervalSince(startsAt) / total, 0), 1)
}
}
/// Ein Eintrag aus dem Programmraster.
///
/// Plex liefert das Raster als gewöhnliche Metadaten mit den Zeitangaben in
/// Sekunden seit 1970. Die Übersetzung in `PlexProgram` passiert hier, damit
/// die Oberfläche keine Zeitstempel rechnen muss.
struct PlexGridEntry: Decodable {
let ratingKey: String?
/// Die Adresse der Sendung beim Programmanbieter — über sie läuft das
/// Einschalten, denn Plex behandelt eine laufende Sendung wie einen Film.
let key: String?
let title: String?
let summary: String?
let grandparentTitle: String?
let thumb: String?
let beginsAt: Int?
let endsAt: Int?
/// Die Sendermedien; darin steht, zu welchem Sender die Sendung gehört.
let media: [PlexGridMedia]?
private enum CodingKeys: String, CodingKey {
case ratingKey, key, title, summary, grandparentTitle, thumb, beginsAt, endsAt
case media = "Media"
}
/// Der Sender, auf dem diese Sendung läuft.
///
/// Hier steht der Sender vollständig: Name, Nummer, Logo, Einschaltweg.
/// Das ist der Grund, warum das Raster die Senderliste ersetzen kann.
var channel: PlexChannel? { media?.compactMap(\.channel).first }
func program(channelID: String?) -> PlexProgram? {
// Die Zeiten stehen beim Programmanbieter im Medien-Block, nicht am
// Eintrag. Beide Stellen lesen, damit es auf beiden Serverfassungen
// trägt.
let beginsAt = self.beginsAt ?? media?.first?.beginsAt
let endsAt = self.endsAt ?? media?.first?.endsAt
guard let beginsAt, let endsAt, let title else { return nil }
return PlexProgram(
id: ratingKey ?? "\(channelID ?? "")-\(beginsAt)",
title: title,
subtitle: grandparentTitle,
summary: summary,
startsAt: Date(timeIntervalSince1970: TimeInterval(beginsAt)),
endsAt: Date(timeIntervalSince1970: TimeInterval(endsAt)),
channelID: channelID,
thumb: thumb,
metadataKey: key
)
}
}
@@ -1,23 +0,0 @@
import Foundation
import MediaKit
extension PlexMedia {
/// Was die Wiedergabe-Entscheidung über diese Fassung wissen muss.
///
/// Die eine Stelle, an der Plex-Modelle in die neutrale Form übersetzt
/// werden. Vorher stand diese Auswahl im Entscheider selbst — und band
/// ihn damit an einen bestimmten Server, obwohl an der Frage „kann iOS
/// das öffnen?" nichts serverspezifisch ist.
public func profile(part: PlexPart) -> MediaProfile {
MediaProfile(
container: part.container ?? container,
videoCodec: videoCodec ?? part.videoStream?.codec,
// Die **gewählte** Tonspur, nicht irgendeine: eine Datei mit
// deutschem AAC und englischem DTS ist für AVPlayer in Ordnung,
// solange Deutsch klingt.
audioCodec: part.selectedAudioStream?.codec ?? audioCodec,
bitrate: bitrate,
hasKnownStreams: !part.streams.isEmpty
)
}
}
@@ -1,165 +0,0 @@
import Foundation
import MediaKit
/// Eine Datei auf der Platte des Servers.
///
/// Plex schachtelt hier zweifach: ein Titel hat mehrere `Media` (dieselbe
/// Fassung in 4K und 1080p), und jedes `Media` hat `Part` (ein Film, der auf
/// zwei Dateien verteilt ist). Für die Wiedergabe zählt der `Part` — er trägt
/// den Pfad.
public struct PlexPart: Sendable, Equatable, Codable, Identifiable {
public let id: Int
/// Der Pfad, unter dem die Datei abrufbar ist.
public let key: String
public let container: String?
public let size: Int64?
public let duration: Int?
public let file: String?
public let streams: [PlexStream]
private enum CodingKeys: String, CodingKey {
case id, key, container, size, duration, file
// Plex schreibt verschachtelte Sammlungen groß.
case streams = "Stream"
}
public init(
id: Int,
key: String,
container: String? = nil,
size: Int64? = nil,
duration: Int? = nil,
file: String? = nil,
streams: [PlexStream] = []
) {
self.id = id
self.key = key
self.container = container
self.size = size
self.duration = duration
self.file = file
self.streams = streams
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(Int.self, forKey: .id)
self.key = try container.decode(String.self, forKey: .key)
self.container = try container.decodeIfPresent(String.self, forKey: .container)
self.size = try container.decodeIfPresent(Int64.self, forKey: .size)
self.duration = try container.decodeIfPresent(Int.self, forKey: .duration)
self.file = try container.decodeIfPresent(String.self, forKey: .file)
// Fehlt die Spurenliste, ist die Datei noch nicht analysiert. Das ist
// kein Fehler — es heißt nur, dass wir nicht über DirectPlay
// entscheiden können und im Zweifel transcodieren lassen.
self.streams = try container.decodeIfPresent([PlexStream].self, forKey: .streams) ?? []
}
public var videoStream: PlexStream? { streams.first { $0.kind == .video } }
public var audioStreams: [PlexStream] { streams.filter { $0.kind == .audio } }
public var subtitleStreams: [PlexStream] { streams.filter { $0.kind == .subtitle } }
/// Die Tonspur, die der Server als aktiv meldet — sonst die erste.
public var selectedAudioStream: PlexStream? {
audioStreams.first { $0.isSelected } ?? audioStreams.first
}
}
/// Eine Fassung eines Titels.
public struct PlexMedia: Sendable, Equatable, Codable, Identifiable {
public let id: Int
public let container: String?
public let videoCodec: String?
public let audioCodec: String?
/// Plex meldet hier Werte wie `"1080"`, `"4k"` oder `"sd"` — keine Zahl.
public let videoResolution: String?
public let videoProfile: String?
/// In Kilobit pro Sekunde.
public let bitrate: Int?
public let width: Int?
public let height: Int?
public let audioChannels: Int?
public let duration: Int?
public let parts: [PlexPart]
private enum CodingKeys: String, CodingKey {
case id, container, videoCodec, audioCodec, videoResolution, videoProfile
case bitrate, width, height, audioChannels, duration
case parts = "Part"
}
public init(
id: Int,
container: String? = nil,
videoCodec: String? = nil,
audioCodec: String? = nil,
videoResolution: String? = nil,
videoProfile: String? = nil,
bitrate: Int? = nil,
width: Int? = nil,
height: Int? = nil,
audioChannels: Int? = nil,
duration: Int? = nil,
parts: [PlexPart] = []
) {
self.id = id
self.container = container
self.videoCodec = videoCodec
self.audioCodec = audioCodec
self.videoResolution = videoResolution
self.videoProfile = videoProfile
self.bitrate = bitrate
self.width = width
self.height = height
self.audioChannels = audioChannels
self.duration = duration
self.parts = parts
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(Int.self, forKey: .id)
self.container = try container.decodeIfPresent(String.self, forKey: .container)
self.videoCodec = try container.decodeIfPresent(String.self, forKey: .videoCodec)
self.audioCodec = try container.decodeIfPresent(String.self, forKey: .audioCodec)
self.videoResolution = try container.decodeIfPresent(String.self, forKey: .videoResolution)
self.videoProfile = try container.decodeIfPresent(String.self, forKey: .videoProfile)
self.bitrate = try container.decodeIfPresent(Int.self, forKey: .bitrate)
self.width = try container.decodeIfPresent(Int.self, forKey: .width)
self.height = try container.decodeIfPresent(Int.self, forKey: .height)
self.audioChannels = try container.decodeIfPresent(Int.self, forKey: .audioChannels)
self.duration = try container.decodeIfPresent(Int.self, forKey: .duration)
self.parts = try container.decodeIfPresent([PlexPart].self, forKey: .parts) ?? []
}
/// Die Bildhöhe in Bildpunkten.
///
/// Bevorzugt aus `height`. Fehlt die, wird `videoResolution` gedeutet —
/// dort steht bei Plex kein Zahlenwert, sondern „1080", „4k" oder „sd".
public var pixelHeight: Int? {
if let height, height > 0 { return height }
guard let videoResolution = videoResolution?.lowercased() else { return nil }
switch videoResolution {
case "4k": return 2160
case "sd": return 480
default: return Int(videoResolution)
}
}
/// Die Stufe, in der diese Fassung vorliegt.
public var resolution: VideoResolution? {
VideoResolution(width: width, height: height, serverLabel: videoResolution)
}
/// Die Auflösung in der Schreibweise, die Menschen erwarten.
public var resolutionLabel: String? { resolution?.name }
/// Eine kurze Beschreibung für die Versionsauswahl auf der Detailseite,
/// etwa „4K · HEVC · 38,2 Mbit/s".
public var qualityLabel: String {
var pieces: [String] = []
if let label = resolutionLabel { pieces.append(label) }
if let videoCodec { pieces.append(videoCodec.uppercased()) }
if let bitrate { pieces.append("\(bitrate / 1000) Mbit/s") }
return pieces.joined(separator: " · ")
}
}
@@ -1,74 +0,0 @@
import Foundation
/// Eine einzelne Spur in einer Datei: Bild, Ton oder Untertitel.
///
/// Die Feldnamen folgen der JSON-Antwort von Plex. Was dort fehlen darf, ist
/// hier optional — Plex lässt bei unvollständig analysierten Dateien ganze
/// Felder weg, und ein nicht-optionales Feld würde den gesamten Titel
/// unlesbar machen statt nur eine Angabe.
public struct PlexStream: Sendable, Equatable, Codable, Identifiable {
/// Plex unterscheidet die Spurart über eine Zahl.
public enum Kind: Int, Sendable, Codable {
case video = 1
case audio = 2
case subtitle = 3
}
public let id: Int
public let streamType: Int
public let codec: String?
public let language: String?
public let languageCode: String?
public let displayTitle: String?
public let title: String?
/// Bei Ton: die Kanalzahl. Entscheidet mit darüber, ob AVPlayer den
/// Ton annimmt — 5.1-DTS kann es nicht, 2.0-AAC schon.
public let channels: Int?
public let bitDepth: Int?
/// Nur bei Untertiteln: liegt die Spur als eigene Datei neben dem Video?
/// Solche lassen sich bei DirectPlay dazuschalten, eingebettete nicht
/// immer.
public let key: String?
private let selected: Bool?
private let `default`: Bool?
private let forced: Bool?
public var kind: Kind? { Kind(rawValue: streamType) }
public var isSelected: Bool { selected ?? false }
public var isDefault: Bool { `default` ?? false }
public var isForced: Bool { forced ?? false }
/// Untertitel, die als eigene Datei vorliegen.
public var isExternal: Bool { key != nil }
public init(
id: Int,
streamType: Int,
codec: String? = nil,
language: String? = nil,
languageCode: String? = nil,
displayTitle: String? = nil,
title: String? = nil,
channels: Int? = nil,
bitDepth: Int? = nil,
key: String? = nil,
selected: Bool? = nil,
default: Bool? = nil,
forced: Bool? = nil
) {
self.id = id
self.streamType = streamType
self.codec = codec
self.language = language
self.languageCode = languageCode
self.displayTitle = displayTitle
self.title = title
self.channels = channels
self.bitDepth = bitDepth
self.key = key
self.selected = selected
self.default = `default`
self.forced = forced
}
}
@@ -1,84 +0,0 @@
import Foundation
/// Ein Schlagwort am Titel: Genre, Regie, Rolle, Land.
///
/// Plex gibt sie alle in derselben Form aus und unterscheidet nur über den
/// Feldnamen. `role` ist dabei nur bei Besetzungseinträgen gefüllt und trägt
/// den Namen der gespielten Figur.
public struct PlexTag: Sendable, Equatable, Hashable, Decodable, Identifiable {
public let tagKey: String?
public let tag: String
public let role: String?
public let thumb: String?
private let rawID: Int?
/// Eigene Kennung statt `id` vom Server: Genres kommen ohne, und zwei
/// Einträge ohne Kennung würden in `ForEach` zusammenfallen.
public var id: String { tagKey ?? rawID.map(String.init) ?? tag }
private enum CodingKeys: String, CodingKey {
case rawID = "id"
case tagKey, tag, role, thumb
}
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.rawID = try values.decodeIfPresent(Int.self, forKey: .rawID)
self.tagKey = try values.decodeIfPresent(String.self, forKey: .tagKey)
self.tag = try values.decode(String.self, forKey: .tag)
self.role = try values.decodeIfPresent(String.self, forKey: .role)
self.thumb = try values.decodeIfPresent(String.self, forKey: .thumb)
}
public init(tag: String, role: String? = nil, thumb: String? = nil, tagKey: String? = nil) {
self.rawID = nil
self.tagKey = tagKey
self.tag = tag
self.role = role
self.thumb = thumb
}
}
/// Eine Sprungmarke in einer Datei: Intro oder Abspann.
///
/// Wird schon in Phase 1 mitgelesen, obwohl sie erst in Phase 2 ausgewertet
/// wird — sie kostet nichts und erspart später eine zweite Anfrage.
public struct PlexMarker: Sendable, Equatable, Decodable, Identifiable {
public let id: Int
public let type: String
/// In Millisekunden ab Beginn.
public let startTimeOffset: Int
public let endTimeOffset: Int
public var isIntro: Bool { type == "intro" }
public var isCredits: Bool { type == "credits" }
public init(id: Int, type: String, startTimeOffset: Int, endTimeOffset: Int) {
self.id = id
self.type = type
self.startTimeOffset = startTimeOffset
self.endTimeOffset = endTimeOffset
}
}
/// Ein Bild zu einem Titel.
///
/// Plex liefert neben Plakat und Hintergrund noch das `clearLogo`: den
/// Schriftzug des Films als freigestelltes PNG — „Harry Potter" in seiner
/// eigenen Schrift statt in der der App. Genau dafür ist dieser Typ da.
public struct PlexImage: Sendable, Equatable, Decodable, Identifiable {
public let alt: String?
public let type: String
public let url: String
public var id: String { url }
public var isClearLogo: Bool { type == "clearLogo" }
public init(alt: String? = nil, type: String, url: String) {
self.alt = alt
self.type = type
self.url = url
}
}
@@ -1,229 +0,0 @@
import Foundation
import MediaKit
/// Plex' Live-TV mit Gedächtnis.
///
/// Ein Actor und kein Werttyp, und das hat einen einzigen Grund: `PlexBackend`
/// ist ein `struct` und kann sich zwischen zwei Aufrufen nichts merken. Zum
/// Einschalten braucht Plex aber den **echten** `PlexChannel` und den
/// **echten** `PlexDVR` — an letzterem hängt die Sendernummer am Empfänger,
/// und an der hängt, ob überhaupt ein Bild kommt.
///
/// Ohne Gedächtnis müsste das Einschalten die Senderliste neu laden. Das ist
/// bei Plex kein Abruf, sondern ein Suchlauf über vier Strategien — der
/// mühsamste Teil dieser ganzen Ecke. Er soll einmal laufen, nicht bei jedem
/// Antippen.
///
/// Der Actor liegt als `let` in `PlexBackend`. Ein Actor ist ein Referenztyp;
/// Kopien des Struct teilen sich denselben und damit dasselbe Gedächtnis.
public actor PlexLiveTVSession {
private let server: PlexServer
/// Der Empfänger, über den eingeschaltet wird.
private var dvr: PlexDVR?
/// Die zuletzt geladenen Sender nach Kennung — der Weg vom neutralen
/// `LiveChannel` zurück zum `PlexChannel`, den `tune` braucht.
private var known: [String: PlexChannel] = [:]
/// Das zuletzt geladene Programm.
///
/// Plex liefert Sender und Programm in **einem** Aufruf. Es hier
/// festzuhalten spart den zweiten, den der neutrale Vertrag sonst nach
/// sich zöge: erst `liveChannels()`, dann `livePrograms()`.
private var guide: [String: [PlexProgram]] = [:]
public init(server: PlexServer) {
self.server = server
}
// MARK: - Verfügbarkeit
func availability() async -> LiveTVAvailability {
switch await server.liveTVAvailability() {
case .available(let dvrs):
// Den ersten merken: alles Weitere — Sender, Programm,
// Einschalten — hängt an ihm.
dvr = dvrs.first
return dvrs.isEmpty ? .notConfigured : .available
case .notConfigured:
return .notConfigured
case .failed(let message):
return .failed(message: message)
}
}
// MARK: - Sender und Programm
func channels() async -> LiveChannelListing {
guard let dvr = await resolvedDVR() else {
return LiveChannelListing(channels: [], source: nil, probes: [])
}
let result = await server.liveTVGuide(dvr: dvr, hours: 6)
known = Dictionary(result.channels.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
guide = result.programs
return LiveChannelListing(
channels: result.channels.map { $0.asLiveChannel(running: running(on: $0)) },
source: result.source,
probes: result.trace.map { LiveChannelProbe(path: $0.path, result: $0.result) }
)
}
func programs(channelIDs: [String], from: Date, to: Date) async -> [String: [LiveProgram]] {
// Ohne gemerktes Programm erst eines holen. Das passiert nur, wenn
// jemand nach dem Programm fragt, ohne vorher die Sender geladen zu
// haben — in der Oberfläche kommt das nicht vor, über den Vertrag
// könnte es.
if guide.isEmpty, let dvr = await resolvedDVR() {
let result = await server.liveTVGuide(dvr: dvr, hours: 6)
known = Dictionary(result.channels.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
guide = result.programs
}
let wanted = Set(channelIDs)
var out: [String: [LiveProgram]] = [:]
for (channelID, programs) in guide where wanted.isEmpty || wanted.contains(channelID) {
let inRange = programs.filter { $0.endsAt > from && $0.startsAt < to }
if !inRange.isEmpty {
out[channelID] = inRange.map { $0.asLiveProgram(channelID: channelID) }
}
}
return out
}
// MARK: - Einschalten
func tune(channel: LiveChannel, program: LiveProgram?, maximumBitrate: Int?) async -> LiveTuneResult {
// Zurück zum echten Sender. Ohne ihn fehlen Rasterschlüssel und
// Sendernummer, und ohne die schaltet nichts ein.
guard let plexChannel = known[channel.id] else {
return LiveTuneResult(
url: nil,
sessionID: UUID().uuidString,
trace: [LiveChannelProbe(
path: channel.id,
result: "Sender nicht im geladenen Bestand — erst die Senderliste laden"
)]
)
}
let plexProgram = program.flatMap { wanted in
guide[channel.id]?.first { $0.id == wanted.id }
}
let result = await server.tune(
channel: plexChannel,
program: plexProgram,
dvr: await resolvedDVR(),
maximumBitrate: maximumBitrate
)
return LiveTuneResult(
url: result.url,
sessionID: result.sessionIdentifier,
subscriptionKey: result.subscriptionKey,
trace: result.trace.map { LiveChannelProbe(path: $0.path, result: $0.result) }
)
}
func endSession(subscriptionKey: String) async {
await server.endLiveSession(subscriptionKey: subscriptionKey)
}
// MARK: - Hilfen
/// Der Empfänger — gemerkt, sonst erfragt.
private func resolvedDVR() async -> PlexDVR? {
if let dvr { return dvr }
_ = await availability()
return dvr
}
/// Was auf diesem Sender gerade läuft.
private func running(on channel: PlexChannel) -> LiveProgram? {
let now = Date()
guard let match = guide[channel.id]?.first(where: { $0.startsAt <= now && $0.endsAt > now })
else { return nil }
return match.asLiveProgram(channelID: channel.id)
}
}
// MARK: - Übersetzung
extension PlexChannel {
/// Der Sender in neutraler Form.
///
/// Ohne `tuning`: bei Plex merkt sich die Sitzung den echten Sender, und
/// ein Griff, der nur wiederholt, was ohnehin dort liegt, wäre eine
/// zweite Wahrheit.
func asLiveChannel(running: LiveProgram?) -> LiveChannel {
LiveChannel(
id: id,
number: channelIdentifier,
title: title,
artworkPath: thumb,
currentProgram: running
)
}
}
extension PlexProgram {
/// Die Sendung in neutraler Form.
///
/// Die Senderkennung kommt von außen: `PlexProgram` trägt sie optional,
/// und im Programmraster steht sie ohnehin schon als Schlüssel darüber.
func asLiveProgram(channelID: String) -> LiveProgram {
LiveProgram(
id: id,
channelID: self.channelID ?? channelID,
title: title,
subtitle: subtitle,
summary: summary,
startsAt: startsAt,
endsAt: endsAt,
artworkPath: thumb
)
}
}
// MARK: - Der Vertrag
/// Plex als `LiveTVBackend`.
///
/// Reine Weiterleitung an die Sitzung. Der Suchlauf, der die Senderliste
/// findet, und alles, was das Einschalten kostet, bleiben unangetastet in
/// `PlexServer+LiveTV` — hier wird nur übersetzt.
///
/// **Kein** `LiveTVRecordingBackend`: Plex kann in dieser App nichts
/// aufnehmen, und das mit werfenden Methoden zu behaupten wäre schlechter,
/// als es gar nicht erst zu versprechen.
extension PlexBackend: LiveTVBackend {
public func liveTVAvailability() async -> LiveTVAvailability {
await live.availability()
}
public func liveChannels() async -> LiveChannelListing {
await live.channels()
}
public func livePrograms(
channelIDs: [String],
from: Date,
to: Date
) async throws(MediaBackendError) -> [String: [LiveProgram]] {
await live.programs(channelIDs: channelIDs, from: from, to: to)
}
public func tune(
channel: LiveChannel,
program: LiveProgram?,
maximumBitrate: Int?
) async -> LiveTuneResult {
await live.tune(channel: channel, program: program, maximumBitrate: maximumBitrate)
}
public func endLiveSession(subscriptionKey: String) async {
await live.endSession(subscriptionKey: subscriptionKey)
}
}
@@ -1,308 +0,0 @@
import Foundation
import MediaKit
/// Plex als `MediaBackend`.
///
/// Eine Hülle um `PlexServer`, keine Umschreibung: die Endpunkte bleiben, wo
/// sie sind, und werden hier nur in die neutrale Form gebracht. Das ist der
/// ganze Zweck — die Oberfläche fragt nach `MediaItem`, und was dafür über die
/// Leitung geht, ist ihre Sache nicht.
public struct PlexBackend: MediaBackend {
/// Der darunterliegende Server.
///
/// Noch öffentlich, weil Player, Downloads und Live-TV vorerst direkt mit
/// ihm sprechen — diese drei Bereiche wandern als Nächstes.
public let server: PlexServer
/// Live-TV mit Gedächtnis.
///
/// Ein Actor, also ein Referenztyp: Kopien dieses Struct teilen sich
/// denselben und damit die geladene Senderliste. Warum das nötig ist,
/// steht bei `PlexLiveTVSession`.
let live: PlexLiveTVSession
public init(server: PlexServer) {
self.server = server
self.live = PlexLiveTVSession(server: server)
}
public nonisolated var serverName: String { server.name }
/// Die Maschinenkennung — sie überlebt Umbenennen und Adresswechsel,
/// genau dafür gibt es sie.
public nonisolated var identity: String { "plex:\(server.resource.clientIdentifier)" }
public nonisolated var capabilities: Set<BackendCapability> {
// Keine Favoriten: Plex kennt sie nicht, und ein Knopf, der nichts
// bewirkt, ist schlimmer als keiner.
[.liveTV, .ratings, .libraryScan, .transcoding]
}
// MARK: - Blättern
public func libraries() async throws(MediaBackendError) -> [MediaLibrary] {
try await mapping { try await server.libraries().map(\.asMediaLibrary) }
}
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
try await mapping { try await server.homeHubs().map(\.asMediaRowGroup) }
}
public func items(
inLibrary library: MediaLibrary,
query: MediaQuery,
offset: Int,
limit: Int
) async throws(MediaBackendError) -> MediaPage {
try await mapping {
let page = try await server.items(
inLibrary: plexLibrary(library),
query: LibraryQuery(query),
offset: offset,
limit: limit
)
return MediaPage(
items: page.items.map(\.asMediaItem),
offset: page.offset,
totalCount: page.totalSize
)
}
}
public func item(id: String) async throws(MediaBackendError) -> MediaItem {
try await mapping { try await server.metadata(ratingKey: id).asMediaItem }
}
public func children(of id: String) async throws(MediaBackendError) -> [MediaItem] {
try await mapping { try await server.children(of: id).map(\.asMediaItem) }
}
public func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup] {
try await mapping { try await server.search(text, limit: limit).map(\.asMediaRowGroup) }
}
public func filterOptions(
inLibrary library: MediaLibrary,
field: MediaQuery.Field
) async throws(MediaBackendError) -> [MediaFilterOption] {
try await mapping {
try await server
.filterOptions(inLibrary: plexLibrary(library), field: field.rawValue)
.map { MediaFilterOption(id: $0.key, title: $0.title) }
}
}
public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
try await mapping {
// Der Weg führt über die Geschwister; dafür braucht es den
// vollständigen Plex-Eintrag, nicht nur die Kennung.
let plex = try await server.metadata(ratingKey: item.id)
return try await server.nextEpisode(after: plex)?.asMediaItem
}
}
// MARK: - Bilder
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
server.artworkURL(path: path, width: width, height: height)
}
public nonisolated func rawImageURL(path: String?) -> URL? {
server.rawImageURL(path: path)
}
// MARK: - Herunterladen
public nonisolated func downloadURL(for file: MediaFile) -> URL? {
guard let key = file.key else { return nil }
// Nur der Schlüssel wird gebraucht; alles andere steckt schon in der
// Adresse oder ist für den Abruf ohne Belang.
return server.downloadURL(part: PlexPart(id: 0, key: key))
}
public nonisolated func compressedDownloadURL(
itemID: String,
versionIndex: Int,
maximumBitrate: Int,
resolution: String?
) -> URL? {
server.transcodedDownloadURL(
ratingKey: itemID,
mediaIndex: versionIndex,
maximumBitrate: maximumBitrate,
resolution: resolution
)
}
// MARK: - Abspielen
public func preparePlayback(
_ intent: PlaybackIntent
) async throws(MediaBackendError) -> PlaybackTarget {
guard let transcode = intent.transcode else {
// Unverändert übertragen: die Datei selbst, ohne Sitzung — es gibt
// nichts anzuhalten, was der Server angelegt hätte.
guard let key = intent.file.key,
let url = server.directPlayURL(part: PlexPart(id: 0, key: key))
else { throw .notFound }
return PlaybackTarget(url: url)
}
let request = TranscodeRequest(
ratingKey: intent.itemID,
mediaIndex: intent.versionIndex,
maximumBitrate: transcode.maximumBitrate,
resolution: transcode.resolution,
isLocal: intent.isLocalConnection
)
// Vorher fragen, ob der Server überhaupt mitspielt. Sagt er hier schon
// nein, steht der Grund im Klartext da — statt eines schwarzen Bildes
// nach zwanzig Sekunden Warten.
//
// **Ohne `autoAdjustQuality`**, und das mit Absicht: der Entscheider
// antwortet auf Unbekanntes mit nackten 400ern — dafür gibt es in
// diesem Paket eigens `transcoderBisect`. Er beantwortet ohnehin nur
// „darf das gespielt werden", und daran ändert eine Leiter nichts.
// Stünde der Wert hier und der Server nähme ihn übel, wäre nicht die
// Leiter kaputt, sondern die Wiedergabe.
let decision = try await mapping { try await server.transcodeDecision(for: request) }
guard decision.isAllowed else {
throw .refused(reason: decision.explanation ?? "")
}
// **`autoAdjustQuality` wird nicht geschickt — gemessen, nicht vermutet.**
//
// Die Zeile stand hier und ist wieder raus. Gegen den Testserver
// beantwortet Plex dieselbe Anfrage mit dem Wert mit einer nackten
// **HTTP 400**, ohne ihn mit einer Playlist. Zwei Anfragen, getrennte
// Sitzungskennungen, sonst identisch — der Wert ist die einzige
// Abweichung.
//
// Damit hätte er nicht die Qualität verbessert, sondern die
// Wiedergabe verhindert. Plex' eigene Clients schicken ihn; welche
// weitere Angabe sie dabei mitgeben, ist offen. Bis das gemessen ist,
// bleibt er draußen: eine Leiter, die es vielleicht gibt, ist keinen
// Stream wert, der sicher nicht läuft.
//
// `TranscodeRequest.adaptive` bleibt samt Tests bestehen — das
// Gerüst steht, es fehlt die richtige Beschwörungsformel.
guard let url = server.transcodeURL(for: request) else { throw .notFound }
return PlaybackTarget(url: url, sessionID: request.sessionIdentifier)
}
public func endPlayback(sessionID: String) async {
await server.stopTranscode(sessionIdentifier: sessionID)
}
public func reportProgress(_ report: PlaybackReport) async {
await server.reportProgress(
ratingKey: report.itemID,
state: report.state,
time: report.time,
duration: report.duration
)
}
// MARK: - Zustand melden
public func markWatched(id: String) async throws(MediaBackendError) {
try await mapping { try await server.markWatched(ratingKey: id) }
}
public func markUnwatched(id: String) async throws(MediaBackendError) {
try await mapping { try await server.markUnwatched(ratingKey: id) }
}
public func removeFromContinueWatching(_ item: MediaItem) async throws(MediaBackendError) {
// Plex hat einen eigenen Weg und braucht vom Titel nur die Kennung.
try await mapping { try await server.removeFromContinueWatching(ratingKey: item.id) }
}
public func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError) {
// Plex kennt keine Favoriten. Stillschweigend nichts zu tun wäre
// schlimmer als zu sagen, dass es das hier nicht gibt.
throw .notFound
}
public func rate(id: String, rating: Int) async throws(MediaBackendError) {
try await mapping { try await server.rate(ratingKey: id, rating: rating) }
}
public func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError) {
try await mapping { try await server.refreshLibrary(plexLibrary(library)) }
}
// MARK: - Hilfen
/// Baut aus der neutralen Mediathek die Plex-Form zurück.
///
/// Nur `key` und `type` werden gebraucht; alles andere hat der Server
/// ohnehin. Ein zweiter Abruf, nur um ein Modell vollständig zu machen,
/// das gleich wieder zu einer Adresse wird, wäre verschwendet.
private func plexLibrary(_ library: MediaLibrary) -> PlexLibrary {
let type: String = switch library.kind {
case .movie: "movie"
case .show: "show"
case .music: "artist"
case .photo: "photo"
case .liveTV: "livetv"
case .other: "unknown"
}
return PlexLibrary(key: library.id, title: library.title, type: type)
}
/// Führt einen Plex-Aufruf aus und übersetzt seinen Fehler.
///
/// Der Block wirft ungetypt, weil Swift den getippten Wurf durch eine
/// Closure hindurch nicht ableiten kann. Der zweite `catch` ist deshalb
/// nicht Zierde: gäbe es ihn nicht, fiele ein unerwarteter Fehler durch
/// und die Signatur wäre eine Lüge.
private func mapping<Value>(
_ work: () async throws -> Value
) async throws(MediaBackendError) -> Value {
do {
return try await work()
} catch let error as PlexError {
throw error.asMediaBackendError
} catch {
throw .transport(description: String(describing: error))
}
}
}
extension PlexError {
public var asMediaBackendError: MediaBackendError {
switch self {
case .unauthorized: .unauthorized
case .notFound: .notFound
case .noReachableConnection: .noReachableConnection
case .server(let status): .server(status: status)
case .transport(let description): .transport(description: description)
case .decoding(let description): .decoding(description: description)
}
}
}
extension LibraryQuery {
/// Übersetzt die neutrale Abfrage in Plex' Schreibweise.
init(_ query: MediaQuery) {
let sort: Sort = switch query.sort {
case .titleAscending: .titleAscending
case .titleDescending: .titleDescending
case .recentlyAdded: .recentlyAdded
case .releaseDate: .releaseDate
case .rating: .rating
case .lastPlayed: .lastViewed
}
self.init(
sort: sort,
unwatchedOnly: query.unwatchedOnly,
genre: query.genre,
year: query.year,
contentRating: query.contentRating,
resolution: query.resolution
)
}
}
@@ -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)
}
}