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
+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
)
}
}