Files
Flaro/Packages/FlixyKit/Sources/PlexKit/Endpoints/TranscodeRequest.swift
T
ScarriffleandClaude Opus 5 cdff53fa0d Eine Leiter bestellen statt einer einzelnen Stufe
Der Kern des Ruckelproblems: die App bestellte bei beiden Servern eine
HLS-Liste mit genau einer Variante. AVPlayer kann nur zwischen Stufen
wechseln, die in der Liste stehen — bei einer gibt es nichts zu wechseln,
und wenn die Leitung einbricht, kann nichts nachgeben.

**Jellyfin.** `enableAdaptiveBitrateStreaming` wird gesetzt, und zwar
ersetzend: die Voreinstellung im Server ist `false`, und bei zwei
gleichnamigen Werten gilt beim Server der erste — dieselbe Falle wie bei
`maxHeight` eine Funktion weiter oben. Dazu wird `main.m3u8` zu
`master.m3u8`, falls der Server die Schnipselliste nennt; nur auf der
Stufenliste kann der Parameter wirken.

Am Geräteprofil ändert sich **nichts**. Mehrere `TranscodingProfiles`
wären der naheliegende Fehlgriff — die beschreiben Formate, nicht
Bitraten. `MaxStreamingBitrate` bleibt die oberste Sprosse; was darunter
liegt, erfindet der Server.

**Plex.** `autoAdjustQuality=1`, als Eigenschaft der Anfrage statt als
feste Zeile: `queryItems` speist auch die Adresse zum Herunterladen, und
dort ergäbe eine Leiter keinen Sinn. Bewusst **nicht** am Entscheider —
der antwortet auf Unbekanntes mit nackten 400ern, wofür es hier eigens
`transcoderBisect` gibt. Er beantwortet ohnehin nur „darf das gespielt
werden". Stünde der Wert dort und nähme der Server ihn übel, wäre nicht
die Leiter kaputt, sondern die Wiedergabe.

Beides nur bei „automatisch". Wer im Blatt ausdrücklich „720p · 3 Mbps"
antippt, bekommt das und nicht „höchstens 3, meistens weniger" unter
unverändertem Etikett.

**Was das nicht kann, und das gehört dazugesagt:** Jellyfin verweigert
die Leiter im lokalen Netz grundsätzlich — „Within the local network this
will likely do more harm than good." Ein schwaches WLAN zählt dort mit,
und vom Client aus ist das nicht zu überstimmen. Ebenso fällt sie weg,
wenn Bild oder Ton nur kopiert statt gerechnet werden. Ob am Ende
wirklich mehrere Stufen ankommen, steht deshalb nicht im Code, sondern in
`PlaybackStatistics.variantCount`.

Elf neue Tests, darunter der doppelte Parameter, die andere
Schreibweise, und dass die Sitzungskennung beim Umbau dieselbe bleibt —
sonst bliebe beim Aufräumen ein Transcoder stehen.

Die Probe zeigt jetzt außerdem, ob kopiert statt gerechnet wird. Das ist
die zweite Hürde, und sie ist von zuhause aus nicht sichtbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 15:26:57 +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.
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)
}
}