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; } }