Zwei Dinge, die ich mehrere Runden zu spät tue. Die Adresse steht jetzt in der Spur, mit entferntem Token. Ohne sie diskutiere ich über eine Anfrage, die niemand gesehen hat — und genau das habe ich getan, während ich einzelne Parameter der Reihe nach verdächtigt habe. Und eine Form, die nur das Nötigste enthält: Pfad, Protokoll, hasMDE, Indizes, Sitzung, Token, Gerätekennung. Eine nackte 400 sagt nicht, welche Angabe stört. Drei Formen nebeneinander — voll, ohne Profil, nur das Nötigste — sagen es. Dazu zwei Kleinigkeiten: die Abspielliste wird ein zweites Mal nach zwei Sekunden abgerufen, weil ein Empfänger ein Stück Hardware ist und einen Moment braucht, bis er abgestimmt hat. Und wenn das Belegen klappt, hört die Suche danach auf — die übrigen zwanzig Abfragen sind dann nur noch Lärm im Bericht und Last für den Server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
347 lines
15 KiB
Swift
347 lines
15 KiB
Swift
import Foundation
|
|
|
|
extension PlexServer {
|
|
// MARK: - Transcoding
|
|
|
|
/// Die Adresse des HLS-Stroms 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,
|
|
keepPathEncoding: 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
|
|
components.queryItems = items
|
|
|
|
components.percentEncodedQueryItems = components.percentEncodedQueryItems?.map { item in
|
|
// `path` von Hand einsetzen heißt: genau einmal kodiert. Das ist
|
|
// für Dateien richtig. Für eine Sendung beim Programmführer muss
|
|
// die Kennung ein zweites Mal kodiert werden — dann übernimmt das
|
|
// `URLComponents` von selbst, und hier ist nichts zu tun.
|
|
if item.name == "path", keepPathEncoding {
|
|
return URLQueryItem(name: "path", value: request.percentEncodedPath)
|
|
}
|
|
|
|
// Das Geräteprofil ist ein Wert voller `&`, `=` und Klammern —
|
|
// und `URLComponents` lässt genau die unangetastet. Der Server
|
|
// liest sie dann als eigene Parameter: aus einem Profil werden
|
|
// zwei Dutzend Angaben, darunter ein zweites `protocol=`. Das
|
|
// steckte in **jeder** Transcoder-Adresse, seit es sie gibt.
|
|
if item.name.hasSuffix("Profile-Extra"),
|
|
// Der **ursprüngliche** Wert, nicht der bereits von
|
|
// `URLComponents` behandelte: den ein zweites Mal zu kodieren
|
|
// macht aus `%3D` ein `%253D`.
|
|
let value = items.first(where: { $0.name == item.name })?.value {
|
|
return URLQueryItem(
|
|
name: item.name,
|
|
value: value.addingPercentEncoding(withAllowedCharacters: .plexParameterAllowed)
|
|
?? value
|
|
)
|
|
}
|
|
|
|
return item
|
|
}
|
|
return components.url
|
|
}
|
|
|
|
/// Baut die Adresse eines Stroms, den der Server schon bereitstellt.
|
|
///
|
|
/// Für Live-TV. Das Belegen eines Tuners liefert eine fertige
|
|
/// HLS-Abspielliste — die gehört abgerufen, nicht umgerechnet. Sie durch
|
|
/// den Transcoder zu schicken heißt, ihn um die Umrechnung von etwas zu
|
|
/// bitten, das bereits fertig ist; er antwortet mit einer nackten 400.
|
|
///
|
|
/// 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 Strom, der hier stumm bliebe oder
|
|
/// schwarz.
|
|
private nonisolated var clientProfileItems: [URLQueryItem] {
|
|
[
|
|
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 Strom
|
|
/// 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 Strom, 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 Strom, 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-._~"
|
|
)
|
|
}
|