fixes to time conversion and play head ux + improvements to file scanner

This commit is contained in:
2026-03-10 22:07:04 -04:00
parent 951e285f81
commit b7c00323e8
7 changed files with 141 additions and 45 deletions

View File

@@ -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<IActionResult> VideoAsync(ObjectId id)

View File

@@ -21,8 +21,8 @@ public class AZKiRpcService(MediaService mediaService) : RPC.AZKi.AZKiBase
public override async Task<PlaybackInfo> 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;

View File

@@ -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<string, int> 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<int> ScanFileChunkAsync(string path, IEnumerable<string> files, FrozenDictionary<string, int> existingFiles, CancellationToken cancellationToken = default)
{
var entries = new List<MediaEntry>();
var upgradeEntries = new List<MediaEntry>();
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;
}

View File

@@ -37,7 +37,10 @@ public class MediaService(IMongoDatabase db)
/// <returns></returns>
public async Task DeleteEntriesBulkAsync(IEnumerable<string> 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<List<MediaEntry>> GetEntriesInRangeAsync(MediaType mediaType, DateTime from, DateTime to, CancellationToken cancellationToken = default)

View File

@@ -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%;
}
}
}

View File

@@ -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<PlaybackInfo>, zoom: Signal<f32>, time: Si
}
#[component]
fn PlayHead(start: i64, time: Signal<i64>, zoom: Signal<f32>) -> Element
fn PlayControls(start: i64, time: Signal<i64>, zoom: Signal<f32>) -> 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<MediaChannel>, start: i64, zoom: Signal<f32>, 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<Local> = DateTime::from(date_time);
return format!("{}", local.format("%Y-%m-%d %H:%M"));
}

View File

@@ -1,3 +1,4 @@
use chrono::DateTime;
use dioxus::prelude::*;
use crate::{
@@ -25,6 +26,7 @@ fn ViewportFull(playback_info: PlaybackInfo, time: Signal<i64>) -> 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<MediaEntry
.find(|e| {
let meta = e.metadata.unwrap();
let date = e.date.unwrap();
let timestamp = date.seconds - start;
return timestamp <= time && timestamp + meta.duration as i64 >= time;
@@ -64,13 +67,23 @@ fn Video(entry: Option<MediaEntry>) -> 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" }},
}
}