about summary refs log tree commit diff stats
path: root/src/select
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2024-08-21 10:49:23 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2024-08-21 11:28:43 +0200
commit1debeb77f7986de1b659dcfdc442de6415e1d9f5 (patch)
tree4df3e7c3f6a2d1ec116e4088c5ace7f143a8b05f /src/select
downloadyt-1debeb77f7986de1b659dcfdc442de6415e1d9f5.tar.gz
yt-1debeb77f7986de1b659dcfdc442de6415e1d9f5.zip
chore: Initial Commit
This repository was migrated out of my nixos-config.
Diffstat (limited to 'src/select')
-rw-r--r--src/select/cmds.rs82
-rw-r--r--src/select/mod.rs184
-rw-r--r--src/select/selection_file/display.rs103
-rw-r--r--src/select/selection_file/duration.rs102
-rw-r--r--src/select/selection_file/help.str10
-rw-r--r--src/select/selection_file/help.str.license9
-rw-r--r--src/select/selection_file/mod.rs35
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))
+    }
+}