Fünf Fehler auf einem Weg — warum nie ein Untertitel kam
Sechs Reparaturversuche haben nichts bewirkt, weil jeder an einer Stelle
ansetzte, die gar nicht die Ursache war. Eine systematische Untersuchung —
45 Hypothesen, jede von drei Skeptikern gegengeprüft — hat fünf voneinander
unabhängige Defekte auf demselben Weg gefunden. Jeder einzelne genügt.
**1. Es wurde eine Playlist bestellt, die keine Untertitelspur tragen kann.**
Jellyfins TranscodingUrl lautet `main.m3u8` — eine Media Playlist, also eine
Liste von Schnipseln. `#EXT-X-MEDIA`, die Zeile, mit der ein Server eine
Untertitelspur ankündigt, darf nach RFC 8216 nur in einer Master Playlist
stehen. Der Wechsel main→master hing aber an der Leiter, und die wird beim
Umpacken und bei jeder von Hand gewählten Stufe nicht bestellt. Ein Matroska
geht immer über das Umpacken — die halbe Sammlung konnte strukturell keine
Untertitel haben.
Der Kommentar daneben war überholt und hat den Fehler verdeckt: er stammte
aus der Zeit, als Umpacken eine fortlaufende MP4 war. Seit es über HLS läuft,
ist die Master-Liste genau richtig. Pfadwechsel und Leiter sind jetzt zwei
Fragen, nicht eine.
**2. Der lokale Server etikettierte jeden Schnipsel als video/mp4.** Auch die
WebVTT-Schnipsel der Untertitelspur. So angekündigt gehen sie bei
AVFoundation an den falschen Parser, finden keine Box, und es entsteht
lautlos keine Einblendung — die Spur steht im Blatt, ist ausgewählt, bleibt
leer. Der Vorrat behält jetzt die Sorte der Gegenstelle; die Endung ist nur
noch der Notnagel.
**3. Nach dem ersten Untertitel wählte niemand mehr etwas aus.**
`selectedSubtitleTrack` wurde beim Neuaufbau nie zurückgesetzt, und beide
Wähler prüfen genau darauf. Eine frische Engine hat aber nichts ausgewählt —
also blieb sie für den Rest des Films stumm, während das Symbol „ein" zeigte.
Auch das Nachfassen im Sekundentakt war damit stillgelegt. Die Bestellung
(`selectedSubtitleStream`) bleibt, die Merker fallen.
**4. Die Schutzmarke war nach 250 ms tot.** `restart()` setzte sie als
Allererstes — und wartete danach auf einen echten Netzaufruf mit fünf
Sekunden Zeitgrenze, bevor es den alten Ereignisstrom abbestellte. In diesem
Fenster meldete der alte Player weiter seine Stelle, und die Prüfung „sind
wir angekommen" traf sofort zu. Die Marke war gelöscht, bevor der neue Stream
bestellt war; danach setzte jede Meldung des anlaufenden Streams die Stelle
auf null, und der nächste Neuaufbau merkte sich den Filmanfang. Das war der
Sprung — und der Grund, warum mein Fix von 88ba1a5 wirkungslos blieb.
**5. Der Sprung wurde einmal versucht und nie geprüft.** Der Rückgabewert von
`seek` wurde verworfen, die gemerkte Stelle verbraucht, bevor gesprungen
wurde, und framegenaue Toleranz verlangt — in einen Stream, den der Server
gerade erst erzeugt. Jetzt bleibt die Stelle stehen, bis ein Sprung bestätigt
ist, die Toleranz liegt bei einer halben Sekunde, und jedes Wachsen des
springbaren Bereichs ist ein neuer Anlass.
Belegt statt vermutet: ein neuer Test startet den lokalen Server gegen einen
eigenen Herkunftsserver und liest die Antworten Byte für Byte — ohne Gerät,
ohne Simulator, in 0,03 Sekunden. Er hat Fehler 2 vor der Reparatur
reproduziert und danach bestätigt. Fünf weitere Tests halten Fehler 1 fest.
Zwei alte Tests hielten das fehlerhafte Verhalten fest und waren damit
mitschuldig; beide sind mitgeändert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8e0e8ec246
commit
c681d69fcc
@@ -65,7 +65,13 @@ struct PlaybackTests {
|
||||
transcode: TranscodeSettings(maximumBitrate: 4_000, resolution: "1280x720")
|
||||
))
|
||||
|
||||
#expect(target.url.absoluteString.hasPrefix("http://server:8096/Videos/film-1/main.m3u8"))
|
||||
// **`master.m3u8`, nicht `main.m3u8`.** Der Pfad des Servers bleibt,
|
||||
// nur die Liste wechselt: `main` führt die Schnipsel einer Stufe,
|
||||
// `master` die Spuren. Nur in der zweiten darf `#EXT-X-MEDIA` stehen
|
||||
// — die Zeile, mit der eine Untertitelspur angekündigt wird. Dieser
|
||||
// Test hielt vorher das Gegenteil fest und war damit mitschuldig,
|
||||
// dass nie ein Untertitel erschien.
|
||||
#expect(target.url.absoluteString.hasPrefix("http://server:8096/Videos/film-1/master.m3u8"))
|
||||
#expect(target.sessionID == "ps-7")
|
||||
|
||||
// Die Bitrate versteht Jellyfin in Bit, nicht in Kilobit. Eine Null zu
|
||||
@@ -344,3 +350,94 @@ struct SubtitleStreamIndexTests {
|
||||
#expect(!(try query(transport)).contains { $0.name == "subtitleStreamIndex" })
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Die Master-Liste, auch ohne Leiter")
|
||||
struct MasterPlaylistTests {
|
||||
private func backend(_ transport: RecordingTransport) -> JellyfinBackend {
|
||||
JellyfinBackend(
|
||||
client: JellyfinClient(
|
||||
baseURL: URL(string: "http://server:8096")!,
|
||||
identity: JellyfinIdentity(
|
||||
client: "Flixy", device: "Testgerät", deviceID: "gerät-1", version: "1"
|
||||
),
|
||||
token: "tok",
|
||||
transport: transport
|
||||
),
|
||||
account: JellyfinAccount(
|
||||
userID: "u1", userName: "Test", token: "tok", isAdministrator: false
|
||||
),
|
||||
serverName: "Attrappe"
|
||||
)
|
||||
}
|
||||
|
||||
private func transport() -> RecordingTransport {
|
||||
RecordingTransport(response: """
|
||||
{"MediaSources": [{"Id": "src-1", "TranscodingUrl": "/Videos/film-1/main.m3u8?api_key=tok"}],
|
||||
"PlaySessionId": "ps-7"}
|
||||
""")
|
||||
}
|
||||
|
||||
private func target(remux: Bool, adaptive: Bool) async throws -> URL {
|
||||
try await backend(transport()).preparePlayback(PlaybackIntent(
|
||||
itemID: "film-1",
|
||||
versionIndex: 0,
|
||||
file: MediaFile(id: "src-1", key: "src-1", container: "mkv"),
|
||||
transcode: TranscodeSettings(
|
||||
maximumBitrate: nil,
|
||||
resolution: nil,
|
||||
prefersAdaptive: adaptive,
|
||||
prefersRemux: remux
|
||||
)
|
||||
)).url
|
||||
}
|
||||
|
||||
/// **Der Fehler, wegen dem nie ein Untertitel erschien.** `main.m3u8` ist
|
||||
/// eine Liste von Schnipseln; `#EXT-X-MEDIA` — die Zeile, mit der ein
|
||||
/// Server eine Untertitelspur ankündigt — darf nach RFC 8216 nur in einer
|
||||
/// Master-Liste stehen. Auf `main.m3u8` kann es strukturell keine
|
||||
/// Untertitel geben. Und ein Matroska geht immer über das Umpacken.
|
||||
@Test("Umgepackt kommt die Master-Liste")
|
||||
func remuxGetsMaster() async throws {
|
||||
let adresse = try await target(remux: true, adaptive: false)
|
||||
#expect(adresse.lastPathComponent == "master.m3u8")
|
||||
}
|
||||
|
||||
/// Die Leiter ist eine **andere** Frage als die Master-Liste. Beim
|
||||
/// Umpacken gibt es nur eine Fassung — sie zu bestellen wäre sinnlos.
|
||||
@Test("Umgepackt aber keine Leiter")
|
||||
func remuxHasNoLadder() async throws {
|
||||
let adresse = try await target(remux: true, adaptive: true)
|
||||
let werte = URLComponents(url: adresse, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||
|
||||
#expect(adresse.lastPathComponent == "master.m3u8")
|
||||
#expect(!werte.contains { $0.name.lowercased() == "enableadaptivebitratestreaming" })
|
||||
}
|
||||
|
||||
/// Auch eine von Hand gewählte Stufe braucht die Master-Liste — sonst
|
||||
/// verliert man die Untertitel, sobald man die Qualität einstellt.
|
||||
@Test("Eine feste Stufe bekommt sie auch")
|
||||
func fixedRungGetsMaster() async throws {
|
||||
let adresse = try await target(remux: false, adaptive: false)
|
||||
let werte = URLComponents(url: adresse, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||
|
||||
#expect(adresse.lastPathComponent == "master.m3u8")
|
||||
#expect(!werte.contains { $0.name.lowercased() == "enableadaptivebitratestreaming" })
|
||||
}
|
||||
|
||||
@Test("Bei automatisch kommt zusätzlich die Leiter")
|
||||
func automaticGetsLadder() async throws {
|
||||
let adresse = try await target(remux: false, adaptive: true)
|
||||
let werte = URLComponents(url: adresse, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||
|
||||
#expect(adresse.lastPathComponent == "master.m3u8")
|
||||
#expect(werte.contains(URLQueryItem(name: "enableAdaptiveBitrateStreaming", value: "true")))
|
||||
}
|
||||
|
||||
/// Heisst die Adresse schon so, bleibt sie — und wird nicht doppelt
|
||||
/// umgeschrieben.
|
||||
@Test("Was schon Master heisst, bleibt es")
|
||||
func masterStaysMaster() {
|
||||
let schon = URL(string: "http://s/Videos/x/master.m3u8?api_key=t")!
|
||||
#expect(JellyfinBackend.masterPlaylist(schon) == schon)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user