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
@@ -49,6 +49,8 @@ public final class AVPlayerEngine: VideoPlayerEngine {
|
||||
/// Ein `seek` vor `readyToPlay` verwirft AVPlayer kommentarlos — das
|
||||
/// Fortsetzen landete dann wieder am Anfang.
|
||||
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 endObserver: (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
|
||||
// fängt bei null an, sonst schleppte die Zahl den vorigen Film mit.
|
||||
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
|
||||
stallCount = 0
|
||||
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.
|
||||
//
|
||||
// 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.
|
||||
///
|
||||
/// **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() {
|
||||
guard pendingStartTime > .zero else { return }
|
||||
guard pendingStartTime > .zero, !isSeekingToStart else { return }
|
||||
let target = pendingStartTime
|
||||
pendingStartTime = .zero
|
||||
Task { await seek(to: target) }
|
||||
isSeekingToStart = true
|
||||
|
||||
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() {
|
||||
|
||||
@@ -195,10 +195,15 @@ public actor LocalStreamServer {
|
||||
let adresse = teile.queryItems?.first(where: { $0.name == "u" })?.value
|
||||
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())
|
||||
}
|
||||
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(
|
||||
|
||||
@@ -22,6 +22,12 @@ import OSLog
|
||||
/// **Der Vorsprung ist begrenzt**, und zwar in Sekunden Film, nicht in
|
||||
/// Schnipseln: die Zahl soll dasselbe bedeuten, egal wie der Server schneidet.
|
||||
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.
|
||||
public struct Policy: Sendable {
|
||||
/// Wie viel Film im Voraus geholt werden soll.
|
||||
@@ -47,8 +53,14 @@ public actor StreamCache {
|
||||
private var segments: [String] = []
|
||||
private var targetDuration: Duration = .seconds(6)
|
||||
|
||||
/// Was schon auf der Platte liegt — Pfad auf Datei.
|
||||
private var stored: [String: URL] = [:]
|
||||
/// Was schon auf der Platte liegt — Adresse auf Datei **und Sorte**.
|
||||
///
|
||||
/// **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
|
||||
/// Welchen Schnipsel der Player zuletzt verlangt hat.
|
||||
private var lastRequested = 0
|
||||
@@ -147,13 +159,15 @@ public actor StreamCache {
|
||||
// MARK: - Ausliefern
|
||||
|
||||
/// 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) {
|
||||
lastRequested = max(lastRequested, index)
|
||||
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 Vorrat darf ihm dabei nicht die Leitung wegnehmen. Rechnet der
|
||||
@@ -218,23 +232,43 @@ public actor StreamCache {
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func fetch(_ address: String) async -> Data? {
|
||||
private func fetch(_ address: String) async -> Segment? {
|
||||
guard let ziel = URL(string: address) else { return nil }
|
||||
|
||||
do {
|
||||
let (daten, antwort) = try await session.data(from: ziel)
|
||||
guard (antwort as? HTTPURLResponse)?.statusCode ?? 200 < 400 else { return nil }
|
||||
store(daten, at: address)
|
||||
return daten
|
||||
var anfrage = URLRequest(url: ziel)
|
||||
for (name, wert) in headers { anfrage.setValue(wert, forHTTPHeaderField: name) }
|
||||
let (daten, antwort) = try await session.data(for: anfrage)
|
||||
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 {
|
||||
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)
|
||||
guard (try? daten.write(to: ort, options: .atomic)) != nil else { return }
|
||||
stored[path] = ort
|
||||
stored[path] = (ort, type)
|
||||
storedBytes += daten.count
|
||||
}
|
||||
|
||||
@@ -249,11 +283,11 @@ public actor StreamCache {
|
||||
|
||||
for index in 0..<min(grenze, segments.count) {
|
||||
let pfad = segments[index]
|
||||
guard let ort = stored.removeValue(forKey: pfad) else { continue }
|
||||
let größe = (try? FileManager.default.attributesOfItem(atPath: ort.path))
|
||||
guard let eintrag = stored.removeValue(forKey: pfad) else { continue }
|
||||
let größe = (try? FileManager.default.attributesOfItem(atPath: eintrag.ort.path))
|
||||
.flatMap { $0[.size] as? Int } ?? 0
|
||||
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)
|
||||
// Beim Umpacken **keine** Leiter: es gibt nur eine Fassung, nämlich
|
||||
// die Datei selbst. Und `master.m3u8` wäre ohnehin das Gegenteil
|
||||
// dessen, worum es geht — eine Abspielliste statt einer Datei.
|
||||
|
||||
// **Immer die Master-Liste, auch ohne Leiter.**
|
||||
//
|
||||
// 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 {
|
||||
adresse = Self.adaptive(adresse)
|
||||
} else {
|
||||
adresse = Self.masterPlaylist(adresse)
|
||||
}
|
||||
|
||||
return PlaybackTarget(
|
||||
@@ -280,14 +296,8 @@ extension JellyfinBackend {
|
||||
/// deshalb nicht hier, sondern in `PlaybackStatistics.variantCount` — und
|
||||
/// das ist die einzige belastbare Auskunft darüber.
|
||||
static func adaptive(_ url: URL) -> URL {
|
||||
var url = url
|
||||
if url.lastPathComponent == "main.m3u8" {
|
||||
url = url.deletingLastPathComponent().appending(path: "master.m3u8")
|
||||
}
|
||||
|
||||
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return url
|
||||
}
|
||||
guard var components = URLComponents(url: masterPlaylist(url), resolvingAgainstBaseURL: false)
|
||||
else { return masterPlaylist(url) }
|
||||
var items = (components.queryItems ?? []).filter {
|
||||
$0.name.lowercased() != "enableadaptivebitratestreaming"
|
||||
}
|
||||
@@ -295,6 +305,29 @@ extension JellyfinBackend {
|
||||
components.queryItems = items
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user