diff --git a/.gitignore b/.gitignore
index 456abb2..d2b10de 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@ xcuserdata/
# er verweist nur auf das Projekt und überlebt jedes Neuerzeugen. Warum das
# nötig ist, steht in Flixy.xcworkspace/README.md.
Flixy.xcodeproj/
+node_modules/
diff --git a/Flixy/Branding.swift b/Flixy/Branding.swift
index 9144c86..dc30d62 100644
--- a/Flixy/Branding.swift
+++ b/Flixy/Branding.swift
@@ -18,13 +18,6 @@ enum Branding {
/// Wie die App sich selbst nennt.
static let appName = "Flixy"
- /// Unter welchem Namen die App in der Geräteliste des Plex-Kontos steht.
- ///
- /// Wandert in die Kopfzeile `X-Plex-Product`. Eine Änderung hier legt beim
- /// nächsten Anmelden einen neuen Eintrag an — der alte bleibt als Karteileiche
- /// stehen, bis er von Hand entfernt wird.
- static let plexProduct = appName
-
/// Unter welchem Namen die Tokens im Schlüsselbund liegen.
///
/// An die Bundle-Kennung gebunden statt an den Anzeigenamen: eine spätere
@@ -33,20 +26,6 @@ enum Branding {
Bundle.main.bundleIdentifier ?? "com.scarriffleservices.flixy"
}
- /// Das eigene URL-Schema, über das Plex nach der Anmeldung zurückschickt.
- ///
- /// Die Bundle-Kennung statt eines kurzen Wortes wie „flixy": sie ist
- /// garantiert eindeutig. Bei einem kurzen Schema könnte eine andere App
- /// denselben Namen beanspruchen und die Anmeldung abfangen.
- static var urlScheme: String {
- Bundle.main.bundleIdentifier ?? "com.scarriffleservices.flixy"
- }
-
- /// Wohin Plex nach der Anmeldung zurückschickt.
- static var authCallbackURL: URL {
- URL(string: "\(urlScheme)://auth")!
- }
-
/// Wann dieser Build entstanden ist.
///
/// Gelesen als Änderungsdatum der eigenen Binärdatei — kein Build-Skript,
diff --git a/Flixy/FlixyApp.swift b/Flixy/FlixyApp.swift
index 64b895f..9b95a17 100644
--- a/Flixy/FlixyApp.swift
+++ b/Flixy/FlixyApp.swift
@@ -8,7 +8,6 @@ import FlixyDownloads
import FlixyPlayback
import JellyfinKit
import MediaKit
-import PlexKit
@main
struct FlixyApp: App {
@@ -41,12 +40,7 @@ struct FlixyApp: App {
private let modelContainer: ModelContainer
init() {
- let identity = PlexClientIdentity.current(
- product: Branding.plexProduct,
- version: Branding.version
- )
_session = State(initialValue: FlixySession(
- identity: identity,
jellyfinIdentity: JellyfinIdentity.current(
client: Branding.appName,
version: Branding.version
diff --git a/Flixy/Info.plist b/Flixy/Info.plist
index 2d6e4ca..ff46941 100644
--- a/Flixy/Info.plist
+++ b/Flixy/Info.plist
@@ -18,17 +18,6 @@
APPL
CFBundleShortVersionString
1.0
- CFBundleURLTypes
-
-
- CFBundleURLName
- com.scarriffleservices.flixy.auth
- CFBundleURLSchemes
-
- com.scarriffleservices.flixy
-
-
-
CFBundleVersion
1
ITSAppUsesNonExemptEncryption
@@ -39,7 +28,7 @@
NSLocalNetworkUsageDescription
- Flixy benötigt Zugriff auf das lokale Netzwerk, um deinen Plex-Server zu finden und Medien direkt von ihm zu laden.
+ Flixy benötigt Zugriff auf das lokale Netzwerk, um deinen Jellyfin-Server zu erreichen und Medien direkt von ihm zu laden.
NSSupportsLiveActivities
UIBackgroundModes
diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings
index 48aac42..60f3cf8 100644
--- a/Flixy/Resources/Localizable.xcstrings
+++ b/Flixy/Resources/Localizable.xcstrings
@@ -888,39 +888,6 @@
}
}
},
- "diagnostics.liveTV": {
- "extractionState": "stale",
- "localizations": {
- "de": {
- "stringUnit": {
- "state": "translated",
- "value": "Live-TV-Antwort ansehen"
- }
- },
- "en": {
- "stringUnit": {
- "state": "translated",
- "value": "Inspect Live TV response"
- }
- }
- }
- },
- "diagnostics.liveTVHint": {
- "localizations": {
- "de": {
- "stringUnit": {
- "state": "translated",
- "value": "Zeigt den Aufbau der Serverantwort — nur Schlüssel, Anzahl und Sendernamen, keine Zugangsdaten. Hilft dabei zu finden, wo die Sender stehen."
- }
- },
- "en": {
- "stringUnit": {
- "state": "translated",
- "value": "Shows the structure of the server's response — keys, counts and channel names only, no credentials. Helps locate where the channels are."
- }
- }
- }
- },
"diagnostics.method": {
"extractionState": "stale",
"localizations": {
@@ -970,23 +937,6 @@
}
}
},
- "diagnostics.relayWarning": {
- "extractionState": "stale",
- "localizations": {
- "de": {
- "stringUnit": {
- "state": "translated",
- "value": "Über die Plex-Vermittlung. Stark gedrosselt — große Dateien laufen darüber kaum an."
- }
- },
- "en": {
- "stringUnit": {
- "state": "translated",
- "value": "Via the Plex relay. Heavily throttled — large files barely start over it."
- }
- }
- }
- },
"diagnostics.source": {
"localizations": {
"de": {
@@ -1801,23 +1751,6 @@
}
}
},
- "error.noServers": {
- "extractionState": "stale",
- "localizations": {
- "de": {
- "stringUnit": {
- "state": "translated",
- "value": "An deinem Plex-Konto hängt kein Server."
- }
- },
- "en": {
- "stringUnit": {
- "state": "translated",
- "value": "There's no server on your Plex account."
- }
- }
- }
- },
"error.notFound": {
"extractionState": "stale",
"localizations": {
@@ -1909,13 +1842,13 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Die Antwort des Servers war unerwartet. Möglicherweise ist eine neuere Plex-Version installiert."
+ "value": "Die Antwort des Servers war unerwartet. Möglicherweise läuft dort eine neuere Fassung, als diese App kennt."
}
},
"en": {
"stringUnit": {
"state": "translated",
- "value": "The server's response was unexpected. It may be running a newer version of Plex."
+ "value": "The server's response was unexpected. It may be running a newer version than this app knows."
}
}
}
@@ -3371,13 +3304,13 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Live-TV braucht eine Fernsehkarte oder einen Netzwerk-Tuner am Plex-Server. Richte ihn in den Servereinstellungen ein, dann erscheinen die Sender hier."
+ "value": "Live-TV braucht eine Fernsehkarte oder einen Netzwerk-Tuner am Server. Richte ihn in den Servereinstellungen ein, dann erscheinen die Sender hier."
}
},
"en": {
"stringUnit": {
"state": "translated",
- "value": "Live TV needs a tuner card or network tuner on your Plex server. Set one up in the server settings and channels will appear here."
+ "value": "Live TV needs a tuner card or network tuner on your server. Set one up in the server settings and channels will appear here."
}
}
}
@@ -4158,24 +4091,6 @@
}
}
},
- "player.relayWarning": {
- "comment": "Warnung im Player, wenn die Verbindung über die Plex-Vermittlung läuft",
- "extractionState": "stale",
- "localizations": {
- "de": {
- "stringUnit": {
- "state": "translated",
- "value": "Die Verbindung läuft über die Vermittlung von Plex und ist stark gedrosselt. Große Dateien laufen darüber kaum an."
- }
- },
- "en": {
- "stringUnit": {
- "state": "translated",
- "value": "You're connected through the Plex relay, which is heavily throttled. Large files barely start over it."
- }
- }
- }
- },
"player.rememberedTrack": {
"extractionState": "manual",
"localizations": {
@@ -4418,13 +4333,13 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Plex kennt für Filme keinen reinen Tonstrom. Diese Stufe überträgt das Bild in der kleinsten Auflösung und blendet es aus."
+ "value": "Der Server kennt für Filme keinen reinen Tonstrom. Diese Stufe überträgt das Bild in der kleinsten Auflösung und blendet es aus."
}
},
"en": {
"stringUnit": {
"state": "translated",
- "value": "Plex has no audio-only stream for video. This setting sends the picture at the smallest size and hides it."
+ "value": "The server has no audio-only stream for video. This setting sends the picture at the smallest size and hides it."
}
}
}
@@ -5142,56 +5057,6 @@
}
}
},
- "serverPicker.owned": {
- "extractionState": "stale",
- "localizations": {
- "de": {
- "stringUnit": {
- "state": "translated",
- "value": "Eigener Server"
- }
- },
- "en": {
- "stringUnit": {
- "state": "translated",
- "value": "Your server"
- }
- }
- }
- },
- "serverPicker.shared": {
- "extractionState": "stale",
- "localizations": {
- "de": {
- "stringUnit": {
- "state": "translated",
- "value": "Geteilt"
- }
- },
- "en": {
- "stringUnit": {
- "state": "translated",
- "value": "Shared"
- }
- }
- }
- },
- "serverPicker.title": {
- "localizations": {
- "de": {
- "stringUnit": {
- "state": "translated",
- "value": "Server wählen"
- }
- },
- "en": {
- "stringUnit": {
- "state": "translated",
- "value": "Choose a server"
- }
- }
- }
- },
"settings.account": {
"extractionState": "stale",
"localizations": {
@@ -5395,23 +5260,6 @@
}
}
},
- "settings.connectionRelay": {
- "extractionState": "stale",
- "localizations": {
- "de": {
- "stringUnit": {
- "state": "translated",
- "value": "Über Plex-Vermittlung (gedrosselt)"
- }
- },
- "en": {
- "stringUnit": {
- "state": "translated",
- "value": "Via Plex relay (throttled)"
- }
- }
- }
- },
"settings.connectionRemote": {
"extractionState": "stale",
"localizations": {
@@ -5998,23 +5846,6 @@
}
}
},
- "settings.serverKind": {
- "extractionState": "stale",
- "localizations": {
- "de": {
- "stringUnit": {
- "state": "translated",
- "value": "Art"
- }
- },
- "en": {
- "stringUnit": {
- "state": "translated",
- "value": "Type"
- }
- }
- }
- },
"settings.serverName": {
"extractionState": "stale",
"localizations": {
@@ -6770,35 +6601,18 @@
}
}
},
- "signIn.manualHint": {
- "comment": "Hinweis, wenn die Browser-Verknüpfung nicht greift",
- "localizations": {
- "de": {
- "stringUnit": {
- "state": "translated",
- "value": "Hat das Fenster nicht verknüpft? Öffne plex.tv/link und gib diesen Code ein:"
- }
- },
- "en": {
- "stringUnit": {
- "state": "translated",
- "value": "Window didn't link? Open plex.tv/link and enter this code:"
- }
- }
- }
- },
"signIn.privacy": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
- "value": "Die Anmeldung läuft über deinen Server. Dein Passwort erreicht diese App nie."
+ "value": "Die Anmeldung läuft unmittelbar mit deinem Server. Kein fremder Dienst ist daran beteiligt."
}
},
"en": {
"stringUnit": {
"state": "translated",
- "value": "Sign-in happens on your server. Your password never reaches this app."
+ "value": "You sign in directly with your own server. No third-party service is involved."
}
}
}
@@ -6820,41 +6634,6 @@
}
}
},
- "signIn.timedOut": {
- "comment": "Die Anmeldung wurde nicht innerhalb der Gültigkeit des Codes abgeschlossen",
- "extractionState": "stale",
- "localizations": {
- "de": {
- "stringUnit": {
- "state": "translated",
- "value": "Die Anmeldung wurde nicht abgeschlossen. Versuch es noch einmal."
- }
- },
- "en": {
- "stringUnit": {
- "state": "translated",
- "value": "Sign-in wasn't completed. Please try again."
- }
- }
- }
- },
- "signIn.windowFailed": {
- "extractionState": "stale",
- "localizations": {
- "de": {
- "stringUnit": {
- "state": "translated",
- "value": "Das Anmeldefenster ließ sich nicht öffnen."
- }
- },
- "en": {
- "stringUnit": {
- "state": "translated",
- "value": "The sign-in window couldn't be opened."
- }
- }
- }
- },
"signIn.withJellyfin": {
"localizations": {
"de": {
@@ -6871,22 +6650,6 @@
}
}
},
- "signIn.withPlex": {
- "localizations": {
- "de": {
- "stringUnit": {
- "state": "translated",
- "value": "Mit Plex anmelden"
- }
- },
- "en": {
- "stringUnit": {
- "state": "translated",
- "value": "Sign in with Plex"
- }
- }
- }
- },
"sort.lastPlayed": {
"extractionState": "stale",
"localizations": {
diff --git a/Flixy/Session/FlixySession.swift b/Flixy/Session/FlixySession.swift
index da64158..dd12c97 100644
--- a/Flixy/Session/FlixySession.swift
+++ b/Flixy/Session/FlixySession.swift
@@ -2,7 +2,6 @@ import Foundation
import JellyfinKit
import MediaKit
import Observation
-import PlexKit
/// Der Zustand der Verbindung zum Server.
///
@@ -17,54 +16,22 @@ import PlexKit
@MainActor
@Observable
final class FlixySession {
- /// Welche Art von Server gerade bedient wird.
- enum ServerKind: String, Sendable, CaseIterable {
- case plex
- case jellyfin
- }
-
enum Phase: Equatable {
/// Beim Start: liegt ein Token im Schlüsselbund?
case launching
case signedOut
- /// Das Anmeldefenster von Plex ist offen.
- case authenticating
- /// Mehrere Server am Konto — einer muss gewählt werden.
- case choosingServer([PlexResource])
/// Ein Weg zum Server wird gesucht.
case connecting(serverName: String)
case ready
case failed(message: String)
-
- static func == (lhs: Phase, rhs: Phase) -> Bool {
- switch (lhs, rhs) {
- case (.launching, .launching), (.signedOut, .signedOut), (.ready, .ready):
- true
- case (.authenticating, .authenticating):
- true
- case (.choosingServer(let l), .choosingServer(let r)):
- l.map(\.clientIdentifier) == r.map(\.clientIdentifier)
- case (.connecting(let l), .connecting(let r)):
- l == r
- case (.failed(let l), .failed(let r)):
- l == r
- default:
- false
- }
- }
+ // Kein eigener ==-Operator mehr: er war nur nötig, weil `PlexResource`
+ // nicht `Equatable` war und über die Kennung verglichen werden musste.
+ // Alle verbliebenen Nutzlasten sind `String`, also leitet der Compiler
+ // die Gleichheit selbst ab — und ein künftiger Fall wird nicht
+ // stillschweigend als „ungleich" behandelt.
}
private enum Key {
- /// Welche Serverart zuletzt aktiv war.
- ///
- /// Fehlt der Eintrag, aber es liegt ein Plex-Token bereit, war es
- /// Plex — so kam die App bis hierher, und niemand soll durch ein
- /// Update abgemeldet werden.
- static let serverKind = "flixy.backend.kind"
- static let preferredServer = "flixy.server.clientIdentifier"
- static let cachedConnectionURI = "flixy.server.connectionURI"
- static let cachedServerName = "flixy.server.name"
- static let cachedConnectionIsLocal = "flixy.server.connectionIsLocal"
static let jellyfinAddress = "flixy.jellyfin.address"
static let jellyfinUserID = "flixy.jellyfin.userID"
static let jellyfinUserName = "flixy.jellyfin.userName"
@@ -72,33 +39,38 @@ final class FlixySession {
static let jellyfinIsAdministrator = "flixy.jellyfin.isAdministrator"
}
- /// Unter welchem Namen die Server-Token im Schlüsselbund liegen.
+ /// Unter welchem Namen das Server-Token im Schlüsselbund liegt.
///
- /// Nicht in den Voreinstellungen: sie geben vollen Zugriff auf die
- /// Mediathek. Getrennte Konten je Serverart, damit ein späteres Umschalten
- /// zwischen beiden nicht jedes Mal eine neue Anmeldung kostet. Das
- /// ausdrückliche Abmelden räumt trotzdem beide ab — siehe `signOut()`.
- private static let plexTokenAccount = "plex.server"
+ /// Nicht in den Voreinstellungen: es gibt vollen Zugriff auf die
+ /// Mediathek. Der Name trägt die Serverart, damit ein zweiter Server
+ /// später daneben passt, ohne diesen zu verdrängen.
private static let jellyfinTokenAccount = "jellyfin.server"
- /// Wird für die Anmelde-Adresse gebraucht: plex.tv ordnet den
- /// bestätigten Code darüber diesem Gerät zu.
- var clientIdentifier: String { identity.clientIdentifier }
-
- /// Der Code der laufenden Anmeldung.
+ /// Was die Plex-Zeit auf dem Gerät hinterlassen hat.
///
- /// Wird nach kurzer Zeit im Anmeldebildschirm eingeblendet — als Ausweg,
- /// falls die Browser-Seite den Code nicht verknüpft. Das passiert, wenn
- /// man dort schon angemeldet ist, und ohne diesen Ausweg säße man fest.
- private(set) var pendingCode: String?
- private(set) var showsManualCode = false
+ /// **Diese Namen stehen sonst nirgends mehr.** Mit dem Plex-Code ging auch
+ /// das Einzige, was die beiden Token je wieder entfernt hätte — wer sich
+ /// nie abgemeldet hat, trüge ein gültiges plex.tv-Token bis in alle
+ /// Ewigkeit im Schlüsselbund, ohne dass irgendein Weg es noch löschen
+ /// könnte. Deshalb wird einmalig aufgeräumt, bevor die Namen endgültig
+ /// verschwinden.
+ private enum Altlast {
+ static let merker = "flixy.migration.plexPurged"
+ static let tokenKonten = ["plex.account", "plex.server"]
+ static let schlüssel = [
+ "flixy.backend.kind",
+ "flixy.server.clientIdentifier",
+ "flixy.server.connectionURI",
+ "flixy.server.name",
+ "flixy.server.connectionIsLocal",
+ ]
+ }
/// Ob der Server in **demselben** Netz steht wie wir.
///
/// Entscheidet, ob „Auto" gedeckelt anfängt: zuhause trägt die Leitung
- /// das Original, unterwegs nicht. Bei Plex steht es im gewählten Weg; bei
- /// Jellyfin wird der Server gefragt, weil ein Blick auf die Adresse nicht
- /// genügt — wer über einen Reverse Proxy geht, erreicht seinen Server
+ /// das Original, unterwegs nicht. Der Server wird danach gefragt, weil
+ /// ein Blick auf die Adresse nicht genügt — wer über einen Reverse Proxy geht, erreicht seinen Server
/// auch von der Couch aus unter einem öffentlichen Namen.
///
/// Im Zweifel `false`: lieber einmal zu vorsichtig gedeckelt als eine
@@ -106,32 +78,19 @@ final class FlixySession {
private(set) var isLocalConnection = false
private(set) var phase: Phase = .launching
- /// Welche Serverart gerade bedient wird — `nil`, solange niemand
- /// angemeldet ist.
- private(set) var kind: ServerKind?
/// Der aktive Server hinter dem neutralen Vertrag.
///
- /// Die Ansichten sprechen ausschließlich hierüber. Ob dahinter Plex oder
- /// Jellyfin steckt, sehen sie nicht — und genau deshalb musste für
- /// Jellyfin keine einzige Ansicht neu geschrieben werden.
+ /// Die Ansichten sprechen ausschließlich hierüber und sehen nicht, wer
+ /// dahintersteckt. Dass dieser Vertrag einmal zwei Server gleichzeitig
+ /// getragen hat, ist kein Zufall, sondern der Grund, warum für einen
+ /// weiteren keine einzige Ansicht neu geschrieben werden muss.
private(set) var backend: (any MediaBackend)?
- /// Der Plex-Server, solange Plex aktiv ist.
- ///
- /// Player, Live-TV und die Diagnose sprechen noch unmittelbar mit ihm.
- /// Bei Jellyfin ist er `nil` — diese drei Bereiche wandern als Nächstes
- /// auf den neutralen Vertrag.
- private(set) var server: PlexServer?
-
/// Das angemeldete Jellyfin-Konto, solange Jellyfin aktiv ist.
private(set) var jellyfinAccount: JellyfinAccount?
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
- ///
- /// Plex führt den Kontonamen am Konto, nicht am Server; dort steht er hier
- /// noch nicht zur Verfügung, und deshalb bleibt die Zeile leer, statt
- /// etwas Falsches zu behaupten.
var accountName: String? { jellyfinAccount?.userName }
/// Die Mediatheken, die Flixy darstellen kann — Musik und Fotos
@@ -142,46 +101,31 @@ final class FlixySession {
var serverName: String { backend?.serverName ?? "" }
/// Unter welcher Adresse er erreicht wird.
- var serverAddress: String? {
- switch kind {
- case .plex: server?.connection.uri
- case .jellyfin: defaults.string(forKey: Key.jellyfinAddress)
- case nil: nil
- }
- }
+ var serverAddress: String? { defaults.string(forKey: Key.jellyfinAddress) }
/// Wie die Verbindung zum Server zu beschreiben ist.
///
/// Für die Diagnose, und deshalb hier: welcher Weg zum Server führt, weiß
- /// die Sitzung. Bei Plex ist es die entscheidende Angabe — über die
- /// Vermittlung von plex.tv ist alles gedrosselt, und das erklärt die
- /// Hälfte aller Ruckler, bevor man weitersucht.
+ /// die Sitzung. Nah oder fern ist dabei die Angabe, die etwas erklärt —
+ /// an ihr hängt, ob „Auto" gedeckelt anfängt, und damit die Hälfte aller
+ /// Fragen nach Rucklern. Die blosse Adresse steht schon eine Zeile höher.
var connectionDescription: String? {
- switch kind {
- case .plex:
- guard let connection = server?.connection else { return nil }
- if connection.local { return "Lokal · \(connection.uri)" }
- if connection.relay { return "Plex-Vermittlung (gedrosselt)" }
- return "Direkt von außen · \(connection.uri)"
- case .jellyfin:
- return serverAddress
- case nil:
- return nil
- }
+ guard let serverAddress else { return nil }
+ return isLocalConnection
+ ? "Lokal · \(serverAddress)"
+ : "Von außen · \(serverAddress)"
}
/// Ob der aktive Server Live-TV anbietet.
///
- /// Endlich die Fähigkeit selbst. Hier stand `server != nil` — also „nur
- /// Plex" —, weil der Programmführer unmittelbar mit Plex sprach; einen
+ /// Die Fähigkeit selbst, nicht der Umweg über die Serverart. Einen
/// Eintrag zu zeigen, der zu einem leeren Bildschirm führt, wäre eine
- /// Zusage gewesen, die niemand einlöst. Seit Live-TV über den neutralen
- /// Vertrag läuft, löst sie jeder ein, der sie gibt.
+ /// Zusage, die niemand einlöst — so gibt sie nur, wer sie auch einlöst.
var supportsLiveTV: Bool { backend?.capabilities.contains(.liveTV) == true }
/// Ob er darüber hinaus aufnehmen kann.
///
- /// Getrennt gefragt, weil Plex das eine kann und das andere nicht.
+ /// Getrennt gefragt: ein Server kann empfangen, ohne aufnehmen zu können.
var supportsRecordings: Bool { backend?.capabilities.contains(.liveTVRecordings) == true }
/// Der Server als Live-TV-Server — sofern er einer ist.
@@ -192,229 +136,83 @@ final class FlixySession {
/// Warum gerade nicht abgespielt werden kann.
///
- /// Bei Jellyfin ist die Anmeldung fertig, die Wiedergabe noch nicht — sie
- /// ist der nächste Schritt. „Server nicht erreichbar" wäre an dieser
- /// Stelle schlicht gelogen: er ist erreichbar, die App kann nur noch
- /// nichts von ihm abspielen.
+ /// Zwei verschiedene Lagen, und sie dürfen nicht verwechselt werden: ist
+ /// ein Server da, ist die Anmeldung fertig und nur die Wiedergabe noch
+ /// nicht — „nicht erreichbar" wäre dann gelogen. Ist **keiner** da, ist
+ /// niemand angemeldet, und „Wiedergabe steht aus" wäre es.
+ ///
+ /// Die Unterscheidung hing früher an der Serverart, und deren `nil`-Fall
+ /// lief stillschweigend im selben Zweig mit. Jetzt steht sie da, wo sie
+ /// hingehört: an der Frage, ob überhaupt ein Server da ist.
var playbackUnavailableMessage: String {
- kind == .jellyfin
+ backend != nil
? String(localized: "player.jellyfinPending")
: String(localized: "error.serverUnreachable")
}
- private let identity: PlexClientIdentity
private let jellyfinIdentity: JellyfinIdentity
private let tokenStore: any TokenStoring
- private let authenticator: PlexAuthenticator
- private let webAuthenticator = PlexWebAuthenticator()
private let defaults: UserDefaults
- private var authorizationTask: Task?
init(
- identity: PlexClientIdentity,
jellyfinIdentity: JellyfinIdentity,
tokenStore: any TokenStoring,
defaults: UserDefaults = .standard
) {
- self.identity = identity
self.jellyfinIdentity = jellyfinIdentity
self.tokenStore = tokenStore
self.defaults = defaults
- self.authenticator = PlexAuthenticator(identity: identity, tokenStore: tokenStore)
}
// MARK: - Start
/// Versucht, mit einem bereits gespeicherten Token weiterzumachen.
func restore() async {
+ purgePlexLeftovers()
+
#if DEBUG
if await adoptStubServerIfRequested() { return }
#endif
- switch storedKind {
- case .jellyfin:
- if await restoreJellyfin() { return }
- phase = .signedOut
-
- case .plex, nil:
- guard let token = authenticator.storedToken else {
- phase = .signedOut
- return
- }
- // Wer vor dieser Version angemeldet war, hat keinen Eintrag über
- // die Serverart — nachtragen, damit „nicht eingetragen" von hier
- // an eindeutig „abgemeldet" heißt.
- defaults.set(ServerKind.plex.rawValue, forKey: Key.serverKind)
-
- // Erst der kurze Weg: die Verbindung, die beim letzten Mal
- // funktioniert hat. Ohne ihn kostet jeder Start eine Anfrage an
- // plex.tv plus eine neue Verbindungssuche — zusammen mehrere
- // Sekunden, in denen nur ein Ladekreis zu sehen ist.
- if await restoreFromRememberedConnection() { return }
-
- await connect(accountToken: token)
- }
- }
-
- private var storedKind: ServerKind? {
- defaults.string(forKey: Key.serverKind).flatMap(ServerKind.init(rawValue:))
- }
-
- /// Nimmt die zuletzt funktionierende Verbindung wieder auf.
- ///
- /// Gibt `false` zurück, wenn nichts gespeichert ist oder der Server nicht
- /// mehr darüber erreichbar ist — dann übernimmt die vollständige Suche.
- /// Das ist der Fall, wenn man von zuhause weggeht: die lokale Adresse
- /// antwortet nicht mehr, und der direkte Weg muss neu gefunden werden.
- private func restoreFromRememberedConnection() async -> Bool {
- guard let identifier = defaults.string(forKey: Key.preferredServer),
- let uri = defaults.string(forKey: Key.cachedConnectionURI),
- let serverToken = tokenStore.token(for: Self.plexTokenAccount)
- else { return false }
-
- let name = defaults.string(forKey: Key.cachedServerName) ?? ""
- phase = .connecting(serverName: name)
-
- let connection = PlexConnection(
- uri: uri,
- local: defaults.bool(forKey: Key.cachedConnectionIsLocal)
- )
- let resource = PlexResource(
- name: name,
- clientIdentifier: identifier,
- accessToken: serverToken,
- connections: [connection]
- )
-
- // Kurze Zeitgrenze: wenn die gemerkte Adresse nicht sofort antwortet,
- // ist die vollständige Suche schneller als weiteres Warten.
- let selector = PlexConnectionSelector(identity: identity, probeTimeout: .seconds(2))
- guard await selector.selectConnection(for: resource) != nil,
- let server = PlexServer(resource: resource, connection: connection, identity: identity)
- else { return false }
-
- let plex = adopt(server: server)
- do {
- libraries = try await loadLibraries(from: plex)
- phase = .ready
- return true
- } catch {
- clearActiveServer()
- return false
- }
- }
-
- // MARK: - Anmeldung bei Plex
-
- /// Öffnet die Anmeldeseite von Plex.
- ///
- /// Der PIN steckt weiterhin darunter — er ist der einzige Weg, auf dem
- /// Plex einen Token herausgibt. Sichtbar ist er nicht mehr.
- ///
- /// Das Fenster wird geöffnet, aber **nicht abgewartet**: Plex führt den
- /// Rücksprung über `forwardUrl` nicht zuverlässig aus. Auf dem iPad landet
- /// der Browser nach dem Anmelden auf watch.plex.tv und bleibt dort stehen
- /// — der Token liegt längst bereit, aber die App wartete auf einen
- /// Rückruf, der nie kam. Stattdessen wird hier im Sekundentakt beim Server
- /// nachgefragt und nebenbei geprüft, ob das Fenster geschlossen wurde.
- func beginSignIn() async {
- phase = .authenticating
-
- let pin: PlexPin
- do {
- pin = try await authenticator.requestPin()
- } catch {
- phase = .failed(message: ErrorText.describe(error))
- return
- }
-
- guard let url = pin.linkURL else {
- phase = .failed(message: String(localized: "signIn.windowFailed"))
- return
- }
-
- pendingCode = pin.code
- showsManualCode = false
- webAuthenticator.present(url: url, callbackScheme: Branding.urlScheme)
-
- // Die Frist entspricht der Gültigkeit des Codes bei Plex.
- for second in 0..<900 {
- try? await Task.sleep(for: .seconds(1))
-
- // Nach einer Viertelminute den Code zeigen. Wer bis dahin nicht
- // durch ist, hat vermutlich den Fall erwischt, in dem die Seite
- // nicht verknüpft — dann hilft nur noch das Abtippen.
- if second == 15 { showsManualCode = true }
-
- if let token = try? await authenticator.token(for: pin) {
- webAuthenticator.dismiss()
- clearPendingCode()
- await connect(accountToken: token)
- return
- }
-
- // Fenster geschlossen, ohne dass ein Token da ist.
- if webAuthenticator.didReturn {
- switch webAuthenticator.outcome {
- case .cancelled, nil:
- // Abgebrochen oder zurückgesprungen, ohne dass der Token
- // schon bereitlag. Noch ein paar Sekunden nachfragen,
- // bevor aufgegeben wird — zwischen Rücksprung und Eintrag
- // bei Plex liegt manchmal eine Sekunde.
- if let token = try? await graceTokenLookup(pin: pin) {
- clearPendingCode()
- await connect(accountToken: token)
- return
- }
- // Fenster zu, kein Token: nicht abmelden, sondern den Code
- // stehen lassen. Er gilt weiter, und das Abtippen bei
- // plex.tv/link ist jetzt der schnellste Weg.
- showsManualCode = true
- case .presentationFailed(let description):
- phase = .failed(message: description)
- }
- return
- }
- }
-
- webAuthenticator.dismiss()
- clearPendingCode()
- phase = .failed(message: String(localized: "signIn.timedOut"))
- }
-
- private func clearPendingCode() {
- pendingCode = nil
- showsManualCode = false
- }
-
- /// Fragt nach dem Schließen des Fensters noch ein paar Sekunden nach.
- private func graceTokenLookup(pin: PlexPin) async throws -> String? {
- for _ in 0..<5 {
- if let token = try? await authenticator.token(for: pin) { return token }
- try? await Task.sleep(for: .milliseconds(700))
- }
- return nil
- }
-
- func cancelSignIn() {
- authorizationTask?.cancel()
- authorizationTask = nil
- webAuthenticator.dismiss()
- clearPendingCode()
+ if await restoreJellyfin() { return }
phase = .signedOut
}
- /// Meldet ab — von allen Servern, nicht nur vom gerade aktiven.
+ /// Räumt einmal weg, was die Plex-Zeit hinterlassen hat.
///
- /// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen". Nur den
- /// aktiven zu vergessen hieße, dass nach dem Abmelden beim nächsten Start
- /// wieder eine Mediathek dasteht — die des anderen Servers.
+ /// **Der einzige Weg, auf dem das je wieder passiert.** Vorher entfernte
+ /// `signOut()` die Plex-Token über den Authenticator; den gibt es nicht
+ /// mehr, und `plex.account` stünde sonst in keiner Zeile dieses Projekts.
+ /// Wer bis heute angemeldet war und nie auf „Abmelden" gedrückt hat,
+ /// behielte ein gültiges Token am fremden Konto — unsichtbar und
+ /// unlöschbar.
+ ///
+ /// Der Merker wird **vor** der Prüfung gesetzt: ein Aufräumen, das
+ /// scheitert, soll nicht bei jedem Start wiederkehren.
+ private func purgePlexLeftovers() {
+ guard !defaults.bool(forKey: Altlast.merker) else { return }
+ defaults.set(true, forKey: Altlast.merker)
+
+ // Nur anfassen, wo auch etwas liegt. Sonst verlöre jeder
+ // Jellyfin-Nutzer beim ersten Start seinen Mediatheken-Speicher für
+ // nichts.
+ let hattePlex = Altlast.tokenKonten.contains { tokenStore.token(for: $0) != nil }
+ || Altlast.schlüssel.contains { defaults.object(forKey: $0) != nil }
+ guard hattePlex else { return }
+
+ for konto in Altlast.tokenKonten { tokenStore.remove(for: konto) }
+ for schlüssel in Altlast.schlüssel { defaults.removeObject(forKey: schlüssel) }
+ // Titel und Sehfortschritt gehörten zu einem Konto, das es hier nicht
+ // mehr gibt. Gelesen würden sie ohnehin nie wieder.
+ Task { await LibraryCache.shared.clearAll() }
+ }
+
+ /// Meldet ab und räumt dabei alles ab, was zur Anmeldung gehörte.
+ ///
+ /// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen": nach
+ /// dem Abmelden soll beim nächsten Start keine Mediathek mehr dastehen.
func signOut() {
- authorizationTask?.cancel()
- authenticator.signOut()
- forgetRememberedConnection()
forgetJellyfin()
- defaults.removeObject(forKey: Key.serverKind)
clearActiveServer()
// Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Was
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
@@ -423,91 +221,6 @@ final class FlixySession {
phase = .signedOut
}
- // MARK: - Verbinden mit Plex
-
- private func connect(accountToken: String) async {
- phase = .connecting(serverName: "")
-
- let directory = PlexServerDirectory(identity: identity, token: accountToken)
- let servers: [PlexResource]
- do {
- servers = try await directory.servers()
- } catch PlexError.unauthorized {
- // Der Token wurde am Konto zurückgezogen. Ihn zu behalten würde
- // bei jedem Start denselben Fehler erzeugen.
- authenticator.signOut()
- phase = .signedOut
- return
- } catch {
- phase = .failed(message: ErrorText.describe(error))
- return
- }
-
- guard !servers.isEmpty else {
- phase = .failed(message: String(localized: "error.noServers"))
- return
- }
-
- // Beim zweiten Start soll niemand erneut wählen müssen.
- let remembered = defaults.string(forKey: Key.preferredServer)
- if let chosen = servers.first(where: { $0.clientIdentifier == remembered }) ?? servers.singleElement {
- await connect(to: chosen)
- } else {
- phase = .choosingServer(servers)
- }
- }
-
- func connect(to resource: PlexResource) async {
- phase = .connecting(serverName: resource.name)
-
- let selector = PlexConnectionSelector(identity: identity)
- guard let connection = await selector.selectConnection(for: resource) else {
- phase = .failed(message: String(localized: "error.serverUnreachable"))
- return
- }
-
- guard let server = PlexServer(
- resource: resource,
- connection: connection,
- identity: identity
- ) else {
- phase = .failed(message: String(localized: "error.serverUnreachable"))
- return
- }
-
- let plex = adopt(server: server)
- remember(resource: resource, connection: connection)
-
- do {
- libraries = try await loadLibraries(from: plex)
- phase = .ready
- } catch {
- phase = .failed(message: ErrorText.describe(error))
- }
- }
-
- /// Merkt sich, worüber es funktioniert hat.
- private func remember(resource: PlexResource, connection: PlexConnection) {
- defaults.set(ServerKind.plex.rawValue, forKey: Key.serverKind)
- defaults.set(resource.clientIdentifier, forKey: Key.preferredServer)
- defaults.set(connection.uri, forKey: Key.cachedConnectionURI)
- defaults.set(connection.local, forKey: Key.cachedConnectionIsLocal)
- isLocalConnection = connection.local
- defaults.set(resource.name, forKey: Key.cachedServerName)
- if let token = resource.accessToken {
- try? tokenStore.store(token, for: Self.plexTokenAccount)
- }
- }
-
- /// Vergisst die gemerkte Verbindung.
- private func forgetRememberedConnection() {
- for key in [Key.preferredServer, Key.cachedConnectionURI,
- Key.cachedConnectionIsLocal, Key.cachedServerName] {
- defaults.removeObject(forKey: key)
- }
- tokenStore.remove(for: Self.plexTokenAccount)
- }
-
// MARK: - Verbinden mit Jellyfin
/// Übernimmt eine fertige Jellyfin-Anmeldung.
@@ -540,7 +253,6 @@ final class FlixySession {
let client = JellyfinClient(baseURL: baseURL, identity: jellyfinIdentity, token: account.token)
let backend = JellyfinBackend(client: client, account: account, serverName: "Attrappe")
- kind = .jellyfin
jellyfinAccount = account
self.backend = backend
@@ -563,7 +275,6 @@ final class FlixySession {
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
clearActiveServer()
- kind = .jellyfin
jellyfinAccount = account
self.backend = backend
isLocalConnection = await backend.isInLocalNetwork()
@@ -573,7 +284,6 @@ final class FlixySession {
} catch {
self.backend = nil
jellyfinAccount = nil
- kind = nil
phase = .failed(message: ErrorText.describe(error))
return
}
@@ -609,7 +319,6 @@ final class FlixySession {
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
clearActiveServer()
- kind = .jellyfin
jellyfinAccount = account
self.backend = backend
// Auch hier fragen: dasselbe Konto kann heute zuhause und morgen im
@@ -633,7 +342,6 @@ final class FlixySession {
}
private func rememberJellyfin(account: JellyfinAccount, serverName: String, baseURL: URL) {
- defaults.set(ServerKind.jellyfin.rawValue, forKey: Key.serverKind)
defaults.set(baseURL.absoluteString, forKey: Key.jellyfinAddress)
defaults.set(account.userID, forKey: Key.jellyfinUserID)
defaults.set(account.userName, forKey: Key.jellyfinUserName)
@@ -652,22 +360,6 @@ final class FlixySession {
// MARK: - Der aktive Server
- /// Setzt Plex als aktiven Server und gibt die Hülle zurück.
- ///
- /// Zurückgegeben, damit der Aufrufer sie benutzen kann, ohne sich ein
- /// zweites Mal eine zu bauen — die trüge eine eigene Live-TV-Sitzung mit
- /// sich, und dann wären zwei im Umlauf, die sich nichts voneinander
- /// erzählen.
- @discardableResult
- private func adopt(server: PlexServer) -> PlexBackend {
- let hülle = PlexBackend(server: server)
- kind = .plex
- self.server = server
- backend = hülle
- jellyfinAccount = nil
- return hülle
- }
-
/// Holt die Mediathekenliste und merkt sie sich.
///
/// **Der Speicher ist hier nur ein Rettungsanker**, kein Beschleuniger:
@@ -691,26 +383,15 @@ final class FlixySession {
}
private func clearActiveServer() {
- server = nil
backend = nil
jellyfinAccount = nil
- kind = nil
libraries = []
}
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
func retry() async {
- switch storedKind {
- case .jellyfin:
- if await restoreJellyfin() { return }
- phase = .signedOut
- case .plex, nil:
- if let token = authenticator.storedToken {
- await connect(accountToken: token)
- } else {
- phase = .signedOut
- }
- }
+ if await restoreJellyfin() { return }
+ phase = .signedOut
}
/// Die Kennung für Jellyfin-Anmeldungen.
@@ -720,8 +401,3 @@ final class FlixySession {
/// nach dem Anmelden ein zweites Mal in der Geräteliste des Servers.
var jellyfinDeviceIdentity: JellyfinIdentity { jellyfinIdentity }
}
-
-extension Array {
- /// Das einzige Element — oder `nil`, wenn es keines oder mehrere gibt.
- var singleElement: Element? { count == 1 ? first : nil }
-}
diff --git a/Flixy/Session/HomeLayoutSettings.swift b/Flixy/Session/HomeLayoutSettings.swift
index 198225a..19aca00 100644
--- a/Flixy/Session/HomeLayoutSettings.swift
+++ b/Flixy/Session/HomeLayoutSettings.swift
@@ -1,7 +1,6 @@
import Foundation
import Observation
import MediaKit
-import PlexKit
/// Welche Reihen die Startseite zeigt und in welcher Reihenfolge.
///
diff --git a/Flixy/Session/PlexWebAuthenticator.swift b/Flixy/Session/PlexWebAuthenticator.swift
deleted file mode 100644
index 5ad5cfa..0000000
--- a/Flixy/Session/PlexWebAuthenticator.swift
+++ /dev/null
@@ -1,152 +0,0 @@
-#if os(tvOS)
-import Foundation
-
-/// **Auf dem Fernseher gibt es kein Anmeldefenster.**
-///
-/// `ASWebAuthenticationSession` steht im tvOS-SDK, aber fast alles daran ist
-/// als nicht verfügbar markiert: kein `presentationContextProvider`, kein
-/// `prefersEphemeralWebBrowserSession`, nicht einmal `cancel()`. Ein
-/// Fernseher hat auch keinen Browser, in dem sich jemand bei Plex anmelden
-/// würde — und Plex ist in der ersten Fassung ohnehin nicht dabei.
-///
-/// **Der Typ bleibt trotzdem stehen.** `FlixySession` hält ihn als Feld und
-/// ruft ihn an sechs Stellen; ihn herauszuschneiden hiesse, die
-/// Zustandsmaschine der Anmeldung für eine Plattform zu spalten, die diesen
-/// Zweig nie betritt. Dieselbe Bauform wie bei `OrientationLock`: derselbe
-/// Name, dieselben Aufrufe, und nichts passiert.
-@MainActor
-final class PlexWebAuthenticator {
- enum Failure: Error, Equatable {
- case cancelled
- case presentationFailed(String)
- }
-
- /// Immer „ging nicht": wer hier landet, soll nicht warten.
- private(set) var outcome: Failure?
- private(set) var didReturn = false
-
- func present(url: URL, callbackScheme: String) {
- outcome = .presentationFailed(String(localized: "signIn.windowFailed"))
- didReturn = true
- }
-
- func dismiss() {}
-}
-#else
-import AuthenticationServices
-import Foundation
-#if canImport(UIKit)
-import UIKit
-#else
-import AppKit
-#endif
-
-/// Öffnet die Anmeldeseite von Plex in einem Fenster innerhalb der App.
-///
-/// `ASWebAuthenticationSession` ist dafür der vorgesehene Weg: die Seite kommt
-/// von Plex, das Passwort wird dort eingegeben und erreicht die App nie. Nach
-/// dem Anmelden schickt Plex den Browser auf eine Adresse mit unserem eigenen
-/// Schema, das Fenster schließt sich von selbst, und die App weiß, dass sie
-/// den Token abholen kann.
-///
-/// Ein einfacher `SFSafariViewController` täte es nicht: er meldet nicht, wann
-/// die Rückadresse erreicht wurde, und müsste von Hand geschlossen werden.
-@MainActor
-final class PlexWebAuthenticator: NSObject {
- enum Failure: Error, Equatable {
- /// Der Nutzer hat das Fenster geschlossen. Kein Fehler im eigentlichen
- /// Sinn — die Oberfläche soll darauf kommentarlos zurückgehen.
- case cancelled
- case presentationFailed(String)
- }
-
- /// Das laufende Fenster — damit es sich auch von außen schließen lässt.
- ///
- /// Nötig, weil Plex den Rücksprung nicht immer ausführt: manchmal landet
- /// der Browser nach dem Anmelden auf watch.plex.tv und bleibt dort stehen.
- /// Der Token liegt dann längst bereit, nur das Fenster weiß es nicht.
- private var session: ASWebAuthenticationSession?
-
- /// Wie das Fenster ausgegangen ist.
- ///
- /// `nil`, solange es noch offen ist.
- private(set) var outcome: Failure?
- private(set) var didReturn = false
-
- /// Öffnet das Anmeldefenster und kehrt **sofort** zurück.
- ///
- /// Bewusst nicht abwartend: Plex führt den Rücksprung über `forwardUrl`
- /// nicht zuverlässig aus. Auf dem iPad landet der Browser nach dem
- /// Anmelden auf watch.plex.tv und bleibt stehen — der Token liegt längst
- /// bereit, aber ein abwartender Aufruf hinge auf einen Rückruf, der nie
- /// kommt. Der Aufrufer fragt deshalb parallel beim Server nach und sieht
- /// hier nur nach, ob das Fenster zwischendurch geschlossen wurde.
- func present(url: URL, callbackScheme: String) {
- outcome = nil
- didReturn = false
-
- let session = ASWebAuthenticationSession(
- url: url,
- callbackURLScheme: callbackScheme
- ) { [weak self] _, error in
- // ASWebAuthenticationSession ruft den Abschlussblock auf dem
- // Hauptthread auf — anders als mancher Rückruf, wo dieselbe Annahme die App
- // zum Absturz gebracht hat. Hier ist sie dokumentiert.
- MainActor.assumeIsolated {
- guard let self else { return }
- if let error = error as? ASWebAuthenticationSessionError,
- error.code == .canceledLogin {
- self.outcome = .cancelled
- } else if let error {
- self.outcome = .presentationFailed(error.localizedDescription)
- }
- self.didReturn = true
- self.session = nil
- }
- }
-
- session.presentationContextProvider = self
- // `false`, damit eine bestehende Plex-Anmeldung in Safari mitgenutzt
- // wird: wer dort schon angemeldet ist, braucht nur zu bestätigen.
- session.prefersEphemeralWebBrowserSession = false
-
- guard session.start() else {
- outcome = .presentationFailed(String(localized: "signIn.windowFailed"))
- didReturn = true
- return
- }
- self.session = session
- }
-}
-
-extension PlexWebAuthenticator {
- /// Schließt das Anmeldefenster.
- ///
- /// Wird gerufen, sobald der Token auf anderem Weg eingetroffen ist — sonst
- /// bliebe der Browser mit der Plex-Seite offen stehen, obwohl die App
- /// schon angemeldet ist.
- func dismiss() {
- session?.cancel()
- session = nil
- }
-}
-
-extension PlexWebAuthenticator: ASWebAuthenticationPresentationContextProviding {
- func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
- // Das Fenster der App. Gesucht statt gehalten, weil eine SwiftUI-App
- // keinen festen Verweis darauf hat — und ein frisch erzeugtes Fenster
- // erschiene hinter dem sichtbaren.
- #if canImport(UIKit)
- let scene = UIApplication.shared.connectedScenes
- .compactMap { $0 as? UIWindowScene }
- .first { $0.activationState == .foregroundActive }
-
- return scene?.keyWindow ?? ASPresentationAnchor()
- #else
- return NSApplication.shared.keyWindow
- ?? NSApplication.shared.windows.first
- ?? ASPresentationAnchor()
- #endif
- }
-}
-#endif
diff --git a/Flixy/Views/Downloads/DownloadButton.swift b/Flixy/Views/Downloads/DownloadButton.swift
index 7c35457..b4b991e 100644
--- a/Flixy/Views/Downloads/DownloadButton.swift
+++ b/Flixy/Views/Downloads/DownloadButton.swift
@@ -2,7 +2,6 @@ import SwiftUI
import FlixyDesign
import FlixyDownloads
import MediaKit
-import PlexKit
/// Der Download-Knopf auf einer Detailseite.
///
diff --git a/Flixy/Views/LoadState.swift b/Flixy/Views/LoadState.swift
index e8d1eac..b8788fe 100644
--- a/Flixy/Views/LoadState.swift
+++ b/Flixy/Views/LoadState.swift
@@ -1,6 +1,5 @@
import Foundation
import MediaKit
-import PlexKit
/// Der Ladezustand eines Bildschirms.
///
@@ -58,15 +57,12 @@ extension LoadState {
/// Bildschirm eine andere Erklärung.
enum ErrorText {
static func describe(_ error: any Error) -> String {
- // Die neutrale Form zuerst, weil die Ansichten nur noch sie sehen.
- // Plex' eigener Fehler kommt nur noch dort an, wo unmittelbar mit ihm
- // gesprochen wird — Player, Downloads, Live-TV.
+ // Die neutrale Form zuerst: die Ansichten sehen nur noch sie, und
+ // jeder Server übersetzt seine eigenen Fehler schon an der Grenze
+ // seines Moduls dorthin.
if let backendError = error as? MediaBackendError {
return describe(backendError)
}
- if let plexError = error as? PlexError {
- return describe(plexError.asMediaBackendError)
- }
return error.localizedDescription
}
diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift
index f9b0eeb..886bd04 100644
--- a/Flixy/Views/Navigation/RootView.swift
+++ b/Flixy/Views/Navigation/RootView.swift
@@ -3,7 +3,6 @@ import FlixyDesign
import MediaKit
import FlixyDownloads
import FlixyPlayback
-import PlexKit
/// Die Wurzelansicht.
///
@@ -88,20 +87,8 @@ struct RootView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.flixyCanvas)
- case .signedOut, .authenticating:
- SignInView(
- phase: session.phase,
- manualCode: session.showsManualCode ? session.pendingCode : nil,
- onSignIn: { Task { await session.beginSignIn() } },
- onCancel: { session.cancelSignIn() }
- )
-
- case .choosingServer(let servers):
- NavigationStack {
- ServerPickerView(servers: servers) { resource in
- Task { await session.connect(to: resource) }
- }
- }
+ case .signedOut:
+ SignInView()
case .connecting(let name):
ConnectingView(serverName: name)
@@ -247,10 +234,11 @@ struct RootView: View {
settings: settings,
languages: languages,
subtitles: subtitleAppearance,
- // **Nicht mehr über `server`.** Der ist ein `PlexServer` und bei
- // Jellyfin immer `nil` — damit galt dort jede Wiedergabe als
- // entfernt, auch zuhause am Kabel, und „Auto" hätte ein
- // 67-Mbit-Remux ohne Not heruntergerechnet.
+ // **Vom Server erfragt, nicht aus der Adresse geschlossen.** Wer
+ // über einen Reverse Proxy geht, erreicht seinen Server auch von
+ // der Couch aus unter einem öffentlichen Namen — ihn deshalb als
+ // entfernt zu führen hiesse, „Auto" würde ein 67-Mbit-Remux
+ // zuhause am Kabel ohne Not herunterrechnen.
isLocalConnection: session.isLocalConnection,
connectionDescription: session.connectionDescription,
unavailableMessage: session.playbackUnavailableMessage
diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift
index ba90926..ef819d8 100644
--- a/Flixy/Views/Player/PlayerModel.swift
+++ b/Flixy/Views/Player/PlayerModel.swift
@@ -4,7 +4,6 @@ import MediaKit
import Observation
import FlixyDownloads
import FlixyPlayback
-import PlexKit
/// Führt einen Abspielwunsch aus.
///
diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift
index 5ec78f3..ca0bf39 100644
--- a/Flixy/Views/Player/PlayerView.swift
+++ b/Flixy/Views/Player/PlayerView.swift
@@ -2,7 +2,6 @@ import SwiftUI
import FlixyDesign
import FlixyDownloads
import FlixyPlayback
-import PlexKit
/// Der Vollbildplayer.
struct PlayerView: View {
@@ -757,18 +756,6 @@ struct PlayerView: View {
Text("player.preparing")
.font(.flixyCaption)
.foregroundStyle(.white.opacity(0.6))
-
- // Über die Vermittlung von Plex ist die Leitung auf wenige Mbit
- // gedrosselt. Ein großer Film läuft darüber nicht an, und ohne
- // diesen Hinweis sucht man den Fehler in der App statt in der
- // Verbindung.
- if session.server?.connection.relay == true {
- Label("player.relayWarning", systemImage: "exclamationmark.triangle")
- .font(.flixyCaption)
- .foregroundStyle(.white.opacity(0.8))
- .multilineTextAlignment(.center)
- .padding(.top, FlixySpacing.snug)
- }
}
.padding(FlixySpacing.section)
}
diff --git a/Flixy/Views/Settings/DiagnosticsView.swift b/Flixy/Views/Settings/DiagnosticsView.swift
index b557977..8524347 100644
--- a/Flixy/Views/Settings/DiagnosticsView.swift
+++ b/Flixy/Views/Settings/DiagnosticsView.swift
@@ -1,6 +1,5 @@
import SwiftUI
import FlixyDesign
-import PlexKit
/// Was beim letzten Abspielversuch passiert ist.
///
@@ -11,23 +10,18 @@ struct DiagnosticsView: View {
@Environment(FlixySession.self) private var session
@Environment(\.flixyAccent) private var accent
@Environment(PlaybackDiagnostics.self) private var diagnostics
- @State private var liveTVStructure: String?
- @State private var isInspectingLiveTV = false
-
var body: some View {
Form {
Section("diagnostics.connection") {
- if let server = session.server {
- LabeledContent("settings.serverName", value: server.name)
- LabeledContent("settings.address", value: server.connection.uri)
- .font(.flixyCaption)
-
- if server.connection.relay {
- // Der häufigste Grund dafür, dass nichts anläuft. Ohne
- // Hinweis sucht man den Fehler in der App.
- Label("diagnostics.relayWarning", systemImage: "exclamationmark.triangle")
+ if session.backend != nil {
+ LabeledContent("settings.serverName", value: session.serverName)
+ if let address = session.serverAddress {
+ LabeledContent("settings.address", value: address)
+ .font(.flixyCaption)
+ }
+ if let beschreibung = session.connectionDescription {
+ LabeledContent("settings.connection", value: beschreibung)
.font(.flixyCaption)
- .foregroundStyle(Color.flixyDestructive)
}
} else {
Text("diagnostics.noServer").foregroundStyle(Color.flixyTextMuted)
@@ -73,36 +67,6 @@ struct DiagnosticsView: View {
}
}
- Section {
- Button {
- Task { await inspectLiveTV() }
- } label: {
- if isInspectingLiveTV {
- ProgressView().controlSize(.small)
- } else {
- Label("diagnostics.liveTV", systemImage: "antenna.radiowaves.left.and.right")
- }
- }
- .disabled(isInspectingLiveTV)
-
- if let liveTVStructure {
- Text(liveTVStructure)
- .font(.system(.caption2, design: .monospaced))
- .foregroundStyle(Color.flixyTextSecondary)
- .textSelection(.enabled)
-
- Button {
- FlixyClipboard.copy(liveTVStructure)
- } label: {
- Label("diagnostics.copy", systemImage: "doc.on.doc")
- }
- }
- } header: {
- Text("sidebar.liveTV")
- } footer: {
- Text("diagnostics.liveTVHint")
- }
-
Section("settings.app") {
LabeledContent("settings.version", value: Branding.version)
LabeledContent("settings.build", value: Branding.buildStamp)
@@ -122,14 +86,6 @@ struct DiagnosticsView: View {
.flixyInlineTitle()
}
- /// Fragt den Server, wie seine Antwort auf `/livetv/dvrs` aufgebaut ist.
- private func inspectLiveTV() async {
- guard let server = session.server else { return }
- isInspectingLiveTV = true
- defer { isInspectingLiveTV = false }
- liveTVStructure = await server.liveTVStructure()
- }
-
/// Der Bericht als Text zum Weitergeben.
///
/// Enthält bewusst keinen Token: die Quelladresse ist schon ohne
diff --git a/Flixy/Views/Settings/ServerSettingsView.swift b/Flixy/Views/Settings/ServerSettingsView.swift
index ed02a0f..c39ff40 100644
--- a/Flixy/Views/Settings/ServerSettingsView.swift
+++ b/Flixy/Views/Settings/ServerSettingsView.swift
@@ -1,7 +1,6 @@
import SwiftUI
import FlixyDesign
import MediaKit
-import PlexKit
/// Mit welchem Server gesprochen wird — und was er gerade anbietet.
struct ServerSettingsView: View {
@@ -12,12 +11,11 @@ struct ServerSettingsView: View {
Form {
Section("settings.server") {
LabeledContent("settings.serverName", value: session.serverName)
- // Welche Art von Server — bei zwei möglichen ist das keine
- // Nebensache mehr, sondern die erste Frage bei jeder
- // Rückmeldung.
- LabeledContent("settings.serverKind", value: kindLabel)
- if let connection = session.server?.connection {
- LabeledContent("settings.connection", value: connectionLabel(connection))
+ // Nah oder fern: daran hängt, ob „Auto" gedeckelt anfängt,
+ // und damit die erste Frage bei jeder Rückmeldung über
+ // Ruckeln.
+ if session.backend != nil {
+ LabeledContent("settings.connection", value: connectionLabel)
}
if let address = session.serverAddress {
LabeledContent("settings.address", value: address)
@@ -53,20 +51,12 @@ struct ServerSettingsView: View {
.flixyInlineTitle()
}
- private var kindLabel: String {
- switch session.kind {
- case .plex: "Plex"
- case .jellyfin: "Jellyfin"
- case nil: "—"
- }
- }
-
- /// Zeigt, über welchen Weg die Verbindung läuft — beim Suchen nach
- /// Ruckeln ist das die erste Frage.
- private func connectionLabel(_ connection: PlexConnection) -> String {
- if connection.local { return String(localized: "settings.connectionLocal") }
- if connection.relay { return String(localized: "settings.connectionRelay") }
- return String(localized: "settings.connectionRemote")
+ /// Zeigt, ob der Server im selben Netz steht — beim Suchen nach Ruckeln
+ /// ist das die erste Frage.
+ private var connectionLabel: String {
+ session.isLocalConnection
+ ? String(localized: "settings.connectionLocal")
+ : String(localized: "settings.connectionRemote")
}
}
diff --git a/Flixy/Views/SignIn/ConnectionStateViews.swift b/Flixy/Views/SignIn/ConnectionStateViews.swift
index f012d4c..6f9ec8e 100644
--- a/Flixy/Views/SignIn/ConnectionStateViews.swift
+++ b/Flixy/Views/SignIn/ConnectionStateViews.swift
@@ -1,6 +1,5 @@
import SwiftUI
import FlixyDesign
-import PlexKit
/// Während ein Weg zum Server gesucht wird.
struct ConnectingView: View {
@@ -20,58 +19,6 @@ struct ConnectingView: View {
}
}
-/// Wenn am Konto mehrere Server hängen.
-struct ServerPickerView: View {
- let servers: [PlexResource]
- let onSelect: (PlexResource) -> Void
-
- @Environment(\.flixyAccent) private var accent
-
- var body: some View {
- List(servers) { server in
- Button {
- onSelect(server)
- } label: {
- HStack(spacing: FlixySpacing.regular) {
- Image(systemName: server.owned ? "server.rack" : "person.2")
- .foregroundStyle(accent)
- .frame(width: 28)
-
- VStack(alignment: .leading, spacing: 2) {
- Text(server.name)
- .font(.flixyCardTitle)
- .foregroundStyle(Color.flixyTextPrimary)
- Text(subtitle(for: server))
- .font(.flixyCaption)
- .foregroundStyle(Color.flixyTextMuted)
- }
-
- Spacer()
- Image(systemName: "chevron.right")
- .font(.flixyCaption)
- .foregroundStyle(Color.flixyTextMuted)
- }
- .contentShape(.rect)
- }
- .buttonStyle(.plain)
- .listRowBackground(Color.flixySurface)
- }
- .scrollContentBackground(.hidden)
- .background(Color.flixyCanvas)
- .navigationTitle(Text("serverPicker.title"))
- }
-
- private func subtitle(for server: PlexResource) -> String {
- var pieces: [String] = []
- pieces.append(server.owned
- ? String(localized: "serverPicker.owned")
- : String(localized: "serverPicker.shared"))
- if let platform = server.platform { pieces.append(platform) }
- if let version = server.productVersion { pieces.append(version) }
- return pieces.joined(separator: " · ")
- }
-}
-
/// Wenn etwas schiefgegangen ist.
struct ConnectionFailureView: View {
let message: String
diff --git a/Flixy/Views/SignIn/SignInView.swift b/Flixy/Views/SignIn/SignInView.swift
index e48b3b8..f268436 100644
--- a/Flixy/Views/SignIn/SignInView.swift
+++ b/Flixy/Views/SignIn/SignInView.swift
@@ -3,30 +3,18 @@ import FlixyDesign
/// Der Anmeldebildschirm.
///
-/// Zwei Wege, weil es zwei Serverarten gibt. **Plex** öffnet seine eigene
-/// Anmeldemaske in einem Fenster innerhalb der App; das Passwort wird dort
-/// eingegeben und erreicht Flixy nie. **Jellyfin** hat kein Verzeichnis wie
-/// plex.tv — dort wird die Adresse des eigenen Servers eingetippt und, wann
-/// immer der Server es anbietet, ein Code bestätigt statt eines Passworts.
+/// **Ein Weg, seit es einen Server gibt.** Hier standen zwei Knöpfe
+/// nebeneinander, und der obere war der auffälligere — er führte zu Plex.
+/// Bleibt einer übrig, erbt er die Hervorhebung: ein Knopf, der aussieht wie
+/// die zweite Wahl, lässt den Benutzer nach der ersten suchen.
///
-/// Die beiden Wege sind absichtlich nicht zu einem zusammengefasst. Sie haben
-/// nichts gemeinsam außer dem Ziel, und der Versuch, das zu verstecken, würde
-/// beide verschlechtern.
+/// Jellyfin hat kein Verzeichnis, an dem die eigenen Server hängen. Deshalb
+/// wird die Adresse eingetippt und, wann immer der Server es anbietet, ein
+/// Code bestätigt statt eines Passworts — das führt `JellyfinSignInView`.
struct SignInView: View {
- let phase: FlixySession.Phase
- /// Der Code der laufenden Plex-Anmeldung, sobald er gezeigt werden soll.
- let manualCode: String?
- let onSignIn: () -> Void
- let onCancel: () -> Void
-
@Environment(\.flixyAccent) private var accent
@State private var showsJellyfinSignIn = false
- private var isAuthenticating: Bool {
- if case .authenticating = phase { return true }
- return false
- }
-
var body: some View {
VStack(spacing: FlixySpacing.section) {
Spacer()
@@ -49,21 +37,6 @@ struct SignInView: View {
Spacer()
VStack(spacing: FlixySpacing.regular) {
- Button(action: onSignIn) {
- Group {
- if isAuthenticating {
- ProgressView().tint(Color.flixyCanvas)
- } else {
- Text("signIn.withPlex").font(.flixyRowHeader)
- }
- }
- .frame(maxWidth: .infinity)
- .padding(.vertical, FlixySpacing.regular)
- }
- .buttonStyle(.borderedProminent)
- .tint(accent)
- .disabled(isAuthenticating)
-
Button {
showsJellyfinSignIn = true
} label: {
@@ -72,18 +45,13 @@ struct SignInView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, FlixySpacing.regular)
}
- .buttonStyle(.bordered)
- .tint(Color.flixyTextSecondary)
- .disabled(isAuthenticating)
+ .buttonStyle(.borderedProminent)
+ .tint(accent)
- if let manualCode {
- manualFallback(code: manualCode)
- } else {
- Text("signIn.privacy")
- .font(.flixyCaption)
- .foregroundStyle(Color.flixyTextMuted)
- .multilineTextAlignment(.center)
- }
+ Text("signIn.privacy")
+ .font(.flixyCaption)
+ .foregroundStyle(Color.flixyTextMuted)
+ .multilineTextAlignment(.center)
}
Spacer().frame(height: FlixySpacing.section)
@@ -96,35 +64,4 @@ struct SignInView: View {
JellyfinSignInView()
}
}
-
- /// Der Ausweg, wenn die Browser-Seite den Code nicht verknüpft.
- ///
- /// Das passiert, wenn man dort schon bei Plex angemeldet ist: die Seite
- /// springt weiter, ohne zu verknüpfen, und die App wartete vergeblich.
- /// Der Code gilt weiter — abtippen führt immer zum Ziel.
- @ViewBuilder
- private func manualFallback(code: String) -> some View {
- VStack(spacing: FlixySpacing.regular) {
- Divider().overlay(Color.flixySeparator)
-
- Text("signIn.manualHint")
- .font(.flixyCaption)
- .foregroundStyle(Color.flixyTextSecondary)
- .multilineTextAlignment(.center)
-
- Text(code)
- .font(.system(.title, design: .monospaced, weight: .semibold))
- .tracking(6)
- .foregroundStyle(Color.flixyTextPrimary)
- .textSelection(.enabled)
- .padding(.vertical, FlixySpacing.snug)
- .frame(maxWidth: .infinity)
- .background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control))
-
- Button("action.cancel", action: onCancel)
- .font(.flixyCaption)
- .foregroundStyle(Color.flixyTextSecondary)
- }
- .transition(.opacity)
- }
}
diff --git a/FlixyTV/FlixyTVApp.swift b/FlixyTV/FlixyTVApp.swift
index 24396f4..f3ae2e1 100644
--- a/FlixyTV/FlixyTVApp.swift
+++ b/FlixyTV/FlixyTVApp.swift
@@ -3,7 +3,6 @@ import FlixyDesign
import FlixyPlayback
import MediaKit
import JellyfinKit
-import PlexKit
/// Die App auf dem Fernseher.
///
@@ -42,10 +41,6 @@ struct FlixyTVApp: App {
init() {
_session = State(initialValue: FlixySession(
- identity: PlexClientIdentity.current(
- product: Branding.plexProduct,
- version: Branding.version
- ),
jellyfinIdentity: JellyfinIdentity.current(
client: Branding.appName,
version: Branding.version
diff --git a/FlixyTV/Views/TVRootView.swift b/FlixyTV/Views/TVRootView.swift
index f780606..3890732 100644
--- a/FlixyTV/Views/TVRootView.swift
+++ b/FlixyTV/Views/TVRootView.swift
@@ -25,11 +25,7 @@ struct TVRootView: View {
case .launching:
ProgressView()
- case .signedOut, .authenticating, .choosingServer:
- // Plex bietet die erste Fassung nicht an: die Anmeldung dort
- // läuft über ein Browserfenster, und das gibt es auf tvOS
- // nicht. Alle drei Zustände führen deshalb zur
- // Jellyfin-Anmeldung.
+ case .signedOut:
TVSignInView()
case .connecting(let name):
diff --git a/Packages/FlixyKit/Package.swift b/Packages/FlixyKit/Package.swift
index 20171f2..fad3b07 100644
--- a/Packages/FlixyKit/Package.swift
+++ b/Packages/FlixyKit/Package.swift
@@ -13,7 +13,6 @@ let package = Package(
platforms: [.iOS(.v26), .macOS(.v26), .tvOS(.v26)],
products: [
.library(name: "MediaKit", targets: ["MediaKit"]),
- .library(name: "PlexKit", targets: ["PlexKit"]),
.library(name: "JellyfinKit", targets: ["JellyfinKit"]),
.library(name: "FlixyDesign", targets: ["FlixyDesign"]),
.library(name: "FlixyPlayback", targets: ["FlixyPlayback"]),
@@ -24,16 +23,15 @@ let package = Package(
// damit die gesamte Protokoll-Schicht ohne Oberfläche testbar bleibt.
// Die neutrale Grundschicht: Typen und Rechnungen, die für jeden
// Server gelten. Hängt von nichts ab — genau das macht sie zur
- // Grundlage, auf der Plex, Jellyfin und später das Web-Projekt
+ // Grundlage, auf der jeder Server und später das Web-Projekt
// dasselbe meinen.
.target(name: "MediaKit"),
.testTarget(name: "MediaKitTests", dependencies: ["MediaKit"]),
- .target(name: "PlexKit", dependencies: ["MediaKit"]),
- // Jellyfin. Eigenes Target neben PlexKit, nicht darin: die beiden
- // teilen die neutrale Schicht und sonst nichts. Wer später Plex
- // herausnimmt, löscht ein Verzeichnis.
+ // Jellyfin. Eigenes Target auf der neutralen Schicht — ein Server,
+ // eine Schachtel. Kommt ein zweiter dazu, steht er daneben und
+ // nicht darin; herausnehmen heisst dann, ein Verzeichnis zu löschen.
.target(name: "JellyfinKit", dependencies: ["MediaKit"], resources: [.process("Localizable.xcstrings")]),
.testTarget(
name: "JellyfinKitTests",
@@ -44,15 +42,7 @@ let package = Package(
// Ein Werkzeug für die Fragen, die reines HTTP sind — ohne Oberfläche,
// ohne Gerät, ohne Upload. Eine Runde dauert damit Sekunden statt
// eines halben Tages, und das Upload-Kontingent bleibt für Builds.
- .executableTarget(name: "FlixyProbe", dependencies: ["MediaKit", "PlexKit", "JellyfinKit"]),
- .testTarget(
- name: "PlexKitTests",
- dependencies: ["PlexKit"],
- // Echte Server-Antworten als Dateien. `copy` statt `process`, weil
- // sie unverändert im Bundle liegen sollen — sonst deutet Xcode die
- // JSON-Dateien um.
- resources: [.copy("Fixtures")]
- ),
+ .executableTarget(name: "FlixyProbe", dependencies: ["MediaKit", "JellyfinKit"]),
// Farben, Typografie, wiederkehrende Bausteine. Hängt bewusst von
// nichts ab: dadurch bleibt das Umstellen der Akzentfarbe ein
diff --git a/Packages/FlixyKit/Sources/FlixyProbe/main.swift b/Packages/FlixyKit/Sources/FlixyProbe/main.swift
index 6585cd8..cb53ef7 100644
--- a/Packages/FlixyKit/Sources/FlixyProbe/main.swift
+++ b/Packages/FlixyKit/Sources/FlixyProbe/main.swift
@@ -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 Eine HLS-Leiter prüfen: Varianten, Tonspuren,
+ erster Schnipsel
+ --jellyfin Die Leiter holen, die Jellyfin für einen Titel
+ baut, und begründen, warum sie so aussieht
+ --spuren 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
diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift
index 753b75e..8887414 100644
--- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift
+++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift
@@ -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
diff --git a/Packages/FlixyKit/Sources/MediaKit/Duration+Units.swift b/Packages/FlixyKit/Sources/MediaKit/Duration+Units.swift
index 2f1eec4..2060699 100644
--- a/Packages/FlixyKit/Sources/MediaKit/Duration+Units.swift
+++ b/Packages/FlixyKit/Sources/MediaKit/Duration+Units.swift
@@ -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)
}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Auth/PlexAuthenticator.swift b/Packages/FlixyKit/Sources/PlexKit/Auth/PlexAuthenticator.swift
deleted file mode 100644
index f5eef79..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Auth/PlexAuthenticator.swift
+++ /dev/null
@@ -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
-
- public init(
- identity: PlexClientIdentity,
- tokenStore: any TokenStoring,
- transport: any PlexTransport = URLSession.shared,
- clock: any Clock = 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.. 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)
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Client/PlexClientIdentity.swift b/Packages/FlixyKit/Sources/PlexKit/Client/PlexClientIdentity.swift
deleted file mode 100644
index c9adadc..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Client/PlexClientIdentity.swift
+++ /dev/null
@@ -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
- )
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Client/PlexError.swift b/Packages/FlixyKit/Sources/PlexKit/Client/PlexError.swift
deleted file mode 100644
index af6b447..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Client/PlexError.swift
+++ /dev/null
@@ -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
- }
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Client/PlexHTTPClient.swift b/Packages/FlixyKit/Sources/PlexKit/Client/PlexHTTPClient.swift
deleted file mode 100644
index 72b2df5..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Client/PlexHTTPClient.swift
+++ /dev/null
@@ -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(
- _ 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
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Client/PlexResponse.swift b/Packages/FlixyKit/Sources/PlexKit/Client/PlexResponse.swift
deleted file mode 100644
index da04e16..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Client/PlexResponse.swift
+++ /dev/null
@@ -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(
- _ 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))
- }
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Client/PlexTransport.swift b/Packages/FlixyKit/Sources/PlexKit/Client/PlexTransport.swift
deleted file mode 100644
index d86ba83..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Client/PlexTransport.swift
+++ /dev/null
@@ -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
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexConnectionSelector.swift b/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexConnectionSelector.swift
deleted file mode 100644
index acc73f0..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexConnectionSelector.swift
+++ /dev/null
@@ -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(
- _ 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)
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexResource.swift b/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexResource.swift
deleted file mode 100644
index c00645d..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexResource.swift
+++ /dev/null
@@ -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
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexServerDirectory.swift b/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexServerDirectory.swift
deleted file mode 100644
index cd877bf..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexServerDirectory.swift
+++ /dev/null
@@ -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)
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/LibraryQuery.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/LibraryQuery.swift
deleted file mode 100644
index 8a14817..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/LibraryQuery.swift
+++ /dev/null
@@ -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: 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 }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift
deleted file mode 100644
index e15980a..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift
+++ /dev/null
@@ -1,1704 +0,0 @@
-import Foundation
-import MediaKit
-
-/// Was beim Live-TV herauskommen kann.
-public enum PlexLiveTVAvailability: Sendable, Equatable {
- /// Es gibt einen Empfänger und Sender.
- case available(dvrs: [PlexDVR])
- /// Der Server läuft, hat aber keinen Empfänger eingerichtet.
- ///
- /// Der mit Abstand häufigste Fall — Live-TV verlangt eine Fernsehkarte
- /// oder einen Netzwerk-Tuner. Die Oberfläche soll das erklären, statt
- /// einen Fehler zu zeigen.
- case notConfigured
- case failed(message: String)
-}
-
-extension PlexServer {
- /// Prüft, ob der Server überhaupt Live-TV kann.
- public func liveTVAvailability() async -> PlexLiveTVAvailability {
- do {
- let container = try await client.send(
- PlexRequest(path: "/livetv/dvrs", timeout: .seconds(10)),
- as: PlexMediaContainer.self
- )
- return container.content.isEmpty ? .notConfigured : .available(dvrs: container.content)
- } catch PlexError.notFound {
- // Ältere Serverfassungen kennen den Endpunkt gar nicht. Das ist
- // dasselbe Ergebnis wie „kein Empfänger": es gibt nichts zu sehen.
- return .notConfigured
- } catch {
- return .failed(message: String(describing: error))
- }
- }
-
- /// Die Sender eines Empfängers — gefunden, nicht geraten.
- ///
- /// Die Live-TV-Schnittstelle von Plex hat keine Beschreibung, und die
- /// Adresse der Senderliste unterscheidet sich zwischen Serverfassungen,
- /// Tunertypen und Programmanbietern. Drei Versuche, sie zu erraten,
- /// endeten auf einem echten Server alle mit 404.
- ///
- /// Deshalb wird sie jetzt gesucht statt geraten, in einem Durchgang und
- /// gleichzeitig:
- ///
- /// 1. Der Server sagt unter `/media/providers` selbst, welche Endpunkte
- /// er anbietet.
- /// 2. Der Programmanbieter und der Empfänger führen Unterverzeichnisse,
- /// deren Adressen in ihrer eigenen Antwort stehen.
- /// 3. Dazu die bekannten Formen, die andere Server verwenden.
- /// 4. Und zuletzt das Programmraster, in dem jeder Eintrag seinen Sender
- /// mitträgt.
- ///
- /// Was dabei herauskam, steht in `trace` — jede Adresse mit ihrer
- /// Antwort. Schlägt alles fehl, ist damit ohne Rückfrage zu sehen, was
- /// der Server tatsächlich geantwortet hat.
- public func channels(inDVR dvr: PlexDVR) async -> ChannelLookup {
- var identifier = dvr.epgIdentifier
- if identifier == nil { identifier = await electronicProgramGuideIdentifier() }
- guard let epgIdentifier = identifier else {
- return ChannelLookup(channels: [], source: .noLineup, trace: [])
- }
-
- let paths = await candidatePaths(dvr: dvr, epgIdentifier: epgIdentifier)
- var found: [Int: [PlexChannel]] = [:]
- var trace: [Int: ChannelProbe] = [:]
-
- // Gleichzeitig, nicht nacheinander: ein Dutzend Adressen einzeln
- // abzuklopfen dauert bei einem Server, der schweigt statt abzulehnen,
- // eine Minute. Der Actor gibt an jedem `await` frei, deshalb laufen
- // die Abfragen wirklich nebeneinander.
- await withTaskGroup(of: (Int, [PlexChannel], ChannelProbe).self) { group in
- for (index, path) in paths.enumerated() {
- group.addTask {
- let outcome = await self.probe(path: path)
- return (index, outcome.channels, outcome.probe)
- }
- }
- for await (index, channels, probe) in group {
- found[index] = channels
- trace[index] = probe
- }
- }
-
- let ordered = paths.indices.compactMap { trace[$0] }
- let enabled = dvr.enabledChannelKeys
-
- // Die Reihenfolge der Kandidaten ist die Rangfolge: was der Server
- // selbst angibt, zählt mehr als eine bekannte Form.
- for index in paths.indices {
- guard let channels = found[index], !channels.isEmpty else { continue }
-
- // Ohne Zuordnung alles zeigen, und auch dann, wenn keiner passt:
- // eine vollständige Liste ist besser als eine leere Ansicht, und
- // die Schlüssel der Zuordnung weichen je nach Anbieter ab.
- let matching = enabled.isEmpty ? channels : channels.filter { $0.matches(anyOf: enabled) }
- let result = matching.isEmpty ? channels : matching
-
- return ChannelLookup(
- channels: result,
- source: .lineup(available: channels.count, path: paths[index]),
- trace: ordered
- )
- }
-
- return ChannelLookup(channels: [], source: .failed("keine der \(paths.count) Adressen lieferte Sender"), trace: ordered)
- }
-
- /// Eine Adresse abklopfen: was steht dort, und sind es Sender?
- private func probe(path: String) async -> (channels: [PlexChannel], probe: ChannelProbe) {
- let data: Data
- do {
- data = try await client.send(PlexRequest(path: path, timeout: .seconds(12)))
- } catch {
- return ([], ChannelProbe(path: path, result: Self.shortDescription(of: error)))
- }
-
- let channels = (try? PlexResponse.decode(PlexMediaContainer.self, from: data))?.content ?? []
- // Namenlose zählen nicht: eine Liste aus dreißig Strichen sieht aus
- // wie eine Senderliste, ist aber keine — dieser Fehler ist hier schon
- // einmal passiert.
- let named = channels.filter(\.hasName)
-
- return (
- named,
- ChannelProbe(
- path: path,
- result: named.isEmpty ? Self.containerSummary(data) : "\(named.count) Sender"
- )
- )
- }
-
- /// Alle Adressen, an denen die Senderliste stehen könnte — erfragt und bekannt.
- private func candidatePaths(dvr: PlexDVR, epgIdentifier: String) async -> [String] {
- var paths: [String] = []
-
- // 1. Was der Server selbst angibt.
- for feature in await providerFeatures(for: epgIdentifier)
- where ["channels", "lineups", "livetv", "grid", "tuner"].contains(feature.type) {
- if let key = feature.key { paths.append(key) }
- }
-
- // 2. Was Anbieter und Empfänger an Unterverzeichnissen führen.
- for root in ["/\(epgIdentifier)", "/livetv/dvrs/\(dvr.key)"] {
- for entry in await directories(at: root) {
- guard let key = entry.key else { continue }
- let haystack = (key + " " + (entry.title ?? "")).lowercased()
- guard ["channel", "sender", "lineup", "grid", "guide"].contains(where: haystack.contains)
- else { continue }
- paths.append(key.hasPrefix("/") ? key : "\(root)/\(key)")
- }
- }
-
- // 3. Die bekannten Formen.
- paths.append(contentsOf: Self.knownPaths(dvr: dvr, epgIdentifier: epgIdentifier))
-
- var seen = Set()
- return paths.filter { seen.insert($0).inserted }
- }
-
- /// Die Adressen, die andere Serverfassungen verwenden.
- ///
- /// Entscheidend ist `pathIdentifier`: die Kennung der Senderliste kommt
- /// als vollständige Adresse mit Schema und Anhang. Roh eingesetzt ergibt
- /// jeder dieser Pfade 404 — das war der erste Fehlschlag von vieren.
- static func knownPaths(dvr: PlexDVR, epgIdentifier: String) -> [String] {
- var paths = ["/livetv/dvrs/\(dvr.key)/channels"]
-
- if let lineup = dvr.lineups.first {
- paths.append("/\(epgIdentifier)/lineups/\(lineup.pathIdentifier)/channels")
- paths.append("/livetv/dvrs/\(dvr.key)/lineups/\(lineup.pathIdentifier)/channels")
- }
- paths.append("/\(epgIdentifier)/channels")
- paths.append("/\(epgIdentifier)/lineups")
-
- // Der Empfänger selbst führt bei Netzwerk-Tunern seine eigene
- // Senderliste — die vom Gerät gefundenen Sender, mit Namen.
- for device in dvr.devices {
- for key in [device.key, device.deviceIdentifier].compactMap({ $0 }) {
- let base = key.hasPrefix("/") ? key : "/media/grabbers/devices/\(key)"
- paths.append("\(base)/channels")
- paths.append("/livetv/dvrs/\(dvr.key)/devices/\(key)/channels")
- }
- }
-
- var seen = Set()
- return paths.filter { seen.insert($0).inserted }
- }
-
- /// Die Unterverzeichnisse an einer Adresse.
- private func directories(at path: String) async -> [PlexNavigationEntry] {
- let container = try? await client.send(
- PlexRequest(path: path, timeout: .seconds(12)),
- as: PlexMediaContainer.self
- )
- return container?.content ?? []
- }
-
- /// Welche Sammlungen eine Antwort enthält, mit Anzahl.
- ///
- /// Beantwortet die Frage, die nach einem Fehlschlag als Nächstes kommt:
- /// war dort etwas, und unter welchem Namen?
- static func containerSummary(_ data: Data) -> String {
- guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
- let container = object["MediaContainer"] as? [String: Any]
- else { return "keine Plex-Antwort (\(data.count) Bytes)" }
-
- let collections = container
- .compactMap { key, value -> String? in
- guard let array = value as? [Any] else { return nil }
- return "\(key): \(array.count)"
- }
- .sorted()
-
- return collections.isEmpty ? "leer" : collections.joined(separator: ", ")
- }
-
- static func shortDescription(of error: any Error) -> String {
- if let plex = error as? PlexError { return String(describing: plex) }
- return (error as? URLError).map { "URLError \($0.errorCode)" } ?? "Fehler"
- }
-
- /// Was der Programmanbieter an Fähigkeiten angibt.
- public func providerFeatures(for identifier: String) async -> [PlexProviderFeature] {
- guard let container = try? await client.send(
- PlexRequest(path: "/media/providers"),
- as: PlexMediaContainer.self
- ) else { return [] }
-
- return container.content.first { $0.identifier == identifier }?.features ?? []
- }
-
- /// Die Kennung des Programmanbieters.
- ///
- /// Sie wechselt je nach Server und Einrichtung (etwa
- /// `tv.plex.providers.epg.cloud:2`) und steckt in der Anbieterliste. Fest
- /// verdrahtet wäre sie auf der Hälfte der Server falsch.
- public func electronicProgramGuideIdentifier() async -> String? {
- guard let container = try? await client.send(
- PlexRequest(path: "/media/providers"),
- as: PlexMediaContainer.self
- ) else { return nil }
-
- return container.content.first { $0.identifier.contains("epg") }?.identifier
- }
-
- /// Das Programmraster für einen Zeitraum, mit Sendern.
- ///
- /// Bewusst über einen Zeitraum statt „alles": ein Tag über hundert Sender
- /// sind mehrere tausend Sendungen, und mehr als die nächsten Stunden
- /// braucht niemand beim Umschalten.
- ///
- /// Zurück kommt, was den Zeitraum **berührt** — nicht, was vollständig
- /// hineinpasst. Das ist der Unterschied, an dem das Programm vorher falsch
- /// war: gefiltert wurde auf `endsAt <` Fensterende und `beginsAt >`
- /// Fensteranfang, und damit fiel ausgerechnet die laufende Sendung heraus.
- /// Ein Zweistünder, der um halb vier begann, war um Viertel nach fünf
- /// unsichtbar, und angezeigt wurde stattdessen die nächste, die durch den
- /// Filter passte — sichtbar falsch, und anders als Plex' eigene Anzeige.
- public func guide(
- providerIdentifier: String,
- from start: Date,
- to end: Date
- ) async throws(PlexError) -> [PlexGuideEntry] {
- // Nur der **Anfang** wird eingegrenzt, und mit Vorlauf. Eine Grenze
- // auf das Ende schließt jede Sendung aus, die über das Fenster
- // hinausläuft — also gerade die längsten.
- let lead = start.addingTimeInterval(-Self.longestProgramme)
-
- let container = try await client.send(
- PlexRequest(
- path: "/\(providerIdentifier)/grid",
- query: [
- // Ohne Typfilter. `type=1` steht bei Plex für Filme, und
- // im Programm laufen auch Folgen, Sport und Nachrichten —
- // der Filter hat das Raster leer zurückgegeben.
- URLQueryItem(name: "beginsAt>", value: String(Int(lead.timeIntervalSince1970))),
- URLQueryItem(name: "beginsAt<", value: String(Int(end.timeIntervalSince1970))),
- ],
- timeout: .seconds(30)
- ),
- as: PlexMediaContainer.self
- )
-
- return container.content.compactMap { entry in
- // Sender und Sendung stammen aus demselben Eintrag — deshalb
- // passen die Kennungen zueinander. Getrennt geholt taten sie das
- // nicht, und das Programm landete in keiner Spalte.
- let channel = entry.channel
- guard let program = entry.program(channelID: channel?.id) else { return nil }
- return PlexGuideEntry(channel: channel, program: program)
- }
- }
-
- /// Wie weit vor dem Fenster gesucht wird.
- ///
- /// Sechs Stunden decken alles ab, was im Fernsehen am Stück läuft — lange
- /// Spielfilme, Sportübertragungen, Wahlabende.
- static var longestProgramme: TimeInterval { 6 * 3_600 }
-
- /// Senderliste und Programm in einem Zug.
- ///
- /// Der Weg über die Senderliste des Anbieters ist der erste Versuch; wo
- /// er greift, sind Logos und Nummern vollständiger. Wo er nicht greift —
- /// und bei einem Server mit Swisscom-Liste tut er das nicht, alle
- /// bekannten Adressen antworten mit 404 — tragen die Rastereinträge die
- /// Sender selbst. Die Weboberfläche von Plex zeichnet ihren Guide genau
- /// daraus.
- public func liveTVGuide(
- dvr: PlexDVR,
- hours: Int = 6
- ) async -> LiveTVGuide {
- let lookup = await channels(inDVR: dvr)
-
- var identifier = dvr.epgIdentifier
- if identifier == nil { identifier = await electronicProgramGuideIdentifier() }
- guard let epgIdentifier = identifier else {
- return LiveTVGuide(
- channels: lookup.channels,
- programs: [:],
- source: lookup.description,
- trace: lookup.trace
- )
- }
-
- let now = Date()
- let entries = (try? await guide(
- providerIdentifier: epgIdentifier,
- from: now.addingTimeInterval(-3_600),
- to: now.addingTimeInterval(TimeInterval(hours) * 3_600)
- )) ?? []
-
- // Der Vorlauf der Abfrage holt auch Abgelaufenes mit — das gehört
- // nicht ins Programm. **Nur** die Sendungen werden so gefiltert, nicht
- // die Sender: ein Sender ohne frische Programmdaten soll trotzdem in
- // der Liste stehen, sonst verschwindet er, statt „kein Programm" zu
- // sagen.
- let fresh = entries.filter { $0.program.endsAt > now }
-
- let programs = Dictionary(grouping: fresh.compactMap { entry in
- entry.program.channelID.map { (key: $0, program: entry.program) }
- }, by: \.key).mapValues { pairs in
- pairs.map(\.program).sorted { $0.startsAt < $1.startsAt }
- }
-
- // Aus dem Raster: jeden Sender einmal, nach Sendernummer sortiert.
- var seen = Set()
- let fromGrid = entries
- .compactMap(\.channel)
- .filter { seen.insert($0.id).inserted }
- .sorted { ($0.channelIdentifier ?? "") < ($1.channelIdentifier ?? "") }
-
- // Das Raster hat Vorrang vor der Senderliste des Tuners, auch wenn
- // beide antworten: nur dort gehören Sender und Programm zusammen,
- // stehen Logos dabei, und es enthält ohnehin nur die eingeschalteten
- // Sender. Die Tunerliste führt alle 278, die meisten davon aus.
- if !fromGrid.isEmpty {
- return LiveTVGuide(
- channels: fromGrid,
- programs: programs,
- source: "Raster: \(fromGrid.count) Sender",
- trace: lookup.trace
- )
- }
-
- if !lookup.channels.isEmpty {
- return LiveTVGuide(
- channels: lookup.channels,
- programs: programs,
- source: lookup.description,
- trace: lookup.trace
- )
- }
-
- return LiveTVGuide(
- channels: [],
- programs: programs,
- source: "Raster: keine Sender (\(lookup.description))",
- trace: lookup.trace + [ChannelProbe(
- path: "/\(epgIdentifier)/grid",
- result: fromGrid.isEmpty
- ? "\(entries.count) Sendungen, kein Sender benannt"
- : "\(fromGrid.count) Sender"
- )]
- )
- }
-
- /// Schaltet einen Sender ein.
- ///
- /// Live-TV kommt immer als umgerechneter Stream: der Tuner liefert einen
- /// Transportstrom, den kein iPhone direkt abspielt.
- ///
- /// Zwei Schritte, und der erste ist der, den ich zuerst übersprungen
- /// hatte: **der Server muss den Tuner belegen**. Erst danach gibt es
- /// etwas umzurechnen.
- ///
- /// Weder der Schlüssel für die Sitzung noch die Adresse, die anschließend
- /// umzurechnen ist, sind für diesen Teil der Plex-Schnittstelle
- /// beschrieben. Deshalb wird jede Kombination **bis zum Ende geprüft**:
- /// die Adresse wird einmal selbst abgerufen, ohne Kopfzeilen — genau so,
- /// wie der Systemplayer es tut. Erst was dabei mit 200 antwortet, geht an
- /// den Player. Vorher galt schon eine gebaute Adresse als Erfolg, und der
- /// Fehler tauchte erst im Player auf, als „HTTP 400" ohne Begründung.
- public func tune(
- channel: PlexChannel,
- program: PlexProgram? = nil,
- dvr: PlexDVR? = nil,
- maximumBitrate: Int?
- ) async -> PlexTuneResult {
- let session = UUID().uuidString
- var attempts = 0
-
- // Womit überhaupt gearbeitet wird. Ich habe den Raster-Schlüssel
- // benutzt, ohne ihn je gesehen zu haben — und im Suchraum nur seinen
- // letzten Abschnitt verwendet.
- // Die Sendernummer am Empfänger. Der Programmführer kennt den Sender
- // als `6228f7f2…-5fc706…`, der Tuner als `300` — ohne diese
- // Übersetzung antwortet der Empfänger auf alles mit 404.
- let deviceChannel = dvr?.deviceChannel(forChannelKey: channel.id)
-
- var trace: [ChannelProbe] = [ChannelProbe(
- path: "Sender-Schlüssel",
- result: [
- "gridKey=\(channel.gridKey ?? "–")",
- "key=\(channel.key ?? "–")",
- "id=\(channel.id)",
- "nummer=\(channel.channelIdentifier ?? "–")",
- "amEmpfänger=\(deviceChannel ?? "–")",
- ].joined(separator: ", ")
- )]
-
- // Der Weg, den Plex' eigener Client geht. Nachgelesen in seinem
- // Quelltext, nicht erraten:
- //
- // POST /livetv/dvrs/{dvr}/channels/{kennung}/tune
- // X-Plex-Session-Identifier:
- //
- // **POST**, nicht GET. Genau diesen Pfad hatte ich seit mehreren
- // Runden richtig — und jedes Mal mit der falschen Methode abgefragt.
- // Ein GET darauf antwortet mit 404, und das sah aus wie „gibt es
- // nicht" statt wie „nicht so".
- if let dvr {
- attempts += 1
- let outcome = await tuneThroughDVR(
- channel: channel,
- dvr: dvr,
- session: session,
- maximumBitrate: maximumBitrate
- )
- trace.append(contentsOf: outcome.probes)
- if let url = outcome.url {
- return PlexTuneResult(
- url: url,
- sessionIdentifier: session,
- subscriptionKey: outcome.subscription,
- trace: trace
- )
- }
-
- // Belegt, aber nicht abspielbar: den Tuner sofort wieder
- // freigeben, statt ihn für nichts zu halten.
- if let subscription = outcome.subscription {
- await endLiveSession(subscriptionKey: subscription)
-
- // Und hier aufhören. Dass das Belegen klappt, beantwortet die
- // Frage nach dem Weg — die übrigen zwanzig Abfragen sind dann
- // nur noch Lärm im Bericht und Last für den Server.
- return PlexTuneResult(
- url: nil,
- sessionIdentifier: session,
- subscriptionKey: nil,
- trace: trace
- )
- }
- }
-
- // Der Raster-Schlüssel als Adresse, unverkürzt. Bisher habe ich nur
- // seinen letzten Abschnitt als Sitzungsschlüssel probiert — als
- // vollständige Adresse war er nie an der Reihe.
- if let gridKey = channel.gridKey, gridKey.hasPrefix("/") {
- attempts += 1
- let outcome = await attemptStream(
- path: gridKey,
- ratingKey: channel.id,
- mediaIndex: 0,
- includeProfile: true,
- maximumBitrate: maximumBitrate,
- session: session
- )
- trace.append(outcome.probe)
- if let url = outcome.url {
- return PlexTuneResult(url: url, sessionIdentifier: session, trace: trace)
- }
- }
-
- // Die Sendung erst nachschlagen. Das Raster nennt keine abspielbare
- // Adresse — die Einzelabfrage beim Programmanbieter womöglich schon.
- // Ihre Adresse blind an den Transcoder zu geben, hat dreimal eine
- // nackte 400 ergeben: eine Sendung beim Cloud-Programmführer hat
- // keine Datei, die sich umrechnen ließe.
- if let metadataKey = program?.metadataKey {
- let lookup = await programMedia(atKey: metadataKey)
- trace.append(ChannelProbe(path: "\(metadataKey) (nachgeschlagen)", result: lookup.summary))
-
- for part in lookup.parts {
- guard attempts < 14 else { break }
- attempts += 1
-
- let outcome = await attemptStream(
- path: part,
- ratingKey: lookup.ratingKey ?? channel.id,
- mediaIndex: 0,
- includeProfile: true,
- maximumBitrate: maximumBitrate,
- session: session
- )
- trace.append(outcome.probe)
- if let url = outcome.url {
- return PlexTuneResult(url: url, sessionIdentifier: session, trace: trace)
- }
- }
- }
-
- if let metadataKey = program?.metadataKey {
- // Ohne Profil als dritter Versuch: die Profilangaben stecken
- // voller `&` und `=`, die in einem Abfragewert mehrdeutig sind.
- // Trägt es ohne, liegt es daran — und das steht dann in der Spur.
- // Doppelt kodiert zuerst: der Server dekodiert den Abfragewert
- // einmal, bevor er ihn als Pfad liest. Nur so kommt bei ihm
- // wieder `plex%3A%2F%2Fepisode%2F…` an statt `plex://episode/…`.
- let variants: [(index: Int, profile: Bool)] = [(0, true), (-1, true), (0, false)]
- for variant in variants {
- attempts += 1
- let outcome = await attemptStream(
- path: metadataKey,
- ratingKey: channel.id,
- mediaIndex: variant.index,
- includeProfile: variant.profile,
- maximumBitrate: maximumBitrate,
- session: session
- )
- trace.append(outcome.probe)
- if let url = outcome.url {
- return PlexTuneResult(url: url, sessionIdentifier: session, trace: trace)
- }
- }
- }
-
- // Was der Programmanbieter an Endpunkten angibt — für den Fall, dass
- // dieser Server den Tuner doch selbst belegt haben will. Steht mit in
- // der Spur, damit auch ein Fehlschlag etwas verrät.
- if let epgIdentifier = program?.metadataKey?
- .split(separator: "/").first.map(String.init) {
- let features = await providerFeatures(for: epgIdentifier)
- trace.append(ChannelProbe(
- path: "Angebotene Endpunkte",
- result: features.isEmpty
- ? "keine"
- : features.map { "\($0.type)=\($0.key ?? "–")" }.joined(separator: ", ")
- ))
- }
-
- let sessionPath = "/livetv/sessions/\(session)"
- for openPath in Self.openPaths(
- channel: channel,
- program: program,
- dvr: dvr,
- sessionPath: sessionPath
- ) {
- let item: PlexItem?
- do {
- item = try await client.send(
- PlexRequest(
- path: openPath,
- query: [URLQueryItem(name: "X-Plex-Session-Identifier", value: session)],
- timeout: .seconds(30)
- ),
- as: PlexMediaContainer.self
- ).content.first
- } catch {
- trace.append(ChannelProbe(path: openPath, result: Self.shortDescription(of: error)))
- continue
- }
-
- guard let item else {
- trace.append(ChannelProbe(path: openPath, result: "leer"))
- continue
- }
- trace.append(ChannelProbe(path: openPath, result: "Sitzung offen"))
-
- for candidate in Self.streamCandidates(item: item, sessionPath: sessionPath) {
- guard attempts < 14 else { break }
- attempts += 1
-
- let outcome = await attemptStream(
- path: candidate.path,
- ratingKey: item.ratingKey,
- mediaIndex: candidate.mediaIndex,
- includeProfile: true,
- maximumBitrate: maximumBitrate,
- session: session
- )
- trace.append(outcome.probe)
- if let url = outcome.url {
- return PlexTuneResult(url: url, sessionIdentifier: session, trace: trace)
- }
- }
- }
-
- // Kein Weg hat mit 200 geantwortet. Den Transcoder anhalten, sonst
- // rechnet der Server für eine Wiedergabe weiter, die es nicht gibt.
- await stopTranscode(sessionIdentifier: session)
- return PlexTuneResult(url: nil, sessionIdentifier: session, trace: trace)
- }
-
- /// Schlägt eine Sendung beim Programmanbieter nach.
- ///
- /// Liefert die abspielbaren Teil-Adressen und eine Kurzbeschreibung
- /// dessen, was zurückkam. Die Beschreibung steht auch dann in der Spur,
- /// wenn nichts Abspielbares dabei war — sonst bliebe nach einem
- /// Fehlschlag wieder nur Raten, wo die Adresse steckt.
- private func programMedia(atKey key: String) async -> (parts: [String], ratingKey: String?, summary: String) {
- let data: Data
- do {
- data = try await client.send(PlexRequest(
- path: key,
- query: [URLQueryItem(name: "includeChildren", value: "1")],
- timeout: .seconds(20),
- isPathEncoded: true
- ))
- } catch {
- return ([], nil, Self.shortDescription(of: error))
- }
-
- guard let item = (try? PlexResponse.decode(PlexMediaContainer.self, from: data))?
- .content.first
- else {
- return ([], nil, Self.containerSummary(data))
- }
-
- let parts = item.media.flatMap(\.parts).compactMap(\.key)
- guard parts.isEmpty else {
- return (parts, item.ratingKey, "\(parts.count) Teil-Adressen")
- }
-
- // Keine Teil-Adresse — dann zählt, was stattdessen dort steht.
- // „1 Fassung, keine Teil-Adresse" beendet nichts; der Aufbau der
- // Antwort schon, denn irgendwo muss der Server sagen, wie diese
- // Sendung abzuspielen ist.
- return ([], item.ratingKey, "ohne Teil-Adresse\n" + Self.mediaOutline(data))
- }
-
- /// Der Aufbau des Medien-Blocks einer Antwort, kurz gehalten.
- static func mediaOutline(_ data: Data) -> String {
- guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
- let container = object["MediaContainer"] as? [String: Any],
- let first = (container["Metadata"] as? [[String: Any]])?.first
- else { return Self.containerSummary(data) }
-
- var lines: [String] = []
- if let media = (first["Media"] as? [[String: Any]])?.first {
- lines += media.keys.sorted().map { key in
- let value = media[key]!
- if let text = value as? String { return " Media.\(key) = \(text)" }
- if let number = value as? NSNumber { return " Media.\(key) = \(number)" }
- if let array = value as? [Any] { return " Media.\(key): \(array.count) Einträge" }
- return " Media.\(key): \(type(of: value))"
- }
- } else {
- lines.append(" kein Media-Block")
- }
-
- // Und die Kurzform der Sendung selbst — dort stehen `key` und `guid`.
- for key in ["key", "guid", "ratingKey", "type", "live", "onAir"] where first[key] != nil {
- lines.append(" \(key) = \(first[key]!)")
- }
- return lines.joined(separator: "\n")
- }
-
- /// Belegt einen Tuner über den Empfänger und liefert den Stream.
- ///
- /// Die Antwort auf das POST trägt den Vorgang mitsamt Metadaten-Eintrag,
- /// und **dort** steht die abspielbare Teil-Adresse — nicht im Raster und
- /// nicht bei der Sendung. Danach ist es gewöhnliches Umrechnen.
- private func tuneThroughDVR(
- channel: PlexChannel,
- dvr: PlexDVR,
- session: String,
- maximumBitrate: Int?
- ) async -> (url: URL?, subscription: String?, probes: [ChannelProbe]) {
- let identifier = channel.id.addingPercentEncoding(
- withAllowedCharacters: .uriComponentAllowed
- ) ?? channel.id
- let path = "/livetv/dvrs/\(dvr.key)/channels/\(identifier)/tune"
-
- let data: Data
- do {
- data = try await client.send(PlexRequest(
- method: .post,
- path: path,
- additionalHeaders: ["X-Plex-Session-Identifier": session],
- timeout: .seconds(30),
- isPathEncoded: true
- ))
- } catch {
- return (nil, nil, [ChannelProbe(path: "POST \(path)", result: Self.shortDescription(of: error))])
- }
-
- // Die Kennung des Vorgangs. Ohne sie bleibt der Tuner nach dem
- // Zusehen belegt, und bei vier Tunern ist der fünfte Sender dann
- // nicht mehr einzuschalten.
- let subscription = Self.subscriptionKey(in: data)
-
- guard let part = Self.firstPartKey(in: data) else {
- return (nil, subscription, [ChannelProbe(
- path: "POST \(path)",
- result: "belegt, aber ohne Teil-Adresse — \(Self.containerSummary(data))"
- )])
- }
-
- // Die ganze Antwort, nicht nur das, wonach ich gesucht habe. Ich
- // habe sie mehrfach durchsucht und dabei übersehen, was sonst noch
- // darin steht — Behälter, Teil-Kennungen, vielleicht eine fertige
- // Transcode-Sitzung.
- var probes = [
- ChannelProbe(path: "POST \(path)", result: "Tuner belegt: \(part)"),
- ChannelProbe(
- path: "Antwort auf das Belegen",
- result: redacted(String(decoding: data.prefix(900), as: UTF8.self))
- ),
- ]
-
- // Die Sitzung selbst fragen — und **nur** ihr die Abspieladresse
- // glauben. Die Antwort auf das Belegen nennt eine Adresse mit der
- // Sitzungskennung darin; die Sitzung nennt dieselbe Adresse mit der
- // **Gerätekennung**. Nur die zweite gibt es wirklich. Ich habe die
- // erste genommen und mich über 404 gewundert.
- if let sessionKey = Self.firstSessionKey(in: data),
- let sessionData = try? await client.send(
- PlexRequest(path: sessionKey, timeout: .seconds(20))
- ),
- let livePart = Self.firstPartKey(in: sessionData),
- let direct = directStreamURL(forPath: livePart) {
- let outcome = await probeStream(url: direct)
- probes.append(ChannelProbe(path: "\(livePart) (aus der Sitzung)", result: outcome))
- if outcome.hasPrefix("HTTP 200") { return (direct, subscription, probes) }
- }
-
- // Abwarten, bis der Empfänger abgestimmt hat. Er meldet
- // `status: inprogress` und `currentSize: 0` — es gibt in dem Moment
- // noch keinen Stream, und jede Frage danach musste scheitern. Ein
- // Tuner ist ein Stück Hardware; er braucht Sekunden, keine
- // Millisekunden.
- if let operation = Self.grabOperationKey(in: data) {
- probes.append(ChannelProbe(
- path: "Abstimmen abwarten",
- result: await waitForGrab(atKey: operation)
- ))
- }
-
- // Und wie die Sitzung danach aussieht — dorthin zeigt der Pfad, den
- // der Transcoder bekommt.
- if let sessionKey = Self.firstSessionKey(in: data) {
- // Großzügig: bisher war genau dort abgeschnitten, wo `Part`
- // anfängt — also an der einzigen Stelle, die zählt.
- probes.append(ChannelProbe(
- path: sessionKey,
- result: await rawResponse(forPath: sessionKey, limit: 2_500)
- ))
- }
-
- // Der Weg des Plex-Clients: die **Sitzung**, die der Server gerade
- // angelegt hat, wird umgerechnet — nicht die Abspielliste darin und
- // nicht meine eigene Kennung. Ihr Schlüssel steht in der Antwort.
- if let sessionKey = Self.firstSessionKey(in: data) {
- // Voll, ohne Profil, und auf das Nötigste zusammengestrichen.
- // Eine nackte 400 sagt nicht, welche Angabe sie stört; die drei
- // Formen nebeneinander sagen es.
- let variants: [(profile: Bool, minimal: Bool)] = [(true, false), (false, false), (true, true)]
- for variant in variants {
- let outcome = await attemptStream(
- path: sessionKey,
- ratingKey: channel.id,
- mediaIndex: 0,
- includeProfile: variant.profile,
- minimal: variant.minimal,
- maximumBitrate: maximumBitrate,
- session: session
- )
- probes.append(outcome.probe)
- if let url = outcome.url { return (url, subscription, probes) }
- }
-
- // Dieselben Angaben an den Entscheider. Der Transcoder antwortet
- // mit HTML und schweigt sich aus; der Entscheider antwortet in
- // Plex' eigenem Format und nennt den Grund.
- let request = TranscodeRequest(
- ratingKey: channel.id,
- maximumBitrate: maximumBitrate,
- isLocal: connection.local,
- sessionIdentifier: session,
- pathOverride: sessionKey
- )
- if let url = transcodeURL(for: request),
- let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.percentEncodedQueryItems {
- probes.append(ChannelProbe(
- path: "Entscheider zur Sitzung",
- result: await rawDecision(percentEncodedItems: items)
- ))
- }
- }
-
- // Und die Abspielliste unmittelbar. Sie antwortete mit 404 statt 400:
- // die Adresse stimmt, die Ressource steht nur nicht bereit. Ein
- // Empfänger ist ein Stück Hardware — er braucht einen Moment, bis er
- // abgestimmt hat. Deshalb ein zweiter Versuch nach kurzer Pause.
- if let direct = directStreamURL(forPath: part) {
- for attempt in 0..<2 {
- if attempt > 0 { try? await Task.sleep(for: .seconds(2)) }
- let outcome = await probeStream(url: direct)
- probes.append(ChannelProbe(
- path: "\(part) (unmittelbar\(attempt > 0 ? ", nach 2 s" : ""))",
- result: outcome
- ))
- if outcome.hasPrefix("HTTP 200") { return (direct, subscription, probes) }
- }
- }
-
- let outcome = await attemptStream(
- path: part,
- ratingKey: channel.id,
- mediaIndex: -1,
- includeProfile: true,
- maximumBitrate: maximumBitrate,
- session: session
- )
- probes.append(outcome.probe)
- return (outcome.url, subscription, probes)
- }
-
- /// Die Adresse, unter der dem Empfänger beim Abstimmen zuzusehen ist.
- static func grabOperationKey(in data: Data) -> String? {
- guard let root = try? JSONSerialization.jsonObject(with: data) else { return nil }
- return firstKey(in: root, matching: "/media/grabbers/operations/")
- }
-
- private static func firstKey(in value: Any, matching prefix: String) -> String? {
- if let dictionary = value as? [String: Any] {
- if let key = dictionary["key"] as? String, key.hasPrefix(prefix) { return key }
- for nested in dictionary.values {
- if let found = firstKey(in: nested, matching: prefix) { return found }
- }
- }
- if let array = value as? [Any] {
- for nested in array {
- if let found = firstKey(in: nested, matching: prefix) { return found }
- }
- }
- return nil
- }
-
- /// Wartet, bis der Empfänger Daten liefert.
- ///
- /// Höchstens zwölf Sekunden. Länger zu warten hieße, den Nutzer vor einem
- /// schwarzen Bild sitzen zu lassen; gar nicht zu warten hieß bisher, nach
- /// einem Stream zu fragen, den es noch nicht gibt.
- private func waitForGrab(atKey key: String, limit: Int = 12) async -> String {
- for second in 1...limit {
- try? await Task.sleep(for: .seconds(1))
-
- guard let data = try? await client.send(
- PlexRequest(path: key, timeout: .seconds(10))
- ) else { continue }
-
- guard let root = try? JSONSerialization.jsonObject(with: data),
- let size = Self.firstNumber(in: root, forKey: "currentSize")
- else { continue }
-
- if size > 0 { return "nach \(second) s: \(size) Bytes" }
- }
- return "nach \(limit) s noch keine Daten"
- }
-
- private static func firstNumber(in value: Any, forKey name: String) -> Int? {
- if let dictionary = value as? [String: Any] {
- if let number = dictionary[name] as? NSNumber { return number.intValue }
- for nested in dictionary.values {
- if let found = firstNumber(in: nested, forKey: name) { return found }
- }
- }
- if let array = value as? [Any] {
- for nested in array {
- if let found = firstNumber(in: nested, forKey: name) { return found }
- }
- }
- return nil
- }
-
- /// Die Antwort des Entscheiders im Wortlaut.
- private func rawDecision(percentEncodedItems items: [URLQueryItem]) async -> String {
- guard let baseURL = connection.url,
- var components = URLComponents(
- url: baseURL.appending(path: "/video/:/transcode/universal/decision"),
- resolvingAgainstBaseURL: false
- )
- else { return "Adresse nicht baubar" }
- components.percentEncodedQueryItems = items
-
- guard let url = components.url else { return "Adresse nicht baubar" }
- var request = URLRequest(url: url)
- request.timeoutInterval = 20
- for (field, value) in identity.headers { request.setValue(value, forHTTPHeaderField: field) }
- request.setValue(token, forHTTPHeaderField: "X-Plex-Token")
-
- do {
- let (data, response) = try await transport.data(for: request)
- let status = (response as? HTTPURLResponse)?.statusCode ?? 0
- let body = String(decoding: data.prefix(700), as: UTF8.self)
- .trimmingCharacters(in: .whitespacesAndNewlines)
- return "HTTP \(status): \(redacted(body))"
- } catch {
- return Self.shortDescription(of: error)
- }
- }
-
- /// Der Schlüssel der Live-Sitzung, die der Server angelegt hat.
- ///
- /// `/livetv/sessions/1268be73-…` — die Sitzung des **Servers**, nicht die
- /// Kennung, die der Client für seine Wiedergabe erzeugt. Ich habe
- /// mehrfach die eigene eingesetzt und mich über 404 gewundert.
- static func firstSessionKey(in data: Data) -> String? {
- guard let root = try? JSONSerialization.jsonObject(with: data) else { return nil }
- return firstSessionKey(in: root)
- }
-
- private static func firstSessionKey(in value: Any) -> String? {
- if let dictionary = value as? [String: Any] {
- if let key = dictionary["key"] as? String,
- key.hasPrefix("/livetv/sessions/"),
- !key.hasSuffix(".m3u8") {
- return key
- }
- for nested in dictionary.values {
- if let found = firstSessionKey(in: nested) { return found }
- }
- }
- if let array = value as? [Any] {
- for nested in array {
- if let found = firstSessionKey(in: nested) { return found }
- }
- }
- return nil
- }
-
- /// Die Kennung des Aufnahme-/Live-Vorgangs in einer Antwort.
- static func subscriptionKey(in data: Data) -> String? {
- guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
- let container = root["MediaContainer"] as? [String: Any]
- else { return nil }
-
- for name in ["MediaSubscription", "MediaGrabOperation"] {
- if let entries = container[name] as? [[String: Any]],
- let key = entries.first?["key"] {
- return String(describing: key)
- }
- if let entry = container[name] as? [String: Any], let key = entry["key"] {
- return String(describing: key)
- }
- }
- return nil
- }
-
- /// Gibt den Tuner wieder frei.
- ///
- /// So macht es Plex' eigener Client: `DELETE /media/subscriptions/{id}`.
- /// Ohne dieses Signal bleibt der Tuner belegt, bis der Server es von
- /// selbst merkt — und solange fehlt er beim nächsten Einschalten.
- public func endLiveSession(subscriptionKey: String) async {
- _ = try? await client.send(PlexRequest(
- method: .delete,
- path: "/media/subscriptions/\(subscriptionKey)",
- timeout: .seconds(10)
- ))
- }
-
- /// Sucht die erste abspielbare Teil-Adresse irgendwo in einer Antwort.
- ///
- /// Bewusst suchend statt an einer festen Stelle lesend: der Vorgang
- /// liegt je nach Serverfassung unterschiedlich tief verschachtelt, und
- /// ein falscher Pfad in die Antwort hinein sähe wieder aus wie „nichts da".
- static func firstPartKey(in data: Data) -> String? {
- guard let root = try? JSONSerialization.jsonObject(with: data) else { return nil }
- return firstPartKey(in: root)
- }
-
- private static func firstPartKey(in value: Any) -> String? {
- if let dictionary = value as? [String: Any] {
- if let parts = dictionary["Part"] as? [[String: Any]],
- let key = parts.first?["key"] as? String {
- return key
- }
- for nested in dictionary.values {
- if let found = firstPartKey(in: nested) { return found }
- }
- }
- if let array = value as? [Any] {
- for nested in array {
- if let found = firstPartKey(in: nested) { return found }
- }
- }
- return nil
- }
-
- /// Baut eine Stream-Adresse und ruft sie einmal ab.
- ///
- /// Getrennte Methode statt einer Hilfsfunktion in `tune`: eine solche
- /// Funktion greift über jede Unterbrechung hinweg auf die veränderlichen
- /// Werte daneben zu, und genau das lässt der Nebenläufigkeitsprüfer von
- /// Swift 6 nicht durchgehen.
- private func attemptStream(
- path: String,
- ratingKey: String,
- mediaIndex: Int,
- includeProfile: Bool,
- minimal: Bool = false,
- maximumBitrate: Int?,
- session: String
- ) async -> (url: URL?, probe: ChannelProbe) {
- let request = TranscodeRequest(
- ratingKey: ratingKey,
- mediaIndex: mediaIndex,
- partIndex: mediaIndex,
- maximumBitrate: maximumBitrate,
- isLocal: connection.local,
- sessionIdentifier: session,
- pathOverride: path
- )
-
- guard let url = transcodeURL(
- for: request,
- includeProfile: includeProfile,
- minimal: minimal
- ) else {
- return (nil, ChannelProbe(path: path, result: "Adresse nicht baubar"))
- }
-
- let outcome = await probeStream(url: url)
- let label = "\(path) (mediaIndex \(mediaIndex)"
- + "\(includeProfile ? "" : ", ohne Profil")"
- + "\(minimal ? ", nur das Nötigste" : "") )"
-
- // Die gebaute Adresse mit in die Spur. Ohne sie diskutiert man über
- // eine Anfrage, die niemand gesehen hat — und genau das habe ich
- // mehrere Runden lang getan.
- let detail = outcome.hasPrefix("HTTP 200")
- ? outcome
- : "\(outcome)\n \(redacted(url.absoluteString))"
- return (outcome.hasPrefix("HTTP 200") ? url : nil, ChannelProbe(path: label, result: detail))
- }
-
- /// Welche Adresse mit welchem Index umzurechnen ist.
- ///
- /// Die Adresse der Sitzung zuerst — der Transcoder sucht sich den Teil
- /// dann selbst, und dafür steht der Index -1. Die übrigen Formen stehen
- /// dahinter, weil sich dieser Teil der Schnittstelle zwischen
- /// Serverfassungen unterscheidet und nirgends beschrieben ist.
- static func streamCandidates(
- item: PlexItem,
- sessionPath: String
- ) -> [(path: String, mediaIndex: Int)] {
- var paths = [sessionPath]
- if item.key.hasPrefix("/livetv/"), !paths.contains(item.key) { paths.append(item.key) }
- if let part = item.media.first?.parts.first?.key, !paths.contains(part) { paths.append(part) }
-
- return paths.flatMap { path in
- [(path: path, mediaIndex: -1), (path: path, mediaIndex: 0)]
- }
- }
-
- /// Holt eine Stream-Adresse einmal selbst ab und sagt, was zurückkam.
- ///
- /// **Ohne Plex-Kopfzeilen**, weil AVPlayer sie ebenfalls nicht schickt.
- /// Mit Kopfzeilen zu prüfen hieße, etwas anderes zu prüfen als das, was
- /// später wirklich passiert — und dann stünde der Fehler wieder erst im
- /// Player, wo niemand mehr sieht, woran es lag.
- public func probeStream(url: URL) async -> String {
- var request = URLRequest(url: url)
- request.timeoutInterval = 20
-
- do {
- let (data, response) = try await transport.data(for: request)
- let status = (response as? HTTPURLResponse)?.statusCode ?? 0
- guard status != 200 else { return "HTTP 200" }
-
- // Plex legt dem Fehler eine Begründung bei. Genau die fehlte
- // bisher: der Player zeigte „HTTP 400 (unhandled)" und sonst nichts.
- let body = String(decoding: data.prefix(300), as: UTF8.self)
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .replacingOccurrences(of: "\n", with: " ")
- return body.isEmpty ? "HTTP \(status)" : "HTTP \(status): \(body)"
- } catch {
- return Self.shortDescription(of: error)
- }
- }
-
- /// Wo sich ein Tuner belegen lässt.
- ///
- /// Der Programmanbieter gibt unter `/media/providers` keinen einzigen
- /// Endpunkt zum Einschalten an — er kann Inhalt, Metadaten und
- /// Programmraster, mehr nicht. Der Empfänger muss also über den DVR
- /// belegt werden, und dessen Adressen stehen hier neben den
- /// Sitzungsformen, weil dieser Teil der Schnittstelle nirgends
- /// beschrieben ist.
- static func openPaths(
- channel: PlexChannel,
- program: PlexProgram?,
- dvr: PlexDVR?,
- sessionPath: String
- ) -> [String] {
- var paths: [String] = []
-
- if let dvr {
- // Die Nummer am Empfänger zuerst: nur sie spricht seine Sprache.
- let keys = ([dvr.deviceChannel(forChannelKey: channel.id)]
- + [channel.gridKey, channel.key, channel.channelIdentifier, channel.id])
- .compactMap { $0 }
- .map { $0.split(separator: "/").last.map(String.init) ?? $0 }
-
- for key in keys {
- paths.append("/livetv/dvrs/\(dvr.key)/channels/\(key)/tune")
- paths.append("/livetv/dvrs/\(dvr.key)/channels/\(key)")
- }
-
- // Und über das Gerät. Es ist die einzige Stelle, die auf diesem
- // Server überhaupt Sender führt — 278 Stück unter `DeviceChannel`.
- // Wenn der DVR keine kennt, kennt sie das Gerät.
- for device in dvr.devices {
- guard let deviceKey = device.key else { continue }
- let base = deviceKey.hasPrefix("/") ? deviceKey : "/media/grabbers/devices/\(deviceKey)"
- for key in keys.prefix(3) {
- paths.append("\(base)/channels/\(key)/tune")
- paths.append("\(base)/channels/\(key)")
- }
- }
- }
-
- paths += sessionKeys(channel: channel, program: program).map { "\(sessionPath)/\($0)" }
- // Und die Sitzung ohne Schlüssel — bisher nie probiert.
- paths.append(sessionPath)
-
- var seen = Set()
- return paths.filter { seen.insert($0).inserted }
- }
-
- /// Unter welchen Schlüsseln sich eine Live-Sitzung eröffnen lässt.
- ///
- /// Der Raster-Schlüssel zuerst: er bezeichnet den Sender genau so, wie
- /// das Programmraster ihn führt, aus dem auch die Senderliste kommt.
- static func sessionKeys(channel: PlexChannel, program: PlexProgram?) -> [String] {
- var keys = [
- channel.gridKey,
- program?.metadataKey.flatMap { $0.split(separator: "/").last.map(String.init) },
- program?.id,
- channel.key,
- channel.id,
- ].compactMap { $0 }
-
- // Volle Adressen taugen hier nicht: an `/livetv/sessions//`
- // gehört ein Schlüssel, kein zweiter Pfad.
- keys = keys.map { $0.split(separator: "/").last.map(String.init) ?? $0 }
- // Und ein Schlüssel, der noch `%3A` enthält, ist als Pfadabschnitt
- // keiner — die entpackte Form gehört mit in die Auswahl. Aber nur,
- // solange dabei kein Schrägstrich herauskommt: `plex://movie/abc` ist
- // als ein Pfadabschnitt genauso wenig brauchbar.
- keys += keys.compactMap { key in
- guard key.contains("%"), let plain = key.removingPercentEncoding,
- !plain.contains("/") else { return nil }
- return plain
- }
-
- var seen = Set()
- return keys.filter { !$0.isEmpty && seen.insert($0).inserted }
- }
-
- /// Fragt den Server, ob er diesen Stream zulässt — und warum nicht.
- public func liveTVVerdict(for url: URL) async -> LiveTVVerdict {
- // Dieselben Angaben, nur an den Entscheider statt an den Transcoder.
- // Er antwortet im Klartext, warum er ablehnt — sonst bleibt nur „die
- // Wiedergabe wurde unterbrochen", und das sagt nichts.
- guard let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems
- else { return .unknown("keine Abfrageteile in der Adresse") }
-
- do {
- let decision = try await client.send(
- PlexRequest(path: "/video/:/transcode/universal/decision", query: items),
- as: TranscodeDecision.self
- )
- guard decision.isAllowed else {
- return .refused(decision.explanation ?? "ohne Begründung")
- }
- return .allowed
- } catch {
- // Antwortet der Entscheider gar nicht, heißt das **nicht** nein.
- // Live-Adressen kennt er womöglich nicht; darauf die Wiedergabe
- // zu verweigern hieße, eine funktionierende Sendung zu blocken.
- return .unknown(Self.shortDescription(of: error))
- }
- }
-}
-
-extension PlexServer {
- /// Der Aufbau der Antwort auf `/livetv/dvrs`, zum Ansehen.
- ///
- /// Nicht zum Anzeigen im Alltag, sondern zum Nachsehen: die
- /// Live-TV-Endpunkte von Plex haben keine offizielle Beschreibung, und
- /// ohne die tatsächliche Antwort bleibt jede Vermutung darüber, wo die
- /// Sender stehen, eine Vermutung. Diese Ausgabe beendet das Raten.
- ///
- /// Werte werden nicht gezeigt, nur Struktur und Anzahl — das genügt, um
- /// den richtigen Schlüssel zu finden, und nichts davon ist vertraulich.
- public func liveTVStructure() async -> String {
- var report = ["=== /livetv/dvrs ==="]
- report.append(await structure(ofPath: "/livetv/dvrs"))
-
- // Was der Server an Endpunkten angibt. Nach drei geratenen Adressen
- // mit 404 ist das die Stelle, an der die Antwort stehen muss.
- report.append("\n=== /media/providers ===")
- report.append(await structure(ofPath: "/media/providers"))
-
- // Und die Senderliste beim Anbieter — dort stehen die Namen. Ohne sie
- // im selben Abzug bliebe nach einem Fehlschlag wieder eine Runde
- // Raten übrig.
- if let dvr = try? await client.send(
- PlexRequest(path: "/livetv/dvrs", timeout: .seconds(10)),
- as: PlexMediaContainer.self
- ).content.first,
- let epgIdentifier = dvr.epgIdentifier {
- for path in await candidatePaths(dvr: dvr, epgIdentifier: epgIdentifier) {
- report.append("\n=== \(path) ===")
- report.append(await structure(ofPath: path))
- }
- // Und das Raster: wenn keine Senderliste antwortet, muss die
- // Antwort hier stehen — die Weboberfläche zeichnet ihren Guide
- // daraus.
- let now = Int(Date().timeIntervalSince1970)
- let gridPath = "/\(epgIdentifier)/grid"
- report.append("\n=== \(gridPath) ===")
- report.append(await structure(
- ofPath: gridPath,
- query: [
- URLQueryItem(name: "type", value: "1"),
- URLQueryItem(name: "beginsAt>", value: String(now - 3_600)),
- URLQueryItem(name: "endsAt<", value: String(now + 3_600)),
- ]
- ))
- } else {
- report.append("\n=== Senderliste ===\nKeine Kennung des Programmanbieters am Empfänger")
- }
-
- return report.joined(separator: "\n")
- }
-
- private func structure(ofPath path: String, query: [URLQueryItem] = []) async -> String {
- let data: Data
- do {
- data = try await client.send(PlexRequest(path: path, query: query, timeout: .seconds(30)))
- } catch {
- return "Abfrage gescheitert: \(error)"
- }
- guard let object = try? JSONSerialization.jsonObject(with: data) else {
- return "Antwort ist kein JSON (\(data.count) Bytes)"
- }
- return Self.outline(object).joined(separator: "\n")
- }
-
- /// Beschreibt den Aufbau eines JSON-Werts, ohne die Werte selbst.
- ///
- /// Ausnahme sind Titel und Sendernummern: die stehen ohnehin im
- /// Programmführer und sind beim Suchen die nützlichste Angabe.
- private static func outline(_ value: Any, indent: Int = 0) -> [String] {
- let padding = String(repeating: " ", count: indent)
-
- switch value {
- case let dictionary as [String: Any]:
- return dictionary.keys.sorted().flatMap { key -> [String] in
- let child = dictionary[key]!
- if ["title", "channelIdentifier", "lineupTitle", "type", "key", "identifier",
- "channelTitle", "channelCallSign", "channelVcn"].contains(key),
- let text = child as? String {
- return ["\(padding)\(key): \(text)"]
- }
- switch child {
- case is [Any], is [String: Any]:
- return ["\(padding)\(key):"] + outline(child, indent: indent + 1)
- default:
- return ["\(padding)\(key): \(type(of: child))"]
- }
- }
-
- case let array as [Any]:
- guard let first = array.first else { return ["\(padding)(leer)"] }
- // Nur das erste Element ausklappen — bei dreißig Sendern wäre die
- // vollständige Ausgabe unlesbar, und alle haben denselben Aufbau.
- return ["\(padding)[\(array.count) Einträge, erster:]"]
- + outline(first, indent: indent + 1)
-
- default:
- return ["\(padding)\(type(of: value))"]
- }
- }
-}
-
-/// Eine abgeklopfte Adresse und was sie geantwortet hat.
-public struct ChannelProbe: Sendable, Equatable, Identifiable {
- public let path: String
- /// „28 Sender", „notFound", „Channel: 0" — kurz genug für eine Zeile.
- public let result: String
-
- public var id: String { path }
-}
-
-/// Ein Verzeichniseintrag, wie ihn Plex in Wurzelantworten führt.
-///
-/// Nur Adresse und Beschriftung: mehr braucht es nicht, um zu erkennen, wo
-/// die Sender stehen, und mehr steht dort oft auch nicht.
-struct PlexNavigationEntry: Decodable {
- let key: String?
- let title: String?
-}
-
-/// Woher die Senderliste kam.
-public struct ChannelLookup: Sendable {
- public enum Source: Sendable, Equatable {
- /// Aus der Senderliste des Programmanbieters, über diesen Pfad.
- case lineup(available: Int, path: String)
- /// Die Senderliste kam an, aber keiner passte zur Zuordnung.
- case lineupWithoutMatches(available: Int, enabled: Int)
- /// Am Empfänger hängt keine Senderliste oder kein Programmanbieter.
- case noLineup
- case failed(String)
- }
-
- public let channels: [PlexChannel]
- public let source: Source
-
- /// Jede abgeklopfte Adresse mit ihrer Antwort.
- ///
- /// Steht in der Ansicht, wenn nichts gefunden wurde. Der Grund ist
- /// unbequem, aber nützlich: bei einer Schnittstelle ohne Beschreibung ist
- /// „keine Sender" allein eine Sackgasse, und jede Runde Rückfragen
- /// kostet Tage.
- public let trace: [ChannelProbe]
-
- public init(channels: [PlexChannel], source: Source, trace: [ChannelProbe] = []) {
- self.channels = channels
- self.source = source
- self.trace = trace
- }
-
- /// Kurzbeschreibung für die Diagnose.
- public var description: String {
- switch source {
- case .lineup(let available, let path):
- "\(channels.count) von \(available) Sendern · \(path)"
- case .lineupWithoutMatches(let available, let enabled):
- "Senderliste hat \(available) Sender, \(enabled) sind zugeordnet — keiner passte zusammen"
- case .noLineup:
- "Am Empfänger hängt keine Senderliste"
- case .failed(let reason):
- "Abfrage gescheitert: \(reason)"
- }
- }
-}
-
-/// Ein Anbieter am Server — Mediathek, Programmführer, Entdecken.
-public struct PlexMediaProvider: Sendable, Equatable, Decodable, Identifiable {
- public let identifier: String
- public let title: String?
- public let types: String?
- /// Was dieser Anbieter kann, und unter welcher Adresse.
- ///
- /// Der Grund, warum hier überhaupt etwas steht: Plex sagt selbst, welche
- /// Endpunkte es anbietet. Drei Versuche, die Adresse der Senderliste zu
- /// erraten, endeten in 404 — sie hier abzulesen ist kein Ratespiel mehr.
- public let features: [PlexProviderFeature]
-
- public var id: String { identifier }
-
- private enum CodingKeys: String, CodingKey {
- case identifier, title, types
- case features = "Feature"
- }
-
- public init(from decoder: any Decoder) throws {
- let values = try decoder.container(keyedBy: CodingKeys.self)
- self.identifier = try values.decode(String.self, forKey: .identifier)
- self.title = try values.decodeIfPresent(String.self, forKey: .title)
- self.types = try values.decodeIfPresent(String.self, forKey: .types)
- self.features = try values.decodeIfPresent([PlexProviderFeature].self, forKey: .features) ?? []
- }
-
- public init(identifier: String, title: String? = nil, types: String? = nil, features: [PlexProviderFeature] = []) {
- self.identifier = identifier
- self.title = title
- self.types = types
- self.features = features
- }
-
- /// Die Adresse für eine Fähigkeit, falls der Anbieter sie anbietet.
- public func key(forFeature type: String) -> String? {
- features.first { $0.type == type }?.key
- }
-}
-
-/// Eine Fähigkeit eines Anbieters, mit der Adresse dahinter.
-public struct PlexProviderFeature: Sendable, Equatable, Decodable, Identifiable {
- public let type: String
- public let key: String?
-
- public var id: String { key ?? type }
-
- public init(type: String, key: String? = nil) {
- self.type = type
- self.key = key
- }
-}
-
-/// Ein Rastereintrag: eine Sendung und der Sender, auf dem sie läuft.
-public struct PlexGuideEntry: Sendable {
- public let channel: PlexChannel?
- public let program: PlexProgram
-}
-
-/// Senderliste und Programm, so wie die Live-TV-Ansicht sie braucht.
-public struct LiveTVGuide: Sendable {
- public let channels: [PlexChannel]
- /// Sendungen je Sender, nach Anfangszeit sortiert.
- public let programs: [String: [PlexProgram]]
- /// Woher die Sender kamen — steht in der leeren Ansicht und in der Diagnose.
- public let source: String
- /// Jede abgeklopfte Adresse mit ihrer Antwort, falls nichts gefunden wurde.
- public let trace: [ChannelProbe]
-}
-
-/// Was beim Einschalten herauskam.
-public struct PlexTuneResult: Sendable {
- public let url: URL?
- public let sessionIdentifier: String
- /// Womit der Tuner wieder freigegeben wird.
- public var subscriptionKey: String?
- /// Jeder versuchte Weg mit seiner Antwort.
- public let trace: [ChannelProbe]
-}
-
-/// Was der Server zu einem Stream sagt.
-public enum LiveTVVerdict: Sendable, Equatable {
- case allowed
- /// Ausdrücklich abgelehnt, mit Begründung.
- case refused(String)
- /// Der Entscheider hat nicht geantwortet — kein Grund, es nicht zu versuchen.
- case unknown(String)
-}
-
-
-extension PlexServer {
- /// Was der Entscheider zu einer Sendung sagt — im Wortlaut.
- ///
- /// Der Transcoder antwortet mit einer nackten „400 Bad Request"-Seite und
- /// verrät nichts. Der Entscheider bekommt dieselben Angaben, gibt aber
- /// eine Plex-Antwort zurück, in der steht, woran es liegt. Das ist die
- /// letzte Stelle, an der dieser Server noch etwas erklärt.
- public func decisionDump(channel: PlexChannel, program: PlexProgram?) async -> String {
- guard let metadataKey = program?.metadataKey else {
- return "Keine Sendung — ohne sie gibt es nichts zu entscheiden."
- }
-
- var report: [String] = []
- do {
- let request = TranscodeRequest(
- ratingKey: channel.id,
- isLocal: connection.local,
- pathOverride: metadataKey
- )
- guard let url = transcodeURL(for: request),
- let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems
- else { return "Adresse nicht baubar" }
-
- report.append("--- Entscheider ---")
- do {
- let data = try await client.send(PlexRequest(
- path: "/video/:/transcode/universal/decision",
- query: items,
- timeout: .seconds(20)
- ))
- report.append(redacted(String(decoding: data.prefix(900), as: UTF8.self)))
- } catch {
- report.append(Self.shortDescription(of: error))
- }
- }
- return report.joined(separator: "\n")
- }
-
- /// Die rohe Antwort auf eine Adresse, gekürzt und ohne Token.
- ///
- /// Zum Erkunden gedacht: die Live-Schnittstelle von Plex ist nirgends
- /// beschrieben, und die Antwort auf „was steht unter dieser Adresse" ist
- /// die einzige verlässliche Quelle. Der Token wird aus der Ausgabe
- /// entfernt, falls der Server ihn irgendwo zurückspiegelt.
- public func rawResponse(forPath path: String, limit: Int = 1_200) async -> String {
- let encoded = path.contains("%")
- do {
- let data = try await client.send(PlexRequest(
- path: path,
- timeout: .seconds(20),
- isPathEncoded: encoded
- ))
- let text = String(decoding: data.prefix(limit), as: UTF8.self)
- return redacted(text)
- } catch {
- return Self.shortDescription(of: error)
- }
- }
-
- nonisolated func redacted(_ text: String) -> String {
- text.replacingOccurrences(of: token, with: "«token»")
- }
-}
-
-
-extension CharacterSet {
- /// Was `encodeURIComponent` unangetastet lässt.
- ///
- /// Nicht `.alphanumerics`: das kodiert auch den Bindestrich, und
- /// Senderkennungen bestehen aus zwei Hälften mit einem dazwischen. Aus
- /// `6228f7f2-5fc705fa` würde `6228f7f2%2D5fc705fa`, und der Server fände
- /// den Sender nicht.
- static let uriComponentAllowed = CharacterSet(
- charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()"
- )
-}
-
-
-extension PlexServer {
- /// Klopft den Transcoder mit verschiedenen Angaben ab.
- ///
- /// Eine gewöhnliche Datei scheitert genauso wie Live-TV: der Transcoder
- /// antwortet auf alles mit einer nackten 400. Damit ist es kein
- /// Live-Problem, sondern eines der Anfrage selbst — und welche Angabe
- /// stört, sagt nur das Halbieren.
- public func transcoderBisect(ratingKey: String) async -> [String] {
- let session = UUID().uuidString
- let path = "/library/metadata/\(ratingKey)"
-
- // Erst prüfen, ob es diesen Eintrag überhaupt gibt. Der Titel für die
- // Gegenprobe kam aus den Startseiten-Reihen, und dort stehen auch
- // Einträge von plex.tv, deren Kennung kein Mediathek-Schlüssel ist.
- // Dann wäre die ganze Gegenprobe wertlos gewesen.
- var lines = [" Eintrag \(ratingKey): \(await rawResponse(forPath: path, limit: 150))"]
- let encoded = path.addingPercentEncoding(withAllowedCharacters: .plexParameterAllowed) ?? path
-
- let variants: [(String, [URLQueryItem])] = [
- ("nur Pfad und Token", [
- URLQueryItem(name: "path", value: encoded),
- URLQueryItem(name: "X-Plex-Token", value: token),
- ]),
- ("Pfad unkodiert", [
- URLQueryItem(name: "path", value: path),
- URLQueryItem(name: "X-Plex-Token", value: token),
- ]),
- ("mit Protokoll und Indizes", [
- URLQueryItem(name: "path", value: encoded),
- URLQueryItem(name: "protocol", value: "hls"),
- URLQueryItem(name: "mediaIndex", value: "0"),
- URLQueryItem(name: "partIndex", value: "0"),
- URLQueryItem(name: "X-Plex-Token", value: token),
- ]),
- ("dazu die Gerätekennung", [
- URLQueryItem(name: "path", value: encoded),
- URLQueryItem(name: "protocol", value: "hls"),
- URLQueryItem(name: "mediaIndex", value: "0"),
- URLQueryItem(name: "partIndex", value: "0"),
- URLQueryItem(name: "X-Plex-Token", value: token),
- URLQueryItem(name: "X-Plex-Client-Identifier", value: identity.clientIdentifier),
- ]),
- ("dazu hasMDE", [
- URLQueryItem(name: "path", value: encoded),
- URLQueryItem(name: "protocol", value: "hls"),
- URLQueryItem(name: "hasMDE", value: "1"),
- URLQueryItem(name: "mediaIndex", value: "0"),
- URLQueryItem(name: "partIndex", value: "0"),
- URLQueryItem(name: "X-Plex-Token", value: token),
- URLQueryItem(name: "X-Plex-Client-Identifier", value: identity.clientIdentifier),
- ]),
- ("dazu die Sitzung", [
- URLQueryItem(name: "path", value: encoded),
- URLQueryItem(name: "protocol", value: "hls"),
- URLQueryItem(name: "hasMDE", value: "1"),
- URLQueryItem(name: "mediaIndex", value: "0"),
- URLQueryItem(name: "partIndex", value: "0"),
- URLQueryItem(name: "session", value: session),
- URLQueryItem(name: "X-Plex-Session-Identifier", value: session),
- URLQueryItem(name: "X-Plex-Token", value: token),
- URLQueryItem(name: "X-Plex-Client-Identifier", value: identity.clientIdentifier),
- ]),
- ]
-
- for (name, items) in variants {
- lines.append(" \(name): \(await probe(items: items, endpoint: "start.m3u8"))")
- }
- lines.append(" Entscheider: \(await probe(items: variants.last!.1, endpoint: "decision"))")
-
- // Lebt der Transcoder überhaupt? `ping` und `stop` gehören zu
- // derselben Familie und brauchen nur eine Sitzungskennung. Wenn auch
- // sie mit 400 antworten, ist nicht die Anfrage falsch, sondern der
- // ganze Zweig nicht da.
- let session2 = [URLQueryItem(name: "session", value: session),
- URLQueryItem(name: "X-Plex-Token", value: token)]
- lines.append(" ping: \(await probe(items: session2, endpoint: "ping"))")
- lines.append(" stop: \(await probe(items: session2, endpoint: "stop"))")
-
- // Und zur Gegenkontrolle etwas, von dem wir wissen, dass es geht:
- // die Bilder kommen über denselben `/:/`-Aufbau.
- lines.append(" Mediathek: \(await rawResponse(forPath: "/library/sections", limit: 90))")
- lines.append(" Server sagt über sich: \(await capabilities())")
- return lines
- }
-
- /// Was der Server über seine eigenen Fähigkeiten sagt.
- ///
- /// Die Felder stehen in jeder Antwort der Wurzel, und ihre Werte habe ich
- /// nie angesehen. Steht dort `transcoderVideo: false`, kann dieser Server
- /// kein Video umrechnen — und dann war die ganze Suche der letzten Tage
- /// die Suche nach einer Fähigkeit, die es nicht gibt.
- public func capabilities() async -> String {
- guard let data = try? await client.send(PlexRequest(path: "/", timeout: .seconds(15))),
- let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
- let container = root["MediaContainer"] as? [String: Any]
- else { return "keine Antwort" }
-
- let interesting = [
- "transcoderVideo", "transcoderAudio", "transcoderSubtitles",
- "transcoderActiveVideoSessions", "transcoderVideoBitrates",
- "offlineTranscode", "allowSync", "version", "platform",
- ]
- return interesting
- .compactMap { key in container[key].map { "\(key)=\($0)" } }
- .joined(separator: ", ")
- }
-
- private func probe(items: [URLQueryItem], endpoint: String) async -> String {
- guard let baseURL = connection.url,
- var components = URLComponents(
- url: baseURL.appending(path: "/video/:/transcode/universal/\(endpoint)"),
- resolvingAgainstBaseURL: false
- )
- else { return "Adresse nicht baubar" }
-
- components.percentEncodedQuery = items
- .map { "\($0.name)=\($0.value ?? "")" }
- .joined(separator: "&")
- guard let url = components.url else { return "Adresse nicht baubar" }
-
- return await probeStream(url: url)
- }
-}
-
-
-extension PlexServer {
- /// Schickt eine beliebige Anfrage und zeigt Status und Antwort.
- ///
- /// Zum Prüfen einzelner Vermutungen. `headers:` entscheidet, ob die
- /// Anfrage so aussieht wie die der App oder wie die des Systemplayers —
- /// der schickt keine Kopfzeilen mit, und dieser Unterschied war schon
- /// einmal die Ursache.
- public func attempt(target: String, headers: Bool = true) async -> String {
- guard let baseURL = connection.url else { return "keine Verbindung" }
-
- let pieces = target.split(separator: "?", maxSplits: 1)
- guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
- else { return "Adresse nicht baubar" }
- components.percentEncodedPath = String(pieces[0])
- if pieces.count > 1 {
- components.percentEncodedQuery = String(pieces[1])
- .replacingOccurrences(of: "{token}", with: token)
- }
- guard let url = components.url else { return "Adresse nicht baubar" }
-
- var request = URLRequest(url: url)
- request.timeoutInterval = 25
- if headers {
- for (field, value) in identity.headers { request.setValue(value, forHTTPHeaderField: field) }
- request.setValue(token, forHTTPHeaderField: "X-Plex-Token")
- }
-
- do {
- let (data, response) = try await transport.data(for: request)
- let status = (response as? HTTPURLResponse)?.statusCode ?? 0
- let body = String(decoding: data.prefix(500), as: UTF8.self)
- .trimmingCharacters(in: .whitespacesAndNewlines)
- return "HTTP \(status)\n\(redacted(body))"
- } catch {
- return Self.shortDescription(of: error)
- }
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift
deleted file mode 100644
index 878018a..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift
+++ /dev/null
@@ -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-._~"
- )
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift
deleted file mode 100644
index e64ec9d..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift
+++ /dev/null
@@ -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.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.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.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 {
- 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.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.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.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.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.self
- )
- return container.content
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/TranscodeRequest.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/TranscodeRequest.swift
deleted file mode 100644
index 61588c6..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/TranscodeRequest.swift
+++ /dev/null
@@ -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)
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem+Media.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem+Media.swift
deleted file mode 100644
index 773aa36..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem+Media.swift
+++ /dev/null
@@ -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)
- )
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift
deleted file mode 100644
index dae558d..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift
+++ /dev/null
@@ -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: 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 = []
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexLibrary.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexLibrary.swift
deleted file mode 100644
index bb91daa..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexLibrary.swift
+++ /dev/null
@@ -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
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift
deleted file mode 100644
index 0610ade..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift
+++ /dev/null
@@ -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 {
- 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) -> 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,
- 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
- )
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexMedia+Profile.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexMedia+Profile.swift
deleted file mode 100644
index 287f39b..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexMedia+Profile.swift
+++ /dev/null
@@ -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
- )
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexMedia.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexMedia.swift
deleted file mode 100644
index c211581..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexMedia.swift
+++ /dev/null
@@ -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: " · ")
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexStream.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexStream.swift
deleted file mode 100644
index 821b435..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexStream.swift
+++ /dev/null
@@ -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
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexTag.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexTag.swift
deleted file mode 100644
index e904ec5..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexTag.swift
+++ /dev/null
@@ -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
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/PlexBackend+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/PlexBackend+LiveTV.swift
deleted file mode 100644
index 8f4c259..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/PlexBackend+LiveTV.swift
+++ /dev/null
@@ -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)
- }
-}
diff --git a/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift b/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift
deleted file mode 100644
index 8976a2d..0000000
--- a/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift
+++ /dev/null
@@ -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 {
- // 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(
- _ 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
- )
- }
-}
diff --git a/Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadRulesTests.swift b/Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadRulesTests.swift
index 1e2a71f..c51ddfd 100644
--- a/Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadRulesTests.swift
+++ b/Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadRulesTests.swift
@@ -1,5 +1,5 @@
import Foundation
-import PlexKit
+import MediaKit
import Testing
@testable import FlixyDownloads
diff --git a/Packages/FlixyKit/Tests/PlexKitTests/AuthorizationURLTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/AuthorizationURLTests.swift
deleted file mode 100644
index 19bad1f..0000000
--- a/Packages/FlixyKit/Tests/PlexKitTests/AuthorizationURLTests.swift
+++ /dev/null
@@ -1,101 +0,0 @@
-import Foundation
-import Testing
-@testable import PlexKit
-
-@Suite("Anmelde-Adresse")
-struct AuthorizationURLTests {
- private let pin = PlexPin(id: 42, code: "5474")
-
- private func url() throws -> URL {
- try #require(pin.authorizationURL(clientIdentifier: "abc-123", product: "Flixy"))
- }
-
- @Test("Die Klammern der Kontext-Parameter werden nicht doppelt kodiert")
- func bracketsAreEncodedExactlyOnce() throws {
- // Der Fehler, der plex.tv „Diese Anfrage konnte nicht abgeschlossen
- // werden" antworten ließ: eine bereits kodierte Klammer (%5B) wurde
- // beim Setzen des Fragments ein zweites Mal kodiert (%255B). Plex sah
- // dann einen Parameter namens „context%5Bdevice%5D…" statt
- // „context[device]…" und konnte nichts damit anfangen.
- let text = try url().absoluteString
- #expect(!text.contains("%25"), "doppelt kodiert: \(text)")
- #expect(text.contains("context%5Bdevice%5D%5Bproduct%5D=Flixy"))
- }
-
- @Test("Code und Client-Kennung stehen im Fragment")
- func carriesCodeAndClient() throws {
- let text = try url().absoluteString
- #expect(text.hasPrefix("https://app.plex.tv/auth#?"))
- #expect(text.contains("clientID=abc-123"))
- #expect(text.contains("code=5474"))
- }
-
- @Test("Produktnamen mit Leerzeichen werden kodiert")
- func productNamesWithSpacesAreEncoded() throws {
- // Der Name steht noch nicht fest. Wird daraus „Flixy Player", darf
- // das Leerzeichen die Adresse nicht zerreißen.
- let url = try #require(pin.authorizationURL(clientIdentifier: "abc", product: "Flixy Player"))
- #expect(url.absoluteString.contains("Flixy%20Player"))
- #expect(!url.absoluteString.contains("Flixy Player"))
- }
-}
-
-@Suite("Anmelde-Adresse mit Rücksprung")
-struct AuthorizationForwardURLTests {
- private let pin = PlexPin(id: 42, code: "5474")
-
- @Test("Ohne Rückadresse bleibt die Adresse wie bisher")
- func withoutForwardURL() throws {
- let url = try #require(pin.authorizationURL(clientIdentifier: "abc", product: "Flixy"))
- #expect(!url.absoluteString.contains("forwardUrl"))
- }
-
- @Test("Die Rückadresse wird angehängt und kodiert")
- func forwardURLIsAppendedAndEncoded() throws {
- // Das Schema enthält Doppelpunkte und Schrägstriche. Unkodiert
- // zerrissen sie die Adresse, und Plex führte den Rücksprung nie aus —
- // das Anmeldefenster bliebe offen stehen.
- let url = try #require(pin.authorizationURL(
- clientIdentifier: "abc",
- product: "Flixy",
- forwardURL: URL(string: "com.scarriffleservices.flixy://auth")!
- ))
-
- let text = url.absoluteString
- #expect(text.contains("forwardUrl=com.scarriffleservices.flixy%3A%2F%2Fauth"))
- #expect(!text.contains("%25"))
- }
-
- @Test("Die übrigen Parameter bleiben unversehrt")
- func otherParametersSurvive() throws {
- let url = try #require(pin.authorizationURL(
- clientIdentifier: "abc",
- product: "Flixy",
- forwardURL: URL(string: "flixy://auth")!
- ))
- let text = url.absoluteString
- #expect(text.contains("clientID=abc"))
- #expect(text.contains("code=5474"))
- #expect(text.contains("context%5Bdevice%5D%5Bproduct%5D=Flixy"))
- }
-}
-
-@Suite("Verknüpfungsseite")
-struct LinkURLTests {
- @Test("Der Code steht in der Adresse")
- func carriesTheCode() throws {
- let pin = PlexPin(id: 1, code: "S6J9")
- let url = try #require(pin.linkURL)
- #expect(url.absoluteString == "https://plex.tv/link/?pin=S6J9")
- }
-
- @Test("Sonderzeichen im Code werden kodiert")
- func encodesUnusualCodes() throws {
- // Kurze Codes sind alphanumerisch, aber darauf verlassen möchte ich
- // mich nicht — ein ungeschütztes `&` würde die Adresse zerreißen.
- let pin = PlexPin(id: 1, code: "A&B=C")
- let url = try #require(pin.linkURL)
- #expect(!url.absoluteString.contains("&B"))
- #expect(url.absoluteString.contains("A%26B%3DC"))
- }
-}
diff --git a/Packages/FlixyKit/Tests/PlexKitTests/ClearLogoTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/ClearLogoTests.swift
deleted file mode 100644
index fcb5d5b..0000000
--- a/Packages/FlixyKit/Tests/PlexKitTests/ClearLogoTests.swift
+++ /dev/null
@@ -1,228 +0,0 @@
-import Foundation
-import Testing
-@testable import PlexKit
-
-@Suite("Schriftzüge und Bildliste")
-struct ClearLogoTests {
- private func item(from json: String) throws -> PlexItem {
- let data = Data(#"{"MediaContainer":{"Metadata":[\#(json)]}}"#.utf8)
- return try #require(
- try PlexResponse.decode(PlexMediaContainer.self, from: data).content.first
- )
- }
-
- @Test("Der Schriftzug wird aus der Bildliste gelesen")
- func findsClearLogo() throws {
- let decoded = try item(from: """
- {
- "ratingKey": "1", "type": "movie", "title": "Harry Potter",
- "Image": [
- {"alt": "Harry Potter", "type": "coverPoster", "url": "/library/metadata/1/thumb/1"},
- {"alt": "Harry Potter", "type": "background", "url": "/library/metadata/1/art/1"},
- {"alt": "Harry Potter", "type": "clearLogo", "url": "/library/metadata/1/clearLogo/1"}
- ]
- }
- """)
-
- #expect(decoded.clearLogoPath == "/library/metadata/1/clearLogo/1")
- }
-
- @Test("Ohne Schriftzug bleibt es beim Titel")
- func withoutClearLogo() throws {
- // Der Normalfall bei selbst zusammengestellten Mediatheken.
- let decoded = try item(from: """
- {
- "ratingKey": "1", "type": "movie", "title": "Ein Film",
- "Image": [{"type": "coverPoster", "url": "/library/metadata/1/thumb/1"}]
- }
- """)
- #expect(decoded.clearLogoPath == nil)
- }
-
- @Test("Eine fehlende Bildliste ist kein Fehler")
- func missingImageList() throws {
- // Listen liefern keine Bildliste mit — nur die Einzelabfrage tut das.
- let decoded = try item(from: #"{"ratingKey": "1", "type": "movie", "title": "Ein Film"}"#)
- #expect(decoded.images.isEmpty)
- #expect(decoded.clearLogoPath == nil)
- }
-}
-
-@Suite("Empfänger, Zuordnung und Senderliste")
-struct TunerDecodingTests {
-
- /// Die Antwort, wie sie ein echter Server liefert.
- ///
- /// Nachgebildet aus dem Strukturabzug eines Servers mit Swisscom-Liste:
- /// 28 zugeordnete Sender von 521 in der Liste. Entscheidend daran ist,
- /// was NICHT drinsteht — die Zuordnung führt keine Namen.
- private let dvrJSON = """
- {"MediaContainer":{"size":1,"Dvr":[{
- "key": "1",
- "uuid": "dvr-uuid",
- "epgIdentifier": "tv.plex.providers.epg.cloud:2",
- "lineupTitle": "Swisscom Blue TV (Deutschschweiz)",
- "lineup": "lineup://tv.plex.providers.epg.cloud/swisscom",
- "Lineup": [{"id": "swisscom-de", "title": "Swisscom Blue TV (Deutschschweiz)"}],
- "Device": [{
- "key": "2", "uuid": "tuner-1", "title": "Swisscom Blue TV",
- "ChannelMapping": [
- {"channelKey": "srf1", "lineupIdentifier": "swisscom-de", "enabled": "1"},
- {"channelKey": "srf2", "lineupIdentifier": "swisscom-de", "enabled": "1"},
- {"channelKey": "abgeschaltet", "lineupIdentifier": "swisscom-de", "enabled": "0"}
- ]
- }]
- }]}}
- """
-
- private func dvr() throws -> PlexDVR {
- let container = try PlexResponse.decode(
- PlexMediaContainer.self,
- from: Data(dvrJSON.utf8)
- )
- return try #require(container.content.first)
- }
-
- @Test("Die Kennung des Programmanbieters steht am Empfänger")
- func readsEPGIdentifier() throws {
- // Verlässlicher, als sie aus der Anbieterliste zu suchen.
- #expect(try dvr().epgIdentifier == "tv.plex.providers.epg.cloud:2")
- }
-
- @Test("Die Senderliste wird gelesen")
- func readsLineup() throws {
- #expect(try dvr().lineups.first?.id == "swisscom-de")
- }
-
- @Test("Nur eingeschaltete Sender zählen zur Zuordnung")
- func onlyEnabledMappings() throws {
- // `enabled` kommt als Zeichenkette „1" oder „0", nicht als
- // Wahrheitswert.
- let keys = try dvr().enabledChannelKeys
- #expect(keys == ["srf1", "srf2"])
- #expect(!keys.contains("abgeschaltet"))
- }
-
- @Test("Ein Empfänger-Schlüssel als Zahl wird gelesen")
- func numericDVRKey() throws {
- // Je nach Serverfassung kommt er als Zahl oder als Zeichenkette.
- let json = #"{"MediaContainer":{"Dvr":[{"key":42}]}}"#
- let container = try PlexResponse.decode(
- PlexMediaContainer.self,
- from: Data(json.utf8)
- )
- #expect(container.content.first?.key == "42")
- }
-}
-
-@Suite("Abgleich von Zuordnung und Senderliste")
-struct ChannelMatchingTests {
-
- @Test("Übereinstimmung über die Kennung")
- func matchesByIdentifier() {
- let channel = PlexChannel(id: "srf1", title: "SRF 1")
- #expect(channel.matches(anyOf: ["srf1", "srf2"]))
- }
-
- @Test("Übereinstimmung über die Sendernummer")
- func matchesByChannelNumber() {
- let channel = PlexChannel(id: "x", title: "SRF 1", channelIdentifier: "1.1")
- #expect(channel.matches(anyOf: ["1.1"]))
- }
-
- @Test("Übereinstimmung über den Pfad")
- func matchesByPath() {
- // Je nach Anbieter führt die Zuordnung den vollen Pfad, der Sender
- // aber nur die Kennung — oder umgekehrt.
- let channel = PlexChannel(id: "x", title: "SRF 1", key: "/livetv/channels/srf1")
- #expect(channel.matches(anyOf: ["srf1"]))
- #expect(channel.matches(anyOf: ["/livetv/channels/srf1"]))
- }
-
- @Test("Umgekehrt: Zuordnung führt den Pfad, Sender die Kennung")
- func matchesReversed() {
- let channel = PlexChannel(id: "srf1", title: "SRF 1")
- #expect(channel.matches(anyOf: ["/lineups/swisscom/channels/srf1"]))
- }
-
- @Test("Nicht zugeordnete Sender fallen heraus")
- func nonMatchingIsExcluded() {
- // Der Punkt der ganzen Übung: von 521 Sendern der Liste sollen die 28
- // übrig bleiben, die eingeschaltet sind.
- let channel = PlexChannel(id: "orf1", title: "ORF 1")
- #expect(!channel.matches(anyOf: ["srf1", "srf2"]))
- }
-}
-
-@Suite("Kennung der Senderliste")
-struct LineupIdentifierTests {
-
- @Test("Aus der vollen Adresse wird der nackte Teil")
- func extractsBareIdentifier() {
- // Genau der Wert von einem echten Server. Roh in den Pfad gesetzt
- // ergab er 404: Schema, Doppelschrägstrich, Prozentzeichen und ein
- // Anhang hinter der Raute.
- let lineup = PlexLineup(
- id: "lineup://tv.plex.providers.epg.cloud/6228f7f2364e02fa5ec8e51c#Swisscom%20Blue%20TV%20%28521%20channels%29"
- )
- #expect(lineup.pathIdentifier == "6228f7f2364e02fa5ec8e51c")
- }
-
- @Test("Eine Adresse ohne Anhang funktioniert auch")
- func withoutFragment() {
- let lineup = PlexLineup(id: "lineup://tv.plex.providers.epg.cloud/abc123")
- #expect(lineup.pathIdentifier == "abc123")
- }
-
- @Test("Eine bereits nackte Kennung bleibt unverändert")
- func alreadyBare() {
- // Nicht jeder Anbieter liefert eine Adresse — manche geben die
- // Kennung direkt.
- #expect(PlexLineup(id: "abc123").pathIdentifier == "abc123")
- }
-
- @Test("Ein leerer Anhang stört nicht")
- func emptyFragment() {
- #expect(PlexLineup(id: "lineup://host/abc123#").pathIdentifier == "abc123")
- }
-}
-
-@Suite("Adressen der Senderliste")
-struct ChannelPathTests {
- private let dvr = PlexDVR(
- key: "1",
- lineups: [PlexLineup(
- id: "lineup://tv.plex.providers.epg.cloud/6228f7f2364e02fa5ec8e51c#Swisscom%20Blue%20TV"
- )]
- )
-
- @Test("Die Kennung der Senderliste wird ausgepackt, nicht roh eingesetzt")
- func usesBareIdentifier() throws {
- // Der Fehler, der 404 ergab: die volle Adresse mit Schema, Anhang und
- // Prozentzeichen landete unverändert im Pfad.
- let paths = PlexServer.knownPaths(dvr: dvr, epgIdentifier: "epg:2")
- let lineupPath = try #require(paths.first { $0.contains("/lineups/") })
-
- #expect(lineupPath == "/epg:2/lineups/6228f7f2364e02fa5ec8e51c/channels")
- #expect(!lineupPath.contains("lineup://"))
- #expect(!lineupPath.contains("#"))
- #expect(!lineupPath.contains("%"))
- }
-
- @Test("Es gibt Rückfalllinien")
- func offersFallbacks() {
- // Die Form unterscheidet sich zwischen Serverfassungen, und dieser
- // Teil der API hat keine Beschreibung.
- let paths = PlexServer.knownPaths(dvr: dvr, epgIdentifier: "epg:2")
- #expect(paths.count >= 2)
- #expect(paths.contains("/livetv/dvrs/1/channels"))
- }
-
- @Test("Ohne Senderliste bleiben die übrigen Wege")
- func withoutLineup() {
- let bare = PlexDVR(key: "7")
- let paths = PlexServer.knownPaths(dvr: bare, epgIdentifier: "epg:2")
- #expect(!paths.isEmpty)
- #expect(paths.allSatisfy { !$0.contains("lineups//") })
- }
-}
diff --git a/Packages/FlixyKit/Tests/PlexKitTests/ConnectionSelectorTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/ConnectionSelectorTests.swift
deleted file mode 100644
index 5f81b62..0000000
--- a/Packages/FlixyKit/Tests/PlexKitTests/ConnectionSelectorTests.swift
+++ /dev/null
@@ -1,257 +0,0 @@
-import Foundation
-import Testing
-@testable import PlexKit
-
-@Suite("Verbindungsauswahl")
-struct ConnectionSelectorTests {
- private let serverID = "server-abc-123"
-
- private func identityResponse(_ machineIdentifier: String) -> String {
- #"{"MediaContainer":{"machineIdentifier":"\#(machineIdentifier)","version":"1.42.0"}}"#
- }
-
- private func resource(connections: [PlexConnection]) -> PlexResource {
- PlexResource(
- name: "Wohnzimmer",
- clientIdentifier: serverID,
- accessToken: "server-token",
- connections: connections
- )
- }
-
- @Test("Der lokale Weg gewinnt, auch wenn die Vermittlung erreichbar ist")
- func localWinsOverRelay() async {
- // Der eigentliche Zweck der Rangordnung: die Vermittlung von Plex
- // antwortet im Zweifel schneller, ist aber gedrosselt. Ein Rennen ohne
- // Rang würde ausgerechnet den langsamsten Weg krönen.
- let transport = FakeTransport.routes(["/identity": identityResponse(serverID)])
- let selector = PlexConnectionSelector(identity: .test, transport: transport)
-
- let chosen = await selector.selectConnection(for: resource(connections: [
- PlexConnection(uri: "https://relay.plex.direct:443", relay: true),
- PlexConnection(uri: "http://192.168.1.10:32400", local: true),
- PlexConnection(uri: "https://1-2-3-4.abc.plex.direct:32400"),
- ]))
-
- #expect(chosen?.uri == "http://192.168.1.10:32400")
- }
-
- @Test("Der direkte Weg von außen kommt vor der Vermittlung")
- func directRemoteBeatsRelay() async {
- let transport = FakeTransport.routes(["/identity": identityResponse(serverID)])
- let selector = PlexConnectionSelector(identity: .test, transport: transport)
-
- let chosen = await selector.selectConnection(for: resource(connections: [
- PlexConnection(uri: "https://relay.plex.direct:443", relay: true),
- PlexConnection(uri: "https://1-2-3-4.abc.plex.direct:32400"),
- ]))
-
- #expect(chosen?.uri == "https://1-2-3-4.abc.plex.direct:32400")
- }
-
- @Test("Ein Server mit fremder Kennung wird abgelehnt")
- func rejectsWrongServer() async {
- // Eine veraltete plex.direct-Adresse kann auf einen fremden Server
- // zeigen. Ohne Abgleich der Kennung zeigte die App fremde Mediatheken.
- let transport = FakeTransport.routes(["/identity": identityResponse("ein-anderer-server")])
- let selector = PlexConnectionSelector(identity: .test, transport: transport)
-
- let chosen = await selector.selectConnection(for: resource(connections: [
- PlexConnection(uri: "http://192.168.1.10:32400", local: true),
- ]))
-
- #expect(chosen == nil)
- }
-
- @Test("Ohne erreichbaren Weg gibt es kein Ergebnis")
- func noReachableConnection() async {
- let selector = PlexConnectionSelector(identity: .test, transport: FakeTransport.unreachable())
-
- let chosen = await selector.selectConnection(for: resource(connections: [
- PlexConnection(uri: "http://192.168.1.10:32400", local: true),
- PlexConnection(uri: "https://relay.plex.direct:443", relay: true),
- ]))
-
- #expect(chosen == nil)
- }
-
- @Test("Ohne Server-Token wird gar nicht erst probiert")
- func missingAccessTokenFailsFast() async {
- // Bei geteilten Servern liefert plex.tv gelegentlich keinen Token —
- // etwa wenn die Freigabe zurückgezogen wurde. Anfragen ohne ihn
- // beantwortet der Server wortlos mit 401.
- let selector = PlexConnectionSelector(identity: .test, transport: FakeTransport.unreachable())
- let resource = PlexResource(
- name: "Geteilt",
- clientIdentifier: serverID,
- accessToken: nil,
- connections: [PlexConnection(uri: "http://192.168.1.10:32400", local: true)]
- )
-
- #expect(await selector.selectConnection(for: resource) == nil)
- }
-
- @Test("Die Rangordnung der Wege stimmt")
- func tierOrdering() {
- #expect(PlexConnection(uri: "x", local: true).tier == 0)
- #expect(PlexConnection(uri: "x").tier == 1)
- #expect(PlexConnection(uri: "x", relay: true).tier == 2)
- }
-}
-
-@Suite("Serverliste")
-struct ServerDirectoryTests {
- private let resourcesJSON = """
- [
- {
- "name": "Wohnzimmer",
- "product": "Plex Media Server",
- "clientIdentifier": "server-abc",
- "provides": "server",
- "owned": true,
- "accessToken": "token-eigen",
- "connections": [
- { "uri": "http://192.168.1.10:32400", "address": "192.168.1.10", "port": 32400, "local": true, "relay": false, "protocol": "http", "IPv6": false },
- { "uri": "https://1-2-3-4.abc.plex.direct:32400", "local": false, "relay": false, "protocol": "https" }
- ]
- },
- {
- "name": "iPhone von Guido",
- "clientIdentifier": "client-xyz",
- "provides": "client,player",
- "owned": true,
- "connections": []
- },
- {
- "name": "Server eines Freundes",
- "clientIdentifier": "server-def",
- "provides": "server",
- "owned": false,
- "accessToken": "token-geteilt",
- "connections": [
- { "uri": "https://5-6-7-8.def.plex.direct:32400", "local": false, "relay": false }
- ]
- }
- ]
- """
-
- @Test("Nur Server erscheinen in der Liste, keine Abspielgeräte")
- func filtersOutNonServers() async throws {
- // Das Konto listet auch jedes iPhone und jeden Fernseher, der je
- // angemeldet war. Ungefiltert stünden sie alle in der Serverauswahl.
- let directory = PlexServerDirectory(
- identity: .test,
- token: "konto-token",
- transport: FakeTransport.routes(["/api/v2/resources": resourcesJSON])
- )
-
- let servers = try await directory.servers()
- #expect(servers.count == 2)
- #expect(!servers.contains { $0.clientIdentifier == "client-xyz" })
- }
-
- @Test("Geteilte Server behalten ihren eigenen Token")
- func sharedServersKeepTheirOwnToken() async throws {
- let directory = PlexServerDirectory(
- identity: .test,
- token: "konto-token",
- transport: FakeTransport.routes(["/api/v2/resources": resourcesJSON])
- )
-
- let servers = try await directory.servers()
- let shared = try #require(servers.first { !$0.owned })
-
- #expect(shared.accessToken == "token-geteilt")
- #expect(shared.accessToken != "konto-token")
- }
-}
-
-@Suite("Verbindungsauswahl unter Zeitdruck")
-struct ConnectionSelectorTimeoutTests {
- private let serverID = "server-abc-123"
-
- private func identityResponse(_ machineIdentifier: String) -> String {
- #"{"MediaContainer":{"machineIdentifier":"\#(machineIdentifier)","version":"1.42.0"}}"#
- }
-
- private var resource: PlexResource {
- PlexResource(
- name: "Wohnzimmer",
- clientIdentifier: serverID,
- accessToken: "server-token",
- connections: [
- PlexConnection(uri: "http://192.168.1.10:32400", local: true),
- PlexConnection(uri: "https://1-2-3-4.abc.plex.direct:32400"),
- PlexConnection(uri: "https://relay.plex.direct:443", relay: true),
- ]
- )
- }
-
- @Test("Eine schweigende lokale Adresse blockiert die Anmeldung nicht", .timeLimit(.minutes(1)))
- func hangingLocalDoesNotBlock() async {
- // Der Fehler aus der Praxis: die Ränge wurden nacheinander geprüft,
- // also kostete eine schweigende lokale Adresse erst die volle
- // Zeitgrenze, bevor der direkte Weg überhaupt begann. Ergebnis war
- // eine Anmeldung, die anderthalb Minuten hing.
- let transport = FakeTransport.hosts(
- answering: [
- "1-2-3-4.abc.plex.direct": identityResponse(serverID),
- "relay.plex.direct": identityResponse(serverID),
- ],
- hanging: ["192.168.1.10"]
- )
- let selector = PlexConnectionSelector(
- identity: .test,
- transport: transport,
- probeTimeout: .milliseconds(200)
- )
-
- let started = ContinuousClock.now
- let chosen = await selector.selectConnection(for: resource)
- let elapsed = ContinuousClock.now - started
-
- // Der direkte Weg gewinnt, weil lokal nicht antwortet.
- #expect(chosen?.uri == "https://1-2-3-4.abc.plex.direct:32400")
- // Und zwar in der Größenordnung der Zeitgrenze, nicht in deren
- // Vielfachem.
- #expect(elapsed < .seconds(2), "dauerte \(elapsed)")
- }
-
- @Test("Die Vermittlung kommt nur zum Zug, wenn sonst nichts geht", .timeLimit(.minutes(1)))
- func relayOnlyWhenNothingElseAnswers() async {
- let transport = FakeTransport.hosts(
- answering: ["relay.plex.direct": identityResponse(serverID)],
- hanging: ["192.168.1.10"]
- )
- let selector = PlexConnectionSelector(
- identity: .test,
- transport: transport,
- probeTimeout: .milliseconds(200)
- )
-
- let chosen = await selector.selectConnection(for: resource)
- #expect(chosen?.relay == true)
- }
-
- @Test("Eine erreichbare lokale Adresse gewinnt sofort", .timeLimit(.minutes(1)))
- func reachableLocalWinsImmediately() async {
- // Hier hängen die beiden anderen. Würde die Auswahl auf alle Antworten
- // warten, bevor sie entscheidet, wäre auch der gute Fall langsam.
- let transport = FakeTransport.hosts(
- answering: ["192.168.1.10": identityResponse(serverID)],
- hanging: ["1-2-3-4.abc.plex.direct", "relay.plex.direct"]
- )
- let selector = PlexConnectionSelector(
- identity: .test,
- transport: transport,
- probeTimeout: .seconds(30)
- )
-
- let started = ContinuousClock.now
- let chosen = await selector.selectConnection(for: resource)
- let elapsed = ContinuousClock.now - started
-
- #expect(chosen?.local == true)
- #expect(elapsed < .seconds(2), "dauerte \(elapsed)")
- }
-}
diff --git a/Packages/FlixyKit/Tests/PlexKitTests/ContainerKeysTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/ContainerKeysTests.swift
deleted file mode 100644
index 8f07ad3..0000000
--- a/Packages/FlixyKit/Tests/PlexKitTests/ContainerKeysTests.swift
+++ /dev/null
@@ -1,77 +0,0 @@
-import Foundation
-import Testing
-@testable import PlexKit
-
-/// Unter welchen Namen Plex seine Sammlungen ablegt.
-///
-/// Es gibt keinen einheitlichen, und das ist keine Kleinigkeit: Live-TV galt
-/// als „nicht eingerichtet", weil die Hülle `Dvr` nicht kannte, eine leere
-/// Liste las und daraus die falsche Schlussfolgerung zog — auf einem Server,
-/// auf dem ein Empfänger eingerichtet war.
-@Suite("Sammlungsnamen in der Hülle")
-struct ContainerKeysTests {
-
- @Test("Fernsehempfänger stehen unter Dvr")
- func readsDvrKey() throws {
- let json = """
- {"MediaContainer":{"size":1,"Dvr":[
- {"key":"1","uuid":"abc","lineupTitle":"Kabel","language":"deu"}
- ]}}
- """
- let container = try PlexResponse.decode(
- PlexMediaContainer.self,
- from: Data(json.utf8)
- )
- #expect(container.content.count == 1)
- #expect(container.content.first?.key == "1")
- }
-
- @Test("Die Anbieterliste steht unter MediaProvider")
- func readsMediaProviderKey() throws {
- // Darüber wird die Kennung des Programmführers gefunden. Ohne sie gibt
- // es kein Programm, auch wenn die Sender da sind.
- let json = """
- {"MediaContainer":{"size":2,"MediaProvider":[
- {"identifier":"com.plexapp.plugins.library","title":"Mediathek"},
- {"identifier":"tv.plex.providers.epg.cloud:2","title":"Programm"}
- ]}}
- """
- let container = try PlexResponse.decode(
- PlexMediaContainer.self,
- from: Data(json.utf8)
- )
- #expect(container.content.count == 2)
- #expect(container.content.contains { $0.identifier.contains("epg") })
- }
-
- @Test("Titel stehen weiterhin unter Metadata")
- func stillReadsMetadata() throws {
- let json = #"{"MediaContainer":{"Metadata":[{"ratingKey":"1","type":"movie","title":"Film"}]}}"#
- let container = try PlexResponse.decode(
- PlexMediaContainer.self,
- from: Data(json.utf8)
- )
- #expect(container.content.first?.title == "Film")
- }
-
- @Test("Eine fehlende Sammlung ist kein Fehler")
- func missingCollectionIsEmpty() throws {
- // Eine leere Mediathek liefert gar kein Feld.
- let json = #"{"MediaContainer":{"size":0}}"#
- let container = try PlexResponse.decode(
- PlexMediaContainer.self,
- from: Data(json.utf8)
- )
- #expect(container.content.isEmpty)
- }
-
- @Test("Eine vorhandene, aber kaputte Sammlung meldet den Fehler")
- func malformedCollectionThrows() {
- // Der Unterschied, der beim Umbau beinahe verloren ging: aus „ein Feld
- // heißt jetzt anders" darf nicht „die Mediathek ist leer" werden.
- let json = #"{"MediaContainer":{"Metadata":[{"title":"ohne ratingKey"}]}}"#
- #expect(throws: PlexError.self) {
- try PlexResponse.decode(PlexMediaContainer.self, from: Data(json.utf8))
- }
- }
-}
diff --git a/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/README.md b/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/README.md
deleted file mode 100644
index a09b498..0000000
--- a/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/README.md
+++ /dev/null
@@ -1,16 +0,0 @@
-# Antwort-Vorlagen
-
-Diese Dateien bilden das Antwortformat von Plex nach. Sie sind **von Hand
-geschrieben**, nicht von einem laufenden Server mitgeschnitten — es stand bei
-der Entstehung keiner zur Verfügung.
-
-Sie decken das Format ab, nicht die Wirklichkeit eines gewachsenen Bestands.
-Sobald ein Server erreichbar ist, gehören sie durch echte Mitschnitte ersetzt:
-
- curl -H "Accept: application/json" \
- "http://:32400/library/sections/1/all?X-Plex-Token=" \
- > movies-section.json
-
-Erfahrungsgemäß tauchen dabei Fälle auf, die man sich nicht ausdenkt — fehlende
-`Media`-Blöcke bei noch nicht analysierten Dateien, Titel ohne `year`, Folgen
-ohne `grandparentThumb`. Genau dafür sind die Vorlagen da.
diff --git a/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/episodes-unanalyzed.json b/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/episodes-unanalyzed.json
deleted file mode 100644
index 59a87d2..0000000
--- a/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/episodes-unanalyzed.json
+++ /dev/null
@@ -1,44 +0,0 @@
-{
- "MediaContainer": {
- "size": 2,
- "title1": "Serien",
- "title2": "Staffel 1",
- "Metadata": [
- {
- "ratingKey": "77801",
- "key": "/library/metadata/77801",
- "type": "episode",
- "title": "Die erste Folge",
- "index": 1,
- "parentIndex": 1,
- "parentTitle": "Staffel 1",
- "parentRatingKey": "77800",
- "grandparentTitle": "Eine Serie",
- "grandparentRatingKey": "77700",
- "grandparentThumb": "/library/metadata/77700/thumb/1699000000",
- "duration": 2760000,
- "addedAt": 1699000000,
- "Media": [
- {
- "id": 90001,
- "container": "mkv",
- "Part": [
- { "id": 95001, "key": "/library/parts/95001/1699000000/file.mkv", "container": "mkv" }
- ]
- }
- ]
- },
- {
- "ratingKey": "77802",
- "key": "/library/metadata/77802",
- "type": "episode",
- "title": "Die zweite Folge",
- "index": 2,
- "parentIndex": 1,
- "grandparentTitle": "Eine Serie",
- "duration": 2820000,
- "addedAt": 1699000100
- }
- ]
- }
-}
diff --git a/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/livetv-grid-overlap.json b/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/livetv-grid-overlap.json
deleted file mode 100644
index c358393..0000000
--- a/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/livetv-grid-overlap.json
+++ /dev/null
@@ -1,43 +0,0 @@
-{
- "MediaContainer": {
- "size": 2,
- "Metadata": [
- {
- "ratingKey": "long",
- "title": "Der lange Film",
- "Media": [
- {
- "channelIdentifier": "ch-1",
- "channelTitle": "SRF 1 HD",
- "channelVcn": "002",
- "beginsAt": 1789205400,
- "endsAt": 1789212600,
- "Part": [
- {
- "key": "/livetv/channels/2"
- }
- ]
- }
- ]
- },
- {
- "ratingKey": "after",
- "title": "Was danach kommt",
- "Media": [
- {
- "channelIdentifier": "ch-1",
- "channelTitle": "SRF 1 HD",
- "channelVcn": "002",
- "beginsAt": 1789212600,
- "endsAt": 1789216200,
- "Part": [
- {
- "key": "/livetv/channels/2"
- }
- ]
- }
- ]
- }
- ]
- }
-}
\ No newline at end of file
diff --git a/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/livetv-grid.json b/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/livetv-grid.json
deleted file mode 100644
index 730c786..0000000
--- a/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/livetv-grid.json
+++ /dev/null
@@ -1,58 +0,0 @@
-{
- "MediaContainer": {
- "size": 3,
- "Metadata": [
- {
- "ratingKey": "5f1",
- "key": "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2F6254592919e23c64f6c5605d",
- "title": "Endlich Witwer",
- "summary": "Komödie",
- "type": "movie",
- "Media": [
- {
- "channelCallSign": "ZDFGERH",
- "channelID": 5,
- "channelIdentifier": "6228f7f2364e02fa5ec8e51c-5fc705f9c8d56d002e06f849",
- "channelThumb": "/library/metadata/5/thumb",
- "channelTitle": "006 ZDFGERH (ZDF HD)",
- "channelVcn": "006",
- "onAir": true,
- "beginsAt": 4102444800,
- "endsAt": 4102445700
- }
- ]
- },
- {
- "ratingKey": "5f2",
- "key": "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fepisode%2Fabc",
- "title": "heute journal",
- "type": "episode",
- "Media": [
- {
- "channelID": 5,
- "channelIdentifier": "6228f7f2364e02fa5ec8e51c-5fc705f9c8d56d002e06f849",
- "channelTitle": "006 ZDFGERH (ZDF HD)",
- "channelVcn": "006",
- "beginsAt": 4102445700,
- "endsAt": 4102446000
- }
- ]
- },
- {
- "ratingKey": "5f3",
- "key": "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fshow%2Fxyz",
- "title": "Sport aktuell",
- "Media": [
- {
- "channelCallSign": "SRF2",
- "channelID": 12,
- "channelIdentifier": "6228f7f2364e02fa5ec8e51c-12",
- "channelVcn": "002",
- "beginsAt": 4102444800,
- "endsAt": 4102448400
- }
- ]
- }
- ]
- }
-}
\ No newline at end of file
diff --git a/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/movie-with-two-versions.json b/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/movie-with-two-versions.json
deleted file mode 100644
index af52790..0000000
--- a/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/movie-with-two-versions.json
+++ /dev/null
@@ -1,89 +0,0 @@
-{
- "MediaContainer": {
- "size": 1,
- "totalSize": 1,
- "offset": 0,
- "title1": "Filme",
- "title2": "Zuletzt hinzugefügt",
- "Metadata": [
- {
- "ratingKey": "45231",
- "key": "/library/metadata/45231",
- "guid": "plex://movie/5d776b59ad5437001f79c36d",
- "type": "movie",
- "title": "Blade Runner 2049",
- "titleSort": "Blade Runner 2049",
- "summary": "Dreißig Jahre nach den Ereignissen des ersten Films.",
- "year": 2017,
- "duration": 9784000,
- "contentRating": "de/16",
- "rating": 8.0,
- "audienceRating": 8.1,
- "thumb": "/library/metadata/45231/thumb/1699887654",
- "art": "/library/metadata/45231/art/1699887654",
- "viewCount": 1,
- "viewOffset": 4892000,
- "lastViewedAt": 1699900000,
- "addedAt": 1699887654,
- "Media": [
- {
- "id": 88121,
- "container": "mkv",
- "videoCodec": "hevc",
- "audioCodec": "truehd",
- "videoResolution": "4k",
- "videoProfile": "main 10",
- "bitrate": 58420,
- "width": 3840,
- "height": 2160,
- "audioChannels": 8,
- "duration": 9784000,
- "Part": [
- {
- "id": 91002,
- "key": "/library/parts/91002/1699887654/file.mkv",
- "container": "mkv",
- "size": 71453298112,
- "duration": 9784000,
- "file": "/media/Filme/Blade Runner 2049 (2017)/Blade Runner 2049 2160p.mkv",
- "Stream": [
- { "id": 310021, "streamType": 1, "codec": "hevc", "bitDepth": 10 },
- { "id": 310022, "streamType": 2, "codec": "truehd", "language": "Deutsch", "languageCode": "deu", "channels": 8, "displayTitle": "Deutsch (TrueHD 7.1)", "selected": true },
- { "id": 310023, "streamType": 2, "codec": "eac3", "language": "English", "languageCode": "eng", "channels": 6, "displayTitle": "English (EAC3 5.1)" },
- { "id": 310024, "streamType": 3, "codec": "subrip", "language": "Deutsch", "languageCode": "deu", "key": "/library/streams/310024", "displayTitle": "Deutsch (SRT)" },
- { "id": 310025, "streamType": 3, "codec": "pgs", "language": "English", "languageCode": "eng", "forced": true, "displayTitle": "English (Forced)" }
- ]
- }
- ]
- },
- {
- "id": 88122,
- "container": "mp4",
- "videoCodec": "h264",
- "audioCodec": "aac",
- "videoResolution": "1080",
- "bitrate": 8240,
- "width": 1920,
- "height": 1080,
- "audioChannels": 2,
- "duration": 9784000,
- "Part": [
- {
- "id": 91003,
- "key": "/library/parts/91003/1699887655/file.mp4",
- "container": "mp4",
- "size": 10083446784,
- "duration": 9784000,
- "file": "/media/Filme/Blade Runner 2049 (2017)/Blade Runner 2049 1080p.mp4",
- "Stream": [
- { "id": 310031, "streamType": 1, "codec": "h264" },
- { "id": 310032, "streamType": 2, "codec": "aac", "language": "Deutsch", "languageCode": "deu", "channels": 2, "selected": true }
- ]
- }
- ]
- }
- ]
- }
- ]
- }
-}
diff --git a/Packages/FlixyKit/Tests/PlexKitTests/HubShapeTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/HubShapeTests.swift
deleted file mode 100644
index 4219953..0000000
--- a/Packages/FlixyKit/Tests/PlexKitTests/HubShapeTests.swift
+++ /dev/null
@@ -1,90 +0,0 @@
-import Foundation
-import Testing
-@testable import PlexKit
-
-/// Welche Reihe welche Kachelform bekommt.
-///
-/// Weiterschauen lebt vom Szenenbild — man erkennt die Stelle wieder, an der
-/// man aufgehört hat. Überall sonst ist das Plakat richtig, denn danach sucht
-/// man einen Film aus.
-@Suite("Form der Reihen")
-struct HubShapeTests {
-
- @Test(
- "Angefangenes wird als Szenenbild gezeigt",
- arguments: ["home.continue", "continueWatching", "tv.ondeck", "home.ondeck"]
- )
- func continueWatchingUsesLandscape(identifier: String) {
- #expect(PlexHub(hubIdentifier: identifier, title: "Weiterschauen").isContinueWatching)
- }
-
- @Test(
- "Alles andere bekommt Plakate",
- arguments: ["home.movies.recent", "movie.recentlyadded", "home.watchlist",
- "movie.collection", "tv.recentlyaired"]
- )
- func everythingElseUsesPosters(identifier: String) {
- #expect(!PlexHub(hubIdentifier: identifier, title: "Irgendeine Reihe").isContinueWatching)
- }
-
- @Test("Die Entscheidung hängt nicht am übersetzten Titel")
- func titleDoesNotDecide() {
- // Der Titel kommt in der Sprache des Servers. Würde er die Form
- // bestimmen, sähe die Startseite auf Englisch anders aus als auf
- // Deutsch — und niemand käme auf die Ursache.
- let english = PlexHub(hubIdentifier: "home.continue", title: "Continue Watching")
- let german = PlexHub(hubIdentifier: "home.continue", title: "Weiterschauen")
- #expect(english.isContinueWatching == german.isContinueWatching)
-
- let misleading = PlexHub(hubIdentifier: "home.movies.recent", title: "Weiterschauen")
- #expect(!misleading.isContinueWatching)
- }
-
- @Test("Groß- und Kleinschreibung spielt keine Rolle")
- func caseInsensitive() {
- #expect(PlexHub(hubIdentifier: "Home.ContinueWatching", title: "x").isContinueWatching)
- }
-}
-
-/// Aus welcher Mediathek eine Reihe stammt.
-///
-/// **Der Fehler, der es hierher gebracht hat.** Ohne diese Angabe kann die
-/// App eine Reihe keiner Mediathek zuordnen — und die Einstellung „welche
-/// Mediatheken speisen den Aufmacher" wirft dann jede Reihe weg, fällt auf
-/// „alles" zurück und sieht aus wie ein Schalter, der nichts tut.
-@Suite("Mediathek einer Reihe")
-struct HubLibraryTests {
-
- private func hub(_ json: String) throws -> PlexHub {
- try JSONDecoder().decode(PlexHub.self, from: Data(json.utf8))
- }
-
- /// Plex liefert die Abschnittsnummer je nach Endpunkt als Zahl oder als
- /// Zeichenkette. Nur eines zu lesen hiesse: geht auf manchen Servern und
- /// auf anderen nicht, und zwar lautlos.
- @Test("Die Nummer kommt als Zahl")
- func readsNumber() throws {
- let reihe = try hub(#"{"hubIdentifier":"movie.recentlyadded","title":"Neu","librarySectionID":3}"#)
- #expect(reihe.librarySectionID == "3")
- }
-
- @Test("Die Nummer kommt als Zeichenkette")
- func readsString() throws {
- let reihe = try hub(#"{"hubIdentifier":"movie.recentlyadded","title":"Neu","librarySectionID":"3"}"#)
- #expect(reihe.librarySectionID == "3")
- }
-
- /// „Fortsetzen" geht quer durch alle Mediatheken; Plex nennt dort keine
- /// Nummer. Das ist kein Fehler, sondern die richtige Auskunft.
- @Test("Übergreifende Reihen nennen keine")
- func crossLibraryHasNone() throws {
- let reihe = try hub(#"{"hubIdentifier":"home.continue","title":"Fortsetzen"}"#)
- #expect(reihe.librarySectionID == nil)
- }
-
- @Test("Die Reihe reicht sie weiter")
- func rowCarriesIt() throws {
- let reihe = try hub(#"{"hubIdentifier":"movie.recentlyadded","title":"Neu","librarySectionID":7}"#)
- #expect(reihe.asMediaRowGroup.libraryID == "7")
- }
-}
diff --git a/Packages/FlixyKit/Tests/PlexKitTests/LibraryQueryTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/LibraryQueryTests.swift
deleted file mode 100644
index 99d7948..0000000
--- a/Packages/FlixyKit/Tests/PlexKitTests/LibraryQueryTests.swift
+++ /dev/null
@@ -1,100 +0,0 @@
-import Foundation
-import Testing
-@testable import PlexKit
-
-@Suite("Mediatheks-Abfragen")
-struct LibraryQueryTests {
- private func value(_ items: [URLQueryItem], _ name: String) -> String? {
- items.first { $0.name == name }?.value
- }
-
- @Test("Eine Serienmediathek liefert Serien, nicht einzelne Folgen")
- func showLibraryRequestsShows() {
- // Ohne `type=2` schüttet Plex alle Folgen einzeln aus — bei einer
- // Serienmediathek sind das schnell zehntausend Einträge statt dreißig
- // Serien.
- let items = LibraryQuery().queryItems(libraryKind: .show)
- #expect(value(items, "type") == "2")
- }
-
- @Test("Eine Filmmediathek fragt nach Filmen")
- func movieLibraryRequestsMovies() {
- #expect(value(LibraryQuery().queryItems(libraryKind: .movie), "type") == "1")
- }
-
- @Test("Unbekannte Mediathekenarten bekommen keine Artangabe")
- func unknownKindOmitsType() {
- #expect(value(LibraryQuery().queryItems(libraryKind: nil), "type") == nil)
- }
-
- @Test("Sortierung landet immer in der Abfrage")
- func sortIsAlwaysPresent() {
- for sort in LibraryQuery.Sort.allCases {
- let items = LibraryQuery(sort: sort).queryItems(libraryKind: .movie)
- #expect(value(items, "sort") == sort.rawValue)
- }
- }
-
- @Test("Ohne Filter stehen keine Filterparameter in der Abfrage")
- func unsetFiltersAreOmitted() {
- // Ein leerer Parameter ist für Plex nicht dasselbe wie ein fehlender:
- // `genre=` filtert auf ein Genre ohne Namen und liefert nichts.
- let items = LibraryQuery().queryItems(libraryKind: .movie)
- #expect(value(items, "genre") == nil)
- #expect(value(items, "year") == nil)
- #expect(value(items, "unwatched") == nil)
- #expect(value(items, "resolution") == nil)
- }
-
- @Test("Gesetzte Filter landen vollständig in der Abfrage")
- func setFiltersAppear() {
- let query = LibraryQuery(
- sort: .recentlyAdded,
- unwatchedOnly: true,
- genre: "18",
- year: 2017,
- resolution: "4k"
- )
- let items = query.queryItems(libraryKind: .movie)
-
- #expect(value(items, "sort") == "addedAt:desc")
- #expect(value(items, "unwatched") == "1")
- #expect(value(items, "genre") == "18")
- #expect(value(items, "year") == "2017")
- #expect(value(items, "resolution") == "4k")
- }
-
- @Test("Der Filterknopf weiß, ob gefiltert wird")
- func filteringFlagReflectsState() {
- #expect(!LibraryQuery().isFiltering)
- // Die Sortierung allein zählt nicht als Filter — sonst wäre der Knopf
- // dauerhaft hervorgehoben.
- #expect(!LibraryQuery(sort: .rating).isFiltering)
- #expect(LibraryQuery(unwatchedOnly: true).isFiltering)
- #expect(LibraryQuery(genre: "18").isFiltering)
- }
-}
-
-@Suite("Seitenweises Nachladen")
-struct PlexPageTests {
- @Test("Eine volle Seite kündigt weitere Titel an")
- func fullPageHasMore() {
- let page = PlexPage(items: Array(0..<60), offset: 0, totalSize: 250)
- #expect(page.hasMore)
- #expect(page.nextOffset == 60)
- }
-
- @Test("Die letzte Seite kündigt keine weiteren Titel an")
- func lastPageHasNoMore() {
- // Genau die Bedingung, die sonst zu einer Endlosschleife führt: die
- // Ansicht lädt nach, bekommt nichts, lädt wieder nach.
- let page = PlexPage(items: Array(0..<10), offset: 240, totalSize: 250)
- #expect(!page.hasMore)
- }
-
- @Test("Eine leere Mediathek verlangt kein Nachladen")
- func emptyLibraryHasNoMore() {
- let page = PlexPage(items: [Int](), offset: 0, totalSize: 0)
- #expect(!page.hasMore)
- }
-}
diff --git a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift
deleted file mode 100644
index e2a841a..0000000
--- a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift
+++ /dev/null
@@ -1,533 +0,0 @@
-import Foundation
-import Testing
-@testable import PlexKit
-
-/// Woher die Senderliste kommt.
-///
-/// Drei Adressen, unter denen die Senderliste stehen sollte, antworteten auf
-/// einem echten Server mit 404 — geraten war jede. Das Programmraster ist
-/// keine Vermutung: die Weboberfläche von Plex zeichnet ihren Guide daraus,
-/// und jeder Eintrag trägt den Sender mit sich. Diese Tests halten fest, dass
-/// die Sender dort auch dann herauskommen, wenn sonst nichts antwortet.
-/// Hält fest, womit tatsächlich gefragt wurde.
-///
-/// Ein Actor, weil der Transport aus einem anderen Zusammenhang schreibt und
-/// Swift 6 das sonst zu Recht verweigert.
-private actor QueryRecorder {
- private(set) var items: [URLQueryItem] = []
- func note(_ new: [URLQueryItem]) { items = new }
-}
-
-@Suite("Senderliste aus dem Programmraster")
-struct LiveTVGuideTests {
-
- private func server(transport: FakeTransport) -> PlexServer {
- PlexServer(
- resource: PlexResource(
- name: "Test",
- clientIdentifier: "srv",
- accessToken: "token",
- connections: [PlexConnection(uri: "http://server:32400", local: true)]
- ),
- connection: PlexConnection(uri: "http://server:32400", local: true),
- identity: .test,
- transport: transport
- )!
- }
-
- private func server(routes: [String: String]) -> PlexServer {
- PlexServer(
- resource: PlexResource(
- name: "Test",
- clientIdentifier: "srv",
- accessToken: "token",
- connections: [PlexConnection(uri: "http://server:32400", local: true)]
- ),
- connection: PlexConnection(uri: "http://server:32400", local: true),
- identity: .test,
- transport: FakeTransport.routes(routes)
- )!
- }
-
- // Die Zeiten in `livetv-grid.json` liegen bewusst weit in der Zukunft:
- // das Programm wird auf Frische gefiltert, und ein Abzug mit den echten
- // Zeitstempeln von damals wäre ab dem Tag nach der Aufnahme leer gewesen.
- private func fixture(_ name: String) throws -> String {
- let url = try #require(Bundle.module.url(forResource: "Fixtures/\(name)", withExtension: "json"))
- return try String(contentsOf: url, encoding: .utf8)
- }
-
- private var dvr: PlexDVR {
- PlexDVR(key: "2", epgIdentifier: "tv.plex.providers.epg.cloud:2")
- }
-
- @Test("Jeder Sender kommt genau einmal, obwohl er in mehreren Sendungen steht")
- func derivesChannelsFromGrid() async throws {
- let server = server(routes: [
- "/tv.plex.providers.epg.cloud:2/grid": try fixture("livetv-grid"),
- ])
-
- let guide = await server.liveTVGuide(dvr: dvr)
-
- #expect(guide.channels.count == 2)
- // Nach Sendernummer, und ohne die Nummer noch einmal im Namen:
- // Plex schreibt sie dort mit hinein.
- #expect(guide.channels.map(\.title) == ["SRF2", "ZDFGERH (ZDF HD)"])
- }
-
- @Test("Fehlt der Sendername, trägt das Kürzel den Sender")
- func fallsBackToCallSign() async throws {
- let server = server(routes: [
- "/tv.plex.providers.epg.cloud:2/grid": try fixture("livetv-grid"),
- ])
-
- let guide = await server.liveTVGuide(dvr: dvr)
- let callSignOnly = try #require(guide.channels.first)
-
- #expect(callSignOnly.title == "SRF2")
- #expect(callSignOnly.channelIdentifier == "002")
- }
-
- @Test("Sendungen landen beim richtigen Sender, nach Anfangszeit sortiert")
- func groupsProgramsByChannel() async throws {
- let server = server(routes: [
- "/tv.plex.providers.epg.cloud:2/grid": try fixture("livetv-grid"),
- ])
-
- let guide = await server.liveTVGuide(dvr: dvr)
- let zdf = try #require(guide.channels.last)
- let programs = try #require(guide.programs[zdf.id])
-
- // Auch die Folge ist dabei: mit `type=1` kam nur „Film" durch, und
- // das Raster blieb leer.
- #expect(programs.map(\.title) == ["Endlich Witwer", "heute journal"])
- #expect(guide.programs.count == 2)
- }
-
- @Test("Die laufende Sendung fällt nicht aus dem Fenster")
- func keepsTheRunningProgramme() async throws {
- // Der Fehler, der das Programm sichtbar falsch machte: gefiltert wurde
- // auf „endet vor Fensterende" und „beginnt nach Fensteranfang". Ein
- // Zweistünder ab 15:30 war damit um 17:18 unsichtbar, und angezeigt
- // wurde die nächste Sendung, die durch den Filter passte.
- let body = try fixture("livetv-grid-overlap")
- let recorder = QueryRecorder()
- let server = server(transport: FakeTransport { request in
- await recorder.note(
- URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
- )
- return (Data(body.utf8), FakeTransport.response(for: request, status: 200))
- })
-
- // 17:18 — mitten im langen Film.
- let now = Date(timeIntervalSince1970: 1_789_211_880)
- let entries = try await server.guide(
- providerIdentifier: "tv.plex.providers.epg.cloud:2",
- from: now,
- to: now.addingTimeInterval(6 * 3_600)
- )
-
- #expect(entries.map(\.program.title).contains("Der lange Film"))
- #expect(entries.first?.program.isRunning(at: now) == true)
-
- // Und der Grund dafür steht in der Abfrage: nur der Anfang wird
- // eingegrenzt, mit Vorlauf — keine Grenze auf das Ende.
- let seen = await recorder.items
- #expect(seen.contains { $0.name == "beginsAt<" })
- #expect(!seen.contains { $0.name == "endsAt<" })
- }
-
- @Test("Ein Sender ohne Namen taucht nicht als Strich auf")
- func skipsNamelessChannels() async throws {
- let json = """
- {"MediaContainer":{"size":1,"Metadata":[
- {"ratingKey":"x","title":"Irgendwas","beginsAt":1757682000,"endsAt":1757685600,
- "Media":[{"channelIdentifier":"nur-eine-kennung","Part":[{"key":"/livetv/channels/9"}]}]}
- ]}}
- """
- let server = server(routes: ["/tv.plex.providers.epg.cloud:2/grid": json])
-
- let guide = await server.liveTVGuide(dvr: dvr)
-
- #expect(guide.channels.isEmpty)
- }
-
- @Test("Die Senderliste des Empfängers wird gefunden, ohne dass sie geraten wird")
- func sweepsUntilSomethingAnswers() async throws {
- // Der Fall, der viermal danebenging: die Senderliste steht weder beim
- // Programmanbieter noch unter dem Empfänger, sondern beim Gerät — und
- // sie schreibt den Namen in `name` statt in `title`.
- let device = """
- {"MediaContainer":{"size":2,"Channel":[
- {"name":"SRF 1 HD","vcn":1,"key":"/livetv/channels/11","enabled":"1"},
- {"name":"SRF zwei HD","vcn":2,"key":"/livetv/channels/12","enabled":"1"}
- ]}}
- """
- let server = server(routes: ["/media/grabbers/devices/tuner-7/channels": device])
- let withTuner = PlexDVR(
- key: "2",
- devices: [PlexTunerDevice(key: "tuner-7")],
- epgIdentifier: "tv.plex.providers.epg.cloud:2"
- )
-
- let lookup = await server.channels(inDVR: withTuner)
-
- #expect(lookup.channels.map(\.title) == ["SRF 1 HD", "SRF zwei HD"])
- #expect(lookup.channels.first?.channelIdentifier == "1")
- }
-
- @Test("Jede abgeklopfte Adresse steht mit ihrer Antwort in der Spur")
- func recordsWhatEveryPathAnswered() async throws {
- // Ohne diese Spur ist „keine Sender" eine Sackgasse: sie sagt nicht,
- // ob der Server nichts hergibt oder die App am falschen Ort sucht.
- let server = server(routes: [:])
- let withTuner = PlexDVR(key: "2", epgIdentifier: "tv.plex.providers.epg.cloud:2")
-
- let lookup = await server.channels(inDVR: withTuner)
-
- #expect(lookup.channels.isEmpty)
- #expect(lookup.trace.count >= 3)
- #expect(lookup.trace.allSatisfy { !$0.result.isEmpty })
- #expect(lookup.trace.contains { $0.path == "/livetv/dvrs/2/channels" })
- }
-
- @Test("Eine Liste aus Strichen zählt nicht als Senderliste")
- func rejectsNamelessChannels() async throws {
- // Genau das ist einmal durchgegangen: dreißig Einträge ohne Namen
- // sahen aus wie eine Senderliste und verdrängten die richtige Quelle.
- let nameless = """
- {"MediaContainer":{"size":2,"Channel":[
- {"channelKey":"a","enabled":"1"},{"channelKey":"b","enabled":"1"}
- ]}}
- """
- let server = server(routes: ["/livetv/dvrs/2/channels": nameless])
- let withTuner = PlexDVR(key: "2", epgIdentifier: "tv.plex.providers.epg.cloud:2")
-
- let lookup = await server.channels(inDVR: withTuner)
-
- #expect(lookup.channels.isEmpty)
- #expect(lookup.trace.contains { $0.path == "/livetv/dvrs/2/channels" && $0.result.contains("Channel: 2") })
- }
-
- @Test("Jeder Wert wird streng kodiert, Schrägstriche eingeschlossen")
- func encodesEveryValueStrictly() async throws {
- // Plex' eigener Client schickt alle Parameter durch `qs.stringify`,
- // und das kodiert alles. `URLComponents` allein lässt `/`, `:`, `&`
- // und `=` in Werten stehen — beim Server kam `path=/livetv/…` an
- // statt `path=%2Flivetv%2F…`, und die Antwort war eine nackte 400.
- let server = server(routes: [:])
- let url = try #require(await server.transcodeURL(for: TranscodeRequest(
- ratingKey: "5",
- isLocal: true,
- pathOverride: "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fepisode%2Fabc"
- ))).absoluteString
-
- #expect(url.contains("path=%2Ftv.plex.providers.epg.cloud%3A2%2Fmetadata%2F"))
- // Eine Kennung, die schon `%3A` trägt, wird dabei zu `%253A` — und
- // ist damit genau einmal kodiert.
- #expect(url.contains("plex%253A%252F%252Fepisode%252Fabc"))
- #expect(!url.contains("plex://"))
- }
-
- @Test("Nur was tatsächlich mit 200 antwortet, geht an den Player")
- func rejectsStreamsTheServerRefuses() async throws {
- // Vorher galt schon eine gebaute Adresse als Erfolg. Der Fehler
- // tauchte dann erst im Player auf — als „HTTP 400" ohne Begründung.
- let session = """
- {"MediaContainer":{"size":1,"Metadata":[
- {"ratingKey":"live-1","key":"/livetv/sessions/abc","type":"clip","title":"ZDF HD"}
- ]}}
- """
- let server = server(routes: ["/gk-5": session])
- let channel = PlexChannel(id: "5", title: "ZDF HD", key: "5", gridKey: "gk-5")
-
- let result = await server.tune(channel: channel, maximumBitrate: nil)
-
- #expect(result.url == nil)
- // Und die Antwort des Servers steht in der Spur, nicht nur ein Code.
- #expect(result.trace.contains { $0.result.hasPrefix("HTTP 404") })
- }
-
- @Test("Erst den Tuner belegen, dann umrechnen lassen")
- func opensSessionBeforeTranscoding() async throws {
- // Der übersprungene Schritt: ohne belegten Tuner beginnt die
- // Wiedergabe und stirbt sofort — „die Wiedergabe wurde unterbrochen".
- let session = """
- {"MediaContainer":{"size":1,"Metadata":[
- {"ratingKey":"live-1","key":"/livetv/sessions/abc","type":"clip","title":"ZDF HD",
- "Media":[{"id":1,"Part":[{"id":1,"key":"/livetv/sessions/abc/stream"}]}]}
- ]}}
- """
- // Nur unter dem Raster-Schlüssel antwortet eine Sitzung, und der
- // Transcoder liefert erst danach eine Abspielliste.
- let server = server(routes: ["/gk-5": session, "/start.m3u8": "#EXTM3U"])
- let channel = PlexChannel(id: "5", title: "ZDF HD", key: "5", gridKey: "gk-5")
-
- let result = await server.tune(channel: channel, maximumBitrate: nil)
- let url = try #require(result.url)
- let items = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems)
-
- // Die Adresse der Sitzung, nicht die des Senders und nicht die der
- // Sendung — der Transcoder sucht sich den Teil darin selbst.
- let path = try #require(items.first { $0.name == "path" }?.value)
- #expect(path.hasPrefix("/livetv/sessions/"))
- // Ein laufender Sender hat keine Fassungen zur Auswahl.
- #expect(items.first { $0.name == "mediaIndex" }?.value == "-1")
- // Ohne die Gerätekennung in der Adresse kann der Server die Abholung
- // keiner Sitzung zuordnen: AVPlayer schickt keine Kopfzeilen mit.
- #expect(items.contains { $0.name == "X-Plex-Client-Identifier" })
- }
-
- @Test("Der Raster-Schlüssel wird zuerst versucht, und nie ein ganzer Pfad")
- func sessionKeyOrder() {
- let channel = PlexChannel(id: "hash-5", title: "ZDF HD", key: "5", gridKey: "gk-5")
- let program = PlexProgram(
- id: "p1",
- title: "heute",
- startsAt: .now,
- endsAt: .now.addingTimeInterval(1800),
- metadataKey: "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2Fabc"
- )
-
- let keys = PlexServer.sessionKeys(channel: channel, program: program)
-
- #expect(keys.first == "gk-5")
- // An `/livetv/sessions//` gehört ein Schlüssel, kein zweiter
- // Pfad — auch nicht nach dem Entpacken.
- #expect(keys.allSatisfy { !$0.contains("/") })
- #expect(keys.contains("5"))
- }
-
- @Test("Die nachgeschlagene Sendung liefert die abspielbare Adresse")
- func usesPartFromLookup() async throws {
- // Das Raster nennt keine Teil-Adresse, die Einzelabfrage schon. Sie
- // blind an den Transcoder zu geben, ergab dreimal eine nackte 400:
- // eine Sendung beim Cloud-Programmführer hat keine Datei.
- let metadata = """
- {"MediaContainer":{"size":1,"Metadata":[
- {"ratingKey":"epg-1","key":"/tv.plex.providers.epg.cloud:2/metadata/x","type":"movie",
- "title":"Tagesschau",
- "Media":[{"id":1,"Part":[{"id":1,"key":"/livetv/sessions/xyz/stream"}]}]}
- ]}}
- """
- let server = server(routes: [
- "/metadata/plex%3A%2F%2Fmovie%2Fabc": metadata,
- "/start.m3u8": "#EXTM3U",
- ])
- let channel = PlexChannel(id: "5", title: "SRF 1 HD", key: "5")
- let program = PlexProgram(
- id: "p1",
- title: "Tagesschau",
- startsAt: .now,
- endsAt: .now.addingTimeInterval(1800),
- metadataKey: "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2Fabc"
- )
-
- let result = await server.tune(channel: channel, program: program, maximumBitrate: nil)
- let url = try #require(result.url)
- let path = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?
- .queryItems?.first { $0.name == "path" }?.value)
-
- #expect(path == "/livetv/sessions/xyz/stream")
- }
-
- @Test("Der Tuner wird per POST belegt, und die Antwort trägt den Stream")
- func tunesThroughDVRWithPost() async throws {
- // Nachgelesen im Quelltext von Plex' eigenem Client:
- // POST /livetv/dvrs/{dvr}/channels/{kennung}/tune
- // Genau diesen Pfad hatte ich seit Runden richtig — und jedes Mal
- // mit GET abgefragt. Die Antwort darauf ist 404, und das sah aus wie
- // „gibt es nicht" statt wie „nicht so".
- let tuned = """
- {"MediaContainer":{"size":1,"MediaSubscription":[{"key":"77",
- "MetadataItem":{"ratingKey":"live-77","key":"/livetv/sessions/abc","type":"clip",
- "title":"blue ZOOM HD",
- "Media":[{"id":9,"Part":[{"id":9,
- "key":"/livetv/sessions/abc/SITZUNG/index.m3u8?offset=-1.000000"}]}]}}]}}
- """
- let server = server(routes: [
- "/channels/6228f7f2-5fc705fa/tune": tuned,
- "/index.m3u8": "#EXTM3U",
- ])
- let channel = PlexChannel(id: "6228f7f2-5fc705fa", title: "blue ZOOM HD", key: "1")
- let dvr = PlexDVR(key: "2")
-
- let result = await server.tune(channel: channel, dvr: dvr, maximumBitrate: nil)
- let url = try #require(result.url)
-
- // Unmittelbar abgerufen, nicht umgerechnet: was beim Belegen
- // herauskommt, ist bereits eine fertige HLS-Abspielliste.
- #expect(url.path() == "/livetv/sessions/abc/SITZUNG/index.m3u8")
- #expect(!url.absoluteString.contains("/transcode/"))
- // Die eigene Abfrage der Sitzung darf nicht verlorengehen.
- let items = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems)
- #expect(items.contains { $0.name == "offset" && $0.value == "-1.000000" })
- // Und der Systemplayer schickt keine Kopfzeilen mit.
- #expect(items.contains { $0.name == "X-Plex-Token" })
- #expect(items.contains { $0.name == "X-Plex-Client-Identifier" })
- #expect(result.trace.contains { $0.path.hasPrefix("POST ") })
- // Womit der Tuner hinterher wieder freigegeben wird.
- #expect(result.subscriptionKey == "77")
- }
-
- @Test("Umgerechnet wird die Sitzung des Servers, nicht die eigene Kennung")
- func transcodesTheServersSession() throws {
- // `/livetv/sessions/1268be73-…` ist die Sitzung, die der Server beim
- // Belegen angelegt hat. Ich habe mehrfach meine eigene erzeugte
- // Kennung eingesetzt und mich über 404 gewundert.
- let json = Data("""
- {"MediaContainer":{"MediaSubscription":[{"key":"77","MetadataItem":{
- "key":"/livetv/sessions/1268be73-1598-4fb9-ac9e-35825eba3fdd","live":"1",
- "Media":[{"id":1,"Part":[{"key":"/livetv/sessions/1268be73/ABC/index.m3u8?offset=-1"}]}]}}]}}
- """.utf8)
-
- // Die Abspielliste selbst ist nicht gemeint — sie endet auf .m3u8.
- #expect(PlexServer.firstSessionKey(in: json)
- == "/livetv/sessions/1268be73-1598-4fb9-ac9e-35825eba3fdd")
- }
-
- @Test("Das Geräteprofil zerfällt nicht in eigene Parameter")
- func encodesProfileExtra() async throws {
- // `URLComponents` lässt `&` und `=` in einem Wert unangetastet. Das
- // Geräteprofil steckt voll davon, und der Server las es als zwei
- // Dutzend eigene Angaben — darunter ein zweites `protocol=`. Der
- // Fehler steckte in jeder Transcoder-Adresse, seit es sie gibt.
- let server = server(routes: [:])
- let url = try #require(
- await server.transcodeURL(for: TranscodeRequest(ratingKey: "5", isLocal: true))
- )
- let text = url.absoluteString
- let profile = try #require(text.split(separator: "&")
- .first { $0.hasPrefix("X-Plex-Client-Profile-Extra=") })
-
- // Das ganze Profil steht in genau einem Parameter.
- #expect(profile.contains("%26"))
- #expect(profile.contains("add-transcode-target%28"))
- #expect(profile.contains("%2B"))
- // Und `protocol` kommt nur ein einziges Mal vor.
- #expect(text.split(separator: "&").filter { $0.hasPrefix("protocol=") }.count == 1)
- }
-
- @Test("Jede Wiedergabe nennt ein Basisprofil")
- func alwaysNamesBaseProfile() async throws {
- // Ohne `X-Plex-Client-Profile-Name` lehnt der Server jede Anfrage an
- // den Transcoder mit einer nackten 400 ab. `…-Profile-Extra`
- // erweitert ein Profil, es ersetzt keins — ohne Basis gibt es nichts
- // zu erweitern. Das hat zwei Tage gekostet.
- let server = server(routes: [:])
- let url = try #require(
- await server.transcodeURL(for: TranscodeRequest(ratingKey: "5", isLocal: true))
- ).absoluteString
-
- #expect(url.contains("X-Plex-Client-Profile-Name=Chrome"))
- }
-
- @Test("Jede Wiedergabe meldet, dass sie die Entscheidungsantwort versteht")
- func alwaysSendsHasMDE() {
- // Plex' eigener Client schickt `hasMDE=1` bei jeder Wiedergabe mit.
- // Ohne diese Angabe lehnt der Server eine Live-Sitzung mit einer
- // nackten 400 ab — ohne zu sagen, was ihm fehlt.
- let items = TranscodeRequest(ratingKey: "5", isLocal: true).queryItems
-
- #expect(items.contains { $0.name == "hasMDE" && $0.value == "1" })
- }
-
- @Test("Die Abspieladresse kommt aus der Sitzung, nicht aus der Tune-Antwort")
- func prefersPartFromSession() async throws {
- // Beide Antworten nennen eine Adresse derselben Form, aber mit
- // verschiedenen Kennungen darin: die Tune-Antwort setzt die
- // Sitzungskennung ein, die Sitzung selbst die Gerätekennung. Nur die
- // zweite gibt es wirklich — die erste ergab jedes Mal 404.
- let tuned = """
- {"MediaContainer":{"size":1,"MediaSubscription":[{"key":"22",
- "MetadataItem":{"key":"/livetv/sessions/abc","live":"1",
- "Media":[{"id":1,"Part":[{"key":"/livetv/sessions/abc/SITZUNGSKENNUNG/index.m3u8"}]}]}}]}}
- """
- let session = """
- {"MediaContainer":{"size":1,"Metadata":[{"key":"/livetv/sessions/abc","live":"1",
- "Media":[{"id":1,"Part":[{"key":"/livetv/sessions/abc/GERAETEKENNUNG/index.m3u8?offset=-1.000000"}]}]}]}}
- """
- let server = server(routes: [
- "/channels/kanal-7/tune": tuned,
- "/livetv/sessions/abc": session,
- // Nur unter der Gerätekennung liefert der Server eine
- // Abspielliste; die andere Form gibt es nicht.
- "/GERAETEKENNUNG/index.m3u8": "#EXTM3U",
- ])
- let channel = PlexChannel(id: "kanal-7", title: "SRF 1 HD", key: "2")
-
- let result = await server.tune(channel: channel, dvr: PlexDVR(key: "2"), maximumBitrate: nil)
- let url = try #require(result.url)
-
- #expect(url.path() == "/livetv/sessions/abc/GERAETEKENNUNG/index.m3u8")
- #expect(!url.absoluteString.contains("SITZUNGSKENNUNG"))
- #expect(!url.absoluteString.contains("/transcode/"))
- }
-
- @Test("Die Teil-Adresse wird gefunden, egal wie tief sie liegt")
- func findsPartAtAnyDepth() throws {
- // Der Vorgang liegt je nach Serverfassung unterschiedlich tief
- // verschachtelt. Ein fest verdrahteter Weg hinein sähe wieder aus
- // wie „nichts da".
- let json = Data("""
- {"MediaContainer":{"Tief":{"Tiefer":[{"Media":[{"Part":[{"key":"/gefunden"}]}]}]}}}
- """.utf8)
-
- #expect(PlexServer.firstPartKey(in: json) == "/gefunden")
- #expect(PlexServer.firstPartKey(in: Data("{}".utf8)) == nil)
- }
-
- @Test("Die Zuordnung übersetzt zwischen Programmführer und Empfänger")
- func translatesChannelKeyToDeviceChannel() {
- // Der Programmführer kennt den Sender als `6228f7f2…-5fc706…`, der
- // Tuner als `300`. Ohne diese Übersetzung spricht man den Empfänger
- // in einer Sprache an, die er nicht versteht — und bekommt 404 auf
- // alles. Genau daran ist das Einschalten reihenweise gescheitert.
- let dvr = PlexDVR(
- key: "2",
- devices: [PlexTunerDevice(key: "1", channelMappings: [
- PlexChannelMapping(
- channelKey: "6228f7f2364e02fa5ec8e51c-5fc706063f45c6002eab58a3",
- deviceIdentifier: "300",
- lineupIdentifier: "002"
- ),
- PlexChannelMapping(
- channelKey: "6228f7f2364e02fa5ec8e51c-5fc705fa6b022a002d77f212",
- deviceIdentifier: "299",
- lineupIdentifier: "001"
- ),
- ])]
- )
-
- #expect(dvr.deviceChannel(forChannelKey: "6228f7f2364e02fa5ec8e51c-5fc706063f45c6002eab58a3") == "300")
- #expect(dvr.deviceChannel(forChannelKey: "6228f7f2364e02fa5ec8e51c-5fc705fa6b022a002d77f212") == "299")
- // Auch ohne den Vorsatz der Senderliste, denn die Kennung kommt je
- // nach Quelle mit oder ohne ihn.
- #expect(dvr.deviceChannel(forChannelKey: "5fc706063f45c6002eab58a3") == "300")
- #expect(dvr.deviceChannel(forChannelKey: "unbekannt") == nil)
- }
-
- @Test("Die Sender des Tuners stehen unter DeviceChannel")
- func readsDeviceChannelKey() async throws {
- // Der Server lieferte 278 Sender, die Hülle verwarf sie: sie kannte
- // nur `Channel`. Der Tuner schreibt `DeviceChannel`.
- let json = """
- {"MediaContainer":{"size":2,"DeviceChannel":[
- {"drm":false,"favorite":false,"hd":true,"identifier":"300","key":"300","name":"ZDF HD"},
- {"hd":true,"identifier":"301","key":"301","name":"SRF 1 HD"}
- ]}}
- """
- let server = server(routes: ["/media/grabbers/devices/1/channels": json])
- let withTuner = PlexDVR(
- key: "2",
- devices: [PlexTunerDevice(key: "1")],
- epgIdentifier: "tv.plex.providers.epg.cloud:2"
- )
-
- let lookup = await server.channels(inDVR: withTuner)
-
- #expect(lookup.channels.map(\.title) == ["ZDF HD", "SRF 1 HD"])
- // Die Nummer steht beim Tuner schlicht im Schlüssel.
- #expect(lookup.channels.first?.channelIdentifier == "300")
- }
-}
diff --git a/Packages/FlixyKit/Tests/PlexKitTests/MediaTranslationTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/MediaTranslationTests.swift
deleted file mode 100644
index 2882d23..0000000
--- a/Packages/FlixyKit/Tests/PlexKitTests/MediaTranslationTests.swift
+++ /dev/null
@@ -1,79 +0,0 @@
-import Foundation
-import Testing
-import MediaKit
-@testable import PlexKit
-
-/// Die Übersetzung der Plex-Modelle in die neutrale Form.
-///
-/// Gegen eine echte Server-Antwort, nicht gegen erfundene Werte: die
-/// Eigenheiten, an denen eine Übersetzung scheitert, stehen genau dort —
-/// „grandparent" für die Serie, Sekunden seit 1970 als Zahl, Fassungen und
-/// Dateien ineinander verschachtelt.
-@Suite("Übersetzung nach MediaKit")
-struct MediaTranslationTests {
-
- private func item(_ fixture: String) throws -> PlexItem {
- let url = try #require(Bundle.module.url(forResource: "Fixtures/\(fixture)", withExtension: "json"))
- let container = try PlexResponse.decode(
- PlexMediaContainer.self,
- from: try Data(contentsOf: url)
- )
- return try #require(container.content.first)
- }
-
- @Test("Aus dem Großelternteil wird die Serie")
- func translatesTheGrandparent() throws {
- let episode = try item("episodes-unanalyzed").asMediaItem
-
- // Der Name war von Anfang an irreführend: gemeint ist die Serie, und
- // in jeder Ansicht musste man ihn im Kopf übersetzen.
- #expect(episode.kind == .episode)
- #expect(episode.title == "Die erste Folge")
- #expect(episode.seriesTitle == "Eine Serie")
- #expect(episode.index == 1)
- #expect(episode.parentIndex == 1)
- }
-
- @Test("Fassungen und Dateien kommen vollständig mit")
- func translatesVersions() throws {
- let movie = try item("movie-with-two-versions").asMediaItem
-
- #expect(movie.versions.count == 2)
- #expect(movie.hasMultipleVersions)
-
- let first = try #require(movie.versions.first)
- #expect(first.files.isEmpty == false)
- #expect(first.container != nil)
- }
-
- @Test("Die Wiedergabe-Entscheidung bekommt dasselbe Profil wie vorher")
- func buildsTheSameProfile() throws {
- // Beide Wege müssen dasselbe sagen — sonst spielte ein Titel nach dem
- // Umbau plötzlich über einen anderen Player.
- let plex = try item("movie-with-two-versions")
- let media = try #require(plex.media.first)
- let part = try #require(media.parts.first)
-
- let viaPlex = media.profile(part: part)
- let viaMedia = try #require(plex.asMediaItem.versions.first)
- .profile(file: plex.asMediaItem.versions.first?.files.first)
-
- #expect(viaPlex == viaMedia)
- }
-
- @Test("Ohne Fortschritt gibt es keinen Balken")
- func leavesUnstartedItemsAlone() throws {
- let movie = try item("movie-with-two-versions").asMediaItem
-
- // `watchProgress` ist bewusst `nil` statt 0: ein Balken bei null
- // Prozent sieht aus wie ein Fehler, nicht wie „noch nicht begonnen".
- if movie.resumeMilliseconds == nil || movie.resumeMilliseconds == 0 {
- #expect(movie.watchProgress == nil)
- }
- }
-
- @Test("Plex kennt keine Favoriten und behauptet auch keine")
- func doesNotInventFavourites() throws {
- #expect(try item("movie-with-two-versions").asMediaItem.isFavourite == false)
- }
-}
diff --git a/Packages/FlixyKit/Tests/PlexKitTests/NextEpisodeTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/NextEpisodeTests.swift
deleted file mode 100644
index 0fe571c..0000000
--- a/Packages/FlixyKit/Tests/PlexKitTests/NextEpisodeTests.swift
+++ /dev/null
@@ -1,158 +0,0 @@
-import Foundation
-import Testing
-@testable import PlexKit
-
-/// Die Suche nach der nächsten Folge.
-///
-/// Plex kennt dafür keinen Endpunkt — der Weg führt über die Geschwister, und
-/// am Staffelende über die nächste Staffel. Genau dort bricht es ohne diese
-/// Logik ab, und zwar an der Stelle, an der man es am wenigsten gebrauchen
-/// kann: mitten in einer Serie.
-@Suite("Nächste Folge")
-struct NextEpisodeTests {
-
- private func episode(
- ratingKey: String,
- index: Int,
- season: Int,
- seasonKey: String,
- showKey: String = "show-1"
- ) -> String {
- """
- {
- "ratingKey": "\(ratingKey)", "type": "episode", "title": "Folge \(index)",
- "index": \(index), "parentIndex": \(season),
- "parentRatingKey": "\(seasonKey)", "grandparentRatingKey": "\(showKey)",
- "grandparentTitle": "Eine Serie"
- }
- """
- }
-
- private func container(_ items: [String]) -> String {
- """
- {"MediaContainer":{"size":\(items.count),"Metadata":[\(items.joined(separator: ","))]}}
- """
- }
-
- private func season(ratingKey: String, index: Int) -> String {
- """
- {"ratingKey":"\(ratingKey)","type":"season","title":"Staffel \(index)","index":\(index)}
- """
- }
-
- private func server(routes: [String: String]) -> PlexServer {
- PlexServer(
- resource: PlexResource(
- name: "Test",
- clientIdentifier: "srv",
- accessToken: "token",
- connections: [PlexConnection(uri: "http://server:32400", local: true)]
- ),
- connection: PlexConnection(uri: "http://server:32400", local: true),
- identity: .test,
- transport: FakeTransport.routes(routes)
- )!
- }
-
- private func current(index: Int, season: Int = 1, seasonKey: String = "season-1") throws -> PlexItem {
- let data = Data(container([episode(ratingKey: "e\(index)", index: index, season: season, seasonKey: seasonKey)]).utf8)
- let decoded = try PlexResponse.decode(PlexMediaContainer.self, from: data)
- return try #require(decoded.content.first)
- }
-
- @Test("Innerhalb einer Staffel kommt die nächsthöhere Folge")
- func nextWithinSeason() async throws {
- let server = server(routes: [
- "/library/metadata/season-1/children": container([
- episode(ratingKey: "e1", index: 1, season: 1, seasonKey: "season-1"),
- episode(ratingKey: "e2", index: 2, season: 1, seasonKey: "season-1"),
- episode(ratingKey: "e3", index: 3, season: 1, seasonKey: "season-1"),
- ]),
- ])
-
- let next = try await server.nextEpisode(after: try current(index: 1))
- #expect(next?.ratingKey == "e2")
- }
-
- @Test("Lücken in der Nummerierung überspringen nichts Falsches")
- func handlesGapsInNumbering() async throws {
- // Fehlt eine Folge auf der Platte, darf nicht die Folge mit der
- // höchsten Nummer kommen, sondern die nächsterreichbare.
- let server = server(routes: [
- "/library/metadata/season-1/children": container([
- episode(ratingKey: "e1", index: 1, season: 1, seasonKey: "season-1"),
- episode(ratingKey: "e5", index: 5, season: 1, seasonKey: "season-1"),
- episode(ratingKey: "e9", index: 9, season: 1, seasonKey: "season-1"),
- ]),
- ])
-
- let next = try await server.nextEpisode(after: try current(index: 1))
- #expect(next?.ratingKey == "e5")
- }
-
- @Test("Am Staffelende geht es in der nächsten Staffel weiter")
- func rollsOverToNextSeason() async throws {
- let server = server(routes: [
- "/library/metadata/season-1/children": container([
- episode(ratingKey: "e1", index: 1, season: 1, seasonKey: "season-1"),
- episode(ratingKey: "e2", index: 2, season: 1, seasonKey: "season-1"),
- ]),
- "/library/metadata/show-1/children": container([
- season(ratingKey: "season-1", index: 1),
- season(ratingKey: "season-2", index: 2),
- ]),
- "/library/metadata/season-2/children": container([
- episode(ratingKey: "s2e1", index: 1, season: 2, seasonKey: "season-2"),
- episode(ratingKey: "s2e2", index: 2, season: 2, seasonKey: "season-2"),
- ]),
- ])
-
- let next = try await server.nextEpisode(after: try current(index: 2))
- #expect(next?.ratingKey == "s2e1")
- }
-
- @Test("Nach der letzten Folge der letzten Staffel kommt nichts mehr")
- func stopsAtTheEnd() async throws {
- let server = server(routes: [
- "/library/metadata/season-1/children": container([
- episode(ratingKey: "e1", index: 1, season: 1, seasonKey: "season-1"),
- ]),
- "/library/metadata/show-1/children": container([season(ratingKey: "season-1", index: 1)]),
- ])
-
- let next = try await server.nextEpisode(after: try current(index: 1))
- #expect(next == nil)
- }
-
- @Test("Ein Film hat keine nächste Folge")
- func moviesHaveNoNext() async throws {
- let data = Data(#"{"MediaContainer":{"Metadata":[{"ratingKey":"m1","type":"movie","title":"Ein Film"}]}}"#.utf8)
- let movie = try #require(try PlexResponse.decode(PlexMediaContainer.self, from: data).content.first)
-
- let next = try await server(routes: [:]).nextEpisode(after: movie)
- #expect(next == nil)
- }
-
- @Test("Staffel 0 mit Specials wird nicht vorgezogen")
- func specialsDoNotJumpAhead() async throws {
- // Plex führt Specials als Staffel 0. Sie stehen in der Liste vor der
- // ersten regulären Staffel — würde einfach die erste genommen, landete
- // man nach dem Staffelfinale bei einem Weihnachtsspecial.
- let server = server(routes: [
- "/library/metadata/season-1/children": container([
- episode(ratingKey: "e1", index: 1, season: 1, seasonKey: "season-1"),
- ]),
- "/library/metadata/show-1/children": container([
- season(ratingKey: "season-0", index: 0),
- season(ratingKey: "season-1", index: 1),
- season(ratingKey: "season-2", index: 2),
- ]),
- "/library/metadata/season-2/children": container([
- episode(ratingKey: "s2e1", index: 1, season: 2, seasonKey: "season-2"),
- ]),
- ])
-
- let next = try await server.nextEpisode(after: try current(index: 1))
- #expect(next?.ratingKey == "s2e1")
- }
-}
diff --git a/Packages/FlixyKit/Tests/PlexKitTests/PlexDecodingTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/PlexDecodingTests.swift
deleted file mode 100644
index 71b22be..0000000
--- a/Packages/FlixyKit/Tests/PlexKitTests/PlexDecodingTests.swift
+++ /dev/null
@@ -1,203 +0,0 @@
-import Foundation
-import Testing
-@testable import PlexKit
-
-/// Lädt eine Antwort-Vorlage aus dem Test-Bundle.
-private func fixture(_ name: String) throws -> Data {
- let url = try #require(
- Bundle.module.url(forResource: "Fixtures/\(name)", withExtension: "json"),
- "Vorlage \(name).json nicht im Bundle — steht sie in Package.swift unter resources?"
- )
- return try Data(contentsOf: url)
-}
-
-@Suite("Antworten lesen")
-struct PlexDecodingTests {
-
- @Test("Ein Film mit zwei Fassungen wird vollständig gelesen")
- func decodesMovieWithTwoVersions() throws {
- let container = try PlexResponse.decode(
- PlexMediaContainer.self,
- from: try fixture("movie-with-two-versions")
- )
-
- let movie = try #require(container.content.first)
- #expect(movie.ratingKey == "45231")
- #expect(movie.kind == .movie)
- #expect(movie.title == "Blade Runner 2049")
- #expect(movie.year == 2017)
- #expect(movie.hasMultipleVersions)
- #expect(movie.media.count == 2)
- }
-
- @Test("Die Fassung mit der höchsten Bitrate wird vorgewählt")
- func prefersHighestBitrateVersion() throws {
- let container = try PlexResponse.decode(
- PlexMediaContainer.self,
- from: try fixture("movie-with-two-versions")
- )
- let movie = try #require(container.content.first)
- let preferred = try #require(movie.preferredMedia)
-
- #expect(preferred.videoResolution == "4k")
- #expect(preferred.bitrate == 58420)
- }
-
- @Test("Der Fortschritt wird als Anteil berechnet")
- func computesWatchProgress() throws {
- let container = try PlexResponse.decode(
- PlexMediaContainer.self,
- from: try fixture("movie-with-two-versions")
- )
- let movie = try #require(container.content.first)
- let progress = try #require(movie.watchProgress)
-
- // 4 892 000 von 9 784 000 Millisekunden — genau die Hälfte.
- #expect(abs(progress - 0.5) < 0.001)
- }
-
- @Test("Spuren werden nach Art getrennt")
- func separatesStreamsByKind() throws {
- let container = try PlexResponse.decode(
- PlexMediaContainer.self,
- from: try fixture("movie-with-two-versions")
- )
- let part = try #require(container.content.first?.preferredMedia?.parts.first)
-
- #expect(part.videoStream?.codec == "hevc")
- #expect(part.audioStreams.count == 2)
- #expect(part.subtitleStreams.count == 2)
- #expect(part.selectedAudioStream?.language == "Deutsch")
- }
-
- @Test("Untertitel als eigene Datei werden erkannt")
- func detectsExternalSubtitles() throws {
- let container = try PlexResponse.decode(
- PlexMediaContainer.self,
- from: try fixture("movie-with-two-versions")
- )
- let part = try #require(container.content.first?.preferredMedia?.parts.first)
- let subtitles = part.subtitleStreams
-
- // Die SRT-Spur liegt als Datei daneben und lässt sich deshalb auch bei
- // DirectPlay dazuschalten; die PGS-Spur steckt im Container.
- #expect(subtitles.first { $0.codec == "subrip" }?.isExternal == true)
- #expect(subtitles.first { $0.codec == "pgs" }?.isExternal == false)
- #expect(subtitles.first { $0.codec == "pgs" }?.isForced == true)
- }
-
- @Test("Folgen ohne analysierte Datei brechen das Lesen nicht ab")
- func toleratesUnanalyzedEpisodes() throws {
- // Der wichtigste Fall aus der Praxis: Plex liefert Einträge aus, bevor
- // es die Dateien durchgesehen hat. Ein nicht-optionales Feld würde
- // hier die ganze Staffel unlesbar machen statt nur eine Angabe.
- let container = try PlexResponse.decode(
- PlexMediaContainer.self,
- from: try fixture("episodes-unanalyzed")
- )
-
- #expect(container.content.count == 2)
-
- let first = try #require(container.content.first)
- #expect(first.media.first?.parts.first?.streams.isEmpty == true)
-
- // Die zweite Folge hat gar keinen `Media`-Block.
- let second = try #require(container.content.last)
- #expect(second.media.isEmpty)
- #expect(second.title == "Die zweite Folge")
- #expect(second.index == 2)
- }
-
- @Test("Serienzugehörigkeit von Folgen wird gelesen")
- func decodesEpisodeHierarchy() throws {
- let container = try PlexResponse.decode(
- PlexMediaContainer.self,
- from: try fixture("episodes-unanalyzed")
- )
- let episode = try #require(container.content.first)
-
- #expect(episode.kind == .episode)
- #expect(episode.index == 1)
- #expect(episode.parentIndex == 1)
- #expect(episode.grandparentTitle == "Eine Serie")
- #expect(episode.grandparentRatingKey == "77700")
- }
-
- @Test("Ungesehene Titel haben keinen Fortschritt")
- func unwatchedItemsHaveNoProgress() throws {
- let container = try PlexResponse.decode(
- PlexMediaContainer.self,
- from: try fixture("episodes-unanalyzed")
- )
- // Ohne diese Unterscheidung bekäme jede ungesehene Kachel einen
- // Fortschrittsbalken der Breite null — sichtbarer Strich, keine Aussage.
- #expect(container.content.allSatisfy { $0.watchProgress == nil })
- #expect(container.content.allSatisfy { !$0.isWatched })
- }
-
- @Test("Kaputte Antworten melden, woran es lag")
- func decodingErrorsCarryDetail() {
- let garbage = Data(#"{"MediaContainer":{"Metadata":[{"title":"ohne ratingKey"}]}}"#.utf8)
-
- #expect(throws: PlexError.self) {
- try PlexResponse.decode(PlexMediaContainer.self, from: garbage)
- }
-
- do {
- _ = try PlexResponse.decode(PlexMediaContainer.self, from: garbage)
- Issue.record("Hätte scheitern müssen")
- } catch let error as PlexError {
- guard case .decoding(let description) = error else {
- Issue.record("Falsche Fehlerart: \(error)")
- return
- }
- // Der Schlüsselpfad muss drinstehen — sonst ist beim Nachziehen
- // einer geänderten Plex-Version nicht zu erkennen, welches Feld
- // gewandert ist.
- #expect(description.contains("ratingKey"))
- } catch {
- Issue.record("Unerwarteter Fehler: \(error)")
- }
- }
-}
-
-@Suite("HTTP-Status übersetzen")
-struct PlexResponseValidationTests {
- private func response(_ status: Int) -> HTTPURLResponse {
- HTTPURLResponse(
- url: URL(string: "http://server:32400/library/sections")!,
- statusCode: status,
- httpVersion: nil,
- headerFields: nil
- )!
- }
-
- @Test("Erfolgsstatus geben die Daten durch", arguments: [200, 201, 204])
- func successPassesDataThrough(status: Int) throws {
- let payload = Data("inhalt".utf8)
- #expect(try PlexResponse.validate(data: payload, response: response(status)) == payload)
- }
-
- @Test("401 und 403 führen zurück zur Anmeldung", arguments: [401, 403])
- func authenticationFailuresMapToUnauthorized(status: Int) {
- #expect(throws: PlexError.unauthorized) {
- try PlexResponse.validate(data: Data(), response: response(status))
- }
- }
-
- @Test("404 ist ein fehlender Eintrag")
- func notFoundMaps() {
- #expect(throws: PlexError.notFound) {
- try PlexResponse.validate(data: Data(), response: response(404))
- }
- }
-
- @Test("Serverfehler dürfen wiederholt werden, Anfragefehler nicht")
- func retryabilityFollowsStatusClass() {
- #expect(PlexError.server(status: 503).isRetryable)
- #expect(PlexError.server(status: 500).isRetryable)
- #expect(!PlexError.server(status: 400).isRetryable)
- #expect(!PlexError.unauthorized.isRetryable)
- #expect(PlexError.transport(description: "Zeitüberschreitung").isRetryable)
- }
-}
diff --git a/Packages/FlixyKit/Tests/PlexKitTests/Support/FakeTransport.swift b/Packages/FlixyKit/Tests/PlexKitTests/Support/FakeTransport.swift
deleted file mode 100644
index 7dcdf68..0000000
--- a/Packages/FlixyKit/Tests/PlexKitTests/Support/FakeTransport.swift
+++ /dev/null
@@ -1,81 +0,0 @@
-import Foundation
-@testable import PlexKit
-
-/// Ein Transport, der vorbereitete Antworten liefert.
-///
-/// Statt `URLProtocol` zu unterwandern: das ist global und bringt bei parallel
-/// laufenden Tests die Antworten durcheinander. Hier hat jeder Test seinen
-/// eigenen Transport.
-struct FakeTransport: PlexTransport {
- let handler: @Sendable (URLRequest) async throws -> (Data, URLResponse)
-
- func data(for request: URLRequest) async throws -> (Data, URLResponse) {
- try await handler(request)
- }
-
- /// Antwortet auf jeden Pfad, der als Schlüssel hinterlegt ist, und mit 404
- /// auf alle übrigen.
- static func routes(
- _ routes: [String: String],
- status: Int = 200
- ) -> FakeTransport {
- FakeTransport { request in
- let path = request.url?.path() ?? ""
- guard let body = routes.first(where: { path.hasSuffix($0.key) })?.value else {
- return (Data(), response(for: request, status: 404))
- }
- return (Data(body.utf8), response(for: request, status: status))
- }
- }
-
- /// Verweigert jede Verbindung — für die Wege, die im Test nicht gewinnen
- /// sollen.
- static func unreachable() -> FakeTransport {
- FakeTransport { _ in throw URLError(.cannotConnectToHost) }
- }
-
- /// Antwortet je nach Adresse unterschiedlich — und lässt einzelne Adressen
- /// ins Leere laufen.
- ///
- /// Bildet den Fall nach, der in der Wirklichkeit am meisten weh tut: eine
- /// lokale Adresse, die nicht ablehnt, sondern schweigt. Ein abgelehnter
- /// Verbindungsversuch ist sofort vorbei; ein schweigender läuft bis zur
- /// Zeitgrenze.
- static func hosts(
- answering answers: [String: String],
- hanging: Set = []
- ) -> FakeTransport {
- FakeTransport { request in
- let host = request.url?.host() ?? ""
- if hanging.contains(host) {
- // Länger als jede Zeitgrenze, die ein Test setzen würde.
- try await Task.sleep(for: .seconds(3600))
- }
- guard let body = answers[host] else {
- throw URLError(.cannotConnectToHost)
- }
- return (Data(body.utf8), response(for: request, status: 200))
- }
- }
-
- static func response(for request: URLRequest, status: Int) -> HTTPURLResponse {
- HTTPURLResponse(
- url: request.url ?? URL(string: "http://example.invalid")!,
- statusCode: status,
- httpVersion: nil,
- headerFields: nil
- )!
- }
-}
-
-extension PlexClientIdentity {
- static let test = PlexClientIdentity(
- product: "FlixyTests",
- version: "0",
- clientIdentifier: "test-client",
- platform: "iOS",
- platformVersion: "26.0",
- device: "iPhone",
- deviceName: "Testgerät"
- )
-}
diff --git a/Packages/FlixyKit/Tests/PlexKitTests/TranscodeRequestTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/TranscodeRequestTests.swift
deleted file mode 100644
index 4e99f31..0000000
--- a/Packages/FlixyKit/Tests/PlexKitTests/TranscodeRequestTests.swift
+++ /dev/null
@@ -1,186 +0,0 @@
-import Foundation
-import Testing
-@testable import PlexKit
-
-@Suite("Transcode-Anfrage")
-struct TranscodeRequestTests {
- private func value(_ request: TranscodeRequest, _ name: String) -> String? {
- request.queryItems.first { $0.name == name }?.value
- }
-
- @Test("Der Pfad zeigt auf den Titel, nicht auf die Datei")
- func pathPointsAtMetadata() {
- // Plex erwartet hier die Metadaten-Adresse. Gibt man den Dateipfad an,
- // antwortet der Transcoder mit 404.
- let request = TranscodeRequest(ratingKey: "45231", isLocal: true)
- #expect(value(request, "path") == "/library/metadata/45231")
- }
-
- @Test("Umpacken ist erlaubt, DirectPlay ausdrücklich nicht")
- func allowsDirectStreamButNotDirectPlay() {
- // directStream=1 lässt Plex nur den Container wechseln, wenn der
- // Bildcodec passt — das spart die Neukodierung. directPlay=0, weil
- // diese Frage der PlaybackDecider bereits entschieden hat.
- let request = TranscodeRequest(ratingKey: "1", isLocal: true)
- #expect(value(request, "directStream") == "1")
- #expect(value(request, "directPlay") == "0")
- }
-
- @Test("Ohne fastSeek müsste der Transcoder beim Springen von vorn beginnen")
- func enablesFastSeek() {
- #expect(value(TranscodeRequest(ratingKey: "1", isLocal: true), "fastSeek") == "1")
- }
-
- @Test("Die Bitratengrenze bringt eine passende Qualitätsstufe mit")
- func bitrateCarriesQuality() {
- // Ohne videoQuality ignoriert Plex die Grenze bei manchen Fassungen.
- let request = TranscodeRequest(ratingKey: "1", maximumBitrate: 4_000, isLocal: false)
- #expect(value(request, "maxVideoBitrate") == "4000")
- #expect(value(request, "videoQuality") != nil)
- }
-
- @Test("Höhere Bitraten ergeben höhere Qualitätsstufen")
- func qualityRisesWithBitrate() {
- func quality(_ bitrate: Int) -> Int {
- Int(value(TranscodeRequest(ratingKey: "1", maximumBitrate: bitrate, isLocal: true), "videoQuality") ?? "0")!
- }
- #expect(quality(2_000) < quality(8_000))
- #expect(quality(8_000) < quality(20_000))
- #expect(quality(20_000) <= 100)
- }
-
- @Test("Ohne Grenze steht keine Bitrate in der Anfrage")
- func noBitrateWhenUnlimited() {
- // Ein leerer Wert ist für Plex nicht dasselbe wie ein fehlender — er
- // würde als Grenze von null gelesen.
- let request = TranscodeRequest(ratingKey: "1", isLocal: true)
- #expect(value(request, "maxVideoBitrate") == nil)
- #expect(value(request, "videoQuality") == nil)
- }
-
- @Test("Lokal und außer Haus werden unterschieden")
- func distinguishesLocation() {
- #expect(value(TranscodeRequest(ratingKey: "1", isLocal: true), "location") == "lan")
- #expect(value(TranscodeRequest(ratingKey: "1", isLocal: false), "location") == "wan")
- }
-
- @Test("Die Sitzungskennung steht in beiden Feldern")
- func sessionAppearsTwice() {
- // Plex erwartet sie sowohl als `session` als auch als
- // `X-Plex-Session-Identifier`. Fehlt eines, lässt sich der Transcoder
- // später nicht gezielt anhalten — und er rechnet minutenlang weiter.
- let request = TranscodeRequest(ratingKey: "1", isLocal: true, sessionIdentifier: "sitzung-1")
- #expect(value(request, "session") == "sitzung-1")
- #expect(value(request, "X-Plex-Session-Identifier") == "sitzung-1")
- }
-
- @Test("Untertitel werden nur eingebrannt, wenn eine Spur gewählt ist")
- func subtitlesOnlyWhenRequested() {
- let without = TranscodeRequest(ratingKey: "1", isLocal: true)
- #expect(value(without, "subtitles") == nil)
-
- let with = TranscodeRequest(ratingKey: "1", isLocal: true, subtitleStreamID: 310_024)
- #expect(value(with, "subtitles") == "burn")
- #expect(value(with, "subtitleStreamID") == "310024")
- }
-}
-
-@Suite("Dauer in Plex-Einheiten")
-struct DurationConversionTests {
- @Test("Millisekunden werden ganzzahlig gerechnet")
- func millisecondsAreWholeNumbers() {
- #expect(Duration.seconds(90).milliseconds == 90_000)
- #expect(Duration.milliseconds(4_892_000).milliseconds == 4_892_000)
- #expect(Duration.zero.milliseconds == 0)
- }
-
- @Test("Sekunden als Gleitkommazahl")
- func secondsAsDouble() {
- #expect(abs(Duration.milliseconds(1_500).seconds - 1.5) < 0.0001)
- }
-}
-
-/// Was der Server mit seinen Codes meint.
-@Suite("Entscheidung des Servers")
-struct TranscodeDecisionTests {
-
- private func decision(code: Int, text: String) throws -> TranscodeDecision {
- let json = """
- {"MediaContainer":{"generalDecisionCode":\(code),"generalDecisionText":"\(text)"}}
- """
- return try PlexResponse.decode(TranscodeDecision.self, from: Data(json.utf8))
- }
-
- @Test("Umwandlung erlaubt ist eine Zusage, keine Absage")
- func treatsConversionAsAllowed() throws {
- // Der Fehler, an dem nichts mehr abspielte: die App fordert eine
- // Umrechnung an, der Server sagt „direkte Wiedergabe nicht verfügbar;
- // Umwandlung ok — und die App las das als Nein.
- #expect(try decision(code: 1_001, text: "Direct play not available").isAllowed)
- #expect(try decision(code: 2_000, text: "Conversion OK").isAllowed)
- }
-
- @Test("Direkte Wiedergabe bleibt eine Zusage")
- func treatsDirectPlayAsAllowed() throws {
- #expect(try decision(code: 1_000, text: "Direct play OK").isAllowed)
- }
-
- @Test("Ab dreitausend ist es eine Absage")
- func treatsHighCodesAsRefusal() throws {
- #expect(try decision(code: 3_000, text: "Not allowed").isAllowed == false)
- #expect(try decision(code: 6_000, text: "Transcoder busy").isAllowed == false)
- }
-
- @Test("Ohne Code keine Absage")
- func allowsWhenSilent() throws {
- let json = #"{"MediaContainer":{}}"#
- let decision = try PlexResponse.decode(TranscodeDecision.self, from: Data(json.utf8))
-
- #expect(decision.isAllowed)
- }
-}
-
-@Suite("Leiter bei Plex")
-struct PlexLadderTests {
- private func werte(_ request: TranscodeRequest, _ name: String) -> [String] {
- request.queryItems
- .filter { $0.name == name }
- .compactMap(\.value)
- }
-
- @Test("Ohne Wunsch keine Leiter")
- func noLadderByDefault() {
- let request = TranscodeRequest(ratingKey: "1", isLocal: false)
- #expect(werte(request, "autoAdjustQuality").isEmpty)
- }
-
- @Test("Mit Wunsch steht der Wert genau einmal da")
- func ladderIsRequested() {
- let request = TranscodeRequest(ratingKey: "1", isLocal: false, adaptive: true)
- #expect(werte(request, "autoAdjustQuality") == ["1"])
- }
-
- /// Die Bitrate bleibt die **oberste** Sprosse, nicht die einzige — beide
- /// Angaben gehören zusammen in die Anfrage.
- @Test("Die Obergrenze bleibt neben der Leiter stehen")
- func ceilingSurvives() {
- let request = TranscodeRequest(
- ratingKey: "1",
- maximumBitrate: 8_000,
- isLocal: false,
- adaptive: true
- )
- #expect(werte(request, "autoAdjustQuality") == ["1"])
- #expect(werte(request, "maxVideoBitrate") == ["8000"])
- }
-
- /// Dieselbe Wiedergabe, nicht zwei: sonst bliebe beim Aufräumen ein
- /// Transcoder stehen, weil die Kennung nicht mehr passt.
- @Test("Die Sitzungskennung bleibt dieselbe")
- func sessionSurvives() {
- let original = TranscodeRequest(ratingKey: "1", isLocal: false)
- let mitLeiter = original.askingForLadder()
- #expect(mitLeiter.sessionIdentifier == original.sessionIdentifier)
- #expect(mitLeiter.adaptive)
- }
-}
diff --git a/README.md b/README.md
index 414c9c2..4a39ac6 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# Flixy
-> Ein nativer Plex-Client für iPhone und iPad — dunkel, mit ausfahrbarer
-> Seitenleiste und Offline-Downloads.
+> Ein nativer Jellyfin-Client für iPhone, iPad, Mac und Apple TV — dunkel,
+> mit ausfahrbarer Seitenleiste und Offline-Downloads.
Der Name ist ein Platzhalter. Siehe [`Flixy/Branding.swift`](Flixy/Branding.swift).
@@ -31,14 +31,13 @@ aber noch nicht gesehen.
Drei Fehler, die kein Test gefunden hätte, weil sie in Annahmen über fremde
Bibliotheken steckten — sie stehen als Warnung im jeweiligen Quelltext:
-- **VLCKit ruft seinen Delegaten vom eigenen Thread**, nicht über die
- Hauptwarteschlange. `MainActor.assumeIsolated` brach die App beim Drücken
- auf Play hart ab.
-- **`app.plex.tv/auth` verknüpft den Code nicht**, wenn man im Browser schon
- bei Plex angemeldet ist — es springt auf watch.plex.tv weiter. `plex.tv/link`
- tut es zuverlässig.
- **`URLSessionDownloadTask` gilt auch bei 404 als erfolgreich** und legt die
Fehlerseite als Datei ab.
+- **Hintergrund-Sitzungen setzen abgerissene Übertragungen still bei null
+ fort**, ohne es zu melden — gemessen wurden sieben Anläufe hintereinander.
+- **Jellyfins Transcode-Endpunkt bildet eine Tonspur je Sitzung ab.** Ein
+ Download kommt deshalb einspurig an, und welche Spur es ist, entscheidet
+ sich am Server. Der Grund, warum beim Herunterladen gefragt wird.
## Aufbau
@@ -69,17 +68,23 @@ Das App-Target enthält nur Oberfläche und Navigation.
| Modul | Aufgabe | Hängt ab von |
|---|---|---|
-| `PlexKit` | Anmeldung, Endpunkte, Modelle. Kein SwiftUI. | — |
+| `MediaKit` | Die serverneutrale Grundschicht: Typen und Rechnungen, die für jeden Server gelten. | — |
+| `JellyfinKit` | Anmeldung, Endpunkte, Modelle. Kein SwiftUI. | `MediaKit` |
| `FlixyDesign` | Farben, Typografie, wiederkehrende Bausteine. | — |
-| `FlixyPlayback` | Wiedergabe-Abstraktion und die Entscheidung DirectPlay/Transcode. | `PlexKit` |
-| `FlixyDownloads` | Warteschlange, Hintergrundübertragung, Offline-Bestand. | `PlexKit` |
+| `FlixyPlayback` | Wiedergabe-Abstraktion und die Entscheidung DirectPlay/Umpacken/Transcode. | `MediaKit` |
+| `FlixyDownloads` | Warteschlange, Hintergrundübertragung, Offline-Bestand. | `MediaKit` |
-### Warum zwei Player
+### Warum umgepackt wird
iOS spielt weder Matroska-Dateien noch DTS- oder TrueHD-Ton — und genau das
-liegt in typischen Plex-Beständen. `PlaybackDecider` prüft deshalb pro Datei,
-ob AVPlayer sie nativ öffnen kann (dann gibt es Bild-in-Bild und AirPlay
-gratis), und schickt sie sonst an VLCKit.
+liegt in typischen Beständen. `PlaybackDecider` prüft deshalb pro Datei, ob
+AVPlayer sie nativ öffnen kann, und lässt sie sonst **vom Server umpacken**:
+Bild und Ton wandern unverändert in eine MP4, nur die Schachtel wechselt. Das
+kostet den Server fast nichts — und die Wiedergabe bleibt im Systemplayer,
+also mit Bild-in-Bild, AirPlay und Systembedienung.
+
+Ein zweiter Player stand hier einmal. Er konnte zwar alles öffnen, brachte
+aber keines dieser drei Dinge mit.
Ab Phase 2 kommt der dritte Weg dazu: Server-Transcoding. Dafür ist genau eine
Funktion zu erweitern — `PlaybackDecider.decide(media:part:policy:)`. Die
@@ -97,29 +102,24 @@ Die wichtigste Testdatei ist `PlaybackDeciderTests` — sie hält fest, welche
Datei auf welchem Weg landet, und schlägt an, sobald jemand
`DeviceCapabilities` anfasst.
-Die Antwort-Vorlagen unter `Tests/PlexKitTests/Fixtures` sind von Hand
-geschrieben und gehören durch echte Mitschnitte ersetzt, sobald ein Server
-erreichbar ist — siehe das README dort.
+Die Antwort-Vorlagen unter `Tests/JellyfinKitTests/Fixtures` sind echte
+Mitschnitte vom Server.
Die Oberfläche wird von Hand im Simulator geprüft.
## Was noch am echten Server zu prüfen ist
-1. **Komprimierte Downloads.** Sie laufen über den Universal-Transcoder mit
- `protocol=http` statt über die umständliche Sync-API. Offen: ob Plex eine
- `Content-Length` mitschickt (ohne sie gibt es keinen Fortschrittsbalken und
- kein Fortsetzen) und ob die Datei durchsuchbar ist. Original bleibt solange
- Voreinstellung. Siehe `transcodedDownloadURL` in `PlexServer+Playback.swift`.
- Ein Fehlschlag erscheint immerhin als solcher — die Plausibilitätsprüfung in
- `DownloadCoordinator.implausibility` verwirft Fehlerseiten und abgebrochene
- Übertragungen, statt sie als fertig zu verbuchen.
-2. **Live-TV: die Senderliste.** Dass die Empfänger unter `Dvr` gefunden
- werden, ist geprüft. Ob `/livetv/dvrs/{id}/channels` die Sender liefert,
- nicht. Die Ansicht unterscheidet beide Fälle: „Kein Empfänger eingerichtet"
- gegen „Keine Sender" — daran ist ablesbar, wo es hakt.
-3. **App-Größe.** Die iOS-Slice von VLCKit ist unstripped 239 MB. Was davon im
- fertigen Archiv übrig bleibt, ist noch nicht gemessen.
-4. **Große Dateien im Hintergrund.** Das Verhalten mit einem 40-GB-Remux — vor
+1. **Komprimierte Downloads.** Offen: ob der Server bei den umgerechneten
+ Stufen eine `Content-Length` mitschickt. Ohne sie greift beim Prüfen auf
+ Vollständigkeit nur die weiche Regel, und ein Fortschrittsbalken ist eine
+ Schätzung. Ein Fehlschlag erscheint immerhin als solcher — die
+ Plausibilitätsprüfung in `DownloadCoordinator.implausibility` verwirft
+ Fehlerseiten und abgerissene Übertragungen, statt sie als fertig zu
+ verbuchen.
+2. **Sprachkennungen nach dem Umpacken.** Jellyfin verliert sie: alle Spuren
+ kommen als `und` an, obwohl die Quelle sie führt. Am fertigen Download
+ gemessen, Ursache noch offen.
+3. **Große Dateien im Hintergrund.** Das Verhalten mit einem 40-GB-Remux — vor
allem Pausieren und Fortsetzen — ist nur gegen Testdaten geprüft.
## Voraussetzungen
@@ -128,21 +128,24 @@ Die Oberfläche wird von Hand im Simulator geprüft.
- iOS 26.0 auf dem Zielgerät
- XcodeGen (`brew install xcodegen`)
-## Live-TV untersuchen, ohne zu bauen
+## Fragen stellen, ohne zu bauen
-Die Live-TV-Schnittstelle von Plex ist nirgends beschrieben, und jede Vermutung
-darüber ging bisher als TestFlight-Build hinaus und kam als Screenshot zurück.
+Jede Vermutung über die Schnittstelle eines Servers ging bisher als
+TestFlight-Build hinaus 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.
```bash
-cd Packages/FlixyKit && swift run FlixyProbe
+cd Packages/FlixyKit
+swift run FlixyProbe --jellyfin # Welche Leiter baut der Server?
+swift run FlixyProbe --spuren # Welche Tonspuren hat ein Titel?
+swift run FlixyProbe --playlist # Was steht in dieser HLS-Leiter?
```
-Beim ersten Lauf zeigt das Werkzeug einen Code für plex.tv/link; danach liegt
-der Token im Schlüsselbund dieses Rechners. Es sucht den Server, liest
-Senderliste und Programm und probiert das Einschalten durch — mit derselben
-Logik wie die App, nur in Sekunden statt in einem halben Tag.
+`--jellyfin` und `--spuren` fragen nach Benutzername und Passwort und legen den
+Token im Schlüsselbund dieses Rechners ab. `--playlist` braucht gar keine
+Anmeldung.
Ausgegeben werden Pfade, Kennungen und die Antworten des Servers. Keine
vollständigen Adressen, also auch kein Token.
+
diff --git a/project.yml b/project.yml
index f76aeab..33c033f 100644
--- a/project.yml
+++ b/project.yml
@@ -49,14 +49,12 @@ targets:
dependencies:
- target: DownloadWidget
# Die neutrale Grundschicht. Ausdrücklich genannt, obwohl sie über
- # PlexKit ohnehin im Baum hängt: die App soll sie direkt benutzen und
+ # JellyfinKit ohnehin im Baum hängt: die App soll sie direkt benutzen und
# nicht davon abhängen, dass ein bestimmter Server sie mitschleppt.
- package: FlixyKit
product: MediaKit
- - package: FlixyKit
- product: PlexKit
- # Der zweite Server. Steht neben PlexKit, nicht darin: die beiden teilen
- # die neutrale Schicht und sonst nichts.
+ # Der Server. Steht auf der neutralen Schicht, nicht darin — kommt ein
+ # zweiter dazu, steht er daneben.
- package: FlixyKit
product: JellyfinKit
- package: FlixyKit
@@ -107,14 +105,6 @@ targets:
UIBackgroundModes:
- audio
- # Das Schema, über das Plex nach der Anmeldung zurückschickt. Ohne
- # diesen Eintrag kennt iOS es nicht, und ASWebAuthenticationSession
- # käme nie zurück — das Anmeldefenster bliebe offen stehen.
- CFBundleURLTypes:
- - CFBundleURLName: com.scarriffleservices.flixy.auth
- CFBundleURLSchemes:
- - com.scarriffleservices.flixy
-
# Die Ausfuhr-Erklärung zur Verschlüsselung.
#
# `false` heißt: die App nutzt ausschließlich Verschlüsselung, die
@@ -137,9 +127,9 @@ targets:
NSSupportsLiveActivities: true
NSAppTransportSecurity:
- # Plex-Server im Heimnetz laufen überwiegend auf blankem HTTP an
- # Port 32400. Diese Ausnahme erlaubt genau das, ohne die
- # Transportsicherheit für das offene Netz abzuschalten.
+ # Medienserver im Heimnetz laufen überwiegend auf blankem HTTP —
+ # Jellyfin ab Werk auf Port 8096. Diese Ausnahme erlaubt genau das,
+ # ohne die Transportsicherheit für das offene Netz abzuschalten.
NSAllowsLocalNetworking: true
# Seit iOS 14 verlangt jeder Zugriff auf Adressen im eigenen Netz eine
@@ -147,7 +137,7 @@ targets:
# die Serversuche findet dauerhaft nichts.
NSLocalNetworkUsageDescription: >-
Flixy benötigt Zugriff auf das lokale Netzwerk, um deinen
- Plex-Server zu finden und Medien direkt von ihm zu laden.
+ Jellyfin-Server zu erreichen und Medien direkt von ihm zu laden.
# Die Fassung für den Fernseher.
#
@@ -196,13 +186,6 @@ targets:
dependencies:
- package: FlixyKit
product: MediaKit
- # Mitgenommen, obwohl Plex in Fassung 1 nicht angeboten wird:
- # FlixySession kennt PlexResource und PlexBackend in seiner
- # Zustandsmaschine. Das Paket mitzunehmen ist billiger, als die Sitzung
- # zu spalten — PlexClientIdentity benutzt nur UIDevice, und das gibt es
- # auf tvOS.
- - package: FlixyKit
- product: PlexKit
- package: FlixyKit
product: JellyfinKit
- package: FlixyKit
@@ -245,7 +228,7 @@ targets:
# UILaunchScreen — tvOS hat kein Startbild dieser Art.
# UISupportedInterfaceOrientations — ein Fernseher dreht sich nicht.
# NSSupportsLiveActivities — ActivityKit fehlt im tvOS-SDK.
- # CFBundleURLTypes — das Schema ist für die Plex-Rückkehr.
+ # CFBundleURLTypes — die Anmeldung braucht kein Rückkehr-Schema.
# UIBackgroundModes: audio — ein Apple TV schickt keine App in den
# Hintergrund, aus dem sie weiterspielen müsste.
NSAppTransportSecurity: