Die vier erfundenen Stufen weichen der Leiter, die der Server selbst meldet: 64, 96, 208, 320, 720, 1500, 2000, 3000, 4000, 8000, 10000, 12000, 20000 kbps mit den Auflösungen, die Plex dafür vorsieht. Eine eigene Leiter hieße, Stufen anzubieten, die der Transcoder nicht kennt — er rundet dann still auf die nächste, und die Anzeige log. Angeboten wird nur, was zur Quelle passt. Eine 720p-Datei auf 1080p hochzurechnen bringt kein Bild dazu, und die Stufen sähen alle gleich aus. Oben im Blatt steht, was tatsächlich über die Leitung kommt — gemessene Bitrate, Auflösung, verworfene Bilder. Der Unterschied zur gewählten Stufe ist keine Haarspalterei: die eine sagt, was erlaubt ist, die andere, was ankommt. Ruckelt es trotz hoher Stufe, steht die Antwort dort. „Nur Ton" gibt es, aber ehrlich beschriftet: Plex kennt für Videos keinen reinen Tonstrom, also wird die unterste Sprosse geholt und das Bild ausgeblendet. Dazu Doppeltipp links und rechts für zehn Sekunden zurück und vor. Die Reihenfolge der Gesten ist entscheidend — das einfache Tippen verschluckt sonst das doppelte. Die Kachel hängt nicht mehr in vier Ecken fest, sondern klebt links oder rechts und ist in der Höhe frei. Vier Ecken sind schnell gebaut, aber im Weg: steht sie über einem Knopf, bleiben drei Orte, die ebenso stören. Größer wird sie durch Zusammen- und Auseinanderziehen; der Griff ist weg, er saß zwangsläufig auf dem Bild und stritt sich mit dem Verschieben. Und die Begriffe: „Motor" für engine sagt kein deutscher Entwickler. Fachwörter, die im Deutschen englisch bleiben, bleiben jetzt englisch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
345 lines
15 KiB
Swift
345 lines
15 KiB
Swift
import Foundation
|
|
|
|
extension PlexServer {
|
|
// MARK: - Transcoding
|
|
|
|
/// Die Adresse des HLS-Streams für diese Wiedergabe.
|
|
///
|
|
/// HLS ist genau die Kost, für die AVPlayer gebaut ist: er wechselt die
|
|
/// Bitrate während der Wiedergabe von selbst, je nachdem was die Leitung
|
|
/// hergibt. Genau das ist die zugesagte automatische Qualitätsanpassung —
|
|
/// sie wird nicht selbst gerechnet, sondern kommt mit dem Format.
|
|
/// - 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
|
|
)
|
|
}
|
|
|
|
/// 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)"),
|
|
URLQueryItem(name: "state", value: 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))
|
|
}
|
|
|
|
/// 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.
|
|
public enum PlaybackReportState: String, Sendable {
|
|
case playing
|
|
case paused
|
|
/// Beim Verlassen des Players. Erst dieser Wert lässt Plex den Eintrag aus
|
|
/// „wird gerade abgespielt" entfernen.
|
|
case stopped
|
|
case buffering
|
|
}
|
|
|
|
extension Duration {
|
|
/// Die Dauer in ganzen Millisekunden — die Einheit, in der Plex rechnet.
|
|
public var milliseconds: Int {
|
|
Int(components.seconds * 1000 + components.attoseconds / 1_000_000_000_000_000)
|
|
}
|
|
}
|
|
|
|
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-._~"
|
|
)
|
|
}
|