- config: koanf-based loading (defaults → YAML → KINDEXR_ env vars) - db: embedded SQLite migrations with BEGIN/END-aware statement splitter - server: chi router, GET /health returns JSON stats - cmd/kindexr: graceful SIGTERM shutdown - cmd/kindexr-cli: stub - deploy: systemd unit, example config, nginx snippet - all packages covered by race-clean tests
30 KiB
nzbstr — Nostr-Native Torznab Indexer
Working name. Alternatives:
dtanr,torstr,arrostr,noindex. Rename before v0.2 ships if you're going to.
What this is
A daemon that bridges NIP-35 torrent events on the Nostr relay network into the Torznab API that Sonarr / Radarr / Lidarr / Readarr / Prowlarr already speak. Two halves:
- Reader (Phase 1+): subscribes to kind 2003 (torrent) and kind 2004 (torrent comment) events on configured relays, indexes them locally, exposes a Torznab-compatible HTTP API.
- Writer (Phase 4+): watches a torrent client (qBittorrent / Transmission / Deluge) for completed downloads, builds and publishes NIP-35 events signed by your npub.
Positioning: same slot as Jackett or Prowlarr. Not a frontend like dtan.xyz; not a relay; not a downloader. Middleware that sits between Nostr and the *arr automation stack.
Non-goals
- No web browse UI for humans in v1. dtan.xyz and nostrudel/torrents already do that. A browse UI may come later as a nice-to-have; it is not the product.
- No new download protocol. BitTorrent is unchanged. Magnet links and
.torrentfiles come out of nzbstr exactly as they go in. - No relay implementation. nzbstr is a relay client. If you want a relay, run strfry.
- No replacement for Sonarr/Radarr/Lidarr. nzbstr sits underneath them.
- No login system, no accounts, no web admin in v1. Config file + API keys.
Architecture
Sonarr / Radarr / Lidarr / Readarr / Prowlarr
|
v Torznab HTTP/XML
|
+-----------+
| nzbstr |
| reader | <-- subscribes to relays via WebSocket
| writer | --> publishes via WebSocket
+-----------+
^ ^
| |
v v
Nostr relays qBittorrent / Transmission / Deluge
(NIP-35 events) (download client)
Tech stack (locked)
These are decisions. Don't litigate them in Phase 1.
| Concern | Choice | Why |
|---|---|---|
| Language | Go 1.22+ | Mature nostr libraries, single static binary, matches existing nostr-poster pattern, systemd-friendly |
| Nostr client | github.com/nbd-wtf/go-nostr |
De facto Go nostr lib, NIP-77 negentropy, NIP-42 AUTH, NIP-46 bunker support |
| Storage | SQLite + FTS5 | Single-file backup, no separate service, FTS5 handles millions of rows fine |
| SQLite driver | modernc.org/sqlite |
Pure Go, no CGO, simpler cross-compile |
| HTTP router | github.com/go-chi/chi/v5 |
Lightweight, good middleware ergonomics |
| Torrent parsing | github.com/anacrolix/torrent/metainfo |
Reference implementation, computes info-hash correctly |
| Config | github.com/knadh/koanf/v2 |
Cleaner than viper, supports YAML+env+flags |
| Logging | log/slog (stdlib) |
Structured, JSON for Loki/journald |
| TMDB | github.com/cyruzin/golang-tmdb |
For metadata enrichment when events lack IDs |
| Deployment | systemd + single binary | Same shape as everything else on his boxes |
Postgres is explicitly not used in v1. SQLite for everything. Reconsider only if scale becomes a real problem (>5M events indexed or >100 concurrent Torznab clients).
Repository layout
nzbstr/
├── cmd/
│ ├── nzbstr/ # main daemon binary
│ │ └── main.go
│ └── nzbstr-cli/ # admin CLI (relay add/remove, publisher trust, db stats)
│ └── main.go
├── internal/
│ ├── config/ # koanf-based config loading
│ ├── db/ # SQLite, migrations, queries
│ │ ├── migrations/
│ │ └── queries.sql # sqlc-style if you go that route, or hand-rolled
│ ├── nostr/
│ │ ├── reader.go # relay subscription loop, NIP-77 negentropy bootstrap
│ │ ├── writer.go # event publishing
│ │ ├── signer.go # local nsec / NIP-49 ncryptsec / NIP-46 bunker
│ │ └── parser.go # kind 2003 -> Torrent struct
│ ├── torznab/
│ │ ├── server.go # chi routes, auth middleware
│ │ ├── caps.go # t=caps response
│ │ ├── search.go # t=search, tvsearch, movie, music, book
│ │ ├── xml.go # torznab XML marshalling
│ │ └── categories.go # newznab category mapping
│ ├── enrich/
│ │ ├── tmdb.go # TMDB lookups for missing IDs
│ │ └── parser.go # parse "Show.Name.S01E02.1080p" into structured fields
│ ├── health/
│ │ ├── tracker.go # UDP/HTTP tracker scrape (optional)
│ │ └── dht.go # DHT peer-count scrape (optional)
│ ├── publisher/
│ │ ├── qbittorrent.go # qBit Web API client
│ │ ├── transmission.go # Transmission RPC client
│ │ └── watcher.go # event loop: on download complete -> publish
│ └── wot/
│ ├── follows.go # build allowed-pubkey set from kind 3 events
│ └── trust.go # per-pubkey trust scoring
├── deploy/
│ ├── nzbstr.service # systemd unit
│ ├── nzbstr.example.yaml # commented config template
│ └── nginx.conf.example # reverse proxy with TLS
├── docs/
│ ├── ARCHITECTURE.md
│ ├── TORZNAB.md # which subset of Torznab is supported
│ └── PUBLISHING.md # how the writer side works
├── go.mod
├── go.sum
├── Makefile
└── README.md
Database schema
Single SQLite file at /var/lib/nzbstr/nzbstr.db. Migrations as numbered SQL files under internal/db/migrations/. Apply on startup; refuse to start on migration error.
-- 001_initial.sql
CREATE TABLE torrents (
event_id TEXT PRIMARY KEY, -- nostr event id (hex)
info_hash TEXT NOT NULL, -- v1 info-hash (hex, lowercase)
pubkey TEXT NOT NULL, -- publisher pubkey (hex)
created_at INTEGER NOT NULL, -- nostr event created_at
ingested_at INTEGER NOT NULL, -- when we saw it
title TEXT NOT NULL,
description TEXT,
size_bytes INTEGER, -- sum of file sizes if no top-level size
category TEXT, -- normalized: movie/tv/music/book/audio/xxx/other
newznab_cat INTEGER, -- 2000/3000/5000/7000/etc
imdb_id TEXT, -- e.g. "tt15239678"
tmdb_id TEXT, -- "movie:693134" or "tv:1396"
tvdb_id TEXT,
season INTEGER, -- parsed from title or i tags
episode INTEGER,
quality TEXT, -- 480p/720p/1080p/2160p/SD/HD/UHD
source TEXT, -- WEB-DL/BluRay/HDTV/REMUX
raw_event TEXT NOT NULL -- full JSON for re-parsing
);
CREATE UNIQUE INDEX idx_torrents_event_id ON torrents(event_id);
CREATE INDEX idx_torrents_info_hash ON torrents(info_hash);
CREATE INDEX idx_torrents_pubkey ON torrents(pubkey);
CREATE INDEX idx_torrents_imdb ON torrents(imdb_id) WHERE imdb_id IS NOT NULL;
CREATE INDEX idx_torrents_tmdb ON torrents(tmdb_id) WHERE tmdb_id IS NOT NULL;
CREATE INDEX idx_torrents_tvdb ON torrents(tvdb_id) WHERE tvdb_id IS NOT NULL;
CREATE INDEX idx_torrents_created ON torrents(created_at DESC);
CREATE INDEX idx_torrents_cat ON torrents(newznab_cat);
-- FTS index for text search
CREATE VIRTUAL TABLE torrents_fts USING fts5(
title, description,
content='torrents',
content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
);
-- triggers to keep FTS in sync (insert/update/delete)
CREATE TRIGGER torrents_ai AFTER INSERT ON torrents BEGIN
INSERT INTO torrents_fts(rowid, title, description) VALUES (new.rowid, new.title, new.description);
END;
CREATE TRIGGER torrents_ad AFTER DELETE ON torrents BEGIN
INSERT INTO torrents_fts(torrents_fts, rowid, title, description) VALUES ('delete', old.rowid, old.title, old.description);
END;
CREATE TRIGGER torrents_au AFTER UPDATE ON torrents BEGIN
INSERT INTO torrents_fts(torrents_fts, rowid, title, description) VALUES ('delete', old.rowid, old.title, old.description);
INSERT INTO torrents_fts(rowid, title, description) VALUES (new.rowid, new.title, new.description);
END;
CREATE TABLE files (
event_id TEXT NOT NULL,
idx INTEGER NOT NULL, -- file order within torrent
path TEXT NOT NULL,
size_bytes INTEGER,
PRIMARY KEY (event_id, idx),
FOREIGN KEY (event_id) REFERENCES torrents(event_id) ON DELETE CASCADE
);
CREATE TABLE trackers (
event_id TEXT NOT NULL,
url TEXT NOT NULL,
PRIMARY KEY (event_id, url),
FOREIGN KEY (event_id) REFERENCES torrents(event_id) ON DELETE CASCADE
);
CREATE TABLE tags (
event_id TEXT NOT NULL,
tag TEXT NOT NULL, -- e.g. "movie", "4k", "REMUX"
PRIMARY KEY (event_id, tag),
FOREIGN KEY (event_id) REFERENCES torrents(event_id) ON DELETE CASCADE
);
CREATE INDEX idx_tags_tag ON tags(tag);
CREATE TABLE publishers (
pubkey TEXT PRIMARY KEY, -- hex
npub TEXT, -- bech32 cached for display
name TEXT, -- from kind 0 if available
trust REAL DEFAULT 0, -- -1.0 .. 1.0
notes TEXT,
blocked INTEGER DEFAULT 0, -- 0/1
torrents_n INTEGER DEFAULT 0,
first_seen INTEGER,
last_seen INTEGER
);
CREATE TABLE relays (
url TEXT PRIMARY KEY,
enabled INTEGER DEFAULT 1,
last_event INTEGER, -- created_at of most recent event from this relay
last_sync INTEGER, -- when we last successfully connected
notes TEXT
);
CREATE TABLE api_keys (
key TEXT PRIMARY KEY, -- random 32-byte hex
label TEXT NOT NULL, -- "sonarr", "radarr", "friend-jane"
created_at INTEGER NOT NULL,
-- visibility filter: which pubkeys this key can see
visibility TEXT NOT NULL DEFAULT 'all', -- 'all' | 'wot' | 'curated'
curation_set TEXT, -- naddr1... of a kind 30004 set, if visibility='curated'
last_used INTEGER
);
CREATE TABLE health (
info_hash TEXT PRIMARY KEY,
seeders INTEGER,
leechers INTEGER,
checked_at INTEGER NOT NULL
);
-- Comments (kind 2004) - lower priority, store but don't surface in Torznab v1
CREATE TABLE comments (
event_id TEXT PRIMARY KEY,
torrent_event_id TEXT NOT NULL,
pubkey TEXT NOT NULL,
created_at INTEGER NOT NULL,
content TEXT NOT NULL,
raw_event TEXT NOT NULL,
FOREIGN KEY (torrent_event_id) REFERENCES torrents(event_id) ON DELETE CASCADE
);
Config file
/etc/nzbstr/config.yaml — load order: defaults → file → env vars (prefix NZBSTR_) → CLI flags.
# nzbstr config
server:
listen: "127.0.0.1:9117" # bind addr; sit behind nginx for TLS
base_url: "https://nzbstr.example.com" # used in Torznab feed links
database:
path: "/var/lib/nzbstr/nzbstr.db"
logging:
level: "info" # debug|info|warn|error
format: "json" # json|text
# Relays to subscribe to for NIP-35 events.
# If empty on first run, defaults to a sane starter set.
relays:
- "wss://relay.damus.io"
- "wss://nos.lol"
- "wss://relay.primal.net"
- "wss://nostr.mom"
- "wss://relay.snort.social"
- "wss://sovbit.host" # eric's own relay
# add more
# Initial backfill via NIP-77 negentropy. Set false to start from "now" only.
negentropy_bootstrap: true
backfill_days: 365 # don't go further back than this
# Curation
curation:
# If true, only ingest events from pubkeys in your follow graph (within follow_depth hops).
wot_only: false
follow_depth: 2
# Always allow these pubkeys regardless of WoT
allowlist:
- "npub1..."
# Always block these
blocklist:
- "npub1..."
# Auto-subscribe to these curation sets (kind 30004 naddr)
curation_sets:
- "naddr1..."
# TMDB enrichment (optional; without it, only events with imdb/tmdb i-tags are searchable by ID)
tmdb:
enabled: true
api_key: "${TMDB_API_KEY}"
cache_ttl: "168h"
# Health scraping (optional)
health:
enabled: false # off by default; rude to private trackers
method: "dht" # dht|tracker|both
refresh_interval: "30m"
# Writer side - publishing your own torrents to nostr
publisher:
enabled: false # off until you set it up explicitly
signer:
mode: "bunker" # local|ncryptsec|bunker
bunker_uri: "bunker://..." # for NIP-46
ncryptsec: "" # for ncryptsec mode
nsec: "" # for local mode (NOT RECOMMENDED)
outbox_relays:
- "wss://sovbit.host"
- "wss://relay.damus.io"
- "wss://nos.lol"
# Where to watch for completed downloads
client:
type: "qbittorrent" # qbittorrent|transmission|deluge|watch_dir
qbittorrent:
url: "http://127.0.0.1:8080"
username: "admin"
password: "${QBIT_PASSWORD}"
# Only publish torrents tagged with this category (so you don't accidentally publish everything)
category: "publish-nostr"
watch_dir:
path: "/var/lib/nzbstr/watch"
# Auto-enrich title parsing -> TMDB lookup before publishing
enrich_before_publish: true
API keys
Bootstrap via CLI:
nzbstr-cli apikey create --label sonarr --visibility wot
# prints: <key>
Sonarr config: paste key in. Each *arr instance gets its own.
Torznab API surface
Minimum viable surface for full *arr compatibility. All endpoints under /api.
Auth
Query param: apikey=<key>. Required on every endpoint except /health. Missing or bad key returns 401 with a Torznab <error code="100"/>.
GET /api?t=caps
Capabilities document. Sonarr/Radarr hit this on add to learn supported categories and search modes. Return XML like:
<?xml version="1.0" encoding="UTF-8"?>
<caps>
<server version="0.1.0" title="nzbstr" strapline="Nostr-native Torznab"
email="" url="https://nzbstr.example.com/" image=""/>
<limits max="100" default="50"/>
<searching>
<search available="yes" supportedParams="q"/>
<tv-search available="yes" supportedParams="q,season,ep,imdbid,tvdbid,tmdbid"/>
<movie-search available="yes" supportedParams="q,imdbid,tmdbid"/>
<music-search available="yes" supportedParams="q,artist,album,year"/>
<audio-search available="yes" supportedParams="q,artist,album,year"/>
<book-search available="yes" supportedParams="q,author,title"/>
</searching>
<categories>
<category id="2000" name="Movies">
<subcat id="2030" name="Movies/SD"/>
<subcat id="2040" name="Movies/HD"/>
<subcat id="2045" name="Movies/UHD"/>
<subcat id="2050" name="Movies/BluRay"/>
<subcat id="2060" name="Movies/3D"/>
</category>
<category id="3000" name="Audio">
<subcat id="3010" name="Audio/MP3"/>
<subcat id="3030" name="Audio/Audiobook"/>
<subcat id="3040" name="Audio/Lossless"/>
</category>
<category id="5000" name="TV">
<subcat id="5030" name="TV/SD"/>
<subcat id="5040" name="TV/HD"/>
<subcat id="5045" name="TV/UHD"/>
<subcat id="5070" name="TV/Anime"/>
</category>
<category id="7000" name="Books">
<subcat id="7020" name="Books/EBook"/>
<subcat id="7030" name="Books/Comics"/>
</category>
</categories>
</caps>
Don't get creative. Copy structure from an existing Torznab indexer. Sonarr will reject the indexer outright if <categories> is wrong shape.
GET /api?t=search&q=<query>&cat=<id>,<id>&limit=&offset=&apikey=
Generic search. q is FTS5 query against title+description. Returns Torznab RSS:
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:torznab="http://torznab.com/schemas/2015/feed">
<channel>
<atom:link rel="self" type="application/rss+xml"/>
<title>nzbstr</title>
<description>Nostr NIP-35 torrent index</description>
<link>https://nzbstr.example.com</link>
<language>en-us</language>
<category>search</category>
<item>
<title>Some.Show.S01E02.2160p.WEB-DL.x265-GRP</title>
<guid isPermaLink="false">nostr:nevent1...</guid>
<link>magnet:?xt=urn:btih:HASH&dn=...&tr=...</link>
<comments>https://nzbstr.example.com/torrent/nevent1...</comments>
<pubDate>Tue, 12 May 2026 18:30:00 +0000</pubDate>
<size>15234567890</size>
<description><![CDATA[ ... ]]></description>
<category>5045</category>
<enclosure url="magnet:?xt=urn:btih:HASH..." length="15234567890" type="application/x-bittorrent"/>
<torznab:attr name="category" value="5045"/>
<torznab:attr name="category" value="5000"/>
<torznab:attr name="size" value="15234567890"/>
<torznab:attr name="infohash" value="HASH"/>
<torznab:attr name="magneturl" value="magnet:?xt=urn:btih:HASH..."/>
<torznab:attr name="seeders" value="42"/>
<torznab:attr name="peers" value="50"/>
<torznab:attr name="downloadvolumefactor" value="0"/>
<torznab:attr name="uploadvolumefactor" value="1"/>
<torznab:attr name="tvdbid" value="355567"/>
<torznab:attr name="imdbid" value="15239678"/>
<torznab:attr name="tmdbid" value="693134"/>
</item>
<!-- more items -->
</channel>
</rss>
GET /api?t=tvsearch&q=&tvdbid=&imdbid=&tmdbid=&season=&ep=&cat=&apikey=
TV search. Prefers structured ID matches (tvdbid, imdbid, tmdbid) over q. If season and/or ep provided, filter by parsed season/episode columns.
GET /api?t=movie&q=&imdbid=&tmdbid=&cat=&apikey=
Movie search. Same shape.
GET /api?t=music&q=&artist=&album=&year=&cat=&apikey=
Music search.
GET /api?t=audio&q=&artist=&album=&year=&cat=&apikey=
Audio search (audiobooks etc).
GET /api?t=book&q=&author=&title=&cat=&apikey=
Book search.
GET /health
No auth. Returns JSON {status, version, relays_connected, events_total, last_event_at}. For Prometheus scrape compatibility, also expose /metrics (text exposition format).
NIP-35 event handling
Reference shape (kind 2003):
{
"kind": 2003,
"pubkey": "<hex>",
"created_at": 1715000000,
"content": "<long description, may contain newlines>",
"tags": [
["title", "Some.Show.S01E02.2160p.WEB-DL.x265-GRP"],
["x", "abcdef...0123"],
["file", "Some.Show.S01E02.mkv", "15234567890"],
["tracker", "udp://tracker.opentrackr.org:1337"],
["tracker", "http://tracker.example.org/announce"],
["i", "tcat:video,tv,4k"],
["i", "newznab:5045"],
["i", "imdb:tt15239678"],
["i", "tmdb:tv:693134"],
["i", "tvdb:355567"],
["t", "tv"],
["t", "4k"]
]
}
Parser rules
- Reject events without a valid
["x", <40-hex>]info-hash tag. Log + drop. - Reject events without at least one
["title", ...]or fall back tocontentfirst line if missing. size_bytes= sum offiletag sizes (parse position 2 as integer).trackertags accumulate intotrackerstable.itags drive ID matching: parse prefixesimdb:,tmdb:movie:,tmdb:tv:,tvdb:,tcat:,newznab:.newznab_catcomes from["i", "newznab:NNNN"]if present, else inferred fromtcat:path (see category map below).ttags accumulate intotagstable for filtering.- Validate signature on ingest (go-nostr handles this). Drop if invalid.
Magnet construction
magnet:?xt=urn:btih:<info_hash>
&dn=<url-encoded title>
&tr=<url-encoded tracker 1>
&tr=<url-encoded tracker 2>
...
Always include trackers from the event plus a fixed list of public DHT trackers as fallback (configurable).
Category mapping (tcat → newznab)
Hand-rolled map. Add entries as you encounter them. Defaults below; extend liberally.
| tcat path | newznab |
|---|---|
video,movie |
2000 |
video,movie,sd |
2030 |
video,movie,hd |
2040 |
video,movie,4k or video,movie,uhd |
2045 |
video,movie,bluray or video,movie,remux |
2050 |
video,tv |
5000 |
video,tv,sd |
5030 |
video,tv,hd |
5040 |
video,tv,4k or video,tv,uhd |
5045 |
video,tv,anime |
5070 |
audio,music |
3000 |
audio,music,lossless |
3040 |
audio,audiobook |
3030 |
book |
7000 |
book,ebook |
7020 |
book,comic |
7030 |
| anything else | 8000 (Other) |
Title parser
For events lacking IMDB/TMDB/TVDB tags, parse the title to extract:
- Show/movie name
- Year (4-digit pattern)
- Season (Sxx, xx digits)
- Episode (Exx)
- Quality (480p|576p|720p|1080p|2160p|SD|HD|UHD)
- Source (WEB-DL|WEBRip|BluRay|BDRip|HDTV|DVDRip|REMUX)
- Codec (x264|x265|HEVC|AV1|XViD)
- Release group (after final
-)
Use a port of one of the existing parsers (e.g. guessit from Python land is the reference). For Go specifically, look at github.com/middelink/go-parse-torrent-name and github.com/megalol/parsetorrentname and fork if needed. Don't write this from scratch unless you must.
After parsing, optionally hit TMDB's search/tv or search/movie endpoint with the cleaned name + year to backfill imdb_id/tmdb_id/tvdb_id. Cache results aggressively (TMDB IDs don't change).
Web of Trust filter
When curation.wot_only: true:
- On startup and every N hours, fetch the operator's own kind 3 (Follow List) event.
- For each followed pubkey, optionally fetch their kind 3 (depth 2). Cache.
- Build
allowed_pubkeysset: operator + follows + (follows-of-follows if depth ≥ 2) +allowlist, minusblocklist. - Reject any incoming NIP-35 event from a pubkey not in the set.
The operator's pubkey is derived from the publisher signer config if present, or set explicitly in curation.operator_pubkey.
Phase plan
Each phase has explicit acceptance criteria. Don't move to the next phase until current passes.
Phase 0 — bootstrap (1 evening)
- Repo scaffolded with the layout above
cmd/nzbstr/main.goboots, parses config, opens DB, applies migrations- systemd unit installs cleanly
/healthreturns 200 with version- Logging goes to journald in JSON
Acceptance: systemctl start nzbstr works; journalctl -u nzbstr shows clean startup; curl localhost:9117/health returns expected JSON.
Phase 1 — reader, basic Torznab (1 weekend)
- Subscribes to configured relays for kind 2003
- Parses, validates, stores events into SQLite
- Implements
t=capsandt=search(generic FTS5 over title) - Returns valid Torznab RSS with magnet links
- Sonarr can add nzbstr as a Torznab indexer and run a test search successfully
Acceptance: Add nzbstr to Sonarr → "Test" succeeds → manual search for a known popular release returns results with valid magnets that load in qBittorrent.
Phase 2 — full *arr compatibility (1 weekend)
t=tvsearch,t=movie,t=music,t=audio,t=bookendpoints- Structured ID matching (imdbid/tmdbid/tvdbid)
- Newznab category normalization complete
- Title parser fills in season/episode/quality/source when missing
- TMDB enrichment for events without ID tags
Acceptance: Sonarr automatically finds a known recent episode by tvdbid+season+ep with no manual intervention; Radarr finds a known movie by imdbid; Lidarr finds an album by MusicBrainz fields. End-to-end: episode airs → Sonarr queries → nzbstr returns → Sonarr sends to qBittorrent → download completes → Plex picks it up.
Phase 3 — curation (1-2 weekends)
- WoT filter active when configured
- NIP-32 label ingestion (kind 1985)
- NIP-51 set subscription (kind 30004)
- Per-API-key visibility filter
nzbstr-clicommands for publisher trust management- Block/mute lists honored
Acceptance: Two API keys configured, one "all", one "wot-only" → same query returns different result counts and different publishers. Subscribing to a curation set adds those events to the "curated" visibility.
Phase 4 — writer / publisher (2 weekends)
- NIP-46 bunker connection working
- qBittorrent integration: poll for completed torrents in tagged category
- Build kind 2003 event from torrent metadata
- TMDB enrichment of own publishes
- Publish to configured outbox relays
- Stored in local DB as if ingested
Acceptance: Add a torrent to qBittorrent with category publish-nostr, wait for download to complete → within 60 seconds, the event appears in your own DB and on dtan.xyz.
Phase 5 — Usenet/Blossom binary bridge (longer horizon)
This is propose-a-NIP territory. Punt to a separate spec doc after Phase 4 is solid. The basic shape:
- Sister kind (say, 2005 — must claim, check current NIP registry first) for "Blossom binary release"
- Same tag surface as NIP-35 minus
x/trackerplusblobtags pointing to Blossom blob hashes on listed servers - Downloader sidecar that fetches blobs from configured Blossom servers, reassembles, hands to media library
- This is the Nostr-native Usenet replacement
Don't start Phase 5 until Phase 4 is rock solid and ideally adopted by at least a couple of other people running nzbstr.
Things to flag for Eric
- NIP-46 bunker on a public-facing seedbox is the right answer security-wise; consider running a self-hosted bunker on UTS-01 with the nzbstr daemon as a client. Don't put nsec on the seedbox.
- Title parsing is the part most likely to suck. Budget more time than you think. The reference implementations all have edge cases.
- TMDB rate limits are real (50 req/sec). Implement a circuit breaker.
- NIP-77 negentropy bootstrap against six relays for a year of events is slow on first run. Show progress in logs. Consider a "fresh start" mode that skips backfill entirely.
- The Sonarr
<categories>block in caps must be exactly right or Sonarr silently rejects the indexer with no useful error. Copy from a working Jackett/Prowlarr indefinitely. - Don't scrape private trackers for seeder counts. That'll get a publisher's IP burned. Default
health.enabled: false. - Backup the SQLite file, not just relay subscription state. Use SQLite's online backup API or wrap
sqlite3 .backup.
systemd unit (deploy/nzbstr.service)
[Unit]
Description=nzbstr — Nostr-native Torznab indexer
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=nzbstr
Group=nzbstr
ExecStart=/usr/local/bin/nzbstr --config /etc/nzbstr/config.yaml
Restart=on-failure
RestartSec=5
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/nzbstr
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictRealtime=true
RestrictNamespaces=true
StandardOutput=journal
StandardError=journal
SyslogIdentifier=nzbstr
[Install]
WantedBy=multi-user.target
Makefile (sketch)
.PHONY: build test clean install run lint
BINARY := nzbstr
CLI := nzbstr-cli
VERSION := $(shell git describe --tags --always --dirty)
LDFLAGS := -ldflags "-X main.version=$(VERSION) -s -w"
build:
go build $(LDFLAGS) -o ./bin/$(BINARY) ./cmd/nzbstr
go build $(LDFLAGS) -o ./bin/$(CLI) ./cmd/nzbstr-cli
test:
go test -race ./...
lint:
golangci-lint run
install: build
install -D -m 0755 ./bin/$(BINARY) /usr/local/bin/$(BINARY)
install -D -m 0755 ./bin/$(CLI) /usr/local/bin/$(CLI)
install -D -m 0644 ./deploy/nzbstr.service /etc/systemd/system/nzbstr.service
install -D -m 0640 ./deploy/nzbstr.example.yaml /etc/nzbstr/config.yaml
run:
go run ./cmd/nzbstr --config ./deploy/nzbstr.example.yaml
clean:
rm -rf ./bin
Open questions for the operator
These should be decided before Phase 1 starts. Default in parens if punted.
- Single-user or multi-tenant from day one? (single-user; multi-tenant comes for free via API keys in Phase 3)
- Embed a web browse UI eventually, or keep it pure middleware? (keep pure; build a separate thin frontend later if needed)
- Support kind 2004 comments in search results? (no in v1, store but don't surface)
- Tracker scraping for seeder counts: opt-in via DHT only? (yes, DHT only, opt-in)
- NIP-50 search relays for query offload instead of local FTS? (no, local FTS is faster and more flexible)
- Support fetching blob from Blossom for events that include
["url", ...]non-magnet links? (deferred to Phase 5) - Should the writer side also publish kind 1063 (NIP-94 File Metadata) for individual files, or just kind 2003? (just 2003 in v1)
- Rate limit Torznab queries per API key? (yes, 60/min default, configurable)
Reference material
- NIP-35 spec:
https://github.com/nostr-protocol/nips/blob/master/35.md - NIP-46 (remote signing):
https://github.com/nostr-protocol/nips/blob/master/46.md - NIP-77 (negentropy sync):
https://github.com/nostr-protocol/nips/blob/master/77.md - NIP-32 (labeling):
https://github.com/nostr-protocol/nips/blob/master/32.md - NIP-51 (lists):
https://github.com/nostr-protocol/nips/blob/master/51.md - Torznab spec:
https://torznab.github.io/spec-1.3-draft/index.html - Newznab categories:
https://github.com/Prowlarr/Prowlarr/wiki/Indexer-Categories - dtan source for behavior reference:
https://git.v0l.io/Kieran/dtan - Example NIP-35 implementer (Jackett):
https://github.com/Jackett/Jackett/pull/16416 - go-nostr:
https://github.com/nbd-wtf/go-nostr
Done criteria for "v1.0"
- All of Phases 1-4 acceptance criteria pass
- nzbstr has been running on a real seedbox for 30 days without crashing or needing intervention
- At least one external user (someone other than the operator) is running an instance and reporting back
- README is good enough that someone new can deploy in under an hour
- A blog post / nostr long-form (kind 30023) explaining what it is and how to run it exists
- Listed in the awesome-nostr README and the relevant *arr community resources