feature/ci (#1)
Consolidate data and config into separate folders that will be expected to be mapped in the container Reviewed-on: #1 Co-authored-by: Huy Nguyen <ecenshu@gmail.com> Co-committed-by: Huy Nguyen <ecenshu@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
@@ -3,13 +3,14 @@ using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using TinfoilVibeServer.Models;
|
||||
using TinfoilVibeServer.Utilities;
|
||||
|
||||
namespace TinfoilVibeServer.Services;
|
||||
public interface ISnapshotService
|
||||
{
|
||||
event EventHandler SnapshotRebuilt; // raised after a rebuild
|
||||
event EventHandler SnapshotRebuilt; // event raised after a rebuild
|
||||
void RebuildSnapshot();
|
||||
SnapshotService.ROMSnapshot GetSnapshot();
|
||||
|
||||
@@ -33,6 +34,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
||||
private readonly INSPExtractor _nspExtractor;
|
||||
private readonly IArchiveHandler _archiveHandler;
|
||||
private readonly ILogger<SnapshotService> _logger;
|
||||
private readonly IHostEnvironment _environment;
|
||||
private readonly string _jsonPath;
|
||||
private readonly string _snapshotPath;
|
||||
private readonly ConcurrentDictionary<string, SnapshotEntry> _cache = new();
|
||||
@@ -54,67 +56,68 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
||||
IOptionsMonitor<SnapshotOptions> options,
|
||||
INSPExtractor nspExtractor,
|
||||
IArchiveHandler archiveHandler,
|
||||
ILogger<SnapshotService> logger)
|
||||
ILogger<SnapshotService> logger,
|
||||
IHostEnvironment environment
|
||||
)
|
||||
{
|
||||
_options = options.CurrentValue;
|
||||
_debouncerCache = debouncerCache;
|
||||
_nspExtractor = nspExtractor;
|
||||
_archiveHandler = archiveHandler;
|
||||
_logger = logger;
|
||||
_jsonPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotFile);
|
||||
_environment = environment;
|
||||
_jsonPath = Path.Combine(Path.DirectorySeparatorChar.ToString(),"app","data", _options.SnapshotFile);
|
||||
|
||||
// Debounce timer for persisting snapshot
|
||||
long debounceTime = 200;
|
||||
var entryOptions = new MemoryCacheEntryOptions()
|
||||
.SetSlidingExpiration(TimeSpan.FromSeconds(debounceTime)).RegisterPostEvictionCallback((key, value, reason,
|
||||
state) =>
|
||||
{
|
||||
|
||||
_logger.LogInformation("Should persist the snapshot {Key}, {Reason}", key, reason);
|
||||
}); // <‑‑ sliding!
|
||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_jsonPath)));
|
||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_jsonPath) ?? throw new InvalidOperationException()));
|
||||
if (!File.Exists(_jsonPath))
|
||||
{
|
||||
_snapshotFileSemaphore.Wait();
|
||||
File.WriteAllText(_jsonPath, "[]");
|
||||
_snapshotFileSemaphore.Release();
|
||||
}
|
||||
_snapshotPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotBackupFile);
|
||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_snapshotPath)));
|
||||
// 1️⃣ Register for *property* changes
|
||||
_options.PropertyChanged += (s, e) => OnOptionsChanged(e.PropertyName);
|
||||
_snapshotPath = Path.Combine(Path.DirectorySeparatorChar.ToString(),"app","data", _options.SnapshotBackupFile);
|
||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_snapshotPath) ?? throw new InvalidOperationException()));
|
||||
|
||||
// 1️⃣ Register for *property* changes
|
||||
options.OnChange(snapshotOptions =>
|
||||
{
|
||||
_options.RootDirectories = snapshotOptions.RootDirectories;
|
||||
});
|
||||
_options.PropertyChanged += (s, e) => OnOptionsChanged(e.PropertyName);
|
||||
|
||||
if (_options.RootDirectories.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No directories set to watch for ROMS/Archives");
|
||||
}
|
||||
foreach (var path in _options.RootDirectories)
|
||||
{
|
||||
AddWatchDirectory(path);
|
||||
}
|
||||
}
|
||||
// --------- Private helpers ---------
|
||||
private void OnOptionsChanged(string propertyName)
|
||||
private void OnOptionsChanged(string? propertyName)
|
||||
{
|
||||
if (propertyName == nameof(SnapshotOptions.RootDirectories))
|
||||
if (propertyName != nameof(SnapshotOptions.RootDirectories)) return;
|
||||
|
||||
_logger.LogInformation("Root directories changed, rebuilding snapshot");
|
||||
var fileSystemWatchers = _watchers.Where(watcher => !_options.RootDirectories.Contains(watcher.Path));
|
||||
var systemWatchers = fileSystemWatchers.ToList();
|
||||
foreach (var watcher in systemWatchers)
|
||||
{
|
||||
var fileSystemWatchers = _watchers.Where(watcher => !_options.RootDirectories.Contains(watcher.Path));
|
||||
foreach (var watcher in fileSystemWatchers)
|
||||
{
|
||||
watcher.EnableRaisingEvents = false;
|
||||
watcher.Dispose();
|
||||
_watchers.Remove(watcher);
|
||||
}
|
||||
|
||||
var newWatchedDirectories = _options.RootDirectories.Where(newWatchedDirectory =>
|
||||
!_watchers.Any(watcher =>
|
||||
string.Equals(watcher.Path, newWatchedDirectory, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
foreach (var newWatchedDirectory in newWatchedDirectories)
|
||||
{
|
||||
AddWatchDirectory(newWatchedDirectory);
|
||||
|
||||
}
|
||||
|
||||
BuildSnapshotAsync(); // rebuild everything
|
||||
PersistSnapshotAsync();
|
||||
RemoveWatchDirectory(watcher.Path);
|
||||
}
|
||||
|
||||
var newWatchedDirectories = _options.RootDirectories.Where(newWatchedDirectory =>
|
||||
!_watchers.Any(watcher =>
|
||||
string.Equals(watcher.Path, newWatchedDirectory, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
foreach (var newWatchedDirectory in newWatchedDirectories)
|
||||
{
|
||||
AddWatchDirectory(newWatchedDirectory);
|
||||
}
|
||||
|
||||
BuildSnapshotAsync(); // rebuild everything
|
||||
PersistSnapshotAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -154,8 +157,10 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
||||
private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs)
|
||||
{
|
||||
SnapshotRebuilding?.Invoke(this, fileSystemEventArgs);
|
||||
CancellationTokenSource cts = new();
|
||||
|
||||
using var cacheEntry = _debouncerCache.CreateEntry(fileSystemEventArgs.FullPath)
|
||||
//.SetAbsoluteExpiration(TimeSpan.FromMilliseconds(DebounceMs))
|
||||
.AddExpirationToken(new CancellationChangeToken(cts.Token))
|
||||
.SetValue(fileSystemEventArgs)
|
||||
.SetOptions(new MemoryCacheEntryOptions
|
||||
{
|
||||
@@ -166,7 +171,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
||||
EvictionCallback =
|
||||
(key, value, reason, state) =>
|
||||
{
|
||||
if (reason != EvictionReason.Expired) return;
|
||||
if (!(reason == EvictionReason.Expired || reason == EvictionReason.TokenExpired)) return;
|
||||
|
||||
if (value is FileSystemEventArgs args)
|
||||
{
|
||||
@@ -184,10 +189,9 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
||||
}
|
||||
}
|
||||
});
|
||||
cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(DebounceMs);
|
||||
|
||||
cts.CancelAfter(TimeSpan.FromMilliseconds(DebounceMs));
|
||||
_logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType,
|
||||
fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss"));
|
||||
fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss.fff"));
|
||||
}
|
||||
private static bool IsFileLocked(string filePath)
|
||||
{
|
||||
@@ -299,9 +303,10 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
||||
});
|
||||
}
|
||||
|
||||
var snapshotEmptied = fileInfo.Exists && index.Count == 0 && _options.RootDirectories.Count == 0;
|
||||
// Replace the entire snapshot
|
||||
ComputeSnapshotHash(entries);
|
||||
if (snapshotChanged)
|
||||
var currentSnapshotHash = ComputeSnapshotHash(entries);
|
||||
if (snapshotChanged || snapshotEmptied)
|
||||
{
|
||||
_logger.LogInformation("Snapshot rebuilt");
|
||||
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
||||
@@ -318,11 +323,10 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
||||
// Each entry that has not been added to the lookup table is added to the cache
|
||||
private IEnumerable<FileEntry?> BuildSnapshot(string dir)
|
||||
{
|
||||
FileEntry entry;
|
||||
if (!Directory.Exists(dir)) yield break;
|
||||
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
string hash = string.Empty;
|
||||
var hash = string.Empty;
|
||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||
|
||||
if (!(_options.ArchiveExtensions.Contains(ext) || _options.RomExtensions.Contains(ext)))
|
||||
@@ -406,6 +410,11 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ValidateSnapshotAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private string ComputeFirstStreamHash(Stream nspStream)
|
||||
{
|
||||
@@ -436,9 +445,11 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
||||
var newHash = ComputeSnapshotHash(fileEntries);
|
||||
if (snapshot.Hash == newHash) return Task.CompletedTask;
|
||||
|
||||
CancellationTokenSource cts = new();
|
||||
_logger.LogInformation("Snapshot hash changed – persisting new snapshot");
|
||||
using var debouncedPersistence = _debouncerCache.CreateEntry(_jsonPath);
|
||||
debouncedPersistence.SlidingExpiration = TimeSpan.FromMilliseconds(DebounceMs);
|
||||
debouncedPersistence.AddExpirationToken(new CancellationChangeToken(cts.Token));
|
||||
//debouncedPersistence.AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(DebounceMs);
|
||||
debouncedPersistence.Value = fileEntries;
|
||||
debouncedPersistence.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration
|
||||
{
|
||||
@@ -468,6 +479,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
||||
}
|
||||
}
|
||||
});
|
||||
cts.CancelAfter(TimeSpan.FromMilliseconds(DebounceMs));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -489,6 +501,8 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
||||
}
|
||||
/// <summary>
|
||||
/// From filesystem cache, load each entry and build the lookups
|
||||
/// Check for duplicate hashes
|
||||
/// Check for nonexistent entries against filesystem
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
private Dictionary<string, FileEntry> LoadSnapshotIndex()
|
||||
@@ -509,17 +523,23 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
||||
_logger.LogWarning("Duplicate hash found in snapshot: {Hash}, {OldPath}, {newPath}", fileEntry.Hash, value, fileEntry.Path);
|
||||
}
|
||||
|
||||
if (!File.Exists(fileEntry.Path))
|
||||
{
|
||||
_logger.LogWarning("Nonexistent entry found: {Path}", fileEntry.Path);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_options.RomExtensions.Contains(Path.GetExtension(fileEntry.Path)))
|
||||
{
|
||||
if (fileEntry.Path.Contains(ArchivePathSeparator))
|
||||
{
|
||||
var filename = fileEntry.Path.Split(ArchivePathSeparator)[0];
|
||||
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileEntry.Titles);
|
||||
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileEntry.Titles!);
|
||||
_archiveLookup[filename] = fileEntry.Path;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileEntry.Titles);
|
||||
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileEntry.Titles!);
|
||||
fileEntries.TryAdd(fileEntry.Path, fileEntry);
|
||||
_hashCache[fileEntry.Hash] = fileEntry.Path;
|
||||
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
|
||||
@@ -531,6 +551,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
||||
}
|
||||
}
|
||||
}
|
||||
_logger.LogInformation("Loaded snapshot index {Count} entries", fileEntries.Count);
|
||||
return fileEntries;
|
||||
}
|
||||
catch (ArgumentException e)
|
||||
@@ -540,7 +561,6 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void RebuildSnapshot()
|
||||
{
|
||||
// 1️⃣ Flush the old in‑memory snapshot
|
||||
@@ -658,10 +678,12 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
||||
_logger.LogInformation("Starting snapshot service");
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await ValidateSnapshotAsync(cancellationToken);
|
||||
await BuildSnapshotAsync();
|
||||
await PersistSnapshotAsync();
|
||||
}, cancellationToken); // initial scan
|
||||
new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);
|
||||
/*var timer = new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);*/
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
|
||||
Reference in New Issue
Block a user