diff --git a/internal/core/local_service.go b/internal/core/local_service.go index 16b0a1bc..77711fcf 100644 --- a/internal/core/local_service.go +++ b/internal/core/local_service.go @@ -252,6 +252,7 @@ func (s *LocalDownloadService) reportProgressLoop() { Speed: currentSpeed, Elapsed: totalElapsed, ActiveConnections: int(connections), + RateLimited: cfg.State.RateLimited.Load(), } // Chunk snapshots are expensive due to bitmap/progress copies. diff --git a/internal/engine/events/events.go b/internal/engine/events/events.go index 13a4168e..ff1ec257 100644 --- a/internal/engine/events/events.go +++ b/internal/engine/events/events.go @@ -20,6 +20,7 @@ type ProgressMsg struct { BitmapWidth int ActualChunkSize int64 ChunkProgress []int64 + RateLimited bool } // DownloadCompleteMsg signals that the download finished successfully diff --git a/internal/engine/single/downloader.go b/internal/engine/single/downloader.go index e8399a2a..6aed5592 100644 --- a/internal/engine/single/downloader.go +++ b/internal/engine/single/downloader.go @@ -141,11 +141,17 @@ func (d *SingleDownloader) Download(ctx context.Context, rawurl, destPath string ra = 5 * time.Second } utils.Debug("Single downloader: rate limited (%d), waiting %v (retry %d/%d)", resp.StatusCode, ra, rlRetries, maxRlRetries) + if d.State != nil { + d.State.RateLimited.Store(true) + } select { case <-dlCtx.Done(): return dlCtx.Err() case <-time.After(ra): } + if d.State != nil { + d.State.RateLimited.Store(false) + } continue } diff --git a/internal/engine/types/progress.go b/internal/engine/types/progress.go index f130e458..6e4ae01a 100644 --- a/internal/engine/types/progress.go +++ b/internal/engine/types/progress.go @@ -22,6 +22,7 @@ type ProgressState struct { Error atomic.Pointer[error] Paused atomic.Bool Pausing atomic.Bool // Intermediate state: Pause requested but workers not yet exited + RateLimited atomic.Bool // Set when the downloader is backing off due to HTTP 429/rate-limit cancelFunc context.CancelFunc VerifiedProgress atomic.Int64 // Verified bytes written to disk (for UI progress) @@ -251,6 +252,7 @@ func (ps *ProgressState) SessionReset() { ps.Done.Store(false) ps.Paused.Store(false) ps.Pausing.Store(false) + ps.RateLimited.Store(false) ps.Error.Store(nil) // Clear mirrors error status diff --git a/internal/tui/model.go b/internal/tui/model.go index 03f0e262..8b589c99 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -109,9 +109,10 @@ type DownloadModel struct { done bool started bool // Engine has confirmed start err error - paused bool - pausing bool // UI state: transitioning to pause - resuming bool // UI state: waiting for async resume + paused bool + pausing bool // UI state: transitioning to pause + resuming bool // UI state: waiting for async resume + rateLimited bool } type RootModel struct { diff --git a/internal/tui/process.go b/internal/tui/process.go index b291c060..9453c57e 100644 --- a/internal/tui/process.go +++ b/internal/tui/process.go @@ -26,6 +26,7 @@ func (m *RootModel) processProgressMsg(msg events.ProgressMsg) tea.Cmd { d.Speed = msg.Speed d.Elapsed = msg.Elapsed d.Connections = msg.ActiveConnections + d.rateLimited = msg.RateLimited // Keep "Resuming..." visible until we observe actual transfer. if d.resuming && (d.Speed > 0 || d.Downloaded > prevDownloaded) { diff --git a/internal/tui/view.go b/internal/tui/view.go index dd3428bd..91e30af2 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -673,6 +673,9 @@ func renderFocusedDetails(d *DownloadModel, w int, spinnerView string) string { } else if d.resuming { speedStr = "Resuming..." etaStr = "..." + } else if d.rateLimited { + speedStr = "Rate limited, retrying..." + etaStr = "..." } else if d.paused || d.Speed == 0 { speedStr = "Paused" if d.RateLimitSet && d.RateLimit > 0 {