diff --git a/src/CasaBot/AutoScan/AutoScanApp.cs b/src/CasaBot/AutoScan/AutoScanApp.cs index a27edb4..8256d2a 100644 --- a/src/CasaBot/AutoScan/AutoScanApp.cs +++ b/src/CasaBot/AutoScan/AutoScanApp.cs @@ -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 _logger; private readonly IScheduler _scheduler; + private readonly IChainerListenerFactory _chainerListenerFactory; - public AutoScanApp(IOptions options, ILogger logger, IScheduler scheduler) + public AutoScanApp(IOptions options, ILogger 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() - .WithIdentity("job1", "group1") + const string group = "ScanGroup"; + + IJobDetail downloaderJob = JobBuilder.Create() + .WithIdentity("downloader", group) + .Build(); + + IJobDetail scannerJob = JobBuilder.Create() + .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.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(':'); diff --git a/src/CasaBot/AutoScan/DependencyInjectionExtensions.cs b/src/CasaBot/AutoScan/DependencyInjectionExtensions.cs index 0e398d3..c12985c 100644 --- a/src/CasaBot/AutoScan/DependencyInjectionExtensions.cs +++ b/src/CasaBot/AutoScan/DependencyInjectionExtensions.cs @@ -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(); services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); diff --git a/src/CasaBot/AutoScan/Jobs/DownloaderJob.cs b/src/CasaBot/AutoScan/Jobs/DownloaderJob.cs new file mode 100644 index 0000000..5f06c33 --- /dev/null +++ b/src/CasaBot/AutoScan/Jobs/DownloaderJob.cs @@ -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 _logger; + private readonly AutoScanOptions _options; + private readonly ShinobiConnector _shinobiConnector; + + public DownloaderJob(ILogger logger, IOptionsSnapshot 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); + } + } + + + } +} \ No newline at end of file diff --git a/src/CasaBot/AutoScan/Jobs/JobResult.cs b/src/CasaBot/AutoScan/Jobs/JobResult.cs new file mode 100644 index 0000000..d20271b --- /dev/null +++ b/src/CasaBot/AutoScan/Jobs/JobResult.cs @@ -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 +} + diff --git a/src/CasaBot/AutoScan/Jobs/ScannerJob.cs b/src/CasaBot/AutoScan/Jobs/ScannerJob.cs new file mode 100644 index 0000000..f0ba22a --- /dev/null +++ b/src/CasaBot/AutoScan/Jobs/ScannerJob.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Logging; +using Quartz; + +namespace AutoScan.Jobs; + +public class ScannerJob : IJob +{ + private readonly ILogger _logger; + public ScannerJob(ILogger logger) + { + _logger = logger; + } + public Task Execute(IJobExecutionContext context) + { + _logger.LogWarning("ScannerJob is not implemented yet!"); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/CasaBot/AutoScan/Listener/ChainerListener.cs b/src/CasaBot/AutoScan/Listener/ChainerListener.cs new file mode 100644 index 0000000..a483a98 --- /dev/null +++ b/src/CasaBot/AutoScan/Listener/ChainerListener.cs @@ -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 _logger; + private readonly Dictionary _chainLinks; + + public ChainerListener(string name, ILogger logger) + { + _logger = logger; + Name = name ?? throw new ArgumentException("Listener name cannot be null!"); + _chainLinks = new Dictionary(); + } + 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); + } + } + +} \ No newline at end of file diff --git a/src/CasaBot/AutoScan/Listener/CharinerFactory.cs b/src/CasaBot/AutoScan/Listener/CharinerFactory.cs new file mode 100644 index 0000000..b1e0076 --- /dev/null +++ b/src/CasaBot/AutoScan/Listener/CharinerFactory.cs @@ -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(); + return new ChainerListener(name, logger); + } +} diff --git a/src/CasaBot/AutoScan/Models/ShinobiFetch.cs b/src/CasaBot/AutoScan/Models/ShinobiFetch.cs new file mode 100644 index 0000000..319a8ed --- /dev/null +++ b/src/CasaBot/AutoScan/Models/ShinobiFetch.cs @@ -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 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; } +} + diff --git a/src/CasaBot/AutoScan/Options/AutoScanOptions.cs b/src/CasaBot/AutoScan/Options/AutoScanOptions.cs index 8888461..a915c3e 100644 --- a/src/CasaBot/AutoScan/Options/AutoScanOptions.cs +++ b/src/CasaBot/AutoScan/Options/AutoScanOptions.cs @@ -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; } } \ No newline at end of file diff --git a/src/CasaBot/AutoScan/ScanJob.cs b/src/CasaBot/AutoScan/ScanJob.cs deleted file mode 100644 index cfaefca..0000000 --- a/src/CasaBot/AutoScan/ScanJob.cs +++ /dev/null @@ -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 _logger; - private readonly AutoScanOptions _options; - - public ScanJob(ILogger logger, IOptionsSnapshot options) - { - _logger = logger; - _options = options.Value; - } - - public Task Execute(IJobExecutionContext context) - { - _logger.LogWarning("Scheduled scan executed with ops: {Options}", _options); - return Task.CompletedTask; - } -} \ No newline at end of file diff --git a/src/CasaBot/AutoScan/ShinobiConnector.cs b/src/CasaBot/AutoScan/ShinobiConnector.cs index bbcd386..7a180cf 100644 --- a/src/CasaBot/AutoScan/ShinobiConnector.cs +++ b/src/CasaBot/AutoScan/ShinobiConnector.cs @@ -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 _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 logger, HttpClient httpClient) + public ShinobiConnector(ILogger logger, HttpClient httpClient, IOptions options) { _logger = logger; _httpClient = httpClient; + _options = options.Value; } - public async Task FetchLastVideo(string filename = "2025-02-12T08-00-01.mp4") + public async Task> 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(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(); - } - } } \ No newline at end of file diff --git a/src/CasaBot/CasaBotApp/Program.cs b/src/CasaBot/CasaBotApp/Program.cs index 8092b4a..cd3c6d0 100644 --- a/src/CasaBot/CasaBotApp/Program.cs +++ b/src/CasaBot/CasaBotApp/Program.cs @@ -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(configuration.GetSection("Telegram")); -services.Configure(configuration.GetSection("AutoScan")); +services.Configure(configuration.GetSection("AutoScan")); +services.Configure(configuration.GetSection("Shinobi")); services.AddSingleton(); diff --git a/src/CasaBot/CasaBotApp/appsettings.json b/src/CasaBot/CasaBotApp/appsettings.json index 5039a3a..f8d2336 100644 --- a/src/CasaBot/CasaBotApp/appsettings.json +++ b/src/CasaBot/CasaBotApp/appsettings.json @@ -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 } }