Files
Flaro/Packages/FlixyKit/Sources/PlexKit/Endpoints/TranscodeRequest.swift
T
ScarriffleandClaude Opus 5 e324a474fd Watchdog heisst Watchdog, Engine heisst Engine
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>
2026-09-27 17:47:31 +02:00

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)
}
}