feat(API): Adds method to modify the FileWatcherService: folders, time

This commit is contained in:
MrFastwind
2025-07-30 20:39:56 +02:00
parent bd9cef0352
commit 3de1cd6b14
4 changed files with 219 additions and 90 deletions

View File

@@ -1,27 +1,52 @@
using Lactose.Context; using Lactose.Context;
using Lactose.Models; using Lactose.Models;
using Lactose.Services;
namespace Lactose.Repositories; namespace Lactose.Repositories;
public class FolderRepository(LactoseDbContext context) : IFolderRepository, IAsyncDisposable { public class FolderRepository(LactoseDbContext context, FileSystemScannerService scanner) : IFolderRepository, IAsyncDisposable {
public void Create(Folder folder) => context.Folders.Add(folder); public void Create(Folder folder) {
context.Folders.Add(folder);
context.SaveChanges();
if (folder.Active) scanner.AddFolder(folder);
}
public void Update(Guid id, Folder folder) { public void Update(Guid id, Folder folder) {
var origFolder = context.Folders.Find(id); var origFolder = context.Folders.Find(id);
if (origFolder != null) { if (origFolder == null) { return; }
origFolder.BasePath = folder.BasePath;
origFolder.Active = folder.Active; var oldPath = origFolder;
context.Folders.Update(origFolder); var hasChanged = origFolder.BasePath != folder.BasePath;
context.SaveChanges(); var wasActive = origFolder.Active;
origFolder.BasePath = folder.BasePath;
origFolder.Active = folder.Active;
context.Folders.Update(origFolder);
context.SaveChanges();
if (hasChanged && wasActive) {
scanner.RemoveFolder(oldPath);
} }
if (hasChanged && folder.Active) {}
if (folder.Active) scanner.AddFolder(origFolder);
else scanner.RemoveFolder(origFolder);
} }
public void Delete(Guid id) { public void Delete(Guid id) {
var folder = context.Folders.Find(id); var folder = context.Folders.Find(id);
if(folder != null) context.Folders.Remove(folder);
if (folder != null) {
context.Folders.Remove(folder);
scanner.RemoveFolder(folder);
}
} }
public IEnumerable<Folder> GetAllUntracked() => context.Folders.AsNoTracking();
public Folder? Get(Guid id) => context.Folders.Find(id); public Folder? Get(Guid id) => context.Folders.Find(id);
public IEnumerable<Folder> GetAll() => context.Folders; public IEnumerable<Folder> GetAll() => context.Folders;

View File

@@ -36,4 +36,6 @@ public interface IFolderRepository : IDisposable {
/// Retrieves all folders. /// Retrieves all folders.
/// </summary> /// </summary>
IEnumerable<Folder> GetAll(); IEnumerable<Folder> GetAll();
IEnumerable<Folder> GetAllUntracked();
} }

View File

@@ -4,6 +4,7 @@ using Butter.Dtos.Settings;
using Butter.Settings; using Butter.Settings;
using Lactose.Models; using Lactose.Models;
using Lactose.Repositories; using Lactose.Repositories;
using Lactose.Utils;
using System.Data; using System.Data;
using static System.String; using static System.String;
@@ -11,72 +12,115 @@ namespace Lactose.Services;
public class FileSystemScannerService(ILogger<FileSystemScannerService> logger, IServiceProvider serviceProvider) public class FileSystemScannerService(ILogger<FileSystemScannerService> logger, IServiceProvider serviceProvider)
: BackgroundService { : BackgroundService {
public bool IsRunning { get; private set; } = false; public bool IsScanning { get; private set; } = false;
public bool IsInitialized { get; private set; } = false; public bool IsEnabled { get; private set; } = false;
public TimeSpan ScanInterval { get; private set; } public bool IsInitialized { get; private set; } = false;
List<Folder> folders = []; readonly PeriodicTimer scanTimer = new(TimeSpan.FromMinutes(30));
readonly List<Task> tasks = []; readonly Dictionary<string,CancellationTokenSource> folders = new();
readonly List<Task> tasks = [];
protected override Task ExecuteAsync(CancellationToken stoppingToken) { protected override Task ExecuteAsync(CancellationToken stoppingToken) {
// Resolve the repositories from the service provider // Resolve the repositories from the service provider
var settingsRepository = serviceProvider.GetRequiredService<ISettingsRepository>(); using (var settingsRepository = serviceProvider.GetRequiredService<ISettingsRepository>()) {
if (settingsRepository.Get(Settings.FolderScanEnabled.AsString())?.Value == "false") { IsEnabled = settingsRepository.Get(Settings.FolderScanEnabled.AsString())?.Value != "false";
logger.LogInformation("Folder scanning is disabled. Service will not start.");
return Task.CompletedTask; stoppingToken.Register(() => {
logger.LogInformation("Filesystem Scan Service stopping...");
folders.Values
.Where(tokenSource => !tokenSource.IsCancellationRequested)
.ForEach(tokenSource => {
tokenSource.Cancel();
tokenSource.Dispose();
});
folders.Clear();
});
using ( var folderRepository = serviceProvider.GetRequiredService<IFolderRepository>()) {
folderRepository.GetAllUntracked()
.Where(f => f.Active)
.Select(f => f.BasePath)
.ForEach(AddFolder);
}
logger.LogInformation("Filesystem Scan Service starting...");
var value = settingsRepository.Get(Settings.FolderScanInterval.AsString())?.Value;
if (value != null && !int.TryParse(value, out var interval)) {
logger.LogWarning("Invalid scan interval setting. Defaulting to 30 minutes.");
interval = 30; // Default to 30 minutes if parsing fails
} else interval = int.Parse(value!);
SetTimerInterval(TimeSpan.FromMinutes(interval));
} }
var service = new Task(() => ServiceLogic(stoppingToken)); IsInitialized = true;
var service = new Task(() => TaskWatcher(stoppingToken));
service.Start(); service.Start();
return service; return service;
} }
//TODO: This really needs a decent name. void TaskWatcher(CancellationToken stoppingToken) {
void ServiceLogic(CancellationToken stoppingToken) {
// Resolve the repositories from the service provider
using var folderRepository = serviceProvider.GetRequiredService<IFolderRepository>();
using var settingsRepository = serviceProvider.GetRequiredService<ISettingsRepository>();
PeriodicTimer scanTimer;
logger.LogInformation("Filesystem Scan Service starting...");
var value = settingsRepository.Get(Settings.FolderScanInterval.AsString())?.Value;
if (value != null && !int.TryParse(value, out var interval)) {
logger.LogWarning("Invalid scan interval setting. Defaulting to 30 minutes.");
interval = 30; // Default to 30 minutes if parsing fails
} else interval = int.Parse(value!);
ScanInterval = TimeSpan.FromMinutes(interval);
logger.LogInformation($"Service will scan every {ScanInterval.TotalMinutes} minutes.");
scanTimer = new(ScanInterval);
IsInitialized = true;
while (!stoppingToken.IsCancellationRequested) { while (!stoppingToken.IsCancellationRequested) {
logger.LogInformation("Retrieving folder paths to scan...");
folders = folderRepository.GetAll().ToList(); scanTimer.WaitForNextTickAsync(stoppingToken);
// Wait for all tasks to complete before starting a new scan // Wait for all tasks to complete before starting a new scan
if (tasks.Count > 0) { if (IsScanning) {
logger.LogInformation("Waiting for {count} tasks to complete...", tasks.Count); logger.LogInformation("Waiting for {count} tasks to complete...", tasks.Count);
Task.WaitAll(tasks.ToArray(), stoppingToken); WaitTasks(stoppingToken);
IsRunning = false;
} }
if (folders.Count != 0) { if (IsEnabled && folders.Count != 0 && !IsScanning) {
logger.LogInformation("Found {count} folders to scan.", folders.Count); logger.LogInformation("Found {count} folders to scan.", folders.Count);
logger.LogInformation("Starting Tasks for scanning folders..."); logger.LogInformation("Starting Tasks for scanning folders...");
// Launch a folder scan for each folder, passing the stopping token to each task // Launch a folder scan for each folder, passing the stopping token to each task
folders.ForEach(folder => { tasks.Add(Task.Run(() => ScanFolder(folder.BasePath), stoppingToken)); }); folders.Where(entry => !entry.Value.IsCancellationRequested)
IsRunning = true; .Select(entry => entry.Key)
} else { .ToList()
logger.LogWarning("No folders to scan. Waiting for next scan interval..."); .ForEach(path => {
scanTimer.WaitForNextTickAsync(stoppingToken); folders[path] = new CancellationTokenSource();
}
);
foreach (var entry in folders) {
tasks.Add(Task.Run(() => ScanFolder(entry.Key, entry.Value.Token), entry.Value.Token));
}
IsScanning = true;
} }
} }
} }
void ScanFolder(string path) { void WaitTasks(CancellationToken stoppingToken) {
using var assetRepository = serviceProvider.GetRequiredService<IAssetRepository>(); while (IsScanning) {
try {
Task.WaitAll(tasks.ToArray(), stoppingToken);
tasks.Clear();
IsScanning = false;
} catch (OperationCanceledException e) {
logger.LogInformation("Scanning Service was cancelled.");
IsScanning = false;
IsEnabled = false;
}
catch (AggregateException e) {
if (!e.InnerExceptions.All(ie => ie is OperationCanceledException)) throw;
logger.LogDebug("A Folder Has been removed. Continuing to scan.");
}
}
}
void ScanFolder(string path, CancellationToken stoppingToken) {
if (stoppingToken.IsCancellationRequested) {
stoppingToken.ThrowIfCancellationRequested();
};
List<string> filePaths = []; List<string> filePaths = [];
List<string> folderPaths = []; List<string> folderPaths = [];
@@ -88,51 +132,99 @@ public class FileSystemScannerService(ILogger<FileSystemScannerService> logger,
} catch (Exception e) { logger.LogError(e, "Error scanning folder {folder}", path); } } catch (Exception e) { logger.LogError(e, "Error scanning folder {folder}", path); }
// Launch a folder scan for each subfolder // Launch a folder scan for each subfolder
folderPaths.ForEach(folderPath => { tasks.Add(Task.Run(() => ScanFolder(folderPath))); }); folderPaths.ForEach(folderPath => { tasks.Add(Task.Run(() => ScanFolder(folderPath, stoppingToken), stoppingToken)); });
using var assetRepository = serviceProvider.GetRequiredService<IAssetRepository>();
// Process all files // Process all files
filePaths.ForEach(filePath => { filePaths.ForEach(filePath => {
// Check if the file is already in the database // Check if the file is already in the database
if (assetRepository.FindByPath(filePath) != null) return; if (assetRepository.FindByPath(filePath) != null) return;
// Get the file info var asset = AssetFromPath(filePath);
var finfo = new FileInfo(filePath);
// Determine the type of the file if (asset == null) return;
string ext = finfo.Extension.ToLower();
EAssetType? type;
// ReSharper disable ArrangeObjectCreationWhenTypeNotEvident
var mimeImg = MimeTypes.Image.FirstOrDefault(mime => mime.Extensions.Contains(ext), new(Empty, []));
var mimeVid = MimeTypes.Video.FirstOrDefault(mime => mime.Extensions.Contains(ext), new(Empty, []));
if (mimeImg.MimeType != Empty) type = EAssetType.Image;
else if (mimeVid.MimeType != Empty) type = EAssetType.Video;
else return;
// Create a new asset
var asset = new Asset() {
OriginalPath = finfo.FullName,
OriginalFilename = finfo.Name,
Type = type.Value,
IsPubliclyShared = false,
CreatedAt = finfo.CreationTime,
UpdatedAt = finfo.LastWriteTime,
MimeType = type.Value switch {
EAssetType.Image => mimeImg.MimeType,
EAssetType.Video => mimeVid.MimeType,
_ => throw new ArgumentOutOfRangeException()
},
FileSize = finfo.Length,
Hash = []
};
//TODO: check if the file is already in the database
// Add the asset to the database // Add the asset to the database
try { assetRepository.Insert(asset); } catch (DuplicateNameException e) { try { assetRepository.Insert(asset); } catch (DuplicateNameException e) {
logger.LogError(e, $"Duplicate asset name \"{finfo.FullName}\", skipped."); logger.LogError(e, $"Duplicate asset name \"{asset.OriginalFilename}\", skipped.");
} }
} }
); );
} }
Asset? AssetFromPath(string filePath) {
// Get the file info
var finfo = new FileInfo(filePath);
// Determine the type of the file
string ext = finfo.Extension.ToLower();
EAssetType? type;
// ReSharper disable ArrangeObjectCreationWhenTypeNotEvident
var mimeImg = MimeTypes.Image.FirstOrDefault(mime => mime.Extensions.Contains(ext), new(Empty, []));
var mimeVid = MimeTypes.Video.FirstOrDefault(mime => mime.Extensions.Contains(ext), new(Empty, []));
if (mimeImg.MimeType != Empty) type = EAssetType.Image;
else if (mimeVid.MimeType != Empty) type = EAssetType.Video;
else return null;
// Create a new asset
var asset = new Asset() {
OriginalPath = finfo.FullName,
OriginalFilename = finfo.Name,
Type = type.Value,
IsPubliclyShared = false,
CreatedAt = finfo.CreationTime,
UpdatedAt = finfo.LastWriteTime,
MimeType = type.Value switch {
EAssetType.Image => mimeImg.MimeType,
EAssetType.Video => mimeVid.MimeType,
_ => throw new ArgumentOutOfRangeException()
},
FileSize = finfo.Length,
Hash = []
};
return asset;
}
public void SetTimerInterval(TimeSpan interval) {
scanTimer.Period = interval;
logger.LogInformation($"Service will scan every {scanTimer.Period.TotalMinutes} minutes.");
}
public void AddFolder(string folder) {
if (folders.ContainsKey(folder)) {
logger.LogWarning("Folder {folder} already exists", folder);
return;
}
folders.Add(folder, new CancellationTokenSource());
logger.LogInformation("Added folder {folder}", folder);
}
public void AddFolder(Folder folder) => AddFolder(folder.BasePath);
public void RemoveFolder(string folder) {
if (!folders.TryGetValue(folder, out CancellationTokenSource? cancellationToken)) {
logger.LogWarning("Folder {folder} does not exist", folder);
return;
}
cancellationToken.Cancel();
cancellationToken.Dispose();
folders.Remove(folder);
logger.LogInformation("Removed folder {folder}", folder);
}
public void RemoveFolder(Folder folder) => RemoveFolder(folder.BasePath);
public void Enable() {
IsEnabled = true;
logger.LogInformation("Service is now enabled.");
}
public void Disable() => IsEnabled = false;
} }

View File

@@ -0,0 +1,10 @@
namespace Lactose.Utils;
public static class EnumerableExtensions {
public static void ForEach<TSource>(this IEnumerable<TSource> source, Action<TSource> action) {
foreach (var item in source) {
action(item);
}
}
}