Usenet with zurg
zurg's nzb backend turns a directory of .nzb files into a mounted library. There is no downloader, no completed folder, and nothing on disk but the NZB files themselves: when a player seeks into a file, zurg fetches exactly the articles covering those bytes, yEnc-decodes them in memory, and answers the ranged read. A 60 GB remux costs 60 GB of disk nowhere.
It is a debrid backend as far as the rest of zurg is concerned — the same library model, the same directory filters, the same mount, the same media-server integration — so a Usenet release sits alongside your Real-Debrid or TorBox content in one tree. It can also be the only account you configure; no debrid service is required.
Several Usenet providers are supported. One nzb entry carries any number of news accounts — unlimited providers, block accounts, whatever you hold — and zurg falls back through them article by article. That matters more here than redundancy usually does: retention is per-provider, so a second account is very often the difference between one extra fetch and rebuilding a whole release from PAR2. See
This guide goes end to end: news accounts, NZBs, the mount, Plex.
What you need
- At least one Usenet provider account. Retention is what matters most — a 4000+ day provider will still have posts a 1000-day one has aged out. Your plan's connection allowance is the second number to know; it is what zurg reads at.
- More accounts, if you have them. A second unlimited provider on a different backbone, or a cheap block account for the gaps, is the highest-value thing you can add to a Usenet setup — see
More than one Usenet provider . - NZB files or something that fetches them. Download them from your indexer and drop them into
nzbs/. Or turn on the SABnzbd-compatible endpoint and let Sonarr and Radarr hand them over. It writes into the same directory. zurg can also search your Newznab indexers by itself. It does that when you press play in Stremio. It also does it for requests from your watchlist and from Seerr and the *arrs through acquisition. Torrents for a debrid account have an endpoint of their own that speaks qBittorrent. - zurg, plus
rcloneif you want a filesystem mount rather than WebDAV directly. zurg downloads rclone for you on first run.
1. Configure your news accounts
Each account in zurg is one entry under providers:. A Usenet one authenticates with server credentials instead of an API token, so it carries an nntp block instead of token:
zurg: v1
providers:
- type: nzb
nntp:
host: news.example.com
port: 563
tls: true
username: YOUR_USENET_USERNAME
password: YOUR_USENET_PASSWORD
connections: 30 # your plan's allowance — see below
cache_size_mb: 512 # decoded articles held in memory, across all files
That is the whole of it. Add debrid accounts beside it if you have them:
providers:
- type: realdebrid
token: YOUR_RD_API_TOKEN
- type: nzb
nntp:
host: news.example.com
...
Two rules the config validator enforces:
- Only one enabled
nzbprovider entry. Two would each scan the samenzbs/directory and duplicate every release. This is not a limit on how many Usenet providers you can use — extra news accounts go under that one entry'snntp.servers, where they serve the same library instead of a second copy of it. watchlist: truecannot target it. The Usenet backend cannot add content, so it can never be the account a Plex watchlist addition lands in.
More than one Usenet provider
Retention is per-provider, and the difference between one extra fetch and a full-release PAR2 rebuild is whether some other account still has the article. That is why a second account is worth more here than redundancy usually is.
The keys already in the nntp block describe the first account. Anything under servers is another one, asked only when the accounts before it did not have the article. An existing single-account config keeps working untouched:
providers:
- type: nzb
nntp:
host: unlimited.example.com # first account: the workhorse
tls: true
username: USERNAME
password: PASSWORD
connections: 30
servers:
- host: second-unlimited.example.com # different backbone = different retention
tls: true
username: USERNAME
password: PASSWORD
connections: 20
backbone: usenetexpress
- host: block.example.com # metered — only for what the others lack
tls: true
username: USERNAME
password: PASSWORD
connections: 10
backup: true
backbone: omicron
Each account keeps its own connection allowance, and reads are driven at the combined allowance of the non-backup accounts — 30 + 20 = 50 in the example above. A second unlimited provider therefore buys throughput as well as retention insurance; a backup account buys neither, by design, since it only ever carries what the primaries could not supply and counting it would widen every read-ahead window for capacity steady-state playback never touches.
Three keys shape who gets asked:
Every account keeps a warm floor of its own as well, out of its own allowance: a retention gap sends a read straight past the primary, so the account reached for the first time is exactly the one whose dial nobody has paid yet. A two-connection block account can be told to hold none with warm_connections: -1.
Reselling is rife, so backbone is worth filling in: two accounts you bought from different companies may sit on the same spool, and without it every miss is asked twice. Group them by whatever name you like — the string is only compared against other entries.
At startup zurg names every account it will use:
Usenet account nzb connected: nntps://unlimited.example.com:563 (30 connections) + nntps://second-unlimited.example.com:563 (20 connections) [usenetexpress] + nntps://block.example.com:563 (10 connections) [backup] [omicron]
The census that decides whether a release needs repairing asks every account before calling an article missing, and asks each one only about what its predecessors lacked. A transport failure is never mistaken for an aged-out article: if any account failed to answer at all, that failure is what gets reported, so a timeout never causes silence to be served and a repair to be scheduled for something that was never gone.
Configuring providers in the Dashboard
http://localhost:9999/config/ → Essentials → Providers does the same thing without editing YAML. The table lists every account in config order:
# Name Type Token Flags
1 realdebrid realdebrid …DFFND4OA — [Edit] [Delete]
2 nzb nzb news.example.com — [Edit] [Delete]
For an nzb row the Token column shows the news server host instead of a token hint — there is no token to redact.
To add a Usenet account:
- Click Add provider.
- Set Type to
nzb. The token fields disappear and the NNTP fields appear in their place. - Leave Name empty unless you want something other than
nzb— it is what the per-account directory is called, sousenethere gives you__usenet__in the mount. - Fill in NNTP host, Port, TLS (checked by default — leave it on), Username, Password, Connections (placeholder
8; put your plan's real allowance here) and Cache size MB (placeholder512). - Leave Watchlist target unchecked — the backend cannot add content and saving it checked is rejected. Disabled keeps the entry in
config.ymlwithout loading it. - Save.
The form is validated as if the edit had been applied, so mistakes come back as an alert rather than a broken config. Adding a second nzb provider is refused there — the error points you at nntp.servers, which is where further news accounts belong.
To edit one, click Edit on its row. The form loads everything except the password, which stays blank and means keep the existing one — type a new password only to change it.
Delete asks to confirm, then removes the provider entry from config.yml. It does not touch your nzbs/ directory — the NZB files stay where they are and come back with the provider. The last enabled provider can be neither deleted nor disabled.
Every change here writes straight to config.yml and shows:
Provider changes saved to config.yml — restart zurg to apply
Provider edits are not hot-reloaded. Restart zurg before expecting anything to change.
The Dashboard cannot see
nntp.servers. The form has fields for one news account, and saving annzbprovider rewrites its wholenntpblock from those fields — so the extra accounts are written out of the config. If you run more than one Usenet provider, keep them inconfig.ymland use the Dashboard's other sections rather than this provider's Edit button.
2. Start zurg and confirm the accounts
./zurg
zurg verifies the news accounts once at startup, because a wrong password otherwise shows up much later as every file failing to play:
Usenet account nzb connected: nntps://news.example.com:563 (30 connections)
A total failure is just as loud:
Cannot reach the news server for nzb: <reason>
Check the port and TLS pairing first (563+tls: true, or 119+tls: false), then the credentials, then whether the plan is actually active.
With several accounts, read the warnings, not just the connected line. Each unreachable account gets one of its own, and startup carries on as long as one account answers — the error above appears only when every account is unreachable. So a dead second provider looks like a healthy start unless you notice:
Usenet account block.example.com is not reachable: <reason>
3. Add NZB files
zurg makes the nzbs/ watch folder when it starts. It sits in the folder zurg runs from. Copy NZB files into it.
cp ~/Downloads/Some.Release.2024.2160p.nzb nzbs/
Within a second or two zurg notices the file and reads it, and the library lists it as soon as the read finishes. The log says what it read:
Loaded NZB Some.Release.2024.2160p.nzb: 94 files
How quickly it appears
zurg watches nzbs/ itself, a check about once a second that costs one syscall
however many NZBs the directory holds, so a file arriving in it is read almost
at once rather than whenever the next library change check comes round. An
check_for_changes_every_secs any more. It used to: an obfuscated
release was listed up to two whole intervals after it was dropped, one for the
directory to be read and another because the names had been resolved just after
the check that would have noticed. Measured at an interval of 10 seconds, that
was 17.7 seconds from the drop to the release appearing, of which 0.67 seconds
was the work.
The periodic check is still there and is still the backstop. It is what catches the two things a watched directory cannot report on its own: an NZB replaced in place under a name it already had, and a filesystem whose directory timestamps are too coarse to separate two changes made in the same second.
What gets picked up
- Files directly inside
nzbs/whose name ends in.nzb, case-insensitively. - Subdirectories are skipped.
nzbs/movies/foo.nzbis invisible. Flatten instead of organising — the library's organisation comes from directory filters, not from this folder. - Plain XML only.
.nzb.gzis not unpacked and not read; decompress it first. - A file that fails to parse is skipped with a warning and does not stop the scan.
What the release is called
The name zurg shows — and therefore the folder Plex sees — is:
- the NZB's own
<meta type="name">value, if it has one and it survives sanitising, otherwise - the filename with
.nzbstripped.
So most of the time, the NZB's filename is the release name. Name the file the way you would name the release, because that is what your media server will try to match:
# good
nzbs/Some.Movie.2024.2160p.UHD.BluRay.x265-GROUP.nzb
# bad — Plex has nothing to work with
nzbs/indexer_download_48213.nzb
Two different releases that end up with the same name are told apart by a short hash tag (Some.Release {a1b2c3}), the same as anywhere else in zurg.
Password-protected releases
If the indexer recorded the archive password in the NZB's metadata block, zurg uses it when streaming out of the RAR set:
<head>
<meta type="password">thepassword</meta>
</head>
Nothing else is needed. A protected release whose NZB carries no password meta cannot be opened.
Obfuscated releases
Posts whose filenames are random are handled, at a cost paid once at scan time. zurg tries the cheap source first — the filename an article still declares in its own yEnc header, one article per file — and falls back to the release's PAR2 index, which records each file's true name against the MD5 of its first 16 KiB. The name and exact length are both in the article's header, so the scan can answer as soon as that header arrives while the body continues into the cache for a later read. An obfuscated release is therefore named and sized by the same articles. It logs what it recovered:
Recovered 27 filename(s) in Some.Release
The whole naming pass is bounded at 90 seconds per NZB, so an unreachable server delays a scan rather than hanging it. Names are settled before the library is built from them, because the library keys files by name and a later rename would lose them.
A fully obfuscated post has no real name to recover. Some posters obfuscate before building the recovery set, so the PAR2 index restores names that are themselves random (aJPSgA7eYizyzjJXT.part01.rar), and the file inside the archive is a hash too. Nothing in the post knows what the release is — only the NZB's own name does. So zurg presents the payload under the release name, the same thing SABnzbd does at the end of unpacking:
Ugliest.House.in.America.S08E02.720p.AMZN.WEB-DL-RAWR/
└── Ugliest.House.in.America.S08E02.720p.AMZN.WEB-DL-RAWR.mkv
Only the file that is clearly the release is renamed: the biggest entry in the archive, and only when it is at least three times the size of the next one, its extension is not part of a disc structure (.vob, .m2ts, .mpls), and its name says nothing. Subtitles and samples sharing its name follow it. An archive holding several files of a similar size — a season pack — is left alone, because no one of them is the release. The archive's own names keep working as addresses, so a player that started before a rescan does not lose the file.
Replacing and removing
- Removing an NZB from
nzbs/removes the release from the library, drops its cached reader and article cache, and forgets any repaired bytes it held. - Deleting a Usenet release from the Dashboard deletes the
.nzbfile from disk. There is nowhere else for it to live. This is not the same as deleting a torrent from a debrid account, where the file stays on the service. - Replacing an NZB in place is the slow way to do it. Writing over a file does not change the directory, so the watch above cannot see it; what notices is the periodic change check, which reads the file's modification time and re-parses it — up to
check_for_changes_every_secslater. Removing the old file and adding the new one is noticed within a second or two, and is what an indexer or the SABnzbd endpoint does anyway.
4. Where releases land in the mount
A Usenet release is an ordinary library entry, so your existing directories: filters apply to it unchanged:
directories:
shows:
group: media
group_order: 20
filters:
- has_episodes: true
movies:
group: media
group_order: 30
only_show_the_biggest_file: true
filters:
- any_file_inside_not_regex: /\.(mp3|flac|m4b)$/i
Alongside those, the account gets a directory of its own — __nzb__ by default, or __<name>__ if you set name: on the provider entry. That directory is not just a view: reads under it are pinned to the Usenet account with no failover, which makes it the right place to test whether Usenet playback works without a debrid account quietly answering instead.
Four Usenet-specific things to know when writing filters:
- Tag filters will not match Usenet content. Every
zurg_*tag (resolution, HDR, bitrate, duration, language) comes from ffprobe, and ffprobe is an external binary handed a URL — a link zurg assembles itself is not something it can open, so analysis is skipped for this backend entirely. Filter Usenet content on names, file extensions, sizes andhas_episodesinstead. A directory keyed ontags_match_anywill simply never contain a Usenet release. .par2and.sfvfiles are hidden. They are repair scaffolding; zurg reads them itself and no media server has any use for eight of them per season pack..nfois kept, because Kodi, Emby and Jellyfin read it.- RAR sets are content, not archives to unpack.
.rarand.rNNstay visible and a volume set appears in the mount as a folder whose contents are the files inside it — zurg streams video straight out of the archive without extracting anything. A release that is only one volume set stands in for its own contents instead, with no archive folder in between: the payload sits directly in the release folder, which is where a media server looks for it. Files posted beside the set — the.nfo, the poster's.txt, artwork, subtitles — stay listed there too. force_select_playable_filesdoes not apply. A Usenet post is whole; there is nothing to select. Every content file is already offered.
Browse the result before involving a media server. http://localhost:9999/http/ is a plain HTML index of exactly what the mount will show:
curl -s http://localhost:9999/http/__nzb__/ | head
5. Mount it with rclone
The easy path: let zurg run rclone
rclone_enabled: true
rclone_binary: bin/rclone # downloaded automatically on first run
mount_path: "/mnt/zurg" # /Volumes/Zurg on macOS, Z: on Windows
zurg starts rclone with a VFS profile already tuned for its own read pattern (buffer size, chunk sizing, cache mode, dir-cache time, timeouts), keeps the mount alive, and invalidates the right dircache entries when the library changes. mount_path is also the prefix zurg uses when telling Plex which paths to scan, so it must be set even if you mount elsewhere.
Verify:
ls /mnt/zurg/
ls /mnt/zurg/__nzb__/
The manual path: your own rclone
If you already manage rclone, point a WebDAV remote at zurg:
[zurg]
type = webdav
url = http://localhost:9999/dav/
vendor = other
pacer_min_sleep = 0
rclone mount zurg: /mnt/zurg --config rclone.conf --dir-cache-time 10s --vfs-handle-caching 0
--vfs-handle-caching 0 needs rclone 1.74 or newer, and keeps rclone from downloading for five more seconds after a reader closes a file. The README explains what it costs to leave out.
Still set mount_path in config.yml to wherever you mounted it, or media-server scan notifications are built from the wrong prefix.
Docker
zurg's working directory in the image is /config, and the watch directory and the repair cache both sit under it. One bind mount carries them along with everything else:
services:
zurg:
image: ghcr.io/debridmediamanager/zurg:latest
volumes:
- ./:/config
ports:
- 9999:9999
Dropping an NZB into ./nzbs on the host is then picked up inside the container without a restart.
Installs made before
/configbind-mounted/app/config.yml,/app/nzbsand/app/dataseparately. Those keep working exactly as they did — see docker.md.
6. Point Plex at it
Nothing here is Usenet-specific except the last subsection, but the order matters.
-
Give zurg your Plex credentials, so it can ask Plex to scan just the paths that changed instead of leaving you to wait for a periodic full scan. Easiest through the Dashboard's Plex auth flow, which writes the values back into
config.yml:plex_server_url: "http://localhost:32400" plex_token: YOUR_PLEX_TOKEN -
Add libraries pointing at subdirectories of the mount, never at the mount root:
- Movies →
/mnt/zurg/movies - TV Shows →
/mnt/zurg/shows
The same release appears in every directory whose filter matches it and under
__all__and__nzb__. Point Plex at the root and it scans the same file several times over. - Movies →
-
Set two library options under each library's Edit → Advanced:
- Uncheck "Empty trash automatically after every scan". This is the one that matters. If the mount blips while Plex is scanning — a zurg restart, an rclone remount — Plex concludes every file was deleted. With auto-empty on, they are removed permanently and at once; with it off they sit in the trash and come back when the mount does. This has cost a 30,000-item library before.
- Uncheck "Generate video preview thumbnails". Every thumbnail is a seek across the whole file; on Usenet that is a full read of the release through your connection allowance.
-
Never restart zurg during a Plex scan. Check before you do:
TOKEN=$(grep ^plex_token: config.yml | awk '{print $2}') curl -s "http://localhost:32400/status/sessions?X-Plex-Token=$TOKEN" | grep -o 'size="[0-9]*"' curl -s "http://localhost:32400/library/sections?X-Plex-Token=$TOKEN" | grep -o 'refreshing="1"' | wc -lSessions at
size="0"and a refreshing count of0mean it is safe. (Usegrep -o … | wc -l, notgrep -c: grep exits non-zero when it finds nothing, which is the good case, and will abort a&&chain right before the restart it was guarding.) -
Partial scans.
on_library_updateis handed every changed path;plex_update.shin the repo root is the reference script:on_library_update: sh plex_update.sh "$@"
What Plex will not get from Usenet content: no ffprobe-derived metadata from zurg (see section 4), so Plex does its own analysis on first play like it would for any local file. File sizes settle shortly after a release is scanned: the first listing may report a cheap estimate from the article count, and the exact length arrives behind it — from the recovery index where there is one, and otherwise from one article per file. For a large multi-volume archive whose recovery index already states every exact length, one verified volume can establish the shared article stride; each sibling checks that inference against its own header before serving its first byte. Once a length lands, the library is told the release changed, so the mount and Plex both see the real length. zurg sizes up to eight releases at a time on an account of twelve connections or fewer and more on a larger one (twelve on 16 connections, sixteen from 20), and when acquisition checks a release with the news servers, that check queues beside this sizing and read-ahead and never takes the connection kept for a read a player is waiting on.
Jellyfin / Emby / Infuse
Jellyfin and Emby take jellyfin_server_url / jellyfin_token and emby_server_url / emby_token and get the same scan notifications; see jellyfin.md. Infuse can point at http://<host>:9999/dav/ directly and skip rclone entirely.
7. When an article is missing
This is the failure mode Usenet has and debrid does not. Articles age out of a provider's retention or get taken down, and a release can be perfectly listed while a few of its articles are gone.
zurg's answer, in order of preference:
- Ask another news server. An article one provider has aged out is very often still on another. This is one fetch — see
More than one Usenet provider . - Rebuild it from the release's own PAR2 files. This is what they are posted for. It costs a read of the entire release — Reed-Solomon has to fold every surviving slice to solve for a missing one — so on a 200 GB post it is 200 GB of reading to recover a few megabytes.
- Serve silence. Only when neither of the above can answer.
PAR2 repair is a background job, at most one release at a time, and it needs:
enable_repair: true # PAR2 repair does not run without this
par2_patch_cache_mb: 512 # budget for data/par2/; -1 keeps repairs in memory only
The rebuilt bytes are written under data/par2/<nzb-id>/ and picked back up when the file is next opened, so a restart does not cost a full re-read of the release. Only the spans that are actually gone are kept — a damaged release usually costs single-digit megabytes — and whole releases are dropped least-recently-used first when the budget is exceeded. Each release's spans are stamped with the modification time of the NZB they were rebuilt from, so a re-saved NZB discards them rather than serving bytes at offsets that may have moved.
A repair announces itself and then says what it cost:
PAR2 repair of Some.Release: 5 slice(s) missing; reading the release to rebuild them
PAR2 repair of Some.Release rebuilt 5 slice(s) in 55s, keeping 3.4 MiB of them (library holds 3.4 MiB in memory, 3.4 MiB on disk)
Two things worth knowing:
- Playback always outranks a repair. Connections are handed out by who is waiting: a demand read first, read-ahead next, a repair last — except that a repair is guaranteed a quarter of the allowance against read-ahead, so the file being played can still repair the gap it is stuck on.
- zurg's library-level repair cannot help here. That mechanism re-adds a torrent to the account it came from; a backend that cannot add content can report a release broken but not fix it. For Usenet, PAR2 is the repair.
A release with no PAR2 files in its NZB and no second news account has no recovery path at all. That is a property of the post, not of zurg.
Performance
Measured against a live news account with a large connection allowance:
The connection allowance sets the first ceiling on single-stream rate. zurg keeps that allowance loaded by sizing read-ahead in both articles and bytes: small-article archive posts get at least 64 MiB of reach, while multi-megabyte posts keep the connection-and-pipeline window they already had. So:
- Set
connectionsto your plan's real number. This is the tuning knob. Eight connections will not stream a remux. - Two primary accounts add up. Reads are driven at the combined allowance of every non-
backupaccount, so a second unlimited provider raises the ceiling as well as covering the first one's retention gaps. Abackupaccount does not count toward it. warm_connections(2 by default) buys latency, not throughput. It holds that many connections open, idle and authenticated, so the first read after a start or a quiet spell does not spend ~0.8s dialling before its first byte: 1.5s to first byte cold against 0.15s warm. Once a stream is running it changes nothing.- The dials that do happen stick to one backend, so their TLS sessions resume. A dial costs about 0.8s — measured against a live news server on 2026-08-29: 106ms TCP, 213ms TLS handshake, 256ms greeting, 218ms for the two
AUTHINFOexchanges. A provider hostname is a rotation (that one resolves to 11 addresses) and a TLS session ticket is only good at the machine that issued it, so redialling whichever address the resolver named first resumed 0 of 3 sessions while redialling the same address resumed 3 of 3. Each account now redials the address its last connection came from, which takes 110–140ms off every redial there — a resumed handshake is 109–141ms against 225–261ms for a full one. A server with a ~18ms round trip saves less. The pin is dropped and the name resolved again as soon as that address stops answering, so nothing is stuck to a machine the provider has retired; a refusal that reaches NNTP — the account at its connection ceiling, a rejected password — keeps it, because that is the account's answer and not the backend's. Each dial logs its phases at debug level,resumed=included. cache_size_mb(512 default) is shared across every file being read. Raise it if you run several concurrent streams; it does not make one stream faster. Speculative reads may reserve at most half of it across at most two readers, leaving the other half for articles already consumed or about to be consumed.- Whether a release is RAR-packed or posted as plain files no longer matters much for throughput.
One slow article no longer stalls the stream. A sequential read averaging ~90 MB/s still dips to 1–5 MB/s for about a second at a time, and every one of those dips is the same thing: an article stops arriving while the other connections finish theirs and go idle. When a read has been standing on one article for longer than that reader has learned to expect, zurg asks a second connection for the same article and serves whichever answer arrives first. A stalled pipelined batch may block several consecutive playhead articles on the same dead socket, so up to five distinct articles from one reader may now be hedged together; one is never asked twice. The account still pays for at most 20 second asks a minute. The deadline remains twice the reader's own recent wait, never under 250 ms and never over 2 s, and only a read a client is actually waiting on is hedged — read-ahead, next-volume prefetch and repair are not. An article the servers have just refused is not hedged either: that one is already waiting on its own confirmation a second later, and asking inside that window proves nothing. Each hedge writes one line at debug level saying how long the read had blocked, and the line that follows says whether the second ask won.
For declared ranges, the playhead article enters the priority scheduler before later bytes are offered. Pipelined connections are then dealt consecutive article rounds rather than contiguous chunks, so the first useful articles are spread across the allowance. A request of at least 64 MiB is treated as playback and gets the full range frontier; a small seek remains a probe and opens only a narrow ramp.
Crossing a volume boundary
A file inside a RAR set is served one volume at a time, and each volume is a separate read into the news account. zurg opens the next volume while the current one is still playing and reads its first four megabytes, so a crossing does not start cold. On a release posted in small volumes that head is not enough on its own: four megabytes is about ten articles of a release posted in ~395 KB parts, and volumes of ~26 MB put a boundary every 1.7 seconds of playback — so the stream drained the warm head from memory in a quarter of a second and then waited a round trip for everything behind it. Measured on a Hetzner CCX23 over ten connections on 8 September 2026, that release streamed at 14–16 MB/s where a plain file on the same account reached 86–99 MB/s, its one-second rate swinging between 1 and 29 MB/s, while 46 to 55 of the account's 60 read-ahead slots sat unused.
zurg now warms the next volume a whole read-ahead window deep once the stream comes within 32 MiB of the boundary, and a volume the stream has just crossed into keeps the window it earned instead of starting again at two articles. A volume the playhead will not reach for several seconds is still warmed only to its head, which is what keeps a library scan — a reader per entry, a few megabytes of each — from pulling a window of every next volume it will never read. Nothing here needs configuring, and it changes no memory ceiling: an account still carries two read-ahead windows at once, the volume being played and the one being warmed, inside cache_size_mb.
Repairs are slow by nature — they read the whole release — but they run at background priority and are bounded by the news server rather than by zurg's own arithmetic.
Memory: what a watch directory costs
An NZB is a list of articles, and a big library is a lot of articles. Parsing one keeps its message ids and their offsets in memory so a read can find them, and builds before this one kept every one of them for the life of the process. Measured on a watch directory of 2,636 NZBs — 27.6 million <segment> elements — that is 1.52 GB of live heap, at about 58 bytes per article, on top of the article cache. Almost none of it is in use: a listing needs names, sizes and counts, and only a file actually being read or repaired needs to know what its articles are called.
nzb_segments decides whether zurg keeps them:
nzb_segments: mmap # mmap (default) | reparse | resident
nzb_segments_idle_secs: 120 # how long a release's articles are kept after the last read
mmap— the lists are written todata/nzb-index/<id>.binand that file is mapped on demand, so the bytes sit in the page cache rather than on zurg's heap and the kernel reclaims them under pressure. The default on Linux, macOS and BSD. Costs roughly as much disk as the lists themselves.reparse— the lists are given back once a release has settled, and the NZB is re-read from disk when a read or a repair needs them. Costs no disk at all, which is the reason to choose it: a host wheredata/must not grow.resident— every article list in memory, always. The previous behaviour, and what every earlier build did. Still supported; it is simply no longer what you get by default.
Whichever is set, a listing never loads anything: a file's name, its size and its article count are answered without the article list being anywhere near memory, so a library refresh, a fingerprint check and an *arr asking whether a job has settled all stay free. What the lazy modes cost is the first read of a release nothing has touched for nzb_segments_idle_secs — a sweep runs every 30 seconds — and a release's whole set of files loads together, so walking a season pack's rar volumes in order pays it once.
Measured on a 60-file, 30,000-segment release:
Fifty nanoseconds an article is nothing beside the milliseconds it takes to fetch one, so the read-path figures matter less than they look. The one to weigh is the reload: reparse re-reads and re-parses the whole XML document, which on a season pack is tens of milliseconds before the first byte moves; mmap is a mmap call, a slice header per file, and a checksum of the index it just mapped — 0.08 ms of the three, and the other 0.19 ms is the price of catching a bad byte rather than serving it as a message id.
And on a whole library rather than one release — 2,636 NZBs, 27.6 million articles, the same binary run three times over the same corpus, idle after a scan:
Warm opens (1 ms), streaming throughput (38–39 MB/s) and startup time were indistinguishable across all three. mmap opens a cold release faster than keeping everything in memory, because a release that was never opened this run has to be parsed under resident too — and mapping a written arena is a syscall where parsing is megabytes of XML.
The index rewrites itself whenever the NZB changes, repairs itself if it is ever unreadable — falling back to the NZB and writing it out again — and is removed when the NZB leaves the watch directory. Both its header and its contents are checksummed, so a bad byte is caught rather than served as a message id nobody posted. What it currently weighs is on the Dashboard's front page, under Article Index, and in the line the backend logs at startup. On Windows there is no mapping: the index would be read into the heap instead, which is resident memory plus a reload, so the default there stays resident and reparse is the lazy mode worth choosing.
Troubleshooting
Asking about one article
GET /debug/nzb/probe puts a single question to the news accounts through the
pool zurg is already using, at the priority a demand read gets. It exists
because the alternative did not work: an account gives out eight connections and
zurg holds all of them, so probing a post by hand meant a second account or a
stopped server — and stopping the server destroys the state being diagnosed.
# Is this article still there? A STAT, so nothing is transferred.
curl -u user:pass 'http://localhost:9999/debug/nzb/probe?message_id=abc@news.example'
# The same question addressed the way you have it: a release id from the
# library, a file index counting from zero, an article number counting from one.
curl -u user:pass 'http://localhost:9999/debug/nzb/probe?release=<nzb id>&file=0&article=3'
# &body=1 fetches the one article and reports what actually arrived against what
# the article says should have. This is how a provider that strips trailing
# bytes is identified: `short: true` with `decoded_bytes` below `span_bytes`.
curl -u user:pass 'http://localhost:9999/debug/nzb/probe?release=<nzb id>&article=3&body=1'
It answers one JSON object per configured Usenet account — found, missing,
which account answered, and how long it took — so "which of my providers still
has this" is a single call. It is read-only: nothing it fetches enters the
article cache, and an article it fails to find is not recorded as a dead one, so
a probe cannot silence a file that plays.
See also
- config.md — every option, including the Usenet account reference and the directory filter reference
- plex.md — how zurg drives Plex scanning, matching and the trash sweep
- jellyfin.md — Jellyfin integration
- docker.md — Docker setup, FUSE propagation and troubleshooting
- tags.md — the tag system, and therefore what Usenet content does not get
- architecture.md —
internal/nzbandinternal/nntpin context