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<f64>,
pub duration: String,
pub thumbnail_url: String,
pub extractor_hash: String,
pub id: u32,
pub playlists: Vec<YtccPlaylistData>,
}
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::<Vec<String>>()
.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<Self, <Self as std::str::FromStr>::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::<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;
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<Option<Downloadable>> {
// 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)
}
}
}