From b7c00323e840654f0177b667c7739de2ae978815 Mon Sep 17 00:00:00 2001 From: Amatsugu Date: Tue, 10 Mar 2026 22:07:04 -0400 Subject: [PATCH] fixes to time conversion and play head ux + improvements to file scanner --- AZKiServer/Controllers/MediaController.cs | 1 + AZKiServer/Services/AZKiRpcService.cs | 6 +- AZKiServer/Services/FileScannerService.cs | 28 ++++++--- AZKiServer/Services/MediaService.cs | 5 +- client/assets/styling/player.scss | 71 ++++++++++++++++++++-- client/src/components/playback/timeline.rs | 50 +++++++++------ client/src/components/playback/viewport.rs | 25 ++++++-- 7 files changed, 141 insertions(+), 45 deletions(-) diff --git a/AZKiServer/Controllers/MediaController.cs b/AZKiServer/Controllers/MediaController.cs index 4dca4fb..08c1d4c 100644 --- a/AZKiServer/Controllers/MediaController.cs +++ b/AZKiServer/Controllers/MediaController.cs @@ -11,6 +11,7 @@ public class MediaController(MediaService mediaService, IConfiguration configura { private readonly string _basePath = configuration["SCAN_LOCATION"] ?? throw new NullReferenceException("SCAN_LOCATION is not set"); + [HttpGet("v/{id}")] [ResponseCache(Duration = int.MaxValue)] public async Task VideoAsync(ObjectId id) diff --git a/AZKiServer/Services/AZKiRpcService.cs b/AZKiServer/Services/AZKiRpcService.cs index b5118be..b7866fb 100644 --- a/AZKiServer/Services/AZKiRpcService.cs +++ b/AZKiServer/Services/AZKiRpcService.cs @@ -21,8 +21,8 @@ public class AZKiRpcService(MediaService mediaService) : RPC.AZKi.AZKiBase public override async Task GetMediaPlayback(MediaPlaybackRequest request, ServerCallContext context) { - var from = request.Date.ToDateTime().Date; - var to = request.Date.ToDateTime().Date.AddDays(1); + var from = request.Date.ToDateTime().ToLocalTime().Date.AddDays(1); + var to = request.Date.ToDateTime().ToLocalTime().Date.AddDays(2); var items = await mediaService.GetEntriesInRangeAsync(Models.MediaType.All, from, to); var channels = items.GroupBy(i => i.CameraId).Select(c => { @@ -38,7 +38,7 @@ public class AZKiRpcService(MediaService mediaService) : RPC.AZKi.AZKiBase }); var playback = new PlaybackInfo { - Date = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(from), + Date = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(from.ToUniversalTime()), }; playback.Channels.AddRange(channels.OrderBy(c => c.CameraId)); return playback; diff --git a/AZKiServer/Services/FileScannerService.cs b/AZKiServer/Services/FileScannerService.cs index 15fed74..88151df 100644 --- a/AZKiServer/Services/FileScannerService.cs +++ b/AZKiServer/Services/FileScannerService.cs @@ -12,6 +12,7 @@ using SixLabors.ImageSharp; using System.Collections.Frozen; using System.IO; +using System.Threading; namespace AZKiServer.Services; @@ -61,8 +62,7 @@ public class FileScannerService(MediaService mediaService, IConfiguration config var upToDateFiles = existingFiles.Where(e => e.Value == MediaEntry.CUR_VERSION) .Select(e => e.Key) .ToFrozenSet(); - var entriesToDelete = existingFiles.Keys.Except(filesRelative).ToArray(); - await mediaService.DeleteEntriesBulkAsync(files, cancellationToken); + //await DeleteEntriesWithoutFilesAsync(existingFiles, filesRelative, cancellationToken); var filesToProcess = filesRelative.Where(f => !upToDateFiles.Contains(f)).ToArray(); var total = 0; var prog = 0; @@ -70,7 +70,7 @@ public class FileScannerService(MediaService mediaService, IConfiguration config { prog += chunk.Length; total += await ScanFileChunkAsync(path, chunk, existingFiles, cancellationToken); - logger.LogInformation("Added {updated} of {count} [{percentage}%]", total, filesToProcess.Length, Math.Round(((float)prog/ filesToProcess.Length) * 100)); + logger.LogInformation("Added {updated} of {count} [{percentage}%]", total, filesToProcess.Length, Math.Round(((float)prog / filesToProcess.Length) * 100)); } } catch (Exception ex) @@ -79,18 +79,28 @@ public class FileScannerService(MediaService mediaService, IConfiguration config } } + private async Task DeleteEntriesWithoutFilesAsync(FrozenDictionary existingFiles, string[] filesRelative, CancellationToken cancellationToken = default) + { + var entriesToDelete = existingFiles.Keys.Except(filesRelative).ToArray(); + if (entriesToDelete.Length == 0) + return; + logger.LogInformation("Deleting {count} entires that no longer exist on file system.", entriesToDelete.Length); + await mediaService.DeleteEntriesBulkAsync(entriesToDelete, cancellationToken); + logger.LogInformation("{count} entries deleted.", entriesToDelete.Length); + } + private async Task ScanFileChunkAsync(string path, IEnumerable files, FrozenDictionary existingFiles, CancellationToken cancellationToken = default) { var entries = new List(); var upgradeEntries = new List(); - foreach (var filePath in files) + foreach (var relativePath in files) { if (cancellationToken.IsCancellationRequested) break; - var relativePath = Path.GetRelativePath(path, filePath); if (relativePath[0] == '.') //Ignore hidden folders continue; + var absolutePath = Path.Combine(path, relativePath); var isUpgrade = false; if (existingFiles.TryGetValue(relativePath, out var version)) { @@ -98,10 +108,10 @@ public class FileScannerService(MediaService mediaService, IConfiguration config continue; isUpgrade = true; } - var metadata = ReadMetadata(filePath); + var metadata = ReadMetadata(absolutePath); if (metadata.HasError) { - logger.LogError(metadata.Error.GetException(), $"Failed to get metadata for file: {filePath}"); + logger.LogError(metadata.Error.GetException(), "Failed to get metadata for file: {filePath}", relativePath); continue; } var entry = MediaEntry.Parse(relativePath, metadata); @@ -137,7 +147,7 @@ public class FileScannerService(MediaService mediaService, IConfiguration config { ".jpg" or ".png" or ".jpeg" => ReadImageMetadata(filePath), ".mp4" => ReadVideoMetadata(filePath), - _ => throw new NotSupportedException($"Files of type {ext} are not supported") + _ => new Error($"Files of type {ext} are not supported") }; } @@ -164,7 +174,7 @@ public class FileScannerService(MediaService mediaService, IConfiguration config return new Error($"Could not find a primirary video stream in file."); return new MediaMetadata((int)info.Duration.TotalSeconds, info.PrimaryVideoStream.Height, info.PrimaryVideoStream.Width); } - catch(Exception ex) + catch (Exception ex) { return ex; } diff --git a/AZKiServer/Services/MediaService.cs b/AZKiServer/Services/MediaService.cs index d60c7da..ae1ebc9 100644 --- a/AZKiServer/Services/MediaService.cs +++ b/AZKiServer/Services/MediaService.cs @@ -37,7 +37,10 @@ public class MediaService(IMongoDatabase db) /// public async Task DeleteEntriesBulkAsync(IEnumerable files, CancellationToken cancellationToken = default) { - await _entries.DeleteManyAsync(e => files.Contains(e.Filepath), cancellationToken); + foreach (var file in files) + { + await _entries.DeleteOneAsync(e => e.Filepath == file, cancellationToken); + } } public async Task> GetEntriesInRangeAsync(MediaType mediaType, DateTime from, DateTime to, CancellationToken cancellationToken = default) diff --git a/client/assets/styling/player.scss b/client/assets/styling/player.scss index bd854b5..e393eb0 100644 --- a/client/assets/styling/player.scss +++ b/client/assets/styling/player.scss @@ -18,12 +18,21 @@ display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(3, 1fr); + aspect-ratio: 4/3; + width: auto; + height: 100%; overflow: hidden; video { width: 100%; height: auto; image-rendering: smooth; + aspect-ratio: 4/3; + } + + .emptyVideo { + width: 100%; + aspect-ratio: 16/9; } } @@ -40,23 +49,73 @@ gap: 1px; } - #playhead { + #playControls { display: grid; width: 100%; - position: relative; grid-template-columns: $labelWidth 1fr; grid-template-areas: "Label Input"; - input { + background-color: $featureBackroundDark; + border-bottom: 1px solid $mainAccent; + + .inputs { grid-area: Input; - width: 100%; + position: relative; + + input { + width: 100%; + height: 100%; + padding: 0; + margin: 0; + border: 0; + appearance: none; + background: transparent; + outline: none; + /* opacity: 0; */ + + &::-webkit-slider-thumb { + background-color: transparent; + appearance: none; + display: block; + width: 1px; + height: 10px; + } + &::-webkit-slider-runnable-track { + border: 0; + padding: 0; + margin: 0; + } + } } - .indicator { + .playHead { width: 1px; background-color: $secondaryAccentLight; - height: 100%; + top: 0; + height: 300px; position: absolute; pointer-events: none; + z-index: 2; + &::before { + display: block; + content: ""; + border: 11px solid transparent; + border-top: 20px solid $secondaryAccentLight; + transform: translateX(-11px); + } + } + + .timelineMarkers { + left: 0; + top: 0; + height: 100%; + width: 100%; + position: absolute; + pointer-events: none; + .marker { + position: absolute; + border-left: 1px dashed #fff; + height: 100%; + } } } diff --git a/client/src/components/playback/timeline.rs b/client/src/components/playback/timeline.rs index a61a16d..fe5d7d8 100644 --- a/client/src/components/playback/timeline.rs +++ b/client/src/components/playback/timeline.rs @@ -1,4 +1,4 @@ -use chrono::DateTime; +use chrono::{DateTime, Local, TimeZone}; use dioxus::prelude::*; use crate::rpc::azki::{MediaChannel, MediaEntry, PlaybackInfo}; @@ -24,32 +24,39 @@ pub fn Timeline(playback_info: Option, zoom: Signal, time: Si } #[component] -fn PlayHead(start: i64, time: Signal, zoom: Signal) -> Element +fn PlayControls(start: i64, time: Signal, zoom: Signal) -> Element { let time_string = get_time_string(start, time()); let p = (time() as f32 / SEC_PER_DAY) * 100.0; rsx! { div { - id: "playhead", - input { - style: "width: calc({zoom() * 100.0}%);", - type: "range", - value: "{time()}", - max: "{SEC_PER_DAY}", - oninput: move |e|{ - if let Ok(t) = e.value().parse() { - time.set(t); - } - } - }, + id: "playControls", + style: "width: calc({zoom() * 100.0}%);", div{ class: "time", "{time_string}" } div{ - class: "indicator", - style: "left: {p}%" + class: "inputs", + input { + type: "range", + value: "{time()}", + max: "{SEC_PER_DAY}", + oninput: move |e|{ + if let Ok(t) = e.value().parse() { + time.set(t); + } + } + }, + div{ + class: "timelineMarkers", + {(0..23).map(|i| rsx!{ div{ class: "marker", style: "left: {((i + 1) as f32/24.0) * 100.0}%;" } })} + } + div{ + class: "playHead", + style: "left: {p}%" + } } } } @@ -61,7 +68,7 @@ fn TrackList(channels: Vec, start: i64, zoom: Signal, time: S rsx! { div { id: "tracklist", - PlayHead { start, time, zoom } + PlayControls { start, time, zoom } {channels.iter().map(|c|rsx!{ TimelineTrack { channel: c.clone(), start, zoom } })} @@ -102,7 +109,10 @@ fn Clip(media: MediaEntry, start: i64) -> Element { let meta = media.metadata.unwrap(); let date = media.date.unwrap(); - let timestamp = date.seconds - start; + // let timestamp = date.seconds - start; + + let utc_date = DateTime::from_timestamp_secs(date.seconds).expect("Failed to convert clip date to utc"); + let timestamp = utc_date.timestamp() - start; let duration = (meta.duration as f32 / SEC_PER_DAY) * 100.0; let offset = (timestamp as f32 / SEC_PER_DAY) * 100.0; rsx! { @@ -116,6 +126,6 @@ fn Clip(media: MediaEntry, start: i64) -> Element fn get_time_string(start: i64, time: i64) -> String { let date_time = DateTime::from_timestamp_secs(start + time).expect("Failed to convert time"); - let local = date_time.naive_local(); - return local.to_string(); + let local: DateTime = DateTime::from(date_time); + return format!("{}", local.format("%Y-%m-%d %H:%M")); } diff --git a/client/src/components/playback/viewport.rs b/client/src/components/playback/viewport.rs index dc34ad4..b30e180 100644 --- a/client/src/components/playback/viewport.rs +++ b/client/src/components/playback/viewport.rs @@ -1,3 +1,4 @@ +use chrono::DateTime; use dioxus::prelude::*; use crate::{ @@ -25,6 +26,7 @@ fn ViewportFull(playback_info: PlaybackInfo, time: Signal) -> Element id: "viewport", {playback_info.channels.iter().map(|c| { let entry = get_entry(c, start, cur_time); + rsx!{ Video{ entry } } @@ -52,6 +54,7 @@ fn get_entry(channel: &MediaChannel, start: i64, time: i64) -> Option= time; @@ -64,13 +67,23 @@ fn Video(entry: Option) -> Element { match entry { - Some(entry) => rsx! { - video{ - autoplay: "true", - muted: "true", - src: "{MEDIA_HOST}/m/v/{entry.id}" + Some(entry) => + { + info!("date: {}, name: {}", entry.date.unwrap().to_string(), entry.file_path); + rsx! { + video{ + autoplay: "true", + muted: "true", + controls: true, + src: "{MEDIA_HOST}/m/v/{entry.id}" + } + } + } + None => rsx! { + div { + class: "emptyVideo", + "No Video" } }, - None => rsx! { div{ "No Video" }}, } }