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:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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
@@ -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
@@ -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 & 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}"#
|
||||
|
||||
Reference in New Issue
Block a user