Skip to content
29 changes: 28 additions & 1 deletion docs/src/content/docs/guides/repair.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ You can force a recheck on any one entry from the Browse UI or the API. This wor
"auto_repair": true,
"skip_nzb_repair": false,
"notify_on_complete": false,
"nntp_connection_percent": 20
"nntp_connection_percent": 20,
"stop_schedule": "06:00"
}
}
```
Expand All @@ -74,6 +75,32 @@ You can force a recheck on any one entry from the Browse UI or the API. This wor
| `skip_nzb_repair` | Skip NZB / Usenet entries during scheduled sweeps. | `false` |
| `notify_on_complete` | Send a notification when a sweep finishes. | `false` |
| `nntp_connection_percent` | Percentage of NNTP connections the probe is allowed to use. Avoids starving downloads. | `20` |
| `stop_schedule` | Optional cron expression, clock time, or interval. If a repair sweep is still running when this fires, it's stopped early. Leave empty to always run to completion. | — |

### Stop schedule

`stop_schedule` is useful when a repair sweep needs to finish (or get out of the way) before something else happens — for example, before your overnight maintenance window ends, or before peak-usage hours when probes would compete for bandwidth.

- The repair sweep is cancelled at the next firing of `stop_schedule`, mid-probe if necessary. Entries not yet probed are left as they were before this repair sweep (their existing health and `next_check_due_at` are untouched, so they're picked up again on the next scheduled run).
- The run is recorded as `completed` (not `cancelled`), with `cancel_reason` noting it was stopped by schedule.
- What happens to entries this repair sweep *did* manage to probe and found broken is controlled by `auto_repair`, same as any other repair sweep:
- `auto_repair: true` repairs them via the normal Arr delete + re-search flow.
- `auto_repair: false` leaves them recorded as broken without triggering Arr repair.

#### Multi-day coverage

Each repair sweep probes due entries **oldest-checked-first** (never-checked entries first, then least-recently-checked, ...). Probing an entry updates its `last_checked_at` immediately, which moves it to the back of the queue for future repair sweeps.

This means a repair sweep scheduled daily with a `stop_schedule` (e.g. `schedule: "0 0 * * *"`, `stop_schedule: "0 9 * * *"`) makes guaranteed forward progress across days, as long as `recheck_interval` is comfortably longer than the time it takes to cycle through the whole library:

- Day 1, 00:00–09:00: probes the oldest N due entries (or however many fit in 9 hours), marking each one's `last_checked_at` as it finishes.
- Day 1, 09:00: `stop_schedule` fires, the repair sweep stops. The remaining entries are untouched and stay oldest in the queue.
- Day 2, 00:00: the next repair sweep's `due` set excludes everything probed on day 1 (their `last_checked_at` is now within `recheck_interval`), so it continues with the next-oldest N.
- This repeats until the whole library has been probed within one `recheck_interval` window, then the cycle restarts from the oldest again.

If a full repair sweep would take 2 days at 9 hours/day but `recheck_interval` is `168h` (1 week), this comfortably completes a full pass well before entries become due again. If `recheck_interval` is shorter than the time a full cycle actually takes, some entries may go stale before they're re-probed — increase `recheck_interval`, the daily window, or `workers` to compensate.



### Strategies

Expand Down
37 changes: 26 additions & 11 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,22 +191,37 @@ const (
// When Enabled is true, a recurring sweep runs on Schedule and visits only
// entries that are unhealthy, dirty, or older than RecheckInterval.
type RepairConfig struct {
Enabled bool `json:"enabled,omitempty"`
Source RepairSource `json:"source,omitempty"`
Schedule string `json:"schedule,omitempty"`
Workers int `json:"workers,omitempty"`
NNTPConnectionPercent int `json:"nntp_connection_percent,omitempty"`
Strategy string `json:"strategy,omitempty"`
RecheckInterval string `json:"recheck_interval,omitempty"`
Arrs []string `json:"arrs,omitempty"`
AutoRepair bool `json:"auto_repair,omitempty"`
SkipNZBRepair bool `json:"skip_nzb_repair,omitempty"`
Enabled bool `json:"enabled,omitempty"`
Source RepairSource `json:"source,omitempty"`
Schedule string `json:"schedule,omitempty"`
Workers int `json:"workers,omitempty"`
// CleanupSuperseded, when true, also DELETES a broken entry from decypharr (not just from the
// broken list) once no Sonarr/Radarr references any of its files anymore - i.e. the library
// has already replaced it with a working copy. Off by default: clearing the broken list is
// always done, but removing the underlying entry is opt-in.
CleanupSuperseded bool `json:"cleanup_superseded,omitempty"`
NNTPConnectionPercent int `json:"nntp_connection_percent,omitempty"`
Strategy string `json:"strategy,omitempty"`
RecheckInterval string `json:"recheck_interval,omitempty"`
Arrs []string `json:"arrs,omitempty"`
AutoRepair bool `json:"auto_repair,omitempty"`
SkipNZBRepair bool `json:"skip_nzb_repair,omitempty"`

// StopSchedule, when set, stops an in-progress repair sweep at this time/interval
// (same formats as Schedule: clock time, cron expression, or duration).
// A repair sweep still running when StopSchedule fires is cancelled before it
// finishes enumerating/probing every candidate. Empty disables the stop
// schedule entirely - the repair sweep always runs to completion. When a stop
// fires mid-repair-sweep, AutoRepair decides what happens to whatever was
// already found broken: repaired if true, left alone if false.
StopSchedule string `json:"stop_schedule,omitempty"`
}

func (r RepairConfig) IsZero() bool {
return !r.Enabled && r.Source == "" && r.Schedule == "" && r.Workers == 0 &&
r.NNTPConnectionPercent == 0 && r.Strategy == "" && r.RecheckInterval == "" && len(r.Arrs) == 0 &&
!r.AutoRepair && !r.SkipNZBRepair
!r.AutoRepair && !r.SkipNZBRepair && r.StopSchedule == "" &&
!r.CleanupSuperseded
}

type Config struct {
Expand Down
7 changes: 7 additions & 0 deletions internal/config/usenet.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,13 @@ func (c *Config) updateUsenetProvider(index int, u UsenetProvider) UsenetProvide
if u.Priority == 0 {
u.Priority = index + 1 // Default priority based on order
}
// Auto-enable TLS for ports that only speak implicit TLS.
// Users who set port 563 (NNTPS) or 443 without ssl:true get a
// plain-TCP connection; the server waits for a TLS ClientHello and
// never sends the greeting, causing a 10-second i/o timeout.
if !u.SSL && (u.Port == 563 || u.Port == 443) {
u.SSL = true
}
return u
}

Expand Down
38 changes: 37 additions & 1 deletion internal/request/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -169,6 +170,40 @@ func (c *Client) Get(url string) (*http.Response, error) {
return c.Do(req)
}

// zerologAdapter bridges zerolog to the retryablehttp.Logger interface so that
// retry events (including 429 backoffs) appear in decypharr's structured log.
type zerologAdapter struct{ log zerolog.Logger }

func (z zerologAdapter) Printf(format string, args ...interface{}) {
z.log.Debug().Msgf(format, args...)
}

// retryAfterBackoff extends DefaultBackoff with Retry-After header support.
// When a 429 response carries a Retry-After header decypharr waits exactly as
// long as the server requests instead of using jittered exponential backoff.
func retryAfterBackoff(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration {
if resp != nil && resp.StatusCode == http.StatusTooManyRequests {
if ra := resp.Header.Get("Retry-After"); ra != "" {
if secs, err := strconv.Atoi(ra); err == nil && secs > 0 {
wait := time.Duration(secs) * time.Second
if wait > max {
return max
}
return wait
}
if t, err := http.ParseTime(ra); err == nil {
if wait := time.Until(t); wait > 0 {
if wait > max {
return max
}
return wait
}
}
}
}
return retryablehttp.DefaultBackoff(min, max, attemptNum, resp)
}

// New creates a new HTTP client with the specified options
func New(options ...ClientOption) *Client {
client := &Client{
Expand Down Expand Up @@ -230,7 +265,8 @@ func New(options ...ClientOption) *Client {
retryClient.RetryMax = client.maxRetries
retryClient.RetryWaitMin = 1 * time.Second
retryClient.RetryWaitMax = 30 * time.Second
retryClient.Logger = nil // Disable default logging
retryClient.Logger = zerologAdapter{log: client.logger}
retryClient.Backoff = retryAfterBackoff

// Custom retry policy based on retryable status codes
retryClient.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) {
Expand Down
11 changes: 10 additions & 1 deletion pkg/debrid/providers/torbox/torbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,20 @@ func New(dc config.Debrid, ratelimits map[string]ratelimit.Limiter) (*Torbox, er
}
_log := logger.New(dc.Name)

// TorBox enforces a hard cap of 300 req/min per API key, applied
// synchronously across all servers since v8.4 (Feb 2026, GAP-002).
// Default to that limit if the user has not configured one explicitly.
mainRL := ratelimits["main"]
if mainRL == nil {
mainRL = ratelimit.New(300, ratelimit.Per(time.Minute), ratelimit.WithSlack(30))
}

opts := []request.ClientOption{
request.WithHeaders(headers),
request.WithRateLimiter(ratelimits["main"]),
request.WithRateLimiter(mainRL),
request.WithMaxRetries(cfg.Retries),
request.WithRetryableStatus(http.StatusTooManyRequests, http.StatusBadGateway),
request.WithLogger(_log),
}
if dc.Proxy != "" {
opts = append(opts, request.WithProxy(dc.Proxy))
Expand Down
28 changes: 28 additions & 0 deletions pkg/manager/disk_usage_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//go:build !windows

package manager

import (
"os"
"syscall"
)

// fileDiskUsage returns the actual disk space info's file occupies (Blocks *
// 512), not its logical length - the two diverge for sparse files. The DFS
// cache stores files preallocated to their full logical length with only
// downloaded chunks actually written (the ffprobe repair sweep's header/moov reads
// create one such sparse file for nearly every entry), so a plain
// info.Size() badly over-reports what deleting a cache dir would actually
// reclaim. Falls back to info.Size() if Sys() doesn't yield a *syscall.Stat_t
// - should be unreachable given the !windows build tag, but a fallback is
// cheap and keeps this safe against an exotic unix variant with a different
// Sys() type.
func fileDiskUsage(info os.FileInfo) int64 {
if info == nil {
return 0
}
if st, ok := info.Sys().(*syscall.Stat_t); ok {
return st.Blocks * 512
}
return info.Size()
}
17 changes: 17 additions & 0 deletions pkg/manager/disk_usage_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//go:build windows

package manager

import "os"

// fileDiskUsage falls back to the logical file length on Windows: there's no
// portable *syscall.Stat_t/Blocks equivalent via os.FileInfo here, and
// decypharr's DFS cache backend isn't built for Windows anyway (see
// pkg/mount/dfs/vfs/sparse_windows.go), so the sparse-file undercounting
// this exists to fix doesn't apply on this platform.
func fileDiskUsage(info os.FileInfo) int64 {
if info == nil {
return 0
}
return info.Size()
}
7 changes: 7 additions & 0 deletions pkg/manager/downloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,13 @@ func (d *Downloader) completeEntry(entry *storage.Entry) {
func (d *Downloader) markAsCompleted(entry *storage.Entry) {
// Mark as completed
entry.MarkAsCompleted(entry.DownloadPath())
// Persist completion to the canonical entry store, not just the queue -
// queue.Update only ever touches the separate active-download bucket, so
// without this an entry's IsComplete/IsDownloading never reach the record
// everything else (Browse, stale-NZB classification, etc.) actually reads.
if err := d.manager.AddOrUpdate(entry, nil); err != nil {
d.logger.Warn().Err(err).Str("name", entry.Name).Msg("Failed to persist completed entry to main storage")
}
_ = d.manager.queue.Update(entry)
}

Expand Down
9 changes: 9 additions & 0 deletions pkg/manager/entry.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,15 @@ func (m *Manager) getEntryChildren(group string) (*FileInfo, []FileInfo) {
var infos []FileInfo
seen := make(map[string]struct{})
err := m.storage.ForEachMeta(func(meta *storage.EntryMetaInfo) error {
// KNOWN ISSUE (not addressed here): if two entries share a Name
// (a same-release-name duplicate - see pkg/manager/supersession.go),
// only the first one encountered in ForEachMeta's iteration order
// is shown here. ForEachMeta walks an xsync.MapOf, whose Range
// order is unordered/arbitrary, so which duplicate's metadata
// (infohash/size/modTime badge) wins this listing is undefined -
// it is not necessarily the newest. This is independent of which
// duplicate's files are served inside the folder (GetEntryItem's
// own, separate per-filename merge).
if _, ok := seen[meta.Name]; ok {
return nil
}
Expand Down
29 changes: 29 additions & 0 deletions pkg/manager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,35 @@ func (m *Manager) GetEntryItem(torrentName string) (*storage.EntryItem, error) {
return m.storage.GetEntryItem(torrentName)
}

// EntryNameHasBackingEntry reports whether name still resolves to at least
// one live entry - i.e. whether an EntryHealth record for this name reflects
// something that actually still exists, rather than being orphaned.
//
// Checks more than "does an EntryItem exist for this name": removeFromEntryItem
// (the only place an entry's deletion updates the merged EntryItem view) reads
// then writes that record without any lock spanning the two, so two entries
// sharing a name deleted concurrently (the repair sweep's own worker pool, or the
// periodic torrent-refresh job, both delete entries in parallel) can lose an
// update and leave the EntryItem non-empty with file entries whose InfoHash
// no longer backs anything. A stale EntryItem alone would pass an "exists"
// check; checking that at least one of its files' InfoHash is still a real
// entry catches that case too.
func (m *Manager) EntryNameHasBackingEntry(name string) bool {
item, err := m.storage.GetEntryItem(name)
if err != nil || item == nil || len(item.Files) == 0 {
return false
}
for _, f := range item.Files {
if f == nil || f.InfoHash == "" {
continue
}
if exists, err := m.storage.Exists(f.InfoHash); err == nil && exists {
return true
}
}
return false
}

func (m *Manager) GetEntryByName(torrentName, filename string) (*storage.Entry, error) {
// First get entry
entry, err := m.storage.GetEntryItem(torrentName)
Expand Down
Loading