aboutsummaryrefslogtreecommitdiffstats
path: root/sys/nixpkgs/pkgs/yt/src
diff options
context:
space:
mode:
Diffstat (limited to 'sys/nixpkgs/pkgs/yt/src')
-rw-r--r--sys/nixpkgs/pkgs/yt/src/bin/yt/main.rs110
-rw-r--r--sys/nixpkgs/pkgs/yt/src/bin/ytc/args.rs26
-rw-r--r--sys/nixpkgs/pkgs/yt/src/bin/ytc/main.rs75
-rw-r--r--sys/nixpkgs/pkgs/yt/src/bin/yts/args.rs41
-rw-r--r--sys/nixpkgs/pkgs/yt/src/bin/yts/main.rs109
-rw-r--r--sys/nixpkgs/pkgs/yt/src/constants.rs36
-rw-r--r--sys/nixpkgs/pkgs/yt/src/downloader.rs197
-rw-r--r--sys/nixpkgs/pkgs/yt/src/help.str7
-rw-r--r--sys/nixpkgs/pkgs/yt/src/lib.rs140
9 files changed, 741 insertions, 0 deletions
diff --git a/sys/nixpkgs/pkgs/yt/src/bin/yt/main.rs b/sys/nixpkgs/pkgs/yt/src/bin/yt/main.rs
new file mode 100644
index 00000000..54d89daa
--- /dev/null
+++ b/sys/nixpkgs/pkgs/yt/src/bin/yt/main.rs
@@ -0,0 +1,110 @@
+use anyhow::{bail, Context, Result};
+use std::{
+ env,
+ io::{BufRead, BufReader, BufWriter, Write},
+ process::Command as StdCmd,
+};
+use tempfile::NamedTempFile;
+use yt::{
+ constants::HELP_STR,
+ downloader::{Downloadable, Downloader},
+ ytcc_drop, Duration, Line, LineCommand, YtccListData,
+};
+
+fn main() -> Result<()> {
+ cli_log::init_cli_log!();
+
+ let json_map = {
+ let mut ytcc = StdCmd::new("ytcc");
+ ytcc.args([
+ "--output",
+ "json",
+ "list",
+ "--order-by",
+ "publish_date",
+ "desc",
+ ]);
+
+ serde_json::from_slice::<Vec<YtccListData>>(
+ &ytcc.output().context("Failed to json from ytcc")?.stdout,
+ )
+ .context("Failed to deserialize json output")?
+ };
+
+ let temp_file = NamedTempFile::new().context("Failed to get tempfile")?;
+ let mut edit_file = BufWriter::new(&temp_file);
+
+ json_map
+ .iter()
+ .map(|line| {
+ format!(
+ r#"pick {} "{}" "{}" "{}" "{}" "{}"{}"#,
+ line.id,
+ line.title.replace('"', "'"),
+ line.publish_date,
+ line.playlists
+ .iter()
+ .map(|p| p.name.replace('"', "'"))
+ .collect::<Vec<String>>()
+ .join(", "),
+ Duration::from(line.duration.trim()),
+ line.url.replace('"', "'"),
+ "\n"
+ )
+ })
+ .for_each(|line| {
+ edit_file
+ .write(line.as_bytes())
+ .expect("This write should not fail");
+ });
+
+ edit_file.write(HELP_STR.as_bytes())?;
+ edit_file.flush().context("Failed to flush edit file")?;
+
+ let read_file = temp_file.reopen()?;
+
+ let mut nvim = StdCmd::new("nvim");
+ nvim.arg(temp_file.path());
+
+ let status = nvim.status().context("Falied to run nvim")?;
+ if !status.success() {
+ bail!("nvim exited with error status: {}", status)
+ }
+
+ let mut watching = Vec::new();
+ let reader = BufReader::new(&read_file);
+ for line in reader.lines() {
+ let line = line.context("Failed to read line")?;
+
+ if line.starts_with("#") {
+ // comment
+ continue;
+ } else if line.trim().len() == 0 {
+ // empty line
+ continue;
+ }
+
+ let line = Line::from(line.as_str());
+ match line.cmd {
+ LineCommand::Pick => (),
+ LineCommand::Drop => {
+ ytcc_drop(line.id).with_context(|| format!("Failed to drop: {}", line.id))?
+ }
+ LineCommand::Watch => watching.push(Downloadable {
+ id: Some(line.id),
+ url: line.url,
+ }),
+ }
+ }
+
+ if watching.len() == 0 {
+ return Ok(());
+ }
+
+ let downloader = Downloader::new(watching).context("Failed to construct downloader")?;
+ downloader
+ .consume()
+ .context("Failed to consume downloader")?;
+
+ Ok(())
+}
diff --git a/sys/nixpkgs/pkgs/yt/src/bin/ytc/args.rs b/sys/nixpkgs/pkgs/yt/src/bin/ytc/args.rs
new file mode 100644
index 00000000..8b2d6a61
--- /dev/null
+++ b/sys/nixpkgs/pkgs/yt/src/bin/ytc/args.rs
@@ -0,0 +1,26 @@
+use clap::{Parser, Subcommand};
+/// A helper for downloading and playing youtube videos
+#[derive(Parser, Debug)]
+#[clap(author, version, about, long_about = None)]
+pub struct Args {
+ #[command(subcommand)]
+ /// The subcommand to execute
+ pub subcommand: Command,
+}
+#[derive(Subcommand, Debug)]
+pub enum Command {
+ #[clap(value_parser)]
+ /// Work based of ytcc ids
+ Id {
+ #[clap(value_parser)]
+ /// A list of ids to play
+ ids: Vec<u32>,
+ },
+ #[clap(value_parser)]
+ /// Work based of raw youtube urls
+ Url {
+ #[clap(value_parser)]
+ /// A list of urls to play
+ urls: Vec<String>,
+ },
+}
diff --git a/sys/nixpkgs/pkgs/yt/src/bin/ytc/main.rs b/sys/nixpkgs/pkgs/yt/src/bin/ytc/main.rs
new file mode 100644
index 00000000..437df803
--- /dev/null
+++ b/sys/nixpkgs/pkgs/yt/src/bin/ytc/main.rs
@@ -0,0 +1,75 @@
+use std::{env, process::Command as StdCmd};
+
+use anyhow::{bail, Context, Result};
+use clap::Parser;
+use log::debug;
+use url::Url;
+use yt::{
+ downloader::{Downloadable, Downloader},
+ YtccListData,
+};
+
+use crate::args::{Args, Command};
+
+mod args;
+
+fn main() -> Result<()> {
+ let args = Args::parse();
+ cli_log::init_cli_log!();
+
+ let playspec: Vec<Downloadable> = match args.subcommand {
+ Command::Id { ids } => {
+ let mut output = Vec::with_capacity(ids.len());
+ for id in ids {
+ debug!("Adding {}", id);
+ let mut ytcc = StdCmd::new("ytcc");
+ ytcc.args([
+ "--output",
+ "json",
+ "list",
+ "--attributes",
+ "url",
+ "--ids",
+ id.to_string().as_str(),
+ ]);
+ let json = serde_json::from_slice::<Vec<YtccListData>>(
+ &ytcc.output().context("Failed to get url from id")?.stdout,
+ )
+ .context("Failed to deserialize json output")?;
+
+ if json.len() == 0 {
+ bail!("Could not find a video with id: {}", id);
+ }
+ assert_eq!(json.len(), 1);
+ let json = json.first().expect("Has only one element");
+
+ debug!("Id resolved to: '{}'", &json.url);
+
+ output.push(Downloadable {
+ url: Url::parse(&json.url)?,
+ id: Some(json.id),
+ })
+ }
+ output
+ }
+ Command::Url { urls } => {
+ let mut output = Vec::with_capacity(urls.len());
+ for url in urls {
+ output.push(Downloadable {
+ url: Url::parse(&url).context("Failed to parse url")?,
+ id: None,
+ })
+ }
+ output
+ }
+ };
+
+ debug!("Initializing downloader");
+ let downloader = Downloader::new(playspec)?;
+
+ downloader
+ .consume()
+ .context("Failed to consume downloader")?;
+
+ Ok(())
+}
diff --git a/sys/nixpkgs/pkgs/yt/src/bin/yts/args.rs b/sys/nixpkgs/pkgs/yt/src/bin/yts/args.rs
new file mode 100644
index 00000000..56989421
--- /dev/null
+++ b/sys/nixpkgs/pkgs/yt/src/bin/yts/args.rs
@@ -0,0 +1,41 @@
+use clap::{Parser, Subcommand};
+/// A helper for selecting which videos to download from ytcc to ytc
+#[derive(Parser, Debug)]
+#[clap(author, version, about, long_about = None)]
+pub struct Args {
+ #[command(subcommand)]
+ /// subcommand to execute
+ pub subcommand: Option<Command>,
+}
+
+#[derive(Subcommand, Debug)]
+pub enum Command {
+ #[clap(value_parser)]
+ /// Which ordering to use
+ Order {
+ #[command(subcommand)]
+ command: OrderCommand,
+ },
+}
+
+#[derive(Subcommand, Debug)]
+pub enum OrderCommand {
+ #[clap(value_parser)]
+ /// Order by date
+ #[group(required = true)]
+ Date {
+ #[arg(value_parser)]
+ /// Order descending
+ desc: bool,
+ #[clap(value_parser)]
+ /// Order ascending
+ asc: bool,
+ },
+ #[clap(value_parser)]
+ /// Pass a raw SQL 'ORDER BY' value
+ Raw {
+ #[clap(value_parser)]
+ /// The raw value(s) to pass
+ value: Vec<String>,
+ },
+}
diff --git a/sys/nixpkgs/pkgs/yt/src/bin/yts/main.rs b/sys/nixpkgs/pkgs/yt/src/bin/yts/main.rs
new file mode 100644
index 00000000..788ecab2
--- /dev/null
+++ b/sys/nixpkgs/pkgs/yt/src/bin/yts/main.rs
@@ -0,0 +1,109 @@
+use anyhow::{bail, Context, Result};
+use clap::Parser;
+use std::{
+ env,
+ io::{BufRead, BufReader, Write},
+ process::Command as StdCmd,
+};
+use tempfile::NamedTempFile;
+use yt::{constants::HELP_STR, ytcc_drop, Line, LineCommand, YtccListData};
+
+use crate::args::{Args, Command, OrderCommand};
+
+mod args;
+
+fn main() -> Result<()> {
+ let args = Args::parse();
+ cli_log::init_cli_log!();
+
+ let ordering = match args.subcommand.unwrap_or(Command::Order {
+ command: OrderCommand::Date {
+ desc: true,
+ asc: false,
+ },
+ }) {
+ Command::Order { command } => match command {
+ OrderCommand::Date { desc, asc } => {
+ if desc {
+ vec!["--order-by".into(), "publish_date".into(), "desc".into()]
+ } else if asc {
+ vec!["--order-by".into(), "publish_date".into(), "asc".into()]
+ } else {
+ vec!["--order-by".into(), "publish_date".into(), "desc".into()]
+ }
+ }
+ OrderCommand::Raw { value } => [vec!["--order-by".into()], value].concat(),
+ },
+ };
+
+ let json_map = {
+ let mut ytcc = StdCmd::new("ytcc");
+ ytcc.args(["--output", "json", "list"]);
+ ytcc.args(ordering);
+
+ serde_json::from_slice::<Vec<YtccListData>>(
+ &ytcc.output().context("Failed to json from ytcc")?.stdout,
+ )
+ .context("Failed to deserialize json output")?
+ };
+
+ let mut edit_file = NamedTempFile::new().context("Failed to get tempfile")?;
+
+ let file: String = json_map
+ .iter()
+ .map(|line| {
+ format!(
+ "pick {} \"{}\" <{}> [{}]\n",
+ line.id,
+ line.title,
+ line.playlists
+ .iter()
+ .map(|p| &p.name[..])
+ .collect::<Vec<&str>>()
+ .join(", "),
+ line.duration.trim()
+ )
+ })
+ .collect();
+
+ for line in file.lines() {
+ writeln!(&edit_file, "{}", line)?;
+ }
+ write!(&edit_file, "{}", HELP_STR)?;
+ edit_file.flush().context("Failed to flush edit file")?;
+
+ let read_file = edit_file.reopen()?;
+
+ let mut nvim = StdCmd::new("nvim");
+ nvim.arg(edit_file.path());
+
+ let status = nvim.status().context("Falied to run nvim")?;
+ if !status.success() {
+ bail!("Nvim exited with error status: {}", status)
+ }
+
+ let mut watching = Vec::new();
+ let reader = BufReader::new(&read_file);
+ for line in reader.lines() {
+ let line = line.context("Failed to read line")?;
+
+ if line.starts_with("#") {
+ continue;
+ } else if line.trim().len() == 0 {
+ // empty line
+ continue;
+ }
+
+ let line = Line::from(line.as_str());
+ match line.cmd {
+ LineCommand::Pick => (),
+ LineCommand::Drop => {
+ ytcc_drop(line.id).with_context(|| format!("Failed to drop: {}", line.id))?
+ }
+ LineCommand::Watch => watching.push(line.id),
+ }
+ }
+
+ dbg!(&watching);
+ Ok(())
+}
diff --git a/sys/nixpkgs/pkgs/yt/src/constants.rs b/sys/nixpkgs/pkgs/yt/src/constants.rs
new file mode 100644
index 00000000..23e1d9b9
--- /dev/null
+++ b/sys/nixpkgs/pkgs/yt/src/constants.rs
@@ -0,0 +1,36 @@
+use std::{env, fs, path::PathBuf};
+
+pub const HELP_STR: &'static str = include_str!("./help.str");
+
+pub const YT_DLP_FLAGS: [&str; 12] = [
+ "--format",
+ "bestvideo[height<=?1080]+bestaudio/best",
+ "--embed-chapters",
+ "--progress",
+ "--write-comments",
+ "--extractor-args",
+ "youtube:max_comments=150,all,100;comment_sort=top",
+ "--write-info-json",
+ "--sponsorblock-mark",
+ "default",
+ "--sponsorblock-remove",
+ "sponsor",
+];
+pub const MPV_FLAGS: [&str; 2] = ["--speed=2.7", "--volume=75"];
+
+pub const CONCURRENT: u32 = 5;
+
+pub const DOWNLOAD_DIR: &str = "/tmp/ytcc";
+
+const STATUS_PATH: &str = "ytcc/running";
+
+pub fn status_path() -> anyhow::Result<PathBuf> {
+ let out: PathBuf = format!(
+ "{}/{}",
+ env::var("XDG_RUNTIME_DIR").expect("This should always exist"),
+ STATUS_PATH
+ )
+ .into();
+ fs::create_dir_all(&out.parent().expect("Parent should exist"))?;
+ Ok(out)
+}
diff --git a/sys/nixpkgs/pkgs/yt/src/downloader.rs b/sys/nixpkgs/pkgs/yt/src/downloader.rs
new file mode 100644
index 00000000..1733500a
--- /dev/null
+++ b/sys/nixpkgs/pkgs/yt/src/downloader.rs
@@ -0,0 +1,197 @@
+use std::{
+ fs::{self, canonicalize},
+ io::{stderr, stdout, Read},
+ mem,
+ os::unix::fs::symlink,
+ path::PathBuf,
+ process::Command,
+ sync::mpsc::{self, Receiver, Sender},
+ thread::{self, JoinHandle},
+};
+
+use anyhow::{bail, Context, Result};
+use log::{debug, warn};
+use url::Url;
+
+use crate::constants::{status_path, CONCURRENT, DOWNLOAD_DIR, MPV_FLAGS, YT_DLP_FLAGS};
+
+#[derive(Debug)]
+pub struct Downloadable {
+ pub url: Url,
+ pub id: Option<u32>,
+}
+
+pub struct Downloader {
+ sent: usize,
+ download_thread: JoinHandle<Result<()>>,
+ orx: Receiver<(PathBuf, Option<u32>)>,
+ itx: Option<Sender<Downloadable>>,
+ playspec: Vec<Downloadable>,
+}
+
+impl Downloader {
+ pub fn new(mut playspec: Vec<Downloadable>) -> anyhow::Result<Downloader> {
+ let (itx, irx): (Sender<Downloadable>, Receiver<Downloadable>) = mpsc::channel();
+ let (otx, orx) = mpsc::channel();
+ let jh = thread::spawn(move || -> Result<()> {
+ while let Some(pt) = irx.recv().ok() {
+ debug!("Got '{}|{}' to be downloaded", pt.url, pt.id.unwrap_or(0));
+ let path = download_url(&pt.url)
+ .with_context(|| format!("Failed to download url: '{}'", &pt.url))?;
+ otx.send((path, pt.id)).expect("Should not be dropped");
+ }
+ debug!("Finished Downloading everything");
+ Ok(())
+ });
+
+ playspec.reverse();
+ let mut output = Downloader {
+ sent: 0,
+ download_thread: jh,
+ orx,
+ itx: Some(itx),
+ playspec,
+ };
+ if output.playspec.len() <= CONCURRENT as usize {
+ output.add(output.playspec.len() as u32)?;
+ } else {
+ output.add(CONCURRENT)?;
+ }
+ Ok(output)
+ }
+
+ pub fn add(&mut self, number_to_add: u32) -> Result<()> {
+ debug!("Adding {} to be downloaded concurrently", number_to_add);
+ for _ in 0..number_to_add {
+ let pt = self.playspec.pop().context("No more playthings to pop")?;
+ self.itx.as_ref().expect("Should still be valid").send(pt)?;
+ }
+ Ok(())
+ }
+
+ /// Return the next video already downloaded, will block until the download is complete
+ pub fn next(&mut self) -> Option<(PathBuf, Option<u32>)> {
+ debug!("Requesting next output");
+ match self.orx.recv() {
+ Ok(ok) => {
+ debug!("Output downloaded to: {}", ok.0.display());
+ self.sent += 1;
+ if self.sent < self.playspec.len() {
+ debug!("Will add 1");
+ self.add(1).ok()?;
+ } else {
+ debug!(
+ "Done sending videos to be downloaded, downoladed: {} videos",
+ self.sent
+ );
+ let itx = mem::take(&mut self.itx);
+ drop(itx)
+ }
+ debug!("Returning: {}|{}", ok.0.display(), ok.1.unwrap_or(0));
+ Some(ok)
+ }
+ Err(err) => {
+ debug!("Received error while listening: {}", err);
+ None
+ }
+ }
+ }
+ pub fn drop(self) -> anyhow::Result<()> {
+ match self.download_thread.join() {
+ Ok(ok) => ok,
+ Err(err) => panic!("Can't join thread: '{:#?}'", err),
+ }
+ }
+
+ pub fn consume(mut self) -> anyhow::Result<()> {
+ while let Some((path, id)) = self.next() {
+ debug!("Next path to play is: '{}'", path.display());
+ let mut info_json = canonicalize(&path).context("Failed to canoncialize path")?;
+ info_json.set_extension("info.json");
+
+ if status_path()?.is_symlink() {
+ fs::remove_file(status_path()?).context("Failed to delete old status file")?;
+ } else if !status_path()?.exists() {
+ debug!(
+ "The status path at '{}' does not exists",
+ status_path()?.display()
+ );
+ } else {
+ bail!(
+ "The status path ('{}') is not a symlink but exists!",
+ status_path()?.display()
+ );
+ }
+
+ symlink(info_json, status_path()?).context("Failed to symlink")?;
+
+ let mut mpv = Command::new("mpv");
+ mpv.stdout(stdout());
+ mpv.stderr(stderr());
+ mpv.args(MPV_FLAGS);
+ mpv.arg(&path);
+
+ let status = mpv.status().context("Failed to run mpv")?;
+ if status.success() {
+ fs::remove_file(&path)?;
+ if let Some(id) = id {
+ println!("\x1b[32;1mMarking {} as watched!\x1b[0m", id);
+ let mut ytcc = std::process::Command::new("ytcc");
+ ytcc.stdout(stdout());
+ ytcc.stderr(stderr());
+ ytcc.args(["mark"]);
+ ytcc.arg(id.to_string());
+ let status = ytcc.status().context("Failed to run ytcc")?;
+ if let Some(code) = status.code() {
+ if code != 0 {
+ bail!("Ytcc failed with status: {}", code);
+ }
+ }
+ }
+ debug!("mpv exited with: '{}'", status);
+ } else {
+ warn!("mpv exited with: '{}'", status);
+ }
+ }
+ self.drop()?;
+ Ok(())
+ }
+}
+
+fn download_url(url: &Url) -> Result<PathBuf> {
+ let output_file = tempfile::NamedTempFile::new().context("Failed to create tempfile")?;
+ output_file
+ .as_file()
+ .set_len(0)
+ .context("Failed to truncate temp-file")?;
+ if !Into::<PathBuf>::into(DOWNLOAD_DIR).exists() {
+ fs::create_dir_all(DOWNLOAD_DIR)
+ .with_context(|| format!("Failed to create download dir at: {}", DOWNLOAD_DIR))?
+ }
+ let mut yt_dlp = Command::new("yt-dlp");
+ yt_dlp.current_dir(DOWNLOAD_DIR);
+ yt_dlp.stdout(stdout());
+ yt_dlp.stderr(stderr());
+ yt_dlp.args(YT_DLP_FLAGS);
+ yt_dlp.args([
+ "--output",
+ "%(channel)s/%(title)s.%(ext)s",
+ url.as_str(),
+ "--print-to-file",
+ "after_move:filepath",
+ ]);
+ yt_dlp.arg(output_file.path().as_os_str());
+
+ let status = yt_dlp.status().context("Failed to run yt-dlp")?;
+ if !status.success() {
+ bail!("yt_dlp execution failed with error: '{}'", status);
+ }
+
+ let mut path = String::new();
+ output_file
+ .as_file()
+ .read_to_string(&mut path)
+ .context("Failed to read output file temp file")?;
+ let path = path.trim();
+ Ok(path.into())
+}
diff --git a/sys/nixpkgs/pkgs/yt/src/help.str b/sys/nixpkgs/pkgs/yt/src/help.str
new file mode 100644
index 00000000..e5b21fce
--- /dev/null
+++ b/sys/nixpkgs/pkgs/yt/src/help.str
@@ -0,0 +1,7 @@
+# Commands:
+# w, watch <id> = watch id
+# d, drop <id> = mark id as watched
+# p, pick <id> = leave id as is; This is a noop
+#
+# These lines can be re-ordered; they are executed from top to bottom.
+# vim: filetype=yts conceallevel=2 concealcursor=nc colorcolumn=
diff --git a/sys/nixpkgs/pkgs/yt/src/lib.rs b/sys/nixpkgs/pkgs/yt/src/lib.rs
new file mode 100644
index 00000000..a08b32db
--- /dev/null
+++ b/sys/nixpkgs/pkgs/yt/src/lib.rs
@@ -0,0 +1,140 @@
+use anyhow::{bail, Context};
+use serde::Deserialize;
+use url::Url;
+
+pub mod constants;
+pub mod downloader;
+
+#[derive(Deserialize)]
+pub struct YtccListData {
+ pub url: String,
+ pub title: String,
+ pub description: String,
+ pub publish_date: String,
+ pub watch_date: Option<String>,
+ pub duration: String,
+ pub thumbnail_url: String,
+ pub extractor_hash: String,
+ pub id: u32,
+ pub playlists: Vec<YtccPlaylistData>,
+}
+#[derive(Deserialize)]
+pub struct YtccPlaylistData {
+ pub name: String,
+ pub url: String,
+ pub reverse: bool,
+}
+
+pub enum LineCommand {
+ Pick,
+ Drop,
+ Watch,
+}
+
+impl std::str::FromStr for LineCommand {
+ type Err = anyhow::Error;
+ fn from_str(v: &str) -> Result<Self, <Self as std::str::FromStr>::Err> {
+ match v {
+ "pick" | "p" => Ok(Self::Pick),
+ "drop" | "d" => Ok(Self::Drop),
+ "watch" | "w" => Ok(Self::Watch),
+ other => bail!("'{}' is not a recognized command!", other),
+ }
+ }
+}
+
+pub struct Line {
+ pub cmd: LineCommand,
+ pub id: u32,
+ pub url: Url,
+}
+
+/// We expect that each line is correctly formatted, and simply use default ones if they are not
+impl From<&str> for Line {
+ fn from(v: &str) -> Self {
+ let buf: Vec<_> = v.split_whitespace().collect();
+ let url: Url = Url::parse(
+ buf.last()
+ .expect("This should always exists")
+ .trim_matches('"'),
+ )
+ .expect("This parsing should work,as the url is generated");
+
+ Line {
+ cmd: buf
+ .get(0)
+ .unwrap_or(&"pick")
+ .parse()
+ .unwrap_or(LineCommand::Pick),
+ id: buf.get(1).unwrap_or(&"0").parse().unwrap_or(0),
+ url,
+ }
+ }
+}
+
+pub struct Duration {
+ time: u32,
+}
+
+impl From<&str> for Duration {
+ fn from(v: &str) -> Self {
+ let buf: Vec<_> = v.split(':').take(2).collect();
+ Self {
+ time: (buf[0]
+ .parse::<u32>()
+ .expect("Should be a number")
+ * 60)
+ + buf[1]
+ .parse::<u32>()
+ .expect("Should be a number"),
+ }
+ }
+}
+
+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) - (((self.time % HOUR) % MINUTE) % SECOND);
+
+ 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 crate::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());
+ }
+}
+
+pub fn ytcc_drop(id: u32) -> anyhow::Result<()> {
+ let mut ytcc = std::process::Command::new("ytcc");
+ ytcc.args(["mark", &format!("{}", id)]);
+ if !ytcc.status().context("Failed to run ytcc")?.success() {
+ bail!("`ytcc mark {}` failed to execute", id)
+ }
+ Ok(())
+}