From 83643e0370b101968bd3de5e9a81c2b309955cbd Mon Sep 17 00:00:00 2001 From: Benedikt Peetz Date: Sun, 25 Aug 2024 15:53:05 +0200 Subject: 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. --- src/watch/events/mod.rs | 374 +++++++++++++++++++++++++++++++++++ src/watch/events/playlist_handler.rs | 96 +++++++++ 2 files changed, 470 insertions(+) create mode 100644 src/watch/events/mod.rs create mode 100644 src/watch/events/playlist_handler.rs (limited to 'src/watch/events') 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 +// 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, 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, + 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.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::>(); + + 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 { + 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) + } +} diff --git a/src/watch/events/playlist_handler.rs b/src/watch/events/playlist_handler.rs new file mode 100644 index 0000000..8f2f322 --- /dev/null +++ b/src/watch/events/playlist_handler.rs @@ -0,0 +1,96 @@ +// 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; + +use anyhow::Result; +use libmpv2::{events::PlaylistEntryId, mpv_node::MpvNode, Mpv}; + +use crate::storage::video_database::extractor_hash::ExtractorHash; + +#[derive(Debug, Default)] +pub struct PlaylistHandler { + /// A map of the original file paths to the videos extractor hashes. + /// Used to get the extractor hash from a video returned by mpv + playlist_cache: HashMap, + + /// A map of the playlist_entry_id field to their corresponding extractor hashes. + playlist_ids: HashMap, +} +impl PlaylistHandler { + pub fn from_cache(cache: HashMap) -> Self { + Self { + playlist_cache: cache, + playlist_ids: HashMap::new(), + } + } + + pub fn reserve(&mut self, len: usize) { + self.playlist_cache.reserve(len) + } + pub fn add(&mut self, cache_path: String, extractor_hash: ExtractorHash) { + assert_eq!( + self.playlist_cache.insert(cache_path, extractor_hash), + None, + "Only new video should ever be added" + ); + } + + pub fn playlist_ids(&mut self, mpv: &Mpv) -> Result<&HashMap> { + 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); + } + + for (id, hash) in playlist { + if !self.playlist_ids.contains_key(&id) { + self.playlist_ids.insert(id, hash); + } + } + + Ok(&self.playlist_ids) + } +} -- cgit 1.4.1