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-._~" ) }