diff --git a/Services/ClipLibrary.cs b/Services/ClipLibrary.cs new file mode 100644 index 0000000..5cbe399 --- /dev/null +++ b/Services/ClipLibrary.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace ytLive.Services; + +/// +/// TASK 65: where clips live and how many survive. +/// +/// Not clip management. There is no gallery, no naming, no per-clip decision. A clip is +/// written to clips\, the oldest is deleted when the next one lands, and the only knobs are the +/// length (10 or 15 s) and how many to keep (3, 4 or 5). Anything richer belongs to a video editor. +/// +public sealed class ClipLibrary +{ + public const string Extension = ".mp4"; + public const string FilePrefix = "clip-"; + + private readonly Func _folder; + + public ClipLibrary(Func folder) => _folder = folder; + + /// Bytes the ring should hold for a length and bitrate, with 25% headroom for bitrate + /// movement. This is a memory cap, not a duration — the ring trims whole fragments. + public static long RingBytesFor(int seconds, int bitrateKbps) + => Math.Max(1, seconds) * (long)bitrateKbps * 1000 / 8 * 5 / 4; + + /// Writes a clip and prunes to ; returns the file path, or null when + /// there was nothing to write (an empty snapshot must never become a zero-byte file). + public string? Save(byte[] mp4Bytes, DateTime nowLocal, int keep) + { + if (mp4Bytes.Length == 0) return null; + try + { + var folder = EnsureFolder(); + var path = Path.Combine(folder, $"{FilePrefix}{nowLocal:yyyyMMdd-HHmmss}{Extension}"); + var unique = path; + for (var n = 2; File.Exists(unique); n++) + unique = Path.Combine(folder, $"{FilePrefix}{nowLocal:yyyyMMdd-HHmmss}-{n}{Extension}"); + + File.WriteAllBytes(unique, mp4Bytes); + Prune(keep); + return unique; + } + catch (Exception ex) + { + Helpers.AppLog.Write(ex, "Clip save failed"); + return null; + } + } + + /// Newest first. The replay layer's playlist is this list, so its order matters. + public IReadOnlyList Recent() + { + try + { + var folder = EnsureFolder(); + return Directory.EnumerateFiles(folder, FilePrefix + "*" + Extension) + .OrderByDescending(p => p, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch (Exception ex) + { + Helpers.AppLog.Write(ex, "Clip list failed"); + return Array.Empty(); + } + } + + /// Deletes everything past the retention count. Missing folders are not an error. + public int Prune(int keep) + { + if (keep < 1) keep = 1; + var removed = 0; + foreach (var stale in Recent().Skip(keep)) + { + try + { + File.Delete(stale); + removed++; + } + catch (Exception ex) + { + Helpers.AppLog.Write(ex, $"Clip prune failed: {stale}"); + } + } + return removed; + } + + /// The folder beside the recordings, created on demand. + public string EnsureFolder() + { + var folder = Path.Combine(_folder(), "clips"); + Directory.CreateDirectory(folder); + return folder; + } +} diff --git a/ytLive.Tests/ClipLibraryTests.cs b/ytLive.Tests/ClipLibraryTests.cs new file mode 100644 index 0000000..5dadd30 --- /dev/null +++ b/ytLive.Tests/ClipLibraryTests.cs @@ -0,0 +1,76 @@ +using System; +using System.IO; +using System.Linq; +using Xunit; +using ytLive.Services; + +namespace ytLive.Tests; + +/// +/// TASK 65 slice 3 — clips on disk, with retention and nothing else. +/// +/// The rule: write one file, delete the oldest past the count. No gallery, no prompt. The tests pin +/// the two ways that could go wrong — a zero-byte clip from an empty ring, and pruning taking something +/// that isn't ours. +/// +public sealed class ClipLibraryTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), $"ytLlive-clips-{Guid.NewGuid():N}"); + + public ClipLibraryTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ } + } + + private ClipLibrary Library() => new(() => _root); + + [Fact] + public void SaveWritesIntoClipsBesideTheRecordings() + { + var path = Library().Save(new byte[] { 1, 2, 3 }, DateTime.Now, keep: 3); + + Assert.NotNull(path); + Assert.EndsWith(".mp4", path); + Assert.Equal(Path.Combine(_root, "clips"), Path.GetDirectoryName(path)); + Assert.True(File.Exists(path)); + } + + [Fact] + public void AnEmptySnapshotNeverBecomesAZeroByteFile() + { + Assert.Null(Library().Save(Array.Empty(), DateTime.Now, keep: 3)); + Assert.False(Directory.Exists(Path.Combine(_root, "clips")) + && Directory.EnumerateFiles(Path.Combine(_root, "clips")).Any()); + } + + [Fact] + public void RetentionKeepsTheNewestAndDeletesTheRest() + { + var library = Library(); + var folder = library.EnsureFolder(); + for (var i = 0; i < 6; i++) + File.WriteAllText(Path.Combine(folder, $"clip-20261110-12000{i}.mp4"), "x"); + File.WriteAllText(Path.Combine(folder, "someone-elses-file.mp4"), "keep me"); + + var removed = library.Prune(keep: 3); + + Assert.Equal(3, removed); + var remaining = library.Recent(); + Assert.Equal(3, remaining.Count); + Assert.Contains("120005", remaining[0]); // newest first + Assert.True(File.Exists(Path.Combine(folder, "someone-elses-file.mp4"))); + } + + [Fact] + public void RingBytesFollowLengthAndBitrate_WithHeadroom() + { + var tenAt8 = ClipLibrary.RingBytesFor(10, 8000); + var fifteenAt8 = ClipLibrary.RingBytesFor(15, 8000); + + Assert.True(tenAt8 > 10L * 8000 * 1000 / 8); // more than the exact wire size + Assert.True(fifteenAt8 > tenAt8); + Assert.True(tenAt8 < 15L * 1024 * 1024); // and still modest + } +}