f98e6f8dfa
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.
262 lines
8.8 KiB
Rust
262 lines
8.8 KiB
Rust
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.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<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)
|
||
}
|