Files
kindexr/src/bin/kindexr-cli.rs
T
enki f98e6f8dfa feat: Phase 4 — publisher, qBittorrent watcher, identity CLI
Adds the full writer/publisher stack: NIP-35 event signing and relay
delivery, qBittorrent polling with publish-delay queue, lava_torrent
.torrent file parsing, TMDB inline lookup before publish, and
kindexr-cli identity/publish subcommands.
2026-05-17 12:43:21 -07:00

262 lines
8.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use clap::{Parser, Subcommand};
use kindexr::{config, db, nostr::signer::Signer, publisher::watcher::build_from_torrent_file};
use nostr::ToBech32;
use rand::Rng;
/// kindexr-cli — admin CLI for kindexr
#[derive(Parser)]
#[command(version)]
struct Cli {
/// Path to configuration file
#[arg(long, default_value = "/etc/kindexr/config.yaml")]
config: String,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Manage API keys
Apikey {
#[command(subcommand)]
cmd: ApikeyCmd,
},
/// Manage publishers (trust, blocks, WoT)
Publisher {
#[command(subcommand)]
cmd: PublisherCmd,
},
/// Manage the local signing identity
Identity {
#[command(subcommand)]
cmd: IdentityCmd,
},
/// Enqueue a .torrent file for publishing
Publish {
/// Path to a .torrent file (or a directory to scan for .torrent files)
#[arg(long)]
from: String,
/// Category tag to assign
#[arg(long, default_value = "")]
category: String,
/// Override the publish delay in seconds (default: from config)
#[arg(long)]
delay: Option<u64>,
},
}
#[derive(Subcommand)]
enum ApikeyCmd {
/// Create a new API key
Create {
/// Key label (e.g. sonarr, radarr)
#[arg(long)]
label: String,
},
}
#[derive(Subcommand)]
enum IdentityCmd {
/// Generate a new keypair and store it in the DB
Init {
/// Use an existing nsec instead of generating one
#[arg(long)]
nsec: Option<String>,
},
/// Show the current identity
Info,
}
#[derive(Subcommand)]
enum PublisherCmd {
/// List known publishers
List {
#[arg(long, default_value = "50")]
limit: i64,
#[arg(long, default_value = "0")]
offset: i64,
},
/// Show details for a specific publisher
Info {
pubkey: String,
},
/// Block a publisher (their events will be dropped at ingest)
Block {
pubkey: String,
},
/// Unblock a publisher
Unblock {
pubkey: String,
},
/// Manually set a publisher's trust score (0.01.0)
Trust {
pubkey: String,
#[arg(long)]
score: f64,
},
/// Mute a publisher
Mute {
pubkey: String,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let cfg = config::load(&cli.config)?;
let pool = db::open(&cfg.database.path).await?;
match cli.command {
Command::Apikey { cmd } => match cmd {
ApikeyCmd::Create { label } => {
let key = generate_key();
db::create_api_key(&pool, &key, &label).await?;
println!("{key}");
}
},
Command::Publisher { cmd } => match cmd {
PublisherCmd::List { limit, offset } => {
let rows = db::list_publishers(&pool, limit, offset).await?;
if rows.is_empty() {
println!("no publishers");
return Ok(());
}
println!("{:<66} {:>5} {:>3} {:>6} {:>5} {:>5}",
"pubkey", "trust", "wot", "events", "block", "muted");
println!("{}", "-".repeat(100));
for r in rows {
println!("{:<66} {:>5.2} {:>3} {:>6} {:>5} {:>5}",
r.pubkey,
r.trust,
r.wot_level.map_or("-".into(), |l| l.to_string()),
r.torrents_n,
if r.blocked { "yes" } else { "no" },
if r.muted { "yes" } else { "no" },
);
}
}
PublisherCmd::Info { pubkey } => {
match db::get_publisher(&pool, &pubkey).await? {
None => println!("unknown publisher"),
Some(r) => {
println!("pubkey: {}", r.pubkey);
println!("name: {}", r.name.as_deref().unwrap_or(""));
println!("trust: {:.3}", r.trust);
println!("wot_level: {}", r.wot_level.map_or("".into(), |l| l.to_string()));
println!("blocked: {}", r.blocked);
println!("muted: {}", r.muted);
println!("report_count: {}", r.report_count);
println!("torrents: {}", r.torrents_n);
}
}
}
PublisherCmd::Block { pubkey } => {
db::block_publisher(&pool, &pubkey).await?;
println!("blocked {pubkey}");
}
PublisherCmd::Unblock { pubkey } => {
db::unblock_publisher(&pool, &pubkey).await?;
println!("unblocked {pubkey}");
}
PublisherCmd::Trust { pubkey, score } => {
let score = score.clamp(0.0, 1.0);
db::set_publisher_trust(&pool, &pubkey, score).await?;
println!("set trust={score:.3} for {pubkey}");
}
PublisherCmd::Mute { pubkey } => {
db::mute_publisher(&pool, &pubkey).await?;
println!("muted {pubkey}");
}
},
Command::Identity { cmd } => match cmd {
IdentityCmd::Init { nsec } => {
let (nsec_bech32, pubkey) = match nsec {
Some(ref key) => {
let signer = Signer::from_nsec(key)?;
(key.clone(), signer.pubkey_hex())
}
None => {
let keys = nostr::Keys::generate();
let nsec_str = keys.secret_key().to_bech32()?;
let signer = Signer::from_nsec(&nsec_str)?;
(nsec_str, signer.pubkey_hex())
}
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
db::upsert_identity(&pool, &pubkey, Some(&nsec_bech32), None, now).await?;
println!("pubkey: {pubkey}");
println!("nsec: {nsec_bech32}");
}
IdentityCmd::Info => {
match db::get_identity(&pool).await? {
None => println!("no identity stored — run `identity init` first"),
Some(row) => {
println!("pubkey: {}", row.pubkey);
println!("nsec set: {}", row.nsec.is_some());
println!("bunker_url: {}", row.bunker_url.as_deref().unwrap_or(""));
}
}
}
},
Command::Publish { from, category, delay } => {
let delay_secs = delay.unwrap_or(cfg.publisher.publish_delay_secs);
let path = std::path::Path::new(&from);
let torrent_paths: Vec<String> = if path.is_dir() {
std::fs::read_dir(path)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("torrent"))
.map(|p| p.to_string_lossy().into_owned())
.collect()
} else {
vec![from.clone()]
};
if torrent_paths.is_empty() {
println!("no .torrent files found in {from}");
return Ok(());
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let mut queued = 0usize;
let mut skipped = 0usize;
for tp in &torrent_paths {
let rec = match build_from_torrent_file(tp, &category) {
Ok(r) => r,
Err(e) => {
eprintln!("skip {tp}: {e}");
skipped += 1;
continue;
}
};
if db::is_queued_or_published(&pool, &rec.info_hash).await? {
skipped += 1;
continue;
}
let scheduled_at = now + delay_secs as i64;
db::enqueue(&pool, &rec.info_hash, &rec.title, &category, Some(tp.as_str()), now, scheduled_at).await?;
println!("queued: {} ({})", rec.title, rec.info_hash);
queued += 1;
}
println!("{queued} queued, {skipped} skipped");
}
}
Ok(())
}
fn generate_key() -> String {
let bytes: [u8; 32] = rand::thread_rng().gen();
hex::encode(bytes)
}