use std::{ env, fs::{self, canonicalize}, io::{stderr, stdout}, os::unix::fs::symlink, path::PathBuf, process::Command as StdCmd, }; use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; use downloader::Downloader; use log::debug; use serde::Deserialize; mod downloader; const STATUS_PATH: &str = "ytcc/running"; /// 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, }, #[clap(value_parser)] /// Work based of raw youtube urls Url { #[clap(value_parser)] /// A list of urls to play urls: Vec, }, } struct PlayThing { url: String, id: Option, } #[derive(Deserialize)] struct YtccListData { url: String, #[allow(unused)] title: String, #[allow(unused)] description: String, #[allow(unused)] publish_date: String, #[allow(unused)] watch_date: Option, #[allow(unused)] duration: String, #[allow(unused)] thumbnail_url: String, #[allow(unused)] extractor_hash: String, id: u32, #[allow(unused)] playlists: Vec, } #[derive(Deserialize)] struct YtccPlaylistData { #[allow(unused)] name: String, #[allow(unused)] url: String, #[allow(unused)] reverse: bool, } fn main() -> Result<()> { let args = Args::parse(); cli_log::init_cli_log!(); let playspec: Vec = 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::>( &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(PlayThing { url: json.url.clone(), id: Some(json.id), }) } output } Command::Url { urls } => urls .into_iter() .map(|url| PlayThing { url, id: None }) .collect(), }; debug!("Initializing downloader"); let mut downloader = Downloader::new(playspec)?; while let Some((path, id)) = downloader.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 { bail!( "The status path ('{}') is not a symlink!", status_path()?.display() ); } symlink(info_json, status_path()?).context("Failed to symlink")?; let mut mpv = StdCmd::new("mpv"); mpv.stdout(stdout()); mpv.stderr(stderr()); mpv.args(["--speed=2.7", "--volume=75"]); mpv.arg(&path); let status = mpv.status().context("Failed to run mpv")?; if let Some(code) = status.code() { if let Some(id) = id { if code == 0 { println!("\x1b[32;1mMarking {} as watched!\x1b[0m", id); fs::remove_file(&path)?; let mut ytcc = StdCmd::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: {}", code); } } downloader.drop()?; Ok(()) } fn status_path() -> Result { 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) }