Der Server antwortet auf eine angeforderte Umrechnung mit „direkte Wiedergabe nicht verfügbar; Umwandlung ok" — Code 1001. Meine Prüfung ließ nur 1000 durch, also ausschließlich DirectPlay. Damit blockierte die App ausgerechnet den Fall, den sie selbst angefordert hatte: wer im Player eine Stufe wählt, will umgerechnet bekommen, und die einzige richtige Antwort darauf kam als Fehler an. Es spielte gar nichts mehr. 1000 heißt „direkt abspielbar", 1001 „direkt nicht, umrechnen schon", 2000 „Umwandlung in Ordnung" — alles Zusagen. Abgelehnt wird erst ab 3000, etwa bei fehlender Berechtigung oder überlastetem Transcoder. Vier Tests halten die Codes fest, damit die Grenze nicht wieder zur Auslegungssache wird. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
8.5 KiB
Swift
191 lines
8.5 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.
|
|
///
|
|
/// `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)
|
|
}
|
|
}
|