Files
TinfoilVibeServer/TinfoilVibeServer/Services/SnapshotService.cs
T
ecenshu 91f394f81b
ci / build_linux (push) Successful in 6m53s
ci / build_linux (pull_request) Successful in 3m25s
If filename can extract to a NcaMetadata entry, don't use nspextractor to pull information
Scan directories sequentially to reduce memory footprint
2025-11-15 17:28:41 +10:30

695 lines
27 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Concurrent;
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; // event raised after a rebuild
void RebuildSnapshot();
SnapshotService.ROMSnapshot GetSnapshot();
Task AddToSnapshotAsync(FileEntry entry);
Task BuildSnapshotAsync();
void GetArchiveName(string titleId);
char GetArchivePathSeparator();
}
/// <summary>
/// Keeps an inmemory snapshot, watches the filesystem for changes, and
/// only reprocesses a file if its hash changed.
/// </summary>
public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedService
{
#region FileSystemWatcher
private readonly List<FileSystemWatcher> _watchers = new();
#endregion
private readonly SnapshotOptions _options;
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();
private readonly ConcurrentDictionary<string, string> _hashCache = new();
// Archive full path -> FileEntry.Path
private readonly ConcurrentDictionary<string, string> _archiveLookup = new();
// hash -> file size
private readonly ConcurrentDictionary<string, long> _sizeLookup = new();
private readonly IMemoryCache _debouncerCache;
public event EventHandler? SnapshotRebuilt;
public event EventHandler? SnapshotRebuilding;
private readonly SemaphoreSlim _snapshotFileSemaphore = new(1,1);
private const char ArchivePathSeparator = '|';
public char GetArchivePathSeparator() => ArchivePathSeparator;
public SnapshotService(
IMemoryCache debouncerCache,
IOptionsMonitor<SnapshotOptions> options,
INSPExtractor nspExtractor,
IArchiveHandler archiveHandler,
ILogger<SnapshotService> logger,
IHostEnvironment environment
)
{
_options = options.CurrentValue;
_debouncerCache = debouncerCache;
_nspExtractor = nspExtractor;
_archiveHandler = archiveHandler;
_logger = logger;
_environment = environment;
_jsonPath = Path.Combine(Path.DirectorySeparatorChar.ToString(),"app","data", _options.SnapshotFile);
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_jsonPath) ?? throw new InvalidOperationException()));
if (!File.Exists(_jsonPath))
{
_snapshotFileSemaphore.Wait();
File.WriteAllText(_jsonPath, "[]");
_snapshotFileSemaphore.Release();
}
_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, arg) =>
{
_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)
{
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)
{
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();
}
#region FileSystemWatcher
private void AddWatchDirectory(string path)
{
if (!Directory.Exists(path)) return;
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;
_logger.LogInformation("Watching {Path}", path);
_watchers.Add(watcher);
}
private void RemoveWatchDirectory(string path)
{
var fileSystemWatchers = _watchers.FirstOrDefault(watcher => watcher.Path == path);
if (fileSystemWatchers == null) return;
fileSystemWatchers.EnableRaisingEvents = false;
fileSystemWatchers.Dispose();
_logger.LogInformation("Stopped watching {Path}", path);
_watchers.Remove(fileSystemWatchers);
}
private void OnChanged(object? _, FileSystemEventArgs e) => ThrottleSnapshotUpdate(e);
private void OnRenamed(object? _, RenamedEventArgs e) => ThrottleSnapshotUpdate(e);
private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs)
{
SnapshotRebuilding?.Invoke(this, fileSystemEventArgs);
CancellationTokenSource cts = new();
using var cacheEntry = _debouncerCache.CreateEntry(fileSystemEventArgs.FullPath)
.AddExpirationToken(new CancellationChangeToken(cts.Token))
.SetValue(fileSystemEventArgs)
.SetOptions(new MemoryCacheEntryOptions
{
PostEvictionCallbacks =
{
new PostEvictionCallbackRegistration
{
EvictionCallback =
(key, value, reason, state) =>
{
if (!(reason == EvictionReason.Expired || reason == EvictionReason.TokenExpired)) return;
if (value is FileSystemEventArgs args)
{
if (IsFileLocked(args.FullPath))
{
_logger.LogInformation("File {FilePath} is locked, skipping snapshot update", args.FullPath);
using var rebounce = _debouncerCache.CreateEntry(args.FullPath)
.SetAbsoluteExpiration(TimeSpan.FromMilliseconds(DebounceMs))
.SetValue(args);
}
}
RebuildSnapshot();
}
}
}
});
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.fff"));
}
private static bool IsFileLocked(string filePath)
{
FileStream? stream = null;
var file = new FileInfo(filePath);
try
{
stream = file.Open(FileMode.Open, FileAccess.ReadWrite, FileShare.None);
}
catch (IOException)
{
return true;
}
finally
{
stream?.Close();
}
return false;
}
private const int DebounceMs = 400;
private readonly JsonSerializerOptions _jsonSerializerOptions = new() { IncludeFields = true };
private int SnapshotFileLockTimeout { get; } = 1000;
private void DebounceElapsed()
{
UpdateSnapshot();
}
#endregion
#region Snapshot logic
public Task AddToSnapshotAsync(FileEntry entry)
{
// Update lookup tables
_cache[entry.Path] = new SnapshotEntry(entry.Path, entry.Hash, entry.Size, entry.Titles);
_hashCache[entry.Hash] = entry.Path;
_sizeLookup[entry.Hash] = entry.Size;
if (entry.Path.Contains(ArchivePathSeparator))
{
var filename = entry.Path.Split(ArchivePathSeparator)[0];
_archiveLookup[filename] = entry.Path;
}
foreach (var ncaMetadataWithHash in entry.Titles)
{
_hashCache[ncaMetadataWithHash.Hash] = entry.Path;
_sizeLookup[ncaMetadataWithHash.Hash] = entry.Size;
_logger.LogInformation("Added entry {titleId} to snapshot (hash={hash})", ncaMetadataWithHash.TitleId, ncaMetadataWithHash.Hash);
}
// Persist snapshot to disk
PersistSnapshotAsync();
return Task.CompletedTask;
}
/// Builds _cache and _hashCache based on directory configuration
public Task BuildSnapshotAsync()
{
_logger.LogInformation("Building snapshot");
var index = LoadSnapshotIndex();
var latestModifiedUtcParallel = FileSystemExtensions.GetLatestModifiedUtcParallel(_options.RootDirectories);
var fileInfo = new FileInfo(_snapshotPath);
bool snapshotVerified = fileInfo.Exists;
if (latestModifiedUtcParallel.HasValue && latestModifiedUtcParallel.Value < fileInfo.LastWriteTimeUtc)
{
if (index.Count != 0)
{
foreach (var dir in _options.RootDirectories)
{
var firstEntry = BuildSnapshot(dir).FirstOrDefault();
if (firstEntry != null && !index.TryGetValue(firstEntry.Path, out _))
{
snapshotVerified = false;
_logger.LogInformation("Snapshot does not contain first entry in directory {Directory}", dir);
}
}
}
}
if (!snapshotVerified)
{
_logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", _options.RootDirectories.Count);
var entries = new List<FileEntry>();
foreach (var dir in _options.RootDirectories)
{
foreach (var entry in BuildSnapshot(dir))
{
if (entry != null) entries.Add(entry);
}
}
var currentHash = ComputeSnapshotHash(entries);
if (entries.Count > 0 || fileInfo.Exists && index.Count == 0)
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
}
PersistSnapshotAsync();
return Task.CompletedTask;
}
public void GetArchiveName(string titleId)
{
;
}
// Returns List of FileEntry that do not have a hash in the cache
// Each entry that has not been added to the lookup table is added to the cache
private IEnumerable<FileEntry?> BuildSnapshot(string dir)
{
if (!Directory.Exists(dir)) yield break;
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
{
var hash = string.Empty;
var ext = Path.GetExtension(file).ToLowerInvariant();
if (!(_options.ArchiveExtensions.Contains(ext) || _options.RomExtensions.Contains(ext)))
continue;
if (_cache.ContainsKey(file) || _hashCache.ContainsKey(hash))
{
continue;
}
// 3) extract title if applicable
var titles = new List<(string, long, NcaMetadataWithHash)>();
if (_options.RomExtensions.Contains(ext))
{
var fileInfo = new FileInfo(file);
var ncaMetadataWithHash = fileInfo.GetNcaMetadataWithHash();
if (ncaMetadataWithHash != null)
{
//var titleInfo = _titleDatabaseService.GetAsync(ncaMetadataWithHash.TitleId).Result;
var fileEntryFromFileName = new FileEntry(file, fileInfo.Length, ncaMetadataWithHash.Hash, [ncaMetadataWithHash]);
AddToSnapshotAsync(fileEntryFromFileName);
yield return fileEntryFromFileName;
}
using var nspStream = File.OpenRead(file);
hash = ComputeFirstStreamHash(nspStream);
if (_hashCache.ContainsKey(hash))
{
continue;
}
var nspStreamLength = nspStream.Length;
var title = _nspExtractor.ExtractFromStream(nspStream);
if (title != null)
{
var archiveEntry = new FileEntry(file, nspStreamLength, hash, [title]);
AddToSnapshotAsync(archiveEntry);
titles.Add((title.TitleId, nspStreamLength, title));
yield return archiveEntry;
}
}
else
{
if (_options.ArchiveExtensions.Contains(ext))
{
if (_archiveLookup.ContainsKey(file)) continue;
hash = ComputeFirstStreamHash(file);
if (_hashCache.ContainsKey(hash))
{
yield return null;
}
IEnumerable<(string, long, NcaMetadataWithHash)>? titlesEnumerable = null;
try
{
titlesEnumerable = _archiveHandler.TryExtractTitleInfos(file);
}
catch (Exception e)
{
_logger.LogError(e, "Failed to extract title info from archive {Archive}", file);
}
if (titlesEnumerable == null) continue;
titles = titlesEnumerable.ToList();
foreach (var title in titles)
{
var archiveEntry = new FileEntry(file + ArchivePathSeparator + title.Item1, title.Item2, title.Item3.Hash, [title.Item3]);
AddToSnapshotAsync(archiveEntry);
yield return archiveEntry;
}
/*var fileEntry = new FileEntry(file, new FileInfo(file).Length, hash, titles.Select((tuple, i) => tuple.Item3).ToList());
AddToSnapshotAsync(fileEntry);
yield return fileEntry;*/
}
else
{
continue;
}
}
if (titles.Count == 0)
{
_logger.LogInformation("Failed to process {File}", file);
}
else
{
_logger.LogInformation("Added {File} to snapshot (hash={Hash})", file, hash);
yield return new FileEntry(file, titles.Select((tuple, i) => tuple.Item2).FirstOrDefault(), hash, titles.Select((tuple, i) => tuple.Item3).ToList());
}
}
}
private async Task ValidateSnapshotAsync(CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
}
private string ComputeFirstStreamHash(Stream nspStream) => _nspExtractor.ExtractHashFromStream(nspStream);
private void UpdateSnapshot() => BuildSnapshotAsync();
private IEnumerable<FileEntry> GetEntries()
{
foreach (var kv in _cache)
yield return new FileEntry(kv.Key, kv.Value.Size, kv.Value.Hash, kv.Value.NcaMetadataWithHash);
}
private Task PersistSnapshotAsync()
{
if (_debouncerCache.TryGetValue(_jsonPath, out _))
{
_logger.LogInformation("Sliding debounce in progress, skipping snapshot persistence");
return Task.CompletedTask;
}
var entries = GetEntries().ToList();
var newHash = ComputeSnapshotHash(entries);
var snapshot = GetSnapshot();
if (snapshot.Hash == newHash) return Task.CompletedTask;
var cancellationTokenSource = new CancellationTokenSource();
using var cacheEntry = _debouncerCache.CreateEntry(_jsonPath)
.AddExpirationToken(new CancellationChangeToken(cancellationTokenSource.Token))
.SetValue(entries)
.SetOptions(new MemoryCacheEntryOptions
{
PostEvictionCallbacks =
{
new PostEvictionCallbackRegistration
{
EvictionCallback = (key, value, reason, state) =>
{
if (!(reason == EvictionReason.Expired || reason == EvictionReason.TokenExpired))
return;
var filePath = (string)key;
if (_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout))
{
try
{
if (IsFileLocked(filePath))
{
_logger.LogInformation("File {FilePath} is locked, skipping snapshot persistence", filePath);
}
else
{
File.WriteAllText(filePath, JsonSerializer.Serialize(value, _jsonSerializerOptions));
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
}
}
finally
{
_snapshotFileSemaphore.Release();
}
}
}
}
}
});
cancellationTokenSource.CancelAfter(TimeSpan.FromMilliseconds(DebounceMs));
return Task.CompletedTask;
}
private static string ComputeHash(string filePath)
{
using var sha = SHA256.Create();
using var stream = File.OpenRead(filePath);
var hash = sha.ComputeHash(stream);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
private static string ComputeSnapshotHash(IEnumerable<FileEntry> entries)
{
var json = JsonSerializer.Serialize(entries);
using var sha = SHA256.Create();
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json));
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
/// <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()
{
if (!File.Exists(_jsonPath)) return new Dictionary<string, FileEntry>();
_snapshotFileSemaphore.Wait();
var json = File.ReadAllText(_jsonPath);
_snapshotFileSemaphore.Release();
var entries = JsonSerializer.Deserialize<List<FileEntry>>(json, _jsonSerializerOptions)!;
try
{
var fileEntries = new Dictionary<string, FileEntry>();
// Reindex the cache
foreach (var fileEntry in entries)
{
if (_hashCache.TryGetValue(fileEntry.Hash, out var value))
{
_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;
}
var fileContainedInRootDirectories = false;
foreach (var optionsRootDirectory in _options.RootDirectories)
{
if (fileEntry.Path.StartsWith(optionsRootDirectory))
{
fileContainedInRootDirectories = true;
break;
}
}
if (!fileContainedInRootDirectories)
{
_logger.LogInformation("Entry {Path} is not contained in any root directory", 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!);
_archiveLookup[filename] = fileEntry.Path;
}
else
{
_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
if (fileEntry.Titles == null) continue;
foreach (var ncaMetadataWithHash in fileEntry.Titles)
{
_hashCache[ncaMetadataWithHash.Hash] = fileEntry.Path;
}
}
}
}
_logger.LogInformation("Loaded snapshot index {Count} entries", fileEntries.Count);
return fileEntries;
}
catch (ArgumentException e)
{
_logger.LogError(e, "Failed to load snapshot");
return new();
}
}
public void RebuildSnapshot()
{
// 1️⃣ Flush the old inmemory snapshot
_cache.Clear();
_hashCache.Clear();
_archiveLookup.Clear();
_sizeLookup.Clear();
//_failedAttempts.Clear(); // if you keep peruser counters
// 2️⃣ Rebuild from disk again
BuildSnapshotAsync().Wait(); // synchronous we already own the lock
PersistSnapshotAsync().Wait(); // same
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
}
#endregion
public ROMSnapshot GetSnapshot()
{
if (!File.Exists(_jsonPath)) return new ROMSnapshot();
if (_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout))
{
try
{
var json = File.ReadAllText(_jsonPath);
var hash = ComputeHash(_jsonPath);
var romSnapshot = new ROMSnapshot
{
Hash = hash,
Files = JsonSerializer.Deserialize<IReadOnlyList<FileEntry>>(json, _jsonSerializerOptions)!
};
return romSnapshot;
}
catch (Exception e)
{
_logger.LogError(e, "Failed to load snapshot");
}
finally
{
_snapshotFileSemaphore.Release();
}
}
else
{
_logger.LogWarning("Failed to load snapshot due to timeout");
}
return new ROMSnapshot();
}
public void Dispose()
{
foreach (var watcher in _watchers)
{
watcher.Dispose();
}
}
private sealed record SnapshotEntry(string Path, string Hash, long Size, List<NcaMetadataWithHash> NcaMetadataWithHash);
// File: TinfoilVibeServer/Services/SnapshotService.cs (inside SnapshotService class)
private string ComputeFirstStreamHash(string filePath)
{
// Only treat NSP/XCI/XCZ as “firststream” 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);
using var firstStream = first.Stream;
var hash = _nspExtractor.ExtractHashFromStream(firstStream);
return hash;
}
catch
{
// On error, fall back to the full file hash
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs);
return ncaMetadataWithHash?.Hash ?? string.Empty;
}
}
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();
}
public class ROMSnapshot
{
public string? Hash { get; set; }
public IReadOnlyList<FileEntry> Files { get; set; } = new List<FileEntry>();
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting snapshot service");
_ = Task.Run(async () =>
{
await ValidateSnapshotAsync(cancellationToken);
await BuildSnapshotAsync();
await PersistSnapshotAsync();
}, cancellationToken); // initial scan
/*var timer = new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);*/
await Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
Dispose();
return Task.CompletedTask;
}
}