aboutsummaryrefslogtreecommitdiffstats
path: root/src/watch/events
diff options
context:
space:
mode:
Diffstat (limited to 'src/watch/events')
-rw-r--r--src/watch/events/mod.rs374
-rw-r--r--src/watch/events/playlist_handler.rs96
2 files changed, 470 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)
+ }
+}
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 <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;
+
+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<String, ExtractorHash>,
+
+ /// A map of the playlist_entry_id field to their corresponding extractor hashes.
+ playlist_ids: HashMap<PlaylistEntryId, ExtractorHash>,
+}
+impl PlaylistHandler {
+ pub fn from_cache(cache: HashMap<String, ExtractorHash>) -> 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<PlaylistEntryId, ExtractorHash>> {
+ 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<String>,
+ id: Option<PlaylistEntryId>,
+ }
+ 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<PlaylistEntryId, ExtractorHash> =
+ 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)
+ }
+}