Phase 2: Transcoding, adaptive Bitrate, Spuren, Marker, Sync
Der Decider brauchte keine Änderung — nur eine andere Richtlinie. Genau dafür war er gebaut. Transcoding - TranscodeRequest baut die Parameter für /video/:/transcode/universal. directStream=1 lässt Plex nur umpacken, wenn der Bildcodec passt, statt pauschal neu zu kodieren. - Vor dem Start wird der Server gefragt (decision); sagt er nein, steht der Grund im Klartext da statt eines schwarzen Bildes. - Beim Verlassen wird der Transcoder angehalten. Ohne dieses Signal rechnet der Server minutenlang für niemanden weiter. - Die adaptive Bitrate kommt mit HLS: AVPlayer wechselt sie selbst. Qualitätseinstellungen - Getrennt für WLAN und Mobilfunk, fünf spürbare Stufen. Voreinstellung: zuhause Original, unterwegs 4 Mbit. - NetworkConditions erkennt Mobilfunk über isExpensive — das deckt auch den Hotspot ab, der technisch WLAN ist und trotzdem Volumen kostet. Spuren und Marken - Ton- und Untertitelwahl im Player, bevorzugte Sprache in den Einstellungen, nachsichtiger Sprachvergleich (de/deu/ger/de-DE). - Intro- und Abspann-Marken mit Überspringen-Knopf, optional automatisch. Bereits übersprungene Marken werden gemerkt, sonst käme man nach einem Rücksprung nie ins Intro zurück. - Nächste Folge inkl. Staffelwechsel; Specials in Staffel 0 werden nicht vorgezogen. Fortschritt - Alle zehn Sekunden an den Server, plus Schlussmeldung beim Verlassen. Erst die nimmt den Titel aus „wird gerade abgespielt" heraus. - markWatched/markUnwatched/rate/refreshLibrary stehen bereit. 86 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
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?
|
||||
|
||||
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
|
||||
) {
|
||||
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
|
||||
}
|
||||
|
||||
var queryItems: [URLQueryItem] {
|
||||
var items = [
|
||||
URLQueryItem(name: "path", value: "/library/metadata/\(ratingKey)"),
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user