From b3785ad44cb48143ed44cee48190b8646d668946 Mon Sep 17 00:00:00 2001 From: Benedikt Peetz Date: Fri, 14 Feb 2025 16:28:28 +0100 Subject: feat(yt/select/cmds/add): Support `start` `stop` args These allow you to only add playlist entries from `start` to `stop`. --- yt/src/cli.rs | 16 +++- yt/src/download/download_options.rs | 12 +-- yt/src/select/cmds.rs | 151 -------------------------------- yt/src/select/cmds/add.rs | 167 ++++++++++++++++++++++++++++++++++++ yt/src/select/cmds/mod.rs | 90 +++++++++++++++++++ 5 files changed, 273 insertions(+), 163 deletions(-) delete mode 100644 yt/src/select/cmds.rs create mode 100644 yt/src/select/cmds/add.rs create mode 100644 yt/src/select/cmds/mod.rs 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 }, + Add { + urls: Vec, + + /// Start adding playlist entries at this playlist index (zero based and inclusive) + #[arg(short = 's', long)] + start: Option, + + /// Stop adding playlist entries at this playlist index (zero based and inclusive) + #[arg(short = 'e', long)] + stop: Option, + }, /// 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 { match json!({ - "extract_flat": false, + "extract_flat": "in_playlist", "extractor_args": { "youtube": { "comment_sort": [ diff --git a/yt/src/select/cmds.rs b/yt/src/select/cmds.rs deleted file mode 100644 index 0d06bd5..0000000 --- a/yt/src/select/cmds.rs +++ /dev/null @@ -1,151 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see . - -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}, - 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; - -pub async fn handle_select_cmd( - app: &App, - cmd: SelectCommand, - line_number: Option, -) -> Result<()> { - match cmd { - SelectCommand::Pick { shared } => { - handle_status_change(app, shared, line_number, VideoStatus::Pick).await?; - } - SelectCommand::Drop { shared } => { - handle_status_change(app, shared, line_number, VideoStatus::Drop).await?; - } - 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::>()?; - } - } 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::Watch { shared } => { - let hash = shared.hash.clone().realize(app).await?; - - let video = get_video_by_hash(app, &hash).await?; - if video.cache_path.is_some() { - handle_status_change(app, shared, line_number, VideoStatus::Cached).await?; - } else { - handle_status_change(app, shared, line_number, VideoStatus::Watch).await?; - } - } - - SelectCommand::Url { shared } => { - let mut firefox = std::process::Command::new("firefox"); - firefox.args(["-P", "timesinks.youtube"]); - firefox.arg(shared.url.as_str()); - let _handle = firefox.spawn().context("Failed to run firefox")?; - } - SelectCommand::File { .. } => unreachable!("This should have been filtered out"), - } - Ok(()) -} - -async fn handle_status_change( - app: &App, - shared: SharedSelectionCommandArgs, - line_number: Option, - new_status: VideoStatus, -) -> Result<()> { - let hash = shared.hash.realize(app).await?; - let video_options = VideoOptions::new( - shared - .subtitle_langs - .unwrap_or(app.config.select.subtitle_langs.clone()), - shared.speed.unwrap_or(app.config.select.playback_speed), - ); - let priority = compute_priority(line_number, shared.priority); - - set_video_status(app, &hash, new_status, priority).await?; - set_video_options(app, &hash, &video_options).await?; - - Ok(()) -} - -fn compute_priority(line_number: Option, priority: Option) -> Option { - if let Some(pri) = priority { - Some(pri) - } else { - line_number - } -} 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, + start: Option, + stop: Option, +) -> Result<()> { + for url in urls { + async fn process_and_add( + app: &App, + entry: yt_dlp::wrapper::info_json::InfoJson, + opts: &Map, + ) -> 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(vector: Vec, start: usize, stop: usize) -> Result> { + 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/mod.rs b/yt/src/select/cmds/mod.rs new file mode 100644 index 0000000..f576241 --- /dev/null +++ b/yt/src/select/cmds/mod.rs @@ -0,0 +1,90 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2024 Benedikt Peetz +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see . + +use crate::{ + app::App, + cli::{SelectCommand, SharedSelectionCommandArgs}, + storage::video_database::{ + getters::get_video_by_hash, + setters::{set_video_options, set_video_status}, + VideoOptions, VideoStatus, + }, +}; + +use anyhow::{Context, Result}; + +mod add; + +pub async fn handle_select_cmd( + app: &App, + cmd: SelectCommand, + line_number: Option, +) -> Result<()> { + match cmd { + SelectCommand::Pick { shared } => { + handle_status_change(app, shared, line_number, VideoStatus::Pick).await?; + } + SelectCommand::Drop { shared } => { + handle_status_change(app, shared, line_number, VideoStatus::Drop).await?; + } + SelectCommand::Watched { shared } => { + handle_status_change(app, shared, line_number, VideoStatus::Watched).await?; + } + SelectCommand::Add { urls, start, stop } => add::add(app, urls, start, stop).await?, + SelectCommand::Watch { shared } => { + let hash = shared.hash.clone().realize(app).await?; + + let video = get_video_by_hash(app, &hash).await?; + if video.cache_path.is_some() { + handle_status_change(app, shared, line_number, VideoStatus::Cached).await?; + } else { + handle_status_change(app, shared, line_number, VideoStatus::Watch).await?; + } + } + + SelectCommand::Url { shared } => { + let mut firefox = std::process::Command::new("firefox"); + firefox.args(["-P", "timesinks.youtube"]); + firefox.arg(shared.url.as_str()); + let _handle = firefox.spawn().context("Failed to run firefox")?; + } + SelectCommand::File { .. } => unreachable!("This should have been filtered out"), + } + Ok(()) +} + +async fn handle_status_change( + app: &App, + shared: SharedSelectionCommandArgs, + line_number: Option, + new_status: VideoStatus, +) -> Result<()> { + let hash = shared.hash.realize(app).await?; + let video_options = VideoOptions::new( + shared + .subtitle_langs + .unwrap_or(app.config.select.subtitle_langs.clone()), + shared.speed.unwrap_or(app.config.select.playback_speed), + ); + let priority = compute_priority(line_number, shared.priority); + + set_video_status(app, &hash, new_status, priority).await?; + set_video_options(app, &hash, &video_options).await?; + + Ok(()) +} + +fn compute_priority(line_number: Option, priority: Option) -> Option { + if let Some(pri) = priority { + Some(pri) + } else { + line_number + } +} -- cgit 1.4.1