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