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:
Scarriffle
2026-09-29 06:04:40 +02:00
co-authored by Claude Opus 5
parent 8e0e8ec246
commit c681d69fcc
7 changed files with 663 additions and 33 deletions
+41 -2
View File
@@ -215,6 +215,8 @@ final class PlayerModel {
/// Einmal gesetzt, bleibt er aus: ein zweiter Anlauf mit demselben /// Einmal gesetzt, bleibt er aus: ein zweiter Anlauf mit demselben
/// Aufbau scheiterte am selben Grund. /// Aufbau scheiterte am selben Grund.
private var prefetchGaveUp = false private var prefetchGaveUp = false
/// Die Notbremse, die eine hängengebliebene Abdeckung wegnimmt.
@ObservationIgnored private var resumeGiveUp: Task<Void, Never>?
/// Der Server, von dem der Player holt — und der Vorrat dahinter. /// Der Server, von dem der Player holt — und der Vorrat dahinter.
private var streamServer: LocalStreamServer? private var streamServer: LocalStreamServer?
private var streamCache: StreamCache? private var streamCache: StreamCache?
@@ -553,7 +555,6 @@ final class PlayerModel {
defer { isChangingQuality = false } defer { isChangingQuality = false }
let resumeAt = currentTime let resumeAt = currentTime
resumeTarget = resumeAt
// Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet // Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet
// der Server zwei Streams gleichzeitig für einen Zuschauer. // der Server zwei Streams gleichzeitig für einen Zuschauer.
@@ -564,6 +565,35 @@ final class PlayerModel {
eventTask?.cancel() eventTask?.cancel()
engine?.teardown() engine?.teardown()
engine = nil engine = nil
// **Die Marke erst jetzt — nicht vorher.**
//
// Hier lag der Sprung an den Filmanfang. Die Zeile stand ganz oben,
// vor dem Abbestellen; dazwischen liegt aber ein echter Netzaufruf
// mit fünf Sekunden Zeitgrenze. In diesem Fenster meldet der **alte**
// Player viermal je Sekunde weiter seine Stelle — und die ist die
// gemerkte. Die Prüfung „sind wir angekommen" traf damit sofort zu
// und löschte die Marke, bevor der neue Stream überhaupt bestellt
// war. Danach setzte jede Meldung des anlaufenden Streams — null,
// eine halbe, eine Sekunde — ungebremst die Stelle, und der nächste
// Neuaufbau merkte sich den Filmanfang.
//
// Nach `eventTask?.cancel()` kommt keine Meldung mehr durch, und die
// Marke gilt für den neuen Stream, für den sie gedacht ist.
resumeTarget = resumeAt
// **Die Spuren des alten Streams gelten nicht mehr.**
//
// Eine frische Engine hat nichts ausgewählt. Blieben die Merker
// stehen, hielten sich beide Wähler für erledigt — und auf dem neuen
// Stream würde nie etwas eingeschaltet. Genau deshalb erschien nach
// dem ersten Untertitel nie wieder einer.
//
// `selectedSubtitleStream` bleibt: das ist die **Bestellung**, und
// die soll den Neuaufbau gerade überleben.
selectedSubtitleTrack = nil
selectedAudioTrack = nil
phase = .preparing phase = .preparing
// Eine erzwungene Entscheidung steht über der Rechnung: sie kommt // Eine erzwungene Entscheidung steht über der Rechnung: sie kommt
@@ -598,8 +628,13 @@ final class PlayerModel {
// Notbremse: springt der Player nie an die Stelle, bliebe sonst für // Notbremse: springt der Player nie an die Stelle, bliebe sonst für
// immer eine Abdeckung über dem Bild. // immer eine Abdeckung über dem Bild.
Task { [weak self] in //
// Festgehalten und beim nächsten Neuaufbau abbestellt — sonst räumte
// der Zeitgeber eines alten Versuchs die Marke eines neuen ab.
resumeGiveUp?.cancel()
resumeGiveUp = Task { [weak self] in
try? await Task.sleep(for: .seconds(30)) try? await Task.sleep(for: .seconds(30))
guard !Task.isCancelled else { return }
self?.resumeTarget = nil self?.resumeTarget = nil
} }
} }
@@ -1386,6 +1421,10 @@ final class PlayerModel {
engine?.teardown() engine?.teardown()
engine = nil engine = nil
// Was auf der alten Engine ausgewählt war, gilt für keine neue.
selectedSubtitleTrack = nil
selectedAudioTrack = nil
resumeGiveUp?.cancel()
// Der Vorrat gehört zu diesem Stream. Stehen zu bleiben hiesse, // Der Vorrat gehört zu diesem Stream. Stehen zu bleiben hiesse,
// Schnipsel für einen Film zu holen, den niemand mehr sieht — und den // Schnipsel für einen Film zu holen, den niemand mehr sieht — und den
// Platz dafür zu behalten. // Platz dafür zu behalten.
@@ -49,6 +49,8 @@ public final class AVPlayerEngine: VideoPlayerEngine {
/// Ein `seek` vor `readyToPlay` verwirft AVPlayer kommentarlos — das /// Ein `seek` vor `readyToPlay` verwirft AVPlayer kommentarlos — das
/// Fortsetzen landete dann wieder am Anfang. /// Fortsetzen landete dann wieder am Anfang.
private var pendingStartTime: Duration = .zero private var pendingStartTime: Duration = .zero
/// Ob gerade ein Sprung an die gemerkte Stelle läuft.
private var isSeekingToStart = false
private var observations: [NSKeyValueObservation] = [] private var observations: [NSKeyValueObservation] = []
private var endObserver: (any NSObjectProtocol)? private var endObserver: (any NSObjectProtocol)?
private var stallObserver: (any NSObjectProtocol)? private var stallObserver: (any NSObjectProtocol)?
@@ -111,6 +113,11 @@ public final class AVPlayerEngine: VideoPlayerEngine {
// Die Buchführung gehört zur Quelle, nicht zur Engine: ein neuer Titel // Die Buchführung gehört zur Quelle, nicht zur Engine: ein neuer Titel
// fängt bei null an, sonst schleppte die Zahl den vorigen Film mit. // fängt bei null an, sonst schleppte die Zahl den vorigen Film mit.
variantBitrates = [] variantBitrates = []
// Sie gehören zum Stream, nicht zur Engine — sonst hielte die Liste
// zwischen zwei Aufbauten die Spuren des alten.
audioTracks = []
subtitleTracks = []
isSeekingToStart = false
wantsToPlay = false wantsToPlay = false
stallCount = 0 stallCount = 0
accumulatedStall = .zero accumulatedStall = .zero
@@ -452,6 +459,20 @@ public final class AVPlayerEngine: VideoPlayerEngine {
} }
) )
// **Ein zweiter Anlass für den Sprung.**
//
// Bei einer Abspielliste wächst der springbare Bereich, während der
// Server produziert. Ein Sprung, der bei „bereit" noch ins Leere
// ging, kann Sekunden später gelingen — und ohne diesen Beobachter
// gäbe es niemanden, der es noch einmal versucht. Die Stelle bleibt
// gemerkt, bis ein Sprung bestätigt ist; danach tut der Beobachter
// nichts mehr.
observations.append(
item.observe(\.seekableTimeRanges, options: [.new]) { [weak self] _, _ in
Task { @MainActor in self?.applyPendingStartTime() }
}
)
// Der ausdrückliche Stocker-Melder des Systems. // Der ausdrückliche Stocker-Melder des Systems.
// //
// Er kommt **nur**, wenn die Wiedergabe schon lief und dann stehen // Er kommt **nur**, wenn die Wiedergabe schon lief und dann stehen
@@ -481,11 +502,45 @@ public final class AVPlayerEngine: VideoPlayerEngine {
} }
/// Springt an die gemerkte Stelle, sobald der Player bereit ist. /// Springt an die gemerkte Stelle, sobald der Player bereit ist.
///
/// **Und merkt sich, ob es geklappt hat.** Vorher wurde die Stelle
/// verbraucht, *bevor* gesprungen wurde, und der Rückgabewert von
/// `seek` verworfen. Misslang der Sprung — und bei einem gerade erst
/// entstehenden Transcode ist das der Normalfall, nicht die Ausnahme —,
/// merkte es niemand und niemand wiederholte ihn. Das Fortsetzen hängt an
/// genau diesem einen Sprung, weil der Server bewusst bei null beginnt.
///
/// Jetzt bleibt die Stelle stehen, bis ein Sprung bestätigt ist, und
/// jede Änderung am springbaren Bereich ist ein neuer Anlass, es zu
/// versuchen.
private func applyPendingStartTime() { private func applyPendingStartTime() {
guard pendingStartTime > .zero else { return } guard pendingStartTime > .zero, !isSeekingToStart else { return }
let target = pendingStartTime let target = pendingStartTime
pendingStartTime = .zero isSeekingToStart = true
Task { await seek(to: target) }
Task { [weak self] in
let geschafft = await self?.seekReportingSuccess(to: target) ?? false
guard let self else { return }
isSeekingToStart = false
if geschafft { pendingStartTime = .zero }
}
}
/// Springt und sagt, ob es getragen hat.
///
/// Die Toleranz ist bewusst grosszügig: framegenau in einen Stream zu
/// springen, den der Server gerade erst erzeugt, ist der teuerste und
/// fragilste Fall. Eine halbe Sekunde daneben merkt niemand, ein
/// misslungener Sprung dagegen kostet den ganzen Fortschritt.
private func seekReportingSuccess(to time: Duration) async -> Bool {
let target = CMTime(seconds: time.seconds, preferredTimescale: 600)
let geschafft = await player.seek(
to: target,
toleranceBefore: CMTime(seconds: 0.5, preferredTimescale: 600),
toleranceAfter: CMTime(seconds: 0.5, preferredTimescale: 600)
)
if geschafft { continuation?.yield(.timeChanged(time)) }
return geschafft
} }
private func teardownObservers() { private func teardownObservers() {
@@ -195,10 +195,15 @@ public actor LocalStreamServer {
let adresse = teile.queryItems?.first(where: { $0.name == "u" })?.value let adresse = teile.queryItems?.first(where: { $0.name == "u" })?.value
else { return finish(verbindung, status: 404, body: Data()) } else { return finish(verbindung, status: 404, body: Data()) }
guard let daten = await cache.data(for: adresse) else { guard let schnipsel = await cache.data(for: adresse) else {
return finish(verbindung, status: 504, body: Data()) return finish(verbindung, status: 504, body: Data())
} }
finish(verbindung, status: 200, body: daten, type: "video/mp4") // **Die Sorte, die wirklich ankam.** Hier stand fest „video/mp4", für
// jeden Schnipsel. Ein WebVTT-Körper, so angekündigt, geht bei
// AVFoundation an den falschen Parser und ergibt lautlos keine
// Einblendung — die Spur steht im Blatt, ist ausgewählt, und bleibt
// leer. Genau daran lag es.
finish(verbindung, status: 200, body: schnipsel.data, type: schnipsel.contentType)
} }
private nonisolated func finish( private nonisolated func finish(
@@ -22,6 +22,12 @@ import OSLog
/// **Der Vorsprung ist begrenzt**, und zwar in Sekunden Film, nicht in /// **Der Vorsprung ist begrenzt**, und zwar in Sekunden Film, nicht in
/// Schnipseln: die Zahl soll dasselbe bedeuten, egal wie der Server schneidet. /// Schnipseln: die Zahl soll dasselbe bedeuten, egal wie der Server schneidet.
public actor StreamCache { public actor StreamCache {
/// Ein Schnipsel — Daten **und** wofür sie sich ausgeben.
public struct Segment: Sendable {
public let data: Data
public let contentType: String
}
/// Wie weit vorausgelaufen wird. /// Wie weit vorausgelaufen wird.
public struct Policy: Sendable { public struct Policy: Sendable {
/// Wie viel Film im Voraus geholt werden soll. /// Wie viel Film im Voraus geholt werden soll.
@@ -47,8 +53,14 @@ public actor StreamCache {
private var segments: [String] = [] private var segments: [String] = []
private var targetDuration: Duration = .seconds(6) private var targetDuration: Duration = .seconds(6)
/// Was schon auf der Platte liegt — Pfad auf Datei. /// Was schon auf der Platte liegt — Adresse auf Datei **und Sorte**.
private var stored: [String: URL] = [:] ///
/// **Die Sorte gehört dazu.** Ein Schnipsel ist nicht zwangsläufig Bild:
/// die Untertitelspur liefert WebVTT, der Ton kann AAC sein. Wer die
/// Sorte wegwirft und später etwas behauptet, liefert dem Player einen
/// Text als Video — der gibt ihn dem falschen Parser, findet keine Box,
/// und es entsteht lautlos gar nichts.
private var stored: [String: (ort: URL, type: String)] = [:]
private var storedBytes = 0 private var storedBytes = 0
/// Welchen Schnipsel der Player zuletzt verlangt hat. /// Welchen Schnipsel der Player zuletzt verlangt hat.
private var lastRequested = 0 private var lastRequested = 0
@@ -147,13 +159,15 @@ public actor StreamCache {
// MARK: - Ausliefern // MARK: - Ausliefern
/// Die Daten zu einer Adresse — aus dem Vorrat, sonst frisch geholt. /// Die Daten zu einer Adresse — aus dem Vorrat, sonst frisch geholt.
public func data(for address: String) async -> Data? { public func data(for address: String) async -> Segment? {
if let index = segments.firstIndex(of: address) { if let index = segments.firstIndex(of: address) {
lastRequested = max(lastRequested, index) lastRequested = max(lastRequested, index)
trimBehind() trimBehind()
} }
if let ort = stored[address], let daten = try? Data(contentsOf: ort) { return daten } if let eintrag = stored[address], let daten = try? Data(contentsOf: eintrag.ort) {
return Segment(data: daten, contentType: eintrag.type)
}
// **Der Player hat Vorrang.** Fehlt ihm etwas, wartet er darauf — und // **Der Player hat Vorrang.** Fehlt ihm etwas, wartet er darauf — und
// der Vorrat darf ihm dabei nicht die Leitung wegnehmen. Rechnet der // der Vorrat darf ihm dabei nicht die Leitung wegnehmen. Rechnet der
@@ -218,23 +232,43 @@ public actor StreamCache {
} }
@discardableResult @discardableResult
private func fetch(_ address: String) async -> Data? { private func fetch(_ address: String) async -> Segment? {
guard let ziel = URL(string: address) else { return nil } guard let ziel = URL(string: address) else { return nil }
do { do {
let (daten, antwort) = try await session.data(from: ziel) var anfrage = URLRequest(url: ziel)
guard (antwort as? HTTPURLResponse)?.statusCode ?? 200 < 400 else { return nil } for (name, wert) in headers { anfrage.setValue(wert, forHTTPHeaderField: name) }
store(daten, at: address) let (daten, antwort) = try await session.data(for: anfrage)
return daten let http = antwort as? HTTPURLResponse
guard http?.statusCode ?? 200 < 400 else { return nil }
// **Die Sorte kommt von der Gegenstelle, nicht von uns.** Sie
// weiss, was sie geschickt hat; wir wüssten es nur aus der
// Endung, und die kann fehlen oder lügen. Erst wenn sie nichts
// sagt, raten wir anhand der Adresse.
let sorte = http?.value(forHTTPHeaderField: "Content-Type")
?? Self.guessedType(for: address)
store(daten, type: sorte, at: address)
return Segment(data: daten, contentType: sorte)
} catch { } catch {
return nil return nil
} }
} }
private func store(_ daten: Data, at path: String) { /// Die Sorte aus der Endung — der Notnagel.
static func guessedType(for address: String) -> String {
let pfad = URL(string: address)?.path.lowercased() ?? address.lowercased()
if pfad.hasSuffix(".vtt") { return "text/vtt" }
if pfad.hasSuffix(".ts") { return "video/MP2T" }
if pfad.hasSuffix(".aac") { return "audio/aac" }
if pfad.hasSuffix(".m3u8") { return "application/vnd.apple.mpegurl" }
return "video/mp4"
}
private func store(_ daten: Data, type: String, at path: String) {
let ort = directory.appending(path: UUID().uuidString) let ort = directory.appending(path: UUID().uuidString)
guard (try? daten.write(to: ort, options: .atomic)) != nil else { return } guard (try? daten.write(to: ort, options: .atomic)) != nil else { return }
stored[path] = ort stored[path] = (ort, type)
storedBytes += daten.count storedBytes += daten.count
} }
@@ -249,11 +283,11 @@ public actor StreamCache {
for index in 0..<min(grenze, segments.count) { for index in 0..<min(grenze, segments.count) {
let pfad = segments[index] let pfad = segments[index]
guard let ort = stored.removeValue(forKey: pfad) else { continue } guard let eintrag = stored.removeValue(forKey: pfad) else { continue }
let größe = (try? FileManager.default.attributesOfItem(atPath: ort.path)) let größe = (try? FileManager.default.attributesOfItem(atPath: eintrag.ort.path))
.flatMap { $0[.size] as? Int } ?? 0 .flatMap { $0[.size] as? Int } ?? 0
storedBytes = max(0, storedBytes - größe) storedBytes = max(0, storedBytes - größe)
try? FileManager.default.removeItem(at: ort) try? FileManager.default.removeItem(at: eintrag.ort)
} }
} }
@@ -60,11 +60,27 @@ extension JellyfinBackend {
} }
var adresse = Self.limiting(height: transcode.resolution, in: url) var adresse = Self.limiting(height: transcode.resolution, in: url)
// Beim Umpacken **keine** Leiter: es gibt nur eine Fassung, nämlich
// die Datei selbst. Und `master.m3u8` wäre ohnehin das Gegenteil // **Immer die Master-Liste, auch ohne Leiter.**
// dessen, worum es geht — eine Abspielliste statt einer Datei. //
// Der Kommentar, der hier stand, war überholt und hat einen Fehler
// verdeckt: er stammte aus der Zeit, als Umpacken eine fortlaufende
// MP4 war — da wäre eine Abspielliste tatsächlich verkehrt gewesen.
// Seit das Umpacken über HLS läuft, ist sie genau richtig.
//
// Und sie ist nicht nur richtig, sie ist notwendig: eine
// Untertitelspur wird mit `#EXT-X-MEDIA` angekündigt, und das steht
// ausschliesslich in einer Master-Liste. Auf `main.m3u8` kann es
// strukturell keine Untertitel geben.
//
// Die **Leiter** bleibt davon unberührt. Sie ist eine eigene Frage
// und wird weiterhin nur bei „automatisch" und ohne Umpacken
// bestellt — beim Umpacken gibt es nur eine Fassung, nämlich die
// Datei selbst.
if transcode.prefersAdaptive, !transcode.prefersRemux { if transcode.prefersAdaptive, !transcode.prefersRemux {
adresse = Self.adaptive(adresse) adresse = Self.adaptive(adresse)
} else {
adresse = Self.masterPlaylist(adresse)
} }
return PlaybackTarget( return PlaybackTarget(
@@ -280,14 +296,8 @@ extension JellyfinBackend {
/// deshalb nicht hier, sondern in `PlaybackStatistics.variantCount` — und /// deshalb nicht hier, sondern in `PlaybackStatistics.variantCount` — und
/// das ist die einzige belastbare Auskunft darüber. /// das ist die einzige belastbare Auskunft darüber.
static func adaptive(_ url: URL) -> URL { static func adaptive(_ url: URL) -> URL {
var url = url guard var components = URLComponents(url: masterPlaylist(url), resolvingAgainstBaseURL: false)
if url.lastPathComponent == "main.m3u8" { else { return masterPlaylist(url) }
url = url.deletingLastPathComponent().appending(path: "master.m3u8")
}
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return url
}
var items = (components.queryItems ?? []).filter { var items = (components.queryItems ?? []).filter {
$0.name.lowercased() != "enableadaptivebitratestreaming" $0.name.lowercased() != "enableadaptivebitratestreaming"
} }
@@ -295,6 +305,29 @@ extension JellyfinBackend {
components.queryItems = items components.queryItems = items
return components.url ?? url return components.url ?? url
} }
/// Von der Liste **einer** Stufe zur Liste **aller** Spuren.
///
/// **Der eigene Handgriff, und der wichtigere.** `main.m3u8` ist eine
/// Media Playlist: eine Liste von Schnipseln, sonst nichts. `master.m3u8`
/// ist eine Master Playlist, und nur dort darf nach RFC 8216 ein
/// `#EXT-X-MEDIA` stehen — also die Zeile, mit der ein Server eine
/// **Untertitelspur** ankündigt.
///
/// Das heisst: auf `main.m3u8` kann es strukturell keine Untertitel
/// geben, ganz gleich was bestellt wurde und was der Server liefern
/// wollte. Genau daran lag es, dass beim Umpacken und bei jeder von Hand
/// gewählten Stufe nie ein Untertitel erschien — dort blieb die Adresse
/// `main.m3u8`, weil der Wechsel an der Leiter hing.
///
/// Deshalb steht er jetzt für sich: **jeder HLS-Stream bekommt die
/// Master-Liste.** Ob darin mehrere Stufen stehen, ist eine andere Frage
/// — die beantwortet `enableAdaptiveBitrateStreaming`, und eine
/// Master-Liste mit einer einzigen Stufe ist völlig in Ordnung.
static func masterPlaylist(_ url: URL) -> URL {
guard url.lastPathComponent == "main.m3u8" else { return url }
return url.deletingLastPathComponent().appending(path: "master.m3u8")
}
} }
/// Was der Server für eine Wiedergabe vorbereitet hat. /// Was der Server für eine Wiedergabe vorbereitet hat.
@@ -0,0 +1,367 @@
import Foundation
import Network
import Testing
@testable import FlixyPlayback
/// Was der lokale Server dem Player wirklich schickt.
///
/// **Warum es diesen Test gibt.** Der Speicher steht zwischen Jellyfin und
/// AVPlayer, und was dort schiefgeht, sieht von aussen aus wie „der Server
/// liefert nichts" oder „der Player zeigt nichts". Sechs Reparaturversuche an
/// den falschen Stellen später steht fest: ohne eine Stelle, an der man die
/// Antwort **liest**, ist das nicht zu finden.
///
/// Hier steht ein eigener Herkunftsserver, der aufschreibt, was der Speicher
/// ihn gefragt hat, und der Test liest die Antworten Byte für Byte. Kein
/// Gerät, kein Simulator, kein echtes Jellyfin.
@Suite("Der lokale Server", .serialized)
struct LocalStreamServerTests {
// MARK: - Vorlagen
private static let master = """
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Deutsch",LANGUAGE="ger",DEFAULT=YES,AUTOSELECT=YES,URI="subtitles/2/subtitles.m3u8?api_key=abc&SubtitleStreamIndex=2"
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1920x1080,SUBTITLES="subs"
main.m3u8?api_key=abc
"""
private static let bildListe = """
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:6
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:6.000000,
hls1/main/0.ts?api_key=abc
#EXTINF:6.000000,
hls1/main/1.ts?api_key=abc
#EXT-X-ENDLIST
"""
private static let untertitelListe = """
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:6
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:6.000000,
0.vtt?api_key=abc&SubtitleStreamIndex=2
#EXTINF:6.000000,
1.vtt?api_key=abc&SubtitleStreamIndex=2
#EXT-X-ENDLIST
"""
private static let vtt = """
WEBVTT
X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000
00:00:01.000 --> 00:00:04.000
Hallo Welt.
"""
/// Der Herkunftsserver mit den Antworten, die Jellyfin geben würde.
private static func jellyfin() -> FakeOrigin {
let server = FakeOrigin()
server.add("/videos/x/master.m3u8", type: "application/vnd.apple.mpegurl", body: master)
server.add("/videos/x/main.m3u8", type: "application/vnd.apple.mpegurl", body: bildListe)
server.add("/videos/x/subtitles/2/subtitles.m3u8", type: "application/vnd.apple.mpegurl", body: untertitelListe)
server.add("/videos/x/hls1/main/0.ts", type: "video/MP2T", body: "TS-DATEN-0")
server.add("/videos/x/hls1/main/1.ts", type: "video/MP2T", body: "TS-DATEN-1")
server.add("/videos/x/subtitles/2/0.vtt", type: "text/vtt", body: vtt)
server.add("/videos/x/subtitles/2/1.vtt", type: "text/vtt", body: vtt)
return server
}
// MARK: - Der Aufbau
/// Startet Herkunftsserver und Speicher und gibt die Adresse zurück, die
/// der Player bekäme.
private func aufbauen() async throws -> (FakeOrigin, LocalStreamServer, URL) {
let herkunft = Self.jellyfin()
let port = try await herkunft.start()
let speicher = StreamCache()
let server = LocalStreamServer(cache: speicher)
let oben = URL(string: "http://127.0.0.1:\(port)/videos/x/master.m3u8?api_key=abc")!
let lokal = try await server.start(upstream: oben)
return (herkunft, server, lokal)
}
/// Holt eine Adresse und gibt Status, Inhaltstyp und Körper zurück.
private func hole(_ url: URL) async throws -> (status: Int, type: String?, body: String) {
let (daten, antwort) = try await URLSession.shared.data(from: url)
let http = antwort as? HTTPURLResponse
return (
http?.statusCode ?? 0,
http?.value(forHTTPHeaderField: "Content-Type"),
String(decoding: daten, as: UTF8.self)
)
}
/// Zieht die erste Adresse aus einem `URI="…"` der Liste.
private func renditionAdresse(in liste: String, basis: URL) -> URL? {
guard let start = liste.range(of: "URI=\""),
let ende = liste[start.upperBound...].firstIndex(of: "\"")
else { return nil }
return URL(string: String(liste[start.upperBound..<ende]), relativeTo: basis)
}
/// Zieht die erste Segment-Zeile aus einer Medienliste.
private func ersteSegmentAdresse(in liste: String, basis: URL) -> URL? {
for zeile in liste.components(separatedBy: .newlines) {
let getrimmt = zeile.trimmingCharacters(in: .whitespaces)
guard !getrimmt.isEmpty, !getrimmt.hasPrefix("#") else { continue }
return URL(string: getrimmt, relativeTo: basis)
}
return nil
}
// MARK: - Die Tests
/// **Der entscheidende.** Ein WebVTT-Körper, angekündigt als `video/mp4`,
/// geht bei AVFoundation an den falschen Parser, findet keine Box, und es
/// entstehen keine Einblendungen — lautlos. Die Spur steht im Blatt, ist
/// ausgewählt, und bleibt leer.
@Test("Ein Untertitel-Schnipsel kommt als text/vtt zurück")
func subtitleSegmentKeepsItsType() async throws {
let (herkunft, server, lokal) = try await aufbauen()
defer { Task { await server.stop() }; herkunft.stop() }
let masterAntwort = try await hole(lokal)
let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal))
let rendition = try await hole(renditionURL)
let segmentURL = try #require(ersteSegmentAdresse(in: rendition.body, basis: lokal))
let segment = try await hole(segmentURL)
#expect(segment.status == 200)
#expect(segment.body.hasPrefix("WEBVTT"))
#expect(segment.type == "text/vtt", "geliefert wurde: \(segment.type ?? "nichts")")
}
/// Gegentest, damit die Reparatur nicht zu breit wird.
@Test("Ein Bild-Schnipsel behält seinen eigenen Typ")
func videoSegmentKeepsItsType() async throws {
let (herkunft, server, lokal) = try await aufbauen()
defer { Task { await server.stop() }; herkunft.stop() }
let masterAntwort = try await hole(lokal)
// Die Bildstufe ist die Zeile ohne `URI=` — die erste gewöhnliche
// Adresse in der Liste.
let stufeURL = try #require(ersteSegmentAdresse(in: masterAntwort.body, basis: lokal))
let stufe = try await hole(stufeURL)
let segmentURL = try #require(ersteSegmentAdresse(in: stufe.body, basis: lokal))
let segment = try await hole(segmentURL)
#expect(segment.status == 200)
#expect(segment.type == "video/MP2T", "geliefert wurde: \(segment.type ?? "nichts")")
}
/// Die Abfrage des Servers wandert durch unsere eigene Abfrage hindurch.
/// Verliert `URLComponents` dabei etwas, holt der Speicher die falsche
/// Datei — und niemand sähe es der Antwort an.
@Test("Die Adresse übersteht die Rundreise zeichengenau")
func addressSurvivesTheRoundTrip() async throws {
let (herkunft, server, lokal) = try await aufbauen()
defer { Task { await server.stop() }; herkunft.stop() }
let masterAntwort = try await hole(lokal)
let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal))
_ = try await hole(renditionURL)
let gesehen = herkunft.requests
#expect(
gesehen.contains { $0.contains("/videos/x/subtitles/2/subtitles.m3u8")
&& $0.contains("api_key=abc")
&& $0.contains("SubtitleStreamIndex=2") },
"der Herkunftsserver sah: \(gesehen)"
)
}
/// Ohne diese Zeilen gibt es für AVPlayer keine Untertitelspur — egal was
/// danach passiert.
@Test("Die Untertitelspur bleibt in der Master-Liste stehen")
func masterKeepsTheRendition() async throws {
let (herkunft, server, lokal) = try await aufbauen()
defer { Task { await server.stop() }; herkunft.stop() }
let antwort = try await hole(lokal)
#expect(antwort.status == 200)
#expect(antwort.type == "application/vnd.apple.mpegurl")
#expect(antwort.body.contains("#EXT-X-MEDIA:TYPE=SUBTITLES"))
#expect(antwort.body.contains("GROUP-ID=\"subs\""))
#expect(antwort.body.contains("LANGUAGE=\"ger\""))
#expect(antwort.body.contains("DEFAULT=YES"))
#expect(antwort.body.contains("SUBTITLES=\"subs\""))
}
/// Sonst lüde der Vorrat Untertitel statt Bild: der Film bliebe stehen,
/// während die Anzeige reichlich Vorrat meldet.
@Test("Die Untertitelspur wird nicht vorgeladen")
func renditionIsNotPrefetched() async throws {
let herkunft = Self.jellyfin()
let port = try await herkunft.start()
let speicher = StreamCache()
let server = LocalStreamServer(cache: speicher)
let oben = URL(string: "http://127.0.0.1:\(port)/videos/x/master.m3u8?api_key=abc")!
let lokal = try await server.start(upstream: oben)
defer { Task { await server.stop() }; herkunft.stop() }
let masterAntwort = try await hole(lokal)
let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal))
_ = try await hole(renditionURL)
#expect(await speicher.bufferedAhead == .zero)
}
/// Ein leeres 200 ist genauso unsichtbar wie der falsche Typ. Die beiden
/// müssen auseinanderzuhalten sein, sonst sucht man wieder an der falschen
/// Stelle.
@Test("Ein Fehler von oben bleibt ein Fehler")
func upstreamFailureStaysAFailure() async throws {
let herkunft = Self.jellyfin()
herkunft.add("/videos/x/subtitles/2/0.vtt", status: 401, type: "text/plain", body: "nope")
let port = try await herkunft.start()
let speicher = StreamCache()
let server = LocalStreamServer(cache: speicher)
let oben = URL(string: "http://127.0.0.1:\(port)/videos/x/master.m3u8?api_key=abc")!
let lokal = try await server.start(upstream: oben)
defer { Task { await server.stop() }; herkunft.stop() }
let masterAntwort = try await hole(lokal)
let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal))
let rendition = try await hole(renditionURL)
let segmentURL = try #require(ersteSegmentAdresse(in: rendition.body, basis: lokal))
let segment = try await hole(segmentURL)
#expect(segment.status == 504)
}
}
// MARK: - Der Herkunftsserver
/// Ein winziger HTTP-Server, der die Antworten gibt, die Jellyfin geben würde
/// — und aufschreibt, wonach gefragt wurde.
///
/// Das Protokoll ist der eigentliche Gewinn: ohne es sieht man nur, was
/// **heraus**kommt, nicht wonach der Speicher gefragt hat. Genau diese Lücke
/// hat die Fehlersuche so lange gekostet.
final class FakeOrigin: @unchecked Sendable {
private struct Antwort {
let status: Int
let type: String
let body: String
}
private let lock = NSLock()
private var antworten: [String: Antwort] = [:]
private var gesehen: [String] = []
private var listener: NWListener?
func add(_ path: String, status: Int = 200, type: String, body: String) {
lock.withLock { antworten[path] = Antwort(status: status, type: type, body: body) }
}
/// Alles, wonach gefragt wurde — mit vollständiger Abfrage.
var requests: [String] {
lock.withLock { gesehen }
}
func start() async throws -> UInt16 {
let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any)
let lauscher = try NWListener(using: parameters)
lauscher.newConnectionHandler = { [weak self] verbindung in
verbindung.start(queue: .global(qos: .userInitiated))
self?.receive(on: verbindung, buffer: Data())
}
let port: UInt16 = try await withCheckedThrowingContinuation { fortsetzen in
let riegel = TestOneShot()
lauscher.stateUpdateHandler = { zustand in
switch zustand {
case .ready:
guard riegel.claim() else { return }
fortsetzen.resume(returning: lauscher.port?.rawValue ?? 0)
case .failed(let fehler):
guard riegel.claim() else { return }
fortsetzen.resume(throwing: fehler)
default:
break
}
}
lauscher.start(queue: .global(qos: .userInitiated))
}
listener = lauscher
return port
}
func stop() {
listener?.cancel()
listener = nil
}
private func receive(on verbindung: NWConnection, buffer: Data) {
verbindung.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1_024) {
[weak self] daten, _, abgeschlossen, fehler in
guard let self else { return }
var gesammelt = buffer
if let daten { gesammelt.append(daten) }
guard let ende = gesammelt.range(of: Data("\r\n\r\n".utf8)) else {
if fehler == nil, !abgeschlossen {
self.receive(on: verbindung, buffer: gesammelt)
} else {
verbindung.cancel()
}
return
}
let kopf = String(decoding: gesammelt[..<ende.lowerBound], as: UTF8.self)
self.antworte(auf: kopf, verbindung: verbindung)
}
}
private func antworte(auf kopf: String, verbindung: NWConnection) {
let zeile = kopf.components(separatedBy: "\r\n").first ?? ""
let pfadMitAbfrage = zeile.components(separatedBy: " ").dropFirst().first ?? ""
let pfad = pfadMitAbfrage.components(separatedBy: "?").first ?? pfadMitAbfrage
let antwort = lock.withLock { () -> Antwort? in
gesehen.append(pfadMitAbfrage)
return antworten[pfad]
}
let a = antwort ?? Antwort(status: 404, type: "text/plain", body: "nicht da")
let koerper = Data(a.body.utf8)
let ausgabe = """
HTTP/1.1 \(a.status) OK\r
Content-Type: \(a.type)\r
Content-Length: \(koerper.count)\r
Connection: close\r
\r
"""
var alles = Data(ausgabe.utf8)
alles.append(koerper)
verbindung.send(content: alles, completion: .contentProcessed { _ in verbindung.cancel() })
}
}
/// Wie im Original: ein Riegel, der genau einmal aufgeht.
private final class TestOneShot: @unchecked Sendable {
private let lock = NSLock()
private var used = false
func claim() -> Bool {
lock.withLock {
guard !used else { return false }
used = true
return true
}
}
}
@@ -65,7 +65,13 @@ struct PlaybackTests {
transcode: TranscodeSettings(maximumBitrate: 4_000, resolution: "1280x720") 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") #expect(target.sessionID == "ps-7")
// Die Bitrate versteht Jellyfin in Bit, nicht in Kilobit. Eine Null zu // 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" }) #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)
}
}