feat(quality): learn the starting rate from real sessions, on the creator's permission

Each finished broadcast is filed locally — tier, probed encoder, rated, settled, steps
spent — and Models/RateHistory turns that into at most a suggestion. The app never
applies one: it asks before the next stream, "Use it" sets the rate, "Keep mine"
remembers that exact number so the same question does not return until the evidence
moves. Nothing leaves the machine.

Two rules keep it from nagging. The high-water mark is the highest rate that
*finished* a session with zero steps, not the highest seen, so one easy session (a
menu screen, a quiet game) cannot set a target the machine cannot hold. And it is
asymmetric: raising needs 10% above the current rate across five clean sessions of the
same tier and encoder, lowering needs three step-downs in five — fixing damage should
not wait for a percentage, and risking it should.

The encoder key is real, not decorative: StreamHealth.VideoEncoder is now filled from
the probe (FfmpegEncoder), so history recorded against NVENC is not applied to a
machine that later runs QSV. At startup, before anything has streamed, the last
session on that tier is the honest guess.

672 pass, 0 warnings.
This commit is contained in:
2026-11-12 08:24:12 -08:00
parent f2cab5ca0f
commit b0f8263414
12 changed files with 407 additions and 1 deletions
+1
View File
@@ -82,6 +82,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
var ffmpegPath = await _locator.LocateAsync(cancellationToken).ConfigureAwait(false);
var encoder = options.VideoEncoder ?? await ProbeEncoderAsync(ffmpegPath, cancellationToken).ConfigureAwait(false);
_health.VideoEncoder = encoder;
var args = FfmpegArgs.Build(options, encoder);
var startInfo = new ProcessStartInfo
+32 -1
View File
@@ -2,7 +2,6 @@ using Microsoft.Data.Sqlite;
using Notification.Core;
using ytLive.Services.Compositor;
using ytLive.Models;
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
@@ -33,6 +32,38 @@ public partial class LayoutStore : IDisposable
public double? LoadRatedMbps(string tierDisplay)
=> double.TryParse(GetSetting($"Video.Rate.{tierDisplay}"), out var mbps) && mbps > 0 ? mbps : null;
/// <summary>Finished sessions, oldest first — the evidence behind any rate suggestion. Capped so the
/// row never grows without bound: 40 sessions is several weeks of streaming for anyone.</summary>
public List<SessionRateResult> LoadRateHistory()
{
var raw = GetSetting("Video.RateHistory");
if (string.IsNullOrWhiteSpace(raw)) return new List<SessionRateResult>();
return raw.Split('\n')
.Select(RateHistory.Parse)
.Where(entry => entry is not null)
.Select(entry => entry!.Value)
.ToList();
}
public void AppendRateHistory(SessionRateResult entry)
{
var history = LoadRateHistory();
history.Add(entry);
if (history.Count > RateHistoryCap)
history.RemoveRange(0, history.Count - RateHistoryCap);
UpsertSetting("Video.RateHistory", string.Join('\n', history.Select(RateHistory.Format)));
}
private const int RateHistoryCap = 40;
/// <summary>The proposed rate the creator already refused for this tier, or zero for none. Stored as the
/// number rather than a flag, so a materially different suggestion may still be asked.</summary>
public double LoadDismissedRateProposal(string tierDisplay)
=> double.TryParse(GetSetting($"Video.RateProposalDismissed.{tierDisplay}"), out var mbps) ? mbps : 0;
public void SaveDismissedRateProposal(string tierDisplay, double mbps)
=> UpsertSetting($"Video.RateProposalDismissed.{tierDisplay}", mbps.ToString("0.#"));
public void SaveRatedMbps(string tierDisplay, double? mbps)
{
if (mbps is not > 0)