A stateless, Kubernetes-native replacement for the nzbhydra2 external API.
stateless-hydra emulates the nzbhydra2 external API — the Newznab/Torznab HTTP interface that Sonarr, Radarr, Lidarr, Readarr and similar tools already speak — but implements it as a stateless proxy/aggregator. It queries multiple Usenet indexers, normalizes and merges their results, and exposes a single Newznab endpoint to clients. There is no WebUI and no local database: all runtime state lives in Redis.
This project is under active development. The configuration, deployment and container contracts below are stable; refer to
AGENTS.mdfor the full project rules.
- Stateless — no sqlite, no local files, no database. Every pod can be killed and recreated at any time, and horizontally scaled.
- Redis-backed cache and limits — search responses are cached in Redis and the per-indexer daily budgets live there too, so limits are shared across replicas.
- Per-indexer daily limits — separate API-hit and NZB-pull budgets, each
with a configurable reset time and IANA timezone (default
00:00UTC).0means unlimited. - Configuration as data — app and indexer settings are read from YAML
ConfigMaps; credentials come only from a Secret. Environment variables
prefixed with
SH_override file values. - Configurable User-Agent — set globally, because some indexers rate-limit or block unknown agents.
- Global and per-indexer proxies — route all indexer traffic through an HTTP(S) or SOCKS5 proxy, or override it for a single indexer.
- Prometheus metrics — counters and histograms under the
stateless_hydra_prefix, scraped from/metrics. - Newznab/Torznab compatible —
caps,search,tvsearch,movie,music,book,detailsandgetnzb, in XML and JSON. - No WebUI — it is an API, not an application. Configure it with files.
Sonarr / Radarr / any Newznab client
|
| Newznab/Torznab HTTP (port 5076, /api?apikey=...)
v
+-------------------+ +----------------------+
| stateless-hydra | <------> | Redis |
| (stateless pods) | cache | search cache and |
+-------------------+ limits | daily limit counters|
| +----------------------+
| fan-out over HTTP (httpx)
v
Usenet indexers (Newznab / Torznab: NZBgeek, ...)
- Clients authenticate against hydra API keys (
hydraApiKeys). - stateless-hydra fans a search out concurrently to the enabled indexers
that support the requested function, using each indexer's own API key
(
apiKeys); total latency tracks the slowest indexer rather than their sum. - Results are merged, optionally de-duplicated and cached in Redis.
getnzbdownloads are routed back to the owning indexer via the composed guid (<indexer>:<original-guid>).
The repo ships docker-compose.yml plus example config in config/.
- Edit the example config files (all values are placeholders):
$EDITOR config/indexers.yaml # add your indexers $EDITOR config/api-keys.yaml # add real indexer keys + a hydra API key
- Start the stack:
docker compose up --build
- Verify:
curl -s "http://localhost:5076/healthz" curl -s "http://localhost:5076/api?t=caps&apikey=YOUR_HYDRA_API_KEY"
The app service mounts ./config read-only at /config and talks to the
redis service with no persistence (everything in Redis is cache or daily
counters). Point Sonarr/Radarr at http://localhost:5076/api with one of the
hydraApiKeys values as the API key.
Manifests live in k8s/ and are wired together with Kustomize:
kubectl apply -k k8s/Before doing so:
- Replace the placeholder values in
k8s/secret-api-keys.yaml— or, better, manage that Secret with External Secrets, SOPS/sealed-secrets, or a cloud secret manager. Never commit real keys. - Replace the example indexers in
k8s/configmap-indexers.yaml. - Point Redis at your own endpoint by editing
redis_urlink8s/configmap-app.yaml(the Service name ink8s/redis.yamlby default). For a managed/external Redis, dropk8s/redis.yamland update that value. Do not setSH_REDIS_URLon the Deployment unless you intend it to override the ConfigMap.
The Deployment mounts the three config files as read-only sub-paths at
/config/app.yaml, /config/indexers.yaml and /config/api-keys.yaml,
runs as non-root with a read-only root filesystem (an emptyDir is mounted at
/tmp), and exposes livenessProbe/startupProbe on /healthz and
readinessProbe on /readyz.
If /readyz returns 503 and the logs say Name or service not known, the
app cannot resolve/reach Redis. Check that redis_url in the app-config
ConfigMap (k8s/configmap-app.yaml) matches your Redis Service DNS name.
Remember that SH_* environment variables override the config file, so do
not set SH_REDIS_URL on the Deployment unless you mean it to win; the
ConfigMap is the single source of truth in Kubernetes.
stateless-hydra reads three YAML files. Paths default to /config/*.yaml and
can be overridden with SH_APP_CONFIG, SH_INDEXERS_FILE and
SH_API_KEYS_FILE.
All keys are optional; defaults are shown. Environment variables (SH_*)
override file values.
| Key | Default | Env var | Description |
|---|---|---|---|
log_level |
INFO |
SH_LOG_LEVEL |
Log verbosity (DEBUG…CRITICAL). |
user_agent |
stateless-hydra/0.1.0 |
SH_USER_AGENT |
User-Agent sent to indexers. |
redis_url |
redis://localhost:6379/0 |
SH_REDIS_URL |
Redis backing store URL. |
cache_ttl_seconds |
900 |
SH_CACHE_TTL_SECONDS |
Default search cache TTL; 0 disables caching. |
dedupe_by_title |
false |
SH_DEDUPE_BY_TITLE |
Collapse identical titles across indexers. |
max_results_per_indexer |
100 |
SH_MAX_RESULTS_PER_INDEXER |
Max results requested per indexer. |
global_proxy_url |
unset | SH_GLOBAL_PROXY_URL |
Default proxy for indexer requests: http://, https://, socks5:// (or socks5h://). |
host |
0.0.0.0 |
SH_HOST |
Bind interface. |
port |
5076 |
SH_PORT |
Bind port (nzbhydra2's default). |
app_config |
/config/app.yaml |
SH_APP_CONFIG |
Path to this file. |
indexers_file |
/config/indexers.yaml |
SH_INDEXERS_FILE |
Path to the indexers file. |
api_keys_file |
/config/api-keys.yaml |
SH_API_KEYS_FILE |
Path to the secrets file. |
Proxy URLs are passed straight to httpx, which supports http://, https://,
socks5:// and socks5h://. SOCKS support is compiled into the image via the
httpx[socks] extra (socksio), so the same schemes work for both
global_proxy_url and per-indexer proxyUrl.
Top-level shape: { indexers: [ ... ] }.
| Key | Default | Description |
|---|---|---|
name |
(required) | Unique indexer id used in logs, metrics and guids. Must not contain :. |
enabled |
true |
Whether this indexer participates in searches. |
host |
(required) | Scheme + host, no trailing slash, e.g. https://api.nzbgeek.info. |
apiPath |
/api |
API path on the indexer. |
apiKeyRef |
(required) | Name looked up in apiKeys in api-keys.yaml. |
apiHitLimit |
0 |
Daily search-request budget; 0 = unlimited. |
nzbPullLimit |
0 |
Daily NZB-download budget; 0 = unlimited. |
resetTime |
00:00 |
Daily reset time, strict HH:MM 24-hour. |
resetTimezone |
UTC |
IANA timezone for resetTime (validated). |
timeoutSeconds |
30.0 |
Upstream request timeout. |
cacheTtlSeconds |
null |
Per-indexer cache TTL; null = global default, <= 0 disables. |
searchTypes |
["search"] |
Any of search, tvsearch, movie, music, book. |
categories |
null (all) |
List of Newznab category ids to search. |
proxyUrl |
null |
Per-indexer proxy; overrides global_proxy_url (same supported schemes). |
forceGetnzbRebuild |
false |
Force the rebuild path (t=<downloadFunction>&id=<guid>) even when the guid is a URL. Set true when a URL-shaped guid points at a details page rather than the .nzb (see NZB download resolution). |
downloadFunction |
getnzb |
Upstream Newznab function used by the rebuild path: getnzb (standard) or get (classic Newznab, implemented by nZEDb indexers such as drunkenSlug/altHUB). See NZB download resolution. |
Reset semantics. A fresh daily counter is used for each indexer. The
counter key includes the current date in the indexer's resetTimezone, so
the new day begins at resetTime in that timezone (default 00:00 UTC). A
limit of 0 means unlimited but usage is still counted for metrics.
This file contains secrets. In production it is a Kubernetes Secret mounted at
/config/api-keys.yaml, and it must never be committed with real values. The checked-in copy hasCHANGE_MEplaceholders only.
| Key | Shape | Description |
|---|---|---|
apiKeys |
{ref: "real-key"} |
Maps each indexer's apiKeyRef to its real API key. |
hydraApiKeys |
["key", ...] |
API keys clients present to /api. Must contain at least one entry (the app refuses to start otherwise). |
Each enabled indexer has two independent, daily, Redis-backed budgets:
- API hits (
apiHitLimit) — one unit per search request sent to the indexer. - NZB pulls (
nzbPullLimit) — one unit per NZB download served from the indexer.
When an indexer reaches a limit it is skipped until its configured reset
time, so a single exhausted indexer does not break searches as long as
others still have budget. When a client requests a download from an indexer
whose pull budget is exhausted, the API responds with Newznab error
930 (Download limit reached). When a search cannot be served because
every candidate indexer is out of API-hit budget, it responds with 910
(API hit limit reached). Because the counters live in Redis, all replicas
share one budget — adding pods does not multiply it.
A search result carries a composed guid of the form INDEXER:TOKEN. When a
client requests t=getnzb&id=INDEXER:TOKEN, stateless-hydra resolves the
download from the owning indexer. Upstream URLs are derived at search time
and stored server-side in Redis under an opaque token, so no upstream URL —
and no indexer API key — is ever sent to the client.
For each result, stateless-hydra derives the upstream download URL:
- URL-shaped guid (
http(s)://…) withforceGetnzbRebuild: false(the default) → the URL is the download (altHUB-style.nzbURLs, and nZEDb indexers such as drunkenSlug whose permalink<link>is the.nzbURL). - Otherwise → the download is rebuilt as
t=<downloadFunction>&id=<guid>against the indexer's API.
The derived URL is stored at stateless_hydra:nzb:{token} (JSON
{"indexer":…,"url":…}) with a 14-day TTL, and the client-facing guid
becomes {indexer}:{token}. t=getnzb looks the token up in Redis, verifies it
belongs to the named indexer, and fetches the stored URL. Missing, expired or
mismatched tokens return Newznab error 300. Because tokens live in the shared
Redis, Redis is required for downloads (as it already is for the cache and
daily limits) and any replica can serve any token.
How the guid is chosen. stateless-hydra takes the guid from the indexer's
RSS. When the RSS marks it isPermaLink="true", the item's <link> is used as
the guid instead (a permalink guid is the link).
| Indexer / guid shape | forceGetnzbRebuild |
downloadFunction |
Upstream URL |
|---|---|---|---|
altHUB-style: guid is the .nzb URL |
false (default) |
getnzb (default, unused) |
the guid, fetched directly |
drunkenSlug-style: isPermaLink="true", <link> is the .nzb URL |
false (default) |
getnzb (default, unused) |
the substituted <link>, fetched directly |
details-page URL guid without a usable .nzb link |
true |
get (nZEDb classic) or getnzb |
t=<downloadFunction>&id=<guid> |
| Non-URL (plain id) | ignored | getnzb default, or get |
t=<downloadFunction>&id=<guid> |
Use downloadFunction: get for an nZEDb-style indexer that implements only the
classic t=get and exposes a plain (non-URL) guid:
indexers:
- name: nzedb_indexer
host: "https://nzedb.example.net"
apiKeyRef: "nzedb_indexer_key"
downloadFunction: get # rebuild non-URL guids with t=getA details-page URL guid (fetching it returns HTML) needs
forceGetnzbRebuild: true; pick downloadFunction for the function the indexer
implements (get for nZEDb classic, getnzb otherwise).
Tokens expire after 14 days: re-run a search to get a fresh download link if a client kept an old one. Upstream Newznab error documents are detected and translated on the resolution path, so a misconfigured option surfaces a clear Newznab error instead of streaming HTML or an error body as a fake NZB.
Search responses are cached in Redis keyed by the normalized query (the
request parameters with apikey removed, values trimmed and pairs sorted, so
parameter order does not matter). The TTL is the indexer's cacheTtlSeconds
when set, otherwise the global cache_ttl_seconds; a non-positive TTL disables
caching. The cache shortens indexer API usage, which is why it is a core part
of the limits story. Clearing Redis is always safe: it only costs a cold cache
and a reset of the current day's counters.
Prometheus metrics are exposed at /metrics under the stateless_hydra_
prefix. The endpoint is unauthenticated and served on the app port
(5076), so a plain Prometheus scrape only needs the Service/pod address:
scrape_configs:
- job_name: stateless-hydra
metrics_path: /metrics
static_configs:
- targets: ["stateless-hydra.stateless-hydra.svc.cluster.local:5076"]| Metric | Type | Labels | Meaning |
|---|---|---|---|
stateless_hydra_search_requests_total |
counter | function |
Search requests handled, by Newznab function. |
stateless_hydra_search_duration_seconds |
histogram | function |
End-to-end search latency. |
stateless_hydra_indexer_api_hits_total |
counter | indexer |
Search requests sent to each indexer. |
stateless_hydra_indexer_nzb_pulls_total |
counter | indexer |
NZB downloads served from each indexer. |
stateless_hydra_indexer_errors_total |
counter | indexer |
Upstream errors per indexer. |
stateless_hydra_indexer_limit_reached_total |
counter | indexer, kind |
Times a budget was hit (kind = api or nzb). |
stateless_hydra_cache_hits_total |
counter | indexer |
Cache hits per indexer. |
stateless_hydra_cache_misses_total |
counter | indexer |
Cache misses per indexer. |
stateless_hydra_limit_remaining |
gauge | indexer, kind |
Remaining budget for the current window (0 = unlimited; only present while limits are tracked). |
If you run the Prometheus
Operator,
k8s/servicemonitor.yaml configures a 30s scrape of /metrics:
kubectl apply -f k8s/servicemonitor.yaml
# or as part of the Kustomize bundle:
kubectl apply -k k8s/The ServiceMonitor selects the stateless-hydra Service by its
app: stateless-hydra label and scrapes the named http port (5076). It
requires the operator's monitoring.coreos.com/v1 CRDs, and the Prometheus
instance that should scrape it must be allowed to discover this namespace — a
serviceMonitorNamespaceSelector/namespaceSelector that includes
stateless-hydra and a serviceMonitorSelector matching the manifest's
labels. If your Prometheus only selects labelled ServiceMonitors, add its
release label under metadata.labels. Clusters without the operator can simply
drop servicemonitor.yaml from k8s/kustomization.yaml.
An importable dashboard ships at grafana/stateless-hydra-dashboard.json
(schemaVersion 39, works with Grafana 9/10/11):
- In Grafana, go to Dashboards → New → Import.
- Upload
grafana/stateless-hydra-dashboard.json(or paste its contents). - When prompted, pick your Prometheus data source for the
DS_PROMETHEUSinput and click Import.
The dashboard has three rows — Overview (search rate, cache hit ratio, 24h
API-hit/NZB-pull/error totals, searches by function and p50/p95 latency),
Indexers (per-indexer API hits, NZB pulls, errors, limit-reached and cache
hit/miss rates, plus tables of remaining API/NZB limits) and Requests
(request-duration overview and range-normalized searches per function). It
refreshes every 30s and defaults to a now-6h range.
Replace YOUR_HYDRA_API_KEY with a value from hydraApiKeys. All endpoints
are under /api; authentication is via the apikey query parameter.
# Capability document (also useful to confirm auth and configuration)
curl -s "http://localhost:5076/api?t=caps&apikey=YOUR_HYDRA_API_KEY"
# General search
curl -s "http://localhost:5076/api?t=search&q=ubuntu&apikey=YOUR_HYDRA_API_KEY"
# TV search with season/episode
curl -s "http://localhost:5076/api?t=tvsearch&q=show+name&season=2&ep=5&apikey=YOUR_HYDRA_API_KEY"
# Movie search by IMDb/TMDb id
curl -s "http://localhost:5076/api?t=movie&imdbid=0111161&apikey=YOUR_HYDRA_API_KEY"
# Music and book searches
curl -s "http://localhost:5076/api?t=music&artist=artist&album=album&apikey=YOUR_HYDRA_API_KEY"
curl -s "http://localhost:5076/api?t=book&title=title&author=author&apikey=YOUR_HYDRA_API_KEY"
# Item details, then download the NZB (id is the guid from a result)
curl -s "http://localhost:5076/api?t=details&id=INDEXER:GUID&apikey=YOUR_HYDRA_API_KEY"
curl -s "http://localhost:5076/api?t=getnzb&id=INDEXER:GUID&apikey=YOUR_HYDRA_API_KEY" -o item.nzbAdd stateless-hydra as a Newznab indexer:
- URL:
http://<host>:5076/api - API key: one of the values in
hydraApiKeys - Categories: select what your indexers provide (Newznab standard ids).
Errors are returned as Newznab XML <error code="..." description="..."/>
with HTTP 200 (the Newznab convention clients expect):
| Code | Meaning |
|---|---|
100 |
Incorrect user credentials — missing/unknown hydraApiKeys entry. |
200 |
Missing parameter. |
201 |
Incorrect parameter. |
202 |
No such function. |
203 |
Function not available. |
300 |
No such item (unknown guid / details not found). |
900 |
Unknown error. |
910 |
API hit limit reached. |
930 |
Download limit reached. |
Requires uv and Python 3.12+.
uv sync # create .venv and install dependencies
uv run pytest # run the test suite (fakeredis + respx, no live services)Quality gates before every commit (see AGENTS.md rule 8):
uv run ruff check . && uv run ruff format --check . && uv run pytestCommits follow Conventional Commits
(type(scope): subject, types feat, fix, test, docs, chore,
refactor, build, ci), one logical change per commit. Read AGENTS.md
for the full set of project rules.
Versioning is driven by Conventional Commits through python-semantic-release
(configured in pyproject.toml): feat → minor, fix → patch, BREAKING CHANGE → major, with tags of the form v{version}. Container images are
published to the GitHub Container Registry (ghcr.io) by the CI release
pipeline.