diff --git a/App.xaml.cs b/App.xaml.cs index 1dbc5c7..35b18fe 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -26,6 +26,10 @@ public partial class App : Application AppLog.Write(args.Exception, "Dispatcher unhandled exception", AppLogErrorClass.Class1); AppLog.Write("App OnStartup begin"); + + // TASK 55·2: a crash leaves the same temp clip a clean exit would have deleted. + var sweptClips = ytLive.Services.AlertClipCleanup.Sweep(System.IO.Path.GetTempPath()); + if (sweptClips > 0) AppLog.Write($"Temp alert clips removed at startup: {sweptClips}"); // Attribution (2026-09-04): every take must be traceable to the exact binary // that produced it — the wordmark shows the release counter (BuildStamp.Release); // the per-build GUID is forensic only and lands here. diff --git a/Services/AlertClipCleanup.cs b/Services/AlertClipCleanup.cs new file mode 100644 index 0000000..37f312a --- /dev/null +++ b/Services/AlertClipCleanup.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; + +namespace ytLive.Services; + +/// +/// TASK 55·2: the default alert clip is materialised into %TEMP% 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. +/// +/// 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. +/// +public static class AlertClipCleanup +{ + public const string Prefix = "ytLive-alert-"; + + /// Deletes stale alert clips left in . Returns how many went. + /// Missing directory is not an error. + 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; + } + } + + /// Deletes one clip if it exists. Safe on null/blank and on a locked file. + 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; + } + } +} diff --git a/Services/DiskSpaceGuard.cs b/Services/DiskSpaceGuard.cs new file mode 100644 index 0000000..e9b8a19 --- /dev/null +++ b/Services/DiskSpaceGuard.cs @@ -0,0 +1,51 @@ +using System; + +namespace ytLive.Services; + +/// +/// 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. +/// +/// Thresholds chosen 2026-11-10 for a 1080p stream at 8 Mbps (~3.6 GB/hour): refusing to start +/// under 1 GB keeps the creator from beginning something that cannot finish; a warning under +/// 5 GB says "you have about eighty minutes" while there is still time to act; and one mid-show +/// warning at 2 GB is the last useful moment before the encoder fails for a reason it will not +/// explain. +/// +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; + + /// One warning per session, once the drive falls below the mid-recording threshold. + public static bool ShouldWarnDuring(long freeBytes) => freeBytes < WarnDuringBytes; + + /// Free bytes on the drive that holds , or null when it cannot be + /// read (a removable drive that just went away must not block a recording that is already running). + 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; + } + } +} diff --git a/TASKS.md b/TASKS.md index 5f3f759..7986a80 100644 --- a/TASKS.md +++ b/TASKS.md @@ -9,7 +9,7 @@ **Creator ruling 2026-11-09 — finish the queue before the new features, gold pass always last.** -`37 → 40·A/C/D → 55·2–3 → Move → 9·5 → 14 (slices) → 65 → 66 → 67 → 68 → 36` +`37 → 40·A/C/D → Move → 9·5 → 14 (slices) → 65 → 66 → 67 → 68 → 36` The rule behind the two retirements below: **do not rebuild the platform's own surface.** Scheduling, publishing, moderation and channel management are YouTube's; the app owns capture → scene → sound → @@ -22,7 +22,7 @@ output. | 35 | ✅ | **Scene-linked audio** — the scene remembers the room: BRB ships muted, three-state checkboxes per scene, applied once when the scene takes the program output (done 2026-11-10) | [`task-35`](TASKS/task-35-scene-linked-audio.md) | | 37 | ☐ | **Defaults vs current layout split** — separate branded defaults from the live workspace; enables one-click revert | [`task-37`](TASKS/task-37-defaults-current-split.md) | | 40·A/C/D | ☐ | **Settings round** — active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) | -| 55·2–3 | ☐ | **Disk hygiene** — delete `%TEMP%` alert clips on close + sweep at startup; low-disk guard before REC (slice 1 shipped) | [`task-55`](TASKS/task-55-disk-hygiene.md) | +| 55 | ✅ | **Disk hygiene** — log rotation (5 MB, one roll), temp alert clips deleted on close + swept at startup, low-disk guard (block under 1 GB, warn under 5 GB at start, one warning under 2 GB mid-show) — done 2026-11-10 | [`task-55`](TASKS/task-55-disk-hygiene.md) | | — | ☐ | **Move transition** — per-layer carry-across-scenes flag, per-scene placement, fixed z band | [`ui-surface`](docs/ai/ui-surface.md) | | 9·5 | ☐ | **Webcam identity key reconciliation** — one stable identity per camera across re-plugs | [`task-09`](TASKS/task-09-live-stream-management.md) | | 14 | 🔶 | **Creator feedback batch** — Branch 2 shipped; Branch 3 = mic input gain, webcam blank/standby, opacity+geometry consistency, Show-in-folder on stop | [`task-14`](TASKS/task-14-creator-feedback-batch.md) | diff --git a/TASKS/task-55-disk-hygiene.md b/TASKS/task-55-disk-hygiene.md index f19614a..d59cb7e 100644 --- a/TASKS/task-55-disk-hygiene.md +++ b/TASKS/task-55-disk-hygiene.md @@ -30,9 +30,9 @@ ticket and a trust breach. Guard: `ytLive.Tests/AppLogRotationTests.cs` (the ONE test: the log rolls at the cap, the live file starts fresh, and the directory still holds exactly `startup.log` + `startup.1.log` after a SECOND roll — the growth is bounded). -2. ☐ **Temp alert clip cleanup** — delete the `%TEMP%` alert clip on app close (and sweep +2. ✅ **Temp alert clip cleanup** — `Services/AlertClipCleanup` deletes the session's clip on close and sweeps `ytLive-alert-*.mp4` at startup (never anything else); shipped 2026-11-10 — delete the `%TEMP%` alert clip on app close (and sweep stale ones at startup). Test: create, close, assert gone. -3. ☐ **Low-disk guard for recordings** — check free space on the record folder's drive before - Record starts, and warn (toast) when it runs low mid-recording. Thresholds TBD with the creator. +3. ✅ **Low-disk guard for recordings** — `Services/DiskSpaceGuard`: refuse to start under **1 GB** (toast, recording not started, stream continues), warn under **5 GB** at start, one mid-show warning under **2 GB**; shipped 2026-11-10 — check free space on the record folder's drive before + Record starts, and warn (toast) when it runs low mid-recording. Thresholds chosen for 8 Mbps ≈ 3.6 GB/h; revisit if tiers change. **Design decisions:** keep `AppLog`'s call surface unchanged — rotation lives inside it. diff --git a/ViewModels/MainViewModel.Recording.cs b/ViewModels/MainViewModel.Recording.cs index 2ade57c..e0f8385 100644 --- a/ViewModels/MainViewModel.Recording.cs +++ b/ViewModels/MainViewModel.Recording.cs @@ -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; diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs index 9f46bcb..b15ddb5 100644 --- a/ViewModels/MainViewModel.Streaming.Operations.cs +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -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."); + } } /// What happened to a finished broadcast at the keep-or-delete prompt. diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 10ba32e..e1e8f64 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -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() diff --git a/ytLive.Tests/DiskHygieneTests.cs b/ytLive.Tests/DiskHygieneTests.cs new file mode 100644 index 0000000..c318eaf --- /dev/null +++ b/ytLive.Tests/DiskHygieneTests.cs @@ -0,0 +1,70 @@ +using System; +using System.IO; +using Xunit; +using ytLive.Services; + +namespace ytLive.Tests; + +/// +/// TASK 55·2–3 — the two disk debts a free app must not leave: the temp alert clip it materialises and +/// never deletes, and a recording that starts with no room to finish. +/// +/// Both are tested as policy, because the alternative — filling a real drive — is not a test. +/// +public sealed class DiskHygieneTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), $"ytLlive-disk-{Guid.NewGuid():N}"); + + public DiskHygieneTests() => Directory.CreateDirectory(_dir); + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { /* best effort */ } + } + + [Fact] + public void Sweep_RemovesOnlyTheClipsWeCreated() + { + var ours1 = Path.Combine(_dir, "ytLive-alert-abc.mp4"); + var ours2 = Path.Combine(_dir, "ytLive-alert-def.mp4"); + var theirs = Path.Combine(_dir, "someone-elses-recording.mp4"); + File.WriteAllText(ours1, "x"); + File.WriteAllText(ours2, "x"); + File.WriteAllText(theirs, "x"); + + var removed = AlertClipCleanup.Sweep(_dir); + + Assert.Equal(2, removed); + Assert.False(File.Exists(ours1)); + Assert.False(File.Exists(ours2)); + Assert.True(File.Exists(theirs)); // never touch anything that isn't ours + } + + [Fact] + public void Delete_IsSafeOnMissingBlankAndNull() + { + Assert.False(AlertClipCleanup.Delete(null)); + Assert.False(AlertClipCleanup.Delete("")); + Assert.False(AlertClipCleanup.Delete(Path.Combine(_dir, "not-there.mp4"))); + + var file = Path.Combine(_dir, "ytLive-alert-gone.mp4"); + File.WriteAllText(file, "x"); + Assert.True(AlertClipCleanup.Delete(file)); + Assert.False(File.Exists(file)); + } + + [Fact] + public void RecordingIsRefusedBelowAGigabyte_WarnedBelowFive_AndQuietAbove() + { + Assert.Equal(DiskSpaceGuard.StartDecision.Block, DiskSpaceGuard.ForStart(500L * 1024 * 1024)); + Assert.Equal(DiskSpaceGuard.StartDecision.Warn, DiskSpaceGuard.ForStart(2L * 1024 * 1024 * 1024)); + Assert.Equal(DiskSpaceGuard.StartDecision.Ok, DiskSpaceGuard.ForStart(20L * 1024 * 1024 * 1024)); + } + + [Fact] + public void MidRecordingWarnsOnceBelowTwoGigabytes() + { + Assert.False(DiskSpaceGuard.ShouldWarnDuring(3L * 1024 * 1024 * 1024)); + Assert.True(DiskSpaceGuard.ShouldWarnDuring(1L * 1024 * 1024 * 1024)); + } +}