Add UnitTests and made code testable with DI
This commit is contained in:
@@ -1,14 +1,17 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TinfoilVibeServer.Models;
|
||||
using TinfoilVibeServer.Utilities;
|
||||
|
||||
namespace TinfoilVibeServer.Services;
|
||||
public interface ISnapshotService
|
||||
{
|
||||
event EventHandler SnapshotRebuilt; // raised after a rebuild
|
||||
void RebuildSnapshot();
|
||||
IReadOnlyList<FileEntry> GetSnapshot();
|
||||
SnapshotService.ROMSnapshot GetSnapshot();
|
||||
void BuildSnapshot();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -17,38 +20,54 @@ public interface ISnapshotService
|
||||
/// </summary>
|
||||
public sealed class SnapshotService : IDisposable, ISnapshotService
|
||||
{
|
||||
private readonly ConfigManager _config;
|
||||
private readonly NSPExtractor _nspExtractor;
|
||||
private readonly ArchiveHandler _archiveHandler;
|
||||
private readonly SnapshotOptions _options;
|
||||
private readonly INSPExtractor _nspExtractor;
|
||||
private readonly IArchiveHandler _archiveHandler;
|
||||
private readonly ILogger<SnapshotService> _logger;
|
||||
private readonly string _jsonPath;
|
||||
private readonly string _snapshotPath;
|
||||
private readonly List<FileSystemWatcher> _watchers = new();
|
||||
private readonly ConcurrentDictionary<string, CachedFile> _cache = new();
|
||||
private string? _currentSnapshotHash;
|
||||
private readonly Timer _debounceTimer;
|
||||
public event EventHandler? SnapshotRebuilt;
|
||||
|
||||
public SnapshotService(ConfigManager config, NSPExtractor nspExtractor, ArchiveHandler archiveHandler, ILogger<SnapshotService> logger)
|
||||
public SnapshotService(
|
||||
IOptionsMonitor<SnapshotOptions> options,
|
||||
INSPExtractor nspExtractor,
|
||||
IArchiveHandler archiveHandler,
|
||||
ILogger<SnapshotService> logger)
|
||||
{
|
||||
_config = config;
|
||||
_options = options.CurrentValue;
|
||||
_nspExtractor = nspExtractor;
|
||||
_archiveHandler = archiveHandler;
|
||||
_logger = logger;
|
||||
_jsonPath = Path.Combine(AppContext.BaseDirectory, _config.Settings.SnapshotFile);
|
||||
_snapshotPath = Path.Combine(AppContext.BaseDirectory, _config.Settings.SnapshotBackupFile);
|
||||
|
||||
_jsonPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotFile);
|
||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(_jsonPath));
|
||||
if (!File.Exists(_jsonPath))
|
||||
{
|
||||
File.WriteAllText(_jsonPath, "[]");
|
||||
}
|
||||
_snapshotPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotBackupFile);
|
||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(_snapshotPath));
|
||||
// 1️⃣ Register for *property* changes
|
||||
_options.PropertyChanged += (s, e) => OnOptionsChanged(e.PropertyName);
|
||||
|
||||
BuildSnapshot(); // initial scan
|
||||
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(GetSnapshot()));
|
||||
_debounceTimer = new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);
|
||||
|
||||
foreach (var path in _config.Settings.RootDirectories)
|
||||
foreach (var path in _options.RootDirectories)
|
||||
{
|
||||
InitializeFileSystemWatcher(path);
|
||||
}
|
||||
|
||||
|
||||
_config.OnChange += cfg =>
|
||||
}
|
||||
// --------- Private helpers ---------
|
||||
private void OnOptionsChanged(string propertyName)
|
||||
{
|
||||
if (propertyName == nameof(SnapshotOptions.RootDirectories))
|
||||
{
|
||||
var fileSystemWatchers = _watchers.Where(watcher => !cfg.RootDirectories.Contains(watcher.Path));
|
||||
var fileSystemWatchers = _watchers.Where(watcher => !_options.RootDirectories.Contains(watcher.Path));
|
||||
foreach (var watcher in fileSystemWatchers)
|
||||
{
|
||||
watcher.EnableRaisingEvents = false;
|
||||
@@ -56,7 +75,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
|
||||
_watchers.Remove(watcher);
|
||||
}
|
||||
|
||||
var newWatchedDirectories = cfg.RootDirectories.Where(newWatchedDirectory =>
|
||||
var newWatchedDirectories = _options.RootDirectories.Where(newWatchedDirectory =>
|
||||
!_watchers.Any(watcher =>
|
||||
string.Equals(watcher.Path, newWatchedDirectory, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
@@ -65,11 +84,11 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
|
||||
InitializeFileSystemWatcher(newWatchedDirectory);
|
||||
|
||||
}
|
||||
|
||||
BuildSnapshot(); // rebuild everything
|
||||
PersistSnapshot();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeFileSystemWatcher(string path)
|
||||
{
|
||||
if (!Directory.Exists(path)) return;
|
||||
@@ -96,42 +115,65 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
|
||||
|
||||
private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs)
|
||||
{
|
||||
Task.Run(async () =>
|
||||
lock (_lock)
|
||||
{
|
||||
_debounceTimer.Change(_debounceMs, Timeout.Infinite); // reset the timer
|
||||
_logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss"));
|
||||
}
|
||||
/*Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(250);
|
||||
_logger.LogDebug("File system event {EventType} on {Path}", fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath);
|
||||
UpdateSnapshot();
|
||||
});
|
||||
});*/
|
||||
}
|
||||
|
||||
private readonly object _lock = new object();
|
||||
private int _debounceMs = 200;
|
||||
|
||||
private void DebounceElapsed()
|
||||
{
|
||||
UpdateSnapshot();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Snapshot logic
|
||||
|
||||
private void BuildSnapshot()
|
||||
public void BuildSnapshot()
|
||||
{
|
||||
var cfg = _config.Settings;
|
||||
_logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", cfg.RootDirectories.Length);
|
||||
var entries = new List<FileEntry>();
|
||||
var index = LoadSnapshotIndex();
|
||||
|
||||
var latestModifiedUtcParallel = FileSystemExtensions.GetLatestModifiedUtcParallel(_options.RootDirectories);
|
||||
var fileInfo = new FileInfo(_snapshotPath);
|
||||
if (latestModifiedUtcParallel.HasValue && latestModifiedUtcParallel.Value < fileInfo.LastWriteTimeUtc)
|
||||
{
|
||||
_logger.LogInformation("Snapshot is up to date");
|
||||
return;
|
||||
}
|
||||
_logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", _options.RootDirectories.Count);
|
||||
var entries = new List<FileEntry>();
|
||||
|
||||
var snapshotChanged = false;
|
||||
foreach (var dir in cfg.RootDirectories)
|
||||
foreach (var dir in _options.RootDirectories)
|
||||
{
|
||||
if (!Directory.Exists(dir)) continue;
|
||||
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||
|
||||
if (!(cfg.WhitelistExtensions.Contains(ext) || cfg.RomExtensions.Contains(ext)))
|
||||
if (!(_options.WhitelistExtensions.Contains(ext) || _options.RomExtensions.Contains(ext)))
|
||||
continue;
|
||||
|
||||
if (index.ContainsKey(file)) continue;
|
||||
if (index.TryGetValue(file, out var value))
|
||||
{
|
||||
entries.Add(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3) extract title if applicable
|
||||
string hash;
|
||||
NcaMetadataWithHash? title = null;
|
||||
if (cfg.RomExtensions.Contains(ext))
|
||||
if (_options.RomExtensions.Contains(ext))
|
||||
{
|
||||
using var nspStream = File.OpenRead(file);
|
||||
hash = ComputeFirstStreamHash(nspStream);
|
||||
@@ -170,6 +212,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
|
||||
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries));
|
||||
if (snapshotChanged)
|
||||
{
|
||||
_logger.LogInformation("Snapshot rebuilt");
|
||||
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
@@ -183,14 +226,14 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
|
||||
|
||||
private void PersistSnapshot()
|
||||
{
|
||||
var entries = GetSnapshot();
|
||||
var newHash = ComputeSnapshotHash(entries);
|
||||
var snapshot = GetSnapshot();
|
||||
var newHash = ComputeSnapshotHash(snapshot.Files);
|
||||
if (_currentSnapshotHash != newHash)
|
||||
{
|
||||
_logger.LogInformation("Snapshot hash changed – persisting new snapshot");
|
||||
_currentSnapshotHash = newHash;
|
||||
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries));
|
||||
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(entries));
|
||||
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(snapshot.Files));
|
||||
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(snapshot.Files));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,10 +262,18 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
|
||||
}
|
||||
#endregion
|
||||
|
||||
public IReadOnlyList<FileEntry> GetSnapshot()
|
||||
public ROMSnapshot GetSnapshot()
|
||||
{
|
||||
if (!File.Exists(_jsonPath)) return new();
|
||||
var json = File.ReadAllText(_jsonPath);
|
||||
return JsonSerializer.Deserialize<IReadOnlyList<FileEntry>>(json, new JsonSerializerOptions(){IncludeFields = true})!;
|
||||
var hash = ComputeHash(_jsonPath);
|
||||
var romSnapshot = new ROMSnapshot()
|
||||
{
|
||||
Hash = hash,
|
||||
Files = JsonSerializer.Deserialize<IReadOnlyList<FileEntry>>(json,
|
||||
new JsonSerializerOptions() { IncludeFields = true })!
|
||||
};
|
||||
return romSnapshot;
|
||||
}
|
||||
|
||||
public void RebuildSnapshot()
|
||||
@@ -285,4 +336,10 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
|
||||
var hash = sha256.ComputeHash(stream);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
|
||||
public class ROMSnapshot
|
||||
{
|
||||
public string Hash { get; set; }
|
||||
public IReadOnlyList<FileEntry> Files { get; set; } = new List<FileEntry>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user