diff --git a/.idea/.idea.TinfoilVibeServer/.idea/vcs.xml b/.idea/.idea.TinfoilVibeServer/.idea/vcs.xml
index 94a25f7..c656bff 100644
--- a/.idea/.idea.TinfoilVibeServer/.idea/vcs.xml
+++ b/.idea/.idea.TinfoilVibeServer/.idea/vcs.xml
@@ -2,5 +2,6 @@
+
\ No newline at end of file
diff --git a/TinfoilVibeServer.sln b/TinfoilVibeServer.sln
index 970b95a..071d091 100644
--- a/TinfoilVibeServer.sln
+++ b/TinfoilVibeServer.sln
@@ -7,6 +7,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinfoilVibeServer", "TinfoilVibeServer\TinfoilVibeServer.csproj", "{DE992FDB-6D13-4152-925D-29D39A23FB75}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinfoilVibeServerTests", "TinfoilVibeServerTests\TinfoilVibeServerTests.csproj", "{EB041FA0-F87C-4F24-9E39-9BAF3D3776D8}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -17,5 +19,9 @@ Global
{DE992FDB-6D13-4152-925D-29D39A23FB75}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DE992FDB-6D13-4152-925D-29D39A23FB75}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DE992FDB-6D13-4152-925D-29D39A23FB75}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EB041FA0-F87C-4F24-9E39-9BAF3D3776D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EB041FA0-F87C-4F24-9E39-9BAF3D3776D8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EB041FA0-F87C-4F24-9E39-9BAF3D3776D8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EB041FA0-F87C-4F24-9E39-9BAF3D3776D8}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
diff --git a/TinfoilVibeServer.sln.DotSettings.user b/TinfoilVibeServer.sln.DotSettings.user
index bfb9262..d493d4b 100644
--- a/TinfoilVibeServer.sln.DotSettings.user
+++ b/TinfoilVibeServer.sln.DotSettings.user
@@ -1,3 +1,8 @@
True
- True
\ No newline at end of file
+ True
+ ForceIncluded
+ ForceIncluded
+ ForceIncluded
+ ForceIncluded
+ ForceIncluded
\ No newline at end of file
diff --git a/TinfoilVibeServer/Config/GameDirectoryOptions.cs b/TinfoilVibeServer/Config/GameDirectoryOptions.cs
new file mode 100644
index 0000000..43d35a5
--- /dev/null
+++ b/TinfoilVibeServer/Config/GameDirectoryOptions.cs
@@ -0,0 +1,11 @@
+// File: TinfoilVibeServer/Config/GameDirectoriesOptions.cs
+
+namespace TinfoilVibeServer.Config;
+
+public sealed class GameDirectoriesOptions
+{
+ ///
+ /// Paths to scan for Switch ROMs and archives. Values may change at runtime.
+ ///
+ public IList Paths { get; set; } = new List();
+}
\ No newline at end of file
diff --git a/TinfoilVibeServer/ConfigManager.cs b/TinfoilVibeServer/ConfigManager.cs
deleted file mode 100644
index aeed52d..0000000
--- a/TinfoilVibeServer/ConfigManager.cs
+++ /dev/null
@@ -1,70 +0,0 @@
-
-using System.Text.Json;
-
-namespace TinfoilVibeServer;
-
-///
-/// Reads the JSON config file and raises an event whenever it changes.
-///
-public sealed class ConfigManager : IDisposable
-{
- private readonly string _configPath;
- private readonly FileSystemWatcher _watcher;
- private readonly object _sync = new();
-
- public AppSettings Settings { get; private set; }
-
- public event Action? OnChange;
-
- public ConfigManager(string configPath)
- {
- _configPath = configPath;
- Settings = Load();
-
- _watcher = new FileSystemWatcher
- {
- Path = Path.GetDirectoryName(_configPath)!,
- Filter = Path.GetFileName(_configPath),
- NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes,
- EnableRaisingEvents = true
- };
-
- _watcher.Changed += (_, _) => Reload();
- }
-
- private AppSettings Load()
- {
- var json = File.ReadAllText(_configPath);
- return JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
- }
-
- private void Reload()
- {
- lock (_sync)
- {
- try
- {
- Settings = Load();
- OnChange?.Invoke(Settings);
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($"Failed to reload config: {ex.Message}");
- }
- }
- }
-
- public void Dispose() => _watcher.Dispose();
-}
-
-///
-/// POCO that matches appsettings.json.
-///
-public sealed record AppSettings(
- string[] RootDirectories,
- string[] WhitelistExtensions,
- string[] RomExtensions,
- string SnapshotFile,
- string SnapshotBackupFile,
- int ArchiveBufferSize
-);
\ No newline at end of file
diff --git a/TinfoilVibeServer/Configuration/AppSettings.cs b/TinfoilVibeServer/Configuration/AppSettings.cs
new file mode 100644
index 0000000..c0bc554
--- /dev/null
+++ b/TinfoilVibeServer/Configuration/AppSettings.cs
@@ -0,0 +1,10 @@
+// Configuration/AppSettings.cs
+namespace TinfoilVibeServer.Configuration
+{
+ public class AppSettings
+ {
+ public List GameDirectories { get; set; } = new();
+ public List ValidExtensions { get; set; } = new() { ".nsp", ".xci", ".zip", ".rar", ".7z" };
+ public string SnapshotPath { get; set; } = "snapshot.json";
+ }
+}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs b/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs
deleted file mode 100644
index 35fa36b..0000000
--- a/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs
+++ /dev/null
@@ -1,100 +0,0 @@
-
-using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.Logging;
-using System.Text;
-using TinfoilVibeServer.Services;
-
-namespace TinfoilVibeServer.Middleware;
-
-public sealed class BasicAuthMiddleware
-{
- private readonly RequestDelegate _next;
- private readonly AuthStore _store;
- private readonly ILogger _logger;
-
- public BasicAuthMiddleware(RequestDelegate next, AuthStore store, ILogger logger)
- {
- _next = next;
- _store = store;
- _logger = logger;
- }
-
- public async Task InvokeAsync(HttpContext context)
- {
- var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
-
- // 1) IP blacklist
- if (_store.IsBlacklisted(ip))
- {
- _logger.LogWarning("Blocked request from blacklisted IP {IP}", ip);
- context.Response.StatusCode = StatusCodes.Status403Forbidden;
- await context.Response.WriteAsync("Forbidden");
- return;
- }
-
- // 2) Authorization header
- if (!context.Request.Headers.TryGetValue("Authorization", out var authHeaders))
- {
- Challenge(context);
- return;
- }
-
- var authHeader = authHeaders.FirstOrDefault() ?? "";
- if (!authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
- {
- Challenge(context);
- return;
- }
-
- string decoded;
- try
- {
- var b64 = authHeader[6..].Trim();
- decoded = Encoding.UTF8.GetString(Convert.FromBase64String(b64));
- }
- catch
- {
- Challenge(context);
- return;
- }
-
- var parts = decoded.Split(':', 2);
- if (parts.Length != 2)
- {
- Challenge(context);
- return;
- }
-
- var username = parts[0];
- var password = parts[1];
-
- // 3) UID header (optional)
- int? uid = null;
- if (context.Request.Headers.TryGetValue("UID", out var uidHeader))
- {
- if (int.TryParse(uidHeader.ToString(), out var parsedUid))
- uid = parsedUid;
- }
-
- // 4) Validate
- if (!_store.TryValidate(username, password, uid, ip, out var error))
- {
- _logger.LogWarning("Auth failed for user {User} from {IP}: {Error}", username, ip, error);
- context.Response.StatusCode = StatusCodes.Status401Unauthorized;
- context.Response.Headers.Add("WWW-Authenticate", "Basic realm=\"FileSnapshot\"");
- await context.Response.WriteAsync(error ?? "Unauthorized");
- return;
- }
-
- // Authentication succeeded – attach username for downstream handlers if needed
- context.Items["User"] = username;
-
- await _next(context);
- }
-
- private static void Challenge(HttpContext ctx)
- {
- ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
- ctx.Response.Headers.Add("WWW-Authenticate", "Basic realm=\"FileSnapshot\"");
- }
-}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Models/FileEntry.cs b/TinfoilVibeServer/Models/FileEntry.cs
deleted file mode 100644
index e921d48..0000000
--- a/TinfoilVibeServer/Models/FileEntry.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-namespace TinfoilVibeServer.Models;
-
-///
-/// One line in the snapshot – the JSON will be an array of these.
-///
-public sealed record FileEntry(
- string Path,
- long Size,
- string Hash, // SHA‑256 hex
- TitleInfo? Title // null unless file is an NSP/XCI or an archive containing one
-);
\ No newline at end of file
diff --git a/TinfoilVibeServer/Models/GameSnapshot.cs b/TinfoilVibeServer/Models/GameSnapshot.cs
new file mode 100644
index 0000000..c65a8d8
--- /dev/null
+++ b/TinfoilVibeServer/Models/GameSnapshot.cs
@@ -0,0 +1,18 @@
+using System;
+
+namespace TinfoilVibeServer.Models
+{
+ public class GameSnapshot
+ {
+ public string TitleId { get; set; } // 16‑hex string
+ public int Version { get; set; }
+ public bool IsApplication { get; set; }
+ public bool IsPatch { get; set; }
+
+ public string FullPath { get; set; } // Path on disk (file or archive)
+ public string InsidePath { get; set; } // Path inside an archive (empty if none)
+ public string FirstNcaHash { get; set; } // SHA‑256 of the first NCA stream
+
+ public DateTime LastModified { get; set; } // For snapshot refresh logic
+ }
+}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Models/TitleInfo.cs b/TinfoilVibeServer/Models/TitleInfo.cs
deleted file mode 100644
index 30d45b6..0000000
--- a/TinfoilVibeServer/Models/TitleInfo.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-namespace TinfoilVibeServer.Models;
-
-///
-/// Metadata extracted from a NSP/XCI archive.
-///
-public sealed record TitleInfo(
- string TitleId, // e.g. 0004000000000000
- string Name, // title name
- string Version, // e.g. 1.02
- bool IsApplication // true for applications, false for patches
-);
\ No newline at end of file
diff --git a/TinfoilVibeServer/Persistence/ISnapshotRepository.cs b/TinfoilVibeServer/Persistence/ISnapshotRepository.cs
new file mode 100644
index 0000000..d0c1816
--- /dev/null
+++ b/TinfoilVibeServer/Persistence/ISnapshotRepository.cs
@@ -0,0 +1,12 @@
+// File: TinfoilVibeServer/Persistence/ISnapshotRepository.cs
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using TinfoilVibeServer.Models;
+
+namespace TinfoilVibeServer.Persistence;
+
+public interface ISnapshotRepository
+{
+ Task> LoadAsync();
+ Task PersistAsync(IReadOnlyDictionary snapshot);
+}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Persistence/SnapshotRepository.cs b/TinfoilVibeServer/Persistence/SnapshotRepository.cs
new file mode 100644
index 0000000..a7f666e
--- /dev/null
+++ b/TinfoilVibeServer/Persistence/SnapshotRepository.cs
@@ -0,0 +1,34 @@
+// File: TinfoilVibeServer/Persistence/SnapshotRepository.cs
+using System.Collections.Generic;
+using System.IO;
+using System.Text.Json;
+using System.Threading.Tasks;
+using TinfoilVibeServer.Models;
+
+namespace TinfoilVibeServer.Persistence;
+
+public sealed class SnapshotRepository : ISnapshotRepository
+{
+ private readonly string _path = "snapshot.json";
+
+ public async Task> LoadAsync()
+ {
+ if (!File.Exists(_path))
+ {
+ return new Dictionary();
+ }
+
+ await using var stream = File.OpenRead(_path);
+ var snapshot = await JsonSerializer.DeserializeAsync>(stream,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+
+ return snapshot ?? new Dictionary();
+ }
+
+ public async Task PersistAsync(IReadOnlyDictionary snapshot)
+ {
+ await using var stream = File.Create(_path);
+ await JsonSerializer.SerializeAsync(stream, snapshot,
+ new JsonSerializerOptions { WriteIndented = true });
+ }
+}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Program.cs b/TinfoilVibeServer/Program.cs
index 0d64a40..f13e1be 100644
--- a/TinfoilVibeServer/Program.cs
+++ b/TinfoilVibeServer/Program.cs
@@ -1,42 +1,53 @@
-using TinfoilVibeServer.Middleware;
+// File: TinfoilVibeServer/Program.cs
+
+using LibHac.Common.Keys;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Configuration;
+using TinfoilVibeServer.Config;
+using TinfoilVibeServer.Models;
using TinfoilVibeServer.Services;
+using TinfoilVibeServer.Persistence;
-var builder = WebApplication.CreateBuilder(args);
+namespace TinfoilVibeServer;
-// -----------------------------------------------------
-// 1. Register AuthStore as a singleton
-// -----------------------------------------------------
-builder.Services.AddSingleton();
-
-// -----------------------------------------------------
-// 2. Snapshot + other services (unchanged)
-// -----------------------------------------------------
-builder.Services.AddSingleton();
-// … any other services you already have
-
-var app = builder.Build();
-
-// -----------------------------------------------------
-// 3. Apply authentication middleware *before* the
-// snapshot endpoints. This guarantees all routes
-// are protected.
-// -----------------------------------------------------
-app.UseMiddleware();
-
-// -----------------------------------------------------
-// 4. Existing endpoints – unchanged
-// -----------------------------------------------------
-app.MapGet("/", () => Results.Redirect("/index.tfl"));
-app.MapGet("/index.tfl", async context =>
+internal static class Program
{
- var jsonPath = Path.Combine(AppContext.BaseDirectory, "index.tfl");
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(await File.ReadAllTextAsync(jsonPath));
-});
-app.MapGet("/debug", () => new SnapshotService(builder.Configuration).GetSnapshot());
-app.MapGet("/stream/{*relativePath}", async context =>
-{
- // … (unchanged streaming logic – same as before)
-});
+ public static void Main(string[] args)
+ {
+ var host = Host.CreateDefaultBuilder(args)
+ .ConfigureAppConfiguration((ctx, cfg) =>
+ {
+ cfg.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
+ cfg.AddEnvironmentVariables();
+ cfg.AddCommandLine(args);
+ })
+ .ConfigureServices((ctx, services) =>
+ {
+ // Configuration POCO
+ services.Configure(ctx.Configuration.GetSection("GameDirectories"));
-app.Run();
\ No newline at end of file
+ // Snapshot persistence
+ services.AddSingleton();
+
+ // LibHac parser
+ services.AddSingleton();
+
+ // Main service
+ services.AddHostedService();
+ KeySetHolder.KeySet = ExternalKeyReader.ReadKeyFile(ctx.Configuration.GetSection("KeySet").Get());
+ })
+ .ConfigureLogging((ctx, logging) =>
+ {
+ logging.ClearProviders();
+ logging.AddConsole(options =>
+ {
+ options.TimestampFormat = "[yyyy-MM-dd HH:mm:ss] ";
+ });
+ })
+ .Build();
+
+ host.Run();
+ }
+}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Repositories/SnapshotRepository.cs b/TinfoilVibeServer/Repositories/SnapshotRepository.cs
new file mode 100644
index 0000000..59229e6
--- /dev/null
+++ b/TinfoilVibeServer/Repositories/SnapshotRepository.cs
@@ -0,0 +1,32 @@
+// Repositories/SnapshotRepository.cs
+using System.IO;
+using System.Text.Json;
+using TinfoilVibeServer.Models;
+
+namespace TinfoilVibeServer.Repositories
+{
+ public class SnapshotRepository
+ {
+ private readonly string _path;
+
+ public SnapshotRepository(string path)
+ {
+ _path = path;
+ }
+
+ public List Load()
+ {
+ if (!File.Exists(_path))
+ return new();
+
+ var json = File.ReadAllText(_path);
+ return JsonSerializer.Deserialize>(json) ?? new();
+ }
+
+ public void Persist(IEnumerable entries)
+ {
+ var json = JsonSerializer.Serialize(entries, new JsonSerializerOptions { WriteIndented = true });
+ File.WriteAllText(_path, json);
+ }
+ }
+}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Services/ArchiveHandler.cs b/TinfoilVibeServer/Services/ArchiveHandler.cs
deleted file mode 100644
index a3cbe67..0000000
--- a/TinfoilVibeServer/Services/ArchiveHandler.cs
+++ /dev/null
@@ -1,127 +0,0 @@
-
-using System.IO.Compression;
-using FileSnapshot;
-using SharpCompress.Archives;
-using SharpCompress.Archives.Zip;
-using SharpCompress.Archives.Rar;
-using SharpCompress.Archives.SevenZip;
-using SharpCompress.Readers;
-using TinfoilVibeServer.Models;
-using ZipArchive = SharpCompress.Archives.Zip.ZipArchive;
-
-namespace TinfoilVibeServer.Services;
-
-///
-/// Tries to open a file as an archive and look for an embedded NSP/XCI.
-/// The goal is to be as memory‑friendly as possible – never load a whole archive into RAM.
-///
-public sealed class ArchiveHandler
-{
- ///
- /// Return TitleInfo if an embedded Nintendo archive is found; otherwise null.
- ///
- public static TitleInfo? TryExtractTitleInfo(string filePath)
- {
- var ext = Path.GetExtension(filePath).ToLowerInvariant();
-
- try
- {
- switch (ext)
- {
- case ".zip":
- return HandleZip(filePath);
- case ".7z":
- return Handle7z(filePath);
- case ".rar":
- return HandleRar(filePath);
- default:
- return null;
- }
- }
- catch
- {
- // Graceful fallback – return null
- return null;
- }
- }
-
- private static TitleInfo? HandleZip(string path)
- {
- using var archive = ZipArchive.Open(path);
- foreach (var entry in archive.Entries)
- {
- if (!entry.IsDirectory && IsRomArchive(entry.Key))
- {
- var temp = Path.GetTempFileName();
- entry.WriteToFile(temp);
- var title = NSPExtactor.ExtractFromFile(temp);
- File.Delete(temp);
- return title;
- }
- }
- return null;
- }
-
- private static TitleInfo? Handle7z(string path)
- {
- using var archive = SevenZipArchive.Open(path);
- foreach (var entry in archive.Entries)
- {
- if (!entry.IsDirectory && IsRomArchive(entry.Key))
- {
- var temp = Path.GetTempFileName();
- entry.WriteToFile(temp);
- var title = NSPExtactor.ExtractFromFile(temp);
- File.Delete(temp);
- return title;
- }
- }
- return null;
- }
-
- private static TitleInfo? HandleRar(string path)
- {
- // SharpCompress can handle most RAR5 files – fallback to SharpSevenZip if it fails
- try
- {
- using var archive = RarArchive.Open(path);
- foreach (var entry in archive.Entries)
- {
- if (!entry.IsDirectory && IsRomArchive(entry.Key))
- {
- var temp = Path.GetTempFileName();
- entry.WriteToFile(temp);
- var title = NSPExtactor.ExtractFromFile(temp);
- File.Delete(temp);
- return title;
- }
- }
- return null;
- }
- catch (SharpCompress.Common.ArchiveException)
- {
- // Fallback to SharpSevenZip for RAR5
- /*using var stream = File.OpenRead(path);
- Stream outStream = new Mem;
- using var extractor = SharpSevenZip.SharpSevenZipExtractor.DecompressStream(stream, outStream);
- while (extractor.MoveToNextEntry())
- {
- if (!extractor.IsDirectory && IsRomArchive(extractor.CurrentFileName))
- {
- var temp = Path.GetTempFileName();
- extractor.ExtractFile(temp);
- var title = NSPExtactor.ExtractFromFile(temp);
- File.Delete(temp);
- return title;
- }
- }*/
- return null;
- }
- }
-
- private static bool IsRomArchive(string entryName)
- {
- var ext = Path.GetExtension(entryName).ToLowerInvariant();
- return ext is ".xci" or ".nsp" or ".xcz";
- }
-}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Services/AuthStore.cs b/TinfoilVibeServer/Services/AuthStore.cs
deleted file mode 100644
index a9be168..0000000
--- a/TinfoilVibeServer/Services/AuthStore.cs
+++ /dev/null
@@ -1,211 +0,0 @@
-using System.Collections.Concurrent;
-using System.Security.Cryptography;
-using System.Text;
-using System.Text.Json;
-
-namespace TinfoilVibeServer.Services;
-
-///
-/// Configuration section used by the auth system.
-///
-public sealed class AuthSettings
-{
- public string CredentialsFile { get; init; } = "credentials.json";
- public string FingerprintsFile { get; init; } = "fingerprints.json";
- public string BlacklistFile { get; init; } = "blacklist.json";
- public int MaxFailedAttempts { get; init; } = 5;
-}
-
-///
-/// One user record – stored in *credentials.json*.
-///
-public sealed record Credential(
- string Username,
- string PasswordHash, // SHA‑256 hex
- int AllowedUidCount = 1,
- bool Verified = true); // new flag – defaults to true for pre‑existing users
-
-///
-/// Thread‑safe singleton that keeps the authentication state in memory
-/// and writes it back to disk whenever it changes.
-///
-public sealed class AuthStore
-{
- private readonly AuthSettings _settings;
- private readonly object _sync = new();
-
- // In‑memory state
- public ConcurrentDictionary Credentials { get; } = new();
- public ConcurrentDictionary> Fingerprints { get; } = new();
- public ConcurrentDictionary FailedAttempts { get; } = new();
- public HashSet BlacklistIPs { get; } = new();
-
- public AuthStore(IConfiguration config)
- {
- _settings = new AuthSettings
- {
- CredentialsFile = config.GetValue("Authentication:CredentialsFile") ?? "credentials.json",
- FingerprintsFile = config.GetValue("Authentication:FingerprintsFile") ?? "fingerprints.json",
- BlacklistFile = config.GetValue("Authentication:BlacklistFile") ?? "blacklist.json",
- MaxFailedAttempts = config.GetValue("Authentication:MaxFailedAttempts", 5)
- };
-
- LoadAll();
- }
-
- #region Loading / Persisting
-
- private void LoadAll()
- {
- // Load credentials
- if (File.Exists(_settings.CredentialsFile))
- {
- var txt = File.ReadAllText(_settings.CredentialsFile);
- var dict = JsonSerializer.Deserialize>(txt)!;
- foreach (var kv in dict)
- Credentials[kv.Key] = kv.Value;
- }
-
- // Load fingerprints
- if (File.Exists(_settings.FingerprintsFile))
- {
- var txt = File.ReadAllText(_settings.FingerprintsFile);
- var dict = JsonSerializer.Deserialize>>(txt)!;
- foreach (var kv in dict)
- Fingerprints[kv.Key] = kv.Value;
- }
-
- // Load blacklist
- if (File.Exists(_settings.BlacklistFile))
- {
- var txt = File.ReadAllText(_settings.BlacklistFile);
- var arr = JsonSerializer.Deserialize(txt)!;
- foreach (var ip in arr)
- BlacklistIPs.Add(ip);
- }
- }
-
- private void PersistCredentials()
- {
- var json = JsonSerializer.Serialize(Credentials, new JsonSerializerOptions { WriteIndented = true });
- File.WriteAllText(_settings.CredentialsFile, json);
- }
-
- private void PersistFingerprints()
- {
- var json = JsonSerializer.Serialize(Fingerprints, new JsonSerializerOptions { WriteIndented = true });
- File.WriteAllText(_settings.FingerprintsFile, json);
- }
-
- private void PersistBlacklist()
- {
- var json = JsonSerializer.Serialize(BlacklistIPs.ToArray(), new JsonSerializerOptions { WriteIndented = true });
- File.WriteAllText(_settings.BlacklistFile, json);
- }
-
- #endregion
-
- public bool IsBlacklisted(string ip) => BlacklistIPs.Contains(ip);
-
- ///
- /// Validates username/password/UID, updates fingerprints and blacklists as needed.
- ///
- /// true if the user is authenticated; otherwise false.
- public bool TryValidate(string username, string password, int? uid, string ip, out string? error)
- {
- error = null;
- lock (_sync)
- {
- // 1) User existence – create on‑the‑fly if missing
- if (!Credentials.TryGetValue(username, out var cred))
- {
- // Create a *new* user that is not yet verified
- cred = new Credential(username, ComputeHash(password), 1, Verified: false);
- Credentials[username] = cred;
- PersistCredentials();
-
- // Create empty fingerprint list (or pre‑add the first UID)
- var list = Fingerprints.GetOrAdd(username, _ => new List());
- if (uid.HasValue && !list.Contains(uid.Value))
- {
- if (list.Count < cred.AllowedUidCount)
- list.Add(uid.Value);
- PersistFingerprints();
- }
-
- error = "User not verified";
- IncrementFailed(username, ip);
- return false;
- }
-
- // 2) Password check
- if (!VerifyPasswordHash(password, cred.PasswordHash))
- {
- error = "Invalid password";
- IncrementFailed(username, ip);
- return false;
- }
-
- // 3) Verify flag – only verified users can pass
- if (!cred.Verified)
- {
- error = "User not verified";
- IncrementFailed(username, ip);
- return false;
- }
-
- // 4) UID handling
- if (uid.HasValue)
- {
- var list = Fingerprints.GetOrAdd(username, _ => new List());
-
- if (!list.Contains(uid.Value))
- {
- if (list.Count < cred.AllowedUidCount)
- {
- list.Add(uid.Value);
- PersistFingerprints();
- }
- else
- {
- error = $"UID limit ({cred.AllowedUidCount}) exceeded";
- IncrementFailed(username, ip);
- return false;
- }
- }
- }
-
- // 5) Success – reset counter
- FailedAttempts[username] = 0;
- return true;
- }
- }
-
- private void IncrementFailed(string username, string ip)
- {
- var newCount = FailedAttempts.GetOrAdd(username, 0) + 1;
- FailedAttempts[username] = newCount;
-
- if (newCount >= _settings.MaxFailedAttempts)
- {
- BlacklistIPs.Add(ip);
- PersistBlacklist();
- // reset counter for the next session
- FailedAttempts[username] = 0;
- }
- }
-
- #region Helpers
-
- public static string ComputeHash(string input)
- {
- using var sha = SHA256.Create();
- var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(input));
- return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
- }
-
- private static bool VerifyPasswordHash(string plain, string storedHash)
- => string.Equals(ComputeHash(plain), storedHash, StringComparison.OrdinalIgnoreCase);
-
- #endregion
-}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Services/GameDirectoryWatcherService.cs b/TinfoilVibeServer/Services/GameDirectoryWatcherService.cs
new file mode 100644
index 0000000..d91fe5d
--- /dev/null
+++ b/TinfoilVibeServer/Services/GameDirectoryWatcherService.cs
@@ -0,0 +1,229 @@
+// File: TinfoilVibeServer/Services/GameDirectoryWatcherService.cs
+
+using Microsoft.Extensions.Options;
+using SharpCompress.Archives;
+using TinfoilVibeServer.Config;
+using TinfoilVibeServer.Persistence;
+using TinfoilVibeServer.Models;
+
+namespace TinfoilVibeServer.Services;
+
+///
+/// 1. Loads persisted snapshot on start.
+/// 2. Scans configured directories for Switch ROMs or archives, extracts metadata, and keeps snapshot up‑to‑date.
+/// 3. Watches directories for changes and updates snapshot incrementally.
+///
+public sealed class GameDirectoryWatcherService : BackgroundService
+{
+ private readonly ILogger _log;
+ private readonly IOptionsMonitor _options;
+ private readonly ISnapshotRepository _repo;
+ private readonly ILibHacParser _parser;
+ private readonly Dictionary _snapshot = new();
+ private readonly List _watchers = new();
+
+ // Valid extensions for Switch ROMs (case‑insensitive).
+ private static readonly string[] _romExtensions = { ".nsp", ".xci" };
+
+ // Archive extensions that we can open with SharpCompress.
+ private static readonly string[] _archiveExtensions = { ".zip", ".rar", ".7z", ".tar", ".gz" };
+
+ public GameDirectoryWatcherService(
+ ILogger log,
+ IOptionsMonitor options,
+ ISnapshotRepository repo,
+ ILibHacParser parser)
+ {
+ _log = log;
+ _options = options;
+ _repo = repo;
+ _parser = parser;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ // 1. Load persisted snapshot
+ var persisted = await _repo.LoadAsync();
+ foreach (var kvp in persisted)
+ {
+ _snapshot[kvp.Key] = kvp.Value;
+ }
+
+ // 2. Initial scan
+ await ScanAllDirectoriesAsync(stoppingToken);
+
+ // 3. Setup watchers
+ foreach (var dir in _options.CurrentValue.Paths)
+ {
+ var watcher = new FileSystemWatcher(dir)
+ {
+ IncludeSubdirectories = true,
+ NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite
+ };
+
+ watcher.Created += async (_, e) => await HandleCreatedAsync(e.FullPath, stoppingToken);
+ watcher.Changed += async (_, e) => await HandleChangedAsync(e.FullPath, stoppingToken);
+ watcher.Deleted += (_, e) => HandleDeleted(e.FullPath);
+ watcher.Renamed += async (_, e) => await HandleRenamedAsync(e.OldFullPath, e.FullPath, stoppingToken);
+
+ watcher.EnableRaisingEvents = true;
+ _watchers.Add(watcher);
+ }
+
+ // Keep the service alive until cancellation
+ await Task.Delay(Timeout.Infinite, stoppingToken);
+ }
+
+ private async Task ScanAllDirectoriesAsync(CancellationToken token)
+ {
+ foreach (var dir in _options.CurrentValue.Paths)
+ {
+ if (!Directory.Exists(dir))
+ {
+ _log.LogWarning("Configured directory does not exist: {Dir}", dir);
+ continue;
+ }
+
+ await foreach (var file in Directory.EnumerateFiles(dir, "*.*", SearchOption.AllDirectories).ToAsyncEnumerable().WithCancellation(token))
+ {
+ await ProcessFileAsync(file, token);
+ _log.LogInformation("Processed file: {File}", file);
+ }
+ }
+
+ await PersistSnapshotAsync();
+ }
+
+ private async Task ProcessFileAsync(string fullPath, CancellationToken token)
+ {
+ var ext = Path.GetExtension(fullPath).ToLowerInvariant();
+
+ if (_romExtensions.Contains(ext))
+ {
+ await ProcessRomAsync(fullPath, string.Empty, token);
+ }
+ else if (_archiveExtensions.Contains(ext))
+ {
+ await ProcessArchiveAsync(fullPath, token);
+ }
+ }
+
+ private async Task ProcessRomAsync(string fullPath, string insidePath, CancellationToken token)
+ {
+ try
+ {
+ await using var stream = File.OpenRead(fullPath);
+ var entry = await _parser.ExtractAsync(stream, fullPath, insidePath);
+
+ if (entry == null)
+ {
+ _log.LogDebug("No valid NCA found in {Path}", fullPath);
+ return;
+ }
+
+ await UpsertSnapshotAsync(entry, token);
+ }
+ catch (Exception ex)
+ {
+ _log.LogError(ex, "Failed to process ROM {Path}", fullPath);
+ }
+ }
+
+ private async Task ProcessArchiveAsync(string archivePath, CancellationToken token)
+ {
+ try
+ {
+ using var archive = ArchiveFactory.Open(archivePath);
+ foreach (var entry in archive.Entries.Where(e => !e.IsDirectory))
+ {
+ var ext = Path.GetExtension(entry.Key).ToLowerInvariant();
+ if (_romExtensions.Contains(ext))
+ {
+ await using var entryStream = entry.OpenEntryStream();
+ await ProcessRomAsync(archivePath, entry.Key, token);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _log.LogWarning(ex, "SharpCompress failed on {Archive}. Trying SharpSevenZip fallback.", archivePath);
+ await ProcessArchiveWithSharpSevenZipAsync(archivePath, token);
+ }
+ }
+
+ private async Task ProcessArchiveWithSharpSevenZipAsync(string archivePath, CancellationToken token)
+ {
+ // Placeholder – actual implementation requires SharpSevenZip integration.
+ // The idea is to open the archive, enumerate entries, and for each ROM
+ // call ProcessRomAsync with the archive path and internal entry path.
+ _log.LogDebug("SharpSevenZip fallback not yet implemented for {Archive}", archivePath);
+ }
+
+ private async Task UpsertSnapshotAsync(SnapshotEntry entry, CancellationToken token)
+ {
+ if (_snapshot.TryGetValue(entry.TitleId, out var existing))
+ {
+ // File exists – compare paths and hashes
+ if (existing.FullPath == entry.FullPath &&
+ existing.Hash.SequenceEqual(entry.Hash))
+ {
+ // No change
+ return;
+ }
+
+ _log.LogInformation("Updating snapshot for TitleId {TitleId} – path change or hash update.", entry.TitleId);
+ _snapshot[entry.TitleId] = entry;
+ }
+ else
+ {
+ _log.LogInformation("Adding new entry to snapshot: {TitleId}", entry.TitleId);
+ _snapshot[entry.TitleId] = entry;
+ }
+
+ await PersistSnapshotAsync();
+ }
+
+ private void HandleDeleted(string fullPath)
+ {
+ var titleId = _snapshot.Values.FirstOrDefault(e => e.FullPath == fullPath)?.TitleId;
+ if (titleId != null)
+ {
+ _log.LogInformation("Removing deleted file from snapshot: {TitleId} ({Path})", titleId, fullPath);
+ _snapshot.Remove(titleId);
+ _repo.PersistAsync(_snapshot).ConfigureAwait(false);
+ }
+ }
+
+ private async Task HandleCreatedAsync(string fullPath, CancellationToken token)
+ {
+ _log.LogInformation("Detected new file: {Path}", fullPath);
+ await ProcessFileAsync(fullPath, token);
+ }
+
+ private async Task HandleChangedAsync(string fullPath, CancellationToken token)
+ {
+ _log.LogInformation("Detected changed file: {Path}", fullPath);
+ await ProcessFileAsync(fullPath, token);
+ }
+
+ private async Task HandleRenamedAsync(string oldFullPath, string newFullPath, CancellationToken token)
+ {
+ _log.LogInformation("Detected renamed file: {Old} → {New}", oldFullPath, newFullPath);
+ // Treat as delete + create
+ HandleDeleted(oldFullPath);
+ await ProcessFileAsync(newFullPath, token);
+ }
+
+ private async Task PersistSnapshotAsync()
+ {
+ try
+ {
+ await _repo.PersistAsync(_snapshot);
+ _log.LogDebug("Snapshot persisted with {Count} entries.", _snapshot.Count);
+ }
+ catch (Exception ex)
+ {
+ _log.LogError(ex, "Failed to persist snapshot");
+ }
+ }
+}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Services/ILibHacParser.cs b/TinfoilVibeServer/Services/ILibHacParser.cs
new file mode 100644
index 0000000..a090f97
--- /dev/null
+++ b/TinfoilVibeServer/Services/ILibHacParser.cs
@@ -0,0 +1,14 @@
+// File: TinfoilVibeServer/Services/ILibHacParser.cs
+using System.IO;
+using System.Threading.Tasks;
+using TinfoilVibeServer.Models;
+
+namespace TinfoilVibeServer.Services;
+
+public interface ILibHacParser
+{
+ ///
+ /// Reads a ROM stream (NSP/XCI/…) and extracts metadata.
+ ///
+ Task ExtractAsync(Stream romStream, string fullPath, string insidePath);
+}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Services/KeySetHolder.cs b/TinfoilVibeServer/Services/KeySetHolder.cs
new file mode 100644
index 0000000..6339141
--- /dev/null
+++ b/TinfoilVibeServer/Services/KeySetHolder.cs
@@ -0,0 +1,8 @@
+using LibHac.Common.Keys;
+
+namespace TinfoilVibeServer.Models;
+
+public class KeySetHolder
+{
+ public static KeySet KeySet { get; set; }
+}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Services/LibHacParser.cs b/TinfoilVibeServer/Services/LibHacParser.cs
new file mode 100644
index 0000000..225dbd2
--- /dev/null
+++ b/TinfoilVibeServer/Services/LibHacParser.cs
@@ -0,0 +1,59 @@
+// File: TinfoilVibeServer/Services/LibHacParser.cs
+using System;
+using System.IO;
+using System.Linq;
+using System.Buffers;
+using System.Threading.Tasks;
+using LibHac.Fs;
+using LibHac.Fs.Fsa;
+using LibHac.FsSystem;
+using LibHac.Ncm;
+using LibHac.Tools.FsSystem;
+using LibHac.Tools.FsSystem.NcaUtils;
+using TinfoilVibeServer.Models;
+
+namespace TinfoilVibeServer.Services;
+
+///
+/// Implements the LibHac extraction logic copied from the provided NSPExtractor.cs [4].
+///
+public sealed class LibHacParser : ILibHacParser
+{
+ private readonly NSPExtactor _nspExtractor;
+
+ public LibHacParser(NSPExtactor nspExtractor)
+ {
+ _nspExtractor = nspExtractor;
+ }
+ ///
+ /// Reads the stream of a single Switch ROM (NSP or XCI) and returns metadata.
+ ///
+ public async Task ExtractAsync(Stream romStream, string fullPath, string insidePath)
+ {
+ var extractFromStream = _nspExtractor.ExtractFromStream(romStream);
+ // Build snapshot entry
+ if (extractFromStream?.TitleId == null) return null;
+
+ var entry = new SnapshotEntry
+ {
+ TitleId = extractFromStream.TitleId,
+ Version = extractFromStream.Version,
+ IsApp = extractFromStream.ContentMetaType == ContentMetaType.Application,
+ IsPatch = extractFromStream.ContentMetaType == ContentMetaType.Patch,
+ IsDlc = extractFromStream.ContentMetaType == ContentMetaType.AddOnContent,
+ FullPath = fullPath,
+ InsidePath = insidePath,
+ Hash = await ComputeHashAsync(romStream),
+ LastModified = File.GetLastWriteTimeUtc(fullPath)
+ };
+
+ return entry;
+ }
+
+ private static async Task ComputeHashAsync(Stream stream)
+ {
+ stream.Seek(0, SeekOrigin.Begin);
+ using var sha256 = System.Security.Cryptography.SHA256.Create();
+ return await sha256.ComputeHashAsync(stream);
+ }
+}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Services/NSPExtractor.cs b/TinfoilVibeServer/Services/NSPExtractor.cs
index dff4a4f..91e0c26 100644
--- a/TinfoilVibeServer/Services/NSPExtractor.cs
+++ b/TinfoilVibeServer/Services/NSPExtractor.cs
@@ -1,65 +1,143 @@
-using System;
-using System.IO;
-using System.Text.Json;
+using LibHac.Common;
+using LibHac.Common.Keys;
using LibHac.Fs;
+using LibHac.Fs.Fsa;
using LibHac.FsSystem;
-using LibHac.FsSystem.Impl;
-using LibHac.Util;
-using TinfoilVibeServer.Models;
+using LibHac.Tools.FsSystem;
+using LibHac.Tools.FsSystem.NcaUtils;
+using LibHac.Ncm;
+using LibHac.Tools.Ncm;
+using Microsoft.Extensions.Options;
-namespace FileSnapshot;
-
-///
-/// Extracts title information from a Nintendo NSP/XCI file using LibHac 0.20.0.
-///
-public sealed class NSPExtactor
+namespace TinfoilVibeServer.Services
{
///
- /// Return TitleInfo for the file, or null if the file is not a valid Nintendo archive.
+ /// Extracts the TitleId, version and patch/application flag from an NSP/XCI file.
///
- public static TitleInfo? ExtractFromFile(string filePath)
+ public sealed class NSPExtactor
{
- // LibHac works with byte streams. We open the file once and hand the stream to RomArchiveReader.
- try
+ private readonly KeySetProvider _keySetProvider;
+
+ public NSPExtactor(KeySetProvider keySetProvider)
{
- using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
- using var reader = new RomArchiveReader(fs, new RomArchiveSettings { UseCache = false });
+ _keySetProvider = keySetProvider;
+ }
+ public NcaMetadataDto? ExtractFromFile(string filePath)
+ {
+ using var stream = File.OpenRead(filePath);
+ return ExtractFromStream(stream);
+ }
- if (!reader.IsValid)
- return null; // Not an NSP/XCI
+ public NcaMetadataDto? ExtractFromStream(Stream stream)
+ {
+ if (!IsPfsFileSystem(stream))
+ return null;
- // The ROM contains one or more NCA headers. For most cases the first one is the title.
- // LibHac exposes the *content* list – we pick the first NCA that is a title.
- foreach (var nca in reader.GetContentInfos())
+ stream.Seek(0, SeekOrigin.Begin);
+
+ // 0‑19.0.0 stream wrapper
+ using var storage = new StreamStorage(stream, false);
+
+ var partition = new PartitionFileSystem();
+ partition.Initialize(storage).ThrowIfFailure();
+
+ // Find the first Meta‑NCA inside the NSP/XCI
+ foreach (var entry in partition.EnumerateEntries("*.nca",
+ SearchOptions.RecurseSubdirectories)
+ .Where(e => e.Type == DirectoryEntryType.File))
{
- // NcaId.Type gives Application / Patch / DLC etc.
- // We only care that the type is not null – the NCA itself contains the metadata we need.
- var meta = nca.GetMetaData();
+ using var fileRef = new UniqueRef();
+ partition.OpenFile(ref fileRef.Ref, entry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
+ using var file = fileRef.Release();
+ using var fileStorage = new FileStorage(file);
+ var nca = new Nca(_keySetProvider.Get(), fileStorage);
- // 1) Title ID
- string titleId = nca.Id.ToString("X16");
-
- // 2) Name and version
- // 0.20.x provides a simple string accessor
- string? titleName = meta.GetStringValue("title");
- string? versionStr = meta.GetStringValue("version");
-
- // 3) Determine if it is an application
- bool isApp = meta.GetStringValue("content_type") == "Application";
-
- return new TitleInfo(
- titleId,
- titleName ?? $"Unknown ({titleId})",
- versionStr ?? "0.00",
- isApp);
+ // Meta‑NCA contains the TitleId & version
+ if (nca.Header.ContentType == NcaContentType.Meta)
+ {
+ var titleId = nca.Header.TitleId.ToString("X16");
+ int version = nca.Header.Version;
+ var contentMetaType = GetMetaDataType(nca);
+ if (contentMetaType != null) return new NcaMetadataDto(titleId, version, contentMetaType.Value);
+ }
}
- return null; // No NCA found
- }
- catch
- {
- // Any exception (bad file, invalid archive, etc.) -> treat as non‑NXP
return null;
}
+
+ private static ContentMetaType? GetMetaDataType(Nca nca)
+ {
+ if (nca.Header.ContentType != NcaContentType.Meta) return null;
+ using var openFileSystem = nca.OpenFileSystem(0, IntegrityCheckLevel.ErrorOnInvalid);
+ foreach (var entry in openFileSystem.EnumerateEntries("*.cnmt", SearchOptions.Default))
+ {
+ using var fileRef = new UniqueRef();
+
+ var result = openFileSystem.OpenFile(ref fileRef.Ref, entry.FullPath.ToU8Span(), OpenMode.Read);
+ if (result.IsFailure()) continue;
+ using var nacpFile = fileRef.Release();
+ using var asStream = nacpFile.AsStream();
+
+ var cnmt = new Cnmt(asStream);
+ return cnmt.Type;
+ }
+
+ return null;
+ }
+
+ private static bool IsPfsFileSystem(Stream stream)
+ {
+ try
+ {
+ if (!stream.CanSeek) return false;
+ stream.Seek(0, SeekOrigin.Begin);
+
+ var storage = new StreamStorage(stream, false);
+ var partition = new PartitionFileSystem();
+ partition.Initialize(storage).ThrowIfFailure();
+
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
}
+
+ public class KeySetProvider
+ {
+ private readonly KeySet _keySet;
+
+ public KeySetProvider(IOptions nspExtractorSettings)
+ {
+ _keySet = ExternalKeyReader.ReadKeyFile(nspExtractorSettings.Value.KeyFilePath);
+ }
+
+ public KeySet Get() => _keySet;
+ }
+
+ public class NSPExtractorSettings
+ {
+ public string KeyFilePath { get; set; } = string.Empty;
+ }
+
+ ///
+ /// Simple DTO that matches the information extracted by NSPExtactor.
+ ///
+ public sealed class NcaMetadataDto
+ {
+ public string TitleId { get; }
+ public int Version { get; }
+ public ContentMetaType ContentMetaType { get; }
+
+ public NcaMetadataDto(string titleId, int version, ContentMetaType contentMetaType)
+ {
+ TitleId = titleId;
+ Version = version;
+ ContentMetaType = contentMetaType;
+ }
+ }
+
+
}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Services/NcaMetadataDto.cs b/TinfoilVibeServer/Services/NcaMetadataDto.cs
new file mode 100644
index 0000000..2ac196d
--- /dev/null
+++ b/TinfoilVibeServer/Services/NcaMetadataDto.cs
@@ -0,0 +1,8 @@
+namespace TinfoilVibeServer.Models;
+
+public class NcaMetadataDto(string titleId, int version, bool isApp)
+{
+ public string TitleId { get; set; } = titleId;
+ public int Version { get; set; } = version;
+ public bool IsApp { get; set; } = isApp;
+}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Services/SnapshotService.cs b/TinfoilVibeServer/Services/SnapshotService.cs
deleted file mode 100644
index 4d680a2..0000000
--- a/TinfoilVibeServer/Services/SnapshotService.cs
+++ /dev/null
@@ -1,191 +0,0 @@
-using System.Collections.Concurrent;
-using System.Security.Cryptography;
-using System.Text.Json;
-using FileSnapshot;
-using TinfoilVibeServer.Models;
-
-namespace TinfoilVibeServer.Services;
-
-///
-/// Keeps an in‑memory snapshot, watches the filesystem for changes, and
-/// only re‑processes a file if its hash changed. The snapshot is
-/// automatically re‑generated when the configuration changes.
-///
-public sealed class SnapshotService : IDisposable
-{
- private readonly ConfigManager _config;
- private readonly string _jsonPath;
- private readonly string _snapshotPath;
- private readonly FileSystemWatcher _watcher;
-
- // path -> CachedFile
- private readonly ConcurrentDictionary _cache = new();
-
- private string? _currentSnapshotHash;
-
- public SnapshotService(ConfigManager config)
- {
- _config = config;
- _jsonPath = Path.Combine(AppContext.BaseDirectory, config.Settings.SnapshotFile);
- _snapshotPath = Path.Combine(AppContext.BaseDirectory, config.Settings.SnapshotBackupFile);
-
- // Initial snapshot
- BuildSnapshot();
-
- // Persist a copy for quick load on next run
- File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(GetSnapshot()));
-
- // File system watcher
- _watcher = new FileSystemWatcher
- {
- 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;
-
- // React to config changes
- _config.OnChange += cfg =>
- {
- // Re‑initialise the watcher with the new root directories
- _watcher.Path = string.Join(Path.PathSeparator, cfg.RootDirectories);
- _watcher.EnableRaisingEvents = true;
- BuildSnapshot(); // rebuild everything
- PersistSnapshot();
- };
- }
-
- private sealed record CachedFile(string Path, string Hash, TitleInfo? Title);
-
- #region File system change handlers
-
- private void OnChanged(object? _, FileSystemEventArgs e) =>
- ThrottleSnapshotUpdate();
-
- private void OnRenamed(object? _, RenamedEventArgs e)
- {
- // Treat rename as delete + create
- OnChanged(_, new FileSystemEventArgs(WatcherChangeTypes.Deleted, e.OldFullPath, e.OldName));
- OnChanged(_, new FileSystemEventArgs(WatcherChangeTypes.Created, e.FullPath, e.Name));
- }
-
- private void ThrottleSnapshotUpdate()
- {
- // Debounce: only trigger once in a short window
- Task.Run(async () =>
- {
- await Task.Delay(250);
- UpdateSnapshot();
- });
- }
-
- #endregion
-
- ///
- /// Full rebuild – called on start‑up and on config change.
- ///
- private void BuildSnapshot()
- {
- var cfg = _config.Settings;
- var entries = new List();
-
- foreach (var dir in cfg.RootDirectories)
- {
- foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
- {
- var ext = Path.GetExtension(file).ToLowerInvariant();
-
- if (!(cfg.WhitelistExtensions.Contains(ext) || cfg.RomExtensions.Contains(ext)))
- continue;
-
- // 1) compute hash
- var hash = ComputeHash(file);
-
- // 2) decide if we need to re‑process
- if (_cache.TryGetValue(file, out var cached) && cached.Hash == hash)
- {
- // nothing changed – use cached title info
- entries.Add(new FileEntry(file, new FileInfo(file).Length, hash, cached.Title));
- continue;
- }
-
- // 3) extract title if applicable
- TitleInfo? 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));
- }
- }
-
- // Replace the entire snapshot
- lock (_cache)
- {
- // the snapshot itself is not stored in _cache – it's only used for the JSON
- }
- // we keep entries in a local variable for now
- _currentSnapshotHash = ComputeSnapshotHash(entries);
- File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries));
- }
-
- private static string ComputeSnapshotHash(IEnumerable entries)
- {
- var json = JsonSerializer.Serialize(entries);
- using var sha256 = SHA256.Create();
- return BitConverter.ToString(sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json))).Replace("-", "").ToLowerInvariant();
- }
-
- private void UpdateSnapshot()
- {
- BuildSnapshot();
- PersistSnapshot();
- }
-
- private void PersistSnapshot()
- {
- var entries = GetSnapshot();
- var newHash = ComputeSnapshotHash(entries);
- if (_currentSnapshotHash != newHash)
- {
- _currentSnapshotHash = newHash;
- File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries));
- File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(entries));
- }
- }
-
- private static string ComputeHash(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 IReadOnlyList GetSnapshot()
- {
- var json = File.ReadAllText(_jsonPath);
- return JsonSerializer.Deserialize>(json)!;
- }
-
- public void Dispose()
- {
- _watcher.Dispose();
- _config.Dispose();
- }
-}
\ No newline at end of file
diff --git a/TinfoilVibeServer/TinfoilVibeServer.csproj b/TinfoilVibeServer/TinfoilVibeServer.csproj
index 06fa2e5..db91a59 100644
--- a/TinfoilVibeServer/TinfoilVibeServer.csproj
+++ b/TinfoilVibeServer/TinfoilVibeServer.csproj
@@ -12,7 +12,9 @@
-
+
+
+
@@ -22,6 +24,9 @@
appsettings.json
+
+ Always
+
@@ -36,4 +41,15 @@
+
+
+
+
+
+
+
+ x86\Models\SnapshotEntry.cs
+
+
+
diff --git a/TinfoilVibeServer/appsettings.json b/TinfoilVibeServer/appsettings.json
index ecd2905..fe8acc5 100644
--- a/TinfoilVibeServer/appsettings.json
+++ b/TinfoilVibeServer/appsettings.json
@@ -1,23 +1,39 @@
{
- "Logging": {
- "LogLevel": {
- "Default": "Information",
- "Microsoft.AspNetCore": "Warning"
- }
+ "Serilog": {
+ "Using": [
+ "Serilog.Sinks.Console"
+ ],
+ "MinimumLevel": "Information",
+ "WriteTo": [
+ {
+ "Name": "Console",
+ "Args": {
+ "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"
+ }
+ }
+ ]
},
"AllowedHosts": "*",
-
- "RootDirectories": [
- "\\\\NAS\\Games",
- "\\\\NAS\\Backups"
- ],
+ "GameDirectories": {
+ "Paths": [
+ "\\\\NAS\\Games",
+ "Z:\\imgs\\roms\\Switch"
+ ]
+ },
+ "SnapshotPath": "snapshot.json",
"WhitelistExtensions": [
- ".bin", ".jpg", ".png", ".txt"
+ ".bin",
+ ".jpg",
+ ".png",
+ ".txt"
],
"RomExtensions": [
- ".xci", ".nsp", ".xcz"
+ ".xci",
+ ".nsp",
+ ".xcz"
],
"SnapshotFile": "index.tfl",
"SnapshotBackupFile": "snapshot.bin",
- "ArchiveBufferSize": 8192
-}
+ "ArchiveBufferSize": 8192,
+ "KeySet": "prod.keys"
+}
\ No newline at end of file
diff --git a/TinfoilVibeServerTests/TinfoilVibeServerTests.csproj b/TinfoilVibeServerTests/TinfoilVibeServerTests.csproj
new file mode 100644
index 0000000..d3517ca
--- /dev/null
+++ b/TinfoilVibeServerTests/TinfoilVibeServerTests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net9.0
+ latest
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+