Nachgelesen im Quelltext des Plex-Clients, Funktion `video`:
path: server.resolveUrl(item.get("key")), // /livetv/sessions/1268be73-…
hasMDE: 1, mediaIndex: …, partIndex: …,
protocol: "hls", …
Zwei Dinge fehlten. Umgerechnet wird die **Sitzung, die der Server beim
Belegen angelegt hat** — nicht die Abspielliste darin, nicht die Sendung
beim Programmführer, und schon gar nicht die Kennung, die ich selbst
erzeuge. Genau die hatte ich mehrfach eingesetzt und mich über 404
gewundert.
Und `hasMDE=1` — „ich verstehe die Entscheidungsantwort". Plex' Client
schickt das bei jeder Wiedergabe mit. Es fehlte bei allen bisherigen
Versuchen, auch bei denen, die sonst richtig waren.
Der Schlüssel wird suchend gelesen und dabei von der Abspielliste
unterschieden: beide beginnen mit /livetv/sessions/, aber nur eine endet
auf .m3u8.
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 Strom. 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)
|
|
}
|
|
}
|