Files
kindexr/spec.md
T
enki 1b7b70426c feat: Phase 0 bootstrap — kindexr boots, migrates, serves /health
- 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
2026-05-16 18:45:15 -07:00

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 .torrent files 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&amp;dn=...&amp;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 to content first line if missing.
  • size_bytes = sum of file tag sizes (parse position 2 as integer).
  • tracker tags accumulate into trackers table.
  • i tags drive ID matching: parse prefixes imdb:, tmdb:movie:, tmdb:tv:, tvdb:, tcat:, newznab:.
  • newznab_cat comes from ["i", "newznab:NNNN"] if present, else inferred from tcat: path (see category map below).
  • t tags accumulate into tags table 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:

  1. On startup and every N hours, fetch the operator's own kind 3 (Follow List) event.
  2. For each followed pubkey, optionally fetch their kind 3 (depth 2). Cache.
  3. Build allowed_pubkeys set: operator + follows + (follows-of-follows if depth ≥ 2) + allowlist, minus blocklist.
  4. 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.go boots, parses config, opens DB, applies migrations
  • systemd unit installs cleanly
  • /health returns 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=caps and t=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=book endpoints
  • 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-cli commands 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/tracker plus blob tags 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.

  1. Single-user or multi-tenant from day one? (single-user; multi-tenant comes for free via API keys in Phase 3)
  2. Embed a web browse UI eventually, or keep it pure middleware? (keep pure; build a separate thin frontend later if needed)
  3. Support kind 2004 comments in search results? (no in v1, store but don't surface)
  4. Tracker scraping for seeder counts: opt-in via DHT only? (yes, DHT only, opt-in)
  5. NIP-50 search relays for query offload instead of local FTS? (no, local FTS is faster and more flexible)
  6. Support fetching blob from Blossom for events that include ["url", ...] non-magnet links? (deferred to Phase 5)
  7. Should the writer side also publish kind 1063 (NIP-94 File Metadata) for individual files, or just kind 2003? (just 2003 in v1)
  8. 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