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;