diff options
57 files changed, 2428 insertions, 2402 deletions
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 <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// 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 <https://www.gnu.org/licenses/gpl-3.0.txt>. - -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<Operation>) { - 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 <https://www.gnu.org/licenses/gpl-3.0.txt>. -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<u64>, - }, - - /// 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<String>, - }, - - /// 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<SelectCommand>, - }, - - /// Update the video database - Update { - /// The maximal number of videos to fetch for each subscription. - #[arg(short, long)] - max_backlog: Option<usize>, - - /// The subscriptions to update - #[arg(add = ArgValueCompleter::new(complete_subscription))] - subscriptions: Vec<String>, - }, - - /// Manipulate subscription - #[command(visible_alias = "subs")] - Subscriptions { - #[command(subcommand)] - cmd: SubscriptionCommand, - }, -} - -fn byte_parser(input: &str) -> Result<u64, anyhow::Error> { - Ok(input - .parse::<Bytes>() - .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<String>, - - /// The format string to use. - // TODO(@bpeetz): Encode the default format, as the default string here. <2025-07-04> - #[arg(short, long)] - format: Option<String>, - - /// The number of videos to show - #[arg(short, long)] - limit: Option<usize>, - }, - - /// 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<String>, - }, -} - -#[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<String>, - - /// 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<PathBuf>, - - /// 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<i64>, - - /// The subtitles to download (e.g. 'en,de,sv') - #[arg(short = 'l', long)] - pub(crate) subtitle_langs: Option<String>, - - /// The speed to set mpv to - #[arg(short = 's', long)] - pub(crate) playback_speed: Option<f64>, - - /// The short extractor hash - pub(crate) hash: LazyExtractorHash, - - pub(crate) title: Option<String>, - - pub(crate) date: Option<OptionalNaiveDate>, - - pub(crate) publisher: Option<OptionalPublisher>, - - pub(crate) duration: Option<MaybeDuration>, - - pub(crate) url: Option<Url>, -} - -impl SelectCommand { - pub(crate) fn into_shared(self) -> Option<SharedSelectionCommandArgs> { - 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<NaiveDate>, -} -impl FromStr for OptionalNaiveDate { - type Err = anyhow::Error; - fn from_str(v: &str) -> Result<Self, Self::Err> { - 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<String>, -} -impl FromStr for OptionalPublisher { - type Err = anyhow::Error; - fn from_str(v: &str) -> Result<Self, Self::Err> { - 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<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")] - 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 <CTRL-C>). - /// - /// 1. Check every path for validity (removing all invalid cache entries) - #[command(verbatim_doc_comment)] - Maintain {}, -} - -fn complete_subscription(current: &OsStr) -> Vec<CompletionCandidate> { - 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/download/download_options.rs b/crates/yt/src/commands/download/implm/download/download_options.rs index 15fed7e..15fed7e 100644 --- a/crates/yt/src/download/download_options.rs +++ b/crates/yt/src/commands/download/implm/download/download_options.rs diff --git a/crates/yt/src/download/mod.rs b/crates/yt/src/commands/download/implm/download/mod.rs index 3eb046a..f0d5f67 100644 --- a/crates/yt/src/download/mod.rs +++ b/crates/yt/src/commands/download/implm/download/mod.rs @@ -9,23 +9,23 @@ // You should have received a copy of the License along with this program. // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. -use std::{collections::HashMap, io, path::PathBuf, str::FromStr, sync::Arc, time::Duration}; +use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration}; use crate::{ app::App, - download::download_options::download_opts, + 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 futures::{FutureExt, future::BoxFuture}; use log::{debug, error, info, warn}; -use tokio::{fs, select, task::JoinHandle, time}; -use yt_dlp::{YoutubeDL, json_cast, json_get, options::YoutubeDLOptions}; +use tokio::{select, task::JoinHandle, time}; +use yt_dlp::YoutubeDL; #[allow(clippy::module_name_repetitions)] pub(crate) mod download_options; @@ -146,7 +146,7 @@ impl Downloader { return Ok(CacheSizeCheck::Fits); } } - let cache_allocation = Self::get_current_cache_allocation(app).await?; + 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 { @@ -239,10 +239,10 @@ impl Downloader { } else { info!( "Noticed, that the next video is not the video being downloaded, replacing it ('{}' vs. '{}')!", - next_video.extractor_hash.into_short_hash(&app).await?, + next_video.extractor_hash.as_short_hash(&app).await?, current_download .extractor_hash - .into_short_hash(&app) + .as_short_hash(&app) .await? ); @@ -276,107 +276,11 @@ impl Downloader { Ok(()) } - pub(crate) async fn get_current_cache_allocation(app: &App) -> Result<Bytes> { - fn dir_size(mut dir: fs::ReadDir) -> BoxFuture<'static, Result<Bytes>> { - async move { - let mut acc = 0; - while let Some(entry) = dir.next_entry().await? { - let size = match entry.metadata().await? { - data if data.is_dir() => { - let path = entry.path(); - let read_dir = fs::read_dir(path).await?; - - dir_size(read_dir).await?.as_u64() - } - data => data.len(), - }; - acc += size; - } - Ok(Bytes::new(acc)) - } - .boxed() - } - - let read_dir_result = match fs::read_dir(&app.config.paths.download_dir).await { - Ok(ok) => ok, - Err(err) => match err.kind() { - io::ErrorKind::NotFound => { - fs::create_dir_all(&app.config.paths.download_dir) - .await - .with_context(|| { - format!( - "Failed to create download dir at: '{}'", - &app.config.paths.download_dir.display() - ) - })?; - - info!( - "Created empty download dir at '{}'", - &app.config.paths.download_dir.display(), - ); - - // The new dir should not contain anything (otherwise we would not have had to - // create it) - return Ok(Bytes::new(0)); - } - err => Err(io::Error::from(err)).with_context(|| { - format!( - "Failed to get dir size of download dir at: '{}'", - &app.config.paths.download_dir.display() - ) - })?, - }, - }; - - dir_size(read_dir_result).await - } - fn get_approx_video_size(&mut self, video: &Video) -> Result<u64> { if let Some(value) = self.video_size_cache.get(&video.extractor_hash) { Ok(*value) } else { - let yt_dlp = { - YoutubeDLOptions::new() - .set("prefer_free_formats", true) - .set("format", "bestvideo[height<=?1080]+bestaudio/best") - .set("fragment_retries", 10) - .set("retries", 10) - .set("getcomments", false) - .set("ignoreerrors", false) - .build() - .context("Failed to instanciate get approx size yt_dlp") - }?; - - let result = yt_dlp - .extract_info(&video.url, false, true) - .with_context(|| { - format!("Failed to extract video information: '{}'", video.title) - })?; - - let size = if let Some(val) = result.get("filesize") { - json_cast!(val, as_u64) - } else if let Some(serde_json::Value::Number(num)) = result.get("filesize_approx") { - // NOTE(@bpeetz): yt_dlp sets this value to `Null`, instead of omitting it when it - // can't calculate the approximate filesize. - // Thus, we have to check, that it is actually non-null, before we cast it. <2025-06-15> - json_cast!(num, as_u64) - } else if result.get("duration").is_some() && result.get("tbr").is_some() { - #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - let duration = json_get!(result, "duration", as_f64).ceil() as u64; - - // TODO: yt_dlp gets this from the format - #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - let tbr = json_get!(result, "tbr", as_f64).ceil() as u64; - - duration * tbr * (1000 / 8) - } else { - let hardcoded_default = Bytes::from_str("250 MiB").expect("This is hardcoded"); - error!( - "Failed to find a filesize for video: '{}' (Using hardcoded value of {})", - video.title, hardcoded_default - ); - hardcoded_default.as_u64() - }; + let size = video.get_approx_size()?; assert_eq!( self.video_size_cache.insert(video.extractor_hash, size), diff --git a/crates/yt/src/download/progress_hook.rs b/crates/yt/src/commands/download/implm/download/progress_hook.rs index e5605fd..19fe122 100644 --- a/crates/yt/src/download/progress_hook.rs +++ b/crates/yt/src/commands/download/implm/download/progress_hook.rs @@ -11,15 +11,17 @@ use std::{ io::{Write, stderr}, process, + sync::atomic::Ordering, }; +use colors::{Colorize, IntoCanvas}; use log::{Level, log_enabled}; -use owo_colors::OwoColorize; use yt_dlp::{json_cast, json_get, wrap_progress_hook}; use crate::{ ansi_escape_codes::{clear_whole_line, move_to_col}, - select::selection_file::duration::MaybeDuration, + config::SHOULD_DISPLAY_COLOR, + select::duration::MaybeDuration, shared::bytes::Bytes, }; @@ -125,16 +127,33 @@ pub(crate) fn progress_hook( clear_whole_line(); move_to_col(1); + let should_use_color = SHOULD_DISPLAY_COLOR.load(Ordering::Relaxed); + eprint!( "{} [{}/{} at {}] -> [{} of {}{} {}] ", - get_title().bold().blue(), - MaybeDuration::from_secs_f64(elapsed).bold().yellow(), - MaybeDuration::from_secs_f64(eta).bold().yellow(), - format_speed(speed).bold().green(), - format_bytes(downloaded_bytes).bold().red(), - bytes_is_estimate.bold().red(), - format_bytes(total_bytes).bold().red(), - format!("{percent:.02}%").bold().cyan(), + 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()?; } 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<App>) -> 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<u64>, +} + +fn byte_parser(input: &str) -> Result<u64, anyhow::Error> { + Ok(input + .parse::<Bytes>() + .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<SelectCommand>, + }, + + /// 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<CompletionCandidate> { + 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::<String, anyhow::Error>(output) + }) + .collect::<FuturesOrdered<_>>() + .try_collect::<String>() + .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/select/selection_file/help.str b/crates/yt/src/commands/select/implm/fs_generators/help.str index e3cc347..e3cc347 100644 --- a/crates/yt/src/select/selection_file/help.str +++ b/crates/yt/src/commands/select/implm/fs_generators/help.str diff --git a/crates/yt/src/select/selection_file/help.str.license b/crates/yt/src/commands/select/implm/fs_generators/help.str.license index a0e196c..a0e196c 100644 --- a/crates/yt/src/select/selection_file/help.str.license +++ b/crates/yt/src/commands/select/implm/fs_generators/help.str.license 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<Video>)| name.to_owned()); + } + SelectSplitSortKey::Videos => { + temp_vec.sort_by_key(|(_, videos): &(String, Vec<Video>)| videos.len()); + } + } + + match sort_mode { + SelectSplitSortMode::Asc => { + // Std's default mode is ascending. + } + SelectSplitSortMode::Desc => { + temp_vec.reverse(); + } + } + + temp_vec + }; + + for (index, (name, videos)) in author_map + .into_iter() + .chain(iter::once(( + "<No parent subscription>".to_owned(), + no_author, + ))) + .enumerate() + { + let mut file_path = temp_dir.path().join(format!("{index:02}_{name}")); + file_path.set_extension("yts"); + + let tmp_file = File::create(&file_path) + .with_context(|| format!("Falied to create file at: {}", file_path.display()))?; + + write_videos_to_file(app, &tmp_file, &videos) + .await + .with_context(|| format!("Falied to populate file at: {}", file_path.display()))?; + } + + open_editor_at(temp_dir.path()).await?; + + let mut paths = vec![]; + for maybe_entry in temp_dir + .path() + .read_dir() + .context("Failed to open temp dir for reading")? + { + let entry = maybe_entry.context("Failed to read entry in temp dir")?; + + if !entry.file_type()?.is_file() { + bail!("Found non-file entry: {}", entry.path().display()); + } + + paths.push(entry.path()); + } + + paths.sort(); + + let mut persistent_file = OpenOptions::new() + .read(false) + .write(true) + .create(true) + .truncate(true) + .open(&app.config.paths.last_selection_path) + .context("Failed to open persistent selection file")?; + + for path in paths { + let mut read_file = File::open(path)?; + + let mut buffer = vec![]; + read_file.read_to_end(&mut buffer)?; + persistent_file.write_all(&buffer)?; + } + + persistent_file.flush()?; + let persistent_file = OpenOptions::new() + .read(true) + .open(format!( + "/proc/self/fd/{}", + persistent_file.as_fd().as_raw_fd() + )) + .context("Failed to re-open persistent file")?; + + let processed = process_file(app, &persistent_file).await?; + + info!("Processed {processed} records."); + temp_dir.close().context("Failed to close the temp dir")?; + Ok(()) +} + +pub(crate) async fn select_file(app: &App, done: bool, use_last_selection: bool) -> Result<()> { + let temp_file = tempfile::Builder::new() + .prefix("yt_video_select-") + .suffix(".yts") + .rand_bytes(6) + .tempfile() + .context("Failed to get tempfile")?; + + if use_last_selection { + fs::copy(&app.config.paths.last_selection_path, &temp_file)?; + } else { + let matching_videos = get_videos(app, done).await?; + + write_videos_to_file(app, temp_file.as_file(), &matching_videos).await?; + } + + open_editor_at(temp_file.path()).await?; + + let read_file = OpenOptions::new().read(true).open(temp_file.path())?; + fs::copy(temp_file.path(), &app.config.paths.last_selection_path) + .context("Failed to persist selection file")?; + + let processed = process_file(app, &read_file).await?; + info!("Processed {processed} records."); + + Ok(()) +} + +async fn get_videos(app: &App, include_done: bool) -> Result<Vec<Video>> { + if include_done { + Video::in_states(app, VideoStatusMarker::ALL).await + } else { + Video::in_states( + app, + &[ + VideoStatusMarker::Pick, + // + VideoStatusMarker::Watch, + VideoStatusMarker::Cached, + ], + ) + .await + } +} + +async fn write_videos_to_file(app: &App, file: &File, videos: &[Video]) -> Result<()> { + // Warm-up the cache for the display rendering of the videos. + // Otherwise the futures would all try to warm it up at the same time. + if let Some(vid) = videos.first() { + drop(vid.to_line_display(app, None).await?); + } + + let mut edit_file = BufWriter::new(file); + + videos + .iter() + .map(|vid| vid.to_select_file_display(app)) + .collect::<FuturesOrdered<_>>() + .try_collect::<Vec<String>>() + .await? + .into_iter() + .try_for_each(|line| -> Result<()> { + edit_file + .write_all(line.as_bytes()) + .context("Failed to write to `edit_file`")?; + + Ok(()) + })?; + + edit_file.write_all(HELP_STR.as_bytes())?; + edit_file.flush().context("Failed to flush edit file")?; + + Ok(()) +} + +async fn process_file(app: &App, file: &File) -> Result<i64> { + let mut line_number = 0; + + let mut ops = Operations::new("Select: process file"); + + // Fetch all the hashes once, instead of every time we need to process a line. + let all_hashes = ExtractorHash::get_all(app).await?; + + let reader = BufReader::new(file); + for line in reader.lines() { + let line = line.context("Failed to read a line")?; + + if let Some(line) = process_line(&line)? { + line_number -= 1; + + // debug!( + // "Parsed command: `{}`", + // line.iter() + // .map(|val| format!("\"{}\"", val)) + // .collect::<Vec<String>>() + // .join(" ") + // ); + + let arg_line = ["yt", "select"] + .into_iter() + .chain(line.iter().map(String::as_str)); + + let args = CliArgs::parse_from(arg_line); + + let Command::Select { cmd } = args + .command + .expect("This will be some, as we constructed it above.") + else { + unreachable!("This is checked in the `filter_line` function") + }; + + match cmd.expect( + "This value should always be some \ + here, as it would otherwise thrown an error above.", + ) { + SelectCommand::File { .. } | SelectCommand::Split { .. } => { + bail!("You cannot use `select file` or `select split` recursively.") + } + SelectCommand::Add { urls, start, stop } => { + Box::pin(standalone::add::add(app, urls, start, stop)).await?; + } + other => { + let shared = other + .clone() + .into_shared() + .expect("The ones without shared should have been filtered out."); + + let hash = shared.hash.realize(app, Some(&all_hashes)).await?; + let mut video = hash + .get_with_app(app) + .await + .expect("The hash was already realized, it should therefore exist"); + + handle_select_cmd(app, other, &mut video, Some(line_number), &mut ops).await?; + } + } + } + } + + ops.commit(app).await?; + Ok(-line_number) +} + +async fn open_editor_at(path: &Path) -> Result<()> { + let editor = env::var("EDITOR").unwrap_or("nvim".to_owned()); + + let mut nvim = process::Command::new(&editor); + nvim.arg(path); + let status = nvim + .status() + .await + .with_context(|| format!("Falied to run editor: {editor}"))?; + + if status.success() { + Ok(()) + } else { + bail!("Editor ({editor}) exited with error status: {}", status) + } +} + +fn process_line(line: &str) -> Result<Option<Vec<String>>> { + // Filter out comments and empty lines + if line.starts_with('#') || line.trim().is_empty() { + Ok(None) + } else { + let split: Vec<_> = { + let mut shl = Shlex::new(line); + let res = shl.by_ref().collect(); + + if shl.had_error { + bail!("Failed to parse line '{line}'") + } + + assert_eq!(shl.line_no, 1, "A unexpected newline appeared"); + res + }; + + assert!(!split.is_empty()); + + Ok(Some(split)) + } +} diff --git a/crates/yt/src/commands/select/implm/mod.rs b/crates/yt/src/commands/select/implm/mod.rs new file mode 100644 index 0000000..755076c --- /dev/null +++ b/crates/yt/src/commands/select/implm/mod.rs @@ -0,0 +1,42 @@ +use crate::{app::App, commands::select::SelectCommand, storage::db::insert::Operations}; + +use anyhow::Result; + +mod fs_generators; +mod standalone; + +impl SelectCommand { + pub(crate) async fn implm(self, app: &App) -> Result<()> { + match self { + SelectCommand::File { + done, + use_last_selection, + } => Box::pin(fs_generators::select_file(&app, done, use_last_selection)).await?, + SelectCommand::Split { + done, + sort_key, + sort_mode, + } => Box::pin(fs_generators::select_split(&app, done, sort_key, sort_mode)).await?, + SelectCommand::Add { urls, start, stop } => { + Box::pin(standalone::add::add(&app, urls, start, stop)).await?; + } + other => { + let shared = other + .clone() + .into_shared() + .expect("The ones without shared should have been filtered out."); + let hash = shared.hash.realize(&app, None).await?; + let mut video = hash + .get_with_app(&app) + .await + .expect("The hash was already realized, it should therefore exist"); + + let mut ops = Operations::new("Main: handle select cmd"); + standalone::handle_select_cmd(&app, other, &mut video, None, &mut ops).await?; + ops.commit(&app).await?; + } + } + + Ok(()) + } +} diff --git a/crates/yt/src/select/cmds/add.rs b/crates/yt/src/commands/select/implm/standalone/add.rs index 43c9f75..ec32039 100644 --- a/crates/yt/src/select/cmds/add.rs +++ b/crates/yt/src/commands/select/implm/standalone/add.rs @@ -10,9 +10,8 @@ use crate::{ app::App, - download::download_options::download_opts, - storage::db::{extractor_hash::ExtractorHash, insert::Operations}, - update::video_entry_to_video, + storage::db::{extractor_hash::ExtractorHash, insert::Operations, video::Video}, + yt_dlp::yt_dlp_opts_updating, }; use anyhow::{Context, Result, bail}; @@ -52,7 +51,7 @@ pub(crate) async fn add( error!( "Video '{}'{} is already in the database. Skipped adding it", extractor_hash - .into_short_hash(app) + .as_short_hash(app) .await .with_context(|| format!( "Failed to format hash of video '{}' as short hash", @@ -69,7 +68,7 @@ pub(crate) async fn add( } let mut ops = Operations::new("SelectAdd: Video entry to video"); - let video = video_entry_to_video(&entry, None)?.add(&mut ops)?; + let video = Video::from_info_json(&entry, None)?.add(&mut ops)?; ops.commit(app).await?; println!("{}", &video.to_line_display(app, None).await?); @@ -77,7 +76,7 @@ pub(crate) async fn add( Ok(()) } - let yt_dlp = download_opts(app, None)?; + let yt_dlp = yt_dlp_opts_updating(start.unwrap_or(0) + stop.unwrap_or(0))?; let entry = yt_dlp .extract_info(&url, false, true) @@ -160,7 +159,7 @@ fn take_vector<T>(vector: &[T], start: usize, stop: usize) -> Result<&[T]> { #[cfg(test)] mod test { - use crate::select::cmds::add::take_vector; + use super::take_vector; #[test] fn test_vector_take() { diff --git a/crates/yt/src/select/cmds/mod.rs b/crates/yt/src/commands/select/implm/standalone/mod.rs index 1713233..dd6de45 100644 --- a/crates/yt/src/select/cmds/mod.rs +++ b/crates/yt/src/commands/select/implm/standalone/mod.rs @@ -1,20 +1,9 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// 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 <https://www.gnu.org/licenses/gpl-3.0.txt>. - use std::io::{Write, stderr}; use crate::{ ansi_escape_codes, app::App, - cli::{SelectCommand, SharedSelectionCommandArgs}, + commands::select::{SelectCommand, SharedSelectionCommandArgs}, storage::db::{ insert::{Operations, video::Operation}, video::{Priority, Video, VideoStatus}, @@ -23,7 +12,7 @@ use crate::{ use anyhow::{Context, Result, bail}; -pub(crate) mod add; +pub(super) mod add; pub(crate) async fn handle_select_cmd( app: &App, diff --git a/crates/yt/src/commands/select/mod.rs b/crates/yt/src/commands/select/mod.rs new file mode 100644 index 0000000..1b06206 --- /dev/null +++ b/crates/yt/src/commands/select/mod.rs @@ -0,0 +1,219 @@ +use std::{ + fmt::{self, Display, Formatter}, + str::FromStr, +}; + +use chrono::NaiveDate; +use clap::{Args, Subcommand, ValueEnum}; +use url::Url; + +use crate::{select::duration::MaybeDuration, storage::db::extractor_hash::LazyExtractorHash}; + +mod implm; + +#[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<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")] + 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(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<i64>, + + /// The subtitles to download (e.g. 'en,de,sv') + #[arg(short = 'l', long)] + pub(crate) subtitle_langs: Option<String>, + + /// The speed to set mpv to + #[arg(short = 's', long)] + pub(crate) playback_speed: Option<f64>, + + /// The short extractor hash + pub(crate) hash: LazyExtractorHash, + + pub(crate) title: Option<String>, + + pub(crate) date: Option<OptionalNaiveDate>, + + pub(crate) publisher: Option<OptionalPublisher>, + + pub(crate) duration: Option<MaybeDuration>, + + pub(crate) url: Option<Url>, +} + +impl SelectCommand { + pub(crate) fn into_shared(self) -> Option<SharedSelectionCommandArgs> { + 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<NaiveDate>, +} +impl FromStr for OptionalNaiveDate { + type Err = anyhow::Error; + fn from_str(v: &str) -> Result<Self, Self::Err> { + 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<String>, +} +impl FromStr for OptionalPublisher { + type Err = anyhow::Error; + fn from_str(v: &str) -> Result<Self, Self::Err> { + 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"), + } + } +} diff --git a/crates/yt/src/commands/status/implm.rs b/crates/yt/src/commands/status/implm.rs new file mode 100644 index 0000000..fc046c9 --- /dev/null +++ b/crates/yt/src/commands/status/implm.rs @@ -0,0 +1,147 @@ +use std::time::Duration; + +use crate::{ + app::App, + commands::status::StatusCommand, + select::duration::MaybeDuration, + shared::bytes::Bytes, + storage::db::{ + subscription::Subscriptions, + video::{Video, VideoStatusMarker}, + }, + yt_dlp::get_current_cache_allocation, +}; + +use anyhow::{Context, Result}; + +macro_rules! get { + ($videos:expr, $status:ident) => { + $videos + .iter() + .filter(|vid| vid.status.as_marker() == VideoStatusMarker::$status) + .count() + }; + + (@collect $videos:expr, $status:ident) => { + $videos + .iter() + .filter(|vid| vid.status.as_marker() == VideoStatusMarker::$status) + .collect() + }; +} + +impl StatusCommand { + pub(crate) async fn implm(self, app: &App) -> Result<()> { + let StatusCommand { format } = self; + + let all_videos = Video::in_states(app, VideoStatusMarker::ALL).await?; + + // lengths + let picked_videos_len = get!(all_videos, Pick); + + let watch_videos_len = get!(all_videos, Watch); + let cached_videos_len = get!(all_videos, Cached); + let watched_videos_len = get!(all_videos, Watched); + let watched_videos: Vec<_> = get!(@collect all_videos, Watched); + + let drop_videos_len = get!(all_videos, Drop); + let dropped_videos_len = get!(all_videos, Dropped); + + let subscriptions = Subscriptions::get(app).await?; + let subscriptions_len = subscriptions.0.len(); + + let watchtime_status = { + let total_watch_time_raw = watched_videos + .iter() + .fold(Duration::default(), |acc, vid| acc + vid.watch_progress); + + // Most things are watched at a speed of s (which is defined in the config file). + // Thus + // y = x * s -> y / s = x + let total_watch_time = Duration::from_secs_f64( + (total_watch_time_raw.as_secs_f64()) / app.config.select.playback_speed, + ); + + let speed = app.config.select.playback_speed; + + // Do not print the adjusted time, if the user has keep the speed level at 1. + #[allow(clippy::float_cmp)] + if speed == 1.0 { + format!( + "Total Watchtime: {}\n", + MaybeDuration::from_std(total_watch_time_raw) + ) + } else { + format!( + "Total Watchtime: {} (at {speed} speed: {})\n", + MaybeDuration::from_std(total_watch_time_raw), + MaybeDuration::from_std(total_watch_time), + ) + } + }; + + let watch_rate: f64 = { + fn to_f64(input: usize) -> f64 { + f64::from(u32::try_from(input).expect("This should never exceed u32::MAX")) + } + + let count = + to_f64(watched_videos_len) / (to_f64(drop_videos_len) + to_f64(dropped_videos_len)); + count * 100.0 + }; + + let cache_usage: Bytes = get_current_cache_allocation(app) + .await + .context("Failed to get current cache allocation")?; + + if let Some(fmt) = format { + let output = fmt + .replace( + "{picked_videos_len}", + picked_videos_len.to_string().as_str(), + ) + .replace("{watch_videos_len}", watch_videos_len.to_string().as_str()) + .replace( + "{cached_videos_len}", + cached_videos_len.to_string().as_str(), + ) + .replace( + "{watched_videos_len}", + watched_videos_len.to_string().as_str(), + ) + .replace("{watch_rate}", watch_rate.to_string().as_str()) + .replace("{drop_videos_len}", drop_videos_len.to_string().as_str()) + .replace( + "{dropped_videos_len}", + dropped_videos_len.to_string().as_str(), + ) + .replace("{watchtime_status}", watchtime_status.to_string().as_str()) + .replace( + "{subscriptions_len}", + subscriptions_len.to_string().as_str(), + ) + .replace("{cache_usage}", cache_usage.to_string().as_str()); + + print!("{output}"); + } else { + println!( + "\ +Picked Videos: {picked_videos_len} + +Watch Videos: {watch_videos_len} +Cached Videos: {cached_videos_len} +Watched Videos: {watched_videos_len} (watch rate: {watch_rate:.2} %) + +Drop Videos: {drop_videos_len} +Dropped Videos: {dropped_videos_len} + +{watchtime_status} + + Subscriptions: {subscriptions_len} + Cache usage: {cache_usage}" + ); + } + + Ok(()) + } +} diff --git a/crates/yt/src/commands/status/mod.rs b/crates/yt/src/commands/status/mod.rs new file mode 100644 index 0000000..dc6e865 --- /dev/null +++ b/crates/yt/src/commands/status/mod.rs @@ -0,0 +1,10 @@ +use clap::Parser; + +mod implm; + +#[derive(Parser, Debug)] +pub(crate) struct StatusCommand { + /// Which format to use + #[arg(short, long)] + format: Option<String>, +} diff --git a/crates/yt/src/subscribe/mod.rs b/crates/yt/src/commands/subscriptions/implm.rs index bcf778b..3051522 100644 --- a/crates/yt/src/subscribe/mod.rs +++ b/crates/yt/src/commands/subscriptions/implm.rs @@ -1,46 +1,83 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// 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 <https://www.gnu.org/licenses/gpl-3.0.txt>. - use std::str::FromStr; -use anyhow::{Context, Result, bail}; -use log::{error, warn}; -use tokio::io::{AsyncBufRead, AsyncBufReadExt}; -use url::Url; -use yt_dlp::{json_cast, json_get, options::YoutubeDLOptions}; - use crate::{ app::App, + commands::subscriptions::SubscriptionCommand, storage::db::{ insert::{Operations, subscription::Operation}, subscription::{Subscription, Subscriptions, check_url}, }, }; -pub(crate) async fn unsubscribe(app: &App, name: String) -> Result<()> { - let mut present_subscriptions = Subscriptions::get(app).await?; - - let mut ops = Operations::new("Subscribe: unsubscribe"); - if let Some(subscription) = present_subscriptions.0.remove(&name) { - subscription.remove(&mut ops); - } else { - bail!("Couldn't find subscription: '{}'", &name); - } +use anyhow::{Context, Result, bail}; +use log::{error, warn}; +use tokio::{ + fs::File, + io::{AsyncBufRead, AsyncBufReadExt, BufReader, stdin}, +}; +use url::Url; +use yt_dlp::{json_cast, json_get, options::YoutubeDLOptions}; - ops.commit(app).await?; +impl SubscriptionCommand { + pub(crate) async fn implm(self, app: &App) -> Result<()> { + match self { + SubscriptionCommand::Add { + name, + url, + no_check, + } => { + let mut ops = Operations::new("main: subscribe"); + subscribe(&app, name, url, no_check, &mut ops) + .await + .context("Failed to add a subscription")?; + ops.commit(&app).await?; + } + SubscriptionCommand::Remove { name } => { + let mut present_subscriptions = Subscriptions::get(app).await?; + + let mut ops = Operations::new("Subscribe: unsubscribe"); + if let Some(subscription) = present_subscriptions.0.remove(&name) { + subscription.remove(&mut ops); + } else { + bail!("Couldn't find subscription: '{}'", &name); + } + ops.commit(app) + .await + .with_context(|| format!("Failed to unsubscribe from {name:?}"))?; + } + SubscriptionCommand::List {} => { + let all_subs = Subscriptions::get(&app).await?; + + for (key, val) in all_subs.0 { + println!("{}: '{}'", key, val.url); + } + } + SubscriptionCommand::Export {} => { + let all_subs = Subscriptions::get(&app).await?; + for val in all_subs.0.values() { + println!("{}", val.url); + } + } + SubscriptionCommand::Import { + file, + force, + no_check, + } => { + if let Some(file) = file { + let f = File::open(file).await?; + + import(&app, BufReader::new(f), force, no_check).await?; + } else { + import(&app, BufReader::new(stdin()), force, no_check).await?; + } + } + } - Ok(()) + Ok(()) + } } -pub(crate) async fn import<W: AsyncBufRead + AsyncBufReadExt + Unpin>( +async fn import<W: AsyncBufRead + AsyncBufReadExt + Unpin>( app: &App, reader: W, force: bool, @@ -77,7 +114,7 @@ pub(crate) async fn import<W: AsyncBufRead + AsyncBufReadExt + Unpin>( Ok(()) } -pub(crate) async fn subscribe( +async fn subscribe( app: &App, name: Option<String>, url: Url, @@ -190,7 +227,8 @@ async fn actual_subscribe( if let Some(subs) = present_subscriptions.0.get(&name) { bail!( "The subscription '{}' could not be added, \ - as another one with the same name ('{}') already exists. It links to the Url: '{}'", + as another one with the same name ('{}') already exists. \ + It points to the Url: '{}'", name, name, subs.url diff --git a/crates/yt/src/commands/subscriptions/mod.rs b/crates/yt/src/commands/subscriptions/mod.rs new file mode 100644 index 0000000..530f5f5 --- /dev/null +++ b/crates/yt/src/commands/subscriptions/mod.rs @@ -0,0 +1,52 @@ +use std::path::PathBuf; + +use clap::Subcommand; +use clap_complete::ArgValueCompleter; +use url::Url; + +use crate::commands::complete_subscription; + +mod implm; + +#[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<String>, + + /// 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<PathBuf>, + + /// 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 {}, +} diff --git a/crates/yt/src/commands/update/implm/mod.rs b/crates/yt/src/commands/update/implm/mod.rs new file mode 100644 index 0000000..bb9323e --- /dev/null +++ b/crates/yt/src/commands/update/implm/mod.rs @@ -0,0 +1,52 @@ +use crate::{ + app::App, + commands::update::{UpdateCommand, implm::updater::Updater}, + storage::db::{ + extractor_hash::ExtractorHash, + subscription::{Subscription, Subscriptions}, + }, +}; + +use anyhow::{Result, bail}; + +mod updater; + +impl UpdateCommand { + pub(crate) async fn implm(self, app: &App) -> Result<()> { + let UpdateCommand { + max_backlog, + subscriptions: subscription_names_to_update, + } = self; + + let mut all_subs = Subscriptions::get(&app).await?; + + let max_backlog = max_backlog.unwrap_or(app.config.update.max_backlog); + + let subs: Vec<Subscription> = if subscription_names_to_update.is_empty() { + all_subs.0.into_values().collect() + } else { + subscription_names_to_update + .into_iter() + .map(|sub| { + if let Some(val) = all_subs.0.remove(&sub) { + Ok(val) + } else { + bail!( + "Your specified subscription to update '{}' is not a subscription!", + sub + ) + } + }) + .collect::<Result<_>>()? + }; + + // We can get away with not having to re-fetch the hashes every time, as the returned video + // should not contain duplicates. + let hashes = ExtractorHash::get_all(app).await?; + + let updater = Updater::new(max_backlog, app.config.update.pool_size, hashes); + updater.update(app, subs).await?; + + Ok(()) + } +} diff --git a/crates/yt/src/update/updater.rs b/crates/yt/src/commands/update/implm/updater.rs index ae9acb1..5969d54 100644 --- a/crates/yt/src/update/updater.rs +++ b/crates/yt/src/commands/update/implm/updater.rs @@ -1,22 +1,9 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// 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 <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::{ - io::{Write, stderr}, - sync::atomic::{AtomicUsize, Ordering}, -}; +use std::sync::atomic::{AtomicUsize, Ordering}; use anyhow::{Context, Result}; use futures::{StreamExt, future::join_all, stream}; use log::{Level, debug, error, log_enabled}; -use serde_json::json; +use tokio::io::{AsyncWriteExt, stderr}; use tokio_util::task::LocalPoolHandle; use yt_dlp::{ info_json::InfoJson, json_cast, options::YoutubeDLOptions, process_ie_result, @@ -24,13 +11,14 @@ use yt_dlp::{ }; use crate::{ - ansi_escape_codes::{clear_whole_line, move_to_col}, + ansi_escape_codes, app::App, - storage::db::{extractor_hash::ExtractorHash, subscription::Subscription}, + storage::db::{ + extractor_hash::ExtractorHash, insert::Operations, subscription::Subscription, video::Video, + }, + yt_dlp::yt_dlp_opts_updating, }; -use super::process_subscription; - pub(super) struct Updater { max_backlog: usize, hashes: Vec<ExtractorHash>, @@ -83,32 +71,21 @@ impl Updater { let max_backlog = self.max_backlog; let hashes = self.hashes.clone(); - let yt_dlp = YoutubeDLOptions::new() - .set("playliststart", 1) - .set("playlistend", max_backlog) - .set("noplaylist", false) - .set( - "extractor_args", - json! {{"youtubetab": {"approximate_date": [""]}}}, - ) - // // TODO: This also removes unlisted and other stuff. Find a good way to remove the - // // members-only videos from the feed. <2025-04-17> - // .set("match-filter", "availability=public") - .build()?; + let yt_dlp = yt_dlp_opts_updating(max_backlog)?; self.pool .spawn_pinned(move || { async move { if !log_enabled!(Level::Debug) { - clear_whole_line(); - move_to_col(1); + ansi_escape_codes::clear_whole_line(); + ansi_escape_codes::move_to_col(1); eprint!( "({}/{total_number}) Checking playlist {}...", REACHED_NUMBER.fetch_add(1, Ordering::Relaxed), sub.name ); - move_to_col(1); - stderr().flush()?; + ansi_escape_codes::move_to_col(1); + stderr().flush().await?; } let info = yt_dlp @@ -191,3 +168,30 @@ impl Updater { .await? } } + +async fn process_subscription(app: &App, sub: Subscription, entry: InfoJson) -> Result<()> { + let mut ops = Operations::new("Update: process subscription"); + let video = Video::from_info_json(&entry, Some(&sub)) + .context("Failed to parse search entry as Video")?; + + let title = video.title.clone(); + let url = video.url.clone(); + let video = video.add(&mut ops).with_context(|| { + format!("Failed to add video to database: '{title}' (with url: '{url}')") + })?; + + ops.commit(app).await.with_context(|| { + format!( + "Failed to add video to database: '{}' (with url: '{}')", + video.title, video.url + ) + })?; + println!( + "{}", + &video + .to_line_display(app, None) + .await + .with_context(|| format!("Failed to format video: '{}'", video.title))? + ); + Ok(()) +} diff --git a/crates/yt/src/commands/update/mod.rs b/crates/yt/src/commands/update/mod.rs new file mode 100644 index 0000000..6f1c865 --- /dev/null +++ b/crates/yt/src/commands/update/mod.rs @@ -0,0 +1,17 @@ +use clap::Parser; +use clap_complete::ArgValueCompleter; + +use crate::commands::complete_subscription; + +mod implm; + +#[derive(Parser, Debug)] +pub(crate) struct UpdateCommand { + /// The maximal number of videos to fetch for each subscription. + #[arg(short, long)] + max_backlog: Option<usize>, + + /// The subscriptions to update + #[arg(add = ArgValueCompleter::new(complete_subscription))] + subscriptions: Vec<String>, +} diff --git a/crates/yt/src/commands/videos/implm.rs b/crates/yt/src/commands/videos/implm.rs new file mode 100644 index 0000000..7d13ceb --- /dev/null +++ b/crates/yt/src/commands/videos/implm.rs @@ -0,0 +1,63 @@ +use crate::{ + app::App, + commands::videos::VideosCommand, + storage::db::video::{Video, VideoStatusMarker}, +}; + +use anyhow::{Context, Result}; +use futures::{TryStreamExt, stream::FuturesUnordered}; + +impl VideosCommand { + pub(crate) async fn implm(self, app: &App) -> Result<()> { + match self { + VideosCommand::List { + search_query, + limit, + format, + } => { + let all_videos = Video::in_states(app, VideoStatusMarker::ALL).await?; + + // turn one video to a color display, to pre-warm the hash shrinking cache + if let Some(val) = all_videos.first() { + val.to_line_display(app, format.clone()).await?; + } + + let limit = limit.unwrap_or(all_videos.len()); + + let all_video_strings: Vec<String> = all_videos + .into_iter() + .take(limit) + .map(|vid| to_line_display_owned(vid, app, format.clone())) + .collect::<FuturesUnordered<_>>() + .try_collect::<Vec<String>>() + .await?; + + if let Some(query) = search_query { + all_video_strings + .into_iter() + .filter(|video| video.to_lowercase().contains(&query.to_lowercase())) + .for_each(|video| println!("{video}")); + } else { + println!("{}", all_video_strings.join("\n")); + } + } + VideosCommand::Info { hash, format } => { + let video = hash.realize(&app, None).await?.get_with_app(&app).await?; + + print!( + "{}", + &video + .to_info_display(&app, format) + .await + .context("Failed to format video")? + ); + } + } + + Ok(()) + } +} + +async fn to_line_display_owned(video: Video, app: &App, format: Option<String>) -> Result<String> { + video.to_line_display(app, format).await +} diff --git a/crates/yt/src/commands/videos/mod.rs b/crates/yt/src/commands/videos/mod.rs new file mode 100644 index 0000000..93a11a1 --- /dev/null +++ b/crates/yt/src/commands/videos/mod.rs @@ -0,0 +1,36 @@ +use clap::{ArgAction, Subcommand}; + +use crate::storage::db::extractor_hash::LazyExtractorHash; + +mod implm; + +#[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<String>, + + /// The format string to use. + // TODO(@bpeetz): Encode the default format, as the default string here. <2025-07-04> + #[arg(short, long)] + format: Option<String>, + + /// The number of videos to show + #[arg(short, long)] + limit: Option<usize>, + }, + + /// 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<String>, + }, +} diff --git a/crates/yt/src/commands/watch/implm/mod.rs b/crates/yt/src/commands/watch/implm/mod.rs new file mode 100644 index 0000000..338f80a --- /dev/null +++ b/crates/yt/src/commands/watch/implm/mod.rs @@ -0,0 +1,20 @@ +use std::sync::Arc; + +use crate::{app::App, commands::watch::WatchCommand}; + +use anyhow::Result; + +mod watch; + +impl WatchCommand { + pub(crate) async fn implm(self, app: Arc<App>) -> Result<()> { + let WatchCommand { + provide_ipc_socket, + headless, + } = self; + + watch::watch(app, provide_ipc_socket, headless).await?; + + Ok(()) + } +} diff --git a/crates/yt/src/watch/mod.rs b/crates/yt/src/commands/watch/implm/watch/mod.rs index 2fe34b2..1436d8d 100644 --- a/crates/yt/src/watch/mod.rs +++ b/crates/yt/src/commands/watch/implm/watch/mod.rs @@ -26,14 +26,12 @@ use tokio::{task, time}; use self::playlist_handler::Status; use crate::{ app::App, - cache::maintain, storage::{ - db::{insert::Operations, playlist::Playlist}, + db::{insert::{maintenance::clear_stale_downloaded_paths, Operations}, playlist::Playlist}, notify::wait_for_db_write, }, }; -pub(crate) mod playlist; pub(crate) mod playlist_handler; fn init_mpv(app: &App, ipc_socket: Option<PathBuf>, headless: bool) -> Result<(Mpv, EventContext)> { @@ -112,7 +110,7 @@ fn init_mpv(app: &App, ipc_socket: Option<PathBuf>, headless: bool) -> Result<(M } pub(crate) async fn watch(app: Arc<App>, provide_ipc_socket: bool, headless: bool) -> Result<()> { - maintain(&app).await?; + clear_stale_downloaded_paths(&app).await?; let ipc_socket = if provide_ipc_socket { Some(app.config.paths.mpv_ipc_socket_path.clone()) diff --git a/crates/yt/src/watch/playlist_handler/client_messages/mod.rs b/crates/yt/src/commands/watch/implm/watch/playlist_handler/client_messages.rs index c05ca87..6c8ebbe 100644 --- a/crates/yt/src/watch/playlist_handler/client_messages/mod.rs +++ b/crates/yt/src/commands/watch/implm/watch/playlist_handler/client_messages.rs @@ -10,7 +10,7 @@ use std::{env, time::Duration}; -use crate::{app::App, comments}; +use crate::{app::App, storage::db::video::Video}; use anyhow::{Context, Result, bail}; use libmpv2::Mpv; @@ -72,7 +72,7 @@ pub(super) async fn handle_yt_description_external(app: &App) -> Result<()> { Ok(()) } pub(super) async fn handle_yt_description_local(app: &App, mpv: &Mpv) -> Result<()> { - let description: String = comments::description::get(app) + let description: String = Video::get_current_description(app) .await? .chars() .take(app.config.watch.local_displays_length) @@ -87,7 +87,7 @@ pub(super) async fn handle_yt_comments_external(app: &App) -> Result<()> { Ok(()) } pub(super) async fn handle_yt_comments_local(app: &App, mpv: &Mpv) -> Result<()> { - let comments: String = comments::get(app) + let comments: String = Video::get_current_comments(app) .await? .render(false) .chars() diff --git a/crates/yt/src/watch/playlist_handler/mod.rs b/crates/yt/src/commands/watch/implm/watch/playlist_handler/mod.rs index 443fd26..443fd26 100644 --- a/crates/yt/src/watch/playlist_handler/mod.rs +++ b/crates/yt/src/commands/watch/implm/watch/playlist_handler/mod.rs diff --git a/crates/yt/src/commands/watch/mod.rs b/crates/yt/src/commands/watch/mod.rs new file mode 100644 index 0000000..8bae5c9 --- /dev/null +++ b/crates/yt/src/commands/watch/mod.rs @@ -0,0 +1,14 @@ +use clap::Parser; + +mod implm; + +#[derive(Parser, Debug)] +pub(crate) struct WatchCommand { + /// 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, +} diff --git a/crates/yt/src/comments/description.rs b/crates/yt/src/comments/description.rs deleted file mode 100644 index 2065970..0000000 --- a/crates/yt/src/comments/description.rs +++ /dev/null @@ -1,39 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// 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 <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use crate::{App, comments::output::display_fmt_and_less, storage::db::video::Video}; - -use anyhow::{Result, bail}; -use yt_dlp::json_cast; - -pub(crate) async fn description(app: &App) -> Result<()> { - let description = get(app).await?; - display_fmt_and_less(description).await?; - - Ok(()) -} - -pub(crate) async fn get(app: &App) -> Result<String> { - let currently_playing_video: Video = if let Some(video) = Video::currently_focused(app).await? { - video - } else { - bail!("Could not find a currently playing video!"); - }; - - let info_json = ¤tly_playing_video - .get_info_json()? - .expect("A currently *playing* must be cached. And thus the info.json should be available"); - - Ok(info_json - .get("description") - .map_or("<No description>", |val| json_cast!(val, as_str)) - .to_owned()) -} diff --git a/crates/yt/src/comments/mod.rs b/crates/yt/src/comments/mod.rs deleted file mode 100644 index e667bd9..0000000 --- a/crates/yt/src/comments/mod.rs +++ /dev/null @@ -1,162 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// 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 <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::mem; - -use anyhow::{Result, bail}; -use comment::{Comment, CommentExt, Comments, Parent}; -use output::display_fmt_and_less; -use regex::Regex; -use yt_dlp::json_cast; - -use crate::{app::App, storage::db::video::Video}; - -mod comment; -mod display; -pub(crate) mod output; - -pub(crate) mod description; -pub(crate) use description::*; - -#[allow(clippy::too_many_lines)] -pub(crate) async fn get(app: &App) -> Result<Comments> { - let currently_playing_video: Video = if let Some(video) = Video::currently_focused(app).await? { - video - } else { - bail!("Could not find a currently playing video!"); - }; - - let info_json = ¤tly_playing_video.get_info_json()?.expect( - "A currently *playing* video must be cached. And thus the info.json should be available", - ); - - let base_comments = if let Some(comments) = info_json.get("comments") { - json_cast!(comments, as_array) - } else { - bail!( - "The video ('{}') does not have comments!", - info_json - .get("title") - .map(|val| json_cast!(val, as_str)) - .unwrap_or("<No Title>") - ) - }; - - let mut comments = Comments::new(); - for c in base_comments { - let c: Comment = serde_json::from_value(c.to_owned())?; - if let Parent::Id(id) = &c.parent { - comments.insert(&(id.clone()), CommentExt::from(c)); - } else { - comments.push(CommentExt::from(c)); - } - } - - comments.vec.iter_mut().for_each(|comment| { - let replies = mem::take(&mut comment.replies); - let mut output_replies: Vec<CommentExt> = vec![]; - - let re = Regex::new(r"\u{200b}?(@[^\t\s]+)\u{200b}?").expect("This is hardcoded"); - for reply in replies { - if let Some(replyee_match) = re.captures(&reply.value.text){ - let full_match = replyee_match.get(0).expect("This will always exist"); - let text = reply. - value. - text[0..full_match.start()] - .to_owned() - + - &reply - .value - .text[full_match.end()..]; - let text: &str = text.trim().trim_matches('\u{200b}'); - - let replyee = replyee_match.get(1).expect("This should also exist").as_str(); - - - if let Some(parent) = output_replies - .iter_mut() - // .rev() - .flat_map(|com| &mut com.replies) - .flat_map(|com| &mut com.replies) - .flat_map(|com| &mut com.replies) - .filter(|com| com.value.author == replyee) - .last() - { - parent.replies.push(CommentExt::from(Comment { - text: text.to_owned(), - ..reply.value - })); - } else if let Some(parent) = output_replies - .iter_mut() - // .rev() - .flat_map(|com| &mut com.replies) - .flat_map(|com| &mut com.replies) - .filter(|com| com.value.author == replyee) - .last() - { - parent.replies.push(CommentExt::from(Comment { - text: text.to_owned(), - ..reply.value - })); - } else if let Some(parent) = output_replies - .iter_mut() - // .rev() - .flat_map(|com| &mut com.replies) - .filter(|com| com.value.author == replyee) - .last() - { - parent.replies.push(CommentExt::from(Comment { - text: text.to_owned(), - ..reply.value - })); - } else if let Some(parent) = output_replies.iter_mut() - // .rev() - .filter(|com| com.value.author == replyee) - .last() - { - parent.replies.push(CommentExt::from(Comment { - text: text.to_owned(), - ..reply.value - })); - } else { - eprintln!( - "Failed to find a parent for ('{}') both directly and via replies! The reply text was:\n'{}'\n", - replyee, - reply.value.text - ); - output_replies.push(reply); - } - } else { - output_replies.push(reply); - } - } - comment.replies = output_replies; - }); - - Ok(comments) -} - -pub(crate) async fn comments(app: &App) -> Result<()> { - let comments = get(app).await?; - - display_fmt_and_less(comments.render(true)).await?; - - Ok(()) -} - -#[cfg(test)] -mod test { - #[test] - fn test_string_replacement() { - let s = "A \n\nB\n\nC".to_owned(); - assert_eq!("A \n \n B\n \n C", s.replace('\n', "\n ")); - } -} diff --git a/crates/yt/src/constants.rs b/crates/yt/src/constants.rs index 690e018..e69de29 100644 --- a/crates/yt/src/constants.rs +++ b/crates/yt/src/constants.rs @@ -1,12 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// 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 <https://www.gnu.org/licenses/gpl-3.0.txt>. - -pub(crate) const HELP_STR: &str = include_str!("./select/selection_file/help.str"); diff --git a/crates/yt/src/main.rs b/crates/yt/src/main.rs index 2331dd7..4abebce 100644 --- a/crates/yt/src/main.rs +++ b/crates/yt/src/main.rs @@ -13,50 +13,31 @@ // to print it anyways. #![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)] -use std::sync::Arc; - -use anyhow::{Context, Result, bail}; +use anyhow::{Context, Result}; use app::App; -use cache::{invalidate, maintain}; use clap::{CommandFactory, Parser}; -use cli::{CacheCommand, SelectCommand, SubscriptionCommand, VideosCommand}; use config::Config; -use log::{error, info}; -use select::cmds::handle_select_cmd; -use tokio::{ - fs::File, - io::{BufReader, stdin}, - task::JoinHandle, -}; +use log::info; + +use crate::commands::Command; -use crate::{ - cli::Command, - shared::bytes::Bytes, - storage::db::{insert::Operations, subscription::Subscriptions}, -}; +pub(crate) mod output; +pub(crate) mod yt_dlp; pub(crate) mod ansi_escape_codes; pub(crate) mod app; pub(crate) mod cli; +pub(crate) mod commands; pub(crate) mod shared; -pub(crate) mod cache; -pub(crate) mod comments; pub(crate) mod config; pub(crate) mod constants; -pub(crate) mod download; pub(crate) mod select; -pub(crate) mod status; pub(crate) mod storage; -pub(crate) mod subscribe; -pub(crate) mod update; pub(crate) mod version; pub(crate) mod videos; -pub(crate) mod watch; #[tokio::main] -// This is _the_ main function after all. It is not really good, but it sort of works. -#[allow(clippy::too_many_lines)] async fn main() -> Result<()> { clap_complete::CompleteEnv::with_factory(cli::CliArgs::command).complete(); @@ -100,194 +81,10 @@ async fn main() -> Result<()> { let app = App::new(config, !args.no_migrate_db).await?; - match args.command.unwrap_or(Command::default()) { - Command::Download { - force, - max_cache_size, - } => { - 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)); - - maintain(&app).await?; - if force { - invalidate(&app).await?; - } - - download::Downloader::new() - .consume(Arc::new(app), max_cache_size) - .await?; - } - Command::Select { cmd } => { - let cmd = cmd.unwrap_or(SelectCommand::default()); - - match cmd { - SelectCommand::File { - done, - use_last_selection, - } => Box::pin(select::select_file(&app, done, use_last_selection)).await?, - SelectCommand::Split { - done, - sort_key, - sort_mode, - } => Box::pin(select::select_split(&app, done, sort_key, sort_mode)).await?, - SelectCommand::Add { urls, start, stop } => { - Box::pin(select::cmds::add::add(&app, urls, start, stop)).await?; - } - other => { - let shared = other - .clone() - .into_shared() - .expect("The ones without shared should have been filtered out."); - let hash = shared.hash.realize(&app).await?; - let mut video = hash - .get_with_app(&app) - .await - .expect("The hash was already realized, it should therefore exist"); - - let mut ops = Operations::new("Main: handle select cmd"); - handle_select_cmd(&app, other, &mut video, None, &mut ops).await?; - ops.commit(&app).await?; - } - } - } - Command::Sedowa {} => { - Box::pin(select::select_file(&app, false, false)).await?; - - let arc_app = Arc::new(app); - dowa(arc_app).await?; - } - Command::Dowa {} => { - let arc_app = Arc::new(app); - dowa(arc_app).await?; - } - Command::Videos { cmd } => match cmd { - VideosCommand::List { - search_query, - limit, - format, - } => { - videos::query(&app, limit, search_query, format) - .await - .context("Failed to query videos")?; - } - VideosCommand::Info { hash, format } => { - let video = hash.realize(&app).await?.get_with_app(&app).await?; - - print!( - "{}", - &video - .to_info_display(&app, format) - .await - .context("Failed to format video")? - ); - } - }, - Command::Update { - max_backlog, - subscriptions, - } => { - let all_subs = Subscriptions::get(&app).await?; - - for sub in &subscriptions { - if !all_subs.0.contains_key(sub) { - bail!( - "Your specified subscription to update '{}' is not a subscription!", - sub - ) - } - } - - let max_backlog = max_backlog.unwrap_or(app.config.update.max_backlog); - - update::update(&app, max_backlog, subscriptions).await?; - } - Command::Subscriptions { cmd } => match cmd { - SubscriptionCommand::Add { - name, - url, - no_check, - } => { - let mut ops = Operations::new("main: subscribe"); - subscribe::subscribe(&app, name, url, no_check, &mut ops) - .await - .context("Failed to add a subscription")?; - ops.commit(&app).await?; - } - SubscriptionCommand::Remove { name } => { - subscribe::unsubscribe(&app, name) - .await - .context("Failed to remove a subscription")?; - } - SubscriptionCommand::List {} => { - let all_subs = Subscriptions::get(&app).await?; - - for (key, val) in all_subs.0 { - println!("{}: '{}'", key, val.url); - } - } - SubscriptionCommand::Export {} => { - let all_subs = Subscriptions::get(&app).await?; - for val in all_subs.0.values() { - println!("{}", val.url); - } - } - SubscriptionCommand::Import { - file, - force, - no_check, - } => { - if let Some(file) = file { - let f = File::open(file).await?; - - subscribe::import(&app, BufReader::new(f), force, no_check).await?; - } else { - subscribe::import(&app, BufReader::new(stdin()), force, no_check).await?; - } - } - }, - - Command::Watch { - provide_ipc_socket, - headless, - } => watch::watch(Arc::new(app), provide_ipc_socket, headless).await?, - Command::Playlist { watch } => watch::playlist::playlist(&app, watch).await?, - - Command::Status { format } => status::show(&app, format).await?, - Command::Config {} => status::config(&app)?, - - Command::Database { command } => match command { - CacheCommand::Invalidate {} => invalidate(&app).await?, - CacheCommand::Maintain {} => maintain(&app).await?, - }, - - Command::Comments {} => { - comments::comments(&app).await?; - } - Command::Description {} => { - comments::description(&app).await?; - } - } - - Ok(()) -} - -async fn dowa(arc_app: Arc<App>) -> Result<()> { - let max_cache_size = arc_app.config.download.max_cache_size; - info!("Max cache size: '{max_cache_size}'"); - - let arc_app_clone = Arc::clone(&arc_app); - let download: JoinHandle<()> = tokio::spawn(async move { - let result = download::Downloader::new() - .consume(arc_app_clone, max_cache_size.as_u64()) - .await; - - if let Err(err) = result { - error!("Error from downloader: {err:?}"); - } - }); + args.command + .unwrap_or(Command::default()) + .implm(app) + .await?; - watch::watch(arc_app, false, false).await?; - download.await?; Ok(()) } diff --git a/crates/yt/src/comments/output.rs b/crates/yt/src/output/mod.rs index 4a27f3b..2f74519 100644 --- a/crates/yt/src/comments/output.rs +++ b/crates/yt/src/output/mod.rs @@ -17,7 +17,7 @@ use std::{ use anyhow::{Context, Result}; use uu_fmt::{FmtOptions, process_text}; -pub(crate) async fn display_fmt_and_less(input: String) -> Result<()> { +pub(crate) fn display_less(input: String) -> Result<()> { let mut less = Command::new("less") .args(["--raw-control-chars"]) .stdin(Stdio::piped()) @@ -25,7 +25,6 @@ pub(crate) async fn display_fmt_and_less(input: String) -> Result<()> { .spawn() .context("Failed to run less")?; - let input = format_text(&input); let mut stdin = less.stdin.take().context("Failed to open stdin")?; std::thread::spawn(move || { stdin @@ -38,11 +37,15 @@ pub(crate) async fn display_fmt_and_less(input: String) -> Result<()> { Ok(()) } +pub(crate) fn display_fmt_and_less(input: &str) -> Result<()> { + display_less(format_text(&input, None)) +} + #[must_use] -pub(crate) fn format_text(input: &str) -> String { +pub(crate) fn format_text(input: &str, termsize: Option<u16>) -> String { let input = input.trim(); - let width = termsize::get().map_or(90, |size| size.cols); + let width = termsize.unwrap_or_else(|| termsize::get().map_or(90, |size| size.cols)); let fmt_opts = FmtOptions { uniform: true, split_only: true, diff --git a/crates/yt/src/select/selection_file/duration.rs b/crates/yt/src/select/duration.rs index e536f18..f1de2ea 100644 --- a/crates/yt/src/select/selection_file/duration.rs +++ b/crates/yt/src/select/duration.rs @@ -203,7 +203,7 @@ impl std::fmt::Display for MaybeDuration { mod test { use std::str::FromStr; - use crate::select::selection_file::duration::{DAY, HOUR, MINUTE}; + use crate::select::duration::{DAY, HOUR, MINUTE}; use super::MaybeDuration; diff --git a/crates/yt/src/select/mod.rs b/crates/yt/src/select/mod.rs index b39bea2..91940ce 100644 --- a/crates/yt/src/select/mod.rs +++ b/crates/yt/src/select/mod.rs @@ -9,322 +9,8 @@ // You should have received a copy of the License along with this program. // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. -use std::{ - collections::HashMap, - env::{self}, - fs::{self, File, OpenOptions}, - io::{BufRead, BufReader, BufWriter, Read, Write}, - iter, - os::fd::{AsFd, AsRawFd}, - path::Path, - string::String, -}; +pub(crate) mod duration; -use crate::{ - app::App, - cli::{CliArgs, SelectCommand, SelectSplitSortKey, SelectSplitSortMode}, - constants::HELP_STR, - storage::db::{ - insert::Operations, - video::{Video, VideoStatusMarker}, - }, -}; - -use anyhow::{Context, Result, bail}; -use clap::Parser; -use cmds::handle_select_cmd; -use futures::{TryStreamExt, stream::FuturesOrdered}; -use log::info; -use selection_file::process_line; -use tempfile::Builder; -use tokio::process::Command; - -pub(crate) mod cmds; -pub(crate) mod selection_file; - -pub(crate) async fn select_split( - app: &App, - done: bool, - sort_key: SelectSplitSortKey, - sort_mode: SelectSplitSortMode, -) -> Result<()> { - let temp_dir = 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<Video>)| name.to_owned()); - } - SelectSplitSortKey::Videos => { - temp_vec.sort_by_key(|(_, videos): &(String, Vec<Video>)| videos.len()); - } - } - - match sort_mode { - SelectSplitSortMode::Asc => { - // Std's default mode is ascending. - } - SelectSplitSortMode::Desc => { - temp_vec.reverse(); - } - } - - temp_vec - }; - - for (index, (name, videos)) in author_map - .into_iter() - .chain(iter::once(( - "<No parent subscription>".to_owned(), - no_author, - ))) - .enumerate() - { - let mut file_path = temp_dir.path().join(format!("{index:02}_{name}")); - file_path.set_extension("yts"); - - let tmp_file = File::create(&file_path) - .with_context(|| format!("Falied to create file at: {}", file_path.display()))?; - - write_videos_to_file(app, &tmp_file, &videos) - .await - .with_context(|| format!("Falied to populate file at: {}", file_path.display()))?; - } - - open_editor_at(temp_dir.path()).await?; - - let mut paths = vec![]; - for maybe_entry in temp_dir - .path() - .read_dir() - .context("Failed to open temp dir for reading")? - { - let entry = maybe_entry.context("Failed to read entry in temp dir")?; - - if !entry.file_type()?.is_file() { - bail!("Found non-file entry: {}", entry.path().display()); - } - - paths.push(entry.path()); - } - - paths.sort(); - - let mut persistent_file = OpenOptions::new() - .read(false) - .write(true) - .create(true) - .truncate(true) - .open(&app.config.paths.last_selection_path) - .context("Failed to open persistent selection file")?; - - for path in paths { - let mut read_file = File::open(path)?; - - let mut buffer = vec![]; - read_file.read_to_end(&mut buffer)?; - persistent_file.write_all(&buffer)?; - } - - persistent_file.flush()?; - let persistent_file = OpenOptions::new() - .read(true) - .open(format!( - "/proc/self/fd/{}", - persistent_file.as_fd().as_raw_fd() - )) - .context("Failed to re-open persistent file")?; - - let processed = process_file(app, &persistent_file).await?; - - info!("Processed {processed} records."); - temp_dir.close().context("Failed to close the temp dir")?; - Ok(()) -} - -pub(crate) async fn select_file(app: &App, done: bool, use_last_selection: bool) -> Result<()> { - let temp_file = Builder::new() - .prefix("yt_video_select-") - .suffix(".yts") - .rand_bytes(6) - .tempfile() - .context("Failed to get tempfile")?; - - if use_last_selection { - fs::copy(&app.config.paths.last_selection_path, &temp_file)?; - } else { - let matching_videos = get_videos(app, done).await?; - - write_videos_to_file(app, temp_file.as_file(), &matching_videos).await?; - } - - open_editor_at(temp_file.path()).await?; - - let read_file = OpenOptions::new().read(true).open(temp_file.path())?; - fs::copy(temp_file.path(), &app.config.paths.last_selection_path) - .context("Failed to persist selection file")?; - - let processed = process_file(app, &read_file).await?; - info!("Processed {processed} records."); - - Ok(()) -} - -async fn get_videos(app: &App, include_done: bool) -> Result<Vec<Video>> { - if include_done { - Video::in_states(app, VideoStatusMarker::ALL).await - } else { - Video::in_states( - app, - &[ - VideoStatusMarker::Pick, - // - VideoStatusMarker::Watch, - VideoStatusMarker::Cached, - ], - ) - .await - } -} - -async fn write_videos_to_file(app: &App, file: &File, videos: &[Video]) -> Result<()> { - // Warm-up the cache for the display rendering of the videos. - // Otherwise the futures would all try to warm it up at the same time. - if let Some(vid) = videos.first() { - drop(vid.to_line_display(app, None).await?); - } - - let mut edit_file = BufWriter::new(file); - - videos - .iter() - .map(|vid| vid.to_select_file_display(app)) - .collect::<FuturesOrdered<_>>() - .try_collect::<Vec<String>>() - .await? - .into_iter() - .try_for_each(|line| -> Result<()> { - edit_file - .write_all(line.as_bytes()) - .context("Failed to write to `edit_file`")?; - - Ok(()) - })?; - - edit_file.write_all(HELP_STR.as_bytes())?; - edit_file.flush().context("Failed to flush edit file")?; - - Ok(()) -} - -async fn process_file(app: &App, file: &File) -> Result<i64> { - let mut line_number = 0; - - let mut ops = Operations::new("Select: process file"); - - let reader = BufReader::new(file); - for line in reader.lines() { - let line = line.context("Failed to read a line")?; - - if let Some(line) = process_line(&line)? { - line_number -= 1; - - // debug!( - // "Parsed command: `{}`", - // line.iter() - // .map(|val| format!("\"{}\"", val)) - // .collect::<Vec<String>>() - // .join(" ") - // ); - - let arg_line = ["yt", "select"] - .into_iter() - .chain(line.iter().map(String::as_str)); - - let args = CliArgs::parse_from(arg_line); - - let crate::cli::Command::Select { cmd } = args - .command - .expect("This will be some, as we constructed it above.") - else { - unreachable!("This is checked in the `filter_line` function") - }; - - match cmd.expect( - "This value should always be some \ - here, as it would otherwise thrown an error above.", - ) { - SelectCommand::File { .. } | SelectCommand::Split { .. } => { - bail!("You cannot use `select file` or `select split` recursively.") - } - SelectCommand::Add { urls, start, stop } => { - Box::pin(cmds::add::add(app, urls, start, stop)).await?; - } - other => { - let shared = other - .clone() - .into_shared() - .expect("The ones without shared should have been filtered out."); - - let hash = shared.hash.realize(app).await?; - let mut video = hash - .get_with_app(app) - .await - .expect("The hash was already realized, it should therefore exist"); - - handle_select_cmd(app, other, &mut video, Some(line_number), &mut ops).await?; - } - } - } - } - - ops.commit(app).await?; - Ok(-line_number) -} - -async fn open_editor_at(path: &Path) -> Result<()> { - let editor = env::var("EDITOR").unwrap_or("nvim".to_owned()); - - let mut nvim = Command::new(&editor); - nvim.arg(path); - let status = nvim - .status() - .await - .with_context(|| format!("Falied to run editor: {editor}"))?; - - if status.success() { - Ok(()) - } else { - bail!("Editor ({editor}) exited with error status: {}", status) - } -} // // FIXME: There should be no reason why we need to re-run yt, just to get the help string. But I've // // yet to find a way to do it without the extra exec <2024-08-20> diff --git a/crates/yt/src/select/selection_file/mod.rs b/crates/yt/src/select/selection_file/mod.rs deleted file mode 100644 index 36342f8..0000000 --- a/crates/yt/src/select/selection_file/mod.rs +++ /dev/null @@ -1,42 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// 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 <https://www.gnu.org/licenses/gpl-3.0.txt>. - -//! The data structures needed to express the file, which the user edits - -use anyhow::{Result, bail}; -use shlex::Shlex; - -pub(crate) mod duration; - -/// # Panics -/// If internal assertions fail. -pub(crate) fn process_line(line: &str) -> Result<Option<Vec<String>>> { - // Filter out comments and empty lines - if line.starts_with('#') || line.trim().is_empty() { - Ok(None) - } else { - let split: Vec<_> = { - let mut shl = Shlex::new(line); - let res = shl.by_ref().collect(); - - if shl.had_error { - bail!("Failed to parse line '{line}'") - } - - assert_eq!(shl.line_no, 1, "A unexpected newline appeared"); - res - }; - - assert!(!split.is_empty()); - - Ok(Some(split)) - } -} diff --git a/crates/yt/src/status/mod.rs b/crates/yt/src/status/mod.rs deleted file mode 100644 index de706ec..0000000 --- a/crates/yt/src/status/mod.rs +++ /dev/null @@ -1,162 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// 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 <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::time::Duration; - -use crate::{ - app::App, - download::Downloader, - select::selection_file::duration::MaybeDuration, - shared::bytes::Bytes, - storage::db::{ - subscription::Subscriptions, - video::{Video, VideoStatusMarker}, - }, -}; - -use anyhow::{Context, Result}; - -macro_rules! get { - ($videos:expr, $status:ident) => { - $videos - .iter() - .filter(|vid| vid.status.as_marker() == VideoStatusMarker::$status) - .count() - }; - - (@collect $videos:expr, $status:ident) => { - $videos - .iter() - .filter(|vid| vid.status.as_marker() == VideoStatusMarker::$status) - .collect() - }; -} - -pub(crate) async fn show(app: &App, format: Option<String>) -> Result<()> { - let all_videos = Video::in_states(app, VideoStatusMarker::ALL).await?; - - // lengths - let picked_videos_len = get!(all_videos, Pick); - - let watch_videos_len = get!(all_videos, Watch); - let cached_videos_len = get!(all_videos, Cached); - let watched_videos_len = get!(all_videos, Watched); - let watched_videos: Vec<_> = get!(@collect all_videos, Watched); - - let drop_videos_len = get!(all_videos, Drop); - let dropped_videos_len = get!(all_videos, Dropped); - - let subscriptions = Subscriptions::get(app).await?; - let subscriptions_len = subscriptions.0.len(); - - let watchtime_status = { - let total_watch_time_raw = watched_videos - .iter() - .fold(Duration::default(), |acc, vid| acc + vid.watch_progress); - - // Most things are watched at a speed of s (which is defined in the config file). - // Thus - // y = x * s -> y / s = x - let total_watch_time = Duration::from_secs_f64( - (total_watch_time_raw.as_secs_f64()) / app.config.select.playback_speed, - ); - - let speed = app.config.select.playback_speed; - - // Do not print the adjusted time, if the user has keep the speed level at 1. - #[allow(clippy::float_cmp)] - if speed == 1.0 { - format!( - "Total Watchtime: {}\n", - MaybeDuration::from_std(total_watch_time_raw) - ) - } else { - format!( - "Total Watchtime: {} (at {speed} speed: {})\n", - MaybeDuration::from_std(total_watch_time_raw), - MaybeDuration::from_std(total_watch_time), - ) - } - }; - - let watch_rate: f64 = { - fn to_f64(input: usize) -> f64 { - f64::from(u32::try_from(input).expect("This should never exceed u32::MAX")) - } - - let count = - to_f64(watched_videos_len) / (to_f64(drop_videos_len) + to_f64(dropped_videos_len)); - count * 100.0 - }; - - let cache_usage_raw = Downloader::get_current_cache_allocation(app) - .await - .context("Failed to get current cache allocation")?; - let cache_usage: Bytes = cache_usage_raw; - - if let Some(fmt) = format { - let output = fmt - .replace( - "{picked_videos_len}", - picked_videos_len.to_string().as_str(), - ) - .replace("{watch_videos_len}", watch_videos_len.to_string().as_str()) - .replace( - "{cached_videos_len}", - cached_videos_len.to_string().as_str(), - ) - .replace( - "{watched_videos_len}", - watched_videos_len.to_string().as_str(), - ) - .replace("{watch_rate}", watch_rate.to_string().as_str()) - .replace("{drop_videos_len}", drop_videos_len.to_string().as_str()) - .replace( - "{dropped_videos_len}", - dropped_videos_len.to_string().as_str(), - ) - .replace("{watchtime_status}", watchtime_status.to_string().as_str()) - .replace( - "{subscriptions_len}", - subscriptions_len.to_string().as_str(), - ) - .replace("{cache_usage}", cache_usage.to_string().as_str()); - - print!("{output}"); - } else { - println!( - "\ -Picked Videos: {picked_videos_len} - -Watch Videos: {watch_videos_len} -Cached Videos: {cached_videos_len} -Watched Videos: {watched_videos_len} (watch rate: {watch_rate:.2} %) - -Drop Videos: {drop_videos_len} -Dropped Videos: {dropped_videos_len} - -{watchtime_status} - - Subscriptions: {subscriptions_len} - Cache usage: {cache_usage}" - ); - } - - Ok(()) -} - -pub(crate) fn config(app: &App) -> Result<()> { - let config_str = toml::to_string(&app.config)?; - - print!("{config_str}"); - - Ok(()) -} diff --git a/crates/yt/src/storage/db/get/video/mod.rs b/crates/yt/src/storage/db/get/video/mod.rs index ec00934..5f6700e 100644 --- a/crates/yt/src/storage/db/get/video/mod.rs +++ b/crates/yt/src/storage/db/get/video/mod.rs @@ -3,11 +3,15 @@ use std::{fs::File, path::PathBuf}; use anyhow::{Context, Result, bail}; use log::debug; use sqlx::query; -use yt_dlp::info_json::InfoJson; +use yt_dlp::{info_json::InfoJson, json_cast}; use crate::{ app::App, - storage::db::video::{Video, VideoStatus, VideoStatusMarker, video_from_record}, + storage::db::video::{ + Video, VideoStatus, VideoStatusMarker, + comments::{Comments, raw::RawComment}, + video_from_record, + }, }; impl Video { @@ -42,6 +46,70 @@ impl Video { } } + /// Returns the description of the current video. + /// The returned description will be set to `<No description>` in the absence of one. + /// + /// # Errors + /// If no current video exists. + /// + /// # Panics + /// If the current video lacks the `info.json` file. + pub(crate) async fn get_current_description(app: &App) -> Result<String> { + let Some(currently_playing_video) = Video::currently_focused(app).await? else { + bail!("Could not find a currently playing video!"); + }; + + let info_json = ¤tly_playing_video.get_info_json()?.expect( + "A currently *playing* must be cached. \ + And thus the info.json should be available.", + ); + + let description = info_json + .get("description") + .map_or("<No description>", |val| json_cast!(val, as_str)) + .to_owned(); + + Ok(description) + } + + /// Returns the comments of the current video. + /// The returned [`Comments`] will be empty in the absence of comments. + /// + /// # Errors + /// If no current video exists. + /// + /// # Panics + /// If the current video lacks the `info.json` file. + pub(crate) async fn get_current_comments(app: &App) -> Result<Comments> { + let Some(currently_playing_video) = Video::currently_focused(app).await? else { + bail!("Could not find a currently playing video!"); + }; + + let info_json = ¤tly_playing_video.get_info_json()?.expect( + "A currently *playing* video must be cached. \ + And thus the info.json should be available.", + ); + + let raw_comments = if let Some(comments) = info_json.get("comments") { + json_cast!(comments, as_array) + .iter() + .cloned() + .map(serde_json::from_value) + .collect::<Result<Vec<RawComment>, _>>()? + } else { + // TODO(@bpeetz): We could display a `<No-comments>` here. <2025-07-15> + + bail!( + "The video ('{}') does not have comments!", + info_json + .get("title") + .map_or("<No Title>", |val| json_cast!(val, as_str)) + ) + }; + + Ok(Comments::from_raw(raw_comments)) + } + /// Optionally returns the video that is currently focused. /// /// # Panics @@ -178,9 +246,7 @@ impl Video { let real_videos: Vec<Video> = videos .iter() - .map(|base| -> Video { - video_from_record!(base) - }) + .map(|base| -> Video { video_from_record!(base) }) .collect(); Ok(real_videos) diff --git a/crates/yt/src/comments/display.rs b/crates/yt/src/storage/db/video/comments/display.rs index de50614..084e54c 100644 --- a/crates/yt/src/comments/display.rs +++ b/crates/yt/src/storage/db/video/comments/display.rs @@ -1,39 +1,23 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// 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 <https://www.gnu.org/licenses/gpl-3.0.txt>. - use std::fmt::Write; use chrono::{Local, TimeZone}; use chrono_humanize::{Accuracy, HumanTime, Tense}; +use colors::{Colorize, IntoCanvas}; -use crate::comments::comment::CommentExt; - -use super::comment::Comments; +use crate::{ + output::format_text, + storage::db::video::comments::{Comment, Comments}, +}; impl Comments { - pub(crate) fn render(&self, color: bool) -> String { - self.render_help(color).expect("This should never fail.") + pub(crate) fn render(&self, use_color: bool) -> String { + self.render_help(use_color) + .expect("This should never fail.") } - fn render_help(&self, color: bool) -> Result<String, std::fmt::Error> { - macro_rules! c { - ($color_str:expr, $write:ident, $color:expr) => { - if $color { - $write.write_str(concat!("\x1b[", $color_str, "m"))? - } - }; - } - + fn render_help(&self, use_color: bool) -> Result<String, std::fmt::Error> { fn format( - comment: &CommentExt, + comment: &Comment, f: &mut String, ident_count: u32, color: bool, @@ -43,14 +27,16 @@ impl Comments { f.write_str(ident)?; - if value.author_is_uploader { - c!("91;1", f, color); - } else { - c!("35", f, color); - } + write!( + f, + "{}", + if value.author_is_uploader { + (&value.author).bold().bright_red().render(color) + } else { + (&value.author).purple().render(color) + } + )?; - f.write_str(&value.author)?; - c!("0", f, color); if value.edited || value.is_favorited { f.write_str("[")?; if value.edited { @@ -65,7 +51,6 @@ impl Comments { f.write_str("]")?; } - c!("36;1", f, color); write!( f, " {}", @@ -76,17 +61,31 @@ impl Comments { .expect("This should be valid") ) .to_text_en(Accuracy::Rough, Tense::Past) + .bold() + .cyan() + .render(color) )?; - c!("0", f, color); - // c!("31;1", f); - // f.write_fmt(format_args!(" [{}]", comment.value.like_count))?; - // c!("0", f); + write!( + f, + " [{}]", + comment.value.like_count.bold().red().render(color) + )?; f.write_str(":\n")?; f.write_str(ident)?; - f.write_str(&value.text.replace('\n', &format!("\n{ident}")))?; + f.write_str( + &format_text( + value.text.trim(), + Some( + termsize::get().map_or(90, |ts| ts.cols) + - u16::try_from(ident_count).expect("Should never overflow"), + ), + ) + .trim() + .replace('\n', &format!("\n{ident}")), + )?; f.write_str("\n")?; if comment.replies.is_empty() { @@ -105,12 +104,12 @@ impl Comments { let mut f = String::new(); - if !&self.vec.is_empty() { - let mut children = self.vec.clone(); + if !&self.inner.is_empty() { + let mut children = self.inner.clone(); children.sort_by(|a, b| b.value.like_count.cmp(&a.value.like_count)); for child in children { - format(&child, &mut f, 0, color)?; + format(&child, &mut f, 0, use_color)?; } } Ok(f) diff --git a/crates/yt/src/storage/db/video/comments/mod.rs b/crates/yt/src/storage/db/video/comments/mod.rs new file mode 100644 index 0000000..d2249b3 --- /dev/null +++ b/crates/yt/src/storage/db/video/comments/mod.rs @@ -0,0 +1,187 @@ +use std::{iter, mem}; + +use regex::{Captures, Regex}; + +use crate::storage::db::video::comments::raw::{Parent, RawComment}; + +pub(crate) mod display; +pub(crate) mod raw; + +#[cfg(test)] +mod tests; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Comment { + value: RawComment, + replies: Vec<Self>, +} + +#[derive(Debug, Default, PartialEq)] +pub(crate) struct Comments { + inner: Vec<Comment>, +} + +impl Comments { + pub(crate) fn from_raw(raw: Vec<RawComment>) -> Self { + let mut me = Self::default(); + + // Apply the parent -> child mapping yt provides us with. + for raw_comment in raw { + if let Parent::Id(id) = &raw_comment.parent { + me.insert(&(id.clone()), Comment::from(raw_comment)); + } else { + me.inner.push(Comment::from(raw_comment)); + } + } + + { + // Sort the final comments chronologically. + // This ensures that replies are matched with the comment they actually replied to and + // not a later comment from the same author. + for comment in &mut me.inner { + comment + .replies + .sort_by_key(|comment| comment.value.timestamp); + + for reply in &comment.replies { + assert!(reply.replies.is_empty()); + } + } + } + + { + let find_reply_indicator = + Regex::new(r"\u{200b}?(@[^\t\s]+)\u{200b}?").expect("This is hardcoded"); + + // Try to re-construct the replies for the reply comments. + for comment in &mut me.inner { + let previous_replies = mem::take(&mut comment.replies); + + let mut reply_tree = Comments::default(); + + for reply in previous_replies { + // We try to reconstruct the parent child relation ship by looking (naively) + // for a reply indicator. Currently, this is just the `@<some_name>`, as yt + // seems to insert that by default if you press `reply-to` in their clients. + // + // This follows these steps: + // - Does this reply have a “reply indicator”? + // - If yes, try to resolve the indicator. + // - If it is resolvable, add this reply to the [`Comment`] it resolved to. + // - If not, keep the comment as reply. + + if let Some(reply_indicator_matches) = + find_reply_indicator.captures(&reply.value.text.clone()) + { + // We found a reply indicator. + // First we traverse the current `reply_tree` in reversed order to find a + // match, than we check if the reply indicator matches the reply tree root + // and afterward we declare it unmatching and add it as toplevel. + + let reply_target_author = reply_indicator_matches + .get(1) + .expect("This should also exist") + .as_str(); + + if let Some(parent) = reply_tree.find_author_mut(reply_target_author) { + parent + .replies + .push(comment_from_reply(reply, &reply_indicator_matches)); + } else if comment.value.author == reply_target_author { + reply_tree + .add_toplevel(comment_from_reply(reply, &reply_indicator_matches)); + } else { + eprintln!( + "Failed to find a parent for ('{}') both directly \ + and via replies! The reply text was:\n'{}'\n", + reply_target_author, reply.value.text + ); + reply_tree.add_toplevel(reply); + } + } else { + // The comment text did not contain a reply indicator, so add it as + // toplevel. + reply_tree.add_toplevel(reply); + } + } + + comment.replies = reply_tree.inner; + } + } + + me + } + + fn add_toplevel(&mut self, value: Comment) { + self.inner.push(value); + } + + fn insert(&mut self, id: &str, value: Comment) { + let parent = self + .inner + .iter_mut() + .find(|c| c.value.id.id == id) + .expect("One of these should exist"); + + parent.replies.push(value); + } + + fn find_author_mut(&mut self, reply_target_author: &str) -> Option<&mut Comment> { + fn perform_check<'a>( + comment: &'a mut Comment, + reply_target_author: &str, + ) -> Option<&'a mut Comment> { + let base_check = if comment.value.author == reply_target_author { + return Some(comment); + } else { + None + }; + + if comment.replies.is_empty() { + base_check + } else { + for reply in comment.replies.iter_mut().rev() { + if let Some(out) = perform_check(reply, reply_target_author) { + return Some(out); + } + } + + base_check + } + } + + for comment in self.inner.iter_mut().rev() { + if let Some(output) = perform_check(comment, reply_target_author) { + return Some(output); + } + } + + None + } +} +fn comment_from_reply(reply: Comment, reply_indicator_matches: &Captures<'_>) -> Comment { + Comment::from(RawComment { + text: { + // Remove the `@<some_name>` for the comment text. + let full_match = reply_indicator_matches + .get(0) + .expect("This will always exist"); + + let text = reply.value.text[0..full_match.start()].to_owned() + + &reply.value.text[full_match.end()..]; + + text.trim_matches(|c: char| c == '\u{200b}' || c == '\u{2060}' || c.is_whitespace()) + .to_owned() + }, + ..reply.value + }) +} + +impl From<RawComment> for Comment { + fn from(value: RawComment) -> Self { + Self { + value, + replies: vec![], + } + } +} diff --git a/crates/yt/src/comments/comment.rs b/crates/yt/src/storage/db/video/comments/raw.rs index 30b8ea0..a79820a 100644 --- a/crates/yt/src/comments/comment.rs +++ b/crates/yt/src/storage/db/video/comments/raw.rs @@ -1,18 +1,22 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// 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 <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Deserializer}; use url::Url; -#[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq, PartialOrd, Ord)] +#[derive(Debug, Deserialize, Clone, Eq, PartialEq, PartialOrd, Ord)] +#[serde(from = "String")] +#[serde(deny_unknown_fields)] +pub(crate) struct Id { + pub(crate) id: String, +} +impl From<String> for Id { + fn from(value: String) -> Self { + Self { + // Take the last element if the string is split with dots, otherwise take the full id + id: value.split('.').next_back().unwrap_or(&value).to_owned(), + } + } +} + +#[derive(Debug, Deserialize, Clone, Eq, PartialEq, PartialOrd, Ord)] #[serde(from = "String")] #[serde(deny_unknown_fields)] pub(crate) enum Parent { @@ -30,24 +34,9 @@ impl From<String> for Parent { } } -#[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq, PartialOrd, Ord)] -#[serde(from = "String")] -#[serde(deny_unknown_fields)] -pub(crate) struct Id { - pub(crate) id: String, -} -impl From<String> for Id { - fn from(value: String) -> Self { - Self { - // Take the last element if the string is split with dots, otherwise take the full id - id: value.split('.').next_back().unwrap_or(&value).to_owned(), - } - } -} - -#[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq, PartialOrd, Ord)] +#[derive(Debug, Deserialize, Clone, Eq, PartialEq, PartialOrd, Ord)] #[allow(clippy::struct_excessive_bools)] -pub(crate) struct Comment { +pub(crate) struct RawComment { pub(crate) id: Id, pub(crate) text: String, #[serde(default = "zero")] @@ -86,47 +75,3 @@ where Ok(false) } } - -#[derive(Debug, Clone)] -#[allow(clippy::module_name_repetitions)] -pub(crate) struct CommentExt { - pub(crate) value: Comment, - pub(crate) replies: Vec<CommentExt>, -} - -#[derive(Debug, Default)] -pub(crate) struct Comments { - pub(super) vec: Vec<CommentExt>, -} - -impl Comments { - pub(crate) fn new() -> Self { - Self::default() - } - pub(crate) fn push(&mut self, value: CommentExt) { - self.vec.push(value); - } - pub(crate) fn insert(&mut self, key: &str, value: CommentExt) { - let parent = self - .vec - .iter_mut() - .filter(|c| c.value.id.id == key) - .next_back() - .expect("One of these should exist"); - parent.push_reply(value); - } -} -impl CommentExt { - pub(crate) fn push_reply(&mut self, value: CommentExt) { - self.replies.push(value); - } -} - -impl From<Comment> for CommentExt { - fn from(value: Comment) -> Self { - Self { - replies: vec![], - value, - } - } -} diff --git a/crates/yt/src/storage/db/video/comments/tests.rs b/crates/yt/src/storage/db/video/comments/tests.rs new file mode 100644 index 0000000..138b1b6 --- /dev/null +++ b/crates/yt/src/storage/db/video/comments/tests.rs @@ -0,0 +1,219 @@ +use pretty_assertions::assert_eq; +use url::Url; + +use crate::storage::db::video::comments::{ + Comment, Comments, RawComment, + raw::{Id, Parent}, +}; + +/// Generate both an [`expected`] and an [`input`] value from an expected comment expression. +macro_rules! mk_comments { + () => {{ + let input: Vec<RawComment> = vec![]; + let expected: Comments = Comments { + inner: vec![], + }; + + (input, expected) + }}; + + ( + $( + parent: $parent:expr, $actual_parent:ident, + ( + @ $name:ident : $comment:literal + $( + $reply_chain:tt + )* + ) + )+ + ) => {{ + let (nested_input, _) = mk_comments!( + $( + $( + parent: $parent, $name, + $reply_chain + )* + )+ + ); + + let mut input: Vec<RawComment> = vec![ + $( + mk_comments!(@to_raw input $name $comment $parent, $actual_parent) + ),+ + ]; + input.extend(nested_input); + + let expected: Comments = Comments { + inner: vec![ + $( + Comment { + value: mk_comments!(@to_raw expected $name $comment $parent, $actual_parent), + replies: { + let (_, nested_expected) = mk_comments!( + $( + parent: $parent, $name, + $reply_chain + )* + ); + + nested_expected.inner + }, + } + ),+ + ] + }; + + (input, expected) + }}; + ( + $( + ( + @ $name:ident : $comment:literal + $( + $reply_chain:tt + )* + ) + )+ + ) => {{ + let (nested_input, _) = mk_comments!( + $( + $( + parent: mk_comments!(@mk_id $name $comment), $name, + $reply_chain + )* + )+ + ); + + let mut input: Vec<RawComment> = vec![ + $( + mk_comments!(@to_raw input $name $comment) + ),+ + ]; + input.extend(nested_input); + + let expected: Comments = Comments { + inner: vec![ + $( + Comment { + value: mk_comments!(@to_raw expected $name $comment), + replies: { + let (_, nested_expected) = mk_comments!( + $( + parent: mk_comments!(@mk_id $name $comment), $name, + $reply_chain + )* + ); + + nested_expected.inner + }, + } + ),+ + ] + }; + + (input, expected) + }}; + + (@mk_id $name:ident $comment:literal) => {{ + use std::hash::{Hash, Hasher}; + + let input = format!("{}{}", stringify!($name), $comment); + + let mut digest = std::hash::DefaultHasher::new(); + input.hash(&mut digest); + Id { id: digest.finish().to_string() } + }}; + + (@to_raw $state:ident $name:ident $comment:literal $($parent:expr, $actual_parent:ident)?) => { + RawComment { + id: mk_comments!(@mk_id $name $comment), + text: mk_comments!(@mk_text $state $comment $(, $actual_parent)?), + like_count: 0, + is_pinned: false, + author_id: stringify!($name).to_owned(), + author: format!("@{}", stringify!($name)), + author_is_verified: false, + author_thumbnail: Url::from_file_path("/dev/null").unwrap(), + parent: mk_comments!(@mk_parent $($parent)?), + edited: false, + timestamp: 0, + author_url: None, + author_is_uploader: false, + is_favorited: false, + } + }; + + (@mk_parent) => { + Parent::Root + }; + (@mk_parent $parent:expr) => { + Parent::Id($parent.id) + }; + + (@mk_text input $text:expr) => { + $text.to_owned() + }; + (@mk_text input $text:expr, $actual_parent:ident) => { + format!("@{} {}", stringify!($actual_parent), $text) + }; + (@mk_text expected $text:expr $(, $_:tt)?) => { + $text.to_owned() + }; +} + +#[test] +fn test_comments_toplevel() { + let (input, expected) = mk_comments!( + (@kant: "I think, that using the results of an action to determine morality is flawed.") + (@hume: "I think, that we should use our feeling for morality more.") + (@lock: "I think, that we should rely on the sum of happiness caused by an action to determine it's morality.") + ); + + assert_eq!(Comments::from_raw(input), expected); +} + +#[test] +fn test_comments_replies_1_level() { + let (input, expected) = mk_comments!( + (@hume: "I think, that we should use our feeling for morality more." + (@kant: "This is so wrong! I shall now dedicate my next 7? years to writing books that prove this.") + (@lock: "It feels not very applicable, no? We should focus on something that can be used in the court of law!")) + ); + + assert_eq!( + Comments::from_raw(input).render(true), + expected.render(true) + ); +} + +#[test] +fn test_comments_replies_2_levels() { + let (input, expected) = mk_comments! { + (@singer: "We perform medical studies on animals; Children have lower or similar mental ability as these animals.." + (@singer: "Therefore, we should perform these studies on children instead, if we were to follow our own principals" + (@james: "This is ridiculous! I will not entertain this thought.") + (@singer: "Although one could also use this argument to argue for abortion _after_ birth."))) + }; + + assert_eq!( + Comments::from_raw(input).render(true), + expected.render(true) + ); +} + +#[test] +fn test_comments_replies_3_levels() { + let (input, expected) = mk_comments! { + (@singer: "We perform medical studies on animals; Children have lower or similar mental ability as these animals.." + (@singer: "Therefore, we should perform these studies on children instead, if we were to follow our own principals" + (@james: "This is ridiculous! I will not entertain this thought." + (@singer: "You know that I am not actually suggesting that? This is but a way to critizise the society")) + (@singer: "Although one could also use this argument to argue for abortion _after_ birth."))) + }; + + assert_eq!( + Comments::from_raw(input).render(true), + expected.render(true) + ); +} diff --git a/crates/yt/src/storage/db/video.rs b/crates/yt/src/storage/db/video/mod.rs index f1b8bb9..e768cec 100644 --- a/crates/yt/src/storage/db/video.rs +++ b/crates/yt/src/storage/db/video/mod.rs @@ -1,17 +1,18 @@ use std::{fmt::Display, path::PathBuf, time::Duration}; use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; use url::Url; -use crate::{ - select::selection_file::duration::MaybeDuration, storage::db::extractor_hash::ExtractorHash, -}; +use crate::{select::duration::MaybeDuration, storage::db::extractor_hash::ExtractorHash}; + +pub(crate) mod comments; macro_rules! video_from_record { ($record:expr) => { $crate::storage::db::video::Video { description: $record.description.clone(), - duration: $crate::select::selection_file::duration::MaybeDuration::from_maybe_secs_f64( + duration: $crate::select::duration::MaybeDuration::from_maybe_secs_f64( $record.duration, ), extractor_hash: $crate::storage::db::extractor_hash::ExtractorHash::from_hash( @@ -91,7 +92,7 @@ pub(crate) struct Video { } /// The priority of a [`Video`]. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(crate) struct Priority { value: i64, } @@ -156,7 +157,7 @@ impl Display for TimeStamp { /// Cache // yt cache /// | /// Watched // yt watch -#[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] +#[derive(Default, Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] pub(crate) enum VideoStatus { #[default] Pick, diff --git a/crates/yt/src/update/mod.rs b/crates/yt/src/update/mod.rs deleted file mode 100644 index 809289c..0000000 --- a/crates/yt/src/update/mod.rs +++ /dev/null @@ -1,211 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// 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 <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::{str::FromStr, time::Duration}; - -use anyhow::{Context, Ok, Result}; -use chrono::{DateTime, Utc}; -use log::warn; -use url::Url; -use yt_dlp::{info_json::InfoJson, json_cast, json_get}; - -use crate::{ - app::App, - select::selection_file::duration::MaybeDuration, - storage::db::{ - extractor_hash::ExtractorHash, - insert::Operations, - subscription::{Subscription, Subscriptions}, - video::{Priority, TimeStamp, Video, VideoStatus}, - }, -}; - -mod updater; -use updater::Updater; - -pub(crate) async fn update( - app: &App, - max_backlog: usize, - subscription_names_to_update: Vec<String>, -) -> Result<()> { - let subscriptions = Subscriptions::get(app).await?; - - let subs: Vec<Subscription> = if subscription_names_to_update.is_empty() { - subscriptions.0.into_values().collect() - } else { - subscriptions - .0 - .into_values() - .filter(|sub| subscription_names_to_update.contains(&sub.name)) - .collect() - }; - - // We can get away with not having to re-fetch the hashes every time, as the returned video - // should not contain duplicates. - let hashes = ExtractorHash::get_all(app).await?; - - let updater = Updater::new(max_backlog, app.config.update.pool_size, hashes); - updater.update(app, subs).await?; - - Ok(()) -} - -#[allow(clippy::too_many_lines)] -pub(crate) fn video_entry_to_video(entry: &InfoJson, sub: Option<&Subscription>) -> Result<Video> { - fn fmt_context(date: &str, extended: Option<&str>) -> String { - let f = format!( - "Failed to parse the `upload_date` of the entry ('{date}'). \ - Expected `YYYY-MM-DD`, has the format changed?" - ); - if let Some(date_string) = extended { - format!("{f}\nThe parsed '{date_string}' can't be turned to a valid UTC date.'") - } else { - f - } - } - - let publish_date = if let Some(date) = &entry.get("upload_date") { - let date = json_cast!(date, as_str); - - let year: u32 = date - .chars() - .take(4) - .collect::<String>() - .parse() - .with_context(|| fmt_context(date, None))?; - let month: u32 = date - .chars() - .skip(4) - .take(2) - .collect::<String>() - .parse() - .with_context(|| fmt_context(date, None))?; - let day: u32 = date - .chars() - .skip(4 + 2) - .take(2) - .collect::<String>() - .parse() - .with_context(|| fmt_context(date, None))?; - - let date_string = format!("{year:04}-{month:02}-{day:02}T00:00:00Z"); - Some( - DateTime::<Utc>::from_str(&date_string) - .with_context(|| fmt_context(date, Some(&date_string)))? - .timestamp(), - ) - } else { - warn!( - "The video '{}' lacks it's upload date!", - json_get!(entry, "title", as_str) - ); - None - }; - - let thumbnail_url = match (&entry.get("thumbnails"), &entry.get("thumbnail")) { - (None, None) => None, - (None, Some(thumbnail)) => Some(Url::from_str(json_cast!(thumbnail, as_str))?), - - // TODO: The algorithm is not exactly the best <2024-05-28> - (Some(thumbnails), None) => { - if let Some(thumbnail) = json_cast!(thumbnails, as_array).first() { - Some(Url::from_str(json_get!( - json_cast!(thumbnail, as_object), - "url", - as_str - ))?) - } else { - None - } - } - (Some(_), Some(thumnail)) => Some(Url::from_str(json_cast!(thumnail, as_str))?), - }; - - let url = { - let smug_url: Url = json_get!(entry, "webpage_url", as_str).parse()?; - // TODO(@bpeetz): We should probably add this? <2025-06-14> - // if '#__youtubedl_smuggle' not in smug_url: - // return smug_url, default - // url, _, sdata = smug_url.rpartition('#') - // jsond = urllib.parse.parse_qs(sdata)['__youtubedl_smuggle'][0] - // data = json.loads(jsond) - // return url, data - - smug_url - }; - - let extractor_hash = ExtractorHash::from_info_json(entry); - - let subscription_name = if let Some(sub) = sub { - Some(sub.name.clone()) - } else if let Some(uploader) = entry.get("uploader").map(|val| json_cast!(val, as_str)) { - if entry - .get("webpage_url_domain") - .map(|val| json_cast!(val, as_str)) - == Some("youtube.com") - { - Some(format!("{uploader} - Videos")) - } else { - Some(uploader.to_owned()) - } - } else { - None - }; - - let video = Video { - description: entry - .get("description") - .map(|val| json_cast!(val, as_str).to_owned()), - duration: MaybeDuration::from_maybe_secs_f64( - entry.get("duration").map(|val| json_cast!(val, as_f64)), - ), - extractor_hash, - last_status_change: TimeStamp::from_now(), - parent_subscription_name: subscription_name, - priority: Priority::default(), - publish_date: publish_date.map(TimeStamp::from_secs), - status: VideoStatus::Pick, - thumbnail_url, - title: json_get!(entry, "title", as_str).to_owned(), - url, - watch_progress: Duration::default(), - playback_speed: None, - subtitle_langs: None, - }; - Ok(video) -} - -async fn process_subscription(app: &App, sub: Subscription, entry: InfoJson) -> Result<()> { - let mut ops = Operations::new("Update: process subscription"); - let video = video_entry_to_video(&entry, Some(&sub)) - .context("Failed to parse search entry as Video")?; - - let title = video.title.clone(); - let url = video.url.clone(); - let video = video.add(&mut ops).with_context(|| { - format!("Failed to add video to database: '{title}' (with url: '{url}')") - })?; - - ops.commit(app).await.with_context(|| { - format!( - "Failed to add video to database: '{}' (with url: '{}')", - video.title, video.url - ) - })?; - println!( - "{}", - &video - .to_line_display(app, None) - .await - .with_context(|| format!("Failed to format video: '{}'", video.title))? - ); - Ok(()) -} diff --git a/crates/yt/src/videos/display/mod.rs b/crates/yt/src/videos/display/mod.rs deleted file mode 100644 index 54e98ed..0000000 --- a/crates/yt/src/videos/display/mod.rs +++ /dev/null @@ -1,241 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// 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 <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::fmt::Write; - -use anyhow::{Context, Result}; -use owo_colors::OwoColorize; -use url::Url; - -use crate::{ - app::App, - select::selection_file::duration::MaybeDuration, - storage::db::video::{TimeStamp, Video, VideoStatus}, -}; - -pub(crate) mod format_video; - -macro_rules! get { - ($value:expr, $key:ident, $name:expr, $code:tt) => { - if let Some(value) = &$value.$key { - $code(value) - } else { - concat!("[No ", $name, "]").to_owned() - } - }; -} - -fn maybe_add_color<F>(app: &App, input: String, mut color_fn: F) -> String -where - F: FnMut(String) -> String, -{ - if app.config.global.display_colors { - color_fn(input) - } else { - input - } -} -impl Video { - #[must_use] - pub(crate) fn cache_path_fmt(&self, app: &App) -> String { - let cache_path = if let VideoStatus::Cached { - cache_path, - is_focused: _, - } = &self.status - { - cache_path.to_string_lossy().to_string() - } else { - "[No Cache Path]".to_owned() - }; - maybe_add_color(app, cache_path, |v| v.blue().bold().to_string()) - } - - #[must_use] - pub(crate) fn description_fmt(&self) -> String { - get!( - self, - description, - "Description", - (|value: &str| value.to_owned()) - ) - } - - #[must_use] - pub(crate) fn duration_fmt_no_color(&self) -> String { - self.duration.to_string() - } - #[must_use] - pub(crate) fn duration_fmt(&self, app: &App) -> String { - let duration = self.duration_fmt_no_color(); - maybe_add_color(app, duration, |v| v.cyan().bold().to_string()) - } - - #[must_use] - pub(crate) fn watch_progress_fmt(&self, app: &App) -> String { - maybe_add_color( - app, - MaybeDuration::from_std(self.watch_progress).to_string(), - |v| v.cyan().bold().to_string(), - ) - } - - pub(crate) async fn extractor_hash_fmt_no_color(&self, app: &App) -> Result<String> { - let hash = self - .extractor_hash - .into_short_hash(app) - .await - .with_context(|| { - format!( - "Failed to format extractor hash, whilst formatting video: '{}'", - self.title - ) - })? - .to_string(); - Ok(hash) - } - pub(crate) async fn extractor_hash_fmt(&self, app: &App) -> Result<String> { - let hash = self.extractor_hash_fmt_no_color(app).await?; - Ok(maybe_add_color(app, hash, |v| { - v.bright_purple().italic().to_string() - })) - } - - #[must_use] - pub(crate) fn in_playlist_fmt(&self, app: &App) -> String { - let output = match &self.status { - VideoStatus::Pick - | VideoStatus::Watch - | VideoStatus::Watched - | VideoStatus::Drop - | VideoStatus::Dropped => "Not in the playlist", - VideoStatus::Cached { is_focused, .. } => { - if *is_focused { - "In the playlist and focused" - } else { - "In the playlist" - } - } - }; - maybe_add_color(app, output.to_owned(), |v| v.yellow().italic().to_string()) - } - #[must_use] - pub(crate) fn last_status_change_fmt(&self, app: &App) -> String { - maybe_add_color(app, self.last_status_change.to_string(), |v| { - v.bright_cyan().to_string() - }) - } - - #[must_use] - pub(crate) fn parent_subscription_name_fmt_no_color(&self) -> String { - get!( - self, - parent_subscription_name, - "author", - (|sub: &str| sub.replace('"', "'")) - ) - } - #[must_use] - pub(crate) fn parent_subscription_name_fmt(&self, app: &App) -> String { - let psn = self.parent_subscription_name_fmt_no_color(); - maybe_add_color(app, psn, |v| v.bright_magenta().to_string()) - } - - #[must_use] - pub(crate) fn priority_fmt(&self) -> String { - self.priority.to_string() - } - - #[must_use] - pub(crate) fn publish_date_fmt_no_color(&self) -> String { - get!( - self, - publish_date, - "release date", - (|date: &TimeStamp| date.to_string()) - ) - } - #[must_use] - pub(crate) fn publish_date_fmt(&self, app: &App) -> String { - let date = self.publish_date_fmt_no_color(); - maybe_add_color(app, date, |v| v.bright_white().bold().to_string()) - } - - #[must_use] - pub(crate) fn status_fmt_no_color(&self) -> String { - // TODO: We might support `.trim()`ing that, as the extra whitespace could be bad in the - // selection file. <2024-10-07> - self.status.as_marker().as_command().to_string() - } - #[must_use] - pub(crate) fn status_fmt(&self, app: &App) -> String { - let status = self.status_fmt_no_color(); - maybe_add_color(app, status, |v| v.red().bold().to_string()) - } - - #[must_use] - pub(crate) fn thumbnail_url_fmt(&self) -> String { - get!( - self, - thumbnail_url, - "thumbnail URL", - (|url: &Url| url.to_string()) - ) - } - - #[must_use] - pub(crate) fn title_fmt_no_color(&self) -> String { - self.title.replace(['"', '„', '”', '“'], "'") - } - #[must_use] - pub(crate) fn title_fmt(&self, app: &App) -> String { - let title = self.title_fmt_no_color(); - maybe_add_color(app, title, |v| v.green().bold().to_string()) - } - - #[must_use] - pub(crate) fn url_fmt_no_color(&self) -> String { - self.url.as_str().replace('"', "\\\"") - } - #[must_use] - pub(crate) fn url_fmt(&self, app: &App) -> String { - let url = self.url_fmt_no_color(); - maybe_add_color(app, url, |v| v.italic().to_string()) - } - - pub(crate) fn video_options_fmt_no_color(&self, app: &App) -> String { - let video_options = { - let mut opts = String::new(); - - if let Some(playback_speed) = self.playback_speed { - if (playback_speed - app.config.select.playback_speed).abs() > f64::EPSILON { - write!(opts, " --playback-speed '{}'", playback_speed).expect("In-memory"); - } - } - - if let Some(subtitle_langs) = &self.subtitle_langs { - if subtitle_langs != &app.config.select.subtitle_langs { - write!(opts, " --subtitle-langs '{}'", subtitle_langs).expect("In-memory"); - } - } - - let opts = opts.trim().to_owned(); - - let opts_white = if opts.is_empty() { "" } else { " " }; - format!("{opts_white}{opts}") - }; - video_options - } - - pub(crate) fn video_options_fmt(&self, app: &App) -> String { - let opts = self.video_options_fmt_no_color(app); - maybe_add_color(app, opts, |v| v.bright_green().to_string()) - } -} diff --git a/crates/yt/src/videos/display/format_video.rs b/crates/yt/src/videos/format_video.rs index 80ac5dd..9e86205 100644 --- a/crates/yt/src/videos/display/format_video.rs +++ b/crates/yt/src/videos/format_video.rs @@ -10,8 +10,9 @@ // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. use anyhow::Result; +use colors::{Colorize, IntoCanvas}; -use crate::{app::App, comments::output::format_text, storage::db::video::Video}; +use crate::{app::App, output::format_text, storage::db::video::Video, videos::RenderWithApp}; impl Video { pub(crate) async fn to_info_display( @@ -19,21 +20,21 @@ impl Video { app: &App, format: Option<String>, ) -> Result<String> { - let cache_path = self.cache_path_fmt(app); - let description = self.description_fmt(); - let duration = self.duration_fmt(app); - let extractor_hash = self.extractor_hash_fmt(app).await?; - let in_playlist = self.in_playlist_fmt(app); - let last_status_change = self.last_status_change_fmt(app); - let parent_subscription_name = self.parent_subscription_name_fmt(app); - let priority = self.priority_fmt(); - let publish_date = self.publish_date_fmt(app); - let status = self.status_fmt(app); - let thumbnail_url = self.thumbnail_url_fmt(); - let title = self.title_fmt(app); - let url = self.url_fmt(app); - let watch_progress = self.watch_progress_fmt(app); - let video_options = self.video_options_fmt(app); + let cache_path = self.cache_path_fmt().to_string(app); + let description = self.description_fmt().to_string(app); + let duration = self.duration_fmt().to_string(app); + let extractor_hash = self.extractor_hash_fmt(app).await?.to_string(app); + let in_playlist = self.in_playlist_fmt().to_string(app); + let last_status_change = self.last_status_change_fmt().to_string(app); + let parent_subscription_name = self.parent_subscription_name_fmt().to_string(app); + let priority = self.priority_fmt().to_string(app); + let publish_date = self.publish_date_fmt().to_string(app); + let status = self.status_fmt().to_string(app); + let thumbnail_url = self.thumbnail_url_fmt().to_string(app); + let title = self.title_fmt().to_string(app); + let url = self.url_fmt().to_string(app); + let watch_progress = self.watch_progress_fmt().to_string(app); + let video_options = self.video_options_fmt(app).to_string(app); let watched_percentage_fmt = { if let Some(duration) = self.duration.as_secs() { @@ -42,13 +43,15 @@ impl Video { (self.watch_progress.as_secs() / duration) * 100 ) } else { - format!(" {watch_progress}") + format!(" {}", watch_progress) } - }; + .into_canvas() + } + .to_string(app); let options = video_options.to_string(); let options = options.trim(); - let description = format_text(description.to_string().as_str()); + let description = format_text(description.to_string().as_str(), None); let string = if let Some(format) = format { format @@ -91,13 +94,13 @@ impl Video { app: &App, format: Option<String>, ) -> Result<String> { - let status = self.status_fmt(app); - let extractor_hash = self.extractor_hash_fmt(app).await?; - let title = self.title_fmt(app); - let publish_date = self.publish_date_fmt(app); - let parent_subscription_name = self.parent_subscription_name_fmt(app); - let duration = self.duration_fmt(app); - let url = self.url_fmt(app); + let status = self.status_fmt().to_string(app); + let extractor_hash = self.extractor_hash_fmt(app).await?.to_string(app); + let title = self.title_fmt().to_string(app); + let publish_date = self.publish_date_fmt().to_string(app); + let parent_subscription_name = self.parent_subscription_name_fmt().to_string(app); + let duration = self.duration_fmt().to_string(app); + let url = self.url_fmt().to_string(app); let f = if let Some(format) = format { format @@ -120,14 +123,14 @@ impl Video { pub(crate) async fn to_select_file_display(&self, app: &App) -> Result<String> { let f = format!( r#"{}{} {} "{}" "{}" "{}" "{}" "{}"{}"#, - self.status_fmt_no_color(), - self.video_options_fmt_no_color(app), - self.extractor_hash_fmt_no_color(app).await?, - self.title_fmt_no_color(), - self.publish_date_fmt_no_color(), - self.parent_subscription_name_fmt_no_color(), - self.duration_fmt_no_color(), - self.url_fmt_no_color(), + self.status_fmt().render(false), + self.video_options_fmt(app).render(false), + self.extractor_hash_fmt(app).await?.render(false), + self.title_fmt().render(false), + self.publish_date_fmt().render(false), + self.parent_subscription_name_fmt().render(false), + self.duration_fmt().render(false), + self.url_fmt().render(false), '\n' ); diff --git a/crates/yt/src/watch/playlist.rs b/crates/yt/src/watch/playlist.rs deleted file mode 100644 index 7f1db2b..0000000 --- a/crates/yt/src/watch/playlist.rs +++ /dev/null @@ -1,111 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// 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 <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::{fmt::Write, path::Path}; - -use crate::{ - ansi_escape_codes::{cursor_up, erase_in_display_from_cursor}, - app::App, - storage::{ - db::{ - playlist::Playlist, - video::{Video, VideoStatus}, - }, - notify::wait_for_db_write, - }, -}; - -use anyhow::Result; -use futures::{TryStreamExt, stream::FuturesOrdered}; - -/// 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"); - } -} - -/// # Panics -/// Only if internal assertions fail. -pub(crate) async fn playlist(app: &App, watch: bool) -> Result<()> { - 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(app)); - - output.push_str(" ("); - output.push_str(&video.parent_subscription_name_fmt(app)); - output.push(')'); - - output.push_str(" ["); - output.push_str(&video.duration_fmt(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(app))?; - } - - output.push(')'); - } - output.push(']'); - - output.push('\n'); - - Ok::<String, anyhow::Error>(output) - }) - .collect::<FuturesOrdered<_>>() - .try_collect::<String>() - .await?; - - // Delete the previous output - cursor_up(previous_output_length); - erase_in_display_from_cursor(); - - previous_output_length = output.chars().filter(|ch| *ch == '\n').count(); - - print!("{output}"); - - if !watch { - break; - } - - wait_for_db_write(app).await?; - } - - Ok(()) -} diff --git a/crates/yt/src/yt_dlp/mod.rs b/crates/yt/src/yt_dlp/mod.rs new file mode 100644 index 0000000..edf27e8 --- /dev/null +++ b/crates/yt/src/yt_dlp/mod.rs @@ -0,0 +1,249 @@ +use std::{str::FromStr, time::Duration}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use futures::{FutureExt, future::BoxFuture}; +use log::{error, warn}; +use serde_json::json; +use tokio::{fs, io}; +use url::Url; +use yt_dlp::{YoutubeDL, info_json::InfoJson, json_cast, json_get, options::YoutubeDLOptions}; + +use crate::{ + app::App, + select::duration::MaybeDuration, + shared::bytes::Bytes, + storage::db::{ + extractor_hash::ExtractorHash, + subscription::Subscription, + video::{Priority, TimeStamp, Video, VideoStatus}, + }, +}; + +pub(crate) fn yt_dlp_opts_updating(max_backlog: usize) -> Result<YoutubeDL> { + Ok(YoutubeDLOptions::new() + .set("playliststart", 1) + .set("playlistend", max_backlog) + .set("noplaylist", false) + .set( + "extractor_args", + json! {{"youtubetab": {"approximate_date": [""]}}}, + ) + // // TODO: This also removes unlisted and other stuff. Find a good way to remove the + // // members-only videos from the feed. <2025-04-17> + // .set("match-filter", "availability=public") + .build()?) +} + +impl Video { + pub(crate) fn get_approx_size(&self) -> Result<u64> { + let yt_dlp = { + YoutubeDLOptions::new() + .set("prefer_free_formats", true) + .set("format", "bestvideo[height<=?1080]+bestaudio/best") + .set("fragment_retries", 10) + .set("retries", 10) + .set("getcomments", false) + .set("ignoreerrors", false) + .build() + .context("Failed to instanciate get approx size yt_dlp") + }?; + + let result = yt_dlp + .extract_info(&self.url, false, true) + .with_context(|| format!("Failed to extract video information: '{}'", self.title))?; + + let size = if let Some(val) = result.get("filesize") { + json_cast!(val, as_u64) + } else if let Some(serde_json::Value::Number(num)) = result.get("filesize_approx") { + // NOTE(@bpeetz): yt_dlp sets this value to `Null`, instead of omitting it when it + // can't calculate the approximate filesize. + // Thus, we have to check, that it is actually non-null, before we cast it. <2025-06-15> + json_cast!(num, as_u64) + } else if result.get("duration").is_some() && result.get("tbr").is_some() { + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + let duration = json_get!(result, "duration", as_f64).ceil() as u64; + + // TODO: yt_dlp gets this from the format + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + let tbr = json_get!(result, "tbr", as_f64).ceil() as u64; + + duration * tbr * (1000 / 8) + } else { + let hardcoded_default = Bytes::from_str("250 MiB").expect("This is hardcoded"); + error!( + "Failed to find a filesize for video: {:?} (Using hardcoded value of {})", + self.title, hardcoded_default + ); + hardcoded_default.as_u64() + }; + + Ok(size) + } +} + +impl Video { + #[allow(clippy::too_many_lines)] + pub(crate) fn from_info_json(entry: &InfoJson, sub: Option<&Subscription>) -> Result<Video> { + fn fmt_context(date: &str, extended: Option<&str>) -> String { + let f = format!( + "Failed to parse the `upload_date` of the entry ('{date}'). \ + Expected `YYYY-MM-DD`, has the format changed?" + ); + if let Some(date_string) = extended { + format!("{f}\nThe parsed '{date_string}' can't be turned to a valid UTC date.'") + } else { + f + } + } + + let publish_date = if let Some(date) = &entry.get("upload_date") { + let date = json_cast!(date, as_str); + + let year: u32 = date + .chars() + .take(4) + .collect::<String>() + .parse() + .with_context(|| fmt_context(date, None))?; + let month: u32 = date + .chars() + .skip(4) + .take(2) + .collect::<String>() + .parse() + .with_context(|| fmt_context(date, None))?; + let day: u32 = date + .chars() + .skip(4 + 2) + .take(2) + .collect::<String>() + .parse() + .with_context(|| fmt_context(date, None))?; + + let date_string = format!("{year:04}-{month:02}-{day:02}T00:00:00Z"); + Some( + DateTime::<Utc>::from_str(&date_string) + .with_context(|| fmt_context(date, Some(&date_string)))? + .timestamp(), + ) + } else { + warn!( + "The video '{}' lacks it's upload date!", + json_get!(entry, "title", as_str) + ); + None + }; + + let thumbnail_url = match (&entry.get("thumbnails"), &entry.get("thumbnail")) { + (None, None) => None, + (None, Some(thumbnail)) => Some(Url::from_str(json_cast!(thumbnail, as_str))?), + + // TODO: The algorithm is not exactly the best <2024-05-28> + (Some(thumbnails), None) => { + if let Some(thumbnail) = json_cast!(thumbnails, as_array).first() { + Some(Url::from_str(json_get!( + json_cast!(thumbnail, as_object), + "url", + as_str + ))?) + } else { + None + } + } + (Some(_), Some(thumnail)) => Some(Url::from_str(json_cast!(thumnail, as_str))?), + }; + + let url = { + let smug_url: Url = json_get!(entry, "webpage_url", as_str).parse()?; + // TODO(@bpeetz): We should probably add this? <2025-06-14> + // if '#__youtubedl_smuggle' not in smug_url: + // return smug_url, default + // url, _, sdata = smug_url.rpartition('#') + // jsond = urllib.parse.parse_qs(sdata)['__youtubedl_smuggle'][0] + // data = json.loads(jsond) + // return url, data + + smug_url + }; + + let extractor_hash = ExtractorHash::from_info_json(entry); + + let subscription_name = if let Some(sub) = sub { + Some(sub.name.clone()) + } else if let Some(uploader) = entry.get("uploader").map(|val| json_cast!(val, as_str)) { + if entry + .get("webpage_url_domain") + .map(|val| json_cast!(val, as_str)) + == Some("youtube.com") + { + Some(format!("{uploader} - Videos")) + } else { + Some(uploader.to_owned()) + } + } else { + None + }; + + let video = Video { + description: entry + .get("description") + .map(|val| json_cast!(val, as_str).to_owned()), + duration: MaybeDuration::from_maybe_secs_f64( + entry.get("duration").map(|val| json_cast!(val, as_f64)), + ), + extractor_hash, + last_status_change: TimeStamp::from_now(), + parent_subscription_name: subscription_name, + priority: Priority::default(), + publish_date: publish_date.map(TimeStamp::from_secs), + status: VideoStatus::Pick, + thumbnail_url, + title: json_get!(entry, "title", as_str).to_owned(), + url, + watch_progress: Duration::default(), + playback_speed: None, + subtitle_langs: None, + }; + Ok(video) + } +} + +pub(crate) async fn get_current_cache_allocation(app: &App) -> Result<Bytes> { + fn dir_size(mut dir: fs::ReadDir) -> BoxFuture<'static, Result<Bytes>> { + async move { + let mut acc = 0; + while let Some(entry) = dir.next_entry().await? { + let size = match entry.metadata().await? { + data if data.is_dir() => { + let path = entry.path(); + let read_dir = fs::read_dir(path).await?; + + dir_size(read_dir).await?.as_u64() + } + data => data.len(), + }; + acc += size; + } + Ok(Bytes::new(acc)) + } + .boxed() + } + + let read_dir_result = match fs::read_dir(&app.config.paths.download_dir).await { + Ok(ok) => ok, + Err(err) => match err.kind() { + io::ErrorKind::NotFound => { + unreachable!("The download dir should always be created in the config finalizers."); + } + err => Err(io::Error::from(err)).with_context(|| { + format!( + "Failed to get dir size of download dir at: '{}'", + &app.config.paths.download_dir.display() + ) + })?, + }, + }; + + dir_size(read_dir_result).await +} |