zurg Configuration Documentation

This document details all available configuration options for zurg. Configure these options in your config.yml file.

Tip: Most settings can be configured via the Dashboard at http://localhost:9999/config/. The web interface provides an easier way to view and modify settings without editing the config file directly.

Migrating from a pre-providers config

If your config was written before the multi-account support landed, it still starts: the top-level token:, download_tokens: and strm_link_token: keys are read as the one thing they could ever have described — a single Real-Debrid account — and startup warns that they are deprecated. Moving them into a providers entry is the only change your config file needs, whenever you get to it: no other key was renamed, retyped or removed.

It is not, however, the only thing that changes. A handful of directives keep their name and their value and decide something slightly different than they used to, because the code reading them moved. Those are collected under What else changes when you upgrade below — worth a read if you filter on archives, on tags, or on when a release was added.

The one change to make

A single Real-Debrid account used to be three top-level keys. All three now live inside one providers entry.

Before:

zurg: v1
token: YOUR_RD_API_TOKEN
download_tokens:
  - ANOTHER_RD_API_TOKEN
strm_link_token: RD_TOKEN_FOR_STRM

After:

zurg: v1
providers:
  - type: realdebrid
    token: YOUR_RD_API_TOKEN
    download_tokens:
      - ANOTHER_RD_API_TOKEN
    strm_link_token: RD_TOKEN_FOR_STRM

Delete the old keys once they are moved. Left beside a providers block that already configures Real-Debrid they are ignored, and startup warns about them, so the two can never silently disagree about which token is live. Nothing else in your file needs touching — every other key kept its name and meaning, and a config whose only credential was token: needs only this edit.

You are not forced to make it. An unmigrated config still starts: the three keys are read as one Real-Debrid account named realdebrid, and startup names the keys it read and what to write instead. They are deprecated, not supported — new account features are reachable only from a providers entry.

The TOKEN / RD_TOKEN environment variables still work and still auto-create a config on first run.

What became possible, that you may want

None of this is required. It is what the providers block bought:

Now available Where
TorBox, AllDebrid and Usenet backends, alongside Real-Debrid or instead of it Accounts
Several accounts of the same service, told apart by name Accounts
disabled: true to park an account without deleting its credentials Accounts
watchlist: true to choose which account new torrent adds are tried on first Accounts
provider: / not_provider: directory filters, and per-account directories Directories
rd_cdn_host_preference and tb_cdn_host_preference for per-service CDN choice CDN & Host Selection

Two smaller changes worth knowing about:

  • cdn_host_preference still works and still means Real-Debrid. It is now an alias for rd_cdn_host_preference, which wins if both are set.
  • Plex credentials from the Dashboard's auth flow are written back into config.yml as plex_server_url and plex_token. They used to live in data/plex.json.

Migrating by hand vs. by dashboard

The Dashboard can finish it for you. An unmigrated config loads, so the providers editor is reachable, and it lists the Real-Debrid account your top-level keys were read as; saving any account moves those keys into the providers block on disk. Editing the file by hand and restarting does the same thing and is the only option if the config does not load at all.

One thing the Dashboard no longer accepts: POST /config used to take token, download_tokens and strm_link_token as top-level keys, and now answers 400 Unknown config option. Credentials are edited per account instead. Anything scripting those three needs updating.

What else changes when you upgrade

Every directive below keeps its name, type and value. What changed is the code that reads it, so the same config can produce a different result. Nothing here needs an edit — this is what to expect, and what to change only if you disagree with it.

Filters now see inside archives. any_file_inside_regex, any_file_inside_contains, any_file_inside_size_gte/_lte, their not_ forms, has_episodes and is_music used to be evaluated against the archive itself: one entry named Show.part01.rar, sized like the volume. Once zurg has listed an archive, they are evaluated against its contents instead — the real filenames, their real sizes, and deliberately not the archive's own size, which dwarfed everything in it. A season pack that read as one enormous file now matches has_episodes; any_file_inside_regex: /\.mkv$/i starts matching where it used to fail, and any_file_inside_not_regex stops matching where it used to pass. Separately, a release whose every file is .rar/.r00/.par2 reaches the filters at all now, where before it went straight to __unplayable__.

This arrives release by release rather than all at once: it applies only to archives zurg has already listed, and listings written by an older parser are discarded, so a release moves the first time something opens it. If a directory of yours depends on the old reading, express it against the archive name with regex/not_regex, which still match the release name.

Tag filters no longer catch releases that never probed. zurg_* tags come from ffprobe. When nothing could be probed, every tier table bottomed out and the release was still stamped zurg_sdr, zurg_very_low_video_bitrate, zurg_very_low_audio_bitrate and zurg_0to20mins_duration — describing a file nobody had measured. Those are no longer emitted. A filter naming one of those four changes meaning for un-probeable releases: has_tag/tags_match_all/tags_match_any stop catching them, and — the direction worth checking — not_has_tag, tags_missing_all and tags_missing_any start catching them. Filters naming any other tier are unaffected.

added_within_hours / added_within_days / added_after / added_before read an older timestamp for duplicates. When one release is present more than once, the entry now reports the earliest add time rather than the first one seen, so a second copy of an old release no longer makes it look newly added. A recent-style directory can drop a release it previously kept.

The mount caches directory listings for 12 hours. check_for_changes_every_secs used to set rclone's --dir-cache-time, so listings expired every 15 seconds by default. They now hold for 12h and zurg forgets the affected entries explicitly whenever the library changes, which keeps media-server scans warm. To go back, override it directly — rclone_extra_args wins over any built-in flag:

rclone_extra_args: ["--dir-cache-time", "15s"]

A __<account>__ directory appears at the mount root. Every configured account gets one, single-account configs included, created from the providers block rather than from directories. It holds everything that account holds, ignores your directory filters, and pins reads to that account. It is not something directories can suppress.

Two releases that shared a name are now two entries. They used to merge into one, silently folding one release's files into the other's. The one that does not keep the bare name is suffixed with six characters of its info hash — Movie.2024 {a1b2c3}. That suffixed string is what regex, not_regex, contains and not_contains match against, so an anchored pattern will miss it. Separately, entries disambiguated by a duplicate basename are renamed once on upgrade, from an account-minted file id to a path-derived tag.

.strm files change shape. With serve_strm_files, every .strm the mount serves changes content and reported size — the URL now addresses the library entry rather than one account's link, so it fails over and survives repairs. A media server sees every .strm change. With save_strm_files, the files already written are rewritten in place under the same names the first time zurg starts, and a media server picks up the new URL at its next scan. The new form is signed with a secret at data/strm_secret; if that cannot be written zurg logs a warning and starts anyway, and the links stop resolving after the next restart — worth not ignoring.

load_dumped_torrents will not load a pre-upgrade dump/. Cached torrents now carry the account they came from, and ones without that stamp are skipped. The main cache re-imports from the account on the next refresh; dump/ has no account behind it, so it comes up empty with a warning in the log. Re-export from a running instance if you need it.

Repair takes a different route when restrict_repair_to_cached and only_full_torrent_repair are false. Both keys are unchanged when set to true. With them off — the default — the per-file and batched strategies were replaced by a single re-add naming the broken files. Same intent, different requests against the account.

Smaller ones. ignore_renames now also decides the name of the on-disk .zurgtorrent (self-migrating on load). retries_until_failed now bounds Real-Debrid fair-usage retries, which previously retried forever. auto_analyze_new_torrents: false and a missing ffprobe_binary no longer suppress cache cleanup, which used to be skipped along with the analysis. A size filter that excludes an archive's first volume now hides that archive entirely rather than falling back to a later volume. Unauthenticated static assets widened from /static/css/* to /static/*; no previously protected route became public.

Windows Configuration

When running zurg on Windows, paths must be formatted differently:

# Mount path - use drive letter with escaped backslash or forward slash
mount_path: "P:\\"       # Drive letter (escaped backslash)
mount_path: "P:/"        # Drive letter (forward slash also works)

# Binary paths - use forward slashes for paths to executables
ffprobe_binary: "Q:/zurg/bin/ffprobe.exe"
rclone_binary: "Q:/zurg/bin/rclone.exe"

# Alternative: use double forward slashes (also valid)
ffprobe_binary: "Q://zurg//bin//ffprobe.exe"
rclone_binary: "Q://zurg//bin//rclone.exe"

Key points for Windows:

  • Use quotes around all paths
  • For mount_path: use a drive letter like P:\ (escaped as P:\\ in YAML) or P:/
  • For binary paths: forward slashes / work and avoid escaping issues
  • Ensure .exe extension is included for executables

1. Essentials (Setup First)

These are the non-negotiables to get the server up and running.

Option Type Default Description
zurg string (required) Configuration version identifier. Must be set to v1; any other value (including an absent key) fails the load.
providers list (required) Your debrid and Usenet accounts, one entry each. See Accounts below. The pre-provider top-level token, download_tokens and strm_link_token keys are deprecated: they still load, as a single Real-Debrid account, and startup warns. A providers block that already configures Real-Debrid wins and the stray key is ignored.
mount_path string OS-dependent The filesystem path where your rclone mount lives. Zurg uses this to tell media servers (Plex, Jellyfin, Emby) where to find files. Also used as the mount point when rclone_enabled is true. Defaults to /mnt/zurg (Linux), /Volumes/Zurg (macOS), or Z: (Windows). Docker users should set this to /zurg_mnt/zurg with /zurg_mnt:/zurg_mnt:rshared volume mount (see docker.md).
base_url string "" The externally-reachable URL of your zurg instance. Embedded into STRM files so media servers can stream content. If unset, STRM files will contain localhost URLs that won't work from other devices. Use your Tailscale IP, LAN IP, or a reverse-proxied domain. Example: http://100.x.x.x:9999
zurg: v1
providers:
  - type: realdebrid
    token: YOUR_RD_API_TOKEN
mount_path: "/mnt/zurg"
base_url: "http://192.168.0.123:9999"

Accounts

Each entry in providers is one account. Any number can run side by side — Real-Debrid, TorBox, AllDebrid, Premiumize, Debrid-Link and Offcloud together, or several accounts on the same service.

Option Type Default Description
type string (required) Which backend the account uses: realdebrid, torbox, alldebrid, premiumize, debridlink, offcloud, or nzb.
name string value of type Distinguishes this account from others of the same type. It is the name used by the provider filter, the per-account directory, cache paths, and the dashboard. Names must be unique across entries, and e is reserved for STRM link routing.
token string (required) The account's API token or key. Get a Real-Debrid one from http://real-debrid.com/?id=20475782, a TorBox one from https://torbox.app/settings, an AllDebrid one from https://alldebrid.com/apikeys?uid=1kk5i. Not used by nzb, which authenticates through nntp instead. A Real-Debrid token can also be provided via the TOKEN or RD_TOKEN environment variable to auto-create a config on first run.
download_tokens list [] Real-Debrid and AllDebrid: backup API tokens for the same service, used when the active token's bandwidth allowance is exhausted. They use this entry's catalog, including a local catalog, without importing the extra accounts' torrent libraries. TorBox: accepted, but rotation does not remap account-specific torrent/file IDs; use separate provider entries for different accounts. Unused by NZB. Premiumize, Debrid-Link and Offcloud require separate account entries because their stored file IDs belong to the listing account.
strm_link_token string "" A separate API token that resolves the reads arriving at the /strm/ endpoint, so a player opening a .strm does not spend the token the live mount streams on. Falls back to token if not set. Real-Debrid and AllDebrid — the two backends that rotate credentials, and so the two where pinning .strm traffic to a key of its own means something. Accepted and unused on torbox and nzb entries. Premiumize, Debrid-Link and Offcloud accept only the account’s own token here.
disabled bool false Keeps an account in the config without loading it. If every entry is disabled, the load fails.
watchlist bool false Marks the account tried first for new torrent adds. That covers the download-client endpoints and acquisition's Torznab indexers. At most one entry may set it, and never an nzb one. With none set, the first account that can add torrents is used. Acquisition's NZBs go to the Usenet backend and never to this account. See Watchlist.
add_torrents bool true Whether this account is offered new torrents at all — the ones the download-client endpoints are handed, and the ones acquisition takes from Torznab indexers. Set it false for an archive account, or one whose quota is spoken for: it goes on serving and reading its library while nothing new is ever put on it. The order of the providers: list is the order the accounts that do take adds are tried in, so this key and that order are read together; an account marked watchlist: true is tried first wherever it sits. Cannot be combined with watchlist: true on the same entry, and on an nzb entry it is ignored with a startup warning, since a news server is never handed a torrent. See qbittorrent.
warm_connections int 1 How many connections this account keeps dialled and handshaken to each host it has been using, so a read does not pay to open one. Capped at 4. -1 keeps none. Ignored on an nzb entry, whose floor is per account and lives at nntp.warm_connections. See warm connections.
nntp block (required for nzb) News server credentials. Required for type nzb and ignored by every other type.
providers:
  - type: realdebrid
    token: YOUR_RD_API_TOKEN
    strm_link_token: YOUR_RD_API_TOKEN_FOR_STRM
    download_tokens:
      - ANOTHER_RD_API_TOKEN
      - ANOTHER_RD_API_TOKEN_2
  - type: torbox
    token: YOUR_TORBOX_API_KEY
    watchlist: true
  - type: realdebrid
    name: rd-backup
    token: YET_ANOTHER_RD_API_TOKEN
    disabled: true

TorBox's download_tokens setting does not provide the cross-account playback fallback that RD and AD locked links allow. Its resolver changes the API key while retaining the torrent/file IDs from the existing source. Separate TorBox provider entries keep each account's IDs and catalog independent. For a local library, put the portable manifests in each entry's own source directory.

TorBox seeding

Option Type Default Description
tb_seed_torrents string never Whether the torrents zurg adds to a TorBox account seed. never turns seeding off for every add. auto leaves the choice to the seeding setting on your TorBox account. always turns seeding on for every add. Applies to every TorBox account in the config, and to the next add as soon as it is changed. Any other value stops zurg from starting.
tb_seed_torrents: never

Leave it at never unless you need it. It is there for private trackers that count your ratio. A release grabbed through the qbittorrent endpoint as a .torrent file is added with this setting, so with always (or auto on an account set to seed) TorBox uploads it back to the tracker's swarm. Importing it into Sonarr or Radarr does not stop that: a finished download the client removes stays on the account.

A seeding torrent holds one of your plan's active slots for as long as it seeds. TorBox counts a torrent that is seeding the same as one that is downloading. Its plans allow 3, 5 or 10 at once (Essential, Standard, Pro) and seed for up to 24 hours, 14 days or 30 days. Once every slot is held by seeding torrents, new grabs and repairs on that account stall until a slot frees, either when a torrent's seeding time runs out or when you delete it on TorBox. Seeding also counts as upload against TorBox's fair-use limits. With never none of this happens, which is why it is the default.

auto sends TorBox no seeding preference at all, so whatever your account's seeding setting says applies. On an account where nobody has changed it, that is TorBox's own Auto. always and never override the account setting for the torrents zurg adds and leave everything else on the account alone.

Usenet accounts

The nzb type is Usenet rather than a debrid service: .nzb files dropped into the nzbs/ directory become torrents in the library, and reads are satisfied by fetching articles from your news server and decoding them on demand. Only one enabled nzb entry is supported, since both would scan the same directory — extra news servers go under that entry's nntp.servers instead, where they serve the same library rather than a second copy of it.

Option Type Default Description
host string (required) The news server hostname, e.g. news.example.com.
port int 563 with TLS, 119 without The news server port.
tls bool false Wraps the connection in TLS.
username string "" The account username.
password string "" The account password.
connections int 8 The account's concurrent connection allowance. Zurg reads at this number — one connection carries a fraction of a plan's throughput, so streaming saturates the allowance rather than fetching one article at a time. Going over what the plan permits gets connections refused, so set it to the real figure.
pipeline_depth int 4 How many articles zurg asks for at once on one connection. The cap is 8. Set it to 1 for a server that cannot handle several commands in flight. zurg drops to 1 by itself with a warning when batches keep tearing.
socket_receive_buffer_kb int unset Pins each connection's kernel receive buffer at this many KiB. Leave it unset. A pinned buffer is one the kernel stops tuning. On Linux that holds the receive window near 64 KiB and costs most of the account's throughput over a link with any latency. It exists for kernels that never tune or whose ceiling is set too low.
cache_size_mb int 512 How much decoded article data is held in memory, across every file being read rather than per file.
servers list [] Further news accounts to fall back to, article by article. See more than one news server.
providers:
  - type: nzb
    nntp:
      host: news.example.com
      port: 563
      tls: true
      username: YOUR_USENET_USERNAME
      password: YOUR_USENET_PASSWORD
      connections: 8
      cache_size_mb: 512
More than one news server

Retention is per-provider: an article one server has aged out is very often still on another. Without a second account the only way to recover a dead article is PAR2, and that costs a read of the entire release — so a second server is the difference between fetching one article and re-reading everything.

The keys above describe the first account. Anything under servers is a further account, asked only when the ones before it did not have the article. An existing single-server config keeps working unchanged.

Option Type Default Description
name string value of host Labels the account in logs.
host, port, tls, username, password, connections, pipeline_depth, warm_connections, socket_receive_buffer_kb     As above. Each account has its own connection allowance. Pipelining and the receive buffer are set per account because tolerance for them belongs to the server.
priority int 0 Who is asked first, lowest first. Accounts sharing a priority are asked in the order written, except that one with a free connection is preferred over one whose allowance is spent — so two equal accounts share the load.
backup bool false Marks a block account, consulted only once every primary has answered no such article. A primary being busy is not enough: zurg waits for it rather than spending metered bytes on something an unlimited account would have served.
backbone string "" The article spool this account resolves to. Two accounts on one backbone hold the same articles, so once one has said it lacks an article the other is skipped instead of being asked the same question.
providers:
  - type: nzb
    nntp:
      host: unlimited.example.com          # the first account
      tls: true
      username: USERNAME
      password: PASSWORD
      connections: 30
      servers:
        - host: second-unlimited.example.com
          tls: true
          username: USERNAME
          password: PASSWORD
          connections: 20
          backbone: usenetexpress
        - host: block.example.com          # only for what the others lack
          tls: true
          username: USERNAME
          password: PASSWORD
          connections: 10
          backup: true
          backbone: omicron

The census that decides whether a release needs repairing asks every account before calling an article missing, so a release one provider has holes in does not trigger a PAR2 rebuild when another provider can still serve it.

A top-level key governs what repairs leave behind:

Option Type Default Description
par2_patch_cache_mb int 512 How much of data/par2/ the bytes rebuilt by PAR2 repair may occupy. Those bytes are the one thing a reader cannot ask the news server for again — the articles carrying them are gone — so keeping them means a restart does not cost a full re-read of the release to recover the same few megabytes. Only the dead articles' spans are stored, which is single-digit megabytes per damaged release; whole releases are dropped, least recently used first, when the budget is exceeded. A negative value turns persistence off and keeps repairs in memory for the life of the process.

And two govern what a parsed NZB costs in memory:

Option Type Default Description
nzb_segments string mmap on Linux/macOS/BSD, resident on Windows How a parsed NZB's article list is retained. Needs a restart. mmap writes the lists to data/nzb-index/<id>.bin and maps that file on demand, so the bytes live in the page cache instead of on zurg's heap. reparse gives them back once the release has settled and re-reads the NZB from disk when a read or a repair needs them, costing no disk at all. resident keeps every one of them in memory for the life of the process — the previous behaviour, and what every earlier build did. Anything unrecognised takes the default and is named in a startup warning; the mode actually in force is logged at startup either way.
nzb_segments_idle_secs int 120 How long a release's article list is kept after the last read of it, under the lazy modes. Needs a restart. A sweep runs every 30 seconds, so 10 is the shortest window it can honour and the Dashboard's floor. Ignored under resident, which never gives anything back. 0 takes the default.

The trade is worth stating plainly, because it is not the usual memory-for-speed one. A listing — a library refresh, a fingerprint check, an *arr asking whether a job has settled — never touches an article under any mode: a file's name, its size and its article count are answered from the entry itself. What the lazy modes cost is the first read of a release nothing has touched for nzb_segments_idle_secs, and what they save is the heap the article lists occupy the rest of the time. Measured on a 60-file, 30,000-segment release (Apple M3 Pro):

  heap per release, settled first read of an idle release per article on the read path
resident 1.74 MB (58 B/segment) — 6 ns
reparse 21.3 kB (0.71 B/segment) 39 ms 52 ns
mmap 16.6 kB (0.55 B/segment) 0.27 ms 63 ns

Two thirds of the mmap reload is the checksum over the index it has just mapped — the map itself is 0.08 ms — and it is worth that: without it a single bad byte inside an arena is served as a message id nobody posted, which the news server answers as a dead article and every layer above reads as damage to the release.

And on a real library — 2,636 NZBs, 27.6 million segments, the same binary run three times over the same corpus, idle after a scan:

  resident set live heap idle CPU cold open, p50 index on disk
resident 2,921 MB 2,677 MB 23 % 806 ms —
reparse 1,188 MB 987 MB 31 % 1,018 ms —
mmap 1,135 MB 921 MB 23 % 694 ms 1,456 MB

Warm opens, streaming throughput and startup time were indistinguishable. mmap is the default wherever there is a real mapping because it wins on every axis but disk — a cold open included, since mapping a written arena is a syscall where parsing the NZB again is megabytes of XML. It costs about 55 bytes of disk per article and one extra write per release at scan time; the index is rewritten whenever the NZB changes and removed when the NZB goes. Choose reparse on a host where data/ must not grow. On Windows there is no mapping — the index would be read into the heap, which is resident memory plus a reload — so the default there stays resident and reparse is the lazy mode worth choosing.

2. Media Server Integration

Connect zurg to your media server so library updates, metadata matching, and watchlist monitoring work automatically.

These backends support library browsing, streamed and redirected reads, STRM, magnet adds, .torrent uploads and deletion. Each account also appears in its own __<name>__ directory. Add accounts through the dashboard or zurg setup; unattended setup accepts --premiumize-token-file, --debridlink-token-file and --offcloud-token-file, or PREMIUMIZE_TOKEN, DEBRIDLINK_TOKEN and OFFCLOUD_TOKEN.

Premiumize mounts completed transfers and cloud files retained after the transfer list is cleared. Nested folders keep their paths. Hashes supplied through zurg are saved under data/premiumize/<account>/sources.json so matching across accounts survives a restart or clearing completed transfers. Imported cloud content without a recorded hash still streams, but cannot be repaired by hash or matched to another account by hash. Deleting a Premiumize transfer through zurg also deletes the files owned by that transfer. Cloud storage and fair-use quotas remain the account's own limits.

Debrid-Link lists the seedbox with pagination and resolves each file by its ID. Its retired cache endpoint is not used; a cached-only add can only establish a hit from the add response. Daily add and traffic quota failures are reported as account limits. The backend pauses after a flood refusal.

Offcloud resolves file handles through the cloud explore endpoint. File sizes are checked against the delivery server and cached torrent metadata supplies folder paths where it can be matched unambiguously. An ambiguous file listing is reported as an error. Malformed magnets are rejected before submission.

Delivery URLs are refreshed when their cache expires or a read invalidates them. Keep generated URLs and STRM access private; provider quotas belong to the account serving the bytes. These three backends use one credential per account entry and do not rotate through another account's file IDs.

Plex Integration

Option Type Default Description
plex_server_url string "" URL of your Plex server. Required for Plex matching and watchlist features. Example: http://localhost:32400
plex_token string "" Your Plex authentication token. Required alongside plex_server_url.
plex_scan_check_url string value of plex_server_url The Plex server zurg asks whether a library scan is running before it restarts the mount. It waits while one is. A mount that vanishes in the middle of a scan makes Plex trash everything it could not see. Set this on an instance that runs no Plex integration of its own. One instance per provider is the usual case. Setting plex_server_url there would also start Plex matching.
plex_scan_check_token string value of plex_token The token for plex_scan_check_url.
plex_match_every_mins int 1440 How often (in minutes) zurg scans your Plex library to match torrents to Plex items. This enables features like showing which torrents correspond to which Plex media. Minimum 1; 0 falls back to the default. Requires mount_path to be set.
plex_watchlist_enabled bool false Legacy spelling of watchlist.enabled (see Watchlist); still honoured.
plex_watchlist_check_every_secs int — Legacy spelling of watchlist.check_every_secs; still honoured when the block does not set one.
watchlist_quality string "best" Legacy spelling of watchlist.quality; still honoured when the block does not set one.
plex_settings_policy string "guard" How far zurg may go in correcting the Plex preferences that matter for a debrid mount. One of off (touch nothing), warn (report everything, change nothing), guard (fix only the settings whose wrong value loses a library, report the rest) or enforce (also fix the settings that make Plex decode whole files for a result nobody sees). Anything with a visible feature behind it — Skip Intro, Skip Credits, scrubbing previews, chapter pictures, volume levelling, sonic analysis — is only ever reported. See Plex settings.
plex_settings_ignore list — Plex preference ids zurg leaves alone whatever the policy says, matched case-insensitively. Per setting rather than per tier, so opting out of one does not give up the others beside it. An id naming no setting zurg knows is reported at startup and changes nothing. The dashboard's Let Plex Empty Its Own Trash switch writes autoEmptyTrash into this list. See Opting out of one setting.

Note: Plex is best configured through the Dashboard (web UI) authentication flow; it writes plex_server_url and plex_token back into config.yml. The keys above remain supported for manual configuration.

plex_server_url: "http://localhost:32400"
plex_token: "your-plex-token"
plex_match_every_mins: 1440

Watchlist

Add something to your Plex watchlist and zurg fetches it. Every new watchlist item is searched on your indexers. A Newznab indexer gives an NZB. It drops into the Usenet backend exactly as a Sonarr grab or a Stremio play would. The three share the naming rules so they find each other's grabs instead of duplicating them. A Torznab indexer gives a torrent. It is added to a debrid account only when that account already has it cached. prefer decides which kind is tried first. A movie becomes one release. A show is acquired season by season and season packs come before loose episodes. A season nobody posted a pack of falls back to the loose episodes the search surfaced, the best release for each. The item leaves the watchlist only once the release behind it has been checked and only when remove_after_grab allows it. Failures stay on the list. They are tried again after 5, 10 and 15 minutes, then after an hour, then further and further apart, and a title that has failed for two weeks is parked until you resume it on the acquisition page. See persistence and recovery.

It needs a plex_token and at least one indexer. With no list of its own it borrows the Stremio addon's. The monitor talks to Plex's cloud service, so plex_server_url is not required. NZBs need an nzb provider on the same instance. Torrents need a debrid account that takes them. Either one is enough.

Every option below is on the config page under Plex Watchlist, indexer list included; the dashboard writes this block rather than the legacy flat keys, and switching the monitor off there clears plex_watchlist_enabled too, since that one is ORed into the switch.

Option Type Default Description
enabled bool false Starts the monitor. The legacy plex_watchlist_enabled key still counts.
check_every_secs int 60 How often the watchlist is polled on Plex's cloud service.
indexers list [] Indexers to search, each with name, url, api_key and optional api_path and type. type: torznab marks a torrent indexer. Absent means newznab. The same shape as stremio.indexers, which is borrowed wholesale when this list is empty.
prefer string "usenet" Which kind is tried first when both an NZB and a torrent could do. usenet tries NZBs first, torrents tries torrents first, and best lets the ranking alone decide. See Newznab and Torznab.
max_size_gb int 40 Releases larger than this are dropped for movies and single episodes. Releases whose size the indexer did not state are kept.
max_season_size_gb int 100 The same ceiling for season packs, which are legitimately several times a movie. A pack of several seasons is held to it per season it names.
quality string "best" Which release wins: best (resolution first, then size), 4k, 1080p, 720p (prefer that resolution, fall back to best) or smallest. The legacy watchlist_quality key still counts.

The acquisition block also takes concurrency (default 8, 1 to 200), how many titles are searched, checked and placed at once, and indexer_concurrency (default 3, 1 to 16), how many calls are made to one indexer at once; keep the latter low when an indexer key is shared.

exclude_words has no copy in this block. To keep words such as DV or HDR10+ out of watchlist grabs, add an acquisition: block holding only exclude_words. Every setting above still applies. See excluding releases by name.

plex_token: "your-plex-token"
watchlist:
  enabled: true
  check_every_secs: 60
  max_size_gb: 40
  max_season_size_gb: 100
  quality: best
  indexers:
    - name: my-indexer
      url: https://indexer.example
      api_key: your-api-key

Acquisition sources

acquisition.sources lists the services zurg acquires for, each on the shared queue described in acquisition sources. The search settings (indexers, quality, prefer, max_size_gb, max_season_size_gb) sit beside sources in the acquisition: block and fall back to the watchlist: block above.

best_first belongs to the acquisition: block alone and is off by default. Turned on it makes a radarr or sonarr source's first grab follow the quality profile's order of preference. The best allowed resolution comes first. Anything at or above the cutoff counts as the cutoff. When a release fails zurg moves down the profile. When it is off a movie takes the allowed release that is cheapest to check. A series takes the one quality ranks first. The upgrade pass heads for the cutoff afterwards. The best release is more often a large archive set so the first file tends to arrive later. See taking the profile's best first.

exclude_words (list, default empty) sits there too and has no watchlist fallback. A release whose name has one of these words is never taken, from any source. Matching is by whole word and ignores case, so DV does not catch DVDRip. DV, DoVi and Dolby Vision count as one word, and so do HDR10+ and HDR10Plus. HDR10 and HDR10+ are different words. An entry with no letter or digit is refused at startup. The full rules and how they combine with Radarr and Sonarr profiles are in excluding releases by name.

Option Type Default Description
name string required Letters, digits, _ and -, unique. Identifies the source's saved queue, so keep it stable.
type string required plex_watchlist, seerr, radarr or sonarr.
enabled bool false A disabled source is kept in the file and not polled. A disabled radarr or sonarr source is still read, read-only, by the manage page's duplicate view.
check_every_secs int 60 How often the source is polled.
url string   seerr, radarr and sonarr: the service's HTTP(S) base URL, without credentials, query or fragment. A URL base and a trailing /api/v1 (Seerr) or /api/v3 (Radarr, Sonarr) are accepted.
api_key string   seerr, radarr and sonarr: the service's API key. Sent as a header, never in a URL, and redacted from shared logs and configs.
library_path string mount_path + /__magic__ radarr and sonarr only: where the *arr sees zurg's __magic__ directory, such as /data/zurg/__magic__ for a container that mounts zurg at /data/zurg. A movie or series folder under it is placed at the same relative path inside __magic__; a folder elsewhere is skipped. Must be absolute; Windows paths such as Z:\__magic__ are accepted. zurg also compares it with where the *arr sees the download folder of a zurg download client, and reports a layout that makes imports copies; see sabnzbd.md.
remove_after_grab bool true plex_watchlist only; inert for seerr, radarr and sonarr.
only_new_items bool true plex_watchlist only; inert for seerr, radarr and sonarr, whose wanted items are always work.
acquisition:
  indexers:
    - name: my-indexer
      url: https://indexer.example
      api_key: YOUR_INDEXER_API_KEY
  sources:
    - name: movies
      type: radarr
      enabled: true
      url: http://radarr:7878
      api_key: YOUR_RADARR_API_KEY
      library_path: /data/zurg/__magic__
    - name: shows
      type: sonarr
      enabled: true
      url: http://sonarr:8989
      api_key: YOUR_SONARR_API_KEY
      library_path: /data/zurg/__magic__

Plex Library Maintenance (same host only)

These need Plex installed on the same machine as zurg, because they read its database file directly. plex_server_url is frequently another host or container and no HTTP endpoint can hand over that file, so they are driven by an explicit path and stay off until you set one.

zurg only ever reads this database, through a read-only handle. It never writes to it and never stops Plex. The one file it creates is a snapshot in its own data/plex-backups/, never in Plex's directory.

Option Type Default Description
plex_database_path string "" Full path to com.plexapp.plugins.library.db. Empty turns every feature in this section off.
plex_sqlite_binary string /usr/lib/plexmediaserver/Plex SQLite Plex ships its own SQLite build. A stock sqlite3 can fail on, or damage, this database.
plex_backups_keep int 5 Snapshots retained. Each is the size of the whole database. An explicit 0 observes without ever snapshotting.
plex_backups_every_mins int 15 Minimum gap between snapshots, so a crash-looping zurg does not copy the database on every restart.
plex_trash_sweep_every_mins int unset (see note) How often to remove Plex entries whose files are gone from the mount. Unset lets zurg decide: removal runs every 60 minutes when nothing else is emptying Plex's trash, and stands down when Plex still is. An explicit 0 keeps zurg's own removal off and leaves the trash to you; a negative value turns the whole sweep off, repairing half included. 0 does not hand Plex's own trash emptying back, which is plex_settings_ignore and the dashboard's Let Plex Empty Its Own Trash switch. See Who empties the trash.
plex_trash_sweep_max_items int 50 Most entries one sweep may remove. Exceeding it aborts the sweep without removing anything.
plex_trash_sweep_max_percent int 10 The same cap as a percentage of what is in the trash, so it scales with library size.
plex_trash_sweep_min_age_days int 14 How long an entry keeps its red trash icon before the sweep may remove it — the fallback for entries zurg has no verdict on. A release zurg knows to be permanently dead is removed without the wait, and one still under repair stays regardless of age. An explicit 0 removes as soon as the other guards allow.

Why snapshots, when Plex already backs itself up nightly: the danger is the mount being disturbed while Plex is mid-scan, which can empty a library in seconds. A nightly backup can be 24 hours stale at that moment; one taken just before zurg touches the mount is seconds old.

What the trash sweep will never remove

Plex's trash is not a list of things that may be deleted. It is a list of candidates, and a large share of them are wrong:

  • An item that still has a file. Plex tombstones an entire movie when a single one of its versions disappears, so a trashed item routinely has versions that still play. The sweep drops the phantom version instead, which brings the item back out of the trash with its collections, artwork, watch state and added_at intact. If that repair fails for any reason, the item is left alone — it stays in your library with its trash icon. It is never deleted.
  • A show or season whose episodes are still there. These carry no files at all, so nothing about the mount can justify removing one.
  • Anything whose file it did not individually confirm missing, immediately before removing it.

Removal is per item rather than Plex's own emptyTrash. emptyTrash is section-wide and would take exactly the entries above, which is the one thing the sweep must not do.

zurg already knows whether it is coming back

For every absent file the sweep asks zurg's own repair state before reaching for the timer:

zurg's verdict The sweep
the file still reads (the filters hide it from the mount) falls back to the age window
broken, and repair has not given up keeps the entry, however long repair takes
broken with a permanently unrepairable reason (infringing, invalid, not allowed, …) removes it at once — waiting cannot bring it back
no verdict — the torrent left the account entirely, or the path does not resolve falls back to the age window

An item with several versions is judged as a unit: one version still under repair keeps the whole entry, and an entry is only known dead when every version is. A failed lookup is never a licence to delete — anything zurg cannot vouch for is handled by the age window below, exactly as before.

The trash icon is a status, so removal waits

Plex has three states, and only the middle one is visible as a problem:

State What you see
Live a normal entry that plays
Trashed the entry is still in your library and still counted, greyed out with a red trash icon, and will not play
Purged nothing — the entry is gone from Plex entirely

The trash icon is the only notice you get that content has gone missing, and purging the entry takes that notice away along with it: the item stops being visibly broken and simply stops existing. So an entry zurg has no verdict on is left alone until it has carried the icon for plex_trash_sweep_min_age_days. That window is your chance to see it and re-acquire; anything still broken afterwards was junk worth clearing.

It also acts as a third safety layer under the caps and the mount probe — a transient mount problem resolves itself long before the window elapses.

The grace period governs removal only. A repair puts a playable item back in your library, so it happens on the next sweep regardless: waiting would leave a working item wearing a trash icon for no reason.

None of these states touch files. The file was already gone from the mount — that is what caused the tombstone — and nothing is ever deleted from your debrid account.

When it refuses to run

  • zurg is not managing rclone (rclone_enabled: false) — it cannot vouch for a mount it does not own, so "the file is missing" means nothing.
  • rclone is not running, or the mount does not read. The probe opens and reads a file rather than calling stat, because a stat is answered from rclone's directory cache and keeps succeeding long after reads have stopped working.
  • Plex is scanning. A scan in flight is still deciding what is missing.
  • Either cap would be exceeded. This is the important one: a mount that came back empty but readable passes every other check, and makes the whole library look deleted. Going over a cap aborts the sweep entirely, removes nothing, and says so in the log.

A snapshot is taken before anything is removed, and a snapshot failure aborts the sweep.

Turning it off: set plex_trash_sweep_every_mins: 0, or remove the key. Changing it needs a zurg restart. That turns off zurg's removal only. To have Plex empty its own trash again, use the Let Plex Empty Its Own Trash switch on the dashboard, or list autoEmptyTrash in plex_settings_ignore.

plex_database_path: "/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db"
plex_trash_sweep_every_mins: 360
plex_trash_sweep_min_age_days: 14
plex_trash_sweep_max_items: 50
plex_trash_sweep_max_percent: 10

Related Plex setting, outside zurg: turn off Plex's Settings → Library → Empty trash automatically after every scan. It is enabled by default in Plex and is unsafe on a zurg mount — a scan that meets a briefly unreadable mount deletes the library permanently instead of parking it in the trash, where it would come back with the mount. zurg turns it off itself under the default guard policy, and says so on the dashboard and in the log. If your files move between library folders and you need that cleanup back, the config page's Let Plex Empty Its Own Trash switch hands it to Plex again at your own risk.

Other Integrations

Option Type Default Description
jellyfin_server_url string "" URL of your Jellyfin server. Enables library refresh notifications when zurg detects changes. Example: http://localhost:8096
jellyfin_token string "" Jellyfin API token for authentication.
emby_server_url string "" URL of your Emby server. Enables library refresh notifications when zurg detects changes. Example: http://localhost:8096
emby_token string "" Emby API token for authentication.
dmm_api_key string "" API key for DebridMediaManager integration.
dmm_metadata_secret string "" Secret for DMM's internal metadata API. When set, torrents with no IMDb id (or only OpenSubtitles' miss marker) are identified at startup from their release name through DMM, which answers only when one title matches exactly. Torrents it cannot name keep an empty id, so the Plex matcher can still fill them.
jellyfin_server_url: "http://localhost:8096"
jellyfin_token: ""
emby_server_url: "http://localhost:8096"
emby_token: ""
dmm_api_key: ""

3. Playback & File Structure

Controls what files your media server sees and how content is streamed.

Streaming Mode

Option Type Default Description
disable_stream_proxy bool false When true, a mount read is answered with a redirect to the account's own download URL instead of being proxied through zurg. Reduces zurg's CPU and bandwidth but means the client must be able to reach the account's servers directly. Ignored for a backend whose links are served locally — a Usenet file has no URL to hand out, so it is always proxied.
serve_strm_files bool false When true, zurg serves .strm files in place of actual video files in the WebDAV mount. Media servers like Plex/Jellyfin read the .strm file to get the streaming URL. Important: When enabled, the actual video files are hidden and replaced by .strm entries.
save_strm_files bool false When true, zurg writes .strm files to a strm/ directory alongside the zurg binary. Useful for setups where you want persistent STRM files on disk (e.g., for manual import into a media server). Uses base_url for the URLs inside the files. Only files a player can open get one — recognised video and audio plus anything addl_playable_extensions names — so subtitles, posters, NFOs and PAR2 volumes stay out of the dump, and any an older build wrote there are removed the next time the release is walked. A .strm whose URL no longer matches, because base_url changed or an older build wrote it, is rewritten in place at the next start, or straight away when base_url is changed on the dashboard. Files that already match are not touched, so a media server does not re-read the whole folder on every scan.
disable_stream_proxy: false
serve_strm_files: false
save_strm_files: false

What disable_stream_proxy does and does not reach

It governs the mount. It does not reach the /strm/ endpoint, on any backend:

Read arrives via Real-Debrid / TorBox / AllDebrid Usenet
Mount, proxy on (default) proxied through zurg proxied
Mount, proxy off 302 to the account's URL proxied — no URL exists to redirect to
A .strm file, either setting 307 to the account's URL proxied

A .strm is opened by a player zurg has no session with, so the endpoint can only hand over a URL the player can fetch itself — turning the proxy on does not route that traffic back through zurg. The consequence worth knowing: reads that arrive from .strm files get no bandwidth accounting, no mid-stream failover to another account, and no broken-link detection, all of which a mount read gets. A library served mainly through .strm therefore sees little from disable_stream_proxy either way.

serve_strm_files and disable_stream_proxy are independent, despite what older versions of this page said. A .strm is served as text before any streaming decision is reached, so the two can be enabled together — though with serve_strm_files on, every playback arrives at the /strm/ endpoint, which is exactly the row above where disable_stream_proxy changes nothing.

File Filtering & Detection

Option Type Default Description
addl_playable_extensions list [] File extensions that zurg should treat as playable media in addition to the built-in video/audio formats. Dots, quotes, and case are normalized by the dashboard. Add extensions like m3u or cbz if you want those files to be visible and selectable.
force_select_playable_files bool false When true, zurg automatically selects all playable files in a torrent, even if they weren't originally selected. Works with both built-in video extensions and addl_playable_extensions. Useful when torrents have unselected video files you want access to.
delete_torrent_if_extensions_found list [] If any file in a torrent has one of these extensions, the entire torrent is deleted from RD. Useful for automatically removing torrents that contain unwanted content like .rar archives (not RD-extracted ones) or .zipx files.
stream_compressed_archives bool true Serves a file packed inside a compressed archive by decoding it as it is read. The decoder keeps a bounded window in memory. Set false to refuse compressed archives the way older builds did.

Built-in playable formats are .avi, .flv, .m2ts, .m4v, .mkv, .mov, .mp4, .mpg, .mpeg, .ts, .webm, .wmv, .mp3, .flac, .m4a, and .m4b.

addl_playable_extensions:
  - m3u
  - cbz
force_select_playable_files: false
delete_torrent_if_extensions_found:
  - zipx
  - rar

Metadata & Naming

Option Type Default Description
retain_folder_name_extension bool false Deprecated — will be removed. When true, the folder name is the account's Name verbatim, extension and all, instead of OriginalName with a trailing .mp4 then .mkv trimmed off. It applies only where Name contains OriginalName as a substring; everywhere else the trim happens anyway. retain_rd_torrent_name is checked first and shadows it entirely.
retain_rd_torrent_name bool false Deprecated — will be removed. When true, the folder name is the account's Name (Real-Debrid's filename); when false it is OriginalName (original_filename), which is the name the torrent itself declared. This matters for a pack with one file selected: Real-Debrid collapses filename to that file's own basename while original_filename stays the pack name, so false gives the pack name and true gives the individual file's.
ignore_renames bool false When true, zurg ignores any rename metadata in torrents and uses original filenames. Enable this if renamed files are causing issues with your media server's matching. Not deprecated.
retain_folder_name_extension: false   # deprecated
retain_rd_torrent_name: false         # deprecated
ignore_renames: false

Both deprecated flags key the library on Name, which the account rewrites underneath zurg; the defaults key it on the name the torrent declared, which the account leaves alone. Setting either logs a warning at startup, and on an instance fronting Sonarr or Radarr (a sabnzbd: or qbittorrent: block) the defaults are the only correct values — an access key that moves after an import loses the release. Behaviour is unchanged this release: nobody's folder names move. docs/naming.txt is the full set of naming rules.

Mount Writes

Option Type Default Description
dav_allow_rename bool true When false, a WebDAV MOVE through the mount is refused with 403. On by default: renaming a torrent or a file is what the writable mount exists for and it destroys nothing.

A WebDAV DELETE through the mount is always honoured — there is no key for it. It removes a file from the library, and the release from every debrid account holding it once nothing is left. Know what that costs before pointing a writer at the mount: rclone flushes an overwritten file as DELETE followed by PUT, so anything that rewrites a file in place (an .nfo writer, a trickplay pass, an *arr rename, a stray touch) deletes content instead of replacing it, and the mount carries rclone's own credentials so authentication cannot tell the two apart. Only the PUT half being refused keeps that sequence from completing quietly. mount_read_only: true is what stops a delete reaching zurg at all.

A refused write answers 403 and logs the config key that would allow it, so a client whose write stopped working says why in logs/zurg.log.

One aftermath to know about: when a program writes through the FUSE mount, rclone's VFS cache keeps the modified copy and retries the refused upload on a backoff, and the mount shows the locally-modified file until that cache entry is discarded — the server-side file is untouched the whole time. mount_read_only: true avoids this entirely by failing the write at the kernel, before rclone caches anything.

dav_allow_rename: true

See also mount_read_only, which makes the kernel refuse the write before it reaches zurg.

Directory listing cache

Option Type Default Description
disable_listing_cache bool false When true, every directory listing is rendered from scratch on every request. Leave it off: with it on, a PROPFIND Depth:1 of __all__ re-renders the whole library each time it is asked — measured at 59 ms for 6,120 releases, 121 ms for 2,636 Usenet releases, 148 ms for __magic__/__all__ — and a media server scan asks for the same listing over and over.
disable_listing_cache: false

The cache keeps the last rendering of each top-level directory, one per mount flavour (/dav, /infuse, /http), and hands it back until the library changes. What counts as a change is not a guess: it is exactly the set of events that already make rclone forget its own cached copy of the same listing — a release added, removed, renamed, refiled, a file going broken or healing — and rclone holds those listings for twelve hours. So a listing served from here is never staler than the one the mount is already serving, and there is a hard 60-second cap on top of that as belt and braces.

__magic__/__all__ — the mirror, and the only listing in the namespace worth caching, since the root above it holds the mirror and a handful of rows — additionally tracks the stored layout, so an *arr import invalidates that one listing and leaves every other directory's rendering intact. __downloads__ and Depth: 0 requests are not cached at all.

Responses carry an X-Zurg-Listing: hit or miss header, so a live install can be measured:

curl -s -o /dev/null -D - -X PROPFIND -H 'Depth: 1' http://localhost:9999/dav/__all__/ | grep -i x-zurg-listing

__magic__, a directory you can organise

Every other directory is a saved filter — a release is in movies because it matches the movies filter — so there is nowhere in the library to put something. __magic__ is the exception: __magic__/__all__ is an exact copy of __all__ and the root beside it is yours, and anything can be moved out of the mirror to anywhere else in the namespace. A move rewrites a row in data/magic.journal, compacted into data/magic.json; no bytes move, no torrent is renamed, and the rows key on the release's content hash, so a repair that rebuilds the release does not lose where you put things. It is what lets Radarr and Sonarr import by rename instead of by copy.

Full write-up, including what survives a repair and what each refusal means, in docs/magic.md.

Option Type Default Description
magic.enabled bool false Serve __magic__ at all. Off by default: it is a writable tree, and an *arr pointed at the wrong root folder can reorganise a library. With nothing moved its own __all__ reads exactly as the library's does, and the root holds nothing else.
magic.allow_delete bool false When true, a DELETE of a file under __magic__ also deletes the content, as a DELETE on the mount proper always does. Off by default, a delete only hides: the entry leaves __magic__ and stays in __all__ and in every filter directory. A release folder and a directory never delete content whatever this is set to — Sonarr deletes the job folder after every import. Deleting a video or an entry inside an archive also deletes its whole release once nothing under __magic__ uses any of it.
magic.sidecar_max_mb int 32 The largest single file a client may PUT into __magic__. Over it the write is refused with 413.
magic.sidecar_budget_mb int 2048 The largest the whole sidecar tree may grow. Over it a write is refused with 507 Insufficient Storage, which says what 413 does not: deleting something makes the same request succeed. The total is measured by walking the tree at startup, so a restart does not hand the allowance back to a tree that is already full, and again before any refusal — the tree is the same data/local directory zurg's own mount writes into, so a file removed there behind zurg's back must not go on being charged for. Zero or a negative takes the default; there is no way to ask for no cap.
magic:
  enabled: true
  allow_delete: false
  sidecar_max_mb: 32
  sidecar_budget_mb: 2048

dav_allow_rename does not apply here, and neither does the mount's ungated delete path. Those cover the routes that rename and destroy what the debrid account holds; a write under __magic__ reaches no account. mount_read_only: true still overrides everything, at the kernel.

Two things worth knowing. The filter directories are untouched by design, so a release you moved inside __magic__ is still in recent and movies under its own name — point a media server at __magic__ or at the filters, not at both. And anything genuinely new written into the mount (an .nfo, a subtitle, a poster) lands in zurg's own data/local and is merged into the listing, which is why sidecars beside a release simply work.

Sidecar files, and what the two caps are for

The embedded mount is a union whose first upstream is data/local, so when a program creates a new file on the mount, rclone writes it there and zurg is never asked. Nothing caps that tree — unless union_writable: server flips the union order, in which case the create arrives at zurg as a PUT and is refused outside __magic__ and bounded by the two caps inside it.

With the default order, the two caps are for the other kind of client: one that mounts /dav directly, with no union in front of it. Those send zurg the PUT, and zurg writes the body into data/local/__magic__/<path> — the very directory the union serves — so the two views show one directory rather than two. Without a bound, that is a general file store reachable over WebDAV, which is what sidecar_max_mb and sidecar_budget_mb exist to prevent.

Two rules follow from what __magic__ is, and both are refusals a client may see:

  • The mirror is the library's own. __magic__/__all__ and the release folders directly inside it are computed, so a MOVE, MKCOL or DELETE aimed at either is 403: a row there would be a second address for a release that already has one. Deeper is a release's own folder, where a write is as ordinary as anywhere else — it is where an *arr puts the .nfo beside a file it imported.
  • Nothing the library answers for may be written over. A release folder, an entry of one, and a path a move has placed something at are all rendered from the library, so a PUT or a MOVE of a real file onto any of them is 403. That includes a name inside a release folder the release does not have: the folder's listing is the release's, so a file put among its entries would be listed by nothing.
  • A placement never destroys a real file. Moving something the library holds onto a path a sidecar occupies is refused rather than overwriting it, even with Overwrite: T. The same holds for a directory of real files moved the other way: it may not be renamed onto a path the library answers for, because its files would then sit in a folder that lists the release and nothing else.

A DELETE of a sidecar really deletes it, which is the one place in __magic__ where a delete is not a tombstone: a tombstone hides an entry the library still holds elsewhere, and a sidecar's bytes live in data/local and nowhere else.

The __magic__ page

The dashboard has a page of its own for the namespace, at /magic/, linked from the dashboard's quick links whenever __magic__ is configured or served. It is where the table is visible, and it is the only place two of its states can be reached at all.

It reports how many rows are stored — placements, tombstones and directories, counted separately — what the journal and the snapshot take on disk, how much of sidecar_budget_mb the sidecar tree is using, and the size of the whole of data/local. That last number is the one to watch: a client that copies rather than moves puts the bytes there, so a data/local climbing into gigabytes means something is importing the wrong way round. The rows themselves are grouped by release; a very large table is truncated on the page and says by how much, and the counts above it are always of the whole table.

The stored rows list shows the first thousand rows, and an *arr leaves a tombstone behind on every import, so on an organised library most rows are past that point. A search box on the stored rows and dangling pages finds one release by name, by a path on the mount or by its hash, and lists every row stored about it with its buttons. data/magic.json is not a way round that: zurg writes it out from memory when it stops, so a row deleted from it by hand while zurg runs comes back.

Three buttons, all of which go through the same one operation — a row ceasing to exist:

  • Reset a placement, and the file or folder goes back to where the library puts it.
  • Unhide a tombstone, and the entry is listed in __magic__ again. Nothing was destroyed to hide it. A release in __all__ that never shows up in __magic__/__all__, even after deleting it and adding it again, has a release tombstone: the rows are keyed on its hash, which a re-add does not change. Search for it and unhide that row.
  • Delete a dangling row — one whose release, or whose file inside it, the library no longer holds. Such a row resolves to nothing, so it cannot be listed, moved or deleted through the mount, and it is kept rather than dropped because a repair that restores the release restores where you put it. Until this page there was no way to clean one up; Clear all dangling rows sweeps them in one go.

Sidecars are listed as well, and the ones nothing accounts for any more are listed apart from the rest and counted. Those are the sidecar's equivalent of a dangling row: an .nfo or a poster written beside a release that has since left the library, or inside a folder whose placement has been forgotten. They are still served — a real file is the last thing a __magic__ path resolves to, and nothing above them is claiming the name any longer — so the only thing that has gone is the reason they were put there. zurg never sweeps one: a file left beside a release that was deleted is still yours, and a repair that brings the release back accounts for the folder again. A directory you made for sidecars yourself, through the mount rather than through zurg, reads the same way here, because that leaves no row either.

Deleting a sidecar from the page really deletes the file, the same way a DELETE through the mount does. Every button changes what the mount has cached for the paths involved, because the mount holds a directory listing for twelve hours with polling off.

If the SABnzbd endpoint is on, the page also lists the jobs it has been handed — the id the client knows each by, its category, whether the release has arrived in the library yet, and the folder to import from once it has. Sonarr and Radarr remove their own jobs. A Failed or Stuck job that none of them will ever remove can be cleared from the page. That happens when zurg is no longer their download client. See sabnzbd.md.

Both the magic: and the sabnzbd: block are editable in full from the config page, and both say Restart Required, which they mean literally: the routes that serve __magic__ and the SABnzbd endpoint are registered once, at startup, out of these values. Turning either on writes it to config.yml and changes nothing about the run that answered — the __magic__ page says so too rather than rendering an empty namespace.

sabnzbd, the download client Sonarr and Radarr see

zurg can answer Sonarr and Radarr as though it were a SABnzbd. They hand it an NZB, it writes the file into nzbs/ for the Usenet backend, and once the release is in the library the job reports Completed with a job folder under __magic__/__all__ to import from. Nothing is downloaded to import: the *arr renames the file inside the mount, which is a row in the __magic__ table.

It needs both halves to be useful — an nzb provider to read the NZB, and magic.enabled to have somewhere to import from — and it is off until asked for. Full setup, including what to put in the *arr, is in docs/sabnzbd.md.

Option Type Default Description
sabnzbd.enabled bool false Register the endpoint at /api and /sabnzbd/api. While off, neither route exists.
sabnzbd.api_key string generated The only gate on the endpoint. Sonarr and Radarr send no basic auth, so these routes sit outside it and the key is what stands in. Left empty with the block enabled, zurg generates one, keeps it in data/sabnzbd-apikey so it survives a restart, and logs it once at startup.
sabnzbd.categories list [tv, movies] The categories reported to the clients. Every one of them resolves to the same directory, so this exists only to stop a client warning about a category it cannot find — add whatever you configured in the *arr. * is always reported as well.
sabnzbd.history_limit int 60 How many history entries Sonarr and Radarr read. zurg holds finished jobs past this in the queue until a slot frees, which happens when the client clears one or when one has gone ten minutes uncleared while jobs wait. Match it to the client's DownloadClientHistoryLimit if you raised that. 0 keeps their default of 60.
sabnzbd.complete_dir string <mount_path>/__magic__/__all__ The completed directory reported to the clients. It must be the path the *arr sees, which is not zurg's own when the *arr runs in a container that mounts the library elsewhere, and it must sit in the same volume as the root folders, or every import is a copy. See sabnzbd.md.
sabnzbd:
  enabled: true
  api_key: ""
  categories: [tv, movies]
  complete_dir: ""

The API key is the whole of the authentication, so treat the endpoint the way you treat the rest of zurg's port: on a trusted network, or behind something that is. Basic auth cannot be used for it — the clients never send it, and a 401 is reported to the user as "unable to connect" rather than as an authentication failure.

qbittorrent, the torrent download client Sonarr and Radarr see

zurg can answer Sonarr and Radarr as though it were a qBittorrent. They hand it a magnet or a .torrent and zurg adds the info hash to a debrid account. Once the release is in the library the torrent reports finished with a folder under __magic__/__all__ to import from. Nothing is downloaded to import. The *arr renames the file inside the mount and that is a row in the __magic__ table.

Two halves make it useful. An account that can add torrents reads the magnet and magic.enabled gives the *arr somewhere to import from. It is off until asked for. Full setup including what to put in the *arr is in docs/qbittorrent.md.

Option Type Default Description
qbittorrent.enabled bool false Register the endpoint at /api/v2 and /qbittorrent/api/v2. While off, neither route exists.
qbittorrent.api_key string generated The only gate on the endpoint. The clients send it as a bearer token when their API Key field is set, and accept it as the password on auth/login when it is not. Left empty with the block enabled, zurg generates one, keeps it in data/qbittorrent-apikey so it survives a restart, and logs it once at startup.
qbittorrent.categories list [tv-sonarr, radarr] The categories reported to the clients — the two the *arrs ship with. Every one of them resolves to the same directory, so this exists only to stop a client warning about a category it cannot find.
qbittorrent.save_path string <mount_path>/__magic__/__all__ The save path reported to the clients, and the parent of every folder they import from. It must be the path the *arr sees, which is not zurg's own when the *arr runs in a container that mounts the library elsewhere. It must sit in the same volume as the root folders, or every import is a copy; see sabnzbd.md. Set sabnzbd.complete_dir to the same value if you run both endpoints.
qbittorrent.download_timeout_mins int 15 How long a grab may go with no movement — no change of stage and no rise in progress — before that account is given up on and the next one that takes torrents is tried. 0 means cached-only: a grab is accepted only onto an account that already holds the content, and refused inside the add otherwise, which is the one refusal Sonarr and Radarr act on. A negative number never gives up. See Timeouts and cached-only mode.
qbittorrent:
  enabled: true
  api_key: ""
  categories: [tv-sonarr, radarr]
  save_path: ""
  download_timeout_mins: 15

Fifteen minutes is a measured figure rather than a chosen one. AllDebrid parks a healthy job in its own queue with every counter at zero before it moves a byte. Two runs out of two sat there for 580 and 622 seconds. The captures are in docs/torrent-lifecycle.md.

Which account a grab goes to is decided by add_torrents and by the order of the providers: list. A grab that timed out or that an account gave up on is deleted from that account before the next one is tried. Only an instance zurg added itself for that grab and that never finished is ever a candidate. Nothing else this endpoint answers deletes anything from a debrid account.

The API key is the whole of the authentication. Treat the endpoint the way you treat the rest of zurg's port. Put it on a trusted network or behind something that is. Basic auth cannot be used for it. The clients never send it and they read a 401 as a hard authentication failure they never retry.

The stremio addon

zurg can be a Stremio addon that searches your own Newznab indexers and plays through the Usenet backend. It needs an nzb provider and at least one indexer. It is off until asked for. Setup and what a play does are in stremio.md.

Option Type Default Description
stremio.enabled bool false Registers the addon routes under /stremio/. While off they answer 404.
stremio.token string generated The path segment that gates every addon route. It is the whole authorization. Left empty zurg generates one and keeps it in data/stremio-token.
stremio.indexers list [] Newznab endpoints to search. Each takes name, url, api_key and an optional api_path.
stremio.max_results int 10 How many releases each resolution keeps after ranking. The cap is per resolution. Each tier keeps a spread from its largest release to its smallest. 0 takes the default.
stremio.search_limit int 100 How many releases each indexer is asked for per search. Every other limit chooses among what this asked for, so it is the ceiling on the whole answer. 0 takes the default.
stremio.max_size_gb int 40 Drops releases larger than this before ranking. Releases with no stated size are kept. 0 takes the default.
stremio.cache_hours int 24 How long a full answer of five or more results is served from data/stremio-cache before the indexers are asked again. Thinner answers are kept for less time. An empty answer is never kept. 0 takes the default.

Media Analysis

Option Type Default Description
auto_analyze_new_torrents bool false When true, zurg automatically runs ffprobe on every newly added torrent to extract media metadata (resolution, codecs, duration, audio/subtitle tracks). This metadata powers tag-based directory filters like zurg_4k, zurg_aud_eng, and duration tiers. Disable if you don't use tag-based filters or want to reduce CPU usage on new additions.
ffprobe_binary string "ffprobe" Path to the ffprobe executable. Only needed if ffprobe is not on your system PATH. Used for media analysis.
auto_analyze_new_torrents: true
ffprobe_binary: "ffprobe"

4. Health & Repairs (Automation)

The "set it and forget it" section. Controls how zurg keeps your library healthy by detecting and fixing broken torrents automatically.

Option Type Default Description
enable_repair bool true Enables automatic torrent repair. Unset means enabled. When a torrent becomes unavailable (e.g., removed from RD cache), zurg will attempt to find and add a replacement. On AllDebrid it also restarts a magnet that failed for a reason a second try can fix, such as no peers or a tracker error, instead of leaving it failed. Important: Only one zurg instance should have repair enabled to avoid conflicts. With this and delete_error_torrents both off, zurg leaves failed torrents on the account exactly as they are and only logs them.
repair_every_mins int 60 How often (in minutes) zurg scans for broken torrents that need repair. Lower values catch problems faster but increase API usage.
repair_timeout_mins int 30 Maximum time (in minutes) to wait for a repair operation to complete. If a repair takes longer than this, the torrent is marked as broken and skipped until the next repair cycle.
stalled_download_mins int 10 Minimum minutes before a downloading torrent is considered stalled. The actual threshold is max(GB_downloaded, stalled_download_mins) — so large downloads get more time automatically. Increase this for slow or low-seed torrents (e.g., public trackers) that need more time to complete.
restrict_repair_to_cached bool false When true, zurg only uses torrents already cached on Real-Debrid for repairs. This means faster repairs (no waiting for downloads) but may fail if no cached alternative exists. When false, zurg can add uncached torrents that need time to download.
only_full_torrent_repair bool false When true, repair reinserts the whole torrent with its original file selection or gives up. If that first step fails the torrent is marked broken immediately, skipping the archive and per-file strategies that would otherwise follow.
check_for_changes_every_secs int 15 How frequently (in seconds) zurg polls Real-Debrid to detect library changes (new torrents, removed torrents, status changes). Lower values mean faster updates in your media server but more API calls.
library_resync_every_mins int 10 How often the change check is rebuilt from a full listing instead of a cheap probe. It is a safety net that catches drift a probe can miss. It is not how often zurg polls. Real-Debrid takes its own path and ignores it.
library_detail string resident resident keeps file details in memory. lazy releases idle details. auto releases idle details only under sustained memory pressure. All providers share this instance-wide policy. Restart required. See Library detail.
library_detail_idle_secs int 300 Minimum time without a detail read before lazy or auto may release a table. The sweep runs every 30 seconds. Ignored by resident; zero or invalid values use 300. Restart required.
downloads_every_mins int 720 How often (in minutes) zurg re-fetches your RD downloads (unrestricted links, file locker links) and mounts them. These are non-torrent downloads from RD.
delete_error_torrents bool false When true, automatically deletes torrents from RD that are in an error state (e.g., dead torrents that can't be downloaded). Keeps your RD library clean but means the torrent is permanently removed. On AllDebrid a failed magnet a second try can fix is restarted instead of deleted, even with repair off, and is deleted only if that restart does not help.
on_library_update string "" A shell command executed whenever zurg detects library changes. Each changed directory path is passed as an argument. Commonly used to trigger Plex/Jellyfin library scans on specific folders for faster updates.
enable_repair: true
repair_every_mins: 60
repair_timeout_mins: 30
stalled_download_mins: 10
restrict_repair_to_cached: false
only_full_torrent_repair: false
check_for_changes_every_secs: 15
library_detail: resident
library_detail_idle_secs: 300
downloads_every_mins: 720
delete_error_torrents: false
on_library_update: |
  for arg in "$@"
  do
      echo "detected update on: $arg"
  done

Repairing across accounts

Every other repair strategy re-adds a release to the account it came from, which is the right shape for as long as that account can still produce the content. It is the wrong shape for one case.

The services keep independent caches. A release Real-Debrid has purged, or one it refuses on the filename alone, can be sitting cached on AllDebrid right now, under the same info hash. Nothing is wrong with the release. Only the account is wrong.

So when a release has been given up on by every account that holds it, zurg offers it to the configured accounts that do not, in the order they appear in your providers block. The first one that already has it takes it, zurg lists that account, and the release picks up a second copy the way any multi-account release does: its own file ids, its own links, read failover between the two. The verdict on the entry clears itself once a copy that can be repaired exists.

There is no setting. Configuring a second account is the decision, and zurg already lists it, serves from it and fails reads over to it; asking it about a release the first account cannot produce is the same arrangement. It follows enable_repair like every other repair.

The repair button on a release does this too, and in the right order: it retries the account that holds the release first, exactly as it always has, and only offers the release around if that attempt fails. That is the one path that works with the repair sweep switched off entirely.

What keeps it from becoming an add loop is the shape of the offer rather than a switch:

  • An account is only ever handed content it already holds. The offer is an add that is withdrawn and deleted again unless the account completes it within fifteen seconds. An account that would have to fetch the release off the swarm is left exactly as it was found, having spent no transfer allowance. This is a repair, never a re-download. restrict_repair_to_cached does not control this and is never read here: that key is about an account repairing its own copy, and this path is cached-only whatever it says.
  • Only a release nothing can serve. A working release is never copied around, and neither is one repair has not given up on yet.
  • An account that says no is asked less and less. The wait after a miss starts at a day and doubles with each further miss, up to about a fortnight. A release that is merely out of cache this week is found again within days; one that is gone for good settles into a fortnightly question. Without that decay, always-on would mean a daily add for every dead release on every account, for ever.
  • At most ten releases a sweep, so the first sweep after a second account is added works through a backlog over hours rather than in one burst.

An account that already holds a copy of the release is never offered a second instance of it, however broken the copy it holds is: repair's own strategies own that account, and on Real-Debrid a duplicate add never downloads at all. Releases whose key is not a bittorrent info hash, which is how the Usenet backend identifies its own, are never offered to anything.

This is the one place zurg adds to an account whose content expires without being asked. Repair will not re-add a release to such an account on a timer, because re-fetching content the service deliberately removed is the pattern those services call abuse. A cached offer is not that: nothing is fetched, no allowance is spent, and the content being claimed is already in the service's cache because somebody else put it there.

Library detail

library_detail: resident remains the default. Choose a mode and idle time in the dashboard's configuration page or in config.yml. Both settings take effect after restarting zurg; saving them does not partly switch a running library. The policy applies to every provider in the instance.

Mode Behavior Tradeoff
resident Keeps every release's file details in memory. Predictable access with no detail reloads; highest retained memory.
lazy Releases details after library_detail_idle_secs without use. Reduces idle memory even when RAM is plentiful; accessing cold details costs a reload.
auto Keeps details while memory is plentiful and releases the oldest idle tables under sustained pressure. Responds to runtime conditions; adds pressure monitoring and less predictable reload latency.

Names, sizes, renames and visibility remain available to listings in every mode. Opening or repairing a file rebuilds released details on demand. auto does not eagerly reload the library when pressure ends: details return as they are used and stay resident until pressure and the idle window allow eviction again.

Automatic pressure detection

auto samples every 30 seconds. Two readings at least 30 seconds apart at or above 90% of a measured budget start eviction, in batches of at most 128 idle tables per sweep. Eviction continues until usage reaches 80% or less, then pauses for at least a minute before considering a new episode. Brief spikes do not start eviction. The configured idle window always applies.

The tightest available reading drives the decision:

  • Host memory: total minus available memory, including the OS's estimate of reclaimable caches. A full filesystem cache alone is not a shortage of RAM.

  • Linux containers and service limits: cgroup v2 memory.max and memory.high, or v1 memory.limit_in_bytes, with each visible ancestor's own usage. Cold file cache is discounted. Container limits are reread at runtime; private cgroup namespaces can hide limits above the visible root.

  • An explicit runtime budget: a positive memory_limit_mb, in MiB, compares against Go-managed memory. GOMEMLIMIT, when set, takes precedence; GOMEMLIMIT=off disables this particular signal. Host and container pressure still apply. The automatic Go ceiling derived from Usenet caches is ignored because it rises with the live heap and does not measure available RAM.

No additional budget is required to enable auto:

library_detail: auto
library_detail_idle_secs: 300
# Optional explicit Go runtime budget, also used by the collector:
# memory_limit_mb: 1024

Without a usable measurement, automatic eviction pauses. This is a gradual memory optimization, not a hard memory cap: active files, unsaved changes, recovery snapshots, and other caches may dominate memory, and sudden allocation spikes can outpace the sweeper. memory_limit_mb remains a soft collector limit; setting it below the live data can cause excessive garbage collection.

Data integrity and cost

Eviction retains a compressed recovery snapshot as well as the existing data/*.zurgtorrent dump. A disk reload must match that snapshot. If the dump is missing, damaged or replaced, zurg restores the snapshot and keeps the release resident, then retries saving the recovered data. auto also retries idle dirty tables when pressure has ended, with a separate bounded save pass. File writes and state changes restore ownership immediately, including writes through a pointer held by a stream across eviction. Failed saves leave changes resident and dirty; overlapping writes are not acknowledged by an older save. A successful save is still required for changes to survive a process exit.

Measured on an Apple Silicon Mac with Go 1.26.4, using 500 releases per shape through the conversion and persistence paths, including the recovery snapshot:

Shape Resident bytes/entry Lazy bytes/entry Reduction Average rebuild
Real-Debrid movie, 3 files 3,662 2,845 22% 88 µs
Real-Debrid season pack, 24 files 15,670 7,366 53% 371 µs
TorBox, 2 files 3,019 2,680 11% 56 µs
AllDebrid, 1 file 2,580 2,516 2% 55 µs
RAR set, 7 volumes 5,343 3,303 38% 109 µs

These are retained heap figures per release, not whole-process RSS. Rebuilds read recently written local files; cold or remote storage can take longer. Choose lazy for consistent idle reclamation or auto to reclaim only under pressure. Keep resident when predictable access matters more than RAM. Small releases save little, and the recovery snapshot deliberately spends memory to protect data. These measurements describe evicted entries in both lazy and auto; automatic mode may retain more entries at any given time.

5. Network & Connectivity (Technical)

Infrastructure settings that control how zurg binds, authenticates, and connects to the network.

Server Binding

Option Type Default Description
host string "[::]" The network interface to bind to. [::] listens on all interfaces (IPv4 and IPv6). Use 0.0.0.0 for IPv4-only, or a specific IP to restrict access to one interface.
port string "9999" The port zurg listens on. The PORT environment variable overrides this value when set, which is useful for containerized deployments.
host: "[::]"
port: 9999

Warm connections

Opening a connection to a debrid service is expensive and using one is nearly free. Measured from a live host on 2026-08-29, a fresh connection to a Real-Debrid download host costs a TCP round trip and then 68–85 ms of TLS handshake before a byte of the file has been asked for — and zurg's HTTP transport opens nothing until a read wants it, dropping an unused connection again after 90 seconds. On anything but a busy library, the read that follows a quiet spell pays for that on the viewer's own path.

A warm floor moves it off that path: each account keeps a stated number of connections dialled and handshaken ahead of demand, so the transport has one in hand when a read arrives.

Option Type Default Description
warm_connections int (unset) The floor for every account that states none of its own. Unset leaves each backend its default — 1 per host for a debrid account, 2 per account for a news account. -1 turns warming off everywhere. An account's own warm_connections always wins, including over a global -1.
warm_connections: 2        # every account keeps 2, unless it says otherwise

providers:
  - type: realdebrid
    token: YOUR_RD_API_TOKEN
    warm_connections: 4    # this one keeps 4 to each host it uses
  - type: torbox
    token: YOUR_TORBOX_API_KEY
    warm_connections: -1   # and this one keeps none

How much this buys you depends on the host, and for the debrid services it is less than it sounds. A connection that has sent nothing is the first thing these services reap, and they do it quickly. Timed from a live host on 2026-08-29, holding a silent connection open and waiting to be hung up on:

Host Silent connection After serving a request
a Real-Debrid download host closed after 30s closed at once
api.alldebrid.com closed after 15s still open at 4 min
api.torbox.app closed after 15s still open at 4 min
api.real-debrid.com closed after 4s closed after 5s

So a floor is worth keeping to a download host and not much else, and Real-Debrid's API cannot be warmed at all — no floor outruns a four-second hang-up without manufacturing traffic to keep the connection alive, which zurg will not do. Rather than assume, the floor measures: it retires its own connections at 15 seconds and checks whether each was still alive when it did. A host that keeps closing them before a read could use them is given up on, and its reads dial as they always did. Nothing has to be configured for that, and a floor going unread on a quiet library is not mistaken for it.

Two more things worth knowing before raising it:

  • A debrid account's floor is per host, a news account's is per account. A debrid account is reached over many hosts — an API hostname, and a delivery host per link — and which ones is not knowable until a link has been minted, so the hosts kept warm are the ones the account has actually been using. At most 16 are kept at once, and one unused for 10 minutes is retired. A news account is one host, named in the config, and holds its session for as long as the plan allows — which is why its floor is the one that reliably pays off. See Usenet.
  • Warm connections are real connections, and count against the per-host ceiling the provider polices — 32 on Real-Debrid and AllDebrid, 20 on TorBox. That is why the debrid floor is capped at 4: a floor that size leaves the ceiling essentially intact, where a large one would spend it on sockets carrying nothing.

The floor is disabled when proxy is set, since an HTTP or SOCKS proxy makes zurg dial the proxy rather than the host, and there is nothing on the far side worth holding open.

Authentication & Proxy

Option Type Default Description
username string "" HTTP basic auth username. When set along with password, all requests to zurg (dashboard, WebDAV, API) require authentication. Leave empty to disable auth.
password string "" HTTP basic auth password. Must be set together with username.
proxy string "" Route all of zurg's outbound network traffic through a proxy. Supports http://, https://, and socks5:// protocols. Useful if your ISP blocks or throttles Real-Debrid traffic. Example: socks5://user:pass@host:port. The PROXY environment variable is used when this key is empty.
username: ""
password: ""
proxy: "http://[username:password@]host:port"

Rclone Settings

Option Type Default Description
rclone_enabled bool false When true, zurg manages an rclone mount automatically. This eliminates the need to run rclone separately — zurg starts, monitors, and restarts the rclone process as needed. The mount uses mount_path as the mount point.
rclone_binary string "rclone" Path to the rclone executable. Only needed if rclone is not on your system PATH.
rclone_extra_args list [] Additional command-line flags passed to the rclone mount command. Use this to override zurg's benchmark-optimized VFS defaults (see table below). Each entry is a single flag string. The RC settings and the --union-*/--webdav-* backend flags are refused: zurg constructs those itself, and a flag would outrank what it builds (union_writable would stop describing the mount).
mount_read_only bool false When true, the mount is started with rclone's --read-only, so the kernel refuses a write before it ever reaches zurg. Nothing else gates a mount DELETE, so this is the only way to refuse one — though it does not cover clients talking to the WebDAV endpoint directly — and it additionally stops a stray write from landing in the mount's local union upstream. Override it per-flag with rclone_extra_args: ["--read-only=false"].
rclone_cache_reclaim bool true Whether zurg deletes the VFS cache trees left behind by an earlier mount configuration. rclone names its cache after the remote, and that name hashes the union upstreams, which carry the absolute path of data/local — so moving the zurg directory or flipping union_writable renames the cache and strands the old tree where no cap counts it and no cleaner visits it. At mount start zurg asks the running rclone which tree is live and removes the rest, logging what it freed; it does nothing unless the live tree is where rclone says it is, and skips the sweep entirely when rclone_extra_args sets --cache-dir. Set it to false to keep every stranded tree. See Disk the mount uses.
union_writable string "local" Which side of the embedded mount's union a created file is written to. local, the default, puts it straight onto the local upstream (data/local) without zurg seeing it. server lists the WebDAV upstream first, so the create arrives at zurg: refused outside __magic__, written as a size-capped sidecar inside it. A refused write never reaches the disk — rclone's VFS cache accepts it and retries the upload on a backoff, so it is bounded by the cache cap and visible in logs/rclone.log, where the local order would have put unbounded bytes in data/local. One caveat measured under server: renaming a sidecar file through the mount can answer an error (the file is visible on both upstreams, so rclone applies the move to each); release moves inside __magic__ — the *arr import path — are unaffected. Switching to server changes where new creates go and nothing else: whatever the local order already wrote to data/local stays until you delete it. The line zurg logs at startup names the live mode (new files go to zurg over WebDAV (union_writable: server)), so it is the thing to check rather than the presence of the data/local path, which is part of the union under either order.
rclone_enabled: false
rclone_binary: "rclone"
rclone_extra_args: []
mount_read_only: false
rclone_cache_reclaim: true
union_writable: "local"

Benchmark-Optimized Defaults

Zurg uses benchmark-tested rclone VFS settings optimized for streaming performance:

Setting Default Why
--buffer-size 256M Large buffer for smooth streaming
--vfs-read-chunk-size 32M How long a fresh read spends ramping. The first chunk is fetched at this size and doubles from there, so at 4M a read from a cold offset spent most of its life below line rate. A request size, not a forced transfer — rclone still stops pulling when the reader stops.
--vfs-read-chunk-size-limit 512M Where the doubling above stops. Chunking is on by default, so this is a ceiling the ramp reaches rather than something an override switches on.
--vfs-read-ahead 128M Balance between buffer and bandwidth efficiency
--max-read-ahead 1M Small FUSE kernel buffer reduces small reads. Not passed on Windows.
--vfs-read-wait 5ms Critical: higher values cause severe slowdowns
--vfs-cache-mode full Full caching for best performance. Not always the right trade for a player reading the mount over a share — see When the cache costs more than it buys.
--vfs-cache-max-size 256G Hard cap on the on-disk VFS cache. Not a config key — override it with rclone_extra_args.
--vfs-cache-min-free-space 10G Stops caching before the cache disk fills
--vfs-cache-max-age 72h How long an untouched cached file is kept. Untouched is the operative word, and a media server rarely leaves anything untouched — see Disk the mount uses.
--vfs-cache-poll-interval 1m Responsive cache updates
--vfs-fast-fingerprint on Identifies files without hashing them
--dir-cache-time 12h Listings hold for hours; zurg forgets affected entries over the RC API when the library changes, so media-server scans stay warm
--attr-timeout 60s Library files are immutable, and state changes forget the affected entries explicitly. Bounded so a stale size can't outlive a scan pass.
--poll-interval 0 Change polling is zurg's job, not rclone's
--timeout 5m IO idle timeout
--no-modtime / --no-checksum on Neither is meaningful for a debrid library, and asking for them costs requests
--async-read rclone default (on), not passed Zurg does not put this flag on the command line, and rclone defaults it to true — so the mount reads asynchronously, and every figure on this page was measured that way. A tuning field once claimed to disable it; all it gated was appending the bare flag, which sets the default it already had. To actually turn it off: rclone_extra_args: ["--async-read=false"].
--low-level-retries 3 rclone's VFS downloader already restarts a failed read ten times per open, and each restart is retried by the backend pacer this many times. At rclone's default of 10 that is 100 requests for one open of a file zurg is answering 503 deliberately.
--retries 1 Whole-command budget; for a mount that never exits it only multiplies a startup failure
--vfs-handle-caching 0, on rclone 1.74+ rclone 1.74 started keeping a file's downloaders alive for five seconds after the last close, so touching a file costs five seconds of whatever the link can carry rather than what was read: measured against a 50 MB/s server, one 1 MiB read pulled 245-250 MiB instead of 3-6 MiB, and a twenty-file sweep went from 80 MiB to 4.2 GiB. The grace period exists for serve nfs, which zurg never runs. Older rclone has no such flag and refuses to start on an unknown one, so zurg asks the binary its version first and passes nothing below 1.74.
--log-level NOTICE rclone's own verbosity, always passed
--cache-dir data/rclone-cache Resolved against the working directory
--log-file logs/rclone.log Resolved against the working directory

--vfs-cache-max-size, --vfs-cache-min-free-space, --low-level-retries, --retries, --vfs-handle-caching, --log-level, --cache-dir and --log-file are constants in internal/rclone/manager.go; every other row comes from tuneVFSOptions in internal/rclone/tuning.go — --vfs-cache-max-age and --vfs-cache-poll-interval included, despite the names.

Disk the mount uses

--vfs-cache-mode full means a file read through the mount is downloaded in full to data/rclone-cache and kept there after playback ends. It is not a playback scratch buffer that empties when the video stops. Left alone, the cache grows to --vfs-cache-max-size, which defaults to 256G, and stays there.

Two limits are supposed to bound it, and in practice only one does:

  • --vfs-cache-max-size (256G) is the real bound. Set it to no more than half the free space on whatever disk data/ sits on.

  • --vfs-cache-max-age (72h) evicts a file nothing has touched for three days. rclone measures that from its own record in data/rclone-cache/vfsMeta/, not from the filesystem's atime, and anything that reads the library refreshes it. A Plex server with scheduled tasks enabled reads every cached file during its nightly maintenance window, so on 2026-09-09 an instance whose cache held 362 files had an ATime from that same morning on every one of them. Nothing had aged out in months, and nothing ever would. Do not plan around age eviction; plan around the size cap.

Lower the cap with rclone_extra_args:

rclone_extra_args:
  - "--vfs-cache-max-size"
  - "50G"
  - "--vfs-cache-min-free-space"
  - "20G"

--vfs-cache-min-free-space (10G by default) stops caching when the disk gets that close to full. Keep it well above zero on a disk shared with anything else.

To reclaim the cache by hand, stop zurg first, then empty both trees:

rm -rf data/rclone-cache/vfs/* data/rclone-cache/vfsMeta/*

Measure it with du -sh data/rclone-cache. Partially downloaded entries are sparse files, so a size that counts apparent bytes reports far more than the disk actually holds.

When the cache costs more than it buys

full is the right default for a media server on the same host: it reads a file once, and everything after that is local. It is not automatically right for a hardware player reading the mount over a file share.

Under full a read has to land on disk before it reaches the reader, and what gets fetched is the whole file rather than the part being watched. Put a share in front of that and one disk is serving the player while rclone is still writing to it. A Zidoo player reading a Windows share stuttered on every title until the cache was turned off, and played cleanly afterwards (reported 2026-08-30). The mechanism above is the plausible reading of that report rather than something measured here, so treat it as the first thing to try when a direct-reading client stutters on a mount that a media server on the same host plays without trouble:

rclone_extra_args:
  - "--vfs-cache-mode"
  - "off"

What that gives up is real. With no local copy a seek re-reads from the provider, a second play downloads the file again, and nothing survives a restart. It also changes what the mount accepts as a write, so leave the default alone on a host where an *arr imports through the mount — see qbittorrent.md.

Caches stranded by a move

rclone names its cache after the remote it is caching, and that name carries a hash of the remote's configuration. Zurg's remote is a union whose upstreams embed the absolute path of data/local, so the name moves when the configuration does:

Union upstreams Cache tree
/home/you/zurg/data/local :webdav: vfs/zurg{QaSkn}
:webdav: /home/you/zurg/data/local (union_writable: server) vfs/zurg{HdSp7}
/other/root/data/local :webdav: (zurg moved) vfs/zurg{Psid2}

rclone only ever accounts for the tree it is using. A tree under an old name is counted against no cap, visited by no cleaner, and deleted by nothing — so moving the zurg directory, or an external drive that comes back under a new mount point, leaves a whole cache behind. Several such moves is how an install with a 256G cap ends up holding multiple terabytes.

Zurg reclaims those trees at mount start: it asks the running rclone which tree is live and deletes the rest, logging what it freed. It does nothing unless the live tree is on disk where rclone says it is, and it skips the sweep entirely when rclone_extra_args sets --cache-dir, since a directory you chose may be shared with another zurg. Set rclone_cache_reclaim: false to turn it off and keep every stranded tree.

Four more are added by platform, all of them on Linux: --allow-other and --allow-non-empty always, --uid and --gid only when the running user's ids can be read. --max-read-ahead is passed everywhere except Windows, and --read-only whenever mount_read_only is set.

The buffer and read-wait values were determined through benchmarking on a 600 Mbps connection, achieving ~533 Mbps cold-cache throughput and ~235ms TTFB. The read chunk size was re-measured later on a ~70 MB/s host: paired and interleaved, median throughput went 49.1 -> 56.5 MB/s on Real-Debrid moving it from 4M to 32M, and moved the same way on TorBox.

Override defaults using rclone_extra_args:

rclone_extra_args:
  - "--buffer-size=128M"        # Use less memory
  - "--vfs-read-chunk-size=2M"  # Smaller chunks

RC flags (--rc, --rc-addr, and anything else starting with rc-) are rejected: zurg runs rclone's remote control itself to invalidate the VFS cache when the library changes.

User Agent & Network

Option Type Default Description
user_agent string Chrome UA Shared HTTP User-Agent for providers, indexers, ffprobe and outbound integrations. Blank restores the generic browser default; explicit custom values are sent as entered. Requires restart. See outbound request identity.
omit_user_agent boolean false Suppress the HTTP User-Agent (ffprobe uses an empty value), overriding user_agent. Requests to GitHub still send one, because GitHub refuses requests without it. Requires restart.
outbound_client_name string "Media Client" Product/client name for media-server integrations where supported. Blank restores the default. Requires restart.
outbound_client_id string "media-client" Plex client identifier, including sign-in for media-server integrations where supported. Blank restores the default. Requires restart.
outbound_client_version string "1.0" Client version for media-server integrations where supported. Blank restores the default. Requires restart.
outbound_device_name string "Media Client" Device name for media-server integrations where supported. Blank restores the default. Requires restart.
outbound_device_id string "media-client" Jellyfin/Emby device identifier for media-server integrations where supported. Blank restores the default. Requires restart.
outbound_platform string "Desktop" Plex platform for media-server integrations where supported. Blank restores the default. Requires restart.
force_ipv6 bool false Forces all of zurg's network tests and host selection to use IPv6. Enable this if your network has better IPv6 connectivity to RD servers (e.g., some ISPs throttle IPv4 but not IPv6).
unrestrict_ip string "" The IP address to pass to RD when unrestricting download links. Useful in multi-network setups where you want downloads to be generated for a specific IP (e.g., a VPN exit IP) rather than the IP zurg is running on.
dns_servers list [] (system DNS) Custom DNS servers for resolving RD hostnames. Useful if your system DNS is slow, unreliable, or blocked by your ISP. Leave empty to use the system resolver. Must include the port (e.g., 1.1.1.1:53).
network_test_every_mins int 1440 How often (in minutes) zurg re-runs the network latency test to find the fastest RD download servers. Cannot be disabled — values <= 0 are treated as 1440. Results are cached to data/network_test_results.json and data/network_test_timestamp; on restart, if the cache is fresh enough the network test is skipped.
user_agent: "Mozilla/5.0 ..."
force_ipv6: false
unrestrict_ip: ""
network_test_every_mins: 1440
dns_servers:
  - "1.1.1.1:53"   # Cloudflare
  - "8.8.8.8:53"   # Google

Public NZB sharing

An NZB share is a URL a recipient fetches — so it only helps if they can reach this host, which a zurg behind a home NAT cannot promise. These two keys route that URL through a zrok service instead: zurg dials the service outbound and serves the share through the connection, so no local port opens and nothing new answers on this host. What the tunnel carries is only the two token-gated share routes, never the dashboard and never the library.

Both empty, and with no enabled environment under data/zrok, sharing stays on the network this host is already on, dialing nowhere — that is the default. Note the second half of that: once an environment has been enabled, an empty zrok_account_token no longer means local-only, because the enable is what turns sharing on and it persists. Turning public sharing back off means removing data/zrok.

Building, serving and revoking shares work the same either way — the share token in the path is the authorization on the local URL and the public one alike.

The public URL survives a restart: the service-side share is looked up and re-attached to rather than created again. What it does not survive is enabling again — a new environment gets a new share, and URLs handed out under the old one stop resolving. That happens when you remove data/zrok, and also if the identity saved in there goes missing or unreadable, since enabling again is the only way back from that.

Removing data/zrok by hand also leaves that environment, and the share under it, registered on the zrok account — the identity that could have released them is what you just deleted, so tidy them from the zrok console if the clutter matters. It is not a way to switch sharing off in place, either: a running zurg holds the connection it is already serving on, so the old URL keeps working until the process restarts. zurg releases an environment itself whenever it replaces one, and refuses to replace one it could not reach the service to release — or one whose files it finds on disk but cannot read, since enabling over that would abandon a live environment on every restart.

Option Type Default Description
zrok_account_token string "" The zrok enable token that turns public sharing on. Free from myzrok.io for the hosted service; from your own controller if you self-host. Needed for the first enable only: enabling redeems it once and saves it, with the environment identity, under data/zrok, and every later call authenticates off that saved copy — so the key can come back out of config.yml afterwards and public sharing still comes up on the next restart. Note what that means for the secret: taking the key out of the config does not take the token off the host. Two reasons to leave it in anyway: an enable that failed — a refused token, a service that could not be reached — is not a completed one, and the retry needs the key; and any later re-enable needs it too, so an instance that has had it removed and then loses the identity under data/zrok cannot recover on its own. A rotated token is picked up from here and applied to the saved environment, identity and public URL intact.
zrok_api_endpoint string "" The zrok service to talk to, for self-hosted deployments; empty keeps the SDK's default, https://api-v2.zrok.io (the hosted service). Read at enable time only — once data/zrok holds an enabled environment, the endpoint saved there wins, so pointing an already-enabled zurg at a different service means deleting data/zrok and enabling again. The identity belongs to whichever service issued it, so that is the honest way round anyway. It does not replace zrok_account_token: the first enable still needs a token from the service this names. The SDK's own ZROK2_API_ENDPOINT environment variable outranks this key at that same moment.

Reachability cuts two different ways for a self-hosted service, and only one of them is yours: this zurg reaches the controller API — a tailnet address is enough for that — while your recipients reach the service's frontend. A service reachable only on your own tailnet therefore hands out share URLs only your own tailnet can open.

Two more things a self-hosted service has to line up with. The share URL is the one the service advertises for its frontend, and a bare host with no scheme is assumed to be https — so a frontend serving plain HTTP wants TLS in front of it, or an advertised URL that carries its own scheme and port. And the share is created in the namespace the zrok SDK resolves, which is public unless the SDK's own ZROK2_DEFAULT_NAMESPACE says otherwise; zurg pins neither, so a service whose namespace is named something else is told through that variable.

zrok_account_token: ""   # the enable token, needed for the first enable only
zrok_api_endpoint: ""    # self-hosted only; empty = the hosted service

6. Performance & Tuning (Advanced)

These settings are rarely touched unless debugging or optimizing for your specific network conditions.

Persistent read caches

Two byte caches survive a restart. Both are shared across the instance and each has its own disk budget in MiB. A change takes effect after a restart.

Option Type Default Description
nzb_article_disk_cache_mb int 512 Decoded Usenet articles kept in data/bytecache/articles/. An article read once is served from disk the next time. 0 turns it off.
archive_decoded_disk_cache_mb int 512 Decompressed archive blocks kept in data/bytecache/decoded/. A backward seek or a restart does not decode from the start of the entry again. The same budget again is used for data/bytecache/decoded-tails/, which keeps the last 8 MiB of a compressed entry once something has asked for it. 0 turns both off.

The oldest unused records go first when a budget fills. persistent-caches.md covers every cache that survives a restart.

CDN & Host Selection

Related: The network latency test that determines the fastest hosts runs periodically based on network_test_every_mins (default: every 1440 minutes / 24 hours).

Option Type Default Description
rd_cdn_host_preference string "auto" Controls which RD download servers zurg prefers. auto uses whatever RD provides. force_cloudflare rewrites the host to its .cloud twin, which reaches the same machine through Cloudflare — useful where the direct IPs are blocked. force_numbered uses RD's numbered hosts directly. force_location_<location> (e.g. force_location_syd, force_location_fra) routes through a specific country's servers.
cdn_host_preference string — Legacy alias for rd_cdn_host_preference, kept for compatibility and Real-Debrid only. If both are set, the explicit one wins.
tb_cdn_host_preference string "auto" TorBox CDN region. auto leaves TorBox's own choice alone; force_location_<region> serves downloads from that region. Only regions currently advertising a delivery node can be forced, and the dashboard's select lists exactly those. No fallback to the legacy key — its values are Real-Debrid location codes and mean nothing here.
number_of_hosts int 0 (all) Limits zurg to the N fastest download hosts. After speed-testing available hosts, zurg only uses the top N. Set to 0 to use all available hosts. A value like 3 reduces connection spreading but ensures you always use the fastest servers.
rd_cdn_host_preference: "auto"
tb_cdn_host_preference: "auto"
number_of_hosts: 3

force_cloudflare and force_location_* are mutually exclusive. RD publishes a .cloud twin for its numbered hosts only — measured 2026-08-09, all 25 numbered subdomains have one and none of the 22 country-specific ones do. So an account with Cloudflare enabled in its RD account settings is always handed a numbered host, and no location preference can be honoured for it. Zurg detects this from the link it unrestricts at startup, skips the geo probing that cannot succeed, and logs why if a location is configured anyway.

Note on force_location_* verification: When using a geo-location preference like force_location_nyk, the RD /rest/1.0/downloads API endpoint normalizes download URLs to numbered servers (e.g., 128-4.download.real-debrid.com) even though the actual unrestrict response returned a geo server (e.g., nyk7-4.download.real-debrid.com). The download code itself embeds the numbered server (e.g., code ...4128 maps to server 128-4). To verify that geo routing is working, check zurg's logs or the raw unrestrict response — do not rely on the RD downloads API.

Rate Limits

Option Type Default Description
api_rate_limit_per_minute int 250 Maximum RD API calls per minute for general operations (everything except torrent listing). RD enforces its own limits; this prevents zurg from hitting them and getting temporarily blocked. Increase only if you're confident your RD plan allows it.
torrents_rate_limit_per_minute int 75 Maximum RD API calls per minute specifically for the /torrents endpoint used to fetch your torrent list. This is separated because torrent listing is the most frequent API call and has its own rate dynamics.
fetch_torrents_page_size int 5000 How many torrents to fetch per page when syncing your RD library. Larger values mean fewer API calls but larger responses. If you have a very large library (10,000+ torrents), a higher value reduces sync time.
api_rate_limit_per_minute: 250
torrents_rate_limit_per_minute: 75
fetch_torrents_page_size: 5000
Option Type Default Description
use_range_verification bool false Controls how zurg verifies that download links are still valid. When false, zurg uses a HEAD request (lightweight, no data transferred). When true, uses a Range header (GET bytes=0-0) which downloads just 1 byte. Enable this if your network or RD servers don't respond correctly to HEAD requests, causing false link failures.

Timeouts & Retries

Option Type Default Description
api_timeout_secs int 60 Maximum seconds to wait for any RD API call to respond. Increase if you're on a slow connection and API calls are timing out.
download_timeout_secs int 15 Maximum seconds to wait when testing download links (used during host speed tests and link verification). Not the timeout for actual file downloads.
retries_until_failed int 2 How many times to retry a failed API call before giving up and marking it as failed. Higher values add resilience against transient RD outages but slow down failure detection.
retry_503_errors bool false When true, retries requests that get a 503 (Service Unavailable) response from RD. RD returns 503 during maintenance or overload. Enable this if you experience frequent 503 errors that resolve themselves quickly.
api_timeout_secs: 60
download_timeout_secs: 15
retries_until_failed: 2
retry_503_errors: false

Downloads & Debugging

Option Type Default Description
get_downloads_limit int -1 Maximum number of RD download entries (non-torrent) to fetch. -1 is no limit (fetch all), 0 skips fetching downloads entirely, and a positive number caps it. Useful if you use RD's direct link unrestricting feature and want those files visible in zurg. Leaving the key out means -1; an explicit 0 is honoured as "cache none" and logs a warning at startup, because that is usually a config written before 0 changed meaning.
load_dumped_torrents bool false When true, loads torrent data from the dump/ folder on startup. This is a recovery mechanism — if zurg has previously dumped torrent state (e.g., during a crash), enabling this restores that state without re-fetching everything from RD.
log_requests bool false When true, logs detailed statistics for every download request (file path, response time, bytes served). Useful for debugging streaming issues or understanding access patterns. Generates significant log volume — disable in production.
log_level string DEBUG How much detail to log: DEBUG, INFO, WARN, ERROR or FATAL. Changing it from the dashboard takes effect immediately — no restart, and it re-levels every component at once. It overrides the LOG_LEVEL environment variable, so the dashboard control still works on a container that bakes one in; leave the key out to hand the choice back to LOG_LEVEL. A value that names no level is ignored with a warning at startup rather than being fatal.
traffic_alert_gb int 0 Warns when one debrid account moves more than this many GiB through zurg in a day (the day resets at 00:05 CET, with Real-Debrid's allowance). The warning names the files that used the most this hour and who read them, so a library scan or a client pulling whole files shows up while it is happening. zurg also logs a summary of the hour's largest files at the top of every hour, and a line for any single read of 1 GiB or more, whether or not the alert is set. 0 turns the alert off.
traffic_alert_webhook string — A Discord webhook the traffic alert is also posted to. Keep it private: anyone with the URL can post to that channel.
get_downloads_limit: -1
load_dumped_torrents: false
log_requests: false
log_level: DEBUG
traffic_alert_gb: 500

7. Directories & Filters

Define virtual directories that organize your torrents based on flexible filter rules. Each directory appears as a folder in your mount and shows only the torrents matching its filters.

directories:
  shows:
    group: media
    group_order: 20
    filters:
      - has_episodes: true

  # ... (see config.example.yml for full examples)

Directory options

Option Type Default Description
group string the directory's own name Directories sharing a group compete for each torrent: it lands in the first one whose filters match and no other in that group. Directories in different groups each get their own copy.
group_order int 0 (treated as 1) Priority within the group — lower goes first. Ties are broken by directory name.
filters list [] The rules deciding what belongs here. A torrent matches the directory if any entry in the list matches. An empty or absent list matches every torrent.
only_show_the_biggest_file bool false Show only the largest file of each torrent, hiding samples, extras and companion files.
only_show_files_with_size_lte int 0 (off) Hide files larger than this many bytes.
only_show_files_with_size_gte int 0 (off) Hide files smaller than this many bytes.

A file explicitly forced visible in zurg ignores all three visibility options.

Filter conditions

Condition Type Description
id string The torrent's ID. Decisive: nothing else in the same entry is considered.
regex string Torrent name matches the pattern.
not_regex string Torrent name does not match the pattern.
contains string Torrent name contains the text (case-insensitive).
contains_strict string Torrent name contains the text (case-sensitive).
not_contains string Torrent name does not contain the text (case-insensitive).
not_contains_strict string Torrent name does not contain the text (case-sensitive).
size_gte int Total torrent size is at least this many bytes.
size_lte int Total torrent size is at most this many bytes.
any_file_inside_regex string Some file in the torrent matches the pattern.
any_file_inside_not_regex string Some file in the torrent does not match the pattern.
any_file_inside_contains string Some file contains the text (case-insensitive).
any_file_inside_contains_strict string Some file contains the text (case-sensitive).
any_file_inside_not_contains string Some file does not contain the text (case-insensitive).
any_file_inside_not_contains_strict string Some file does not contain the text (case-sensitive).
any_file_inside_size_gte int Some file is at least this many bytes.
any_file_inside_size_lte int Some file is at most this many bytes.
added_within_hours int The torrent was added within this many hours.
added_within_days int The torrent was added within this many days.
added_after string The torrent was added on or after this date. Format YYYY-MM-DD or YYYY-MM-DD HH:MM.
added_before string The torrent was added before this date, same formats. When paired with added_after, both must hold.
has_episodes bool Episode detection: season/episode markers or CRC hashes in the torrent or file names, or a run of numbered files. A torrent whose largest file is at least 5x the second-largest is read as a movie with extras and rejected.
is_music bool Some file ends in .m3u, .mp3, .flac, .m4a or .m4b.
has_tag string The torrent carries this tag. Acts as a gate: without it, nothing else in the entry can match.
not_has_tag string The torrent does not carry this tag, and gates the same way.
tags_match_all list Every listed tag is present (case-insensitive).
tags_match_any list At least one listed tag is present.
tags_missing_all list None of the listed tags is present.
tags_missing_any list At least one listed tag is absent.
provider string The release is held by this account, named by its name in the providers block. A gate, not a match: combined with another condition it means both must hold. On its own it puts the whole account in the directory.
not_provider string No account holding the release is this one. Gates the same way.
and list Every nested condition must match.
or list At least one nested condition must match.

Tags come from media analysis — see tags.md for the full list.

Patterns are written /expression/flags, where the flags are any of i (case-insensitive), m (multi-line), s (dot matches newline) and x (extended). A value with no slashes is compiled as a bare Go regular expression.

Conditions within one filter entry are not ANDed by default — most of them match the entry as soon as one of them holds. Use an explicit and: block when you mean all of them:

directories:
  anime:
    group: media
    group_order: 10
    filters:
      - and:
          - has_episodes: true
          - any_file_inside_regex: /^\[/
          - not_regex: /\[One Pace\]/

Per-account directories

Alongside __all__, every configured account gets a directory of its own, named after it — __realdebrid__, __torbox__, __alldebrid__, __nzb__. They are not configured; they appear because the account exists, and disappear when it is removed.

Each one holds whatever that account holds, and — unlike a filtered directory — it also decides where reads go. A release held by two accounts is one entry in the library, so without this every copy of it would stream from whichever account sorts first in the providers block:

__realdebrid__/Some.Release/movie.mkv   → streamed from Real-Debrid
__torbox__/Some.Release/movie.mkv       → streamed from TorBox

The pin excludes the other accounts rather than merely preferring the named one. There is no silent failover: if TorBox cannot serve the file, the read under __torbox__ fails instead of quietly answering with Real-Debrid's bytes. Reads elsewhere in the mount are unaffected and still fail over normally.

Two consequences follow from accounts not holding identical content:

  • A directory lists only the files its account actually holds. If TorBox downloaded part of a release Real-Debrid has whole, __torbox__ shows the part.
  • Brokenness is judged per account. A file dead on Real-Debrid but healthy on TorBox is a 404 under __realdebrid__ and plays under __torbox__, while the rest of the mount still serves it.

The directory is named after the account's name, which defaults to its type. Two accounts on one service need distinct names (rd-main, rd-backup) and get a directory each. The Usenet backend is nzb by default, so it appears as __nzb__; set name: usenet on that provider entry if you would rather browse __usenet__.

You can rename an account later. zurg sees the same releases listed under the new name and moves them there. The old directory goes away with the old name. If a media server reads that directory point it at the new one before you rename.

Because the same release appears under every account holding it as well as in your filtered directories, point a media server at one part of the mount rather than all of it, or it will scan the same content several times.

An account named after a directory zurg already uses (all, unplayable, dump, downloads) gets no per-account directory, and zurg logs a warning at startup rather than taking the existing one over.

Local debrid catalogs

Each debrid provider entry accepts an optional library block:

providers:
  - name: rd-local
    type: realdebrid
    token: PRIMARY_RD_TOKEN
    download_tokens: # Optional bandwidth fallback.
      - SECOND_RD_TOKEN
    library:
      source: local
      path: torrents/rd-local

source defaults to provider. With local, portable .zurgtorrent files own membership and playback uses this account's credentials. path defaults to torrents/<resolved-account-name> and must stay under torrents/. The same source directory cannot belong to two enabled accounts. NZB providers retain their existing nzbs/ library.

In this example, both RD tokens serve the one catalog in torrents/rd-local/. The second token needs no provider entry or directory, and its torrent library is not imported. download_tokens keeps its bandwidth-fallback behavior; it does not force every download through the second token. Two separate local provider entries instead own two distinct catalog directories, even when both are Real-Debrid. Credentials stay in the config and are never exported into portable files.

See portable libraries for offline export, migration from dump/, sharing semantics and playback limits.

Offloading an online account into a local library

A Real-Debrid or AllDebrid account that owns its catalog (no source, or source: provider) can move its finished releases into a local library and delete them from the account, which frees their slots:

providers:
  - name: rd
    type: realdebrid
    token: YOUR_RD_TOKEN
    library:
      offload_to: rd-local      # a local entry of the same type
      offload_after_mins: 30    # optional, 30 is the default

  - name: rd-local
    type: realdebrid
    token: YOUR_RD_TOKEN        # the same account
    library:
      source: local
Key Default Meaning
library.offload_to none Name of an enabled source: local entry of the same type. Only realdebrid and alldebrid accounts accept it, since only their links keep working after the torrent is deleted. It cannot name the account itself.
library.offload_after_mins 30 How long a release must have been complete on the account before it is moved automatically. Counted from when zurg first saw it complete, so a restart starts the wait again. Requires offload_to.

A release moves only when every file has a link another account could redeem, nothing is repairing or downloading it, no Sonarr or Radarr job in the qBittorrent endpoint still lists it, and moving it keeps its folder name and every directory it is in. Its folder stays where it was and no media server scan is started. The release page in the dashboard can move one release by hand, with or without offload_to. See portable libraries for the order of the steps, what happens when one fails, and how local playback on a shared token is protected.