feature/ci (#1)
Build & Push Docker image / build-and-push (push) Has been cancelled
ci / build_linux (push) Has been cancelled

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:
2025-11-13 09:11:21 +00:00
committed by ecenshu
parent 314af37b3d
commit a1ea34bc01
37 changed files with 511 additions and 295 deletions
+75 -53
View File
@@ -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 inmemory 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)