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

@ -42,8 +42,15 @@ type UsenetDownloader struct {
// Cached NZB search results (from Available → Download)
nzbCache map[string]*agent.NzbSearchResult // taskID → best result
nzbCacheMu sync.RWMutex
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 (u *UsenetDownloader) SetMinFreeBytes(n int64) { u.minFreeBytes = n }
// NewUsenetDownloader creates a usenet downloader.
// apiClient is used to call the web API for NZB search, download, and credentials.
func NewUsenetDownloader(apiClient *agent.Client) *UsenetDownloader {
@ -171,6 +178,12 @@ func (u *UsenetDownloader) Download(ctx context.Context, task *Task, outputDir s
if resumed {
log.Printf("[%s] resuming usenet download (%d/%d segments completed)",
shortID, tracker.TotalCompleted(), totalSegs)
} else {
// Pre-flight disk-space guard on a fresh download (a resume already has
// its partial bytes on disk; ENOSPC stays the backstop there).
if err := CheckDiskSpace(outputDir, totalBytes, u.minFreeBytes); err != nil {
return nil, err
}
}
// Always flush progress on exit — covers graceful shutdown, SIGTERM,