use anyhow::{bail, Context}; use downloader::Downloadable; 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, pub duration: String, pub thumbnail_url: Option, pub extractor_hash: String, pub id: u32, pub playlists: Vec, } impl std::fmt::Display for YtccListData { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { write!( f, r#"pick {} "{}" "{}" "{}" "{}" "{}"{}"#, self.id, self.title.replace(['"', '„', '”'], "'"), self.publish_date, self.playlists .iter() .map(|p| p.name.replace('"', "'")) .collect::>() .join(", "), Duration::from(self.duration.trim()), self.url.replace('"', "'"), "\n" ) } } #[derive(Deserialize)] pub struct YtccPlaylistData { pub name: String, pub url: String, pub reverse: bool, } pub enum LineCommand { Pick, Drop, Watch, Url, } impl std::str::FromStr for LineCommand { type Err = anyhow::Error; fn from_str(v: &str) -> Result::Err> { match v { "pick" | "p" => Ok(Self::Pick), "drop" | "d" => Ok(Self::Drop), "watch" | "w" => Ok(Self::Watch), "url" | "u" => Ok(Self::Url), 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::().expect("Should be a number") * 60) + buf[1].parse::().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; 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(()) } pub fn filter_line(line: &str) -> anyhow::Result> { // Filter out comments and empty lines if line.starts_with('#') || line.trim().is_empty() { return Ok(None); } let line = Line::from(line); match line.cmd { LineCommand::Pick => Ok(None), LineCommand::Drop => ytcc_drop(line.id) .with_context(|| format!("Failed to drop: {}", line.id)) .map(|_| None), LineCommand::Watch => Ok(Some(Downloadable { id: Some(line.id), url: line.url, })), LineCommand::Url => { let mut firefox = std::process::Command::new("firefox"); firefox.args(["-P", "timesinks.youtube"]); firefox.arg(line.url.as_str()); let _handle = firefox.spawn().context("Failed to run firefox")?; Ok(None) } } }