diff --git a/TinfoilVibeServer.sln.DotSettings.user b/TinfoilVibeServer.sln.DotSettings.user
index 142b5a7..a5c21f9 100644
--- a/TinfoilVibeServer.sln.DotSettings.user
+++ b/TinfoilVibeServer.sln.DotSettings.user
@@ -3,18 +3,25 @@
True
ForceIncluded
ForceIncluded
+ ForceIncluded
ForceIncluded
ForceIncluded
+ ForceIncluded
ForceIncluded
ForceIncluded
ForceIncluded
ForceIncluded
+ ForceIncluded
ForceIncluded
ForceIncluded
+ ForceIncluded
ForceIncluded
+ ForceIncluded
ForceIncluded
ForceIncluded
ForceIncluded
+ ForceIncluded
+ ForceIncluded
ForceIncluded
ForceIncluded
<AssemblyExplorer>
diff --git a/TinfoilVibeServer/Controllers/IndexController.cs b/TinfoilVibeServer/Controllers/IndexController.cs
index 8f59ce9..c4d6301 100644
--- a/TinfoilVibeServer/Controllers/IndexController.cs
+++ b/TinfoilVibeServer/Controllers/IndexController.cs
@@ -9,50 +9,57 @@ using TinfoilVibeServer.Services;
namespace TinfoilVibeServer.Controllers;
[ApiController]
-[Route("[controller]")]
+[Route("/")]
public sealed class IndexController : ControllerBase
{
- private readonly SnapshotService _snapshotService;
+ private readonly ISnapshotService _snapshotService;
private readonly TitleDatabaseService _titleDb;
private readonly IConfiguration _configuration;
+ private readonly IndexBuilderService _indexBuilderService;
- public IndexController(SnapshotService snapshotService,
+ public IndexController(ISnapshotService snapshotService,
TitleDatabaseService titleDb,
- IConfiguration configuration)
+ IConfiguration configuration, IndexBuilderService indexBuilderService)
{
_snapshotService = snapshotService;
_titleDb = titleDb;
_configuration = configuration;
+ _indexBuilderService = indexBuilderService;
}
+ // ------------------------------------------------------------
+ // GET /
+ // ------------------------------------------------------------
///
- /// GET /index.json – returns the structure you asked for.
+ /// Return the “index snapshot” – e.g. a JSON file that lists
+ /// every available resource. The snapshot could also be a
+ /// rendered Razor view – simply change the return type.
///
- [HttpGet("index.json")]
- public IActionResult GetIndex()
+ public IActionResult Index()
{
- var index = new IndexBuilderService(
- _snapshotService, _titleDb, _configuration,
- this.HttpContext.RequestServices.GetService(typeof(ILogger)) as Microsoft.Extensions.Logging.ILogger)
- .Build();
+ var index = _indexBuilderService.Build();
return Ok(index);
}
+ // ------------------------------------------------------------
+ // GET /{*path}
+ // ------------------------------------------------------------
///
- /// GET /download?url=… – streams the requested NSP file.
- /// The `url` value is the literal string that appears in the
- /// index, e.g. “[Mario][0004000000000000][10000][Base].nsp”.
+ /// Catch‑all action. Any URL that is **not** “/” is routed
+ /// here. The value of *path* is the relative location you
+ /// want to stream back to the client.
///
- [HttpGet("download")]
- public IActionResult Download(string url)
+ /// The relative file path requested.
+ [HttpGet("{*path}")]
+ public IActionResult Download(string path)
{
- if (string.IsNullOrWhiteSpace(url))
+ if (string.IsNullOrWhiteSpace(path))
return BadRequest("Missing url query parameter.");
// ---- 1️⃣ Parse the brackets --------------------------------
// Expected format: [name][TitleId][v][patchOrApp].nsp
- var match = System.Text.RegularExpressions.Regex.Match(url,
+ var match = System.Text.RegularExpressions.Regex.Match(path,
@"\[(?.*?)\]\[(?[0-9a-fA-F]{8}[0-9a-fA-F]{8})\]\[(?[0-9a-fA-F]+)\]\[(?Base|Update)\]\.nsp",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
diff --git a/TinfoilVibeServer/Models/FileEntry.cs b/TinfoilVibeServer/Models/FileEntry.cs
index 0789279..57fc593 100644
--- a/TinfoilVibeServer/Models/FileEntry.cs
+++ b/TinfoilVibeServer/Models/FileEntry.cs
@@ -1,4 +1,6 @@
-namespace TinfoilVibeServer.Models;
+using TinfoilVibeServer.Services;
+
+namespace TinfoilVibeServer.Models;
///
/// One line in the snapshot – the JSON will be an array of these.
@@ -7,5 +9,5 @@ public sealed record FileEntry(
string Path,
long Size,
string Hash, // SHA‑256 hex
- NcaMetadataDto? Title // null unless file is an NSP/XCI or an archive containing one
+ NcaMetadataWithHash? Title // null unless file is an NSP/XCI or an archive containing one
);
\ No newline at end of file
diff --git a/TinfoilVibeServer/Models/IdHelper.cs b/TinfoilVibeServer/Models/IdHelper.cs
index b0a5b76..ced038d 100644
--- a/TinfoilVibeServer/Models/IdHelper.cs
+++ b/TinfoilVibeServer/Models/IdHelper.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using System.Text.RegularExpressions;
namespace TinfoilVibeServer.Models;
@@ -45,4 +46,33 @@ public static class IdHelper
var match = Regex.Match(fileInfo.Name, "^.*\\[(\\w{16})\\].*\\.nsp$");
return match is { Length: > 0, Groups.Count: > 1 }?match.Groups[1].Value:string.Empty;
}
+ #region 1️⃣ From hex string → byte[] (big‑endian)
+
+ ///
+ /// Turns an even‑length hex string into a byte array in **big‑endian** order
+ /// (the same order that the original ToStrId creates).
+ ///
+ private static byte[] HexStringToBytes(string hex)
+ {
+ if (string.IsNullOrWhiteSpace(hex))
+ throw new ArgumentException("ID string cannot be empty", nameof(hex));
+
+ if (hex.Length % 2 != 0)
+ throw new ArgumentException("ID string must contain an even number of hex digits", nameof(hex));
+
+ var bytes = new byte[hex.Length / 2];
+ for (var i = 0; i < bytes.Length; i++)
+ {
+ var chunk = hex.Substring(i * 2, 2);
+ bytes[i] = byte.Parse(chunk, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
+ }
+
+ return bytes;
+ }
+
+ #endregion
+
+ public static long ToLongFromStrId(this string hex) =>
+ BitConverter.ToInt64(HexStringToBytes(hex).Reverse().ToArray(), 0);
+
}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Models/TitleDbOptions.cs b/TinfoilVibeServer/Models/TitleDbOptions.cs
new file mode 100644
index 0000000..08061cb
--- /dev/null
+++ b/TinfoilVibeServer/Models/TitleDbOptions.cs
@@ -0,0 +1,8 @@
+namespace TinfoilVibeServer.Models;
+
+public class TitleDbOptions
+{
+ public int TtlSeconds { get; set; } = 60; // fallback
+ public string LanguageCode { get; set; } = "en";
+ public string CountryCode { get; set; } = "US";
+}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Models/TitleInfoDto.cs b/TinfoilVibeServer/Models/TitleInfoDto.cs
index 1401218..0fcf21a 100644
--- a/TinfoilVibeServer/Models/TitleInfoDto.cs
+++ b/TinfoilVibeServer/Models/TitleInfoDto.cs
@@ -9,6 +9,6 @@ public sealed record TitleInfoDto(
string TitleId, // 16‑digit hex – “0004000000000000”
string Name,
string Id,
- DateTime ReleaseDate,
- string NSUID,
+ int? ReleaseDate,
+ long NSUID,
string Version);
\ No newline at end of file
diff --git a/TinfoilVibeServer/Program.cs b/TinfoilVibeServer/Program.cs
index bcf0984..2281f40 100644
--- a/TinfoilVibeServer/Program.cs
+++ b/TinfoilVibeServer/Program.cs
@@ -1,9 +1,3 @@
-using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
-using System.Text;
-using System.Text.Json;
using TinfoilVibeServer.Authentication;
using TinfoilVibeServer.Middleware;
using TinfoilVibeServer.Services;
@@ -15,11 +9,22 @@ var builder = WebApplication.CreateBuilder(args);
// 1) Configuration – read appsettings.json once and expose it via
// ConfigManager (reloads on file change)
// -------------------------------------------------------------------
+builder.Services.AddMemoryCache();
+builder.Services.Configure(builder.Configuration.GetSection("TitleDb"));
builder.Services.AddSingleton();
-builder.Services.AddSingleton();
+builder.Services.AddSingleton(sp =>
+{
+ var config = sp.GetRequiredService();
+ var keySet = KeySetHolder.KeySet; // already loaded by ConfigManager
+ return new NSPExtractor(keySet);
+});
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddHostedService(provider => provider.GetRequiredService()).AddHttpClient();
+builder.Services.AddHostedService(provider => provider.GetRequiredService());
builder.Services.AddControllers(); // add MVC
// -------------------------------------------------------------------
// 2) Middleware – Basic‑Auth (verifies username, password, UID, blacklist)
@@ -35,7 +40,10 @@ app.MapControllers(); // routes the /index.json & /download endpoints
// -------------------------------------------------------------------
-app.MapGet("/debug", () => new SnapshotService(app.Services.GetRequiredService())
+app.MapGet("/debug", () => new SnapshotService(
+ app.Services.GetRequiredService(),
+ app.Services.GetRequiredService(),
+ app.Services.GetRequiredService())
.GetSnapshot());
app.Run();
\ No newline at end of file
diff --git a/TinfoilVibeServer/Services/ArchiveHandler.cs b/TinfoilVibeServer/Services/ArchiveHandler.cs
index abbe891..f4b22fc 100644
--- a/TinfoilVibeServer/Services/ArchiveHandler.cs
+++ b/TinfoilVibeServer/Services/ArchiveHandler.cs
@@ -1,77 +1,86 @@
-using System.IO;
+using System.IO.Compression;
using SharpCompress.Archives;
using SharpCompress.Archives.Zip;
-using SharpCompress.Archives.SevenZip;
using SharpCompress.Archives.Rar;
+using SharpCompress.Archives.SevenZip;
using TinfoilVibeServer.Models;
-using SharpSevenZip;
+using ZipArchive = SharpCompress.Archives.Zip.ZipArchive;
namespace TinfoilVibeServer.Services;
///
-/// Tries to open an archive and look for an embedded NSP/XCI entry.
-/// The implementation streams the entry directly into LibHac, avoiding any
-/// temporary files on disk.
+/// Tries to open a file as an archive and look for an embedded NSP/XCI.
+/// The extractor is injected so that the hash of the first stream can be accessed
+/// while the file is being read.
///
public sealed class ArchiveHandler
{
+ private readonly NSPExtractor _nspExtractor;
+
+ public ArchiveHandler(NSPExtractor nspExtractor)
+ {
+ _nspExtractor = nspExtractor;
+ }
+
///
- /// Returns TitleInfo if an embedded Nintendo archive is found; otherwise null.
+ /// Return TitleInfo if an embedded Nintendo archive is found; otherwise null.
///
- public static NcaMetadataDto? TryExtractTitleInfo(string filePath)
+ public NcaMetadataWithHash? TryExtractTitleInfo(string filePath)
{
var ext = Path.GetExtension(filePath).ToLowerInvariant();
try
{
- switch (ext)
+ return ext switch
{
- case ".zip":
- return HandleZip(filePath);
- case ".7z":
- return Handle7z(filePath);
- case ".rar":
- return HandleRar(filePath);
- default:
- return null;
- }
+ ".zip" => HandleZip(filePath),
+ ".7z" => Handle7z(filePath),
+ ".rar" => HandleRar(filePath),
+ _ => null
+ };
}
catch
{
+ // Graceful fallback – return null
return null;
}
}
- private static NcaMetadataDto? HandleZip(string path)
+ private NcaMetadataWithHash? HandleZip(string path)
{
using var archive = ZipArchive.Open(path);
foreach (var entry in archive.Entries)
{
if (!entry.IsDirectory && IsRomArchive(entry.Key))
{
- using var src = entry.OpenEntryStream(); // already seekable
- return NSPExtactor.ExtractFromStream(src);
+ var temp = Path.GetTempFileName();
+ entry.WriteToFile(temp);
+ var title = _nspExtractor.ExtractFromFile(temp); // instance call
+ File.Delete(temp);
+ return title;
}
}
return null;
}
- private static NcaMetadataDto? Handle7z(string path)
+ private NcaMetadataWithHash? Handle7z(string path)
{
using var archive = SevenZipArchive.Open(path);
foreach (var entry in archive.Entries)
{
if (!entry.IsDirectory && IsRomArchive(entry.Key))
{
- using var src = entry.OpenEntryStream(); // not seekable
- var seekable = MakeSeekable(src);
- return NSPExtactor.ExtractFromStream(seekable);
+ var temp = Path.GetTempFileName();
+ entry.WriteToFile(temp);
+ var title = _nspExtractor.ExtractFromFile(temp); // instance call
+ File.Delete(temp);
+ return title;
}
}
return null;
}
- private static NcaMetadataDto? HandleRar(string path)
+ private NcaMetadataWithHash? HandleRar(string path)
{
try
{
@@ -80,59 +89,25 @@ public sealed class ArchiveHandler
{
if (!entry.IsDirectory && IsRomArchive(entry.Key))
{
- using var src = entry.OpenEntryStream(); // not seekable
- var seekable = MakeSeekable(src);
- return NSPExtactor.ExtractFromStream(seekable);
+ var temp = Path.GetTempFileName();
+ entry.WriteToFile(temp);
+ var title = _nspExtractor.ExtractFromFile(temp); // instance call
+ File.Delete(temp);
+ return title;
}
}
return null;
}
- catch (SharpCompress.Common.ExtractionException)
+ catch (SharpCompress.Common.ArchiveException)
{
- // ---------- RAR5 fallback (SharpSevenZip) ----------
- // We decompress the entire archive into a MemoryStream
- // and then feed that stream into SevenZipExtractor.
-
- using var inStream = File.OpenRead(path); // source stream
- using var outStream = new MemoryStream(); // destination
-
- // Decompress – progress event can be null
-
- outStream.Position = 0; // rewind for reading
-
-// using var extractor = SharpSevenZipExtractor.OpenStream(outStream);
- using var extractor = new SharpSevenZip.SharpSevenZipExtractor(inStream);
- for (int i = 0; i < extractor.ArchiveFileData.Count; i++)
- {
- var archiveFileInfo =extractor.ArchiveFileData[i];
-
- if (!archiveFileInfo.IsDirectory && extractor.FileName != null && IsRomArchive(extractor.FileName))
- {
- var ms = new MemoryStream(); // extract a single entry
- extractor.ExtractFile(extractor.FileName, ms);
- ms.Position = 0;
- return NSPExtactor.ExtractFromStream(ms);
- }
- }
-
- return null; // nothing found
+ // Fallback to SharpSevenZip (if needed)
+ return null;
}
}
- ///
- /// Turn a non‑seekable stream into a seekable one by buffering it into memory.
- ///
- private static Stream MakeSeekable(Stream nonSeekable)
+ private bool IsRomArchive(string entryName)
{
- if (nonSeekable.CanSeek)
- return nonSeekable;
-
- var ms = new MemoryStream();
- nonSeekable.CopyTo(ms);
- ms.Position = 0;
- return ms;
+ var ext = Path.GetExtension(entryName).ToLowerInvariant();
+ return ext is ".xci" or ".nsp" or ".xcz";
}
-
- private static bool IsRomArchive(string name) =>
- Path.GetExtension(name).ToLowerInvariant() is ".xci" or ".nsp" or ".xcz";
}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Services/IndexBuilderService.cs b/TinfoilVibeServer/Services/IndexBuilderService.cs
index 7f2ddf2..d73cc2c 100644
--- a/TinfoilVibeServer/Services/IndexBuilderService.cs
+++ b/TinfoilVibeServer/Services/IndexBuilderService.cs
@@ -2,77 +2,118 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
+using System.Security.Cryptography;
+using System.Text.Json;
+using LibHac.Ncm;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using TinfoilVibeServer.Models;
namespace TinfoilVibeServer.Services;
-///
-/// Builds the from the current snapshot
-/// and reads the “Directories” & “SuccessMessage” values from
-/// configuration.
-///
-public sealed class IndexBuilderService
+// File: Services/IndexBuilderService.cs
+// *** NEW ***
+public sealed class IndexBuilderService: IHostedService
{
- private readonly SnapshotService _snapshotService;
+ private const string CacheFileName = "indexcache.json";
+
+ private readonly ISnapshotService _snapshotService;
private readonly TitleDatabaseService _titleDb;
private readonly IConfiguration _configuration;
private readonly ILogger _logger;
+ private readonly string _cachePath;
- public IndexBuilderService(SnapshotService snapshotService,
- TitleDatabaseService titleDb,
- IConfiguration configuration,
- ILogger logger)
+ public IndexBuilderService(
+ ISnapshotService snapshotService,
+ TitleDatabaseService titleDb,
+ IConfiguration configuration,
+ ILogger logger)
{
- _snapshotService = snapshotService;
- _titleDb = titleDb;
- _configuration = configuration;
- _logger = logger;
+ _snapshotService = snapshotService;
+ _titleDb = titleDb;
+ _configuration = configuration;
+ _logger = logger;
+ _cachePath = Path.Combine(AppContext.BaseDirectory, CacheFileName);
}
- ///
- /// Build the IndexDto that is sent to the client.
- ///
public IndexDto Build()
{
+ // 1️⃣ Load cache if it exists
+ var cached = LoadCache();
var snapshot = _snapshotService.GetSnapshot();
+ // 2️⃣ Re‑build only if the snapshot hash changed
+ string snapshotHash = ComputeSnapshotHash(snapshot);
+ if (cached != null && cached.SnapshotHash == snapshotHash)
+ {
+ _logger.LogInformation("Index cache is up‑to‑date – re‑using it.");
+ return cached.Index;
+ }
+
+ // 3️⃣ Build new index from snapshot entries
var files = snapshot
- .Where(e => e.Title != null) // only NSP/XCI files
+ .Where(e => e.Title != null)
.Select(e =>
{
- var titleId = e.Title.TitleId; // 16‑digit hex
+ var titleId = e.Title.TitleId;
+ var name = _titleDb.TryGetTitle(titleId, out var t)
+ ? t.Name
+ : "Unknown";
- // 1️⃣ Get the human readable name from the title DB.
- var name = _titleDb.TryGetTitle(titleId, out var titleInfo)
- ? titleInfo.Name
- : "Unknown";
+ var vProcessed = e.Title.Version * 0x10000;
+ var patchOrApp = e.Title.ContentMetaType == ContentMetaType.Application ? "Base" : "Update";
+ var url = $"{name}[{titleId}][{vProcessed:X}][{patchOrApp}].nsp";
- // 2️⃣ Parse the version string (e.g. “1.02” → 102)
- int versionNumber = e.Title.Version;
-
- // 3️⃣ vProcessed = versionNumber * 0x10000
- var vProcessed = versionNumber * 0x10000;
-
- // 4️⃣ patchOrApplication
- var patchOrApp = e.Title.IsApplication ? "Base" : "Update";
-
- // 5️⃣ Build the URL string
- var url = $"[{name}][{titleId}][{vProcessed:X}][{patchOrApp}].nsp";
-
- return new FileDto(
- Url: url,
- Size: e.Size);
+ return new FileDto(url, e.Size);
})
.ToList();
- // Directories & success message come straight from the config.
var directories = _configuration.GetSection("Directories")
.Get() ?? Array.Empty();
var success = _configuration["SuccessMessage"] ?? string.Empty;
- return new IndexDto(files, directories.ToList(), success);
+ var index = new IndexDto(files, directories.ToList(), success);
+
+ // 4️⃣ Persist cache
+ PersistCache(snapshotHash, index);
+
+ return index;
}
+
+ private IndexCache? LoadCache()
+ {
+ if (!File.Exists(_cachePath)) return null;
+ var json = File.ReadAllText(_cachePath);
+ return JsonSerializer.Deserialize(json);
+ }
+
+ private void PersistCache(string snapshotHash, IndexDto index)
+ {
+ var cache = new IndexCache(snapshotHash, index);
+ File.WriteAllText(_cachePath, JsonSerializer.Serialize(cache, new JsonSerializerOptions{WriteIndented=true}));
+ }
+
+ 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();
+ }
+
+ // DTO for the cache file
+ private sealed record IndexCache(string SnapshotHash, IndexDto Index);
+
+ #region IHostedService
+
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ Build();
+ return Task.CompletedTask;
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken)
+ => Task.CompletedTask; // nothing special to do on shutdown
+
+ #endregion
}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Services/NSPExtractor.cs b/TinfoilVibeServer/Services/NSPExtractor.cs
index 1fe21f1..17de19a 100644
--- a/TinfoilVibeServer/Services/NSPExtractor.cs
+++ b/TinfoilVibeServer/Services/NSPExtractor.cs
@@ -1,107 +1,192 @@
-using System;
+// File: Services/NSPExtactor.cs
+// *** UPDATED ***
+using System;
using System.IO;
using System.Linq;
+using System.Collections.Generic;
+using System.Security.Cryptography;
+using LibHac.Common;
+using LibHac.Fs;
+using LibHac.Fs.Fsa;
+using LibHac.FsSystem;
+using LibHac.Tools.FsSystem;
+using LibHac.Tools.FsSystem.NcaUtils;
+using LibHac.Common.Keys;
+using LibHac.Ncm;
+using LibHac.Tools.Ncm;
using TinfoilVibeServer.Models;
-using LibHac.Fs; // OpenMode, StreamStorage, FileStorage
-using LibHac.Fs.Fsa; // IFile
-using LibHac.FsSystem; // PartitionFileSystem
-using LibHac.Tools.FsSystem; // SearchOptions
-using LibHac.Tools.FsSystem.NcaUtils; // Nca, NcaContentType
-using LibHac.Common; // UniqueRef
-
-namespace TinfoilVibeServer.Services;
-
-///
-/// Extracts only the three fields you asked for from a full NSP/XCI container.
-///
-public sealed class NSPExtactor
+namespace TinfoilVibeServer.Services
{
///
- /// Convenience overload – read the NSP/XCI from disk.
+ /// Extracts the TitleId, version, type *and* the SHA‑256 of the first NCA stream.
///
- public static NcaMetadataDto? ExtractFromFile(string filePath)
+ public sealed class NSPExtractor
{
- using var stream = File.OpenRead(filePath);
- return ExtractFromStream(stream);
- }
+ private readonly KeySet _keySet;
- ///
- /// Core implementation – works on any seekable stream that contains a
- /// full NSP/XCI container.
- ///
- public static NcaMetadataDto? ExtractFromStream(Stream stream)
- {
- if (!IsPFSFileSystem(stream))
- return null;
-
- stream.Seek(0, SeekOrigin.Begin);
-
- // Open the whole NSP container as a StreamStorage (LibHac.Fs).
- using var storage = new StreamStorage(stream, false);
-
- // Build a PartitionFileSystem that can walk the PFS layout.
- var partition = new PartitionFileSystem();
- partition.Initialize(storage).ThrowIfFailure();
-
- // Enumerate all *.nca entries (recursively).
- var ncaEntries = partition
- .EnumerateEntries("*.nca", SearchOptions.RecurseSubdirectories)
- .Where(e => e.Type == DirectoryEntryType.File) // <-- use the enum comparison
- .ToList();
-
- foreach (var dirEntry in ncaEntries)
+ public NSPExtractor(KeySet keySet)
{
- // Open the NCA file as an IFile (LibHac.Fs.Fsa).
- using var fileRef = new UniqueRef();
- var openResult = partition.OpenFile(ref fileRef.Ref,
- dirEntry.FullPath.ToU8Span(), OpenMode.Read);
-
- if (openResult.IsFailure())
- continue;
-
- // Convert the IFile to an IStorage (FileStorage – LibHac.Fs).
- using var ncaFile = fileRef.Release(); // IFile
- using var ncaFileStorage = new FileStorage(ncaFile);
-
- // Feed the storage into the Nca constructor.
- var nca = new Nca(KeySetHolder.KeySet, ncaFileStorage);
-
- // Only the meta NCA contains the title metadata.
- if (nca.Header.ContentType != NcaContentType.Meta)
- continue;
-
- string titleId = nca.Header.TitleId.ToString("X16");
- int version = nca.Header.Version;
- bool isPatch = nca.IsPatch;
- bool isApp = nca.IsProgram && !isPatch;
-
- return new NcaMetadataDto(titleId, version, isApp, isPatch);
+ _keySet = keySet;
}
- // No meta NCA found.
- return null;
- }
-
- ///
- /// Check that the stream looks like a PFS0 file system.
- ///
- private static bool IsPFSFileSystem(Stream stream)
- {
- try
+ ///
+ /// Public convenience wrapper that opens the file on disk.
+ ///
+ public NcaMetadataWithHash? ExtractFromFile(string filePath)
{
- if (!stream.CanSeek) return false;
+ using var stream = File.OpenRead(filePath);
+ return ExtractFromStream(stream);
+ }
+
+ ///
+ /// Core implementation – works on any seekable stream that contains a full NSP/XCI container.
+ ///
+ public NcaMetadataWithHash? ExtractFromStream(Stream stream)
+ {
+ if (!IsPfs0FileSystem(stream))
+ return null;
+
stream.Seek(0, SeekOrigin.Begin);
- var storage = new StreamStorage(stream, false);
+ using var storage = new StreamStorage(stream, false);
+ var partition = new PartitionFileSystem();
+ partition.Initialize(storage).ThrowIfFailure();
+
+ // Find the first *.nca that contains the meta header
+ var ncaEntries = partition
+ .EnumerateEntries("*.nca", SearchOptions.RecurseSubdirectories)
+ .Where(e => e.Type == DirectoryEntryType.File)
+ .ToList();
+
+ foreach (var dirEntry in ncaEntries)
+ {
+ using var fileRef = new UniqueRef();
+ partition.OpenFile(ref fileRef.Ref, dirEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
+ using var ncaFile = fileRef.Release();
+ using var ncaFileStorage = new FileStorage(ncaFile);
+
+ var nca = new Nca(_keySet, ncaFileStorage);
+ if (nca.Header.ContentType != NcaContentType.Meta)
+ continue; // only the meta NCA contains title metadata
+
+ string titleId = nca.Header.TitleId.ToString("X16");
+ int version = nca.Header.Version;
+ bool isPatch = nca.IsPatch;
+ bool isApp = nca.IsProgram && !isPatch;
+
+ // Hash the *first* NCA stream – the stream we just opened
+ using var ncaStream = ncaFile.AsStream();
+ using var sha256 = SHA256.Create();
+ var hash = sha256.ComputeHash(ncaStream);
+
+ var contentMetaType = GetMetaDataType(nca);
+ if (contentMetaType != null)
+ return new NcaMetadataWithHash(titleId, version, contentMetaType.Value, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant());
+ }
+
+ return null; // no meta NCA found
+ }
+ 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;
+ }
+ ///
+ /// Quick sanity check that the stream looks like a PFS0 file system.
+ ///
+ private bool IsPfs0FileSystem(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 string ExtractHashFromStream(Stream nspStream)
+ {
+ if (!IsPfs0FileSystem(nspStream))
+ return null;
+
+ nspStream.Seek(0, SeekOrigin.Begin);
+
+ using var storage = new StreamStorage(nspStream, true);
var partition = new PartitionFileSystem();
partition.Initialize(storage).ThrowIfFailure();
- return true;
+ // Find the first *.nca that contains the meta header
+ var ncaEntries = partition
+ .EnumerateEntries("*.nca", SearchOptions.RecurseSubdirectories)
+ .Where(e => e.Type == DirectoryEntryType.File)
+ .ToList();
+
+ foreach (var dirEntry in ncaEntries)
+ {
+ using var fileRef = new UniqueRef();
+ partition.OpenFile(ref fileRef.Ref, dirEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
+ using var ncaFile = fileRef.Release();
+ using var ncaFileStorage = new FileStorage(ncaFile);
+
+ var nca = new Nca(_keySet, ncaFileStorage);
+ if (nca.Header.ContentType != NcaContentType.Meta)
+ continue; // only the meta NCA contains title metadata
+
+ string titleId = nca.Header.TitleId.ToString("X16");
+ int version = nca.Header.Version;
+ bool isPatch = nca.IsPatch;
+ bool isApp = nca.IsProgram && !isPatch;
+
+ // Hash the *first* NCA stream – the stream we just opened
+ using var ncaStream = ncaFile.AsStream();
+ using var sha256 = SHA256.Create();
+ var hash = sha256.ComputeHash(ncaStream);
+ return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
+ }
+ return string.Empty;
}
- catch
+ }
+
+ ///
+ /// DTO returned by the extractor – contains all data the snapshot needs.
+ ///
+ public sealed class NcaMetadataWithHash
+ {
+ public string TitleId { get; }
+ public int Version { get; }
+
+ public ContentMetaType ContentMetaType { get; set; }
+ public string Hash { get; }
+
+ public NcaMetadataWithHash(string titleId, int version, ContentMetaType contentMetaType, string hash)
{
- return false;
+ TitleId = titleId;
+ Version = version;
+ ContentMetaType = contentMetaType;
+ Hash = hash;
}
}
}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Services/ROMArchiveReader.cs b/TinfoilVibeServer/Services/ROMArchiveReader.cs
new file mode 100644
index 0000000..9b1a716
--- /dev/null
+++ b/TinfoilVibeServer/Services/ROMArchiveReader.cs
@@ -0,0 +1,112 @@
+// File: Services/RomArchiveReader.cs
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using SharpCompress.Archives;
+using SharpCompress.Common;
+
+namespace TinfoilVibeServer.Services
+{
+ ///
+ /// Reads a ROM archive (zip / 7z / rar) from a stream.
+ ///
+ public sealed class RomArchiveReader : IDisposable
+ {
+ private readonly ZipArchive? _zipArchive;
+ private readonly IArchive? _sharpArchive;
+ private readonly Stream? _archiveStream; // the stream actually handed to SharpCompress
+
+ public RomArchiveReader(string path) : this(File.OpenRead(path), path) { }
+ ///
+ /// Opens an archive from a stream.
+ /// The *fileName* parameter is only used to decide which archive format
+ /// to open; it can be null if the caller already knows the format.
+ ///
+ public RomArchiveReader(Stream stream, string? fileName = null)
+ {
+ if (stream == null) throw new ArgumentNullException(nameof(stream));
+
+ var ext = fileName?.ToLowerInvariant() ?? string.Empty;
+
+ switch (ext)
+ {
+ case ".zip":
+ // System.IO.Compression can use the stream directly
+ _zipArchive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false);
+ break;
+
+ case ".7z":
+ case ".rar":
+ // SharpCompress requires a seekable stream; copy if necessary
+ if (!stream.CanSeek)
+ {
+ var ms = new MemoryStream();
+ stream.CopyTo(ms);
+ ms.Position = 0;
+ _archiveStream = ms;
+ stream.Dispose(); // original non‑seekable stream no longer needed
+ }
+ else
+ {
+ _archiveStream = stream;
+ }
+ _sharpArchive = ArchiveFactory.Open(_archiveStream);
+ break;
+
+ default:
+ throw new NotSupportedException($"Archive type '{ext}' is not supported.");
+ }
+ }
+
+ ///
+ /// Enumerates every file entry inside the archive.
+ ///
+ public IEnumerable GetEntries()
+ {
+ if (_zipArchive != null)
+ {
+ foreach (var entry in _zipArchive.Entries)
+ {
+ if (entry.FullName.EndsWith("/", StringComparison.Ordinal))
+ continue; // skip directories
+
+ // ZipArchiveEntry.Open returns a seekable stream that must be disposed by the caller
+ yield return new RomArchiveEntry(entry.FullName, entry.Open());
+ }
+ }
+ else if (_sharpArchive != null)
+ {
+ foreach (var entry in _sharpArchive.Entries)
+ {
+ if (entry.IsDirectory)
+ continue;
+
+ // SharpCompress gives us a stream that must be disposed by the caller
+ yield return new RomArchiveEntry(entry.Key, entry.OpenEntryStream());
+ }
+ }
+ }
+
+ ///
+ /// Back‑compat wrapper used by SnapshotService.
+ ///
+ public IEnumerable GetContentInfos() => GetEntries();
+
+ ///
+ /// Disposes the underlying archive objects and the stream(s).
+ ///
+ public void Dispose()
+ {
+ _zipArchive?.Dispose();
+ _sharpArchive?.Dispose();
+ _archiveStream?.Dispose();
+ }
+
+ ///
+ /// Lightweight container that holds the entry name and the opened stream.
+ /// The caller must dispose Stream after it is done.
+ ///
+ public sealed record RomArchiveEntry(string Name, Stream Stream);
+ }
+}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Services/SnapshotService.cs b/TinfoilVibeServer/Services/SnapshotService.cs
index 6f2879e..6ae6a1c 100644
--- a/TinfoilVibeServer/Services/SnapshotService.cs
+++ b/TinfoilVibeServer/Services/SnapshotService.cs
@@ -4,51 +4,91 @@ using System.Text.Json;
using TinfoilVibeServer.Models;
namespace TinfoilVibeServer.Services;
+public interface ISnapshotService
+{
+ event EventHandler SnapshotRebuilt; // raised after a rebuild
+ void RebuildSnapshot();
+ IReadOnlyList GetSnapshot();
+}
///
/// Keeps an in‑memory snapshot, watches the filesystem for changes, and
/// only re‑processes a file if its hash changed.
///
-public sealed class SnapshotService : IDisposable
+public sealed class SnapshotService : IDisposable, ISnapshotService
{
private readonly ConfigManager _config;
+ private readonly NSPExtractor _nspExtractor;
+ private readonly ArchiveHandler _archiveHandler;
private readonly string _jsonPath;
private readonly string _snapshotPath;
- private readonly FileSystemWatcher _watcher;
+ private readonly List _watchers = new();
private readonly ConcurrentDictionary _cache = new();
private string? _currentSnapshotHash;
-
- public SnapshotService(ConfigManager config)
+ public event EventHandler? SnapshotRebuilt;
+
+ public SnapshotService(ConfigManager config, NSPExtractor nspExtractor, ArchiveHandler archiveHandler)
{
_config = config;
- _jsonPath = Path.Combine(AppContext.BaseDirectory, _config.Settings.SnapshotFile);
+ _nspExtractor = nspExtractor;
+ _archiveHandler = archiveHandler;
+ _jsonPath = Path.Combine(AppContext.BaseDirectory, _config.Settings.SnapshotFile);
_snapshotPath = Path.Combine(AppContext.BaseDirectory, _config.Settings.SnapshotBackupFile);
- BuildSnapshot(); // initial scan
+ BuildSnapshot(); // initial scan
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(GetSnapshot()));
- _watcher = new FileSystemWatcher
+ foreach (var path in _config.Settings.RootDirectories)
{
- Path = string.Join(Path.PathSeparator, _config.Settings.RootDirectories),
- IncludeSubdirectories = true,
- NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName |
- NotifyFilters.Size | NotifyFilters.LastWrite
- };
- _watcher.Created += OnChanged;
- _watcher.Changed += OnChanged;
- _watcher.Deleted += OnChanged;
- _watcher.Renamed += OnRenamed;
- _watcher.EnableRaisingEvents = true;
-
+ InitializeFileSystemWatcher(path);
+ }
+
+
_config.OnChange += cfg =>
{
- _watcher.Path = string.Join(Path.PathSeparator, cfg.RootDirectories);
- _watcher.EnableRaisingEvents = true;
- BuildSnapshot(); // rebuild everything
+ var fileSystemWatchers = _watchers.Where(watcher => !cfg.RootDirectories.Contains(watcher.Path));
+ foreach (var watcher in fileSystemWatchers)
+ {
+ watcher.EnableRaisingEvents = false;
+ watcher.Dispose();
+ _watchers.Remove(watcher);
+ }
+
+ var newWatchedDirectories = cfg.RootDirectories.Where(newWatchedDirectory =>
+ !_watchers.Any(watcher =>
+ string.Equals(watcher.Path, newWatchedDirectory, StringComparison.OrdinalIgnoreCase)));
+
+ foreach (var newWatchedDirectory in newWatchedDirectories)
+ {
+ InitializeFileSystemWatcher(newWatchedDirectory);
+
+ }
+ BuildSnapshot(); // rebuild everything
PersistSnapshot();
};
}
+ private void InitializeFileSystemWatcher(string path)
+ {
+ if (Directory.Exists(path))
+ {
+ var _watcher = new FileSystemWatcher
+ {
+ Path = path,
+ IncludeSubdirectories = true,
+ NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName |
+ NotifyFilters.Size | NotifyFilters.LastWrite
+ };
+ _watcher.Created += OnChanged;
+ _watcher.Changed += OnChanged;
+ _watcher.Deleted += OnChanged;
+ _watcher.Renamed += OnRenamed;
+ _watcher.EnableRaisingEvents = true;
+
+ _watchers.Add(_watcher);
+ }
+ }
+
#region FileSystemWatcher
private void OnChanged(object? _, FileSystemEventArgs e) => ThrottleSnapshotUpdate();
@@ -71,9 +111,12 @@ public sealed class SnapshotService : IDisposable
{
var cfg = _config.Settings;
var entries = new List();
+ var index = LoadSnapshotIndex(); // <‑ new
+ var snapshotChanged = false;
foreach (var dir in cfg.RootDirectories)
{
+ if (!Directory.Exists(dir)) continue;
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
{
var ext = Path.GetExtension(file).ToLowerInvariant();
@@ -81,30 +124,52 @@ public sealed class SnapshotService : IDisposable
if (!(cfg.WhitelistExtensions.Contains(ext) || cfg.RomExtensions.Contains(ext)))
continue;
- var hash = ComputeHash(file);
-
- // Cache hit?
- if (_cache.TryGetValue(file, out var cached) && cached.Hash == hash)
+ if (index.ContainsKey(file)) continue;
+
+ // 3) extract title if applicable
+ string hash;
+ NcaMetadataWithHash? title = null;
+ if (cfg.RomExtensions.Contains(ext))
{
- entries.Add(new FileEntry(file, new FileInfo(file).Length, hash, cached.Title));
- continue;
+ using var nspStream = File.OpenRead(file);
+ hash = ComputeFirstStreamHash(nspStream);
+
+ // 2) use cached title if unchanged
+ if (index.TryGetValue(file, out var cached) && cached.Hash == hash)
+ {
+ entries.Add(cached);
+ continue;
+ }
+
+ title = _nspExtractor.ExtractFromStream(nspStream);
+ }
+ else
+ {
+ hash = ComputeFirstStreamHash(file);
+ title = _archiveHandler.TryExtractTitleInfo(file);
}
- // Extract title if possible
- NcaMetadataDto? title = null;
- if (cfg.RomExtensions.Contains(ext))
- title = NSPExtactor.ExtractFromFile(file);
- else
- title = ArchiveHandler.TryExtractTitleInfo(file);
-
+ // 4) update cache
_cache[file] = new CachedFile(file, hash, title);
+ // 5) add to snapshot
entries.Add(new FileEntry(file, new FileInfo(file).Length, hash, title));
+ snapshotChanged = true;
}
}
+ // Replace the entire snapshot
_currentSnapshotHash = ComputeSnapshotHash(entries);
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries));
+ if (snapshotChanged)
+ {
+ SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ private string ComputeFirstStreamHash(Stream nspStream)
+ {
+ return _nspExtractor.ExtractHashFromStream(nspStream);
}
private void UpdateSnapshot() => BuildSnapshot();
@@ -136,22 +201,80 @@ public sealed class SnapshotService : IDisposable
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json));
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
+ private Dictionary LoadSnapshotIndex()
+ {
+ if (!File.Exists(_jsonPath)) return new();
+ var json = File.ReadAllText(_jsonPath);
+ var entries = JsonSerializer.Deserialize>(json, new JsonSerializerOptions(){IncludeFields = true})!;
+ return entries.ToDictionary(e => e.Path, e => e);
+ }
#endregion
public IReadOnlyList GetSnapshot()
{
var json = File.ReadAllText(_jsonPath);
- return JsonSerializer.Deserialize>(json)!;
+ return JsonSerializer.Deserialize>(json, new JsonSerializerOptions(){IncludeFields = true})!;
}
+
public void RebuildSnapshot()
{
// Build a fresh snapshot and persist it.
- BuildSnapshot(); // private method inside the same class
- PersistSnapshot(); // private method inside the same class
+ BuildSnapshot(); // private method inside the same class
+ PersistSnapshot(); // private method inside the same class
+ SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
}
-
- public void Dispose() => _watcher.Dispose();
- private sealed record CachedFile(string Path, string Hash, NcaMetadataDto? Title);
+ public void Dispose()
+ {
+ foreach (var watcher in _watchers)
+ {
+ watcher.Dispose();
+ }
+ }
+
+ private sealed record CachedFile(string Path, string Hash, NcaMetadataWithHash? NcaMetadataWithHash);
+
+ // File: TinfoilVibeServer/Services/SnapshotService.cs (inside SnapshotService class)
+
+ private string ComputeFirstStreamHash(string filePath)
+ {
+ // Only treat NSP/XCI/XCZ as “first‑stream” files
+ var ext = Path.GetExtension(filePath).ToLowerInvariant();
+ if (ext is not ".nsp" and not ".xci" and not ".xcz")
+ {
+
+ // Open the NSP/XCI with LibHac and read the first stream.
+ // The first stream is the first entry returned by GetContentInfos().
+ try
+ {
+ using var reader = new RomArchiveReader(filePath);
+
+ var first = reader.GetEntries().FirstOrDefault();
+ if (first == null) return ComputeFullHash(filePath);
+
+ return _nspExtractor.ExtractHashFromStream(first.Stream);
+ }
+ catch
+ {
+ // On error, fall back to the full file hash
+ return ComputeFullHash(filePath);
+ }
+ }
+ else
+ {
+ using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
+ var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs);
+ return ncaMetadataWithHash?.Hash ?? string.Empty;
+ }
+
+ }
+
+ private static string ComputeFullHash(string filePath)
+ {
+ using var sha256 = SHA256.Create();
+ using var stream = File.OpenRead(filePath);
+ var hash = sha256.ComputeHash(stream);
+ return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
+ }
}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Services/TitleDatabaseService.cs b/TinfoilVibeServer/Services/TitleDatabaseService.cs
index b506120..0f4096b 100644
--- a/TinfoilVibeServer/Services/TitleDatabaseService.cs
+++ b/TinfoilVibeServer/Services/TitleDatabaseService.cs
@@ -1,15 +1,7 @@
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.IO;
-using System.Net.Http;
-using System.Text.RegularExpressions;
+using System.Text.RegularExpressions;
using System.Text.Json;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
using TinfoilVibeServer.Models;
namespace TinfoilVibeServer.Services;
@@ -24,28 +16,29 @@ namespace TinfoilVibeServer.Services;
public sealed class TitleDatabaseService : IHostedService
{
#region Configuration keys
-
- // These come from appsettings.json (see 7. ConfigManager.cs).
- private readonly string _countryCode; // e.g. “US”
- private readonly string _language; // e.g. “en”
-
+ private const string CacheKey = "TitleDb";
#endregion
#region Fields
+ private readonly IOptionsMonitor _options;
private readonly ILogger _logger;
private readonly IHttpClientFactory _httpFactory;
+ private readonly NSPExtractor _nspExtractor;
private readonly string _cacheFolder; // Where the JSON is cached.
- private readonly string _baseCacheFolder; // Directory that contains all NSP files to index
private readonly List _rootDirectories; // directories that contain game files
+ private readonly IMemoryCache _cache;
+ private readonly ISnapshotService _snapshotService;
// 1️⃣ Cache for the JSON data (key = TitleId)
+ /*
private readonly ConcurrentDictionary _titleData
= new();
// 2️⃣ Reverse lookup: TitleId → real file‑system path
private readonly ConcurrentDictionary _titleIdToPath
= new();
+ */
// Regex to find a 16‑digit hex TitleId in a filename
private static readonly Regex _titleIdRegex = new(
@@ -63,43 +56,45 @@ public sealed class TitleDatabaseService : IHostedService
///
public TitleDatabaseService(
IConfiguration configuration,
+ IOptionsMonitor options,
ILogger logger,
- IHttpClientFactory httpFactory)
+ ISnapshotService snapshotService,
+ IHttpClientFactory httpFactory,
+ NSPExtractor nspExtractor,
+ IMemoryCache cache)
{
- // The following values must be present in appsettings.json.
- _countryCode = configuration["TitleDb:CountryCode"]?.ToUpperInvariant()
- ?? throw new ArgumentException("TitleDb:CountryCode not configured");
- _language = configuration["TitleDb:Language"]?.ToLowerInvariant()
- ?? throw new ArgumentException("TitleDb:Language not configured");
-
+ _options = options;
_logger = logger;
+ _snapshotService = snapshotService;
_httpFactory = httpFactory;
+ _nspExtractor = nspExtractor;
+ _cache = cache;
_cacheFolder = Path.Combine(AppContext.BaseDirectory, "titledb-cache");
- _baseCacheFolder = Path.Combine(AppContext.BaseDirectory, "titledb-data");
_rootDirectories = new List
{
// You can extend this list – it is the set of directories that
// are scanned when the service starts up.
Path.Combine(AppContext.BaseDirectory, "Games")
};
+ // Reload cache immediately when a snapshot rebuild occurs
+ _snapshotService.SnapshotRebuilt += (_, _) => ReloadCacheAsync();
}
#endregion
#region IHostedService
- public Task StartAsync(CancellationToken cancellationToken)
+ public async Task StartAsync(CancellationToken cancellationToken)
{
// 1️⃣ Load the JSON (download if not cached).
- LoadAndCacheTitleDb(cancellationToken).GetAwaiter().GetResult();
+ await ReloadCacheAsync();
// 2️⃣ Scan the file‑system and build the title‑id → path map.
BuildFilesystemIndex();
_logger.LogInformation("Title database ready – {Count} entries loaded.",
- _titleData.Count);
- return Task.CompletedTask;
+ GetAllAsync().Result.Count);
}
public Task StopAsync(CancellationToken cancellationToken)
@@ -107,29 +102,98 @@ public sealed class TitleDatabaseService : IHostedService
#endregion
+ /* ---------------------------------------------------------------- */
+ /* 1️⃣ Cache loading / reloading – sliding expiration
+ /* ---------------------------------------------------------------- */
+ private async Task ReloadCacheAsync()
+ {
+ var ttlSec = _options.CurrentValue.TtlSeconds;
+ var entryOptions = new MemoryCacheEntryOptions()
+ .SetSlidingExpiration(TimeSpan.FromSeconds(ttlSec)).RegisterPostEvictionCallback((key, value, reason,
+ state) =>
+ {
+ _logger.LogInformation("Cache eviction: {Key} ({Reason})", key, reason);
+ }); // <‑‑ sliding!
+
+ var dict = await LoadFromDiskAsync() ?? new Dictionary();
+
+ // Set the new entry – the sliding expiration will now
+ // automatically move 30 s (or whatever you configured) forward
+ // every time the entry is accessed via Get/Set.
+ var titleInfoDtos = _cache.Set(CacheKey, dict, entryOptions);
+ _logger.LogInformation("Title DB reloaded – {Count} items cached (TTL={TTL}s).",
+ dict.Count, ttlSec);
+ }
+
+ private async Task?> LoadFromDiskAsync()
+ {
+ var cacheFile = Path.Combine(_cacheFolder, $"{_options.CurrentValue.CountryCode}.{_options.CurrentValue.LanguageCode}.json");
+ if (!File.Exists(cacheFile))
+ {
+ await LoadAndCacheTitleDb(CancellationToken.None);
+ }
+
+ return await ReadTitleDbAsync(cacheFile, CancellationToken.None);
+ }
+
+ /* ---------------------------------------------------------------- */
+ /* 2️⃣ Public API – every call slides the cache
+ /* ---------------------------------------------------------------- */
+ public async Task> GetAllAsync()
+ {
+ if (!_cache.TryGetValue(CacheKey, out Dictionary? dict))
+ {
+ await ReloadCacheAsync(); // cache miss → load from disk
+ _cache.TryGetValue(CacheKey, out dict);
+ }
+ return dict!; // Get() has already slid the entry
+ }
+
+ public async Task GetAsync(string titleId)
+ {
+ var all = await GetAllAsync(); // slides the entry
+ all.TryGetValue(titleId, out var dto);
+ return dto;
+ }
+
+ /*public async Task AddOrUpdateAsync(string titleId, TitleInfoDto dto)
+ {
+ var all = await GetAllAsync(); // slides the entry
+ all[id] = dto;
+ await PersistAsync(all); // writes to disk & triggers rebuild
+ }
+
+ public async Task RemoveAsync(string titleId)
+ {
+ var all = await GetAllAsync(); // slides the entry
+ if (all.Remove(id))
+ await PersistAsync(all); // writes to disk & triggers rebuild
+ }*/
+ /* ---------------------------------------------------------------- */
+ /* 3️⃣ Persist to disk & notify snapshot service
+ /* ---------------------------------------------------------------- */
+ private async Task PersistAsync(Dictionary dict)
+ {
+ // Trigger a rebuild so SnapshotService (and any other listeners)
+ // can pick up the new snapshot.
+ //_snapshotService.RebuildSnapshot();
+ }
+
+ /* ---------------------------------------------------------------- */
+ /* 4️⃣ Dispose
+ /* ---------------------------------------------------------------- */
+ public void Dispose() => _snapshotService.SnapshotRebuilt -= (_, _) => ReloadCacheAsync();
+
#region Public API
///
/// Return the TitleInfoDto for a known TitleId.
///
public bool TryGetTitle(string titleId, out TitleInfoDto? title)
- => _titleData.TryGetValue(titleId, out title);
-
- ///
- /// If a file was indexed, return its full path. If the file was
- /// indexed by extracting the TitleId from its contents, this will still
- /// work.
- ///
- public bool TryGetFilePath(string titleId, out string? path)
- => _titleIdToPath.TryGetValue(titleId, out path);
-
- ///
- /// Convenience helper – look‑up the file path for a TitleId and return
- /// it as a string. Returns null if the TitleId is unknown.
- ///
- public string? GetFilePathByTitleId(string titleId)
- => _titleIdToPath.TryGetValue(titleId, out var p) ? p : null;
-
+ {
+ title = GetAsync(titleId).GetAwaiter().GetResult();
+ return title != null;
+ }
#endregion
#region Private helpers
@@ -141,11 +205,11 @@ public sealed class TitleDatabaseService : IHostedService
private async Task LoadAndCacheTitleDb(CancellationToken ct)
{
// Build the raw URL
- var rawUrl = $"https://raw.githubusercontent.com/blawar/titledb/refs/heads/master/{_countryCode}.{_language}.json";
+ var rawUrl = $"https://raw.githubusercontent.com/blawar/titledb/refs/heads/master/{_options.CurrentValue.CountryCode}.{_options.CurrentValue.LanguageCode}.json";
// Ensure the cache directory exists.
Directory.CreateDirectory(_cacheFolder);
- var cacheFile = Path.Combine(_cacheFolder, $"{_countryCode}.{_language}.json");
+ var cacheFile = Path.Combine(_cacheFolder, $"{_options.CurrentValue.CountryCode}.{_options.CurrentValue.LanguageCode}.json");
// If the file exists & is recent – no download needed.
if (File.Exists(cacheFile))
@@ -155,7 +219,7 @@ public sealed class TitleDatabaseService : IHostedService
if (fi.LastWriteTimeUtc > DateTime.UtcNow.AddHours(-24))
{
_logger.LogInformation("Using cached title database {File}", cacheFile);
- await ReadTitleDbAsync(cacheFile, ct);
+ await LoadAndCacheTitleDb(ct);
return;
}
}
@@ -174,33 +238,38 @@ public sealed class TitleDatabaseService : IHostedService
///
/// Read the JSON file and populate _titleData.
///
- private async Task ReadTitleDbAsync(string filePath, CancellationToken ct)
+ private async Task> ReadTitleDbAsync(string filePath, CancellationToken ct)
{
var json = await File.ReadAllTextAsync(filePath, ct);
- // The JSON structure used by blawar is:
- // { "entries": [ { "titleId":"0004000000000000", "name":"Mario", … }, … ] }
- using var doc = JsonDocument.Parse(json);
- var root = doc.RootElement;
- if (!root.TryGetProperty("entries", out var entries))
- {
- _logger.LogWarning("Title database file {File} has no \"entries\" property", filePath);
- return;
- }
+ var titleInfoDtos = JsonSerializer.Deserialize>(
+ json,
+ new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ }) ?? new Dictionary(StringComparer.OrdinalIgnoreCase);
- foreach (var entry in entries.EnumerateArray())
+ _logger.LogInformation("Loaded {Count} titles from the database.", titleInfoDtos.Count);
+
+ var titleData = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (var i =0; i< titleInfoDtos.Values.Count; i++)
{
- var dto = new TitleInfoDto(
- TitleId: entry.GetProperty("titleId").GetString() ?? "",
- Name: entry.GetProperty("name").GetString() ?? "",
- Id: entry.GetProperty("id").GetString() ?? "",
- ReleaseDate: entry.GetProperty("releaseDate").GetDateTime(),
- NSUID: entry.GetProperty("nsuid").GetString() ?? "",
- Version: entry.GetProperty("version").GetString() ?? ""
- );
- if (!string.IsNullOrWhiteSpace(dto.TitleId))
- _titleData[dto.TitleId] = dto;
+ var entry = titleInfoDtos.Values.ElementAt(i);
+ var key = titleInfoDtos.Keys.ElementAt(i);
+ if (!string.IsNullOrWhiteSpace(key))
+ {
+ if (entry.Id != null)
+ {
+ if (entry.Id.Length == 16)
+ {
+ titleData[entry.Id] = entry;
+ continue;
+ }
+ }
+ }
}
+ return titleData;
}
///
@@ -220,7 +289,7 @@ public sealed class TitleDatabaseService : IHostedService
{
// 1️⃣ Does the file name already contain a TitleId?
var match = _titleIdRegex.Match(Path.GetFileName(file));
- string titleId;
+ string? titleId;
if (match.Success)
{
titleId = match.Groups[1].Value + match.Groups[2].Value;
@@ -228,7 +297,7 @@ public sealed class TitleDatabaseService : IHostedService
else
{
// 2️⃣ Extract the TitleId from the NSP using the extractor.
- titleId = NSPExtactor.ExtractFromStream(File.OpenRead(file))?.TitleId;
+ titleId = _nspExtractor.ExtractFromStream(File.OpenRead(file))?.TitleId;
if (string.IsNullOrWhiteSpace(titleId))
{
_logger.LogWarning("Could not extract TitleId from {File}", file);
@@ -238,7 +307,7 @@ public sealed class TitleDatabaseService : IHostedService
// Normalise to 16‑digit hex (upper‑case).
titleId = titleId.ToUpperInvariant();
- _titleIdToPath[titleId] = file;
+ //_titleIdToPath[titleId] = file;
}
}
}
diff --git a/TinfoilVibeServer/TinfoilVibeServer.csproj b/TinfoilVibeServer/TinfoilVibeServer.csproj
index d43ac7a..fe4bf34 100644
--- a/TinfoilVibeServer/TinfoilVibeServer.csproj
+++ b/TinfoilVibeServer/TinfoilVibeServer.csproj
@@ -21,12 +21,14 @@
appsettings.json
+
+
@@ -35,4 +37,12 @@
+
+
+
+
+
+
+
+
diff --git a/TinfoilVibeServer/appsettings.json b/TinfoilVibeServer/appsettings.json
index 97ac829..3e7e63a 100644
--- a/TinfoilVibeServer/appsettings.json
+++ b/TinfoilVibeServer/appsettings.json
@@ -19,8 +19,11 @@
"KeySetFile": "prod.keys",
"TitleDb": {
"CountryCode": "AU",
- "Language": "en"
- },
+ "Language": "en",
+ "TtlSeconds" : 30,
+ "SnapshotFile" : "snapshot.json"
+ },
+
"IndexDirectories": [
"https://url1",
"sdmc:/url2",