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.
This commit is contained in:
2026-05-17 12:43:21 -07:00
parent b6705d5b85
commit f98e6f8dfa
13 changed files with 849 additions and 4 deletions
+112 -1
View File
@@ -1,5 +1,6 @@
use clap::{Parser, Subcommand};
use kindexr::{config, db};
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
@@ -26,6 +27,23 @@ enum Command {
#[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)]
@@ -38,6 +56,18 @@ enum ApikeyCmd {
},
}
#[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
@@ -139,6 +169,87 @@ async fn main() -> anyhow::Result<()> {
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(())