diff --git a/src/TeslaCamPlayer.BlazorHosted/Client/Pages/Index.razor b/src/TeslaCamPlayer.BlazorHosted/Client/Pages/Index.razor index d600590..82df4b9 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Client/Pages/Index.razor +++ b/src/TeslaCamPlayer.BlazorHosted/Client/Pages/Index.razor @@ -4,7 +4,14 @@ @if (_filteredclips == null) {
- Loading... + @if (_state.IsProcessing) + { + @($"{_state.Description ?? "Loading..."} {_state.Percentage}%") + } + else + { + Loading... + }
} else @@ -25,7 +32,7 @@ else - +
diff --git a/src/TeslaCamPlayer.BlazorHosted/Client/Pages/Index.razor.cs b/src/TeslaCamPlayer.BlazorHosted/Client/Pages/Index.razor.cs index 9af40d6..7b50cdc 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Client/Pages/Index.razor.cs +++ b/src/TeslaCamPlayer.BlazorHosted/Client/Pages/Index.razor.cs @@ -33,6 +33,7 @@ public partial class Index : ComponentBase private bool _showFilter; private bool _filterChanged; private EventFilterValues _eventFilter = new(); + private State _state = new(); protected override async Task OnInitializedAsync() { @@ -44,7 +45,7 @@ protected override async Task OnInitializedAsync() protected override async Task OnAfterRenderAsync(bool firstRender) { - if (!_setDatePickerInitialDate && _filteredclips?.Any() == true && _datePicker != null) + if (!_setDatePickerInitialDate && _filteredclips?.Length > 0 && _datePicker != null) { _setDatePickerInitialDate = true; var latestClip = _filteredclips.MaxBy(c => c.EndDate)!; @@ -57,15 +58,43 @@ private async Task RefreshEventsAsync(bool refreshCache) { _filteredclips = null; _clips = null; + _setDatePickerInitialDate = false; await Task.Delay(10); await InvokeAsync(StateHasChanged); - _setDatePickerInitialDate = false; - _clips = await HttpClient.GetFromNewtonsoftJsonAsync("Api/GetClips?refreshCache=" + refreshCache); + if (!refreshCache) + { + _clips = await HttpClient.GetFromNewtonsoftJsonAsync("Api/GetClips.json"); + if (_clips != null) + return; + } + + if (refreshCache) + { + _state = new("Refreshing...", 1, 0); + await HttpClient.PostAsync("Api/RefreshClips", null); + } + + await WaitForProcessing(); + _clips = await HttpClient.GetFromNewtonsoftJsonAsync("Api/GetClips.json"); FilterClips(); } + private async Task WaitForProcessing() + { + while (true) + { + _state = await HttpClient.GetFromNewtonsoftJsonAsync("Api/GetState.json"); + if (!_state.IsProcessing) + break; + + _ = InvokeAsync(StateHasChanged); + + await Task.Delay(1000); + } + } + private void FilterClips() { _filteredclips = (_clips ??= Array.Empty()) @@ -114,7 +143,7 @@ private static string[] GetClipIcons(Clip clip) }; if (clip.Type == ClipType.Recent || clip.Type == ClipType.Unknown || clip.Event == null) - return new[] { baseIcon }; + return [baseIcon]; var secondIcon = clip.Event.Reason switch { @@ -128,7 +157,7 @@ private static string[] GetClipIcons(Clip clip) if (clip.Event.Reason.StartsWith(CamEvents.SentryAwareAccelerationPrefix)) secondIcon = Icons.Material.Filled.OpenWith; - return secondIcon == null ? new [] { baseIcon } : new[] { baseIcon, secondIcon }; + return secondIcon == null ? [baseIcon] : [baseIcon, secondIcon]; } private class ScrollToOptions diff --git a/src/TeslaCamPlayer.BlazorHosted/Client/TeslaCamPlayer.BlazorHosted.Client.csproj b/src/TeslaCamPlayer.BlazorHosted/Client/TeslaCamPlayer.BlazorHosted.Client.csproj index b19a2d9..3dd6c35 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Client/TeslaCamPlayer.BlazorHosted.Client.csproj +++ b/src/TeslaCamPlayer.BlazorHosted/Client/TeslaCamPlayer.BlazorHosted.Client.csproj @@ -7,9 +7,9 @@ - - - + + + diff --git a/src/TeslaCamPlayer.BlazorHosted/Server/Controllers/ApiController.cs b/src/TeslaCamPlayer.BlazorHosted/Server/Controllers/ApiController.cs index e9106c1..31a2bbe 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Server/Controllers/ApiController.cs +++ b/src/TeslaCamPlayer.BlazorHosted/Server/Controllers/ApiController.cs @@ -7,7 +7,7 @@ namespace TeslaCamPlayer.BlazorHosted.Server.Controllers; [ApiController] -[Route("Api/[action]")] +[Route("Api")] public class ApiController : ControllerBase { private readonly IClipsService _clipsService; @@ -19,18 +19,29 @@ public ApiController(ISettingsProvider settingsProvider, IClipsService clipsServ _clipsService = clipsService; } - [HttpGet] - public async Task GetClips(bool refreshCache = false) - => await _clipsService.GetClipsAsync(refreshCache); + [HttpGet("GetClips.json")] + public async Task GetClips() + => await _clipsService.GetClipsAsync(); + + [HttpPost("RefreshClips")] + public IActionResult RefreshClips() + { + _clipsService.RefreshClips(); + return Ok(); + } + + [HttpGet("GetState.json")] + public State GetState() + => _clipsService.State; private bool IsUnderRootPath(string path) => path.StartsWith(_rootFullPath); - [HttpGet("{path}.mp4")] + [HttpGet("Video/{path}.mp4")] public IActionResult Video(string path) => ServeFile(path, ".mp4", "video/mp4", true); - [HttpGet("{path}.png")] + [HttpGet("Thumbnail/{path}.png")] public IActionResult Thumbnail(string path) => ServeFile(path, ".png", "image/png"); diff --git a/src/TeslaCamPlayer.BlazorHosted/Server/Program.cs b/src/TeslaCamPlayer.BlazorHosted/Server/Program.cs index eed8bfc..b861f98 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Server/Program.cs +++ b/src/TeslaCamPlayer.BlazorHosted/Server/Program.cs @@ -12,10 +12,12 @@ var builder = WebApplication.CreateBuilder(args); -builder.Services.AddControllersWithViews().AddNewtonsoftJson(); +builder.Services + .AddControllers() + .AddNewtonsoftJson(); builder.Services.AddRazorPages(); builder.Services.AddSingleton(); -builder.Services.AddTransient(); +builder.Services.AddSingleton(); #if WINDOWS builder.Services.AddTransient(); #elif DOCKER @@ -55,7 +57,6 @@ app.UseRouting(); - app.MapRazorPages(); app.MapControllers(); app.MapFallbackToFile("index.html"); diff --git a/src/TeslaCamPlayer.BlazorHosted/Server/Properties/launchSettings.json b/src/TeslaCamPlayer.BlazorHosted/Server/Properties/launchSettings.json index 1711492..76f9334 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Server/Properties/launchSettings.json +++ b/src/TeslaCamPlayer.BlazorHosted/Server/Properties/launchSettings.json @@ -12,7 +12,7 @@ }, "https": { "commandName": "Project", - "launchBrowser": false, + "launchBrowser": true, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, diff --git a/src/TeslaCamPlayer.BlazorHosted/Server/Services/ClipsService.cs b/src/TeslaCamPlayer.BlazorHosted/Server/Services/ClipsService.cs index 347871c..a8f3174 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Server/Services/ClipsService.cs +++ b/src/TeslaCamPlayer.BlazorHosted/Server/Services/ClipsService.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using System.Threading.Tasks; +using System.Text.RegularExpressions; using Newtonsoft.Json; using Serilog; using TeslaCamPlayer.BlazorHosted.Server.Providers.Interfaces; @@ -18,10 +13,13 @@ public partial class ClipsService : IClipsService private static readonly string CacheFilePath = Path.Combine(AppContext.BaseDirectory, "clips.json"); private static readonly Regex FileNameRegex = FileNameRegexGenerated(); - private static Clip[] _cache; + private static readonly SemaphoreSlim RefreshSemaphore = new(1, 1); + private static Clip[] _clips; private readonly ISettingsProvider _settingsProvider; private readonly IFfProbeService _ffProbeService; + + public State State { get; private set; } = new(); public ClipsService(ISettingsProvider settingsProvider, IFfProbeService ffProbeService) { @@ -29,52 +27,106 @@ public ClipsService(ISettingsProvider settingsProvider, IFfProbeService ffProbeS _ffProbeService = ffProbeService; } - private async Task GetCachedAsync() - => File.Exists(CacheFilePath) - ? JsonConvert.DeserializeObject(await File.ReadAllTextAsync(CacheFilePath)) - : null; + private static async Task GetCachedAsync() + { + try + { + return File.Exists(CacheFilePath) + ? JsonConvert.DeserializeObject(await File.ReadAllTextAsync(CacheFilePath)) + : null; + } + catch (Exception e) + { + Log.Error(e, "Failed to read cached clips data from {Path}", CacheFilePath); + return null; + } + } - public async Task GetClipsAsync(bool refreshCache = false) + public async Task GetClipsAsync() { - _cache ??= await GetCachedAsync(); + _clips ??= await GetCachedAsync(); - if (!refreshCache && _cache != null) - return _cache; + if (_clips != null) + return _clips; + + if (!State.IsProcessing) + State = new("", 1, 1); + + _ = Task.Run(RefreshClips); + return null; + } + + public async void RefreshClips() + { + var isRefreshing = RefreshSemaphore.CurrentCount == 0; + await RefreshSemaphore.WaitAsync(); + if (isRefreshing) + return; + + try + { + await RefreshClipsInternal(); + } + catch (Exception e) + { + Log.Error(e, "Failed to refresh clips"); + } + finally + { + RefreshSemaphore.Release(); + } + } - _cache ??= []; + private async Task RefreshClipsInternal() + { + var knownClips = _clips ?? []; - var knownVideoFiles = _cache + var knownVideoFiles = knownClips .SelectMany(c => c.Segments.SelectMany(s => s.VideoFiles)) .Where(f => f != null) .ToDictionary(v => v.FilePath, v => v); - var videoFiles = (await Task.WhenAll(Directory + var files = Directory .GetFiles(_settingsProvider.Settings.ClipsRootPath, "*.mp4", SearchOption.AllDirectories) - .AsParallel() .Select(path => new { Path = path, RegexMatch = FileNameRegex.Match(path) }) .Where(f => f.RegexMatch.Success) - .ToList() - .Select(async f => knownVideoFiles.TryGetValue(f.Path, out var knownVideo) ? knownVideo : await TryParseVideoFileAsync(f.Path, f.RegexMatch)))) - .AsParallel() - .Where(vfi => vfi != null) - .ToList(); + .ToArray(); - var recentClips = GetRecentClips(videoFiles - .Where(vfi => vfi.ClipType == ClipType.Recent).ToList()); - - var clips = videoFiles + var videoFiles = new List(files.Length); + for (var i = 0; i < files.Length; i++) + { + State = new("Analyzing video files...", files.Length, i + 1); + + var file = files[i]; + + if (knownVideoFiles.TryGetValue(file.Path, out var knownVideo)) + videoFiles.Add(knownVideo); + + var parsedFile = await TryParseVideoFileAsync(file.Path, file.RegexMatch); + if (parsedFile != null) + videoFiles.Add(parsedFile); + } + + var recentClips = GetRecentClips(videoFiles.Where(vfi => vfi.ClipType == ClipType.Recent).ToList()); + + var distinctClips = videoFiles .Select(vfi => vfi.EventFolderName) + .Where(f => !string.IsNullOrWhiteSpace(f)) .Distinct() - .AsParallel() - .Where(e => !string.IsNullOrWhiteSpace(e)) - .Select(e => ParseClip(e, videoFiles)) - .Concat(recentClips.AsParallel()) - .OrderByDescending(c => c.StartDate) .ToArray(); + var clips = new List(); + for (var i = 0; i < distinctClips.Length; i++) + { + clips.Add(await ParseClip(distinctClips[i], videoFiles)); + State = new("Reading event data...", distinctClips.Length, i + 1); + } - _cache = clips; - await File.WriteAllTextAsync(CacheFilePath, JsonConvert.SerializeObject(clips)); - return _cache; + _clips = clips + .Concat(recentClips) + .OrderByDescending(c => c.StartDate) + .ToArray(); + + await File.WriteAllTextAsync(CacheFilePath, JsonConvert.SerializeObject(_clips)); } private static IEnumerable GetRecentClips(List recentVideoFiles) @@ -190,7 +242,7 @@ private async Task ParseVideoFileAsync(string path, Match regexMatch) }; } - private static Clip ParseClip(string eventFolderName, IEnumerable videoFiles) + private static async Task ParseClip(string eventFolderName, IEnumerable videoFiles) { var eventVideoFiles = videoFiles .AsParallel() @@ -213,7 +265,7 @@ private static Clip ParseClip(string eventFolderName, IEnumerable vid var eventFolderPath = Path.GetDirectoryName(eventVideoFiles.First().FilePath)!; var expectedEventJsonPath = Path.Combine(eventFolderPath, "event.json"); - var eventInfo = TryReadEvent(expectedEventJsonPath); + var eventInfo = await TryReadEvent(expectedEventJsonPath); var expectedEventThumbnailPath = Path.Combine(eventFolderPath, "thumb.png"); var thumbnailUrl = File.Exists(expectedEventThumbnailPath) @@ -227,14 +279,14 @@ private static Clip ParseClip(string eventFolderName, IEnumerable vid }; } - private static Event TryReadEvent(string path) + private static async Task TryReadEvent(string path) { try { if (!File.Exists(path)) return null; - var json = File.ReadAllText(path); + var json = await File.ReadAllTextAsync(path); return JsonConvert.DeserializeObject(json); } catch (Exception e) diff --git a/src/TeslaCamPlayer.BlazorHosted/Server/Services/Interfaces/IClipsService.cs b/src/TeslaCamPlayer.BlazorHosted/Server/Services/Interfaces/IClipsService.cs index 47f210a..403fe1e 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Server/Services/Interfaces/IClipsService.cs +++ b/src/TeslaCamPlayer.BlazorHosted/Server/Services/Interfaces/IClipsService.cs @@ -4,5 +4,7 @@ namespace TeslaCamPlayer.BlazorHosted.Server.Services.Interfaces; public interface IClipsService { - Task GetClipsAsync(bool refreshCache = false); + Task GetClipsAsync(); + State State { get; } + void RefreshClips(); } \ No newline at end of file diff --git a/src/TeslaCamPlayer.BlazorHosted/Server/TeslaCamPlayer.BlazorHosted.Server.csproj b/src/TeslaCamPlayer.BlazorHosted/Server/TeslaCamPlayer.BlazorHosted.Server.csproj index e0124c5..8756f62 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Server/TeslaCamPlayer.BlazorHosted.Server.csproj +++ b/src/TeslaCamPlayer.BlazorHosted/Server/TeslaCamPlayer.BlazorHosted.Server.csproj @@ -11,8 +11,8 @@ - - + + diff --git a/src/TeslaCamPlayer.BlazorHosted/Shared/Models/Cameras.cs b/src/TeslaCamPlayer.BlazorHosted/Shared/Models/Cameras.cs index 8426794..1983be9 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Shared/Models/Cameras.cs +++ b/src/TeslaCamPlayer.BlazorHosted/Shared/Models/Cameras.cs @@ -1,5 +1,17 @@ namespace TeslaCamPlayer.BlazorHosted.Shared.Models; +/// +/// According to a random post on the internet +/// 0 = front camera +/// 1 = fisheye +/// 2 = narrow +/// 3 = left repeater +/// 4 = right repeater +/// 5 = left B pillar +/// 6 = right B pillar +/// 7 = rear +/// 8 = cabin +/// public enum Cameras { Unknown = -1, Front = 0, diff --git a/src/TeslaCamPlayer.BlazorHosted/Shared/Models/Event.cs b/src/TeslaCamPlayer.BlazorHosted/Shared/Models/Event.cs index cce2f1f..610d6c5 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Shared/Models/Event.cs +++ b/src/TeslaCamPlayer.BlazorHosted/Shared/Models/Event.cs @@ -2,18 +2,6 @@ namespace TeslaCamPlayer.BlazorHosted.Shared.Models; -/// -/// According to a random post on the internet -/// 0 = front camera -/// 1 = fisheye -/// 2 = narrow -/// 3 = left repeater -/// 4 = right repeater -/// 5 = left B pillar -/// 6 = right B pillar -/// 7 = rear -/// 8 = cabin -/// public class Event { [JsonProperty("timestamp")] diff --git a/src/TeslaCamPlayer.BlazorHosted/Shared/Models/State.cs b/src/TeslaCamPlayer.BlazorHosted/Shared/Models/State.cs new file mode 100644 index 0000000..c2ec2dd --- /dev/null +++ b/src/TeslaCamPlayer.BlazorHosted/Shared/Models/State.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace TeslaCamPlayer.BlazorHosted.Shared.Models; + +public class State +{ + [JsonIgnore] + public bool IsProcessing => ProcessedFiles < ProcessingFiles; + + [JsonIgnore] + public int Percentage => (int)((double)ProcessedFiles / ProcessingFiles * 100); + + public string Description { get; set; } + + public int ProcessingFiles { get; set; } + + public int ProcessedFiles { get; set; } + + public State(string description, int processingFiles, int processedFiles) + { + Description = description; + ProcessingFiles = processingFiles; + ProcessedFiles = processedFiles; + } + + public State() + { + } +} \ No newline at end of file