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>
187 lines
7.5 KiB
Swift
187 lines
7.5 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import PlexKit
|
|
|
|
@Suite("Transcode-Anfrage")
|
|
struct TranscodeRequestTests {
|
|
private func value(_ request: TranscodeRequest, _ name: String) -> String? {
|
|
request.queryItems.first { $0.name == name }?.value
|
|
}
|
|
|
|
@Test("Der Pfad zeigt auf den Titel, nicht auf die Datei")
|
|
func pathPointsAtMetadata() {
|
|
// Plex erwartet hier die Metadaten-Adresse. Gibt man den Dateipfad an,
|
|
// antwortet der Transcoder mit 404.
|
|
let request = TranscodeRequest(ratingKey: "45231", isLocal: true)
|
|
#expect(value(request, "path") == "/library/metadata/45231")
|
|
}
|
|
|
|
@Test("Umpacken ist erlaubt, DirectPlay ausdrücklich nicht")
|
|
func allowsDirectStreamButNotDirectPlay() {
|
|
// directStream=1 lässt Plex nur den Container wechseln, wenn der
|
|
// Bildcodec passt — das spart die Neukodierung. directPlay=0, weil
|
|
// diese Frage der PlaybackDecider bereits entschieden hat.
|
|
let request = TranscodeRequest(ratingKey: "1", isLocal: true)
|
|
#expect(value(request, "directStream") == "1")
|
|
#expect(value(request, "directPlay") == "0")
|
|
}
|
|
|
|
@Test("Ohne fastSeek müsste der Transcoder beim Springen von vorn beginnen")
|
|
func enablesFastSeek() {
|
|
#expect(value(TranscodeRequest(ratingKey: "1", isLocal: true), "fastSeek") == "1")
|
|
}
|
|
|
|
@Test("Die Bitratengrenze bringt eine passende Qualitätsstufe mit")
|
|
func bitrateCarriesQuality() {
|
|
// Ohne videoQuality ignoriert Plex die Grenze bei manchen Fassungen.
|
|
let request = TranscodeRequest(ratingKey: "1", maximumBitrate: 4_000, isLocal: false)
|
|
#expect(value(request, "maxVideoBitrate") == "4000")
|
|
#expect(value(request, "videoQuality") != nil)
|
|
}
|
|
|
|
@Test("Höhere Bitraten ergeben höhere Qualitätsstufen")
|
|
func qualityRisesWithBitrate() {
|
|
func quality(_ bitrate: Int) -> Int {
|
|
Int(value(TranscodeRequest(ratingKey: "1", maximumBitrate: bitrate, isLocal: true), "videoQuality") ?? "0")!
|
|
}
|
|
#expect(quality(2_000) < quality(8_000))
|
|
#expect(quality(8_000) < quality(20_000))
|
|
#expect(quality(20_000) <= 100)
|
|
}
|
|
|
|
@Test("Ohne Grenze steht keine Bitrate in der Anfrage")
|
|
func noBitrateWhenUnlimited() {
|
|
// Ein leerer Wert ist für Plex nicht dasselbe wie ein fehlender — er
|
|
// würde als Grenze von null gelesen.
|
|
let request = TranscodeRequest(ratingKey: "1", isLocal: true)
|
|
#expect(value(request, "maxVideoBitrate") == nil)
|
|
#expect(value(request, "videoQuality") == nil)
|
|
}
|
|
|
|
@Test("Lokal und außer Haus werden unterschieden")
|
|
func distinguishesLocation() {
|
|
#expect(value(TranscodeRequest(ratingKey: "1", isLocal: true), "location") == "lan")
|
|
#expect(value(TranscodeRequest(ratingKey: "1", isLocal: false), "location") == "wan")
|
|
}
|
|
|
|
@Test("Die Sitzungskennung steht in beiden Feldern")
|
|
func sessionAppearsTwice() {
|
|
// Plex erwartet sie sowohl als `session` als auch als
|
|
// `X-Plex-Session-Identifier`. Fehlt eines, lässt sich der Transcoder
|
|
// später nicht gezielt anhalten — und er rechnet minutenlang weiter.
|
|
let request = TranscodeRequest(ratingKey: "1", isLocal: true, sessionIdentifier: "sitzung-1")
|
|
#expect(value(request, "session") == "sitzung-1")
|
|
#expect(value(request, "X-Plex-Session-Identifier") == "sitzung-1")
|
|
}
|
|
|
|
@Test("Untertitel werden nur eingebrannt, wenn eine Spur gewählt ist")
|
|
func subtitlesOnlyWhenRequested() {
|
|
let without = TranscodeRequest(ratingKey: "1", isLocal: true)
|
|
#expect(value(without, "subtitles") == nil)
|
|
|
|
let with = TranscodeRequest(ratingKey: "1", isLocal: true, subtitleStreamID: 310_024)
|
|
#expect(value(with, "subtitles") == "burn")
|
|
#expect(value(with, "subtitleStreamID") == "310024")
|
|
}
|
|
}
|
|
|
|
@Suite("Dauer in Plex-Einheiten")
|
|
struct DurationConversionTests {
|
|
@Test("Millisekunden werden ganzzahlig gerechnet")
|
|
func millisecondsAreWholeNumbers() {
|
|
#expect(Duration.seconds(90).milliseconds == 90_000)
|
|
#expect(Duration.milliseconds(4_892_000).milliseconds == 4_892_000)
|
|
#expect(Duration.zero.milliseconds == 0)
|
|
}
|
|
|
|
@Test("Sekunden als Gleitkommazahl")
|
|
func secondsAsDouble() {
|
|
#expect(abs(Duration.milliseconds(1_500).seconds - 1.5) < 0.0001)
|
|
}
|
|
}
|
|
|
|
/// Was der Server mit seinen Codes meint.
|
|
@Suite("Entscheidung des Servers")
|
|
struct TranscodeDecisionTests {
|
|
|
|
private func decision(code: Int, text: String) throws -> TranscodeDecision {
|
|
let json = """
|
|
{"MediaContainer":{"generalDecisionCode":\(code),"generalDecisionText":"\(text)"}}
|
|
"""
|
|
return try PlexResponse.decode(TranscodeDecision.self, from: Data(json.utf8))
|
|
}
|
|
|
|
@Test("Umwandlung erlaubt ist eine Zusage, keine Absage")
|
|
func treatsConversionAsAllowed() throws {
|
|
// Der Fehler, an dem nichts mehr abspielte: die App fordert eine
|
|
// Umrechnung an, der Server sagt „direkte Wiedergabe nicht verfügbar;
|
|
// Umwandlung ok — und die App las das als Nein.
|
|
#expect(try decision(code: 1_001, text: "Direct play not available").isAllowed)
|
|
#expect(try decision(code: 2_000, text: "Conversion OK").isAllowed)
|
|
}
|
|
|
|
@Test("Direkte Wiedergabe bleibt eine Zusage")
|
|
func treatsDirectPlayAsAllowed() throws {
|
|
#expect(try decision(code: 1_000, text: "Direct play OK").isAllowed)
|
|
}
|
|
|
|
@Test("Ab dreitausend ist es eine Absage")
|
|
func treatsHighCodesAsRefusal() throws {
|
|
#expect(try decision(code: 3_000, text: "Not allowed").isAllowed == false)
|
|
#expect(try decision(code: 6_000, text: "Transcoder busy").isAllowed == false)
|
|
}
|
|
|
|
@Test("Ohne Code keine Absage")
|
|
func allowsWhenSilent() throws {
|
|
let json = #"{"MediaContainer":{}}"#
|
|
let decision = try PlexResponse.decode(TranscodeDecision.self, from: Data(json.utf8))
|
|
|
|
#expect(decision.isAllowed)
|
|
}
|
|
}
|
|
|
|
@Suite("Leiter bei Plex")
|
|
struct PlexLadderTests {
|
|
private func werte(_ request: TranscodeRequest, _ name: String) -> [String] {
|
|
request.queryItems
|
|
.filter { $0.name == name }
|
|
.compactMap(\.value)
|
|
}
|
|
|
|
@Test("Ohne Wunsch keine Leiter")
|
|
func noLadderByDefault() {
|
|
let request = TranscodeRequest(ratingKey: "1", isLocal: false)
|
|
#expect(werte(request, "autoAdjustQuality").isEmpty)
|
|
}
|
|
|
|
@Test("Mit Wunsch steht der Wert genau einmal da")
|
|
func ladderIsRequested() {
|
|
let request = TranscodeRequest(ratingKey: "1", isLocal: false, adaptive: true)
|
|
#expect(werte(request, "autoAdjustQuality") == ["1"])
|
|
}
|
|
|
|
/// Die Bitrate bleibt die **oberste** Sprosse, nicht die einzige — beide
|
|
/// Angaben gehören zusammen in die Anfrage.
|
|
@Test("Die Obergrenze bleibt neben der Leiter stehen")
|
|
func ceilingSurvives() {
|
|
let request = TranscodeRequest(
|
|
ratingKey: "1",
|
|
maximumBitrate: 8_000,
|
|
isLocal: false,
|
|
adaptive: true
|
|
)
|
|
#expect(werte(request, "autoAdjustQuality") == ["1"])
|
|
#expect(werte(request, "maxVideoBitrate") == ["8000"])
|
|
}
|
|
|
|
/// Dieselbe Wiedergabe, nicht zwei: sonst bliebe beim Aufräumen ein
|
|
/// Transcoder stehen, weil die Kennung nicht mehr passt.
|
|
@Test("Die Sitzungskennung bleibt dieselbe")
|
|
func sessionSurvives() {
|
|
let original = TranscodeRequest(ratingKey: "1", isLocal: false)
|
|
let mitLeiter = original.askingForLadder()
|
|
#expect(mitLeiter.sessionIdentifier == original.sessionIdentifier)
|
|
#expect(mitLeiter.adaptive)
|
|
}
|
|
}
|