diff --git a/Manager.App/Components/Pages/Services.razor b/Manager.App/Components/Pages/Services.razor
index e9ca48e..99b9942 100644
--- a/Manager.App/Components/Pages/Services.razor
+++ b/Manager.App/Components/Pages/Services.razor
@@ -7,7 +7,7 @@
Services
-
+
Services
@@ -20,16 +20,19 @@
-
+
-
- Pause
-
- Resume
-
-
+
+ @foreach (var action in context.Item?.Actions ?? [])
+ {
+
+
+ @action.Id
+
+
+ }
+
diff --git a/Manager.App/Services/ExtendedBackgroundService.cs b/Manager.App/Services/ExtendedBackgroundService.cs
index 6f03f8a..93cf8f0 100644
--- a/Manager.App/Services/ExtendedBackgroundService.cs
+++ b/Manager.App/Services/ExtendedBackgroundService.cs
@@ -7,52 +7,91 @@ public abstract class ExtendedBackgroundService(string name, string description,
: BackgroundService
{
private TaskCompletionSource _resumeSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private readonly List _actions = [];
+ private TaskCompletionSource? _manualContinue;
+
public ServiceState State { get; private set; } = ServiceState.Stopped;
public CircularBuffer ProgressEvents { get; } = new(500);
public string Name { get; } = name;
- public string Description { get; set; } = description;
- public TimeSpan ExecuteInterval { get; set; } = executeInterval ?? TimeSpan.FromMinutes(1);
+ public string Description { get; } = description;
+ public TimeSpan ExecuteInterval { get; } = executeInterval ?? TimeSpan.FromSeconds(5);
+
+ public IReadOnlyList Actions => _actions;
+
+ protected void AddActions(IEnumerable actions)
+ {
+ _actions.AddRange(actions);
+ }
protected sealed override async Task ExecuteAsync(CancellationToken stoppingToken)
{
State = ServiceState.Running;
logger.LogInformation("Initializing background service: {ServiceName}", Name);
+
+ _actions.AddRange(
+ [
+ new ServiceAction("Start", "Start the service (after the service is stopped of faulted.)", Start, () => State is ServiceState.Stopped or ServiceState.Faulted),
+ new ServiceAction("Pause", "Pause the service", Pause, () => State != ServiceState.Paused),
+ new ServiceAction("Resume", "Resume the service", Resume, () => State != ServiceState.Running)
+ ]);
+
await InitializeAsync(stoppingToken);
- try
+ while (!stoppingToken.IsCancellationRequested)
{
- logger.LogInformation("Running background service: {ServiceName}", Name);
- while (!stoppingToken.IsCancellationRequested)
+ if (State == ServiceState.Running)
{
- if (State == ServiceState.Paused)
+ try
{
- _resumeSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- await _resumeSignal.Task.WaitAsync(stoppingToken);
- }
+ logger.LogInformation("Started running background service: {ServiceName}", Name);
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ if (State == ServiceState.Paused)
+ {
+ _resumeSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ await _resumeSignal.Task.WaitAsync(stoppingToken);
+ }
- await ExecuteServiceAsync(stoppingToken);
-
- await Task.Delay(ExecuteInterval, stoppingToken);
+ await ExecuteServiceAsync(stoppingToken);
+
+ await Task.Delay(ExecuteInterval, stoppingToken);
+ }
+ }
+ catch (OperationCanceledException e)
+ {
+ logger.LogInformation(e, "Service {ServiceName} received cancellation", Name);
+ }
+ catch (Exception e)
+ {
+ State = ServiceState.Faulted;
+ logger.LogError(e, "Background service {ServiceName} faulted!", Name);
+ LogEvent("Error executing background service.", LogSeverity.Error);
+ }
+ finally
+ {
+ State = ServiceState.Stopped;
+ }
}
- }
- catch (Exception e)
- {
- if (e is not OperationCanceledException)
- {
- State = ServiceState.Faulted;
- logger.LogError(e,"Background service {ServiceName} faulted!", Name);
- throw;
- }
- logger.LogInformation(e,"Service {ServiceName} received cancellation", Name);
- }
- finally
- {
- State = ServiceState.Stopped;
+
+ _manualContinue = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var delayTask = Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
+ await Task.WhenAny(delayTask, _manualContinue.Task);
+ _manualContinue = null;
}
}
protected void LogEvent(string message, LogSeverity severity = LogSeverity.Info) => ProgressEvents.Add(new ServiceEvent(string.Intern(Name), message, DateTime.UtcNow, severity));
+ public void Start()
+ {
+ if (State is ServiceState.Stopped or ServiceState.Faulted)
+ {
+ State = ServiceState.Running;
+ _manualContinue?.TrySetResult();
+ LogEvent("Started service.");
+ }
+ }
+
public void Pause()
{
if (State == ServiceState.Running)
@@ -94,4 +133,6 @@ public enum ServiceState
Paused
}
-public record struct ServiceEvent(string Source, string Message, DateTime DateUtc, LogSeverity Severity);
\ No newline at end of file
+public record struct ServiceEvent(string Source, string Message, DateTime DateUtc, LogSeverity Severity);
+
+public record ServiceAction(string Id, string Description, Action Action, Func IsEnabled);
\ No newline at end of file
diff --git a/Manager.App/Services/System/CacheService.cs b/Manager.App/Services/System/CacheService.cs
index 956dfb8..44a3433 100644
--- a/Manager.App/Services/System/CacheService.cs
+++ b/Manager.App/Services/System/CacheService.cs
@@ -2,6 +2,7 @@ using System.Security.Cryptography;
using System.Text;
using DotBased.Logging;
using DotBased.Monads;
+using DotBased.Utilities;
using Manager.Data.Contexts;
using Manager.Data.Entities.Cache;
using Manager.YouTube;
@@ -10,23 +11,45 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
namespace Manager.App.Services.System;
-public class CacheService(ILogger logger, IHostEnvironment environment) : ExtendedBackgroundService(nameof(CacheService), "Manages caching.", logger, TimeSpan.FromHours(5))
+public class CacheService(ILogger logger, IHostEnvironment environment)
+ : ExtendedBackgroundService(nameof(CacheService), "Manages caching.", logger, TimeSpan.FromHours(5))
{
private DirectoryInfo? _cacheDirectory;
private PooledDbContextFactory? _dbContextFactory;
private const string DataSubDir = "data";
-
+ private const int CacheMaxAgeDays = 1;
+ private readonly SemaphoreSlim _cacheSemaphoreSlim = new(1, 1);
+
protected override Task InitializeAsync(CancellationToken stoppingToken)
{
_cacheDirectory = new DirectoryInfo(Path.Combine(Directory.GetCurrentDirectory(), "cache"));
_cacheDirectory.Create();
Directory.CreateDirectory(Path.Combine(_cacheDirectory.FullName, DataSubDir));
LogEvent($"Cache directory: {_cacheDirectory.FullName}");
-
+
+ AddActions([
+ new ServiceAction("Clear cache", "Manually clear cache", () =>
+ {
+ LogEvent("Manual cache clear requested.");
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await ClearCacheAsync(stoppingToken);
+ }
+ catch (Exception e)
+ {
+ logger.LogError(e, "Error clearing cache manually!");
+ LogEvent("Error manually clearing cache.", LogSeverity.Error);
+ }
+ }, stoppingToken);
+ }, () => true)
+ ]);
+
var dbContextOptionsBuilder = new DbContextOptionsBuilder();
dbContextOptionsBuilder.UseSqlite($"Data Source={Path.Combine(_cacheDirectory.FullName, "cache_index.db")}");
_dbContextFactory = new PooledDbContextFactory(dbContextOptionsBuilder.Options);
-
+
return Task.CompletedTask;
}
@@ -72,20 +95,24 @@ public class CacheService(ILogger logger, IHostEnvironment environ
}
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
-
+
var urlKeyBytes = SHA1.HashData(Encoding.UTF8.GetBytes(url));
var urlKey = Convert.ToHexString(urlKeyBytes);
- var cacheEntity = await context.Cache.FirstOrDefaultAsync(c => c.Id == urlKey, cancellationToken: cancellationToken);
+ var cacheEntity =
+ await context.Cache.FirstOrDefaultAsync(c => c.Id == urlKey, cancellationToken: cancellationToken);
if (cacheEntity == null)
{
- var downloadResult = await NetworkService.DownloadBytesAsync(new HttpRequestMessage(HttpMethod.Get, url));
+ var downloadResult =
+ await NetworkService.DownloadBytesAsync(new HttpRequestMessage(HttpMethod.Get, url));
if (!downloadResult.IsSuccess)
{
LogEvent($"Failed to download from url: {url}");
return ResultError.Fail("Download failed.");
}
+
var download = downloadResult.Value;
- await using var downloadFile = File.Create(Path.Combine(_cacheDirectory.FullName, DataSubDir, $"{urlKey}.cache"));
+ await using var downloadFile =
+ File.Create(Path.Combine(_cacheDirectory.FullName, DataSubDir, $"{urlKey}.cache"));
await downloadFile.WriteAsync(download.Data.AsMemory(0, download.Data.Length), cancellationToken);
cacheEntity = new CacheEntity
@@ -96,7 +123,7 @@ public class CacheService(ILogger logger, IHostEnvironment environ
ContentType = download.ContentType,
OriginalFileName = download.FileName,
};
-
+
context.Cache.Add(cacheEntity);
var saved = await context.SaveChangesAsync(cancellationToken);
if (saved <= 0)
@@ -126,58 +153,71 @@ public class CacheService(ILogger logger, IHostEnvironment environ
private async Task ClearCacheAsync(CancellationToken cancellationToken)
{
- if (_dbContextFactory == null)
+ if (!await _cacheSemaphoreSlim.WaitAsync(0, cancellationToken))
{
- throw new InvalidOperationException("No DbContext factory configured.");
- }
-
- if (_cacheDirectory == null)
- {
- throw new InvalidOperationException("No cache directory configured.");
- }
-
- await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
-
- var toRemove = dbContext.Cache.Where(c => c.CachedAtUtc < DateTime.UtcNow.AddDays(-1));
- if (!toRemove.Any())
- {
- LogEvent("No items found to purge from cache.");
+ LogEvent("The cache cleaning task is already running. Skipping this call.", LogSeverity.Warning);
return;
}
- var totalToRemove = toRemove.Count();
- LogEvent($"Found {totalToRemove} cache items that are older than 1 day(s)");
-
- var deleted = new List();
- foreach (var entity in toRemove)
+ try
{
- var pathToFile = Path.Combine(_cacheDirectory.FullName, DataSubDir, $"{entity.Id}.cache");
- if (!File.Exists(pathToFile))
+ if (_dbContextFactory == null)
+ throw new InvalidOperationException("No DbContext factory configured.");
+
+ if (_cacheDirectory == null)
+ throw new InvalidOperationException("No cache directory configured.");
+
+ await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
+
+ var toRemove = dbContext.Cache.Where(c => c.CachedAtUtc < DateTime.UtcNow.AddDays(-CacheMaxAgeDays));
+ if (!toRemove.Any())
{
+ LogEvent($"No items older than {CacheMaxAgeDays} day(s) found to clear from cache.");
+ return;
+ }
+
+ var totalToRemove = toRemove.Count();
+ LogEvent($"Found {totalToRemove} cache items that are older than 1 day(s)");
+
+ var deleted = new List();
+ long totalBytesRemoved = 0;
+ foreach (var entity in toRemove)
+ {
+ var pathToFile = Path.Combine(_cacheDirectory.FullName, DataSubDir, $"{entity.Id}.cache");
+ if (!File.Exists(pathToFile))
+ {
+ deleted.Add(entity);
+ totalBytesRemoved += entity.ContentLength;
+ continue;
+ }
+
+ try
+ {
+ File.Delete(pathToFile);
+ }
+ catch (Exception e)
+ {
+ logger.LogError(e, "Failed to delete cache entity with id: {EntityId}. Skipping cache entity...",
+ entity.Id);
+ continue;
+ }
+
+ totalBytesRemoved += entity.ContentLength;
deleted.Add(entity);
- continue;
}
- try
- {
- File.Delete(pathToFile);
- }
- catch (Exception e)
- {
- logger.LogError(e, "Failed to delete cache entity with id: {EntityId}. Skipping cache entity...", entity.Id);
- continue;
- }
- deleted.Add(entity);
+ dbContext.RemoveRange(deleted);
+ var dbDeleted = await dbContext.SaveChangesAsync(cancellationToken);
+
+ if (dbDeleted < deleted.Count)
+ LogEvent("Could not delete all files from cache.", LogSeverity.Warning);
+
+ LogEvent($"Removed {dbDeleted}/{totalToRemove} items from cache. Total of {Suffix.BytesToSizeSuffix(totalBytesRemoved)} removed from disk.");
}
-
- dbContext.RemoveRange(deleted);
- var dbDeleted = await dbContext.SaveChangesAsync(cancellationToken);
- if (dbDeleted < deleted.Count)
+ finally
{
- LogEvent("Could not delete all files from cache.", LogSeverity.Warning);
+ _cacheSemaphoreSlim.Release();
}
-
- LogEvent($"Removed {dbDeleted}/{totalToRemove} items");
}
}
diff --git a/Manager.App/Services/System/SettingsService.cs b/Manager.App/Services/System/SettingsService.cs
new file mode 100644
index 0000000..19b45d7
--- /dev/null
+++ b/Manager.App/Services/System/SettingsService.cs
@@ -0,0 +1,16 @@
+namespace Manager.App.Services.System;
+
+public class SettingsService(ILogger logger) : ExtendedBackgroundService(nameof(SettingsService), "Service for handling application settings.", logger, TimeSpan.FromMinutes(10))
+{
+ protected override async Task InitializeAsync(CancellationToken stoppingToken)
+ {
+ AddActions([
+ new ServiceAction("Save settings", "Save the application settings to the database", () => { }, () => true)
+ ]);
+ }
+
+ protected override async Task ExecuteServiceAsync(CancellationToken stoppingToken)
+ {
+
+ }
+}
\ No newline at end of file
diff --git a/Manager.Data/Contexts/ApplicationContext.cs b/Manager.Data/Contexts/ApplicationContext.cs
new file mode 100644
index 0000000..ce2f7b3
--- /dev/null
+++ b/Manager.Data/Contexts/ApplicationContext.cs
@@ -0,0 +1,24 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace Manager.Data.Contexts;
+
+public sealed class ApplicationContext : DbContext
+{
+ public ApplicationContext(DbContextOptions options) : base(options)
+ {
+ ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
+ ChangeTracker.LazyLoadingEnabled = false;
+ Database.EnsureCreated();
+ }
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ {
+ optionsBuilder.AddInterceptors(new DateInterceptor());
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+
+ base.OnModelCreating(modelBuilder);
+ }
+}
\ No newline at end of file
diff --git a/Manager.Data/Entities/ApplicationContext/WorkItemEntity.cs b/Manager.Data/Entities/ApplicationContext/WorkItemEntity.cs
new file mode 100644
index 0000000..67879e4
--- /dev/null
+++ b/Manager.Data/Entities/ApplicationContext/WorkItemEntity.cs
@@ -0,0 +1,10 @@
+namespace Manager.Data.Entities.ApplicationContext;
+
+public class WorkItemEntity : DateTimeBase
+{
+ public required Guid Id { get; set; }
+ public required string Name { get; set; }
+ public required string Description { get; set; }
+ public WorkStatus Status { get; set; } = WorkStatus.Pending;
+ public required string ClientId { get; set; }
+}
\ No newline at end of file
diff --git a/Manager.Data/Entities/ApplicationContext/WorkStatus.cs b/Manager.Data/Entities/ApplicationContext/WorkStatus.cs
new file mode 100644
index 0000000..be23d9a
--- /dev/null
+++ b/Manager.Data/Entities/ApplicationContext/WorkStatus.cs
@@ -0,0 +1,10 @@
+namespace Manager.Data.Entities.ApplicationContext;
+
+public enum WorkStatus
+{
+ Pending = 0,
+ InProgress = 1,
+ Paused = 2,
+ Completed = 3,
+ Faulted = 4,
+}
\ No newline at end of file