From c4524db090d2d31af8bc3e7ec64c1ea9f5ec72aa Mon Sep 17 00:00:00 2001 From: Benedikt Peetz Date: Fri, 18 Jul 2025 18:01:29 +0200 Subject: feat(crates/yt): Separate all commands from their implementation code This also comes with a re-worked and tested implementation of the comments rendering code. --- crates/yt/src/cache/mod.rs | 64 --- crates/yt/src/cli.rs | 458 +-------------------- crates/yt/src/commands/comments/implm/mod.rs | 15 + crates/yt/src/commands/comments/mod.rs | 6 + crates/yt/src/commands/config/implm.rs | 13 + crates/yt/src/commands/config/mod.rs | 6 + crates/yt/src/commands/description/implm.rs | 16 + crates/yt/src/commands/description/mod.rs | 6 + .../download/implm/download/download_options.rs | 121 ++++++ .../yt/src/commands/download/implm/download/mod.rs | 293 +++++++++++++ .../download/implm/download/progress_hook.rs | 175 ++++++++ crates/yt/src/commands/download/implm/mod.rs | 45 ++ crates/yt/src/commands/download/mod.rs | 24 ++ crates/yt/src/commands/mod.rs | 153 +++++++ crates/yt/src/commands/playlist/implm.rs | 105 +++++ crates/yt/src/commands/playlist/mod.rs | 10 + .../commands/select/implm/fs_generators/help.str | 12 + .../select/implm/fs_generators/help.str.license | 10 + .../src/commands/select/implm/fs_generators/mod.rs | 345 ++++++++++++++++ crates/yt/src/commands/select/implm/mod.rs | 42 ++ .../yt/src/commands/select/implm/standalone/add.rs | 186 +++++++++ .../yt/src/commands/select/implm/standalone/mod.rs | 122 ++++++ crates/yt/src/commands/select/mod.rs | 219 ++++++++++ crates/yt/src/commands/status/implm.rs | 147 +++++++ crates/yt/src/commands/status/mod.rs | 10 + crates/yt/src/commands/subscriptions/implm.rs | 243 +++++++++++ crates/yt/src/commands/subscriptions/mod.rs | 52 +++ crates/yt/src/commands/update/implm/mod.rs | 52 +++ crates/yt/src/commands/update/implm/updater.rs | 197 +++++++++ crates/yt/src/commands/update/mod.rs | 17 + crates/yt/src/commands/videos/implm.rs | 63 +++ crates/yt/src/commands/videos/mod.rs | 36 ++ crates/yt/src/commands/watch/implm/mod.rs | 20 + crates/yt/src/commands/watch/implm/watch/mod.rs | 235 +++++++++++ .../watch/playlist_handler/client_messages.rs | 99 +++++ .../watch/implm/watch/playlist_handler/mod.rs | 218 ++++++++++ crates/yt/src/commands/watch/mod.rs | 14 + crates/yt/src/comments/comment.rs | 132 ------ crates/yt/src/comments/description.rs | 39 -- crates/yt/src/comments/display.rs | 118 ------ crates/yt/src/comments/mod.rs | 162 -------- crates/yt/src/comments/output.rs | 53 --- crates/yt/src/constants.rs | 12 - crates/yt/src/download/download_options.rs | 121 ------ crates/yt/src/download/mod.rs | 389 ----------------- crates/yt/src/download/progress_hook.rs | 156 ------- crates/yt/src/main.rs | 225 +--------- crates/yt/src/output/mod.rs | 56 +++ crates/yt/src/select/cmds/add.rs | 187 --------- crates/yt/src/select/cmds/mod.rs | 133 ------ crates/yt/src/select/duration.rs | 240 +++++++++++ crates/yt/src/select/mod.rs | 316 +------------- crates/yt/src/select/selection_file/duration.rs | 240 ----------- crates/yt/src/select/selection_file/help.str | 12 - .../yt/src/select/selection_file/help.str.license | 10 - crates/yt/src/select/selection_file/mod.rs | 42 -- crates/yt/src/status/mod.rs | 162 -------- crates/yt/src/storage/db/get/video/mod.rs | 76 +++- crates/yt/src/storage/db/video.rs | 313 -------------- crates/yt/src/storage/db/video/comments/display.rs | 117 ++++++ crates/yt/src/storage/db/video/comments/mod.rs | 187 +++++++++ crates/yt/src/storage/db/video/comments/raw.rs | 77 ++++ crates/yt/src/storage/db/video/comments/tests.rs | 219 ++++++++++ crates/yt/src/storage/db/video/mod.rs | 314 ++++++++++++++ crates/yt/src/subscribe/mod.rs | 205 --------- crates/yt/src/update/mod.rs | 211 ---------- crates/yt/src/update/updater.rs | 193 --------- crates/yt/src/videos/display/format_video.rs | 136 ------ crates/yt/src/videos/display/mod.rs | 241 ----------- crates/yt/src/videos/format_video.rs | 139 +++++++ crates/yt/src/watch/mod.rs | 237 ----------- crates/yt/src/watch/playlist.rs | 111 ----- .../watch/playlist_handler/client_messages/mod.rs | 99 ----- crates/yt/src/watch/playlist_handler/mod.rs | 218 ---------- crates/yt/src/yt_dlp/mod.rs | 249 +++++++++++ 75 files changed, 5011 insertions(+), 4985 deletions(-) delete mode 100644 crates/yt/src/cache/mod.rs create mode 100644 crates/yt/src/commands/comments/implm/mod.rs create mode 100644 crates/yt/src/commands/comments/mod.rs create mode 100644 crates/yt/src/commands/config/implm.rs create mode 100644 crates/yt/src/commands/config/mod.rs create mode 100644 crates/yt/src/commands/description/implm.rs create mode 100644 crates/yt/src/commands/description/mod.rs create mode 100644 crates/yt/src/commands/download/implm/download/download_options.rs create mode 100644 crates/yt/src/commands/download/implm/download/mod.rs create mode 100644 crates/yt/src/commands/download/implm/download/progress_hook.rs create mode 100644 crates/yt/src/commands/download/implm/mod.rs create mode 100644 crates/yt/src/commands/download/mod.rs create mode 100644 crates/yt/src/commands/mod.rs create mode 100644 crates/yt/src/commands/playlist/implm.rs create mode 100644 crates/yt/src/commands/playlist/mod.rs create mode 100644 crates/yt/src/commands/select/implm/fs_generators/help.str create mode 100644 crates/yt/src/commands/select/implm/fs_generators/help.str.license create mode 100644 crates/yt/src/commands/select/implm/fs_generators/mod.rs create mode 100644 crates/yt/src/commands/select/implm/mod.rs create mode 100644 crates/yt/src/commands/select/implm/standalone/add.rs create mode 100644 crates/yt/src/commands/select/implm/standalone/mod.rs create mode 100644 crates/yt/src/commands/select/mod.rs create mode 100644 crates/yt/src/commands/status/implm.rs create mode 100644 crates/yt/src/commands/status/mod.rs create mode 100644 crates/yt/src/commands/subscriptions/implm.rs create mode 100644 crates/yt/src/commands/subscriptions/mod.rs create mode 100644 crates/yt/src/commands/update/implm/mod.rs create mode 100644 crates/yt/src/commands/update/implm/updater.rs create mode 100644 crates/yt/src/commands/update/mod.rs create mode 100644 crates/yt/src/commands/videos/implm.rs create mode 100644 crates/yt/src/commands/videos/mod.rs create mode 100644 crates/yt/src/commands/watch/implm/mod.rs create mode 100644 crates/yt/src/commands/watch/implm/watch/mod.rs create mode 100644 crates/yt/src/commands/watch/implm/watch/playlist_handler/client_messages.rs create mode 100644 crates/yt/src/commands/watch/implm/watch/playlist_handler/mod.rs create mode 100644 crates/yt/src/commands/watch/mod.rs delete mode 100644 crates/yt/src/comments/comment.rs delete mode 100644 crates/yt/src/comments/description.rs delete mode 100644 crates/yt/src/comments/display.rs delete mode 100644 crates/yt/src/comments/mod.rs delete mode 100644 crates/yt/src/comments/output.rs delete mode 100644 crates/yt/src/download/download_options.rs delete mode 100644 crates/yt/src/download/mod.rs delete mode 100644 crates/yt/src/download/progress_hook.rs create mode 100644 crates/yt/src/output/mod.rs delete mode 100644 crates/yt/src/select/cmds/add.rs delete mode 100644 crates/yt/src/select/cmds/mod.rs create mode 100644 crates/yt/src/select/duration.rs delete mode 100644 crates/yt/src/select/selection_file/duration.rs delete mode 100644 crates/yt/src/select/selection_file/help.str delete mode 100644 crates/yt/src/select/selection_file/help.str.license delete mode 100644 crates/yt/src/select/selection_file/mod.rs delete mode 100644 crates/yt/src/status/mod.rs delete mode 100644 crates/yt/src/storage/db/video.rs create mode 100644 crates/yt/src/storage/db/video/comments/display.rs create mode 100644 crates/yt/src/storage/db/video/comments/mod.rs create mode 100644 crates/yt/src/storage/db/video/comments/raw.rs create mode 100644 crates/yt/src/storage/db/video/comments/tests.rs create mode 100644 crates/yt/src/storage/db/video/mod.rs delete mode 100644 crates/yt/src/subscribe/mod.rs delete mode 100644 crates/yt/src/update/mod.rs delete mode 100644 crates/yt/src/update/updater.rs delete mode 100644 crates/yt/src/videos/display/format_video.rs delete mode 100644 crates/yt/src/videos/display/mod.rs create mode 100644 crates/yt/src/videos/format_video.rs delete mode 100644 crates/yt/src/watch/mod.rs delete mode 100644 crates/yt/src/watch/playlist.rs delete mode 100644 crates/yt/src/watch/playlist_handler/client_messages/mod.rs delete mode 100644 crates/yt/src/watch/playlist_handler/mod.rs create mode 100644 crates/yt/src/yt_dlp/mod.rs diff --git a/crates/yt/src/cache/mod.rs b/crates/yt/src/cache/mod.rs deleted file mode 100644 index 44a7e72..0000000 --- a/crates/yt/src/cache/mod.rs +++ /dev/null @@ -1,64 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz -// Copyright (C) 2025 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 anyhow::Result; -use log::info; - -use crate::{ - app::App, - storage::db::{ - insert::{Operations, video::Operation}, - video::{Video, VideoStatus, VideoStatusMarker}, - }, -}; - -fn invalidate_video(video: &mut Video, ops: &mut Operations) { - info!("Deleting downloaded path of video: '{}'", video.title); - - assert_eq!(video.status.as_marker(), VideoStatusMarker::Cached); - - video.remove_download_path(ops); -} - -pub(crate) async fn invalidate(app: &App) -> Result<()> { - let mut all_cached_things = Video::in_states(app, &[VideoStatusMarker::Cached]).await?; - - info!("Got videos to invalidate: '{}'", all_cached_things.len()); - - let mut ops = Operations::new("Cache: Invalidate cache entries"); - - for video in &mut all_cached_things { - invalidate_video(video, &mut ops); - } - - ops.commit(app).await?; - - Ok(()) -} - -/// Remove the cache paths from the db, that no longer exist on the file system. -pub(crate) async fn maintain(app: &App) -> Result<()> { - let mut cached_videos = Video::in_states(app, &[VideoStatusMarker::Cached]).await?; - - let mut ops = Operations::new("DbMaintain: init"); - for vid in &mut cached_videos { - if let VideoStatus::Cached { cache_path, .. } = &vid.status { - if !cache_path.exists() { - invalidate_video(vid, &mut ops); - } - } else { - unreachable!("We only asked for cached videos.") - } - } - ops.commit(app).await?; - - Ok(()) -} diff --git a/crates/yt/src/cli.rs b/crates/yt/src/cli.rs index f12b58d..9a24403 100644 --- a/crates/yt/src/cli.rs +++ b/crates/yt/src/cli.rs @@ -9,28 +9,11 @@ // You should have received a copy of the License along with this program. // If not, see . -use std::{ - ffi::OsStr, - fmt::{self, Display, Formatter}, - path::PathBuf, - str::FromStr, - thread, -}; +use std::path::PathBuf; -use anyhow::Context; -use chrono::NaiveDate; -use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum}; -use clap_complete::{ArgValueCompleter, CompletionCandidate}; -use tokio::runtime::Runtime; -use url::Url; +use clap::{ArgAction, Parser}; -use crate::{ - app::App, - config::Config, - select::selection_file::duration::MaybeDuration, - shared::bytes::Bytes, - storage::db::{extractor_hash::LazyExtractorHash, subscription::Subscriptions}, -}; +use crate::commands::Command; #[derive(Parser, Debug)] #[clap(author, about, long_about = None)] @@ -72,441 +55,6 @@ pub(crate) struct CliArgs { pub(crate) quiet: bool, } -#[derive(Subcommand, Debug)] -pub(crate) enum Command { - /// Download and cache URLs - Download { - /// Forcefully re-download all cached videos (i.e. delete the cache path, then download). - #[arg(short, long)] - force: bool, - - /// The maximum size the download dir should have. Beware that the value must be given in - /// bytes. - #[arg(short, long, value_parser = byte_parser)] - max_cache_size: Option, - }, - - /// Select, download and watch in one command. - Sedowa {}, - /// Download and watch in one command. - Dowa {}, - - /// Work with single videos - Videos { - #[command(subcommand)] - cmd: VideosCommand, - }, - - /// Watch the already cached (and selected) videos - Watch { - /// Print the path to an ipc socket for mpv control to stdout at startup. - #[arg(long)] - provide_ipc_socket: bool, - - /// Don't start an mpv window at all. - #[arg(long)] - headless: bool, - }, - - /// Visualize the current playlist - Playlist { - /// Linger and display changes - #[arg(short, long)] - watch: bool, - }, - - /// Show, which videos have been selected to be watched (and their cache status) - Status { - /// Which format to use - #[arg(short, long)] - format: Option, - }, - - /// Show, the configuration options in effect - Config {}, - - /// Display the comments of the currently playing video - Comments {}, - /// Display the description of the currently playing video - Description {}, - - /// Manipulate the video cache in the database - #[command(visible_alias = "db")] - Database { - #[command(subcommand)] - command: CacheCommand, - }, - - /// Change the state of videos in the database (the default) - Select { - #[command(subcommand)] - cmd: Option, - }, - - /// Update the video database - Update { - /// The maximal number of videos to fetch for each subscription. - #[arg(short, long)] - max_backlog: Option, - - /// The subscriptions to update - #[arg(add = ArgValueCompleter::new(complete_subscription))] - subscriptions: Vec, - }, - - /// Manipulate subscription - #[command(visible_alias = "subs")] - Subscriptions { - #[command(subcommand)] - cmd: SubscriptionCommand, - }, -} - -fn byte_parser(input: &str) -> Result { - Ok(input - .parse::() - .with_context(|| format!("Failed to parse '{input}' as bytes!"))? - .as_u64()) -} - -impl Default for Command { - fn default() -> Self { - Self::Select { - cmd: Some(SelectCommand::default()), - } - } -} - -#[derive(Subcommand, Clone, Debug)] -pub(crate) enum VideosCommand { - /// List the videos in the database - #[command(visible_alias = "ls")] - List { - /// An optional search query to limit the results - #[arg(action = ArgAction::Append)] - search_query: Option, - - /// The format string to use. - // TODO(@bpeetz): Encode the default format, as the default string here. <2025-07-04> - #[arg(short, long)] - format: Option, - - /// The number of videos to show - #[arg(short, long)] - limit: Option, - }, - - /// Get detailed information about a video - Info { - /// The short hash of the video - hash: LazyExtractorHash, - - /// The format string to use. - // TODO(@bpeetz): Encode the default format, as the default string here. <2025-07-04> - #[arg(short, long)] - format: Option, - }, -} - -#[derive(Subcommand, Clone, Debug)] -pub(crate) enum SubscriptionCommand { - /// Subscribe to an URL - Add { - #[arg(short, long)] - /// The human readable name of the subscription - name: Option, - - /// The URL to listen to - url: Url, - - /// Don't check, whether the URL actually points to something yt understands. - #[arg(long, default_value_t = false)] - no_check: bool, - }, - - /// Unsubscribe from an URL - Remove { - /// The human readable name of the subscription - #[arg(add = ArgValueCompleter::new(complete_subscription))] - name: String, - }, - - /// Import a bunch of URLs as subscriptions. - Import { - /// The file containing the URLs. Will use Stdin otherwise. - file: Option, - - /// Remove any previous subscriptions - #[arg(short, long)] - force: bool, - - /// Don't check, whether the URLs actually point to something yt understands. - #[arg(long, default_value_t = false)] - no_check: bool, - }, - /// Write all subscriptions in an format understood by `import` - Export {}, - - /// List all subscriptions - List {}, -} - -#[derive(Clone, Debug, Args)] -#[command(infer_subcommands = true)] -/// Mark the video given by the hash to be watched -pub(crate) struct SharedSelectionCommandArgs { - /// The ordering priority (higher means more at the top) - #[arg(short, long)] - pub(crate) priority: Option, - - /// The subtitles to download (e.g. 'en,de,sv') - #[arg(short = 'l', long)] - pub(crate) subtitle_langs: Option, - - /// The speed to set mpv to - #[arg(short = 's', long)] - pub(crate) playback_speed: Option, - - /// The short extractor hash - pub(crate) hash: LazyExtractorHash, - - pub(crate) title: Option, - - pub(crate) date: Option, - - pub(crate) publisher: Option, - - pub(crate) duration: Option, - - pub(crate) url: Option, -} - -impl SelectCommand { - pub(crate) fn into_shared(self) -> Option { - match self { - SelectCommand::File { .. } - | SelectCommand::Split { .. } - | SelectCommand::Add { .. } => None, - SelectCommand::Watch { shared } - | SelectCommand::Drop { shared } - | SelectCommand::Watched { shared } - | SelectCommand::Url { shared } - | SelectCommand::Pick { shared } => Some(shared), - } - } -} - -#[derive(Clone, Debug, Copy)] -pub(crate) struct OptionalNaiveDate { - pub(crate) date: Option, -} -impl FromStr for OptionalNaiveDate { - type Err = anyhow::Error; - fn from_str(v: &str) -> Result { - if v == "[No release date]" { - Ok(Self { date: None }) - } else { - Ok(Self { - date: Some(NaiveDate::from_str(v)?), - }) - } - } -} -#[derive(Clone, Debug)] -pub(crate) struct OptionalPublisher { - pub(crate) publisher: Option, -} -impl FromStr for OptionalPublisher { - type Err = anyhow::Error; - fn from_str(v: &str) -> Result { - if v == "[No author]" { - Ok(Self { publisher: None }) - } else { - Ok(Self { - publisher: Some(v.to_owned()), - }) - } - } -} - -#[derive(Default, ValueEnum, Clone, Copy, Debug)] -pub(crate) enum SelectSplitSortKey { - /// Sort by the name of the publisher. - #[default] - Publisher, - - /// Sort by the number of unselected videos per publisher. - Videos, -} -impl Display for SelectSplitSortKey { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - match self { - SelectSplitSortKey::Publisher => f.write_str("publisher"), - SelectSplitSortKey::Videos => f.write_str("videos"), - } - } -} - -#[derive(Default, ValueEnum, Clone, Copy, Debug)] -pub(crate) enum SelectSplitSortMode { - /// Sort in ascending order (small -> big) - #[default] - Asc, - - /// Sort in descending order (big -> small) - Desc, -} - -impl Display for SelectSplitSortMode { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - match self { - SelectSplitSortMode::Asc => f.write_str("asc"), - SelectSplitSortMode::Desc => f.write_str("desc"), - } - } -} - -#[derive(Subcommand, Clone, Debug)] -// NOTE: Keep this in sync with the [`constants::HELP_STR`] constant. <2024-08-20> -// NOTE: Also keep this in sync with the `tree-sitter-yts/grammar.js`. <2024-11-04> -pub(crate) enum SelectCommand { - /// Open a `git rebase` like file to select the videos to watch (the default) - File { - /// Include done (watched, dropped) videos - #[arg(long, short)] - done: bool, - - /// Use the last selection file (useful if you've spend time on it and want to get it again) - #[arg(long, short, conflicts_with = "done")] - use_last_selection: bool, - }, - - /// Generate a directory, where each file contains only one subscription. - Split { - /// Include done (watched, dropped) videos - #[arg(long, short)] - done: bool, - - /// Which key to use for sorting. - #[arg(default_value_t)] - sort_key: SelectSplitSortKey, - - /// Which mode to use for sorting. - #[arg(default_value_t)] - sort_mode: SelectSplitSortMode, - }, - - /// 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, - - /// 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")] - Watch { - #[command(flatten)] - shared: SharedSelectionCommandArgs, - }, - - /// Mark the video given by the hash to be dropped - #[command(visible_alias = "d")] - Drop { - #[command(flatten)] - shared: SharedSelectionCommandArgs, - }, - - /// Mark the video given by the hash as already watched - #[command(visible_alias = "wd")] - Watched { - #[command(flatten)] - shared: SharedSelectionCommandArgs, - }, - - /// Open the video URL in Firefox's `timesinks.youtube` profile - #[command(visible_alias = "u")] - Url { - #[command(flatten)] - shared: SharedSelectionCommandArgs, - }, - - /// Reset the videos status to 'Pick' - #[command(visible_alias = "p")] - Pick { - #[command(flatten)] - shared: SharedSelectionCommandArgs, - }, -} -impl Default for SelectCommand { - fn default() -> Self { - Self::File { - done: false, - use_last_selection: false, - } - } -} - -#[derive(Subcommand, Clone, Copy, Debug)] -pub(crate) enum CacheCommand { - /// Invalidate all cache entries - Invalidate {}, - - /// Perform basic maintenance operations on the database. - /// This helps recovering from invalid db states after a crash (or force exit via ). - /// - /// 1. Check every path for validity (removing all invalid cache entries) - #[command(verbatim_doc_comment)] - Maintain {}, -} - -fn complete_subscription(current: &OsStr) -> Vec { - let mut output = vec![]; - - let Some(current_prog) = current.to_str().map(ToOwned::to_owned) else { - return output; - }; - - let Ok(config) = Config::from_config_file(None, None, None) else { - return output; - }; - - let handle = thread::spawn(move || { - let Ok(rt) = Runtime::new() else { - return output; - }; - - let Ok(app) = rt.block_on(App::new(config, false)) else { - return output; - }; - - let Ok(all) = rt.block_on(Subscriptions::get(&app)) else { - return output; - }; - - for sub in all.0.into_keys() { - if sub.starts_with(¤t_prog) { - output.push(CompletionCandidate::new(sub)); - } - } - - output - }); - - handle.join().unwrap_or_default() -} - #[cfg(test)] mod test { use clap::CommandFactory; diff --git a/crates/yt/src/commands/comments/implm/mod.rs b/crates/yt/src/commands/comments/implm/mod.rs new file mode 100644 index 0000000..1c02718 --- /dev/null +++ b/crates/yt/src/commands/comments/implm/mod.rs @@ -0,0 +1,15 @@ +use crate::{ + app::App, commands::comments::CommentsCommand, output::display_less, storage::db::video::Video, +}; + +use anyhow::Result; + +impl CommentsCommand { + pub(crate) async fn implm(self, app: &App) -> Result<()> { + let comments = Video::get_current_comments(app).await?; + + display_less(comments.render(app.config.global.display_colors))?; + + Ok(()) + } +} diff --git a/crates/yt/src/commands/comments/mod.rs b/crates/yt/src/commands/comments/mod.rs new file mode 100644 index 0000000..d87c75d --- /dev/null +++ b/crates/yt/src/commands/comments/mod.rs @@ -0,0 +1,6 @@ +use clap::Parser; + +mod implm; + +#[derive(Parser, Debug)] +pub(crate) struct CommentsCommand {} diff --git a/crates/yt/src/commands/config/implm.rs b/crates/yt/src/commands/config/implm.rs new file mode 100644 index 0000000..409ef43 --- /dev/null +++ b/crates/yt/src/commands/config/implm.rs @@ -0,0 +1,13 @@ +use crate::{app::App, commands::config::ConfigCommand}; + +use anyhow::Result; + +impl ConfigCommand { + pub(crate) fn implm(self, app: &App) -> Result<()> { + let config_str = toml::to_string(&app.config)?; + + print!("{config_str}"); + + Ok(()) + } +} diff --git a/crates/yt/src/commands/config/mod.rs b/crates/yt/src/commands/config/mod.rs new file mode 100644 index 0000000..9ec289b --- /dev/null +++ b/crates/yt/src/commands/config/mod.rs @@ -0,0 +1,6 @@ +use clap::Parser; + +mod implm; + +#[derive(Parser, Debug)] +pub(crate) struct ConfigCommand {} diff --git a/crates/yt/src/commands/description/implm.rs b/crates/yt/src/commands/description/implm.rs new file mode 100644 index 0000000..7c39b1c --- /dev/null +++ b/crates/yt/src/commands/description/implm.rs @@ -0,0 +1,16 @@ +use crate::{ + app::App, commands::description::DescriptionCommand, output::display_fmt_and_less, + storage::db::video::Video, +}; + +use anyhow::Result; + +impl DescriptionCommand { + pub(crate) async fn implm(self, app: &App) -> Result<()> { + let description = Video::get_current_description(app).await?; + + display_fmt_and_less(&description)?; + + Ok(()) + } +} diff --git a/crates/yt/src/commands/description/mod.rs b/crates/yt/src/commands/description/mod.rs new file mode 100644 index 0000000..b5b2a10 --- /dev/null +++ b/crates/yt/src/commands/description/mod.rs @@ -0,0 +1,6 @@ +use clap::Parser; + +mod implm; + +#[derive(Parser, Debug)] +pub(crate) struct DescriptionCommand {} diff --git a/crates/yt/src/commands/download/implm/download/download_options.rs b/crates/yt/src/commands/download/implm/download/download_options.rs new file mode 100644 index 0000000..15fed7e --- /dev/null +++ b/crates/yt/src/commands/download/implm/download/download_options.rs @@ -0,0 +1,121 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2024 Benedikt Peetz +// Copyright (C) 2025 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 anyhow::Context; +use serde_json::{Value, json}; +use yt_dlp::{YoutubeDL, options::YoutubeDLOptions}; + +use crate::app::App; + +use super::progress_hook::wrapped_progress_hook; + +pub(crate) fn download_opts( + app: &App, + subtitle_langs: Option<&String>, +) -> anyhow::Result { + YoutubeDLOptions::new() + .with_progress_hook(wrapped_progress_hook) + .set("extract_flat", "in_playlist") + .set( + "extractor_args", + json! { + { + "youtube": { + "comment_sort": [ "top" ], + "max_comments": [ "150", "all", "100" ] + } + } + }, + ) + //.set("cookiesfrombrowser", json! {("firefox", "me.google", None::, "youtube_dlp")}) + .set("prefer_free_formats", true) + .set("ffmpeg_location", env!("FFMPEG_LOCATION")) + .set("format", "bestvideo[height<=?1080]+bestaudio/best") + .set("fragment_retries", 10) + .set("getcomments", true) + .set("ignoreerrors", false) + .set("retries", 10) + .set("writeinfojson", true) + // NOTE: This results in a constant warning message. <2025-01-04> + //.set("writeannotations", true) + .set("writesubtitles", true) + .set("writeautomaticsub", true) + .set( + "outtmpl", + json! { + { + "default": app.config.paths.download_dir.join("%(channel)s/%(title)s.%(ext)s"), + "chapter": "%(title)s - %(section_number)03d %(section_title)s [%(id)s].%(ext)s" + } + }, + ) + .set("compat_opts", json! {{}}) + .set("forceprint", json! {{}}) + .set("print_to_file", json! {{}}) + .set("windowsfilenames", false) + .set("restrictfilenames", false) + .set("trim_file_names", false) + .set( + "postprocessors", + json! { + [ + { + "api": "https://sponsor.ajay.app", + "categories": [ + "interaction", + "intro", + "music_offtopic", + "sponsor", + "outro", + "poi_highlight", + "preview", + "selfpromo", + "filler", + "chapter" + ], + "key": "SponsorBlock", + "when": "after_filter" + }, + { + "force_keyframes": false, + "key": "ModifyChapters", + "remove_chapters_patterns": [], + "remove_ranges": [], + "remove_sponsor_segments": [ "sponsor" ], + "sponsorblock_chapter_title": "[SponsorBlock]: %(category_names)l" + }, + { + "add_chapters": true, + "add_infojson": null, + "add_metadata": false, + "key": "FFmpegMetadata" + }, + { + "key": "FFmpegConcat", + "only_multi_video": true, + "when": "playlist" + } + ] + }, + ) + .set( + "subtitleslangs", + Value::Array( + subtitle_langs + .map_or("", String::as_str) + .split(',') + .map(|val| Value::String(val.to_owned())) + .collect::>(), + ), + ) + .build() + .context("Failed to instanciate download yt_dlp") +} diff --git a/crates/yt/src/commands/download/implm/download/mod.rs b/crates/yt/src/commands/download/implm/download/mod.rs new file mode 100644 index 0000000..f0d5f67 --- /dev/null +++ b/crates/yt/src/commands/download/implm/download/mod.rs @@ -0,0 +1,293 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2024 Benedikt Peetz +// Copyright (C) 2025 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 std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration}; + +use crate::{ + app::App, + commands::download::implm::download::download_options::download_opts, + shared::bytes::Bytes, + storage::{ + db::{extractor_hash::ExtractorHash, insert::Operations, video::Video}, + notify::{wait_for_cache_reduction, wait_for_db_write}, + }, + yt_dlp::get_current_cache_allocation, +}; + +use anyhow::{Context, Result, bail}; +use log::{debug, error, info, warn}; +use tokio::{select, task::JoinHandle, time}; +use yt_dlp::YoutubeDL; + +#[allow(clippy::module_name_repetitions)] +pub(crate) mod download_options; +pub(crate) mod progress_hook; + +#[derive(Debug)] +#[allow(clippy::module_name_repetitions)] +pub(crate) struct CurrentDownload { + task_handle: JoinHandle>, + yt_dlp: Arc, + extractor_hash: ExtractorHash, +} + +impl CurrentDownload { + fn new_from_video(app: &App, video: Video) -> Result { + let extractor_hash = video.extractor_hash; + + debug!("Download started: {}", &video.title); + let yt_dlp = Arc::new(download_opts(app, video.subtitle_langs.as_ref())?); + + let local_yt_dlp = Arc::clone(&yt_dlp); + + let task_handle = tokio::task::spawn_blocking(move || { + let mut result = local_yt_dlp + .download(&[video.url.clone()]) + .with_context(|| format!("Failed to download video: '{}'", video.title))?; + + assert_eq!(result.len(), 1); + Ok((result.remove(0), video)) + }); + + Ok(Self { + task_handle, + yt_dlp, + extractor_hash, + }) + } + + fn abort(self) -> Result<()> { + debug!("Cancelling download."); + self.yt_dlp.close()?; + + Ok(()) + } + + fn is_finished(&self) -> bool { + self.task_handle.is_finished() + } + + async fn finalize(self, app: &App) -> Result<()> { + let (result, mut video) = self.task_handle.await??; + + let mut ops = Operations::new("Downloader: Set download path"); + video.set_download_path(&result, &mut ops); + ops.commit(app) + .await + .with_context(|| format!("Failed to committ download of video: '{}'", video.title))?; + + info!( + "Video '{}' was downlaoded to path: {}", + video.title, + result.display() + ); + + Ok(()) + } +} + +enum CacheSizeCheck { + /// The video can be downloaded + Fits, + + /// The video and the current cache size together would exceed the size + TooLarge, + + /// The video would not even fit into the empty cache + ExceedsMaxCacheSize, +} + +#[derive(Debug)] +pub(crate) struct Downloader { + current_download: Option, + video_size_cache: HashMap, + printed_warning: bool, + cached_cache_allocation: Option, +} + +impl Default for Downloader { + fn default() -> Self { + Self::new() + } +} + +impl Downloader { + #[must_use] + pub(crate) fn new() -> Self { + Self { + current_download: None, + video_size_cache: HashMap::new(), + printed_warning: false, + cached_cache_allocation: None, + } + } + + /// Check if enough cache is available. + /// + /// Will wait for the next cache deletion if not. + async fn is_enough_cache_available( + &mut self, + app: &App, + max_cache_size: u64, + next_video: &Video, + ) -> Result { + if let Some(cdownload) = &self.current_download { + if cdownload.extractor_hash == next_video.extractor_hash { + // If the video is already being downloaded it will always fit. Otherwise the + // download would not have been started. + return Ok(CacheSizeCheck::Fits); + } + } + let cache_allocation = get_current_cache_allocation(app).await?; + let video_size = self.get_approx_video_size(next_video)?; + + if video_size >= max_cache_size { + error!( + "The video '{}' ({}) exceeds the maximum cache size ({})! \ + Please set a bigger maximum (`--max-cache-size`) or skip it.", + next_video.title, + Bytes::new(video_size), + Bytes::new(max_cache_size) + ); + + return Ok(CacheSizeCheck::ExceedsMaxCacheSize); + } + + if cache_allocation.as_u64() + video_size >= max_cache_size { + if !self.printed_warning { + warn!( + "Can't download video: '{}' ({}) as it's too large for the cache ({} of {} allocated). \ + Waiting for cache size reduction..", + next_video.title, + Bytes::new(video_size), + &cache_allocation, + Bytes::new(max_cache_size) + ); + self.printed_warning = true; + + // Update this value immediately. + // This avoids printing the "Current cache size has changed .." warning below. + self.cached_cache_allocation = Some(cache_allocation); + } + + if let Some(cca) = self.cached_cache_allocation { + if cca != cache_allocation { + // Only print the warning if the display string has actually changed. + // Otherwise, we might confuse the user + if cca.to_string() != cache_allocation.to_string() { + warn!("Current cache size has changed, it's now: '{cache_allocation}'"); + } + debug!( + "Cache size has changed: {} -> {}", + cca.as_u64(), + cache_allocation.as_u64() + ); + self.cached_cache_allocation = Some(cache_allocation); + } + } else { + unreachable!( + "The `printed_warning` should be false in this case, \ + and thus should have already set the `cached_cache_allocation`." + ); + } + + // Wait and hope, that a large video is deleted from the cache. + wait_for_cache_reduction(app).await?; + Ok(CacheSizeCheck::TooLarge) + } else { + self.printed_warning = false; + Ok(CacheSizeCheck::Fits) + } + } + + /// The entry point to the Downloader. + /// This Downloader will periodically check if the database has changed, and then also + /// change which videos it downloads. + /// This will run, until the database doesn't contain any watchable videos + pub(crate) async fn consume(&mut self, app: Arc, max_cache_size: u64) -> Result<()> { + while let Some(next_video) = Video::next_to_download(&app).await? { + match self + .is_enough_cache_available(&app, max_cache_size, &next_video) + .await? + { + CacheSizeCheck::Fits => (), + CacheSizeCheck::TooLarge => continue, + CacheSizeCheck::ExceedsMaxCacheSize => bail!("Giving up."), + } + + if self.current_download.is_some() { + let current_download = self.current_download.take().expect("It is `Some`."); + + if current_download.is_finished() { + // The download is done, finalize it and leave it removed. + current_download.finalize(&app).await?; + continue; + } + + if next_video.extractor_hash == current_download.extractor_hash { + // We still want to download the same video. + // reset the taken value + self.current_download = Some(current_download); + } else { + info!( + "Noticed, that the next video is not the video being downloaded, replacing it ('{}' vs. '{}')!", + next_video.extractor_hash.as_short_hash(&app).await?, + current_download + .extractor_hash + .as_short_hash(&app) + .await? + ); + + // Replace the currently downloading video + current_download + .abort() + .context("Failed to abort last download")?; + + let new_current_download = CurrentDownload::new_from_video(&app, next_video)?; + + self.current_download = Some(new_current_download); + } + } else { + info!( + "No video is being downloaded right now, setting it to '{}'", + next_video.title + ); + let new_current_download = CurrentDownload::new_from_video(&app, next_video)?; + self.current_download = Some(new_current_download); + } + + // We have to continuously check, if the current download is done. + // As such we simply wait or recheck on the next write to the db. + select! { + () = time::sleep(Duration::from_secs(1)) => (), + Ok(()) = wait_for_db_write(&app) => (), + } + } + + info!("Finished downloading!"); + Ok(()) + } + + fn get_approx_video_size(&mut self, video: &Video) -> Result { + if let Some(value) = self.video_size_cache.get(&video.extractor_hash) { + Ok(*value) + } else { + let size = video.get_approx_size()?; + + assert_eq!( + self.video_size_cache.insert(video.extractor_hash, size), + None + ); + + Ok(size) + } + } +} diff --git a/crates/yt/src/commands/download/implm/download/progress_hook.rs b/crates/yt/src/commands/download/implm/download/progress_hook.rs new file mode 100644 index 0000000..19fe122 --- /dev/null +++ b/crates/yt/src/commands/download/implm/download/progress_hook.rs @@ -0,0 +1,175 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 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 std::{ + io::{Write, stderr}, + process, + sync::atomic::Ordering, +}; + +use colors::{Colorize, IntoCanvas}; +use log::{Level, log_enabled}; +use yt_dlp::{json_cast, json_get, wrap_progress_hook}; + +use crate::{ + ansi_escape_codes::{clear_whole_line, move_to_col}, + config::SHOULD_DISPLAY_COLOR, + select::duration::MaybeDuration, + shared::bytes::Bytes, +}; + +macro_rules! json_get_default { + ($value:expr, $name:literal, $convert:ident, $default:expr) => { + $value.get($name).map_or($default, |v| { + if v == &serde_json::Value::Null { + $default + } else { + json_cast!(@log_key $name, v, $convert) + } + }) + }; +} + +fn format_bytes(bytes: u64) -> String { + let bytes = Bytes::new(bytes); + bytes.to_string() +} + +fn format_speed(speed: f64) -> String { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let bytes = Bytes::new(speed.floor() as u64); + format!("{bytes}/s") +} + +/// # Panics +/// If expectations fail. +#[allow(clippy::needless_pass_by_value)] +pub(crate) fn progress_hook( + input: serde_json::Map, +) -> Result<(), std::io::Error> { + // Only add the handler, if the log-level is higher than Debug (this avoids covering debug + // messages). + if log_enabled!(Level::Debug) { + return Ok(()); + } + + let info_dict = json_get!(input, "info_dict", as_object); + + let get_title = || -> String { + match json_get!(info_dict, "ext", as_str) { + "vtt" => { + format!( + "Subtitles ({})", + json_get_default!(info_dict, "name", as_str, "") + ) + } + "webm" | "mp4" | "mp3" | "m4a" => { + json_get_default!(info_dict, "title", as_str, "").to_owned() + } + other => panic!("The extension '{other}' is not yet implemented"), + } + }; + + match json_get!(input, "status", as_str) { + "downloading" => { + let elapsed = json_get_default!(input, "elapsed", as_f64, 0.0); + let eta = json_get_default!(input, "eta", as_f64, 0.0); + let speed = json_get_default!(input, "speed", as_f64, 0.0); + + let downloaded_bytes = json_get!(input, "downloaded_bytes", as_u64); + let (total_bytes, bytes_is_estimate): (u64, &'static str) = { + let total_bytes = json_get_default!(input, "total_bytes", as_u64, 0); + + if total_bytes == 0 { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let maybe_estimate = + json_get_default!(input, "total_bytes_estimate", as_f64, 0.0) as u64; + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + if maybe_estimate == 0 { + // The download speed should be in bytes + // per second and the eta in seconds. + // Thus multiplying them gets us the raw bytes + // (which were estimated by `yt_dlp`, from their `info.json`) + let bytes_still_needed = (speed * eta).ceil() as u64; + + (downloaded_bytes + bytes_still_needed, "~") + } else { + (maybe_estimate, "~") + } + } else { + (total_bytes, "") + } + }; + + let percent: f64 = { + if total_bytes == 0 { + 100.0 + } else { + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss + )] + { + (downloaded_bytes as f64 / total_bytes as f64) * 100.0 + } + } + }; + + clear_whole_line(); + move_to_col(1); + + let should_use_color = SHOULD_DISPLAY_COLOR.load(Ordering::Relaxed); + + eprint!( + "{} [{}/{} at {}] -> [{} of {}{} {}] ", + get_title().bold().blue().render(should_use_color), + MaybeDuration::from_secs_f64(elapsed) + .bold() + .yellow() + .render(should_use_color), + MaybeDuration::from_secs_f64(eta) + .bold() + .yellow() + .render(should_use_color), + format_speed(speed).bold().green().render(should_use_color), + format_bytes(downloaded_bytes) + .bold() + .red() + .render(should_use_color), + bytes_is_estimate.bold().red().render(should_use_color), + format_bytes(total_bytes) + .bold() + .red() + .render(should_use_color), + format!("{percent:.02}%") + .bold() + .cyan() + .render(should_use_color), + ); + stderr().flush()?; + } + "finished" => { + eprintln!("-> Finished downloading."); + } + "error" => { + // TODO: This should probably return an Err. But I'm not so sure where the error would + // bubble up to (i.e., who would catch it) <2025-01-21> + eprintln!("-> Error while downloading: {}", get_title()); + process::exit(1); + } + other => unreachable!("'{other}' should not be a valid state!"), + } + + Ok(()) +} + +wrap_progress_hook!(progress_hook, wrapped_progress_hook); diff --git a/crates/yt/src/commands/download/implm/mod.rs b/crates/yt/src/commands/download/implm/mod.rs new file mode 100644 index 0000000..e8867cf --- /dev/null +++ b/crates/yt/src/commands/download/implm/mod.rs @@ -0,0 +1,45 @@ +use std::sync::Arc; + +use crate::{ + app::App, + commands::download::DownloadCommand, + shared::bytes::Bytes, + storage::db::{ + insert::{Operations, maintenance::clear_stale_downloaded_paths}, + video::{Video, VideoStatusMarker}, + }, +}; + +use anyhow::Result; +use log::info; + +mod download; + +impl DownloadCommand { + pub(crate) async fn implm(self, app: Arc) -> Result<()> { + let DownloadCommand { + force, + max_cache_size, + } = self; + + let max_cache_size = max_cache_size.unwrap_or(app.config.download.max_cache_size.as_u64()); + info!("Max cache size: '{}'", Bytes::new(max_cache_size)); + + clear_stale_downloaded_paths(&app).await?; + if force { + let mut all = Video::in_states(&app, &[VideoStatusMarker::Cached]).await?; + + let mut ops = Operations::new("Download: Clear old download paths due to `--force`"); + for a in &mut all { + a.remove_download_path(&mut ops); + } + ops.commit(&app).await?; + } + + download::Downloader::new() + .consume(app, max_cache_size) + .await?; + + Ok(()) + } +} diff --git a/crates/yt/src/commands/download/mod.rs b/crates/yt/src/commands/download/mod.rs new file mode 100644 index 0000000..48c6ee4 --- /dev/null +++ b/crates/yt/src/commands/download/mod.rs @@ -0,0 +1,24 @@ +use anyhow::Context; +use clap::Parser; + +use crate::shared::bytes::Bytes; + +mod implm; + +#[derive(Parser, Debug)] +pub(crate) struct DownloadCommand { + /// Forcefully re-download all cached videos (i.e. delete all already downloaded paths, then download). + #[arg(short, long)] + force: bool, + + /// The maximum size the download dir should have. + #[arg(short, long, value_parser = byte_parser)] + max_cache_size: Option, +} + +fn byte_parser(input: &str) -> Result { + Ok(input + .parse::() + .with_context(|| format!("Failed to parse '{input}' as bytes!"))? + .as_u64()) +} diff --git a/crates/yt/src/commands/mod.rs b/crates/yt/src/commands/mod.rs new file mode 100644 index 0000000..a6aa2af --- /dev/null +++ b/crates/yt/src/commands/mod.rs @@ -0,0 +1,153 @@ +use std::{ffi::OsStr, thread}; + +use clap::Subcommand; +use clap_complete::CompletionCandidate; +use tokio::runtime::Runtime; + +use crate::{ + app::App, + commands::{ + comments::CommentsCommand, config::ConfigCommand, database::DatabaseCommand, + description::DescriptionCommand, download::DownloadCommand, playlist::PlaylistCommand, + select::SelectCommand, status::StatusCommand, subscriptions::SubscriptionCommand, + update::UpdateCommand, videos::VideosCommand, watch::WatchCommand, + }, + config::Config, + storage::db::subscription::Subscriptions, +}; + +pub(crate) mod implm; + +mod comments; +mod config; +mod database; +mod description; +mod download; +mod playlist; +mod select; +mod status; +mod subscriptions; +mod update; +mod videos; +mod watch; + +#[derive(Subcommand, Debug)] +pub(crate) enum Command { + /// Display the comments of the currently playing video. + Comments { + #[command(flatten)] + cmd: CommentsCommand, + }, + + /// Show, the configuration options in effect. + Config { + #[command(flatten)] + cmd: ConfigCommand, + }, + + /// Interact with the video database. + #[command(visible_alias = "db")] + Database { + #[command(subcommand)] + cmd: DatabaseCommand, + }, + + /// Display the description of the currently playing video + Description { + #[command(flatten)] + cmd: DescriptionCommand, + }, + + /// Download and cache URLs + Download { + #[command(flatten)] + cmd: DownloadCommand, + }, + + /// Visualize the current playlist + Playlist { + #[command(flatten)] + cmd: PlaylistCommand, + }, + + /// Change the state of videos in the database (the default) + Select { + #[command(subcommand)] + cmd: Option, + }, + + /// Show, which videos have been selected to be watched (and their cache status) + Status { + #[command(flatten)] + cmd: StatusCommand, + }, + + /// Manipulate subscription + #[command(visible_alias = "subs")] + Subscriptions { + #[command(subcommand)] + cmd: SubscriptionCommand, + }, + + /// Update the video database + Update { + #[command(flatten)] + cmd: UpdateCommand, + }, + + /// Work with single videos + Videos { + #[command(subcommand)] + cmd: VideosCommand, + }, + + /// Watch the already cached (and selected) videos + Watch { + #[command(flatten)] + cmd: WatchCommand, + }, +} + +impl Default for Command { + fn default() -> Self { + Self::Select { + cmd: Some(SelectCommand::default()), + } + } +} + +fn complete_subscription(current: &OsStr) -> Vec { + let mut output = vec![]; + + let Some(current_prog) = current.to_str().map(ToOwned::to_owned) else { + return output; + }; + + let Ok(config) = Config::from_config_file(None, None, None) else { + return output; + }; + + let handle = thread::spawn(move || { + let Ok(rt) = Runtime::new() else { + return output; + }; + + let Ok(app) = rt.block_on(App::new(config, false)) else { + return output; + }; + + let Ok(all) = rt.block_on(Subscriptions::get(&app)) else { + return output; + }; + + for sub in all.0.into_keys() { + if sub.starts_with(¤t_prog) { + output.push(CompletionCandidate::new(sub)); + } + } + + output + }); + + handle.join().unwrap_or_default() +} diff --git a/crates/yt/src/commands/playlist/implm.rs b/crates/yt/src/commands/playlist/implm.rs new file mode 100644 index 0000000..98a8e64 --- /dev/null +++ b/crates/yt/src/commands/playlist/implm.rs @@ -0,0 +1,105 @@ +use std::{fmt::Write, path::Path}; + +use crate::{ + ansi_escape_codes, + app::App, + commands::playlist::PlaylistCommand, + storage::{ + db::{ + playlist::Playlist, + video::{Video, VideoStatus}, + }, + notify::wait_for_db_write, + }, + videos::RenderWithApp, +}; + +use anyhow::Result; +use futures::{TryStreamExt, stream::FuturesOrdered}; + +impl PlaylistCommand { + pub(crate) async fn implm(self, app: &App) -> Result<()> { + let PlaylistCommand { watch } = self; + + let mut previous_output_length = 0; + loop { + let playlist = Playlist::create(app).await?.videos; + + let output = playlist + .into_iter() + .map(|video| async move { + let mut output = String::new(); + + let (_, is_focused) = cache_values(&video); + + if is_focused { + output.push_str("🔻 "); + } else { + output.push_str(" "); + } + + output.push_str(&video.title_fmt().to_string(app)); + + output.push_str(" ("); + output.push_str(&video.parent_subscription_name_fmt().to_string(app)); + output.push(')'); + + output.push_str(" ["); + output.push_str(&video.duration_fmt().to_string(app)); + + if is_focused { + output.push_str(" ("); + if let Some(duration) = video.duration.as_secs() { + let watch_progress: f64 = f64::from( + u32::try_from(video.watch_progress.as_secs()).expect("No overflow"), + ); + let duration = f64::from(u32::try_from(duration).expect("No overflow")); + + write!(output, "{:0.0}%", (watch_progress / duration) * 100.0)?; + } else { + write!(output, "{}", video.watch_progress_fmt().to_string(app))?; + } + + output.push(')'); + } + output.push(']'); + + output.push('\n'); + + Ok::(output) + }) + .collect::>() + .try_collect::() + .await?; + + // Delete the previous output + ansi_escape_codes::cursor_up(previous_output_length); + ansi_escape_codes::erase_from_cursor_to_bottom(); + + previous_output_length = output.chars().filter(|ch| *ch == '\n').count(); + + print!("{output}"); + + if !watch { + break; + } + + wait_for_db_write(app).await?; + } + + Ok(()) + } +} + +/// Extract the values of the [`VideoStatus::Cached`] value from a Video. +fn cache_values(video: &Video) -> (&Path, bool) { + if let VideoStatus::Cached { + cache_path, + is_focused, + } = &video.status + { + (cache_path, *is_focused) + } else { + unreachable!("All of these videos should be cached"); + } +} diff --git a/crates/yt/src/commands/playlist/mod.rs b/crates/yt/src/commands/playlist/mod.rs new file mode 100644 index 0000000..8290b3e --- /dev/null +++ b/crates/yt/src/commands/playlist/mod.rs @@ -0,0 +1,10 @@ +use clap::Parser; + +mod implm; + +#[derive(Parser, Debug)] +pub(crate) struct PlaylistCommand { + /// Linger and display changes + #[arg(short, long)] + watch: bool, +} diff --git a/crates/yt/src/commands/select/implm/fs_generators/help.str b/crates/yt/src/commands/select/implm/fs_generators/help.str new file mode 100644 index 0000000..e3cc347 --- /dev/null +++ b/crates/yt/src/commands/select/implm/fs_generators/help.str @@ -0,0 +1,12 @@ +# Commands: +# w, watch [-p,-s,-l] Mark the video given by the hash to be watched +# wd, watched [-p,-s,-l] Mark the video given by the hash as already watched +# d, drop [-p,-s,-l] Mark the video given by the hash to be dropped +# u, url [-p,-s,-l] Open the video URL in Firefox's `timesinks.youtube` profile +# p, pick [-p,-s,-l] Reset the videos status to 'Pick' +# a, add URL Add a video, defined by the URL +# +# See `yt select --help` for more help. +# +# These lines can be re-ordered; they are executed from top to bottom. +# vim: filetype=yts conceallevel=2 concealcursor=nc colorcolumn= nowrap diff --git a/crates/yt/src/commands/select/implm/fs_generators/help.str.license b/crates/yt/src/commands/select/implm/fs_generators/help.str.license new file mode 100644 index 0000000..a0e196c --- /dev/null +++ b/crates/yt/src/commands/select/implm/fs_generators/help.str.license @@ -0,0 +1,10 @@ +yt - A fully featured command line YouTube client + +Copyright (C) 2024 Benedikt Peetz +Copyright (C) 2025 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 . diff --git a/crates/yt/src/commands/select/implm/fs_generators/mod.rs b/crates/yt/src/commands/select/implm/fs_generators/mod.rs new file mode 100644 index 0000000..8ccda3c --- /dev/null +++ b/crates/yt/src/commands/select/implm/fs_generators/mod.rs @@ -0,0 +1,345 @@ +use std::{ + collections::HashMap, + env, + fs::{self, File, OpenOptions}, + io::{BufRead, BufReader, BufWriter, Read, Write}, + iter, + os::fd::{AsFd, AsRawFd}, + path::Path, +}; + +use crate::{ + app::App, + cli::CliArgs, + commands::{ + Command, + select::{ + SelectCommand, SelectSplitSortKey, SelectSplitSortMode, + implm::standalone::{self, handle_select_cmd}, + }, + }, + storage::db::{ + extractor_hash::ExtractorHash, + insert::Operations, + video::{Video, VideoStatusMarker}, + }, +}; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use futures::{TryStreamExt, stream::FuturesOrdered}; +use log::info; +use shlex::Shlex; +use tokio::process; + +const HELP_STR: &str = include_str!("./help.str"); + +pub(crate) async fn select_split( + app: &App, + done: bool, + sort_key: SelectSplitSortKey, + sort_mode: SelectSplitSortMode, +) -> Result<()> { + let temp_dir = tempfile::Builder::new() + .prefix("yt_video_select-") + .rand_bytes(6) + .tempdir() + .context("Failed to get tempdir")?; + + let matching_videos = get_videos(app, done).await?; + + let mut no_author = vec![]; + let mut author_map = HashMap::new(); + for video in matching_videos { + if let Some(sub) = &video.parent_subscription_name { + if author_map.contains_key(sub) { + let vec: &mut Vec<_> = author_map + .get_mut(sub) + .expect("This key is set, we checked in the if above"); + + vec.push(video); + } else { + author_map.insert(sub.to_owned(), vec![video]); + } + } else { + no_author.push(video); + } + } + + let author_map = { + let mut temp_vec: Vec<_> = author_map.into_iter().collect(); + + match sort_key { + SelectSplitSortKey::Publisher => { + // PERFORMANCE: The clone here should not be neeed. <2025-06-15> + temp_vec.sort_by_key(|(name, _): &(String, Vec