From 6c276f1de3b6e6118d721773500c9a0b8563525e Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Tue, 4 Nov 2025 07:40:27 +1030 Subject: [PATCH] Working implementation --- TinfoilVibeServer.sln.DotSettings.user | 7 + .../Controllers/IndexController.cs | 43 +-- TinfoilVibeServer/Models/FileEntry.cs | 6 +- TinfoilVibeServer/Models/IdHelper.cs | 30 +++ TinfoilVibeServer/Models/TitleDbOptions.cs | 8 + TinfoilVibeServer/Models/TitleInfoDto.cs | 4 +- TinfoilVibeServer/Program.cs | 24 +- TinfoilVibeServer/Services/ArchiveHandler.cs | 117 ++++---- .../Services/IndexBuilderService.cs | 123 ++++++--- TinfoilVibeServer/Services/NSPExtractor.cs | 253 ++++++++++++------ .../Services/ROMArchiveReader.cs | 112 ++++++++ TinfoilVibeServer/Services/SnapshotService.cs | 203 +++++++++++--- .../Services/TitleDatabaseService.cs | 215 ++++++++++----- TinfoilVibeServer/TinfoilVibeServer.csproj | 10 + TinfoilVibeServer/appsettings.json | 7 +- 15 files changed, 821 insertions(+), 341 deletions(-) create mode 100644 TinfoilVibeServer/Models/TitleDbOptions.cs create mode 100644 TinfoilVibeServer/Services/ROMArchiveReader.cs 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",