feat: Add chain jobs and complete downloader job

This commit is contained in:
Guillermo Marcel 2025-02-15 15:55:29 -03:00
parent be89bddf1b
commit 8fbe439ed4
13 changed files with 373 additions and 75 deletions

View File

@ -1,7 +1,10 @@
using AutoScan.Jobs;
using AutoScan.Listener;
using AutoScan.Options;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Quartz;
using Quartz.Impl.Matchers;
namespace AutoScan;
@ -10,12 +13,14 @@ public class AutoScanApp
private readonly AutoScanOptions _options;
private readonly ILogger<AutoScanApp> _logger;
private readonly IScheduler _scheduler;
private readonly IChainerListenerFactory _chainerListenerFactory;
public AutoScanApp(IOptions<AutoScanOptions> options, ILogger<AutoScanApp> logger, IScheduler scheduler)
public AutoScanApp(IOptions<AutoScanOptions> options, ILogger<AutoScanApp> logger, IScheduler scheduler, IChainerListenerFactory chainerListenerFactory)
{
_options = options.Value;
_logger = logger;
_scheduler = scheduler;
_chainerListenerFactory = chainerListenerFactory;
}
public async Task Run(CancellationToken cancellationToken)
@ -24,25 +29,41 @@ public class AutoScanApp
var at = DateTime.Now.AddMinutes(1).ToString("HH:mm");
var cron = CronFromAt(at);
//var cron = CronFromAt(_options.At);
_logger.LogInformation("Waiting for next scan at {At} [{cron}].", at, cron);
await _scheduler.Start(cancellationToken);
_logger.LogInformation("Scheduler started successfully!");
_logger.LogDebug("Scheduler started successfully!");
// define the job and tie it to our HelloJob class
IJobDetail job = JobBuilder.Create<ScanJob>()
.WithIdentity("job1", "group1")
const string group = "ScanGroup";
IJobDetail downloaderJob = JobBuilder.Create<DownloaderJob>()
.WithIdentity("downloader", group)
.Build();
IJobDetail scannerJob = JobBuilder.Create<ScannerJob>()
.WithIdentity("scanner", group)
.StoreDurably(true)
.Build();
ITrigger trigger = TriggerBuilder.Create()
.WithIdentity("trigger1", "group1")
.WithIdentity("trigger1", group)
.WithCronSchedule(cron)
.StartNow()
.Build();
await _scheduler.ScheduleJob(job, trigger, cancellationToken);
_logger.LogInformation("Scheduled job successfully!");
var chainer = _chainerListenerFactory.CreateChainerListener("Scan Chainer");
chainer.AddJobChainLink(downloaderJob.Key, scannerJob.Key);
_scheduler.ListenerManager.AddJobListener(chainer, GroupMatcher<JobKey>.GroupEquals(group));
await _scheduler.ScheduleJob(downloaderJob, trigger, cancellationToken);
await _scheduler.AddJob(scannerJob, false, true, cancellationToken);
_logger.LogDebug("Scheduled job successfully!");
}
private string CronFromAt(string at)
{
var parts = at.Split(':');

View File

@ -1,3 +1,4 @@
using AutoScan.Listener;
using CasaBotApp;
using Microsoft.Extensions.DependencyInjection;
using Quartz;
@ -16,6 +17,7 @@ public static class DependencyInjectionExtensions
{
q.UseMicrosoftDependencyInjectionJobFactory();
});
services.AddTransient<IChainerListenerFactory, ChainerListenerFactory>();
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

View File

@ -0,0 +1,109 @@
using AutoScan.Options;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Quartz;
namespace AutoScan.Jobs;
public class DownloaderJob : IJob
{
private readonly ILogger<DownloaderJob> _logger;
private readonly AutoScanOptions _options;
private readonly ShinobiConnector _shinobiConnector;
public DownloaderJob(ILogger<DownloaderJob> logger, IOptionsSnapshot<AutoScanOptions> options, ShinobiConnector shinobiConnector)
{
_logger = logger;
_options = options.Value;
_shinobiConnector = shinobiConnector;
}
public async Task Execute(IJobExecutionContext context)
{
_logger.LogInformation("Scheduled scan executed at {At}", DateTime.Now);
if (_options.MediaFolder is null)
{
_logger.LogError("MediaFolder is not set in options!");
context.Result = new JobResult()
{
Status = JobResultStatus.JobFailed,
Result = "MediaFolder is not set in options!"
};
return;
}
//create from variable with the datetime of last night with the variables in options.From (23:00) and options.FromDayBefore (true) [yesterday]
//for example, if options.From is 23:00 and options.FromDayBefore is true, from should be yesterday at 23:00
var now = DateTime.Now;
var minutes = _options.From.Split(":")[1];
var hours = _options.From.Split(":")[0];
var from = new DateTime(now.Year, now.Month, now.Day, int.Parse(hours), int.Parse(minutes), 0);
if(_options.FromDayBefore)
from = from.AddDays(-1);
//create to variable with the datetime of last night with the variables in options.To (1:00) [today]
//for example, if options.To is 1:00, to should be today at 1:00
minutes = _options.To.Split(":")[1];
hours = _options.To.Split(":")[0];
var to = new DateTime(now.Year, now.Month, now.Day, int.Parse(hours), int.Parse(minutes), 0);
_logger.LogInformation("Fetching videos from {From} to {To}", from, to);
var videos = await _shinobiConnector.FetchMonitorVideosBetween(from, to);
//if the amount of videos is greater than the max amount in options, log a warning
if (_options.MaxAmount > 0 && videos.Count > _options.MaxAmount)
{
_logger.LogWarning("Amount of videos fetched is greater than the max amount in options ({MaxAmount})", _options.MaxAmount);
videos = videos.Take(_options.MaxAmount).ToList();
}
CleanMediaFolder();
//download each video to the media folder
foreach (var video in videos)
{
_logger.LogDebug("Downloading video {Filename}", video.filename);
await _shinobiConnector.DownloadMonitorVideo(video, _options.MediaFolder);
}
context.Result = new JobResult()
{
Status = JobResultStatus.JobSucceeded,
Result = $"Downloaded {videos.Count} videos to {_options.MediaFolder}"
};
}
private void CleanMediaFolder()
{
if (_options.MediaFolder is not null)
{
Directory.CreateDirectory(Path.GetDirectoryName(_options.MediaFolder)!);
foreach (var file in Directory.GetFiles(_options.MediaFolder))
{
File.Delete(file);
}
}
if(_options.Scanner?.DetectionFolder is not null)
{
Directory.CreateDirectory(Path.GetDirectoryName(_options.Scanner.DetectionFolder)!);
foreach (var file in Directory.GetFiles(_options.Scanner.DetectionFolder))
{
File.Delete(file);
}
}
if(_options.Screenshot?.Folder is not null)
{
Directory.CreateDirectory(Path.GetDirectoryName(_options.Screenshot.Folder)!);
foreach (var file in Directory.GetFiles(_options.Screenshot.Folder))
{
File.Delete(file);
}
}
}
}

View File

@ -0,0 +1,13 @@
namespace AutoScan.Jobs;
public class JobResult
{
public JobResultStatus Status { get; set; }
public object? Result { get; set; }
}
public enum JobResultStatus
{
JobFailed,
JobSucceeded
}

View File

@ -0,0 +1,18 @@
using Microsoft.Extensions.Logging;
using Quartz;
namespace AutoScan.Jobs;
public class ScannerJob : IJob
{
private readonly ILogger<ScannerJob> _logger;
public ScannerJob(ILogger<ScannerJob> logger)
{
_logger = logger;
}
public Task Execute(IJobExecutionContext context)
{
_logger.LogWarning("ScannerJob is not implemented yet!");
return Task.CompletedTask;
}
}

View File

@ -0,0 +1,74 @@
using AutoScan.Jobs;
using Microsoft.Extensions.Logging;
using Quartz;
using Quartz.Listener;
namespace AutoScan.Listener;
public class ChainerListener : JobListenerSupport
{
private readonly ILogger<ChainerListener> _logger;
private readonly Dictionary<JobKey, JobKey> _chainLinks;
public ChainerListener(string name, ILogger<ChainerListener> logger)
{
_logger = logger;
Name = name ?? throw new ArgumentException("Listener name cannot be null!");
_chainLinks = new Dictionary<JobKey, JobKey>();
}
public override string Name { get; }
public void AddJobChainLink(JobKey firstJob, JobKey secondJob)
{
if (firstJob == null || secondJob == null)
{
throw new ArgumentException("Key cannot be null!");
}
if (firstJob.Name == null || secondJob.Name == null)
{
throw new ArgumentException("Key cannot have a null name!");
}
_chainLinks.Add(firstJob, secondJob);
}
public override async Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException,
CancellationToken cancellationToken = default)
{
if(context.Result is JobResult { Status: JobResultStatus.JobFailed })
{
_logger.LogError("There was an error in job {JobKey}. Chain will not continue", context.JobDetail.Key);
return;
}
if (jobException is not null)
{
_logger.LogError(jobException, "There was an error in job {JobKey}. Chain will not continue", context.JobDetail.Key);
return;
}
_chainLinks.TryGetValue(context.JobDetail.Key, out var sj);
if (sj == null)
{
return;
}
_logger.LogInformation("Job '{JobKey}' will now chain to Job '{sj}'", context.JobDetail.Key, sj);
try
{
var jobDataMap = new JobDataMap();
if (context.Result is JobResult { Result: not null } jobResult)
{
jobDataMap.Put("previousResult", jobResult.Result);
}
await context.Scheduler.TriggerJob(sj, jobDataMap, cancellationToken).ConfigureAwait(false);
}
catch (SchedulerException se)
{
_logger.LogError(se, "Error encountered during chaining to Job '{sj}'", sj);
}
}
}

View File

@ -0,0 +1,25 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace AutoScan.Listener;
public interface IChainerListenerFactory
{
ChainerListener CreateChainerListener(string name);
}
public class ChainerListenerFactory : IChainerListenerFactory
{
private readonly ILoggerFactory _loggerFactory;
public ChainerListenerFactory(ILoggerFactory loggerFactory)
{
_loggerFactory = loggerFactory;
}
public ChainerListener CreateChainerListener(string name)
{
var logger = _loggerFactory.CreateLogger<ChainerListener>();
return new ChainerListener(name, logger);
}
}

View File

@ -0,0 +1,41 @@
namespace AutoScan.Models;
//Bulk converted from endpoint response. TODO: Cleanup later, format properties names
public class VideoStorageLocations
{
public string dir { get; set; }
}
public class VideoActionLink
{
public string changeToRead { get; set; }
public string changeToUnread { get; set; }
public string deleteVideo { get; set; }
}
public class FetchVideoResponse
{
public bool endIsStartTo { get; set; }
public bool ok { get; set; }
public List<VideoDetail> videos { get; set; }
}
public class VideoDetail
{
public string actionUrl { get; set; }
public int archive { get; set; }
public VideoStorageLocations details { get; set; }
public DateTime end { get; set; }
public string ext { get; set; }
public string filename { get; set; }
public string href { get; set; }
public string ke { get; set; }
public VideoActionLink links { get; set; }
public string mid { get; set; }
public string objects { get; set; }
public object saveDir { get; set; }
public int size { get; set; }
public int status { get; set; }
public DateTime time { get; set; }
}

View File

@ -3,13 +3,12 @@ namespace AutoScan.Options;
public record AutoScanOptions
{
public bool Enabled { get; set; }
public string? At { get; set; }
public string At { get; set; } = "06:00";
public bool FromDayBefore { get; set; }
public string? From { get; set; }
public string? To { get; set; }
public string From { get; set; } = "23:00";
public string To { get; set; } = "1:00";
public int MaxAmount { get; set; }
public string? MediaFolder { get; set; }
public ShinobiOptions? Shinobi { get; set; }
public ScannerOptions? Scanner { get; set; }
public ScreenshotOptions? Screenshot { get; set; }
}

View File

@ -1,24 +0,0 @@
using AutoScan.Options;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Quartz;
namespace AutoScan;
public class ScanJob : IJob
{
private readonly ILogger<ScanJob> _logger;
private readonly AutoScanOptions _options;
public ScanJob(ILogger<ScanJob> logger, IOptionsSnapshot<AutoScanOptions> options)
{
_logger = logger;
_options = options.Value;
}
public Task Execute(IJobExecutionContext context)
{
_logger.LogWarning("Scheduled scan executed with ops: {Options}", _options);
return Task.CompletedTask;
}
}

View File

@ -1,40 +1,64 @@
using AutoScan.Models;
using AutoScan.Options;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Net.Http.Json;
namespace CasaBotApp;
namespace AutoScan;
public class ShinobiConnector
{
//TODO move class to auto scan library
private readonly ILogger<ShinobiConnector> _logger;
private readonly string _shinobivUrl = "";
private readonly string _apikey = "";
private readonly string _groupId = "";
private readonly string _monitorId = "";
private readonly HttpClient _httpClient;
private readonly ShinobiOptions _options;
public ShinobiConnector(ILogger<ShinobiConnector> logger, HttpClient httpClient)
public ShinobiConnector(ILogger<ShinobiConnector> logger, HttpClient httpClient, IOptions<ShinobiOptions> options)
{
_logger = logger;
_httpClient = httpClient;
_options = options.Value;
}
public async Task FetchLastVideo(string filename = "2025-02-12T08-00-01.mp4")
public async Task<List<VideoDetail>> FetchMonitorVideosBetween(DateTime from, DateTime to)
{
const string fetchVideoEndpoint = "/{0}/videos/{1}/{2}/{3}";
var endpoint = string.Format(_shinobivUrl+fetchVideoEndpoint, _apikey, _groupId, _monitorId, filename);
_logger.LogInformation("Fetching video from endpoint: {Endpoint}", endpoint);
var endpoint = $"{_options.URL}/{_options.APIKey}/videos/{_options.GroupId}/{_options.MonitorId}";
endpoint += $"?start={from:yyyy-MM-ddTHH:mm:sszzz}&end={to:yyyy-MM-ddTHH:mm:sszzz}";
//fetch video
const string mediaPath = @".\media\"; //TODO. Use options
var videoPath = mediaPath + filename;
_logger.LogDebug("Fetching videos details from endpoint: {Endpoint}", endpoint);
//get from the server the response with type VideoDetails
var response = await _httpClient.GetFromJsonAsync<FetchVideoResponse>(endpoint);
if (response is null)
{
_logger.LogWarning("No videos found in the specified range");
return [];
}
_logger.LogInformation("Found {Count} videos in the specified range", response.videos.Count);
foreach (var video in response.videos.OrderBy(x => x.time))
{
video.end = video.end.ToLocalTime();
video.time = video.time.ToLocalTime();
_logger.LogDebug("Video: {Filename} - Time: {time} - Ends: {end}", video.filename, video.time, video.end);
}
return response.videos;
}
public async Task DownloadMonitorVideo(VideoDetail video, string downloadFolder)
{
var endpoint = $"{_options.URL}{video.href}";
_logger.LogDebug("Fetching video from endpoint: {Endpoint}", endpoint);
//Video filenames format: "monitorId-2025-02-15T07-45-01.mp4"
var videoTime = video.time.ToString("yyyy-MM-ddTHH-mm-ss");
var videoPath = Path.Combine(downloadFolder, $"{video.mid}-{videoTime}.{video.ext}");
try
{
//make sure the directory exists
Directory.CreateDirectory(Path.GetDirectoryName(mediaPath)!);
_logger.LogDebug("Cleaning media folder");
CleanDirectory(mediaPath);
_logger.LogDebug("Downloading video...");
var videoData = await _httpClient.GetByteArrayAsync(endpoint);
@ -44,18 +68,11 @@ public class ShinobiConnector
}
catch (Exception ex)
{
_logger.LogError(ex, "An error occurred while downloading the video");
_logger.LogError(ex, "An error occurred while downloading the video {video}", video.filename);
throw;
}
}
private void CleanDirectory(string path)
{
DirectoryInfo di = new DirectoryInfo(path);
foreach (var file in di.GetFiles())
{
file.Delete();
}
}
}

View File

@ -1,4 +1,5 @@
using AutoScan;
using AutoScan.Options;
using CasaBotApp;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@ -30,7 +31,8 @@ services.AddLogging(builder =>
});
services.Configure<TelegramOptions>(configuration.GetSection("Telegram"));
services.Configure<AutoScan.Options.AutoScanOptions>(configuration.GetSection("AutoScan"));
services.Configure<AutoScanOptions>(configuration.GetSection("AutoScan"));
services.Configure<ShinobiOptions>(configuration.GetSection("Shinobi"));
services.AddSingleton<BotHandler>();

View File

@ -4,13 +4,20 @@
"Default": "Debug",
"System": "Information",
"Microsoft": "Information",
"Quartz": "Information"
"Quartz": "Information",
"System.Net.Http.HttpClient": "Warning"
}
},
"Telegram":{
"BotToken": "__token__",
"SubscribedChatIds": []
},
"Shinobi": {
"URL": "http://localhost:8080",
"APIKey": "APIKEY",
"GroupId": "Group",
"MonitorId": "Monitor"
},
"AutoScan": {
"Enabled": false,
"At": "07:00",
@ -18,20 +25,14 @@
"From": "23:00",
"To": "05:00",
"MaxAmount": 1,
"MediaFolder": "./media/originals",
"Shinobi": {
"URL": "http://localhost:8080",
"APIKey": "APIKEY",
"GroupId": "Group",
"MonitorId": "Monitor"
},
"MediaFolder": "./media/originals/",
"Scanner": {
"Exe": "./dvr-scanner/dvr.exe",
"ConfigFile": "./dvr-scanner/dvr-scan.cfg",
"DetectionFolder": "./media/detections"
"DetectionFolder": "./media/detections/"
},
"Screenshot": {
"Folder": "./media/screenshots",
"Folder": "./media/screenshots/",
"OffsetSeconds": 0
}
}