diff options
| author | Nathan Jaremko | 2019-03-02 20:30:54 -0500 |
|---|---|---|
| committer | Nathan Jaremko | 2019-03-02 20:30:54 -0500 |
| commit | 9c9fb906cd6c05105d96fa586f429764b12169ca (patch) | |
| tree | 1499d2e38c368952886fb72a01251be47448603c | |
| parent | 2d34d8e812f3e85b0d10f126bf5f334847d0fbca (diff) | |
| download | podcast-9c9fb906cd6c05105d96fa586f429764b12169ca.tar.bz2 | |
0.10.0 - Lots of Improvements
| -rw-r--r-- | CHANGELOG | 5 | ||||
| -rw-r--r-- | Cargo.toml | 6 | ||||
| -rw-r--r-- | src/actions.rs | 485 | ||||
| -rw-r--r-- | src/arg_parser.rs | 96 | ||||
| -rw-r--r-- | src/command_handler.rs | 63 | ||||
| -rw-r--r-- | src/commands.rs | 12 | ||||
| -rw-r--r-- | src/download.rs | 199 | ||||
| -rw-r--r-- | src/main.rs | 42 | ||||
| -rw-r--r-- | src/match_handler.rs | 112 | ||||
| -rw-r--r-- | src/migration_handler.rs | 9 | ||||
| -rw-r--r-- | src/playback.rs | 183 | ||||
| -rw-r--r-- | src/structs.rs | 214 | ||||
| -rw-r--r-- | src/utils.rs | 124 |
13 files changed, 764 insertions, 786 deletions
@@ -1,3 +1,8 @@ +0.10.0 +- Partial re-write of the application to be idiomatic +- Improves performance throughout the application (downloads are also faster) +- Changed from error-chain to failure for error handling + 0.9.1 - Improve unsubscribe messages @@ -1,7 +1,7 @@ [package] name = "podcast" edition = "2018" -version = "0.9.1" +version = "0.10.0" authors = ["Nathan Jaremko <njaremko@gmail.com>"] description = "A command line podcast manager" license = "GPL-3.0" @@ -22,8 +22,8 @@ name = "podcast" chrono = { version = "0.4", features = ["serde"] } clap = "2.32" dirs = "1.0" -error-chain = "0.12" -lazy_static = "1.2" +failure = "0.1" +lazy_static = "1.3" rayon = "1.0" regex = "1.1" reqwest = "0.9" diff --git a/src/actions.rs b/src/actions.rs index c0518c5..a44a9cf 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -1,12 +1,12 @@ +use crate::download; +use crate::errors::*; use crate::structs::*; use crate::utils::*; use std::collections::HashSet; -use std::fs::{self, DirBuilder, File}; -use std::io::{self, BufReader, Read, Write}; -use std::process::Command; +use std::fs::{self, File}; +use std::io::{self, BufReader, BufWriter, Write}; -use crate::errors::*; use clap::App; use clap::Shell; use rayon::prelude::*; @@ -17,77 +17,54 @@ use std::path::PathBuf; use toml; pub fn list_episodes(search: &str) -> Result<()> { - let re = Regex::new(&format!("(?i){}", &search)).chain_err(|| UNABLE_TO_PARSE_REGEX)?; + let re = Regex::new(&format!("(?i){}", &search))?; let path = get_xml_dir()?; create_dir_if_not_exist(&path)?; - for entry in fs::read_dir(&path).chain_err(|| UNABLE_TO_READ_DIRECTORY)? { - let entry = entry.chain_err(|| UNABLE_TO_READ_ENTRY)?; + for entry in fs::read_dir(&path)? { + let entry = entry?; if re.is_match(&entry.file_name().into_string().unwrap()) { - let file = File::open(&entry.path()).chain_err(|| UNABLE_TO_OPEN_FILE)?; - let channel = Channel::read_from(BufReader::new(file)) - .chain_err(|| UNABLE_TO_CREATE_CHANNEL_FROM_FILE)?; + let file = File::open(&entry.path())?; + let channel = Channel::read_from(BufReader::new(file))?; let podcast = Podcast::from(channel); let episodes = podcast.episodes(); let stdout = io::stdout(); let mut handle = stdout.lock(); - for (num, ep) in episodes.iter().enumerate() { - writeln!( - &mut handle, - "({}) {}", - episodes.len() - num, - ep.title() - .chain_err(|| "unable to retrieve episode title")? - ) - .ok(); - } + episodes + .iter() + .filter(|ep| ep.title().is_some()) + .enumerate() + .for_each(|(num, ep)| { + writeln!( + &mut handle, + "({}) {}", + episodes.len() - num, + ep.title().unwrap() + ) + .ok(); + }); return Ok(()); } } Ok(()) } -pub fn download_rss(config: &Config, url: &str) -> Result<()> { - let channel = download_rss_feed(url)?; - let mut download_limit = config.auto_download_limit as usize; - if 0 < download_limit { - println!("Subscribe auto-download limit set to: {}", download_limit); - println!("Downloading episode(s)..."); - let podcast = Podcast::from(channel); - let episodes = podcast.episodes(); - if episodes.len() < download_limit { - download_limit = episodes.len() - } - episodes[..download_limit].par_iter().for_each(|ep| { - if let Err(err) = download(podcast.title(), ep) { - eprintln!("Error downloading {}: {}", podcast.title(), err); - } - }); - } - Ok(()) -} - pub fn update_subscription(sub: &mut Subscription) -> Result<()> { + println!("Updating {}", sub.title); let mut path: PathBuf = get_podcast_dir()?; path.push(&sub.title); create_dir_if_not_exist(&path)?; let mut titles = HashSet::new(); - for entry in fs::read_dir(&path).chain_err(|| UNABLE_TO_READ_DIRECTORY)? { - let unwrapped_entry = &entry.chain_err(|| UNABLE_TO_READ_ENTRY)?; + for entry in fs::read_dir(&path)? { + let unwrapped_entry = &entry?; titles.insert(trim_extension( &unwrapped_entry.file_name().into_string().unwrap(), )); } - let mut resp = reqwest::get(&sub.url).chain_err(|| UNABLE_TO_GET_HTTP_RESPONSE)?; - let mut content: Vec<u8> = Vec::new(); - resp.read_to_end(&mut content) - .chain_err(|| UNABLE_TO_READ_RESPONSE_TO_END)?; - let podcast = Podcast::from( - Channel::read_from(BufReader::new(&content[..])) - .chain_err(|| UNABLE_TO_CREATE_CHANNEL_FROM_RESPONSE)?, - ); + let resp = reqwest::get(&sub.url)?; + let podcast = Podcast::from(Channel::read_from(BufReader::new(resp))?); let mut filename = String::from(podcast.title()); filename.push_str(".xml"); @@ -95,17 +72,16 @@ pub fn update_subscription(sub: &mut Subscription) -> Result<()> { let mut podcast_rss_path = get_xml_dir()?; podcast_rss_path.push(&filename); - let mut file = File::create(&podcast_rss_path).unwrap(); - file.write_all(&content).unwrap(); + let file = File::create(&podcast_rss_path)?; + (*podcast).write_to(BufWriter::new(file))?; if sub.num_episodes < podcast.episodes().len() { - podcast.episodes()[..podcast.episodes().len() - sub.num_episodes] + podcast.episodes() + [..podcast.episodes().len() - sub.num_episodes] .par_iter() - .for_each(|ep: &Episode| { - if let Err(err) = download(podcast.title(), ep) { - eprintln!("Error downloading {}: {}", podcast.title(), err); - } - }); + .map(|ep| download::download(podcast.title(), ep)) + .flat_map(|e| e.err()) + .for_each(|err| eprintln!("Error: {}", err)); } sub.num_episodes = podcast.episodes().len(); Ok(()) @@ -114,327 +90,18 @@ pub fn update_subscription(sub: &mut Subscription) -> Result<()> { pub fn update_rss(state: &mut State) { println!("Checking for new episodes..."); let _result: Vec<Result<()>> = state - .subscriptions + .subscriptions_mut() .par_iter_mut() .map(|sub: &mut Subscription| update_subscription(sub)) .collect(); + println!("Done."); } pub fn list_subscriptions(state: &State) -> Result<()> { let stdout = io::stdout(); let mut handle = stdout.lock(); - for podcast in state.subscriptions() { - writeln!(&mut handle, "{}", &podcast.title).ok(); - } - Ok(()) -} - -pub fn download_range(state: &State, p_search: &str, e_search: &str) -> Result<()> { - let re_pod = Regex::new(&format!("(?i){}", &p_search)).chain_err(|| UNABLE_TO_PARSE_REGEX)?; - - for subscription in &state.subscriptions { - if re_pod.is_match(&subscription.title) { - let podcast = Podcast::from_title(&subscription.title) - .chain_err(|| UNABLE_TO_RETRIEVE_PODCAST_BY_TITLE)?; - let episodes_to_download = parse_download_episodes(e_search) - .chain_err(|| "unable to parse episodes to download")?; - podcast - .download_specific(&episodes_to_download) - .chain_err(|| "unable to download episodes")?; - } - } - Ok(()) -} - -pub fn download_episode_by_num(state: &State, p_search: &str, e_search: &str) -> Result<()> { - let re_pod = Regex::new(&format!("(?i){}", &p_search)).chain_err(|| UNABLE_TO_PARSE_REGEX)?; - - if let Ok(ep_num) = e_search.parse::<usize>() { - for subscription in &state.subscriptions { - if re_pod.is_match(&subscription.title) { - let podcast = Podcast::from_title(&subscription.title) - .chain_err(|| UNABLE_TO_RETRIEVE_PODCAST_BY_TITLE)?; - let episodes = podcast.episodes(); - download(podcast.title(), &episodes[episodes.len() - ep_num]) - .chain_err(|| "unable to download episode")?; - } - } - } else { - { - let stdout = io::stdout(); - let mut handle = stdout.lock(); - writeln!( - &mut handle, - "Failed to parse episode number...\nAttempting to find episode by name..." - ) - .ok(); - } - download_episode_by_name(state, p_search, e_search, false) - .chain_err(|| "Failed to download episode.")?; - } - - Ok(()) -} - -pub fn download(podcast_name: &str, episode: &Episode) -> Result<()> { - let stdout = io::stdout(); - - let mut path = get_podcast_dir()?; - path.push(podcast_name); - create_dir_if_not_exist(&path)?; - - if let Some(url) = episode.url() { - if let Some(title) = episode.title() { - let mut filename = title; - filename.push_str( - episode - .extension() - .chain_err(|| "unable to retrieve extension")?, - ); - path.push(filename); - if !path.exists() { - { - let mut handle = stdout.lock(); - writeln!(&mut handle, "Downloading: {:?}", &path).ok(); - } - let mut file = File::create(&path).chain_err(|| UNABLE_TO_CREATE_FILE)?; - let mut resp = reqwest::get(url).chain_err(|| UNABLE_TO_GET_HTTP_RESPONSE)?; - let mut content: Vec<u8> = Vec::new(); - resp.read_to_end(&mut content) - .chain_err(|| UNABLE_TO_READ_RESPONSE_TO_END)?; - file.write_all(&content) - .chain_err(|| UNABLE_TO_WRITE_FILE)?; - } else { - let mut handle = stdout.lock(); - writeln!(&mut handle, "File already exists: {:?}", &path).ok(); - } - } - } - Ok(()) -} - -pub fn download_episode_by_name( - state: &State, - p_search: &str, - e_search: &str, - download_all: bool, -) -> Result<()> { - let re_pod = Regex::new(&format!("(?i){}", &p_search)).chain_err(|| UNABLE_TO_PARSE_REGEX)?; - - for subscription in &state.subscriptions { - if re_pod.is_match(&subscription.title) { - let podcast = Podcast::from_title(&subscription.title) - .chain_err(|| UNABLE_TO_RETRIEVE_PODCAST_BY_TITLE)?; - let episodes = podcast.episodes(); - if download_all { - episodes - .iter() - .filter(|ep| ep.title().is_some()) - .filter(|ep| ep.title().unwrap().contains(e_search)) - .for_each(|ep| { - download(podcast.title(), ep).unwrap_or_else(|_| { - eprintln!("Error downloading episode: {}", podcast.title()) - }); - }) - } else { - let filtered_episodes: Vec<&Episode> = episodes - .iter() - .filter(|ep| ep.title().is_some()) - .filter(|ep| { - ep.title() - .unwrap() - .to_lowercase() - .contains(&e_search.to_lowercase()) - }) - .collect(); - - if let Some(ep) = filtered_episodes.first() { - download(podcast.title(), ep).chain_err(|| "unable to download episode")?; - } - } - } - } - Ok(()) -} - -pub fn download_all(state: &State, p_search: &str) -> Result<()> { - let re_pod = Regex::new(&format!("(?i){}", &p_search)).chain_err(|| UNABLE_TO_PARSE_REGEX)?; - - for subscription in &state.subscriptions { - if re_pod.is_match(&subscription.title) { - let podcast = Podcast::from_title(&subscription.title) - .chain_err(|| UNABLE_TO_RETRIEVE_PODCAST_BY_TITLE)?; - podcast - .download() - .chain_err(|| "unable to download podcast")?; - } - } - Ok(()) -} - -pub fn play_latest(state: &State, p_search: &str) -> Result<()> { - let re_pod: Regex = - Regex::new(&format!("(?i){}", &p_search)).chain_err(|| UNABLE_TO_PARSE_REGEX)?; - let mut path: PathBuf = get_xml_dir()?; - DirBuilder::new() - .recursive(true) - .create(&path) - .chain_err(|| UNABLE_TO_CREATE_DIRECTORY)?; - for subscription in &state.subscriptions { - if re_pod.is_match(&subscription.title) { - let mut filename: String = subscription.title.clone(); - filename.push_str(".xml"); - path.push(filename); - - let mut file: File = File::open(&path).chain_err(|| UNABLE_TO_OPEN_FILE)?; - let mut content: Vec<u8> = Vec::new(); - file.read_to_end(&mut content) - .chain_err(|| "unable to read file to end")?; - - let podcast: Podcast = Podcast::from( - Channel::read_from(content.as_slice()) - .chain_err(|| UNABLE_TO_CREATE_CHANNEL_FROM_FILE)?, - ); - let episodes = podcast.episodes(); - let episode = episodes[0].clone(); - - filename = episode - .title() - .chain_err(|| "unable to retrieve episode name")?; - filename.push_str( - episode - .extension() - .chain_err(|| "unable to retrieve episode extension")?, - ); - path = get_podcast_dir()?; - path.push(podcast.title()); - path.push(filename); - if path.exists() { - launch_player( - path.to_str() - .chain_err(|| "unable to convert path to &str")?, - )?; - } else { - launch_player( - episode - .url() - .chain_err(|| "unable to retrieve episode url")?, - )?; - } - return Ok(()); - } - } - Ok(()) -} - -pub fn play_episode_by_num(state: &State, p_search: &str, ep_num_string: &str) -> Result<()> { - let re_pod: Regex = - Regex::new(&format!("(?i){}", &p_search)).chain_err(|| UNABLE_TO_PARSE_REGEX)?; - if let Ok(ep_num) = ep_num_string.parse::<usize>() { - let mut path: PathBuf = get_xml_dir()?; - if let Err(err) = DirBuilder::new().recursive(true).create(&path) { - eprintln!( - "Couldn't create directory: {}\nReason: {}", - path.to_str().unwrap(), - err - ); - return Ok(()); - } - for subscription in &state.subscriptions { - if re_pod.is_match(&subscription.title) { - let mut filename: String = subscription.title.clone(); - filename.push_str(".xml"); - path.push(filename); - - let mut file: File = File::open(&path).unwrap(); - let mut content: Vec<u8> = Vec::new(); - file.read_to_end(&mut content).unwrap(); - - let podcast = Podcast::from(Channel::read_from(content.as_slice()).unwrap()); - let episodes = podcast.episodes(); - let episode = episodes[episodes.len() - ep_num].clone(); - - filename = episode.title().unwrap(); - filename.push_str(episode.extension().unwrap()); - path = get_podcast_dir()?; - path.push(podcast.title()); - path.push(filename); - if path.exists() { - launch_player(path.to_str().chain_err(|| UNABLE_TO_CONVERT_TO_STR)?)?; - } else { - launch_player( - episode - .url() - .chain_err(|| "unable to retrieve episode url")?, - )?; - } - return Ok(()); - } - } - } else { - { - let stdout = io::stdout(); - let mut handle = stdout.lock(); - writeln!(&mut handle, "Failed to parse episode index number...").ok(); - writeln!(&mut handle, "Attempting to find episode by name...").ok(); - } - play_episode_by_name(state, p_search, ep_num_string) - .chain_err(|| "Failed to play episode by name.")?; - } - Ok(()) -} - -pub fn play_episode_by_name(state: &State, p_search: &str, ep_string: &str) -> Result<()> { - let re_pod: Regex = - Regex::new(&format!("(?i){}", &p_search)).chain_err(|| UNABLE_TO_PARSE_REGEX)?; - let mut path: PathBuf = get_xml_dir()?; - if let Err(err) = DirBuilder::new().recursive(true).create(&path) { - eprintln!( - "Couldn't create directory: {}\nReason: {}", - path.to_str().unwrap(), - err - ); - return Ok(()); - } - for subscription in &state.subscriptions { - if re_pod.is_match(&subscription.title) { - let mut filename: String = subscription.title.clone(); - filename.push_str(".xml"); - path.push(filename); - - let mut file: File = File::open(&path).unwrap(); - let mut content: Vec<u8> = Vec::new(); - file.read_to_end(&mut content).unwrap(); - - let podcast = Podcast::from(Channel::read_from(content.as_slice()).unwrap()); - let episodes = podcast.episodes(); - let filtered_episodes: Vec<&Episode> = episodes - .iter() - .filter(|ep| { - ep.title() - .unwrap_or_else(|| "".to_string()) - .to_lowercase() - .contains(&ep_string.to_lowercase()) - }) - .collect(); - if let Some(episode) = filtered_episodes.first() { - filename = episode.title().unwrap(); - filename.push_str(episode.extension().unwrap()); - path = get_podcast_dir()?; - path.push(podcast.title()); - path.push(filename); - if path.exists() { - launch_player(path.to_str().chain_err(|| UNABLE_TO_CONVERT_TO_STR)?)?; - } else { - launch_player( - episode - .url() - .chain_err(|| "unable to retrieve episode url")?, - )?; - } - } - return Ok(()); - } + for subscription in state.subscriptions() { + writeln!(&mut handle, "{}", subscription.title())?; } Ok(()) } @@ -442,53 +109,13 @@ pub fn play_episode_by_name(state: &State, p_search: &str, ep_string: &str) -> R pub fn check_for_update(version: &str) -> Result<()> { println!("Checking for updates..."); let resp: String = - reqwest::get("https://raw.githubusercontent.com/njaremko/podcast/master/Cargo.toml") - .chain_err(|| UNABLE_TO_GET_HTTP_RESPONSE)? - .text() - .chain_err(|| "unable to convert response to text")?; + reqwest::get("https://raw.githubusercontent.com/njaremko/podcast/master/Cargo.toml")? + .text()?; - let config = resp - .parse::<toml::Value>() - .chain_err(|| "unable to parse toml")?; - let latest = config["package"]["version"] - .as_str() - .chain_err(|| UNABLE_TO_CONVERT_TO_STR)?; + let config = resp.parse::<toml::Value>()?; + let latest = config["package"]["version"].as_str().expect(&format!("Cargo.toml didn't have a version {:?}", config)); if version != latest { - println!("New version avaliable: {} -> {}", version, latest); - } - Ok(()) -} - -fn launch_player(url: &str) -> Result<()> { - if launch_mpv(url).is_err() { - return launch_vlc(url); - } - Ok(()) -} - -fn launch_mpv(url: &str) -> Result<()> { - if let Err(err) = Command::new("mpv") - .args(&["--audio-display=no", "--ytdl=no", url]) - .status() - { - match err.kind() { - io::ErrorKind::NotFound => { - eprintln!("Couldn't open mpv\nTrying vlc..."); - } - _ => eprintln!("Error: {}", err), - } - } - Ok(()) -} - -fn launch_vlc(url: &str) -> Result<()> { - if let Err(err) = Command::new("vlc").args(&["-I ncurses", url]).status() { - match err.kind() { - io::ErrorKind::NotFound => { - eprintln!("Couldn't open vlc...aborting"); - } - _ => eprintln!("Error: {}", err), - } + println!("New version available: {} -> {}", version, latest); } Ok(()) } @@ -499,7 +126,7 @@ pub fn remove_podcast(state: &mut State, p_search: &str) -> Result<()> { return delete_all(); } - let re_pod = Regex::new(&format!("(?i){}", &p_search)).chain_err(|| UNABLE_TO_PARSE_REGEX)?; + let re_pod = Regex::new(&format!("(?i){}", &p_search))?; for subscription in 0..state.subscriptions.len() { let title = state.subscriptions[subscription].title.clone(); @@ -513,11 +140,23 @@ pub fn remove_podcast(state: &mut State, p_search: &str) -> Result<()> { pub fn print_completion(app: &mut App, arg: &str) { match arg { - "zsh" => app.gen_completions_to("podcast", Shell::Zsh, &mut io::stdout()), - "bash" => app.gen_completions_to("podcast", Shell::Bash, &mut io::stdout()), - "powershell" => app.gen_completions_to("podcast", Shell::PowerShell, &mut io::stdout()), - "fish" => app.gen_completions_to("podcast", Shell::Fish, &mut io::stdout()), - "elvish" => app.gen_completions_to("podcast", Shell::Elvish, &mut io::stdout()), - other => eprintln!("Completions are not available for {}", other), + "zsh" => { + app.gen_completions_to("podcast", Shell::Zsh, &mut io::stdout()); + } + "bash" => { + app.gen_completions_to("podcast", Shell::Bash, &mut io::stdout()); + } + "powershell" => { + app.gen_completions_to("podcast", Shell::PowerShell, &mut io::stdout()); + } + "fish" => { + app.gen_completions_to("podcast", Shell::Fish, &mut io::stdout()); + } + "elvish" => { + app.gen_completions_to("podcast", Shell::Elvish, &mut io::stdout()); + } + other => { + println!("Completions are not available for {}", other); + } } } diff --git a/src/arg_parser.rs b/src/arg_parser.rs new file mode 100644 index 0000000..8f9650a --- /dev/null +++ b/src/arg_parser.rs @@ -0,0 +1,96 @@ +use clap::{App, ArgMatches}; + +use std::env; +use std::path::Path; + +use crate::actions::*; +use crate::download; +use crate::errors::*; +use crate::playback; +use crate::structs::*; + +pub fn download(state: &mut State, matches: &ArgMatches) -> Result<()> { + let download_matches = matches.subcommand_matches("download").unwrap(); + let podcast = download_matches.value_of("PODCAST").unwrap(); + match download_matches.value_of("EPISODE") { + Some(ep) => { + if String::from(ep).contains(|c| c == '-' || c == ',') { + download::download_range(&state, podcast, ep)? + } else if download_matches.occurrences_of("name") > 0 { + download::download_episode_by_name( + &state, + podcast, + ep, + download_matches.occurrences_of("all") > 0, + )? + } else { + download::download_episode_by_num(&state, podcast, ep)? + } + } + None => download::download_all(&state, podcast)?, + } + Ok(()) +} + +pub fn list(state: &mut State, matches: &ArgMatches) -> Result<()> { + let list_matches = matches + .subcommand_matches("ls") + .or_else(|| matches.subcommand_matches("list")) + .unwrap(); + match list_matches.value_of("PODCAST") { + Some(regex) => list_episodes(regex)?, + None => list_subscriptions(&state)?, + } + Ok(()) +} + +pub fn play(state: &mut State, matches: &ArgMatches) -> Result<()> { + let play_matches = matches.subcommand_matches("play").unwrap(); + let podcast = play_matches.value_of("PODCAST").unwrap(); + match play_matches.value_of("EPISODE") { + Some(episode) => { + if play_matches.occurrences_of("name") > 0 { + playback::play_episode_by_name(&state, podcast, episode)? + } else { + playback::play_episode_by_num(&state, podcast, episode)? + } + } + None => playback::play_latest(&state, podcast)?, + } + Ok(()) +} + +pub fn subscribe(state: &mut State, config: &Config, matches: &ArgMatches) -> Result<()> { + let subscribe_matches = matches + .subcommand_matches("sub") + .or_else(|| matches.subcommand_matches("subscribe")) + .unwrap(); + let url = subscribe_matches.value_of("URL").unwrap(); + state.subscribe(url)?; + download::download_rss(&config, url)?; + Ok(()) +} + +pub fn remove(state: &mut State, matches: &ArgMatches) -> Result<()> { + let rm_matches = matches.subcommand_matches("rm").unwrap(); + let regex = rm_matches.value_of("PODCAST").unwrap(); + remove_podcast(state, regex)?; + Ok(()) +} + +pub fn complete(app: &mut App, matches: &ArgMatches) -> Result<()> { + let matches = matches.subcommand_matches("completion").unwrap(); + match matches.value_of("SHELL") { + Some(shell) => print_completion(app, shell), + None => { + let shell_path_env = env::var("SHELL"); + if let Ok(p) = shell_path_env { + let shell_path = Path::new(&p); + if let Some(shell) = shell_path.file_name() { + print_completion(app, shell.to_str().unwrap()) + } + } + } + } + Ok(()) +} diff --git a/src/command_handler.rs b/src/command_handler.rs new file mode 100644 index 0000000..742b79e --- /dev/null +++ b/src/command_handler.rs @@ -0,0 +1,63 @@ +use clap::{App, ArgMatches}; + +use crate::actions::*; +use crate::arg_parser; +use crate::commands; +use crate::errors::*; +use crate::structs::*; + +pub fn parse_sub_command(matches: &ArgMatches) -> commands::Command { + match matches.subcommand_name() { + Some("download") => commands::Command::Download, + Some("ls") | Some("list") => commands::Command::List, + Some("play") => commands::Command::Play, + Some("sub") | Some("subscribe") => commands::Command::Subscribe, + Some("search") => commands::Command::Search, + Some("rm") => commands::Command::Remove, + Some("completion") => commands::Command::Complete, + Some("refresh") => commands::Command::Refresh, + Some("update") => commands::Command::Update, + _ => commands::Command::NoMatch, + } +} + +pub fn handle_matches( + version: &str, + state: &mut State, + config: &Config, + app: &mut App, + matches: &ArgMatches, +) -> Result<()> { + let command = parse_sub_command(matches); + match command { + commands::Command::Download => { + arg_parser::download(state, matches)?; + } + commands::Command::List => { + arg_parser::list(state, matches)?; + } + commands::Command::Play => { + arg_parser::play(state, matches)?; + } + commands::Command::Subscribe => { + arg_parser::subscribe(state, config, matches)?; + } + commands::Command::Search => { + println!("This feature is coming soon..."); + } + commands::Command::Remove => { + arg_parser::remove(state, matches)?; + } + commands::Command::Complete => { + arg_parser::complete(app, matches)?; + } + commands::Command::Refresh => { + update_rss(state); + } + commands::Command::Update => { + check_for_update(version)?; + } + _ => (), + }; + Ok(()) +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..f7c19b6 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,12 @@ +pub enum Command { + Download, + List, + Play, + Subscribe, + Search, + Remove, + Complete, + Refresh, + Update, + NoMatch, +} diff --git a/src/download.rs b/src/download.rs new file mode 100644 index 0000000..69dc853 --- /dev/null +++ b/src/download.rs @@ -0,0 +1,199 @@ +use crate::structs::*; +use crate::utils::*; + +use std::collections::HashSet; +use std::fs::File; +use std::io::{self, BufReader, BufWriter, Write}; + +use failure::Error; +use rayon::prelude::*; +use regex::Regex; +use reqwest; + +pub fn download_range(state: &State, p_search: &str, e_search: &str) -> Result<(), Error> { + let re_pod = Regex::new(&format!("(?i){}", &p_search))?; + + for subscription in &state.subscriptions { + if re_pod.is_match(&subscription.title) { + let podcast = Podcast::from_title(&subscription.title)?; + let downloaded = already_downloaded(podcast.title())?; + let episodes = podcast.episodes(); + let episodes_to_download = parse_download_episodes(e_search)?; + + episodes_to_download + .par_iter() + .map(|ep_num| &episodes[episodes.len() - ep_num]) + .filter(|e| e.title().is_some()) + .filter(|e| !downloaded.contains(&e.title().unwrap())) + .map(|ep| download(podcast.title(), ep)) + .flat_map(|e| e.err()) + .for_each(|err| eprintln!("Error: {}", err)); + } + } + Ok(()) +} + +pub fn download_episode_by_num(state: &State, p_search: &str, e_search: &str) -> Result<(), Error> { + let re_pod = Regex::new(&format!("(?i){}", &p_search))?; + + if let Ok(ep_num) = e_search.parse::<usize>() { + for subscription in &state.subscriptions { + if re_pod.is_match(&subscription.title) { + let podcast = Podcast::from_title(&subscription.title)?; + let episodes = podcast.episodes(); + download(podcast.title(), &episodes[episodes.len() - ep_num])?; + } + } + } else { + eprintln!("Failed to parse episode number...\nAttempting to find episode by name..."); + download_episode_by_name(state, p_search, e_search, false)?; + } + + Ok(()) +} + +pub fn download(podcast_name: &str, episode: &Episode) -> Result<(), Error> { + let mut path = get_podcast_dir()?; + path.push(podcast_name); + create_dir_if_not_exist(&path)?; + + if let (Some(title), Some(url)) = (episode.title(), episode.url()) { + path.push(title); + episode.extension().map(|ext| path.set_extension(ext)); + if !path.exists() { + println!("Downloading: {:?}", &path); + let resp = reqwest::get(url)?; + let file = File::create(&path)?; + let mut reader = BufReader::new(resp); + let mut writer = BufWriter::new(file); + io::copy(&mut reader, &mut writer)?; + } else { + eprintln!("File already exists: {:?}", &path); + } + } + Ok(()) +} + +pub fn download_episode_by_name( + state: &State, + p_search: &str, + e_search: &str, + download_all: bool, +) -> Result<(), Error> { + let re_pod = Regex::new(&format!("(?i){}", &p_search))?; + + for subscription in &state.subscriptions { + if re_pod.is_match(&subscription.title) { + let podcast = Podcast::from_title(&subscription.title)?; + let episodes = podcast.episodes(); + let filtered_episodes = + episodes + .iter() + .filter(|ep| ep.title().is_some()) + .filter(|ep| { + ep.title() + .unwrap() + .to_lowercase() + .contains(&e_search.to_lowercase()) + }); + + if download_all { + filtered_episodes + .map(|ep| download(podcast.title(), ep)) + .flat_map(|e| e.err()) + .for_each(|err| eprintln!("Error: {}", err)); + } else { + filtered_episodes + .take(1) + .map(|ep| download(podcast.title(), ep)) + .flat_map(|e| e.err()) + .for_each(|err| eprintln!("Error: {}", err)); + } + } + } + Ok(()) +} + +pub fn download_all(state: &State, p_search: &str) -> Result<(), Error> { + let re_pod = Regex::new(&format!("(?i){}", &p_search))?; + + for subscription in &state.subscriptions { + if re_pod.is_match(&subscription.title) { + let podcast = Podcast::from_title(&subscription.title)?; + print!( + "You are about to download all episodes of {} (y/n): ", + podcast.title() + ); + io::stdout().flush().ok(); + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + if input.to_lowercase().trim() != "y" { + return Ok(()); + } + + let mut path = get_podcast_dir()?; + path.push(podcast.title()); + + already_downloaded(podcast.title()).map(|downloaded| { + podcast + .episodes() + .par_iter() + .filter(|e| e.title().is_some()) + .filter(|e| !downloaded.contains(&e.title().unwrap())) + .map(|e| download(podcast.title(), e)) + .flat_map(|e| e.err()) + .for_each(|err| eprintln!("Error: {}", err)) + })?; + } + } + Ok(()) +} + +pub fn download_rss(config: &Config, url: &str) -> Result<(), Error> { + let channel = download_rss_feed(url)?; + let mut download_limit = config.auto_download_limit as usize; + if 0 < download_limit { + println!( + "Subscribe auto-download limit set to: {}\nDownloading episode(s)...", + download_limit + ); + let podcast = Podcast::from(channel); + let episodes = podcast.episodes(); + if episodes.len() < download_limit { + download_limit = episodes.len() + } + + episodes[..download_limit] + .par_iter() + .map(|ep| download(podcast.title(), ep)) + .flat_map(|e| e.err()) + .for_each(|err| eprintln!("Error downloading {}: {}", podcast.title(), err)); + } + Ok(()) +} + +fn parse_download_episodes(e_search: &str) -> Result<HashSet<usize>, Error> { + let input = String::from(e_search); + let mut ranges = Vec::<(usize, usize)>::new(); + let mut elements = HashSet::<usize>::new(); + let comma_separated: Vec<&str> = input.split(',').collect(); + for elem in comma_separated { + if elem.contains('-') { + let range: Vec<usize> = elem + .split('-') + .map(|i| i.parse::<usize>()) + .collect::<Result<Vec<usize>, std::num::ParseIntError>>()?; + ranges.push((range[0], range[1])); + } else { + elements.insert(elem.parse::<usize>()?); + } + } + + for range in ranges { + // Include given episode in the download + for num in range.0..=range.1 { + elements.insert(num); + } + } + Ok(elements) +} diff --git a/src/main.rs b/src/main.rs index b9085bb..bdf913a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,11 @@ -#![recursion_limit = "1024"] +#![feature(impl_trait_in_bindings)] extern crate chrono; extern crate clap; extern crate dirs; +#[allow(unused_imports)] #[macro_use] -extern crate error_chain; +extern crate failure; #[macro_use] extern crate lazy_static; extern crate rayon; @@ -17,28 +18,35 @@ extern crate serde_json; extern crate serde_yaml; extern crate toml; -pub mod actions; -pub mod match_handler; -pub mod migration_handler; -pub mod parser; -pub mod structs; -pub mod utils; -pub mod errors { - error_chain! {} +mod actions; +mod arg_parser; +mod command_handler; +mod commands; +mod download; +mod migration_handler; +mod parser; +mod playback; +mod structs; +mod utils; + +mod errors { + use failure::Error; + use std::result; + pub type Result<T> = result::Result<T, Error>; } -use self::errors::*; use self::structs::*; +use errors::Result; -const VERSION: &str = "0.9.1"; +const VERSION: &str = "0.10.0"; fn main() -> Result<()> { - utils::create_directories().chain_err(|| "unable to create directories")?; - migration_handler::migrate_old_subscriptions()?; - let mut state = State::new(VERSION).chain_err(|| "unable to load state")?; + utils::create_directories()?; + migration_handler::migrate()?; + let mut state = State::new(VERSION)?; let config = Config::new()?; let mut app = parser::get_app(&VERSION); let matches = app.clone().get_matches(); - match_handler::handle_matches(&VERSION, &mut state, &config, &mut app, &matches)?; - state.save().chain_err(|| "unable to save state") + command_handler::handle_matches(&VERSION, &mut state, &config, &mut app, &matches)?; + state.save() } diff --git a/src/match_handler.rs b/src/match_handler.rs deleted file mode 100644 index ac09d15..0000000 --- a/src/match_handler.rs +++ /dev/null @@ -1,112 +0,0 @@ -use clap::{App, ArgMatches}; - -use std::env; -use std::path::Path; - -use crate::actions::*; -use crate::errors::*; -use crate::structs::*; - -pub fn handle_matches( - version: &str, - state: &mut State, - config: &Config, - app: &mut App, - matches: &ArgMatches, -) -> Result<()> { - match matches.subcommand_name() { - Some("download") => { - let download_matches = matches - .subcommand_matches("download") - .chain_err(|| "unable to find subcommand matches")?; - let podcast = download_matches - .value_of("PODCAST") - .chain_err(|| "unable to find subcommand match")?; - match download_matches.value_of("EPISODE") { - Some(ep) => { - if String::from(ep).contains(|c| c == '-' || c == ',') { - download_range(&state, podcast, ep)? - } else if download_matches.occurrences_of("name") > 0 { - download_episode_by_name( - &state, - podcast, - ep, - download_matches.occurrences_of("all") > 0, - )? - } else { - download_episode_by_num(&state, podcast, ep)? - } - } - None => download_all(&state, podcast)?, - } - } - Some("ls") | Some("list") => { - let list_matches = matches - .subcommand_matches("ls") - .or_else(|| matches.subcommand_matches("list")) - .chain_err(|| "unable to find subcommand matches")?; - match list_matches.value_of("PODCAST") { - Some(regex) => list_episodes(regex)?, - None => list_subscriptions(&state)?, - } - } - Some("play") => { - let play_matches = matches - .subcommand_matches("play") - .chain_err(|| "unable to find subcommand matches")?; - let podcast = play_matches - .value_of("PODCAST") - .chain_err(|| "unable to find subcommand match")?; - match play_matches.value_of("EPISODE") { - Some(episode) => { - if play_matches.occurrences_of("name") > 0 { - play_episode_by_name(&state, podcast, episode)? - } else { - play_episode_by_num(&state, podcast, episode)? - } - } - None => play_latest(&state, podcast)?, - } - } - Some("sub") | Some("subscribe") => { - let subscribe_matches = matches - .subcommand_matches("sub") - .or_else(|| matches.subcommand_matches("subscribe")) - .chain_err(|| "unable to find subcommand matches")?; - let url = subscribe_matches - .value_of("URL") - .chain_err(|| "unable to find subcommand match")?; - state.subscribe(url).chain_err(|| "unable to subscribe")?; - download_rss(&config, url)?; - } - Some("search") => println!("This feature is coming soon..."), - Some("rm") => { - let rm_matches = matches - .subcommand_matches("rm") - .chain_err(|| "unable to find subcommand matches")?; - let regex = rm_matches.value_of("PODCAST").chain_err(|| "")?; - remove_podcast(state, regex)? - } - Some("completion") => { - let matches = matches - .subcommand_matches("completion") - .chain_err(|| "unable to find subcommand matches")?; - match matches.value_of("SHELL") { - Some(shell) => print_completion(app, shell), - None => { - let shell_path_env = env::var("SHELL"); - if let Ok(p) = shell_path_env { - let shell_path = Path::new(&p); - if let Some(shell) = shell_path.file_name() { - print_completion(app, shell.to_str().chain_err(|| format!("Unable to convert {:?} to string", shell))?) - } - } - } - } - } - Some("refresh") => update_rss(state), - Some("update") => check_for_update(version)?, - _ => (), - }; - Ok(()) -} diff --git a/src/migration_handler.rs b/src/migration_handler.rs index 73a0241..9f8fbc4 100644 --- a/src/migration_handler.rs +++ b/src/migration_handler.rs @@ -2,15 +2,18 @@ use crate::errors::*; use crate::utils::*; use std::fs; -pub fn migrate_old_subscriptions() -> Result<()> { +fn migrate_old_subscriptions() -> Result<()> { let path = get_podcast_dir()?; let mut old_path = path.clone(); old_path.push(".subscriptions"); if old_path.exists() { println!("Migrating old subscriptions file..."); let new_path = get_sub_file()?; - fs::rename(&old_path, &new_path) - .chain_err(|| format!("Unable to move {:?} to {:?}", &old_path, &new_path))?; + fs::rename(&old_path, &new_path)?; } Ok(()) } + +pub fn migrate() -> Result<()> { + migrate_old_subscriptions() +} diff --git a/src/playback.rs b/src/playback.rs new file mode 100644 index 0000000..05196dd --- /dev/null +++ b/src/playback.rs @@ -0,0 +1,183 @@ +use crate::errors::*; +use crate::structs::*; +use crate::utils::*; + +use std::fs::{DirBuilder, File}; +use std::io::{self, BufReader, Read, Write}; +use std::process::Command; + +use regex::Regex; +use rss::Channel; +use std::path::PathBuf; + +fn launch_player(url: &str) -> Result<()> { + if launch_mpv(url).is_err() { + return launch_vlc(url); + } + Ok(()) +} + +fn launch_mpv(url: &str) -> Result<()> { + if let Err(err) = Command::new("mpv") + .args(&["--audio-display=no", "--ytdl=no", url]) + .status() + { + let stderr = io::stderr(); + let mut handle = stderr.lock(); + match err.kind() { + io::ErrorKind::NotFound => { + writeln!(&mut handle, "Couldn't open mpv\nTrying vlc...").ok() + } + _ => writeln!(&mut handle, "Error: {}", err).ok(), + }; + } + Ok(()) +} + +fn launch_vlc(url: &str) -> Result<()> { + if let Err(err) = Command::new("vlc").args(&["-I ncurses", url]).status() { + let stderr = io::stderr(); + let mut handle = stderr.lock(); + match err.kind() { + io::ErrorKind::NotFound => writeln!(&mut handle, "Couldn't open vlc...aborting").ok(), + _ => writeln!(&mut handle, "Error: {}", err).ok(), + }; + } + Ok(()) +} + +pub fn play_latest(state: &State, p_search: &str) -> Result<()> { + let re_pod: Regex = Regex::new(&format!("(?i){}", &p_search))?; + let mut path: PathBuf = get_xml_dir()?; + DirBuilder::new().recursive(true).create(&path)?; + for subscription in &state.subscriptions { + if re_pod.is_match(&subscription.title) { + let mut filename: String = subscription.title.clone(); + filename.push_str(".xml"); + path.push(filename); + + let file: File = File::open(&path)?; + let podcast: Podcast = Podcast::from(Channel::read_from(BufReader::new(file))?); + let episodes = podcast.episodes(); + let episode = episodes[0].clone(); + + filename = episode.title().unwrap(); + filename.push_str(&episode.extension().unwrap()); + path = get_podcast_dir()?; + path.push(podcast.title()); + path.push(filename); + if path.exists() { + launch_player(path.to_str().unwrap())?; + } else { + launch_player(episode.url().unwrap())?; + } + return Ok(()); + } + } + Ok(()) +} + +pub fn play_episode_by_num(state: &State, p_search: &str, ep_num_string: &str) -> Result<()> { + let re_pod: Regex = Regex::new(&format!("(?i){}", &p_search))?; + if let Ok(ep_num) = ep_num_string.parse::<usize>() { + let mut path: PathBuf = get_xml_dir()?; + let stderr = io::stderr(); + let mut handle = stderr.lock(); + if let Err(err) = DirBuilder::new().recursive(true).create(&path) { + writeln!( + &mut handle, + "Couldn't create directory: {}\nReason: {}", + path.to_str().unwrap(), + err + ) + .ok(); + return Ok(()); + } + for subscription in &state.subscriptions { + if re_pod.is_match(&subscription.title) { + let mut filename: String = subscription.title.clone(); + filename.push_str(".xml"); + path.push(filename); + + let file: File = File::open(&path).unwrap(); + let podcast = Podcast::from(Channel::read_from(BufReader::new(file)).unwrap()); + let episodes = podcast.episodes(); + let episode = episodes[episodes.len() - ep_num].clone(); + + filename = episode.title().unwrap(); + filename.push_str(&episode.extension().unwrap()); + path = get_podcast_dir()?; + path.push(podcast.title()); + path.push(filename); + if path.exists() { + launch_player(path.to_str().unwrap())?; + } else { + launch_player(episode.url().unwrap())?; + } + return Ok(()); + } + } + } else { + { + let stdout = io::stdout(); + let mut handle = stdout.lock(); + writeln!(&mut handle, "Failed to parse episode index number...").ok(); + writeln!(&mut handle, "Attempting to find episode by name...").ok(); + } + play_episode_by_name(state, p_search, ep_num_string)?; + } + Ok(()) +} + +pub fn play_episode_by_name(state: &State, p_search: &str, ep_string: &str) -> Result<()> { + let re_pod: Regex = Regex::new(&format!("(?i){}", &p_search))?; + let mut path: PathBuf = get_xml_dir()?; + if let Err(err) = DirBuilder::new().recursive(true).create(&path) { + let stderr = io::stderr(); + let mut handle = stderr.lock(); + writeln!( + &mut handle, + "Couldn't create directory: {:?}\nReason: {}", + path, err + ) + .ok(); + return Ok(()); + } + for subscription in &state.subscriptions { + if re_pod.is_match(&subscription.title) { + let mut filename: String = subscription.title.clone(); + filename.push_str(".xml"); + path.push(filename); + + let mut file: File = File::open(&path).unwrap(); + let mut content: Vec<u8> = Vec::new(); + file.read_to_end(&mut content).unwrap(); + + let podcast = Podcast::from(Channel::read_from(content.as_slice()).unwrap()); + let episodes = podcast.episodes(); + let filtered_episodes: Vec<&Episode> = episodes + .iter() + .filter(|ep| { + ep.title() + .unwrap_or_else(|| "".to_string()) + .to_lowercase() + .contains(&ep_string.to_lowercase()) + }) + .collect(); + if let Some(episode) = filtered_episodes.first() { + filename = episode.title().unwrap(); + filename.push_str(&episode.extension().unwrap()); + path = get_podcast_dir()?; + path.push(podcast.title()); + path.push(filename); + if path.exists() { + launch_player(path.to_str().unwrap())?; + } else { + launch_player(episode.url().unwrap())?; + } + } + return Ok(()); + } + } + Ok(()) +} diff --git a/src/structs.rs b/src/structs.rs index 1c3ceaf..2524034 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -1,30 +1,46 @@ use super::actions::*; use super::utils::*; use crate::errors::*; +use core::ops::Deref; -use std::collections::BTreeSet; +use std::collections::HashSet; use std::fs::{self, File}; -use std::io::{self, BufReader, Write}; +use std::io::{self, BufReader, BufWriter, Write}; use chrono::prelude::*; -use rayon::prelude::*; use regex::Regex; use rss::{Channel, Item}; use serde_json; use std::path::PathBuf; #[cfg(target_os = "macos")] -static ESCAPE_REGEX: &str = r"/"; +const ESCAPE_REGEX: &str = r"/"; #[cfg(target_os = "linux")] -static ESCAPE_REGEX: &str = r"/"; +const ESCAPE_REGEX: &str = r"/"; #[cfg(target_os = "windows")] -static ESCAPE_REGEX: &str = r#"[\\/:*?"<>|]"#; +const ESCAPE_REGEX: &str = r#"[\\/:*?"<>|]"#; lazy_static! { static ref FILENAME_ESCAPE: Regex = Regex::new(ESCAPE_REGEX).unwrap(); } -#[derive(Debug, PartialEq, Serialize, Deserialize)] +fn create_new_config_file(path: &PathBuf) -> Result<Config> { + writeln!( + io::stdout().lock(), + "Creating new config file at {:?}", + &path + ) + .ok(); + let download_limit = 1; + let file = File::create(&path)?; + let config = Config { + auto_download_limit: download_limit, + }; + serde_yaml::to_writer(file, &config)?; + Ok(config) +} + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Config { pub auto_download_limit: i64, } @@ -34,16 +50,21 @@ impl Config { let mut path = get_podcast_dir()?; path.push(".config.yaml"); let config = if path.exists() { - let file = File::open(&path).chain_err(|| UNABLE_TO_OPEN_FILE)?; + let file = File::open(&path)?; match serde_yaml::from_reader(file) { Ok(config) => config, Err(err) => { let mut new_path = path.clone(); new_path.set_extension("yaml.bk"); - eprintln!("{}", err); - eprintln!("Failed to open config file, moving to {:?}", &new_path); - fs::rename(&path, new_path) - .chain_err(|| "Failed to move old config file...")?; + let stderr = io::stderr(); + let mut handle = stderr.lock(); + writeln!( + &mut handle, + "{}\nFailed to open config file, moving to {:?}", + err, &new_path + ) + .ok(); + fs::rename(&path, new_path)?; create_new_config_file(&path)? } } @@ -54,17 +75,6 @@ impl Config { } } -fn create_new_config_file(path: &PathBuf) -> Result<Config> { - println!("Creating new config file at {:?}", &path); - let download_limit = 1; - let file = File::create(&path).chain_err(|| UNABLE_TO_CREATE_FILE)?; - let config = Config { - auto_download_limit: download_limit, - }; - serde_yaml::to_writer(file, &config).chain_err(|| UNABLE_TO_WRITE_FILE)?; - Ok(config) -} - #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Subscription { pub title: String, @@ -72,6 +82,12 @@ pub struct Subscription { pub num_episodes: usize, } +impl Subscription { + pub fn title(&self) -> &str { + &self.title + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct State { pub version: String, @@ -83,24 +99,8 @@ impl State { pub fn new(version: &str) -> Result<State> { let path = get_sub_file()?; if path.exists() { - let file = File::open(&path).chain_err(|| UNABLE_TO_OPEN_FILE)?; - let mut state: State = match serde_json::from_reader(&file) { - Ok(val) => val, - // This will happen if the struct has changed between versions - Err(_) => { - let v: serde_json::Value = serde_json::from_reader(&file) - .chain_err(|| "unable to read json from string")?; - State { - version: String::from(version), - last_run_time: Utc::now(), - subscriptions: match serde_json::from_value(v["subscriptions"].clone()) { - Ok(val) => val, - Err(_) => serde_json::from_value(v["subs"].clone()) - .chain_err(|| "unable to parse value from json")?, - }, - } - } - }; + let file = File::open(&path)?; + let mut state: State = serde_json::from_reader(BufReader::new(&file))?; state.version = String::from(version); // Check if a day has passed (86400 seconds) since last launch if 86400 @@ -115,7 +115,7 @@ impl State { state.save()?; Ok(state) } else { - println!("Creating new file {:?}", &path); + writeln!(io::stdout().lock(), "Creating new file: {:?}", &path).ok(); Ok(State { version: String::from(version), last_run_time: Utc::now(), @@ -124,12 +124,20 @@ impl State { } } + pub fn subscriptions(&self) -> &[Subscription] { + &self.subscriptions + } + + pub fn subscriptions_mut(&mut self) -> &mut [Subscription] { + &mut self.subscriptions + } + pub fn subscribe(&mut self, url: &str) -> Result<()> { - let mut set = BTreeSet::new(); + let mut set = HashSet::new(); for sub in self.subscriptions() { set.insert(sub.title.clone()); } - let podcast = Podcast::from(Channel::from_url(url).unwrap()); + let podcast = Podcast::from(Channel::from_url(url)?); if !set.contains(podcast.title()) { self.subscriptions.push(Subscription { title: String::from(podcast.title()), @@ -140,45 +148,30 @@ impl State { self.save() } - pub fn subscriptions(&self) -> &[Subscription] { - &self.subscriptions - } - - pub fn subscriptions_mut(&mut self) -> &mut [Subscription] { - &mut self.subscriptions - } - pub fn save(&self) -> Result<()> { let mut path = get_sub_file()?; path.set_extension("json.tmp"); - let serialized = serde_json::to_string(self).chain_err(|| "unable to serialize state")?; - { - let mut file = File::create(&path).chain_err(|| UNABLE_TO_CREATE_FILE)?; - file.write_all(serialized.as_bytes()) - .chain_err(|| UNABLE_TO_WRITE_FILE)?; - } - let sub_file_path = get_sub_file()?; - fs::rename(&path, &sub_file_path) - .chain_err(|| format!("unable to rename file {:?} to {:?}", &path, &sub_file_path))?; + let file = File::create(&path)?; + serde_json::to_writer(BufWriter::new(file), self)?; + fs::rename(&path, get_sub_file()?)?; Ok(()) } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct Podcast(Channel); -#[derive(Clone, Debug)] -pub struct Episode(Item); - impl From<Channel> for Podcast { fn from(channel: Channel) -> Podcast { Podcast(channel) } } -impl From<Item> for Episode { - fn from(item: Item) -> Episode { - Episode(item) +impl Deref for Podcast { + type Target = Channel; + + fn deref(&self) -> &Channel { + &self.0 } } @@ -194,9 +187,7 @@ impl Podcast { #[allow(dead_code)] pub fn from_url(url: &str) -> Result<Podcast> { - Ok(Podcast::from( - Channel::from_url(url).chain_err(|| UNABLE_TO_CREATE_CHANNEL_FROM_RESPONSE)?, - )) + Ok(Podcast::from(Channel::from_url(url)?)) } pub fn from_title(title: &str) -> Result<Podcast> { @@ -205,11 +196,8 @@ impl Podcast { filename.push_str(".xml"); path.push(filename); - let file = File::open(&path).chain_err(|| UNABLE_TO_OPEN_FILE)?; - Ok(Podcast::from( - Channel::read_from(BufReader::new(file)) - .chain_err(|| UNABLE_TO_CREATE_CHANNEL_FROM_FILE)?, - )) + let file = File::open(&path)?; + Ok(Podcast::from(Channel::read_from(BufReader::new(file))?)) } pub fn episodes(&self) -> Vec<Episode> { @@ -219,65 +207,14 @@ impl Podcast { } result } +} - pub fn download(&self) -> Result<()> { - print!( - "You are about to download all episodes of {} (y/n): ", - self.title() - ); - io::stdout().flush().ok(); - let mut input = String::new(); - io::stdin() - .read_line(&mut input) - .chain_err(|| "unable to read stdin")?; - if input.to_lowercase().trim() != "y" { - return Ok(()); - } - - let mut path = get_podcast_dir()?; - path.push(self.title()); - - match already_downloaded(self.title()) { - Ok(downloaded) => { - self.episodes().par_iter().for_each(|i| { - if let Some(ep_title) = i.title() { - if !downloaded.contains(&ep_title) { - if let Err(err) = download(self.title(), i) { - eprintln!("{}", err); - } - } - } - }); - } - Err(_) => { - self.episodes().par_iter().for_each(|i| { - if let Err(err) = download(self.title(), i) { - eprintln!("{}", err); - } - }); - } - } - - Ok(()) - } - - pub fn download_specific(&self, episode_numbers: &[usize]) -> Result<()> { - let mut path = get_podcast_dir()?; - path.push(self.title()); - - let downloaded = already_downloaded(self.title())?; - let episodes = self.episodes(); +#[derive(Clone, Debug, PartialEq)] +pub struct Episode(Item); - episode_numbers.par_iter().for_each(|ep_num| { - if let Some(ep_title) = episodes[episodes.len() - ep_num].title() { - if !downloaded.contains(&ep_title) { - if let Err(err) = download(self.title(), &episodes[episodes.len() - ep_num]) { - eprintln!("{}", err); - } - } - } - }); - Ok(()) +impl From<Item> for Episode { + fn from(item: Item) -> Episode { + Episode(item) } } @@ -297,11 +234,14 @@ impl Episode { } } - pub fn extension(&self) -> Option<&str> { + pub fn extension(&self) -> Option<String> { match self.0.enclosure()?.mime_type() { - "audio/mpeg" => Some(".mp3"), - "audio/mp4" => Some(".m4a"), - "audio/ogg" => Some(".ogg"), + "audio/mpeg" => Some("mp3".into()), + "audio/mp4" => Some("m4a".into()), + "audio/aac" => Some("m4a".into()), + "audio/ogg" => Some("ogg".into()), + "audio/vorbis" => Some("ogg".into()), + "audio/opus" => Some("opus".into()), _ => find_extension(self.url().unwrap()), } } diff --git a/src/utils.rs b/src/utils.rs index f495805..e00df98 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; use std::env; use std::fs::{self, DirBuilder, File}; -use std::io::{BufReader, Read, Write}; +use std::io::{self, BufReader, Read, Write}; use std::path::PathBuf; use crate::errors::*; @@ -9,24 +9,6 @@ use dirs; use reqwest; use rss::Channel; -pub const UNABLE_TO_PARSE_REGEX: &str = "unable to parse regex"; -pub const UNABLE_TO_OPEN_FILE: &str = "unable to open file"; -pub const UNABLE_TO_CREATE_FILE: &str = "unable to create file"; -pub const UNABLE_TO_READ_FILE: &str = "unable to read file"; -pub const UNABLE_TO_WRITE_FILE: &str = "unable to write file"; -pub const UNABLE_TO_READ_FILE_TO_STRING: &str = "unable to read file to string"; -pub const UNABLE_TO_READ_DIRECTORY: &str = "unable to read directory"; -pub const UNABLE_TO_READ_ENTRY: &str = "unable to read entry"; -pub const UNABLE_TO_CREATE_DIRECTORY: &str = "unable to create directory"; -pub const UNABLE_TO_READ_RESPONSE_TO_END: &str = "unable to read response to end"; -pub const UNABLE_TO_GET_HTTP_RESPONSE: &str = "unable to get http response"; -pub const UNABLE_TO_CONVERT_TO_STR: &str = "unable to convert to &str"; -pub const UNABLE_TO_REMOVE_FILE: &str = "unable to remove file"; -pub const UNABLE_TO_CREATE_CHANNEL_FROM_RESPONSE: &str = - "unable to create channel from http response"; -pub const UNABLE_TO_CREATE_CHANNEL_FROM_FILE: &str = "unable to create channel from xml file"; -pub const UNABLE_TO_RETRIEVE_PODCAST_BY_TITLE: &str = "unable to retrieve podcast by title"; - const UNSUBSCRIBE_NOTE: &str = "Note: this does NOT delete any downloaded podcasts"; pub fn trim_extension(filename: &str) -> Option<String> { @@ -35,28 +17,19 @@ pub fn trim_extension(filename: &str) -> Option<String> { Some(String::from(&name[0..index])) } -pub fn find_extension(input: &str) -> Option<&str> { - let tmp = String::from(input); - if tmp.ends_with(".mp3") { - Some(".mp3") - } else if tmp.ends_with(".m4a") { - Some(".m4a") - } else if tmp.ends_with(".wav") { - Some(".wav") - } else if tmp.ends_with(".ogg") { - Some(".ogg") - } else if tmp.ends_with(".opus") { - Some(".opus") - } else { - None +pub fn find_extension(input: &str) -> Option<String> { + let s: Vec<String> = input.split(".").map(|s| s.to_string()).collect(); + if s.len() > 1 { + return s.last().cloned(); } + None } pub fn get_podcast_dir() -> Result<PathBuf> { match env::var_os("PODCAST") { Some(val) => Ok(PathBuf::from(val)), None => { - let mut path = dirs::home_dir().chain_err(|| "Couldn't find your home directory")?; + let mut path = dirs::home_dir().unwrap(); path.push("Podcasts"); Ok(path) } @@ -64,15 +37,13 @@ pub fn get_podcast_dir() -> Result<PathBuf> { } pub fn create_dir_if_not_exist(path: &PathBuf) -> Result<()> { - DirBuilder::new() - .recursive(true) - .create(&path) - .chain_err(|| UNABLE_TO_CREATE_DIRECTORY)?; + DirBuilder::new().recursive(true).create(&path)?; Ok(()) } pub fn create_directories() -> Result<()> { let mut path = get_podcast_dir()?; + writeln!(io::stdout().lock(), "Using PODCAST dir: {:?}", &path).ok(); path.push(".rss"); create_dir_if_not_exist(&path) } @@ -84,14 +55,15 @@ pub fn delete(title: &str) -> Result<()> { path.push(filename); println!("Removing '{}' from subscriptions...", &title); println!("{}", UNSUBSCRIBE_NOTE); - fs::remove_file(path).chain_err(|| UNABLE_TO_REMOVE_FILE) + fs::remove_file(path)?; + Ok(()) } - pub fn delete_all() -> Result<()> { println!("Removing all subscriptions..."); println!("{}", UNSUBSCRIBE_NOTE); - fs::remove_dir_all(get_xml_dir()?).chain_err(|| UNABLE_TO_READ_DIRECTORY) + fs::remove_dir_all(get_xml_dir()?)?; + Ok(()) } pub fn already_downloaded(dir: &str) -> Result<HashSet<String>> { @@ -100,12 +72,12 @@ pub fn already_downloaded(dir: &str) -> Result<HashSet<String>> { let mut path = get_podcast_dir()?; path.push(dir); - let entries = fs::read_dir(path).chain_err(|| "unable to read directory")?; + let entries = fs::read_dir(path)?; for entry in entries { - let entry = entry.chain_err(|| "unable to read entry")?; + let entry = entry?; match entry.file_name().into_string() { Ok(name) => { - let index = name.find('.').chain_err(|| "unable to find string index")?; + let index = name.find('.').unwrap(); result.insert(String::from(&name[0..index])); } Err(_) => { @@ -136,85 +108,55 @@ pub fn download_rss_feed(url: &str) -> Result<Channel> { let mut path = get_podcast_dir()?; path.push(".rss"); create_dir_if_not_exist(&path)?; - let mut resp = reqwest::get(url).chain_err(|| "unable to open url")?; + let mut resp = reqwest::get(url)?; let mut content: Vec<u8> = Vec::new(); - resp.read_to_end(&mut content) - .chain_err(|| "unable to read http response to end")?; - let channel = Channel::read_from(BufReader::new(&content[..])) - .chain_err(|| "unable to create channel from xml http response")?; + resp.read_to_end(&mut content)?; + let channel = Channel::read_from(BufReader::new(&content[..]))?; let mut filename = String::from(channel.title()); filename.push_str(".xml"); path.push(filename); - let mut file = File::create(&path).chain_err(|| "unable to create file")?; - file.write_all(&content) - .chain_err(|| "unable to write file")?; + let mut file = File::create(&path)?; + file.write_all(&content)?; Ok(channel) } -pub fn parse_download_episodes(e_search: &str) -> Result<Vec<usize>> { - let input = String::from(e_search); - let mut ranges = Vec::<(usize, usize)>::new(); - let mut elements = Vec::<usize>::new(); - let comma_separated: Vec<&str> = input.split(',').collect(); - for elem in comma_separated { - let temp = String::from(elem); - if temp.contains('-') { - let range: Vec<usize> = elem - .split('-') - .map(|i| i.parse::<usize>().chain_err(|| "unable to parse number")) - .collect::<Result<Vec<usize>>>() - .chain_err(|| "unable to collect ranges")?; - ranges.push((range[0], range[1])); - } else { - elements.push( - elem.parse::<usize>() - .chain_err(|| "unable to parse number")?, - ); - } - } - - for range in ranges { - // Add 1 to upper range to include given episode in the download - for num in range.0..=range.1 { - elements.push(num); - } - } - elements.dedup(); - Ok(elements) -} - #[cfg(test)] mod tests { use super::*; #[test] fn test_find_extension_mp3() { - assert_eq!(find_extension("test.mp3"), Some(".mp3")) + assert_eq!(find_extension("test.mp3"), Some("mp3".into())) } #[test] fn test_find_extension_m4a() { - assert_eq!(find_extension("test.m4a"), Some(".m4a")) + assert_eq!(find_extension("test.m4a"), Some("m4a".into())) } #[test] fn test_find_extension_wav() { - assert_eq!(find_extension("test.wav"), Some(".wav")) + assert_eq!(find_extension("test.wav"), Some("wav".into())) } #[test] fn test_find_extension_ogg() { - assert_eq!(find_extension("test.ogg"), Some(".ogg")) + assert_eq!(find_extension("test.ogg"), Some("ogg".into())) } #[test] fn test_find_extension_opus() { - assert_eq!(find_extension("test.opus"), Some(".opus")) + assert_eq!(find_extension("test.opus"), Some("opus".into())) + } + + #[test] + fn test_find_weird_extension() { + assert_eq!(find_extension("test.taco"), Some("taco".into())) } #[test] - fn test_find_extension_invalid() { - assert_eq!(find_extension("test.taco"), None) + fn test_find_no_extension() { + assert_eq!(find_extension("test"), None) } #[test] |
