From 99d4f688868ee664470b13a0d61ac65832263bab Mon Sep 17 00:00:00 2001 From: Benedikt Peetz Date: Thu, 24 Jul 2025 15:54:05 +0200 Subject: feat(crates/yt/commands/watch/mpv_commands): Hook-up the new show commands --- crates/yt/src/commands/show/mod.rs | 20 ++ crates/yt/src/commands/watch/implm/mod.rs | 224 +++++++++++++++++++- .../implm/playlist_handler/client_messages.rs | 93 ++++++++ .../commands/watch/implm/playlist_handler/mod.rs | 225 ++++++++++++++++++++ crates/yt/src/commands/watch/implm/watch/mod.rs | 235 --------------------- .../watch/playlist_handler/client_messages.rs | 99 --------- .../watch/implm/watch/playlist_handler/mod.rs | 218 ------------------- crates/yt/src/config/mod.rs | 23 ++ crates/yt/src/config/non_empty_vec.rs | 73 +++++++ 9 files changed, 653 insertions(+), 557 deletions(-) create mode 100644 crates/yt/src/commands/show/mod.rs create mode 100644 crates/yt/src/commands/watch/implm/playlist_handler/client_messages.rs create mode 100644 crates/yt/src/commands/watch/implm/playlist_handler/mod.rs delete mode 100644 crates/yt/src/commands/watch/implm/watch/mod.rs delete mode 100644 crates/yt/src/commands/watch/implm/watch/playlist_handler/client_messages.rs delete mode 100644 crates/yt/src/commands/watch/implm/watch/playlist_handler/mod.rs create mode 100644 crates/yt/src/config/non_empty_vec.rs 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 ` + 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) -> 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, 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/playlist_handler/client_messages.rs b/crates/yt/src/commands/watch/implm/playlist_handler/client_messages.rs new file mode 100644 index 0000000..fd7e035 --- /dev/null +++ b/crates/yt/src/commands/watch/implm/playlist_handler/client_messages.rs @@ -0,0 +1,93 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see . + +use std::{env, time::Duration}; + +use crate::{app::App, storage::db::video::Video}; + +use anyhow::{Context, Result, bail}; +use libmpv2::Mpv; +use tokio::process::Command; + +use super::mpv_message; + +async fn run_self_in_external_command(app: &App, args: &[&str]) -> Result<()> { + // TODO(@bpeetz): Can we trust this value? <2025-06-15> + let binary = + env::current_exe().context("Failed to determine the current executable to re-execute")?; + + let arguments = [ + &[ + binary + .to_str() + .context("Failed to turn the executable path to a utf8-string")?, + "--db-path", + app.config + .paths + .database_path + .to_str() + .context("Failed to parse the database_path as a utf8-string")?, + ], + args, + ] + .concat(); + + 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!("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, &["show", "description"]).await?; + Ok(()) +} +pub(super) async fn handle_yt_description_local(app: &App, mpv: &Mpv) -> Result<()> { + let description: String = Video::get_current_description(app) + .await? + .chars() + .take(app.config.watch.local_displays_length) + .collect(); + + mpv_message(mpv, &description, Duration::from_secs(6))?; + Ok(()) +} + +pub(super) async fn handle_yt_comments_external(app: &App) -> Result<()> { + run_self_in_external_command(app, &["show", "comments"]).await?; + Ok(()) +} +pub(super) async fn handle_yt_comments_local(app: &App, mpv: &Mpv) -> Result<()> { + let comments: String = Video::get_current_comments(app) + .await? + .render(false) + .chars() + .take(app.config.watch.local_displays_length) + .collect(); + + 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/playlist_handler/mod.rs b/crates/yt/src/commands/watch/implm/playlist_handler/mod.rs new file mode 100644 index 0000000..bdb77d2 --- /dev/null +++ b/crates/yt/src/commands/watch/implm/playlist_handler/mod.rs @@ -0,0 +1,225 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see . + +use std::time::Duration; + +use crate::{ + app::App, + storage::db::{ + insert::{Operations, playlist::VideoTransition}, + playlist::{Playlist, PlaylistIndex}, + video::{Video, VideoStatusMarker}, + }, +}; + +use anyhow::{Context, Result}; +use libmpv2::{EndFileReason, Mpv, events::Event}; +use log::{debug, info}; + +mod client_messages; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum Status { + /// There are no videos cached and no more marked to be watched. + /// Waiting is pointless. + NoMoreAvailable, + + /// There are no videos cached, but some (> 0) are marked to be watched. + /// So we should wait for them to become available. + NoCached { marked_watch: usize }, + + /// There are videos cached and ready to be inserted into the playback queue. + Available { newly_available: usize }, +} + +fn mpv_message(mpv: &Mpv, message: &str, time: Duration) -> Result<()> { + mpv.command( + "show-text", + &[message, time.as_millis().to_string().as_str()], + )?; + Ok(()) +} + +/// Return the status of the playback queue +pub(crate) async fn status(app: &App) -> Result { + let playlist = Playlist::create(app).await?; + + let playlist_len = playlist.len(); + let marked_watch_num = Video::in_states(app, &[VideoStatusMarker::Watch]) + .await? + .len(); + + if playlist_len == 0 && marked_watch_num == 0 { + Ok(Status::NoMoreAvailable) + } else if playlist_len == 0 && marked_watch_num != 0 { + Ok(Status::NoCached { + marked_watch: marked_watch_num, + }) + } else if playlist_len != 0 { + Ok(Status::Available { + newly_available: playlist_len, + }) + } else { + unreachable!( + "The playlist length is {playlist_len}, but the number of marked watch videos is {marked_watch_num}! This is a bug." + ); + } +} + +/// # Returns +/// This will return [`true`], if the event handling should be stopped +/// +/// # Panics +/// Only if internal assertions fail. +#[allow(clippy::too_many_lines)] +pub(crate) async fn handle_mpv_event(app: &App, mpv: &Mpv, event: &Event<'_>) -> Result { + let mut ops = Operations::new("PlaylistHandler: handle event"); + + // Construct the playlist lazily. + // This avoids unneeded db lookups. + // (We use the moved `call_once` as guard for this) + let call_once = String::new(); + let playlist = move || { + drop(call_once); + Playlist::create(app) + }; + + let should_stop_event_handling = match event { + Event::EndFile(r) => match r.reason { + EndFileReason::Eof => { + info!("Mpv reached the end of the current video. Marking it watched."); + playlist().await?.resync_with_mpv(app, mpv)?; + + false + } + EndFileReason::Stop => { + // This reason is incredibly ambiguous. It _both_ means actually pausing a + // video and going to the next one in the playlist. + // Oh, and it's also called, when a video is removed from the playlist (at + // least via "playlist-remove current") + info!("Paused video (or went to next playlist entry); Doing nothing"); + + false + } + EndFileReason::Quit => { + info!("Mpv quit. Exiting playback"); + + playlist().await?.save_current_watch_progress(mpv, &mut ops); + + true + } + EndFileReason::Error => { + unreachable!("This should have been raised as a separate error") + } + EndFileReason::Redirect => { + // TODO: We probably need to handle this somehow <2025-02-17> + false + } + }, + Event::StartFile(_) => { + let mut playlist = playlist().await?; + + let mpv_pos = usize::try_from(mpv.get_property::("playlist-pos")?) + .expect("The value is strictly positive"); + + let yt_pos = playlist.current_index().map(usize::from); + + if (Some(mpv_pos) != yt_pos) || yt_pos.is_none() { + debug!( + "StartFileHandler: mpv pos {mpv_pos} and our pos {yt_pos:?} do not align. Reloading.." + ); + + if let Some((_, vid)) = playlist.get_focused_mut() { + vid.set_focused(false, &mut ops); + ops.commit(app) + .await + .context("Failed to commit video unfocusing")?; + + ops = Operations::new("PlaylistHandler: after set-focused"); + } + + let video = playlist + .get_mut(PlaylistIndex::from(mpv_pos)) + .expect("The mpv pos should not be out of bounds"); + + video.set_focused(true, &mut ops); + + playlist.resync_with_mpv(app, mpv)?; + } + + false + } + Event::Seek => { + playlist().await?.save_current_watch_progress(mpv, &mut ops); + + false + } + Event::ClientMessage(a) => { + debug!("Got Client Message event: '{}'", a.join(" ")); + + match a.as_slice() { + &["yt-comments-external"] => { + client_messages::handle_yt_comments_external(app).await?; + } + &["yt-comments-local"] => { + client_messages::handle_yt_comments_local(app, mpv).await?; + } + + &["yt-description-external"] => { + client_messages::handle_yt_description_external(app).await?; + } + &["yt-description-local"] => { + 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, + mpv, + VideoTransition::Picked, + &mut ops, + )?; + + mpv_message(mpv, "Marked the video as picked", Duration::from_secs(3))?; + } + &["yt-mark-watched"] => { + playlist().await?.mark_current_done( + app, + mpv, + VideoTransition::Watched, + &mut ops, + )?; + + mpv_message(mpv, "Marked the video watched", Duration::from_secs(3))?; + } + &["yt-check-new-videos"] => { + playlist().await?.resync_with_mpv(app, mpv)?; + } + other => { + debug!("Unknown message: {}", other.join(" ")); + } + } + + false + } + _ => false, + }; + + ops.commit(app).await?; + + Ok(should_stop_event_handling) +} 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 -// Copyright (C) 2025 Benedikt Peetz -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see . - -use std::{ - 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, 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, 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/commands/watch/implm/watch/playlist_handler/client_messages.rs b/crates/yt/src/commands/watch/implm/watch/playlist_handler/client_messages.rs deleted file mode 100644 index 6c8ebbe..0000000 --- a/crates/yt/src/commands/watch/implm/watch/playlist_handler/client_messages.rs +++ /dev/null @@ -1,99 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2025 Benedikt Peetz -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see . - -use std::{env, time::Duration}; - -use crate::{app::App, storage::db::video::Video}; - -use anyhow::{Context, Result, bail}; -use libmpv2::Mpv; -use tokio::process::Command; - -use super::mpv_message; - -async fn run_self_in_external_command(app: &App, args: &[&str]) -> Result<()> { - // TODO(@bpeetz): Can we trust this value? <2025-06-15> - 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")?, - "--db-path", - app.config - .paths - .database_path - .to_str() - .context("Failed to parse the database_path as a utf8-string")?, - ], - args, - ] - .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"]) - .status() - .await?; - - if !status.success() { - bail!("focusing the next output failed!"); - } - - Ok(()) -} - -pub(super) async fn handle_yt_description_external(app: &App) -> Result<()> { - run_self_in_external_command(app, &["description"]).await?; - Ok(()) -} -pub(super) async fn handle_yt_description_local(app: &App, mpv: &Mpv) -> Result<()> { - let description: String = Video::get_current_description(app) - .await? - .chars() - .take(app.config.watch.local_displays_length) - .collect(); - - mpv_message(mpv, &description, Duration::from_secs(6))?; - Ok(()) -} - -pub(super) async fn handle_yt_comments_external(app: &App) -> Result<()> { - run_self_in_external_command(app, &["comments"]).await?; - Ok(()) -} -pub(super) async fn handle_yt_comments_local(app: &App, mpv: &Mpv) -> Result<()> { - let comments: String = Video::get_current_comments(app) - .await? - .render(false) - .chars() - .take(app.config.watch.local_displays_length) - .collect(); - - mpv_message(mpv, &comments, Duration::from_secs(6))?; - Ok(()) -} diff --git a/crates/yt/src/commands/watch/implm/watch/playlist_handler/mod.rs b/crates/yt/src/commands/watch/implm/watch/playlist_handler/mod.rs deleted file mode 100644 index 443fd26..0000000 --- a/crates/yt/src/commands/watch/implm/watch/playlist_handler/mod.rs +++ /dev/null @@ -1,218 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2025 Benedikt Peetz -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see . - -use std::time::Duration; - -use crate::{ - app::App, - storage::db::{ - insert::{Operations, playlist::VideoTransition}, - playlist::{Playlist, PlaylistIndex}, - video::{Video, VideoStatusMarker}, - }, -}; - -use anyhow::{Context, Result}; -use libmpv2::{EndFileReason, Mpv, events::Event}; -use log::{debug, info}; - -mod client_messages; - -#[derive(Debug, Clone, Copy)] -pub(crate) enum Status { - /// There are no videos cached and no more marked to be watched. - /// Waiting is pointless. - NoMoreAvailable, - - /// There are no videos cached, but some (> 0) are marked to be watched. - /// So we should wait for them to become available. - NoCached { marked_watch: usize }, - - /// There are videos cached and ready to be inserted into the playback queue. - Available { newly_available: usize }, -} - -fn mpv_message(mpv: &Mpv, message: &str, time: Duration) -> Result<()> { - mpv.command( - "show-text", - &[message, time.as_millis().to_string().as_str()], - )?; - Ok(()) -} - -/// Return the status of the playback queue -pub(crate) async fn status(app: &App) -> Result { - let playlist = Playlist::create(app).await?; - - let playlist_len = playlist.len(); - let marked_watch_num = Video::in_states(app, &[VideoStatusMarker::Watch]) - .await? - .len(); - - if playlist_len == 0 && marked_watch_num == 0 { - Ok(Status::NoMoreAvailable) - } else if playlist_len == 0 && marked_watch_num != 0 { - Ok(Status::NoCached { - marked_watch: marked_watch_num, - }) - } else if playlist_len != 0 { - Ok(Status::Available { - newly_available: playlist_len, - }) - } else { - unreachable!( - "The playlist length is {playlist_len}, but the number of marked watch videos is {marked_watch_num}! This is a bug." - ); - } -} - -/// # Returns -/// This will return [`true`], if the event handling should be stopped -/// -/// # Panics -/// Only if internal assertions fail. -#[allow(clippy::too_many_lines)] -pub(crate) async fn handle_mpv_event(app: &App, mpv: &Mpv, event: &Event<'_>) -> Result { - let mut ops = Operations::new("PlaylistHandler: handle event"); - - // Construct the playlist lazily. - // This avoids unneeded db lookups. - // (We use the moved `call_once` as guard for this) - let call_once = String::new(); - let playlist = move || { - drop(call_once); - Playlist::create(app) - }; - - let should_stop_event_handling = match event { - Event::EndFile(r) => match r.reason { - EndFileReason::Eof => { - info!("Mpv reached the end of the current video. Marking it watched."); - playlist().await?.resync_with_mpv(app, mpv)?; - - false - } - EndFileReason::Stop => { - // This reason is incredibly ambiguous. It _both_ means actually pausing a - // video and going to the next one in the playlist. - // Oh, and it's also called, when a video is removed from the playlist (at - // least via "playlist-remove current") - info!("Paused video (or went to next playlist entry); Doing nothing"); - - false - } - EndFileReason::Quit => { - info!("Mpv quit. Exiting playback"); - - playlist().await?.save_current_watch_progress(mpv, &mut ops); - - true - } - EndFileReason::Error => { - unreachable!("This should have been raised as a separate error") - } - EndFileReason::Redirect => { - // TODO: We probably need to handle this somehow <2025-02-17> - false - } - }, - Event::StartFile(_) => { - let mut playlist = playlist().await?; - - let mpv_pos = usize::try_from(mpv.get_property::("playlist-pos")?) - .expect("The value is strictly positive"); - - let yt_pos = playlist.current_index().map(usize::from); - - if (Some(mpv_pos) != yt_pos) || yt_pos.is_none() { - debug!( - "StartFileHandler: mpv pos {mpv_pos} and our pos {yt_pos:?} do not align. Reloading.." - ); - - if let Some((_, vid)) = playlist.get_focused_mut() { - vid.set_focused(false, &mut ops); - ops.commit(app) - .await - .context("Failed to commit video unfocusing")?; - - ops = Operations::new("PlaylistHandler: after set-focused"); - } - - let video = playlist - .get_mut(PlaylistIndex::from(mpv_pos)) - .expect("The mpv pos should not be out of bounds"); - - video.set_focused(true, &mut ops); - - playlist.resync_with_mpv(app, mpv)?; - } - - false - } - Event::Seek => { - playlist().await?.save_current_watch_progress(mpv, &mut ops); - - false - } - Event::ClientMessage(a) => { - debug!("Got Client Message event: '{}'", a.join(" ")); - - match a.as_slice() { - &["yt-comments-external"] => { - client_messages::handle_yt_comments_external(app).await?; - } - &["yt-comments-local"] => { - client_messages::handle_yt_comments_local(app, mpv).await?; - } - - &["yt-description-external"] => { - client_messages::handle_yt_description_external(app).await?; - } - &["yt-description-local"] => { - client_messages::handle_yt_description_local(app, mpv).await?; - } - - &["yt-mark-picked"] => { - playlist().await?.mark_current_done( - app, - mpv, - VideoTransition::Picked, - &mut ops, - )?; - - mpv_message(mpv, "Marked the video as picked", Duration::from_secs(3))?; - } - &["yt-mark-watched"] => { - playlist().await?.mark_current_done( - app, - mpv, - VideoTransition::Watched, - &mut ops, - )?; - - mpv_message(mpv, "Marked the video watched", Duration::from_secs(3))?; - } - &["yt-check-new-videos"] => { - playlist().await?.resync_with_mpv(app, mpv)?; - } - other => { - debug!("Unknown message: {}", other.join(" ")); - } - } - - false - } - _ => false, - }; - - ops.commit(app).await?; - - Ok(should_stop_event_handling) -} 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 =: 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, + /// ` --db-path comments` + external_spawn: NonEmptyVec =: 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 =: 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")] +#[serde(into = "Vec")] +pub(crate) struct NonEmptyVec { + first: T, + rest: Vec, +} + +impl TryFrom> for NonEmptyVec { + type Error = anyhow::Error; + + fn try_from(value: Vec) -> Result { + 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 From> for Vec { + fn from(value: NonEmptyVec) -> Self { + let mut base = vec![value.first]; + base.extend(value.rest); + base + } +} + +impl NonEmptyVec { + 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 + } +} -- cgit 1.4.1