chore(deps): update all dependencies and GitHub Actions to latest

- Go deps: cobra 1.10.2, fatih/color 1.19, tablewriter 1.1.4,
  anacrolix/torrent 1.61, charmbracelet/huh 1.0, pion/webrtc 4.2.11
- GitHub Actions: checkout v6, setup-go v6, golangci-lint-action v9,
  codecov-action v5, ghaction-upx v4, goreleaser-action v7
- CI matrix: drop Go 1.22, test on 1.24 + 1.25
- Migrate tablewriter API from v0 to v1 (breaking change)
- Fix data race in WSTransport.readLoop (pass conn as parameter)
- Add file.Sync() before close in debrid and usenet downloaders
- Improve progress tracker: dedup MarkDone, re-mark dirty on flush error
This commit is contained in:
Deivid Soto 2026-03-28 21:50:10 +01:00
parent 719429b06e
commit c9bcb96dab
10 changed files with 346 additions and 352 deletions

View file

@ -91,6 +91,9 @@ func (d *Downloader) DownloadFile(ctx context.Context, file nzb.File, fileIndex
if _, statErr := os.Stat(destPath); statErr == nil && tracker.CompletedSegments(fileIndex) > 0 {
// Partial file exists and we have progress — open for read-write (no truncate)
outFile, err = os.OpenFile(destPath, os.O_RDWR, 0o644)
if err != nil {
return "", fmt.Errorf("open file for resume: %w", err)
}
resuming = true
}
}
@ -105,10 +108,13 @@ func (d *Downloader) DownloadFile(ctx context.Context, file nzb.File, fileIndex
if totalBytes > 0 {
outFile.Truncate(totalBytes)
}
} else if err != nil {
return "", fmt.Errorf("open file for resume: %w", err)
}
defer outFile.Close()
defer func() {
if err := outFile.Sync(); err != nil {
log.Printf("[usenet] sync warning: %v", err)
}
outFile.Close()
}()
// Download segments using worker pool
var downloaded atomic.Int64
@ -329,7 +335,10 @@ func (d *Downloader) DownloadNZB(ctx context.Context, n *nzb.NZB, outputDir stri
default:
}
fileIdx := nzbFileIndex[file.Subject]
fileIdx, ok := nzbFileIndex[file.Subject]
if !ok {
fileIdx = -1 // unknown index — tracker will treat as no-op
}
// Skip fully completed files
if tracker != nil && tracker.IsFileDone(fileIdx) {

View file

@ -43,7 +43,7 @@ type ProgressTracker struct {
dir string // directory where progress files are stored
files []fileProgress
mu sync.Mutex
mu sync.RWMutex
dirty bool
lastFlush time.Time
markCount int // marks since last flush
@ -185,12 +185,16 @@ func (p *ProgressTracker) MarkDone(fileIndex, segIndex int) {
}
p.mu.Lock()
fp.completed[segIndex/8] |= 1 << uint(segIndex%8)
fp.doneCount.Add(1)
p.dirty = true
p.markCount++
mask := byte(1 << uint(segIndex%8))
alreadyDone := fp.completed[segIndex/8]&mask != 0
if !alreadyDone {
fp.completed[segIndex/8] |= mask
fp.doneCount.Add(1)
p.dirty = true
p.markCount++
}
shouldFlush := p.markCount >= flushSegmentFreq || time.Since(p.lastFlush) >= flushInterval
shouldFlush := !alreadyDone && (p.markCount >= flushSegmentFreq || time.Since(p.lastFlush) >= flushInterval)
p.mu.Unlock()
if shouldFlush {
@ -207,10 +211,10 @@ func (p *ProgressTracker) IsDone(fileIndex, segIndex int) bool {
if segIndex < 0 || segIndex >= fp.segCount {
return false
}
// Read without lock — single-byte read is atomic on aligned data,
// and we only ever set bits (never clear), so a stale read just means
// we might re-download a segment (harmless, WriteAt is idempotent).
return fp.completed[segIndex/8]&(1<<uint(segIndex%8)) != 0
p.mu.RLock()
done := fp.completed[segIndex/8]&(1<<uint(segIndex%8)) != 0
p.mu.RUnlock()
return done
}
// IsFileDone returns true if all segments of a file are completed.
@ -294,16 +298,25 @@ func (p *ProgressTracker) Flush() error {
// Atomic write: tmp file + rename
if err := os.MkdirAll(p.dir, 0o755); err != nil {
p.mu.Lock()
p.dirty = true // re-mark so next Flush retries
p.mu.Unlock()
return fmt.Errorf("create resume dir: %w", err)
}
tmpPath := p.progressPath() + ".tmp"
if err := os.WriteFile(tmpPath, buf, 0o644); err != nil {
p.mu.Lock()
p.dirty = true
p.mu.Unlock()
return fmt.Errorf("write progress tmp: %w", err)
}
if err := os.Rename(tmpPath, p.progressPath()); err != nil {
os.Remove(tmpPath)
p.mu.Lock()
p.dirty = true
p.mu.Unlock()
return fmt.Errorf("rename progress: %w", err)
}