aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--crates/yt/src/cache/mod.rs64
-rw-r--r--crates/yt/src/cli.rs458
-rw-r--r--crates/yt/src/commands/comments/implm/mod.rs15
-rw-r--r--crates/yt/src/commands/comments/mod.rs6
-rw-r--r--crates/yt/src/commands/config/implm.rs13
-rw-r--r--crates/yt/src/commands/config/mod.rs6
-rw-r--r--crates/yt/src/commands/description/implm.rs16
-rw-r--r--crates/yt/src/commands/description/mod.rs6
-rw-r--r--crates/yt/src/commands/download/implm/download/download_options.rs (renamed from crates/yt/src/download/download_options.rs)0
-rw-r--r--crates/yt/src/commands/download/implm/download/mod.rs (renamed from crates/yt/src/download/mod.rs)114
-rw-r--r--crates/yt/src/commands/download/implm/download/progress_hook.rs (renamed from crates/yt/src/download/progress_hook.rs)39
-rw-r--r--crates/yt/src/commands/download/implm/mod.rs45
-rw-r--r--crates/yt/src/commands/download/mod.rs24
-rw-r--r--crates/yt/src/commands/mod.rs153
-rw-r--r--crates/yt/src/commands/playlist/implm.rs105
-rw-r--r--crates/yt/src/commands/playlist/mod.rs10
-rw-r--r--crates/yt/src/commands/select/implm/fs_generators/help.str (renamed from crates/yt/src/select/selection_file/help.str)0
-rw-r--r--crates/yt/src/commands/select/implm/fs_generators/help.str.license (renamed from crates/yt/src/select/selection_file/help.str.license)0
-rw-r--r--crates/yt/src/commands/select/implm/fs_generators/mod.rs345
-rw-r--r--crates/yt/src/commands/select/implm/mod.rs42
-rw-r--r--crates/yt/src/commands/select/implm/standalone/add.rs (renamed from crates/yt/src/select/cmds/add.rs)13
-rw-r--r--crates/yt/src/commands/select/implm/standalone/mod.rs (renamed from crates/yt/src/select/cmds/mod.rs)15
-rw-r--r--crates/yt/src/commands/select/mod.rs219
-rw-r--r--crates/yt/src/commands/status/implm.rs147
-rw-r--r--crates/yt/src/commands/status/mod.rs10
-rw-r--r--crates/yt/src/commands/subscriptions/implm.rs (renamed from crates/yt/src/subscribe/mod.rs)98
-rw-r--r--crates/yt/src/commands/subscriptions/mod.rs52
-rw-r--r--crates/yt/src/commands/update/implm/mod.rs52
-rw-r--r--crates/yt/src/commands/update/implm/updater.rs (renamed from crates/yt/src/update/updater.rs)74
-rw-r--r--crates/yt/src/commands/update/mod.rs17
-rw-r--r--crates/yt/src/commands/videos/implm.rs63
-rw-r--r--crates/yt/src/commands/videos/mod.rs36
-rw-r--r--crates/yt/src/commands/watch/implm/mod.rs20
-rw-r--r--crates/yt/src/commands/watch/implm/watch/mod.rs (renamed from crates/yt/src/watch/mod.rs)6
-rw-r--r--crates/yt/src/commands/watch/implm/watch/playlist_handler/client_messages.rs (renamed from crates/yt/src/watch/playlist_handler/client_messages/mod.rs)6
-rw-r--r--crates/yt/src/commands/watch/implm/watch/playlist_handler/mod.rs (renamed from crates/yt/src/watch/playlist_handler/mod.rs)0
-rw-r--r--crates/yt/src/commands/watch/mod.rs14
-rw-r--r--crates/yt/src/comments/description.rs39
-rw-r--r--crates/yt/src/comments/mod.rs162
-rw-r--r--crates/yt/src/constants.rs12
-rw-r--r--crates/yt/src/main.rs225
-rw-r--r--crates/yt/src/output/mod.rs (renamed from crates/yt/src/comments/output.rs)11
-rw-r--r--crates/yt/src/select/duration.rs (renamed from crates/yt/src/select/selection_file/duration.rs)2
-rw-r--r--crates/yt/src/select/mod.rs316
-rw-r--r--crates/yt/src/select/selection_file/mod.rs42
-rw-r--r--crates/yt/src/status/mod.rs162
-rw-r--r--crates/yt/src/storage/db/get/video/mod.rs76
-rw-r--r--crates/yt/src/storage/db/video/comments/display.rs (renamed from crates/yt/src/comments/display.rs)83
-rw-r--r--crates/yt/src/storage/db/video/comments/mod.rs187
-rw-r--r--crates/yt/src/storage/db/video/comments/raw.rs (renamed from crates/yt/src/comments/comment.rs)93
-rw-r--r--crates/yt/src/storage/db/video/comments/tests.rs219
-rw-r--r--crates/yt/src/storage/db/video/mod.rs (renamed from crates/yt/src/storage/db/video.rs)13
-rw-r--r--crates/yt/src/update/mod.rs211
-rw-r--r--crates/yt/src/videos/display/mod.rs241
-rw-r--r--crates/yt/src/videos/format_video.rs (renamed from crates/yt/src/videos/display/format_video.rs)71
-rw-r--r--crates/yt/src/watch/playlist.rs111
-rw-r--r--crates/yt/src/yt_dlp/mod.rs249
57 files changed, 2427 insertions, 2401 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(&current_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(&current_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?;
+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};
- 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);
- }
+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?;
- ops.commit(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?;
- Ok(())
+ 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(())
+ }
}
-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 = &currently_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 = &currently_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 = &currently_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 = &currently_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
+}