diff --git a/TinfoilVibeServer.sln.DotSettings.user b/TinfoilVibeServer.sln.DotSettings.user
index 36c18d7..31bbd05 100644
--- a/TinfoilVibeServer.sln.DotSettings.user
+++ b/TinfoilVibeServer.sln.DotSettings.user
@@ -3,75 +3,121 @@
True
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
+ 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
@@ -83,16 +129,27 @@
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" />
diff --git a/TinfoilVibeServer/Authentication/AuthStore.cs b/TinfoilVibeServer/Authentication/AuthStore.cs
index a2f65e6..74d5b35 100644
--- a/TinfoilVibeServer/Authentication/AuthStore.cs
+++ b/TinfoilVibeServer/Authentication/AuthStore.cs
@@ -1,7 +1,15 @@
-using System.Collections.Concurrent;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
using TinfoilVibeServer.Services;
using TinfoilVibeServer.Utilities;
diff --git a/TinfoilVibeServer/Models/FileEntry.cs b/TinfoilVibeServer/Models/FileEntry.cs
index 1c22ec2..5cebce4 100644
--- a/TinfoilVibeServer/Models/FileEntry.cs
+++ b/TinfoilVibeServer/Models/FileEntry.cs
@@ -1,4 +1,5 @@
-using TinfoilVibeServer.Services;
+using System.Collections.Generic;
+using TinfoilVibeServer.Services;
namespace TinfoilVibeServer.Models;
diff --git a/TinfoilVibeServer/Models/SnapshotOptions.cs b/TinfoilVibeServer/Models/SnapshotOptions.cs
index ccd56c9..d93da6a 100644
--- a/TinfoilVibeServer/Models/SnapshotOptions.cs
+++ b/TinfoilVibeServer/Models/SnapshotOptions.cs
@@ -1,5 +1,8 @@
-using System.ComponentModel;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
+using System.Linq;
namespace TinfoilVibeServer.Models;
diff --git a/TinfoilVibeServer/Program.cs b/TinfoilVibeServer/Program.cs
index 95ae4ce..2dc68cc 100644
--- a/TinfoilVibeServer/Program.cs
+++ b/TinfoilVibeServer/Program.cs
@@ -1,5 +1,13 @@
+using System;
+using System.IO;
+using System.Linq;
using System.Text.Json;
+using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TinfoilVibeServer.Authentication;
using TinfoilVibeServer.Middleware;
@@ -42,7 +50,7 @@ if (!File.Exists(configPath))
var config = new ConfigurationBuilder()
.AddJsonFile(Path.Combine(dataRoot,"appsettings.json"), optional: false, reloadOnChange: true)
.AddJsonFile(Path.Combine(dataRoot,$"appsettings.{builder.Environment.EnvironmentName}.json"), optional: true, reloadOnChange: true)
- .AddJsonFile(Path.Combine(dataRoot,"appsettings.local.json"), optional: true, reloadOnChange: true)
+ .AddJsonFile(Path.Combine(dataRoot,$"appsettings.{builder.Environment.EnvironmentName}.local.json"), optional: true, reloadOnChange: true)
.AddEnvironmentVariables()
.AddCommandLine(args).Build();
builder.Configuration.AddConfiguration(config);
diff --git a/TinfoilVibeServer/Services/ArchiveHandler.cs b/TinfoilVibeServer/Services/ArchiveHandler.cs
index 2776e33..56940f5 100644
--- a/TinfoilVibeServer/Services/ArchiveHandler.cs
+++ b/TinfoilVibeServer/Services/ArchiveHandler.cs
@@ -1,6 +1,9 @@
-using System.IO.Compression;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.Extensions.Logging;
using SharpCompress.Archives;
-using SharpCompress.Archives.Zip;
using SharpCompress.Archives.Rar;
using SharpCompress.Archives.SevenZip;
using SharpCompress.Common;
@@ -114,33 +117,39 @@ public sealed class ArchiveHandler : IArchiveHandler
var entryCount = 0;
try
{
- // todo: handle and skip multipart archives
- using var archive = RarArchive.Open(path);
- entryCount = archive.Entries.Count;
- foreach (var entry in archive.Entries)
+ using var romArchive = new RomArchiveReader(path);
+ var archiveEntries = romArchive.GetEntries().ToList();
+ //using var archive = RarArchive.Open(path);
+ entryCount = archiveEntries.Count;
+ foreach (var archiveEntry in archiveEntries)
{
- if (entry.IsDirectory || entry.Key == null || !IsRomArchive(entry.Key)) continue;
+ var archiveEntryName = archiveEntry.Name;
+ try
{
- try
+ using var rewindableWrapper = new RewindableStream(archiveEntry.Stream, () =>
{
- using var streamWrapper = new SeekableBufferedStream(entry.OpenEntryStream(), entry.Size, 64 * 1024 * 1024, true);
- var title = _nspExtractor.ExtractFromStream(streamWrapper);
- if (title != null) titles.Add((entry.Key, entry.Size, title));
+ _logger.LogDebug("Rewinding archive entry {ArchiveEntry}", archiveEntryName);
+ return romArchive.GetEntries().First(romArchiveEntry => romArchiveEntry.Name == archiveEntryName).Stream;
+ },10*1024*1024, archiveEntry.Stream.Length);
+ var title = _nspExtractor.ExtractFromStream(rewindableWrapper);
+ if (title != null)
+ {
+ titles.Add((archiveEntry.Name, archiveEntry.Stream.Length, title));
}
- catch (IncompleteArchiveException incompleteArchiveException)
+ }
+ catch (IncompleteArchiveException incompleteArchiveException)
+ {
+ _logger.LogWarning("Incomplete archive {Archive}: {Exception}", path, incompleteArchiveException.Message);
+ }
+ catch (Exception e)
+ {
+ if (e.Message.StartsWith("Failed to extract NSP"))
{
- _logger.LogWarning("Incomplete archive {Archive}: {Exception}", path, incompleteArchiveException.Message);
+ _logger.LogError("Failed to extract title info from archive {Archive}: {Exception}", path, e.Message);
}
- catch (Exception e)
+ else
{
- if (e.Message.StartsWith("Failed to extract NSP"))
- {
- _logger.LogError("Failed to extract title info from archive {Archive}: {Exception}", path, e.Message);
- }
- else
- {
- throw;
- }
+ throw;
}
}
}
diff --git a/TinfoilVibeServer/Services/ConfigManager.cs b/TinfoilVibeServer/Services/ConfigManager.cs
index 4fd7a70..72d6725 100644
--- a/TinfoilVibeServer/Services/ConfigManager.cs
+++ b/TinfoilVibeServer/Services/ConfigManager.cs
@@ -1,4 +1,7 @@
-using System.Text.Json;
+using System;
+using System.IO;
+using System.Text.Json;
+using System.Threading;
using LibHac.Common.Keys;
using TinfoilVibeServer.Models;
diff --git a/TinfoilVibeServer/Services/IndexBuilderService.cs b/TinfoilVibeServer/Services/IndexBuilderService.cs
index 28520c5..1112ca9 100644
--- a/TinfoilVibeServer/Services/IndexBuilderService.cs
+++ b/TinfoilVibeServer/Services/IndexBuilderService.cs
@@ -1,7 +1,16 @@
-using System.Security.Cryptography;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
using System.Text.Json;
using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
using LibHac.Ncm;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TinfoilVibeServer.Models;
@@ -95,7 +104,7 @@ public sealed class IndexBuilderService: IHostedService
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)
+ title.ContentMetaType == ContentMetaType.AddOnContent || (title.ContentMetaType == ContentMetaType.Application && name == "Unknown"))
{
// Patch should use the application title name
name = _titleDb.TryGetTitle(title.ApplicationTitle, out var appTitle)
@@ -107,13 +116,11 @@ public sealed class IndexBuilderService: IHostedService
// if still unknown, its probably a demo, use filename extraction
if (name == "Unknown")
{
- var match = Regex.Match(Path.GetFileNameWithoutExtension(e.Path), "^(.+?)\\s*(?:\\[.*)?$",
- RegexOptions.None);
+ 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);
+ _logger.LogInformation("Name not found for {TitleId}, using filename: {Name}", titleId, name);
}
}
diff --git a/TinfoilVibeServer/Services/NSPExtractor.cs b/TinfoilVibeServer/Services/NSPExtractor.cs
index 9343680..8ecb22d 100644
--- a/TinfoilVibeServer/Services/NSPExtractor.cs
+++ b/TinfoilVibeServer/Services/NSPExtractor.cs
@@ -1,4 +1,9 @@
-using System.Security.Cryptography;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
using LibHac.Common;
using LibHac.Fs;
using LibHac.Fs.Fsa;
@@ -9,8 +14,12 @@ using LibHac.Common.Keys;
using LibHac.Ncm;
using LibHac.Tools.Fs;
using LibHac.Tools.Ncm;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TinfoilVibeServer.Models;
+using TinfoilVibeServer.Utilities;
+using NcaHeader = LibHac.Tools.FsSystem.NcaUtils.NcaHeader;
using Path = System.IO.Path;
namespace TinfoilVibeServer.Services
@@ -95,12 +104,12 @@ namespace TinfoilVibeServer.Services
public NcaMetadataWithHash? ExtractFromStream(Stream stream)
{
if (KeySet == null) 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);
+ using var storage = new StreamStorage(stream, false);
if (IsPfs0FileSystem(stream))
{
return ExtractNSPFromStream(storage);
@@ -109,16 +118,24 @@ namespace TinfoilVibeServer.Services
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
+ using var partition = xci.OpenPartition(XciPartitionType.Secure);
+ // Find the smallest file
+ var hashEntry = partition.EnumerateEntries("*", SearchOptions.Default)
+ .First(e => e.Type == DirectoryEntryType.File && e.Size < 10 * 1024 * 1024);
+ using var hashFileRef = new UniqueRef();
+ partition.OpenFile(ref hashFileRef.Ref, hashEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
+ using var hashFile = hashFileRef.Release();
+ using var sha256 = SHA256.Create();
+ using var hashStream = hashFile.AsStream();
+ var hash = sha256.ComputeHash(hashStream);
+
+ List 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();
@@ -127,21 +144,14 @@ 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
string titleId = nca.Header.TitleId.ToString("X16");
var (contentMetaType, applicationTitleId, titleVersion) = GetMetaData(nca);
-
- _logger.LogInformation("Meta NCA found – TitleId={TitleId} Version={Version}", titleId, titleVersion);
+
+ _logger.LogInformation("Meta NCA found – TitleId={TitleId} ApplicationTitleId={ApplicationTitleId} Version={Version}", titleId, applicationTitleId.ToStrId(), titleVersion);
// XCI can never be a patch?
return new NcaMetadataWithHash(titleId, applicationTitleId.ToString("X16"), titleVersion.Major, ContentMetaType.Application, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant());
}
@@ -154,17 +164,28 @@ namespace TinfoilVibeServer.Services
private NcaMetadataWithHash? ExtractNSPFromStream(StreamStorage storage)
{
if (KeySet == null) return null;
-
+
List ncaEntries;
_logger.LogInformation("Processing as NSP");
- var partition = new PartitionFileSystem();
+ using var partition = new PartitionFileSystem();
partition.Initialize(storage).ThrowIfFailure();
// Find the first *.nca that contains the meta header
+ var hashEntry = partition
+ .EnumerateEntries("*", SearchOptions.Default)
+ .First(e => e.Type == DirectoryEntryType.File && e.Size < 10 * 1024 * 1024);
+ // Hash the *first* NCA stream – the stream we just opened
+ using var hashRef = new UniqueRef();
+ partition.OpenFile(ref hashRef.Ref, hashEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
+ using var hashFile = hashRef.Release();
+ using var sha256 = SHA256.Create();
+ using var hashStream = hashFile.AsStream();
+ var hash = sha256.ComputeHash(hashStream);
+
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();
@@ -173,31 +194,25 @@ 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
-
+ 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");
-
- var (contentMetaType,applicationTitle,titleVersion) = GetMetaData(nca);
- if (contentMetaType != null)
+
+ var (contentMetaType, applicationTitle, titleVersion) = GetMetaData(nca);
+ if (contentMetaType != null)
return new NcaMetadataWithHash(titleId, applicationTitle.ToString("X16"), titleVersion.Minor, contentMetaType.Value, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant());
}
return null;
}
- private static (ContentMetaType?,ulong, TitleVersion) GetMetaData(Nca nca)
+ private static (ContentMetaType?, ulong, TitleVersion) GetMetaData(Nca nca)
{
- if (nca.Header.ContentType != NcaContentType.Meta) return (null,0, new TitleVersion(0, true));
+ 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))
{
@@ -210,12 +225,12 @@ namespace TinfoilVibeServer.Services
var cnmt = new Cnmt(asStream);
var applicationTitle = cnmt.ApplicationTitleId;
- return (cnmt.Type,applicationTitle, cnmt.TitleVersion);
+ return (cnmt.Type, applicationTitle, cnmt.TitleVersion);
}
- return (null,0, new TitleVersion(0, true));
+ return (null, 0, new TitleVersion(0, true));
}
-
+
///
/// Quick sanity check that the stream looks like a PFS0 file system.
///
@@ -226,8 +241,8 @@ namespace TinfoilVibeServer.Services
if (!stream.CanSeek) return false;
stream.Seek(0, SeekOrigin.Begin);
- var storage = new StreamStorage(stream, true);
- var partition = new PartitionFileSystem();
+ using var storage = new StreamStorage(stream, true);
+ using var partition = new PartitionFileSystem();
partition.Initialize(storage).ThrowIfFailure();
_logger.LogInformation("PFS0 found");
return true;
@@ -239,16 +254,16 @@ namespace TinfoilVibeServer.Services
return false;
}
+
private bool IsXciFileSystem(Stream stream)
{
if (KeySet == null) return false;
-
+
try
{
if (!stream.CanSeek) return false;
stream.Seek(0, SeekOrigin.Begin);
-
- var storage = new StreamStorage(stream, true);
+ using var storage = new StreamStorage(stream, true);
try
{
var xciBlock = new Xci(KeySet, storage);
@@ -259,6 +274,7 @@ namespace TinfoilVibeServer.Services
{
// ignored
}
+
return false;
}
catch (Exception e)
@@ -271,42 +287,59 @@ namespace TinfoilVibeServer.Services
public string ExtractHashFromStream(Stream nspStream)
{
if (KeySet == null) return string.Empty;
-
- if (!IsPfs0FileSystem(nspStream))
+ var seekableStream = !nspStream.CanSeek ? new SeekableBufferedStream(nspStream, nspStream.Length) : nspStream;
+
+ var isNsp = IsPfs0FileSystem(seekableStream);
+ var isXci = false;
+
+ if (!isNsp)
+ {
+ isXci = IsXciFileSystem(seekableStream);
+ }
+
+ if (!isNsp && !isXci)
return string.Empty;
- nspStream.Seek(0, SeekOrigin.Begin);
+ seekableStream.Seek(0, SeekOrigin.Begin);
- using var storage = new StreamStorage(nspStream, true);
- 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 storage = new StreamStorage(seekableStream, true);
+ if (isXci)
{
- 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 xci = new Xci(KeySet, storage);
+ using IFileSystem partition = xci.OpenPartition(XciPartitionType.Secure);
+ var hashEntry = partition
+ .EnumerateEntries("*", SearchOptions.Default)
+ .First(e => e.Type == DirectoryEntryType.File && e.Size < 10*1024*1024);
+ using var fileRef = new UniqueRef();
+ partition.OpenFile(ref fileRef.Ref, hashEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
+ using var ncaFile = fileRef.Release();
+ using var sha256 = SHA256.Create();
+ using var ncaStream = ncaFile.AsStream();
+ var hash = sha256.ComputeHash(ncaStream);
+ var extractHashFromStream = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
+ _logger.LogInformation("Computed first‑stream hash {Hash}", extractHashFromStream);
+ return extractHashFromStream;
+ }
+
+ if (isNsp)
+ {
+ using var partition = new PartitionFileSystem();
+ partition.Initialize(storage).ThrowIfFailure();
+ var hashEntry = partition
+ .EnumerateEntries("*", SearchOptions.Default)
+ .First(e => e.Type == DirectoryEntryType.File && e.Size < 10 * 1024 * 1024);
+
+ using var fileRef = new UniqueRef();
+ partition.OpenFile(ref fileRef.Ref, hashEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
+ using var ncaFile = fileRef.Release();
try
{
- var nca = new Nca(KeySet, ncaFileStorage);
- if (nca.Header.ContentType != NcaContentType.Meta)
- continue; // only the meta NCA contains title metadata
-
- // 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 extractHashFromStream = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
- _logger.LogInformation("Computed first‑stream hash {Hash} for {TitleId}", extractHashFromStream,
- nca.Header.TitleId);
+ _logger.LogInformation("Computed first‑stream hash {Hash}", extractHashFromStream);
return extractHashFromStream;
}
catch (Exception e)
@@ -314,6 +347,7 @@ namespace TinfoilVibeServer.Services
_logger.LogError("Failed to extract NSP: {Exception}", e.Message);
}
}
+
return string.Empty;
}
}
diff --git a/TinfoilVibeServer/Services/ROMArchiveReader.cs b/TinfoilVibeServer/Services/ROMArchiveReader.cs
index 80920d5..cecad01 100644
--- a/TinfoilVibeServer/Services/ROMArchiveReader.cs
+++ b/TinfoilVibeServer/Services/ROMArchiveReader.cs
@@ -3,8 +3,14 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
+using System.Linq;
+using System.Text.RegularExpressions;
using SharpCompress.Archives;
+using SharpCompress.Archives.Rar;
using SharpCompress.Common;
+using SharpCompress.IO;
+using SharpCompress.Readers;
+using TinfoilVibeServer.Utilities;
namespace TinfoilVibeServer.Services
{
@@ -14,22 +20,26 @@ namespace TinfoilVibeServer.Services
public sealed class RomArchiveReader : IDisposable
{
private readonly ZipArchive? _zipArchive;
- private readonly IArchive? _sharpArchive;
+ private readonly IArchive? _archive;
private readonly Stream? _archiveStream; // the stream actually handed to SharpCompress
+ private readonly ICollection? _partStreams;
- public RomArchiveReader(string path) : this(File.OpenRead(path), path) { }
+ public RomArchiveReader(string path)
+ {
+ _archive = DetectAndWrap(path);
+ }
///
/// Opens an archive from a stream.
/// The *fileName* parameter is only used to decide which archive format
/// to open; it can be null if the caller already knows the format.
///
- public RomArchiveReader(Stream stream, string? fileName = null)
+ public RomArchiveReader(string path, Stream stream)
{
if (stream == null) throw new ArgumentNullException(nameof(stream));
- var ext = fileName?.ToLowerInvariant() ?? string.Empty;
+ var fileInfo = new FileInfo(path);
- switch (ext)
+ switch (fileInfo.Extension)
{
case ".zip":
// System.IO.Compression can use the stream directly
@@ -51,14 +61,61 @@ namespace TinfoilVibeServer.Services
{
_archiveStream = stream;
}
- _sharpArchive = ArchiveFactory.Open(_archiveStream);
+
+ _archive = ArchiveFactory.Open(_archiveStream, new ReaderOptions() { LeaveStreamOpen = false, BufferSize = 10 * 1024 * 1024});
break;
default:
- throw new NotSupportedException($"Archive type '{ext}' is not supported.");
+ throw new NotSupportedException($"Archive type '{fileInfo.Extension}' is not supported.");
}
}
+ // Detect whether the file is a multi‑part RAR and wrap it if necessary
+ private static IArchive DetectAndWrap(string path)
+ {
+ string ext = Path.GetExtension(path).ToLowerInvariant();
+
+ if (ext == ".rar" || ext == ".r00" || ext == ".r01" || ext == ".r02")
+ {
+ var dir = Path.GetDirectoryName(path)!;
+ var fileName = Path.GetFileName(path);
+
+ // ----- 1️⃣ Determine the base name (everything before the first ".rar" or ".partNN") -----
+ string baseName = MultiPartRarHelper.GetBaseNameForRarVolume(fileName);
+
+ // Any file that ends with .rar or .rNN could be the start of a multi‑part set
+ // Let MultiPartRarStream decide which parts belong together.
+ var volumes = MultiPartRarHelper.DiscoverVolumes(dir, baseName);
+ if (volumes.Count is 0 or 1)
+ {
+ return ArchiveFactory.Open(path);
+ }
+
+ var streams = new List(volumes.Count);
+ foreach (var volume in volumes)
+ {
+ streams.Add(new FileStream(volume, FileMode.Open, FileAccess.Read, FileShare.Read));
+ }
+
+ return ArchiveFactory.Open(streams, new ReaderOptions { LeaveStreamOpen = false });
+ }
+
+ // Normal single‑file archive (zip, 7z, single‑rar, etc.)
+ using var archiveStream = File.OpenRead(path);
+ return ArchiveFactory.Open(archiveStream);
+ }
+
+ private static Stream? GetPart(int arg)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Stream? GetEntryStream(string entryName)
+ {
+ var entry = _archive?.Entries
+ .Single(e => e.Key != null && e.Key.Equals(entryName, StringComparison.OrdinalIgnoreCase));
+ return entry?.OpenEntryStream();
+ }
///
/// Enumerates every file entry inside the archive.
///
@@ -71,19 +128,29 @@ namespace TinfoilVibeServer.Services
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)
+ else if (_archive != null)
{
- foreach (var entry in _sharpArchive.Entries)
+ foreach (var entry in _archive.Entries)
{
if (entry.IsDirectory)
continue;
// SharpCompress gives us a stream that must be disposed by the caller
- if (entry.Key != null) yield return new RomArchiveEntry(entry.Key, entry.OpenEntryStream());
+ if (!entry.Archive.IsComplete)
+ {
+ if (entry.Key != null)
+ {
+ var entryStream = GetEntryStream(entry.Key);
+ if (entryStream != null) yield return new RomArchiveEntry(entry.Key, entryStream);
+ }
+ }
+ else
+ {
+ if (entry.Key != null) yield return new RomArchiveEntry(entry.Key, entry.OpenEntryStream());
+ }
}
}
}
@@ -99,7 +166,7 @@ namespace TinfoilVibeServer.Services
public void Dispose()
{
_zipArchive?.Dispose();
- _sharpArchive?.Dispose();
+ _archive?.Dispose();
_archiveStream?.Dispose();
}
diff --git a/TinfoilVibeServer/Services/SnapshotService.cs b/TinfoilVibeServer/Services/SnapshotService.cs
index ab962fb..d81b4cb 100644
--- a/TinfoilVibeServer/Services/SnapshotService.cs
+++ b/TinfoilVibeServer/Services/SnapshotService.cs
@@ -1,7 +1,16 @@
-using System.Collections.Concurrent;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using TinfoilVibeServer.Models;
@@ -91,7 +100,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
_archiveHandler = archiveHandler;
_logger = logger;
_environment = environment;
- _jsonPath = Path.Combine(Path.DirectorySeparatorChar.ToString(), "app", "data", _options.SnapshotFile);
+ _jsonPath = !Path.IsPathRooted(_options.SnapshotFile) ? Path.Combine(Path.DirectorySeparatorChar.ToString(), "app", "data", _options.SnapshotFile) : _options.SnapshotFile;
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_jsonPath) ?? throw new InvalidOperationException()));
if (!File.Exists(_jsonPath))
@@ -101,7 +110,8 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
_snapshotFileSemaphore.Release();
}
- _snapshotPath = Path.Combine(Path.DirectorySeparatorChar.ToString(), "app", "data", _options.SnapshotBackupFile);
+ _snapshotPath = !Path.IsPathRooted(_options.SnapshotBackupFile) ? Path.Combine(Path.DirectorySeparatorChar.ToString(), "app", "data", _options.SnapshotBackupFile) : _options.SnapshotBackupFile;
+
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_snapshotPath) ?? throw new InvalidOperationException()));
// 1️⃣ Register for *property* changes
@@ -272,7 +282,8 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
// Update lookup tables
if (entry.Hash != null)
{
- var lastModified = File.GetLastWriteTimeUtc(entry.Path);
+ var lastModified = File.GetLastWriteTimeUtc(entry.Path.Contains(ArchivePathSeparator) ? entry.Path.Split(ArchivePathSeparator)[0] : entry.Path);
+
_cache[entry.Path] = new SnapshotEntry(entry.Path, entry.Hash, entry.Size, lastModified, entry.Titles);
_hashCache[entry.Hash] = entry.Path;
_sizeLookup[entry.Hash] = entry.Size;
@@ -299,7 +310,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
_hashCache[ncaMetadataWithHash.Hash] = entry.Path;
_sizeLookup[ncaMetadataWithHash.Hash] = entry.Size;
- _logger.LogInformation("Added entry {titleId} to snapshot (hash={hash})", ncaMetadataWithHash.TitleId, ncaMetadataWithHash.Hash);
+ //_logger.LogInformation("Added entry {titleId} to snapshot (hash={hash})", ncaMetadataWithHash.TitleId, ncaMetadataWithHash.Hash);
}
// Persist snapshot to disk
@@ -379,6 +390,8 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
// Each entry that has not been added to the lookup table is added to the cache
private IEnumerable BuildSnapshot(string dir)
{
+ var processedFiles = new HashSet();
+
if (!Directory.Exists(dir)) yield break;
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories).OrderBy(file =>
{
@@ -392,11 +405,6 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
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))
@@ -409,12 +417,14 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
var fileEntryFromFileName = new FileEntry(file, fileInfo.Length, ncaMetadataWithHash.Hash, [ncaMetadataWithHash]);
AddToSnapshotAsync(fileEntryFromFileName);
yield return fileEntryFromFileName;
+ continue;
}
using var nspStream = File.OpenRead(file);
+ _logger.LogDebug("Extracting hash for {File}", file);
hash = ComputeFirstStreamHash(nspStream);
- if (_hashCache.ContainsKey(hash))
+ if (_hashCache.TryGetValue(hash, out var value) && file == _cache[value].Path)
{
continue;
}
@@ -427,6 +437,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
AddToSnapshotAsync(archiveEntry);
titles.Add((title.TitleId, nspStreamLength, title));
yield return archiveEntry;
+ continue;
}
}
else
@@ -434,16 +445,36 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
if (_options.ArchiveExtensions.Contains(ext))
{
if (_archiveLookup.ContainsKey(file)) continue;
+ if (processedFiles.Contains(file)) continue;
+ _logger.LogDebug("Extracting hash for {File}", file);
+ Stopwatch stopwatch = Stopwatch.StartNew();
hash = ComputeFirstStreamHash(file);
- if (_hashCache.ContainsKey(hash))
+ stopwatch.Stop();
+ _logger.LogDebug("Computed hash for {File} in {Time}ms", file, stopwatch.ElapsedMilliseconds);
+ if (_hashCache.TryGetValue(hash, out var value) && file == _cache[value].Path)
{
yield return null;
+ continue;
}
IEnumerable<(string, long, NcaMetadataWithHash)>? titlesEnumerable = null;
try
{
+ Stopwatch stopwatch2 = Stopwatch.StartNew();
titlesEnumerable = _archiveHandler.TryExtractTitleInfos(file);
+ stopwatch2.Stop();
+ _logger.LogDebug("Extracted title infos for {File} in {Time}ms", file, stopwatch2.ElapsedMilliseconds);
+ // if it was multipart, add multiparts to processedFiles
+ var directoryName = Path.GetDirectoryName(file);
+ if (directoryName != null)
+ {
+ var baseName = MultiPartRarHelper.GetBaseNameForRarVolume(Path.GetFileName(file));
+ var discoverVolumes = MultiPartRarHelper.DiscoverVolumes(directoryName, baseName);
+ if (discoverVolumes.Count > 1)
+ {
+ processedFiles.UnionWith(discoverVolumes);
+ }
+ }
}
catch (Exception e)
{
@@ -459,6 +490,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
AddToSnapshotAsync(archiveEntry);
yield return archiveEntry;
}
+ continue;
/*var fileEntry = new FileEntry(file, new FileInfo(file).Length, hash, titles.Select((tuple, i) => tuple.Item3).ToList());
AddToSnapshotAsync(fileEntry);
yield return fileEntry;*/
@@ -624,9 +656,9 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
if (_options.RomExtensions.Contains(Path.GetExtension(fileEntry.Path)))
{
- var fileInfo = new FileInfo(fileEntry.Path);
if (fileEntry.Path.Contains(ArchivePathSeparator))
{
+ var fileInfo = new FileInfo(fileEntry.Path.Split(ArchivePathSeparator)[0]);
var filename = fileEntry.Path.Split(ArchivePathSeparator)[0];
// ReSharper disable once RedundantSuppressNullableWarningExpression
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileInfo.LastWriteTimeUtc, fileEntry.Titles!);
@@ -634,6 +666,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
}
else
{
+ var fileInfo = new FileInfo(fileEntry.Path);
// ReSharper disable once RedundantSuppressNullableWarningExpression
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileInfo.LastWriteTimeUtc, fileEntry.Titles!);
fileEntries.TryAdd(fileEntry.Path, fileEntry);
@@ -753,8 +786,12 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
var first = reader.GetEntries().FirstOrDefault();
if (first == null) return ComputeFullHash(filePath);
- using var firstStream = first.Stream;
- var hash = _nspExtractor.ExtractHashFromStream(firstStream);
+ //using var seekableWrapper = new SeekableBufferedStream(first.Stream, first.Stream.Length, 10*1024*1024, true);
+ using var rewindableWrapper = new RewindableStream(first.Stream, () =>
+ {
+ return reader.GetEntries().FirstOrDefault().Stream;
+ }, 10*1024*1024, first.Stream.Length);
+ var hash = _nspExtractor.ExtractHashFromStream(rewindableWrapper);
return hash;
}
catch
diff --git a/TinfoilVibeServer/Services/TitleDatabaseService.cs b/TinfoilVibeServer/Services/TitleDatabaseService.cs
index 4f93b5b..5b6f04e 100644
--- a/TinfoilVibeServer/Services/TitleDatabaseService.cs
+++ b/TinfoilVibeServer/Services/TitleDatabaseService.cs
@@ -1,6 +1,15 @@
-using System.Text.RegularExpressions;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Text.RegularExpressions;
using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TinfoilVibeServer.Models;
diff --git a/TinfoilVibeServer/TinfoilVibeServer.csproj b/TinfoilVibeServer/TinfoilVibeServer.csproj
index 36cd17b..0c961fd 100644
--- a/TinfoilVibeServer/TinfoilVibeServer.csproj
+++ b/TinfoilVibeServer/TinfoilVibeServer.csproj
@@ -10,6 +10,8 @@
+
+
diff --git a/TinfoilVibeServer/Utilities/FileSystemExtensions.cs b/TinfoilVibeServer/Utilities/FileSystemExtensions.cs
index 1d3815e..f0dde76 100644
--- a/TinfoilVibeServer/Utilities/FileSystemExtensions.cs
+++ b/TinfoilVibeServer/Utilities/FileSystemExtensions.cs
@@ -1,4 +1,10 @@
-namespace TinfoilVibeServer.Utilities;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace TinfoilVibeServer.Utilities;
public static class FileSystemExtensions
{
diff --git a/TinfoilVibeServer/Utilities/IdHelper.cs b/TinfoilVibeServer/Utilities/IdHelper.cs
index f3b1122..20a91af 100644
--- a/TinfoilVibeServer/Utilities/IdHelper.cs
+++ b/TinfoilVibeServer/Utilities/IdHelper.cs
@@ -1,4 +1,8 @@
+using System;
+using System.Collections.Generic;
using System.Globalization;
+using System.IO;
+using System.Linq;
using System.Text.RegularExpressions;
namespace TinfoilVibeServer.Utilities;
diff --git a/TinfoilVibeServer/Utilities/MultipartRarStream.cs b/TinfoilVibeServer/Utilities/MultipartRarStream.cs
new file mode 100644
index 0000000..4f6d0a4
--- /dev/null
+++ b/TinfoilVibeServer/Utilities/MultipartRarStream.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace TinfoilVibeServer.Utilities;
+
+///
+/// Streams a multipart RAR as a single, seekable stream.
+/// Supports:
+/// • myGame.rar (single volume)
+/// • myGame.r00 / r01 … (RAR 4.x style)
+/// • myGame.part01.rar … (WinRAR “part” style)
+///
+public static class MultiPartRarHelper
+{
+ public static string GetBaseNameForRarVolume(string fileName)
+ {
+ string baseName = string.Empty;
+ // Multipart – remove the ".rNN" or ".partNN" suffix
+ var m = Regex.Match(fileName,
+ @"^(?.+?)(\.r\d\d|\.part\d\d)\.rar$",
+ RegexOptions.IgnoreCase);
+ if (!m.Success)
+ {
+ if (fileName.EndsWith(".rar", StringComparison.OrdinalIgnoreCase))
+ {
+ // Single‑volume archive – just drop the suffix
+ baseName = fileName.Substring(0, fileName.Length - 4);
+ }
+ }
+ else
+ {
+ baseName = m.Groups["base"].Value;
+ }
+ return baseName;
+ }
+
+ ///
+ /// Returns the list of files that belong to the same multipart set.
+ ///
+ public static List DiscoverVolumes(string dir, string baseName)
+ {
+ // Pattern: .(rar | rNN | partNN.rar)
+ string pattern =
+ $@"^{Regex.Escape(baseName)}(\.rar|\.r\d\d|\.part\d\d\.rar)$";
+
+ return Directory.GetFiles(dir)
+ .Where(f => Regex.IsMatch(Path.GetFileName(f), pattern, RegexOptions.IgnoreCase))
+ .OrderBy(f => GetPartNumber(Path.GetFileName(f), baseName))
+ .ToList();
+ }
+
+ ///
+ /// Gives each file a numeric key that guarantees the correct order.
+ /// 0 → .rar (first volume)
+ /// 1 → .r00 or .part01
+ /// 2 → .r01 or .part02
+ /// … and so on.
+ ///
+ private static int GetPartNumber(string fileName, string baseName)
+ {
+ // 1️⃣ ".rar" → 0
+ if (fileName.Equals($"{baseName}.rar", StringComparison.OrdinalIgnoreCase))
+ return 0;
+
+ // 2️⃣ ".rNN" or ".partNN.rar"
+ var m = Regex.Match(fileName,
+ $@"\.r(?\d\d)$|\.part(?\d\d)\.rar$",
+ RegexOptions.IgnoreCase);
+
+ if (m.Success)
+ {
+ int partNum = int.Parse(m.Groups["num"].Value);
+ return partNum + 1; // so r00 → 1, r01 → 2; part01 → 1, part02 → 2
+ }
+
+ // 3️⃣ unknown pattern – treat as first part
+ return 0;
+ }
+
+}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Utilities/NcaMetadataWithHashHelper.cs b/TinfoilVibeServer/Utilities/NcaMetadataWithHashHelper.cs
index 4b072c3..25dc033 100644
--- a/TinfoilVibeServer/Utilities/NcaMetadataWithHashHelper.cs
+++ b/TinfoilVibeServer/Utilities/NcaMetadataWithHashHelper.cs
@@ -1,4 +1,6 @@
-using System.Security.Cryptography;
+using System;
+using System.IO;
+using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using LibHac.Ncm;
@@ -13,7 +15,8 @@ public static class NcaMetadataWithHashHelper
var match = Regex.Match(fileInfo.Name, @"^(.+)\[(\w{16})\]\[v(\d{1,7})\]\[(\w+).*\]\.nsp$");
if (!match.Success) return null;
var titleId = match.Groups[2].Value;
- var applicationTitle = match.Groups[1].Value.Trim();
+ var applicationTitle = titleId;
+ //var applicationTitle = match.Groups[1].Value.Trim();
var version = int.Parse(match.Groups[3].Value) / 0x10000;
var nspType = match.Groups[4].Value.ToLowerInvariant() switch
{
diff --git a/TinfoilVibeServer/Utilities/RewindableStream.cs b/TinfoilVibeServer/Utilities/RewindableStream.cs
new file mode 100644
index 0000000..52dd87a
--- /dev/null
+++ b/TinfoilVibeServer/Utilities/RewindableStream.cs
@@ -0,0 +1,299 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace TinfoilVibeServer.Utilities;
+
+///
+/// Wraps a non‑seekable stream so that it can be read and seeked.
+/// The wrapper keeps a small circular buffer of recently read data.
+/// When the caller seeks outside the buffered range the wrapper
+/// disposes the current stream, obtains a new instance via a
+/// supplied factory, and reads forward from the start again.
+///
+public sealed class RewindableStream : Stream
+{
+ private readonly Func _reopenFactory; // function that returns a fresh stream instance
+ private readonly int _bufferLimit; // maximum bytes to keep in memory
+
+ private Stream _source; // the current underlying stream
+ private MemoryStream _buffer; // holds the cached bytes
+ private long _bufferStart; // absolute position in the source of the first byte in _buffer
+ private long _position; // current read position in the virtual stream
+ private long? _length; // cached length once we discover it (null = unknown)
+ private bool _disposed;
+
+ ///
+ /// Creates a new seekable wrapper.
+ ///
+ /// The initial non‑seekable stream.
+ ///
+ /// Factory that returns a *new* instance of the underlying stream.
+ /// It is called whenever we need to seek beyond the cached range.
+ ///
+ ///
+ /// The maximum number of bytes to keep cached in memory.
+ /// Older data will be discarded as new data is read. Typical value: 64 KB.
+ ///
+ public RewindableStream(
+ Stream source,
+ Func reopenFactory,
+ int bufferLimit = 64 * 1024, long? length = null)
+ {
+ if (source == null) throw new ArgumentNullException(nameof(source));
+ if (!source.CanRead) throw new ArgumentException("Source stream must be readable", nameof(source));
+ if (reopenFactory == null) throw new ArgumentNullException(nameof(reopenFactory));
+ if (bufferLimit <= 0) throw new ArgumentOutOfRangeException(nameof(bufferLimit));
+ if (length.HasValue && length.Value < 0) throw new ArgumentOutOfRangeException(nameof(length));
+ _length = null; // unknown until we discover it
+ if (length.HasValue) _length = length;
+ _source = source;
+ _reopenFactory = reopenFactory;
+ _bufferLimit = bufferLimit;
+ _buffer = new MemoryStream();
+ _bufferStart = 0;
+ _position = 0;
+ _disposed = false;
+ }
+
+ #region Stream overrides
+
+ public override bool CanRead => !_disposed && _source.CanRead;
+ public override bool CanSeek => true; // we expose seek behaviour
+ public override bool CanWrite => false; // read‑only wrapper
+ public override long Length
+ {
+ get
+ {
+ EnsureLengthAsync(CancellationToken.None).GetAwaiter().GetResult();
+ return _length.Value;
+ }
+ }
+
+ public override long Position
+ {
+ get => _position;
+ set => Seek(value, SeekOrigin.Begin);
+ }
+
+ public override void Flush()
+ {
+ // Nothing to do – read‑only wrapper.
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ ThrowIfDisposed();
+ if (buffer == null) throw new ArgumentNullException(nameof(buffer));
+ if (offset < 0 || count < 0 || offset + count > buffer.Length)
+ throw new ArgumentOutOfRangeException();
+
+ if (count == 0) return 0;
+
+ int totalRead = 0;
+ while (count > 0)
+ {
+ // Make sure the requested range is buffered.
+ EnsureBufferedUpTo(_position + count - 1).GetAwaiter().GetResult();
+
+ // How many bytes can we copy from the buffer?
+ long bufferEnd = _bufferStart + _buffer.Length;
+ long available = bufferEnd - _position;
+ if (available <= 0)
+ {
+ // We are at EOF – nothing more to read.
+ break;
+ }
+
+ int toCopy = (int)Math.Min(count, available);
+ _buffer.Position = _position - _bufferStart;
+ int read = _buffer.Read(buffer, offset, toCopy);
+
+ offset += read;
+ count -= read;
+ totalRead += read;
+ _position += read;
+
+ if (read == 0) break; // EOF
+ }
+
+ return totalRead;
+ }
+
+ public override long Seek(long offset, SeekOrigin origin)
+ {
+ ThrowIfDisposed();
+
+ long newPos;
+
+ if (origin == SeekOrigin.Begin)
+ {
+ newPos = offset;
+ }
+ else if (origin == SeekOrigin.Current)
+ {
+ newPos = _position + offset;
+ }
+ else if (origin == SeekOrigin.End)
+ {
+ // We need the length first.
+ EnsureLengthAsync(CancellationToken.None).GetAwaiter().GetResult();
+ newPos = _length.Value + offset;
+ }
+ else
+ {
+ throw new ArgumentOutOfRangeException(nameof(origin));
+ }
+
+ if (newPos < 0)
+ throw new IOException("Cannot seek to a negative position.");
+
+ // If the new position lies outside our cached range, we must
+ // restart the underlying stream and read forward again.
+ if (newPos < _bufferStart || newPos > _bufferStart + _buffer.Length)
+ {
+ ReopenFromStart(); // resets _source, _buffer, etc.
+ _position = newPos; // restore the requested position
+ }
+ else
+ {
+ _position = newPos;
+ }
+
+ // Ensure that we actually have bytes buffered up to the new position
+ // (unless we are at the very end – in which case the call will just
+ // return as we hit EOF).
+ EnsureBufferedUpTo(_position).GetAwaiter().GetResult();
+
+ return _position;
+ }
+
+ public override void SetLength(long value)
+ {
+ throw new NotSupportedException("SetLength is not supported on RewindableStream.");
+ }
+
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ throw new NotSupportedException("Write is not supported on RewindableStream.");
+ }
+
+ #endregion
+
+ #region Helper methods
+
+ ///
+ /// Ensures that the buffer contains data up to the specified absolute position.
+ ///
+ private async Task EnsureBufferedUpTo(long position)
+ {
+ ThrowIfDisposed();
+
+ if (position < _bufferStart) return; // we already have data before our buffer.
+
+ // Read from the underlying stream until we have buffered up to 'position'
+ // or until EOF.
+ while (_bufferStart + _buffer.Length <= position)
+ {
+ int toRead = (int)Math.Min(_bufferLimit,
+ position - (_bufferStart + _buffer.Length) + 1);
+
+ // Allocate a temporary buffer
+ byte[] temp = new byte[toRead];
+ int read = await _source.ReadAsync(temp, 0, temp.Length, CancellationToken.None);
+
+ if (read == 0) // EOF
+ {
+ // Store the final length if we don't already know it.
+ if (!_length.HasValue)
+ {
+ _length = _bufferStart + _buffer.Length;
+ }
+ break;
+ }
+
+ // Append to our circular buffer
+ _buffer.Position = _buffer.Length; // move to end
+ _buffer.Write(temp, 0, read);
+
+ // Trim if we exceeded the buffer limit.
+ if (_buffer.Length > _bufferLimit)
+ {
+ long excess = _buffer.Length - _bufferLimit;
+ byte[] remaining = new byte[_buffer.Length - excess];
+ _buffer.Position = excess;
+ _buffer.Read(remaining, 0, remaining.Length);
+
+ _buffer = new MemoryStream();
+ _buffer.Write(remaining, 0, remaining.Length);
+ _bufferStart += excess; // first byte in new buffer is now further ahead
+ }
+ }
+ }
+
+ ///
+ /// Reopens the underlying stream by disposing the current instance
+ /// and calling the factory again.
+ ///
+ private void ReopenFromStart()
+ {
+ _source.Dispose();
+ _source = _reopenFactory();
+
+ _buffer.SetLength(0);
+ _bufferStart = 0;
+ _position = 0;
+ }
+
+ ///
+ /// Attempts to discover the length of the underlying source if it supports it.
+ /// If the source does not expose a length we will read to the end once.
+ ///
+ private async Task EnsureLengthAsync(CancellationToken cancellationToken)
+ {
+ if (_length.HasValue) return;
+
+ if (_source.CanSeek)
+ {
+ long current = _source.Position;
+ _length = _source.Length;
+ _source.Position = current;
+ }
+ else
+ {
+ // We need to read until EOF to determine the length
+ while (true)
+ {
+ byte[] temp = new byte[_bufferLimit];
+ int read = await _source.ReadAsync(temp, 0, temp.Length, cancellationToken);
+ if (read == 0) break;
+ }
+
+ _length = _bufferStart + _buffer.Length;
+ }
+ }
+
+ private void ThrowIfDisposed()
+ {
+ if (_disposed) throw new ObjectDisposedException(nameof(RewindableStream));
+ }
+
+ #endregion
+
+ #region IDisposable
+
+ protected override void Dispose(bool disposing)
+ {
+ if (!_disposed && disposing)
+ {
+ _source?.Dispose();
+ _buffer?.Dispose();
+ }
+
+ _disposed = true;
+ base.Dispose(disposing);
+ }
+
+ #endregion
+}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Utilities/SeekableBufferedStream.cs b/TinfoilVibeServer/Utilities/SeekableBufferedStream.cs
index 4090903..560832e 100644
--- a/TinfoilVibeServer/Utilities/SeekableBufferedStream.cs
+++ b/TinfoilVibeServer/Utilities/SeekableBufferedStream.cs
@@ -1,4 +1,9 @@
-using System.Buffers;
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
namespace TinfoilVibeServer.Utilities;