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:
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 55·2: the default alert clip is materialised into <c>%TEMP%</c> so ffmpeg can read it as a file.
|
||||
/// Nothing used to delete it, so every profile that ever played an alert left an orphan behind.
|
||||
///
|
||||
/// <para>Two touches, deliberately simple: delete this session's file on close, and sweep anything the
|
||||
/// prefix matches at startup (a crash leaves the same debris a clean exit does not). Only files this app
|
||||
/// created are touched — the prefix is the contract, and the sweep never deletes a directory.</para>
|
||||
/// </summary>
|
||||
public static class AlertClipCleanup
|
||||
{
|
||||
public const string Prefix = "ytLive-alert-";
|
||||
|
||||
/// <summary>Deletes stale alert clips left in <paramref name="directory"/>. Returns how many went.
|
||||
/// Missing directory is not an error.</summary>
|
||||
public static int Sweep(string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(directory)) return 0;
|
||||
var removed = 0;
|
||||
foreach (var path in Directory.EnumerateFiles(directory, Prefix + "*.mp4"))
|
||||
{
|
||||
if (TryDelete(path)) removed++;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Helpers.AppLog.Write(ex, "Alert clip sweep failed");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deletes one clip if it exists. Safe on null/blank and on a locked file.</summary>
|
||||
public static bool Delete(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return false;
|
||||
return TryDelete(path);
|
||||
}
|
||||
|
||||
private static bool TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path)) return false;
|
||||
File.Delete(path);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Helpers.AppLog.Write(ex, $"Alert clip delete failed: {path}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 55·3: a recording that dies of a full disk is a trust breach, not a support ticket. The guard is
|
||||
/// a pure threshold policy so the numbers are visible in one place and testable without a drive.
|
||||
///
|
||||
/// <para>Thresholds chosen 2026-11-10 for a 1080p stream at 8 Mbps (~3.6 GB/hour): refusing to start
|
||||
/// under <b>1 GB</b> keeps the creator from beginning something that cannot finish; a warning under
|
||||
/// <b>5 GB</b> says "you have about eighty minutes" while there is still time to act; and one mid-show
|
||||
/// warning at <b>2 GB</b> is the last useful moment before the encoder fails for a reason it will not
|
||||
/// explain.</para>
|
||||
/// </summary>
|
||||
public static class DiskSpaceGuard
|
||||
{
|
||||
public const long BlockStartBytes = 1L * 1024 * 1024 * 1024;
|
||||
public const long WarnStartBytes = 5L * 1024 * 1024 * 1024;
|
||||
public const long WarnDuringBytes = 2L * 1024 * 1024 * 1024;
|
||||
|
||||
public enum StartDecision
|
||||
{
|
||||
Ok,
|
||||
Warn,
|
||||
Block,
|
||||
}
|
||||
|
||||
public static StartDecision ForStart(long freeBytes)
|
||||
=> freeBytes < BlockStartBytes ? StartDecision.Block
|
||||
: freeBytes < WarnStartBytes ? StartDecision.Warn
|
||||
: StartDecision.Ok;
|
||||
|
||||
/// <summary>One warning per session, once the drive falls below the mid-recording threshold.</summary>
|
||||
public static bool ShouldWarnDuring(long freeBytes) => freeBytes < WarnDuringBytes;
|
||||
|
||||
/// <summary>Free bytes on the drive that holds <paramref name="folder"/>, or null when it cannot be
|
||||
/// read (a removable drive that just went away must not block a recording that is already running).</summary>
|
||||
public static long? FreeBytes(string folder)
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = System.IO.Path.GetPathRoot(System.IO.Path.GetFullPath(folder));
|
||||
if (string.IsNullOrEmpty(root)) return null;
|
||||
return new System.IO.DriveInfo(root).AvailableFreeSpace;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user