about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-02-14 16:28:28 +0100
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-02-14 16:29:39 +0100
commitb3785ad44cb48143ed44cee48190b8646d668946 (patch)
treee5ff85497b93fd70cf418053c8482cea1f92b4eb
parentfeat(version): Include `yt-dlp` and `python` version in `--version` (diff)
downloadyt-b3785ad44cb48143ed44cee48190b8646d668946.zip
feat(yt/select/cmds/add): Support `start` `stop` args
These allow you to only add playlist entries from `start` to `stop`.
-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?;