feat(55): stop leaving disk debts — temp clips and a low-disk guard

595 green, 0 warnings. Completes TASK 55 (slice 1, log rotation, shipped 2026-10-22).

- **`AlertClipCleanup`**: the default alert clip is materialised into `%TEMP%` for ffmpeg to read, and
  nothing ever deleted it — one orphan per profile that ever played an alert. Now: delete this session's
  file in `Shutdown`, and sweep `ytLive-alert-*.mp4` at startup for anything a crash left behind. Only
  the prefix we own is touched; a directory is never deleted.
- **`DiskSpaceGuard`**: a recording that dies of a full disk is a trust breach, so the numbers are a
  pure policy — refuse to start under **1 GB** (toast; the stream still starts, recording does not),
  warn under **5 GB** at start ("about eighty minutes" at 8 Mbps ≈ 3.6 GB/h), and one mid-show warning
  under **2 GB** — the last useful moment before the encoder fails for a reason it won't explain.
  Unreadable drives (a USB stick that just left) return null and never block a running recording.

Tests are policy-level on purpose: filling a real drive is not a test. `DiskHygieneTests` proves the
sweep only removes our own files and that the thresholds behave at both ends.
This commit is contained in:
2026-11-10 11:55:42 -08:00
parent c257de1516
commit 1a6a549e17
9 changed files with 222 additions and 5 deletions
+20
View File
@@ -16,12 +16,32 @@ public partial class MainViewModel : ViewModelBase
private DateTime _recordStartTime;
private TimeSpan _recordLength;
private bool _lowDiskWarned;
private string? StartRecordFile()
{
try
{
var folder = _recordFolder ?? DefaultRecordFolder();
Directory.CreateDirectory(folder);
// TASK 55·3: never start something the drive cannot finish.
if (DiskSpaceGuard.FreeBytes(folder) is { } free)
{
var decision = DiskSpaceGuard.ForStart(free);
if (decision == DiskSpaceGuard.StartDecision.Block)
{
AppLog.Write($"Recording refused: {free / (1024 * 1024)} MB free",
AppLogMessageType.Error, AppLogErrorClass.Class2);
_notifications.Error("Not enough disk space",
"There isn't room to record safely. Free up some space or pick another record folder.");
return null;
}
if (decision == DiskSpaceGuard.StartDecision.Warn)
_notifications.Warning("Disk space is getting low",
"Your recording starts, but there isn't much room left. A long show may not fit.");
}
_lowDiskWarned = false;
var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4";
var path = Path.Combine(folder, name);
_activeRecordPath = path;
@@ -641,6 +641,16 @@ public partial class MainViewModel : ViewModelBase
_recordLength = _liveElapsed;
_recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
OnPropertyChanged(nameof(RecDotOpacity));
// TASK 55·3: the last useful warning before the encoder fails for a reason it won't explain.
if (IsRecording && !_lowDiskWarned && _recordFolder is { } folder
&& DiskSpaceGuard.FreeBytes(folder) is { } free
&& DiskSpaceGuard.ShouldWarnDuring(free))
{
_lowDiskWarned = true;
_notifications.Warning("Disk space is nearly gone",
"The recording may stop on its own. Free up space now, or stop and save what you have.");
}
}
/// <summary>What happened to a finished broadcast at the keep-or-delete prompt.</summary>
+2
View File
@@ -599,6 +599,8 @@ public partial class MainViewModel : ViewModelBase
_musicPlayer.Dispose();
_youtubeChat.Dispose();
_alertLayer.Dispose();
// TASK 55·2: the materialised default alert clip is ours, so we clean it up on the way out.
Services.AlertClipCleanup.Delete(_defaultAlertClipScratch);
}
public void SaveLayoutNow()