fix: millisecond segment in the recording filename so two instances can't clobber a take
The dev multi-instance use case (one instance screen-capturing the other for
marketing/training videos) exposed a real bug. BuildStartPath in
MainViewModel.Recording.cs builds the start name with no dedup:
var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4";
var path = Path.Combine(folder, name);
Only FinalizeRecordingAsync calls UniquePath. So two instances that start
recording in the same minute computed the identical path
(ty-20260927-1454-0000.mp4) and both ffmpeg processes wrote one file -- a
corrupt take, not an error message.
Creator's fix (2026-09-27): add a millisecond count to the filename timestamp.
New format is ty-<yyyyMMdd>-<HHmm>-<fff>-<hh2mm2>.mp4. Both the start name and
the final name derive from _recordStartTime, so the ffff segment survives the
rename-on-stop and the final name cannot collide with another same-minute take
either. No UniquePath call needed, which keeps the start path a pure function of
the timestamp and the existing collision suffix behaviour intact.
Tests: 2 new (the same-minute guard and the rename-survival guard), 5 existing
assertions updated for the format. RecordingSaveDialogTests' stem fixtures
updated to match. 369/369 pass, 0 warnings.
Note: the first test run reported 367/367 because only ytLive.csproj had been
built -- the stale test DLL never picked up the new tests. The real count is 369.
This commit is contained in:
@@ -2,11 +2,17 @@ namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pure filename logic for local recordings (TASK 18). The user-requested naming
|
||||
/// is <c>ty-yyyymmdd-hhmm-hh2mm2.mp4</c>: <c>ty</c> is the brand marker, the first
|
||||
/// <c>hhmm</c> is the hour+minute the recording STARTED (saved), and the second
|
||||
/// <c>hh2mm2</c> is the hour+minute of the recording's LENGTH. Length is only known
|
||||
/// when recording ends, so the file is created with a <c>0000</c> length and renamed
|
||||
/// on stop (see <see cref="FinalizeFileName"/>).
|
||||
/// is <c>ty-yyyymmdd-hhmm-fff-hh2mm2.mp4</c>: <c>ty</c> is the brand marker, the first
|
||||
/// <c>hhmm</c> is the hour+minute the recording STARTED (saved), <c>fff</c> is
|
||||
/// milliseconds on the same start timestamp, and the last <c>hh2mm2</c> is the
|
||||
/// hour+minute of the recording's LENGTH. Length is only known when recording ends,
|
||||
/// so the file is created with a <c>0000</c> length and renamed on stop (see
|
||||
/// <see cref="FinalizeFileName"/>).
|
||||
/// <para>The <c>fff</c> exists so two instances started in the same minute cannot
|
||||
/// compute the same path — the dev multi-instance use case (record one instance
|
||||
/// with the other) would otherwise have both ffmpeg processes writing one file.
|
||||
/// Both names derive from the same <c>_recordStartTime</c>, so the segment survives
|
||||
/// the rename.</para>
|
||||
/// </summary>
|
||||
public static class RecordingFile
|
||||
{
|
||||
@@ -14,14 +20,14 @@ public static class RecordingFile
|
||||
public const string StartLengthSuffix = "0000";
|
||||
|
||||
public static string BuildStartName(DateTime start)
|
||||
=> $"ty-{start:yyyyMMdd}-{start:HHmm}-{StartLengthSuffix}";
|
||||
=> $"ty-{start:yyyyMMdd}-{start:HHmm}-{start:fff}-{StartLengthSuffix}";
|
||||
|
||||
/// <summary>Final name with the real recording length (fractional hour+minute),
|
||||
/// e.g. 75 minutes → <c>ty-20260829-1405-0115.mp4</c>.</summary>
|
||||
/// e.g. 75 minutes → <c>ty-20260829-1405-000-0115.mp4</c>.</summary>
|
||||
public static string BuildFinalName(DateTime start, TimeSpan length)
|
||||
{
|
||||
var hh2 = (int)length.TotalHours;
|
||||
var mm2 = length.Minutes;
|
||||
return $"ty-{start:yyyyMMdd}-{start:HHmm}-{hh2:00}{mm2:00}";
|
||||
return $"ty-{start:yyyyMMdd}-{start:HHmm}-{start:fff}-{hh2:00}{mm2:00}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
4. ✅ Top-bar REC + ON-AIR pill toggles, status lights (REC green when recording, ON-AIR green when live), dynamic primary-button text, account status-light tooltip
|
||||
5. ✅ Record-folder persistence (`LayoutStore` `RecordFolder` key) + picker (`ChooseRecordFolderCommand`)
|
||||
6. 🟡 Branding flash carries into local recordings — same frame path as streaming (verify in the running app)
|
||||
7. ✅ Output folder `%APPDATA%\ytLlive\recordings\` default; auto-name `ty-<yyyymmdd>-<hhmm start>-0000.mp4` at start, **rename-on-stop** to `ty-…-<hh2mm2 length>.mp4` (numeric suffix on collision)
|
||||
7. ✅ Output folder `%APPDATA%\ytLlive\recordings\` default; auto-name `ty-<yyyymmdd>-<hhmm start>-<fff>-0000.mp4` at start, **rename-on-stop** to `ty-…-<hh2mm2 length>.mp4` (numeric suffix on collision). The `<fff>` millisecond segment was added 2026-09-27 so two instances started in the same minute cannot compute the same path (dev multi-instance: one instance recording the other)
|
||||
|
||||
Remaining: running-app verification of the rename + dual output.
|
||||
|
||||
|
||||
@@ -717,10 +717,14 @@ lights when a session is actually running.
|
||||
gold/red/green). The account **status dot is gone** — the avatar/Sign In pair carries connectedness.
|
||||
`ShowPrimaryStartButton` remains the always-idle face. `StopStream` clears both pills (see "Stop ends
|
||||
everything; failures roll back" below).
|
||||
- **Filenames** (pure `Services/RecordingFile`): auto-name `ty-<yyyyMMdd>-<HHmm start>-0000.mp4` at start;
|
||||
**rename-on-stop** to `ty-…-<hh2mm2 actual length>.mp4` (`FinalizeRecordingAsync` after the pump stops &
|
||||
the file closes), numeric `-2`/`-3` suffix on collision (`UniquePath`). Length comes from `_liveElapsed`,
|
||||
which the session timer walks while `IsLive || IsRecording`.
|
||||
- **Filenames** (pure `Services/RecordingFile`): auto-name `ty-<yyyyMMdd>-<HHmm start>-<fff>-0000.mp4` at
|
||||
start; **rename-on-stop** to `ty-…-<hh2mm2 actual length>.mp4` (`FinalizeRecordingAsync` after the pump
|
||||
stops & the file closes), numeric `-2`/`-3` suffix on collision (`UniquePath`). Length comes from
|
||||
`_liveElapsed`, which the session timer walks while `IsLive || IsRecording`. The **`<fff>` millisecond
|
||||
segment is load-bearing, not decoration** (2026-09-27): both names derive from `_recordStartTime`, so it
|
||||
survives the rename, and without it two instances started in the same minute compute the *same* start
|
||||
path and both ffmpeg processes write one file — which is exactly the dev multi-instance case
|
||||
(`Helpers/InstanceProfile`, one instance recording the other).
|
||||
- **Save dialog: Cancel DISCARDS (creator ruling 2026-09-26).** The `RenameRecordingDialog` result is a
|
||||
two-outcome decision, and it lives in the extracted `MainViewModel.CompleteRecordingSave(startPath, dir,
|
||||
autoStem, chosenStem, creatorSaved)` (internal, so `ytLive.Tests` can drive it against real files without
|
||||
|
||||
@@ -4,23 +4,48 @@ using ytLive.Services;
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Local recording filename rules (TASK 18): <c>ty-<yyyyMMdd>-<HHmm start>-<hh2mm2 length>.mp4</c>.
|
||||
/// Local recording filename rules (TASK 18):
|
||||
/// <c>ty-<yyyyMMdd>-<HHmm start>-<fff>-<hh2mm2 length>.mp4</c>.
|
||||
/// Recordings start with a <c>0000</c> length placeholder and are renamed on stop.
|
||||
/// </summary>
|
||||
public class RecordingFileTests
|
||||
{
|
||||
/// <summary>Two instances started in the same minute must not compute the same
|
||||
/// path, or both ffmpeg processes write one file and the take is lost. The
|
||||
/// millisecond segment is what separates them (dev multi-instance, recording
|
||||
/// one instance with the other).</summary>
|
||||
[Fact]
|
||||
public void TwoStartsInTheSameMinute_ProduceDifferentNames()
|
||||
{
|
||||
var first = new DateTime(2026, 9, 27, 14, 54, 0, 123);
|
||||
var second = new DateTime(2026, 9, 27, 14, 54, 0, 456);
|
||||
|
||||
Assert.NotEqual(RecordingFile.BuildStartName(first), RecordingFile.BuildStartName(second));
|
||||
}
|
||||
|
||||
/// <summary>The ms segment must also survive the rename-on-stop, or the final
|
||||
/// name could collide with a different take started in the same minute.</summary>
|
||||
[Fact]
|
||||
public void MillisecondsSurviveTheRenameOnStop()
|
||||
{
|
||||
var start = new DateTime(2026, 9, 27, 14, 54, 0, 123);
|
||||
var name = RecordingFile.BuildFinalName(start, TimeSpan.FromMinutes(5));
|
||||
|
||||
Assert.Equal("ty-20260927-1454-123-0005", name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildStartName_UsesDateHourAndMinute_WithZeroLength()
|
||||
{
|
||||
var start = new DateTime(2026, 8, 29, 14, 5, 0);
|
||||
Assert.Equal("ty-20260829-1405-0000", RecordingFile.BuildStartName(start));
|
||||
Assert.Equal("ty-20260829-1405-000-0000", RecordingFile.BuildStartName(start));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildStartName_PadsSingleDigitHourAndMinute()
|
||||
{
|
||||
var start = new DateTime(2026, 1, 2, 9, 7, 0);
|
||||
Assert.Equal("ty-20260102-0907-0000", RecordingFile.BuildStartName(start));
|
||||
Assert.Equal("ty-20260102-0907-000-0000", RecordingFile.BuildStartName(start));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -28,7 +53,7 @@ public class RecordingFileTests
|
||||
{
|
||||
var start = new DateTime(2026, 8, 29, 14, 5, 0);
|
||||
var length = TimeSpan.FromMinutes(2);
|
||||
Assert.Equal("ty-20260829-1405-0002", RecordingFile.BuildFinalName(start, length));
|
||||
Assert.Equal("ty-20260829-1405-000-0002", RecordingFile.BuildFinalName(start, length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -36,7 +61,7 @@ public class RecordingFileTests
|
||||
{
|
||||
var start = new DateTime(2026, 8, 29, 14, 5, 0);
|
||||
var length = new TimeSpan(1, 15, 0); // 1h 15m
|
||||
Assert.Equal("ty-20260829-1405-0115", RecordingFile.BuildFinalName(start, length));
|
||||
Assert.Equal("ty-20260829-1405-000-0115", RecordingFile.BuildFinalName(start, length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -44,6 +69,6 @@ public class RecordingFileTests
|
||||
{
|
||||
var start = new DateTime(2026, 8, 29, 14, 5, 0);
|
||||
var length = new TimeSpan(12, 30, 0); // 12h 30m
|
||||
Assert.Equal("ty-20260829-1405-1230", RecordingFile.BuildFinalName(start, length));
|
||||
Assert.Equal("ty-20260829-1405-000-1230", RecordingFile.BuildFinalName(start, length));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ public sealed class RecordingSaveDialogTests : IDisposable
|
||||
private readonly string _dir = Path.Combine(
|
||||
Path.GetTempPath(), "ytLive-recsave-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
|
||||
private const string AutoStem = "ty-20260926-1405-0130";
|
||||
private const string StartStem = "ty-20260926-1405-0000";
|
||||
private const string AutoStem = "ty-20260926-1405-000-0130";
|
||||
private const string StartStem = "ty-20260926-1405-000-0000";
|
||||
private const string Bytes = "fake-mp4-payload";
|
||||
|
||||
public RecordingSaveDialogTests() => Directory.CreateDirectory(_dir);
|
||||
|
||||
Reference in New Issue
Block a user