diff --git a/Services/RecordingFile.cs b/Services/RecordingFile.cs
index 2e0544d..4e0b4d9 100644
--- a/Services/RecordingFile.cs
+++ b/Services/RecordingFile.cs
@@ -2,11 +2,17 @@ namespace ytLive.Services;
///
/// Pure filename logic for local recordings (TASK 18). The user-requested naming
-/// is ty-yyyymmdd-hhmm-hh2mm2.mp4: ty is the brand marker, the first
-/// hhmm is the hour+minute the recording STARTED (saved), and the second
-/// hh2mm2 is the hour+minute of the recording's LENGTH. Length is only known
-/// when recording ends, so the file is created with a 0000 length and renamed
-/// on stop (see ).
+/// is ty-yyyymmdd-hhmm-fff-hh2mm2.mp4: ty is the brand marker, the first
+/// hhmm is the hour+minute the recording STARTED (saved), fff is
+/// milliseconds on the same start timestamp, and the last hh2mm2 is the
+/// hour+minute of the recording's LENGTH. Length is only known when recording ends,
+/// so the file is created with a 0000 length and renamed on stop (see
+/// ).
+/// The fff 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 _recordStartTime, so the segment survives
+/// the rename.
///
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}";
/// Final name with the real recording length (fractional hour+minute),
- /// e.g. 75 minutes → ty-20260829-1405-0115.mp4.
+ /// e.g. 75 minutes → ty-20260829-1405-000-0115.mp4.
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}";
}
}
diff --git a/TASKS/task-18-local-recording.md b/TASKS/task-18-local-recording.md
index e96ce3e..4ada254 100644
--- a/TASKS/task-18-local-recording.md
+++ b/TASKS/task-18-local-recording.md
@@ -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---0000.mp4` at start, **rename-on-stop** to `ty-…-.mp4` (numeric suffix on collision)
+7. ✅ Output folder `%APPDATA%\ytLlive\recordings\` default; auto-name `ty----0000.mp4` at start, **rename-on-stop** to `ty-…-.mp4` (numeric suffix on collision). The `` 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.
diff --git a/ai.md b/ai.md
index ddada1a..702ebc1 100644
--- a/ai.md
+++ b/ai.md
@@ -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---0000.mp4` at start;
- **rename-on-stop** to `ty-…-.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----0000.mp4` at
+ start; **rename-on-stop** to `ty-…-.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 **`` 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
diff --git a/ytLive.Tests/RecordingFileTests.cs b/ytLive.Tests/RecordingFileTests.cs
index e0d8326..c67694e 100644
--- a/ytLive.Tests/RecordingFileTests.cs
+++ b/ytLive.Tests/RecordingFileTests.cs
@@ -4,23 +4,48 @@ using ytLive.Services;
namespace ytLive.Tests;
///
-/// Local recording filename rules (TASK 18): ty-<yyyyMMdd>-<HHmm start>-<hh2mm2 length>.mp4.
+/// Local recording filename rules (TASK 18):
+/// ty-<yyyyMMdd>-<HHmm start>-<fff>-<hh2mm2 length>.mp4.
/// Recordings start with a 0000 length placeholder and are renamed on stop.
///
public class RecordingFileTests
{
+ /// 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).
+ [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));
+ }
+
+ /// 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.
+ [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));
}
}
diff --git a/ytLive.Tests/RecordingSaveDialogTests.cs b/ytLive.Tests/RecordingSaveDialogTests.cs
index 1b93766..f219c6e 100644
--- a/ytLive.Tests/RecordingSaveDialogTests.cs
+++ b/ytLive.Tests/RecordingSaveDialogTests.cs
@@ -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);