diff options
author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2025-07-24 15:54:05 +0200 |
---|---|---|
committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2025-07-24 15:54:05 +0200 |
commit | 99d4f688868ee664470b13a0d61ac65832263bab (patch) | |
tree | 75150745ae3a81ff9675856d759a7c4a14d34c68 | |
parent | feat(crates/yt/commands/show): Also provide thumbnail and info screen (diff) | |
download | yt-99d4f688868ee664470b13a0d61ac65832263bab.zip |
feat(crates/yt/commands/watch/mpv_commands): Hook-up the new show commands
-rw-r--r-- | crates/yt/src/commands/show/mod.rs | 20 | ||||
-rw-r--r-- | crates/yt/src/commands/watch/implm/mod.rs | 224 | ||||
-rw-r--r-- | crates/yt/src/commands/watch/implm/playlist_handler/client_messages.rs (renamed from crates/yt/src/commands/watch/implm/watch/playlist_handler/client_messages.rs) | 38 | ||||
-rw-r--r-- | crates/yt/src/commands/watch/implm/playlist_handler/mod.rs (renamed from crates/yt/src/commands/watch/implm/watch/playlist_handler/mod.rs) | 7 | ||||
-rw-r--r-- | crates/yt/src/commands/watch/implm/watch/mod.rs | 235 | ||||
-rw-r--r-- | crates/yt/src/config/mod.rs | 23 | ||||
-rw-r--r-- | crates/yt/src/config/non_empty_vec.rs | 73 |
7 files changed, 358 insertions, 262 deletions
diff --git a/crates/yt/src/commands/show/mod.rs b/crates/yt/src/commands/show/mod.rs new file mode 100644 index 0000000..fe583c0 --- /dev/null +++ b/crates/yt/src/commands/show/mod.rs @@ -0,0 +1,20 @@ +use clap::Subcommand; + +mod implm; + +#[derive(Subcommand, Debug)] +pub(super) enum ShowCommand { + /// Display the description of the currently playing video + Description {}, + + /// Display the comments of the currently playing video. + Comments {}, + + /// Display the thumbnail of the currently playing video. + Thumbnail {}, + + /// Display general info of the currently playing video. + /// + /// This is the same as running `yt videos info <hash of current video>` + Info {}, +} diff --git a/crates/yt/src/commands/watch/implm/mod.rs b/crates/yt/src/commands/watch/implm/mod.rs index 338f80a..6aaa076 100644 --- a/crates/yt/src/commands/watch/implm/mod.rs +++ b/crates/yt/src/commands/watch/implm/mod.rs @@ -1,20 +1,234 @@ -use std::sync::Arc; +use std::{ + fs, + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; -use crate::{app::App, commands::watch::WatchCommand}; +use crate::{ + app::App, + commands::watch::{WatchCommand, implm::playlist_handler::Status}, + storage::{ + db::{ + insert::{Operations, maintenance::clear_stale_downloaded_paths}, + playlist::Playlist, + }, + notify::wait_for_db_write, + }, +}; -use anyhow::Result; +use anyhow::{Context, Result}; +use libmpv2::{Mpv, events::EventContext}; +use log::{debug, info, trace, warn}; +use tokio::{task, time}; -mod watch; +mod playlist_handler; impl WatchCommand { + #[allow(clippy::too_many_lines)] 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?; + clear_stale_downloaded_paths(&app).await?; + + let ipc_socket = if provide_ipc_socket { + Some(app.config.paths.mpv_ipc_socket_path.clone()) + } else { + None + }; + + let (mpv, mut ev_ctx) = + init_mpv(&app, ipc_socket, headless).context("Failed to initialize mpv instance")?; + let mpv = Arc::new(mpv); + + if provide_ipc_socket { + println!("{}", app.config.paths.mpv_ipc_socket_path.display()); + } + + let should_break = Arc::new(AtomicBool::new(false)); + let local_app = Arc::clone(&app); + let local_mpv = Arc::clone(&mpv); + let local_should_break = Arc::clone(&should_break); + let progress_handle = task::spawn(async move { + loop { + if local_should_break.load(Ordering::Relaxed) { + trace!("WatchProgressThread: Stopping, as we received exit signal."); + break; + } + + let mut playlist = Playlist::create(&local_app).await?; + + if let Some(index) = playlist.current_index() { + trace!("WatchProgressThread: Saving watch progress for current video"); + + let mut ops = + Operations::new("WatchProgressThread: save watch progress thread"); + playlist.save_watch_progress(&local_mpv, index, &mut ops); + ops.commit(&local_app).await?; + } else { + trace!( + "WatchProgressThread: Tried to save current watch progress, but no video active." + ); + } + + time::sleep(local_app.config.watch.progress_save_intervall).await; + } + + Ok::<(), anyhow::Error>(()) + }); + + let playlist = Playlist::create(&app).await?; + playlist.resync_with_mpv(&app, &mpv)?; + + let mut have_warned = (false, 0); + 'watchloop: loop { + 'waitloop: while let Ok(value) = playlist_handler::status(&app).await { + match value { + Status::NoMoreAvailable => { + break 'watchloop; + } + Status::NoCached { marked_watch } => { + // try again next time. + if have_warned.0 { + if have_warned.1 != marked_watch { + warn!("Now {marked_watch} videos are marked as to be watched."); + have_warned.1 = marked_watch; + } + } else { + warn!( + "There is nothing to watch yet, but still {marked_watch} videos marked as to be watched. \ + Will idle, until they become available" + ); + have_warned = (true, marked_watch); + } + wait_for_db_write(&app).await?; + + // Add the new videos, if they are there. + let playlist = Playlist::create(&app).await?; + playlist.resync_with_mpv(&app, &mpv)?; + } + Status::Available { newly_available } => { + debug!( + "Checked for currently available videos and found {newly_available}!" + ); + have_warned.0 = false; + + // Something just became available! + break 'waitloop; + } + } + } + + // TODO(@bpeetz): Is the following assumption correct? <2025-07-10> + // We wait until forever for the next event, because we really don't need to do anything + // else. + if let Some(ev) = ev_ctx.wait_event(f64::MAX) { + match ev { + Ok(event) => { + trace!("Mpv event triggered: {event:#?}"); + if playlist_handler::handle_mpv_event(&app, &mpv, &event) + .await + .with_context(|| format!("Failed to handle mpv event: '{event:#?}'"))? + { + break; + } + } + Err(e) => debug!("Mpv Event errored: {e}"), + } + } + } + should_break.store(true, Ordering::Relaxed); + progress_handle.await??; + + if provide_ipc_socket { + fs::remove_file(&app.config.paths.mpv_ipc_socket_path).with_context(|| { + format!( + "Failed to clean-up the mpv ipc socket at {}", + app.config.paths.mpv_ipc_socket_path.display() + ) + })?; + } Ok(()) } } + +fn init_mpv(app: &App, ipc_socket: Option<PathBuf>, headless: bool) -> Result<(Mpv, EventContext)> { + // set some default values, to make things easier (these can be overridden by the config file, + // which we load later) + let mpv = Mpv::with_initializer(|mpv| { + if let Some(socket) = ipc_socket { + mpv.set_property( + "input-ipc-server", + socket + .to_str() + .expect("This path comes from us, it should never contain not-utf8"), + )?; + } + + if headless { + // Do not provide video output. + mpv.set_property("vid", "no")?; + } else { + // Enable default key bindings, so the user can actually interact with + // the player (and e.g. close the window). + mpv.set_property("input-default-bindings", "yes")?; + mpv.set_property("input-vo-keyboard", "yes")?; + + // Show the on screen controller. + mpv.set_property("osc", "yes")?; + + // Don't automatically advance to the next video (or exit the player) + mpv.set_option("keep-open", "always")?; + + // Always display an window, even for non-video playback. + // As mpv does not have cli access, no window means no control and no user feedback. + mpv.set_option("force-window", "yes")?; + } + + Ok(()) + }) + .context("Failed to initialize mpv")?; + + let config_path = &app.config.paths.mpv_config_path; + if config_path.try_exists()? { + info!("Found mpv.conf at '{}'!", config_path.display()); + mpv.command( + "load-config-file", + &[config_path + .to_str() + .context("Failed to parse the config path is utf8-stringt")?], + )?; + } else { + warn!( + "Did not find a mpv.conf file at '{}'", + config_path.display() + ); + } + + let input_path = &app.config.paths.mpv_input_path; + if input_path.try_exists()? { + info!("Found mpv.input.conf at '{}'!", input_path.display()); + mpv.command( + "load-input-conf", + &[input_path + .to_str() + .context("Failed to parse the input path as utf8 string")?], + )?; + } else { + warn!( + "Did not find a mpv.input.conf file at '{}'", + input_path.display() + ); + } + + let ev_ctx = EventContext::new(mpv.ctx); + ev_ctx.disable_deprecated_events()?; + + Ok((mpv, ev_ctx)) +} diff --git a/crates/yt/src/commands/watch/implm/watch/playlist_handler/client_messages.rs b/crates/yt/src/commands/watch/implm/playlist_handler/client_messages.rs index 6c8ebbe..fd7e035 100644 --- a/crates/yt/src/commands/watch/implm/watch/playlist_handler/client_messages.rs +++ b/crates/yt/src/commands/watch/implm/playlist_handler/client_messages.rs @@ -23,19 +23,8 @@ async fn run_self_in_external_command(app: &App, args: &[&str]) -> Result<()> { let binary = env::current_exe().context("Failed to determine the current executable to re-execute")?; - let status = Command::new("riverctl") - .args(["focus-output", "next"]) - .status() - .await?; - if !status.success() { - bail!("focusing the next output failed!"); - } - let arguments = [ &[ - "--title", - "floating please", - "--command", binary .to_str() .context("Failed to turn the executable path to a utf8-string")?, @@ -50,25 +39,20 @@ async fn run_self_in_external_command(app: &App, args: &[&str]) -> Result<()> { ] .concat(); - let status = Command::new("alacritty").args(arguments).status().await?; - if !status.success() { - bail!("Falied to start `yt comments`"); - } - - let status = Command::new("riverctl") - .args(["focus-output", "next"]) + let status = Command::new(app.config.commands.external_spawn.first()) + .args(app.config.commands.external_spawn.tail()) + .args(arguments) .status() .await?; - if !status.success() { - bail!("focusing the next output failed!"); + bail!("Falied to start (external) `yt {}`", args.join(" ")); } Ok(()) } pub(super) async fn handle_yt_description_external(app: &App) -> Result<()> { - run_self_in_external_command(app, &["description"]).await?; + run_self_in_external_command(app, &["show", "description"]).await?; Ok(()) } pub(super) async fn handle_yt_description_local(app: &App, mpv: &Mpv) -> Result<()> { @@ -83,7 +67,7 @@ pub(super) async fn handle_yt_description_local(app: &App, mpv: &Mpv) -> Result< } pub(super) async fn handle_yt_comments_external(app: &App) -> Result<()> { - run_self_in_external_command(app, &["comments"]).await?; + run_self_in_external_command(app, &["show", "comments"]).await?; Ok(()) } pub(super) async fn handle_yt_comments_local(app: &App, mpv: &Mpv) -> Result<()> { @@ -97,3 +81,13 @@ pub(super) async fn handle_yt_comments_local(app: &App, mpv: &Mpv) -> Result<()> mpv_message(mpv, &comments, Duration::from_secs(6))?; Ok(()) } + +pub(super) async fn handle_yt_info_external(app: &App) -> Result<()> { + run_self_in_external_command(app, &["show", "info"]).await?; + Ok(()) +} + +pub(super) async fn handle_yt_thumbnail_external(app: &App) -> Result<()> { + run_self_in_external_command(app, &["show", "thumbnail"]).await?; + Ok(()) +} diff --git a/crates/yt/src/commands/watch/implm/watch/playlist_handler/mod.rs b/crates/yt/src/commands/watch/implm/playlist_handler/mod.rs index 443fd26..bdb77d2 100644 --- a/crates/yt/src/commands/watch/implm/watch/playlist_handler/mod.rs +++ b/crates/yt/src/commands/watch/implm/playlist_handler/mod.rs @@ -179,6 +179,13 @@ pub(crate) async fn handle_mpv_event(app: &App, mpv: &Mpv, event: &Event<'_>) -> client_messages::handle_yt_description_local(app, mpv).await?; } + &["yt-info-external"] => { + client_messages::handle_yt_info_external(app).await?; + } + &["yt-thumbnail-external"] => { + client_messages::handle_yt_thumbnail_external(app).await?; + } + &["yt-mark-picked"] => { playlist().await?.mark_current_done( app, diff --git a/crates/yt/src/commands/watch/implm/watch/mod.rs b/crates/yt/src/commands/watch/implm/watch/mod.rs deleted file mode 100644 index 1436d8d..0000000 --- a/crates/yt/src/commands/watch/implm/watch/mod.rs +++ /dev/null @@ -1,235 +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::{ - fs, - path::PathBuf, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, -}; - -use anyhow::{Context, Result}; -use libmpv2::{Mpv, events::EventContext}; -use log::{debug, info, trace, warn}; -use tokio::{task, time}; - -use self::playlist_handler::Status; -use crate::{ - app::App, - storage::{ - db::{insert::{maintenance::clear_stale_downloaded_paths, Operations}, playlist::Playlist}, - notify::wait_for_db_write, - }, -}; - -pub(crate) mod playlist_handler; - -fn init_mpv(app: &App, ipc_socket: Option<PathBuf>, headless: bool) -> Result<(Mpv, EventContext)> { - // set some default values, to make things easier (these can be overridden by the config file, - // which we load later) - let mpv = Mpv::with_initializer(|mpv| { - if let Some(socket) = ipc_socket { - mpv.set_property( - "input-ipc-server", - socket - .to_str() - .expect("This path comes from us, it should never contain not-utf8"), - )?; - } - - if headless { - // Do not provide video output. - mpv.set_property("vid", "no")?; - } else { - // Enable default key bindings, so the user can actually interact with - // the player (and e.g. close the window). - mpv.set_property("input-default-bindings", "yes")?; - mpv.set_property("input-vo-keyboard", "yes")?; - - // Show the on screen controller. - mpv.set_property("osc", "yes")?; - - // Don't automatically advance to the next video (or exit the player) - mpv.set_option("keep-open", "always")?; - - // Always display an window, even for non-video playback. - // As mpv does not have cli access, no window means no control and no user feedback. - mpv.set_option("force-window", "yes")?; - } - - Ok(()) - }) - .context("Failed to initialize mpv")?; - - let config_path = &app.config.paths.mpv_config_path; - if config_path.try_exists()? { - info!("Found mpv.conf at '{}'!", config_path.display()); - mpv.command( - "load-config-file", - &[config_path - .to_str() - .context("Failed to parse the config path is utf8-stringt")?], - )?; - } else { - warn!( - "Did not find a mpv.conf file at '{}'", - config_path.display() - ); - } - - let input_path = &app.config.paths.mpv_input_path; - if input_path.try_exists()? { - info!("Found mpv.input.conf at '{}'!", input_path.display()); - mpv.command( - "load-input-conf", - &[input_path - .to_str() - .context("Failed to parse the input path as utf8 string")?], - )?; - } else { - warn!( - "Did not find a mpv.input.conf file at '{}'", - input_path.display() - ); - } - - let ev_ctx = EventContext::new(mpv.ctx); - ev_ctx.disable_deprecated_events()?; - - Ok((mpv, ev_ctx)) -} - -pub(crate) async fn watch(app: Arc<App>, provide_ipc_socket: bool, headless: bool) -> Result<()> { - clear_stale_downloaded_paths(&app).await?; - - let ipc_socket = if provide_ipc_socket { - Some(app.config.paths.mpv_ipc_socket_path.clone()) - } else { - None - }; - - let (mpv, mut ev_ctx) = - init_mpv(&app, ipc_socket, headless).context("Failed to initialize mpv instance")?; - let mpv = Arc::new(mpv); - - // We now _know_ that the socket is set-up and ready. - if provide_ipc_socket { - println!("{}", app.config.paths.mpv_ipc_socket_path.display()); - } - - let should_break = Arc::new(AtomicBool::new(false)); - - let local_app = Arc::clone(&app); - let local_mpv = Arc::clone(&mpv); - let local_should_break = Arc::clone(&should_break); - let progress_handle = task::spawn(async move { - loop { - if local_should_break.load(Ordering::Relaxed) { - trace!("WatchProgressThread: Stopping, as we received exit signal."); - break; - } - - let mut playlist = Playlist::create(&local_app).await?; - - if let Some(index) = playlist.current_index() { - trace!("WatchProgressThread: Saving watch progress for current video"); - - let mut ops = Operations::new("WatchProgressThread: save watch progress thread"); - playlist.save_watch_progress(&local_mpv, index, &mut ops); - ops.commit(&local_app).await?; - } else { - trace!( - "WatchProgressThread: Tried to save current watch progress, but no video active." - ); - } - - time::sleep(local_app.config.watch.watch_progress_save_intervall).await; - } - - Ok::<(), anyhow::Error>(()) - }); - - // Set up the initial playlist. - let playlist = Playlist::create(&app).await?; - playlist.resync_with_mpv(&app, &mpv)?; - - let mut have_warned = (false, 0); - 'watchloop: loop { - 'waitloop: while let Ok(value) = playlist_handler::status(&app).await { - match value { - Status::NoMoreAvailable => { - break 'watchloop; - } - Status::NoCached { marked_watch } => { - // try again next time. - if have_warned.0 { - if have_warned.1 != marked_watch { - warn!("Now {marked_watch} videos are marked as to be watched."); - have_warned.1 = marked_watch; - } - } else { - warn!( - "There is nothing to watch yet, but still {marked_watch} videos marked as to be watched. \ - Will idle, until they become available" - ); - have_warned = (true, marked_watch); - } - wait_for_db_write(&app).await?; - - // Add the new videos, if they are there. - let playlist = Playlist::create(&app).await?; - playlist.resync_with_mpv(&app, &mpv)?; - } - Status::Available { newly_available } => { - debug!("Checked for currently available videos and found {newly_available}!"); - have_warned.0 = false; - - // Something just became available! - break 'waitloop; - } - } - } - - // TODO(@bpeetz): Is the following assumption correct? <2025-07-10> - // We wait until forever for the next event, because we really don't need to do anything - // else. - if let Some(ev) = ev_ctx.wait_event(f64::MAX) { - match ev { - Ok(event) => { - trace!("Mpv event triggered: {event:#?}"); - if playlist_handler::handle_mpv_event(&app, &mpv, &event) - .await - .with_context(|| format!("Failed to handle mpv event: '{event:#?}'"))? - { - break; - } - } - Err(e) => debug!("Mpv Event errored: {e}"), - } - } - } - - should_break.store(true, Ordering::Relaxed); - progress_handle.await??; - - if provide_ipc_socket { - fs::remove_file(&app.config.paths.mpv_ipc_socket_path).with_context(|| { - format!( - "Failed to clean-up the mpv ipc socket at {}", - app.config.paths.mpv_ipc_socket_path.display() - ) - })?; - } - - Ok(()) -} diff --git a/crates/yt/src/config/mod.rs b/crates/yt/src/config/mod.rs index e02d5f5..947e1f8 100644 --- a/crates/yt/src/config/mod.rs +++ b/crates/yt/src/config/mod.rs @@ -2,6 +2,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use crate::config::support::mk_config; +mod non_empty_vec; mod paths; mod support; @@ -31,6 +32,9 @@ mk_config! { use super::paths::ensure_dir; use super::paths::PREFIX; + use super::non_empty_vec::NonEmptyVec; + use super::non_empty_vec::non_empty_vec; + struct Config { global: GlobalConfig = { /// Whether to display colors. @@ -59,6 +63,25 @@ mk_config! { /// How long to wait between saving the video watch progress. watch_progress_save_intervall: Duration =: Duration::from_secs(10), }, + commands: CommandsConfig = { + /// Which command to execute, when showing the thumbnail. + /// + /// This command will be executed with the one argument, being the path to the image file to display. + image_show: NonEmptyVec<String> =: non_empty_vec!["imv".to_owned()], + + /// Which command to use, when spawing one of the external commands (e.g. + /// `yt-comments-external` from mpv). + /// + /// The command will be called with a series of args that should be executed. + /// For example, + /// `<your_specified_command> <path_to_yt_binary> --db-path <path_to_current_db_path> comments` + external_spawn: NonEmptyVec<String> =: non_empty_vec!["alacritty".to_owned(), "-e".to_owned()], + + /// Which command to use, when opening video urls (like in the `yt select url` case). + /// + /// This command will be called with one argument, being the url of the video to open. + url_opener: NonEmptyVec<String> =: non_empty_vec!["firefox".to_owned()], + }, paths: PathsConfig = { /// Where to store downloaded files. download_dir: PathBuf =: { diff --git a/crates/yt/src/config/non_empty_vec.rs b/crates/yt/src/config/non_empty_vec.rs new file mode 100644 index 0000000..0ca864b --- /dev/null +++ b/crates/yt/src/config/non_empty_vec.rs @@ -0,0 +1,73 @@ +use std::{ + collections::VecDeque, + fmt::{Display, Write}, +}; + +use anyhow::bail; +use serde::{Deserialize, Serialize}; + +macro_rules! non_empty_vec { + ($first:expr $(, $($others:expr),+ $(,)?)?) => {{ + let inner: Vec<_> = vec![$first $(, $($others,)+)?]; + inner.try_into().expect("Has a first arg") + }} +} +pub(crate) use non_empty_vec; + +/// A vector that is non-empty. +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(try_from = "Vec<T>")] +#[serde(into = "Vec<T>")] +pub(crate) struct NonEmptyVec<T: Clone> { + first: T, + rest: Vec<T>, +} + +impl<T: Clone> TryFrom<Vec<T>> for NonEmptyVec<T> { + type Error = anyhow::Error; + + fn try_from(value: Vec<T>) -> Result<Self, Self::Error> { + let mut queue = VecDeque::from(value); + + if let Some(first) = queue.pop_front() { + Ok(Self { + first, + rest: queue.into(), + }) + } else { + bail!("You need to have at least one element in a non-empty vector.") + } + } +} + +impl<T: Clone> From<NonEmptyVec<T>> for Vec<T> { + fn from(value: NonEmptyVec<T>) -> Self { + let mut base = vec![value.first]; + base.extend(value.rest); + base + } +} + +impl<T: Clone> NonEmptyVec<T> { + pub(crate) fn first(&self) -> &T { + &self.first + } + + pub(crate) fn tail(&self) -> &[T] { + self.rest.as_ref() + } + + pub(crate) fn join(&self, sep: &str) -> String + where + T: Display, + { + let mut output = String::new(); + write!(output, "{}", self.first()).expect("In-memory, does not fail"); + + for elem in &self.rest { + write!(output, "{sep}{elem}").expect("In-memory, does not fail"); + } + + output + } +} |