aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--yt/src/cli.rs16
-rw-r--r--yt/src/download/download_options.rs12
-rw-r--r--yt/src/select/cmds/add.rs167
-rw-r--r--yt/src/select/cmds/mod.rs (renamed from yt/src/select/cmds.rs)71
4 files changed, 188 insertions, 78 deletions
diff --git a/yt/src/cli.rs b/yt/src/cli.rs
index 5e7af5b..b110772 100644
--- a/yt/src/cli.rs
+++ b/yt/src/cli.rs
@@ -277,8 +277,22 @@ pub enum SelectCommand {
},
/// Add a video to the database
+ ///
+ /// This optionally supports to add a playlist.
+ /// When a playlist is added, the `start` and `stop` arguments can be used to select which
+ /// playlist entries to include.
#[command(visible_alias = "a")]
- Add { urls: Vec<Url> },
+ Add {
+ urls: Vec<Url>,
+
+ /// Start adding playlist entries at this playlist index (zero based and inclusive)
+ #[arg(short = 's', long)]
+ start: Option<usize>,
+
+ /// Stop adding playlist entries at this playlist index (zero based and inclusive)
+ #[arg(short = 'e', long)]
+ stop: Option<usize>,
+ },
/// Mark the video given by the hash to be watched
#[command(visible_alias = "w")]
diff --git a/yt/src/download/download_options.rs b/yt/src/download/download_options.rs
index 1dc0bf2..148ee56 100644
--- a/yt/src/download/download_options.rs
+++ b/yt/src/download/download_options.rs
@@ -12,20 +12,10 @@ use serde_json::{json, Value};
use crate::{app::App, storage::video_database::YtDlpOptions};
-// {
-// "ratelimit": conf.ratelimit if conf.ratelimit > 0 else None,
-// "retries": conf.retries,
-// "merge_output_format": conf.merge_output_format,
-// "restrictfilenames": conf.restrict_filenames,
-// "ignoreerrors": False,
-// "postprocessors": [{"key": "FFmpegMetadata"}],
-// "logger": _ytdl_logger
-// }
-
#[must_use]
pub fn download_opts(app: &App, additional_opts: &YtDlpOptions) -> serde_json::Map<String, Value> {
match json!({
- "extract_flat": false,
+ "extract_flat": "in_playlist",
"extractor_args": {
"youtube": {
"comment_sort": [
diff --git a/yt/src/select/cmds/add.rs b/yt/src/select/cmds/add.rs
new file mode 100644
index 0000000..ea39789
--- /dev/null
+++ b/yt/src/select/cmds/add.rs
@@ -0,0 +1,167 @@
+use crate::{
+ app::App,
+ download::download_options::download_opts,
+ storage::video_database::{self, setters::add_video},
+ unreachable::Unreachable,
+ update::video_entry_to_video,
+ videos::display::format_video::FormatVideo,
+};
+
+use anyhow::{bail, Context, Result};
+use log::warn;
+use serde_json::{Map, Value};
+use url::Url;
+use yt_dlp::wrapper::info_json::InfoType;
+
+pub(super) async fn add(
+ app: &App,
+ urls: Vec<Url>,
+ start: Option<usize>,
+ stop: Option<usize>,
+) -> Result<()> {
+ for url in urls {
+ async fn process_and_add(
+ app: &App,
+ entry: yt_dlp::wrapper::info_json::InfoJson,
+ opts: &Map<String, Value>,
+ ) -> Result<()> {
+ let url = entry
+ .url
+ .unreachable("`yt_dlp` should guarantee that this is Some at this point");
+
+ let entry = yt_dlp::extract_info(opts, &url, false, true)
+ .await
+ .with_context(|| format!("Failed to fetch entry for url: '{url}'"))?;
+
+ add_entry(app, entry).await?;
+
+ Ok(())
+ }
+
+ async fn add_entry(app: &App, entry: yt_dlp::wrapper::info_json::InfoJson) -> Result<()> {
+ let video = video_entry_to_video(entry, None)?;
+ add_video(app, video.clone()).await?;
+
+ println!(
+ "{}",
+ (&video.to_formatted_video(app).await?.colorize(app)).to_line_display()
+ );
+
+ Ok(())
+ }
+
+ let opts = download_opts(
+ app,
+ &video_database::YtDlpOptions {
+ subtitle_langs: String::new(),
+ },
+ );
+
+ let entry = yt_dlp::extract_info(&opts, &url, false, true)
+ .await
+ .with_context(|| format!("Failed to fetch entry for url: '{url}'"))?;
+
+ match entry._type {
+ Some(InfoType::Video) => {
+ add_entry(app, entry).await?;
+ if start.is_some() || stop.is_some() {
+ warn!("You added `start` and `stop` markers for a single /video/! These will be ignored.");
+ }
+ }
+ Some(InfoType::Playlist) => {
+ if let Some(entries) = entry.entries {
+ let start = start.unwrap_or(0);
+ let stop = stop.unwrap_or(entries.len() - 1);
+
+ let mut respected_entries: Vec<_> =
+ take_vector(entries, start, stop)
+ .with_context(|| {
+ format!(
+ "Failed to take entries starting at: {start} and ending with {stop}")
+ })?;
+
+ if respected_entries.is_empty() {
+ warn!("No entries found, after applying your start/stop limits.");
+ } else {
+ // Pre-warm the cache
+ process_and_add(app, respected_entries.remove(0), &opts).await?;
+
+ let futures: Vec<_> = respected_entries
+ .into_iter()
+ .map(|entry| process_and_add(app, entry, &opts))
+ .collect();
+
+ for fut in futures {
+ fut.await?;
+ }
+ }
+ } else {
+ bail!("Your playlist does not seem to have any entries!")
+ }
+ }
+ other => bail!(
+ "Your URL should point to a video or a playlist, but points to a '{:#?}'",
+ other
+ ),
+ }
+ }
+
+ Ok(())
+}
+
+fn take_vector<T>(vector: Vec<T>, start: usize, stop: usize) -> Result<Vec<T>> {
+ let length = vector.len();
+
+ if stop >= length {
+ bail!("Your stop marker ({stop}) exceeds the possible entries ({length})! Remember that it is zero indexed.");
+ }
+
+ let end_skip = {
+ let base = length
+ .checked_sub(stop)
+ .unreachable("The check above should have caught this case.");
+
+ base.checked_sub(1)
+ .unreachable("The check above should have caught this case.")
+ };
+
+ // NOTE: We're using this instead of the `vector[start..=stop]` notation, because I wanted to
+ // avoid the needed allocation to turn the slice into a vector. <2025-01-04>
+
+ // TODO: This function could also just return a slice, but oh well.. <2025-01-04>
+ Ok(vector
+ .into_iter()
+ .skip(start)
+ .rev()
+ .skip(end_skip)
+ .rev()
+ .collect())
+}
+
+#[cfg(test)]
+mod test {
+ use crate::select::cmds::add::take_vector;
+
+ #[test]
+ fn test_vector_take() {
+ let vec = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
+
+ let new_vec = take_vector(vec, 2, 8).unwrap();
+
+ assert_eq!(new_vec, vec![2, 3, 4, 5, 6, 7, 8]);
+ }
+
+ #[test]
+ fn test_vector_take_overflow() {
+ let vec = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
+
+ assert!(take_vector(vec, 0, 12).is_err());
+ }
+
+ #[test]
+ fn test_vector_take_equal() {
+ let vec = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
+
+ assert!(take_vector(vec, 0, 11).is_err());
+ }
+}
diff --git a/yt/src/select/cmds.rs b/yt/src/select/cmds/mod.rs
index 0d06bd5..f576241 100644
--- a/yt/src/select/cmds.rs
+++ b/yt/src/select/cmds/mod.rs
@@ -11,20 +11,16 @@
use crate::{
app::App,
cli::{SelectCommand, SharedSelectionCommandArgs},
- download::download_options::download_opts,
storage::video_database::{
- self,
getters::get_video_by_hash,
- setters::{add_video, set_video_options, set_video_status},
+ setters::{set_video_options, set_video_status},
VideoOptions, VideoStatus,
},
- update::video_entry_to_video,
- videos::display::format_video::FormatVideo,
};
-use anyhow::{bail, Context, Result};
-use futures::future::join_all;
-use yt_dlp::wrapper::info_json::InfoType;
+use anyhow::{Context, Result};
+
+mod add;
pub async fn handle_select_cmd(
app: &App,
@@ -41,64 +37,7 @@ pub async fn handle_select_cmd(
SelectCommand::Watched { shared } => {
handle_status_change(app, shared, line_number, VideoStatus::Watched).await?;
}
- SelectCommand::Add { urls } => {
- for url in urls {
- async fn add_entry(
- app: &App,
- entry: yt_dlp::wrapper::info_json::InfoJson,
- ) -> Result<()> {
- let video = video_entry_to_video(entry, None)?;
- add_video(app, video.clone()).await?;
-
- println!(
- "{}",
- (&video.to_formatted_video(app).await?.colorize()).to_line_display()
- );
-
- Ok(())
- }
-
- let opts = download_opts(
- app,
- &video_database::YtDlpOptions {
- subtitle_langs: String::new(),
- },
- );
- let entry = yt_dlp::extract_info(&opts, &url, false, true)
- .await
- .with_context(|| format!("Failed to fetch entry for url: '{url}'"))?;
-
- match entry._type {
- Some(InfoType::Video) => {
- add_entry(app, entry).await?;
- }
- Some(InfoType::Playlist) => {
- if let Some(mut entries) = entry.entries {
- if !entries.is_empty() {
- // Pre-warm the cache
- add_entry(app, entries.remove(0)).await?;
-
- let futures: Vec<_> = entries
- .into_iter()
- .map(|entry| add_entry(app, entry))
- .collect();
-
- join_all(futures)
- .await
- .into_iter()
- .collect::<Result<()>>()?;
- }
- } else {
- bail!("Your playlist does not seem to have any entries!")
- }
- }
- other => bail!(
- "Your URL should point to a video or a playlist, but points to a '{:#?}'",
- other
- ),
- }
- }
- }
+ SelectCommand::Add { urls, start, stop } => add::add(app, urls, start, stop).await?,
SelectCommand::Watch { shared } => {
let hash = shared.hash.clone().realize(app).await?;