Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 32 additions & 10 deletions SentryDeck.Data/CamChunk.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,28 +23,50 @@ public CamChunk(DateTime timestamp, IEnumerable<CamFile> files)
Files = BuildFileMap(files);
}

// Keyed by camera name, keeping the first file for each camera.
// A duplicate suffix at one timestamp (two files mapping to the same camera, e.g. after a rear_view -> back alias) would otherwise make ToDictionary throw -- and since CamClip.TryMap swallows that, the WHOLE clip folder would be silently dropped.
// Keep-first + log instead so one stray file can't lose a clip.
// Keyed by camera name, one file per camera.
// Several files can claim one camera at a timestamp: numbered copies ("front-2.mp4") left by copying a drive, or a rear_view -> back alias collision.
// An unguarded ToDictionary would throw, and since CamClip.TryMap swallows that, the WHOLE clip folder would be silently dropped.
// The original wins, unless it's empty and a copy isn't; ties keep enumeration order.
private static IReadOnlyDictionary<string, CamFile> BuildFileMap(IEnumerable<CamFile> files)
{
var map = new Dictionary<string, CamFile>();

foreach (var file in files)
foreach (var group in files.GroupBy(file => file.Camera))
{
if (!map.TryAdd(file.Camera, file))
var candidates = group
.OrderBy(file => IsEmpty(file) ? 1 : 0)
.ThenBy(file => file.CopyNumber)
.ToList();

map[group.Key] = candidates[0];

if (candidates.Count > 1)
{
Log.Warning(
"Duplicate camera file at one timestamp; keeping the first and ignoring the rest. Camera={Camera}; Timestamp={Timestamp}; Ignored={IgnoredPath}",
file.Camera,
file.Timestamp,
file.FullPath);
Log.Debug(
"Several files for one camera at one timestamp; using one and ignoring the rest. Camera={Camera}; Timestamp={Timestamp}; Using={UsedPath}; Ignored={IgnoredPaths}",
group.Key,
candidates[0].Timestamp,
candidates[0].FullPath,
candidates.Skip(1).Select(file => file.FullPath).ToArray());
}
}

return map;
}

private static bool IsEmpty(CamFile file)
{
try
{
var info = new FileInfo(file.FullPath);
return info.Exists && info.Length == 0;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return false;
}
}

/// <summary>
/// Groups valid media files by timestamp and keeps chunks with front-camera video.
/// </summary>
Expand Down
14 changes: 13 additions & 1 deletion SentryDeck.Data/CamEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Serilog;

namespace SentryDeck;

Expand Down Expand Up @@ -137,7 +138,18 @@ public static CamEvent FromFile(string path)
if (!File.Exists(path))
return null;

var json = File.ReadAllText(path);
string json;
try
{
json = File.ReadAllText(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// The event metadata is optional; a bad sector or locked file here must not hide the playable footage beside it.
Log.Warning(ex, "Could not read event metadata; loading the clip without it. File={File}", path);
return null;
}

return Deserialize(json);
}
}
19 changes: 14 additions & 5 deletions SentryDeck.Data/CamFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,17 @@ public partial record class CamFile
/// </summary>
public string Camera { get; private init; }

public CamFile(string path, DateTime timestamp, string camera)
/// <summary>
/// Zero for an original recording; N for a numbered copy such as <c>...-front-2.mp4</c>, which appears when a drive's contents are copied or merged onto another.
/// </summary>
public int CopyNumber { get; private init; }

public CamFile(string path, DateTime timestamp, string camera, int copyNumber = 0)
{
FullPath = Path.GetFullPath(path);
Timestamp = timestamp;
Camera = camera;
CopyNumber = copyNumber;
}

/// <summary>
Expand Down Expand Up @@ -59,11 +65,14 @@ private static CamFile TryMap(string path)
}

// Canonicalize legacy aliases (e.g. rear_view -> back) so old and new clips share one camera vocabulary.
// The capture stays greedy on purpose: an unrecognized suffix (a future camera) is kept as-is rather than dropped.
var camera = CameraNames.Canonicalize(match.Groups["camera"].Value);
return new CamFile(path, timestamp, camera);
// An unrecognized camera name (a future camera) is kept as-is rather than dropped.
var camera = CameraNames.Canonicalize(match.Groups["camera"].Value.ToLowerInvariant());
var copyNumber = match.Groups["copy"].Success ? int.Parse(match.Groups["copy"].Value, CultureInfo.InvariantCulture) : 0;
return new CamFile(path, timestamp, camera, copyNumber);
}

[GeneratedRegex(@"(?<date>\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})-(?<camera>.+)\.mp4")]
// Anchored on both ends: an unanchored match picked up macOS "._" resource-fork files and names like "x.mp4.tmp.mp4", and on NTFS the "._" twin sorts first and displaced the real video.
// The camera is a plain identifier followed by an optional "-N" copy suffix, so "front-2.mp4" is a copy of front rather than a camera called "front-2".
[GeneratedRegex(@"^(?<date>\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})-(?<camera>[a-z][a-z0-9_]*?)(?:-(?<copy>\d{1,3}))?\.mp4$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex FileNameRegex();
}
9 changes: 7 additions & 2 deletions SentryDeck.Data/Playback/FfconcatMediaSourceBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ public ClipMediaSource Build(CamClip clip, IReadOnlySet<int> excludedChunkIndice
return new ClipMediaSource(duration, chunkStarts, playlistPaths, autoExcludedIndices, chunkTimestamps, chunkDurations, clipStartTimestamp);
}

/// <summary>
/// Tesla writes chunks of about a minute; a header claiming far more is corrupt, and trusting it would stretch the clip's timeline by hours of footage that isn't there.
/// </summary>
private static readonly TimeSpan MaxPlausibleChunkDuration = TimeSpan.FromMinutes(10);

private static TimeSpan? ProbeFrontChunkDuration(CamChunk chunk)
{
if (!chunk.Files.TryGetValue(CameraNames.Front, out var frontFile))
Expand All @@ -136,7 +141,7 @@ public ClipMediaSource Build(CamClip clip, IReadOnlySet<int> excludedChunkIndice
}

var probed = Mp4DurationReader.TryReadDuration(frontFile.FullPath);
if (probed is { } duration && duration > TimeSpan.Zero)
if (probed is { } duration && duration > TimeSpan.Zero && duration <= MaxPlausibleChunkDuration)
{
return duration;
}
Expand All @@ -150,7 +155,7 @@ public ClipMediaSource Build(CamClip clip, IReadOnlySet<int> excludedChunkIndice

private static bool IsProbeable(string path)
{
return Mp4DurationReader.TryReadDuration(path) is { } duration && duration > TimeSpan.Zero;
return Mp4DurationReader.TryReadDuration(path) is { } duration && duration > TimeSpan.Zero && duration <= MaxPlausibleChunkDuration;
}

private static void WritePlaylist(string path, IReadOnlyList<(string FilePath, TimeSpan Duration)> entries)
Expand Down
24 changes: 20 additions & 4 deletions SentryDeck.Data/Playback/ICameraPlayer.cs
Original file line number Diff line number Diff line change
@@ -1,25 +1,41 @@
namespace SentryDeck;

/// <summary>
/// One camera's video player.
/// </summary>
/// <remarks>
/// Implementations raise every event on the thread that owns the controller (the UI thread in the app), and never for media that has since been closed or replaced, so the controller can treat each event as current and handle it without locks.
/// Commands may block internally (the real player spins while its threads wind down), so implementations must keep that work off the caller's thread.
/// </remarks>
public interface ICameraPlayer : IDisposable
{
event EventHandler Opened;
event EventHandler Ended;
event EventHandler<CameraPlaybackFailedEventArgs> Failed;
event EventHandler<CameraPositionChangedEventArgs> PositionChanged;

bool IsOpen { get; }

/// <summary>
/// True while the player is parked at end of stream.
/// An ended player ignores Play until it is seeked, so callers check this before resuming.
/// </summary>
bool IsEnded { get; }

double Speed { get; set; }

TimeSpan Position { get; }

Task<bool> OpenAsync(string path);

Task PlayAsync();

Task PauseAsync();
Task StopAsync();

Task CloseAsync();

/// <summary>
/// Seeks to <paramref name="position"/>.
/// Accurate seeks (the default) decode forward to land exactly on the target frame; fast seeks jump to the nearest keyframe, which is far cheaper but can land slightly before the target -- intended for live scrubbing while the seek bar thumb is being dragged, where responsiveness matters more than frame precision.
/// Seeks to <paramref name="position"/> and completes once the player has presented the target frame.
/// Accurate seeks (the default) decode forward to land exactly on the target frame; fast seeks jump to the nearest keyframe, which is far cheaper and suits live scrubbing.
/// </summary>
Task SeekAsync(TimeSpan position, bool accurate = true);

Expand Down
9 changes: 3 additions & 6 deletions SentryDeck.Data/Playback/Mp4DurationReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,9 @@ public static class Mp4DurationReader
var (mvhdStart, mvhdEnd) = mvhdBox.Value;
return ReadMvhdDuration(stream, mvhdStart, mvhdEnd);
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or OverflowException)
{
// A corrupt header can claim box sizes or durations no real file has; any of them means "no readable duration", never a crash that takes the whole clip down with it.
return null;
}
}
Expand Down Expand Up @@ -72,7 +69,7 @@ private static (long ContentStart, long ContentEnd)? FindBox(FileStream stream,
size = end - position;
}

if (size < headerSize)
if (size < headerSize || size > end - position)
return null;

var contentStart = position + headerSize;
Expand Down
98 changes: 98 additions & 0 deletions SentryDeck.Tests/CamDiscoveryResilienceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,102 @@ public void Map_BackAndRearViewAtOneTimestamp_KeepsOneChunkAndDoesNotDropTheClip

CamClip.Map(temp.Path).ShouldNotBeNull();
}

[Fact]
public void FindFiles_NumberedCopySuffix_IsTheSameCameraMarkedAsACopy()
{
// Copying or merging a drive leaves "-2" twins beside the originals; they used to become bogus cameras named "front-2".
using var temp = new TempDirectory();
Touch(temp.Path, "2023-02-23_14-14-48-front.mp4");
Touch(temp.Path, "2023-02-23_14-14-48-front-2.mp4");
Touch(temp.Path, "2023-02-23_14-14-48-left_repeater-2.mp4");

var files = CamFile.FindFiles(temp.Path).ToList();

files.Select(file => (file.Camera, file.CopyNumber)).ShouldBe(
[(CameraNames.Front, 0), (CameraNames.Front, 2), (CameraNames.LeftRepeater, 2)],
ignoreOrder: true);
}

[Theory]
[InlineData("._2023-02-23_14-14-48-front.mp4")] // macOS resource fork left by copying through a Mac
[InlineData("x-2023-02-23_14-14-48-front.mp4")]
[InlineData("2023-02-23_14-14-48-front.mp4.tmp.mp4")]
public void FindFiles_NameWithExtraText_IsIgnored(string name)
{
using var temp = new TempDirectory();
Touch(temp.Path, name);

CamFile.FindFiles(temp.Path).ShouldBeEmpty();
}

[Fact]
public void FindFiles_UppercaseName_ParsesTheCanonicalCamera()
{
using var temp = new TempDirectory();
Touch(temp.Path, "2023-02-23_14-14-48-FRONT.MP4");

CamFile.FindFiles(temp.Path).ShouldHaveSingleItem().Camera.ShouldBe(CameraNames.Front);
}

[Fact]
public void Map_MacResourceForkBesideTheRealFile_KeepsTheRealFile()
{
// On NTFS the "._" twin enumerates first, so keep-first used to hand the player a 4 KB metadata file and the chunk was dropped as unreadable.
using var temp = new TempDirectory();
File.WriteAllBytes(Path.Combine(temp.Path, "._2023-02-23_14-14-48-front.mp4"), new byte[4096]);
Touch(temp.Path, "2023-02-23_14-14-48-front.mp4");

var chunk = CamChunk.Map(temp.Path).ShouldHaveSingleItem();

Path.GetFileName(chunk.Files[CameraNames.Front].FullPath).ShouldBe("2023-02-23_14-14-48-front.mp4");
}

[Fact]
public void Map_OriginalAndNumberedCopy_PrefersTheOriginal()
{
using var temp = new TempDirectory();
File.WriteAllBytes(Path.Combine(temp.Path, "2023-02-23_14-14-48-front-2.mp4"), [1]);
File.WriteAllBytes(Path.Combine(temp.Path, "2023-02-23_14-14-48-front.mp4"), [1]);

var chunk = CamChunk.Map(temp.Path).ShouldHaveSingleItem();

chunk.Files.Keys.ShouldBe([CameraNames.Front]);
chunk.Files[CameraNames.Front].CopyNumber.ShouldBe(0);
}

[Fact]
public void Map_EmptyOriginalWithANonEmptyCopy_UsesTheCopy()
{
using var temp = new TempDirectory();
Touch(temp.Path, "2023-02-23_14-14-48-front.mp4");
File.WriteAllBytes(Path.Combine(temp.Path, "2023-02-23_14-14-48-front-2.mp4"), [1]);

var chunk = CamChunk.Map(temp.Path).ShouldHaveSingleItem();

chunk.Files[CameraNames.Front].CopyNumber.ShouldBe(2);
}

[Fact]
public void Map_OnlyANumberedCopyOfTheFront_StillKeepsTheChunk()
{
using var temp = new TempDirectory();
File.WriteAllBytes(Path.Combine(temp.Path, "2023-02-23_14-14-48-front-2.mp4"), [1]);
File.WriteAllBytes(Path.Combine(temp.Path, "2023-02-23_14-14-48-back.mp4"), [1]);

var chunk = CamChunk.Map(temp.Path).ShouldHaveSingleItem();

chunk.Files.Keys.ShouldBe([CameraNames.Front, CameraNames.Back], ignoreOrder: true);
}

[Fact]
public void CamEventFromFile_WhenTheFileCannotBeRead_ReturnsNullSoTheClipStillLoads()
{
using var temp = new TempDirectory();
var path = Path.Combine(temp.Path, "event.json");
File.WriteAllText(path, "{}");
using var exclusive = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None);

CamEvent.FromFile(path).ShouldBeNull();
}
}
25 changes: 25 additions & 0 deletions SentryDeck.Tests/ConverterTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.IO;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;

namespace SentryDeck.Tests;

Expand Down Expand Up @@ -206,6 +207,19 @@ public void ThumbnailConverter_UndecodableThumbnail_YieldsNoImage()
new ThumbnailConverter().Convert(thumbnail.Path, typeof(ImageSource), null, null).ShouldBeNull();
}

[Fact]
public void ThumbnailConverter_ValidThumbnail_YieldsADecodedImageAndReleasesTheFile()
{
using var thumbnail = new TempFile(BuildPng(width: 384, height: 288), ".png");

var image = new ThumbnailConverter().Convert(thumbnail.Path, typeof(ImageSource), null, null).ShouldBeOfType<BitmapImage>();

// Decoded straight to list size, and fully read up front: the file must not stay locked, or deleting the clip's folder fails.
image.PixelWidth.ShouldBe(192);
image.IsFrozen.ShouldBeTrue();
using var exclusive = new FileStream(thumbnail.Path, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
}

[Fact]
public void ThumbnailConverter_FallbackParameter_IsVisibleOnlyWhenTheFileIsMissing()
{
Expand Down Expand Up @@ -236,6 +250,17 @@ private static CamClip ClipWithChunks(int chunkCount)
return new CamClip(Path.GetTempPath(), "Test Clip", Moment, chunks, camEvent: null);
}

private static byte[] BuildPng(int width, int height)
{
var pixels = new byte[width * height * 4];
var source = BitmapSource.Create(width, height, 96, 96, PixelFormats.Bgra32, null, pixels, width * 4);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(source));
using var stream = new MemoryStream();
encoder.Save(stream);
return stream.ToArray();
}

private static string MissingThumbnailPath() =>
Path.Combine(Path.GetTempPath(), $"SentryDeckTests-{Guid.NewGuid():N}.png");
}
13 changes: 13 additions & 0 deletions SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,19 @@ public void Build_FrontFileProbesToZeroDuration_AutoExcludesChunk()
mediaSource.Duration.ShouldBe(TimeSpan.FromSeconds(120));
}

[Fact]
public void Build_FrontFileClaimingHoursOfFootage_AutoExcludesChunk()
{
// A corrupt header can report an absurd but representable duration; trusted, it stretched the timeline by hours of footage that isn't there.
using var clipFiles = TestClipFiles.Create(chunkCount: 3);
File.WriteAllBytes(clipFiles.GetPath(1, CameraNames.Front), TestMp4.Build(version: 0, timescale: 1, duration: uint.MaxValue));

var mediaSource = Build(clipFiles.Clip);

mediaSource.AutoExcludedChunkIndices.ShouldBe([1]);
mediaSource.Duration.ShouldBe(TimeSpan.FromSeconds(120));
}

[Fact]
public void Build_ChunkWithNoFrontFile_IsAutoExcluded()
{
Expand Down
Loading