diff --git a/Cargo.lock b/Cargo.lock index 0014348..f4ec8be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -270,7 +270,7 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "eggbug" version = "0.1.3" -source = "git+https://github.com/xenofem/eggbug-rs.git?branch=audio-attachments#6bcf51bceb3745f96ef0c9026d3143093fec032b" +source = "git+https://github.com/iliana/eggbug-rs.git?branch=main#94fc2f652a842b0fadfff62750562630e887672a" dependencies = [ "base64 0.13.1", "bytes", @@ -1219,7 +1219,7 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] name = "screencap-bot" -version = "1.4.0-beta" +version = "1.3.0" dependencies = [ "anyhow", "dotenvy", @@ -1236,7 +1236,6 @@ dependencies = [ "serde_yaml", "tempfile", "tokio", - "tracing", ] [[package]] @@ -1583,7 +1582,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" dependencies = [ "cfg-if", - "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/Cargo.toml b/Cargo.toml index 14f6b73..a64add4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "screencap-bot" -version = "1.4.0-beta" +version = "1.3.0" edition = "2021" authors = ["xenofem "] license = "MIT" @@ -8,7 +8,7 @@ license = "MIT" [dependencies] anyhow = "1.0.71" dotenvy = "0.15.7" -eggbug = { git = "https://github.com/xenofem/eggbug-rs.git", branch = "audio-attachments" } +eggbug = { git = "https://github.com/iliana/eggbug-rs.git", branch = "main" } env_logger = "0.10" ffmpeg-next = "6.0.0" imagesize = "0.12.0" @@ -20,5 +20,4 @@ serde = "1" serde_with = "3" serde_yaml = "0.9.22" tempfile = "3.6.0" -tokio = { version = "1.28.2", features = ["full"] } -tracing = { version = "0.1", features = ["log"] } \ No newline at end of file +tokio = { version = "1.28.2", features = ["full"] } \ No newline at end of file diff --git a/README.md b/README.md index 6270d36..81eb594 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ # screencap-bot this is a cohost bot that periodically posts randomly-chosen -screencaps or audio clips from a configured collection of tv -series/movies/podcasts/etc. +screencaps from a configured collection of tv series/movies. ## installation @@ -18,19 +17,15 @@ screencap-bot is configured with the following environment variables, which can also be put in a `.env` file in the program's working directory: -- `SCREENCAP_BOT_CAPTURE_IMAGES`: whether to take screenshots (default: `true`) -- `SCREENCAP_BOT_CAPTURE_AUDIO_DURATION`: length of audio clips to capture, in seconds (default: unset, no audio capture) -- `SCREENCAP_BOT_SHOWS_FILE`: path of a YAML file specifying what shows to take captures 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_POST_INTERVAL`: the interval between posts, in - seconds (default: 0, post a single capture and then exit) + 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 - `SCREENCAP_BOT_COHOST_DRAFT`: whether to create cohost posts as drafts, eg for testing (default: `false`) -- `SCREENCAP_BOT_COHOST_CW`: whether to CW posts with the episode - number (default: `true` if taking screenshots, `false` if not) - `SCREENCAP_BOT_18PLUS`: whether posts should be flagged as containing 18+ content (default: `false`). this can be overridden for individual shows, see below. @@ -61,11 +56,6 @@ MS IGLOO: Gundam 0069: path: /home/user/media/Gundam 0069 18+: true -Friends at the Table: - path: /home/user/media/Friends at the Table - custom_episodes: - prefix: "Friends at the Table: " - regex: '^\d{4}-\d{2}-\d{2} - (?.*)\.mp3$' ``` each top-level key is a show title, which will be used in spoiler @@ -85,10 +75,3 @@ warnings on posts and in image alt text. each show has two keys: - `18+`: an optional setting for whether screencaps from this show should be flagged as containing 18+ content. if present, this takes precedence over the `SCREENCAP_BOT_18PLUS` environment variable. -- `custom_episodes`: Rather than letting the bot auto-detect episode - numbering, you can extract episode numbers from filenames using a regex. - + `regex`: should match a filename, and capture the episode number - or title in a capture group named `episode`. Files that don't - match the regex will be ignored. - + `prefix`: Will be prepended to whatever is captured by the - regex. (default: empty string) diff --git a/flake.nix b/flake.nix index ff7eeaf..173dd83 100644 --- a/flake.nix +++ b/flake.nix @@ -20,7 +20,7 @@ cargoLock = { lockFile = ./Cargo.lock; outputHashes = { - "eggbug-0.1.3" = "sha256-uSUWZ66Z5h7zLgvRQg//XXvgpr9YlYWHW5pacxEsVpo="; + "eggbug-0.1.3" = "sha256-dDnCJNnTJsah1HtrKoS5FxmHosBp5J6nfP4s7ugccog="; }; }; } // buildDeps); diff --git a/src/config.rs b/src/config.rs index 6d461cb..15cf027 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,8 +7,6 @@ use std::{ }; pub struct Config { - pub capture_images: bool, - pub capture_audio_duration: Option, pub shows_file: PathBuf, pub global_tags: Vec, pub post_interval: Duration, @@ -16,7 +14,6 @@ pub struct Config { pub cohost_password: String, pub cohost_page: String, pub cohost_draft: bool, - pub cohost_cw: bool, pub eighteen_plus: bool, } @@ -38,11 +35,7 @@ fn expect_var(name: &str) -> String { } pub fn load() -> Config { - let capture_images = parse_var("CAPTURE_IMAGES").unwrap_or(true); - Config { - capture_images, - capture_audio_duration: parse_var("CAPTURE_AUDIO_DURATION").ok(), 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()) @@ -52,7 +45,6 @@ pub fn load() -> Config { cohost_password: expect_var("COHOST_PASSWORD"), cohost_page: expect_var("COHOST_PAGE"), cohost_draft: parse_var("COHOST_DRAFT").unwrap_or(false), - cohost_cw: parse_var("COHOST_CW").unwrap_or(capture_images), eighteen_plus: parse_var("18PLUS").unwrap_or(false), } } diff --git a/src/main.rs b/src/main.rs index 1981bce..97f5133 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,8 +12,8 @@ use rand::{ use shows::Shows; mod config; -mod media; mod shows; +mod video; lazy_static! { static ref RETRY_INTERVAL: Duration = Duration::from_secs(30); @@ -29,12 +29,6 @@ async fn main() -> anyhow::Result<()> { let conf = config::load(); - if let (false, None) = (conf.capture_images, conf.capture_audio_duration) { - return Err(anyhow!( - "At least one of image capture and audio capture must be enabled!" - )); - } - info!("Loading shows from {}", conf.shows_file.display()); let shows = shows::load(&conf.shows_file).with_context(|| { format!( @@ -55,9 +49,9 @@ async fn main() -> anyhow::Result<()> { .context("Failed to login to cohost")?; loop { - let result = post_random_capture(&conf, &shows, &session, &dist, &mut rng) + let result = post_random_screencap(&conf, &shows, &session, &dist, &mut rng) .await - .context("Failed to post a random capture"); + .context("Failed to post a random screencap"); if conf.post_interval == Duration::ZERO { return result; @@ -74,7 +68,7 @@ async fn main() -> anyhow::Result<()> { } } -async fn post_random_capture( +async fn post_random_screencap( conf: &Config, shows: &Shows, session: &eggbug::Session, @@ -91,73 +85,47 @@ async fn post_random_capture( })?; let (num, file) = episodes.iter().choose(rng).unwrap(); - let descriptor = shows::display_show_episode(show, num); + let descriptor = shows::display_show_episode(show, *num); info!("Selected: {} - {}", descriptor, file.display()); - let media_info = media::get_media_info(file, Some("eng")) - .with_context(|| format!("Failed to get info for media file {}", file.display()))?; + 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!( - "Media duration: {}", - format_timestamp(media_info.duration_secs, None) + "Video duration: {}", + format_timestamp(video_info.duration_secs, None) ); debug!( "Subtitle stream index: {:?}", - media_info.subtitle_stream_index + video_info.subtitle_stream_index ); - let max_timestamp = match conf.capture_audio_duration { - Some(d) => media_info.duration_secs - d, - None => media_info.duration_secs, - }; - let timestamp = max_timestamp * rng.sample::(Standard); - let formatted_timestamp = format_timestamp(timestamp, Some(media_info.duration_secs)); - info!("Taking capture at {}", formatted_timestamp); + 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); - let mut attachments = Vec::new(); + let img_data = video::take_screencap(file, timestamp, video_info.subtitle_stream_index) + .await + .context("Failed to take screencap")?; - if conf.capture_images { - let image_data = media::take_screencap(file, timestamp, media_info.subtitle_stream_index) - .await - .context("Failed to take screencap")?; + let img_size = imagesize::blob_size(&img_data) + .context("Failed to get image size for screencap image data")?; - let image_size = imagesize::blob_size(&image_data) - .context("Failed to get image size for screencap image data")?; - - let image_attachment = eggbug::Attachment::new( - image_data, - format!("{} @{}.png", descriptor, formatted_timestamp), - String::from("image/png"), - eggbug::MediaMetadata::Image { - width: Some(image_size.width as u32), - height: Some(image_size.height as u32), - }, - ) - .with_alt_text(format!( - "Screencap of {} at {}", - descriptor, formatted_timestamp - )); - - attachments.push(image_attachment); - } - - if let Some(duration) = conf.capture_audio_duration { - let audio_data = media::take_audio_clip(file, timestamp, duration) - .await - .context("Failed to take audio clip")?; - - let audio_attachment = eggbug::Attachment::new( - audio_data, - format!("{} @{}.mp3", descriptor, formatted_timestamp), - String::from("audio/mpeg"), - eggbug::MediaMetadata::Audio { - artist: show.title.clone(), - title: descriptor.clone(), - }, - ); - - attachments.push(audio_attachment); - } + let attachment = eggbug::Attachment::new( + img_data, + format!("{} @{}.png", descriptor, formatted_timestamp), + String::from("image/png"), + Some(img_size.width as u32), + Some(img_size.height as u32), + ) + .with_alt_text(format!( + "Screencap of {} at {}", + descriptor, formatted_timestamp + )); let mut tags = show.tags.clone(); tags.extend_from_slice(&conf.global_tags); @@ -166,19 +134,14 @@ async fn post_random_capture( .create_post( &conf.cohost_page, &mut eggbug::Post { - content_warnings: if conf.cohost_cw { - vec![descriptor] - } else { - vec![] - }, - attachments, + content_warnings: vec![descriptor], + attachments: vec![attachment], tags, draft: conf.cohost_draft, adult_content: show.eighteen_plus.unwrap_or(conf.eighteen_plus), headline: String::new(), markdown: String::new(), metadata: None, - ask: None, }, ) .await diff --git a/src/shows/mod.rs b/src/shows/mod.rs index 27776fe..f9644a6 100644 --- a/src/shows/mod.rs +++ b/src/shows/mod.rs @@ -6,9 +6,8 @@ use std::{ use anyhow::{anyhow, Context}; use log::{debug, error}; -use regex::Regex; use serde::Deserialize; -use serde_with::{serde_as, DisplayFromStr, KeyValueMap}; +use serde_with::{serde_as, KeyValueMap}; mod enumeration; @@ -18,8 +17,6 @@ pub struct Show { pub title: String, pub path: PathBuf, #[serde(default)] - pub custom_episodes: Option, - #[serde(default)] pub tags: Vec, #[serde(default)] pub parts: HashMap, @@ -29,15 +26,6 @@ pub struct Show { pub eighteen_plus: Option, } -#[serde_as] -#[derive(Deserialize)] -pub struct CustomEpisodes { - #[serde(default)] - pub prefix: String, - #[serde_as(as = "DisplayFromStr")] - pub regex: Regex, -} - fn default_weight() -> f32 { 1.0 } @@ -48,12 +36,11 @@ pub type Shows = Vec; #[derive(Deserialize)] struct ShowsWrapper(#[serde_as(as = "KeyValueMap<_>")] Shows); -#[derive(Clone, Debug, Eq, Hash, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum EpisodeNumber { Standalone, SingleSeason(u32), MultiSeason(u32, u32), - Custom(String), } type Episodes = HashMap; @@ -66,7 +53,7 @@ pub fn load>(shows_file: P) -> anyhow::Result { .0) } -pub fn display_show_episode(show: &Show, episode: &EpisodeNumber) -> String { +pub fn display_show_episode(show: &Show, episode: EpisodeNumber) -> String { match episode { EpisodeNumber::Standalone => show.title.to_string(), EpisodeNumber::SingleSeason(n) => format!("{} episode {}", show.title, n), @@ -74,17 +61,10 @@ pub fn display_show_episode(show: &Show, episode: &EpisodeNumber) -> String { "{} {} episode {}", show.title, show.parts - .get(season) + .get(&season) .unwrap_or(&format!("season {}", season)), ep ), - EpisodeNumber::Custom(s) => { - show.custom_episodes - .as_ref() - .map(|c| c.prefix.clone()) - .unwrap_or_default() - + s - } } } @@ -130,24 +110,8 @@ impl Show { }) .filter_map(|r| r.transpose()) .collect::>>()?; - if let Some(CustomEpisodes { ref regex, .. }) = self.custom_episodes { - files - .into_iter() - .filter_map(|f| -> Option> { - let episode_name = regex - .captures(f.file_name().unwrap().to_str().unwrap())? - .name("episode") - .map(|m| m.as_str().to_string()) - .ok_or(anyhow!( - "Failed to find capture group `episode` in episode regex" - )); - Some(episode_name.map(|n| (EpisodeNumber::Custom(n), f))) - }) - .collect::>() - } else { - enumeration::enumerate_episodes(files) - .ok_or(anyhow!("Could not detect any episode numbering scheme")) - } + enumeration::enumerate_episodes(files) + .ok_or(anyhow!("Could not detect any episode numbering scheme")) } else { Err(anyhow!("The show's path is not a file or a directory")) } diff --git a/src/media.rs b/src/video.rs similarity index 56% rename from src/media.rs rename to src/video.rs index fcd3e63..724d75d 100644 --- a/src/media.rs +++ b/src/video.rs @@ -4,7 +4,6 @@ use ffmpeg_next::{ media::Type, }; use lazy_static::lazy_static; -use log::debug; use regex::Regex; use tempfile::tempdir; use tokio::{fs, process::Command}; @@ -15,20 +14,19 @@ lazy_static! { static ref SUBTITLE_FORBID_REGEX: Regex = Regex::new("(?i)sign|song").unwrap(); } -pub struct MediaInfo { +pub struct VideoInfo { pub duration_secs: f64, // The index among the subtitle streams, not among the streams in general pub subtitle_stream_index: Option, } -pub fn get_media_info>( +pub fn get_video_info>( source: &P, subtitle_lang: Option<&str>, -) -> anyhow::Result { - let ctx = input(source).context("Failed to load media file")?; +) -> anyhow::Result { + 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); - debug!("{:?}", ctx.metadata()); let subtitle_stream_index = subtitle_lang.and_then(|lang| { ctx.streams() @@ -56,40 +54,53 @@ pub fn get_media_info>( .map(|(idx, _)| idx) }); - Ok(MediaInfo { + Ok(VideoInfo { duration_secs, subtitle_stream_index, }) } -async fn take_ffmpeg_capture( +pub async fn take_screencap>( source: &P, - output_ext: &str, - apply_args: F, -) -> anyhow::Result> -where - P: AsRef, - F: FnOnce(&mut Command, &Path, &Path), -{ - let input_ext = source.as_ref().extension().and_then(|s| s.to_str()); - if input_ext.map(|e| e.chars().all(|c| c.is_ascii_alphanumeric())) != Some(true) { + timestamp_secs: f64, + subtitle_stream_index: Option, +) -> anyhow::Result> { + let ext = source.as_ref().extension().and_then(|s| s.to_str()); + if ext != Some("mkv") && ext != Some("mp4") { return Err(anyhow!( - "Media file {} had unexpected file extension", + "Video file {} had unexpected file extension", source.as_ref().display() )); } 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.{}", input_ext.unwrap())); + let link_path = tmp_dir.path().join(format!("in.{}", ext.unwrap())); fs::symlink(source, &link_path) .await - .context("Failed to create symlink for input file")?; - let dest_path = tmp_dir.path().join(format!("out.{}", output_ext)); + .context("Failed to create symlink for video file")?; + let dest_path = tmp_dir.path().join("out.png"); let mut cmd = Command::new("ffmpeg"); - apply_args(&mut cmd, &link_path, &dest_path); + cmd.arg("-ss") + .arg(format!("{:.2}", timestamp_secs)) + .arg("-copyts") + .arg("-i") + .arg(&link_path); + + if let Some(idx) = subtitle_stream_index { + cmd.arg("-filter_complex").arg(format!( + "[0:v]subtitles={}:si={}", + link_path.to_string_lossy(), + idx + )); + } + + cmd.args(["-vframes", "1"]) + .args(["-loglevel", "quiet"]) + .arg("-y") + .arg(&dest_path); let status = cmd .status() @@ -106,50 +117,3 @@ where .await .with_context(|| format!("Failed to read ffmpeg output file {}", dest_path.display())) } - -pub async fn take_screencap>( - source: &P, - timestamp_secs: f64, - subtitle_stream_index: Option, -) -> anyhow::Result> { - take_ffmpeg_capture(source, "png", |cmd, in_path, out_path| { - cmd.arg("-ss") - .arg(format!("{:.2}", timestamp_secs)) - .arg("-copyts") - .arg("-i") - .arg(in_path); - - if let Some(idx) = subtitle_stream_index { - cmd.arg("-filter_complex").arg(format!( - "[0:v]subtitles={}:si={}", - in_path.to_string_lossy(), - idx - )); - } - - cmd.args(["-vframes", "1"]) - .args(["-loglevel", "quiet"]) - .arg("-y") - .arg(out_path); - }) - .await -} - -pub async fn take_audio_clip>( - source: &P, - timestamp_secs: f64, - duration_secs: f64, -) -> anyhow::Result> { - take_ffmpeg_capture(source, "mp3", |cmd, in_path, out_path| { - cmd.arg("-ss") - .arg(format!("{:.2}", timestamp_secs)) - .arg("-t") - .arg(format!("{:.2}", duration_secs)) - .arg("-i") - .arg(in_path) - .args(["-loglevel", "quiet"]) - .arg("-y") - .arg(out_path); - }) - .await -}