// 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, mem, time::Duration}; 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, playlist_handler: PlaylistHandler, } impl MpvEventHandler { pub fn from_playlist(playlist_cache: HashMap) -> 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 { let play_things = get_videos(app, &[VideoStatus::Cached], Some(false)).await?; // There is nothing to watch if play_things.is_empty() { 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() .any(|a| a == &val.extractor_hash) }) .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 ); 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 { 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 .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=` 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 mut handler = mem::take(&mut self.playlist_handler); let videos = handler.playlist_ids(mpv)?; for hash in videos.values() { 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, "", "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) } }