Eingedeutschte Fachbegriffe in den Kommentaren — „Wachhund" für Watchdog, „Motor" für Engine. Das liest sich albern und ist unklarer als das Original; niemand sagt Wachhund, wenn er einen Watchdog meint. Acht Stellen berichtigt. Gewöhnliche Sprache bleibt deutsch, der Stil der Kommentare ändert sich nicht. Dazu die Plex-Leiter messbar gemacht: `--leiter` baut dieselbe Anfrage zweimal, mit und ohne `autoAdjustQuality=1`, und zählt die Stufen. Die Zeile steht seit dem Umbau im Code und war nie geprüft — genau die Art ungeprüfter Annahme über Serververhalten, die bei Jellyfin vier Runden gekostet hat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
230 lines
10 KiB
Swift
230 lines
10 KiB
Swift
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)
|
|
}
|
|
}
|