Skip to content

feat(usenet): auto-repair files whose bodies are missing on playback (NNTP error 430) using DFS as mount. - #358

Open
TheMightyBattleCat wants to merge 1 commit into
sirrobot01:betafrom
TheMightyBattleCat:feature/NNTP-430-auto-repair
Open

TheMightyBattleCat wants to merge 1 commit into
sirrobot01:betafrom
TheMightyBattleCat:feature/NNTP-430-auto-repair

Conversation

@TheMightyBattleCat

@TheMightyBattleCat TheMightyBattleCat commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

The problem

A Usenet article's body can be purged from every configured provider — retention gaps/expiry, takedowns — while its header stays alive. The result is a file that passes header-level health checks but dies the moment someone actually plays it: the read fails with NNTP 430 "article not found", playback stops, and the file just sits there broken until someone notices, deletes it, and re-searches by hand. The person pressing play is usually not the person who can do any of that.

What this adds

When a read through the built-in DFS mount hits a permanent 430, decypharr now treats it as what it is — proof the release is dead — and repairs it on the spot:

  1. The DFS circuit breaker fast-trips instead of walking its normal retry ladder. Retrying a genuinely missing article is pointless, and the fast trip is what turns a hung, stuttering playback into an immediate, clean failure.
  2. The failure escalates to repair: the played file is deleted, and the owning Sonarr/Radarr is asked to blocklist the dead grab and re-search for a working copy. Best case, the replacement is streamable a minute or two later — the user retries the episode and it plays.
  3. There is deliberately no redundant re-probe before repairing. The failed read already proved the body is missing; a fresh probe can sample different segments, miss the exact dead ones, wrongly report the file healthy, and suppress the repair the user just triggered by pressing play.

Scope is explicit and narrow: the automatic trigger lives entirely in the DFS read path. The repair actions themselves are mount-agnostic, so rclone/WebDAV users get the identical repair via the scheduled sweep — they just don't get the playback-time trigger, and the settings label says so.

Any media repaired on the fly with this feature shows the repaired files in the “View Broken” screen, but they disappear with the “Clean replaced” or "Fix Broken” button (from previous PR). Rclone users have to trigger the sweep manually or wait for their next scheduled run, as they legitimately need repairing.

Safety / fallback behaviour

  • Off by default, and double-gated: repair_on_playback_failure requires repair enabled and auto_repair to be set. It never does anything the sweep's auto-repair wouldn't be allowed to do.
  • "Permanent" is a protocol verdict confirmed across all providers, not an inference. NNTP 430 is the server explicitly stating the article doesn't exist, and it's classified as its own typed error — distinct from timeouts, connection failures, and server-busy, which are each typed separately. A 430 from one provider doesn't end the fetch either: it excludes that provider's entire backbone and fails over to the next, and the article-not-found error only propagates as terminal once every configured provider has said not-found. The 430 that reaches the escalation point means no configured backbone has the body.
  • Everything ambiguous is structurally unable to escalate. In the DFS breaker's error accounting, only article-not-found (or an explicit auth/payment/permission denial — equally definitive provider verdicts) fast-trips and escalates. Timeouts, stalls, "stream produced no data", and "exhausted retries" merely increment the error counter: sustained failure can still open the circuit and stop the bleeding, but an open circuit alone never triggers a repair. A flaky provider cannot delete your files.
  • A per-entry cooldown (2 minutes) is enforced manager-side, not on the per-file downloader — deliberately, because a repair deletes and re-imports the entry, which recreates the downloader and would reset any cooldown stored there. The manager-level guard survives that, so a burst of concurrent reads, the player's immediate retry, or a re-grab that is still dead collapses into one repair per entry per cooldown window instead of a delete/re-search churn loop.
  • Delete failures are non-fatal: if removing the dead file hiccups, the blocklist + re-search still proceed — the point is getting a working copy, not a tidy delete. A per-ID delete fallback treats already-missing files as success rather than error.
  • Repair progress is tracked per entry, matching how Broken/Probed/Healthy are counted in sweep summaries, and partial repairs (some files in an entry healed) log the action explicitly — every repair has a traceable log line.

Testing

Validated live on my own setup: playing a retention-expired or DCMA’d media (through the DFS mount trips the breaker on the 430, the dead grab is blocklisted and re-searched in Sonarr/Radarr, and the replacement plays. Repeated plays of the same dying file within the cooldown window collapse into a single repair. Transient failures were confirmed to keep taking the normal retry path. go build ./... and go vet ./... pass clean.

During testing my entire library has been pretty much been sanitised with this feature and the ffprobe on repair sweep (which among other issues, can also triggers a 430 when the body is probed). This with your 100% NZB Import Availability Sample feature combined with the enhanced ffprobe to stop the ridiculous duration media and missing video/audio streams on import has had a huge impact on my library. 72hr sweep windows only see about 4 or 5 genuine failures each time (17k entry library - all usenet).

Unlike most people, I keep my scheduled plex tasks running in a small window over night (I like the intro skip) and when it detects a 430 during plex’s maintenance - that too triggers a replacement on the fly, which is great.

Config / UI

  • repair_on_playback_failure (bool, default false) on the repair config
  • Settings toggle: "Auto-repair on playback failure (NNTP 430, DFS only)", with a tooltip spelling out the trigger condition and the DFS-only scope
Screenshot 2026-07-19 at 08 44 32

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. Happy to adjust anything if you'd prefer it shaped differently.

A Usenet article body can be purged from your providers over time (retention
expiry, takedowns) while its header stays alive. When you play a file whose
bodies are gone, the read fails with NNTP 430 "article not found" and the file
just sits there broken — you have to notice, delete it, and re-search by hand.

This detects that during DFS playback and fixes it automatically. When a read
hits a permanent 430, the DFS circuit breaker fast-trips (retrying a genuinely
missing article is pointless) and escalates: the played file is deleted and the
owning Sonarr/Radarr is asked to blocklist the dead grab and re-search for a
working copy. The read already proved the body is missing, so there is no
redundant re-probe (a re-probe can miss the exact dead segments and wrongly
report the file healthy, suppressing the repair).

Gated behind Repair.RepairOnPlaybackFailure (requires Enabled + AutoRepair).
A per-file/per-entry cooldown prevents repeated escalation of the same file,
delete failures are non-fatal so the blocklist + re-search still proceed, and a
per-ID delete fallback treats already-missing files as success. The repair
actions themselves are mount-agnostic; the automatic playback trigger is wired
into the DFS read path, so rclone-mount users get the same repair via the
scheduled sweep rather than an immediate playback trigger.

Repair progress is tracked per entry, matching how Broken/Probed/
Healthy are counted in the sweep summary. Partially-repaired entries
(some but not all files in an entry healed) log the repair action
explicitly, so every repair has a traceable log line.

Files:
- pkg/usenet/usenet.go             record + expose permanent per-file failure cause
- pkg/manager/stream.go            surface the failure cause to the DFS layer
- pkg/mount/dfs/vfs/downloaders.go fast-trip breaker on 430; escalate to repair
- pkg/manager/repair_sweep.go      RepairPlaybackFileNow: delete + blocklist + research
- pkg/manager/manager.go           per-entry repair cooldown
- pkg/arr/content.go               non-fatal bulk delete + per-ID delete fallback
- internal/config/config.go        RepairOnPlaybackFailure toggle
@ChrispyBacon-dev

Copy link
Copy Markdown

Thank you 🙏 I was already looking into that problem myself. I will test out your branch.

This branch has not been deployed

No deployments
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