diff --git a/Cargo.lock b/Cargo.lock index 4a77d79..dedc0ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -355,6 +355,7 @@ dependencies = [ "crunchyroll-rs", "ctrlc", "derive_setters", + "dialoguer", "dirs", "fs2", "indicatif", @@ -370,7 +371,6 @@ dependencies = [ "shlex", "sys-locale", "tempfile", - "terminal_size", "tokio", ] @@ -501,6 +501,16 @@ dependencies = [ "syn", ] +[[package]] +name = "dialoguer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59c6f2989294b9a498d3ad5491a79c6deb604617378e1cdc4bfc1c1361fe2f87" +dependencies = [ + "console", + "shell-words", +] + [[package]] name = "dirs" version = "5.0.1" @@ -1539,6 +1549,12 @@ dependencies = [ "syn", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "shlex" version = "1.1.0" @@ -1628,16 +1644,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "terminal_size" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e6bf6f19e9f8ed8d4048dc22981458ebcf406d67e94cd422e5ecd73d63b3237" -dependencies = [ - "rustix", - "windows-sys 0.48.0", -] - [[package]] name = "thiserror" version = "1.0.40" diff --git a/crunchy-cli-core/Cargo.toml b/crunchy-cli-core/Cargo.toml index 10b69fe..cf2b7c3 100644 --- a/crunchy-cli-core/Cargo.toml +++ b/crunchy-cli-core/Cargo.toml @@ -11,6 +11,7 @@ clap = { version = "4.3", features = ["derive", "string"] } chrono = "0.4" crunchyroll-rs = { version = "0.3.6", features = ["dash-stream"] } ctrlc = "3.4" +dialoguer = { version = "0.10", default-features = false } dirs = "5.0" derive_setters = "0.1" fs2 = "0.4" @@ -26,7 +27,6 @@ serde_json = "1.0" serde_plain = "1.0" shlex = "1.1" tempfile = "3.6" -terminal_size = "0.2" tokio = { version = "1.28", features = ["macros", "rt-multi-thread", "time"] } sys-locale = "0.3" diff --git a/crunchy-cli-core/src/archive/command.rs b/crunchy-cli-core/src/archive/command.rs index bb2f30d..9a2ee21 100644 --- a/crunchy-cli-core/src/archive/command.rs +++ b/crunchy-cli-core/src/archive/command.rs @@ -98,6 +98,10 @@ pub struct Archive { #[arg(long, default_value_t = false)] pub(crate) skip_existing: bool, + #[arg(help = "Skip any interactive input")] + #[arg(short, long, default_value_t = false)] + pub(crate) yes: bool, + #[arg(help = "Crunchyroll series url(s)")] pub(crate) urls: Vec, } @@ -149,7 +153,7 @@ impl Execute for Archive { for (i, (media_collection, url_filter)) in parsed_urls.into_iter().enumerate() { let progress_handler = progress!("Fetching series details"); - let single_format_collection = ArchiveFilter::new(url_filter, self.clone()) + let single_format_collection = ArchiveFilter::new(url_filter, self.clone(), !self.yes) .visit(media_collection) .await?; diff --git a/crunchy-cli-core/src/archive/filter.rs b/crunchy-cli-core/src/archive/filter.rs index b181ae6..d6593e5 100644 --- a/crunchy-cli-core/src/archive/filter.rs +++ b/crunchy-cli-core/src/archive/filter.rs @@ -1,10 +1,11 @@ use crate::archive::command::Archive; use crate::utils::filter::{real_dedup_vec, Filter}; use crate::utils::format::{Format, SingleFormat, SingleFormatCollection}; +use crate::utils::interactive_select::{check_for_duplicated_seasons, get_duplicated_seasons}; use crate::utils::parse::UrlFilter; use anyhow::Result; use crunchyroll_rs::{Concert, Episode, Locale, Movie, MovieListing, MusicVideo, Season, Series}; -use log::warn; +use log::{info, warn}; use std::collections::{BTreeMap, HashMap}; enum Visited { @@ -16,6 +17,7 @@ enum Visited { pub(crate) struct ArchiveFilter { url_filter: UrlFilter, archive: Archive, + interactive_input: bool, season_episode_count: HashMap>, season_subtitles_missing: Vec, season_sorting: Vec, @@ -23,10 +25,11 @@ pub(crate) struct ArchiveFilter { } impl ArchiveFilter { - pub(crate) fn new(url_filter: UrlFilter, archive: Archive) -> Self { + pub(crate) fn new(url_filter: UrlFilter, archive: Archive, interactive_input: bool) -> Self { Self { url_filter, archive, + interactive_input, season_episode_count: HashMap::new(), season_subtitles_missing: vec![], season_sorting: vec![], @@ -71,7 +74,44 @@ impl Filter for ArchiveFilter { } self.visited = Visited::Series } - Ok(series.seasons().await?) + + let mut seasons = series.seasons().await?; + let mut remove_ids = vec![]; + for season in seasons.iter_mut() { + if !self.url_filter.is_season_valid(season.season_number) + && !season + .audio_locales + .iter() + .any(|l| self.archive.audio.contains(l)) + && !season + .available_versions() + .await? + .iter() + .any(|l| self.archive.audio.contains(l)) + { + remove_ids.push(season.id.clone()); + } + } + + seasons.retain(|s| !remove_ids.contains(&s.id)); + + let duplicated_seasons = get_duplicated_seasons(&seasons); + if duplicated_seasons.len() > 0 { + if self.interactive_input { + check_for_duplicated_seasons(&mut seasons); + } else { + info!( + "Found duplicated seasons: {}", + duplicated_seasons + .iter() + .map(|d| d.to_string()) + .collect::>() + .join(", ") + ) + } + } + + Ok(seasons) } async fn visit_season(&mut self, mut season: Season) -> Result> { diff --git a/crunchy-cli-core/src/download/command.rs b/crunchy-cli-core/src/download/command.rs index 644e4e5..0167f49 100644 --- a/crunchy-cli-core/src/download/command.rs +++ b/crunchy-cli-core/src/download/command.rs @@ -73,6 +73,10 @@ pub struct Download { #[arg(long, default_value_t = false)] pub(crate) skip_existing: bool, + #[arg(help = "Skip any interactive input")] + #[arg(short, long, default_value_t = false)] + pub(crate) yes: bool, + #[arg(help = "Url(s) to Crunchyroll episodes or series")] pub(crate) urls: Vec, } @@ -119,7 +123,7 @@ impl Execute for Download { for (i, (media_collection, url_filter)) in parsed_urls.into_iter().enumerate() { let progress_handler = progress!("Fetching series details"); - let single_format_collection = DownloadFilter::new(url_filter, self.clone()) + let single_format_collection = DownloadFilter::new(url_filter, self.clone(), !self.yes) .visit(media_collection) .await?; diff --git a/crunchy-cli-core/src/download/filter.rs b/crunchy-cli-core/src/download/filter.rs index f6a4a2e..b04aef8 100644 --- a/crunchy-cli-core/src/download/filter.rs +++ b/crunchy-cli-core/src/download/filter.rs @@ -1,24 +1,27 @@ use crate::download::Download; use crate::utils::filter::Filter; use crate::utils::format::{Format, SingleFormat, SingleFormatCollection}; +use crate::utils::interactive_select::{check_for_duplicated_seasons, get_duplicated_seasons}; use crate::utils::parse::UrlFilter; use anyhow::{bail, Result}; use crunchyroll_rs::{Concert, Episode, Movie, MovieListing, MusicVideo, Season, Series}; -use log::{error, warn}; +use log::{error, info, warn}; use std::collections::HashMap; pub(crate) struct DownloadFilter { url_filter: UrlFilter, download: Download, + interactive_input: bool, season_episode_count: HashMap>, season_subtitles_missing: Vec, } impl DownloadFilter { - pub(crate) fn new(url_filter: UrlFilter, download: Download) -> Self { + pub(crate) fn new(url_filter: UrlFilter, download: Download, interactive_input: bool) -> Self { Self { url_filter, download, + interactive_input, season_episode_count: HashMap::new(), season_subtitles_missing: vec![], } @@ -43,42 +46,61 @@ impl Filter for DownloadFilter { } } - let seasons = series.seasons().await?; + let mut seasons = vec![]; + for mut season in series.seasons().await? { + if !self.url_filter.is_season_valid(season.season_number) { + continue; + } + + if !season + .audio_locales + .iter() + .any(|l| l == &self.download.audio) + { + if season + .available_versions() + .await? + .iter() + .any(|l| l == &self.download.audio) + { + season = season + .version(vec![self.download.audio.clone()]) + .await? + .remove(0) + } else { + error!( + "Season {} - '{}' is not available with {} audio", + season.season_number, + season.title, + self.download.audio.clone(), + ); + continue; + } + } + + seasons.push(season) + } + + let duplicated_seasons = get_duplicated_seasons(&seasons); + if duplicated_seasons.len() > 0 { + if self.interactive_input { + check_for_duplicated_seasons(&mut seasons); + } else { + info!( + "Found duplicated seasons: {}", + duplicated_seasons + .iter() + .map(|d| d.to_string()) + .collect::>() + .join(", ") + ) + } + } Ok(seasons) } - async fn visit_season(&mut self, mut season: Season) -> Result> { - if !self.url_filter.is_season_valid(season.season_number) { - return Ok(vec![]); - } - - if !season - .audio_locales - .iter() - .any(|l| l == &self.download.audio) - { - if season - .available_versions() - .await? - .iter() - .any(|l| l == &self.download.audio) - { - season = season - .version(vec![self.download.audio.clone()]) - .await? - .remove(0) - } else { - error!( - "Season {} - '{}' is not available with {} audio", - season.season_number, - season.title, - self.download.audio.clone(), - ); - return Ok(vec![]); - } - } - + async fn visit_season(&mut self, season: Season) -> Result> { let mut episodes = season.episodes().await?; if Format::has_relative_episodes_fmt(&self.download.output) { diff --git a/crunchy-cli-core/src/lib.rs b/crunchy-cli-core/src/lib.rs index 4de5826..7cb68ce 100644 --- a/crunchy-cli-core/src/lib.rs +++ b/crunchy-cli-core/src/lib.rs @@ -18,6 +18,7 @@ mod search; mod utils; pub use archive::Archive; +use dialoguer::console::Term; pub use download::Download; pub use login::Login; pub use search::Search; @@ -168,6 +169,9 @@ pub async fn cli_entrypoint() { } } } + // when pressing ctrl-c while interactively choosing seasons the cursor stays hidden, this + // line shows it again + let _ = Term::stdout().show_cursor(); std::process::exit(1) }) .unwrap(); diff --git a/crunchy-cli-core/src/utils/interactive_select.rs b/crunchy-cli-core/src/utils/interactive_select.rs new file mode 100644 index 0000000..85116e6 --- /dev/null +++ b/crunchy-cli-core/src/utils/interactive_select.rs @@ -0,0 +1,73 @@ +use crate::utils::log::progress_pause; +use crunchyroll_rs::Season; +use dialoguer::console::Term; +use dialoguer::MultiSelect; +use std::collections::BTreeMap; + +pub fn get_duplicated_seasons(seasons: &Vec) -> Vec { + let mut season_number_counter = BTreeMap::::new(); + for season in seasons { + season_number_counter + .entry(season.season_number) + .and_modify(|c| *c += 1) + .or_default(); + } + season_number_counter + .into_iter() + .filter_map(|(k, v)| if v > 0 { Some(k) } else { None }) + .collect() +} + +pub fn check_for_duplicated_seasons(seasons: &mut Vec) { + let mut as_map = BTreeMap::new(); + for season in seasons.iter() { + as_map + .entry(season.season_number) + .or_insert(vec![]) + .push(season) + } + + let duplicates: Vec<&Season> = as_map + .into_values() + .filter(|s| s.len() > 1) + .flatten() + .collect(); + progress_pause!(); + let _ = Term::stdout().clear_line(); + let keep = select( + "Duplicated seasons were found. Select the one you want to download (space to select/deselect; enter to continue)", + duplicates + .iter() + .map(|s| format!("Season {} ({})", s.season_number, s.title)) + .collect(), + ); + progress_pause!(); + + let mut remove_ids = vec![]; + for (i, duplicate) in duplicates.into_iter().enumerate() { + if !keep.contains(&i) { + remove_ids.push(duplicate.id.clone()) + } + } + + seasons.retain(|s| !remove_ids.contains(&s.id)); +} + +pub fn select(prompt: &str, input: Vec) -> Vec { + if input.is_empty() { + return vec![]; + } + + let def: Vec = (0..input.len()).map(|_| true).collect(); + + let selection = MultiSelect::new() + .with_prompt(prompt) + .items(&input[..]) + .defaults(&def[..]) + .clear(false) + .report(false) + .interact_on(&Term::stdout()) + .unwrap_or_default(); + + selection +} diff --git a/crunchy-cli-core/src/utils/log.rs b/crunchy-cli-core/src/utils/log.rs index 37bc5a9..c74b73d 100644 --- a/crunchy-cli-core/src/utils/log.rs +++ b/crunchy-cli-core/src/utils/log.rs @@ -1,4 +1,4 @@ -use indicatif::{ProgressBar, ProgressStyle}; +use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle}; use log::{ info, set_boxed_logger, set_max_level, Level, LevelFilter, Log, Metadata, Record, SetLoggerError, @@ -37,6 +37,15 @@ macro_rules! progress { } pub(crate) use progress; +macro_rules! progress_pause { + () => { + { + log::info!(target: "progress_pause", "") + } + } +} +pub(crate) use progress_pause; + macro_rules! tab_info { ($($arg:tt)+) => { if log::max_level() == log::LevelFilter::Debug { @@ -62,6 +71,7 @@ impl Log for CliLogger { fn log(&self, record: &Record) { if !self.enabled(record.metadata()) || (record.target() != "progress" + && record.target() != "progress_pause" && record.target() != "progress_end" && !record.target().starts_with("crunchy_cli")) { @@ -75,6 +85,16 @@ impl Log for CliLogger { match record.target() { "progress" => self.progress(record, false), + "progress_pause" => { + let progress = self.progress.lock().unwrap(); + if let Some(p) = &*progress { + p.set_draw_target(if p.is_hidden() { + ProgressDrawTarget::stdout() + } else { + ProgressDrawTarget::hidden() + }) + } + } "progress_end" => self.progress(record, true), _ => { if self.progress.lock().unwrap().is_some() { @@ -158,6 +178,7 @@ impl CliLogger { .unwrap() .tick_strings(&["—", "\\", "|", "/", finish_str]), ); + pb.set_draw_target(ProgressDrawTarget::stdout()); pb.enable_steady_tick(Duration::from_millis(200)); pb.set_message(msg); *progress = Some(pb) diff --git a/crunchy-cli-core/src/utils/mod.rs b/crunchy-cli-core/src/utils/mod.rs index c991cd8..d46cc33 100644 --- a/crunchy-cli-core/src/utils/mod.rs +++ b/crunchy-cli-core/src/utils/mod.rs @@ -4,6 +4,7 @@ pub mod download; pub mod ffmpeg; pub mod filter; pub mod format; +pub mod interactive_select; pub mod locale; pub mod log; pub mod os;