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");
|
private readonly string _basePath = configuration["SCAN_LOCATION"] ?? throw new NullReferenceException("SCAN_LOCATION is not set");
|
||||||
|
|
||||||
|
|
||||||
[HttpGet("v/{id}")]
|
[HttpGet("v/{id}")]
|
||||||
[ResponseCache(Duration = int.MaxValue)]
|
[ResponseCache(Duration = int.MaxValue)]
|
||||||
public async Task<IActionResult> VideoAsync(ObjectId id)
|
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)
|
public override async Task<PlaybackInfo> GetMediaPlayback(MediaPlaybackRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
var from = request.Date.ToDateTime().Date;
|
var from = request.Date.ToDateTime().ToLocalTime().Date.AddDays(1);
|
||||||
var to = request.Date.ToDateTime().Date.AddDays(1);
|
var to = request.Date.ToDateTime().ToLocalTime().Date.AddDays(2);
|
||||||
var items = await mediaService.GetEntriesInRangeAsync(Models.MediaType.All, from, to);
|
var items = await mediaService.GetEntriesInRangeAsync(Models.MediaType.All, from, to);
|
||||||
var channels = items.GroupBy(i => i.CameraId).Select(c =>
|
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
|
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));
|
playback.Channels.AddRange(channels.OrderBy(c => c.CameraId));
|
||||||
return playback;
|
return playback;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using SixLabors.ImageSharp;
|
|||||||
|
|
||||||
using System.Collections.Frozen;
|
using System.Collections.Frozen;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
namespace AZKiServer.Services;
|
namespace AZKiServer.Services;
|
||||||
|
|
||||||
@@ -61,8 +62,7 @@ public class FileScannerService(MediaService mediaService, IConfiguration config
|
|||||||
var upToDateFiles = existingFiles.Where(e => e.Value == MediaEntry.CUR_VERSION)
|
var upToDateFiles = existingFiles.Where(e => e.Value == MediaEntry.CUR_VERSION)
|
||||||
.Select(e => e.Key)
|
.Select(e => e.Key)
|
||||||
.ToFrozenSet();
|
.ToFrozenSet();
|
||||||
var entriesToDelete = existingFiles.Keys.Except(filesRelative).ToArray();
|
//await DeleteEntriesWithoutFilesAsync(existingFiles, filesRelative, cancellationToken);
|
||||||
await mediaService.DeleteEntriesBulkAsync(files, cancellationToken);
|
|
||||||
var filesToProcess = filesRelative.Where(f => !upToDateFiles.Contains(f)).ToArray();
|
var filesToProcess = filesRelative.Where(f => !upToDateFiles.Contains(f)).ToArray();
|
||||||
var total = 0;
|
var total = 0;
|
||||||
var prog = 0;
|
var prog = 0;
|
||||||
@@ -70,7 +70,7 @@ public class FileScannerService(MediaService mediaService, IConfiguration config
|
|||||||
{
|
{
|
||||||
prog += chunk.Length;
|
prog += chunk.Length;
|
||||||
total += await ScanFileChunkAsync(path, chunk, existingFiles, cancellationToken);
|
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)
|
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)
|
private async Task<int> ScanFileChunkAsync(string path, IEnumerable<string> files, FrozenDictionary<string, int> existingFiles, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var entries = new List<MediaEntry>();
|
var entries = new List<MediaEntry>();
|
||||||
var upgradeEntries = new List<MediaEntry>();
|
var upgradeEntries = new List<MediaEntry>();
|
||||||
foreach (var filePath in files)
|
foreach (var relativePath in files)
|
||||||
{
|
{
|
||||||
if (cancellationToken.IsCancellationRequested)
|
if (cancellationToken.IsCancellationRequested)
|
||||||
break;
|
break;
|
||||||
var relativePath = Path.GetRelativePath(path, filePath);
|
|
||||||
if (relativePath[0] == '.') //Ignore hidden folders
|
if (relativePath[0] == '.') //Ignore hidden folders
|
||||||
continue;
|
continue;
|
||||||
|
var absolutePath = Path.Combine(path, relativePath);
|
||||||
var isUpgrade = false;
|
var isUpgrade = false;
|
||||||
if (existingFiles.TryGetValue(relativePath, out var version))
|
if (existingFiles.TryGetValue(relativePath, out var version))
|
||||||
{
|
{
|
||||||
@@ -98,10 +108,10 @@ public class FileScannerService(MediaService mediaService, IConfiguration config
|
|||||||
continue;
|
continue;
|
||||||
isUpgrade = true;
|
isUpgrade = true;
|
||||||
}
|
}
|
||||||
var metadata = ReadMetadata(filePath);
|
var metadata = ReadMetadata(absolutePath);
|
||||||
if (metadata.HasError)
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
var entry = MediaEntry.Parse(relativePath, metadata);
|
var entry = MediaEntry.Parse(relativePath, metadata);
|
||||||
@@ -137,7 +147,7 @@ public class FileScannerService(MediaService mediaService, IConfiguration config
|
|||||||
{
|
{
|
||||||
".jpg" or ".png" or ".jpeg" => ReadImageMetadata(filePath),
|
".jpg" or ".png" or ".jpeg" => ReadImageMetadata(filePath),
|
||||||
".mp4" => ReadVideoMetadata(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 Error($"Could not find a primirary video stream in file.");
|
||||||
return new MediaMetadata((int)info.Duration.TotalSeconds, info.PrimaryVideoStream.Height, info.PrimaryVideoStream.Width);
|
return new MediaMetadata((int)info.Duration.TotalSeconds, info.PrimaryVideoStream.Height, info.PrimaryVideoStream.Width);
|
||||||
}
|
}
|
||||||
catch(Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return ex;
|
return ex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ public class MediaService(IMongoDatabase db)
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public async Task DeleteEntriesBulkAsync(IEnumerable<string> files, CancellationToken cancellationToken = default)
|
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)
|
public async Task<List<MediaEntry>> GetEntriesInRangeAsync(MediaType mediaType, DateTime from, DateTime to, CancellationToken cancellationToken = default)
|
||||||
|
|||||||
@@ -18,12 +18,21 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
grid-template-rows: repeat(3, 1fr);
|
grid-template-rows: repeat(3, 1fr);
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
width: auto;
|
||||||
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
video {
|
video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
image-rendering: smooth;
|
image-rendering: smooth;
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyVideo {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,23 +49,73 @@
|
|||||||
gap: 1px;
|
gap: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#playhead {
|
#playControls {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
|
||||||
grid-template-columns: $labelWidth 1fr;
|
grid-template-columns: $labelWidth 1fr;
|
||||||
grid-template-areas: "Label Input";
|
grid-template-areas: "Label Input";
|
||||||
input {
|
background-color: $featureBackroundDark;
|
||||||
|
border-bottom: 1px solid $mainAccent;
|
||||||
|
|
||||||
|
.inputs {
|
||||||
grid-area: Input;
|
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;
|
width: 1px;
|
||||||
background-color: $secondaryAccentLight;
|
background-color: $secondaryAccentLight;
|
||||||
height: 100%;
|
top: 0;
|
||||||
|
height: 300px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
pointer-events: none;
|
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 dioxus::prelude::*;
|
||||||
|
|
||||||
use crate::rpc::azki::{MediaChannel, MediaEntry, PlaybackInfo};
|
use crate::rpc::azki::{MediaChannel, MediaEntry, PlaybackInfo};
|
||||||
@@ -24,32 +24,39 @@ pub fn Timeline(playback_info: Option<PlaybackInfo>, zoom: Signal<f32>, time: Si
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[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 time_string = get_time_string(start, time());
|
||||||
|
|
||||||
let p = (time() as f32 / SEC_PER_DAY) * 100.0;
|
let p = (time() as f32 / SEC_PER_DAY) * 100.0;
|
||||||
rsx! {
|
rsx! {
|
||||||
div {
|
div {
|
||||||
id: "playhead",
|
id: "playControls",
|
||||||
input {
|
style: "width: calc({zoom() * 100.0}%);",
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
div{
|
div{
|
||||||
class: "time",
|
class: "time",
|
||||||
"{time_string}"
|
"{time_string}"
|
||||||
}
|
}
|
||||||
div{
|
div{
|
||||||
class: "indicator",
|
class: "inputs",
|
||||||
style: "left: {p}%"
|
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! {
|
rsx! {
|
||||||
div {
|
div {
|
||||||
id: "tracklist",
|
id: "tracklist",
|
||||||
PlayHead { start, time, zoom }
|
PlayControls { start, time, zoom }
|
||||||
{channels.iter().map(|c|rsx!{
|
{channels.iter().map(|c|rsx!{
|
||||||
TimelineTrack { channel: c.clone(), start, zoom }
|
TimelineTrack { channel: c.clone(), start, zoom }
|
||||||
})}
|
})}
|
||||||
@@ -102,7 +109,10 @@ fn Clip(media: MediaEntry, start: i64) -> Element
|
|||||||
{
|
{
|
||||||
let meta = media.metadata.unwrap();
|
let meta = media.metadata.unwrap();
|
||||||
let date = media.date.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 duration = (meta.duration as f32 / SEC_PER_DAY) * 100.0;
|
||||||
let offset = (timestamp as f32 / SEC_PER_DAY) * 100.0;
|
let offset = (timestamp as f32 / SEC_PER_DAY) * 100.0;
|
||||||
rsx! {
|
rsx! {
|
||||||
@@ -116,6 +126,6 @@ fn Clip(media: MediaEntry, start: i64) -> Element
|
|||||||
fn get_time_string(start: i64, time: i64) -> String
|
fn get_time_string(start: i64, time: i64) -> String
|
||||||
{
|
{
|
||||||
let date_time = DateTime::from_timestamp_secs(start + time).expect("Failed to convert time");
|
let date_time = DateTime::from_timestamp_secs(start + time).expect("Failed to convert time");
|
||||||
let local = date_time.naive_local();
|
let local: DateTime<Local> = DateTime::from(date_time);
|
||||||
return local.to_string();
|
return format!("{}", local.format("%Y-%m-%d %H:%M"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use chrono::DateTime;
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -25,6 +26,7 @@ fn ViewportFull(playback_info: PlaybackInfo, time: Signal<i64>) -> Element
|
|||||||
id: "viewport",
|
id: "viewport",
|
||||||
{playback_info.channels.iter().map(|c| {
|
{playback_info.channels.iter().map(|c| {
|
||||||
let entry = get_entry(c, start, cur_time);
|
let entry = get_entry(c, start, cur_time);
|
||||||
|
|
||||||
rsx!{
|
rsx!{
|
||||||
Video{ entry }
|
Video{ entry }
|
||||||
}
|
}
|
||||||
@@ -52,6 +54,7 @@ fn get_entry(channel: &MediaChannel, start: i64, time: i64) -> Option<MediaEntry
|
|||||||
.find(|e| {
|
.find(|e| {
|
||||||
let meta = e.metadata.unwrap();
|
let meta = e.metadata.unwrap();
|
||||||
let date = e.date.unwrap();
|
let date = e.date.unwrap();
|
||||||
|
|
||||||
let timestamp = date.seconds - start;
|
let timestamp = date.seconds - start;
|
||||||
|
|
||||||
return timestamp <= time && timestamp + meta.duration as i64 >= time;
|
return timestamp <= time && timestamp + meta.duration as i64 >= time;
|
||||||
@@ -64,13 +67,23 @@ fn Video(entry: Option<MediaEntry>) -> Element
|
|||||||
{
|
{
|
||||||
match entry
|
match entry
|
||||||
{
|
{
|
||||||
Some(entry) => rsx! {
|
Some(entry) =>
|
||||||
video{
|
{
|
||||||
autoplay: "true",
|
info!("date: {}, name: {}", entry.date.unwrap().to_string(), entry.file_path);
|
||||||
muted: "true",
|
rsx! {
|
||||||
src: "{MEDIA_HOST}/m/v/{entry.id}"
|
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