// 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, 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<ExtractorHash, ()>,
// current_playlist: HashMap<PlaylistEntryId, ExtractorHash>,
playlist_cache: HashMap<String, ExtractorHash>,
}
impl MpvEventHandler {
pub fn from_playlist(playlist_cache: HashMap<String, ExtractorHash>) -> Self {
Self {
// current_playlist,
playlist_cache,
watch_later_block_list: HashMap::new(),
}
}
fn get_current_mpv_playlist(
&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);
}
// 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<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.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::<Vec<_>>();
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<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
.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=<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 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, "<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)
}
}