diff --git a/crunchy-cli-core/src/archive/command.rs b/crunchy-cli-core/src/archive/command.rs index 3f455ed9..0ee723ec 100644 --- a/crunchy-cli-core/src/archive/command.rs +++ b/crunchy-cli-core/src/archive/command.rs @@ -39,19 +39,26 @@ pub struct Archive { #[arg(help = "Name of the output file")] #[arg(long_help = "Name of the output file.\ If you use one of the following pattern they will get replaced:\n \ - {title} → Title of the video\n \ - {series_name} → Name of the series\n \ - {season_name} → Name of the season\n \ - {audio} → Audio language of the video\n \ - {resolution} → Resolution of the video\n \ - {season_number} → Number of the season\n \ - {episode_number} → Number of the episode\n \ - {relative_episode_number} → Number of the episode relative to its season\n \ - {series_id} → ID of the series\n \ - {season_id} → ID of the season\n \ - {episode_id} → ID of the episode")] + {title} → Title of the video\n \ + {series_name} → Name of the series\n \ + {season_name} → Name of the season\n \ + {audio} → Audio language of the video\n \ + {resolution} → Resolution of the video\n \ + {season_number} → Number of the season\n \ + {episode_number} → Number of the episode\n \ + {relative_episode_number} → Number of the episode relative to its season\n \ + {sequence_number} → Like '{episode_number}' but without possible non-number characters\n \ + {relative_sequence_number} → Like '{relative_episode_number}' but with support for episode 0's and .5's\n \ + {series_id} → ID of the series\n \ + {season_id} → ID of the season\n \ + {episode_id} → ID of the episode")] #[arg(short, long, default_value = "{title}.mkv")] pub(crate) output: String, + #[arg(help = "Name of the output file if the episode is a special")] + #[arg(long_help = "Name of the output file if the episode is a special. \ + If not set, the '-o'/'--output' flag will be used as name template")] + #[arg(long)] + pub(crate) output_specials: Option, #[arg(help = "Video resolution")] #[arg(long_help = "The video resolution.\ @@ -93,6 +100,9 @@ pub struct Archive { #[arg(help = "Skip files which are already existing")] #[arg(long, default_value_t = false)] pub(crate) skip_existing: bool, + #[arg(help = "Skip special episodes")] + #[arg(long, default_value_t = false)] + pub(crate) skip_specials: bool, #[arg(help = "Skip any interactive input")] #[arg(short, long, default_value_t = false)] @@ -121,6 +131,17 @@ impl Execute for Archive { && self.output != "-" { bail!("File extension is not '.mkv'. Currently only matroska / '.mkv' files are supported") + } else if let Some(special_output) = &self.output_specials { + if PathBuf::from(special_output) + .extension() + .unwrap_or_default() + .to_string_lossy() + != "mkv" + && !is_special_file(special_output) + && special_output != "-" + { + bail!("File extension for special episodes is not '.mkv'. Currently only matroska / '.mkv' files are supported") + } } self.audio = all_locale_in_locales(self.audio.clone()); @@ -145,9 +166,10 @@ 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(), !self.yes) - .visit(media_collection) - .await?; + let single_format_collection = + ArchiveFilter::new(url_filter, self.clone(), !self.yes, self.skip_specials) + .visit(media_collection) + .await?; if single_format_collection.is_empty() { progress_handler.stop(format!("Skipping url {} (no matching videos found)", i + 1)); @@ -173,7 +195,15 @@ impl Execute for Archive { downloader.add_format(download_format) } - let formatted_path = format.format_path((&self.output).into()); + let formatted_path = if format.is_special() { + format.format_path( + self.output_specials + .as_ref() + .map_or((&self.output).into(), |so| so.into()), + ) + } else { + format.format_path((&self.output).into()) + }; let (path, changed) = free_file(formatted_path.clone()); if changed && self.skip_existing { diff --git a/crunchy-cli-core/src/archive/filter.rs b/crunchy-cli-core/src/archive/filter.rs index 851b4b50..a4e3188f 100644 --- a/crunchy-cli-core/src/archive/filter.rs +++ b/crunchy-cli-core/src/archive/filter.rs @@ -2,7 +2,7 @@ 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 crate::utils::parse::{fract, UrlFilter}; use anyhow::Result; use crunchyroll_rs::{Concert, Episode, Locale, Movie, MovieListing, MusicVideo, Season, Series}; use log::{info, warn}; @@ -18,19 +18,26 @@ pub(crate) struct ArchiveFilter { url_filter: UrlFilter, archive: Archive, interactive_input: bool, - season_episode_count: HashMap>, + skip_special: bool, + season_episodes: HashMap>, season_subtitles_missing: Vec, season_sorting: Vec, visited: Visited, } impl ArchiveFilter { - pub(crate) fn new(url_filter: UrlFilter, archive: Archive, interactive_input: bool) -> Self { + pub(crate) fn new( + url_filter: UrlFilter, + archive: Archive, + interactive_input: bool, + skip_special: bool, + ) -> Self { Self { url_filter, archive, interactive_input, - season_episode_count: HashMap::new(), + skip_special, + season_episodes: HashMap::new(), season_subtitles_missing: vec![], season_sorting: vec![], visited: Visited::None, @@ -226,12 +233,12 @@ impl Filter for ArchiveFilter { episodes.extend(eps) } - if Format::has_relative_episodes_fmt(&self.archive.output) { + if Format::has_relative_fmt(&self.archive.output) { for episode in episodes.iter() { - self.season_episode_count + self.season_episodes .entry(episode.season_id.clone()) .or_insert(vec![]) - .push(episode.id.clone()) + .push(episode.clone()) } } @@ -241,7 +248,14 @@ impl Filter for ArchiveFilter { async fn visit_episode(&mut self, mut episode: Episode) -> Result> { if !self .url_filter - .is_episode_valid(episode.episode_number, episode.season_number) + .is_episode_valid(episode.sequence_number, episode.season_number) + { + return Ok(None); + } + + // skip the episode if it's a special + if self.skip_special + && (episode.sequence_number == 0.0 || episode.sequence_number.fract() != 0.0) { return Ok(None); } @@ -299,22 +313,36 @@ impl Filter for ArchiveFilter { episodes.push((episode.clone(), episode.subtitle_locales.clone())) } - let relative_episode_number = if Format::has_relative_episodes_fmt(&self.archive.output) { - if self.season_episode_count.get(&episode.season_id).is_none() { - let season_episodes = episode.season().await?.episodes().await?; - self.season_episode_count.insert( - episode.season_id.clone(), - season_episodes.into_iter().map(|e| e.id).collect(), - ); + let mut relative_episode_number = None; + let mut relative_sequence_number = None; + // get the relative episode number. only done if the output string has the pattern to include + // the relative episode number as this requires some extra fetching + if Format::has_relative_fmt(&self.archive.output) { + let season_eps = match self.season_episodes.get(&episode.season_id) { + Some(eps) => eps, + None => { + self.season_episodes.insert( + episode.season_id.clone(), + episode.season().await?.episodes().await?, + ); + self.season_episodes.get(&episode.season_id).unwrap() + } + }; + let mut non_integer_sequence_number_count = 0; + for (i, ep) in season_eps.iter().enumerate() { + if ep.sequence_number.fract() != 0.0 || ep.sequence_number == 0.0 { + non_integer_sequence_number_count += 1; + } + if ep.id == episode.id { + relative_episode_number = Some(i + 1); + relative_sequence_number = Some( + (i + 1 - non_integer_sequence_number_count) as f32 + + fract(ep.sequence_number), + ); + break; + } } - let relative_episode_number = self - .season_episode_count - .get(&episode.season_id) - .unwrap() - .iter() - .position(|id| id == &episode.id) - .map(|index| index + 1); - if relative_episode_number.is_none() { + if relative_episode_number.is_none() || relative_sequence_number.is_none() { warn!( "Failed to get relative episode number for episode {} ({}) of {} season {}", episode.episode_number, @@ -323,16 +351,18 @@ impl Filter for ArchiveFilter { episode.season_number, ) } - relative_episode_number - } else { - None - }; + } Ok(Some( episodes .into_iter() .map(|(e, s)| { - SingleFormat::new_from_episode(e, s, relative_episode_number.map(|n| n as u32)) + SingleFormat::new_from_episode( + e, + s, + relative_episode_number.map(|n| n as u32), + relative_sequence_number, + ) }) .collect(), )) diff --git a/crunchy-cli-core/src/download/command.rs b/crunchy-cli-core/src/download/command.rs index 9c9ecb4d..18355cde 100644 --- a/crunchy-cli-core/src/download/command.rs +++ b/crunchy-cli-core/src/download/command.rs @@ -35,19 +35,26 @@ pub struct Download { #[arg(help = "Name of the output file")] #[arg(long_help = "Name of the output file.\ If you use one of the following pattern they will get replaced:\n \ - {title} → Title of the video\n \ - {series_name} → Name of the series\n \ - {season_name} → Name of the season\n \ - {audio} → Audio language of the video\n \ - {resolution} → Resolution of the video\n \ - {season_number} → Number of the season\n \ - {episode_number} → Number of the episode\n \ - {relative_episode_number} → Number of the episode relative to its season\n \ - {series_id} → ID of the series\n \ - {season_id} → ID of the season\n \ - {episode_id} → ID of the episode")] + {title} → Title of the video\n \ + {series_name} → Name of the series\n \ + {season_name} → Name of the season\n \ + {audio} → Audio language of the video\n \ + {resolution} → Resolution of the video\n \ + {season_number} → Number of the season\n \ + {episode_number} → Number of the episode\n \ + {relative_episode_number} → Number of the episode relative to its season\n \ + {sequence_number} → Like '{episode_number}' but without possible non-number characters\n \ + {relative_sequence_number} → Like '{relative_episode_number}' but with support for episode 0's and .5's\n \ + {series_id} → ID of the series\n \ + {season_id} → ID of the season\n \ + {episode_id} → ID of the episode")] #[arg(short, long, default_value = "{title}.mp4")] pub(crate) output: String, + #[arg(help = "Name of the output file if the episode is a special")] + #[arg(long_help = "Name of the output file if the episode is a special. \ + If not set, the '-o'/'--output' flag will be used as name template")] + #[arg(long)] + pub(crate) output_specials: Option, #[arg(help = "Video resolution")] #[arg(long_help = "The video resolution.\ @@ -71,6 +78,9 @@ pub struct Download { #[arg(help = "Skip files which are already existing")] #[arg(long, default_value_t = false)] pub(crate) skip_existing: bool, + #[arg(help = "Skip special episodes")] + #[arg(long, default_value_t = false)] + pub(crate) skip_specials: bool, #[arg(help = "Skip any interactive input")] #[arg(short, long, default_value_t = false)] @@ -114,6 +124,25 @@ impl Execute for Download { } } + if let Some(special_output) = &self.output_specials { + if Path::new(special_output) + .extension() + .unwrap_or_default() + .is_empty() + && !is_special_file(special_output) + && special_output != "-" + { + bail!("No file extension found. Please specify a file extension (via `--output-specials`) for the output file") + } + if let Some(ext) = Path::new(special_output).extension() { + if self.force_hardsub { + warn!("Hardsubs are forced for special episodes. Adding subtitles may take a while") + } else if !["mkv", "mov", "mp4"].contains(&ext.to_string_lossy().as_ref()) { + warn!("Detected a container which does not support softsubs. Adding subtitles for special episodes may take a while") + } + } + } + Ok(()) } @@ -133,9 +162,10 @@ 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(), !self.yes) - .visit(media_collection) - .await?; + let single_format_collection = + DownloadFilter::new(url_filter, self.clone(), !self.yes, self.skip_specials) + .visit(media_collection) + .await?; if single_format_collection.is_empty() { progress_handler.stop(format!("Skipping url {} (no matching videos found)", i + 1)); @@ -165,7 +195,15 @@ impl Execute for Download { let mut downloader = download_builder.clone().build(); downloader.add_format(download_format); - let formatted_path = format.format_path((&self.output).into()); + let formatted_path = if format.is_special() { + format.format_path( + self.output_specials + .as_ref() + .map_or((&self.output).into(), |so| so.into()), + ) + } else { + format.format_path((&self.output).into()) + }; let (path, changed) = free_file(formatted_path.clone()); if changed && self.skip_existing { diff --git a/crunchy-cli-core/src/download/filter.rs b/crunchy-cli-core/src/download/filter.rs index 31c0db6c..626896c9 100644 --- a/crunchy-cli-core/src/download/filter.rs +++ b/crunchy-cli-core/src/download/filter.rs @@ -2,7 +2,7 @@ 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 crate::utils::parse::{fract, UrlFilter}; use anyhow::{bail, Result}; use crunchyroll_rs::{Concert, Episode, Movie, MovieListing, MusicVideo, Season, Series}; use log::{error, info, warn}; @@ -12,18 +12,25 @@ pub(crate) struct DownloadFilter { url_filter: UrlFilter, download: Download, interactive_input: bool, - season_episode_count: HashMap>, + skip_special: bool, + season_episodes: HashMap>, season_subtitles_missing: Vec, season_visited: bool, } impl DownloadFilter { - pub(crate) fn new(url_filter: UrlFilter, download: Download, interactive_input: bool) -> Self { + pub(crate) fn new( + url_filter: UrlFilter, + download: Download, + interactive_input: bool, + skip_special: bool, + ) -> Self { Self { url_filter, download, interactive_input, - season_episode_count: HashMap::new(), + skip_special, + season_episodes: HashMap::new(), season_subtitles_missing: vec![], season_visited: false, } @@ -107,18 +114,18 @@ impl Filter for DownloadFilter { let mut episodes = season.episodes().await?; - if Format::has_relative_episodes_fmt(&self.download.output) { + if Format::has_relative_fmt(&self.download.output) { for episode in episodes.iter() { - self.season_episode_count + self.season_episodes .entry(episode.season_number) .or_insert(vec![]) - .push(episode.id.clone()) + .push(episode.clone()) } } episodes.retain(|e| { self.url_filter - .is_episode_valid(e.episode_number, season.season_number) + .is_episode_valid(e.sequence_number, season.season_number) }); Ok(episodes) @@ -127,7 +134,14 @@ impl Filter for DownloadFilter { async fn visit_episode(&mut self, mut episode: Episode) -> Result> { if !self .url_filter - .is_episode_valid(episode.episode_number, episode.season_number) + .is_episode_valid(episode.sequence_number, episode.season_number) + { + return Ok(None); + } + + // skip the episode if it's a special + if self.skip_special + && (episode.sequence_number == 0.0 || episode.sequence_number.fract() != 0.0) { return Ok(None); } @@ -189,28 +203,36 @@ impl Filter for DownloadFilter { } } + let mut relative_episode_number = None; + let mut relative_sequence_number = None; // get the relative episode number. only done if the output string has the pattern to include // the relative episode number as this requires some extra fetching - let relative_episode_number = if Format::has_relative_episodes_fmt(&self.download.output) { - if self - .season_episode_count - .get(&episode.season_number) - .is_none() - { - let season_episodes = episode.season().await?.episodes().await?; - self.season_episode_count.insert( - episode.season_number, - season_episodes.into_iter().map(|e| e.id).collect(), - ); + if Format::has_relative_fmt(&self.download.output) { + let season_eps = match self.season_episodes.get(&episode.season_number) { + Some(eps) => eps, + None => { + self.season_episodes.insert( + episode.season_number, + episode.season().await?.episodes().await?, + ); + self.season_episodes.get(&episode.season_number).unwrap() + } + }; + let mut non_integer_sequence_number_count = 0; + for (i, ep) in season_eps.iter().enumerate() { + if ep.sequence_number.fract() != 0.0 || ep.sequence_number == 0.0 { + non_integer_sequence_number_count += 1; + } + if ep.id == episode.id { + relative_episode_number = Some(i + 1); + relative_sequence_number = Some( + (i + 1 - non_integer_sequence_number_count) as f32 + + fract(ep.sequence_number), + ); + break; + } } - let relative_episode_number = self - .season_episode_count - .get(&episode.season_number) - .unwrap() - .iter() - .position(|id| id == &episode.id) - .map(|index| index + 1); - if relative_episode_number.is_none() { + if relative_episode_number.is_none() || relative_sequence_number.is_none() { warn!( "Failed to get relative episode number for episode {} ({}) of {} season {}", episode.episode_number, @@ -219,10 +241,7 @@ impl Filter for DownloadFilter { episode.season_number, ) } - relative_episode_number - } else { - None - }; + } Ok(Some(SingleFormat::new_from_episode( episode.clone(), @@ -234,6 +253,7 @@ impl Filter for DownloadFilter { } }), relative_episode_number.map(|n| n as u32), + relative_sequence_number, ))) } diff --git a/crunchy-cli-core/src/search/filter.rs b/crunchy-cli-core/src/search/filter.rs index 0b31823e..264b31d0 100644 --- a/crunchy-cli-core/src/search/filter.rs +++ b/crunchy-cli-core/src/search/filter.rs @@ -24,7 +24,7 @@ impl FilterOptions { self.check_audio_language(&vec![e.audio_locale.clone()]) && self .url_filter - .is_episode_valid(e.episode_number, e.season_number) + .is_episode_valid(e.sequence_number, e.season_number) }); episodes } diff --git a/crunchy-cli-core/src/utils/format.rs b/crunchy-cli-core/src/utils/format.rs index f462263b..4afaa077 100644 --- a/crunchy-cli-core/src/utils/format.rs +++ b/crunchy-cli-core/src/utils/format.rs @@ -29,8 +29,9 @@ pub struct SingleFormat { pub episode_id: String, pub episode_number: String, - pub sequence_number: f32, pub relative_episode_number: Option, + pub sequence_number: f32, + pub relative_sequence_number: Option, pub duration: Duration, @@ -42,6 +43,7 @@ impl SingleFormat { episode: Episode, subtitles: Vec, relative_episode_number: Option, + relative_sequence_number: Option, ) -> Self { Self { identifier: if episode.identifier.is_empty() { @@ -73,6 +75,7 @@ impl SingleFormat { }, sequence_number: episode.sequence_number, relative_episode_number, + relative_sequence_number, duration: episode.duration, source: episode.into(), } @@ -92,8 +95,9 @@ impl SingleFormat { season_number: 1, episode_id: movie.id.clone(), episode_number: "1".to_string(), - sequence_number: 1.0, relative_episode_number: Some(1), + sequence_number: 1.0, + relative_sequence_number: Some(1.0), duration: movie.duration, source: movie.into(), } @@ -113,8 +117,9 @@ impl SingleFormat { season_number: 1, episode_id: music_video.id.clone(), episode_number: "1".to_string(), - sequence_number: 1.0, relative_episode_number: Some(1), + sequence_number: 1.0, + relative_sequence_number: Some(1.0), duration: music_video.duration, source: music_video.into(), } @@ -134,8 +139,9 @@ impl SingleFormat { season_number: 1, episode_id: concert.id.clone(), episode_number: "1".to_string(), - sequence_number: 1.0, relative_episode_number: Some(1), + sequence_number: 1.0, + relative_sequence_number: Some(1.0), duration: concert.duration, source: concert.into(), } @@ -328,8 +334,9 @@ pub struct Format { pub episode_id: String, pub episode_number: String, - pub sequence_number: f32, pub relative_episode_number: Option, + pub sequence_number: f32, + pub relative_sequence_number: Option, } impl Format { @@ -363,8 +370,9 @@ impl Format { season_number: first_format.season_number, episode_id: first_format.episode_id, episode_number: first_format.episode_number, - sequence_number: first_format.sequence_number, relative_episode_number: first_format.relative_episode_number, + sequence_number: first_format.sequence_number, + relative_sequence_number: first_format.relative_sequence_number, } } @@ -400,9 +408,28 @@ impl Format { ) .replace( "{relative_episode_number}", - &sanitize( - self.relative_episode_number.unwrap_or_default().to_string(), - true, + &format!( + "{:0>2}", + sanitize( + self.relative_episode_number.unwrap_or_default().to_string(), + true, + ) + ), + ) + .replace( + "{sequence_number}", + &format!("{:0>2}", sanitize(self.sequence_number.to_string(), true)), + ) + .replace( + "{relative_sequence_number}", + &format!( + "{:0>2}", + sanitize( + self.relative_sequence_number + .unwrap_or_default() + .to_string(), + true, + ) ), ); @@ -446,7 +473,12 @@ impl Format { tab_info!("FPS: {:.2}", self.fps) } - pub fn has_relative_episodes_fmt>(s: S) -> bool { - return s.as_ref().contains("{relative_episode_number}"); + pub fn is_special(&self) -> bool { + self.sequence_number == 0.0 || self.sequence_number.fract() != 0.0 + } + + pub fn has_relative_fmt>(s: S) -> bool { + return s.as_ref().contains("{relative_episode_number}") + || s.as_ref().contains("{relative_sequence_number}"); } } diff --git a/crunchy-cli-core/src/utils/parse.rs b/crunchy-cli-core/src/utils/parse.rs index f85d8c27..3fc7e7c4 100644 --- a/crunchy-cli-core/src/utils/parse.rs +++ b/crunchy-cli-core/src/utils/parse.rs @@ -10,8 +10,8 @@ use regex::Regex; /// If `to_*` is [`None`] they're set to [`u32::MAX`]. #[derive(Debug, Default)] pub struct InnerUrlFilter { - from_episode: Option, - to_episode: Option, + from_episode: Option, + to_episode: Option, from_season: Option, to_season: Option, } @@ -39,10 +39,10 @@ impl UrlFilter { }) } - pub fn is_episode_valid(&self, episode: u32, season: u32) -> bool { + pub fn is_episode_valid(&self, episode: f32, season: u32) -> bool { self.inner.iter().any(|f| { - let from_episode = f.from_episode.unwrap_or(u32::MIN); - let to_episode = f.to_episode.unwrap_or(u32::MAX); + let from_episode = f.from_episode.unwrap_or(f32::MIN); + let to_episode = f.to_episode.unwrap_or(f32::MAX); let from_season = f.from_season.unwrap_or(u32::MIN); let to_season = f.to_season.unwrap_or(u32::MAX); @@ -192,3 +192,13 @@ pub fn parse_resolution(mut resolution: String) -> Result { bail!("Could not find resolution") } } + +/// Dirty implementation of [`f32::fract`] with more accuracy. +pub fn fract(input: f32) -> f32 { + if input.fract() == 0.0 { + return 0.0; + } + format!("0.{}", input.to_string().split('.').last().unwrap()) + .parse::() + .unwrap() +}