diff options
Diffstat (limited to 'src/select')
-rw-r--r-- | src/select/cmds.rs | 82 | ||||
-rw-r--r-- | src/select/mod.rs | 184 | ||||
-rw-r--r-- | src/select/selection_file/display.rs | 103 | ||||
-rw-r--r-- | src/select/selection_file/duration.rs | 102 | ||||
-rw-r--r-- | src/select/selection_file/help.str | 10 | ||||
-rw-r--r-- | src/select/selection_file/help.str.license | 9 | ||||
-rw-r--r-- | src/select/selection_file/mod.rs | 35 |
7 files changed, 525 insertions, 0 deletions
diff --git a/src/select/cmds.rs b/src/select/cmds.rs new file mode 100644 index 0000000..40e5b17 --- /dev/null +++ b/src/select/cmds.rs @@ -0,0 +1,82 @@ +// 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 crate::{ + app::App, + cli::SelectCommand, + storage::video_database::{ + getters::get_video_by_hash, + setters::{set_video_options, set_video_status}, + VideoOptions, VideoStatus, + }, +}; + +use anyhow::{Context, Result}; + +pub async fn handle_select_cmd( + app: &App, + cmd: SelectCommand, + line_number: Option<i64>, +) -> Result<()> { + match cmd { + SelectCommand::Pick { shared } => { + set_video_status( + app, + &shared.hash.realize(app).await?, + VideoStatus::Pick, + line_number, + ) + .await? + } + SelectCommand::Drop { shared } => { + set_video_status( + app, + &shared.hash.realize(app).await?, + VideoStatus::Drop, + line_number, + ) + .await? + } + SelectCommand::Watch { + shared, + priority, + subtitle_langs, + speed, + } => { + let hash = shared.hash.realize(&app).await?; + let video = get_video_by_hash(app, &hash).await?; + let video_options = VideoOptions::new(subtitle_langs, speed); + let priority = if let Some(pri) = priority { + Some(pri) + } else if let Some(pri) = line_number { + Some(pri) + } else { + None + }; + + if let Some(_) = video.cache_path { + set_video_status(app, &hash, VideoStatus::Cached, priority).await?; + } else { + set_video_status(app, &hash, VideoStatus::Watch, priority).await?; + } + + set_video_options(app, hash, &video_options).await?; + } + + SelectCommand::Url { shared } => { + let mut firefox = std::process::Command::new("firefox"); + firefox.args(["-P", "timesinks.youtube"]); + firefox.arg(shared.url.as_str()); + let _handle = firefox.spawn().context("Failed to run firefox")?; + } + SelectCommand::File { .. } => unreachable!("This should have been filtered out"), + } + Ok(()) +} diff --git a/src/select/mod.rs b/src/select/mod.rs new file mode 100644 index 0000000..6774ce6 --- /dev/null +++ b/src/select/mod.rs @@ -0,0 +1,184 @@ +// 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::{ + env::{self}, + fs, + io::{BufRead, Write}, + io::{BufReader, BufWriter}, +}; + +use crate::{ + app::App, + cli::CliArgs, + constants::{last_select, HELP_STR}, + storage::video_database::{getters::get_videos, VideoStatus}, +}; + +use anyhow::{bail, Context, Result}; +use clap::Parser; +use cmds::handle_select_cmd; +use futures::future::join_all; +use selection_file::process_line; +use tempfile::Builder; +use tokio::process::Command; + +pub mod cmds; +pub mod selection_file; + +pub async fn select(app: &App, done: bool) -> Result<()> { + let matching_videos = if done { + get_videos( + app, + &[ + VideoStatus::Pick, + // + VideoStatus::Watch, + VideoStatus::Cached, + VideoStatus::Watched, + // + VideoStatus::Drop, + VideoStatus::Dropped, + ], + None, + ) + .await? + } else { + get_videos( + app, + &[ + VideoStatus::Pick, + // + VideoStatus::Watch, + VideoStatus::Cached, + ], + None, + ) + .await? + }; + + // Warmup the cache for the display rendering of the videos. + // Otherwise the futures would all try to warm it up at the same time. + if let Some(vid) = matching_videos.get(0) { + let _ = vid.to_select_file_display(app).await?; + } + + let lines: Vec<String> = join_all( + matching_videos + .iter() + .map(|vid| async { vid.to_select_file_display(app).await }) + .collect::<Vec<_>>(), + ) + .await + .into_iter() + .collect::<Result<Vec<String>>>()?; + + let temp_file = Builder::new() + .prefix("yt_video_select-") + .suffix(".yts") + .rand_bytes(6) + .tempfile() + .context("Failed to get tempfile")?; + + { + let mut edit_file = BufWriter::new(&temp_file); + + lines.iter().for_each(|line| { + edit_file + .write_all(line.as_bytes()) + .expect("This write should not fail"); + }); + + // edit_file.write_all(get_help().await?.as_bytes())?; + edit_file.write_all(HELP_STR.as_bytes())?; + edit_file.flush().context("Failed to flush edit file")?; + + let editor = env::var("EDITOR").unwrap_or("nvim".to_owned()); + + let mut nvim = Command::new(editor); + nvim.arg(temp_file.path()); + let status = nvim.status().await.context("Falied to run nvim")?; + if !status.success() { + bail!("nvim exited with error status: {}", status) + } + } + + let read_file = temp_file.reopen()?; + fs::copy( + temp_file.path(), + last_select().context("Failed to get the persistent selection file path")?, + ) + .context("Failed to persist selection file")?; + + let reader = BufReader::new(&read_file); + + let mut line_number = 0; + for line in reader.lines() { + let line = line.context("Failed to read a line")?; + + if let Some(line) = process_line(&line)? { + line_number -= 1; + + // debug!( + // "Parsed command: `{}`", + // line.iter() + // .map(|val| format!("\"{}\"", val)) + // .collect::<Vec<String>>() + // .join(" ") + // ); + + let arg_line = ["yt", "select"] + .into_iter() + .chain(line.iter().map(|val| val.as_str())); + + let args = CliArgs::parse_from(arg_line); + + let cmd = if let crate::cli::Command::Select { cmd } = + args.command.expect("This will be some") + { + cmd + } else { + unreachable!("This is checked in the `filter_line` function") + }; + + handle_select_cmd( + &app, + cmd.expect("This value should always be some here"), + Some(line_number), + ) + .await? + } + } + + Ok(()) +} + +// // FIXME: There should be no reason why we need to re-run yt, just to get the help string. But I've +// // jet to find a way to do it with out the extra exec <2024-08-20> +// async fn get_help() -> Result<String> { +// let binary_name = current_exe()?; +// let cmd = Command::new(binary_name) +// .args(&["select", "--help"]) +// .output() +// .await?; +// +// assert_eq!(cmd.status.code(), Some(0)); +// +// let output = String::from_utf8(cmd.stdout).expect("Our help output was not utf8?"); +// +// let out = output +// .lines() +// .map(|line| format!("# {}\n", line)) +// .collect::<String>(); +// +// debug!("Returning help: '{}'", &out); +// +// Ok(out) +// } diff --git a/src/select/selection_file/display.rs b/src/select/selection_file/display.rs new file mode 100644 index 0000000..12d128c --- /dev/null +++ b/src/select/selection_file/display.rs @@ -0,0 +1,103 @@ +// 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::fmt::Write; + +use anyhow::Result; +use chrono::DateTime; +use log::debug; + +use crate::{ + app::App, + select::selection_file::duration::Duration, + storage::video_database::{getters::get_video_opts, Video}, +}; + +macro_rules! c { + ($color:expr, $format:expr) => { + format!("\x1b[{}m{}\x1b[0m", $color, $format) + }; +} + +impl Video { + pub async fn to_select_file_display(&self, app: &App) -> Result<String> { + let mut f = String::new(); + + let opts = get_video_opts(app, &self.extractor_hash) + .await? + .to_cli_flags(); + let opts_white = if !opts.is_empty() { " " } else { "" }; + + let publish_date = if let Some(date) = self.publish_date { + DateTime::from_timestamp(date, 0) + .expect("This should not fail") + .format("%Y-%m-%d") + .to_string() + } else { + "[No release date]".to_owned() + }; + + let parent_subscription_name = if let Some(sub) = &self.parent_subscription_name { + sub.replace('"', "'") + } else { + "[No author]".to_owned() + }; + + debug!("Formatting video for selection file: {}", self.title); + write!( + f, + r#"{}{}{} {} "{}" "{}" "{}" "{}" "{}"{}"#, + self.status.as_command(), + opts_white, + opts, + self.extractor_hash.into_short_hash(app).await?, + self.title.replace(['"', '„', '”'], "'"), + publish_date, + parent_subscription_name, + Duration::from(self.duration), + self.url.as_str().replace('"', "\\\""), + "\n" + )?; + + Ok(f) + } + + pub fn to_color_display(&self) -> String { + let mut f = String::new(); + + let publish_date = if let Some(date) = self.publish_date { + DateTime::from_timestamp(date, 0) + .expect("This should not fail") + .format("%Y-%m-%d") + .to_string() + } else { + "[No release date]".to_owned() + }; + + let parent_subscription_name = if let Some(sub) = &self.parent_subscription_name { + sub.replace('"', "'") + } else { + "[No author]".to_owned() + }; + + write!( + f, + r#"{} {} {} {} {}"#, + c!("31;1", self.status.as_command()), + c!("32;1", self.title.replace(['"', '„', '”'], "'")), + c!("37;1", publish_date), + c!("34;1", parent_subscription_name), + c!("35;1", Duration::from(self.duration)), + ) + .expect("This write should always work"); + + f + } +} diff --git a/src/select/selection_file/duration.rs b/src/select/selection_file/duration.rs new file mode 100644 index 0000000..4224ead --- /dev/null +++ b/src/select/selection_file/duration.rs @@ -0,0 +1,102 @@ +// 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::str::FromStr; + +use anyhow::{Context, Result}; + +#[derive(Copy, Clone, Debug)] +pub struct Duration { + time: u32, +} + +impl FromStr for Duration { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + fn parse_num(str: &str, suffix: char) -> Result<u32> { + str.strip_suffix(suffix) + .expect("it has a 'h' suffix") + .parse::<u32>() + .context("Failed to parse hours") + } + + let buf: Vec<_> = s.split(' ').collect(); + + let hours; + let minutes; + let seconds; + + assert_eq!(buf.len(), 2, "Other lengths should not happen"); + + if buf[0].ends_with('h') { + hours = parse_num(buf[0], 'h')?; + minutes = parse_num(buf[1], 'm')?; + seconds = 0; + } else if buf[0].ends_with('m') { + hours = 0; + minutes = parse_num(buf[0], 'm')?; + seconds = parse_num(buf[1], 's')?; + } else { + unreachable!("The first part always ends with 'h' or 'm'") + } + + Ok(Self { + time: (hours * 60 * 60) + (minutes * 60) + seconds, + }) + } +} + +impl From<Option<f64>> for Duration { + fn from(value: Option<f64>) -> Self { + Self { + time: value.unwrap_or(0.0).ceil() as u32, + } + } +} + +impl std::fmt::Display for Duration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + const SECOND: u32 = 1; + const MINUTE: u32 = 60 * SECOND; + const HOUR: u32 = 60 * MINUTE; + + let base_hour = self.time - (self.time % HOUR); + let base_min = (self.time % HOUR) - ((self.time % HOUR) % MINUTE); + let base_sec = (self.time % HOUR) % MINUTE; + + let h = base_hour / HOUR; + let m = base_min / MINUTE; + let s = base_sec / SECOND; + + if self.time == 0 { + write!(f, "[No Duration]") + } else if h > 0 { + write!(f, "{h}h {m}m") + } else { + write!(f, "{m}m {s}s") + } + } +} +#[cfg(test)] +mod test { + use super::Duration; + + #[test] + fn test_display_duration_1h() { + let dur = Duration { time: 60 * 60 }; + assert_eq!("1h 0m".to_owned(), dur.to_string()); + } + #[test] + fn test_display_duration_30min() { + let dur = Duration { time: 60 * 30 }; + assert_eq!("30m 0s".to_owned(), dur.to_string()); + } +} diff --git a/src/select/selection_file/help.str b/src/select/selection_file/help.str new file mode 100644 index 0000000..6e296f6 --- /dev/null +++ b/src/select/selection_file/help.str @@ -0,0 +1,10 @@ +# Commands: +# w, watch [-p,-s,-l] Mark the video given by the hash to be watched +# d, drop Mark the video given by the hash to be dropped +# u, url Open the video URL in Firefox's `timesinks.youtube` profile +# p, pick Reset the videos status to 'Pick' +# +# See `yt select <cmd_name> --help` for more help. +# +# These lines can be re-ordered; they are executed from top to bottom. +# vim: filetype=yts conceallevel=2 concealcursor=nc colorcolumn= diff --git a/src/select/selection_file/help.str.license b/src/select/selection_file/help.str.license new file mode 100644 index 0000000..d4d410f --- /dev/null +++ b/src/select/selection_file/help.str.license @@ -0,0 +1,9 @@ +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>. diff --git a/src/select/selection_file/mod.rs b/src/select/selection_file/mod.rs new file mode 100644 index 0000000..bdb0866 --- /dev/null +++ b/src/select/selection_file/mod.rs @@ -0,0 +1,35 @@ +// 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>. + +//! The data structures needed to express the file, which the user edits + +use anyhow::{Context, Result}; +use trinitry::Trinitry; + +pub mod display; +pub mod duration; + +pub fn process_line(line: &str) -> Result<Option<Vec<String>>> { + // Filter out comments and empty lines + if line.starts_with('#') || line.trim().is_empty() { + Ok(None) + } else { + // pick 2195db "CouchRecherche? Gunnar und Han von STRG_F sind #mitfunkzuhause" "2020-04-01" "STRG_F - Live" "[1h 5m]" "https://www.youtube.com/watch?v=C8UXOaoMrXY" + + let tri = + Trinitry::new(line).with_context(|| format!("Failed to parse line '{}'", line))?; + + let mut vec = Vec::with_capacity(tri.arguments().len() + 1); + vec.push(tri.command().to_owned()); + vec.extend(tri.arguments().to_vec().into_iter()); + + Ok(Some(vec)) + } +} |