diff options
Diffstat (limited to '')
-rw-r--r-- | sys/nixpkgs/pkgs/yt/src/bin/yt/main.rs | 110 | ||||
-rw-r--r-- | sys/nixpkgs/pkgs/yt/src/bin/ytc/args.rs | 26 | ||||
-rw-r--r-- | sys/nixpkgs/pkgs/yt/src/bin/ytc/main.rs | 75 | ||||
-rw-r--r-- | sys/nixpkgs/pkgs/yt/src/bin/yts/args.rs | 41 | ||||
-rw-r--r-- | sys/nixpkgs/pkgs/yt/src/bin/yts/main.rs | 109 | ||||
-rw-r--r-- | sys/nixpkgs/pkgs/yt/src/constants.rs | 36 | ||||
-rw-r--r-- | sys/nixpkgs/pkgs/yt/src/downloader.rs (renamed from sys/nixpkgs/pkgs/ytc/src/downloader.rs) | 112 | ||||
-rw-r--r-- | sys/nixpkgs/pkgs/yt/src/help.str | 7 | ||||
-rw-r--r-- | sys/nixpkgs/pkgs/yt/src/lib.rs | 140 |
9 files changed, 623 insertions, 33 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/ytc/src/downloader.rs b/sys/nixpkgs/pkgs/yt/src/downloader.rs index dddebe05..1733500a 100644 --- a/sys/nixpkgs/pkgs/ytc/src/downloader.rs +++ b/sys/nixpkgs/pkgs/yt/src/downloader.rs @@ -1,7 +1,8 @@ use std::{ - fs, + fs::{self, canonicalize}, io::{stderr, stdout, Read}, mem, + os::unix::fs::symlink, path::PathBuf, process::Command, sync::mpsc::{self, Receiver, Sender}, @@ -9,40 +10,28 @@ use std::{ }; use anyhow::{bail, Context, Result}; -use log::debug; +use log::{debug, warn}; +use url::Url; -use crate::PlayThing; +use crate::constants::{status_path, CONCURRENT, DOWNLOAD_DIR, MPV_FLAGS, YT_DLP_FLAGS}; -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", -]; - -const CONCURRENT: u32 = 5; - -const DOWNLOAD_DIR: &str = "/tmp/ytcc"; +#[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<PlayThing>>, - playspec: Vec<PlayThing>, + itx: Option<Sender<Downloadable>>, + playspec: Vec<Downloadable>, } impl Downloader { - pub fn new(mut playspec: Vec<PlayThing>) -> anyhow::Result<Downloader> { - let (itx, irx): (Sender<PlayThing>, Receiver<PlayThing>) = mpsc::channel(); + 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() { @@ -91,7 +80,10 @@ impl Downloader { debug!("Will add 1"); self.add(1).ok()?; } else { - debug!("Will drop sender"); + debug!( + "Done sending videos to be downloaded, downoladed: {} videos", + self.sent + ); let itx = mem::take(&mut self.itx); drop(itx) } @@ -99,7 +91,7 @@ impl Downloader { Some(ok) } Err(err) => { - debug!("Recieved error while listening: {}", err); + debug!("Received error while listening: {}", err); None } } @@ -110,9 +102,63 @@ impl Downloader { 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: &str) -> Result<PathBuf> { +fn download_url(url: &Url) -> Result<PathBuf> { let output_file = tempfile::NamedTempFile::new().context("Failed to create tempfile")?; output_file .as_file() @@ -130,17 +176,17 @@ fn download_url(url: &str) -> Result<PathBuf> { yt_dlp.args([ "--output", "%(channel)s/%(title)s.%(ext)s", - url, + 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 let Some(code) = status.code() { - if code != 0 { - bail!("yt_dlp execution failed with error: '{}'", status); - } + if !status.success() { + bail!("yt_dlp execution failed with error: '{}'", status); } + let mut path = String::new(); output_file .as_file() 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(()) +} |