about summary refs log tree commit diff stats
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(())
+}