diff --git a/src/config.rs b/src/config.rs index 27c60bc..9f06828 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,17 +1,35 @@ -use std::{collections::HashMap, env, fs, path::PathBuf}; - -pub type Shows = HashMap; +use std::{env::{self, VarError}, path::PathBuf, str::FromStr, fmt::Debug, time::Duration}; pub struct Config { - pub shows: Shows, + pub shows_file: PathBuf, + pub global_tags: Vec, + pub post_interval: Duration, + pub cohost_email: String, + pub cohost_password: String, + pub cohost_page: String, +} + +const VAR_PREFIX: &'static str = "SCREENCAP_BOT_"; + +fn get_var(name: &str) -> Result { + env::var(VAR_PREFIX.to_string() + name) +} + +fn parse_var>(name: &str) -> Result { + get_var(name).map(|s| s.parse().expect(&format!("Failed to parse {}{}", VAR_PREFIX, name))) +} + +fn expect_var(name: &str) -> String { + get_var(name).expect(&format!("{}{} must be set", VAR_PREFIX, name)) } pub fn load() -> Config { - let shows_file = env::var("SCREENCAP_BOT_SHOWS_FILE").unwrap_or(String::from("./shows.yaml")); Config { - shows: serde_yaml::from_reader( - fs::File::open(shows_file).expect("Failed to open shows file"), - ) - .expect("Failed to parse YAML from shows file"), + shows_file: parse_var("SHOWS_FILE").unwrap_or(PathBuf::from("./shows.yaml")), + global_tags: get_var("GLOBAL_TAGS").map(|s| s.split(",").map(String::from).collect()).unwrap_or_default(), + post_interval: Duration::from_secs(parse_var("POST_INTERVAL").unwrap_or(6*3600)), + cohost_email: expect_var("COHOST_EMAIL"), + cohost_password: expect_var("COHOST_PASSWORD"), + cohost_page: expect_var("COHOST_PAGE"), } } diff --git a/src/main.rs b/src/main.rs index 8d62b99..1b794a4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,34 +1,36 @@ -use std::path::Path; - -use enumeration::enumerate_shows; use log::{debug, error, info}; use rand::{distributions::Standard, seq::IteratorRandom, Rng}; mod config; -mod enumeration; +mod shows; mod video; #[tokio::main] -async fn main() -> std::io::Result<()> { +async fn main() { + dotenvy::dotenv().ok(); env_logger::init(); ffmpeg_next::init().unwrap(); - dotenvy::dotenv().ok(); let mut rng = rand::thread_rng(); let conf = config::load(); - let enumerated_shows = enumerate_shows(conf.shows); + + info!("Loading shows from {}", conf.shows_file.display()); + let shows = shows::load(conf.shows_file); + + info!("Logging into cohost as {}", conf.cohost_email); + let session = eggbug::Session::login(&conf.cohost_email, &conf.cohost_password).await.expect("Failed to login to cohost"); loop { - let (show, episodes) = enumerated_shows + let (title, show) = shows .iter() .choose(&mut rng) .expect("No shows found!"); - let (num, file) = episodes.iter().choose(&mut rng).unwrap(); + let (num, file) = show.episodes.iter().choose(&mut rng).unwrap(); let descriptor = format!( "{}{}", - show, + title, if let Some(n) = num { format!(" episode {}", n) } else { @@ -51,18 +53,43 @@ async fn main() -> std::io::Result<()> { let timestamp = video_info.duration_secs * rng.sample::(Standard); let formatted_timestamp = format_timestamp(timestamp, Some(video_info.duration_secs)); info!("Taking screencap at {}", formatted_timestamp); - if let Err(e) = video::take_screencap( + + match video::take_screencap( file, timestamp, video_info.subtitle_stream_index, - &Path::new("./out.png"), - ) { - error!("Failed to take screencap: {}", e); + ).await { + Err(e) => { error!("Failed to take screencap: {}", e); } + Ok(img_data) => { + let attachment = eggbug::Attachment::new( + img_data, + format!("{} @{}.png", descriptor, formatted_timestamp), + String::from("image/png"), + ).with_alt_text(format!("Screencap of {} at {}", descriptor, formatted_timestamp)); + + let mut tags = show.tags.clone(); + tags.extend_from_slice(&conf.global_tags); + + match session.create_post( + &conf.cohost_page, + &mut eggbug::Post { + content_warnings: vec![descriptor], + attachments: vec![attachment], + tags, + draft: false, + adult_content: false, + headline: String::new(), + markdown: String::new(), + } + ).await { + Ok(id) => info!("Created post {}", id), + Err(e) => error!("Failed to create post: {}", e) + } + } } - break; + tokio::time::sleep(conf.post_interval).await; } - Ok(()) } fn format_timestamp(timestamp: f64, total_duration: Option) -> String { diff --git a/src/enumeration.rs b/src/shows.rs similarity index 78% rename from src/enumeration.rs rename to src/shows.rs index a66c7f7..30210f0 100644 --- a/src/enumeration.rs +++ b/src/shows.rs @@ -8,35 +8,47 @@ use std::{ use lazy_static::lazy_static; use log::{debug, error, trace, warn}; use regex::Regex; - -use crate::config::Shows; +use serde::Deserialize; lazy_static! { static ref NUMBER_REGEX: Regex = Regex::new("[0-9]+").unwrap(); } +#[derive(Deserialize)] +pub struct ShowSpec { + pub path: PathBuf, + pub tags: Vec, +} + /// [episode number, or None for a standalone movie] -> [video file path] -type Enumeration = HashMap, PathBuf>; +type Episodes = HashMap, PathBuf>; -/// [show name] -> [enumeration] -type EnumeratedShows = HashMap; +pub struct Show { + pub episodes: Episodes, + pub tags: Vec, +} -pub fn enumerate_shows(shows: Shows) -> EnumeratedShows { - shows +pub fn load(shows_file: PathBuf) -> HashMap { + let show_specs: HashMap = serde_yaml::from_reader( + fs::File::open(shows_file).expect("Failed to open shows file"), + ) + .expect("Failed to parse YAML from shows file"); + + show_specs .into_iter() - .filter_map(|(name, path)| { - debug!("Enumerating {} from {}", name, path.display()); - enumerate_show(path) + .filter_map(|(name, show)| { + debug!("Enumerating show {}: {}", name, show.path.display()); + enumerate_show(show.path) .map_err(|e| { error!("Error processing {}: {}", name, e); }) .ok() - .map(|en| (name, en)) + .map(|eps| (name, Show { episodes: eps, tags: show.tags })) }) .collect() } -fn enumerate_show(path: PathBuf) -> std::io::Result { +fn enumerate_show(path: PathBuf) -> std::io::Result { let metadata = fs::metadata(&path)?; if metadata.is_file() { debug!("{} is a file, standalone", path.display()); @@ -52,7 +64,7 @@ fn enumerate_show(path: PathBuf) -> std::io::Result { } } -fn enumerate_dir(path: PathBuf) -> std::io::Result { +fn enumerate_dir(path: PathBuf) -> std::io::Result { let files: Vec<(PathBuf, String)> = fs::read_dir(&path)? .map(|entry| { let entry = entry?; @@ -108,7 +120,7 @@ fn enumerate_dir(path: PathBuf) -> std::io::Result { "No valid prefixes found", ))?; - let mut result: Enumeration = HashMap::new(); + let mut result: Episodes = HashMap::new(); for (num, path) in best_enumeration.into_iter() { debug!("Episode {}: {}", num, path.display()); if let Some(dup) = result.insert(Some(num), path.to_path_buf()) { diff --git a/src/video.rs b/src/video.rs index c2c8123..1330334 100644 --- a/src/video.rs +++ b/src/video.rs @@ -5,8 +5,9 @@ use ffmpeg_next::{ use lazy_static::lazy_static; use regex::Regex; use tempfile::tempdir; +use tokio::{fs, process::Command}; -use std::{ffi::OsStr, io::ErrorKind, os::unix::fs, path::Path, process::Command}; +use std::{io::ErrorKind, path::Path}; lazy_static! { static ref SUBTITLE_FORBID_REGEX: Regex = Regex::new("(?i)sign|song").unwrap(); @@ -60,12 +61,11 @@ pub fn get_video_info>( }) } -pub fn take_screencap, Q: AsRef>( +pub async fn take_screencap>( source: &P, timestamp_secs: f64, subtitle_stream_index: Option, - dest: &Q, -) -> std::io::Result<()> { +) -> std::io::Result> { let ext = source.as_ref().extension().map(|s| s.to_str()).flatten(); if ext != Some("mkv") && ext != Some("mp4") { return Err(std::io::Error::new( @@ -73,9 +73,12 @@ pub fn take_screencap, Q: AsRef>( "unexpected file extension", )); } + let tmp_dir = tempdir()?; - let link_path = tmp_dir.path().join(format!("input.{}", ext.unwrap())); - fs::symlink(source, &link_path)?; + let link_path = tmp_dir.path().join(format!("in.{}", ext.unwrap())); + fs::symlink(source, &link_path).await?; + let dest_path = tmp_dir.path().join("out.png"); + let mut cmd = Command::new("ffmpeg"); cmd.arg("-ss") @@ -95,13 +98,14 @@ pub fn take_screencap, Q: AsRef>( cmd.args(["-vframes", "1"]) .args(["-loglevel", "quiet"]) .arg("-y") - .arg(&dest); + .arg(&dest_path); - if !cmd.status()?.success() { + if !cmd.status().await?.success() { return Err(std::io::Error::new( ErrorKind::Other, "ffmpeg command failed", )); } - Ok(()) + + fs::read(&dest_path).await }