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>
77 lines
3.2 KiB
Swift
77 lines
3.2 KiB
Swift
import Testing
|
|
import Foundation
|
|
import MediaKit
|
|
@testable import JellyfinKit
|
|
|
|
@Suite("Leiter bestellen")
|
|
struct AdaptiveURLTests {
|
|
private func url(_ text: String) -> URL { URL(string: text)! }
|
|
|
|
private func werte(_ url: URL, _ name: String) -> [String] {
|
|
(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [])
|
|
.filter { $0.name.lowercased() == name.lowercased() }
|
|
.compactMap(\.value)
|
|
}
|
|
|
|
/// `main.m3u8` ist die Liste der Schnipsel **einer** Stufe. Auf ihr kann
|
|
/// der Parameter nichts bewirken — es gibt dort keine Varianten.
|
|
@Test("Aus der Schnipselliste wird die Stufenliste")
|
|
func mainBecomesMaster() {
|
|
let ergebnis = JellyfinBackend.adaptive(url("http://h/videos/abc/main.m3u8?Tag=1"))
|
|
#expect(ergebnis.lastPathComponent == "master.m3u8")
|
|
// Die übrige Abfrage bleibt unangetastet.
|
|
#expect(werte(ergebnis, "Tag") == ["1"])
|
|
}
|
|
|
|
@Test("Eine Stufenliste bleibt, wie sie ist")
|
|
func masterStays() {
|
|
let ergebnis = JellyfinBackend.adaptive(url("http://h/videos/abc/master.m3u8"))
|
|
#expect(ergebnis.lastPathComponent == "master.m3u8")
|
|
}
|
|
|
|
/// Die Falle, an der `maxHeight` schon einmal hängen geblieben ist: bei
|
|
/// zwei gleichnamigen Werten gilt beim Server der erste. Ein
|
|
/// danebengestellter zweiter bewirkt nichts.
|
|
@Test("Der Parameter steht genau einmal da")
|
|
func parameterAppearsOnce() {
|
|
let ergebnis = JellyfinBackend.adaptive(
|
|
url("http://h/videos/abc/master.m3u8?enableAdaptiveBitrateStreaming=false&Tag=1")
|
|
)
|
|
#expect(werte(ergebnis, "enableAdaptiveBitrateStreaming") == ["true"])
|
|
#expect(werte(ergebnis, "Tag") == ["1"])
|
|
}
|
|
|
|
@Test("Ohne vorhandenen Wert kommt er dazu")
|
|
func parameterIsAdded() {
|
|
let ergebnis = JellyfinBackend.adaptive(url("http://h/videos/abc/master.m3u8?Tag=1"))
|
|
#expect(werte(ergebnis, "enableAdaptiveBitrateStreaming") == ["true"])
|
|
}
|
|
|
|
/// Groß- und Kleinschreibung ist beim Server gleichgültig — beim
|
|
/// Herausfiltern darf sie es nicht sein, sonst steht er doch zweimal da.
|
|
@Test("Auch anders geschrieben wird er ersetzt")
|
|
func parameterIsReplacedRegardlessOfCase() {
|
|
let ergebnis = JellyfinBackend.adaptive(
|
|
url("http://h/videos/abc/master.m3u8?EnableAdaptiveBitrateStreaming=false")
|
|
)
|
|
let alle = (URLComponents(url: ergebnis, resolvingAgainstBaseURL: false)?.queryItems ?? [])
|
|
.filter { $0.name.lowercased() == "enableadaptivebitratestreaming" }
|
|
#expect(alle.count == 1)
|
|
#expect(alle.first?.value == "true")
|
|
}
|
|
|
|
/// Die Höhe gehört weiterhin dran: die Leiter ist die Abstufung, die
|
|
/// Obergrenze bleibt unsere.
|
|
@Test("Höhenbegrenzung und Leiter vertragen sich")
|
|
func heightAndLadderCoexist() {
|
|
let begrenzt = JellyfinBackend.limiting(
|
|
height: "1280x720",
|
|
in: url("http://h/videos/abc/main.m3u8")
|
|
)
|
|
let ergebnis = JellyfinBackend.adaptive(begrenzt)
|
|
#expect(werte(ergebnis, "maxHeight") == ["720"])
|
|
#expect(werte(ergebnis, "enableAdaptiveBitrateStreaming") == ["true"])
|
|
#expect(ergebnis.lastPathComponent == "master.m3u8")
|
|
}
|
|
}
|