Homebrew RSS reader server
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 138 lines 4.7 kB view raw
1mod api; 2mod config; 3mod db; 4mod fetcher; 5mod server; 6 7use anyhow::Result; 8use clap::{Parser, Subcommand}; 9use std::path::PathBuf; 10 11#[derive(Parser)] 12#[command(name = "slurp", about = "Headless RSS aggregator with Miniflux API")] 13struct Cli { 14 #[command(subcommand)] 15 command: Command, 16} 17 18#[derive(Subcommand)] 19enum Command { 20 /// Start the server and fetcher 21 Serve { 22 #[arg(short, long, default_value = "slurp.toml")] 23 config: PathBuf, 24 }, 25 /// Generate a random API token 26 Token, 27 /// Import feeds from an OPML file into the database 28 Import { 29 opml_path: PathBuf, 30 #[arg(short, long, default_value = "slurp.toml")] 31 config: PathBuf, 32 }, 33} 34 35#[tokio::main] 36async fn main() -> Result<()> { 37 tracing_subscriber::fmt::init(); 38 39 let cli = Cli::parse(); 40 41 match cli.command { 42 Command::Serve { config: path } => { 43 let config = config::Config::load(&path)?; 44 let pool = db::init_pool(&config.database.path).await?; 45 46 // One-time legacy bootstrap (only if DB is empty and config has groups/feeds) 47 db::bootstrap_from_legacy_config_if_empty(&pool, &config).await?; 48 49 // Set up refresh trigger channel 50 let (refresh_tx, refresh_rx) = tokio::sync::broadcast::channel::<Option<i64>>(16); 51 52 let state = server::AppState { 53 pool: pool.clone(), 54 api_key: config.server.api_key.clone(), 55 refresh_trigger: Some(refresh_tx), 56 }; 57 58 let app = server::router(state); 59 let listener = tokio::net::TcpListener::bind(&config.server.bind).await?; 60 tracing::info!("listening on {}", config.server.bind); 61 62 tokio::spawn(fetcher::run(pool, config.fetcher.interval_minutes, refresh_rx)); 63 64 axum::serve(listener, app).await?; 65 } 66 Command::Token => { 67 // Generate a random 32-byte hex token 68 use std::fmt::Write; 69 let mut buf = [0u8; 32]; 70 getrandom::fill(&mut buf).expect("failed to generate random bytes"); 71 let mut hex = String::with_capacity(64); 72 for byte in &buf { 73 write!(hex, "{byte:02x}").unwrap(); 74 } 75 println!("{hex}"); 76 } 77 Command::Import { opml_path, config } => { 78 let cfg = config::Config::load(&config)?; 79 let pool = db::init_pool(&cfg.database.path).await?; 80 81 let opml_content = std::fs::read_to_string(&opml_path)?; 82 let opml_doc = opml::OPML::from_str(&opml_content)?; 83 84 let mut feed_count = 0u64; 85 86 fn process_outlines<'a>( 87 outlines: &'a [opml::Outline], 88 group_name: &'a str, 89 pool: &'a sqlx::SqlitePool, 90 feed_count: &'a mut u64, 91 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> 92 { 93 Box::pin(async move { 94 // Ensure category exists 95 sqlx::query("INSERT OR IGNORE INTO groups (name) VALUES (?)") 96 .bind(group_name) 97 .execute(pool) 98 .await?; 99 100 for outline in outlines { 101 if let Some(ref url) = outline.xml_url { 102 let group_id: (i64,) = 103 sqlx::query_as("SELECT id FROM groups WHERE name = ?") 104 .bind(group_name) 105 .fetch_one(pool) 106 .await?; 107 sqlx::query( 108 "INSERT OR IGNORE INTO feeds (url, group_id) VALUES (?, ?)", 109 ) 110 .bind(url) 111 .bind(group_id.0) 112 .execute(pool) 113 .await?; 114 *feed_count += 1; 115 } else if !outline.outlines.is_empty() { 116 let child_group = outline.text.as_str(); 117 process_outlines(&outline.outlines, child_group, pool, feed_count) 118 .await?; 119 } 120 } 121 Ok(()) 122 }) 123 } 124 125 process_outlines( 126 &opml_doc.body.outlines, 127 "Uncategorized", 128 &pool, 129 &mut feed_count, 130 ) 131 .await?; 132 133 println!("Imported {feed_count} feeds into the database"); 134 } 135 } 136 137 Ok(()) 138}