feat(65): clips on disk — write one, keep 3/4/5, delete the oldest
Slice 3 of TASK 65. 606 green, 0 warnings. `ClipLibrary` is the whole of clip "management": a file lands in `clips\` beside the recordings, the oldest is deleted past the retention count, and `Recent()` returns newest-first because that is also the replay layer's playlist order. No gallery, no prompt, no naming. Two guards the tests pin: an **empty snapshot never becomes a zero-byte file** (a save that fails loudly is better than a file that won't open), and **pruning only touches our own prefix** — another file in the folder survives. `RingBytesFor(seconds, bitrate)` is the memory cap the ring is sized with: exact wire size × 1.25 headroom, because bitrate moves and the ring trims whole fragments. Still to come: the replay layer, the F10 hotkey, and the Settings switch.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 65: where clips live and how many survive.
|
||||
///
|
||||
/// <para><b>Not clip management.</b> There is no gallery, no naming, no per-clip decision. A clip is
|
||||
/// written to <c>clips\</c>, 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.</para>
|
||||
/// </summary>
|
||||
public sealed class ClipLibrary
|
||||
{
|
||||
public const string Extension = ".mp4";
|
||||
public const string FilePrefix = "clip-";
|
||||
|
||||
private readonly Func<string> _folder;
|
||||
|
||||
public ClipLibrary(Func<string> folder) => _folder = folder;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public static long RingBytesFor(int seconds, int bitrateKbps)
|
||||
=> Math.Max(1, seconds) * (long)bitrateKbps * 1000 / 8 * 5 / 4;
|
||||
|
||||
/// <summary>Writes a clip and prunes to <paramref name="keep"/>; returns the file path, or null when
|
||||
/// there was nothing to write (an empty snapshot must never become a zero-byte file).</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Newest first. The replay layer's playlist is this list, so its order matters.</summary>
|
||||
public IReadOnlyList<string> 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<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deletes everything past the retention count. Missing folders are not an error.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The folder beside the recordings, created on demand.</summary>
|
||||
public string EnsureFolder()
|
||||
{
|
||||
var folder = Path.Combine(_folder(), "clips");
|
||||
Directory.CreateDirectory(folder);
|
||||
return folder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 65 slice 3 — clips on disk, with retention and nothing else.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
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<byte>(), 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user