Oneshot mode and general improved error handling
This commit is contained in:
parent
31e1b6302a
commit
8f74af7c7b
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
|
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.71"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
@ -1133,6 +1139,7 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
|
||||||
name = "screencap-bot"
|
name = "screencap-bot"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"eggbug",
|
"eggbug",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|
|
@ -6,6 +6,7 @@ authors = ["xenofem <xenofem@xeno.science>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
anyhow = "1.0.71"
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
eggbug = { git = "https://github.com/iliana/eggbug-rs.git", branch = "main" }
|
eggbug = { git = "https://github.com/iliana/eggbug-rs.git", branch = "main" }
|
||||||
env_logger = "0.10"
|
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_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_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_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_PASSWORD`: the password the bot should use to log into cohost
|
||||||
- `SCREENCAP_BOT_COHOST_PAGE`: the cohost page the bot should post from
|
- `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")
|
global_tags: get_var("GLOBAL_TAGS")
|
||||||
.map(|s| s.split(',').map(String::from).collect())
|
.map(|s| s.split(',').map(String::from).collect())
|
||||||
.unwrap_or_default(),
|
.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_email: expect_var("COHOST_EMAIL"),
|
||||||
cohost_password: expect_var("COHOST_PASSWORD"),
|
cohost_password: expect_var("COHOST_PASSWORD"),
|
||||||
cohost_page: expect_var("COHOST_PAGE"),
|
cohost_page: expect_var("COHOST_PAGE"),
|
||||||
|
|
102
src/main.rs
102
src/main.rs
|
@ -1,8 +1,11 @@
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context};
|
||||||
|
use config::Config;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use log::{debug, error, info};
|
use log::{debug, error, info};
|
||||||
use rand::{distributions::Standard, seq::IteratorRandom, Rng};
|
use rand::{distributions::Standard, seq::IteratorRandom, Rng};
|
||||||
|
use shows::Shows;
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
mod shows;
|
mod shows;
|
||||||
|
@ -15,7 +18,7 @@ lazy_static! {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() -> anyhow::Result<()> {
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
env_logger::init();
|
env_logger::init();
|
||||||
ffmpeg_next::init().unwrap();
|
ffmpeg_next::init().unwrap();
|
||||||
|
@ -25,24 +28,56 @@ async fn main() {
|
||||||
let conf = config::load();
|
let conf = config::load();
|
||||||
|
|
||||||
info!("Loading shows from {}", conf.shows_file.display());
|
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);
|
info!("Logging into cohost as {}", conf.cohost_email);
|
||||||
let session = eggbug::Session::login(&conf.cohost_email, &conf.cohost_password)
|
let session = eggbug::Session::login(&conf.cohost_email, &conf.cohost_password)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to login to cohost");
|
.context("Failed to login to cohost")?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let (title, show) = shows.iter().choose(&mut rng).expect("No shows found!");
|
let result = post_random_screencap(&conf, &shows, &session, &mut rng)
|
||||||
let episodes = match show.episodes() {
|
.await
|
||||||
Ok(eps) => eps,
|
.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) => {
|
Err(e) => {
|
||||||
error!("Failed to get episodes for {}: {}", title, e);
|
error!("{}", e);
|
||||||
tokio::time::sleep(*RETRY_INTERVAL).await;
|
*RETRY_INTERVAL
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
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!(
|
let descriptor = format!(
|
||||||
"{}{}",
|
"{}{}",
|
||||||
|
@ -50,14 +85,18 @@ async fn main() {
|
||||||
match num {
|
match num {
|
||||||
EpisodeNumber::Standalone => String::new(),
|
EpisodeNumber::Standalone => String::new(),
|
||||||
EpisodeNumber::SingleSeason(n) => format!(" episode {}", n),
|
EpisodeNumber::SingleSeason(n) => format!(" episode {}", n),
|
||||||
EpisodeNumber::MultiSeason(season, ep) =>
|
EpisodeNumber::MultiSeason(season, ep) => format!(" season {} episode {}", season, ep),
|
||||||
format!(" season {} episode {}", season, ep),
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
info!("Selected: {} - {}", descriptor, file.display());
|
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!(
|
debug!(
|
||||||
"Video duration: {}",
|
"Video duration: {}",
|
||||||
format_timestamp(video_info.duration_secs, None)
|
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));
|
let formatted_timestamp = format_timestamp(timestamp, Some(video_info.duration_secs));
|
||||||
info!("Taking screencap at {}", formatted_timestamp);
|
info!("Taking screencap at {}", formatted_timestamp);
|
||||||
|
|
||||||
let img_data =
|
let img_data = video::take_screencap(file, timestamp, video_info.subtitle_stream_index)
|
||||||
match video::take_screencap(file, timestamp, video_info.subtitle_stream_index).await {
|
.await
|
||||||
Ok(data) => data,
|
.context("Failed to take screencap")?;
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to take screencap: {}", e);
|
|
||||||
tokio::time::sleep(*RETRY_INTERVAL).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let img_size = match imagesize::blob_size(&img_data) {
|
let img_size = imagesize::blob_size(&img_data)
|
||||||
Ok(size) => size,
|
.context("Failed to get image size for screencap image data")?;
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to get image size: {}", e);
|
|
||||||
tokio::time::sleep(*RETRY_INTERVAL).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let attachment = eggbug::Attachment::new(
|
let attachment = eggbug::Attachment::new(
|
||||||
img_data,
|
img_data,
|
||||||
|
@ -105,7 +132,7 @@ async fn main() {
|
||||||
let mut tags = show.tags.clone();
|
let mut tags = show.tags.clone();
|
||||||
tags.extend_from_slice(&conf.global_tags);
|
tags.extend_from_slice(&conf.global_tags);
|
||||||
|
|
||||||
match session
|
let chost_id = session
|
||||||
.create_post(
|
.create_post(
|
||||||
&conf.cohost_page,
|
&conf.cohost_page,
|
||||||
&mut eggbug::Post {
|
&mut eggbug::Post {
|
||||||
|
@ -120,17 +147,10 @@ async fn main() {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
.context("Failed to create chost")?;
|
||||||
Ok(id) => info!("Created post {}", id),
|
info!("Created post {}", chost_id);
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to create post: {}", e);
|
|
||||||
tokio::time::sleep(*RETRY_INTERVAL).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::time::sleep(conf.post_interval).await;
|
Ok(())
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_timestamp(timestamp: f64, total_duration: Option<f64>) -> String {
|
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 log::{debug, error};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
@ -12,6 +17,8 @@ pub struct Show {
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub type Shows = HashMap<String, Show>;
|
||||||
|
|
||||||
#[derive(Debug, Eq, Hash, PartialEq)]
|
#[derive(Debug, Eq, Hash, PartialEq)]
|
||||||
pub enum EpisodeNumber {
|
pub enum EpisodeNumber {
|
||||||
Standalone,
|
Standalone,
|
||||||
|
@ -21,15 +28,16 @@ pub enum EpisodeNumber {
|
||||||
|
|
||||||
type Episodes = HashMap<EpisodeNumber, PathBuf>;
|
type Episodes = HashMap<EpisodeNumber, PathBuf>;
|
||||||
|
|
||||||
pub fn load(shows_file: PathBuf) -> HashMap<String, Show> {
|
pub fn load<P: AsRef<Path>>(shows_file: P) -> anyhow::Result<Shows> {
|
||||||
serde_yaml::from_reader(fs::File::open(shows_file).expect("Failed to open shows file"))
|
serde_yaml::from_reader(fs::File::open(shows_file).context("Failed to open shows file")?)
|
||||||
.expect("Failed to parse YAML from shows file")
|
.context("Failed to parse YAML from shows file")
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Show {
|
impl Show {
|
||||||
pub fn episodes(&self) -> std::io::Result<Episodes> {
|
pub fn episodes(&self) -> anyhow::Result<Episodes> {
|
||||||
let path = &self.path;
|
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() {
|
if metadata.is_file() {
|
||||||
debug!("{} is a file, standalone", path.display());
|
debug!("{} is a file, standalone", path.display());
|
||||||
Ok(HashMap::from([(
|
Ok(HashMap::from([(
|
||||||
|
@ -38,10 +46,21 @@ impl Show {
|
||||||
)]))
|
)]))
|
||||||
} else if metadata.is_dir() {
|
} else if metadata.is_dir() {
|
||||||
debug!("{} is a directory, enumerating episodes", path.display());
|
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| {
|
.map(|entry| {
|
||||||
let entry = entry?;
|
let entry =
|
||||||
if !entry.file_type()?.is_file() {
|
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());
|
debug!("Skipping {}, not a file", entry.path().display());
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
@ -55,16 +74,11 @@ impl Show {
|
||||||
Ok(Some(entry.path()))
|
Ok(Some(entry.path()))
|
||||||
})
|
})
|
||||||
.filter_map(|r| r.transpose())
|
.filter_map(|r| r.transpose())
|
||||||
.collect::<std::io::Result<Vec<PathBuf>>>()?;
|
.collect::<anyhow::Result<Vec<PathBuf>>>()?;
|
||||||
enumeration::enumerate_episodes(files).ok_or(std::io::Error::new(
|
enumeration::enumerate_episodes(files)
|
||||||
ErrorKind::InvalidData,
|
.ok_or(anyhow!("Could not detect any episode numbering scheme"))
|
||||||
"No valid prefixes found",
|
|
||||||
))
|
|
||||||
} else {
|
} else {
|
||||||
Err(std::io::Error::new(
|
Err(anyhow!("The show's path is not a file or a directory"))
|
||||||
ErrorKind::InvalidInput,
|
|
||||||
format!("Invalid file type for {}", path.display()),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
40
src/video.rs
40
src/video.rs
|
@ -1,3 +1,4 @@
|
||||||
|
use anyhow::{anyhow, Context};
|
||||||
use ffmpeg_next::{
|
use ffmpeg_next::{
|
||||||
format::{input, stream::Disposition},
|
format::{input, stream::Disposition},
|
||||||
media::Type,
|
media::Type,
|
||||||
|
@ -7,7 +8,7 @@ use regex::Regex;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
use tokio::{fs, process::Command};
|
use tokio::{fs, process::Command};
|
||||||
|
|
||||||
use std::{io::ErrorKind, path::Path};
|
use std::path::Path;
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref SUBTITLE_FORBID_REGEX: Regex = Regex::new("(?i)sign|song").unwrap();
|
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>>(
|
pub fn get_video_info<P: AsRef<Path>>(
|
||||||
source: &P,
|
source: &P,
|
||||||
subtitle_lang: Option<&str>,
|
subtitle_lang: Option<&str>,
|
||||||
) -> Result<VideoInfo, ffmpeg_next::Error> {
|
) -> anyhow::Result<VideoInfo> {
|
||||||
let ctx = input(source)?;
|
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);
|
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,
|
source: &P,
|
||||||
timestamp_secs: f64,
|
timestamp_secs: f64,
|
||||||
subtitle_stream_index: Option<usize>,
|
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());
|
let ext = source.as_ref().extension().and_then(|s| s.to_str());
|
||||||
if ext != Some("mkv") && ext != Some("mp4") {
|
if ext != Some("mkv") && ext != Some("mp4") {
|
||||||
return Err(std::io::Error::new(
|
return Err(anyhow!(
|
||||||
ErrorKind::Other,
|
"Video file {} had unexpected file extension",
|
||||||
"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()));
|
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 dest_path = tmp_dir.path().join("out.png");
|
||||||
|
|
||||||
let mut cmd = Command::new("ffmpeg");
|
let mut cmd = Command::new("ffmpeg");
|
||||||
|
@ -98,12 +102,18 @@ pub async fn take_screencap<P: AsRef<Path>>(
|
||||||
.arg("-y")
|
.arg("-y")
|
||||||
.arg(&dest_path);
|
.arg(&dest_path);
|
||||||
|
|
||||||
if !cmd.status().await?.success() {
|
let status = cmd
|
||||||
return Err(std::io::Error::new(
|
.status()
|
||||||
ErrorKind::Other,
|
.await
|
||||||
"ffmpeg command failed",
|
.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…
Reference in a new issue