#1836 - Fix misleading "Not enough disk space" warnings when Books and In progress are on different drives

This commit is contained in:
rmcrackan
2026-05-25 10:25:46 -04:00
parent a3f272a48e
commit f8c5f0da68
5 changed files with 239 additions and 48 deletions

View File

@@ -15,10 +15,21 @@ public static class DiskSpaceHelper
/// <summary>Conservative per-title estimate (download + decrypt temp + final file) for bulk preflight.</summary>
public const long EstimatedBytesPerAudiobookBackup = 400_000_000L;
/// <summary>Below this free space on a relevant drive, bulk backup is blocked (no Continue).</summary>
/// <summary>Below this free space on a Books drive, bulk backup is blocked (no Continue).</summary>
public const long CriticalFreeBytes = 100_000_000L;
/// <summary>Extra headroom required on the In progress drive beyond one active title.</summary>
public const long InProgressPreflightMarginBytes = 50_000_000L;
private const int HResultDiskFull = unchecked((int)0x80070070);
private const string WinLongPathPrefix = @"\\?\";
[Flags]
public enum BackupDriveUsage
{
InProgress = 1,
Books = 2,
}
public static bool IsDiskFullException(Exception? ex)
{
@@ -52,39 +63,92 @@ public static class DiskSpaceHelper
}
/// <summary>
/// Returns free bytes for the volume containing <paramref name="path"/>, or null if unknown.
/// Null means preflight cannot warn/block on that root (writable shares with no capacity API, offline drive, bad path).
/// On Windows, <see cref="Path.GetPathRoot"/> yields drive letters (C:\) or UNC roots (\\server\share\).
/// Strips the Win32 extended-length prefix so <see cref="DriveInfo"/> and path APIs see a normal root.
/// </summary>
public static long? TryGetAvailableFreeBytes(string? path)
public static string NormalizePathForDriveQuery(string path)
{
if (!path.StartsWith(WinLongPathPrefix, StringComparison.Ordinal))
return path;
var stripped = path[WinLongPathPrefix.Length..];
if (stripped.StartsWith(@"UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + stripped[4..];
return stripped;
}
/// <summary>
/// Returns the volume root used for free-space queries (e.g. C:\ or \\server\share\), or null if unknown.
/// </summary>
public static string? GetPathRootForDiskSpaceCheck(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return null;
try
{
var fullPath = Path.GetFullPath(path);
var normalized = NormalizePathForDriveQuery(path);
var fullPath = Path.GetFullPath(normalized);
var root = Path.GetPathRoot(fullPath);
if (string.IsNullOrWhiteSpace(root))
return null;
var drive = new DriveInfo(root);
// IsReady is false for disconnected network drives; AvailableFreeSpace may be wrong on some NAS reporting.
return drive.IsReady ? drive.AvailableFreeSpace : null;
return string.IsNullOrWhiteSpace(root) ? null : root;
}
catch
{
// DriveInfo can throw for invalid roots; treat as unknown rather than failing backup setup.
return null;
}
}
/// <summary>
/// Returns free bytes for the volume containing <paramref name="path"/>, or null if unknown.
/// Null means preflight cannot warn/block on that root (writable shares with no capacity API, offline drive, bad path).
/// </summary>
public static long? TryGetAvailableFreeBytes(string? path)
{
var root = GetPathRootForDiskSpaceCheck(path);
if (root is null)
return null;
try
{
var drive = new DriveInfo(root);
return drive.IsReady ? drive.AvailableFreeSpace : null;
}
catch
{
return null;
}
}
public static long GetRequiredBytesForDriveUsage(BackupDriveUsage usage, int bookCount)
{
var hasBooks = usage.HasFlag(BackupDriveUsage.Books);
var hasInProgress = usage.HasFlag(BackupDriveUsage.InProgress);
if (hasBooks)
return Math.Max(0, bookCount) * EstimatedBytesPerAudiobookBackup;
if (hasInProgress)
return EstimatedBytesPerAudiobookBackup;
return 0;
}
public static long GetCriticalFreeBytesForDriveUsage(BackupDriveUsage usage)
{
if (usage.HasFlag(BackupDriveUsage.Books))
return CriticalFreeBytes;
if (usage.HasFlag(BackupDriveUsage.InProgress))
return EstimatedBytesPerAudiobookBackup + InProgressPreflightMarginBytes;
return CriticalFreeBytes;
}
public static IReadOnlyList<BackupDriveSpace> GetBackupDriveSpaces(Configuration config, int bookCount)
{
var requiredBytes = Math.Max(0, bookCount) * EstimatedBytesPerAudiobookBackup;
var pathsByRoot = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
var pathsByRoot = new Dictionary<string, (List<string> paths, BackupDriveUsage usage)>(StringComparer.OrdinalIgnoreCase);
void addPath(string? path)
void addPath(string? path, BackupDriveUsage usageFlag)
{
if (string.IsNullOrWhiteSpace(path))
return;
@@ -92,7 +156,7 @@ public static class DiskSpaceHelper
string fullPath;
try
{
fullPath = Path.GetFullPath(path);
fullPath = Path.GetFullPath(NormalizePathForDriveQuery(path));
}
catch
{
@@ -103,31 +167,37 @@ public static class DiskSpaceHelper
if (string.IsNullOrWhiteSpace(root))
return;
// Same physical share via Z: vs \\server\share appears as two roots; each gets the full estimate (conservative).
if (!pathsByRoot.TryGetValue(root, out var list))
{
list = [];
pathsByRoot[root] = list;
}
if (!pathsByRoot.TryGetValue(root, out var entry))
entry = ([], usageFlag);
else
entry.usage |= usageFlag;
if (!list.Contains(fullPath, StringComparer.OrdinalIgnoreCase))
list.Add(fullPath);
if (!entry.paths.Contains(fullPath, StringComparer.OrdinalIgnoreCase))
entry.paths.Add(fullPath);
pathsByRoot[root] = entry;
}
addPath(config.Books?.Path);
addPath(config.InProgress);
addPath(config.Books?.Path, BackupDriveUsage.Books);
addPath(config.InProgress, BackupDriveUsage.InProgress);
return pathsByRoot
.Select(kvp => new BackupDriveSpace(
kvp.Key,
kvp.Value,
TryGetAvailableFreeBytes(kvp.Key),
requiredBytes))
.Select(kvp =>
{
var usage = kvp.Value.usage;
var required = GetRequiredBytesForDriveUsage(usage, bookCount);
return new BackupDriveSpace(
kvp.Key,
kvp.Value.paths,
TryGetAvailableFreeBytes(kvp.Key),
required,
usage);
})
.ToList();
}
/// <summary>
/// True when every root is unknown or has enough reported space. All-unknown => no preflight dialog.
/// True when every root is unknown or has enough reported space for its role. All-unknown => no preflight dialog.
/// </summary>
public static bool HasSufficientSpaceForBulkBackup(IReadOnlyList<BackupDriveSpace> drives)
=> drives.All(d => d.AvailableBytes is null || d.AvailableBytes >= d.RequiredBytes);
@@ -136,13 +206,14 @@ public static class DiskSpaceHelper
/// Only applies when free space was read successfully; unknown (null) never hard-blocks.
/// </summary>
public static bool AnyDriveCriticallyLow(IReadOnlyList<BackupDriveSpace> drives)
=> drives.Any(d => d.AvailableBytes is not null && d.AvailableBytes < CriticalFreeBytes);
=> drives.Any(d => d.AvailableBytes is not null && d.AvailableBytes < GetCriticalFreeBytesForDriveUsage(d.Usage));
public readonly record struct BackupDriveSpace(
/// <summary>Path root from <see cref="Path.GetPathRoot"/> (e.g. C:\ or \\nas\library\).</summary>
/// <summary>Volume root used for free-space display (e.g. C:\ or \\nas\library\).</summary>
string DriveRoot,
IReadOnlyList<string> Paths,
/// <summary>Null when <see cref="TryGetAvailableFreeBytes"/> could not query this root.</summary>
long? AvailableBytes,
long RequiredBytes);
long RequiredBytes,
BackupDriveUsage Usage);
}

View File

@@ -1,6 +1,5 @@
using LibationFileManager;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace LibationUiBase;
@@ -16,7 +15,7 @@ public static class DiskFullUserMessage
=> """
Libation stopped the backup queue because the disk ran out of free space.
Download and decrypt use temporary files under your "In progress" folder, then write finished audiobooks to your Books location. Both need enough free space on their drives.
Download and decrypt use temporary files under your "In progress" folder, then write finished audiobooks to your Books location. Those folders can be on different drives, and each needs enough free space for its role.
Free disk space (or move Books / In progress to a larger drive in Settings), delete partial files under your In progress folder if needed, then retry your backups in smaller batches.
""";
@@ -26,6 +25,8 @@ public static class DiskFullUserMessage
var sb = new StringBuilder();
sb.AppendLine($"You are about to back up {bookCount} books, but a drive Libation uses does not have enough free space to continue safely.");
sb.AppendLine();
sb.AppendLine("Books is where finished audiobooks are stored. In progress holds temporary files while one title downloads and decrypts (about one book at a time, often on a different drive than Books).");
sb.AppendLine();
AppendDriveLines(sb, drives);
sb.AppendLine();
sb.Append("Free space or change the Books and In progress locations in Settings, then try again.");
@@ -35,7 +36,9 @@ public static class DiskFullUserMessage
public static string BuildPreflightWarningBody(IReadOnlyList<DiskSpaceHelper.BackupDriveSpace> drives, int bookCount)
{
var sb = new StringBuilder();
sb.AppendLine($"You are about to back up {bookCount} books. Libation estimates you may need on the order of {FormatBytes(bookCount * DiskSpaceHelper.EstimatedBytesPerAudiobookBackup)} total, plus extra room for temporary files during each download.");
sb.AppendLine($"You are about to back up {bookCount} books. Libation estimates you may need on the order of {FormatBytes(bookCount * DiskSpaceHelper.EstimatedBytesPerAudiobookBackup)} on your Books drive for the finished files, plus about {FormatBytes(DiskSpaceHelper.EstimatedBytesPerAudiobookBackup)} free on your In progress drive at a time for temporary download/decrypt files.");
sb.AppendLine();
sb.AppendLine("Books and In progress can be on different drives. A warning below for your In progress drive does not necessarily mean your Books location is full.");
sb.AppendLine();
AppendDriveLines(sb, drives);
sb.AppendLine();
@@ -47,15 +50,32 @@ public static class DiskFullUserMessage
{
foreach (var drive in drives)
{
// "unknown" when DriveInfo could not report space (typical for some network paths); user may still have chosen Continue.
var free = drive.AvailableBytes is null ? "unknown" : FormatBytes(drive.AvailableBytes.Value);
var needed = FormatBytes(drive.RequiredBytes);
sb.AppendLine($"{drive.DriveRoot} Free: {free} Estimated needed: {needed}");
var role = DescribeDriveUsage(drive.Usage);
sb.AppendLine($"{drive.DriveRoot} ({role}) Free: {free} Estimated needed: {needed}");
foreach (var path in drive.Paths)
sb.AppendLine($" {path}");
}
}
private static string DescribeDriveUsage(DiskSpaceHelper.BackupDriveUsage usage)
{
var hasBooks = usage.HasFlag(DiskSpaceHelper.BackupDriveUsage.Books);
var hasInProgress = usage.HasFlag(DiskSpaceHelper.BackupDriveUsage.InProgress);
if (hasBooks && hasInProgress)
return "Books + In progress";
if (hasBooks)
return "Books";
if (hasInProgress)
return "In progress";
return "Libation paths";
}
private static string FormatBytes(long bytes)
{
const long gb = 1024L * 1024 * 1024;

View File

@@ -1,6 +1,5 @@
using LibationFileManager;
using LibationUiBase.Forms;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace LibationUiBase;
@@ -13,18 +12,29 @@ public static class DiskSpaceBackupPreflight
{
private const int BulkBackupBookThreshold = 2;
public static async Task<bool> ConfirmBulkBackupAsync(int bookCount, Configuration config)
private static bool bulkPreflightConfirmedForQueueRun;
/// <summary>Clears the per-queue-run preflight flag when the backup queue finishes.</summary>
public static void ResetBulkPreflightForQueueRun() => bulkPreflightConfirmedForQueueRun = false;
public static async Task<bool> ConfirmBulkBackupAsync(
int bookCount,
Configuration config,
bool backupQueueAlreadyRunning = false)
{
if (bookCount < BulkBackupBookThreshold)
if (bookCount < BulkBackupBookThreshold
|| backupQueueAlreadyRunning
|| bulkPreflightConfirmedForQueueRun)
return true;
var drives = DiskSpaceHelper.GetBackupDriveSpaces(config, bookCount);
// All roots unknown, or all have enough reported space: queue without prompting.
if (DiskSpaceHelper.HasSufficientSpaceForBulkBackup(drives))
{
bulkPreflightConfirmedForQueueRun = true;
return true;
}
// Known space below CriticalFreeBytes: do not offer Continue (avoids starting a huge queue on a full local disk).
if (DiskSpaceHelper.AnyDriveCriticallyLow(drives))
{
await MessageBoxBase.Show(
@@ -35,7 +45,6 @@ public static class DiskSpaceBackupPreflight
return false;
}
// At least one root reported space below estimate but above critical (or mixed known/unknown with a shortfall).
var result = await MessageBoxBase.Show(
DiskFullUserMessage.BuildPreflightWarningBody(drives, bookCount),
DiskFullUserMessage.DialogCaption,
@@ -43,6 +52,9 @@ public static class DiskSpaceBackupPreflight
MessageBoxIcon.Warning,
MessageBoxDefaultButton.Button2);
if (result == DialogResult.Yes)
bulkPreflightConfirmedForQueueRun = true;
return result == DialogResult.Yes;
}
}

View File

@@ -188,7 +188,7 @@ public class ProcessQueueViewModel : ReactiveObject
if (toLiberate.Length > 0)
{
// May no-op when free space is unknown (common on UNC); see DiskSpaceBackupPreflight.
if (!await DiskSpaceBackupPreflight.ConfirmBulkBackupAsync(toLiberate.Length, config))
if (!await DiskSpaceBackupPreflight.ConfirmBulkBackupAsync(toLiberate.Length, config, backupQueueAlreadyRunning: Running))
return false;
Serilog.Log.Logger.Information("Begin backup of {count} library books", toLiberate.Length);
@@ -372,6 +372,10 @@ public class ProcessQueueViewModel : ReactiveObject
{
Serilog.Log.Logger.Error(ex, "An error was encountered while processing queued items");
}
finally
{
DiskSpaceBackupPreflight.ResetBulkPreflightForQueueRun();
}
string timeToStr(TimeSpan time)
=> time.TotalHours < 1 ? $"{time:mm\\:ss}"

View File

@@ -31,4 +31,88 @@ public class DiskSpaceHelperTests
Assert.IsTrue(DiskSpaceHelper.ErrorMessageIndicatesDiskFull("No space left on device"));
Assert.IsFalse(DiskSpaceHelper.ErrorMessageIndicatesDiskFull("Unable to read beyond the end of the stream."));
}
[TestMethod]
public void NormalizePathForDriveQuery_strips_extended_prefix()
{
Assert.AreEqual(@"C:\Audiobooks\Books", DiskSpaceHelper.NormalizePathForDriveQuery(@"\\?\C:\Audiobooks\Books"));
Assert.AreEqual(@"\\server\share\Books", DiskSpaceHelper.NormalizePathForDriveQuery(@"\\?\UNC\server\share\Books"));
}
[TestMethod]
public void GetPathRootForDiskSpaceCheck_strips_extended_prefix()
{
Assert.AreEqual(@"C:\", DiskSpaceHelper.GetPathRootForDiskSpaceCheck(@"\\?\C:\Audiobooks\Books"));
}
[TestMethod]
public void GetRequiredBytesForDriveUsage_uses_batch_for_books_only()
{
const int bookCount = 500;
var expected = bookCount * DiskSpaceHelper.EstimatedBytesPerAudiobookBackup;
Assert.AreEqual(expected, DiskSpaceHelper.GetRequiredBytesForDriveUsage(DiskSpaceHelper.BackupDriveUsage.Books, bookCount));
Assert.AreEqual(
expected,
DiskSpaceHelper.GetRequiredBytesForDriveUsage(DiskSpaceHelper.BackupDriveUsage.Books | DiskSpaceHelper.BackupDriveUsage.InProgress, bookCount));
}
[TestMethod]
public void GetRequiredBytesForDriveUsage_uses_single_title_for_in_progress_only()
{
Assert.AreEqual(
DiskSpaceHelper.EstimatedBytesPerAudiobookBackup,
DiskSpaceHelper.GetRequiredBytesForDriveUsage(DiskSpaceHelper.BackupDriveUsage.InProgress, 500));
}
[TestMethod]
public void GetCriticalFreeBytesForDriveUsage_in_progress_requires_one_title_plus_margin()
{
Assert.AreEqual(
DiskSpaceHelper.EstimatedBytesPerAudiobookBackup + DiskSpaceHelper.InProgressPreflightMarginBytes,
DiskSpaceHelper.GetCriticalFreeBytesForDriveUsage(DiskSpaceHelper.BackupDriveUsage.InProgress));
Assert.AreEqual(
DiskSpaceHelper.CriticalFreeBytes,
DiskSpaceHelper.GetCriticalFreeBytesForDriveUsage(DiskSpaceHelper.BackupDriveUsage.Books));
}
[TestMethod]
public void HasSufficientSpaceForBulkBackup_in_progress_not_judged_by_batch_estimate()
{
var drives = new[]
{
new DiskSpaceHelper.BackupDriveSpace(
@"D:\",
[@"D:\Books"],
500L * 1024 * 1024 * 1024,
500L * DiskSpaceHelper.EstimatedBytesPerAudiobookBackup,
DiskSpaceHelper.BackupDriveUsage.Books),
new DiskSpaceHelper.BackupDriveSpace(
@"C:\",
[@"C:\Temp\Libation-user"],
600L * 1024 * 1024,
DiskSpaceHelper.EstimatedBytesPerAudiobookBackup,
DiskSpaceHelper.BackupDriveUsage.InProgress),
};
Assert.IsTrue(DiskSpaceHelper.HasSufficientSpaceForBulkBackup(drives));
}
[TestMethod]
public void AnyDriveCriticallyLow_in_progress_uses_single_title_threshold()
{
var drives = new[]
{
new DiskSpaceHelper.BackupDriveSpace(
@"C:\",
[@"C:\Temp\Libation-user"],
200L * 1024 * 1024,
DiskSpaceHelper.EstimatedBytesPerAudiobookBackup,
DiskSpaceHelper.BackupDriveUsage.InProgress),
};
Assert.IsTrue(DiskSpaceHelper.AnyDriveCriticallyLow(drives));
Assert.IsFalse(DiskSpaceHelper.HasSufficientSpaceForBulkBackup(drives));
}
}