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

View File

@ -1,3 +1,4 @@
using AutoScan.Listener;
using CasaBotApp; using CasaBotApp;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Quartz; using Quartz;
@ -16,6 +17,7 @@ public static class DependencyInjectionExtensions
{ {
q.UseMicrosoftDependencyInjectionJobFactory(); q.UseMicrosoftDependencyInjectionJobFactory();
}); });
services.AddTransient<IChainerListenerFactory, ChainerListenerFactory>();
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); 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 record AutoScanOptions
{ {
public bool Enabled { get; set; } public bool Enabled { get; set; }
public string? At { get; set; } public string At { get; set; } = "06:00";
public bool FromDayBefore { get; set; } public bool FromDayBefore { get; set; }
public string? From { get; set; } public string From { get; set; } = "23:00";
public string? To { get; set; } public string To { get; set; } = "1:00";
public int MaxAmount { get; set; } public int MaxAmount { get; set; }
public string? MediaFolder { get; set; } public string? MediaFolder { get; set; }
public ShinobiOptions? Shinobi { get; set; }
public ScannerOptions? Scanner { get; set; } public ScannerOptions? Scanner { get; set; }
public ScreenshotOptions? Screenshot { 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.Logging;
using Microsoft.Extensions.Options;
using System.Net.Http.Json;
namespace CasaBotApp; namespace AutoScan;
public class ShinobiConnector public class ShinobiConnector
{ {
//TODO move class to auto scan library
private readonly ILogger<ShinobiConnector> _logger; 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 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; _logger = logger;
_httpClient = httpClient; _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 = $"{_options.URL}/{_options.APIKey}/videos/{_options.GroupId}/{_options.MonitorId}";
var endpoint = string.Format(_shinobivUrl+fetchVideoEndpoint, _apikey, _groupId, _monitorId, filename); endpoint += $"?start={from:yyyy-MM-ddTHH:mm:sszzz}&end={to:yyyy-MM-ddTHH:mm:sszzz}";
_logger.LogInformation("Fetching video from endpoint: {Endpoint}", endpoint);
//fetch video _logger.LogDebug("Fetching videos details from endpoint: {Endpoint}", endpoint);
const string mediaPath = @".\media\"; //TODO. Use options
var videoPath = mediaPath + filename; //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 try
{ {
//make sure the directory exists
Directory.CreateDirectory(Path.GetDirectoryName(mediaPath)!);
_logger.LogDebug("Cleaning media folder");
CleanDirectory(mediaPath);
_logger.LogDebug("Downloading video..."); _logger.LogDebug("Downloading video...");
var videoData = await _httpClient.GetByteArrayAsync(endpoint); var videoData = await _httpClient.GetByteArrayAsync(endpoint);
@ -44,18 +68,11 @@ public class ShinobiConnector
} }
catch (Exception ex) 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;
using AutoScan.Options;
using CasaBotApp; using CasaBotApp;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -30,7 +31,8 @@ services.AddLogging(builder =>
}); });
services.Configure<TelegramOptions>(configuration.GetSection("Telegram")); 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>(); services.AddSingleton<BotHandler>();

View File

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