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:
Generated
+3522
File diff suppressed because it is too large
Load Diff
+61
@@ -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"
|
||||
@@ -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
|
||||
|
||||
+102
@@ -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 0–5 for Mode C; it will be a Phase 6 task.
|
||||
@@ -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
|
||||
@@ -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
@@ -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()?)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod parser;
|
||||
pub mod reader;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod caps;
|
||||
pub mod categories;
|
||||
pub mod search;
|
||||
pub mod server;
|
||||
pub mod xml;
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user