Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9836ccf81b | |||
| eafb28846f | |||
| b048705024 | |||
| de438f8905 | |||
| 169cf9ecf9 |
@@ -10,3 +10,4 @@ data/*
|
|||||||
TinfoilVibeServer/config/prod.keys
|
TinfoilVibeServer/config/prod.keys
|
||||||
TinfoilVibeServer/data/*
|
TinfoilVibeServer/data/*
|
||||||
!TinfoilVibeServer/data/.gitkeep
|
!TinfoilVibeServer/data/.gitkeep
|
||||||
|
.vs/
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using SharpCompress.Archives;
|
|||||||
using SharpCompress.Archives.Rar;
|
using SharpCompress.Archives.Rar;
|
||||||
using SharpCompress.Archives.SevenZip;
|
using SharpCompress.Archives.SevenZip;
|
||||||
using SharpCompress.Common;
|
using SharpCompress.Common;
|
||||||
|
using SharpCompress.Readers;
|
||||||
using TinfoilVibeServer.Models;
|
using TinfoilVibeServer.Models;
|
||||||
using TinfoilVibeServer.Utilities;
|
using TinfoilVibeServer.Utilities;
|
||||||
using ZipArchive = SharpCompress.Archives.Zip.ZipArchive;
|
using ZipArchive = SharpCompress.Archives.Zip.ZipArchive;
|
||||||
@@ -151,6 +152,10 @@ public sealed class ArchiveHandler : IArchiveHandler
|
|||||||
{
|
{
|
||||||
_logger.LogError("Failed to extract title info from archive {Archive}: {Exception}", path, e.Message);
|
_logger.LogError("Failed to extract title info from archive {Archive}: {Exception}", path, e.Message);
|
||||||
}
|
}
|
||||||
|
else if (e.Message.StartsWith("Unable to decrypt NCA section"))
|
||||||
|
{
|
||||||
|
_logger.LogError("Unable to decrypt NCA section, try updating prod.keys");
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
throw;
|
throw;
|
||||||
@@ -171,17 +176,19 @@ public sealed class ArchiveHandler : IArchiveHandler
|
|||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Failed to open archive with SharpCompress, falling back to SharpSevenZip {Exception}",
|
"Failed to open archive with SharpCompress, falling back to SharpSevenZip {Exception}",
|
||||||
exception.Message);
|
exception.Message);
|
||||||
using var archive = SevenZipArchive.Open(path);
|
using var archive = SevenZipArchive.Open(path, new ReaderOptions { LeaveStreamOpen = false });
|
||||||
foreach (var entry in archive.Entries)
|
foreach (var entry in archive.Entries)
|
||||||
{
|
{
|
||||||
if (entry is { IsDirectory: false, Key: not null } && IsRomArchive(entry.Key))
|
if (entry is not { IsDirectory: false, Key: not null } || !IsRomArchive(entry.Key)) continue;
|
||||||
{
|
|
||||||
var temp = Path.GetTempFileName();
|
var temp = Path.GetTempFileName();
|
||||||
entry.WriteToFile(temp);
|
entry.WriteToFile(temp);
|
||||||
var title = _nspExtractor.ExtractFromFile(temp); // instance call
|
var title = _nspExtractor.ExtractFromFile(temp); // instance call
|
||||||
File.Delete(temp);
|
File.Delete(temp);
|
||||||
if (title != null) titles.Add((entry.Key, entry.Size, title));
|
if (title == null) continue;
|
||||||
}
|
|
||||||
|
_logger.LogInformation("Extracted title {Key} using SharpSevenZip", entry.Key);
|
||||||
|
titles.Add((entry.Key, entry.Size, title));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,11 @@ namespace TinfoilVibeServer.Services
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads a ROM archive (zip / 7z / rar) from a stream.
|
/// Reads a ROM archive (zip / 7z / rar) from a stream.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RomArchiveReader : IDisposable
|
public sealed class RomArchiveReader : IDisposable, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly ZipArchive? _zipArchive;
|
private readonly ZipArchive? _zipArchive;
|
||||||
private readonly IArchive? _archive;
|
private readonly IArchive? _archive;
|
||||||
private readonly Stream? _archiveStream; // the stream actually handed to SharpCompress
|
private readonly Stream? _archiveStream; // the stream actually handed to SharpCompress
|
||||||
private readonly ICollection<Stream>? _partStreams;
|
|
||||||
|
|
||||||
public RomArchiveReader(string path)
|
public RomArchiveReader(string path)
|
||||||
{
|
{
|
||||||
@@ -55,7 +54,15 @@ namespace TinfoilVibeServer.Services
|
|||||||
stream.CopyTo(ms);
|
stream.CopyTo(ms);
|
||||||
ms.Position = 0;
|
ms.Position = 0;
|
||||||
_archiveStream = ms;
|
_archiveStream = ms;
|
||||||
stream.Dispose(); // original non‑seekable stream no longer needed
|
if (stream is IAsyncDisposable asyncDisposable)
|
||||||
|
{
|
||||||
|
var disposeAsync = asyncDisposable.DisposeAsync();
|
||||||
|
disposeAsync.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stream.Dispose(); // original non‑seekable stream no longer needed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -73,28 +80,29 @@ namespace TinfoilVibeServer.Services
|
|||||||
// Detect whether the file is a multi‑part RAR and wrap it if necessary
|
// Detect whether the file is a multi‑part RAR and wrap it if necessary
|
||||||
private static IArchive DetectAndWrap(string path)
|
private static IArchive DetectAndWrap(string path)
|
||||||
{
|
{
|
||||||
string ext = Path.GetExtension(path).ToLowerInvariant();
|
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||||
|
|
||||||
if (ext == ".rar" || ext == ".r00" || ext == ".r01" || ext == ".r02")
|
if (ext is ".rar" or ".r00" or ".r01" or ".r02")
|
||||||
{
|
{
|
||||||
var dir = Path.GetDirectoryName(path)!;
|
var dir = Path.GetDirectoryName(path)!;
|
||||||
var fileName = Path.GetFileName(path);
|
var fileName = Path.GetFileName(path);
|
||||||
|
|
||||||
// ----- 1️⃣ Determine the base name (everything before the first ".rar" or ".partNN") -----
|
// ----- 1️⃣ Determine the base name (everything before the first ".rar" or ".partNN") -----
|
||||||
string baseName = MultiPartRarHelper.GetBaseNameForRarVolume(fileName);
|
var baseName = MultiPartRarHelper.GetBaseNameForRarVolume(fileName);
|
||||||
|
|
||||||
// Any file that ends with .rar or .rNN could be the start of a multi‑part set
|
// Any file that ends with .rar or .rNN could be the start of a multi‑part set
|
||||||
// Let MultiPartRarStream decide which parts belong together.
|
// Let MultiPartRarStream decide which parts belong together.
|
||||||
var volumes = MultiPartRarHelper.DiscoverVolumes(dir, baseName);
|
var volumes = MultiPartRarHelper.DiscoverVolumes(dir, baseName);
|
||||||
if (volumes.Count is 0 or 1)
|
if (volumes.Count is 0 or 1)
|
||||||
{
|
{
|
||||||
return ArchiveFactory.Open(path);
|
return ArchiveFactory.Open(path, new ReaderOptions { LeaveStreamOpen = false });
|
||||||
}
|
}
|
||||||
|
|
||||||
var streams = new List<Stream>(volumes.Count);
|
var streams = new List<Stream>(volumes.Count);
|
||||||
foreach (var volume in volumes)
|
foreach (var volume in volumes)
|
||||||
{
|
{
|
||||||
streams.Add(new FileStream(volume, FileMode.Open, FileAccess.Read, FileShare.Read));
|
// todo: check all streams for read validity? The rar may be available but the parts are not all downloaded yet
|
||||||
|
streams.Add(new FileStream(volume, FileMode.Open, FileAccess.Read, FileShare.Read, 10*1024*1024, FileOptions.Asynchronous));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ArchiveFactory.Open(streams, new ReaderOptions { LeaveStreamOpen = false });
|
return ArchiveFactory.Open(streams, new ReaderOptions { LeaveStreamOpen = false });
|
||||||
@@ -102,7 +110,7 @@ namespace TinfoilVibeServer.Services
|
|||||||
|
|
||||||
// Normal single‑file archive (zip, 7z, single‑rar, etc.)
|
// Normal single‑file archive (zip, 7z, single‑rar, etc.)
|
||||||
using var archiveStream = File.OpenRead(path);
|
using var archiveStream = File.OpenRead(path);
|
||||||
return ArchiveFactory.Open(archiveStream);
|
return ArchiveFactory.Open(archiveStream, new ReaderOptions { LeaveStreamOpen = false });
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Stream? GetPart(int arg)
|
private static Stream? GetPart(int arg)
|
||||||
@@ -164,10 +172,21 @@ namespace TinfoilVibeServer.Services
|
|||||||
/// Disposes the underlying archive objects and the stream(s).
|
/// Disposes the underlying archive objects and the stream(s).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
{
|
||||||
|
DisposeAsync().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
_zipArchive?.Dispose();
|
_zipArchive?.Dispose();
|
||||||
_archive?.Dispose();
|
_archive?.Dispose();
|
||||||
_archiveStream?.Dispose();
|
|
||||||
|
// Dispose of the underlying stream (may support async)
|
||||||
|
if (_archiveStream is IAsyncDisposable asyncStream)
|
||||||
|
await asyncStream.DisposeAsync().ConfigureAwait(false);
|
||||||
|
else
|
||||||
|
// ReSharper disable once MethodHasAsyncOverload
|
||||||
|
_archiveStream?.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public interface ISnapshotService
|
|||||||
Task RebuildSnapshotAsync(CancellationToken cancellationToken = default);
|
Task RebuildSnapshotAsync(CancellationToken cancellationToken = default);
|
||||||
SnapshotService.ROMSnapshot GetSnapshot();
|
SnapshotService.ROMSnapshot GetSnapshot();
|
||||||
|
|
||||||
Task AddToSnapshotAsync(FileEntry entry);
|
Task AddToSnapshotAsync(FileEntry entry, CancellationToken cancellationToken = default);
|
||||||
Task BuildSnapshotAsync(CancellationToken cancellationToken = default);
|
Task BuildSnapshotAsync(CancellationToken cancellationToken = default);
|
||||||
void GetArchiveName(string titleId);
|
void GetArchiveName(string titleId);
|
||||||
char GetArchivePathSeparator();
|
char GetArchivePathSeparator();
|
||||||
@@ -221,8 +221,23 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
_watchers.Remove(fileSystemWatchers);
|
_watchers.Remove(fileSystemWatchers);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnChanged(object? _, FileSystemEventArgs e) => ThrottleSnapshotUpdate(e);
|
private void OnChanged(object? _, FileSystemEventArgs e)
|
||||||
private void OnRenamed(object? _, RenamedEventArgs e) => ThrottleSnapshotUpdate(e);
|
{
|
||||||
|
var fileInfo = new FileInfo(e.FullPath);
|
||||||
|
if (_options.ArchiveExtensions.Contains(fileInfo.Extension) || _options.RomExtensions.Contains(fileInfo.Extension))
|
||||||
|
{
|
||||||
|
ThrottleSnapshotUpdate(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRenamed(object? _, RenamedEventArgs e)
|
||||||
|
{
|
||||||
|
var fileInfo = new FileInfo(e.FullPath);
|
||||||
|
if (_options.ArchiveExtensions.Contains(fileInfo.Extension) || _options.RomExtensions.Contains(fileInfo.Extension))
|
||||||
|
{
|
||||||
|
ThrottleSnapshotUpdate(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Rebuild the snapshot, if rebuild in process, cancel it and restart
|
/// Rebuild the snapshot, if rebuild in process, cancel it and restart
|
||||||
@@ -293,15 +308,19 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
|
|
||||||
#region Snapshot logic
|
#region Snapshot logic
|
||||||
|
|
||||||
public Task AddToSnapshotAsync(FileEntry entry)
|
public async Task AddToSnapshotAsync(FileEntry entry, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Update lookup tables
|
// Update lookup tables
|
||||||
if (entry.Hash == null)
|
if (entry.Hash == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Cannot add entry {Path} to snapshot: no hash", entry.Path);
|
_logger.LogWarning("Cannot add entry {Path} to snapshot: no hash", entry.Path);
|
||||||
return Task.CompletedTask;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cacheUpdated = _cache.ContainsKey(entry.Path);
|
||||||
|
var hashMatch = _hashCache.ContainsKey(entry.Hash) && _hashCache[entry.Hash] == entry.Path;
|
||||||
|
if (cacheUpdated && hashMatch) return;
|
||||||
|
|
||||||
var lastModified = File.GetLastWriteTimeUtc(entry.Path.Contains(ArchivePathSeparator) ? entry.Path.Split(ArchivePathSeparator)[0] : 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);
|
_cache[entry.Path] = new SnapshotEntry(entry.Path, entry.Hash, entry.Size, lastModified, entry.Titles);
|
||||||
@@ -324,12 +343,14 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
|
|
||||||
_hashCache[ncaMetadataWithHash.Hash] = entry.Path;
|
_hashCache[ncaMetadataWithHash.Hash] = entry.Path;
|
||||||
_sizeLookup[ncaMetadataWithHash.Hash] = entry.Size;
|
_sizeLookup[ncaMetadataWithHash.Hash] = entry.Size;
|
||||||
//_logger.LogInformation("Added entry {titleId} to snapshot (hash={hash})", ncaMetadataWithHash.TitleId, ncaMetadataWithHash.Hash);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist snapshot to disk
|
// Persist snapshot to disk
|
||||||
PersistSnapshotAsync();
|
// If entry.Titles is null, treat it as an empty collection
|
||||||
return Task.CompletedTask;
|
var titleIds = string.Join(",", entry.Titles.Select(t => t.TitleId.ToString()));
|
||||||
|
_logger.LogInformation(cacheUpdated ? "Updated snapshot for {Path}, titleIds=[{TitleIds}]" : "Added {Path} to snapshot, titleIds=[{TitleIds}]", entry.Path, titleIds);
|
||||||
|
|
||||||
|
await PersistSnapshotAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==============================================================
|
/* ==============================================================
|
||||||
@@ -390,7 +411,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentHash = ComputeSnapshotHash(entries);
|
//var currentHash = ComputeSnapshotHash(entries);
|
||||||
if (entries.Count > 0 || fileInfo.Exists && index.Count == 0)
|
if (entries.Count > 0 || fileInfo.Exists && index.Count == 0)
|
||||||
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
@@ -421,7 +442,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
return fileInfo.LastWriteTimeUtc;
|
return fileInfo.LastWriteTimeUtc;
|
||||||
}))
|
}))
|
||||||
{
|
{
|
||||||
string hash;
|
string? hash;
|
||||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||||
|
|
||||||
if (!(_options.ArchiveExtensions.Contains(ext) || _options.RomExtensions.Contains(ext)))
|
if (!(_options.ArchiveExtensions.Contains(ext) || _options.RomExtensions.Contains(ext)))
|
||||||
@@ -437,7 +458,8 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
{
|
{
|
||||||
//var titleInfo = _titleDatabaseService.GetAsync(ncaMetadataWithHash.TitleId).Result;
|
//var titleInfo = _titleDatabaseService.GetAsync(ncaMetadataWithHash.TitleId).Result;
|
||||||
var fileEntryFromFileName = new FileEntry(file, fileInfo.Length, ncaMetadataWithHash.Hash, [ncaMetadataWithHash]);
|
var fileEntryFromFileName = new FileEntry(file, fileInfo.Length, ncaMetadataWithHash.Hash, [ncaMetadataWithHash]);
|
||||||
AddToSnapshotAsync(fileEntryFromFileName);
|
var addToSnapshotAsync = AddToSnapshotAsync(fileEntryFromFileName, cancellationToken);
|
||||||
|
addToSnapshotAsync.Wait(cancellationToken);
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
yield return fileEntryFromFileName;
|
yield return fileEntryFromFileName;
|
||||||
continue;
|
continue;
|
||||||
@@ -457,7 +479,8 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
if (title != null)
|
if (title != null)
|
||||||
{
|
{
|
||||||
var romEntry = new FileEntry(file, nspStreamLength, hash, [title]);
|
var romEntry = new FileEntry(file, nspStreamLength, hash, [title]);
|
||||||
AddToSnapshotAsync(romEntry);
|
var addToSnapshotAsync = AddToSnapshotAsync(romEntry, cancellationToken);
|
||||||
|
addToSnapshotAsync.Wait(cancellationToken);
|
||||||
titles.Add((title.TitleId, nspStreamLength, title));
|
titles.Add((title.TitleId, nspStreamLength, title));
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
yield return romEntry;
|
yield return romEntry;
|
||||||
@@ -471,15 +494,18 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
if (_archiveLookup.ContainsKey(file)) continue;
|
if (_archiveLookup.ContainsKey(file)) continue;
|
||||||
if (processedFiles.Contains(file)) continue;
|
if (processedFiles.Contains(file)) continue;
|
||||||
_logger.LogDebug("Extracting hash for {File}", file);
|
_logger.LogDebug("Extracting hash for {File}", file);
|
||||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
var stopwatch = Stopwatch.StartNew();
|
||||||
hash = ComputeFirstStreamHashAsync(file, cancellationToken).Result;
|
hash = ComputeFirstStreamHashAsync(file, cancellationToken).Result;
|
||||||
stopwatch.Stop();
|
stopwatch.Stop();
|
||||||
_logger.LogDebug("Computed hash for {File} in {Time}ms", file, stopwatch.ElapsedMilliseconds);
|
if (!string.IsNullOrEmpty(hash))
|
||||||
if (_hashCache.TryGetValue(hash, out var value) && file == _cache[value].Path)
|
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
_logger.LogDebug("Computed hash for {File} in {Time}ms", file, stopwatch.ElapsedMilliseconds);
|
||||||
yield return null;
|
if (_hashCache.TryGetValue(hash, out var value) && file == _cache[value].Path)
|
||||||
continue;
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
yield return null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IEnumerable<(string, long, NcaMetadataWithHash)>? titlesEnumerable = null;
|
IEnumerable<(string, long, NcaMetadataWithHash)>? titlesEnumerable = null;
|
||||||
@@ -509,7 +535,8 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
foreach (var title in titles)
|
foreach (var title in titles)
|
||||||
{
|
{
|
||||||
var archiveEntry = new FileEntry(file + ArchivePathSeparator + title.Item1, title.Item2, title.Item3.Hash, [title.Item3]);
|
var archiveEntry = new FileEntry(file + ArchivePathSeparator + title.Item1, title.Item2, title.Item3.Hash, [title.Item3]);
|
||||||
AddToSnapshotAsync(archiveEntry);
|
var addToSnapshotAsync = AddToSnapshotAsync(archiveEntry, cancellationToken);
|
||||||
|
addToSnapshotAsync.Wait(cancellationToken);
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
yield return archiveEntry;
|
yield return archiveEntry;
|
||||||
}
|
}
|
||||||
@@ -546,11 +573,15 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
}
|
}
|
||||||
catch (IOException ex) when (attempt < _options.MaxRetryCount - 1)
|
catch (IOException ex) when (attempt < _options.MaxRetryCount - 1)
|
||||||
{
|
{
|
||||||
var delay = (int)((attempt+1) * _options.DebounceTimeoutMs * _options.RetryMultiplier);
|
var delay = (int)((attempt + 1) * _options.DebounceTimeoutMs * _options.RetryMultiplier);
|
||||||
_logger.LogWarning(ex, "Attempt {Attempt} failed for {Path}. Retrying after {Delay}.",
|
_logger.LogWarning(ex, "Failed to load {Path}. Attempt {Attempt}, Retrying after {Delay}.",
|
||||||
attempt + 1, file, delay);
|
file, attempt + 1, delay);
|
||||||
await Task.Delay(delay, cancellationToken);
|
await Task.Delay(delay, cancellationToken);
|
||||||
}
|
}
|
||||||
|
catch (IOException) when (attempt >= _options.MaxRetryCount - 1)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Load {Path} failed after {retries} attempts", file, attempt + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -570,9 +601,11 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
|
|
||||||
private Task PersistSnapshotAsync(CancellationToken cancellationToken = default)
|
private Task PersistSnapshotAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
if (_debouncerCache.TryGetValue(_jsonPath, out _))
|
if (_debouncerCache.TryGetValue(_jsonPath, out _))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Sliding debounce in progress, skipping snapshot persistence");
|
_logger.LogDebug("Sliding debounce in progress, skipping snapshot persistence");
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,26 +630,25 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
if (reason is not (EvictionReason.Expired or EvictionReason.TokenExpired))
|
if (reason is not (EvictionReason.Expired or EvictionReason.TokenExpired))
|
||||||
return;
|
return;
|
||||||
var filePath = (string)key;
|
var filePath = (string)key;
|
||||||
if (_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout))
|
if (!_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout)) return;
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
try
|
if (FileLockHelper.IsFileLocked(filePath))
|
||||||
{
|
{
|
||||||
if (FileLockHelper.IsFileLocked(filePath))
|
_logger.LogInformation("File {FilePath} is locked, skipping snapshot persistence", filePath);
|
||||||
{
|
|
||||||
_logger.LogInformation("File {FilePath} is locked, skipping snapshot persistence", filePath);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
File.WriteAllText(filePath, JsonSerializer.Serialize(value, _jsonSerializerOptions));
|
|
||||||
_logger.LogInformation("Persisted snapshot to {FilePath}", filePath);
|
|
||||||
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
finally
|
else
|
||||||
{
|
{
|
||||||
_snapshotFileSemaphore.Release();
|
File.WriteAllText(filePath, JsonSerializer.Serialize(value, _jsonSerializerOptions));
|
||||||
|
_logger.LogInformation("Persisted snapshot to {FilePath}", filePath);
|
||||||
|
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_snapshotFileSemaphore.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -821,62 +853,71 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
|
|
||||||
// File: TinfoilVibeServer/Services/SnapshotService.cs (inside SnapshotService class)
|
// File: TinfoilVibeServer/Services/SnapshotService.cs (inside SnapshotService class)
|
||||||
|
|
||||||
private async Task<string> ComputeFirstStreamHashAsync(string filePath, CancellationToken cancellationToken = default)
|
private async Task<string?> ComputeFirstStreamHashAsync(string filePath, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
for (var attempt = 0; attempt < _options.MaxRetryCount; attempt++)
|
for (var attempt = 0; attempt < _options.MaxRetryCount; attempt++)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||||
|
|
||||||
|
var multiPartBasePathWithExtension = $"{MultiPartRarHelper.GetBaseNameForRarVolume(filePath)}{ext}";
|
||||||
|
if (string.CompareOrdinal(multiPartBasePathWithExtension, filePath) != 0)
|
||||||
|
{
|
||||||
|
filePath = multiPartBasePathWithExtension;
|
||||||
|
}
|
||||||
|
|
||||||
if (FileLockHelper.IsFileLocked(filePath))
|
if (FileLockHelper.IsFileLocked(filePath))
|
||||||
{
|
{
|
||||||
throw new IOException("File is locked");
|
throw new IOException("File is locked");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only treat NSP/XCI/XCZ as “first‑stream” files
|
// Only treat NSP/XCI/XCZ as “first‑stream” files
|
||||||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
|
||||||
if (ext is not ".nsp" and not ".xci" and not ".xcz")
|
if (ext is not ".nsp" and not ".xci" and not ".xcz")
|
||||||
{
|
{
|
||||||
// Open the NSP/XCI with LibHac and read the first stream.
|
// Open the NSP/XCI with LibHac and read the first stream.
|
||||||
// The first stream is the first entry returned by GetContentInfos().
|
// The first stream is the first entry returned by GetContentInfos().
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var reader = new RomArchiveReader(filePath);
|
await using var reader = new RomArchiveReader(filePath);
|
||||||
|
|
||||||
var first = reader.GetEntries().FirstOrDefault();
|
var first = reader.GetEntries().FirstOrDefault();
|
||||||
if (first == null) return ComputeFullHash(filePath);
|
if (first == null) return ComputeFullHash(filePath);
|
||||||
|
|
||||||
//using var seekableWrapper = new SeekableBufferedStream(first.Stream, first.Stream.Length, 10*1024*1024, true);
|
//using var seekableWrapper = new SeekableBufferedStream(first.Stream, first.Stream.Length, 10*1024*1024, true);
|
||||||
await using var rewindableWrapper = new RewindableStream(first.Stream, () => { return reader.GetEntries().FirstOrDefault().Stream; }, 10 * 1024 * 1024, first.Stream.Length);
|
await using var rewindableWrapper = new RewindableStream(first.Stream, () => first.Stream, 10 * 1024 * 1024, first.Stream.Length);
|
||||||
var hash = _nspExtractor.ExtractHashFromStream(rewindableWrapper);
|
var hash = _nspExtractor.ExtractHashFromStream(rewindableWrapper);
|
||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// On error, fall back to the full file hash
|
// ignored
|
||||||
await using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
||||||
var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs);
|
|
||||||
return ncaMetadataWithHash?.Hash ?? string.Empty;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
await using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
await using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs);
|
||||||
var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs);
|
return ncaMetadataWithHash?.Hash ?? string.Empty;
|
||||||
return ncaMetadataWithHash?.Hash ?? string.Empty;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (IOException ex) when (attempt < _options.MaxRetryCount - 1)
|
catch (IOException ex) when (attempt < _options.MaxRetryCount - 1)
|
||||||
{
|
{
|
||||||
var delay = (int)((attempt+1) * _options.DebounceTimeoutMs * _options.RetryMultiplier);
|
var delay = (int)((attempt + 1) * _options.DebounceTimeoutMs * _options.RetryMultiplier);
|
||||||
_logger.LogWarning(ex, "Attempt {Attempt} failed for {Path}. Retrying after {Delay}.",
|
_logger.LogWarning(ex, "Failed to load {Path}. Attempt {Attempt}, Retrying after {Delay}.",
|
||||||
attempt + 1, filePath, delay);
|
filePath, attempt + 1, delay);
|
||||||
await Task.Delay(delay, cancellationToken);
|
await Task.Delay(delay, cancellationToken);
|
||||||
}
|
}
|
||||||
|
catch (IOException) when (attempt >= _options.MaxRetryCount - 1)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Load {Path} failed after {retries} attempts", filePath, attempt + 1);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogError(exception, "Unknown exception: {exception}", exception.Message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
throw new IOException($"Failed to compute hash for {filePath} after {_options.MaxRetryCount} attempts");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ComputeFullHash(string filePath)
|
private static string ComputeFullHash(string filePath)
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ public sealed class RewindableStream : Stream
|
|||||||
get
|
get
|
||||||
{
|
{
|
||||||
EnsureLengthAsync(CancellationToken.None).GetAwaiter().GetResult();
|
EnsureLengthAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||||
return _length.Value;
|
if (_length != null) return _length.Value;
|
||||||
|
return -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +140,14 @@ public sealed class RewindableStream : Stream
|
|||||||
{
|
{
|
||||||
// We need the length first.
|
// We need the length first.
|
||||||
EnsureLengthAsync(CancellationToken.None).GetAwaiter().GetResult();
|
EnsureLengthAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||||
newPos = _length.Value + offset;
|
if (_length != null)
|
||||||
|
{
|
||||||
|
newPos = _length.Value + offset;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new NullReferenceException(nameof(_length));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user