Homebrew RSS reader server
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}