Die vier erfundenen Stufen weichen der Leiter, die der Server selbst meldet: 64, 96, 208, 320, 720, 1500, 2000, 3000, 4000, 8000, 10000, 12000, 20000 kbps mit den Auflösungen, die Plex dafür vorsieht. Eine eigene Leiter hieße, Stufen anzubieten, die der Transcoder nicht kennt — er rundet dann still auf die nächste, und die Anzeige log. Angeboten wird nur, was zur Quelle passt. Eine 720p-Datei auf 1080p hochzurechnen bringt kein Bild dazu, und die Stufen sähen alle gleich aus. Oben im Blatt steht, was tatsächlich über die Leitung kommt — gemessene Bitrate, Auflösung, verworfene Bilder. Der Unterschied zur gewählten Stufe ist keine Haarspalterei: die eine sagt, was erlaubt ist, die andere, was ankommt. Ruckelt es trotz hoher Stufe, steht die Antwort dort. „Nur Ton" gibt es, aber ehrlich beschriftet: Plex kennt für Videos keinen reinen Tonstrom, also wird die unterste Sprosse geholt und das Bild ausgeblendet. Dazu Doppeltipp links und rechts für zehn Sekunden zurück und vor. Die Reihenfolge der Gesten ist entscheidend — das einfache Tippen verschluckt sonst das doppelte. Die Kachel hängt nicht mehr in vier Ecken fest, sondern klebt links oder rechts und ist in der Höhe frei. Vier Ecken sind schnell gebaut, aber im Weg: steht sie über einem Knopf, bleiben drei Orte, die ebenso stören. Größer wird sie durch Zusammen- und Auseinanderziehen; der Griff ist weg, er saß zwangsläufig auf dem Bild und stritt sich mit dem Verschieben. Und die Begriffe: „Motor" für engine sagt kein deutscher Entwickler. Fachwörter, die im Deutschen englisch bleiben, bleiben jetzt englisch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
179 lines
7.9 KiB
Swift
179 lines
7.9 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?
|
|
|
|
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
|
|
) {
|
|
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
|
|
}
|
|
|
|
/// 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),
|
|
]
|
|
|
|
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.
|
|
///
|
|
/// Code 1000 heißt „in Ordnung". Alles andere ist eine Absage — meist
|
|
/// fehlende Berechtigungen oder ein überlasteter Transcoder.
|
|
public var isAllowed: Bool {
|
|
generalDecisionCode == nil || generalDecisionCode == 1000
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|