diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 27607dc..e44259b 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -215,6 +215,9 @@ final class PlayerModel { /// Einmal gesetzt, bleibt er aus: ein zweiter Anlauf mit demselben /// Aufbau scheiterte am selben Grund. private var prefetchGaveUp = false + /// Wie oft in dieser Wiedergabe nach oben neu aufgebaut wurde. + private var upgradesUsed = 0 + private var lastUpgradeAt: Duration? /// Die Notbremse, die eine hängengebliebene Abdeckung wegnimmt. @ObservationIgnored private var resumeGiveUp: Task? /// Der Server, von dem der Player holt — und der Vorrat dahinter. @@ -327,10 +330,20 @@ final class PlayerModel { /// Unterscheidung ist nicht geraten — sie steht in `isLocalConnection`. private var startingCeiling: Int? { guard !isLocalConnection else { return nil } - // Acht Megabit tragen 1080p ordentlich und laufen auch über eine - // mässige Fernverbindung an. Wer mehr will und mehr hat, kommt über - // das Hinaufschalten dorthin. - return 8_000 + // **Drei Megabit, nicht acht.** + // + // Nicht wegen der Leitung — acht Megabit trägt fast jede. Wegen des + // **Servers**: er muss den Film erst rechnen, und wie lange das + // dauert, hängt vor allem an der Auflösung. Acht Megabit bedeuteten + // 1080p von der ersten Sekunde an, und darauf wartet man eine halbe + // Minute vor einem schwarzen Bild. + // + // Drei Megabit bedeuten 720p. Das läuft in wenigen Sekunden an, sieht + // einen Augenblick lang weicher aus — und von dort geht es hinauf, + // sobald sich zeigt, dass es trägt. Ein Film, der sofort anfängt und + // nach einer Minute schärfer wird, ist besser als einer, auf den man + // erst wartet. + return 3_000 } /// Die Auflösung, die zur aktuellen Stufe gehört. @@ -946,8 +959,10 @@ final class PlayerModel { noteMissingLadder(urteil) } - // Und dann die Frage, ob der teure Weg nötig ist. + // Und dann die beiden teuren Wege: hinunter, wenn es hakt — + // und hinauf, wenn es trägt und die Leiter ausgereizt ist. considerTheHardWay(urteil, statistics: statistics, at: now) + considerGoingHigher(urteil, statistics: statistics, at: now) } // MARK: - Die Sicherheitsleine @@ -989,6 +1004,84 @@ final class PlayerModel { } } + /// Prüft, ob sich ein Neuaufbau **nach oben** lohnt — und tut es. + /// + /// **Warum es den überhaupt braucht.** Damit ein Film zügig anläuft, + /// beginnt er mit einer niedrigen Stufe. Der Server baut seine Leiter + /// aber aus genau dieser Bestellung: wer mit drei Megabit anfängt, + /// bekommt eine Leiter, deren oberste Sprosse drei Megabit ist. Der + /// billige Hebel bewegt sich **innerhalb** dieser Leiter und käme nie + /// darüber hinaus — aus dem schnellen Start würde ein Film, der den + /// ganzen Abend in 720p bleibt. + /// + /// Höher geht es nur mit einem Neuaufbau. Die Bedingungen dafür stehen + /// in `UpgradeRule`, nicht hier. + private func considerGoingHigher( + _ condition: StreamCondition, + statistics: PlaybackStatistics, + at now: Duration + ) { + guard !hasGivenUp, !isChangingQuality, resumeTarget == nil else { return } + + let decke = quality.policy(basedOn: policy).maximumBitrate + let aktuell = automaticCeiling ?? startingCeiling + let facts = UpgradeRule.Facts( + isAutomatic: quality == .automatic, + condition: condition, + // Noch eine Sprosse in der **gelieferten** Leiter? Dann ist der + // billige Weg der richtige. + hasHigherRung: statistics.variantBitrates.contains { $0 > (aktuell ?? .max) }, + ceilingAllowsMore: aktuell.map { jetzt in decke.map { jetzt < $0 } ?? true } ?? false, + bufferedAhead: statistics.bufferedAhead, + playedFor: now, + sinceLastStall: stallLedger.lastStall(at: now).map { now - $0 }, + sinceLastSeek: lastSeekAt.map { now - $0 }, + sinceLastUpgrade: lastUpgradeAt.map { now - $0 }, + used: upgradesUsed, + budget: 2 + ) + + guard UpgradeRule.verdict(facts) == .rebuildHigher else { return } + Task { await upgradeTheHardWay(at: now) } + } + + /// Baut den Stream mit **grösserer** Bitrate neu auf. + private func upgradeTheHardWay(at now: Duration) async { + guard let ziel = nextHigherRung() else { return } + + automaticCeiling = ziel + upgradesUsed += 1 + lastUpgradeAt = now + diagnostics.note("Neuaufbau mit bis zu \(ziel) kbit — die Leitung trägt mehr") + + // Bei Live-TV nicht: dort kostet jeder Neuaufbau den Tuner, und ein + // schärferes Bild ist diesen Einsatz nicht wert. + guard !isLive else { return } + await restart() + } + + /// Die nächste Sprosse **oberhalb** der aktuellen — aus unserer Leiter, + /// gedeckelt von dem, was eingestellt ist. + private func nextHigherRung() -> Int? { + let aktuell = automaticCeiling ?? startingCeiling ?? 0 + let decke = quality.policy(basedOn: policy).maximumBitrate + + let sprossen = PlayerQuality.available(forSourceHeight: sourceHeight) + .compactMap { option -> Int? in + guard case .limited(let rung) = option else { return nil } + return rung.bitrate + } + .sorted() + + // Die nächste darüber, die die Einstellung noch zulässt. Gibt es + // keine mehr, ist die Decke erreicht — dann bleibt es dabei. + return sprossen.first { sprosse in + guard sprosse > aktuell else { return false } + guard let decke else { return true } + return sprosse <= decke + } + } + /// Baut den Stream mit kleinerer Bitrate neu auf. private func downgradeTheHardWay(at now: Duration) async { guard let ziel = nextLowerRung() else { diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/StallLedger.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/StallLedger.swift index 5489509..e2b0a20 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/StallLedger.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/StallLedger.swift @@ -85,6 +85,21 @@ public struct StallLedger: Sendable, Equatable { .max() ?? .zero } + /// Wann der letzte Rückler zu Ende war. + /// + /// `nil`, wenn es im Fenster keinen gab — und das ist etwas anderes als + /// „vor langer Zeit": wer noch nie gestockt hat, wartet auf keine Frist. + /// + /// Gebraucht fürs Hinaufschalten: wer gerade gestockt hat, hat keine + /// Reserve, und ein Neuaufbau nach oben wäre die zweite Unterbrechung + /// binnen einer Minute. + public func lastStall(at now: Duration) -> Duration? { + episodes + .filter { $0.endedAt >= now - window } + .map(\.endedAt) + .max() + } + /// Wirft heraus, was aus dem Fenster gefallen ist. public mutating func prune(at now: Duration) { let horizon = now - window diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/UpgradeRule.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/UpgradeRule.swift new file mode 100644 index 0000000..c9241df --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/UpgradeRule.swift @@ -0,0 +1,140 @@ +import Foundation + +/// Wann **hinauf** gegangen wird — und wann der teure Weg dafür erlaubt ist. +/// +/// **Die Falle, um die es hier geht.** Damit ein Film zügig anläuft, wird mit +/// einer niedrigen Stufe begonnen. Der Server baut seine Leiter aber aus +/// genau dieser Bestellung: wer mit drei Megabit anfängt, bekommt eine +/// Leiter, deren oberste Sprosse drei Megabit ist. Das billige Hinaufschalten +/// über `preferredPeakBitRate` bewegt sich **innerhalb** dieser Leiter — es +/// käme also nie darüber hinaus, und aus dem schnellen Start würde ein Film, +/// der den ganzen Abend in 720p bleibt. +/// +/// Höher geht es nur mit einem Neuaufbau: neu aushandeln, mit einer grösseren +/// Grenze. Der kostet Sekunden mit schwarzem Bild — deshalb dieselbe Bauform +/// wie `DowngradeRule`: eine Liste von Bedingungen, die **alle** gelten +/// müssen, kein Schwellwert. +/// +/// **Und deutlich zurückhaltender als das Absenken.** Ein zu spätes Absenken +/// bedeutet Ruckeln; ein zu spätes Hinaufschalten bedeutet ein etwas +/// weicheres Bild. Das erste ist ein Fehler, das zweite eine Geschmacksfrage. +public enum UpgradeRule { + /// Wie lange es ruhig laufen muss, bevor überhaupt gefragt wird. + /// + /// Lang genug, dass es kein anfängliches Aufatmen ist: der Puffer füllt + /// sich nach dem Start immer, und das sieht zwanzig Sekunden lang nach + /// einer hervorragenden Leitung aus. + public static let settleIn: Duration = .seconds(45) + /// Sperrfrist zwischen zwei Neuaufbauten nach oben. + public static let cooldown: Duration = .seconds(150) + /// Wie lange nach einem Rückler gar nicht hinaufgegangen wird. + /// + /// Wer gerade gestockt hat, hat keine Reserve — und ein Neuaufbau nach + /// oben wäre dann die zweite Unterbrechung binnen einer Minute. + public static let afterStall: Duration = .seconds(90) + /// Wie viel Vorrat dasein muss. + /// + /// Nicht „es läuft gerade", sondern „es liegt etwas auf der hohen Kante". + /// Nur das trägt einen Neuaufbau. + public static let bufferNeeded: Duration = .seconds(20) + + /// Was zum Zeitpunkt der Frage gilt. + public struct Facts: Sendable, Equatable { + /// Eine ausdrücklich gewählte Stufe ist eine Ansage und wird nicht + /// überstimmt — auch nicht nach oben. + public let isAutomatic: Bool + public let condition: StreamCondition + /// Ob die Leiter noch eine Sprosse nach oben hat. Solange ja, ist der + /// **billige** Weg der richtige. + public let hasHigherRung: Bool + /// Ob die eingestellte Obergrenze überhaupt mehr zuliesse. + public let ceilingAllowsMore: Bool + /// Wie viel Vorrat vor der Abspielstelle liegt. + public let bufferedAhead: Duration? + /// Wie lange schon gespielt wird. + public let playedFor: Duration + /// Wie lange der letzte Rückler her ist. `nil` heisst: keiner gewesen. + public let sinceLastStall: Duration? + /// Wie lange der letzte Sprung her ist. + public let sinceLastSeek: Duration? + /// Wie lange der letzte Neuaufbau nach oben her ist. + public let sinceLastUpgrade: Duration? + /// Wie viele es in dieser Wiedergabe schon gab. + public let used: Int + /// Wie viele es höchstens geben darf. + public let budget: Int + + public init( + isAutomatic: Bool, + condition: StreamCondition, + hasHigherRung: Bool, + ceilingAllowsMore: Bool, + bufferedAhead: Duration?, + playedFor: Duration, + sinceLastStall: Duration? = nil, + sinceLastSeek: Duration? = nil, + sinceLastUpgrade: Duration? = nil, + used: Int = 0, + budget: Int = 2 + ) { + self.isAutomatic = isAutomatic + self.condition = condition + self.hasHigherRung = hasHigherRung + self.ceilingAllowsMore = ceilingAllowsMore + self.bufferedAhead = bufferedAhead + self.playedFor = playedFor + self.sinceLastStall = sinceLastStall + self.sinceLastSeek = sinceLastSeek + self.sinceLastUpgrade = sinceLastUpgrade + self.used = used + self.budget = budget + } + } + + /// Warum **nicht** hinaufgegangen wird. + /// + /// Als Aufzählung statt als `Bool`, aus demselben Grund wie beim + /// Absenken: „es läuft gut und es passiert nichts" ist ohne Begründung + /// von einem Fehler nicht zu unterscheiden. + public enum Verdict: Sendable, Equatable { + case rebuildHigher + /// Eine gewählte Stufe wird nicht überstimmt. + case notAutomatic + /// Die Leiter hat noch eine Sprosse — der billige Weg genügt. + case cheaperPathRemains + /// Die Einstellung lässt nicht mehr zu. Kein Fehler, eine Ansage. + case atCeiling + /// Es läuft nicht ruhig genug. + case notCalmEnough + /// Zu früh nach Start, Sprung oder Rückler. + case tooEarly + /// Sperrfrist läuft noch. + case cooling + /// Verbraucht. + case budgetSpent + } + + public static func verdict(_ facts: Facts) -> Verdict { + guard facts.isAutomatic else { return .notAutomatic } + guard facts.ceilingAllowsMore else { return .atCeiling } + + // **Erst die Leiter ausreizen.** Solange es eine Sprosse höher gibt, + // kostet das Hinaufgehen kein schwarzes Bild — und wäre ein + // Neuaufbau dafür Verschwendung. + if facts.hasHigherRung { return .cheaperPathRemains } + + guard facts.condition == .healthy else { return .notCalmEnough } + guard let vorrat = facts.bufferedAhead, vorrat >= bufferNeeded else { + return .notCalmEnough + } + + guard facts.playedFor >= settleIn else { return .tooEarly } + if let seit = facts.sinceLastSeek, seit < DowngradeRule.afterSeek { return .tooEarly } + if let seit = facts.sinceLastStall, seit < afterStall { return .tooEarly } + + if let seit = facts.sinceLastUpgrade, seit < cooldown { return .cooling } + guard facts.used < facts.budget else { return .budgetSpent } + + return .rebuildHigher + } +} diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/StallLedgerTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/StallLedgerTests.swift index 7e80caa..ed05886 100644 --- a/Packages/FlixyKit/Tests/FlixyPlaybackTests/StallLedgerTests.swift +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/StallLedgerTests.swift @@ -86,4 +86,30 @@ struct StallLedgerTests { ledger.record(.init(endedAt: .seconds(30), length: .seconds(200))) #expect(ledger.share(at: .seconds(60)) == 1) } + + /// Gebraucht fürs Hinaufschalten: wer gerade gestockt hat, hat keine + /// Reserve für einen Neuaufbau. + @Test("Der letzte Rückler lässt sich datieren") + func findsTheLastStall() { + var buch = StallLedger(window: .seconds(180)) + buch.record(.init(endedAt: .seconds(30), length: .seconds(2))) + buch.record(.init(endedAt: .seconds(90), length: .seconds(1))) + + #expect(buch.lastStall(at: .seconds(100)) == .seconds(90)) + } + + /// „Noch nie gestockt" ist etwas anderes als „vor langer Zeit" — wer + /// keinen hatte, wartet auf keine Frist. + @Test("Ohne Rückler gibt es kein Datum") + func noStallNoDate() { + let leer = StallLedger(window: .seconds(180)) + #expect(leer.lastStall(at: .seconds(100)) == nil) + } + + @Test("Was aus dem Fenster gefallen ist, zählt nicht mehr") + func forgetsOldStalls() { + var buch = StallLedger(window: .seconds(60)) + buch.record(.init(endedAt: .seconds(10), length: .seconds(2))) + #expect(buch.lastStall(at: .seconds(200)) == nil) + } } diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/UpgradeRuleTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/UpgradeRuleTests.swift new file mode 100644 index 0000000..d1ab496 --- /dev/null +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/UpgradeRuleTests.swift @@ -0,0 +1,143 @@ +import Foundation +import Testing +@testable import FlixyPlayback + +@Suite("Wann hinaufgeschaltet wird") +struct UpgradeRuleTests { + + /// Die Lage, in der es erlaubt ist — alle übrigen Tests weichen davon in + /// genau einem Punkt ab. So steht in jedem Test nur der Unterschied. + private func ruhig( + isAutomatic: Bool = true, + condition: StreamCondition = .healthy, + hasHigherRung: Bool = false, + ceilingAllowsMore: Bool = true, + bufferedAhead: Duration? = .seconds(30), + playedFor: Duration = .seconds(120), + sinceLastStall: Duration? = nil, + sinceLastSeek: Duration? = nil, + sinceLastUpgrade: Duration? = nil, + used: Int = 0, + budget: Int = 2 + ) -> UpgradeRule.Facts { + .init( + isAutomatic: isAutomatic, + condition: condition, + hasHigherRung: hasHigherRung, + ceilingAllowsMore: ceilingAllowsMore, + bufferedAhead: bufferedAhead, + playedFor: playedFor, + sinceLastStall: sinceLastStall, + sinceLastSeek: sinceLastSeek, + sinceLastUpgrade: sinceLastUpgrade, + used: used, + budget: budget + ) + } + + @Test("Läuft es ruhig und ist die Leiter ausgereizt, wird neu aufgebaut") + func calmAndCappedRebuilds() { + #expect(UpgradeRule.verdict(ruhig()) == .rebuildHigher) + } + + // MARK: - Was es verbietet + + /// Wer „720p · 3 Mbps" antippt, meint das — auch wenn die Leitung mehr + /// trüge. + @Test("Eine gewählte Stufe wird nicht überstimmt") + func neverOverridesAChoice() { + #expect(UpgradeRule.verdict(ruhig(isAutomatic: false)) == .notAutomatic) + } + + /// **Der wichtigste Fall.** Solange die Leiter noch eine Sprosse hat, + /// kostet das Hinaufgehen kein schwarzes Bild — ein Neuaufbau dafür wäre + /// Verschwendung. + @Test("Solange die Leiter trägt, kein Neuaufbau") + func prefersTheCheapPath() { + #expect(UpgradeRule.verdict(ruhig(hasHigherRung: true)) == .cheaperPathRemains) + } + + @Test("Über die eingestellte Grenze geht es nicht") + func respectsTheSetting() { + #expect(UpgradeRule.verdict(ruhig(ceilingAllowsMore: false)) == .atCeiling) + } + + @Test("Bei angespannter Lage wird nicht hinaufgegangen") + func notWhenStrained() { + #expect(UpgradeRule.verdict(ruhig(condition: .strained)) == .notCalmEnough) + #expect(UpgradeRule.verdict(ruhig(condition: .failing)) == .notCalmEnough) + } + + /// „Es läuft gerade" genügt nicht — es muss etwas auf der hohen Kante + /// liegen, sonst trägt es den Neuaufbau nicht. + @Test("Ohne Vorrat kein Neuaufbau") + func needsABuffer() { + #expect(UpgradeRule.verdict(ruhig(bufferedAhead: .seconds(5))) == .notCalmEnough) + #expect(UpgradeRule.verdict(ruhig(bufferedAhead: nil)) == .notCalmEnough) + } + + /// Der Puffer füllt sich nach dem Start immer, und das sieht eine halbe + /// Minute lang nach einer hervorragenden Leitung aus. + @Test("Am Anfang nicht, auch wenn alles gut aussieht") + func notRightAfterStart() { + #expect(UpgradeRule.verdict(ruhig(playedFor: .seconds(20))) == .tooEarly) + } + + @Test("Nach einem Sprung nicht") + func notRightAfterSeek() { + #expect(UpgradeRule.verdict(ruhig(sinceLastSeek: .seconds(3))) == .tooEarly) + } + + /// Wer gerade gestockt hat, hat keine Reserve — und ein Neuaufbau wäre + /// die zweite Unterbrechung binnen einer Minute. + @Test("Nach einem Rückler lange nicht") + func waitsAfterAStall() { + #expect(UpgradeRule.verdict(ruhig(sinceLastStall: .seconds(30))) == .tooEarly) + #expect(UpgradeRule.verdict(ruhig(sinceLastStall: .seconds(120))) == .rebuildHigher) + } + + @Test("Die Sperrfrist gilt") + func respectsTheCooldown() { + #expect(UpgradeRule.verdict(ruhig(sinceLastUpgrade: .seconds(60))) == .cooling) + #expect(UpgradeRule.verdict(ruhig(sinceLastUpgrade: .seconds(200))) == .rebuildHigher) + } + + @Test("Das Budget ist endlich") + func budgetIsFinite() { + #expect(UpgradeRule.verdict(ruhig(used: 2, budget: 2)) == .budgetSpent) + #expect(UpgradeRule.verdict(ruhig(used: 1, budget: 2)) == .rebuildHigher) + } + + // MARK: - Die Reihenfolge der Gründe + + /// Bei mehreren Gründen soll der **grundsätzlichste** gemeldet werden — + /// sonst steht im Protokoll „Sperrfrist", wo in Wahrheit die Einstellung + /// das Ende ist, und jemand wartet auf etwas, das nie kommt. + @Test("Der grundsätzlichste Grund gewinnt") + func reportsTheMostFundamentalReason() { + #expect( + UpgradeRule.verdict(ruhig( + isAutomatic: false, hasHigherRung: true, ceilingAllowsMore: false + )) == .notAutomatic + ) + #expect( + UpgradeRule.verdict(ruhig( + hasHigherRung: true, ceilingAllowsMore: false + )) == .atCeiling + ) + #expect( + UpgradeRule.verdict(ruhig( + condition: .failing, hasHigherRung: true + )) == .cheaperPathRemains + ) + } + + /// Das Hinaufschalten muss zurückhaltender sein als das Absenken: ein zu + /// spätes Absenken bedeutet Ruckeln, ein zu spätes Hinaufschalten ein + /// etwas weicheres Bild. + @Test("Zurückhaltender als das Absenken") + func moreCautiousThanDowngrading() { + #expect(UpgradeRule.settleIn > DowngradeRule.settleIn) + #expect(UpgradeRule.cooldown < DowngradeRule.cooldown) + } +}