Skip to content

feat(usenet) - repair: clear replaced broken entries, and add stale NZB cleanup to Browse - #353

Open
TheMightyBattleCat wants to merge 7 commits into
sirrobot01:mainfrom
TheMightyBattleCat:feature/superseded-broken-cleanup
Open

TheMightyBattleCat wants to merge 7 commits into
sirrobot01:mainfrom
TheMightyBattleCat:feature/superseded-broken-cleanup

Conversation

@TheMightyBattleCat

Copy link
Copy Markdown
Contributor

The problem

Two related lifecycle gaps, both stemming from the same fact: when Sonarr or Radarr replaces a release — a repair re-search, a quality upgrade, a PROPER — the replacement arrives as a new entry, and nothing ever closes the book on the old one.

Broken records never die. A healed broken entry's health record sticks around forever. Every subsequent sweep re-probes the dead release's own missing articles and re-confirms it broken — for something the library stopped using weeks ago. Worse, "fixing" it can fire a pointless blocklist + re-search for a file the Arr already replaced. Season packs are the chronic case: a pack whose broken episodes were individually re-grabbed stays a legitimate sweep candidate (its other episodes are still in use), so its dead episodes get re-probed and re-flagged as broken on every single sweep, permanently.

Stale entries never leave. The old NZB entry an upgrade leaves behind stays in Browse forever, gets scanned by every managed sweep, and holds its nzb file, metadata, and DFS cache on local disk — despite nothing referencing it. Same for dangling downloads no Arr tracks, and for orphaned .nzb/.meta files with no database entry at all.

Part 1 — supersession awareness in the repair path

Before probing a broken entry, every check now asks: does any Sonarr/Radarr instance still reference this entry's files, backed by this entry's own copies? This one question is answered in the same way everywhere — scheduled sweeps, per-entry rechecks, and the Fix Broken action — so the pointless-blocklist problem from the intro is closed at every entry point, not just the sweep.

The classification is deliberately InfoHash-aware, not name-based. A reference set is built once per run mapping entry → file → the InfoHash currently serving that slot. A broken file is superseded when its slot is unreferenced, or referenced but backed by a different InfoHash — the re-grabbed working copy of the very same release name. This is what tells a dead copy apart from its healthy same-name twin, so the twin is never touched. Truth comes from what the Arrs reference right now, not from whatever details happen to be stored on the broken record — most broken records simply don't carry enough information for the stored-record approach to work.

What flows from that classification:

  • Fully superseded broken entries are cleared from the broken list instead of re-probed. With cleanup_superseded enabled (off by default), the underlying entry is also deleted — keyed by InfoHash, mirroring the identifier and call pattern of the existing, proven post-re-search deletion path.
  • Season packs are handled at file granularity: the pack stays a valid sweep candidate for its still-referenced episodes, but replaced episodes are filtered out before probing — no STAT check, no re-added BrokenFiles entry — with one log line instead of a repeating false alarm. The sweep summary reports these as skipped_superseded_files.
  • Managed-source sweeps drop fully superseded candidates before probing. (Arr-source sweeps never see them — they only enumerate what an Arr references.)
  • The "Fix Broken" action on the Repair page runs the same check before acting. Every selected candidate is dropped or trimmed against the current Arr references before any repair fires — Fix must never blocklist or re-search on behalf of a file the Arr already replaced with a working copy. A fully replaced entry is removed from the fix pass entirely; a partially replaced season pack is trimmed so only its genuinely broken files are deleted and re-searched.
  • A manual "Clear replaced" button on the broken entries list runs the same cleanup on demand.
  • Orphaned health records are filtered from the broken listing: a record whose underlying entry was deleted out from under it (most commonly the sweep's worker pool racing the periodic refresh) no longer shows up as an unexplained zero-file, zero-date broken entry.
deccy_repair6

Part 2 — stale NZB cleanup on the Browse screen

The nzbs view gains a "Clean up stale NZBs" button. It previews two lists: every usenet entry no Arr references (matched to the specific copy serving each file, same InfoHash discipline as above), and orphaned .nzb/.meta files with no database entry. Each item shows the actual local disk it would free — cache and nzb/metadata reported separately, no inflated content-size numbers, since the articles live on the news servers, not your disk. A filter-as-you-type box narrows both lists.

Every item has a checkbox — manually added downloads the Arrs never tracked can be unticked and kept. Deletion removes the database record, the nzb/metadata files, and (when provably safe — see below) the DFS cache directory, then reports exactly how much disk came back. rclone users' VFS cache is deliberately left alone; rclone reclaims it by its own policy once the entries are gone. Both preview and cleanup log per-phase durations at DEBUG, a summary at INFO, and surface failures persistently in the modal.

deccy_repair3 deccy_repair2 deccy_repair deccy_repair5

Why the deletion is safe

This feature deletes things, so it was designed guard-first. The layers, from classification to the final unlink:

  1. Fail-open, always. If the Arr lookup fails, or no Arrs are configured, nothing is cleared and nothing is deleted. A nil reference set means "couldn't determine anything" everywhere it's consulted — never "nothing is referenced." Real breakage is never dropped because of an outage or misconfiguration.
  2. A file with no recorded InfoHash is never treated as superseded. No evidence, no action.
  3. Anything an Arr still references is structurally excluded from the delete path — not filtered late, but never eligible.
  4. The confirmed selection is a request, not an authorization. The server re-checks every chosen entry at deletion time and skips anything no longer stale — newly referenced by an Arr, too recently touched, or still downloading — reporting each skip with its reason.
  5. A 24-hour grace period is absolute. Entries and files touched in the last 24h are always excluded, because a fresh download is legitimately unreferenced until the Arr imports it — without this, a good in-flight grab is indistinguishable from an abandoned one.
  6. The DFS cache directory gets its own proof obligation. The cache is keyed by folder name, not InfoHash — so after deleting an entry, its cache dir is removed only if no other entry still claims that name in the merged view. If a healthy same-name twin still has files there, the directory is left alone with a debug line. The helper's contract is explicit: not removing a directory is always safe; removing the wrong one is not — so every "not safe" and "nothing there" case returns the same harmless no-op. The resolved path must also stay inside the configured cache dir.
  7. Entry deletion reuses the proven path. It's keyed by InfoHash and mirrors the existing post-re-search DeleteEntry flow that auto-repair has exercised in production, rather than introducing a second deletion mechanism.
  8. Human-facing brakes: two-click confirmation on every delete, and if a preview ever flags more than half the library as stale, a prominent warning says so before anything else happens.
  9. Entry deletion is opt-in. Clearing the broken list is always done; removing the underlying entry requires cleanup_superseded: true, default off.

Testing

This is the most deletion-heavy change in the set, so it wasn't submitted until every path had been exercised live rather than just reasoned about. On my own library (DFS with Sonarr + Radarr):

  • Season-pack partial supersession, the chronic case: packs whose broken episodes had been individually re-grabbed were previously re-flagged broken on every sweep. With this change they stay off the broken list across consecutive scheduled sweeps, the still-referenced episodes keep being probed normally, and the sweep summary shows the replaced episodes under skipped_superseded_files.
  • Same-name twin protection: verified with a dead copy and its re-grabbed live copy sharing a release name — the twin's entry and its name-keyed DFS cache directory survive the dead copy's cleanup, with the "cache dir still claimed" debug line confirming the guard fired.
  • Clear replaced validated live from the broken entries list: superseded records cleared, genuinely broken entries untouched.
  • Stale NZB preview and cleanup run against real accumulated upgrade leftovers: deletions removed exactly the expected files — the database record, nzb/meta files, and cache dir — and nothing else.
  • Deletion-time re-verification observed skipping (with reasons) entries whose state changed between preview and confirm.
  • Fail-open confirmed by running with Arrs unreachable: nothing cleared, nothing deleted.
  • Orphaned zero-file health records no longer appear in the broken listing.

go build ./... and go vet ./... pass clean.

Config / UI

  • cleanup_superseded (bool, default false) on the repair config — controls only whether a fully superseded broken entry's underlying entry is deleted; list-clearing is unconditional
  • Repair page: "Clear replaced" button on the broken entries list
  • Browse page (nzbs view): "Clean up stale NZBs" button with the preview/confirm modal, live progress, and persistent error reporting

Trying it out

A multi-arch Docker test build with this change (alongside my other in-flight fixes) is available at ghcr.io/themightybattlecat/decypharr:usenet-improvements, with the merged source on the usenet-improvements branch of my fork. The PR is two commits — the repair-path supersession awareness, then the Browse cleanup tool built on the same classification — and I'm happy to split or reshape it if you'd prefer.

TheMightyBattleCat and others added 7 commits July 17, 2026 18:40
When you set up a username and password and then later changed any
setting and hit Save Configuration, authentication would be silently
disabled on the next restart - forcing you to re-enter your credentials
every time.

The credentials themselves were never lost, but the flag that tells the
app to actually enforce authentication was being reset to false on every
save because the settings form doesn't include it in what it sends to
the server.

Fixed by preserving the auth enabled flag when saving config changes.

Co-authored-by: Brad Dennison <bradleydennison@googlemail.com>
…ot01#333)

Users who configure port 563 (NNTPS) or 443 without 'ssl: true' in their
usenet provider config get a plain-TCP connection. The NNTP server expects a
TLS ClientHello and never sends the greeting, so decypharr blocks in
ReadString until HandshakeTimeout (10s) and returns 'read greeting: i/o
timeout'.

Fix: auto-set SSL=true in updateUsenetProvider when port is 563 or 443.

Fixes sirrobot01#319
…3) (sirrobot01#330)

GetTorrent() and UpdateTorrent() called /api/torrents/mylist/ with a
trailing slash. Cloudflare returns a 307 redirect for that URL and
Go's http.Client strips the Authorization header on redirect, causing
the re-sent request to fail authentication.

getTorrents() and loadDownloadPresent() already used the correct URL
without a trailing slash — this aligns the two outliers.

Fixes sirrobot01#309

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…rrobot01#329)

TorBox changed rate limiting to per-API-key, synchronous across all
servers in v8.4 (Feb 2026). Three gaps remained after the initial
429-retry work in PR sirrobot01#324/sirrobot01#325:

1. No proactive rate limiter when rate_limit is omitted from config.
   ParseRateLimit("") returns nil so TorBox got zero throttling.
   Fix: default to 300 req/min (10% slack burst) in torbox.New().

2. Retry-After header ignored on 429 responses.
   retryablehttp used fixed jitter backoff regardless of what the
   server requested. Fix: retryAfterBackoff() in request.go parses
   Retry-After as seconds or HTTP date and sleeps exactly that long,
   falling back to DefaultBackoff when the header is absent.

3. Retry events not logged.
   retryClient.Logger was nil, suppressing all retry/429 visibility.
   Fix: zerologAdapter bridges zerolog to retryablehttp.Logger so
   retry attempts appear in decypharr's structured debug log.

Changes are generic where possible:
- retryAfterBackoff and zerologAdapter benefit all providers, not
  just TorBox.
- The 300/min default is TorBox-specific (in torbox.go).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
When a broken file gets healed, the replacement is imported as a new
entry, but the old broken entry and its health record used to stick
around forever - rechecking it just re-probed the dead release's own
missing articles and kept re-confirming it as broken for something the
library stopped using long ago, and fixing it could even trigger a
pointless blocklist and re-search for a file Sonarr or Radarr had
already replaced.

Every check is now aware of this: before probing a broken entry, it
asks whether any Sonarr or Radarr instance still references its files.
If nothing does, the entry is cleared from the broken list instead of
being probed again. This is decided purely by what the Arrs currently
reference, not by whatever details happened to be stored on the broken
record, since most broken records don't carry enough of that
information for that approach to work in practice. It also tells apart
a dead copy and a re-grabbed working copy of the same release that
happen to share the same name, so the healthy copy is never touched by
mistake, and it refuses to clear anything if the Arrs can't be reached
or aren't configured, so real breakage is never dropped because of an
outage or a misconfiguration.

Season packs get the same care at the file level: when only some
episodes in a pack were replaced, the pack rightly stays in the repair
sweep for its still-used episodes, but the replaced ones are no longer
probed and re-flagged as broken on every single repair sweep - they're
recognized as replaced and skipped instead, with one log line rather
than a repeating false alarm.

A manual "Clear replaced" action is also available from the broken
entries list, for running this cleanup on demand instead of waiting
for the next repair sweep.

Also filters orphaned EntryHealth records out of the broken-entries
listing: a health record's underlying entry can be deleted by
something else - most commonly the repair sweep's own worker pool
racing the periodic torrent refresh - without the record itself being
cleaned up, since nothing currently locks that read-then-write across
the two steps. The listing now checks that a record's name still
resolves to a real entry before including it, so a stale record left
behind by that race stops showing up as an unexplained zero-file,
zero-date broken entry.
When Sonarr or Radarr upgrades a release (a WEB-DL upgraded to a BluRay
or REMUX, a PROPER replacing the original), the old NZB entry it leaves
behind stays in decypharr forever - still listed in Browse, still
scanned by managed repair sweeps, still holding its nzb file, metadata
and cache on local disk, despite nothing using it anymore. The same
goes for any dangling download no Arr tracks, and for orphaned .nzb and
.meta files left on disk with no database entry at all.

The Browse screen's nzbs view gains a "Clean up stale NZBs" button. It
shows a preview of every usenet entry no Sonarr/Radarr references
(matched to the specific copy actually serving each file, so a
re-grabbed release's live twin is never mistaken for stale), alongside
a second list of orphaned files with no database entry. Each item shows
the local disk it would free - actual disk usage, with cache and
nzb/metadata reported separately, and no inflated content-size numbers,
since the articles themselves live on the news servers, not your disk.
A filter-as-you-type search box narrows both lists by name.

Every item has a checkbox, so manually added downloads the Arrs never
tracked can be unticked and kept; select-all and delete-all are one
click, and deletion needs a second confirming click either way. The
confirmed selection is a request, not an authorization: the server
re-checks every chosen entry at deletion time and skips anything no
longer stale - newly referenced by an Arr, too recently added, or still
downloading - reporting each skip with its reason. Entries and files
touched in the last 24 hours are always excluded, since a fresh
download is unreferenced until the Arr imports it. The operation
refuses to run at all if the Arr lookup fails or no Arrs are
configured, and anything an Arr still references is structurally
excluded from the delete path. If a preview ever flags more than half
the library as stale, a prominent warning says so.

Deleting an entry removes its database record, its nzb and metadata
files, and its DFS cache directory; deleting an orphan removes the
files. The result reports exactly how much local disk came back.
rclone users' VFS cache is deliberately left alone - rclone reclaims it
by its own policy once the entries are gone. Both preview and cleanup
log per-phase durations and counts at DEBUG and a summary at INFO, and
surface failures persistently in the modal rather than a transient
toast.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants