diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index d9eb368..0187a23 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -208,6 +208,42 @@ final class PlayerModel { /// Wann zuletzt eine Stufe höher gegangen wurde. @ObservationIgnored private var lastStepUp: Duration? @ObservationIgnored private var hasNotedMissingLadder = false + /// Wann zuletzt gesprungen wurde — ein Sprung leert den Puffer immer. + @ObservationIgnored private var lastSeekAt: Duration? + @ObservationIgnored private var lastDowngradeAt: Duration? + @ObservationIgnored private var downgradesUsed = 0 + + /// Ob die Automatik aufgegeben hat. + /// + /// Dann wird es gesagt statt lautlos weiter abgesenkt: still bis zur + /// untersten Sprosse zu gehen ist schlimmer, als einmal zuzugeben, dass + /// die Leitung es nicht trägt. + private(set) var hasGivenUp = false + + /// Wie oft in **dieser** Wiedergabe der teure Weg gegangen werden darf. + /// + /// Bei Live-TV nur einmal. Jeder Neuaufbau stimmt dort den Tuner neu ab, + /// und davon gibt es vier Stück — ein Stottern ist der kleinere Schaden + /// als ein Sender, der gar nicht mehr angeht. + private var downgradeBudget: Int { isLive ? 1 : 2 } + + /// Die Richtlinie, wie sie **jetzt** gilt. + /// + /// Die eingestellte Obergrenze, überschrieben von dem, was die Automatik + /// unterwegs gelernt hat. Getrennt von `quality`, damit eine schlechte + /// Zugfahrt nicht als gemerkte Stufe das Wohnzimmer verschlechtert. + private var effectivePolicy: PlaybackPolicy { + let base = quality.policy(basedOn: policy) + guard quality == .automatic, let ceiling = automaticCeiling else { return base } + return PlaybackPolicy( + allowsTranscoding: true, + // Ohne Ersatzmotor: sonst spielte VLC die Datei unverändert ab, + // und die abgesenkte Grenze bliebe wirkungslos. + allowsFallbackEngine: false, + maximumBitrate: min(ceiling, base.maximumBitrate ?? ceiling), + forcesTranscoding: true + ) + } /// Die Obergrenze, die die Automatik gerade setzt — in Kilobit. /// @@ -359,15 +395,33 @@ final class PlayerModel { let backend, let item, let media = currentMedia, let part = currentPart else { return } + quality = newQuality + // Von Hand gewählt: was die Automatik gelernt hatte, gilt nicht mehr. + automaticCeiling = nil + hasGivenUp = false + // Merken, wenn es gewünscht ist — sonst gilt die Wahl nur für diesen + // einen Film. + if settings.remembersQuality { settings.rememberedQuality = newQuality.storageValue } + + await restart() + } + + /// Baut den Strom 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, + /// steht in `effectivePolicy`, nicht hier: die Wahl setzt `quality`, die + /// Automatik setzt `automaticCeiling`, und beides fließt dort zusammen. + private func restart() async { + guard !isOffline, + let backend, let item, let media = currentMedia, let part = currentPart + else { return } + isChangingQuality = true defer { isChangingQuality = false } let resumeAt = currentTime resumeTarget = resumeAt - quality = newQuality - // Merken, wenn es gewünscht ist — sonst gilt die Wahl nur für diesen - // einen Film. - if settings.remembersQuality { settings.rememberedQuality = newQuality.storageValue } // Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet // der Server zwei Streams gleichzeitig für einen Zuschauer. @@ -380,10 +434,9 @@ final class PlayerModel { engine = nil phase = .preparing - let newPolicy = newQuality.policy(basedOn: policy) let newDecision = PlaybackDecider().decide( profile: media.profile(file: part), - policy: newPolicy + policy: effectivePolicy ) decision = newDecision @@ -596,16 +649,150 @@ final class PlayerModel { indicatedBitrate: statistics.indicatedBitrate )) - // **Ohne Leiter greift der billige Hebel ins Leere.** AVPlayer spielt - // die eine Stufe auch dann, wenn sie über der Grenze liegt. Das ist - // kein Fehler, sondern der Fall, für den es die Sicherheitsleine gibt - // — und bis die steht, gehört er wenigstens ins Protokoll. - guard statistics.hasLadder else { + // Erst der billige Hebel — er kostet kein schwarzes Bild. + if statistics.hasLadder { + adjustCeiling(for: urteil, statistics: statistics, at: now) + } else { noteMissingLadder(urteil) + } + + // Und dann die Frage, ob der teure Weg nötig ist. + considerTheHardWay(urteil, statistics: statistics, at: now) + } + + // MARK: - Die Sicherheitsleine + + /// Prüft, ob der Strom neu aufgebaut werden muss — und tut es. + private func considerTheHardWay( + _ condition: StreamCondition, + statistics: PlaybackStatistics, + at now: Duration + ) { + guard !hasGivenUp, !isChangingQuality, resumeTarget == nil else { return } + + let unterste = statistics.variantBitrates.first + let facts = DowngradeRule.Facts( + isAutomatic: quality == .automatic, + hasLadder: statistics.hasLadder, + isAtLowestRung: unterste.map { (automaticCeiling ?? .max) <= $0 } ?? false, + condition: condition, + stallsInWindow: stallLedger.count(at: now), + longestStallInWindow: stallLedger.longest(at: now), + playedFor: now, + sinceLastSeek: lastSeekAt.map { now - $0 }, + sinceLastDowngrade: lastDowngradeAt.map { now - $0 }, + used: downgradesUsed, + budget: downgradeBudget + ) + + switch DowngradeRule.verdict(facts) { + case .downgrade: + Task { await downgradeTheHardWay(at: now) } + case .budgetSpent: + // **Hier hört es auf und wird gesagt.** Lautlos die ganze Leiter + // hinunterzugehen ist schlimmer, als einmal zuzugeben, dass die + // Leitung es gerade nicht trägt. + hasGivenUp = true + diagnostics.note("Automatik aufgegeben — die Leitung trägt es nicht") + case .notAutomatic, .cheaperPathRemains, .notBadEnough, .tooEarly, .cooling: + break + } + } + + /// Baut den Strom mit kleinerer Bitrate neu auf. + private func downgradeTheHardWay(at now: Duration) async { + guard let ziel = nextLowerRung() else { + hasGivenUp = true + diagnostics.note("Keine kleinere Stufe mehr übrig") return } - adjustCeiling(for: urteil, statistics: statistics, at: now) + automaticCeiling = ziel + downgradesUsed += 1 + lastDowngradeAt = now + diagnostics.note("Neuaufbau mit höchstens \(ziel) kbit — es hakte zu oft") + + if isLive { + await retuneLive(to: ziel) + } else { + await restart() + } + } + + /// Die nächste Sprosse unterhalb der aktuellen — aus **unserer** Leiter. + /// + /// Nicht aus der Playlist: die hat in diesem Fall gerade keine, sonst + /// wären wir hier nicht. Die Sprossen richten sich nach der Höhe der + /// Quelle — eine 720p-Datei auf 1080p „hochzurechnen" bringt kein Bild + /// dazu. + private func nextLowerRung() -> Int? { + let aktuell = automaticCeiling + ?? statistics?.indicatedBitrate + ?? policy.maximumBitrate + ?? 8_000 + + let sprossen = PlayerQuality.available(forSourceHeight: sourceHeight) + .compactMap { option -> Int? in + guard case .limited(let rung) = option else { return nil } + return rung.bitrate + } + .sorted() + return sprossen.last { $0 < aktuell } + } + + /// Stimmt einen Sender mit kleinerer Bitrate neu ab. + /// + /// **Der Tuner wird zuerst freigegeben.** Er ist ein Stück Hardware, vier + /// Stück gibt es, und ein doppelt belegter ist im Alltag schlimmer als + /// ein Stottern. Erst danach wird neu abgestimmt. + /// + /// Bekannte Grenze: `endLiveSession` meldet nicht, **ob** die Freigabe + /// geklappt hat — das Protokoll gibt nichts zurück. Deshalb hier nur ein + /// Versuch je Wiedergabe und jeder Schritt im Protokoll, damit ein + /// verlorener Tuner wenigstens nachvollziehbar bleibt. + private func retuneLive(to bitrate: Int) async { + guard let liveTV, let subscription = request.liveSubscriptionKey else { + hasGivenUp = true + return + } + + isChangingQuality = true + defer { isChangingQuality = false } + + let stelle = currentTime + eventTask?.cancel() + engine?.teardown() + engine = nil + phase = .preparing + + diagnostics.note("Tuner wird freigegeben") + await liveTV.endLiveSession(subscriptionKey: subscription) + + let liste = await liveTV.liveChannels() + guard let sender = liste.channels.first(where: { $0.id == request.ratingKey }) else { + diagnostics.finish(outcome: "Sender nach dem Freigeben nicht mehr gefunden") + phase = .failed(message: String(localized: "player.stalled")) + return + } + + let ergebnis = await liveTV.tune( + channel: sender, + program: nil, + maximumBitrate: bitrate + ) + guard let url = ergebnis.url else { + diagnostics.finish(outcome: "Neues Abstimmen gescheitert") + phase = .failed(message: String(localized: "player.stalled")) + return + } + + diagnostics.note("Neu abgestimmt mit höchstens \(bitrate) kbit") + let entscheidung = PlaybackDecision(method: .transcode(maximumBitrate: bitrate), rationale: []) + self.decision = entscheidung + await load( + PlaybackSource(url: url, origin: .remote, startTime: stelle), + decision: entscheidung + ) } /// Setzt die Obergrenze eine Sprosse tiefer oder höher. @@ -774,6 +961,7 @@ final class PlayerModel { func seek(to time: Duration) { guard let engine else { return } + if let startedAt = playbackStartedAt { lastSeekAt = ContinuousClock.now - startedAt } Task { await engine.seek(to: time) } } @@ -833,6 +1021,10 @@ final class PlayerModel { automaticCeiling = nil lastStepUp = nil hasNotedMissingLadder = false + lastSeekAt = nil + lastDowngradeAt = nil + downgradesUsed = 0 + hasGivenUp = false NowPlaying.shared.release(for: self) } diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/DowngradeRule.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/DowngradeRule.swift new file mode 100644 index 0000000..d65ff69 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/DowngradeRule.swift @@ -0,0 +1,132 @@ +import Foundation + +/// Wann der **teure** Weg gegangen werden darf. +/// +/// Der billige Hebel ist `preferredPeakBitRate`: er wirkt ohne Neuaufbau. +/// Wenn er nichts ausrichtet — weil die Playlist nur eine Stufe hat oder weil +/// schon die unterste läuft —, bleibt nur der Neuaufbau mit kleinerer Bitrate: +/// Transcoder anhalten, Motor abreißen, neu aushandeln, zurückspringen. Das +/// sind Sekunden mit schwarzem Bild. +/// +/// **Ein Notnagel, keine Regelschleife.** Zu oft ausgelöst erzeugt er genau +/// das Ruckeln, das er beheben soll. Deshalb steht hier eine Liste von +/// Bedingungen, die **alle** gelten müssen — und nicht eine Schwelle. +/// +/// Eigener Typ ohne Uhr und ohne Netz, damit sich jede einzelne Bedingung +/// einzeln hinschreiben und prüfen lässt. Eine Regel, die nur im laufenden +/// Film zu beobachten ist, ist keine Regel. +public enum DowngradeRule { + /// Wie lange nach dem Start nichts ausgelöst wird. + /// + /// Das anfängliche Füllen des Puffers sieht aus wie eine Notlage und ist + /// keine. + public static let settleIn: Duration = .seconds(10) + /// Wie lange nach einem Sprung nichts ausgelöst wird. + /// + /// Ein Sprung leert den Puffer **immer**. Ihn als Beleg für eine schlechte + /// Leitung zu nehmen hieße, sich für das eigene Tun zu bestrafen. + public static let afterSeek: Duration = .seconds(10) + /// Sperrfrist zwischen zwei Absenkungen. + public static let cooldown: Duration = .seconds(180) + /// Das Fenster, in dem Rückler gezählt werden. + public static let window: Duration = .seconds(90) + /// Ab wie vielen Rücklern im Fenster es genügt. + public static let stallsNeeded = 3 + /// Ab welcher Länge ein **einzelner** Rückler genügt. + /// + /// Fünfzehn Sekunden Stillstand sind keine Schwankung mehr. Ihn + /// mitzuzählen, bis drei zusammenkommen, hieße viereinhalb Minuten + /// zusehen. + public static let longStall: Duration = .seconds(15) + + /// Was zum Zeitpunkt der Frage gilt. + public struct Facts: Sendable, Equatable { + /// Ob überhaupt eingegriffen werden darf. Eine ausdrücklich gewählte + /// Stufe ist eine Ansage und wird nicht überstimmt. + public let isAutomatic: Bool + /// Ob die Playlist mehrere Stufen führt. + public let hasLadder: Bool + /// Ob schon die unterste davon läuft. + public let isAtLowestRung: Bool + public let condition: StreamCondition + public let stallsInWindow: Int + public let longestStallInWindow: Duration + /// Wie lange schon gespielt wird. + public let playedFor: Duration + /// Wie lange der letzte Sprung her ist. `nil` heißt: keiner gewesen. + public let sinceLastSeek: Duration? + /// Wie lange die letzte Absenkung her ist. `nil` heißt: keine gewesen. + public let sinceLastDowngrade: 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, + hasLadder: Bool, + isAtLowestRung: Bool, + condition: StreamCondition, + stallsInWindow: Int, + longestStallInWindow: Duration, + playedFor: Duration, + sinceLastSeek: Duration? = nil, + sinceLastDowngrade: Duration? = nil, + used: Int = 0, + budget: Int = 2 + ) { + self.isAutomatic = isAutomatic + self.hasLadder = hasLadder + self.isAtLowestRung = isAtLowestRung + self.condition = condition + self.stallsInWindow = stallsInWindow + self.longestStallInWindow = longestStallInWindow + self.playedFor = playedFor + self.sinceLastSeek = sinceLastSeek + self.sinceLastDowngrade = sinceLastDowngrade + self.used = used + self.budget = budget + } + } + + /// Warum **nicht** abgesenkt wird. + /// + /// Als Aufzählung statt als `Bool`, weil genau das im Protokoll stehen + /// soll: „es hakt, aber wir tun nichts" ist ohne Grund von einem Fehler + /// nicht zu unterscheiden. + public enum Verdict: Sendable, Equatable { + case downgrade + /// Eine gewählte Stufe wird nicht überstimmt. + case notAutomatic + /// Der billige Hebel ist noch nicht ausgereizt. + case cheaperPathRemains + /// Es hakt nicht genug. + case notBadEnough + /// Zu früh nach Start oder Sprung. + case tooEarly + /// Sperrfrist läuft noch. + case cooling + /// Verbraucht. Ab hier gehört es dem Nutzer gesagt. + case budgetSpent + } + + public static func verdict(_ facts: Facts) -> Verdict { + guard facts.isAutomatic else { return .notAutomatic } + + // Solange es noch eine Sprosse tiefer gibt, ist der billige Weg der + // richtige — er kostet kein schwarzes Bild. + if facts.hasLadder, !facts.isAtLowestRung { return .cheaperPathRemains } + + let schlimmGenug = facts.stallsInWindow >= stallsNeeded + || facts.longestStallInWindow >= longStall + guard schlimmGenug, facts.condition != .healthy else { return .notBadEnough } + + guard facts.playedFor >= settleIn else { return .tooEarly } + if let seit = facts.sinceLastSeek, seit < afterSeek { return .tooEarly } + + if let seit = facts.sinceLastDowngrade, seit < cooldown { return .cooling } + guard facts.used < facts.budget else { return .budgetSpent } + + return .downgrade + } +} diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/DowngradeRuleTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/DowngradeRuleTests.swift new file mode 100644 index 0000000..d5672af --- /dev/null +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/DowngradeRuleTests.swift @@ -0,0 +1,128 @@ +import Testing +import Foundation +@testable import FlixyPlayback + +@Suite("Wann der teure Weg erlaubt ist") +struct DowngradeRuleTests { + /// Der Normalfall, in dem abgesenkt werden soll: keine Leiter da, es + /// hakt dauerhaft, und es läuft lange genug. + private func notlage( + isAutomatic: Bool = true, + hasLadder: Bool = false, + isAtLowestRung: Bool = false, + condition: StreamCondition = .failing, + stalls: Int = 3, + longest: Duration = .seconds(4), + playedFor: Duration = .seconds(120), + sinceLastSeek: Duration? = nil, + sinceLastDowngrade: Duration? = nil, + used: Int = 0, + budget: Int = 2 + ) -> DowngradeRule.Facts { + .init( + isAutomatic: isAutomatic, + hasLadder: hasLadder, + isAtLowestRung: isAtLowestRung, + condition: condition, + stallsInWindow: stalls, + longestStallInWindow: longest, + playedFor: playedFor, + sinceLastSeek: sinceLastSeek, + sinceLastDowngrade: sinceLastDowngrade, + used: used, + budget: budget + ) + } + + @Test("Ohne Leiter und mit drei Rücklern: ja") + func downgradesWhenNoLadder() { + #expect(DowngradeRule.verdict(notlage()) == .downgrade) + } + + /// Eine ausdrücklich gewählte Stufe ist eine Ansage. `automatic` ist die + /// Erlaubnis einzugreifen, alles andere nicht. + @Test("Eine gewählte Stufe wird nicht überstimmt") + func neverOverridesAChoice() { + #expect(DowngradeRule.verdict(notlage(isAutomatic: false)) == .notAutomatic) + } + + /// Solange es noch eine Sprosse tiefer gibt, kostet der billige Hebel + /// kein schwarzes Bild. + @Test("Solange die Leiter trägt, bleibt es beim billigen Hebel") + func prefersTheCheapLever() { + let facts = notlage(hasLadder: true, isAtLowestRung: false) + #expect(DowngradeRule.verdict(facts) == .cheaperPathRemains) + } + + @Test("Auf der untersten Sprosse ist der billige Hebel erschöpft") + func lowestRungAllowsTheExpensivePath() { + let facts = notlage(hasLadder: true, isAtLowestRung: true) + #expect(DowngradeRule.verdict(facts) == .downgrade) + } + + @Test("Zwei Rückler genügen nicht") + func twoStallsAreNotEnough() { + #expect(DowngradeRule.verdict(notlage(stalls: 2)) == .notBadEnough) + } + + /// Fünfzehn Sekunden Stillstand sind keine Schwankung. Darauf zu warten, + /// bis drei zusammenkommen, hieße viereinhalb Minuten zusehen. + @Test("Ein einzelner langer Rückler genügt allein") + func oneLongStallIsEnough() { + let facts = notlage(stalls: 1, longest: .seconds(20)) + #expect(DowngradeRule.verdict(facts) == .downgrade) + } + + @Test("Ein gesunder Strom löst nichts aus") + func healthyNeverTriggers() { + #expect(DowngradeRule.verdict(notlage(condition: .healthy)) == .notBadEnough) + } + + /// Das anfängliche Füllen des Puffers sieht aus wie eine Notlage und ist + /// keine. + @Test("In den ersten Sekunden passiert nichts") + func waitsAfterStart() { + #expect(DowngradeRule.verdict(notlage(playedFor: .seconds(5))) == .tooEarly) + } + + /// Ein Sprung leert den Puffer immer. Sich dafür zu bestrafen wäre + /// absurd. + @Test("Kurz nach einem Sprung passiert nichts") + func waitsAfterSeek() { + let facts = notlage(sinceLastSeek: .seconds(3)) + #expect(DowngradeRule.verdict(facts) == .tooEarly) + } + + @Test("Ein länger zurückliegender Sprung stört nicht") + func oldSeekIsFine() { + #expect(DowngradeRule.verdict(notlage(sinceLastSeek: .seconds(30))) == .downgrade) + } + + @Test("Innerhalb der Sperrfrist nicht noch einmal") + func respectsCooldown() { + let facts = notlage(sinceLastDowngrade: .seconds(60), used: 1) + #expect(DowngradeRule.verdict(facts) == .cooling) + } + + @Test("Nach der Sperrfrist wieder") + func afterCooldownAgain() { + let facts = notlage(sinceLastDowngrade: .seconds(200), used: 1) + #expect(DowngradeRule.verdict(facts) == .downgrade) + } + + /// Lautlos die ganze Leiter bis 240p hinunterzugehen ist schlimmer, als + /// es zu sagen. + @Test("Das Budget ist endlich") + func budgetIsFinite() { + let facts = notlage(sinceLastDowngrade: .seconds(300), used: 2, budget: 2) + #expect(DowngradeRule.verdict(facts) == .budgetSpent) + } + + /// Bei Live-TV nur einmal: jeder Neuaufbau stimmt den Tuner neu ab, und + /// davon gibt es vier Stück. + @Test("Bei Live-TV ist nach einem Mal Schluss") + func liveBudgetIsOne() { + let facts = notlage(sinceLastDowngrade: .seconds(300), used: 1, budget: 1) + #expect(DowngradeRule.verdict(facts) == .budgetSpent) + } +}