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, }, } #[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, }, /// 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.0–1.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 = 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) }