„Auto" gibt nicht mehr auf, es versucht es kleiner

Der Fehlstart im Zug hat den eigentlichen Mangel gezeigt: die ganze neue
Regelung misst **ab dem ersten Bild**. Kommt nie eines, misst sie nichts,
greift nichts — und „Auto" hat sich als das erwiesen, was es wörtlich
heisst: „nimm, was in den Einstellungen steht". Steht dort „Original",
bittet die App über eine Zugverbindung um eine 4K-Datei und wartet
vierzig Sekunden auf etwas, das nie kommen kann.

Statt aufzugeben geht der Watchdog jetzt eine Sprosse tiefer und baut
neu auf — bei Filmen über `restart()`, bei Sendern über den Tuner-Abbau.
Gezählt gegen dasselbe Budget wie die Sicherheitsleine, sonst schickt
eine tote Leitung die App endlos im Kreis.

Scheitert auch das, nennt die Meldung jetzt den Grund und den Weg statt
nur „keine Daten mehr": wer ohne Grenze angefragt hat, soll erfahren,
dass es daran liegt und wo es sich ändern lässt.

Und „Strom" heisst Stream. Zweiunddreissig Stellen, dazu das
Gedächtnis ergänzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-27 18:30:10 +02:00
co-authored by Claude Opus 5
parent 4abce1c8b9
commit efed8717a3
17 changed files with 98 additions and 34 deletions
+19 -2
View File
@@ -3499,7 +3499,7 @@
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Es kommen keine Daten mehr. Der Strom ist abgerissen."
"value" : "Es kommen keine Daten mehr. Der Stream ist abgerissen."
}
},
"en" : {
@@ -3510,6 +3510,23 @@
}
}
},
"player.stalledUnlimited" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Es kommen keine Daten mehr. Die Qualität steht auf „Original\", der Server überträgt also die Datei unverändert — dafür reicht diese Verbindung nicht. Unter Einstellungen → Wiedergabe eine kleinere Stufe wählen."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No more data is arriving. Quality is set to “Original”, so the server sends the file unchanged — this connection cannot carry that. Pick a smaller step under Settings → Playback."
}
}
}
},
"player.stop" : {
"localizations" : {
"de" : {
@@ -3768,7 +3785,7 @@
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dieser Strom hat nur eine Stufe — es gibt nichts, worauf der Player ausweichen könnte. Bei Rücklern hilft hier nur eine kleinere Qualität von Hand."
"value" : "Dieser Stream hat nur eine Stufe — es gibt nichts, worauf der Player ausweichen könnte. Bei Rücklern hilft hier nur eine kleinere Qualität von Hand."
}
},
"en" : {
+59 -12
View File
@@ -91,7 +91,7 @@ final class PlayerModel {
/// Die eingestellte Stufe in zwei bis fünf Zeichen.
///
/// Steht auf dem Knopf im Player. „Automatisch" heißt dabei: was gerade
/// tatsächlich ankommt — bei einem HLS-Strom entscheidet der Player selbst,
/// tatsächlich ankommt — bei einem HLS-Stream entscheidet der Player selbst,
/// und ihn danach zu fragen ist ehrlicher, als „Auto" hinzuschreiben und
/// die Antwort schuldig zu bleiben.
var qualityLabel: String {
@@ -177,7 +177,7 @@ final class PlayerModel {
/// Wacht darüber, dass aus „lädt" nicht „lädt für immer" wird.
@ObservationIgnored private var bufferWatchdog: Task<Void, Never>?
/// Wie viel Vorrat bei einem Strom über das Netz angelegt wird.
/// Wie viel Vorrat bei einem Stream über das Netz angelegt wird.
///
/// **Nur für Filme und Folgen, nicht für Sender und nicht für Dateien.**
/// Ein laufender Sender soll nah am Jetzt bleiben — ein halbminütiger
@@ -190,7 +190,7 @@ final class PlayerModel {
/// gedrosselten Leitung ermittelt.
private static let forwardBuffer: Duration = .seconds(25)
/// Wie oft der Zustand des Stroms geprüft wird.
/// Wie oft der Zustand des Streams geprüft wird.
///
/// Einmal je Sekunde: fein genug, dass ein schrumpfender Puffer auffällt,
/// bevor er leer ist, und grob genug, dass es nichts kostet.
@@ -202,7 +202,7 @@ final class PlayerModel {
/// kostet Schärfe, ein falscher nach oben einen Rückler.
private static let stepUpCooldown: Duration = .seconds(60)
/// Der Zustand des Stroms, fortlaufend beurteilt.
/// Der Zustand des Streams, fortlaufend beurteilt.
@ObservationIgnored private var health = StreamHealth()
@ObservationIgnored private var healthTask: Task<Void, Never>?
/// Wann zuletzt eine Stufe höher gegangen wurde.
@@ -357,7 +357,7 @@ final class PlayerModel {
// Die zuletzt gewählte Stufe, falls sie gemerkt werden soll. **Vor**
// der Entscheidung, nicht danach: hinterher umzustellen hieße, den
// Strom zweimal aufzubauen und den Anfang zweimal zu puffern.
// Stream zweimal aufzubauen und den Anfang zweimal zu puffern.
if let stored = settings.rememberedQuality,
let restored = PlayerQuality.restored(from: stored, forSourceHeight: media.height) {
quality = restored
@@ -406,7 +406,7 @@ final class PlayerModel {
await restart()
}
/// Baut den Strom mit der **aktuell geltenden** Richtlinie neu auf.
/// Baut den Stream mit der **aktuell geltenden** Richtlinie neu auf.
///
/// Geteilt zwischen der Wahl von Hand und der Sicherheitsleine — der
/// Ablauf ist derselbe, nur der Anlass nicht. Was sich unterscheidet,
@@ -549,7 +549,7 @@ final class PlayerModel {
diagnostics.note("Engine gestartet")
// Weder offline noch live gibt es einen Fortschritt zu melden.
if !isOffline, !isLive { startReporting() }
// Den Zustand des Stroms dagegen auch bei Live beobachten: dort
// Den Zustand des Streams dagegen auch bei Live beobachten: dort
// hakt es am ehesten, und der billige Hebel kostet nichts.
startHealthWatch()
} catch {
@@ -617,7 +617,7 @@ final class PlayerModel {
// MARK: - Die Qualität selbst nachregeln
/// Beobachtet den Strom und passt die Obergrenze an.
/// Beobachtet den Stream und passt die Obergrenze an.
///
/// Läuft nur bei „automatisch". Eine ausdrücklich gewählte Stufe ist eine
/// Ansage und wird nicht überstimmt — `automatic` ist die Erlaubnis
@@ -662,7 +662,7 @@ final class PlayerModel {
// MARK: - Die Sicherheitsleine
/// Prüft, ob der Strom neu aufgebaut werden muss — und tut es.
/// Prüft, ob der Stream neu aufgebaut werden muss — und tut es.
private func considerTheHardWay(
_ condition: StreamCondition,
statistics: PlaybackStatistics,
@@ -699,7 +699,7 @@ final class PlayerModel {
}
}
/// Baut den Strom mit kleinerer Bitrate neu auf.
/// Baut den Stream mit kleinerer Bitrate neu auf.
private func downgradeTheHardWay(at now: Duration) async {
guard let ziel = nextLowerRung() else {
hasGivenUp = true
@@ -719,6 +719,31 @@ final class PlayerModel {
}
}
/// Ein zweiter Versuch mit kleinerer Bitrate, bevor aufgegeben wird.
///
/// Gibt `true` zurück, wenn es einen gab. Zählt gegen dasselbe Budget wie
/// die Sicherheitsleine — sonst könnte eine tote Leitung die App endlos
/// im Kreis schicken.
private func retrySmaller() async -> Bool {
guard quality == .automatic,
downgradesUsed < downgradeBudget,
let ziel = nextLowerRung()
else { return false }
automaticCeiling = ziel
downgradesUsed += 1
diagnostics.note("Nichts kam an — neuer Versuch mit höchstens \(ziel) kbit")
if isLive {
await retuneLive(to: ziel)
} else {
await restart()
}
// Ist der Neuaufbau selbst gescheitert, steht die Meldung schon da.
if case .failed = phase { return false }
return true
}
/// Die nächste Sprosse unterhalb der aktuellen — aus **unserer** Leiter.
///
/// Nicht aus der Playlist: die hat in diesem Fall gerade keine, sonst
@@ -914,11 +939,33 @@ final class PlayerModel {
try? await Task.sleep(for: .seconds(40))
guard !Task.isCancelled, let self, self.isBuffering else { return }
diagnostics.note("Seit 40 s keine Daten — abgebrochen")
diagnostics.note("Seit 40 s keine Daten")
// **Auch das ist ein Beleg**, und ein stärkerer als ein paar
// Rückler: hier kam überhaupt nichts an. Ohne diese Zeile bliebe
// der Hinweis in den Einstellungen aus, weil das Rücklerbuch erst
// ab dem ersten Bild zählt — und das gab es nie.
settings.lastPlaybackStruggled = true
// **Erst kleiner versuchen, dann aufgeben.**
//
// Hier lag der eigentliche Fehler: die ganze Regelung misst ab
// dem ersten Bild. Kommt nie eines, misst sie nichts, greift
// nichts, und „Auto" hat sich als das erwiesen, was es hiess —
// „nimm, was in den Einstellungen steht". Steht dort „Original",
// bittet die App über eine Zugverbindung um eine 4K-Datei und
// wartet vierzig Sekunden auf etwas, das nie kommen kann.
if await self.retrySmaller() { return }
// Den Engine gleich stilllegen: er würde sonst weiter an einem
// toten Stream ziehen, und bei Live-TV bliebe der Tuner belegt.
silence()
phase = .failed(message: String(localized: "player.stalled"))
// Wer ohne Grenze angefragt hat, bekommt den Grund dazu. „Der
// Stream ist abgerissen" ist wahr und führt nirgendwohin; die
// Einstellung zu nennen ist der Unterschied zwischen einer
// Meldung und einer Hilfe.
let ohneGrenze = quality == .automatic && effectivePolicy.maximumBitrate == nil
phase = .failed(message: String(
localized: ohneGrenze ? "player.stalledUnlimited" : "player.stalled"
))
Task { await self.releaseOnServer() }
}
}
@@ -6,7 +6,7 @@ import Foundation
/// es seit vierzig Sekunden am Stück? Bei jedem kurzen Erholen setzte er sich
/// zurück — zehn Rückler à dreißig Sekunden lösten nichts aus, obwohl die
/// halbe Stunde unbrauchbar war. Genau dieser Fall ist der, über den geklagt
/// wird: nicht der tote Strom, sondern der, der immer wieder hakt.
/// wird: nicht der tote Stream, sondern der, der immer wieder hakt.
///
/// **Warum als eigener Typ.** Keine Uhr, kein AVFoundation, kein Netz — die
/// Zeiten kommen von außen herein. Damit lässt sich eine Folge von Rücklern
@@ -1,6 +1,6 @@
import Foundation
/// Eine Probe vom laufenden Strom.
/// Eine Probe vom laufenden Stream.
///
/// Alle Zeiten **seit Beginn dieser Wiedergabe**, nicht als Datum — dasselbe
/// wie beim `StallLedger`, und aus demselben Grund: eine Systemuhr, die sich
@@ -31,7 +31,7 @@ public struct StreamSample: Sendable, Equatable {
}
}
/// Wie es dem Strom geht.
/// Wie es dem Stream geht.
public enum StreamCondition: Sendable, Equatable {
/// Trägt. Es darf wieder eine Stufe höher gehen.
case healthy
@@ -41,7 +41,7 @@ public enum StreamCondition: Sendable, Equatable {
case failing
}
/// Beurteilt den Strom aus einer Folge von Proben.
/// Beurteilt den Stream aus einer Folge von Proben.
///
/// **Warum als reiner Typ.** Kein AVFoundation, keine Uhr, kein Netz — Proben
/// hinein, Urteil heraus. Dieselbe Bauform wie `PlaybackDecider`, und aus
@@ -76,7 +76,7 @@ public final class AVPlayerEngine: VideoPlayerEngine {
///
/// Aus `playbackStalledNotification` und **nicht** aus dem
/// Zugriffsprotokoll: die Meldung kommt bei jeder Quelle, das Protokoll
/// nur bei Strömen über das Netz. Beides zu addieren zählte doppelt.
/// nur bei Streamsn über das Netz. Beides zu addieren zählte doppelt.
private var stallCount = 0
/// Aufsummierte Wartezeit. Siehe `noteStall(from:to:)`.
private var accumulatedStall: Duration = .zero
@@ -17,7 +17,7 @@ public struct PlaybackSource: Sendable, Equatable {
/// Wie viel Vorrat der Player anlegen soll.
///
/// `nil` überlässt es dem System. Das ist für eine Datei auf der Platte
/// richtig und für einen Sender richtig — bei einem Strom über eine
/// richtig und für einen Sender richtig — bei einem Stream über eine
/// wackelige Leitung ist es zu wenig: der Systemwert ist auf schnellen
/// Start ausgelegt, nicht darauf, einen Aussetzer zu überstehen.
///
@@ -369,7 +369,7 @@ func erklärungWarumKeineLeiter() {
/// **Die Playlist zu bekommen beweist aber noch nichts.** Sie ist Text und
/// kostet nichts; die Datenrate steckt in den Schnipseln. Entscheidend ist
/// deshalb allein, ob die Schnipsel bei kleinerer Zahl auch kleiner werden —
/// oder ob der Server stur denselben Strom ausliefert, weil die Sitzung schon
/// oder ob der Server stur denselben Stream ausliefert, weil die Sitzung schon
/// läuft. Deshalb wird hier gewogen, nicht gezählt.
func prüfeSelbstgebauteLeiter(master: URL, kopfzeilen: [String: String]) async {
print("")
@@ -401,7 +401,7 @@ func prüfeSelbstgebauteLeiter(master: URL, kopfzeilen: [String: String]) async
print("")
print(" Werden die Schnipsel kleiner, trägt die Idee. Bleiben sie gleich")
print(" gross, rechnet der Server trotz anderer Zahl denselben Strom —")
print(" gross, rechnet der Server trotz anderer Zahl denselben Stream —")
print(" dann bringt ein selbstgebautes Menü nichts.")
}
@@ -130,7 +130,7 @@ extension JellyfinBackend: LiveTVBackend {
}
// Kein Transcoder-Weg: dann unverändert. Manche Empfänger liefern
// bereits einen Strom, den das Gerät ohne Umrechnung versteht.
// bereits einen Stream, den das Gerät ohne Umrechnung versteht.
if let url = client.url(
path: "/Videos/\(channel.id)/stream",
query: [
@@ -164,7 +164,7 @@ extension JellyfinBackend {
URLQueryItem(name: "maxStreamingBitrate", value: String(bitsPerSecond)),
// **Kein** `startTimeTicks`. Es gäbe zwei Stellen, die
// über den Startpunkt entscheiden: der Server ließe den
// Strom dort beginnen, und der Player spränge anschließend
// Stream dort beginnen, und der Player spränge anschließend
// noch einmal um dieselbe Strecke vor — bei einundzwanzig
// Minuten Fortschritt landete man bei zweiundvierzig.
// Gesprungen wird an genau einer Stelle, und das ist der
@@ -302,7 +302,7 @@ public struct JellyfinBackend: MediaBackend {
/// Eine verkleinerte Fassung zum Herunterladen.
///
/// **Fortlaufendes MP4, nicht HLS.** Zum Abspielen liefert Jellyfin eine
/// Abspielliste mit hunderten Schnipseln; das ist für einen Strom richtig
/// Abspielliste mit hunderten Schnipseln; das ist für einen Stream richtig
/// und für eine Datei, die auf dem Gerät liegen soll, unbrauchbar. Der
/// Endpunkt `/Videos/{id}/stream.mp4` gibt dieselbe Umrechnung als **eine**
/// Datei heraus.
@@ -46,7 +46,7 @@ public struct TranscodeSettings: Sendable, Equatable {
/// Ob der Server eine **Leiter** liefern soll statt einer festen Stufe.
///
/// Dann enthält die Playlist mehrere Varianten, und der Player wechselt
/// zwischen ihnen, ohne dass der Strom neu aufgebaut werden muss. Die
/// zwischen ihnen, ohne dass der Stream neu aufgebaut werden muss. Die
/// angegebene Bitrate bleibt dabei die **oberste** Sprosse; was darunter
/// liegt, erfindet der Server.
///
@@ -1010,7 +1010,7 @@ extension PlexServer {
return nil
}
/// Baut eine Stromadresse und ruft sie einmal ab.
/// Baut eine Stream-Adresse und ruft sie einmal ab.
///
/// Getrennte Methode statt einer Hilfsfunktion in `tune`: eine solche
/// Funktion greift über jede Unterbrechung hinweg auf die veränderlichen
@@ -1076,7 +1076,7 @@ extension PlexServer {
}
}
/// Holt eine Stromadresse einmal selbst ab und sagt, was zurückkam.
/// Holt eine Stream-Adresse einmal selbst ab und sagt, was zurückkam.
///
/// **Ohne Plex-Kopfzeilen**, weil AVPlayer sie ebenfalls nicht schickt.
/// Mit Kopfzeilen zu prüfen hieße, etwas anderes zu prüfen als das, was
@@ -179,7 +179,7 @@ public struct PlexBackend: MediaBackend {
// Wiedergabe verhindert. Plex' eigene Clients schicken ihn; welche
// weitere Angabe sie dabei mitgeben, ist offen. Bis das gemessen ist,
// bleibt er draußen: eine Leiter, die es vielleicht gibt, ist keinen
// Strom wert, der sicher nicht läuft.
// Stream wert, der sicher nicht läuft.
//
// `TranscodeRequest.adaptive` bleibt samt Tests bestehen — das
// Gerüst steht, es fehlt die richtige Beschwörungsformel.
@@ -73,7 +73,7 @@ struct DowngradeRuleTests {
#expect(DowngradeRule.verdict(facts) == .downgrade)
}
@Test("Ein gesunder Strom löst nichts aus")
@Test("Ein gesunder Stream löst nichts aus")
func healthyNeverTriggers() {
#expect(DowngradeRule.verdict(notlage(condition: .healthy)) == .notBadEnough)
}
@@ -2,7 +2,7 @@ import Testing
import Foundation
@testable import FlixyPlayback
@Suite("Zustand des Stroms")
@Suite("Zustand des Streams")
struct StreamHealthTests {
/// Baut eine Folge von Proben im Sekundentakt.
private func lauf(
@@ -21,7 +21,7 @@ public final class VLCPlayerEngine: NSObject, VideoPlayerEngine {
private let player = VLCMediaPlayer()
/// Was VLC über den laufenden Strom weiß.
/// Was VLC über den laufenden Stream weiß.
///
/// Bisher meldete dieser Weg gar nichts: die Standardimplementierung gibt
/// `nil` zurück, und die Bitratenanzeige blieb bei VLC dauerhaft leer.
@@ -227,7 +227,7 @@ extension VLCPlayerEngine: VLCMediaPlayerDelegate {
/// Aufzählung führt nur `opening`, `playing`, `paused`, `stopped` und
/// `error`. Weil diese Methode bisher fehlte, ging der Zustand
/// `.buffering` auf dem VLC-Weg **nie** an — und damit löste der
/// Watchdog im Player dort nie aus. Ein hängender Strom lief stumm ins
/// Watchdog im Player dort nie aus. Ein hängender Stream lief stumm ins
/// Nichts, ohne Kreisel, ohne Meldung, ohne Abbruch.
///
/// Nur aus dem Spielen heraus gewertet. Die Dokumentation sagt, dass vor
+1 -1
View File
@@ -19,7 +19,7 @@ davon, wie gut das Frontend geschrieben ist:
- **Herkunft.** Anfragen laufen über `/api` und damit über dieselbe Adresse wie
die Seite. Kein CORS, auch nicht beim Streamen.
- **Klartext und Verschlüsselung.** Läuft der Medienserver über `http` und die
Seite über `https`, blockiert der Browser den Strom. Dagegen kann keine Zeile
Seite über `https`, blockiert der Browser den Stream. Dagegen kann keine Zeile
im Frontend etwas tun — ein Server davor schon.
Nebenbei bleibt der Zugangsschlüssel beim Server, statt im Browser zu liegen.