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); } }