feat(downloads): pre-flight free-disk guard before each download (hueco medio)

CheckDiskSpace (internal/engine/diskspace.go) refuses a download before
writing when its expected size wouldn't leave a configurable reserve free,
so a download never fills the filesystem to 0 mid-write (which corrupts the
partial file). Wired into all three downloaders ahead of any write — torrent
(DataDir), debrid (outputDir, resume-aware), usenet (outputDir, fresh only).
Reserve from downloads.min_free_disk_mb (default 2048 MiB) via SetMinFreeBytes.

The manager treats an InsufficientDiskError as terminal — no source fallback,
since another source would fill the same disk — and surfaces the clear message.
Best-effort: unknown size or a stat failure doesn't block (ENOSPC stays the
backstop). Also hardens formatBytes against an exabyte-scale out-of-bounds panic.
This commit is contained in:
Deivid Soto 2026-05-31 21:48:34 +02:00
parent 2be92516c6
commit 1cad73b9a7
9 changed files with 196 additions and 4 deletions

View file

@ -90,8 +90,15 @@ type TorrentDownloader struct {
activeMu sync.Mutex
active map[string]*torrent.Torrent // taskID -> torrent handle
minFreeBytes int64 // disk reserve for the pre-flight space check (0 = reserve disabled)
}
// SetMinFreeBytes sets the free-space reserve enforced before a download starts.
// Call once at construction; 0 disables the reserve (the size-vs-free check still
// runs). See CheckDiskSpace.
func (d *TorrentDownloader) SetMinFreeBytes(n int64) { d.minFreeBytes = n }
// NewTorrentDownloader creates a BitTorrent downloader with a long-lived client.
func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
// MetadataTimeout: 0 = unlimited (wait forever like qBittorrent)
@ -340,6 +347,15 @@ func (d *TorrentDownloader) Download(ctx context.Context, task *Task, outputDir
log.Printf("[%s] downloading %s (%s)", task.ID[:8], fileName, formatBytes(totalBytes))
// 2.5 Pre-flight disk-space guard — refuse before writing rather than fill
// the disk to 0 mid-download (corrupts the partial file). Torrents land in
// DataDir (not the manager's outputDir), so stat DataDir. Conservative: uses
// the full selected size without subtracting pieces a resume may already hold.
if err := CheckDiskSpace(d.cfg.DataDir, totalBytes, d.minFreeBytes); err != nil {
cleanup()
return nil, err
}
// 3. Poll progress with stall detection
result, err := d.pollDownload(ctx, t, task, totalBytes, fileName, progressCh)
if err != nil {
@ -715,12 +731,15 @@ func formatBytes(b int64) string {
if b < unit {
return fmt.Sprintf("%d B", b)
}
// Cap exp at the last unit so an exabyte-scale value (or a corrupt/huge
// size) can never index past the slice and panic.
units := []string{"KB", "MB", "GB", "TB", "PB", "EB"}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
for n := b / unit; n >= unit && exp < len(units)-1; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %s", float64(b)/float64(div), []string{"KB", "MB", "GB", "TB"}[exp])
return fmt.Sprintf("%.1f %s", float64(b)/float64(div), units[exp])
}
// ---------------------------------------------------------------------------