Synchronous Snapshot Build and ordered persistence (#5)
Build & Push Docker image / build-and-push (push) Successful in 7m5s
ci / build_linux (push) Successful in 4m53s

Fix build warnings
Snapshot now persisted in lastmodified date descending, hopefully aligns with snapshot simple check against first entry in a directory not existing in the snapshot during build
Building Snapshot should only be done synchrnonously and atomically
Blacklist watched for changes

Reviewed-on: #5
Co-authored-by: Huy Nguyen <ecenshu@gmail.com>
Co-committed-by: Huy Nguyen <ecenshu@gmail.com>
This commit was merged in pull request #5.
This commit is contained in:
2025-11-16 01:27:43 +00:00
committed by ecenshu
parent 8751a72176
commit cb60d768df
8 changed files with 279 additions and 178 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IP/@EntryIndexedValue">IP</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IP/@EntryIndexedValue">IP</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=NSP/@EntryIndexedValue">NSP</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=NSP/@EntryIndexedValue">NSP</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=PFS/@EntryIndexedValue">PFS</s:String></wpf:ResourceDictionary> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=PFS/@EntryIndexedValue">PFS</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=ROM/@EntryIndexedValue">ROM</s:String></wpf:ResourceDictionary>
+37 -15
View File
@@ -34,10 +34,11 @@ public class AuthStore : IDisposable, IAuthStore
public readonly ConcurrentDictionary<string, Credential> Credentials = new(); public readonly ConcurrentDictionary<string, Credential> Credentials = new();
public readonly ConcurrentDictionary<string, List<string>> Fingerprints = new(); public readonly ConcurrentDictionary<string, List<string>> Fingerprints = new();
public readonly ConcurrentDictionary<string, int> FailedAttempts = new(); public readonly ConcurrentDictionary<string, int> FailedAttempts = new();
private readonly HashSet<string> BlacklistIPs = new(); private readonly HashSet<string> _blacklistIPs = new();
private readonly object _sync = new(); private readonly Lock _sync = new();
private readonly FileSystemWatcher _credentialsWatcher; private readonly FileSystemWatcher _credentialsWatcher;
private readonly FileSystemWatcher _blacklistWatcher;
public AuthStore(ILogger<AuthStore> logger, ConfigManager configManager, IHostEnvironment env) public AuthStore(ILogger<AuthStore> logger, ConfigManager configManager, IHostEnvironment env)
{ {
@@ -56,6 +57,16 @@ public class AuthStore : IDisposable, IAuthStore
}; };
_credentialsWatcher.Changed += (_, _) => OnCredentialsChanged(); _credentialsWatcher.Changed += (_, _) => OnCredentialsChanged();
_credentialsWatcher.EnableRaisingEvents = true; _credentialsWatcher.EnableRaisingEvents = true;
_blacklistWatcher = new FileSystemWatcher
{
Path = (!string.IsNullOrEmpty(directoryName)) ? directoryName : AppContext.BaseDirectory,
Filter = Path.GetFileName(_configManager.Settings?.BlacklistFile) ?? throw new InvalidOperationException(),
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes
};
_blacklistWatcher.Changed += (_, _) => OnBlacklistChanged();
_blacklistWatcher.EnableRaisingEvents = true;
_logger.LogInformation("Started watching credentials file {File}", credentialsFilePath);
_logger.LogInformation("Started watching blacklist file {File}", Path.Combine(env.ContentRootPath, "data", Path.GetFileName(_configManager.Settings?.BlacklistFile) ?? throw new InvalidOperationException()));
} }
private static string DetermineCredentialsPath(string? settingsCredentialsFile, IHostEnvironment env) private static string DetermineCredentialsPath(string? settingsCredentialsFile, IHostEnvironment env)
@@ -66,7 +77,7 @@ public class AuthStore : IDisposable, IAuthStore
public void Dispose() public void Dispose()
{ {
_credentialsWatcher?.Dispose(); _credentialsWatcher.Dispose();
} }
#region Loading helpers #region Loading helpers
@@ -89,7 +100,7 @@ public class AuthStore : IDisposable, IAuthStore
} }
// fingerprints // fingerprints
if (File.Exists(_configManager.Settings.FingerprintsFile)) if (File.Exists(_configManager.Settings?.FingerprintsFile))
{ {
var txt = File.ReadAllText(_configManager.Settings.FingerprintsFile); var txt = File.ReadAllText(_configManager.Settings.FingerprintsFile);
var dict = JsonSerializer.Deserialize<Dictionary<string, List<string>>>(txt)!; var dict = JsonSerializer.Deserialize<Dictionary<string, List<string>>>(txt)!;
@@ -98,24 +109,26 @@ public class AuthStore : IDisposable, IAuthStore
} }
else else
{ {
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.FingerprintsFile))); if (_configManager.Settings?.FingerprintsFile != null)
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.FingerprintsFile)));
} }
// blacklist // blacklist
if (File.Exists(_configManager.Settings.BlacklistFile)) if (File.Exists(_configManager.Settings?.BlacklistFile))
{ {
var txt = File.ReadAllText(_configManager.Settings.BlacklistFile); var txt = File.ReadAllText(_configManager.Settings.BlacklistFile);
var arr = JsonSerializer.Deserialize<string[]>(txt)!; var arr = JsonSerializer.Deserialize<string[]>(txt)!;
foreach (var ip in arr) foreach (var ip in arr)
BlacklistIPs.Add(ip); _blacklistIPs.Add(ip);
} }
else else
{ {
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.BlacklistFile))); if (_configManager.Settings?.BlacklistFile != null)
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.BlacklistFile)));
} }
_logger.LogInformation("Loaded {UserCount} users, {FpCount} fingerprints, {IpCount} IPs", _logger.LogInformation("Loaded {UserCount} users, {FpCount} fingerprints, {IpCount} IPs",
Credentials.Count, Fingerprints.Count, BlacklistIPs.Count); Credentials.Count, Fingerprints.Count, _blacklistIPs.Count);
} }
#endregion #endregion
@@ -131,6 +144,16 @@ public class AuthStore : IDisposable, IAuthStore
ReloadCredentials(); ReloadCredentials();
}); });
} }
private void OnBlacklistChanged()
{
// Small debounce the file may still be locked by the editor.
Task.Run(async () =>
{
await Task.Delay(200);
LoadAll();
});
}
private void ReloadCredentials() private void ReloadCredentials()
{ {
@@ -143,7 +166,6 @@ public class AuthStore : IDisposable, IAuthStore
try try
{ {
var txt = File.ReadAllText(credentialsFilePath); var txt = File.ReadAllText(credentialsFilePath);
var newDict = JsonSerializer.Deserialize<Dictionary<string, Credential>>(txt)!; var newDict = JsonSerializer.Deserialize<Dictionary<string, Credential>>(txt)!;
@@ -264,7 +286,7 @@ public class AuthStore : IDisposable, IAuthStore
if (newCount < _configManager.Settings.MaxFailedAttempts + 1) return newCount; if (newCount < _configManager.Settings.MaxFailedAttempts + 1) return newCount;
BlacklistIPs.Add(ip); _blacklistIPs.Add(ip);
PersistBlacklist(); PersistBlacklist();
lock (_sync) lock (_sync)
{ {
@@ -319,7 +341,7 @@ public class AuthStore : IDisposable, IAuthStore
private void PersistBlacklist() private void PersistBlacklist()
{ {
var json = JsonSerializer.Serialize(BlacklistIPs.ToArray(), new JsonSerializerOptions { WriteIndented = true }); var json = JsonSerializer.Serialize(_blacklistIPs.ToArray(), new JsonSerializerOptions { WriteIndented = true });
if (_configManager.Settings == null) if (_configManager.Settings == null)
{ {
_logger.LogCritical("Blacklist file not set, cannot persist"); _logger.LogCritical("Blacklist file not set, cannot persist");
@@ -336,17 +358,17 @@ public class AuthStore : IDisposable, IAuthStore
public bool IsIPBlacklisted(string ipAddress) public bool IsIPBlacklisted(string ipAddress)
{ {
return BlacklistIPs.Contains(ipAddress); return _blacklistIPs.Contains(ipAddress);
} }
public bool UnbanIp(string ipAddress) public bool UnbanIp(string ipAddress)
{ {
return BlacklistIPs.Remove(ipAddress); return _blacklistIPs.Remove(ipAddress);
} }
public bool BlacklistActive() public bool BlacklistActive()
{ {
return BlacklistIPs.Count > 0; return _blacklistIPs.Count > 0;
} }
#endregion #endregion
@@ -1,33 +1,15 @@
using System; using Microsoft.AspNetCore.Mvc;
using System.IO;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using SharpCompress.Readers; using SharpCompress.Readers;
using TinfoilVibeServer.Models; using TinfoilVibeServer.Models;
using TinfoilVibeServer.Services; using TinfoilVibeServer.Services;
using TinfoilVibeServer.Utilities;
namespace TinfoilVibeServer.Controllers; namespace TinfoilVibeServer.Controllers;
[ApiController] [ApiController]
[Route("/")] [Route("/")]
public sealed class IndexController : ControllerBase public sealed class IndexController(ISnapshotService snapshotService, IndexBuilderService indexBuilderService) : ControllerBase
{ {
private readonly ISnapshotService _snapshotService;
private readonly TitleDatabaseService _titleDb;
private readonly IConfiguration _configuration;
private readonly IndexBuilderService _indexBuilderService;
public IndexController(ISnapshotService snapshotService,
TitleDatabaseService titleDb,
IConfiguration configuration, IndexBuilderService indexBuilderService)
{
_snapshotService = snapshotService;
_titleDb = titleDb;
_configuration = configuration;
_indexBuilderService = indexBuilderService;
}
// ------------------------------------------------------------ // ------------------------------------------------------------
// GET / // GET /
// ------------------------------------------------------------ // ------------------------------------------------------------
@@ -40,10 +22,10 @@ public sealed class IndexController : ControllerBase
{ {
if (HttpContext.Request.Headers.CacheControl == "no-cache") if (HttpContext.Request.Headers.CacheControl == "no-cache")
{ {
_indexBuilderService.InvalidateIndex(this, EventArgs.Empty); indexBuilderService.InvalidateIndex(this, EventArgs.Empty);
} }
var index = _indexBuilderService.Build(HttpContext); var index = indexBuilderService.Build(HttpContext);
return Ok(index); return Ok(index);
} }
@@ -75,7 +57,7 @@ public sealed class IndexController : ControllerBase
var titleId = match.Groups["id"].Value.ToUpperInvariant(); var titleId = match.Groups["id"].Value.ToUpperInvariant();
// ---- 2️⃣ Find the file that contains this TitleId ------------ // ---- 2️⃣ Find the file that contains this TitleId ------------
var entry = _snapshotService.GetSnapshot().Files var entry = snapshotService.GetSnapshot().Files
.FirstOrDefault(e => { return e.Titles.FirstOrDefault(hash => hash.TitleId == titleId)?.TitleId == titleId; }); .FirstOrDefault(e => { return e.Titles.FirstOrDefault(hash => hash.TitleId == titleId)?.TitleId == titleId; });
if (entry == null) if (entry == null)
@@ -84,7 +66,7 @@ public sealed class IndexController : ControllerBase
// ---- 3️⃣ If the file is a normal NSP → send it ---------------- // ---- 3️⃣ If the file is a normal NSP → send it ----------------
if (Path.GetExtension(entry.Path).Equals(".nsp", StringComparison.OrdinalIgnoreCase) if (Path.GetExtension(entry.Path).Equals(".nsp", StringComparison.OrdinalIgnoreCase)
&& !entry.Path.Contains(_snapshotService.GetArchivePathSeparator())) && !entry.Path.Contains(snapshotService.GetArchivePathSeparator()))
{ {
if (System.IO.File.Exists(entry.Path)) if (System.IO.File.Exists(entry.Path))
{ {
@@ -116,7 +98,7 @@ public sealed class IndexController : ControllerBase
if (IsInsideArchive(entry.Path)) if (IsInsideArchive(entry.Path))
{ {
// Example: file is inside an archive use ArchiveHandler // Example: file is inside an archive use ArchiveHandler
var innerFileName = entry.Path.Split(_snapshotService.GetArchivePathSeparator()).Last(); var innerFileName = entry.Path.Split(snapshotService.GetArchivePathSeparator()).Last();
var stream = StreamFromArchive(entry, titleId, out var streamContainer); var stream = StreamFromArchive(entry, titleId, out var streamContainer);
if (stream == null) if (stream == null)
@@ -149,7 +131,7 @@ public sealed class IndexController : ControllerBase
// (e.g. "Games/MyGame.nsp" is a regular file; "archive.7z/mygame.nsp" // (e.g. "Games/MyGame.nsp" is a regular file; "archive.7z/mygame.nsp"
// would be inside an archive). For simplicity we only check // would be inside an archive). For simplicity we only check
// for common archive extensions. // for common archive extensions.
var filePath = path.Split(_snapshotService.GetArchivePathSeparator()).First(); var filePath = path.Split(snapshotService.GetArchivePathSeparator()).First();
return filePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) || return filePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) ||
filePath.EndsWith(".7z", StringComparison.OrdinalIgnoreCase) || filePath.EndsWith(".7z", StringComparison.OrdinalIgnoreCase) ||
filePath.EndsWith(".rar", StringComparison.OrdinalIgnoreCase); filePath.EndsWith(".rar", StringComparison.OrdinalIgnoreCase);
@@ -163,9 +145,9 @@ public sealed class IndexController : ControllerBase
private Stream? StreamFromArchive(FileEntry fileEntry, string titleId, out IDisposable? streamContainer) private Stream? StreamFromArchive(FileEntry fileEntry, string titleId, out IDisposable? streamContainer)
{ {
// Example: file is inside an archive use ArchiveHandler // Example: file is inside an archive use ArchiveHandler
var archivePath = fileEntry.Path.Split(_snapshotService.GetArchivePathSeparator()).First(); var archivePath = fileEntry.Path.Split(snapshotService.GetArchivePathSeparator()).First();
_snapshotService.GetArchiveName(titleId); snapshotService.GetArchiveName(titleId);
var innerFileName = Path.GetFileName(fileEntry.Path.Split(_snapshotService.GetArchivePathSeparator()).Last()); var innerFileName = Path.GetFileName(fileEntry.Path.Split(snapshotService.GetArchivePathSeparator()).Last());
// Use SharpCompress to open the archive and find the entry. // Use SharpCompress to open the archive and find the entry.
// Only the 3 archive types we support are handled. // Only the 3 archive types we support are handled.
@@ -212,44 +194,4 @@ public sealed class IndexController : ControllerBase
streamContainer = null; streamContainer = null;
return null; return null;
} }
public class DependentStream : Stream
{
private readonly Stream _innerStream;
private readonly IDisposable? _parentContainer;
public DependentStream(Stream innerStream, IDisposable? parentContainer)
{
_innerStream = innerStream;
_parentContainer = parentContainer;
}
public override void Flush() => _innerStream.Flush();
public override int Read(byte[] buffer, int offset, int count) => _innerStream.Read(buffer, offset, count);
public override long Seek(long offset, SeekOrigin origin) => _innerStream.Seek(offset, origin);
public override void SetLength(long value) => _innerStream.SetLength(value);
public override void Write(byte[] buffer, int offset, int count) => _innerStream.Write(buffer, offset, count);
public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken)
{
return _innerStream.CopyToAsync(destination, bufferSize, cancellationToken);
}
public override bool CanRead => _innerStream.CanRead;
public override bool CanSeek => _innerStream.CanSeek;
public override bool CanWrite => _innerStream.CanWrite;
public override long Length => _innerStream.Length;
public override long Position { get => _innerStream.Position; set => _innerStream.Position = value; }
protected override void Dispose(bool disposing)
{
_parentContainer?.Dispose();
base.Dispose(disposing);
}
}
} }
@@ -4,24 +4,17 @@ using TinfoilVibeServer.Authentication;
namespace TinfoilVibeServer.Middleware; namespace TinfoilVibeServer.Middleware;
/// <summary> /// <summary>
/// Minimal BasicAuth middleware that also checks UID, failure counters and a blacklist. /// Minimal BasicAuth middleware that also checks UID, failure counters, and a blacklist.
/// </summary> /// </summary>
public sealed class BasicAuthMiddleware public sealed class BasicAuthMiddleware(RequestDelegate next)
{ {
private readonly RequestDelegate _next;
public BasicAuthMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, IAuthStore store, ILogger<BasicAuthMiddleware> logger) public async Task InvokeAsync(HttpContext context, IAuthStore store, ILogger<BasicAuthMiddleware> logger)
{ {
// ------------- 1) Bypass auth for every path except “/” ---------------- // ------------- 1) Bypass auth for every path except “/” ----------------
// PathString is a struct compare its value directly. // PathString is a struct compare its value directly.
if (!context.Request.Path.Equals("/", StringComparison.Ordinal)) if (!context.Request.Path.Equals("/", StringComparison.Ordinal))
{ {
await _next(context); await next(context);
return; return;
} }
@@ -96,7 +89,7 @@ public sealed class BasicAuthMiddleware
// Authentication succeeded attach username for downstream handlers if needed // Authentication succeeded attach username for downstream handlers if needed
context.Items["User"] = username; context.Items["User"] = username;
logger.LogInformation("User {User} authenticated successfully (UID={UID})", username, uid); logger.LogInformation("User {User} authenticated successfully (UID={UID})", username, uid);
await _next(context); await next(context);
} }
private static void Challenge(HttpContext ctx) private static void Challenge(HttpContext ctx)
@@ -117,9 +117,9 @@ public sealed class IndexBuilderService: IHostedService
} }
} }
var fileName =Uri.EscapeDataString($"{name}[{titleId}][v{versionNumberParsed}][{patchOrApp}].nsp"); var fileName = Uri.EscapeDataString($"{name}[{titleId}][v{versionNumberParsed}][{patchOrApp}].nsp");
var url = $"{baseUri.ToString().TrimEnd('/')}/{fileName}"; var url = $"{baseUri.ToString().TrimEnd('/')}/{fileName}";
var isWellFormed = Uri.TryCreate(url, UriKind.Absolute, out Uri? parsedUri); var isWellFormed = Uri.TryCreate(url, UriKind.Absolute, out var parsedUri);
if (isWellFormed && parsedUri != null) if (isWellFormed && parsedUri != null)
{ {
+190 -77
View File
@@ -27,9 +27,18 @@ public interface ISnapshotService
public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedService public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedService
{ {
#region FileSystemWatcher #region FileSystemWatcher
/* ==============================================================
* 1️⃣ FileSystemWatcher
* ============================================================== */
private readonly List<FileSystemWatcher> _watchers = new(); private readonly List<FileSystemWatcher> _watchers = new();
#endregion #endregion
#region Snapshot options & helpers
/* ==============================================================
* 2️⃣ Snapshot options & helpers
* ============================================================== */
private readonly SnapshotOptions _options; private readonly SnapshotOptions _options;
private readonly INSPExtractor _nspExtractor; private readonly INSPExtractor _nspExtractor;
private readonly IArchiveHandler _archiveHandler; private readonly IArchiveHandler _archiveHandler;
@@ -38,27 +47,43 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
private readonly string _jsonPath; private readonly string _jsonPath;
private readonly string _snapshotPath; private readonly string _snapshotPath;
private readonly ConcurrentDictionary<string, SnapshotEntry> _cache = new(); private readonly ConcurrentDictionary<string, SnapshotEntry> _cache = new();
private readonly ConcurrentDictionary<string, string> _hashCache = new(); private readonly ConcurrentDictionary<string, string> _hashCache = new();
// Archive full path -> FileEntry.Path // Archive full path -> FileEntry.Path
private readonly ConcurrentDictionary<string, string> _archiveLookup = new(); private readonly ConcurrentDictionary<string, string> _archiveLookup = new();
// hash -> file size // hash -> file size
private readonly ConcurrentDictionary<string, long> _sizeLookup = new(); private readonly ConcurrentDictionary<string, long> _sizeLookup = new();
private readonly IMemoryCache _debouncerCache; private readonly IMemoryCache _debouncerCache;
public event EventHandler? SnapshotRebuilt; public event EventHandler? SnapshotRebuilt;
public event EventHandler? SnapshotRebuilding; public event EventHandler? SnapshotRebuilding;
private readonly SemaphoreSlim _snapshotFileSemaphore = new(1,1); private readonly SemaphoreSlim _snapshotFileSemaphore = new(1, 1);
private const char ArchivePathSeparator = '|'; private const char ArchivePathSeparator = '|';
public char GetArchivePathSeparator() => ArchivePathSeparator; public char GetArchivePathSeparator() => ArchivePathSeparator;
#endregion
public SnapshotService(
/* ==============================================================
* 3️⃣ Buildtime guard
* ============================================================== */
/// <summary>
/// Allows only one rebuild at a time.
/// </summary>
private readonly SemaphoreSlim _buildLock = new(1, 1);
/* ==============================================================
* 4️⃣ Constructor
* ============================================================== */
public SnapshotService(
IMemoryCache debouncerCache, IMemoryCache debouncerCache,
IOptionsMonitor<SnapshotOptions> options, IOptionsMonitor<SnapshotOptions> options,
INSPExtractor nspExtractor, INSPExtractor nspExtractor,
IArchiveHandler archiveHandler, IArchiveHandler archiveHandler,
ILogger<SnapshotService> logger, ILogger<SnapshotService> logger,
IHostEnvironment environment IHostEnvironment environment
) )
{ {
_options = options.CurrentValue; _options = options.CurrentValue;
_debouncerCache = debouncerCache; _debouncerCache = debouncerCache;
@@ -66,8 +91,8 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
_archiveHandler = archiveHandler; _archiveHandler = archiveHandler;
_logger = logger; _logger = logger;
_environment = environment; _environment = environment;
_jsonPath = Path.Combine(Path.DirectorySeparatorChar.ToString(),"app","data", _options.SnapshotFile); _jsonPath = Path.Combine(Path.DirectorySeparatorChar.ToString(), "app", "data", _options.SnapshotFile);
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_jsonPath) ?? throw new InvalidOperationException())); FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_jsonPath) ?? throw new InvalidOperationException()));
if (!File.Exists(_jsonPath)) if (!File.Exists(_jsonPath))
{ {
@@ -75,30 +100,30 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
File.WriteAllText(_jsonPath, "[]"); File.WriteAllText(_jsonPath, "[]");
_snapshotFileSemaphore.Release(); _snapshotFileSemaphore.Release();
} }
_snapshotPath = Path.Combine(Path.DirectorySeparatorChar.ToString(),"app","data", _options.SnapshotBackupFile);
_snapshotPath = Path.Combine(Path.DirectorySeparatorChar.ToString(), "app", "data", _options.SnapshotBackupFile);
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_snapshotPath) ?? throw new InvalidOperationException())); FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_snapshotPath) ?? throw new InvalidOperationException()));
// 1️⃣ Register for *property* changes // 1️⃣ Register for *property* changes
options.OnChange((snapshotOptions, arg) => options.OnChange((snapshotOptions, _) => { _options.RootDirectories = snapshotOptions.RootDirectories; });
{ _options.PropertyChanged += (_, e) => OnOptionsChanged(e.PropertyName);
_options.RootDirectories = snapshotOptions.RootDirectories;
});
_options.PropertyChanged += (s, e) => OnOptionsChanged(e.PropertyName);
if (_options.RootDirectories.Count == 0) if (_options.RootDirectories.Count == 0)
{ {
_logger.LogInformation("No directories set to watch for ROMS/Archives"); _logger.LogInformation("No directories set to watch for ROMS/Archives");
} }
foreach (var path in _options.RootDirectories) foreach (var path in _options.RootDirectories)
{ {
AddWatchDirectory(path); AddWatchDirectory(path);
} }
} }
// --------- Private helpers --------- // --------- Private helpers ---------
private void OnOptionsChanged(string? propertyName) private void OnOptionsChanged(string? propertyName)
{ {
if (propertyName != nameof(SnapshotOptions.RootDirectories)) return; if (propertyName != nameof(SnapshotOptions.RootDirectories)) return;
_logger.LogInformation("Root directories changed, rebuilding snapshot"); _logger.LogInformation("Root directories changed, rebuilding snapshot");
var fileSystemWatchers = _watchers.Where(watcher => !_options.RootDirectories.Contains(watcher.Path)); var fileSystemWatchers = _watchers.Where(watcher => !_options.RootDirectories.Contains(watcher.Path));
var systemWatchers = fileSystemWatchers.ToList(); var systemWatchers = fileSystemWatchers.ToList();
@@ -119,9 +144,13 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
BuildSnapshotAsync(); // rebuild everything BuildSnapshotAsync(); // rebuild everything
PersistSnapshotAsync(); PersistSnapshotAsync();
} }
#region FileSystemWatcher #region FileSystemWatcher
/* ==============================================================
* 5️⃣ FileSystemWatcher helpers
* ============================================================== */
private void AddWatchDirectory(string path) private void AddWatchDirectory(string path)
{ {
if (!Directory.Exists(path)) return; if (!Directory.Exists(path)) return;
@@ -156,9 +185,18 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs) private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs)
{ {
// If a rebuild is in progress, ignore the event immediately
if (_buildLock.CurrentCount == 0) // lock held by a rebuild
{
_logger.LogInformation(
"File system event {ChangeType} on {Path} ignored because a rebuild is already in progress",
fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath);
return;
}
SnapshotRebuilding?.Invoke(this, fileSystemEventArgs); SnapshotRebuilding?.Invoke(this, fileSystemEventArgs);
CancellationTokenSource cts = new(); CancellationTokenSource cts = new();
using var cacheEntry = _debouncerCache.CreateEntry(fileSystemEventArgs.FullPath) using var cacheEntry = _debouncerCache.CreateEntry(fileSystemEventArgs.FullPath)
.AddExpirationToken(new CancellationChangeToken(cts.Token)) .AddExpirationToken(new CancellationChangeToken(cts.Token))
.SetValue(fileSystemEventArgs) .SetValue(fileSystemEventArgs)
@@ -169,9 +207,9 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
new PostEvictionCallbackRegistration new PostEvictionCallbackRegistration
{ {
EvictionCallback = EvictionCallback =
(key, value, reason, state) => (_, value, reason, _) =>
{ {
if (!(reason == EvictionReason.Expired || reason == EvictionReason.TokenExpired)) return; if (reason is not (EvictionReason.Expired or EvictionReason.TokenExpired)) return;
if (value is FileSystemEventArgs args) if (value is FileSystemEventArgs args)
{ {
@@ -179,8 +217,9 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
{ {
_logger.LogInformation("File {FilePath} is locked, skipping snapshot update", args.FullPath); _logger.LogInformation("File {FilePath} is locked, skipping snapshot update", args.FullPath);
using var rebounce = _debouncerCache.CreateEntry(args.FullPath) using var rebounce = _debouncerCache.CreateEntry(args.FullPath)
.SetAbsoluteExpiration(TimeSpan.FromMilliseconds(DebounceMs)) .AddExpirationToken(new CancellationChangeToken(cts.Token))
.SetValue(args); .SetValue(args);
cts.CancelAfter(TimeSpan.FromMilliseconds(DebounceMs));
} }
} }
@@ -193,6 +232,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
_logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType, _logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType,
fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss.fff")); fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss.fff"));
} }
private static bool IsFileLocked(string filePath) private static bool IsFileLocked(string filePath)
{ {
FileStream? stream = null; FileStream? stream = null;
@@ -210,6 +250,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
{ {
stream?.Close(); stream?.Close();
} }
return false; return false;
} }
@@ -229,9 +270,19 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
public Task AddToSnapshotAsync(FileEntry entry) public Task AddToSnapshotAsync(FileEntry entry)
{ {
// Update lookup tables // Update lookup tables
_cache[entry.Path] = new SnapshotEntry(entry.Path, entry.Hash, entry.Size, entry.Titles); if (entry.Hash != null)
_hashCache[entry.Hash] = entry.Path; {
_sizeLookup[entry.Hash] = entry.Size; var lastModified = File.GetLastWriteTimeUtc(entry.Path);
_cache[entry.Path] = new SnapshotEntry(entry.Path, entry.Hash, entry.Size, lastModified, entry.Titles);
_hashCache[entry.Hash] = entry.Path;
_sizeLookup[entry.Hash] = entry.Size;
}
else
{
_logger.LogWarning("Cannot add entry {Path} to snapshot: no hash", entry.Path);
return Task.CompletedTask;
}
if (entry.Path.Contains(ArchivePathSeparator)) if (entry.Path.Contains(ArchivePathSeparator))
{ {
var filename = entry.Path.Split(ArchivePathSeparator)[0]; var filename = entry.Path.Split(ArchivePathSeparator)[0];
@@ -240,62 +291,88 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
foreach (var ncaMetadataWithHash in entry.Titles) foreach (var ncaMetadataWithHash in entry.Titles)
{ {
if (ncaMetadataWithHash.Hash == null)
{
_logger.LogWarning("Cannot add entry {Path} to snapshot: no hash", entry.Path);
continue;
}
_hashCache[ncaMetadataWithHash.Hash] = entry.Path; _hashCache[ncaMetadataWithHash.Hash] = entry.Path;
_sizeLookup[ncaMetadataWithHash.Hash] = entry.Size; _sizeLookup[ncaMetadataWithHash.Hash] = entry.Size;
_logger.LogInformation("Added entry {titleId} to snapshot (hash={hash})", ncaMetadataWithHash.TitleId, ncaMetadataWithHash.Hash); _logger.LogInformation("Added entry {titleId} to snapshot (hash={hash})", ncaMetadataWithHash.TitleId, ncaMetadataWithHash.Hash);
} }
// Persist snapshot to disk // Persist snapshot to disk
PersistSnapshotAsync(); PersistSnapshotAsync();
return Task.CompletedTask; return Task.CompletedTask;
} }
/* ==============================================================
* 6️⃣ Snapshot build / persistence helpers
* ============================================================== */
/// Builds _cache and _hashCache based on directory configuration /// Builds _cache and _hashCache based on directory configuration
public Task BuildSnapshotAsync() public Task BuildSnapshotAsync()
{ {
_logger.LogInformation("Building snapshot"); // Acquire the rebuild lock if we cannot, skip this build.
var index = LoadSnapshotIndex(); if (!_buildLock.Wait(0))
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) _logger.LogInformation("BuildSnapshotAsync called while rebuild in progress, ignoring.");
return Task.CompletedTask;
}
try
{
_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)
{ {
foreach (var dir in _options.RootDirectories) if (index.Count != 0)
{ {
var firstEntry = BuildSnapshot(dir).FirstOrDefault(); foreach (var dir in _options.RootDirectories)
if (firstEntry != null && !index.TryGetValue(firstEntry.Path, out _))
{ {
snapshotVerified = false; // Snapshot is older than the latest modified file in the directory
_logger.LogInformation("Snapshot does not contain first entry in directory {Directory}", dir); var lastOrDefault = BuildSnapshot(dir).LastOrDefault();
if (lastOrDefault != null && !index.TryGetValue(lastOrDefault.Path, out _))
{
snapshotVerified = false;
_logger.LogInformation("Snapshot does not contain first entry in directory {Directory}", dir);
}
} }
} }
} }
}
if (!snapshotVerified) 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)) _logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", _options.RootDirectories.Count);
var entries = new List<FileEntry>();
foreach (var dir in _options.RootDirectories)
{ {
if (entry != null) entries.Add(entry); 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(); var currentHash = ComputeSnapshotHash(entries);
if (entries.Count > 0 || fileInfo.Exists && index.Count == 0)
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
}
PersistSnapshotAsync();
}
finally
{
_buildLock.Release();
}
return Task.CompletedTask; return Task.CompletedTask;
} }
public void GetArchiveName(string titleId) public void GetArchiveName(string titleId)
{ {
;
} }
// Returns List of FileEntry that do not have a hash in the cache // Returns List of FileEntry that do not have a hash in the cache
@@ -303,7 +380,11 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
private IEnumerable<FileEntry?> BuildSnapshot(string dir) private IEnumerable<FileEntry?> BuildSnapshot(string dir)
{ {
if (!Directory.Exists(dir)) yield break; if (!Directory.Exists(dir)) yield break;
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories)) foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories).OrderBy(file =>
{
var fileInfo = new FileInfo(file);
return fileInfo.LastWriteTimeUtc;
}))
{ {
var hash = string.Empty; var hash = string.Empty;
var ext = Path.GetExtension(file).ToLowerInvariant(); var ext = Path.GetExtension(file).ToLowerInvariant();
@@ -329,6 +410,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
AddToSnapshotAsync(fileEntryFromFileName); AddToSnapshotAsync(fileEntryFromFileName);
yield return fileEntryFromFileName; yield return fileEntryFromFileName;
} }
using var nspStream = File.OpenRead(file); using var nspStream = File.OpenRead(file);
hash = ComputeFirstStreamHash(nspStream); hash = ComputeFirstStreamHash(nspStream);
@@ -369,7 +451,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
} }
if (titlesEnumerable == null) continue; if (titlesEnumerable == null) continue;
titles = titlesEnumerable.ToList(); titles = titlesEnumerable.ToList();
foreach (var title in titles) foreach (var title in titles)
{ {
@@ -394,11 +476,11 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
else else
{ {
_logger.LogInformation("Added {File} to snapshot (hash={Hash})", file, hash); _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()); yield return new FileEntry(file, titles.Select((tuple, _) => tuple.Item2).FirstOrDefault(), hash, titles.Select((tuple, _) => tuple.Item3).ToList());
} }
} }
} }
private async Task ValidateSnapshotAsync(CancellationToken cancellationToken = default) private async Task ValidateSnapshotAsync(CancellationToken cancellationToken = default)
{ {
await Task.CompletedTask; await Task.CompletedTask;
@@ -410,7 +492,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
private IEnumerable<FileEntry> GetEntries() private IEnumerable<FileEntry> GetEntries()
{ {
foreach (var kv in _cache) foreach (var kv in _cache.OrderByDescending(pair => pair.Value.LastModified))
yield return new FileEntry(kv.Key, kv.Value.Size, kv.Value.Hash, kv.Value.NcaMetadataWithHash); yield return new FileEntry(kv.Key, kv.Value.Size, kv.Value.Hash, kv.Value.NcaMetadataWithHash);
} }
@@ -423,6 +505,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
} }
var entries = GetEntries().ToList(); var entries = GetEntries().ToList();
var newHash = ComputeSnapshotHash(entries); var newHash = ComputeSnapshotHash(entries);
var snapshot = GetSnapshot(); var snapshot = GetSnapshot();
if (snapshot.Hash == newHash) return Task.CompletedTask; if (snapshot.Hash == newHash) return Task.CompletedTask;
@@ -437,9 +520,9 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
{ {
new PostEvictionCallbackRegistration new PostEvictionCallbackRegistration
{ {
EvictionCallback = (key, value, reason, state) => EvictionCallback = (key, value, reason, _) =>
{ {
if (!(reason == EvictionReason.Expired || reason == EvictionReason.TokenExpired)) if (reason is not (EvictionReason.Expired or EvictionReason.TokenExpired))
return; return;
var filePath = (string)key; var filePath = (string)key;
if (_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout)) if (_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout))
@@ -453,6 +536,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
else else
{ {
File.WriteAllText(filePath, JsonSerializer.Serialize(value, _jsonSerializerOptions)); File.WriteAllText(filePath, JsonSerializer.Serialize(value, _jsonSerializerOptions));
_logger.LogInformation("Persisted snapshot to {FilePath}", filePath);
SnapshotRebuilt?.Invoke(this, EventArgs.Empty); SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
} }
} }
@@ -484,6 +568,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json)); var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json));
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
} }
/// <summary> /// <summary>
/// From filesystem cache, load each entry and build the lookups /// From filesystem cache, load each entry and build the lookups
/// Check for duplicate hashes /// Check for duplicate hashes
@@ -503,6 +588,12 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
// Reindex the cache // Reindex the cache
foreach (var fileEntry in entries) foreach (var fileEntry in entries)
{ {
if (fileEntry.Hash == null)
{
_logger.LogError("Entry {Path} has no hash", fileEntry.Path);
continue;
}
if (_hashCache.TryGetValue(fileEntry.Hash, out var value)) if (_hashCache.TryGetValue(fileEntry.Hash, out var value))
{ {
_logger.LogWarning("Duplicate hash found in snapshot: {Hash}, {OldPath}, {newPath}", fileEntry.Hash, value, fileEntry.Path); _logger.LogWarning("Duplicate hash found in snapshot: {Hash}, {OldPath}, {newPath}", fileEntry.Hash, value, fileEntry.Path);
@@ -514,7 +605,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
_logger.LogWarning("Nonexistent entry found: {Path}", fileEntry.Path); _logger.LogWarning("Nonexistent entry found: {Path}", fileEntry.Path);
continue; continue;
} }
var fileContainedInRootDirectories = false; var fileContainedInRootDirectories = false;
foreach (var optionsRootDirectory in _options.RootDirectories) foreach (var optionsRootDirectory in _options.RootDirectories)
{ {
@@ -529,54 +620,73 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
{ {
_logger.LogInformation("Entry {Path} is not contained in any root directory", fileEntry.Path); _logger.LogInformation("Entry {Path} is not contained in any root directory", fileEntry.Path);
continue; continue;
}; }
if (_options.RomExtensions.Contains(Path.GetExtension(fileEntry.Path))) if (_options.RomExtensions.Contains(Path.GetExtension(fileEntry.Path)))
{ {
var fileInfo = new FileInfo(fileEntry.Path);
if (fileEntry.Path.Contains(ArchivePathSeparator)) if (fileEntry.Path.Contains(ArchivePathSeparator))
{ {
var filename = fileEntry.Path.Split(ArchivePathSeparator)[0]; var filename = fileEntry.Path.Split(ArchivePathSeparator)[0];
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileEntry.Titles!); // ReSharper disable once RedundantSuppressNullableWarningExpression
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileInfo.LastWriteTimeUtc, fileEntry.Titles!);
_archiveLookup[filename] = fileEntry.Path; _archiveLookup[filename] = fileEntry.Path;
} }
else else
{ {
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileEntry.Titles!); // ReSharper disable once RedundantSuppressNullableWarningExpression
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileInfo.LastWriteTimeUtc, fileEntry.Titles!);
fileEntries.TryAdd(fileEntry.Path, fileEntry); fileEntries.TryAdd(fileEntry.Path, fileEntry);
_hashCache[fileEntry.Hash] = fileEntry.Path; _hashCache[fileEntry.Hash] = fileEntry.Path;
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
if (fileEntry.Titles == null) continue; if (fileEntry.Titles == null) continue;
foreach (var ncaMetadataWithHash in fileEntry.Titles) foreach (var ncaMetadataWithHash in fileEntry.Titles)
{ {
if (ncaMetadataWithHash.Hash == null) continue;
_hashCache[ncaMetadataWithHash.Hash] = fileEntry.Path; _hashCache[ncaMetadataWithHash.Hash] = fileEntry.Path;
} }
} }
} }
} }
_logger.LogInformation("Loaded snapshot index {Count} entries", fileEntries.Count); _logger.LogInformation("Loaded snapshot index {Count} entries", fileEntries.Count);
return fileEntries; return fileEntries;
} }
catch (ArgumentException e) catch (ArgumentException e)
{ {
_logger.LogError(e, "Failed to load snapshot"); _logger.LogError(e, "Failed to load snapshot");
return new(); return new();
} }
} }
public void RebuildSnapshot() public void RebuildSnapshot()
{ {
// 1️⃣ Flush the old inmemory snapshot // Fast path: if we already have the lock, just log and exit.
_cache.Clear(); if (!_buildLock.Wait(0))
_hashCache.Clear(); {
_archiveLookup.Clear(); _logger.LogInformation("RebuildSnapshot called while a rebuild is already in progress, ignoring.");
_sizeLookup.Clear(); return;
//_failedAttempts.Clear(); // if you keep peruser counters }
try
{
// 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 // 2️⃣ Rebuild from disk again
BuildSnapshotAsync().Wait(); // synchronous we already own the lock BuildSnapshotAsync().Wait(); // synchronous we already own the lock
PersistSnapshotAsync().Wait(); // same PersistSnapshotAsync().Wait(); // same
SnapshotRebuilt?.Invoke(this, EventArgs.Empty); SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
}
finally
{
_buildLock.Release();
}
} }
#endregion #endregion
public ROMSnapshot GetSnapshot() public ROMSnapshot GetSnapshot()
@@ -612,7 +722,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
return new ROMSnapshot(); return new ROMSnapshot();
} }
public void Dispose() public void Dispose()
{ {
foreach (var watcher in _watchers) foreach (var watcher in _watchers)
@@ -621,7 +731,10 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
} }
} }
private sealed record SnapshotEntry(string Path, string Hash, long Size, List<NcaMetadataWithHash> NcaMetadataWithHash); /// <summary>
/// Represents a single ROM/archive entry in the snapshot cache.
/// </summary>
private sealed record SnapshotEntry(string Path, string Hash, long Size, DateTime LastModified, List<NcaMetadataWithHash> NcaMetadataWithHash);
// File: TinfoilVibeServer/Services/SnapshotService.cs (inside SnapshotService class) // File: TinfoilVibeServer/Services/SnapshotService.cs (inside SnapshotService class)
@@ -671,8 +784,8 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
public class ROMSnapshot public class ROMSnapshot
{ {
public string? Hash { get; set; } public string? Hash { get; init; }
public IReadOnlyList<FileEntry> Files { get; set; } = new List<FileEntry>(); public IReadOnlyList<FileEntry> Files { get; init; } = new List<FileEntry>();
} }
public async Task StartAsync(CancellationToken cancellationToken) public async Task StartAsync(CancellationToken cancellationToken)
@@ -0,0 +1,31 @@
namespace TinfoilVibeServer.Utilities;
public class DependentStream(Stream innerStream, IDisposable? parentContainer) : Stream
{
public override void Flush() => innerStream.Flush();
public override int Read(byte[] buffer, int offset, int count) => innerStream.Read(buffer, offset, count);
public override long Seek(long offset, SeekOrigin origin) => innerStream.Seek(offset, origin);
public override void SetLength(long value) => innerStream.SetLength(value);
public override void Write(byte[] buffer, int offset, int count) => innerStream.Write(buffer, offset, count);
public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken)
{
return innerStream.CopyToAsync(destination, bufferSize, cancellationToken);
}
public override bool CanRead => innerStream.CanRead;
public override bool CanSeek => innerStream.CanSeek;
public override bool CanWrite => innerStream.CanWrite;
public override long Length => innerStream.Length;
public override long Position { get => innerStream.Position; set => innerStream.Position = value; }
protected override void Dispose(bool disposing)
{
parentContainer?.Dispose();
base.Dispose(disposing);
}
}
@@ -112,8 +112,7 @@ public static class FileSystemExtensions
/// <exception cref="IOException">Thrown if a file exists at the target path or the directory cannot be created.</exception> /// <exception cref="IOException">Thrown if a file exists at the target path or the directory cannot be created.</exception>
public static void EnsureDirectoryExists(string? path) public static void EnsureDirectoryExists(string? path)
{ {
if (path is null) ArgumentNullException.ThrowIfNull(path);
throw new ArgumentNullException(nameof(path));
if (string.IsNullOrWhiteSpace(path)) if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Path must not be empty or whitespace.", nameof(path)); throw new ArgumentException("Path must not be empty or whitespace.", nameof(path));