diff --git a/CompactGUI.Core/Compactor.cs b/CompactGUI.Core/Compactor.cs index 282235d..6ad36d1 100644 --- a/CompactGUI.Core/Compactor.cs +++ b/CompactGUI.Core/Compactor.cs @@ -13,11 +13,14 @@ namespace CompactGUI.Core; public sealed class Compactor : ICompressor, IDisposable { + private const int HResultCompressionNotBeneficial = unchecked((int)0x80070158); + private readonly string workingDirectory; private readonly HashSet excludedFileExtensions; private readonly HashSet resumeExcludedFiles; private readonly WOFCompressionAlgorithm wofCompressionAlgorithm; private readonly CompressionProgressBaseline progressBaseline; + private readonly CompressionNotBeneficialCache notBeneficialCache = CompressionNotBeneficialCache.Shared; private IntPtr compressionInfoPtr; @@ -273,6 +276,11 @@ private unsafe FileOperationResult WOFCompressFile(string filePath) if (result == 0) return new FileOperationResult(true); + if (result == HResultCompressionNotBeneficial) + { + notBeneficialCache.Record(filePath, wofCompressionAlgorithm); + } + string failureReason = Marshal.GetExceptionForHR(result)?.Message ?? $"Windows returned HRESULT 0x{result:X8}."; CompactorLog.FileCompressionFailed(_logger, filePath, failureReason); @@ -305,6 +313,7 @@ public async Task> BuildWorkingFilesList(IReadOnlyColle .Where(fl => fl.CompressionMode != wofCompressionAlgorithm && !resumeExcludedFiles.Contains(fl.FileName) + && !notBeneficialCache.ShouldSkip(fl.FileName, wofCompressionAlgorithm) && fl.UncompressedSize > clusterSize && ((fl.FileInfo != null && !excludedFileExtensions.Contains(fl.FileInfo.Extension)) || excludedFileExtensions.Contains(fl.FileName)) ) diff --git a/CompactGUI.Core/CompressionNotBeneficialCache.cs b/CompactGUI.Core/CompressionNotBeneficialCache.cs new file mode 100644 index 0000000..cb33794 --- /dev/null +++ b/CompactGUI.Core/CompressionNotBeneficialCache.cs @@ -0,0 +1,186 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace CompactGUI.Core; + +/// +/// Remembers unchanged files for which Windows reported that WOF compression +/// would not save disk space. Entries are algorithm-specific and become stale +/// automatically when the file size or last-write timestamp changes. +/// +public sealed class CompressionNotBeneficialCache +{ + private const string CacheFileName = "compression-not-beneficial.json"; + + private readonly object syncRoot = new(); + private readonly FileInfo cacheFile; + private readonly JsonSerializerOptions jsonOptions = new() { WriteIndented = true }; + private readonly Dictionary entries; + + public static CompressionNotBeneficialCache Shared { get; } = new(); + + private CompressionNotBeneficialCache() + { + DirectoryInfo dataFolder = new(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "IridiumIO", + "CompactGUI")); + + cacheFile = new FileInfo(Path.Combine(dataFolder.FullName, CacheFileName)); + entries = LoadEntries(); + } + + public bool ShouldSkip(string filePath, WOFCompressionAlgorithm algorithm) + { + string normalizedPath = NormalizePath(filePath); + string key = BuildKey(normalizedPath, algorithm); + + lock (syncRoot) + { + if (!entries.TryGetValue(key, out CompressionNotBeneficialCacheEntry? entry)) + return false; + + try + { + FileInfo file = new(normalizedPath); + if (file.Exists + && file.Length == entry.Length + && file.LastWriteTimeUtc.Ticks == entry.LastWriteTimeUtcTicks) + { + return true; + } + } + catch (Exception ex) + { + Trace.WriteLine($"Unable to validate compression cache entry for '{normalizedPath}': {ex.Message}"); + } + + // The file disappeared or changed. Forget the old result so a future + // file at this path is allowed to try compression again. + if (entries.Remove(key)) + WriteEntries(); + + return false; + } + } + + public void Record(string filePath, WOFCompressionAlgorithm algorithm) + { + string normalizedPath = NormalizePath(filePath); + + try + { + FileInfo file = new(normalizedPath); + if (!file.Exists) return; + + CompressionNotBeneficialCacheEntry entry = new() + { + FilePath = normalizedPath, + Algorithm = algorithm, + Length = file.Length, + LastWriteTimeUtcTicks = file.LastWriteTimeUtc.Ticks + }; + + lock (syncRoot) + { + string key = BuildKey(normalizedPath, algorithm); + if (entries.TryGetValue(key, out CompressionNotBeneficialCacheEntry? existing) + && existing.Length == entry.Length + && existing.LastWriteTimeUtcTicks == entry.LastWriteTimeUtcTicks) + { + return; + } + + entries[key] = entry; + WriteEntries(); + } + } + catch (Exception ex) + { + Trace.WriteLine($"Unable to record non-beneficial compression result for '{normalizedPath}': {ex.Message}"); + } + } + + private Dictionary LoadEntries() + { + Dictionary loaded = new(StringComparer.OrdinalIgnoreCase); + + try + { + if (!cacheFile.Directory!.Exists) + cacheFile.Directory.Create(); + + if (!cacheFile.Exists || cacheFile.Length == 0) + return loaded; + + string json = File.ReadAllText(cacheFile.FullName); + List? savedEntries = + JsonSerializer.Deserialize>(json, jsonOptions); + + if (savedEntries is null) return loaded; + + foreach (CompressionNotBeneficialCacheEntry entry in savedEntries) + { + if (string.IsNullOrWhiteSpace(entry.FilePath)) continue; + + string normalizedPath = NormalizePath(entry.FilePath); + entry.FilePath = normalizedPath; + loaded[BuildKey(normalizedPath, entry.Algorithm)] = entry; + } + } + catch (Exception ex) + { + Trace.WriteLine($"Unable to load compression-not-beneficial cache: {ex.Message}"); + } + + return loaded; + } + + private void WriteEntries() + { + try + { + if (!cacheFile.Directory!.Exists) + cacheFile.Directory.Create(); + + string json = JsonSerializer.Serialize( + entries.Values + .OrderBy(entry => entry.FilePath, StringComparer.OrdinalIgnoreCase) + .ThenBy(entry => entry.Algorithm) + .ToList(), + jsonOptions); + + string tempPath = cacheFile.FullName + ".tmp"; + File.WriteAllText(tempPath, json); + File.Move(tempPath, cacheFile.FullName, true); + cacheFile.Refresh(); + } + catch (Exception ex) + { + Trace.WriteLine($"Unable to save compression-not-beneficial cache: {ex.Message}"); + } + } + + private static string BuildKey(string normalizedPath, WOFCompressionAlgorithm algorithm) + => $"{normalizedPath}|{(int)algorithm}"; + + private static string NormalizePath(string filePath) + { + try + { + return Path.GetFullPath(filePath); + } + catch + { + return filePath; + } + } +} + +public sealed class CompressionNotBeneficialCacheEntry +{ + public string FilePath { get; set; } = string.Empty; + public WOFCompressionAlgorithm Algorithm { get; set; } + public long Length { get; set; } + public long LastWriteTimeUtcTicks { get; set; } +}