Oneshot mode and general improved error handling
This commit is contained in:
		
							parent
							
								
									31e1b6302a
								
							
						
					
					
						commit
						8f74af7c7b
					
				
					 7 changed files with 185 additions and 132 deletions
				
			
		
							
								
								
									
										7
									
								
								Cargo.lock
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										7
									
								
								Cargo.lock
									
										
									
										generated
									
									
									
								
							|  | @ -17,6 +17,12 @@ version = "0.1.1" | |||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" | ||||
| 
 | ||||
| [[package]] | ||||
| name = "anyhow" | ||||
| version = "1.0.71" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8" | ||||
| 
 | ||||
| [[package]] | ||||
| name = "autocfg" | ||||
| version = "1.1.0" | ||||
|  | @ -1133,6 +1139,7 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" | |||
| name = "screencap-bot" | ||||
| version = "0.1.0" | ||||
| dependencies = [ | ||||
|  "anyhow", | ||||
|  "dotenvy", | ||||
|  "eggbug", | ||||
|  "env_logger", | ||||
|  |  | |||
|  | @ -6,6 +6,7 @@ authors = ["xenofem <xenofem@xeno.science>"] | |||
| license = "MIT" | ||||
| 
 | ||||
| [dependencies] | ||||
| anyhow = "1.0.71" | ||||
| dotenvy = "0.15.7" | ||||
| eggbug = { git = "https://github.com/iliana/eggbug-rs.git", branch = "main" } | ||||
| env_logger = "0.10" | ||||
|  |  | |||
|  | @ -19,7 +19,8 @@ directory: | |||
| 
 | ||||
| - `SCREENCAP_BOT_SHOWS_FILE`: path of a YAML file specifying what shows to take screencaps from (default: `./shows.yaml`) | ||||
| - `SCREENCAP_BOT_GLOBAL_TAGS`: tags to put on every post the bot makes, as a comma-separated list (eg `bot account,automated post,The Cohost Bot Feed`) (default: none) | ||||
| - `SCREENCAP_BOT_POST_INTERVAL`: the interval between posts, in seconds (default: 6 hours) | ||||
| - `SCREENCAP_BOT_POST_INTERVAL`: the interval between posts, in | ||||
|   seconds (default: 0, post a single screencap and then exit) | ||||
| - `SCREENCAP_BOT_COHOST_EMAIL`: the email address the bot should use to log into cohost | ||||
| - `SCREENCAP_BOT_COHOST_PASSWORD`: the password the bot should use to log into cohost | ||||
| - `SCREENCAP_BOT_COHOST_PAGE`: the cohost page the bot should post from | ||||
|  |  | |||
|  | @ -38,7 +38,7 @@ pub fn load() -> Config { | |||
|         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)), | ||||
|         post_interval: Duration::from_secs(parse_var("POST_INTERVAL").unwrap_or_default()), | ||||
|         cohost_email: expect_var("COHOST_EMAIL"), | ||||
|         cohost_password: expect_var("COHOST_PASSWORD"), | ||||
|         cohost_page: expect_var("COHOST_PAGE"), | ||||
|  |  | |||
							
								
								
									
										102
									
								
								src/main.rs
									
										
									
									
									
								
							
							
						
						
									
										102
									
								
								src/main.rs
									
										
									
									
									
								
							|  | @ -1,8 +1,11 @@ | |||
| use std::time::Duration; | ||||
| 
 | ||||
| use anyhow::{anyhow, Context}; | ||||
| use config::Config; | ||||
| use lazy_static::lazy_static; | ||||
| use log::{debug, error, info}; | ||||
| use rand::{distributions::Standard, seq::IteratorRandom, Rng}; | ||||
| use shows::Shows; | ||||
| 
 | ||||
| mod config; | ||||
| mod shows; | ||||
|  | @ -15,7 +18,7 @@ lazy_static! { | |||
| } | ||||
| 
 | ||||
| #[tokio::main] | ||||
| async fn main() { | ||||
| async fn main() -> anyhow::Result<()> { | ||||
|     dotenvy::dotenv().ok(); | ||||
|     env_logger::init(); | ||||
|     ffmpeg_next::init().unwrap(); | ||||
|  | @ -25,24 +28,56 @@ async fn main() { | |||
|     let conf = config::load(); | ||||
| 
 | ||||
|     info!("Loading shows from {}", conf.shows_file.display()); | ||||
|     let shows = shows::load(conf.shows_file); | ||||
|     let shows = shows::load(&conf.shows_file).with_context(|| { | ||||
|         format!( | ||||
|             "Failed to load shows from file {}", | ||||
|             conf.shows_file.display() | ||||
|         ) | ||||
|     })?; | ||||
|     if shows.is_empty() { | ||||
|         return Err(anyhow!("Shows file is empty!")); | ||||
|     } | ||||
| 
 | ||||
|     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"); | ||||
|         .context("Failed to login to cohost")?; | ||||
| 
 | ||||
|     loop { | ||||
|         let (title, show) = shows.iter().choose(&mut rng).expect("No shows found!"); | ||||
|         let episodes = match show.episodes() { | ||||
|             Ok(eps) => eps, | ||||
|         let result = post_random_screencap(&conf, &shows, &session, &mut rng) | ||||
|             .await | ||||
|             .context("Failed to post a random screencap"); | ||||
| 
 | ||||
|         if conf.post_interval == Duration::ZERO { | ||||
|             return result; | ||||
|         } | ||||
| 
 | ||||
|         let delay = match result { | ||||
|             Ok(()) => conf.post_interval, | ||||
|             Err(e) => { | ||||
|                 error!("Failed to get episodes for {}: {}", title, e); | ||||
|                 tokio::time::sleep(*RETRY_INTERVAL).await; | ||||
|                 continue; | ||||
|                 error!("{}", e); | ||||
|                 *RETRY_INTERVAL | ||||
|             } | ||||
|         }; | ||||
|         let (num, file) = episodes.iter().choose(&mut rng).unwrap(); | ||||
|         tokio::time::sleep(delay).await; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| async fn post_random_screencap<R: Rng>( | ||||
|     conf: &Config, | ||||
|     shows: &Shows, | ||||
|     session: &eggbug::Session, | ||||
|     rng: &mut R, | ||||
| ) -> anyhow::Result<()> { | ||||
|     let (title, show) = shows.iter().choose(rng).unwrap(); | ||||
|     let episodes = show.episodes().with_context(|| { | ||||
|         format!( | ||||
|             "Failed to get episode list for show {} with path {}", | ||||
|             title, | ||||
|             show.path.display() | ||||
|         ) | ||||
|     })?; | ||||
|     let (num, file) = episodes.iter().choose(rng).unwrap(); | ||||
| 
 | ||||
|     let descriptor = format!( | ||||
|         "{}{}", | ||||
|  | @ -50,14 +85,18 @@ async fn main() { | |||
|         match num { | ||||
|             EpisodeNumber::Standalone => String::new(), | ||||
|             EpisodeNumber::SingleSeason(n) => format!(" episode {}", n), | ||||
|                 EpisodeNumber::MultiSeason(season, ep) => | ||||
|                     format!(" season {} episode {}", season, ep), | ||||
|             EpisodeNumber::MultiSeason(season, ep) => format!(" season {} episode {}", season, ep), | ||||
|         } | ||||
|     ); | ||||
| 
 | ||||
|     info!("Selected: {} - {}", descriptor, file.display()); | ||||
| 
 | ||||
|         let video_info = video::get_video_info(file, Some("eng")).unwrap(); | ||||
|     let video_info = video::get_video_info(file, Some("eng")).with_context(|| { | ||||
|         format!( | ||||
|             "Failed to get duration and subtitle stream index for video file {}", | ||||
|             file.display() | ||||
|         ) | ||||
|     })?; | ||||
|     debug!( | ||||
|         "Video duration: {}", | ||||
|         format_timestamp(video_info.duration_secs, None) | ||||
|  | @ -71,24 +110,12 @@ async fn main() { | |||
|     let formatted_timestamp = format_timestamp(timestamp, Some(video_info.duration_secs)); | ||||
|     info!("Taking screencap at {}", formatted_timestamp); | ||||
| 
 | ||||
|         let img_data = | ||||
|             match video::take_screencap(file, timestamp, video_info.subtitle_stream_index).await { | ||||
|                 Ok(data) => data, | ||||
|                 Err(e) => { | ||||
|                     error!("Failed to take screencap: {}", e); | ||||
|                     tokio::time::sleep(*RETRY_INTERVAL).await; | ||||
|                     continue; | ||||
|                 } | ||||
|             }; | ||||
|     let img_data = video::take_screencap(file, timestamp, video_info.subtitle_stream_index) | ||||
|         .await | ||||
|         .context("Failed to take screencap")?; | ||||
| 
 | ||||
|         let img_size = match imagesize::blob_size(&img_data) { | ||||
|             Ok(size) => size, | ||||
|             Err(e) => { | ||||
|                 error!("Failed to get image size: {}", e); | ||||
|                 tokio::time::sleep(*RETRY_INTERVAL).await; | ||||
|                 continue; | ||||
|             } | ||||
|         }; | ||||
|     let img_size = imagesize::blob_size(&img_data) | ||||
|         .context("Failed to get image size for screencap image data")?; | ||||
| 
 | ||||
|     let attachment = eggbug::Attachment::new( | ||||
|         img_data, | ||||
|  | @ -105,7 +132,7 @@ async fn main() { | |||
|     let mut tags = show.tags.clone(); | ||||
|     tags.extend_from_slice(&conf.global_tags); | ||||
| 
 | ||||
|         match session | ||||
|     let chost_id = session | ||||
|         .create_post( | ||||
|             &conf.cohost_page, | ||||
|             &mut eggbug::Post { | ||||
|  | @ -120,17 +147,10 @@ async fn main() { | |||
|             }, | ||||
|         ) | ||||
|         .await | ||||
|         { | ||||
|             Ok(id) => info!("Created post {}", id), | ||||
|             Err(e) => { | ||||
|                 error!("Failed to create post: {}", e); | ||||
|                 tokio::time::sleep(*RETRY_INTERVAL).await; | ||||
|                 continue; | ||||
|             } | ||||
|         } | ||||
|         .context("Failed to create chost")?; | ||||
|     info!("Created post {}", chost_id); | ||||
| 
 | ||||
|         tokio::time::sleep(conf.post_interval).await; | ||||
|     } | ||||
|     Ok(()) | ||||
| } | ||||
| 
 | ||||
| fn format_timestamp(timestamp: f64, total_duration: Option<f64>) -> String { | ||||
|  |  | |||
|  | @ -1,5 +1,10 @@ | |||
| use std::{collections::HashMap, fs, io::ErrorKind, path::PathBuf}; | ||||
| use std::{ | ||||
|     collections::HashMap, | ||||
|     fs, | ||||
|     path::{Path, PathBuf}, | ||||
| }; | ||||
| 
 | ||||
| use anyhow::{anyhow, Context}; | ||||
| use log::{debug, error}; | ||||
| use serde::Deserialize; | ||||
| 
 | ||||
|  | @ -12,6 +17,8 @@ pub struct Show { | |||
|     pub tags: Vec<String>, | ||||
| } | ||||
| 
 | ||||
| pub type Shows = HashMap<String, Show>; | ||||
| 
 | ||||
| #[derive(Debug, Eq, Hash, PartialEq)] | ||||
| pub enum EpisodeNumber { | ||||
|     Standalone, | ||||
|  | @ -21,15 +28,16 @@ pub enum EpisodeNumber { | |||
| 
 | ||||
| type Episodes = HashMap<EpisodeNumber, PathBuf>; | ||||
| 
 | ||||
| pub fn load(shows_file: PathBuf) -> HashMap<String, Show> { | ||||
|     serde_yaml::from_reader(fs::File::open(shows_file).expect("Failed to open shows file")) | ||||
|         .expect("Failed to parse YAML from shows file") | ||||
| pub fn load<P: AsRef<Path>>(shows_file: P) -> anyhow::Result<Shows> { | ||||
|     serde_yaml::from_reader(fs::File::open(shows_file).context("Failed to open shows file")?) | ||||
|         .context("Failed to parse YAML from shows file") | ||||
| } | ||||
| 
 | ||||
| impl Show { | ||||
|     pub fn episodes(&self) -> std::io::Result<Episodes> { | ||||
|     pub fn episodes(&self) -> anyhow::Result<Episodes> { | ||||
|         let path = &self.path; | ||||
|         let metadata = fs::metadata(path)?; | ||||
|         let metadata = | ||||
|             fs::metadata(path).context("Failed to stat the show's path to determine file type")?; | ||||
|         if metadata.is_file() { | ||||
|             debug!("{} is a file, standalone", path.display()); | ||||
|             Ok(HashMap::from([( | ||||
|  | @ -38,10 +46,21 @@ impl Show { | |||
|             )])) | ||||
|         } else if metadata.is_dir() { | ||||
|             debug!("{} is a directory, enumerating episodes", path.display()); | ||||
|             let files: Vec<PathBuf> = fs::read_dir(path)? | ||||
|             let files: Vec<PathBuf> = fs::read_dir(path) | ||||
|                 .context("Failed to get a directory listing for the show's path")? | ||||
|                 .map(|entry| { | ||||
|                     let entry = entry?; | ||||
|                     if !entry.file_type()?.is_file() { | ||||
|                     let entry = | ||||
|                         entry.context("Failed to read a directory entry under the show's path")?; | ||||
|                     if !entry | ||||
|                         .file_type() | ||||
|                         .with_context(|| { | ||||
|                             format!( | ||||
|                                 "Failed to get file type for directory entry {}", | ||||
|                                 entry.path().display() | ||||
|                             ) | ||||
|                         })? | ||||
|                         .is_file() | ||||
|                     { | ||||
|                         debug!("Skipping {}, not a file", entry.path().display()); | ||||
|                         return Ok(None); | ||||
|                     } | ||||
|  | @ -55,16 +74,11 @@ impl Show { | |||
|                     Ok(Some(entry.path())) | ||||
|                 }) | ||||
|                 .filter_map(|r| r.transpose()) | ||||
|                 .collect::<std::io::Result<Vec<PathBuf>>>()?; | ||||
|             enumeration::enumerate_episodes(files).ok_or(std::io::Error::new( | ||||
|                 ErrorKind::InvalidData, | ||||
|                 "No valid prefixes found", | ||||
|             )) | ||||
|                 .collect::<anyhow::Result<Vec<PathBuf>>>()?; | ||||
|             enumeration::enumerate_episodes(files) | ||||
|                 .ok_or(anyhow!("Could not detect any episode numbering scheme")) | ||||
|         } else { | ||||
|             Err(std::io::Error::new( | ||||
|                 ErrorKind::InvalidInput, | ||||
|                 format!("Invalid file type for {}", path.display()), | ||||
|             )) | ||||
|             Err(anyhow!("The show's path is not a file or a directory")) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  |  | |||
							
								
								
									
										40
									
								
								src/video.rs
									
										
									
									
									
								
							
							
						
						
									
										40
									
								
								src/video.rs
									
										
									
									
									
								
							|  | @ -1,3 +1,4 @@ | |||
| use anyhow::{anyhow, Context}; | ||||
| use ffmpeg_next::{ | ||||
|     format::{input, stream::Disposition}, | ||||
|     media::Type, | ||||
|  | @ -7,7 +8,7 @@ use regex::Regex; | |||
| use tempfile::tempdir; | ||||
| use tokio::{fs, process::Command}; | ||||
| 
 | ||||
| use std::{io::ErrorKind, path::Path}; | ||||
| use std::path::Path; | ||||
| 
 | ||||
| lazy_static! { | ||||
|     static ref SUBTITLE_FORBID_REGEX: Regex = Regex::new("(?i)sign|song").unwrap(); | ||||
|  | @ -22,8 +23,8 @@ pub struct VideoInfo { | |||
| pub fn get_video_info<P: AsRef<Path>>( | ||||
|     source: &P, | ||||
|     subtitle_lang: Option<&str>, | ||||
| ) -> Result<VideoInfo, ffmpeg_next::Error> { | ||||
|     let ctx = input(source)?; | ||||
| ) -> anyhow::Result<VideoInfo> { | ||||
|     let ctx = input(source).context("Failed to load video file")?; | ||||
| 
 | ||||
|     let duration_secs = ctx.duration() as f64 / f64::from(ffmpeg_next::ffi::AV_TIME_BASE); | ||||
| 
 | ||||
|  | @ -63,18 +64,21 @@ pub async fn take_screencap<P: AsRef<Path>>( | |||
|     source: &P, | ||||
|     timestamp_secs: f64, | ||||
|     subtitle_stream_index: Option<usize>, | ||||
| ) -> std::io::Result<Vec<u8>> { | ||||
| ) -> anyhow::Result<Vec<u8>> { | ||||
|     let ext = source.as_ref().extension().and_then(|s| s.to_str()); | ||||
|     if ext != Some("mkv") && ext != Some("mp4") { | ||||
|         return Err(std::io::Error::new( | ||||
|             ErrorKind::Other, | ||||
|             "unexpected file extension", | ||||
|         return Err(anyhow!( | ||||
|             "Video file {} had unexpected file extension", | ||||
|             source.as_ref().display() | ||||
|         )); | ||||
|     } | ||||
| 
 | ||||
|     let tmp_dir = tempdir()?; | ||||
|     let tmp_dir = tempdir() | ||||
|         .context("Failed to create temporary directory for ffmpeg input and output files")?; | ||||
|     let link_path = tmp_dir.path().join(format!("in.{}", ext.unwrap())); | ||||
|     fs::symlink(source, &link_path).await?; | ||||
|     fs::symlink(source, &link_path) | ||||
|         .await | ||||
|         .context("Failed to create symlink for video file")?; | ||||
|     let dest_path = tmp_dir.path().join("out.png"); | ||||
| 
 | ||||
|     let mut cmd = Command::new("ffmpeg"); | ||||
|  | @ -98,12 +102,18 @@ pub async fn take_screencap<P: AsRef<Path>>( | |||
|         .arg("-y") | ||||
|         .arg(&dest_path); | ||||
| 
 | ||||
|     if !cmd.status().await?.success() { | ||||
|         return Err(std::io::Error::new( | ||||
|             ErrorKind::Other, | ||||
|             "ffmpeg command failed", | ||||
|         )); | ||||
|     let status = cmd | ||||
|         .status() | ||||
|         .await | ||||
|         .with_context(|| format!("Error running ffmpeg child process {:?}", cmd))?; | ||||
|     if !status.success() { | ||||
|         match status.code() { | ||||
|             Some(code) => return Err(anyhow!("ffmpeg exited with status code {code}")), | ||||
|             None => return Err(anyhow!("ffmpeg terminated by signal")), | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fs::read(&dest_path).await | ||||
|     fs::read(&dest_path) | ||||
|         .await | ||||
|         .with_context(|| format!("Failed to read ffmpeg output file {}", dest_path.display())) | ||||
| } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue