Working implementation
This commit is contained in:
@@ -4,51 +4,91 @@ using System.Text.Json;
|
||||
using TinfoilVibeServer.Models;
|
||||
|
||||
namespace TinfoilVibeServer.Services;
|
||||
public interface ISnapshotService
|
||||
{
|
||||
event EventHandler SnapshotRebuilt; // raised after a rebuild
|
||||
void RebuildSnapshot();
|
||||
IReadOnlyList<FileEntry> GetSnapshot();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps an in‑memory snapshot, watches the filesystem for changes, and
|
||||
/// only re‑processes a file if its hash changed.
|
||||
/// </summary>
|
||||
public sealed class SnapshotService : IDisposable
|
||||
public sealed class SnapshotService : IDisposable, ISnapshotService
|
||||
{
|
||||
private readonly ConfigManager _config;
|
||||
private readonly NSPExtractor _nspExtractor;
|
||||
private readonly ArchiveHandler _archiveHandler;
|
||||
private readonly string _jsonPath;
|
||||
private readonly string _snapshotPath;
|
||||
private readonly FileSystemWatcher _watcher;
|
||||
private readonly List<FileSystemWatcher> _watchers = new();
|
||||
private readonly ConcurrentDictionary<string, CachedFile> _cache = new();
|
||||
private string? _currentSnapshotHash;
|
||||
|
||||
public SnapshotService(ConfigManager config)
|
||||
public event EventHandler? SnapshotRebuilt;
|
||||
|
||||
public SnapshotService(ConfigManager config, NSPExtractor nspExtractor, ArchiveHandler archiveHandler)
|
||||
{
|
||||
_config = config;
|
||||
_jsonPath = Path.Combine(AppContext.BaseDirectory, _config.Settings.SnapshotFile);
|
||||
_nspExtractor = nspExtractor;
|
||||
_archiveHandler = archiveHandler;
|
||||
_jsonPath = Path.Combine(AppContext.BaseDirectory, _config.Settings.SnapshotFile);
|
||||
_snapshotPath = Path.Combine(AppContext.BaseDirectory, _config.Settings.SnapshotBackupFile);
|
||||
|
||||
BuildSnapshot(); // initial scan
|
||||
BuildSnapshot(); // initial scan
|
||||
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(GetSnapshot()));
|
||||
|
||||
_watcher = new FileSystemWatcher
|
||||
foreach (var path in _config.Settings.RootDirectories)
|
||||
{
|
||||
Path = string.Join(Path.PathSeparator, _config.Settings.RootDirectories),
|
||||
IncludeSubdirectories = true,
|
||||
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName |
|
||||
NotifyFilters.Size | NotifyFilters.LastWrite
|
||||
};
|
||||
_watcher.Created += OnChanged;
|
||||
_watcher.Changed += OnChanged;
|
||||
_watcher.Deleted += OnChanged;
|
||||
_watcher.Renamed += OnRenamed;
|
||||
_watcher.EnableRaisingEvents = true;
|
||||
|
||||
InitializeFileSystemWatcher(path);
|
||||
}
|
||||
|
||||
|
||||
_config.OnChange += cfg =>
|
||||
{
|
||||
_watcher.Path = string.Join(Path.PathSeparator, cfg.RootDirectories);
|
||||
_watcher.EnableRaisingEvents = true;
|
||||
BuildSnapshot(); // rebuild everything
|
||||
var fileSystemWatchers = _watchers.Where(watcher => !cfg.RootDirectories.Contains(watcher.Path));
|
||||
foreach (var watcher in fileSystemWatchers)
|
||||
{
|
||||
watcher.EnableRaisingEvents = false;
|
||||
watcher.Dispose();
|
||||
_watchers.Remove(watcher);
|
||||
}
|
||||
|
||||
var newWatchedDirectories = cfg.RootDirectories.Where(newWatchedDirectory =>
|
||||
!_watchers.Any(watcher =>
|
||||
string.Equals(watcher.Path, newWatchedDirectory, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
foreach (var newWatchedDirectory in newWatchedDirectories)
|
||||
{
|
||||
InitializeFileSystemWatcher(newWatchedDirectory);
|
||||
|
||||
}
|
||||
BuildSnapshot(); // rebuild everything
|
||||
PersistSnapshot();
|
||||
};
|
||||
}
|
||||
|
||||
private void InitializeFileSystemWatcher(string path)
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
var _watcher = new FileSystemWatcher
|
||||
{
|
||||
Path = path,
|
||||
IncludeSubdirectories = true,
|
||||
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName |
|
||||
NotifyFilters.Size | NotifyFilters.LastWrite
|
||||
};
|
||||
_watcher.Created += OnChanged;
|
||||
_watcher.Changed += OnChanged;
|
||||
_watcher.Deleted += OnChanged;
|
||||
_watcher.Renamed += OnRenamed;
|
||||
_watcher.EnableRaisingEvents = true;
|
||||
|
||||
_watchers.Add(_watcher);
|
||||
}
|
||||
}
|
||||
|
||||
#region FileSystemWatcher
|
||||
|
||||
private void OnChanged(object? _, FileSystemEventArgs e) => ThrottleSnapshotUpdate();
|
||||
@@ -71,9 +111,12 @@ public sealed class SnapshotService : IDisposable
|
||||
{
|
||||
var cfg = _config.Settings;
|
||||
var entries = new List<FileEntry>();
|
||||
var index = LoadSnapshotIndex(); // <‑ new
|
||||
|
||||
var snapshotChanged = false;
|
||||
foreach (var dir in cfg.RootDirectories)
|
||||
{
|
||||
if (!Directory.Exists(dir)) continue;
|
||||
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||
@@ -81,30 +124,52 @@ public sealed class SnapshotService : IDisposable
|
||||
if (!(cfg.WhitelistExtensions.Contains(ext) || cfg.RomExtensions.Contains(ext)))
|
||||
continue;
|
||||
|
||||
var hash = ComputeHash(file);
|
||||
|
||||
// Cache hit?
|
||||
if (_cache.TryGetValue(file, out var cached) && cached.Hash == hash)
|
||||
if (index.ContainsKey(file)) continue;
|
||||
|
||||
// 3) extract title if applicable
|
||||
string hash;
|
||||
NcaMetadataWithHash? title = null;
|
||||
if (cfg.RomExtensions.Contains(ext))
|
||||
{
|
||||
entries.Add(new FileEntry(file, new FileInfo(file).Length, hash, cached.Title));
|
||||
continue;
|
||||
using var nspStream = File.OpenRead(file);
|
||||
hash = ComputeFirstStreamHash(nspStream);
|
||||
|
||||
// 2) use cached title if unchanged
|
||||
if (index.TryGetValue(file, out var cached) && cached.Hash == hash)
|
||||
{
|
||||
entries.Add(cached);
|
||||
continue;
|
||||
}
|
||||
|
||||
title = _nspExtractor.ExtractFromStream(nspStream);
|
||||
}
|
||||
else
|
||||
{
|
||||
hash = ComputeFirstStreamHash(file);
|
||||
title = _archiveHandler.TryExtractTitleInfo(file);
|
||||
}
|
||||
|
||||
// Extract title if possible
|
||||
NcaMetadataDto? title = null;
|
||||
if (cfg.RomExtensions.Contains(ext))
|
||||
title = NSPExtactor.ExtractFromFile(file);
|
||||
else
|
||||
title = ArchiveHandler.TryExtractTitleInfo(file);
|
||||
|
||||
// 4) update cache
|
||||
_cache[file] = new CachedFile(file, hash, title);
|
||||
|
||||
// 5) add to snapshot
|
||||
entries.Add(new FileEntry(file, new FileInfo(file).Length, hash, title));
|
||||
snapshotChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the entire snapshot
|
||||
_currentSnapshotHash = ComputeSnapshotHash(entries);
|
||||
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries));
|
||||
if (snapshotChanged)
|
||||
{
|
||||
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private string ComputeFirstStreamHash(Stream nspStream)
|
||||
{
|
||||
return _nspExtractor.ExtractHashFromStream(nspStream);
|
||||
}
|
||||
|
||||
private void UpdateSnapshot() => BuildSnapshot();
|
||||
@@ -136,22 +201,80 @@ public sealed class SnapshotService : IDisposable
|
||||
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json));
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
private Dictionary<string, FileEntry> LoadSnapshotIndex()
|
||||
{
|
||||
if (!File.Exists(_jsonPath)) return new();
|
||||
|
||||
var json = File.ReadAllText(_jsonPath);
|
||||
var entries = JsonSerializer.Deserialize<List<FileEntry>>(json, new JsonSerializerOptions(){IncludeFields = true})!;
|
||||
return entries.ToDictionary(e => e.Path, e => e);
|
||||
}
|
||||
#endregion
|
||||
|
||||
public IReadOnlyList<FileEntry> GetSnapshot()
|
||||
{
|
||||
var json = File.ReadAllText(_jsonPath);
|
||||
return JsonSerializer.Deserialize<IReadOnlyList<FileEntry>>(json)!;
|
||||
return JsonSerializer.Deserialize<IReadOnlyList<FileEntry>>(json, new JsonSerializerOptions(){IncludeFields = true})!;
|
||||
}
|
||||
|
||||
public void RebuildSnapshot()
|
||||
{
|
||||
// Build a fresh snapshot and persist it.
|
||||
BuildSnapshot(); // private method inside the same class
|
||||
PersistSnapshot(); // private method inside the same class
|
||||
BuildSnapshot(); // private method inside the same class
|
||||
PersistSnapshot(); // private method inside the same class
|
||||
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public void Dispose() => _watcher.Dispose();
|
||||
|
||||
private sealed record CachedFile(string Path, string Hash, NcaMetadataDto? Title);
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var watcher in _watchers)
|
||||
{
|
||||
watcher.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CachedFile(string Path, string Hash, NcaMetadataWithHash? NcaMetadataWithHash);
|
||||
|
||||
// File: TinfoilVibeServer/Services/SnapshotService.cs (inside SnapshotService class)
|
||||
|
||||
private string ComputeFirstStreamHash(string filePath)
|
||||
{
|
||||
// Only treat NSP/XCI/XCZ as “first‑stream” files
|
||||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
if (ext is not ".nsp" and not ".xci" and not ".xcz")
|
||||
{
|
||||
|
||||
// Open the NSP/XCI with LibHac and read the first stream.
|
||||
// The first stream is the first entry returned by GetContentInfos().
|
||||
try
|
||||
{
|
||||
using var reader = new RomArchiveReader(filePath);
|
||||
|
||||
var first = reader.GetEntries().FirstOrDefault();
|
||||
if (first == null) return ComputeFullHash(filePath);
|
||||
|
||||
return _nspExtractor.ExtractHashFromStream(first.Stream);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// On error, fall back to the full file hash
|
||||
return ComputeFullHash(filePath);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs);
|
||||
return ncaMetadataWithHash?.Hash ?? string.Empty;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static string ComputeFullHash(string filePath)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
using var stream = File.OpenRead(filePath);
|
||||
var hash = sha256.ComputeHash(stream);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user