diff options
author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2024-08-25 15:53:05 +0200 |
---|---|---|
committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2024-08-25 15:53:05 +0200 |
commit | 83643e0370b101968bd3de5e9a81c2b309955cbd (patch) | |
tree | d1c0f7543db0ad700e0433334cedf4bd051d8b0a /src/watch/events/mod.rs | |
parent | fix(storage/extractor_hash): Remove useless logs (diff) | |
download | yt-83643e0370b101968bd3de5e9a81c2b309955cbd.tar.gz yt-83643e0370b101968bd3de5e9a81c2b309955cbd.zip |
refactor(watch/playlist_handler): Init
This facilitates outsourcing the mpv playlist operations and overlaying them with an cache that provides the facility to convert for `playlist_entry_id`s to `ExtractorHash`es even after their corresponding video has been removed from the playlist.
Diffstat (limited to 'src/watch/events/mod.rs')
-rw-r--r-- | src/watch/events/mod.rs | 374 |
1 files changed, 374 insertions, 0 deletions
diff --git a/src/watch/events/mod.rs b/src/watch/events/mod.rs new file mode 100644 index 0000000..9ca12fd --- /dev/null +++ b/src/watch/events/mod.rs @@ -0,0 +1,374 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2024 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::{collections::HashMap, env::current_exe, mem, time::Duration, usize}; + +use anyhow::{bail, Result}; +use libmpv2::{ + events::{Event, PlaylistEntryId}, + EndFileReason, Mpv, +}; +use log::{debug, info, warn}; +use tokio::{process::Command, time}; + +use crate::{ + app::App, + comments::get_comments, + storage::video_database::{ + extractor_hash::ExtractorHash, + getters::{get_video_by_hash, get_video_mpv_opts, get_videos}, + setters::{set_state_change, set_video_watched}, + VideoStatus, + }, +}; + +use playlist_handler::PlaylistHandler; + +mod playlist_handler; + +#[derive(Debug)] +pub struct MpvEventHandler { + watch_later_block_list: HashMap<ExtractorHash, ()>, + playlist_handler: PlaylistHandler, +} + +impl MpvEventHandler { + pub fn from_playlist(playlist_cache: HashMap<String, ExtractorHash>) -> Self { + let playlist_handler = PlaylistHandler::from_cache(playlist_cache); + Self { + playlist_handler, + watch_later_block_list: HashMap::new(), + } + } + + /// Checks, whether new videos are ready to be played + pub async fn possibly_add_new_videos( + &mut self, + app: &App, + mpv: &Mpv, + force_message: bool, + ) -> Result<usize> { + let play_things = get_videos(app, &[VideoStatus::Cached], Some(false)).await?; + + // There is nothing to watch + if play_things.len() == 0 { + if force_message { + Self::message(&mpv, "No new videos available to add", "3000")?; + } + return Ok(0); + } + + let mut blocked_videos = 0; + let current_playlist = self.playlist_handler.playlist_ids(mpv)?; + let play_things = play_things + .into_iter() + .filter(|val| { + !current_playlist + .values() + .find(|a| *a == &val.extractor_hash) + .is_some() + }) + .filter(|val| { + if self + .watch_later_block_list + .contains_key(&val.extractor_hash) + { + blocked_videos += 1; + false + } else { + true + } + }) + .collect::<Vec<_>>(); + + info!( + "{} videos are cached and will be added to the list to be played ({} are blocked)", + play_things.len(), + blocked_videos + ); + + let num = play_things.len(); + self.playlist_handler.reserve(play_things.len()); + for play_thing in play_things { + debug!("Adding '{}' to playlist.", play_thing.title); + + let orig_cache_path = play_thing.cache_path.expect("Is cached and thus some"); + let cache_path = orig_cache_path.to_str().expect("Should be vaild utf8"); + let fmt_cache_path = format!("\"{}\"", cache_path); + + let args = &[&fmt_cache_path, "append-play"]; + + mpv.execute("loadfile", args)?; + self.playlist_handler + .add(cache_path.to_owned(), play_thing.extractor_hash); + } + + if force_message || num > 0 { + Self::message( + &mpv, + format!( + "Added {} videos ({} are marked as watch later)", + num, blocked_videos + ) + .as_str(), + "3000", + )?; + } + Ok(num) + } + + fn message(mpv: &Mpv, message: &str, time: &str) -> Result<()> { + mpv.execute("show-text", &[format!("\"{}\"", message).as_str(), time])?; + Ok(()) + } + + /// Get the hash of the currently playing video. + /// You can specify an offset, which is added to the playlist_position to get, for example, the + /// previous video (-1) or the next video (+1). + /// Beware that setting an offset can cause an property error if it's out of bound. + fn get_cvideo_hash(&mut self, mpv: &Mpv, offset: i64) -> Result<ExtractorHash> { + let playlist_entry_id = { + let playlist_position = { + let raw = mpv.get_property::<i64>("playlist-pos")?; + if raw == -1 { + unreachable!( "This should only be called when a current video exists. Current state: '{:#?}'", self); + } else { + (raw + offset) as usize + } + }; + + let raw = + mpv.get_property::<i64>(format!("playlist/{}/id", playlist_position).as_str())?; + PlaylistEntryId::new(raw) + }; + + // debug!("Trying to get playlist entry: '{}'", playlist_entry_id); + + let video_hash = self + .playlist_handler + .playlist_ids(mpv)? + .get(&playlist_entry_id) + .expect("The stored playling index should always be in the playlist") + .to_owned(); + + Ok(video_hash) + } + async fn mark_video_watched(&self, app: &App, hash: &ExtractorHash) -> Result<()> { + let video = get_video_by_hash(app, hash).await?; + debug!("MPV handler will mark video '{}' watched.", video.title); + set_video_watched(&app, &video).await?; + Ok(()) + } + + async fn mark_video_inactive( + &mut self, + app: &App, + mpv: &Mpv, + playlist_index: PlaylistEntryId, + ) -> Result<()> { + let current_playlist = self.playlist_handler.playlist_ids(mpv)?; + let video_hash = current_playlist + .get(&playlist_index) + .expect("The video index should always be correctly tracked"); + + set_state_change(&app, video_hash, false).await?; + Ok(()) + } + async fn mark_video_active( + &mut self, + app: &App, + mpv: &Mpv, + playlist_index: PlaylistEntryId, + ) -> Result<()> { + let current_playlist = self.playlist_handler.playlist_ids(mpv)?; + let video_hash = current_playlist + .get(&playlist_index) + .expect("The video index should always be correctly tracked"); + + set_state_change(&app, video_hash, true).await?; + Ok(()) + } + + /// Apply the options set with e.g. `watch --speed=<speed>` + async fn apply_options(&self, app: &App, mpv: &Mpv, hash: &ExtractorHash) -> Result<()> { + let options = get_video_mpv_opts(app, hash).await?; + + mpv.set_property("speed", options.playback_speed)?; + Ok(()) + } + + /// This also returns the hash of the current video + fn remove_cvideo_from_playlist(&mut self, mpv: &Mpv) -> Result<ExtractorHash> { + let hash = self.get_cvideo_hash(mpv, 0)?; + mpv.execute("playlist-remove", &["current"])?; + Ok(hash) + } + + /// Check if the playback queue is empty + pub async fn check_idle(&mut self, app: &App, mpv: &Mpv) -> Result<bool> { + if mpv.get_property::<bool>("idle-active")? { + warn!("There is nothing to watch yet. Will idle, until something is available"); + let number_of_new_videos = self.possibly_add_new_videos(app, mpv, false).await?; + + if number_of_new_videos == 0 { + time::sleep(Duration::from_secs(10)).await; + Ok(true) + } else { + Ok(false) + } + } else { + Ok(false) + } + } + + /// This will return [`true`], if the event handling should be stopped + pub async fn handle_mpv_event<'a>( + &mut self, + app: &App, + mpv: &Mpv, + event: Event<'a>, + ) -> Result<bool> { + match event { + Event::EndFile(r) => match r.reason { + EndFileReason::Eof => { + info!("Mpv reached eof of current video. Marking it inactive."); + + self.mark_video_inactive(app, mpv, r.playlist_entry_id) + .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); Marking it inactive"); + + self.mark_video_inactive(app, mpv, r.playlist_entry_id) + .await?; + } + EndFileReason::Quit => { + info!("Mpv quit. Exiting playback"); + + // draining the playlist is okay, as mpv is done playing + let mut handler = mem::take(&mut self.playlist_handler); + let videos = handler.playlist_ids(mpv)?; + for (_, hash) in videos { + self.mark_video_watched(app, &hash).await?; + set_state_change(&app, &hash, false).await?; + } + return Ok(true); + } + EndFileReason::Error => { + unreachable!("This will be raised as a separate error") + } + EndFileReason::Redirect => { + todo!("We probably need to handle this somehow"); + } + }, + Event::StartFile(entry_id) => { + self.possibly_add_new_videos(app, &mpv, false).await?; + + // We don't need to check, whether other videos are still active, as they should + // have been marked inactive in the `Stop` handler. + self.mark_video_active(app, mpv, entry_id).await?; + let hash = self.get_cvideo_hash(mpv, 0)?; + self.apply_options(app, mpv, &hash).await?; + } + Event::ClientMessage(a) => { + debug!("Got Client Message event: '{}'", a.join(" ")); + + match a.as_slice() { + &["yt-comments-external"] => { + let binary = current_exe().expect("A current exe should exist"); + + let status = Command::new("riverctl") + .args(["focus-output", "next"]) + .status() + .await?; + if !status.success() { + bail!("focusing the next output failed!"); + } + + let status = Command::new("alacritty") + .args(&[ + "--title", + "floating please", + "--command", + binary.to_str().expect("Should be valid unicode"), + "--db-path", + app.config + .paths + .database_path + .to_str() + .expect("This should be convertible?"), + "comments", + ]) + .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!"); + } + } + &["yt-comments-local"] => { + let comments: String = get_comments(app) + .await? + .render(false) + .replace("\"", "") + .replace("'", "") + .chars() + .take(app.config.watch.local_comments_length) + .collect(); + + Self::message(mpv, &comments, "6000")?; + } + &["yt-description"] => { + // let description = description(app).await?; + Self::message(&mpv, "<YT Description>", "6000")?; + } + &["yt-mark-watch-later"] => { + mpv.execute("write-watch-later-config", &[])?; + + let hash = self.remove_cvideo_from_playlist(mpv)?; + assert_eq!( + self.watch_later_block_list.insert(hash, ()), + None, + "A video should not be blocked *and* in the playlist" + ); + + Self::message(&mpv, "Marked the video to be watched later", "3000")?; + } + &["yt-mark-done-and-go-next"] => { + let cvideo_hash = self.remove_cvideo_from_playlist(mpv)?; + self.mark_video_watched(app, &cvideo_hash).await?; + + Self::message(&mpv, "Marked the video watched", "3000")?; + } + &["yt-check-new-videos"] => { + self.possibly_add_new_videos(app, mpv, true).await?; + } + other => { + debug!("Unknown message: {}", other.join(" ")) + } + } + } + _ => {} + } + + Ok(false) + } +} |