Die nackte „400 Bad Request"-Seite war keine Plex-Meldung, sondern der Webserver: er hat die Anfrage selbst nicht verstanden. Grund war mein eigener Fix von vorhin — ich habe `%3A%2F%2F` entpackt, und damit stand `plex://episode/…` im Klartext in der Anfrage. Beide naheliegenden Wege liegen daneben, und ich habe beide durchgemacht: unverändert durchgereicht kodiert URLComponents ein zweites Mal, aus %3A wird %253A. Vorher entpackt bricht sich der Server die Adresse. Richtig ist genau einmal — dafür wird `path` von Hand eingesetzt, nachdem alles andere kodiert ist. Dazu aus der Spur gelernt: jede Form von /livetv/sessions/… antwortet auf diesem Server mit 404. Den Tuner belegt er offenbar selbst, sobald die Sendung umgerechnet werden soll. Deshalb steht die Adresse der Sendung jetzt an erster Stelle, und die Sitzungen kommen nur noch dahinter. Als dritte Form ein Versuch ohne das Geräteprofil: dessen Angaben stecken voller `&` und `=`, die in einem Abfragewert mehrdeutig sind. Trägt es ohne, liegt es daran, und das steht dann in der Spur. Ebenso, was der Programmanbieter überhaupt an Endpunkten angibt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
260 lines
11 KiB
Swift
260 lines
11 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.
|
|
public nonisolated func transcodeURL(
|
|
for request: TranscodeRequest,
|
|
includeProfile: Bool = true
|
|
) -> URL? {
|
|
guard let baseURL = connection.url,
|
|
var components = URLComponents(
|
|
url: baseURL.appending(path: "/video/:/transcode/universal/start.m3u8"),
|
|
resolvingAgainstBaseURL: false
|
|
)
|
|
else { return nil }
|
|
|
|
components.queryItems = request.queryItems + [
|
|
URLQueryItem(name: "X-Plex-Token", value: token),
|
|
] + (includeProfile ? clientProfileItems : []) + identity.queryItems
|
|
|
|
// `path` wird von Hand eingesetzt, nachdem alles andere kodiert ist.
|
|
// Die Adresse kommt beim Programmanbieter bereits kodiert an, und
|
|
// beide Automatiken liegen daneben: `URLComponents` kodiert ein
|
|
// zweites Mal (%3A wird zu %253A), vorheriges Entpacken schreibt
|
|
// `plex://…` im Klartext in die Anfrage, und daran verschluckt sich
|
|
// der Server mit einer nackten 400-Seite.
|
|
components.percentEncodedQueryItems = components.percentEncodedQueryItems?.map { item in
|
|
item.name == "path"
|
|
? URLQueryItem(name: "path", value: request.percentEncodedPath)
|
|
: item
|
|
}
|
|
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
|
|
}
|
|
}
|