diff options
| -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] | 
