Der Vorrat gibt dem Player Vorrang und weicht, wenn er im Weg steht

Sobald das Umrechnen teuer wird — und das Einbrennen eines Untertitels ist
teuer —, bedient Jellyfin einen Schnipsel nach dem anderen. Der Vorrat hat
dabei gleichzeitig weitergeholt und dem Player die Leitung weggenommen:
zwei Anfragen machen die dringende langsamer, nicht schneller. Herauskam
„Es kommen keine Daten mehr\".

Jetzt ruht der Vorrat, solange eine Anfrage des Players offen ist.

Dazu ein Rueckzugsweg, der gefehlt hat. Der Speicher steht zwischen Player
und Server, und was dort schiefgeht, sieht von aussen aus wie eine tote
Leitung. Kommt nach zwoelf Sekunden nichts an, wird er weggenommen und es
laeuft ohne ihn noch einmal an — das kostet nur den Vorsprung und
unterscheidet die beiden Faelle, statt sie zu verwechseln. Erst danach die
kleinere Bitrate, erst danach das Aufgeben.

Und drei Zeilen ins Protokoll, die drei Runden Ratens erspart haetten: was
bestellt wurde, was der Player daraus meldet, und welche Spur eingeschaltet
wurde. „Nicht geliefert\" und „geliefert und nicht angezeigt\" sehen von
aussen gleich aus — im Protokoll nicht mehr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-28 21:50:57 +02:00
co-authored by Claude Opus 5
parent 5e33971452
commit 8e0e8ec246
2 changed files with 70 additions and 6 deletions
+56 -6
View File
@@ -210,6 +210,11 @@ final class PlayerModel {
private var seekTarget: Duration? private var seekTarget: Duration?
/// Ob schon einmal auf VLC ausgewichen wurde. /// Ob schon einmal auf VLC ausgewichen wurde.
private var hasFallenBack = false private var hasFallenBack = false
/// Ob der Vorratsspeicher für diese Wiedergabe aufgegeben wurde.
///
/// Einmal gesetzt, bleibt er aus: ein zweiter Anlauf mit demselben
/// Aufbau scheiterte am selben Grund.
private var prefetchGaveUp = false
/// 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?
@@ -744,6 +749,11 @@ final class PlayerModel {
// niemand bestellt hat. // niemand bestellt hat.
subtitleStreamIndex: selectedSubtitleStream?.index ?? -1 subtitleStreamIndex: selectedSubtitleStream?.index ?? -1
)) ))
diagnostics.note(
"Bestellt — Ton \(selectedAudioStream?.index.map(String.init) ?? "Server")"
+ ", Untertitel \(selectedSubtitleStream?.index.map(String.init) ?? "keine")"
)
playbackSession = target.sessionID playbackSession = target.sessionID
playbackVersionID = target.versionID playbackVersionID = target.versionID
@@ -833,7 +843,7 @@ final class PlayerModel {
/// Bereichsanfragen, und dort puffert AVPlayer von sich aus weit — dort /// Bereichsanfragen, und dort puffert AVPlayer von sich aus weit — dort
/// wäre ein Server davor nur eine zusätzliche Fehlerquelle. /// wäre ein Server davor nur eine zusätzliche Fehlerquelle.
private func startPrefetching(from url: URL) async -> URL? { private func startPrefetching(from url: URL) async -> URL? {
guard url.pathExtension.lowercased() == "m3u8" else { return nil } guard !prefetchGaveUp, url.pathExtension.lowercased() == "m3u8" else { return nil }
await streamServer?.stop() await streamServer?.stop()
let speicher = StreamCache() let speicher = StreamCache()
@@ -989,6 +999,22 @@ final class PlayerModel {
return true return true
} }
/// Noch einmal, ohne den eigenen Vorratsspeicher.
///
/// Gibt `true` zurück, wenn es einen Versuch gab. Genau **einen**: ohne
/// Speicher bleibt nichts mehr wegzunehmen, und ein weiterer Anlauf
/// schickte die App im Kreis.
private func retryWithoutPrefetch() async -> Bool {
guard streamServer != nil, !prefetchGaveUp else { return false }
prefetchGaveUp = true
diagnostics.note("Nichts kam an — noch einmal ohne Vorrat")
prefetchStatus = String(localized: "quality.prefetch.off")
await restart()
if case .failed = phase { return false }
return true
}
/// Der Rückzug auf VLC, wenn das Umpacken nicht getragen hat. /// Der Rückzug auf VLC, wenn das Umpacken nicht getragen hat.
/// ///
/// Genau **einmal** je Wiedergabe: scheitert auch VLC, ist es ein Fehler /// Genau **einmal** je Wiedergabe: scheitert auch VLC, ist es ein Fehler
@@ -1205,7 +1231,16 @@ final class PlayerModel {
guard isBuffering else { return } guard isBuffering else { return }
bufferWatchdog = Task { [weak self] in bufferWatchdog = Task { [weak self] in
try? await Task.sleep(for: .seconds(40)) // **Der Vorrat fliegt früher raus als alles andere.**
//
// Vierzig Sekunden sind die Grenze zum Aufgeben; so lange muss
// niemand warten, um herauszufinden, ob der eigene Speicher im
// Weg steht. Ihn wegzunehmen kostet nur den Vorsprung, und wenn
// es danach läuft, war er die Ursache.
try? await Task.sleep(for: .seconds(12))
if let self, self.isBuffering, await self.retryWithoutPrefetch() { return }
try? await Task.sleep(for: .seconds(28))
guard !Task.isCancelled, let self, self.isBuffering else { return } guard !Task.isCancelled, let self, self.isBuffering else { return }
diagnostics.note("Seit 40 s keine Daten") diagnostics.note("Seit 40 s keine Daten")
@@ -1223,6 +1258,13 @@ final class PlayerModel {
// „nimm, was in den Einstellungen steht". Steht dort „Original", // „nimm, was in den Einstellungen steht". Steht dort „Original",
// bittet die App über eine Zugverbindung um eine 4K-Datei und // bittet die App über eine Zugverbindung um eine 4K-Datei und
// wartet vierzig Sekunden auf etwas, das nie kommen kann. // wartet vierzig Sekunden auf etwas, das nie kommen kann.
// **Erst ohne den Vorratsspeicher.**
//
// Er steht zwischen Player und Server, und was dort schiefgeht,
// sieht von aussen genauso aus wie eine tote Leitung. Ihn
// wegzunehmen ist billig, kostet nur den Vorsprung — und
// unterscheidet die beiden Fälle, statt sie zu verwechseln.
if await self.retryWithoutPrefetch() { return }
if await self.retrySmaller() { return } if await self.retrySmaller() { return }
// Den Engine gleich stilllegen: er würde sonst weiter an einem // Den Engine gleich stilllegen: er würde sonst weiter an einem
// toten Stream ziehen, und bei Live-TV bliebe der Tuner belegt. // toten Stream ziehen, und bei Live-TV bliebe der Tuner belegt.
@@ -1635,6 +1677,14 @@ final class PlayerModel {
case .tracksResolved(let audio, let subtitle): case .tracksResolved(let audio, let subtitle):
audioTracks = audio audioTracks = audio
subtitleTracks = subtitle subtitleTracks = subtitle
// **Die Zeile, die drei Runden Ratens erspart hätte.**
// Bestellt und angekommen sind zweierlei, und von aussen
// sieht „nicht geliefert" genauso aus wie „geliefert und
// nicht angezeigt".
diagnostics.note(
"Player meldet \(audio.count) Tonspuren, "
+ "\(subtitle.count) Untertitel"
)
showOrderedSubtitle() showOrderedSubtitle()
applyPreferredLanguages() applyPreferredLanguages()
} }
@@ -1655,10 +1705,10 @@ final class PlayerModel {
/// **Die erste ist die richtige.** Rechnet der Server um, liegt genau /// **Die erste ist die richtige.** Rechnet der Server um, liegt genau
/// eine im Stream — nämlich die bestellte. Es gibt nichts zu verwechseln. /// eine im Stream — nämlich die bestellte. Es gibt nichts zu verwechseln.
private func showOrderedSubtitle() { private func showOrderedSubtitle() {
guard selectedSubtitleStream != nil, guard selectedSubtitleStream != nil, selectedSubtitleTrack == nil else { return }
selectedSubtitleTrack == nil, guard let erste = subtitleTracks.first else { return }
let erste = subtitleTracks.first
else { return } diagnostics.note("Untertitel eingeschaltet: \(erste.displayName)")
selectSubtitleTrack(erste) selectSubtitleTrack(erste)
} }
@@ -56,6 +56,8 @@ public actor StreamCache {
private var fetched = 0 private var fetched = 0
private var prefetchTask: Task<Void, Never>? private var prefetchTask: Task<Void, Never>?
/// Ob gerade eine Anfrage des Players offen ist.
private var isServingPlayer = false
public init(policy: Policy = Policy()) { public init(policy: Policy = Policy()) {
self.policy = policy self.policy = policy
@@ -152,6 +154,15 @@ public actor StreamCache {
} }
if let ort = stored[address], let daten = try? Data(contentsOf: ort) { return daten } if let ort = stored[address], let daten = try? Data(contentsOf: ort) { return daten }
// **Der Player hat Vorrang.** Fehlt ihm etwas, wartet er darauf — und
// der Vorrat darf ihm dabei nicht die Leitung wegnehmen. Rechnet der
// Server gerade um, bedient er ohnehin einen Schnipsel nach dem
// anderen: zwei Anfragen gleichzeitig machen die dringende langsamer,
// nicht schneller. Genau so kam es zu „Stream abgerissen", sobald das
// Umrechnen teuer wurde.
isServingPlayer = true
defer { isServingPlayer = false }
return await fetch(address) return await fetch(address)
} }
@@ -173,6 +184,9 @@ public actor StreamCache {
/// Gibt `false` zurück, wenn gerade nichts zu tun ist — dann wartet die /// Gibt `false` zurück, wenn gerade nichts zu tun ist — dann wartet die
/// Schleife, statt zu drehen. /// Schleife, statt zu drehen.
private func prefetchNext() async -> Bool { private func prefetchNext() async -> Bool {
// Solange der Player wartet, wird nicht vorgeladen.
guard !isServingPlayer else { return false }
// **Ist der Player vorausgesprungen, dort weitermachen.** // **Ist der Player vorausgesprungen, dort weitermachen.**
// //
// Sonst holt der Vorrat brav weiter Schnipsel hinter der // Sonst holt der Vorrat brav weiter Schnipsel hinter der