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.
204 lines
8.4 KiB
C#
204 lines
8.4 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Threading.Tasks;
|
|
using System.Windows;
|
|
using Microsoft.Win32;
|
|
using ytLive.Helpers;
|
|
using ytLive.Services;
|
|
|
|
namespace ytLive.ViewModels;
|
|
|
|
public partial class MainViewModel : ViewModelBase
|
|
{
|
|
private string? _recordFolder;
|
|
public string RecordFolderDisplay => _recordFolder ?? DefaultRecordFolder();
|
|
private string? _activeRecordPath;
|
|
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;
|
|
return path;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppLog.Write(ex, "Recording: failed to prepare output path", AppLogErrorClass.Class2);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async Task FinalizeRecordingAsync()
|
|
{
|
|
var startPath = _activeRecordPath;
|
|
_activeRecordPath = null;
|
|
if (string.IsNullOrEmpty(startPath)) return;
|
|
try
|
|
{
|
|
while (_framePump.IsRunning) await Task.Delay(50);
|
|
|
|
if (!File.Exists(startPath))
|
|
{
|
|
AppLog.Write($"Recording: output not found for rename: {startPath}",
|
|
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
|
return;
|
|
}
|
|
_recordLength = _liveElapsed;
|
|
var autoStem = RecordingFile.BuildFinalName(_recordStartTime, _recordLength);
|
|
var dir = Path.GetDirectoryName(startPath)!;
|
|
|
|
// Manual rename modal: give the creator a chance to name the recording.
|
|
// CREATOR RULING 2026-09-26: Cancel means DISCARD, not "save under the
|
|
// default name". This used to fall through and File.Move anyway, so a
|
|
// cancelled dialog silently kept the recording — Cancel was a lie.
|
|
var dialog = new RenameRecordingDialog(autoStem);
|
|
var creatorSaved = dialog.ShowDialog() == true;
|
|
|
|
var result = CompleteRecordingSave(
|
|
startPath, dir, autoStem, dialog.ResultFileName, creatorSaved);
|
|
switch (result.Outcome)
|
|
{
|
|
case RecordingSaveOutcome.Saved:
|
|
AppLog.WriteSuccess($"Recording saved: {result.FinalPath}");
|
|
_notifications.Info("Recording saved",
|
|
Path.GetFileName(result.FinalPath!) +
|
|
" — you can rename it in your videos folder at any time.");
|
|
break;
|
|
case RecordingSaveOutcome.Discarded:
|
|
AppLog.Write($"Recording discarded by creator: {startPath}");
|
|
_notifications.Info("Recording discarded",
|
|
"The recording was deleted and nothing was saved.");
|
|
break;
|
|
case RecordingSaveOutcome.DiscardFailed:
|
|
// A "discarded" recording still sitting on disk is worse than no
|
|
// report at all — name the file and the folder so it can be found.
|
|
AppLog.Write($"Recording discard failed: {startPath}",
|
|
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
|
_notifications.Error("Recording discarded?",
|
|
$"Could not delete {Path.GetFileName(startPath)} — it is still in {dir}.");
|
|
break;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppLog.Write(ex, "Recording: rename-on-stop failed", AppLogErrorClass.Class2);
|
|
_notifications.Error("Recording saved",
|
|
$"The recording was written but couldn't be renamed: {Path.GetFileName(startPath)}");
|
|
}
|
|
}
|
|
|
|
/// <summary>What happened to the footage once the save dialog was answered, plus
|
|
/// where it landed (<c>FinalPath</c> is set only for <see cref="RecordingSaveOutcome.Saved"/>).</summary>
|
|
internal readonly record struct RecordingSaveResult(RecordingSaveOutcome Outcome, string? FinalPath);
|
|
|
|
/// <summary>What happened to the footage once the save dialog was answered.</summary>
|
|
internal enum RecordingSaveOutcome
|
|
{
|
|
Saved,
|
|
Discarded,
|
|
DiscardFailed,
|
|
}
|
|
|
|
/// <summary>The commit-or-discard decision behind the recording save dialog, split out
|
|
/// so it can be tested against real files without a frame pump (CREATOR RULING
|
|
/// 2026-09-26). <paramref name="creatorSaved"/> is the dialog's DialogResult:
|
|
/// <c>false</c> — Cancel, Escape, or the X — DELETES the footage; only an explicit
|
|
/// Save (including Enter on the pre-filled default) moves it to its final name.</summary>
|
|
internal static RecordingSaveResult CompleteRecordingSave(
|
|
string startPath, string dir, string autoStem, string? chosenStem, bool creatorSaved)
|
|
{
|
|
if (!creatorSaved)
|
|
{
|
|
if (!File.Exists(startPath)) return new RecordingSaveResult(RecordingSaveOutcome.Discarded, null);
|
|
try
|
|
{
|
|
File.Delete(startPath);
|
|
return new RecordingSaveResult(RecordingSaveOutcome.Discarded, null);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
return new RecordingSaveResult(RecordingSaveOutcome.DiscardFailed, startPath);
|
|
}
|
|
}
|
|
|
|
// A blank name still means "keep the auto name" (the dialog says so), but
|
|
// only on an explicit Save. Enter with the pre-filled default lands here too.
|
|
var stem = string.IsNullOrWhiteSpace(chosenStem) ? autoStem : chosenStem!;
|
|
var finalPath = UniquePath(Path.Combine(dir, stem + ".mp4"));
|
|
File.Move(startPath, finalPath);
|
|
return new RecordingSaveResult(RecordingSaveOutcome.Saved, finalPath);
|
|
}
|
|
|
|
private static string UniquePath(string path)
|
|
{
|
|
if (!File.Exists(path)) return path;
|
|
var dir = Path.GetDirectoryName(path)!;
|
|
var name = Path.GetFileNameWithoutExtension(path);
|
|
var ext = Path.GetExtension(path);
|
|
for (var i = 2; ; i++)
|
|
{
|
|
var candidate = Path.Combine(dir, $"{name}-{i}{ext}");
|
|
if (!File.Exists(candidate)) return candidate;
|
|
}
|
|
}
|
|
|
|
private void ChooseRecordFolder()
|
|
{
|
|
var dialog = new Microsoft.Win32.OpenFolderDialog
|
|
{
|
|
Title = "Choose a folder for local recordings",
|
|
InitialDirectory = _recordFolder ?? DefaultRecordFolder(),
|
|
};
|
|
if (dialog.ShowDialog() == true)
|
|
{
|
|
_recordFolder = dialog.FolderName;
|
|
_layoutStore.SaveRecordFolder(_recordFolder);
|
|
OnPropertyChanged(nameof(RecordFolderDisplay));
|
|
_notifications.Info("Record folder set", _recordFolder);
|
|
}
|
|
}
|
|
|
|
private void ResetRecordFolder()
|
|
{
|
|
_recordFolder = null;
|
|
_layoutStore.SaveRecordFolder(null);
|
|
OnPropertyChanged(nameof(RecordFolderDisplay));
|
|
_notifications.Info("Record folder reset", RecordFolderDisplay);
|
|
}
|
|
|
|
internal static string DefaultRecordFolder()
|
|
{
|
|
var downloads = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
|
|
return Directory.Exists(downloads)
|
|
? downloads
|
|
: Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
|
|
}
|
|
}
|