From 209b766a1f0b3d5bc69efe069087b5b3dfaf17f0 Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Fri, 7 Nov 2025 13:31:37 +1030 Subject: [PATCH] Build Snapshot from archives Download from archives Process XCI files in archives --- .idea/.idea.TinfoilVibeServer/.idea/vcs.xml | 1 + TinfoilVibeServer.sln.DotSettings.user | 48 ++ .../Controllers/IndexController.cs | 148 +++-- .../Middleware/BasicAuthMiddleware.cs | 8 + TinfoilVibeServer/Models/FileEntry.cs | 8 +- TinfoilVibeServer/Models/SnapshotOptions.cs | 12 +- TinfoilVibeServer/Program.cs | 6 +- .../Properties/launchSettings.json | 2 +- TinfoilVibeServer/Services/ArchiveHandler.cs | 98 +++- .../Services/IndexBuilderService.cs | 96 ++- TinfoilVibeServer/Services/NSPExtractor.cs | 146 +++-- TinfoilVibeServer/Services/SnapshotService.cs | 552 ++++++++++++++---- .../Services/TitleDatabaseService.cs | 55 +- TinfoilVibeServer/TinfoilVibeServer.csproj | 2 + .../Utilities/SeekableBufferedStream.cs | 251 ++++++++ TinfoilVibeServer/appsettings.json | 6 +- .../Tests/SnapshotServiceTests.cs | 87 ++- 17 files changed, 1204 insertions(+), 322 deletions(-) create mode 100644 TinfoilVibeServer/Utilities/SeekableBufferedStream.cs diff --git a/.idea/.idea.TinfoilVibeServer/.idea/vcs.xml b/.idea/.idea.TinfoilVibeServer/.idea/vcs.xml index 94a25f7..c656bff 100644 --- a/.idea/.idea.TinfoilVibeServer/.idea/vcs.xml +++ b/.idea/.idea.TinfoilVibeServer/.idea/vcs.xml @@ -2,5 +2,6 @@ + \ No newline at end of file diff --git a/TinfoilVibeServer.sln.DotSettings.user b/TinfoilVibeServer.sln.DotSettings.user index 103bd5f..a8484bd 100644 --- a/TinfoilVibeServer.sln.DotSettings.user +++ b/TinfoilVibeServer.sln.DotSettings.user @@ -3,9 +3,17 @@ True ForceIncluded ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -15,35 +23,75 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded + <AssemblyExplorer> <Assembly Path="D:\Cloud\Git\TinfoilVibeServer\TinfoilVibeServer\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" /> + <Assembly Path="D:\Cloud\Git\TinfoilVibeServer\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" /> </AssemblyExplorer> <SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> diff --git a/TinfoilVibeServer/Controllers/IndexController.cs b/TinfoilVibeServer/Controllers/IndexController.cs index f8c6387..5043574 100644 --- a/TinfoilVibeServer/Controllers/IndexController.cs +++ b/TinfoilVibeServer/Controllers/IndexController.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; +using SharpCompress.Readers; using TinfoilVibeServer.Models; using TinfoilVibeServer.Services; @@ -18,12 +19,12 @@ public sealed class IndexController : ControllerBase private readonly IndexBuilderService _indexBuilderService; public IndexController(ISnapshotService snapshotService, - TitleDatabaseService titleDb, - IConfiguration configuration, IndexBuilderService indexBuilderService) + TitleDatabaseService titleDb, + IConfiguration configuration, IndexBuilderService indexBuilderService) { _snapshotService = snapshotService; - _titleDb = titleDb; - _configuration = configuration; + _titleDb = titleDb; + _configuration = configuration; _indexBuilderService = indexBuilderService; } @@ -39,8 +40,9 @@ public sealed class IndexController : ControllerBase { if (HttpContext.Request.Headers.CacheControl == "no-cache") { - _snapshotService.RebuildSnapshot(); + _indexBuilderService.InvalidateIndex(this, EventArgs.Empty); } + var index = _indexBuilderService.Build(); return Ok(index); @@ -56,7 +58,7 @@ public sealed class IndexController : ControllerBase /// /// The relative file path requested. [HttpGet("{*path}")] - public IActionResult Download(string path) + public async Task Download(string path) { if (string.IsNullOrWhiteSpace(path)) return BadRequest("Missing url query parameter."); @@ -64,7 +66,7 @@ public sealed class IndexController : ControllerBase // ---- 1️⃣ Parse the brackets -------------------------------- // Expected format: [name][TitleId][v][patchOrApp].nsp 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", + @"(?.*?)\[(?[0-9a-fA-F]{8}[0-9a-fA-F]{8})\]\[v(?[0-9]+)\]\[(?Base|Update)\]\.nsp", System.Text.RegularExpressions.RegexOptions.IgnoreCase); if (!match.Success) @@ -74,41 +76,52 @@ public sealed class IndexController : ControllerBase // ---- 2️⃣ Find the file that contains this TitleId ------------ var entry = _snapshotService.GetSnapshot().Files - .FirstOrDefault(e => e.Title?.TitleId == titleId); + .FirstOrDefault(e => { return e.Titles.FirstOrDefault(hash => hash.TitleId == titleId)?.TitleId == titleId; }); if (entry == null) return NotFound("No file with that TitleId found."); // ---- 3️⃣ If the file is a normal NSP → send it ---------------- - if (Path.GetExtension(entry.Path).Equals(".nsp", StringComparison.OrdinalIgnoreCase)) + + if (Path.GetExtension(entry.Path).Equals(".nsp", StringComparison.OrdinalIgnoreCase) && !entry.Path.Contains(_snapshotService.GetArchivePathSeparator())) { - // Check if it is inside an archive. - // If the path contains a slash that is not the root separator - // it might be an entry inside an archive; we simply stream it. - // - For normal files, we can use SendFileAsync. - // - For archives, we stream the entry using ArchiveHandler. - - if (IsInsideArchive(entry.Path)) - { - // Example: file is inside an archive – use ArchiveHandler - var archivePath = Path.GetDirectoryName(entry.Path); - var innerFileName = Path.GetFileName(entry.Path); - var stream = StreamFromArchive(archivePath, innerFileName); - - if (stream == null) - return NotFound("Could not stream entry from archive."); - - return File(stream, "application/octet-stream", - Path.GetFileName(innerFileName)); - } - else + if (System.IO.File.Exists(entry.Path)) { // Regular file – just serve it. return PhysicalFile(entry.Path, "application/octet-stream", - Path.GetFileName(entry.Path)); + Path.GetFileName(entry.Path)); } } + // Check if it is inside an archive. + // If the path contains a slash that is not the root separator + // it might be an entry inside an archive; we simply stream it. + // - For normal files, we can use SendFileAsync. + // - For archives, we stream the entry using ArchiveHandler. + + if (IsInsideArchive(entry.Path)) + { + // Example: file is inside an archive – use ArchiveHandler + var innerFileName = entry.Path.Split(_snapshotService.GetArchivePathSeparator()).Last(); + var stream = StreamFromArchive(entry, titleId, out var streamContainer); + + if (stream == null) + return NotFound("Could not stream entry from archive."); + + var wrappedStream = new DependentStream(stream, streamContainer); + var contentDisposition = $"inline; filename=\"{innerFileName}\""; + + Response.ContentType = "application/octet-stream"; + Response.ContentLength = entry.Size; + Response.Headers["Content-Disposition"] = contentDisposition; + await using var entryStream = wrappedStream; + const int bufferSize = 10 * 1024 * 1024;// 81920; // 80 KB – default used by CopyToAsync + await entryStream.CopyToAsync(Response.Body, bufferSize, HttpContext.RequestAborted); + + // Once the copy completes, the `await using` disposes the archive. + return new EmptyResult(); // We already wrote the stream to Response.Body + } + return NotFound("Requested URL does not reference an NSP file."); } @@ -116,22 +129,30 @@ public sealed class IndexController : ControllerBase /// Very light‑weight helper – decides whether the file path /// represents a file inside an archive. /// - private bool IsInsideArchive(string path) => + private bool IsInsideArchive(string path) + { // If the path contains a separator that is not a root separator // (e.g. "Games/MyGame.nsp" is a regular file; "archive.7z/mygame.nsp" // would be inside an archive). For simplicity we only check // for common archive extensions. - path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) || - path.EndsWith(".7z", StringComparison.OrdinalIgnoreCase) || - path.EndsWith(".rar", StringComparison.OrdinalIgnoreCase); + var filePath = path.Split(_snapshotService.GetArchivePathSeparator()).First(); + return filePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) || + filePath.EndsWith(".7z", StringComparison.OrdinalIgnoreCase) || + filePath.EndsWith(".rar", StringComparison.OrdinalIgnoreCase); + } /// /// If the NSP is inside an archive, this method opens the archive /// and returns the entry stream. It is deliberately minimal – /// if the archive can’t be opened we return null. /// - private Stream? StreamFromArchive(string archivePath, string innerFileName) + private Stream? StreamFromArchive(FileEntry fileEntry, string titleId, out IDisposable? streamContainer) { + // Example: file is inside an archive – use ArchiveHandler + var archivePath = fileEntry.Path.Split(_snapshotService.GetArchivePathSeparator()).First(); + _snapshotService.GetArchiveName(titleId); + var innerFileName = Path.GetFileName(fileEntry.Path.Split(_snapshotService.GetArchivePathSeparator()).Last()); + // Use SharpCompress to open the archive and find the entry. // Only the 3 archive types we support are handled. try @@ -139,28 +160,31 @@ public sealed class IndexController : ControllerBase // Check which archive type if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) { - using var zip = SharpCompress.Archives.Zip.ZipArchive.Open(archivePath); + var zip = SharpCompress.Archives.Zip.ZipArchive.Open(archivePath, new ReaderOptions { LeaveStreamOpen = true }); + streamContainer = zip; var entry = zip.Entries - .FirstOrDefault(e => e.Key.Equals(innerFileName, - StringComparison.OrdinalIgnoreCase)); + .FirstOrDefault(e => e.Key != null && e.Key.Equals(innerFileName, + StringComparison.OrdinalIgnoreCase)); if (entry != null) return entry.OpenEntryStream(); } else if (archivePath.EndsWith(".7z", StringComparison.OrdinalIgnoreCase)) { - using var sevenZip = SharpCompress.Archives.SevenZip.SevenZipArchive.Open(archivePath); + var sevenZip = SharpCompress.Archives.SevenZip.SevenZipArchive.Open(archivePath, new ReaderOptions { LeaveStreamOpen = true }); + streamContainer = sevenZip; var entry = sevenZip.Entries - .FirstOrDefault(e => e.Key.Equals(innerFileName, - StringComparison.OrdinalIgnoreCase)); + .FirstOrDefault(e => e.Key != null && e.Key.Equals(innerFileName, + StringComparison.OrdinalIgnoreCase)); if (entry != null) return entry.OpenEntryStream(); } else if (archivePath.EndsWith(".rar", StringComparison.OrdinalIgnoreCase)) { - using var rar = SharpCompress.Archives.Rar.RarArchive.Open(archivePath); + var rar = SharpCompress.Archives.Rar.RarArchive.Open(archivePath, new ReaderOptions { LeaveStreamOpen = true }); + streamContainer = rar; var entry = rar.Entries - .FirstOrDefault(e => e.Key.Equals(innerFileName, - StringComparison.OrdinalIgnoreCase)); + .FirstOrDefault(e => e.Key != null && e.Key.Equals(innerFileName, + StringComparison.OrdinalIgnoreCase)); if (entry != null) return entry.OpenEntryStream(); } @@ -170,6 +194,42 @@ public sealed class IndexController : ControllerBase // ignore – we will just return null and the controller // will respond with 404. } + + streamContainer = null; return null; } + + public class DependentStream : Stream + { + private readonly Stream _innerStream; + private readonly IDisposable? _parentContainer; + + public DependentStream(Stream innerStream, IDisposable? parentContainer) + { + _innerStream = innerStream; + _parentContainer = parentContainer; + } + + public override void Flush() => _innerStream.Flush(); + + public override int Read(byte[] buffer, int offset, int count) => _innerStream.Read(buffer, offset, count); + + public override long Seek(long offset, SeekOrigin origin) => _innerStream.Seek(offset, origin); + + public override void SetLength(long value) => _innerStream.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => _innerStream.Write(buffer, offset, count); + + public override bool CanRead => _innerStream.CanRead; + public override bool CanSeek => _innerStream.CanSeek; + public override bool CanWrite => _innerStream.CanWrite; + public override long Length => _innerStream.Length; + public override long Position { get => _innerStream.Position; set => _innerStream.Position = value; } + protected override void Dispose(bool disposing) + { + _parentContainer?.Dispose(); + base.Dispose(disposing); + } + } + } \ No newline at end of file diff --git a/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs b/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs index a0f245c..11ab6f2 100644 --- a/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs +++ b/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs @@ -17,6 +17,14 @@ public sealed class BasicAuthMiddleware public async Task InvokeAsync(HttpContext context, IAuthStore store, ILogger logger) { + // ------------- 1) Bypass auth for every path except “/” ---------------- + // PathString is a struct – compare its value directly. + if (!context.Request.Path.Equals("/", StringComparison.Ordinal)) + { + await _next(context); + return; + } + logger.LogInformation("Incoming request from {IP} – {Method} {Path}", context.Connection.RemoteIpAddress, context.Request.Method, context.Request.Path); var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; diff --git a/TinfoilVibeServer/Models/FileEntry.cs b/TinfoilVibeServer/Models/FileEntry.cs index 57fc593..d73f050 100644 --- a/TinfoilVibeServer/Models/FileEntry.cs +++ b/TinfoilVibeServer/Models/FileEntry.cs @@ -6,8 +6,8 @@ namespace TinfoilVibeServer.Models; /// One line in the snapshot – the JSON will be an array of these. /// public sealed record FileEntry( - string Path, - long Size, - string Hash, // SHA‑256 hex - NcaMetadataWithHash? Title // null unless file is an NSP/XCI or an archive containing one + string Path, // nsp or archive path + long Size, // size of nsp or full archive + string Hash, // SHA‑256 hex of first NCA of first NCP in NSP or archive + List Titles // Details of all NSP Roms in the Path ); \ No newline at end of file diff --git a/TinfoilVibeServer/Models/SnapshotOptions.cs b/TinfoilVibeServer/Models/SnapshotOptions.cs index 417060a..46e651f 100644 --- a/TinfoilVibeServer/Models/SnapshotOptions.cs +++ b/TinfoilVibeServer/Models/SnapshotOptions.cs @@ -17,16 +17,16 @@ public sealed class SnapshotOptions : INotifyPropertyChanged } } } - private List _whitelistExtensions = new(); - public List WhitelistExtensions + private List _archiveExtensions = new(); + public List ArchiveExtensions { - get => _whitelistExtensions; + get => _archiveExtensions; set { - if (_whitelistExtensions != value) + if (_archiveExtensions != value) { - _whitelistExtensions = value; - OnPropertyChanged(nameof(_whitelistExtensions)); + _archiveExtensions = value; + OnPropertyChanged(nameof(_archiveExtensions)); } } } diff --git a/TinfoilVibeServer/Program.cs b/TinfoilVibeServer/Program.cs index b2196ba..8dbe16a 100644 --- a/TinfoilVibeServer/Program.cs +++ b/TinfoilVibeServer/Program.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using TinfoilVibeServer.Authentication; using TinfoilVibeServer.Middleware; @@ -24,11 +25,13 @@ builder.Services.AddSingleton(sp => var keySet = KeySetHolder.KeySet; // already loaded by ConfigManager return new NSPExtractor(keySet, logger); }); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddHostedService(provider => provider.GetRequiredService()); builder.Services.AddHostedService(provider => provider.GetRequiredService()).AddHttpClient(); builder.Services.AddHostedService(provider => provider.GetRequiredService()); builder.Services.AddControllers(); // add MVC @@ -47,6 +50,7 @@ app.MapControllers(); // routes the /index.json & /download endpoints app.MapGet("/debug", () => new SnapshotService( + app.Services.GetRequiredService(), app.Services.GetRequiredService>(), app.Services.GetRequiredService(), app.Services.GetRequiredService(), diff --git a/TinfoilVibeServer/Properties/launchSettings.json b/TinfoilVibeServer/Properties/launchSettings.json index c876394..7158c2e 100644 --- a/TinfoilVibeServer/Properties/launchSettings.json +++ b/TinfoilVibeServer/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://localhost:5253", + "applicationUrl": "http://192.168.1.145:80", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/TinfoilVibeServer/Services/ArchiveHandler.cs b/TinfoilVibeServer/Services/ArchiveHandler.cs index 434c0d0..69a8ae4 100644 --- a/TinfoilVibeServer/Services/ArchiveHandler.cs +++ b/TinfoilVibeServer/Services/ArchiveHandler.cs @@ -3,7 +3,9 @@ using SharpCompress.Archives; using SharpCompress.Archives.Zip; using SharpCompress.Archives.Rar; using SharpCompress.Archives.SevenZip; +using SharpCompress.Common; using TinfoilVibeServer.Models; +using TinfoilVibeServer.Utilities; using ZipArchive = SharpCompress.Archives.Zip.ZipArchive; namespace TinfoilVibeServer.Services; @@ -13,7 +15,12 @@ public interface IArchiveHandler /// /// Return TitleInfo if an embedded Nintendo archive is found; otherwise null. /// - NcaMetadataWithHash? TryExtractTitleInfo(string filePath); + IEnumerable<(string, long, NcaMetadataWithHash)> TryExtractTitleInfos(string filePath); + + /// + /// Return TitleInfo if an embedded Nintendo archive is found; otherwise null. + /// + IEnumerable<(string, long, NcaMetadataWithHash)> TryExtractTitleInfos(Stream archiveStream, string archiveType); } /// @@ -35,7 +42,7 @@ public sealed class ArchiveHandler : IArchiveHandler /// /// Return TitleInfo if an embedded Nintendo archive is found; otherwise null. /// - public NcaMetadataWithHash? TryExtractTitleInfo(string filePath) + public IEnumerable<(string, long, NcaMetadataWithHash)> TryExtractTitleInfos(string filePath) { _logger.LogInformation("Examining archive {File} for embedded NSP", filePath); var ext = Path.GetExtension(filePath).ToLowerInvariant(); @@ -53,7 +60,7 @@ public sealed class ArchiveHandler : IArchiveHandler default: { _logger.LogWarning("Unsupported archive type {Extension} – skipping", ext); - return null; + return []; } } } @@ -61,28 +68,31 @@ public sealed class ArchiveHandler : IArchiveHandler { _logger.LogError("Error opening archive {File}: {Exception}", filePath, ex.Message); // Graceful fallback – return null - return null; + return []; } } - private NcaMetadataWithHash? HandleZip(string path) + public IEnumerable<(string, long, NcaMetadataWithHash)> TryExtractTitleInfos(Stream archiveStream, string archiveType) + { + throw new NotImplementedException(); + } + + private IEnumerable<(string, long, NcaMetadataWithHash)> HandleZip(string path) { using var archive = ZipArchive.Open(path); foreach (var entry in archive.Entries) { - if (!entry.IsDirectory && IsRomArchive(entry.Key)) - { - var temp = Path.GetTempFileName(); - entry.WriteToFile(temp); - var title = _nspExtractor.ExtractFromFile(temp); // instance call - File.Delete(temp); - return title; - } + if (entry.IsDirectory || entry.Key == null || !IsRomArchive(entry.Key)) continue; + + var temp = Path.GetTempFileName(); + entry.WriteToFile(temp); + var title = _nspExtractor.ExtractFromFile(temp); // instance call + File.Delete(temp); + if (title != null) yield return (entry.Key, entry.Size, title); } - return null; } - private NcaMetadataWithHash? Handle7z(string path) + private IEnumerable<(string, long, NcaMetadataWithHash)> Handle7z(string path) { using var archive = SevenZipArchive.Open(path); foreach (var entry in archive.Entries) @@ -93,35 +103,71 @@ public sealed class ArchiveHandler : IArchiveHandler entry.WriteToFile(temp); var title = _nspExtractor.ExtractFromFile(temp); // instance call File.Delete(temp); - return title; + if (title != null) yield return (entry.Key, entry.Size, title); } } - return null; } - private NcaMetadataWithHash? HandleRar(string path) + private IEnumerable<(string, long, NcaMetadataWithHash)> HandleRar(string path) { + var titles = new List<(string, long, NcaMetadataWithHash)>(); + var entryCount = 0; try { using var archive = RarArchive.Open(path); + entryCount = archive.Entries.Count; foreach (var entry in archive.Entries) { - if (!entry.IsDirectory && IsRomArchive(entry.Key)) + if (entry.IsDirectory || entry.Key == null || !IsRomArchive(entry.Key)) continue; + { + try + { + using var streamWrapper = new SeekableBufferedStream(entry.OpenEntryStream(), entry.Size, 64 * 1024 * 1024, false); + var title = _nspExtractor.ExtractFromStream(streamWrapper); + if (title != null) titles.Add((entry.Key, entry.Size, title)); + } + catch (Exception e) + { + if (e.Message.StartsWith("Failed to extract NSP")) + { + _logger.LogError("Failed to extract title info from archive {Archive}: {Exception}", path, e.Message); + } + else + { + throw; + } + } + } + } + } + catch (Exception exception) + { + if (titles.Count > 0 && titles.Count == entryCount) + { + // broken archive but managed to read some titles + _logger.LogInformation("Failed to fully process archive with SharpCompress, but found {Count} titles: {Exception}", titles.Count, exception.Message); + return titles; + } + + // Fallback to SharpSevenZip (if needed) + _logger.LogInformation( + "Failed to open archive with SharpCompress, falling back to SharpSevenZip {Exception}", + exception.Message); + using var archive = SevenZipArchive.Open(path); + foreach (var entry in archive.Entries) + { + if (entry is { IsDirectory: false, Key: not null } && IsRomArchive(entry.Key)) { var temp = Path.GetTempFileName(); entry.WriteToFile(temp); var title = _nspExtractor.ExtractFromFile(temp); // instance call File.Delete(temp); - return title; + if (title != null) titles.Add((entry.Key, entry.Size, title)); } } - return null; - } - catch (SharpCompress.Common.ArchiveException) - { - // Fallback to SharpSevenZip (if needed) - return null; } + + return titles; } private bool IsRomArchive(string entryName) diff --git a/TinfoilVibeServer/Services/IndexBuilderService.cs b/TinfoilVibeServer/Services/IndexBuilderService.cs index 9d32d6e..f5de0e2 100644 --- a/TinfoilVibeServer/Services/IndexBuilderService.cs +++ b/TinfoilVibeServer/Services/IndexBuilderService.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Linq; using System.Security.Cryptography; using System.Text.Json; +using System.Text.RegularExpressions; using LibHac.Ncm; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -23,6 +24,7 @@ public sealed class IndexBuilderService: IHostedService private readonly ILogger _logger; private readonly string _cachePath; + private readonly SemaphoreSlim _lock = new(1, 1); public IndexBuilderService( ISnapshotService snapshotService, TitleDatabaseService titleDb, @@ -41,9 +43,10 @@ public sealed class IndexBuilderService: IHostedService // 1️⃣ Load cache if it exists var cached = LoadCache(); var snapshot = _snapshotService.GetSnapshot(); - if (string.IsNullOrEmpty(snapshot.Hash)) + + if (string.IsNullOrEmpty(snapshot.Hash) || snapshot.Files.Count == 0) { - _snapshotService.BuildSnapshot(); + _snapshotService.BuildSnapshotAsync(); snapshot = _snapshotService.GetSnapshot(); } @@ -57,33 +60,14 @@ public sealed class IndexBuilderService: IHostedService _logger.LogInformation("Building index (snapshot size={Count})", snapshot.Files.Count); // 3️⃣ Build new index from snapshot entries - var files = snapshot.Files - .Where(e => e.Title != null) - .Select(e => - { - var titleId = e.Title.TitleId; - var name = _titleDb.TryGetTitle(titleId, out var t) - ? t.Name - : "Unknown"; - - var vProcessed = e.Title.Version * 0x10000; - var patchOrApp = e.Title.ContentMetaType == ContentMetaType.Application ? "Base" : "Update"; - if (e.Title.ContentMetaType == ContentMetaType.Patch) - { - name = _titleDb.TryGetTitle(e.Title.ApplicationTitle, out var appTitle) ? appTitle.Name : "Unknown"; - } - var url = $"{name}[{titleId}][{vProcessed}][{patchOrApp}].nsp"; - - return new FileDto(url, e.Size); - }) - .ToList(); + var files = ParseSnapshotFiles(snapshot); var directories = _configuration.GetSection("Directories") .Get() ?? Array.Empty(); var success = _configuration["SuccessMessage"] ?? string.Empty; - var index = new IndexDto(files, directories.ToList(), success); + var index = new IndexDto(files.SelectMany(inner => inner).ToList(), directories.ToList(), success); // 4️⃣ Persist cache PersistCache(snapshotHash, index); @@ -91,6 +75,57 @@ public sealed class IndexBuilderService: IHostedService return index; } + private List> ParseSnapshotFiles(SnapshotService.ROMSnapshot snapshot) + { + var files = snapshot.Files + .Where(e => e.Titles.Count > 0) + .Select(e => + { + var fileDtos = new List(); + foreach (var title in e.Titles) + { + var titleId = title.TitleId; + var name = _titleDb.TryGetTitle(titleId, out var t) + ? t.Name + : "Unknown"; + + var versionNumberParsed = (title.ContentMetaType == ContentMetaType.Application) ? title.Version : title.Version * 0x10000; + var patchOrApp = title.ContentMetaType == ContentMetaType.Application ? "Base" : "Update"; + if (title.ContentMetaType == ContentMetaType.Patch || + title.ContentMetaType == ContentMetaType.AddOnContent) + { + // Patch should use the application title name + name = _titleDb.TryGetTitle(title.ApplicationTitle, out var appTitle) + ? appTitle.Name + : "Unknown"; + //_logger.LogInformation("Patch title {TitleId} uses application title {ApplicationTitle}", titleId, title.ApplicationTitle); + } + + // if still unknown, its probably a demo, use filename extraction + if (name == "Unknown") + { + var match = Regex.Match(Path.GetFileNameWithoutExtension(e.Path), "^(.+?)\\s*(?:\\[.*)?$", + RegexOptions.None); + if (match.Success) + { + name = match.Groups[1].Value; + _logger.LogInformation("Name not found for {TitleId}, using filename: {Name}", titleId, + name); + } + } + + var url = $"http://192.168.1.145/{name}[{titleId}][v{versionNumberParsed}][{patchOrApp}].nsp"; + + fileDtos.Add(new FileDto(url, e.Size)); + } + + return fileDtos; + + }) + .ToList(); + return files; + } + private IndexCache? LoadCache() { if (!File.Exists(_cachePath)) return null; @@ -120,11 +155,24 @@ public sealed class IndexBuilderService: IHostedService public Task StartAsync(CancellationToken cancellationToken) { Build(); + this._snapshotService.SnapshotRebuilt += InvalidateIndex; return Task.CompletedTask; } + public void InvalidateIndex(object? sender, EventArgs e) + { + if (!File.Exists(_cachePath)) return; + + File.Delete(_cachePath); + _logger.LogInformation("Index cache cleared"); + } + public Task StopAsync(CancellationToken cancellationToken) - => Task.CompletedTask; // nothing special to do on shutdown + { + _snapshotService.SnapshotRebuilt -= InvalidateIndex; + return 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 4bd4487..d51f323 100644 --- a/TinfoilVibeServer/Services/NSPExtractor.cs +++ b/TinfoilVibeServer/Services/NSPExtractor.cs @@ -1,10 +1,4 @@ -// File: Services/NSPExtactor.cs -// *** UPDATED *** -using System; -using System.IO; -using System.Linq; -using System.Collections.Generic; -using System.Security.Cryptography; +using System.Security.Cryptography; using LibHac.Common; using LibHac.Fs; using LibHac.Fs.Fsa; @@ -13,8 +7,8 @@ using LibHac.Tools.FsSystem; using LibHac.Tools.FsSystem.NcaUtils; using LibHac.Common.Keys; using LibHac.Ncm; +using LibHac.Tools.Fs; using LibHac.Tools.Ncm; -using TinfoilVibeServer.Models; namespace TinfoilVibeServer.Services { @@ -61,22 +55,73 @@ namespace TinfoilVibeServer.Services /// public NcaMetadataWithHash? ExtractFromStream(Stream stream) { - _logger.LogInformation("Extracting NSP from stream (length={Length}", stream.Length); - if (!IsPfs0FileSystem(stream)) - return null; - + if (!stream.CanSeek) return null; stream.Seek(0, SeekOrigin.Begin); - + + _logger.LogInformation("Extracting NSP/XCI from stream (length={Length})", stream.Length); using var storage = new StreamStorage(stream, false); - var partition = new PartitionFileSystem(); - partition.Initialize(storage).ThrowIfFailure(); + if (IsPfs0FileSystem(stream)) + { + return ExtractNSPFromStream(storage); + } + if (IsXciFileSystem(stream)) + { + var xci = new Xci(_keySet, storage); + List ncaEntries; + if (xci.HasPartition(XciPartitionType.Secure)) + { + _logger.LogInformation("Processing as XCI"); + var partition = xci.OpenPartition(XciPartitionType.Secure); + ncaEntries = partition + .EnumerateEntries("*.cnmt.nca", SearchOptions.RecurseSubdirectories) + .Where(e => e.Type == DirectoryEntryType.File) + .ToList(); + byte[]? hash = null; + 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 (hash == null) + { + // Hash the *first* NCA stream – the stream we just opened + using var sha256 = SHA256.Create(); + using var ncaStream = ncaFile.AsStream(); + hash = sha256.ComputeHash(ncaStream); + } + + if (nca.Header.ContentType != NcaContentType.Meta) + continue; // only the meta NCA contains title metadata + + string titleId = nca.Header.TitleId.ToString("X16"); + var (contentMetaType, applicationTitleId, titleVersion) = GetMetaData(nca); + + _logger.LogInformation("Meta NCA found – TitleId={TitleId} Version={Version}", titleId, titleVersion); + // XCI can never be a patch? + return new NcaMetadataWithHash(titleId, applicationTitleId.ToString("X16"), titleVersion.Major, ContentMetaType.Application, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant()); + } + } + } + + return null; // no meta NCA found + } + + private NcaMetadataWithHash? ExtractNSPFromStream(StreamStorage storage) + { + List ncaEntries; + _logger.LogInformation("Processing as NSP"); + var partition = new PartitionFileSystem(); + partition.Initialize(storage).ThrowIfFailure(); // Find the first *.nca that contains the meta header - var ncaEntries = partition + ncaEntries = partition .EnumerateEntries("*.nca", SearchOptions.RecurseSubdirectories) .Where(e => e.Type == DirectoryEntryType.File) .ToList(); - + byte[]? hash = null; foreach (var dirEntry in ncaEntries) { using var fileRef = new UniqueRef(); @@ -85,30 +130,31 @@ namespace TinfoilVibeServer.Services using var ncaFileStorage = new FileStorage(ncaFile); var nca = new Nca(_keySet, ncaFileStorage); + if (hash == null) + { + // Hash the *first* NCA stream – the stream we just opened + using var sha256 = SHA256.Create(); + using var ncaStream = ncaFile.AsStream(); + hash = sha256.ComputeHash(ncaStream); + } + if (nca.Header.ContentType != NcaContentType.Meta) continue; // only the meta NCA contains title metadata _logger.LogInformation("Meta NCA found – TitleId={TitleId} Version={Version}", nca.Header.TitleId, nca.Header.Version); 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,applicationTitle) = GetMetaDataType(nca); + + var (contentMetaType,applicationTitle,titleVersion) = GetMetaData(nca); if (contentMetaType != null) - return new NcaMetadataWithHash(titleId, applicationTitle.ToString("X16"), version, contentMetaType.Value, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant()); + return new NcaMetadataWithHash(titleId, applicationTitle.ToString("X16"), titleVersion.Minor, contentMetaType.Value, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant()); } - return null; // no meta NCA found + return null; } - private static (ContentMetaType?,ulong) GetMetaDataType(Nca nca) + + private static (ContentMetaType?,ulong, TitleVersion) GetMetaData(Nca nca) { - if (nca.Header.ContentType != NcaContentType.Meta) return (null,0); + if (nca.Header.ContentType != NcaContentType.Meta) return (null,0, new TitleVersion(0, true)); using var openFileSystem = nca.OpenFileSystem(0, IntegrityCheckLevel.ErrorOnInvalid); foreach (var entry in openFileSystem.EnumerateEntries("*.cnmt", SearchOptions.Default)) { @@ -121,11 +167,12 @@ namespace TinfoilVibeServer.Services var cnmt = new Cnmt(asStream); var applicationTitle = cnmt.ApplicationTitleId; - return (cnmt.Type,applicationTitle); + return (cnmt.Type,applicationTitle, cnmt.TitleVersion); } - return (null,0); + return (null,0, new TitleVersion(0, true)); } + /// /// Quick sanity check that the stream looks like a PFS0 file system. /// @@ -136,7 +183,7 @@ namespace TinfoilVibeServer.Services if (!stream.CanSeek) return false; stream.Seek(0, SeekOrigin.Begin); - var storage = new StreamStorage(stream, false); + var storage = new StreamStorage(stream, true); var partition = new PartitionFileSystem(); partition.Initialize(storage).ThrowIfFailure(); return true; @@ -147,6 +194,32 @@ namespace TinfoilVibeServer.Services return false; } } + private bool IsXciFileSystem(Stream stream) + { + try + { + if (!stream.CanSeek) return false; + stream.Seek(0, SeekOrigin.Begin); + + var storage = new StreamStorage(stream, true); + try + { + var xciBlock = new Xci(_keySet, storage); + _logger.LogInformation("XCI found"); + return xciBlock.HasPartition(XciPartitionType.Secure); + } + catch + { + // ignored + } + return false; + } + catch (Exception e) + { + _logger.LogError("Failed to extract XCI: {Exception}", e.Message); + return false; + } + } public string ExtractHashFromStream(Stream nspStream) { @@ -208,13 +281,14 @@ namespace TinfoilVibeServer.Services public ContentMetaType ContentMetaType { get; set; } public string Hash { get; } - public NcaMetadataWithHash(string titleId, string applicationTitle, int version, ContentMetaType contentMetaType, string hash) + public NcaMetadataWithHash(string titleId, string applicationTitle, int version, + ContentMetaType contentMetaType, string hash) { TitleId = titleId; ApplicationTitle = applicationTitle; Version = version; ContentMetaType = contentMetaType; - Hash = hash; + Hash = hash; } } } \ No newline at end of file diff --git a/TinfoilVibeServer/Services/SnapshotService.cs b/TinfoilVibeServer/Services/SnapshotService.cs index c787d1b..a6c1962 100644 --- a/TinfoilVibeServer/Services/SnapshotService.cs +++ b/TinfoilVibeServer/Services/SnapshotService.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text.Json; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using TinfoilVibeServer.Models; using TinfoilVibeServer.Utilities; @@ -11,55 +12,81 @@ public interface ISnapshotService event EventHandler SnapshotRebuilt; // raised after a rebuild void RebuildSnapshot(); SnapshotService.ROMSnapshot GetSnapshot(); - void BuildSnapshot(); + + Task AddToSnapshotAsync(FileEntry entry); + Task BuildSnapshotAsync(); + void GetArchiveName(string titleId); + char GetArchivePathSeparator(); } /// /// 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, ISnapshotService +public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedService { + #region FileSystemWatcher + private readonly List _watchers = new(); + #endregion + private readonly SnapshotOptions _options; private readonly INSPExtractor _nspExtractor; private readonly IArchiveHandler _archiveHandler; private readonly ILogger _logger; private readonly string _jsonPath; private readonly string _snapshotPath; - private readonly List _watchers = new(); - private readonly ConcurrentDictionary _cache = new(); - private string? _currentSnapshotHash; - private readonly Timer _debounceTimer; + private readonly ConcurrentDictionary _cache = new(); + private readonly ConcurrentDictionary _hashCache = new(); + // Archive full path -> FileEntry.Path + private readonly ConcurrentDictionary _archiveLookup = new(); + // hash -> file size + private readonly ConcurrentDictionary _sizeLookup = new(); + private readonly IMemoryCache _debouncerCache; public event EventHandler? SnapshotRebuilt; - + public event EventHandler? SnapshotRebuilding; + + private readonly SemaphoreSlim _snapshotFileSemaphore = new(1,1); + private const char ArchivePathSeparator = '|'; + public char GetArchivePathSeparator() => ArchivePathSeparator; + public SnapshotService( + IMemoryCache debouncerCache, IOptionsMonitor options, INSPExtractor nspExtractor, IArchiveHandler archiveHandler, ILogger logger) { _options = options.CurrentValue; + _debouncerCache = debouncerCache; _nspExtractor = nspExtractor; _archiveHandler = archiveHandler; _logger = logger; _jsonPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotFile); - FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(_jsonPath)); + + // Debounce timer for persisting snapshot + long debounceTime = 200; + var entryOptions = new MemoryCacheEntryOptions() + .SetSlidingExpiration(TimeSpan.FromSeconds(debounceTime)).RegisterPostEvictionCallback((key, value, reason, + state) => + { + + _logger.LogInformation("Should persist the snapshot {Key}, {Reason}", key, reason); + }); // <‑‑ sliding! + FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_jsonPath))); if (!File.Exists(_jsonPath)) { + _snapshotFileSemaphore.Wait(); File.WriteAllText(_jsonPath, "[]"); + _snapshotFileSemaphore.Release(); } _snapshotPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotBackupFile); - FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(_snapshotPath)); + FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_snapshotPath))); // 1️⃣ Register for *property* changes _options.PropertyChanged += (s, e) => OnOptionsChanged(e.PropertyName); - BuildSnapshot(); // initial scan - File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(GetSnapshot())); - _debounceTimer = new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite); - foreach (var path in _options.RootDirectories) { - InitializeFileSystemWatcher(path); + AddWatchDirectory(path); } } // --------- Private helpers --------- @@ -81,15 +108,18 @@ public sealed class SnapshotService : IDisposable, ISnapshotService foreach (var newWatchedDirectory in newWatchedDirectories) { - InitializeFileSystemWatcher(newWatchedDirectory); + AddWatchDirectory(newWatchedDirectory); } - BuildSnapshot(); // rebuild everything - PersistSnapshot(); + BuildSnapshotAsync(); // rebuild everything + PersistSnapshotAsync(); } } - private void InitializeFileSystemWatcher(string path) + + + #region FileSystemWatcher + private void AddWatchDirectory(string path) { if (!Directory.Exists(path)) return; var watcher = new FileSystemWatcher @@ -104,32 +134,84 @@ public sealed class SnapshotService : IDisposable, ISnapshotService watcher.Deleted += OnChanged; watcher.Renamed += OnRenamed; watcher.EnableRaisingEvents = true; - + _logger.LogInformation("Watching {Path}", path); _watchers.Add(watcher); } - #region FileSystemWatcher + private void RemoveWatchDirectory(string path) + { + var fileSystemWatchers = _watchers.FirstOrDefault(watcher => watcher.Path == path); + if (fileSystemWatchers == null) return; + fileSystemWatchers.EnableRaisingEvents = false; + fileSystemWatchers.Dispose(); + _logger.LogInformation("Stopped watching {Path}", path); + _watchers.Remove(fileSystemWatchers); + } private void OnChanged(object? _, FileSystemEventArgs e) => ThrottleSnapshotUpdate(e); private void OnRenamed(object? _, RenamedEventArgs e) => ThrottleSnapshotUpdate(e); private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs) { - lock (_lock) - { - _debounceTimer.Change(_debounceMs, Timeout.Infinite); // reset the timer - _logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss")); - } - /*Task.Run(async () => - { - await Task.Delay(250); - _logger.LogDebug("File system event {EventType} on {Path}", fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath); - UpdateSnapshot(); - });*/ + SnapshotRebuilding?.Invoke(this, fileSystemEventArgs); + using var cacheEntry = _debouncerCache.CreateEntry(fileSystemEventArgs.FullPath) + //.SetAbsoluteExpiration(TimeSpan.FromMilliseconds(DebounceMs)) + .SetValue(fileSystemEventArgs) + .SetOptions(new MemoryCacheEntryOptions + { + PostEvictionCallbacks = + { + new PostEvictionCallbackRegistration + { + EvictionCallback = + (key, value, reason, state) => + { + if (reason != EvictionReason.Expired) return; + + if (value is FileSystemEventArgs args) + { + if (IsFileLocked(args.FullPath)) + { + _logger.LogInformation("File {FilePath} is locked, skipping snapshot update", args.FullPath); + using var rebounce = _debouncerCache.CreateEntry(args.FullPath) + .SetAbsoluteExpiration(TimeSpan.FromMilliseconds(DebounceMs)) + .SetValue(args); + } + } + + RebuildSnapshot(); + } + } + } + }); + cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(DebounceMs); + + _logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType, + fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss")); } - - private readonly object _lock = new object(); - private int _debounceMs = 200; + private static bool IsFileLocked(string filePath) + { + FileStream? stream = null; + var file = new FileInfo(filePath); + + try + { + stream = file.Open(FileMode.Open, FileAccess.ReadWrite, FileShare.None); + } + catch (IOException) + { + return true; + } + finally + { + stream?.Close(); + } + return false; + } + + private const int DebounceMs = 400; + private readonly JsonSerializerOptions _jsonSerializerOptions = new() { IncludeFields = true }; + private int SnapshotFileLockTimeout { get; } = 1000; private void DebounceElapsed() { @@ -140,15 +222,66 @@ public sealed class SnapshotService : IDisposable, ISnapshotService #region Snapshot logic - public void BuildSnapshot() + public Task AddToSnapshotAsync(FileEntry entry) { + // Update lookup tables + _cache[entry.Path] = new SnapshotEntry(entry.Path, entry.Hash, entry.Size, entry.Titles); + _hashCache[entry.Hash] = entry.Path; + _sizeLookup[entry.Hash] = entry.Size; + if (entry.Path.Contains(ArchivePathSeparator)) + { + var filename = entry.Path.Split(ArchivePathSeparator)[0]; + _archiveLookup[filename] = entry.Path; + } + + foreach (var ncaMetadataWithHash in entry.Titles) + { + _hashCache[ncaMetadataWithHash.Hash] = entry.Path; + _sizeLookup[ncaMetadataWithHash.Hash] = entry.Size; + _logger.LogInformation("Added entry {titleId} to snapshot (hash={hash})", ncaMetadataWithHash.TitleId, ncaMetadataWithHash.Hash); + } + // Persist snapshot to disk + PersistSnapshotAsync(); + return Task.CompletedTask; + } + + /// Builds _cache and _hashCache based on directory configuration + public Task BuildSnapshotAsync() + { + _logger.LogInformation("Building snapshot"); var index = LoadSnapshotIndex(); var latestModifiedUtcParallel = FileSystemExtensions.GetLatestModifiedUtcParallel(_options.RootDirectories); var fileInfo = new FileInfo(_snapshotPath); + bool snapshotVerified = true; if (latestModifiedUtcParallel.HasValue && latestModifiedUtcParallel.Value < fileInfo.LastWriteTimeUtc) { - _logger.LogInformation("Snapshot is up to date"); - return; + if (index.Count != 0) + { + // directory may have been added with older roms, verify that the snapshot is still up to date + foreach (var dir in _options.RootDirectories) + { + // check first entry is in index + var entry = BuildSnapshot(dir).FirstOrDefault(); + if (entry != null) + { + if (!index.TryGetValue(entry.Path, out var cached)) + { + snapshotVerified = false; + _logger.LogInformation("Snapshot does not contain first entry in directory {Directory}", dir); + } + } + } + + if (snapshotVerified) + { + _logger.LogInformation("Snapshot is up to date"); + return Task.CompletedTask; + } + } + else + { + _logger.LogInformation("Snapshot is up to date but index is empty"); + } } _logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", _options.RootDirectories.Count); var entries = new List(); @@ -156,65 +289,122 @@ public sealed class SnapshotService : IDisposable, ISnapshotService var snapshotChanged = false; foreach (var dir in _options.RootDirectories) { - if (!Directory.Exists(dir)) continue; - foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories)) + _ = Task.Run(() => { - var ext = Path.GetExtension(file).ToLowerInvariant(); - - if (!(_options.WhitelistExtensions.Contains(ext) || _options.RomExtensions.Contains(ext))) - continue; - - if (index.TryGetValue(file, out var value)) - { - entries.Add(value); - continue; - } - - // 3) extract title if applicable - string hash; - NcaMetadataWithHash? title = null; - if (_options.RomExtensions.Contains(ext)) - { - 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); - } - - if (title == null) - { - _logger.LogInformation("Failed to process {File}", 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)); - _logger.LogInformation("Added {File} to snapshot (hash={Hash}", file, hash); - snapshotChanged = true; - } + _logger.LogInformation("Rebuilding directory {Directory}", dir); + var buildSnapshot = BuildSnapshot(dir); + var fileEntries = buildSnapshot.ToList(); + snapshotChanged = snapshotChanged || fileEntries.Count != 0; + entries.AddRange(fileEntries.Where(entry => entry != null)!); + }); } // Replace the entire snapshot - _currentSnapshotHash = ComputeSnapshotHash(entries); - File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries)); + ComputeSnapshotHash(entries); if (snapshotChanged) { _logger.LogInformation("Snapshot rebuilt"); SnapshotRebuilt?.Invoke(this, EventArgs.Empty); } + return Task.CompletedTask; + } + + public void GetArchiveName(string titleId) + { + ; + } + + // Returns List of FileEntry that do not have a hash in the cache + // Each entry that has not been added to the lookup table is added to the cache + private IEnumerable BuildSnapshot(string dir) + { + FileEntry entry; + if (!Directory.Exists(dir)) yield break; + foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories)) + { + string hash = string.Empty; + var ext = Path.GetExtension(file).ToLowerInvariant(); + + if (!(_options.ArchiveExtensions.Contains(ext) || _options.RomExtensions.Contains(ext))) + continue; + + if (_cache.ContainsKey(file) || _hashCache.ContainsKey(hash)) + { + continue; + } + + // 3) extract title if applicable + var titles = new List<(string, long, NcaMetadataWithHash)>(); + if (_options.RomExtensions.Contains(ext)) + { + using var nspStream = File.OpenRead(file); + hash = ComputeFirstStreamHash(nspStream); + + if (_hashCache.ContainsKey(hash)) + { + continue; + } + + var nspStreamLength = nspStream.Length; + var title = _nspExtractor.ExtractFromStream(nspStream); + if (title != null) + { + var archiveEntry = new FileEntry(file, nspStreamLength, hash, [title]); + AddToSnapshotAsync(archiveEntry); + titles.Add((title.TitleId, nspStreamLength, title)); + yield return archiveEntry; + } + } + else + { + if (_options.ArchiveExtensions.Contains(ext)) + { + if (_archiveLookup.ContainsKey(file)) continue; + hash = ComputeFirstStreamHash(file); + if (_hashCache.ContainsKey(hash)) + { + yield return null; + } + + IEnumerable<(string, long, NcaMetadataWithHash)>? titlesEnumerable = null; + try + { + titlesEnumerable = _archiveHandler.TryExtractTitleInfos(file); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to extract title info from archive {Archive}", file); + } + + if (titlesEnumerable == null) continue; + + titles = titlesEnumerable.ToList(); + foreach (var title in titles) + { + var archiveEntry = new FileEntry(file + ArchivePathSeparator + title.Item1, title.Item2, title.Item3.Hash, [title.Item3]); + AddToSnapshotAsync(archiveEntry); + yield return archiveEntry; + } + /*var fileEntry = new FileEntry(file, new FileInfo(file).Length, hash, titles.Select((tuple, i) => tuple.Item3).ToList()); + AddToSnapshotAsync(fileEntry); + yield return fileEntry;*/ + } + else + { + continue; + } + } + + if (titles.Count == 0) + { + _logger.LogInformation("Failed to process {File}", file); + } + else + { + _logger.LogInformation("Added {File} to snapshot (hash={Hash})", file, hash); + yield return new FileEntry(file, titles.Select((tuple, i) => tuple.Item2).FirstOrDefault(), hash, titles.Select((tuple, i) => tuple.Item3).ToList()); + } + } } private string ComputeFirstStreamHash(Stream nspStream) @@ -222,20 +412,65 @@ public sealed class SnapshotService : IDisposable, ISnapshotService return _nspExtractor.ExtractHashFromStream(nspStream); } - private void UpdateSnapshot() => BuildSnapshot(); + private void UpdateSnapshot() => BuildSnapshotAsync(); - private void PersistSnapshot() + IEnumerable GetEntries() { - var snapshot = GetSnapshot(); - var newHash = ComputeSnapshotHash(snapshot.Files); - if (_currentSnapshotHash != newHash) + foreach (var snapshotEntry in _cache) { - _logger.LogInformation("Snapshot hash changed – persisting new snapshot"); - _currentSnapshotHash = newHash; - File.WriteAllText(_jsonPath, JsonSerializer.Serialize(snapshot.Files)); - File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(snapshot.Files)); + _sizeLookup.TryGetValue(snapshotEntry.Value.Hash, out var size); + var fileEntry = new FileEntry(snapshotEntry.Key, snapshotEntry.Value.Size, snapshotEntry.Value.Hash, snapshotEntry.Value.NcaMetadataWithHash); + yield return fileEntry; } } + private Task PersistSnapshotAsync() + { + if (_debouncerCache.TryGetValue(_jsonPath, out var value)) + { + _logger.LogInformation("Sliding debounce in progress, skipping snapshot persistence"); + return Task.CompletedTask; + } + var snapshot = GetSnapshot(); + var entries = GetEntries(); + var fileEntries = entries.ToList(); + var newHash = ComputeSnapshotHash(fileEntries); + if (snapshot.Hash == newHash) return Task.CompletedTask; + + _logger.LogInformation("Snapshot hash changed – persisting new snapshot"); + using var debouncedPersistence = _debouncerCache.CreateEntry(_jsonPath); + debouncedPersistence.SlidingExpiration = TimeSpan.FromMilliseconds(DebounceMs); + debouncedPersistence.Value = fileEntries; + debouncedPersistence.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration + { + EvictionCallback = (key, entriesCallback, reason, state) => + { + if (entriesCallback is IEnumerable entriesToPersist && key is string filePath) + { + if (_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout)) + { + if (IsFileLocked(filePath)) + { + _logger.LogInformation("File {FilePath} is locked, skipping snapshot persistence", filePath); + } + else + { + File.WriteAllText(filePath, + JsonSerializer.Serialize(entriesToPersist, _jsonSerializerOptions)); + _snapshotFileSemaphore.Release(); + _logger.LogInformation("Persisted snapshot"); + SnapshotRebuilt?.Invoke(this, EventArgs.Empty); + } + } + else + { + _logger.LogInformation("Failed to persist file {FilePath} due to timeout", filePath); + } + } + } + }); + return Task.CompletedTask; + } + private static string ComputeHash(string filePath) { @@ -252,38 +487,103 @@ public sealed class SnapshotService : IDisposable, ISnapshotService var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json)); return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); } + /// + /// From filesystem cache, load each entry and build the lookups + /// + /// private Dictionary LoadSnapshotIndex() { - if (!File.Exists(_jsonPath)) return new(); - + if (!File.Exists(_jsonPath)) return new Dictionary(); + _snapshotFileSemaphore.Wait(); var json = File.ReadAllText(_jsonPath); - var entries = JsonSerializer.Deserialize>(json, new JsonSerializerOptions(){IncludeFields = true})!; - return entries.ToDictionary(e => e.Path, e => e); + _snapshotFileSemaphore.Release(); + var entries = JsonSerializer.Deserialize>(json, _jsonSerializerOptions)!; + try + { + var fileEntries = new Dictionary(); + // Reindex the cache + foreach (var fileEntry in entries) + { + if (_hashCache.TryGetValue(fileEntry.Hash, out var value)) + { + _logger.LogWarning("Duplicate hash found in snapshot: {Hash}, {OldPath}, {newPath}", fileEntry.Hash, value, fileEntry.Path); + } + + if (_options.RomExtensions.Contains(Path.GetExtension(fileEntry.Path))) + { + if (fileEntry.Path.Contains(ArchivePathSeparator)) + { + var filename = fileEntry.Path.Split(ArchivePathSeparator)[0]; + _cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileEntry.Titles); + _archiveLookup[filename] = fileEntry.Path; + } + else + { + _cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileEntry.Titles); + fileEntries.TryAdd(fileEntry.Path, fileEntry); + _hashCache[fileEntry.Hash] = fileEntry.Path; + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (fileEntry.Titles == null) continue; + foreach (var ncaMetadataWithHash in fileEntry.Titles) + { + _hashCache[ncaMetadataWithHash.Hash] = fileEntry.Path; + } + } + } + } + return fileEntries; + } + catch (ArgumentException e) + { + _logger.LogError(e, "Failed to load snapshot"); + return new(); + } + } + + + public void RebuildSnapshot() + { + // Build a fresh snapshot and persist it. + BuildSnapshotAsync(); // private method inside the same class + PersistSnapshotAsync(); // private method inside the same class + SnapshotRebuilt?.Invoke(this, EventArgs.Empty); } #endregion public ROMSnapshot GetSnapshot() { - if (!File.Exists(_jsonPath)) return new(); - var json = File.ReadAllText(_jsonPath); - var hash = ComputeHash(_jsonPath); - var romSnapshot = new ROMSnapshot() + if (!File.Exists(_jsonPath)) return new ROMSnapshot(); + + if (_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout)) { - Hash = hash, - Files = JsonSerializer.Deserialize>(json, - new JsonSerializerOptions() { IncludeFields = true })! - }; - return romSnapshot; - } + try + { + var json = File.ReadAllText(_jsonPath); + var hash = ComputeHash(_jsonPath); + var romSnapshot = new ROMSnapshot + { + Hash = hash, + Files = JsonSerializer.Deserialize>(json, _jsonSerializerOptions)! + }; + return romSnapshot; + } + catch (Exception e) + { + _logger.LogError(e, "Failed to load snapshot"); + } + finally + { + _snapshotFileSemaphore.Release(); + } + } + else + { + _logger.LogWarning("Failed to load snapshot due to timeout"); + } - public void RebuildSnapshot() - { - // Build a fresh snapshot and persist it. - BuildSnapshot(); // private method inside the same class - PersistSnapshot(); // private method inside the same class - SnapshotRebuilt?.Invoke(this, EventArgs.Empty); + return new ROMSnapshot(); } - + public void Dispose() { foreach (var watcher in _watchers) @@ -292,7 +592,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService } } - private sealed record CachedFile(string Path, string Hash, NcaMetadataWithHash? NcaMetadataWithHash); + private sealed record SnapshotEntry(string Path, string Hash, long Size, List NcaMetadataWithHash); // File: TinfoilVibeServer/Services/SnapshotService.cs (inside SnapshotService class) @@ -342,4 +642,20 @@ public sealed class SnapshotService : IDisposable, ISnapshotService public string Hash { get; set; } public IReadOnlyList Files { get; set; } = new List(); } + + public async Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting snapshot service"); + _ = Task.Run(async () => + { + await BuildSnapshotAsync(); + await PersistSnapshotAsync(); + }, cancellationToken); // initial scan + new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + Dispose(); + } } \ No newline at end of file diff --git a/TinfoilVibeServer/Services/TitleDatabaseService.cs b/TinfoilVibeServer/Services/TitleDatabaseService.cs index 6194130..d29340a 100644 --- a/TinfoilVibeServer/Services/TitleDatabaseService.cs +++ b/TinfoilVibeServer/Services/TitleDatabaseService.cs @@ -32,18 +32,9 @@ public sealed class TitleDatabaseService : IHostedService private readonly ISnapshotService _snapshotService; private readonly Dictionary _titleIdToPath = new Dictionary(); - // 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( + private static readonly Regex TitleIdRegex = new( @"([0-9a-fA-F]{8})([0-9a-fA-F]{8})", RegexOptions.Compiled); #endregion @@ -80,7 +71,15 @@ public sealed class TitleDatabaseService : IHostedService Path.Combine(AppContext.BaseDirectory, "Games") }; // Reload cache immediately when a snapshot rebuild occurs - _snapshotService.SnapshotRebuilt += (_, _) => ReloadCacheAsync(); + _snapshotService.SnapshotRebuilt += SnapshotServiceOnSnapshotRebuilt; + _options.OnChange(OptionsChanged); + } + + private void OptionsChanged(TitleDbOptions arg1, string? arg2) + { + // todo: handle ttl changes + // todo: handle country code change + // todo: handle language change } #endregion @@ -119,9 +118,9 @@ public sealed class TitleDatabaseService : IHostedService var dict = await LoadFromDiskAsync() ?? new Dictionary(); // Set the new entry – the sliding expiration will now - // automatically move 30 s (or whatever you configured) forward + // 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); + _cache.Set(CacheKey, dict, entryOptions); _logger.LogInformation("Title DB reloaded – {Count} items cached (TTL={TTL}s).", dict.Count, ttlSec); } @@ -158,19 +157,6 @@ public sealed class TitleDatabaseService : IHostedService 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 /* ---------------------------------------------------------------- */ @@ -184,7 +170,19 @@ public sealed class TitleDatabaseService : IHostedService /* ---------------------------------------------------------------- */ /* 4️⃣ Dispose /* ---------------------------------------------------------------- */ - public void Dispose() => _snapshotService.SnapshotRebuilt -= (_, _) => ReloadCacheAsync(); + public void Dispose() => _snapshotService.SnapshotRebuilt -= SnapshotServiceOnSnapshotRebuilt; + + private async void SnapshotServiceOnSnapshotRebuilt(object? o, EventArgs eventArgs) + { + try + { + await ReloadCacheAsync(); + } + catch (Exception e) + { + _logger.LogCritical(e, "Failed to reload title database cache"); + } + } #region Public API @@ -240,6 +238,7 @@ public sealed class TitleDatabaseService : IHostedService /// /// Read the JSON file and populate _titleData. + /// Also remap titleId for XCI files based on cnmts.json /// private async Task> ReadTitleDbAsync(string filePath, CancellationToken ct) { diff --git a/TinfoilVibeServer/TinfoilVibeServer.csproj b/TinfoilVibeServer/TinfoilVibeServer.csproj index 5740f29..cb50206 100644 --- a/TinfoilVibeServer/TinfoilVibeServer.csproj +++ b/TinfoilVibeServer/TinfoilVibeServer.csproj @@ -10,8 +10,10 @@ + + diff --git a/TinfoilVibeServer/Utilities/SeekableBufferedStream.cs b/TinfoilVibeServer/Utilities/SeekableBufferedStream.cs new file mode 100644 index 0000000..e942ca8 --- /dev/null +++ b/TinfoilVibeServer/Utilities/SeekableBufferedStream.cs @@ -0,0 +1,251 @@ +using System.Buffers; + +namespace TinfoilVibeServer.Utilities; + +/// +/// A read‑only, seekable wrapper around a non‑seekable stream. +/// It buffers the source data on demand in chunks so that you can seek +/// back and forth without reading the whole source at once. +/// +public sealed class SeekableBufferedStream : Stream +{ + private const int DefaultChunkSize = 128 * 1024 * 1024; // 128 MiB + + private readonly Stream _source; + private readonly ArrayPool _pool; + private readonly int _chunkSize; + private readonly bool _disposeSource; + + // Buffer block – holds a rented byte[] and the number of bytes actually read. + private readonly struct BufferBlock + { + public readonly byte[] Data; + public readonly int Length; + public BufferBlock(byte[] data, int length) { Data = data; Length = length; } + } + + private readonly List _blocks = new(); + private readonly long _specifiedLength = 0; + private long _bufferedLength; // total number of bytes buffered so far + private long _position; // current logical position in the stream + private bool _eof; // true when the source stream has been exhausted + + #region ctor / dispose + + /// + /// Creates a new instance. + /// + /// The underlying source stream. Must be readable. + /// Length of underlying stream if known before using + /// Size of each buffer chunk (bytes). 128 MiB by default. + /// If true, disposing this wrapper will also dispose the source stream. + public SeekableBufferedStream(Stream source, long specifiedLength = 0, int chunkSize = DefaultChunkSize, bool disposeSource = false) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + if (!source.CanRead) throw new ArgumentException("Source stream must be readable.", nameof(source)); + if (chunkSize <= 0) throw new ArgumentOutOfRangeException(nameof(chunkSize), "Chunk size must be positive."); + if (specifiedLength <= 0) throw new ArgumentOutOfRangeException(nameof(specifiedLength), "Specified length must be positive."); + + _source = source; + _specifiedLength = specifiedLength; + _pool = ArrayPool.Shared; + _chunkSize = chunkSize; + _disposeSource = disposeSource; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + foreach (var block in _blocks) + _pool.Return(block.Data, clearArray: true); + _blocks.Clear(); + + if (_disposeSource) + _source.Dispose(); + } + base.Dispose(disposing); + } + + #endregion + + #region helpers + + /// + /// Ensures that at least bytes are buffered. + /// Reads from the source stream until the requested offset is reached or EOF is hit. + /// + private void EnsureBuffered(long requiredOffset) + { + if (_eof || _bufferedLength >= requiredOffset) + return; + + while (_bufferedLength < requiredOffset && !_eof) + { + var buf = _pool.Rent(_chunkSize); + int read = _source.Read(buf, 0, _chunkSize); + if (read == 0) + { + _eof = true; + _pool.Return(buf, clearArray: true); + break; + } + + _blocks.Add(new BufferBlock(buf, read)); + _bufferedLength += read; + } + } + + /// + /// Finds the block that contains and the offset inside that block. + /// + private void GetBlockAndOffset(long pos, out int blockIndex, out int offsetInBlock) + { + long accumulated = 0; + for (int i = 0; i < _blocks.Count; i++) + { + int blockLen = _blocks[i].Length; + if (pos < accumulated + blockLen) + { + blockIndex = i; + offsetInBlock = (int)(pos - accumulated); + return; + } + accumulated += blockLen; + } + + // This should never happen because we always call EnsureBuffered before accessing. + throw new InvalidOperationException("Requested position is outside buffered range."); + } + + #endregion + + #region Stream overrides + + public override bool CanRead => true; + public override bool CanSeek => true; + public override bool CanWrite => false; + public override long Length + { + get + { + // If we were given a length, we can return that. + if (_specifiedLength > 0) return _specifiedLength; + + // If we already hit EOF, we know the length. + if (_eof) return _bufferedLength; + + // If the underlying stream is seekable, we can ask it directly. + if (_source.CanSeek) + return _source.Length; + + // Otherwise we need to drain the source to discover its length. + while (!_eof) + EnsureBuffered(_bufferedLength + _chunkSize); + return _bufferedLength; + } + } + + public override long Position + { + get => _position; + set + { + if (value < 0) throw new ArgumentOutOfRangeException(nameof(value)); + if (value > Length) throw new ArgumentOutOfRangeException(nameof(value)); + _position = value; + } + } + + public override int Read(byte[] buffer, int offset, int count) + { + if (buffer == null) throw new ArgumentNullException(nameof(buffer)); + if (offset < 0 || count < 0 || offset + count > buffer.Length) + throw new ArgumentOutOfRangeException(); + + // If we are already at or beyond the logical end, nothing to read. + if (_position >= Length) + return 0; + + // We will read at most `count` bytes but not past the logical end. + long maxRead = Math.Min(count, Length - _position); + EnsureBuffered(_position + maxRead); + + int bytesRead = 0; + while (bytesRead < maxRead) + { + GetBlockAndOffset(_position, out int blockIdx, out int blockOffset); + var block = _blocks[blockIdx]; + int available = block.Length - blockOffset; + int toCopy = (int)Math.Min(available, maxRead - bytesRead); + + Buffer.BlockCopy(block.Data, blockOffset, buffer, offset + bytesRead, toCopy); + + _position += toCopy; + bytesRead += toCopy; + } + + return bytesRead; + } + + public override long Seek(long offset, SeekOrigin origin) + { + long newPos = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => _position + offset, + SeekOrigin.End => Length + offset, + _ => throw new ArgumentException("Invalid SeekOrigin", nameof(origin)) + }; + + if (newPos < 0) throw new IOException("Attempted to seek before the beginning of the stream."); + + // Make sure we have buffered data up to the new position. + EnsureBuffered(newPos); + _position = newPos; + return _position; + } + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Flush() { /* No-op – read‑only stream */ } + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + public override void WriteByte(byte value) => throw new NotSupportedException(); + + #endregion + + #region async helpers (optional) + + public override async ValueTask ReadAsync(Memory destination, CancellationToken cancellationToken = default) + { + // If we are already at or beyond the logical end, nothing to read. + if (_position >= Length) + return 0; + + long maxRead = Math.Min(destination.Length, Length - _position); + EnsureBuffered(_position + maxRead); + + int bytesRead = 0; + while (bytesRead < maxRead) + { + GetBlockAndOffset(_position, out int blockIdx, out int blockOffset); + var block = _blocks[blockIdx]; + int available = block.Length - blockOffset; + int toCopy = (int)Math.Min(available, maxRead - bytesRead); + + // We copy synchronously – no async source involved + destination.Slice(bytesRead, toCopy).Span + .CopyTo(block.Data.AsSpan(blockOffset, toCopy)); + + _position += toCopy; + bytesRead += toCopy; + } + + return bytesRead; + } + + #endregion +} \ No newline at end of file diff --git a/TinfoilVibeServer/appsettings.json b/TinfoilVibeServer/appsettings.json index da1fc0d..7e1e8ea 100644 --- a/TinfoilVibeServer/appsettings.json +++ b/TinfoilVibeServer/appsettings.json @@ -13,8 +13,8 @@ "BlacklistFile": "blacklist.json", "MaxFailedAttempts": 5, "Snapshot" : { - "RootDirectories": [ "\\\\NAS\\Roms\\Switch", "Z:\\imgs\\roms\\Switch" ], - "WhitelistExtensions": [ ".bin", ".jpg", ".png", ".txt" ], + "RootDirectories": [ "Z:\\downloads\\roms\\switch", "Z:\\imgs\\roms\\Switch" ], + "ArchiveExtensions": [ ".zip", ".rar", ".7z" ], "RomExtensions": [ ".xci", ".nsp", ".xcz" ], "CacheTtl": 60, "SnapshotFile": "index.tfl", @@ -23,7 +23,7 @@ "TitleDb": { "CountryCode": "AU", "Language": "en", - "TtlSeconds" : 30, + "TtlSeconds" : 90, "SnapshotFile" : "snapshot.json" }, diff --git a/TinfoilVibeServerTest/Tests/SnapshotServiceTests.cs b/TinfoilVibeServerTest/Tests/SnapshotServiceTests.cs index 913bbc6..1ff2273 100644 --- a/TinfoilVibeServerTest/Tests/SnapshotServiceTests.cs +++ b/TinfoilVibeServerTest/Tests/SnapshotServiceTests.cs @@ -2,6 +2,7 @@ using System.IO; using System.Threading.Tasks; using LibHac.Ncm; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; @@ -21,6 +22,7 @@ namespace TinfoilVibeServerTest.Tests private Mock _archiveHander; private Mock> _mockOptions; private SnapshotOptions _options; + private MemoryCache _memoryCache; [SetUp] public void SetUp() @@ -42,69 +44,92 @@ namespace TinfoilVibeServerTest.Tests _loggerMock = new Mock>(); _archiveHander = new Mock(); _nspExtractorMock = new Mock(); + var memoryCacheOptions = Options.Create(new MemoryCacheOptions()); + _memoryCache = new MemoryCache(memoryCacheOptions); + + _nspExtractorMock.Setup(extractor => extractor.ExtractHashFromStream(It.IsAny())).Returns("HASH"); _nspExtractorMock.Setup(extractor => extractor.ExtractFromStream(It.IsAny())).Returns( new NcaMetadataWithHash(titleId: "0000000000000000","0000000000000000", version: 1, ContentMetaType.Application, "HASH")); //Settings.RootDirs = new List { "TestData/Root1", "TestData/Root2" }; - _service = new SnapshotService(_mockOptions.Object, _nspExtractorMock.Object, _archiveHander.Object, _loggerMock.Object); + _service = new SnapshotService(_memoryCache, _mockOptions.Object, _nspExtractorMock.Object, _archiveHander.Object, _loggerMock.Object); } [TearDown] public void TearDown() { _service.Dispose(); + _memoryCache.Dispose(); } [Test] public async Task BuildSnapshot_WhenFilesChanged_ShouldPersist() { // Arrange - await File.WriteAllTextAsync(_options.SnapshotFile, "[]"); var initialHash = _service.GetSnapshot()?.Hash; - // Add a file to Root1 - var newFile = Path.Combine(_options.RootDirectories.First(), "new.nsp"); - FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(newFile)); - // Create a new valid NSP file - // copy to temp to touch modified date - foreach (var file in Directory.GetFiles("../../../Data/")) + var rebuilding = false; + var rebuilt = false; + CancellationTokenSource snapshotRebuilding = new(); + _service.SnapshotRebuilding += (sender, args) => { - var filename = Path.GetFileName(file); - var destFilename = Path.Combine(Path.GetTempPath(), filename); - File.Copy(file, destFilename, true); - var info = new FileInfo(destFilename) - { - LastWriteTimeUtc = DateTime.UtcNow - }; - info.CopyTo(Path.Combine(_options.RootDirectories.First(),filename), true); - } - - // Act + rebuilding = true; + snapshotRebuilding.Cancel(); + }; + CancellationTokenSource snapshotPersisting = new(); _service.SnapshotRebuilt+= (sender, args) => { + rebuilt = true; + snapshotPersisting.Cancel(); // Assert var newHash = _service.GetSnapshot()?.Hash; Assert.That(newHash, Is.Not.EqualTo(initialHash)); - - }; - Task.Delay(300).Wait(); - _loggerMock.Verify( - l => l.Log( - LogLevel.Information, - It.IsAny(), - It.Is((v, t) => v.ToString().Contains("Snapshot rebuilt")), - null, - It.IsAny>()), Times.Once); + Timer timer = new(state => + { + snapshotPersisting.Cancel(); + snapshotRebuilding.Cancel(); + }, null, 20*1000, 0); + await File.WriteAllTextAsync(_options.SnapshotFile, "[]", snapshotPersisting.Token); + // Add a file to Root1 + var newFile = Path.Combine(_options.RootDirectories.First(), "new.nsp"); + + // Act + await File.WriteAllTextAsync(newFile,"TEST"); + + Task.Delay(4000).Wait(); + try + { + while (_memoryCache.Count > 0) + { + Task.Delay(200).Wait(snapshotRebuilding.Token); + } + } + catch (OperationCanceledException) + { + Assert.That(rebuilding, Is.True); + } + + try + { + while (_memoryCache.Count > 0) + { + Task.Delay(200).Wait(snapshotPersisting.Token); + } + } + catch (OperationCanceledException e) + { + Assert.That(rebuilt, Is.True); + } } [Test] public async Task BuildSnapshot_NoChange_ShouldNotPersist() { // Act - _service.BuildSnapshot(); + _service.BuildSnapshotAsync(); // Act again – snapshot should be identical - _service.BuildSnapshot(); + _service.BuildSnapshotAsync(); // Assert _loggerMock.Verify(