fixes to time conversion and play head ux + improvements to file scanner
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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" }},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user