diff options
author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2025-02-14 16:28:28 +0100 |
---|---|---|
committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2025-02-14 16:29:39 +0100 |
commit | b3785ad44cb48143ed44cee48190b8646d668946 (patch) | |
tree | e5ff85497b93fd70cf418053c8482cea1f92b4eb | |
parent | feat(version): Include `yt-dlp` and `python` version in `--version` (diff) | |
download | yt-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.rs | 16 | ||||
-rw-r--r-- | yt/src/download/download_options.rs | 12 | ||||
-rw-r--r-- | yt/src/select/cmds/add.rs | 167 | ||||
-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?; |