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: