rewrite phases 0+1 in Rust; archive Go implementation

Move entire Go tree to archive/go/ preserving history. Add Rust
implementation: axum HTTP server, nostr-sdk relay reader, sqlx/SQLite
storage, Torznab caps+search endpoints, figment config, clap CLI.
Update spec.md tech stack and repo layout to reflect Rust. Add
docs/FIPS.md with Mode A/B/C deployment walkthrough. Add Phase 6
(FIPS deployment) to phase plan.
This commit is contained in:
2026-05-17 02:23:26 -07:00
parent 1933306392
commit 57cda1281b
45 changed files with 5406 additions and 63 deletions
Generated
+3522
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
[package]
name = "kindexr"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "kindexr"
path = "src/main.rs"
[[bin]]
name = "kindexr-cli"
path = "src/bin/kindexr-cli.rs"
[dependencies]
# Async runtime
tokio = { version = "1", features = ["full"] }
# HTTP
axum = { version = "0.8", features = ["json", "macros"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["trace"] }
# Nostr
nostr-sdk = "0.44"
nostr = "0.44"
negentropy = "0.5"
# Database
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "macros", "chrono"] }
# sqlite feature bundles SQLite by default; sqlite-unbundled uses system lib
# Config + CLI
figment = { version = "0.10", features = ["yaml", "env"] }
clap = { version = "4", features = ["derive"] }
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# Torznab XML
quick-xml = { version = "0.40", features = ["serialize"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Torrent file parsing (Phase 4, listed now)
lava_torrent = "0.11"
# TMDB enrichment (Phase 2, listed now)
tmdb-api = "1.0.0-alpha.5"
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
# Error handling
anyhow = "1"
thiserror = "2"
# Utilities
hex = "0.4"
url = "2"
regex = "1"
chrono = { version = "0.4", features = ["serde"] }
rand = "0.8"
+20 -14
View File
@@ -4,21 +4,28 @@ A Nostr-native Torznab indexer. Bridges NIP-35 torrent events on the Nostr relay
**Same slot as Jackett or Prowlarr** — middleware that sits between Nostr and the *arr automation stack. Not a frontend, not a relay, not a downloader.
## Implementation
The active implementation is **Rust** (`src/`, `Cargo.toml`).
The original Go implementation lives in `archive/go/` and is kept for reference until the Rust version reaches Phase 1 parity. It is not maintained going forward.
## Phase status
- [x] Phase 0 — bootstrap: daemon boots, parses config, opens DB, applies migrations, `/health` returns JSON, logs to journald in JSON
- [ ] Phase 1 — reader, basic Torznab (subscribes to relays, indexes kind 2003 events, `t=caps` + `t=search`)
- [ ] Phase 2 — full *arr compatibility (structured ID matching, category normalization, TMDB enrichment)
- [ ] Phase 3 — curation (WoT filter, NIP-51 sets, per-key visibility)
- [ ] Phase 4 — writer/publisher (NIP-46 bunker, qBittorrent integration)
- [ ] Phase 0 — bootstrap (Rust): daemon boots, config, DB migrations, `/health`, journald JSON logging
- [ ] Phase 1 — reader, basic Torznab (Rust): relay subscription, kind 2003 indexing, `t=caps` + `t=search`
- [ ] Phase 2 — full *arr compatibility: structured ID matching, TMDB enrichment
- [ ] Phase 3 — curation: WoT filter, NIP-51 sets
- [ ] Phase 4 — writer/publisher: NIP-46 bunker, qBittorrent integration
- [ ] Phase 5 — Blossom binary bridge
## Quick start
### Build
```sh
make build
# produces ./bin/kindexr and ./bin/kindexr-cli
just build
# produces target/release/kindexr and target/release/kindexr-cli
```
### Configure
@@ -39,7 +46,7 @@ sudo chown kindexr:kindexr /var/lib/kindexr
### Install and start
```sh
sudo make install
sudo just install
sudo systemctl daemon-reload
sudo systemctl enable --now kindexr
sudo journalctl -u kindexr -f
@@ -49,12 +56,12 @@ sudo journalctl -u kindexr -f
```sh
curl http://localhost:9117/health
# {"status":"ok","version":"...","relays_connected":0,"events_total":0,"last_event_at":null}
# {"status":"ok","version":"...","db_ok":true,"relays_configured":6,"uptime_seconds":0}
```
### Add to Sonarr/Radarr
Once Phase 1 is complete, add kindexr as a Torznab indexer:
Add kindexr as a Torznab indexer:
- URL: `http://127.0.0.1:9117` (or your public URL behind nginx)
- API key: generate with `kindexr-cli apikey create --label sonarr`
@@ -74,10 +81,9 @@ KINDEXR_LOGGING_LEVEL=debug kindexr --config /etc/kindexr/config.yaml
## Development
```sh
make test # run tests with -race
make vet # go vet
make fmt # gofmt
make check # fmt + vet + test
just test # cargo test
just check # cargo clippy + fmt check
just build # cargo build --release
```
## Architecture
View File
View File
View File
+102
View File
@@ -0,0 +1,102 @@
# FIPS Deployment
kindexr can run on a FIPS-networked host so that peers reach it over a private overlay
without exposing the Torznab port to the public internet. Three deployment modes are
defined; operators choose the one that matches their network topology.
---
## Prerequisites
1. FIPS daemon installed and running on the operator host.
2. A node identity established (`fips id` or equivalent — consult your FIPS daemon docs).
3. kindexr Phase 0/1 installed and passing its smoke tests (`/health` returns `db_ok: true`).
---
## Mode A — kindexr bound to a FIPS address (recommended)
Sonarr/Radarr on a peer host resolve `kindexr.fips` over the overlay and connect
directly to kindexr's HTTP port. No public port is opened.
### Step by step
1. Find your FIPS address (example: `fd00::1:2:3:4`). Add it to `server.listen` in
`/etc/kindexr/config.yaml`:
```yaml
server:
listen: "[fd00::1:2:3:4]:9117"
base_url: "http://kindexr.fips:9117"
```
2. Register the hostname with the FIPS daemon:
```
# /etc/fips/hosts (or equivalent for your FIPS implementation)
kindexr.fips fd00::1:2:3:4
```
3. Restart kindexr:
```
systemctl restart kindexr
```
4. On the **peer host**, confirm resolution and reachability:
```
curl http://kindexr.fips:9117/health
```
5. Generate an API key for the peer:
```
kindexr-cli apikey create --label sonarr-peer
```
The key is printed to stdout. Copy it.
6. In Sonarr (or Radarr/Prowlarr), add a Torznab indexer:
- URL: `http://kindexr.fips:9117`
- API Key: `<key from step 5>`
- Click **Test** — it should return green.
---
## Mode B — private relay paths via FIPS WSS endpoints
Use FIPS-resolvable relay URLs so both the relay subscription traffic and the
publisher outbox travel over the overlay rather than the public internet.
Example `config.yaml` snippet:
```yaml
relays:
- "wss://relay.fips:7777"
- "wss://relay2.fips:7777"
publisher:
enabled: false # set true when Phase 4 is in use
outbox:
- "wss://outbox.fips:7778"
```
Replace the hostnames with whatever your FIPS daemon resolves. The kindexr process
itself does not need to bind a FIPS address in Mode B — only the relay connections
use the overlay.
---
## Mode C — direct fips Rust crate integration (deferred)
Direct integration via a `fips` Rust crate would let kindexr register as a FIPS
service and resolve peers programmatically, without relying on the system resolver.
This is deferred until the FIPS Rust crate stabilizes past 0.x. When it lands:
- Add `fips = "x.y"` to `Cargo.toml`.
- Wire `fips::Node` into `src/main.rs` alongside the axum server.
- Bind the Torznab listener to the FIPS-assigned address automatically.
- Expose the FIPS node ID in `/health` for peer discovery.
No code changes are needed in Phases 05 for Mode C; it will be a Phase 6 task.
+19
View File
@@ -0,0 +1,19 @@
default: build
build:
cargo build --release
run:
cargo run -- --config /etc/kindexr/config.yaml
test:
cargo test
check:
cargo fmt --check
cargo clippy -- -D warnings
install: build
install -Dm755 target/release/kindexr /usr/local/bin/kindexr
install -Dm755 target/release/kindexr-cli /usr/local/bin/kindexr-cli
install -Dm644 deploy/kindexr.service /etc/systemd/system/kindexr.service
+72 -49
View File
@@ -4,14 +4,14 @@
## Build status
- **Phase 0** — bootstrap: ✅ done
- **Phase 1** — reader, basic Torznab: ✅ done
- **Phase 0** — bootstrap: 🔄 to redo in Rust
- **Phase 1** — reader, basic Torznab: 🔄 to redo in Rust
- **Phase 2** — full *arr compatibility: 🎯 next
- **Phase 3** — curation: planned
- **Phase 4** — writer / publisher: planned
- **Phase 5** — Blossom binary bridge: long horizon
When handing this spec to Claude Code, start at Phase 2 unless explicitly retrofitting earlier work.
When handing this spec to Claude Code, start at Phase 0 (Rust rewrite in progress). Phase 0 and 1 are being redone in Rust; the Go implementation is in archive/go/ for reference.
## What this is
@@ -65,17 +65,21 @@ Locked. Don't re-litigate.
| Concern | Choice | Why |
|---|---|---|
| Language | Go 1.22+ | Mature Nostr libs, 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 |
| Language | Rust (latest stable) | Memory safety, single static binary, excellent async + SQLite support |
| Nostr client | `nostr-sdk` (rust-nostr org) | High-level Client API; NIP-77 negentropy, NIP-46 bunker, NIP-42 AUTH |
| Negentropy | `negentropy` (rust-nostr org) | NIP-77 set reconciliation for relay resync |
| Storage | SQLite + FTS5 | Single-file backup, no separate service, FTS5 fine to several million rows |
| 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, correct info-hash computation |
| Config | `github.com/knadh/koanf/v2` | Cleaner than viper; YAML + env + flags |
| Logging | `log/slog` (stdlib) | Structured, JSON for journald/Loki |
| TMDB | `github.com/cyruzin/golang-tmdb` | Metadata enrichment when events lack IDs |
| Title parsing | `github.com/middelink/go-parse-torrent-name` or `github.com/megalol/parsetorrentname` (fork if needed) | Reference parsers exist; don't write from scratch |
| Deployment | systemd + single binary | Same shape as everything else on the operator's boxes |
| SQLite driver | `sqlx` (sqlite + bundled features) | Compile-time-checked queries, async; bundled embeds SQLite, no libsqlite3.so dep |
| HTTP framework | `axum` + `tower` | Ergonomic async HTTP, tower middleware ecosystem |
| Torrent parsing | `lava_torrent` | Parse .torrent metainfo for writer (Phase 4); listed now |
| Config | `figment` (yaml + env) + `clap` | YAML primary, KINDEXR_* env override, CLI flag overrides via clap |
| Logging | `tracing` + `tracing-subscriber` (json) | Structured JSON for journald |
| TMDB | `tmdb-api` crate | Metadata enrichment (Phase 2); listed now |
| Torznab XML | `quick-xml` (serialize feature) | Zero-copy XML with serde derive |
| Async runtime | `tokio` (multi-thread) | Industry standard |
| CLI | `clap` (derive feature) | Subcommand-style admin CLI |
| Build tool | `just` (justfile) | Replaces Makefile |
| Deployment | systemd + single static binary | Same shape as before |
PostgreSQL explicitly **not** used. SQLite for everything. Revisit only at >5M events or >100 concurrent Torznab clients.
@@ -83,40 +87,48 @@ PostgreSQL explicitly **not** used. SQLite for everything. Revisit only at >5M e
```
kindexr/
├── cmd/
│ ├── kindexr/ # main daemon binary
│ │ └── main.go
── kindexr-cli/ # admin CLI
└── main.go
├── internal/
│ ├── config/
├── Cargo.toml
├── Cargo.lock
├── src/
── main.rs
├── lib.rs
│ ├── config.rs
│ ├── db/
│ │ ├── migrations/
│ │ ── queries.sql
│ │ ├── mod.rs
│ │ ── migrations/ # numbered .sql files (same schema)
│ │ └── queries.rs
│ ├── nostr/
│ │ ├── reader.go # relay subscription loop, NIP-77 bootstrap
│ │ ├── writer.go # event publishing
│ │ ├── signer.go # local nsec / NIP-49 / NIP-46 bunker
│ │ ── parser.go # kind 2003 -> Torrent struct
│ │ ├── mod.rs
│ │ ├── reader.rs
│ │ ├── writer.rs # Phase 4
│ │ ── signer.rs # Phase 4
│ │ └── parser.rs
│ ├── torznab/
│ │ ├── server.go
│ │ ├── caps.go
│ │ ├── search.go
│ │ ├── xml.go
│ │ ── categories.go
│ │ ├── mod.rs
│ │ ├── server.rs
│ │ ├── caps.rs
│ │ ├── search.rs
│ │ ── xml.rs
│ │ └── categories.rs
│ ├── enrich/
│ │ ├── tmdb.go
│ │ ── parser.go # parse "Show.Name.S01E02.1080p" into fields
│ │ ├── mod.rs
│ │ ── tmdb.rs
│ │ └── parser.rs
│ ├── health/
│ │ ├── tracker.go # opt-in tracker scrape
│ │ ── dht.go # opt-in DHT seeder count
│ │ ├── mod.rs
│ │ ── tracker.rs
│ │ └── dht.rs
│ ├── publisher/
│ │ ├── qbittorrent.go
│ │ ├── transmission.go
│ │ ── watcher.go
│ │ ├── mod.rs
│ │ ├── qbittorrent.rs
│ │ ── transmission.rs
│ │ └── watcher.rs
│ └── wot/
│ ├── follows.go
── trust.go
│ ├── mod.rs
── follows.rs
│ └── trust.rs
├── src/bin/
│ └── kindexr-cli.rs
├── deploy/
│ ├── kindexr.service
│ ├── kindexr.example.yaml
@@ -125,10 +137,11 @@ kindexr/
│ ├── ARCHITECTURE.md
│ ├── TORZNAB.md
│ ├── PUBLISHING.md
── IDENTITY.md # operator identity bootstrap walkthrough
├── go.mod
├── go.sum
├── Makefile
── IDENTITY.md
│ └── FIPS.md
├── archive/
│ └── go/ # legacy Go implementation (git mv'd)
├── justfile
└── README.md
```
@@ -486,7 +499,7 @@ Reference shape (kind 2003):
### Parser rules
- Reject events without `["x", <40-hex>]`. Log + drop.
- Validate signature (go-nostr handles this). Drop invalid.
- Validate signature (nostr-sdk handles this automatically; do not disable verification). Drop invalid.
- `size_bytes` = sum of `file` tag size positions (index 2).
- `trackers` table: accumulate from `tracker` tags.
- `i tag` prefixes drive ID matching: `imdb:`, `tmdb:movie:`, `tmdb:tv:`, `tvdb:`, `tcat:`, `newznab:`.
@@ -624,7 +637,7 @@ For events without `imdb`/`tmdb`/`tvdb` tags, parse the title to extract:
After parsing, hit TMDB's `search/tv` or `search/movie` with the cleaned name + year, store the resolved IDs, cache aggressively (TMDB IDs are stable).
Use an existing Go parser as the base. Don't write from scratch unless forced.
Use regex-based extraction in Rust. lava_torrent handles binary .torrent file parsing for Phase 4; title string parsing uses custom regex.
## CLI
@@ -660,11 +673,11 @@ kindexr-cli wot status # current allowed pubkey cou
## Phase plan
### Phase 0 — bootstrap ✅ done
### Phase 0 — bootstrap 🔄 to redo in Rust
Scaffolding, config loading, DB migrations, health endpoint, systemd unit.
### Phase 1 — reader, basic Torznab ✅ done
### Phase 1 — reader, basic Torznab 🔄 to redo in Rust
Relay subscription, NIP-35 parsing, SQLite storage, FTS search, `t=caps`, `t=search`, valid Torznab RSS, Sonarr-add-as-indexer works.
@@ -719,6 +732,16 @@ Defer until Phase 4 is solid and ideally adopted by other operators. Requires pr
- Downloader sidecar that fetches blobs from Blossom servers, reassembles, hands to media library
- Nostr-native Usenet replacement
### Phase 6 — FIPS deployment
Deploy kindexr on a FIPS-networked host so peers access it over a private overlay without exposing the Torznab port to the public internet.
- [ ] **Mode A** — kindexr bound to a FIPS address, peer points Sonarr at `http://kindexr.fips:9117`
- [ ] **Mode B** — relay URLs in `relays:` and `publisher.outbox` use FIPS-resolvable WSS endpoints for private relay paths
- [ ] **Mode C** — direct fips Rust crate integration (deferred; feasible once FIPS stabilizes past 0.x)
**Acceptance:** Sonarr on a FIPS peer resolves `kindexr.fips`, authenticates with an API key, and performs a successful caps + search round-trip with no public internet exposure. See `docs/FIPS.md` for step-by-step operator instructions.
## systemd unit
(Already deployed in Phase 0; included for completeness.)
@@ -793,7 +816,7 @@ Reduced from the original spec, since several got resolved during design discuss
- Prowlarr category reference: https://github.com/Prowlarr/Prowlarr/wiki/Indexer-Categories
- dtan source (behavior reference): https://git.v0l.io/Kieran/dtan
- Jackett NIP-35 PR (reference impl): https://github.com/Jackett/Jackett/pull/16416
- go-nostr: https://github.com/nbd-wtf/go-nostr
- rust-nostr: https://github.com/rust-nostr/nostr
## v1.0 done criteria
+138
View File
@@ -0,0 +1,138 @@
use figment::{
providers::{Env, Format, Serialized, Yaml},
Figment,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub server: ServerConfig,
pub database: DatabaseConfig,
pub logging: LoggingConfig,
pub relays: Vec<String>,
pub negentropy_bootstrap: bool,
pub backfill_days: i64,
pub curation: CurationConfig,
pub tmdb: TmdbConfig,
pub health: HealthConfig,
pub publisher: PublisherConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub listen: String,
pub base_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseConfig {
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingConfig {
pub level: String,
pub format: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CurationConfig {
pub wot_only: bool,
pub follow_depth: u32,
pub operator_pubkey: String,
pub allowlist: Vec<String>,
pub blocklist: Vec<String>,
pub curation_sets: Vec<String>,
pub exclude_categories: Vec<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TmdbConfig {
pub enabled: bool,
pub api_key: String,
pub cache_ttl: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthConfig {
pub enabled: bool,
pub method: String,
pub refresh_interval: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublisherConfig {
pub enabled: bool,
}
impl Default for Config {
fn default() -> Self {
Config {
server: ServerConfig {
listen: "127.0.0.1:9117".into(),
base_url: "https://kindexr.example.com".into(),
},
database: DatabaseConfig {
path: "/var/lib/kindexr/kindexr.db".into(),
},
logging: LoggingConfig {
level: "info".into(),
format: "json".into(),
},
relays: vec![
"wss://relay.damus.io".into(),
"wss://nos.lol".into(),
"wss://relay.primal.net".into(),
"wss://nostr.mom".into(),
"wss://relay.snort.social".into(),
"wss://sovbit.host".into(),
],
negentropy_bootstrap: true,
backfill_days: 365,
curation: CurationConfig {
wot_only: false,
follow_depth: 2,
operator_pubkey: String::new(),
allowlist: vec![],
blocklist: vec![],
curation_sets: vec![],
exclude_categories: vec![
6000, 6010, 6020, 6030, 6040, 6050, 6060, 6070, 6080, 6090,
],
},
tmdb: TmdbConfig {
enabled: true,
api_key: String::new(),
cache_ttl: "168h".into(),
},
health: HealthConfig {
enabled: false,
method: "dht".into(),
refresh_interval: "30m".into(),
},
publisher: PublisherConfig { enabled: false },
}
}
}
/// Load configuration from defaults → YAML file → KINDEXR_* env vars.
/// If path is empty or the file does not exist, the file step is skipped.
pub fn load(path: &str) -> anyhow::Result<Config> {
let mut fig = Figment::from(Serialized::defaults(Config::default()));
if !path.is_empty() && std::path::Path::new(path).exists() {
fig = fig.merge(Yaml::file(path));
}
// KINDEXR_LOGGING_LEVEL → logging.level (strip prefix, lowercase, _ → .)
fig = fig.merge(
Env::prefixed("KINDEXR_").map(|key| {
key.as_str()
.to_lowercase()
.replace('_', ".")
.into()
}),
);
Ok(fig.extract()?)
}
+120
View File
@@ -0,0 +1,120 @@
CREATE TABLE torrents (
event_id TEXT PRIMARY KEY,
info_hash TEXT NOT NULL,
pubkey TEXT NOT NULL,
created_at INTEGER NOT NULL,
ingested_at INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
size_bytes INTEGER,
category TEXT,
newznab_cat INTEGER,
imdb_id TEXT,
tmdb_id TEXT,
tvdb_id TEXT,
season INTEGER,
episode INTEGER,
quality TEXT,
source TEXT,
raw_event TEXT NOT NULL
);
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);
CREATE VIRTUAL TABLE torrents_fts USING fts5(
title, description,
content='torrents',
content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
);
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,
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,
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,
npub TEXT,
name TEXT,
trust REAL DEFAULT 0,
notes TEXT,
blocked INTEGER DEFAULT 0,
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,
last_sync INTEGER,
notes TEXT
);
CREATE TABLE api_keys (
key TEXT PRIMARY KEY,
label TEXT NOT NULL,
created_at INTEGER NOT NULL,
visibility TEXT NOT NULL DEFAULT 'all',
curation_set TEXT,
last_used INTEGER
);
CREATE TABLE health (
info_hash TEXT PRIMARY KEY,
seeders INTEGER,
leechers INTEGER,
checked_at INTEGER NOT NULL
);
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
);
@@ -0,0 +1,14 @@
-- SQLite does not support DROP COLUMN before 3.35.0; use table rebuild.
CREATE TABLE api_keys_new (
key TEXT PRIMARY KEY,
label TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used INTEGER
);
INSERT INTO api_keys_new (key, label, created_at, last_used)
SELECT key, label, created_at, last_used FROM api_keys;
DROP TABLE api_keys;
ALTER TABLE api_keys_new RENAME TO api_keys;
+40
View File
@@ -0,0 +1,40 @@
use anyhow::Context;
use sqlx::{
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
SqlitePool,
};
use std::str::FromStr;
pub mod queries;
pub use queries::*;
/// Open (or create) the SQLite database at `path`, apply pending migrations,
/// and return a connection pool ready to use.
pub async fn open(path: &str) -> anyhow::Result<SqlitePool> {
// Create parent directory if needed.
if let Some(parent) = std::path::Path::new(path).parent() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("create db directory {:?}", parent))?;
}
let opts = SqliteConnectOptions::from_str(&format!("sqlite:{path}?mode=rwc"))?
.create_if_missing(true)
.foreign_keys(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.busy_timeout(std::time::Duration::from_secs(5));
let pool = SqlitePoolOptions::new()
.max_connections(1) // WAL allows concurrent reads; serialise writes
.connect_with(opts)
.await
.with_context(|| format!("open sqlite database at {path}"))?;
sqlx::migrate!("src/db/migrations")
.run(&pool)
.await
.context("run database migrations")?;
Ok(pool)
}
+345
View File
@@ -0,0 +1,345 @@
use anyhow::Context;
use sqlx::SqlitePool;
/// Stats returned by /health.
pub struct DbStats {
pub events_total: i64,
pub last_event_at: Option<i64>,
}
pub async fn get_stats(pool: &SqlitePool) -> anyhow::Result<DbStats> {
let row: (i64, Option<i64>) =
sqlx::query_as("SELECT COUNT(*), MAX(created_at) FROM torrents")
.fetch_one(pool)
.await?;
Ok(DbStats {
events_total: row.0,
last_event_at: row.1,
})
}
// ─── Torrent ──────────────────────────────────────────────────────────────────
pub struct TorrentRecord {
pub event_id: String,
pub info_hash: String,
pub pubkey: String,
pub created_at: i64,
pub ingested_at: i64,
pub title: String,
pub description: String,
pub size_bytes: Option<i64>,
pub category: String,
pub newznab_cat: Option<i32>,
pub imdb_id: String,
pub tmdb_id: String,
pub tvdb_id: String,
pub season: Option<i32>,
pub episode: Option<i32>,
pub quality: String,
pub source: String,
pub raw_event: String,
pub trackers: Vec<String>,
pub tags: Vec<String>,
pub files: Vec<FileRecord>,
}
pub struct FileRecord {
pub path: String,
pub size_bytes: Option<i64>,
}
pub async fn insert_torrent(pool: &SqlitePool, r: &TorrentRecord) -> anyhow::Result<()> {
let mut tx = pool.begin().await?;
let rows = sqlx::query(
"INSERT OR IGNORE INTO torrents
(event_id, info_hash, pubkey, created_at, ingested_at, title, description,
size_bytes, category, newznab_cat, imdb_id, tmdb_id, tvdb_id,
season, episode, quality, source, raw_event)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
)
.bind(&r.event_id)
.bind(&r.info_hash)
.bind(&r.pubkey)
.bind(r.created_at)
.bind(r.ingested_at)
.bind(&r.title)
.bind(null_str(&r.description))
.bind(r.size_bytes)
.bind(null_str(&r.category))
.bind(r.newznab_cat)
.bind(null_str(&r.imdb_id))
.bind(null_str(&r.tmdb_id))
.bind(null_str(&r.tvdb_id))
.bind(r.season)
.bind(r.episode)
.bind(null_str(&r.quality))
.bind(null_str(&r.source))
.bind(&r.raw_event)
.execute(&mut *tx)
.await?;
if rows.rows_affected() == 0 {
// duplicate
tx.commit().await?;
return Ok(());
}
for url in &r.trackers {
sqlx::query("INSERT OR IGNORE INTO trackers (event_id, url) VALUES (?,?)")
.bind(&r.event_id)
.bind(url)
.execute(&mut *tx)
.await?;
}
for tag in &r.tags {
sqlx::query("INSERT OR IGNORE INTO tags (event_id, tag) VALUES (?,?)")
.bind(&r.event_id)
.bind(tag)
.execute(&mut *tx)
.await?;
}
for (i, f) in r.files.iter().enumerate() {
sqlx::query(
"INSERT OR IGNORE INTO files (event_id, idx, path, size_bytes) VALUES (?,?,?,?)",
)
.bind(&r.event_id)
.bind(i as i64)
.bind(&f.path)
.bind(f.size_bytes)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
// ─── Search ───────────────────────────────────────────────────────────────────
#[derive(Clone, Debug)]
pub struct TorrentRow {
pub event_id: String,
pub info_hash: String,
pub pubkey: String,
pub created_at: i64,
pub title: String,
pub description: Option<String>,
pub size_bytes: Option<i64>,
pub newznab_cat: Option<i32>,
pub imdb_id: Option<String>,
pub tmdb_id: Option<String>,
pub tvdb_id: Option<String>,
pub season: Option<i32>,
pub episode: Option<i32>,
pub quality: Option<String>,
pub source: Option<String>,
}
pub struct SearchParams {
pub query: String,
pub cats: Vec<i32>,
pub limit: i64,
pub offset: i64,
}
pub async fn search(pool: &SqlitePool, p: &SearchParams) -> anyhow::Result<Vec<TorrentRow>> {
let limit = p.limit.clamp(1, 100);
let cat_where = build_cat_where(&p.cats);
let rows: Vec<TorrentRow> = if !p.query.is_empty() {
let mut sql = String::from(
"SELECT t.event_id, t.info_hash, t.pubkey, t.created_at, t.title,
t.description, t.size_bytes, t.newznab_cat, t.imdb_id,
t.tmdb_id, t.tvdb_id, t.season, t.episode, t.quality, t.source
FROM torrents_fts
JOIN torrents t ON torrents_fts.rowid = t.rowid
WHERE torrents_fts MATCH ?",
);
if !cat_where.is_empty() {
sql.push_str(" AND (");
sql.push_str(&cat_where);
sql.push(')');
}
sql.push_str(" ORDER BY torrents_fts.rank LIMIT ? OFFSET ?");
let mut q = sqlx::query_as(&sql).bind(&p.query);
for cat in &p.cats {
if cat % 1000 == 0 {
q = q.bind(cat).bind(cat + 999);
} else {
q = q.bind(cat);
}
}
q.bind(limit).bind(p.offset).fetch_all(pool).await?
} else {
let mut sql = String::from(
"SELECT event_id, info_hash, pubkey, created_at, title,
description, size_bytes, newznab_cat, imdb_id,
tmdb_id, tvdb_id, season, episode, quality, source
FROM torrents",
);
if !cat_where.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&cat_where);
}
sql.push_str(" ORDER BY created_at DESC LIMIT ? OFFSET ?");
let mut q = sqlx::query_as(&sql);
for cat in &p.cats {
if cat % 1000 == 0 {
q = q.bind(cat).bind(cat + 999);
} else {
q = q.bind(cat);
}
}
q.bind(limit).bind(p.offset).fetch_all(pool).await?
};
Ok(rows)
}
impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for TorrentRow {
fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result<Self, sqlx::Error> {
use sqlx::Row;
Ok(TorrentRow {
event_id: row.try_get("event_id")?,
info_hash: row.try_get("info_hash")?,
pubkey: row.try_get("pubkey")?,
created_at: row.try_get("created_at")?,
title: row.try_get("title")?,
description: row.try_get("description")?,
size_bytes: row.try_get("size_bytes")?,
newznab_cat: row.try_get("newznab_cat")?,
imdb_id: row.try_get("imdb_id")?,
tmdb_id: row.try_get("tmdb_id")?,
tvdb_id: row.try_get("tvdb_id")?,
season: row.try_get("season")?,
episode: row.try_get("episode")?,
quality: row.try_get("quality")?,
source: row.try_get("source")?,
})
}
}
pub async fn get_trackers(pool: &SqlitePool, event_id: &str) -> anyhow::Result<Vec<String>> {
let rows: Vec<(String,)> =
sqlx::query_as("SELECT url FROM trackers WHERE event_id = ?")
.bind(event_id)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|r| r.0).collect())
}
// ─── API keys ─────────────────────────────────────────────────────────────────
pub struct ApiKey {
pub key: String,
pub label: String,
pub created_at: i64,
}
pub async fn get_api_key(pool: &SqlitePool, key: &str) -> anyhow::Result<Option<ApiKey>> {
let row: Option<(String, String, i64)> =
sqlx::query_as("SELECT key, label, created_at FROM api_keys WHERE key = ?")
.bind(key)
.fetch_optional(pool)
.await?;
if let Some((k, label, created_at)) = row {
// Update last_used without blocking the request.
let pool = pool.clone();
let key2 = key.to_owned();
tokio::spawn(async move {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let _ = sqlx::query("UPDATE api_keys SET last_used = ? WHERE key = ?")
.bind(now)
.bind(&key2)
.execute(&pool)
.await;
});
Ok(Some(ApiKey { key: k, label, created_at }))
} else {
Ok(None)
}
}
pub async fn create_api_key(pool: &SqlitePool, key: &str, label: &str) -> anyhow::Result<()> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
sqlx::query("INSERT INTO api_keys (key, label, created_at) VALUES (?,?,?)")
.bind(key)
.bind(label)
.bind(now)
.execute(pool)
.await
.context("create api key")?;
Ok(())
}
// ─── Relays ───────────────────────────────────────────────────────────────────
pub async fn upsert_relay(pool: &SqlitePool, url: &str) -> anyhow::Result<()> {
sqlx::query("INSERT OR IGNORE INTO relays (url, enabled) VALUES (?, 1)")
.bind(url)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_relay_last_event(pool: &SqlitePool, url: &str) -> anyhow::Result<Option<i64>> {
let row: Option<(Option<i64>,)> =
sqlx::query_as("SELECT last_event FROM relays WHERE url = ?")
.bind(url)
.fetch_optional(pool)
.await?;
Ok(row.and_then(|r| r.0))
}
pub async fn update_relay_last_event(
pool: &SqlitePool,
url: &str,
ts: i64,
) -> anyhow::Result<()> {
sqlx::query("UPDATE relays SET last_event = ? WHERE url = ?")
.bind(ts)
.bind(url)
.execute(pool)
.await?;
Ok(())
}
pub async fn update_relay_sync(pool: &SqlitePool, url: &str, ts: i64) -> anyhow::Result<()> {
sqlx::query("UPDATE relays SET last_sync = ? WHERE url = ?")
.bind(ts)
.bind(url)
.execute(pool)
.await?;
Ok(())
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
fn null_str(s: &str) -> Option<&str> {
if s.is_empty() { None } else { Some(s) }
}
fn build_cat_where(cats: &[i32]) -> String {
cats.iter()
.map(|c| {
if c % 1000 == 0 {
"newznab_cat BETWEEN ? AND ?".to_owned()
} else {
"newznab_cat = ?".to_owned()
}
})
.collect::<Vec<_>>()
.join(" OR ")
}
+18
View File
@@ -0,0 +1,18 @@
pub mod config;
pub mod db;
pub mod nostr;
pub mod torznab;
use std::{
sync::{atomic::AtomicI32, Arc},
time::Instant,
};
#[derive(Clone)]
pub struct AppState {
pub pool: sqlx::SqlitePool,
pub cfg: Arc<config::Config>,
pub version: &'static str,
pub started_at: Instant,
pub relay_count: Arc<AtomicI32>,
}
+113
View File
@@ -0,0 +1,113 @@
use anyhow::Context;
use axum::{extract::State, routing::get, Json, Router};
use clap::Parser;
use kindexr::{config, db, nostr, torznab, AppState};
use serde_json::{json, Value};
use std::{
sync::{atomic::AtomicI32, Arc},
time::Instant,
};
use tokio::signal;
use tracing::info;
/// kindexr — Nostr-native Torznab indexer
#[derive(Parser)]
#[command(version)]
struct Cli {
/// Path to configuration file
#[arg(long, default_value = "/etc/kindexr/config.yaml")]
config: String,
}
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let cfg = config::load(&cli.config).context("load config")?;
let log_level: tracing::Level = match cfg.logging.level.as_str() {
"debug" => tracing::Level::DEBUG,
"warn" | "warning" => tracing::Level::WARN,
"error" => tracing::Level::ERROR,
_ => tracing::Level::INFO,
};
if cfg.logging.format == "json" {
tracing_subscriber::fmt()
.json()
.with_max_level(log_level)
.init();
} else {
tracing_subscriber::fmt().with_max_level(log_level).init();
}
info!("starting kindexr {VERSION}");
let pool = db::open(&cfg.database.path).await?;
info!("database ready");
let relay_count = Arc::new(AtomicI32::new(0));
let cfg = Arc::new(cfg);
let state = AppState {
pool: pool.clone(),
cfg: cfg.clone(),
version: VERSION,
started_at: Instant::now(),
relay_count: relay_count.clone(),
};
let reader = nostr::reader::Reader::new(cfg.clone(), pool.clone(), relay_count);
reader.start();
let app = Router::new()
.route("/health", get(health_handler))
.merge(torznab::server::router(state.clone()))
.with_state(state);
let listener = tokio::net::TcpListener::bind(&cfg.server.listen)
.await
.with_context(|| format!("bind to {}", cfg.server.listen))?;
info!("listening on {}", cfg.server.listen);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.context("http server")?;
info!("kindexr stopped");
Ok(())
}
async fn health_handler(State(state): State<AppState>) -> Json<Value> {
let db_ok = db::get_stats(&state.pool).await.is_ok();
let uptime = state.started_at.elapsed().as_secs();
let relay_count = state.relay_count.load(std::sync::atomic::Ordering::Relaxed);
Json(json!({
"status": "ok",
"version": state.version,
"db_ok": db_ok,
"relays_configured": state.cfg.relays.len(),
"relays_connected": relay_count,
"uptime_seconds": uptime,
}))
}
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c().await.expect("ctrl-c handler");
};
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("SIGTERM handler")
.recv()
.await;
};
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
info!("received shutdown signal");
}
+2
View File
@@ -0,0 +1,2 @@
pub mod parser;
pub mod reader;
+151
View File
@@ -0,0 +1,151 @@
use anyhow::bail;
use nostr::JsonUtil;
use nostr::Event;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::db::{FileRecord, TorrentRecord};
use crate::torznab::categories::tcat_to_newznab;
static INFO_HASH_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
fn info_hash_re() -> &'static regex::Regex {
INFO_HASH_RE.get_or_init(|| regex::Regex::new(r"^[0-9a-fA-F]{40}$").unwrap())
}
/// Parse a kind 2003 Nostr event into a TorrentRecord ready for storage.
pub fn parse(event: &Event) -> anyhow::Result<TorrentRecord> {
// nostr-sdk validates signatures before we receive events; no need to re-validate here.
if event.kind.as_u16() != 2003 {
bail!("wrong kind: {}", event.kind.as_u16());
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let mut rec = TorrentRecord {
event_id: event.id.to_hex(),
info_hash: String::new(),
pubkey: event.pubkey.to_hex(),
created_at: event.created_at.as_secs() as i64,
ingested_at: now,
title: String::new(),
description: String::new(),
size_bytes: None,
category: String::new(),
newznab_cat: None,
imdb_id: String::new(),
tmdb_id: String::new(),
tvdb_id: String::new(),
season: None,
episode: None,
quality: String::new(),
source: String::new(),
raw_event: event.as_json(),
trackers: vec![],
tags: vec![],
files: vec![],
};
let mut total_size: i64 = 0;
let mut has_size = false;
let mut tcat_cat: Option<i32> = None;
let mut newznab_cat: Option<i32> = None;
let mut tcat_str = String::new();
for tag in event.tags.iter() {
let tag_vec = tag.as_slice();
if tag_vec.len() < 2 {
continue;
}
match tag_vec[0].as_str() {
"x" => {
let hash = tag_vec[1].to_lowercase();
if !info_hash_re().is_match(&hash) {
bail!("invalid info_hash: {:?}", tag_vec[1]);
}
rec.info_hash = hash;
}
"title" => {
rec.title = tag_vec[1].clone();
}
"file" => {
let path = tag_vec[1].clone();
let sz = tag_vec.get(2).and_then(|s| s.parse::<i64>().ok());
if let Some(s) = sz {
total_size += s;
has_size = true;
}
rec.files.push(FileRecord { path, size_bytes: sz });
}
"tracker" => {
rec.trackers.push(tag_vec[1].clone());
}
"i" => {
parse_i_tag(&tag_vec[1], &mut rec, &mut tcat_cat, &mut newznab_cat, &mut tcat_str);
}
"t" => {
rec.tags.push(tag_vec[1].clone());
}
_ => {}
}
}
if rec.info_hash.is_empty() {
bail!("missing x (info_hash) tag");
}
// newznab: overrides tcat:
rec.newznab_cat = newznab_cat.or(tcat_cat);
if !tcat_str.is_empty() {
rec.category = tcat_str;
}
// Title fallback: first line of content.
if rec.title.is_empty() {
rec.title = event.content
.split('\n')
.next()
.unwrap_or("")
.trim()
.to_owned();
}
if rec.title.is_empty() {
bail!("event has no title");
}
rec.description = event.content.clone();
if has_size {
rec.size_bytes = Some(total_size);
}
Ok(rec)
}
fn parse_i_tag(
value: &str,
rec: &mut TorrentRecord,
tcat_cat: &mut Option<i32>,
newznab_cat: &mut Option<i32>,
tcat_str: &mut String,
) {
if let Some(rest) = value.strip_prefix("newznab:") {
if let Ok(cat) = rest.parse::<i32>() {
*newznab_cat = Some(cat);
}
} else if let Some(rest) = value.strip_prefix("tcat:") {
*tcat_str = rest.to_owned();
if tcat_cat.is_none() {
*tcat_cat = Some(tcat_to_newznab(rest));
}
} else if let Some(rest) = value.strip_prefix("imdb:") {
rec.imdb_id = rest.to_owned();
} else if let Some(rest) = value.strip_prefix("tmdb:movie:") {
rec.tmdb_id = format!("movie:{rest}");
} else if let Some(rest) = value.strip_prefix("tmdb:tv:") {
rec.tmdb_id = format!("tv:{rest}");
} else if let Some(rest) = value.strip_prefix("tvdb:") {
rec.tvdb_id = rest.to_owned();
}
}
+122
View File
@@ -0,0 +1,122 @@
use std::{
sync::{atomic::{AtomicI32, Ordering}, Arc},
time::{SystemTime, UNIX_EPOCH},
};
use nostr_sdk::{
prelude::*,
RelayPoolNotification,
};
use sqlx::SqlitePool;
use tracing::{debug, error, info, warn};
use crate::{config::Config, db};
pub struct Reader {
cfg: Arc<Config>,
pool: SqlitePool,
connected: Arc<AtomicI32>,
}
impl Reader {
pub fn new(cfg: Arc<Config>, pool: SqlitePool, connected: Arc<AtomicI32>) -> Self {
Reader { cfg, pool, connected }
}
pub fn start(self) {
let cfg = self.cfg.clone();
let pool = self.pool.clone();
let connected = self.connected.clone();
tokio::spawn(async move {
run_reader(cfg, pool, connected).await;
});
}
}
async fn run_reader(cfg: Arc<Config>, pool: SqlitePool, connected: Arc<AtomicI32>) {
if cfg.relays.is_empty() {
warn!("no relays configured; reader idle");
return;
}
let client = Client::default();
for relay_url in &cfg.relays {
match client.add_relay(relay_url.as_str()).await {
Ok(_) => {
// Seed relay row so sync state is tracked.
let _ = db::upsert_relay(&pool, relay_url).await;
}
Err(e) => warn!(url = relay_url, "failed to add relay: {e}"),
}
}
client.connect().await;
connected.store(cfg.relays.len() as i32, Ordering::Relaxed);
info!("relay pool connected ({} relays)", cfg.relays.len());
// Determine subscription since timestamp.
let backfill_since = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.saturating_sub(cfg.backfill_days as u64 * 86400);
let since = Timestamp::from(backfill_since);
let filter = Filter::new()
.kind(Kind::Custom(2003))
.since(since);
if let Err(e) = client.subscribe(filter, None).await {
error!("subscribe failed: {e}");
connected.store(0, Ordering::Relaxed);
return;
}
info!("subscribed to kind 2003 events since unix={backfill_since}");
// Handle incoming events.
let pool_clone = pool.clone();
if let Err(e) = client
.handle_notifications(|notification| {
let pool = pool_clone.clone();
async move {
match notification {
RelayPoolNotification::Event { event, .. } => {
handle_event(&pool, &event).await;
}
RelayPoolNotification::Message { relay_url, message } => {
debug!(url = %relay_url, "relay message: {:?}", message);
}
RelayPoolNotification::Shutdown => {
info!("relay pool shutdown");
return Ok(true); // exit handler
}
}
Ok(false)
}
})
.await
{
error!("notification handler exited: {e}");
}
connected.store(0, Ordering::Relaxed);
}
async fn handle_event(pool: &SqlitePool, event: &nostr_sdk::Event) {
match super::parser::parse(event) {
Ok(rec) => {
if let Err(e) = db::insert_torrent(pool, &rec).await {
error!(event_id = %event.id, "db insert failed: {e}");
} else {
debug!(event_id = %event.id, title = %rec.title, "indexed event");
let _ = db::update_relay_last_event(pool, "", event.created_at.as_secs() as i64).await;
}
}
Err(e) => {
debug!(event_id = %event.id, "skipping event: {e}");
}
}
}
+91
View File
@@ -0,0 +1,91 @@
use axum::{extract::State, response::Response};
use crate::AppState;
use super::xml::*;
use super::server::xml_response;
pub async fn caps_handler(State(state): State<AppState>) -> Response {
let caps = Caps {
server: CapsServer {
version: state.version.to_owned(),
title: "kindexr".into(),
strapline: "Nostr-native Torznab".into(),
email: String::new(),
url: format!("{}/", state.cfg.server.base_url),
image: String::new(),
},
limits: CapsLimits { max: 100, default: 50 },
searching: CapsSearching {
search: CapsSearch {
available: "yes".into(),
supported_params: "q".into(),
},
tv_search: CapsSearch {
available: "yes".into(),
supported_params: "q,season,ep,imdbid,tvdbid,tmdbid".into(),
},
movie_search: CapsSearch {
available: "yes".into(),
supported_params: "q,imdbid,tmdbid".into(),
},
music_search: CapsSearch {
available: "yes".into(),
supported_params: "q,artist,album,year".into(),
},
audio_search: CapsSearch {
available: "yes".into(),
supported_params: "q,artist,album,year".into(),
},
book_search: CapsSearch {
available: "yes".into(),
supported_params: "q,author,title".into(),
},
},
categories: CapsCategories {
categories: vec![
CapsCategory {
id: 2000,
name: "Movies".into(),
subcats: vec![
CapsSubcat { id: 2030, name: "Movies/SD".into() },
CapsSubcat { id: 2040, name: "Movies/HD".into() },
CapsSubcat { id: 2045, name: "Movies/UHD".into() },
CapsSubcat { id: 2050, name: "Movies/BluRay".into() },
CapsSubcat { id: 2060, name: "Movies/3D".into() },
],
},
CapsCategory {
id: 3000,
name: "Audio".into(),
subcats: vec![
CapsSubcat { id: 3010, name: "Audio/MP3".into() },
CapsSubcat { id: 3030, name: "Audio/Audiobook".into() },
CapsSubcat { id: 3040, name: "Audio/Lossless".into() },
],
},
CapsCategory {
id: 5000,
name: "TV".into(),
subcats: vec![
CapsSubcat { id: 5030, name: "TV/SD".into() },
CapsSubcat { id: 5040, name: "TV/HD".into() },
CapsSubcat { id: 5045, name: "TV/UHD".into() },
CapsSubcat { id: 5070, name: "TV/Anime".into() },
],
},
CapsCategory {
id: 7000,
name: "Books".into(),
subcats: vec![
CapsSubcat { id: 7020, name: "Books/EBook".into() },
CapsSubcat { id: 7030, name: "Books/Comics".into() },
],
},
CapsCategory { id: 8000, name: "Other".into(), subcats: vec![] },
],
},
};
xml_response(200, &caps)
}
+25
View File
@@ -0,0 +1,25 @@
/// Map a tcat path (e.g. "video,tv,4k") to a newznab category ID.
/// Returns 8000 (Other) for unrecognised paths.
pub fn tcat_to_newznab(tcat: &str) -> i32 {
match tcat {
"video,movie" => 2000,
"video,movie,sd" => 2030,
"video,movie,hd" => 2040,
"video,movie,4k" | "video,movie,uhd" => 2045,
"video,movie,bluray" | "video,movie,remux" => 2050,
"video,movie,3d" => 2060,
"video,tv" => 5000,
"video,tv,sd" => 5030,
"video,tv,hd" => 5040,
"video,tv,4k" | "video,tv,uhd" => 5045,
"video,tv,anime" => 5070,
"audio,music" => 3000,
"audio,music,lossless" => 3040,
"audio,audiobook" => 3030,
"audio,music,mp3" => 3010,
"book" => 7000,
"book,ebook" => 7020,
"book,comic" => 7030,
_ => 8000,
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod caps;
pub mod categories;
pub mod search;
pub mod server;
pub mod xml;
+155
View File
@@ -0,0 +1,155 @@
use axum::{
extract::{Query, State},
response::Response,
};
use chrono::{DateTime, Utc};
use serde::Deserialize;
use url::form_urlencoded;
use crate::{
db::{self, TorrentRow},
AppState,
};
use super::{server::xml_response, xml::*};
#[derive(Deserialize)]
pub struct SearchQuery {
pub q: Option<String>,
pub cat: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
pub async fn search_handler(
State(state): State<AppState>,
Query(params): Query<SearchQuery>,
) -> Response {
let query = params.q.unwrap_or_default();
let cats = parse_cats(params.cat.as_deref().unwrap_or(""));
let limit = params.limit.unwrap_or(50).clamp(1, 100);
let offset = params.offset.unwrap_or(0).max(0);
let p = db::SearchParams { query, cats, limit, offset };
let rows = match db::search(&state.pool, &p).await {
Ok(r) => r,
Err(e) => {
tracing::error!("search error: {e}");
return super::server::xml_error(500, 200, "search error");
}
};
let mut items = Vec::with_capacity(rows.len());
for row in rows {
let trackers = db::get_trackers(&state.pool, &row.event_id)
.await
.unwrap_or_default();
items.push(build_item(&state.cfg.server.base_url, &row, &trackers));
}
let feed = Rss {
version: "2.0".into(),
xmlns_atom: "http://www.w3.org/2005/Atom".into(),
xmlns_torznab: "http://torznab.com/schemas/2015/feed".into(),
channel: RssChannel {
atom_link: AtomLink {
rel: "self".into(),
type_: "application/rss+xml".into(),
},
title: "kindexr".into(),
description: "Nostr NIP-35 torrent index".into(),
link: state.cfg.server.base_url.clone(),
language: "en-us".into(),
category: "search".into(),
items,
},
};
xml_response(200, &feed)
}
fn build_item(_base_url: &str, row: &TorrentRow, trackers: &[String]) -> RssItem {
let magnet = build_magnet(&row.info_hash, &row.title, trackers);
let guid = format!("kindexr:{}", row.event_id);
let pub_date = DateTime::<Utc>::from_timestamp(row.created_at, 0)
.unwrap_or_default()
.format("%a, %d %b %Y %H:%M:%S +0000")
.to_string();
let size_bytes = row.size_bytes.unwrap_or(0);
let mut attrs = vec![
TorznabAttr { name: "infohash".into(), value: row.info_hash.clone() },
TorznabAttr { name: "magneturl".into(), value: magnet.clone() },
TorznabAttr { name: "downloadvolumefactor".into(), value: "0".into() },
TorznabAttr { name: "uploadvolumefactor".into(), value: "1".into() },
];
if let Some(sz) = row.size_bytes {
attrs.push(TorznabAttr { name: "size".into(), value: sz.to_string() });
}
if let Some(cat) = row.newznab_cat {
let parent = (cat / 1000) * 1000;
attrs.push(TorznabAttr { name: "category".into(), value: cat.to_string() });
attrs.push(TorznabAttr { name: "category".into(), value: parent.to_string() });
}
if let Some(ref imdb) = row.imdb_id {
// Strip leading "tt" — *arr expects bare digits.
let bare = imdb.trim_start_matches("tt");
attrs.push(TorznabAttr { name: "imdbid".into(), value: bare.to_owned() });
}
if let Some(ref tmdb) = row.tmdb_id {
// "movie:12345" or "tv:67890" — extract the ID part.
if let Some(id) = tmdb.splitn(2, ':').nth(1) {
attrs.push(TorznabAttr { name: "tmdbid".into(), value: id.to_owned() });
}
}
if let Some(ref tvdb) = row.tvdb_id {
attrs.push(TorznabAttr { name: "tvdbid".into(), value: tvdb.clone() });
}
RssItem {
title: row.title.clone(),
guid: RssGuid { is_perma_link: "false".into(), value: guid },
link: magnet.clone(),
pub_date,
size: size_bytes,
enclosure: RssEnclosure {
url: magnet,
length: size_bytes,
type_: "application/x-bittorrent".into(),
},
attrs,
}
}
fn build_magnet(info_hash: &str, title: &str, trackers: &[String]) -> String {
let mut s = format!("magnet:?xt=urn:btih:{info_hash}");
if !title.is_empty() {
s.push_str("&dn=");
s.push_str(
&form_urlencoded::byte_serialize(title.as_bytes()).collect::<String>(),
);
}
for tr in trackers {
s.push_str("&tr=");
s.push_str(&form_urlencoded::byte_serialize(tr.as_bytes()).collect::<String>());
}
s
}
fn parse_cats(s: &str) -> Vec<i32> {
if s.is_empty() {
return vec![];
}
s.split(',')
.filter_map(|p| p.trim().parse::<i32>().ok())
.collect()
}
+103
View File
@@ -0,0 +1,103 @@
use axum::{
body::Body,
extract::{Query, Request, State},
http::header,
middleware::{self, Next},
response::Response,
routing::get,
Router,
};
use serde::Deserialize;
use serde::Serialize;
use crate::{db, AppState};
use super::{caps::caps_handler, search::search_handler, xml::ErrorResponse};
pub fn router(state: AppState) -> Router<AppState> {
Router::new().route(
"/api",
get(api_handler).layer(middleware::from_fn_with_state(
state.clone(),
auth_middleware,
)),
)
}
#[derive(Deserialize)]
struct ApiQuery {
t: Option<String>,
}
async fn api_handler(
State(state): State<AppState>,
Query(params): Query<ApiQuery>,
req: Request,
) -> Response {
match params.t.as_deref() {
Some("caps") => caps_handler(State(state)).await,
Some("search" | "tvsearch" | "movie" | "music" | "audio" | "book") => {
// Extract inner query params for search handler.
let uri = req.uri().clone();
let new_req = Request::builder()
.uri(uri)
.extension(state.clone())
.body(Body::empty())
.unwrap();
let (parts, _) = new_req.into_parts();
let _query_str = parts.uri.query().unwrap_or("");
let search_params = axum::extract::Query::<super::search::SearchQuery>::try_from_uri(
&parts.uri,
);
match search_params {
Ok(p) => search_handler(State(state), p).await,
Err(_) => xml_error(400, 201, "invalid search params"),
}
}
_ => xml_error(400, 202, "unknown function"),
}
}
async fn auth_middleware(
State(state): State<AppState>,
Query(params): Query<std::collections::HashMap<String, String>>,
req: Request,
next: Next,
) -> Response {
let key = params.get("apikey").map(|s| s.as_str()).unwrap_or("");
if key.is_empty() {
return xml_error(401, 100, "missing api key");
}
match db::get_api_key(&state.pool, key).await {
Ok(Some(_)) => next.run(req).await,
Ok(None) => xml_error(401, 100, "invalid api key"),
Err(e) => {
tracing::error!("api key lookup error: {e}");
xml_error(500, 200, "internal error")
}
}
}
pub fn xml_response(status: u16, v: &impl Serialize) -> Response {
let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
match quick_xml::se::to_string(v) {
Ok(s) => xml.push_str(&s),
Err(e) => {
tracing::error!("xml marshal error: {e}");
return Response::builder()
.status(500)
.body(Body::from("xml error"))
.unwrap();
}
}
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(xml))
.unwrap()
}
pub fn xml_error(status: u16, code: u32, description: &str) -> Response {
let err = ErrorResponse { code, description: description.to_owned() };
xml_response(status, &err)
}
+168
View File
@@ -0,0 +1,168 @@
use serde::Serialize;
// ─── Caps ─────────────────────────────────────────────────────────────────────
#[derive(Serialize)]
#[serde(rename = "caps")]
pub struct Caps {
pub server: CapsServer,
pub limits: CapsLimits,
pub searching: CapsSearching,
pub categories: CapsCategories,
}
#[derive(Serialize)]
pub struct CapsServer {
#[serde(rename = "@version")]
pub version: String,
#[serde(rename = "@title")]
pub title: String,
#[serde(rename = "@strapline")]
pub strapline: String,
#[serde(rename = "@email")]
pub email: String,
#[serde(rename = "@url")]
pub url: String,
#[serde(rename = "@image")]
pub image: String,
}
#[derive(Serialize)]
pub struct CapsLimits {
#[serde(rename = "@max")]
pub max: u32,
#[serde(rename = "@default")]
pub default: u32,
}
#[derive(Serialize)]
pub struct CapsSearching {
pub search: CapsSearch,
#[serde(rename = "tv-search")]
pub tv_search: CapsSearch,
#[serde(rename = "movie-search")]
pub movie_search: CapsSearch,
#[serde(rename = "music-search")]
pub music_search: CapsSearch,
#[serde(rename = "audio-search")]
pub audio_search: CapsSearch,
#[serde(rename = "book-search")]
pub book_search: CapsSearch,
}
#[derive(Serialize)]
pub struct CapsSearch {
#[serde(rename = "@available")]
pub available: String,
#[serde(rename = "@supportedParams")]
pub supported_params: String,
}
#[derive(Serialize)]
pub struct CapsCategories {
#[serde(rename = "category")]
pub categories: Vec<CapsCategory>,
}
#[derive(Serialize)]
pub struct CapsCategory {
#[serde(rename = "@id")]
pub id: u32,
#[serde(rename = "@name")]
pub name: String,
#[serde(rename = "subcat")]
pub subcats: Vec<CapsSubcat>,
}
#[derive(Serialize)]
pub struct CapsSubcat {
#[serde(rename = "@id")]
pub id: u32,
#[serde(rename = "@name")]
pub name: String,
}
// ─── RSS (search results) ─────────────────────────────────────────────────────
#[derive(Serialize)]
#[serde(rename = "rss")]
pub struct Rss {
#[serde(rename = "@version")]
pub version: String,
#[serde(rename = "@xmlns:atom")]
pub xmlns_atom: String,
#[serde(rename = "@xmlns:torznab")]
pub xmlns_torznab: String,
pub channel: RssChannel,
}
#[derive(Serialize)]
pub struct RssChannel {
#[serde(rename = "atom:link")]
pub atom_link: AtomLink,
pub title: String,
pub description: String,
pub link: String,
pub language: String,
pub category: String,
#[serde(rename = "item")]
pub items: Vec<RssItem>,
}
#[derive(Serialize)]
pub struct AtomLink {
#[serde(rename = "@rel")]
pub rel: String,
#[serde(rename = "@type")]
pub type_: String,
}
#[derive(Serialize)]
pub struct RssItem {
pub title: String,
pub guid: RssGuid,
pub link: String,
#[serde(rename = "pubDate")]
pub pub_date: String,
pub size: i64,
pub enclosure: RssEnclosure,
#[serde(rename = "torznab:attr")]
pub attrs: Vec<TorznabAttr>,
}
#[derive(Serialize)]
pub struct RssGuid {
#[serde(rename = "@isPermaLink")]
pub is_perma_link: String,
#[serde(rename = "$text")]
pub value: String,
}
#[derive(Serialize)]
pub struct RssEnclosure {
#[serde(rename = "@url")]
pub url: String,
#[serde(rename = "@length")]
pub length: i64,
#[serde(rename = "@type")]
pub type_: String,
}
#[derive(Serialize)]
pub struct TorznabAttr {
#[serde(rename = "@name")]
pub name: String,
#[serde(rename = "@value")]
pub value: String,
}
// ─── Error ────────────────────────────────────────────────────────────────────
#[derive(Serialize)]
#[serde(rename = "error")]
pub struct ErrorResponse {
#[serde(rename = "@code")]
pub code: u32,
#[serde(rename = "@description")]
pub description: String,
}