using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace LibationFileManager;
///
/// Detects disk-full I/O failures and reports free space for Libation backup paths.
/// Preflight uses when available; runtime detection uses actual write failures
/// (works even when free space cannot be queried, e.g. some UNC/SMB shares).
///
public static class DiskSpaceHelper
{
/// Conservative per-title estimate (download + decrypt temp + final file) for bulk preflight.
public const long EstimatedBytesPerAudiobookBackup = 400_000_000L;
/// Below this free space on a Books drive, bulk backup is blocked (no Continue).
public const long CriticalFreeBytes = 100_000_000L;
/// Extra headroom required on the In progress drive beyond one active title.
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,
}
/// Single byte formatter for user-facing copy, so free space and download limits read the same way.
public static string FormatBytes(long bytes)
{
const long gb = 1024L * 1024 * 1024;
if (bytes >= gb)
return $"{bytes / (double)gb:F1} GB";
const long mb = 1024 * 1024;
return $"{bytes / (double)mb:F0} MB";
}
public static bool IsDiskFullException(Exception? ex)
{
for (var current = ex; current is not null; current = current.InnerException)
{
if (current is IOException && current.HResult == HResultDiskFull)
return true;
if (ErrorMessageIndicatesDiskFull(current.Message))
return true;
}
return false;
}
///
/// Matches disk-full and common quota-exceeded text from logs and StatusHandler errors.
///
public static bool ErrorMessageIndicatesDiskFull(string? message)
{
if (string.IsNullOrWhiteSpace(message))
return false;
return message.Contains("not enough space on the disk", StringComparison.OrdinalIgnoreCase)
|| message.Contains("disk was full", StringComparison.OrdinalIgnoreCase)
|| message.Contains("there is not enough space on the disk", StringComparison.OrdinalIgnoreCase)
|| message.Contains("no space left on device", StringComparison.OrdinalIgnoreCase)
|| message.Contains("disk quota", StringComparison.OrdinalIgnoreCase)
|| message.Contains("quota exceeded", StringComparison.OrdinalIgnoreCase)
|| message.Contains("storage quota", StringComparison.OrdinalIgnoreCase);
}
///
/// Strips the Win32 extended-length prefix so and path APIs see a normal root.
/// No-op on non-Windows platforms (Libation only uses the prefix there).
///
public static string NormalizePathForDriveQuery(string path)
{
if (!OperatingSystem.IsWindows() || !path.StartsWith(WinLongPathPrefix, StringComparison.Ordinal))
return path;
var stripped = path[WinLongPathPrefix.Length..];
if (stripped.StartsWith(@"UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + stripped[4..];
return stripped;
}
///
/// Returns the volume root used for free-space queries and drive grouping
/// (e.g. C:\, \\server\share\, or a Unix mount such as /var/home), or null if unknown.
/// On Unix, is not used: it always returns / for absolute paths and
/// mis-attributes free space when Books/In progress live on another mount (e.g. Bazzite /var/home).
///
public static string? GetPathRootForDiskSpaceCheck(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return null;
try
{
var normalized = NormalizePathForDriveQuery(path);
var fullPath = Path.GetFullPath(normalized);
if (OperatingSystem.IsWindows())
{
var root = Path.GetPathRoot(fullPath);
return string.IsNullOrWhiteSpace(root) ? null : root;
}
return GetUnixMountPoint(fullPath);
}
catch
{
return null;
}
}
///
/// Resolves symlinks in component-by-component so that e.g.
/// /home/user/Books becomes /var/home/user/Books when /home → /var/home.
/// Non-existent trailing segments are kept so a not-yet-created Books folder still resolves.
///
public static string ResolvePathSymlinks(string path)
{
var fullPath = Path.GetFullPath(path);
if (OperatingSystem.IsWindows())
return fullPath;
var parts = fullPath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
var resolved = "/";
foreach (var part in parts)
{
var next = resolved == "/"
? "/" + part
: resolved + Path.DirectorySeparatorChar + part;
try
{
var dirInfo = new DirectoryInfo(next);
if (dirInfo.Exists)
{
var target = dirInfo.ResolveLinkTarget(returnFinalTarget: true);
resolved = target?.FullName ?? dirInfo.FullName;
continue;
}
var fileInfo = new FileInfo(next);
if (fileInfo.Exists)
{
var target = fileInfo.ResolveLinkTarget(returnFinalTarget: true);
resolved = target?.FullName ?? fileInfo.FullName;
continue;
}
}
catch
{
// Keep syntactic path when link resolution fails for a segment.
}
resolved = next;
}
return resolved;
}
///
/// Pure helper: longest mount-point prefix of from .
/// Used for Unix volume identity and unit tests (injectable mount list).
///
public static string? FindLongestMountPointPrefix(string fullPath, IEnumerable mountPoints)
{
if (string.IsNullOrWhiteSpace(fullPath))
return null;
string? best = null;
foreach (var mount in mountPoints)
{
if (string.IsNullOrWhiteSpace(mount))
continue;
if (!IsPathOnMount(fullPath, mount))
continue;
if (best is null || mount.Length > best.Length)
best = mount;
}
return best;
}
///
/// Returns free bytes for the volume containing , or null if unknown.
/// Null means preflight cannot warn/block on that root (writable shares with no capacity API, offline drive, bad path).
///
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 GetBackupDriveSpaces(Configuration config, int bookCount)
{
var pathComparer = OperatingSystem.IsWindows()
? StringComparer.OrdinalIgnoreCase
: StringComparer.Ordinal;
var pathsByRoot = new Dictionary paths, BackupDriveUsage usage)>(pathComparer);
void addPath(string? path, BackupDriveUsage usageFlag)
{
if (string.IsNullOrWhiteSpace(path))
return;
string fullPath;
try
{
fullPath = Path.GetFullPath(NormalizePathForDriveQuery(path));
}
catch
{
return;
}
var root = GetPathRootForDiskSpaceCheck(fullPath);
if (string.IsNullOrWhiteSpace(root))
return;
if (!pathsByRoot.TryGetValue(root, out var entry))
entry = ([], usageFlag);
else
entry.usage |= usageFlag;
if (!entry.paths.Contains(fullPath, pathComparer))
entry.paths.Add(fullPath);
pathsByRoot[root] = entry;
}
addPath(config.Books?.Path, BackupDriveUsage.Books);
addPath(config.InProgress, BackupDriveUsage.InProgress);
return pathsByRoot
.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();
}
///
/// True when every root is unknown or has enough reported space for its role. All-unknown => no preflight dialog.
///
public static bool HasSufficientSpaceForBulkBackup(IReadOnlyList drives)
=> drives.All(d => d.AvailableBytes is null || d.AvailableBytes >= d.RequiredBytes);
///
/// Only applies when free space was read successfully; unknown (null) never hard-blocks.
///
public static bool AnyDriveCriticallyLow(IReadOnlyList drives)
=> drives.Any(d => d.AvailableBytes is not null && d.AvailableBytes < GetCriticalFreeBytesForDriveUsage(d.Usage));
public readonly record struct BackupDriveSpace(
/// Volume root used for free-space display (e.g. C:\ , \\nas\library\ , or /var/home).
string DriveRoot,
IReadOnlyList Paths,
/// Null when could not query this root.
long? AvailableBytes,
long RequiredBytes,
BackupDriveUsage Usage);
private static string? GetUnixMountPoint(string fullPath)
{
var resolved = ResolvePathSymlinks(fullPath);
var mounts = DriveInfo.GetDrives()
.Where(static d => d.IsReady)
.Select(static d => d.Name);
return FindLongestMountPointPrefix(resolved, mounts)
?? Path.GetPathRoot(resolved);
}
///
/// Unix mount paths always use '/'. Do not use —
/// on Windows that is '\', which would break pure unit tests and any cross-OS path handling.
///
private static bool IsPathOnMount(string fullPath, string mount)
{
const char unixSep = '/';
var mountTrimmed = mount.TrimEnd(unixSep);
if (mountTrimmed.Length == 0)
mountTrimmed = "/";
if (fullPath.Equals(mountTrimmed, StringComparison.Ordinal))
return true;
// Root mount "/" prefixes every absolute Unix path.
if (mountTrimmed == "/")
return fullPath.StartsWith("/", StringComparison.Ordinal);
var prefix = mount.EndsWith(unixSep) ? mount : mount + unixSep;
return fullPath.StartsWith(prefix, StringComparison.Ordinal);
}
}