use std::fs::File;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process;
use std::sync::mpsc;
use anyhow::{anyhow, Context, Result};
use clap::{Arg, Command};
mod config;
mod db;
mod downloads;
mod feeds;
mod keymap;
mod main_controller;
mod opml;
mod play_file;
mod threadpool;
mod types;
mod ui;
use crate::config::Config;
use crate::db::Database;
use crate::feeds::{FeedMsg, PodcastFeed};
use crate::main_controller::{MainController, MainMessage};
use crate::threadpool::Threadpool;
use crate::types::*;
const VERSION: &str = env!("CARGO_PKG_VERSION");
fn main() -> Result<()> {
let args = Command::new(clap::crate_name!())
.version(clap::crate_version!())
.author("Jeff Hughes <jeff.hughes@gmail.com>")
.about(clap::crate_description!())
.arg(Arg::new("config")
.short('c')
.long("config")
.env("SHELLCASTER_CONFIG")
.global(true)
.takes_value(true)
.value_name("FILE")
.help("Sets a custom config file location. Can also be set with environment variable."))
.subcommand(Command::new("sync")
.about("Syncs all podcasts in database")
.arg(Arg::new("quiet")
.short('q')
.long("quiet")
.help("Suppresses output messages to stdout.")))
.subcommand(Command::new("import")
.about("Imports podcasts from an OPML file")
.arg(Arg::new("file")
.short('f')
.long("file")
.takes_value(true)
.value_name("FILE")
.help("Specifies the filepath to the OPML file to be imported. If this flag is not set, the command will read from stdin."))
.arg(Arg::new("replace")
.short('r')
.long("replace")
.takes_value(false)
.help("If set, the contents of the OPML file will replace all existing data in the shellcaster database."))
.arg(Arg::new("quiet")
.short('q')
.long("quiet")
.help("Suppresses output messages to stdout.")))
.subcommand(Command::new("export")
.about("Exports podcasts to an OPML file")
.arg(Arg::new("file")
.short('f')
.long("file")
.takes_value(true)
.value_name("FILE")
.help("Specifies the filepath for where the OPML file will be exported. If this flag is not set, the command will print to stdout.")))
.get_matches();
let config_path = get_config_path(args.value_of("config"))
.unwrap_or_else(|| {
eprintln!("Could not identify your operating system's default directory to store configuration files. Please specify paths manually using config.toml and use `-c` or `--config` flag to specify where config.toml is located when launching the program.");
process::exit(1);
});
let config = Config::new(&config_path)?;
let mut db_path = config_path;
if !db_path.pop() {
return Err(anyhow!("Could not correctly parse the config file location. Please specify a valid path to the config file."));
}
return match args.subcommand() {
Some(("sync", sub_args)) => sync_podcasts(&db_path, config, sub_args),
Some(("import", sub_args)) => import(&db_path, config, sub_args),
Some(("export", sub_args)) => export(&db_path, sub_args),
_ => {
let mut main_ctrl = MainController::new(config, &db_path)?;
main_ctrl.loop_msgs();
main_ctrl.tx_to_ui.send(MainMessage::UiTearDown).unwrap();
main_ctrl.ui_thread.join().unwrap(); Ok(())
}
};
}
fn get_config_path(config: Option<&str>) -> Option<PathBuf> {
return match config {
Some(path) => Some(PathBuf::from(path)),
None => {
let default_config = dirs::config_dir();
match default_config {
Some(mut path) => {
path.push("shellcaster");
path.push("config.toml");
Some(path)
}
None => None,
}
}
};
}
fn sync_podcasts(db_path: &Path, config: Config, args: &clap::ArgMatches) -> Result<()> {
let db_inst = Database::connect(db_path)?;
let podcast_list = db_inst.get_podcasts()?;
if podcast_list.is_empty() {
if !args.is_present("quiet") {
println!("No podcasts to sync.");
}
return Ok(());
}
let threadpool = Threadpool::new(config.simultaneous_downloads);
let (tx_to_main, rx_to_main) = mpsc::channel();
for pod in podcast_list.iter() {
let feed = PodcastFeed::new(Some(pod.id), pod.url.clone(), Some(pod.title.clone()));
feeds::check_feed(feed, config.max_retries, &threadpool, tx_to_main.clone());
}
let mut msg_counter: usize = 0;
let mut failure = false;
while let Some(message) = rx_to_main.iter().next() {
match message {
Message::Feed(FeedMsg::SyncData((pod_id, pod))) => {
let title = pod.title.clone();
let db_result = db_inst.update_podcast(pod_id, pod);
match db_result {
Ok(_) => {
if !args.is_present("quiet") {
println!("Synced {title}");
}
}
Err(_err) => {
failure = true;
eprintln!("Error synchronizing {title}");
}
}
}
Message::Feed(FeedMsg::Error(feed)) => {
failure = true;
match feed.title {
Some(t) => eprintln!("Error retrieving RSS feed for {}.", t),
None => eprintln!("Error retrieving RSS feed."),
}
}
_ => (),
}
msg_counter += 1;
if msg_counter >= podcast_list.len() {
break;
}
}
if failure {
return Err(anyhow!("Process finished with errors."));
} else if !args.is_present("quiet") {
println!("Sync successful.");
}
return Ok(());
}
fn import(db_path: &Path, config: Config, args: &clap::ArgMatches) -> Result<()> {
let xml = match args.value_of("file") {
Some(filepath) => {
let mut f = File::open(filepath)
.with_context(|| format!("Could not open OPML file: {filepath}"))?;
let mut contents = String::new();
f.read_to_string(&mut contents)
.with_context(|| format!("Failed to read from OPML file: {filepath}"))?;
contents
}
None => {
let mut contents = String::new();
std::io::stdin()
.read_to_string(&mut contents)
.with_context(|| "Failed to read OPML file from stdin")?;
contents
}
};
let mut podcast_list = opml::import(xml).with_context(|| {
"Could not properly parse OPML file -- file may be formatted improperly or corrupted."
})?;
if podcast_list.is_empty() {
if !args.is_present("quiet") {
println!("No podcasts to import.");
}
return Ok(());
}
let db_inst = Database::connect(db_path)?;
if args.is_present("replace") {
db_inst
.clear_db()
.with_context(|| "Error clearing database")?;
} else {
let old_podcasts = db_inst.get_podcasts()?;
podcast_list = podcast_list
.into_iter()
.filter(|pod| {
for op in &old_podcasts {
if pod.url == op.url {
return false;
}
}
return true;
})
.collect();
}
if podcast_list.is_empty() {
if !args.is_present("quiet") {
println!("No podcasts to import.");
}
return Ok(());
}
println!("Importing {} podcasts...", podcast_list.len());
let threadpool = Threadpool::new(config.simultaneous_downloads);
let (tx_to_main, rx_to_main) = mpsc::channel();
for pod in podcast_list.iter() {
feeds::check_feed(
pod.clone(),
config.max_retries,
&threadpool,
tx_to_main.clone(),
);
}
let mut msg_counter: usize = 0;
let mut failure = false;
while let Some(message) = rx_to_main.iter().next() {
match message {
Message::Feed(FeedMsg::NewData(pod)) => {
let title = pod.title.clone();
let db_result = db_inst.insert_podcast(pod);
match db_result {
Ok(_) => {
if !args.is_present("quiet") {
println!("Added {title}");
}
}
Err(_err) => {
failure = true;
eprintln!("Error adding {title}");
}
}
}
Message::Feed(FeedMsg::Error(feed)) => {
failure = true;
if let Some(t) = feed.title {
eprintln!("Error retrieving RSS feed: {t}");
} else {
eprintln!("Error retrieving RSS feed");
}
}
_ => (),
}
msg_counter += 1;
if msg_counter >= podcast_list.len() {
break;
}
}
if failure {
return Err(anyhow!("Process finished with errors."));
} else if !args.is_present("quiet") {
println!("Import successful.");
}
return Ok(());
}
fn export(db_path: &Path, args: &clap::ArgMatches) -> Result<()> {
let db_inst = Database::connect(db_path)?;
let podcast_list = db_inst.get_podcasts()?;
let opml = opml::export(podcast_list);
let xml = opml
.to_string()
.map_err(|err| anyhow!(err))
.with_context(|| "Could not create OPML format")?;
match args.value_of("file") {
Some(file) => {
let mut dst = File::create(file)
.with_context(|| format!("Could not create output file: {file}"))?;
dst.write_all(xml.as_bytes())
.with_context(|| format!("Could not copy OPML data to output file: {file}"))?;
}
None => println!("{xml}"),
}
return Ok(());
}