From 801779d84a5cea81c5055fae5bc3e8af65756f71 Mon Sep 17 00:00:00 2001 From: enki Date: Tue, 19 May 2026 23:41:05 -0700 Subject: [PATCH] Publishers: click-to-copy npub; settings descriptions; remove mute; fix qBit cookie - Clicking a publisher row fills the override form with their npub and copies it to clipboard - Settings page: section-level descriptions explaining what each setting does and how it affects kindexr - Removed Mute button from publishers table (effectively same as Block; already-muted rows still show as blocked) - qBittorrent: fix session cookie parsing for newer versions that use QBT_SID_= instead of SID= - qBittorrent: empty categories now fetches all completed torrents instead of returning nothing - Update README with Deluge/qBit publishing docs and Settings UI table --- README.md | 73 +++++++++++++++++++++++++++++++----- src/publisher/qbittorrent.rs | 35 +++++++++++++---- src/ui/mod.rs | 12 ++++++ src/ui/publishers.rs | 27 ++++++------- src/ui/settings.rs | 38 ++++++++++++++++++- 5 files changed, 153 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 44a9e62..f7a433b 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,9 @@ A Nostr-native Torznab indexer. Subscribes to NIP-35 torrent events on the Nostr - Serves indexed content as a Torznab API endpoint for *arr apps - Enriches metadata via TMDB (movies and TV shows get proper IDs for *arr matching) - Filters publishers using Web of Trust (WoT) — trust scores derived from your Nostr follow graph, with manual vouch/block/mute controls -- Optionally publishes torrents back to Nostr from a connected qBittorrent instance +- Publishes torrents back to Nostr from a connected **qBittorrent** or **Deluge** instance - Fetches publisher profiles (kind 0) so you see names and avatars instead of raw pubkeys -- Web UI at `/ui` — dashboard, indexed content browser, publisher management, publish queue - +- Web UI at `/ui` — dashboard, indexed content browser, publisher management, publish queue, settings ## Quick start @@ -52,7 +51,7 @@ sudo journalctl -u kindexr -f ```sh curl http://localhost:9117/health -# {"status":"ok","version":"...","db_ok":true,"relays_configured":3,"relays_connected":3,"uptime_seconds":0} +# {"status":"ok","version":"...","db_ok":true,"relays_configured":10,"relays_connected":10,"uptime_seconds":0} ``` ### Add to Sonarr/Radarr @@ -60,7 +59,49 @@ curl http://localhost:9117/health 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` +- API key: generate at `/ui/settings` or with `kindexr-cli apikey create --label sonarr` + +## Publishing to Nostr + +kindexr can publish torrents back to the Nostr network as kind 2003 events. Two torrent clients are supported: + +### Deluge + +Enable the Deluge poller in config or via the Settings UI. kindexr connects to the Deluge Web UI JSON-RPC API, polls for completed torrents, and publishes them. Set a label filter (e.g. `publish-nostr`) to only publish specific torrents, or leave labels empty to publish everything completed. + +```yaml +publisher: + enabled: true + nsec: "nsec1..." + deluge: + enabled: true + url: "http://127.0.0.1:8112" + password: "your-deluge-web-password" + poll_interval_secs: 60 + labels: ["publish-nostr"] # empty = all completed +``` + +### qBittorrent + +```yaml +publisher: + enabled: true + nsec: "nsec1..." + qbittorrent: + url: "http://127.0.0.1:8080" + username: "admin" + password: "adminadmin" + poll_interval_secs: 60 + categories: ["publish-nostr"] +``` + +### Manual publish + +Drop .torrent files into the publish queue via CLI: + +```sh +kindexr-cli publish --from /path/to/torrents/ +``` ## CLI @@ -69,9 +110,9 @@ Add kindexr as a Torznab indexer: kindexr-cli apikey create --label sonarr kindexr-cli apikey list -# Publisher identity (for publishing back to Nostr) -kindexr-cli identity init # generate a fresh keypair -kindexr-cli identity init --nsec # import existing key +# Publisher identity +kindexr-cli identity init # generate a fresh keypair +kindexr-cli identity init --nsec # import existing key kindexr-cli identity info # Enqueue torrent files for publishing @@ -82,7 +123,7 @@ kindexr-cli publish --from /path/to/torrents/ See `deploy/kindexr.example.yaml` for a fully commented configuration reference. -Config is loaded in order: **defaults → YAML file → environment variables** (`KINDEXR_` prefix). +Config is loaded in order: **defaults → YAML file → environment variables** (`KINDEXR_` prefix) → **Settings UI** (stored in SQLite, applied at startup). Example env override: @@ -90,6 +131,18 @@ Example env override: KINDEXR_LOGGING_LEVEL=debug kindexr --config /etc/kindexr/config.yaml ``` +## Web UI + +Available at `http://localhost:9117/ui`: + +| Page | Path | What it shows | +|---|---|---| +| Dashboard | `/ui` | Relay status, ingest stats, publish queue | +| Content | `/ui/content` | Browsable index of indexed torrents | +| Publishers | `/ui/publishers` | Publisher list with WoT trust levels | +| Queue | `/ui/queue` | Publish queue and history | +| Settings | `/ui/settings` | All config options, API key management | + ## Development ```sh @@ -102,7 +155,7 @@ For a local dev run without installing: ```sh cp deploy/kindexr.example.yaml kindexr.dev.yaml -# edit kindexr.dev.yaml — set database.path to /tmp/kindexr-dev.db, disable publisher +# edit kindexr.dev.yaml ./target/release/kindexr --config kindexr.dev.yaml # UI at http://localhost:9117/ui ``` diff --git a/src/publisher/qbittorrent.rs b/src/publisher/qbittorrent.rs index d86321f..c435281 100644 --- a/src/publisher/qbittorrent.rs +++ b/src/publisher/qbittorrent.rs @@ -49,33 +49,52 @@ impl QbClient { .await .context("qbittorrent login")?; - // Parse SID from Set-Cookie header - let sid = resp.headers() + // Return the full "Name=Value" cookie string — older qBit uses "SID=", newer uses "QBT_SID_=" + let cookie = resp.headers() .get("set-cookie") .and_then(|v| v.to_str().ok()) .and_then(|s| { s.split(';').find_map(|part| { let part = part.trim(); - part.strip_prefix("SID=").map(|v| v.to_owned()) + if part.starts_with("SID=") || part.starts_with("QBT_SID_") { + return Some(part.to_owned()); + } + None }) }); - match sid { + match cookie { Some(s) if !s.is_empty() => Ok(s), _ => bail!("qbittorrent login failed: no SID cookie (check credentials)"), } } - /// Fetch completed torrents in one of the watched categories. + /// Fetch completed torrents. If categories is empty, returns all completed torrents. pub async fn completed_in_categories(&self, categories: &[String]) -> anyhow::Result> { let sid = self.login().await?; - let mut results = Vec::new(); + if categories.is_empty() { + let resp = self.http + .get(format!("{}/api/v2/torrents/info", self.url)) + .query(&[("filter", "completed")]) + .header("Cookie", &sid) + .send() + .await + .context("qbittorrent torrents/info")?; + if !resp.status().is_success() { + bail!("qbittorrent torrents/info returned {}", resp.status()); + } + let torrents: Vec = resp.json().await.context("parse torrents/info")?; + debug!("qbittorrent: {} completed (all categories)", torrents.len()); + return Ok(torrents); + } + + let mut results = Vec::new(); for cat in categories { let resp = self.http .get(format!("{}/api/v2/torrents/info", self.url)) .query(&[("filter", "completed"), ("category", cat.as_str())]) - .header("Cookie", format!("SID={sid}")) + .header("Cookie", &sid) .send() .await .context("qbittorrent torrents/info")?; @@ -98,7 +117,7 @@ impl QbClient { let resp = self.http .get(format!("{}/api/v2/torrents/files", self.url)) .query(&[("hash", hash)]) - .header("Cookie", format!("SID={sid}")) + .header("Cookie", &sid) .send() .await .context("qbittorrent torrents/files")?; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b4a000f..f6cd71b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -192,6 +192,15 @@ pub fn pagination(base: &str, page: i64, total_pages: i64) -> String { parts.join("\n") } +/// Convert a 64-char hex pubkey to npub bech32. Returns hex on failure. +pub fn hex_to_npub(hex: &str) -> String { + use nostr::ToBech32; + nostr::PublicKey::from_hex(hex) + .ok() + .and_then(|pk| pk.to_bech32().ok()) + .unwrap_or_else(|| hex.to_owned()) +} + /// Try to normalise a pubkey string (npub bech32 or 64-char hex) to lowercase hex. pub fn parse_pubkey(s: &str) -> Option { let s = s.trim(); @@ -229,6 +238,7 @@ h1 { font-size: 1.1rem; font-weight: 600; margin-bottom: 1.25rem; } .stat .val { font-size: 1.75rem; font-weight: 700; color: var(--accent); line-height: 1; margin-bottom: .15rem; } .stat .lbl { font-size: .68rem; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); } .section { margin-bottom: 2rem; } +.section-desc { font-size: 12px; color: var(--muted); line-height: 1.6; margin: -.25rem 0 .75rem; } .section-title { font-size: .7rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin-bottom: .75rem; font-weight: 600; } .table-wrap { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; } @@ -287,6 +297,8 @@ input.wide { flex: 1; min-width: 260px; } border-radius: 8px; padding: .6rem 1rem; margin-bottom: 1rem; font-size: 13px; } form.ifrm { display: inline; } .pub-identity { display: flex; align-items: center; gap: .5rem; } +.pub-identity-click { cursor: pointer; } +.pub-identity-click:hover { opacity: .75; } .avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; } .avatar-placeholder { width: 28px; height: 28px; border-radius: 50%; background: var(--bg3); border: 1px solid var(--border); display: inline-flex; align-items: center; diff --git a/src/ui/publishers.rs b/src/ui/publishers.rs index dc8d809..3e3d09f 100644 --- a/src/ui/publishers.rs +++ b/src/ui/publishers.rs @@ -5,7 +5,7 @@ use axum::{ }; use serde::Deserialize; use crate::{db, AppState}; -use super::{page, err_page, esc, fmt_ts_ago, short_pubkey, parse_pubkey, pagination}; +use super::{page, err_page, esc, fmt_ts_ago, short_pubkey, parse_pubkey, hex_to_npub, pagination}; const PAGE_SIZE: i64 = 50; @@ -57,7 +57,6 @@ async fn render(state: &AppState, params: &ListParams) -> anyhow::ResultNeutral (score 0.5) - @@ -75,10 +74,8 @@ async fn render(state: &AppState, params: &ListParams) -> anyhow::ResultNo publishers seen yet".into() } else { rows.iter().map(|r| { - let trust_badge = if r.blocked { + let trust_badge = if r.blocked || r.muted { r#"blocked"# - } else if r.muted { - r#"muted"# } else if r.trust >= 0.8 { r#"trusted"# } else if r.trust <= 0.0 { @@ -128,9 +125,10 @@ async fn render(state: &AppState, params: &ListParams) -> anyhow::Result{avatar} {display}"#, - pk_esc = esc(pk), + r#"
{avatar} {display}
"#, + npub_esc = esc(&npub), ); let block_btn = if r.blocked { @@ -138,11 +136,6 @@ async fn render(state: &AppState, params: &ListParams) -> anyhow::Result"#) }; - let mute_btn = if !r.muted { - format!(r#"
"#) - } else { - String::new() - }; format!( r#" @@ -152,7 +145,7 @@ async fn render(state: &AppState, params: &ListParams) -> anyhow::Result{events} {reports} {last} - {block_btn} {mute_btn} + {block_btn} "#, score = r.trust, events = r.torrents_n, @@ -166,6 +159,14 @@ async fn render(state: &AppState, params: &ListParams) -> anyhow::ResultPublishers {flash} + {vouch_form}
{total} known publishers
diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 88c9234..dfa96e2 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -366,34 +366,70 @@ function copyKey() { let body = format!( r#"

Settings

+

+ Settings saved here are stored in the database and applied on the next restart. + They override values in your YAML config file. Sections marked + restart required + take effect after restarting kindexr. +

{flash}
API Keys
+

API keys authenticate requests from Sonarr, Radarr, Lidarr, Readarr, and Prowlarr. + Each app gets its own key so you can revoke access individually without affecting other clients. + The key is entered in the indexer settings of each app — label it so you remember which is which.

{apikeys_section}
Torrent clients
+

kindexr can watch a torrent client for newly completed downloads and automatically + publish them to Nostr as kind 2003 events. Only one client needs to be configured. + Use labels (Deluge) or categories (qBittorrent) to control which torrents get published — + leave them blank to publish everything that finishes downloading.

{deluge_section} {qbittorrent_section}
Network
+

Controls how kindexr connects to Nostr relays and external APIs (TMDB). + Direct mode uses your server's default network. Tor and I2P route all outbound traffic through + the respective anonymity network — the proxy must already be running on the same machine. + SOCKS5 lets you point at any proxy, including PIA or Mullvad's built-in proxies.

{network_section}
Relays
+

Nostr relays kindexr subscribes to for incoming kind 2003 torrent events. + More relays means broader coverage but higher bandwidth and connection overhead. + kindexr tracks the last event seen per relay so it only requests new events on reconnect — + removing and re-adding a relay will re-fetch its full history up to the backfill window.

{relays_section}
Curation & Trust
+

Controls which publishers kindexr trusts and indexes. + The operator pubkey is your Nostr identity — kindexr reads your kind 3 follow list + from the relay network every 24 hours and uses it to score publishers by how many hops away + they are from you. Publishers outside your follow graph get a lower trust score. + Enable WoT only to hard-drop events from anyone not in your graph. + Use the Publishers page to manually override trust for specific pubkeys.

{curation_section}
Ingest
+

Controls how much history kindexr requests when first connecting to a relay. + The backfill window is applied per relay — if you add a new relay, kindexr will request events + going back this many days. Existing relays only receive events newer than the last one seen, + so changing this setting only affects relays added after the change.

{ingest_section}
-
TMDB
+
TMDB enrichment
+

When enabled, kindexr looks up movie and TV show metadata from The Movie Database + for events that don't already have a TMDB ID tag. This helps Sonarr and Radarr match torrents + to their library entries. Requires a free TMDB API read-access token — get one at + themoviedb.org. + Lookups are cached for 7 days to avoid hitting rate limits.

{tmdb_section}
{js}"#