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_<port>= 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
This commit is contained in:
2026-05-19 23:41:05 -07:00
parent e5451bbe83
commit 801779d84a
5 changed files with 153 additions and 32 deletions
+63 -10
View File
@@ -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 <nsec1...> # import existing key
# Publisher identity
kindexr-cli identity init # generate a fresh keypair
kindexr-cli identity init --nsec <nsec1...> # 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
```
+27 -8
View File
@@ -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_<port>="
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<Vec<QbTorrent>> {
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<QbTorrent> = 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")?;
+12
View File
@@ -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<String> {
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;
+14 -13
View File
@@ -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::Result<Html<St
<option value="neutral">Neutral (score 0.5)</option>
<option value="untrusted">Untrusted (score 0.0)</option>
<option value="block">Block</option>
<option value="mute">Mute</option>
</select>
<button type="submit" class="btn-accent">Apply</button>
</div>
@@ -75,10 +74,8 @@ async fn render(state: &AppState, params: &ListParams) -> anyhow::Result<Html<St
"<tr><td colspan='7' class='empty'>No publishers seen yet</td></tr>".into()
} else {
rows.iter().map(|r| {
let trust_badge = if r.blocked {
let trust_badge = if r.blocked || r.muted {
r#"<span class="badge badge-err">blocked</span>"#
} else if r.muted {
r#"<span class="badge badge-warn">muted</span>"#
} else if r.trust >= 0.8 {
r#"<span class="badge badge-ok">trusted</span>"#
} else if r.trust <= 0.0 {
@@ -128,9 +125,10 @@ async fn render(state: &AppState, params: &ListParams) -> anyhow::Result<Html<St
}
};
let npub = hex_to_npub(pk);
let identity_cell = format!(
r#"<div class="pub-identity" title="{pk_esc}">{avatar} {display}</div>"#,
pk_esc = esc(pk),
r#"<div class="pub-identity pub-identity-click" data-npub="{npub_esc}" title="Click to copy npub and fill override form" onclick="fillVouch(this)">{avatar} {display}</div>"#,
npub_esc = esc(&npub),
);
let block_btn = if r.blocked {
@@ -138,11 +136,6 @@ async fn render(state: &AppState, params: &ListParams) -> anyhow::Result<Html<St
} else {
format!(r#"<form class="ifrm" method="POST" action="/ui/publishers/{pk}/block"><button class="btn-danger" type="submit">Block</button></form>"#)
};
let mute_btn = if !r.muted {
format!(r#"<form class="ifrm" method="POST" action="/ui/publishers/{pk}/mute"><button class="btn-warn" type="submit">Mute</button></form>"#)
} else {
String::new()
};
format!(
r#"<tr>
@@ -152,7 +145,7 @@ async fn render(state: &AppState, params: &ListParams) -> anyhow::Result<Html<St
<td class="mono">{events}</td>
<td class="mono">{reports}</td>
<td class="mono">{last}</td>
<td style="white-space:nowrap">{block_btn} {mute_btn}</td>
<td style="white-space:nowrap">{block_btn}</td>
</tr>"#,
score = r.trust,
events = r.torrents_n,
@@ -166,6 +159,14 @@ async fn render(state: &AppState, params: &ListParams) -> anyhow::Result<Html<St
let body = format!(
r#"<h1>Publishers</h1>
{flash}
<script>
function fillVouch(el) {{
const npub = el.dataset.npub;
const input = document.querySelector('input[name="pubkey"]');
if (input) {{ input.value = npub; input.focus(); }}
navigator.clipboard.writeText(npub).catch(() => {{}});
}}
</script>
{vouch_form}
<div class="section">
<div class="section-title">{total} known publishers</div>
+37 -1
View File
@@ -366,34 +366,70 @@ function copyKey() {
let body = format!(
r#"<h1>Settings</h1>
<p style="color:var(--muted);font-size:13px;margin-bottom:1.2rem">
Settings saved here are stored in the database and applied on the next restart.
They override values in your YAML config file. Sections marked
<span class="badge badge-warn" style="font-size:10px;vertical-align:middle">restart required</span>
take effect after restarting kindexr.
</p>
{flash}
<div class="section">
<div class="section-title">API Keys</div>
<p class="section-desc">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.</p>
{apikeys_section}
</div>
<div class="section">
<div class="section-title">Torrent clients</div>
<p class="section-desc">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.</p>
{deluge_section}
{qbittorrent_section}
</div>
<div class="section">
<div class="section-title">Network</div>
<p class="section-desc">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.</p>
{network_section}
</div>
<div class="section">
<div class="section-title">Relays</div>
<p class="section-desc">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.</p>
{relays_section}
</div>
<div class="section">
<div class="section-title">Curation &amp; Trust</div>
<p class="section-desc">Controls which publishers kindexr trusts and indexes.
The <strong>operator pubkey</strong> 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 <strong>WoT only</strong> to hard-drop events from anyone not in your graph.
Use the Publishers page to manually override trust for specific pubkeys.</p>
{curation_section}
</div>
<div class="section">
<div class="section-title">Ingest</div>
<p class="section-desc">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.</p>
{ingest_section}
</div>
<div class="section">
<div class="section-title">TMDB</div>
<div class="section-title">TMDB enrichment</div>
<p class="section-desc">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
<a href="https://www.themoviedb.org/settings/api" target="_blank">themoviedb.org</a>.
Lookups are cached for 7 days to avoid hitting rate limits.</p>
{tmdb_section}
</div>
{js}"#