Working implementation
This commit is contained in:
@@ -3,18 +3,25 @@
|
||||
<s:Boolean x:Key="/Default/AddReferences/RecentPaths/=D_003A_005CCloud_005CGit_005CTinfoilVibeServer_005CTinfoilVibeServer_005Clibhac_005Csrc_005CLibHac_005Cbin_005CRelease_005Cnet8_002E0_005CLibHac_002Edll/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAbstractWritableArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F22ddbc5388b085f3bf3bfcf15c58a6938df55f53cc27a762c1e0d3e974da87_003FAbstractWritableArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAppContext_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F574233347f5bab7d6529a8c4744fb8a213de24439a69fd5c4316ea962144_003FAppContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConcurrentDictionary_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8d7a996932951dc929c6c3855f98f0c58cae8ac5b0d0bbf223f97c6388b3b61f_003FConcurrentDictionary_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F85a5735906a3a06f39a1422a28e0353e3e317f2d923dcc5731fef07dd436f9_003FFileInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemEnumerator_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F326755fc341d349c24999b4c209d69fbe4317313d563859abe51f4ded75c97b_003FFileSystemEnumerator_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemWatcher_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F771dac726bcbf51f1839c45bef2a78c4d834c4bc5420482d9cc3c38eb97535_003FFileSystemWatcher_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemWatcher_002EWin32_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F52fdc6a26ac6d933b95a413e4fb7bc90d22adb6b85734e5eb08036ab03a_003FFileSystemWatcher_002EWin32_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFile_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F3f31e7e8aa33de883c2ccfa62a9c81bfc246c36e825b489476f9472032e512_003FFile_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpResponseMessage_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F4cfeb8b377bc81e1fbb5f7d7a02492cb6ac23e88c8c9d7155944f0716f3d4b_003FHttpResponseMessage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonConverterOfT_002EReadCore_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fa7e99f3da3fc9d80c1949ce87d548d992a745092d1c720f44952dbbd144437f_003FJsonConverterOfT_002EReadCore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializerOptions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5ef7f3c9db445621211faddebc3c1c9bb48a942f1b8cba4caa2501466f85f_003FJsonSerializerOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializer_002EWrite_002EString_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd49cb1d7ce3d716547551bbff70eb2084ebe04c491a927531363631f3e46330_003FJsonSerializer_002EWrite_002EString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMemoryCacheExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Feabafafbeed23dab31f6a8184bdb1fe5a4c2237f39efe53885612b2ccdd3cd_003FMemoryCacheExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APath_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fcf5011822fd54235e86fdc54ee3baa876b4876e5549223ffeadca5607e59f6af_003FPath_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResourceInvoker_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F117275599da316d4e45c62326f3097e45f8b5b17d8fe17bbcf9ca86b0819b16_003FResourceInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProviderServiceExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F621526284d3e4868b98948aff942206525763e57e194a27623b66c384466_003FServiceProviderServiceExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProvider_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fce37be1a06b16c6faa02038d2cc477dd3bca5b217ceeb41c5f2ad45c1bf9_003FServiceProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASharpSevenZipExtractor_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb6d4f34f86579010d492ef5548eca93df749a4e4634ba9c3dee6fbd6c6d74_003FSharpSevenZipExtractor_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc7102cd0ffb8973777e61b1942c3fffac7e14016a511d055c3adf73ff91748_003FThrowHelper_002Ecs_002Fz_003A2_002D1/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Feb62aa945e5eb84a647a452d1924d7a935f01a6cec552d6fc7ab82a065b9ab8_003FThrowHelper_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002ESerialization_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6f4af9413b11943bfdd164b58f2c3faa476fb6ffee3bb2d63ea7882b139c1_003FThrowHelper_002ESerialization_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AZipArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F39ececcf144e1f9c884152723ed93931cd232485eaf2824bf5beb526f1f321b_003FZipArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue"><AssemblyExplorer>
|
||||
|
||||
@@ -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 /
|
||||
// ------------------------------------------------------------
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpGet("index.json")]
|
||||
public IActionResult GetIndex()
|
||||
public IActionResult Index()
|
||||
{
|
||||
var index = new IndexBuilderService(
|
||||
_snapshotService, _titleDb, _configuration,
|
||||
this.HttpContext.RequestServices.GetService(typeof(ILogger<IndexBuilderService>)) as Microsoft.Extensions.Logging.ILogger<IndexBuilderService>)
|
||||
.Build();
|
||||
var index = _indexBuilderService.Build();
|
||||
|
||||
return Ok(index);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// GET /{*path}
|
||||
// ------------------------------------------------------------
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpGet("download")]
|
||||
public IActionResult Download(string url)
|
||||
/// <param name="path">The relative file path requested.</param>
|
||||
[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,
|
||||
@"\[(?<name>.*?)\]\[(?<id>[0-9a-fA-F]{8}[0-9a-fA-F]{8})\]\[(?<v>[0-9a-fA-F]+)\]\[(?<app>Base|Update)\]\.nsp",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace TinfoilVibeServer.Models;
|
||||
using TinfoilVibeServer.Services;
|
||||
|
||||
namespace TinfoilVibeServer.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
);
|
||||
@@ -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)
|
||||
|
||||
/// <summary>
|
||||
/// Turns an even‑length hex string into a byte array in **big‑endian** order
|
||||
/// (the same order that the original <c>ToStrId</c> creates).
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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<TitleDbOptions>(builder.Configuration.GetSection("TitleDb"));
|
||||
builder.Services.AddSingleton<ConfigManager>();
|
||||
builder.Services.AddSingleton<SnapshotService>();
|
||||
builder.Services.AddSingleton<NSPExtractor>(sp =>
|
||||
{
|
||||
var config = sp.GetRequiredService<ConfigManager>();
|
||||
var keySet = KeySetHolder.KeySet; // already loaded by ConfigManager
|
||||
return new NSPExtractor(keySet);
|
||||
});
|
||||
builder.Services.AddSingleton<ISnapshotService, SnapshotService>();
|
||||
builder.Services.AddSingleton<AuthStore>();
|
||||
builder.Services.AddSingleton<TitleDatabaseService>();
|
||||
builder.Services.AddSingleton<ArchiveHandler>();
|
||||
builder.Services.AddSingleton<IndexBuilderService>();
|
||||
builder.Services.AddHostedService<TitleDatabaseService>(provider => provider.GetRequiredService<TitleDatabaseService>()).AddHttpClient();
|
||||
builder.Services.AddHostedService<IndexBuilderService>(provider => provider.GetRequiredService<IndexBuilderService>());
|
||||
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<ConfigManager>())
|
||||
app.MapGet("/debug", () => new SnapshotService(
|
||||
app.Services.GetRequiredService<ConfigManager>(),
|
||||
app.Services.GetRequiredService<NSPExtractor>(),
|
||||
app.Services.GetRequiredService<ArchiveHandler>())
|
||||
.GetSnapshot());
|
||||
|
||||
app.Run();
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class ArchiveHandler
|
||||
{
|
||||
private readonly NSPExtractor _nspExtractor;
|
||||
|
||||
public ArchiveHandler(NSPExtractor nspExtractor)
|
||||
{
|
||||
_nspExtractor = nspExtractor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns TitleInfo if an embedded Nintendo archive is found; otherwise null.
|
||||
/// Return TitleInfo if an embedded Nintendo archive is found; otherwise null.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turn a non‑seekable stream into a seekable one by buffering it into memory.
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="IndexDto"/> from the current snapshot
|
||||
/// and reads the “Directories” & “SuccessMessage” values from
|
||||
/// configuration.
|
||||
/// </summary>
|
||||
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<IndexBuilderService> _logger;
|
||||
private readonly string _cachePath;
|
||||
|
||||
public IndexBuilderService(SnapshotService snapshotService,
|
||||
TitleDatabaseService titleDb,
|
||||
IConfiguration configuration,
|
||||
ILogger<IndexBuilderService> logger)
|
||||
public IndexBuilderService(
|
||||
ISnapshotService snapshotService,
|
||||
TitleDatabaseService titleDb,
|
||||
IConfiguration configuration,
|
||||
ILogger<IndexBuilderService> logger)
|
||||
{
|
||||
_snapshotService = snapshotService;
|
||||
_titleDb = titleDb;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_snapshotService = snapshotService;
|
||||
_titleDb = titleDb;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_cachePath = Path.Combine(AppContext.BaseDirectory, CacheFileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the IndexDto that is sent to the client.
|
||||
/// </summary>
|
||||
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<string[]>() ?? Array.Empty<string>();
|
||||
|
||||
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<IndexCache>(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<FileEntry> 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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts only the three fields you asked for from a full NSP/XCI container.
|
||||
/// </summary>
|
||||
public sealed class NSPExtactor
|
||||
namespace TinfoilVibeServer.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Convenience overload – read the NSP/XCI from disk.
|
||||
/// Extracts the TitleId, version, type *and* the SHA‑256 of the first NCA stream.
|
||||
/// </summary>
|
||||
public static NcaMetadataDto? ExtractFromFile(string filePath)
|
||||
public sealed class NSPExtractor
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
return ExtractFromStream(stream);
|
||||
}
|
||||
private readonly KeySet _keySet;
|
||||
|
||||
/// <summary>
|
||||
/// Core implementation – works on any seekable stream that contains a
|
||||
/// full NSP/XCI container.
|
||||
/// </summary>
|
||||
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<IFile>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check that the stream looks like a PFS0 file system.
|
||||
/// </summary>
|
||||
private static bool IsPFSFileSystem(Stream stream)
|
||||
{
|
||||
try
|
||||
/// <summary>
|
||||
/// Public convenience wrapper that opens the file on disk.
|
||||
/// </summary>
|
||||
public NcaMetadataWithHash? ExtractFromFile(string filePath)
|
||||
{
|
||||
if (!stream.CanSeek) return false;
|
||||
using var stream = File.OpenRead(filePath);
|
||||
return ExtractFromStream(stream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core implementation – works on any seekable stream that contains a full NSP/XCI container.
|
||||
/// </summary>
|
||||
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<IFile>();
|
||||
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<IFile>();
|
||||
|
||||
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;
|
||||
}
|
||||
/// <summary>
|
||||
/// Quick sanity check that the stream looks like a PFS0 file system.
|
||||
/// </summary>
|
||||
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<IFile>();
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO returned by the extractor – contains all data the snapshot needs.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads a ROM archive (zip / 7z / rar) from a stream.
|
||||
/// </summary>
|
||||
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) { }
|
||||
/// <summary>
|
||||
/// Opens an archive from a stream.
|
||||
/// The *fileName* parameter is only used to decide which archive format
|
||||
/// to open; it can be <c>null</c> if the caller already knows the format.
|
||||
/// </summary>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates every file entry inside the archive.
|
||||
/// </summary>
|
||||
public IEnumerable<RomArchiveEntry> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Back‑compat wrapper used by SnapshotService.
|
||||
/// </summary>
|
||||
public IEnumerable<RomArchiveEntry> GetContentInfos() => GetEntries();
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the underlying archive objects and the stream(s).
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_zipArchive?.Dispose();
|
||||
_sharpArchive?.Dispose();
|
||||
_archiveStream?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight container that holds the entry name and the opened stream.
|
||||
/// The caller must dispose <c>Stream</c> after it is done.
|
||||
/// </summary>
|
||||
public sealed record RomArchiveEntry(string Name, Stream Stream);
|
||||
}
|
||||
}
|
||||
@@ -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<FileEntry> GetSnapshot();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps an in‑memory snapshot, watches the filesystem for changes, and
|
||||
/// only re‑processes a file if its hash changed.
|
||||
/// </summary>
|
||||
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<FileSystemWatcher> _watchers = new();
|
||||
private readonly ConcurrentDictionary<string, CachedFile> _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<FileEntry>();
|
||||
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<string, FileEntry> LoadSnapshotIndex()
|
||||
{
|
||||
if (!File.Exists(_jsonPath)) return new();
|
||||
|
||||
var json = File.ReadAllText(_jsonPath);
|
||||
var entries = JsonSerializer.Deserialize<List<FileEntry>>(json, new JsonSerializerOptions(){IncludeFields = true})!;
|
||||
return entries.ToDictionary(e => e.Path, e => e);
|
||||
}
|
||||
#endregion
|
||||
|
||||
public IReadOnlyList<FileEntry> GetSnapshot()
|
||||
{
|
||||
var json = File.ReadAllText(_jsonPath);
|
||||
return JsonSerializer.Deserialize<IReadOnlyList<FileEntry>>(json)!;
|
||||
return JsonSerializer.Deserialize<IReadOnlyList<FileEntry>>(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();
|
||||
}
|
||||
}
|
||||
@@ -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<TitleDbOptions> _options;
|
||||
private readonly ILogger<TitleDatabaseService> _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<string> _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<string, TitleInfoDto> _titleData
|
||||
= new();
|
||||
|
||||
// 2️⃣ Reverse lookup: TitleId → real file‑system path
|
||||
private readonly ConcurrentDictionary<string, string> _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
|
||||
/// </summary>
|
||||
public TitleDatabaseService(
|
||||
IConfiguration configuration,
|
||||
IOptionsMonitor<TitleDbOptions> options,
|
||||
ILogger<TitleDatabaseService> 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<string>
|
||||
{
|
||||
// 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<string, TitleInfoDto>();
|
||||
|
||||
// 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<Dictionary<string, TitleInfoDto>?> 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<Dictionary<string, TitleInfoDto>> GetAllAsync()
|
||||
{
|
||||
if (!_cache.TryGetValue(CacheKey, out Dictionary<string, TitleInfoDto>? dict))
|
||||
{
|
||||
await ReloadCacheAsync(); // cache miss → load from disk
|
||||
_cache.TryGetValue(CacheKey, out dict);
|
||||
}
|
||||
return dict!; // Get() has already slid the entry
|
||||
}
|
||||
|
||||
public async Task<TitleInfoDto?> 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<string, TitleInfoDto> 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
|
||||
|
||||
/// <summary>
|
||||
/// Return the <c>TitleInfoDto</c> for a known <c>TitleId</c>.
|
||||
/// </summary>
|
||||
public bool TryGetTitle(string titleId, out TitleInfoDto? title)
|
||||
=> _titleData.TryGetValue(titleId, out title);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool TryGetFilePath(string titleId, out string? path)
|
||||
=> _titleIdToPath.TryGetValue(titleId, out path);
|
||||
|
||||
/// <summary>
|
||||
/// Convenience helper – look‑up the file path for a TitleId and return
|
||||
/// it as a string. Returns null if the TitleId is unknown.
|
||||
/// </summary>
|
||||
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
|
||||
/// <summary>
|
||||
/// Read the JSON file and populate <c>_titleData</c>.
|
||||
/// </summary>
|
||||
private async Task ReadTitleDbAsync(string filePath, CancellationToken ct)
|
||||
private async Task<Dictionary<string,TitleInfoDto>> 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<Dictionary<string, TitleInfoDto>>(
|
||||
json,
|
||||
new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
}) ?? new Dictionary<string, TitleInfoDto>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var entry in entries.EnumerateArray())
|
||||
_logger.LogInformation("Loaded {Count} titles from the database.", titleInfoDtos.Count);
|
||||
|
||||
var titleData = new Dictionary<string, TitleInfoDto>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,14 @@
|
||||
<Content Update="appsettings.Development.json">
|
||||
<DependentUpon>appsettings.json</DependentUpon>
|
||||
</Content>
|
||||
<Content Remove="obj\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- libhac ships a native helper called Ryujinx.HLE.HOS.Native.dll.
|
||||
The build script copies it to the output folder automatically. -->
|
||||
<None Update="..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Remove="obj\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -35,4 +37,12 @@
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="obj\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Remove="obj\**" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -19,8 +19,11 @@
|
||||
"KeySetFile": "prod.keys",
|
||||
"TitleDb": {
|
||||
"CountryCode": "AU",
|
||||
"Language": "en"
|
||||
},
|
||||
"Language": "en",
|
||||
"TtlSeconds" : 30,
|
||||
"SnapshotFile" : "snapshot.json"
|
||||
},
|
||||
|
||||
"IndexDirectories": [
|
||||
"https://url1",
|
||||
"sdmc:/url2",
|
||||
|
||||
Reference in New Issue
Block a user