diff options
-rw-r--r-- | yt/src/watch/playlist_handler/client_messages/mod.rs | 88 | ||||
-rw-r--r-- | yt/src/watch/playlist_handler/mod.rs | 329 |
2 files changed, 417 insertions, 0 deletions
diff --git a/yt/src/watch/playlist_handler/client_messages/mod.rs b/yt/src/watch/playlist_handler/client_messages/mod.rs new file mode 100644 index 0000000..4c6d9d5 --- /dev/null +++ b/yt/src/watch/playlist_handler/client_messages/mod.rs @@ -0,0 +1,88 @@ +use std::{env, time::Duration}; + +use crate::{app::App, comments}; + +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<()> { + 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 = comments::description::get(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 = comments::get(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/yt/src/watch/playlist_handler/mod.rs b/yt/src/watch/playlist_handler/mod.rs new file mode 100644 index 0000000..e6ceece --- /dev/null +++ b/yt/src/watch/playlist_handler/mod.rs @@ -0,0 +1,329 @@ +use std::{cmp::Ordering, time::Duration}; + +use crate::{ + app::App, + storage::video_database::{ + VideoStatus, VideoStatusMarker, + extractor_hash::ExtractorHash, + get::{self, Playlist, PlaylistIndex}, + set, + }, +}; + +use anyhow::{Context, Result}; +use libmpv2::{EndFileReason, Mpv, events::Event}; +use log::{debug, info}; + +mod client_messages; + +#[derive(Debug, Clone, Copy)] +pub 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(()) +} + +async fn apply_video_options(app: &App, mpv: &Mpv, video: &ExtractorHash) -> Result<()> { + let options = get::video_mpv_opts(app, video).await?; + let video = get::video_by_hash(app, video).await?; + + mpv.set_property("speed", options.playback_speed)?; + + // We already start at 0, so setting it twice adds a uncomfortable skip sound. + if video.watch_progress.as_secs() != 0 { + mpv.set_property( + "time-pos", + i64::try_from(video.watch_progress.as_secs()).expect("This should not overflow"), + )?; + } + Ok(()) +} + +async fn mark_video_watched(app: &App, mpv: &Mpv) -> Result<()> { + let current_video = get::currently_focused_video(app) + .await? + .expect("This should be some at this point"); + + debug!( + "playlist handler will mark video '{}' watched.", + current_video.title + ); + + save_watch_progress(app, mpv).await?; + + set::video_watched(app, ¤t_video.extractor_hash).await?; + + Ok(()) +} + +/// Saves the `watch_progress` of the currently focused video. +pub(super) async fn save_watch_progress(app: &App, mpv: &Mpv) -> Result<()> { + let current_video = get::currently_focused_video(app) + .await? + .expect("This should be some at this point"); + let watch_progress = u32::try_from( + mpv.get_property::<i64>("time-pos") + .context("Failed to get the watchprogress of the currently playling video")?, + ) + .expect("This conversion should never fail as the `time-pos` property is positive"); + + debug!( + "Setting the watch progress for the current_video '{}' to {watch_progress}s", + current_video.title_fmt_no_color() + ); + + set::video_watch_progress(app, ¤t_video.extractor_hash, watch_progress).await +} + +/// Sync the mpv playlist with the internal playlist. +/// +/// This takes an `maybe_playlist` argument, if you have already fetched the playlist and want to +/// add that. +pub(super) async fn reload_mpv_playlist( + app: &App, + mpv: &Mpv, + maybe_playlist: Option<Playlist>, + maybe_index: Option<PlaylistIndex>, +) -> Result<()> { + fn get_playlist_count(mpv: &Mpv) -> Result<usize> { + mpv.get_property::<i64>("playlist/count") + .context("Failed to get mpv playlist len") + .map(|count| { + usize::try_from(count).expect("The playlist_count should always be positive") + }) + } + + if get_playlist_count(mpv)? != 0 { + // We could also use `loadlist`, but that would require use to start a unix socket or even + // write all the video paths to a file beforehand + mpv.command("playlist-clear", &[])?; + mpv.command("playlist-remove", &["current"])?; + } + + assert_eq!( + get_playlist_count(mpv)?, + 0, + "The playlist should be empty at this point." + ); + + let playlist = if let Some(p) = maybe_playlist { + p + } else { + get::playlist(app).await? + }; + + debug!("Will add {} videos to playlist.", playlist.len()); + playlist.into_iter().try_for_each(|cache_path| { + mpv.command("loadfile", &[ + cache_path.to_str().with_context(|| { + format!( + "Failed to parse the video cache path ('{}') as valid utf8", + cache_path.display() + ) + })?, + "append-play", + ])?; + + Ok::<(), anyhow::Error>(()) + })?; + + let index = if let Some(index) = maybe_index { + let index = usize::from(index); + let playlist_length = get_playlist_count(mpv)?; + + match index.cmp(&playlist_length) { + Ordering::Greater => { + unreachable!( + "The index '{index}' execeeds the playlist length '{playlist_length}'." + ); + } + Ordering::Less => index, + Ordering::Equal => { + // The index is pointing to the end of the playlist. We could either go the second + // to last entry (i.e., one entry back) or wrap around to the start. + // We wrap around: + 0 + } + } + } else { + get::current_playlist_index(app) + .await? + .map_or(0, usize::from) + }; + mpv.set_property("playlist-pos", index.to_string().as_str())?; + + Ok(()) +} + +/// Return the status of the playback queue +pub async fn status(app: &App) -> Result<Status> { + let playlist = get::playlist(app).await?; + + let playlist_len = playlist.len(); + let marked_watch_num = get::videos(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 async fn handle_mpv_event(app: &App, mpv: &Mpv, event: &Event<'_>) -> Result<bool> { + match event { + Event::EndFile(r) => match r.reason { + EndFileReason::Eof => { + info!("Mpv reached the end of the current video. Marking it watched."); + mark_video_watched(app, mpv).await?; + reload_mpv_playlist(app, mpv, None, None).await?; + } + 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"); + } + EndFileReason::Quit => { + info!("Mpv quit. Exiting playback"); + + save_watch_progress(app, mpv).await?; + + return Ok(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> + } + }, + Event::StartFile(_) => { + let mpv_pos = usize::try_from(mpv.get_property::<i64>("playlist-pos")?) + .expect("The value is strictly positive"); + + let next_video = { + let yt_pos = get::current_playlist_index(app).await?.map(usize::from); + + if (Some(mpv_pos) != yt_pos) || yt_pos.is_none() { + let playlist = get::playlist(app).await?; + let video = playlist + .get(PlaylistIndex::from(mpv_pos)) + .expect("The mpv pos should not be out of bounds"); + + set::focused( + app, + &video.extractor_hash, + get::currently_focused_video(app) + .await? + .as_ref() + .map(|v| &v.extractor_hash), + ) + .await?; + + video.extractor_hash + } else { + get::currently_focused_video(app) + .await? + .expect("We have a focused video") + .extractor_hash + } + }; + + apply_video_options(app, mpv, &next_video).await?; + } + Event::Seek => { + save_watch_progress(app, mpv).await?; + } + 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"] => { + let current_video = get::currently_focused_video(app) + .await? + .expect("This should exist at this point"); + let current_index = get::current_playlist_index(app) + .await? + .expect("This should exist, as we can mark this video picked"); + + save_watch_progress(app, mpv).await?; + + set::video_status( + app, + ¤t_video.extractor_hash, + VideoStatus::Pick, + Some(current_video.priority), + ) + .await?; + + reload_mpv_playlist(app, mpv, None, Some(current_index)).await?; + mpv_message(mpv, "Marked the video as picked", Duration::from_secs(3))?; + } + &["yt-mark-watched"] => { + let current_index = get::current_playlist_index(app) + .await? + .expect("This should exist, as we can mark this video picked"); + mark_video_watched(app, mpv).await?; + + reload_mpv_playlist(app, mpv, None, Some(current_index)).await?; + mpv_message(mpv, "Marked the video watched", Duration::from_secs(3))?; + } + &["yt-check-new-videos"] => { + reload_mpv_playlist(app, mpv, None, None).await?; + } + other => { + debug!("Unknown message: {}", other.join(" ")); + } + } + } + _ => {} + } + + Ok(false) +} |