// yt - A fully featured command line YouTube client // // Copyright (C) 2024 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::{collections::HashMap, env::current_exe, time::Duration, usize}; use anyhow::{bail, Result}; use libmpv2::{ events::{Event, PlaylistEntryId}, mpv_node::MpvNode, 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, }, }; #[derive(Debug)] pub struct MpvEventHandler { watch_later_block_list: HashMap, // current_playlist: HashMap, playlist_cache: HashMap, } impl MpvEventHandler { pub fn from_playlist(playlist_cache: HashMap) -> Self { Self { // current_playlist, playlist_cache, watch_later_block_list: HashMap::new(), } } fn get_current_mpv_playlist( &self, mpv: &Mpv, ) -> Result> { let mpv_playlist: Vec<(String, PlaylistEntryId)> = match mpv.get_property("playlist")? { MpvNode::ArrayIter(array) => array .map(|val| match val { MpvNode::MapIter(map) => { struct BuildPlaylistEntry { filename: Option, id: Option, } let mut entry = BuildPlaylistEntry { filename: None, id: None, }; map.for_each(|(key, value)| match key.as_str() { "filename" => { entry.filename = Some(value.str().expect("work").to_owned()) } "id" => { entry.id = Some(PlaylistEntryId::new(value.i64().expect("Works"))) } _ => (), }); (entry.filename.expect("is some"), entry.id.expect("is some")) } _ => unreachable!(), }) .collect(), _ => unreachable!(), }; let mut playlist: HashMap = HashMap::with_capacity(mpv_playlist.len()); for (path, key) in mpv_playlist { let hash = self .playlist_cache .get(&path) .expect("All path should also be stored in the cache") .to_owned(); playlist.insert(key, hash); } // debug!("Requested the current playlist: '{:#?}'", &playlist); Ok(playlist) } /// 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 { 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.get_current_mpv_playlist(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::>(); info!( "{} videos are cached and will be added to the list to be played ({} are blocked)", play_things.len(), blocked_videos ); self.playlist_cache.reserve(play_things.len()); let num = 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_cache .insert(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(&self, mpv: &Mpv, offset: i64) -> Result { let playlist_entry_id = { let playlist_position = { let raw = mpv.get_property::("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::(format!("playlist/{}/id", playlist_position).as_str())?; PlaylistEntryId::new(raw) }; // debug!("Trying to get playlist entry: '{}'", playlist_entry_id); let video_hash = self .get_current_mpv_playlist(mpv)? .remove(&playlist_entry_id) .expect("The stored playling index should always be in the playlist"); 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.get_current_mpv_playlist(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.get_current_mpv_playlist(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=` 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 { 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 { if mpv.get_property::("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 { 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 videos = self.get_current_mpv_playlist(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?; self.apply_options(app, mpv, &self.get_cvideo_hash(mpv, 0)?) .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, "", "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) } }