Skip to content

Add NzbDAV as an alternative Usenet provider#425

Open
Jauntiness wants to merge 7 commits into
godver3:devfrom
Jauntiness:nzbdav-provider-support
Open

Add NzbDAV as an alternative Usenet provider#425
Jauntiness wants to merge 7 commits into
godver3:devfrom
Jauntiness:nzbdav-provider-support

Conversation

@Jauntiness

@Jauntiness Jauntiness commented May 26, 2026

Copy link
Copy Markdown

Add NzbDAV as an alternative Usenet provider

Summary

Adds NzbDAV as a second supported
usenet backend alongside the existing Decypharr integration. Both can be
selected at runtime via a config key / settings UI dropdown.

Why

The current DecypharrClient is tightly coupled to Decypharr's custom
endpoints (POST /api/add, GET /api/browse/nzbs/…, DELETE /api/torrents).
NzbDAV exposes a different (standard SABnzbd-style) API
(GET /api?mode=addfile, ?mode=queue, ?mode=history, …) and serves files
via WebDAV. Without a second client it cannot be wired into cli-debrid.

NzbDAV is a useful alternative because:

  • It has a built-in HealthCheckService (automatic NZB-availability repair)
  • It serves a WebDAV mount instead of FUSE-only, easier rclone-mounting

What's changed

New files

File What
app/usenet/nzbdav_client.py NzbdavClient — mirrors DecypharrClient's public interface 1:1
app/usenet/__init__.py get_usenet_client() factory + provider-key reader

Modified files (minimal diffs)

File Lines changed What
app/usenet/decypharr_client.py last ~14 lines (factory funcs only) get_decypharr_client() delegates to NzbdavClient when provider == 'nzbdav'; class body untouched
app/routes/program_operation_routes.py one block in check_service_connectivity() hardcoded /version URL fetch replaced with client.check_connectivity() so it works for any provider
app/templates/settings_tabs/required_tangerine.html banner text + Provider select + Mount Path field + 12-line inline JS Provider dropdown, conditional field visibility (decypharr-only vs nzbdav-only fields shown based on selection)
app/static/js/scraper.js 3 strings Submit-dialog text now provider-agnostic ("Submit to the Usenet provider" instead of "to Decypharr")
app/templates/debrid_manager.html 9 strings Button titles + bulk-action dialogs now provider-agnostic

Backwards compatibility

  • Existing installs without a provider key in Usenet Provider config
    default to decypharrzero behaviour change.
  • All 11 existing from usenet.decypharr_client import get_decypharr_client
    call sites keep working — the function is just provider-aware now.
  • The DecypharrClient class itself is untouched.

Configuration

Settings → Required → Usenet Provider:

Field Decypharr NzbDAV
Provider Decypharr (default) NzbDAV
Provider URL http://x:8888 http://x:3000
API Token optional bearer nzbdav api.key
Download Folder category category (must exist in nzbdav api.categories config)
Decypharr Data Path required for backup tools ignored
Mount Path ignored path where nzbdav-rclone-mount appears (used for browse helpers)

Provider-specific differences (documented in nzbdav_client.py)

DecypharrClient NzbdavClient
POST /api/add with arr + downloadFolder form fields POST /api?mode=addfile with cat query param
URL-add via server-side fetch + pre-fetch fallback for blocked indexers mode=addurl with the same pre-fetch fallback
GET /api/browse/nzbs/<name> folder lookup filesystem listdir under mounted_file_location (nzbdav has no browse API)
/api/repair/health/<n>/check trigger no-op — nzbdav has internal HealthCheckService
DELETE /api/torrents?hashes=<uuid> DELETE /api?mode=history&name=delete&value=<nzo_id>

Testing

Tested locally against:

  • nzbdav v0.6.4 with rclone-sidecar
  • cli-debrid :dev image
  • Existing Decypharr install on the same box (provider-switch toggles cleanly)

Connectivity check confirms with provider-aware label:

INFO - Usenet provider (NzbDAV (Usenet)) reachable at http://192.168.1.x:3000
DEBUG - [Connectivity Check Task] Service connectivity check passed

Follow-ups (not in this PR)

  • Add the same provider-dropdown to onboarding_step_2.html (initial setup
    wizard still hardcodes "Decypharr" wording)
  • Update connections.html provider label display via PROVIDER_NAME
  • Optional: migrate remaining 11 callers from get_decypharr_client() to
    get_usenet_client() for cleaner naming (purely cosmetic, no functional change)

Closes / refs: (add issue link here)


Follow-up commits in this PR

Title-based category routing (commit 98c3a98)

The original commit routes every grab to a single static SAB category
(download_folder, default cli_debrid). In a typical Plex setup that
folder is not a library location, so grabs stay invisible and cli_debrid
loops items between Wanted and Checking.

Added a title-based heuristic in nzbdav_client.py that derives the SAB
category from the release title before submission:

Title pattern nzbdav category
Show + 1080p + (x264/h264/AVC) shows_1080p_264
Show (else) shows
Movie (year present) + 1080p + (x264/h264/AVC) movies_1080p_264
Movie (else) movies
Music markers (FLAC, MP3, hi-res, …) music
Nothing detected default_category (default __unplayable__)

Category names mirror zurg's filter-folder convention so the same Plex
library locations can feed from both providers. Centralized in a single
helper — covers all 11 add_nzb call sites including manual UI submits.

Backward-compatible: explicit category= arg still wins; unmatched titles
fall to default_category (= identical behavior to before, just renamed
from cli_debrid to __unplayable__).

Standalone docs (commit 2f669b7)

usenet/README.md — self-contained docs covering config, interface
differences vs DecypharrClient, the category-routing heuristic, and
optional companion setups (mergerfs union for direct file-detection,
Plex Smart Collections for quality views, separate _1080p Plex
libraries). Includes a "For LLMs / AI agents" section with code
anchors and smoke-test commands so the integration can be set up or
extended autonomously by AI assistants.

Jauntiness and others added 4 commits May 26, 2026 23:52
Adds a second supported usenet backend (https://github.com/nzbdav-dev/nzbdav)
alongside Decypharr. Selectable at runtime via a new "Provider" dropdown in
the Usenet Provider settings.

New files:
  - usenet/nzbdav_client.py  (471 lines, mirrors DecypharrClient 1:1)
  - usenet/__init__.py       (factory + display-name helper)

Modified (small targeted diffs):
  - usenet/decypharr_client.py: factory delegates to NzbdavClient when
    provider == 'nzbdav' (DecypharrClient class itself unchanged).
  - routes/program_operation_routes.py: connectivity-check uses the client
    factory instead of a hardcoded /version URL.
  - routes/scraper_routes.py + magnet_routes.py: toasts use display name.
  - routes/web_server.py: context_processor injects usenet_provider_name.
  - utilities/settings_schema.py: schema description provider-neutral.
  - templates/base.html: injects window.USENET_PROVIDER_NAME global for JS.
  - templates/settings_tabs/required_tangerine.html: Provider dropdown,
    conditional fields (decypharr-only vs nzbdav-only), inline JS toggler.
  - templates/debrid_manager.html: Decypharr labels become provider-aware.
  - static/js/scraper.js: dialogs read window.USENET_PROVIDER_NAME.

Backwards-compatible: installs without a 'provider' key default to
'decypharr' — zero behaviour change for existing users. All 11 existing
call sites that import get_decypharr_client() keep working unchanged.
Previously, all grabs went to a single static SAB category
(`download_folder`, default 'cli_debrid'). In a zurg-style Plex setup
this folder is not a library location, so grabs stay invisible and
cli_debrid loops items in Wanted / Checking.

This commit adds a title-based heuristic in nzbdav_client.py that
derives the SAB category from the release title before submission:

  Show + 1080p + H.264  -> shows_1080p_264
  Show (else)           -> shows
  Movie + 1080p + H.264 -> movies_1080p_264
  Movie (else)          -> movies
  Music markers         -> music
  No match              -> default_category (default '__unplayable__')

Category names mirror zurg's filter-folder convention so the same
Plex library locations can feed from both providers. Centralized in
a single helper — covers all 11 add_nzb call sites (torrent_processor x3,
repair_engine x2, debrid_manager_routes x2, magnet_routes x1,
scraper_routes x3) including manual UI submits. No upstream code touched.

The default fallback category renames from 'cli_debrid' to
'__unplayable__' to mirror zurg's catch-all bucket name.

Backward compatible: an explicit `category=` argument still wins;
unmatched titles fall through to `self.default_category` (= identical
behavior to before modulo the rename).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-contained docs for the nzbdav integration: config, interface
differences vs DecypharrClient, the title-based category-routing
heuristic, and optional companion setups (mergerfs union for
robust file-detection, Plex Smart Collections for quality views,
separate `_1080p` libraries).

Includes a "For LLMs / AI agents" section so this can be set up
or extended autonomously by AI assistants — patch architecture,
code anchors, edge cases, smoke-test commands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…btitle/settings improvements

- templates/_nzbdav_setup_helper.html: in-app setup helper — client-side
  generator for the NzbDAV docker-compose + rclone.conf (+ optional mergerfs
  union for multi-provider setups) + the cli-debrid config block, plus a live
  "test connection & ensure categories" tool. Included in the Usenet Provider
  settings and in onboarding step 2. The setup guide now points at this helper
  rather than the CLI.
- routes/program_operation_routes.py: /api/nzbdav/check and
  /api/nzbdav/ensure_categories endpoints backing the helper. They use NzbDAV's
  get-config/update-config API to read and create the categories cli-debrid
  needs (missing categories otherwise loop grabs in "Wanted"); applied live, no
  restart. Reachable without the SAB key when DISABLE_FRONTEND_AUTH=true.
- usenet/nzbdav_setup.py + nzbdav_migrate.py: optional host-side CLIs. The
  in-app helper covers the GUI path, but a script is still needed for things a
  container can't do: nzbdav_setup.py 'migrate-files' replays an existing
  Decypharr .nzb store into NzbDAV, plus headless 'wizard'/'generate';
  nzbdav_migrate.py is a read-only preflight doctor (reachability, missing
  categories with --fix, mount visibility). stdlib-only, no app imports.
- usenet/repair_engine.py + nzbdav_client.py + decypharr_client.py: route
  health/repair through the client interface so it works on NzbDAV (derives
  broken items from history status=Failed), scoped to owned categories so it
  never touches another SAB client's entries; fixes NzbDAV history-delete to
  use GET (it was a silent no-op via DELETE).
- utilities/downsub.py + post_processing.py: storage-agnostic subtitles —
  write a sidecar where the media dir is writable (Symlinked/Local), otherwise
  upload via the Plex API keyed on ratingKey (works on read-only WebDAV/rclone
  mounts, no Plex Pass needed).
- Settings clarity: relabel the Usenet "Download Folder" field to
  "Fallback Category (rarely used)" with an accurate explanation, and document
  Plex.mounted_file_location for multi-provider / union setups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Jauntiness Jauntiness force-pushed the nzbdav-provider-support branch from a613d2f to 07a3089 Compare May 28, 2026 17:00
@Jauntiness

Copy link
Copy Markdown
Author

Pushed 07a3089d, extending the NzbDAV support:

  • In-app setup helper (templates/_nzbdav_setup_helper.html, in the Usenet Provider settings + onboarding step 2): client-side generator for the nzbdav docker-compose.yml + rclone.conf (+ an optional mergerfs union for multi-provider setups) + the cli-debrid config block, plus a live "test connection & ensure categories" tool backed by /api/nzbdav/{check,ensure_categories} in routes/program_operation_routes.py. These read/create the categories cli-debrid routes into via NzbDAV's get-config/update-config API — applied live, no restart (reachable without the SAB key when DISABLE_FRONTEND_AUTH=true).
  • Host-side CLIs usenet/nzbdav_setup.py + nzbdav_migrate.py for what a container can't do from the GUI: migrate-files replays an existing Decypharr .nzb store into NzbDAV, plus headless wizard/generate, and a read-only preflight doctor (reachability, missing categories with --fix, mount visibility). stdlib-only.
  • Repair/health re-wire so it works on NzbDAV (broken items from mode=history status=Failed, scoped to owned categories so it never touches another SAB client's entries; NzbDAV history-delete fixed to GET — DELETE was a silent no-op).
  • Storage-agnostic subtitles: sidecar where the media dir is writable, otherwise Plex-API upload keyed on ratingKey (works on read-only WebDAV/rclone mounts).
  • Settings clarity: relabeled the Usenet "Download Folder" field to "Fallback Category (rarely used)" and documented Plex.mounted_file_location for multi-provider / union setups.

Details in the commit message — feedback on the direction welcome.

Moves stored downloads between usenet backends so users can switch without
re-searching — the .nzb is copied to the target, which re-fetches it from usenet.
HTTP/mounted-path only (cli-debrid has no Docker socket, so it can't docker-cp a
provider's nzb store):
  - NzbDAV source/target: list mode=history (each slot has nzb_blob_id), fetch via
    GET /api/download-nzb, submit via mode=addfile.
  - Decypharr source: read .nzb files from Decypharr's nzb store mounted into
    cli-debrid (the existing data_path bind); target: submit via POST /api/add.

- utilities/provider_transfer.py: engine — list/preview, read, submit, with a
  throttled background job (target queue-depth cap), skip-already-present, and
  progress/cancel. Both directions.
- routes/program_operation_routes.py: /api/provider_transfer/{config,list,start,
  status,cancel}.
- templates/_provider_transfer.html: UI panel (direction, source/target, preview,
  progress) with guardrail warnings; prefilled from the configured provider.
- templates/debug_functions.html: include the panel in the Library tab.
- templates/settings_tabs/required_tangerine.html: point the "Switching from
  Decypharr?" note and the Decypharr Data Path help at the in-app migration and
  explain the <data_path>/usenet/nzbs source path.

Best-effort by nature: Decypharr trims old .nzb files, and the target re-downloads
from usenet (subject to retention/connection limits).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Jauntiness Jauntiness force-pushed the nzbdav-provider-support branch from 57c93ab to aba2662 Compare May 28, 2026 17:42
Jauntiness and others added 2 commits May 30, 2026 14:27
deletion_manager.py called get_decypharr_client() at both removal sites
(delete_single_item and delete_multiple_items). After a Decypharr -> NzbDAV
migration the removal silently no-ops — Decypharr is offline and the item's
NZB id is in NzbDAV.

- usenet/__init__.py: new USENET_ID_PREFIX + is_usenet_id() / usenet_raw_id() /
  remove_usenet_item(). One source of truth for the 'nzb:<id>' convention and a
  factory-routed remove that hits whichever provider is configured.
- utilities/deletion_manager.py: both removal sites now call
  remove_usenet_item() instead of the Decypharr client directly. Passes
  location_basename as the fallback match key — it equals the provider's
  history entry name and survives migrations better than filled_by_file/title.
- usenet/nzbdav_client.py: remove_nzb() refactored — try id-delete when the id
  exists in history, then fall back to exact-name match for migrated items
  whose stored id is an old Decypharr UUID. Adds _release_name_key,
  _id_in_history, _raw_delete_by_id, _delete_by_exact_name helpers and a
  filtered _history_slots().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- report webdav.enforce-readonly state in /api/nzbdav/check
- new POST /api/nzbdav/set_delete_mode to flip it live (no restart)
- setup helper: 'Enable delete-from-Plex' button + status line
- docs: setup notes step 5b + README 'Delete usenet items natively'
- README: reconcile local sections with upstream intro
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