1.5.0: make preferred audio and subtitle languages configurable

This commit is contained in:
xenofem 2023-08-01 01:09:31 -04:00
parent 0f75889a3e
commit f584b17dd1
6 changed files with 121 additions and 33 deletions

2
Cargo.lock generated
View file

@ -1220,7 +1220,7 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]] [[package]]
name = "screencap-bot" name = "screencap-bot"
version = "1.4.1" version = "1.5.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dotenvy", "dotenvy",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "screencap-bot" name = "screencap-bot"
version = "1.4.1" version = "1.5.0"
edition = "2021" edition = "2021"
authors = ["xenofem <xenofem@xeno.science>"] authors = ["xenofem <xenofem@xeno.science>"]
license = "MIT" license = "MIT"

View file

@ -18,15 +18,33 @@ screencap-bot is configured with the following environment variables,
which can also be put in a `.env` file in the program's working which can also be put in a `.env` file in the program's working
directory: directory:
- `SCREENCAP_BOT_CAPTURE_IMAGES`: whether to take screenshots (default: `true`) - `SCREENCAP_BOT_CAPTURE_IMAGES`: whether to take screenshots
- `SCREENCAP_BOT_CAPTURE_AUDIO_DURATION`: length of audio clips to capture, in seconds (default: unset, no audio capture) (default: `true`)
- `SCREENCAP_BOT_SHOWS_FILE`: path of a YAML file specifying what shows to take captures from (default: `./shows.yaml`) - `SCREENCAP_BOT_CAPTURE_AUDIO_DURATION`: length of audio clips to
- `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) capture, in seconds. if unset, or set to 0, audio will not be
captured. (default: unset, no audio capture)
- `SCREENCAP_BOT_SUBTITLE_LANGUAGE`: ISO-639-2 three-letter code for a
subtitle language to embed in screenshots. if this is set to an
empty string, or if a video doesn't have any subtitle track tagged
with this language, no subtitles will be embedded. (default: `eng`)
- `SCREENCAP_BOT_AUDIO_LANGUAGE`: ISO-639-2 three-letter code for an
audio language to prefer when capturing audio clips. if this is
unset, set to an empty string, or if a media file doesn't have any
audio track tagged with this language, screencap-bot will choose an
arbitrary audio track. (default: unset)
- `SCREENCAP_BOT_SHOWS_FILE`: path of a YAML file specifying what
shows to take captures 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 - `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 capture 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
- `SCREENCAP_BOT_COHOST_PASSWORD`: the password the bot should use to log into cohost to log into cohost
- `SCREENCAP_BOT_COHOST_PAGE`: the cohost page the bot should post from - `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 - `SCREENCAP_BOT_COHOST_DRAFT`: whether to create cohost posts as
drafts, eg for testing (default: `false`) drafts, eg for testing (default: `false`)
- `SCREENCAP_BOT_COHOST_CW`: whether to CW posts with the episode - `SCREENCAP_BOT_COHOST_CW`: whether to CW posts with the episode

View file

@ -12,6 +12,8 @@ use anyhow::{anyhow, Context};
pub struct Config { pub struct Config {
pub capture_images: bool, pub capture_images: bool,
pub capture_audio_duration: Option<f64>, pub capture_audio_duration: Option<f64>,
pub subtitle_language: Option<String>,
pub audio_language: Option<String>,
pub shows_file: PathBuf, pub shows_file: PathBuf,
pub global_tags: Vec<String>, pub global_tags: Vec<String>,
pub post_interval: Duration, pub post_interval: Duration,
@ -49,18 +51,55 @@ fn require_var(name: &str) -> anyhow::Result<String> {
get_var(name)?.ok_or_else(|| anyhow!("{}{} must be set", VAR_PREFIX, name)) get_var(name)?.ok_or_else(|| anyhow!("{}{} must be set", VAR_PREFIX, name))
} }
fn get_language_code_var<F: FnOnce() -> Option<String>>(
name: &str,
default: F,
) -> anyhow::Result<Option<String>> {
match get_var(name)? {
Some(s) => {
if s.is_ascii() && s.len() == 3 {
Ok(Some(s))
} else if s.is_empty() {
Ok(None)
} else {
Err(anyhow!(
"{}{} must be an ISO-639-2 three-letter language code",
VAR_PREFIX,
name
))
}
}
None => Ok(default()),
}
}
pub fn load() -> anyhow::Result<Config> { pub fn load() -> anyhow::Result<Config> {
let capture_images = parse_var("CAPTURE_IMAGES")?.unwrap_or(true); let capture_images = parse_var("CAPTURE_IMAGES")?.unwrap_or(true);
let capture_audio_duration = parse_var("CAPTURE_AUDIO_DURATION")?; let capture_audio_duration = match parse_var::<_, f64>("CAPTURE_AUDIO_DURATION")? {
if let Some(d) = capture_audio_duration { Some(d) => {
if d <= 0.0 { if !d.is_finite() {
return Err(anyhow!( return Err(anyhow!(
"{}CAPTURE_AUDIO_DURATION cannot be <= 0", "non-finite float value for {}CAPTURE_AUDIO_DURATION",
VAR_PREFIX
));
} else if d >= 1.0 {
Some(d)
} else if d > 0.0 {
return Err(anyhow!(
"cannot capture audio clips less than 1 second long"
));
} else if d == 0.0 {
None
} else {
return Err(anyhow!(
"{}CAPTURE_AUDIO_DURATION cannot be negative",
VAR_PREFIX VAR_PREFIX
)); ));
} }
} }
None => None,
};
if let (false, None) = (capture_images, capture_audio_duration) { if let (false, None) = (capture_images, capture_audio_duration) {
return Err(anyhow!( return Err(anyhow!(
@ -71,6 +110,10 @@ pub fn load() -> anyhow::Result<Config> {
Ok(Config { Ok(Config {
capture_images, capture_images,
capture_audio_duration, capture_audio_duration,
subtitle_language: get_language_code_var("SUBTITLE_LANGUAGE", || {
Some(String::from("eng"))
})?,
audio_language: get_language_code_var("AUDIO_LANGUAGE", || None)?,
shows_file: parse_var("SHOWS_FILE")?.unwrap_or_else(|| PathBuf::from("./shows.yaml")), shows_file: parse_var("SHOWS_FILE")?.unwrap_or_else(|| PathBuf::from("./shows.yaml")),
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())

View file

@ -89,7 +89,11 @@ async fn post_random_capture<R: Rng>(
info!("Selected: {} - {}", descriptor, file.display()); info!("Selected: {} - {}", descriptor, file.display());
let media_info = media::get_media_info(file, Some("eng")) let media_info = media::get_media_info(
file,
conf.subtitle_language.as_deref(),
conf.audio_language.as_deref(),
)
.with_context(|| format!("Failed to get info for media file {}", file.display()))?; .with_context(|| format!("Failed to get info for media file {}", file.display()))?;
debug!( debug!(
"Media duration: {}", "Media duration: {}",
@ -142,7 +146,8 @@ async fn post_random_capture<R: Rng>(
} }
if let Some(duration) = conf.capture_audio_duration { if let Some(duration) = conf.capture_audio_duration {
let audio_data = media::take_audio_clip(file, timestamp, duration) let audio_data =
media::take_audio_clip(file, timestamp, duration, media_info.audio_stream_index)
.await .await
.context("Failed to take audio clip")?; .context("Failed to take audio clip")?;

View file

@ -2,6 +2,7 @@ use anyhow::{anyhow, Context};
use ffmpeg_next::{ use ffmpeg_next::{
format::{input, stream::Disposition}, format::{input, stream::Disposition},
media::Type, media::Type,
Stream,
}; };
use lazy_static::lazy_static; use lazy_static::lazy_static;
use log::debug; use log::debug;
@ -19,11 +20,27 @@ pub struct MediaInfo {
pub duration_secs: f64, pub duration_secs: f64,
// The index among the subtitle streams, not among the streams in general // The index among the subtitle streams, not among the streams in general
pub subtitle_stream_index: Option<usize>, pub subtitle_stream_index: Option<usize>,
// The index among the audio streams, not among the streams in general
pub audio_stream_index: Option<usize>,
}
fn indexed_streams(
ctx: &ffmpeg_next::format::context::common::Context,
stream_type: Type,
) -> impl Iterator<Item = (usize, Stream<'_>)> {
ctx.streams()
.filter(move |stream| {
ffmpeg_next::codec::context::Context::from_parameters(stream.parameters())
.map(|c| c.medium())
== Ok(stream_type)
})
.enumerate()
} }
pub fn get_media_info<P: AsRef<Path>>( pub fn get_media_info<P: AsRef<Path>>(
source: &P, source: &P,
subtitle_lang: Option<&str>, subtitle_lang: Option<&str>,
audio_lang: Option<&str>,
) -> anyhow::Result<MediaInfo> { ) -> anyhow::Result<MediaInfo> {
let ctx = input(source).context("Failed to load media file")?; let ctx = input(source).context("Failed to load media file")?;
@ -31,13 +48,7 @@ pub fn get_media_info<P: AsRef<Path>>(
debug!("{:?}", ctx.metadata()); debug!("{:?}", ctx.metadata());
let subtitle_stream_index = subtitle_lang.and_then(|lang| { let subtitle_stream_index = subtitle_lang.and_then(|lang| {
ctx.streams() indexed_streams(&ctx, Type::Subtitle)
.filter(|stream| {
ffmpeg_next::codec::context::Context::from_parameters(stream.parameters())
.map(|c| c.medium())
== Ok(Type::Subtitle)
})
.enumerate()
.filter(|(_, stream)| { .filter(|(_, stream)| {
let metadata = stream.metadata(); let metadata = stream.metadata();
if metadata.get("language") != Some(lang) { if metadata.get("language") != Some(lang) {
@ -56,9 +67,16 @@ pub fn get_media_info<P: AsRef<Path>>(
.map(|(idx, _)| idx) .map(|(idx, _)| idx)
}); });
let audio_stream_index = audio_lang.and_then(|lang| {
indexed_streams(&ctx, Type::Audio)
.find(|(_, stream)| stream.metadata().get("language") == Some(lang))
.map(|(idx, _)| idx)
});
Ok(MediaInfo { Ok(MediaInfo {
duration_secs, duration_secs,
subtitle_stream_index, subtitle_stream_index,
audio_stream_index,
}) })
} }
@ -139,6 +157,7 @@ pub async fn take_audio_clip<P: AsRef<Path>>(
source: &P, source: &P,
timestamp_secs: f64, timestamp_secs: f64,
duration_secs: f64, duration_secs: f64,
audio_stream_index: Option<usize>,
) -> anyhow::Result<Vec<u8>> { ) -> anyhow::Result<Vec<u8>> {
take_ffmpeg_capture(source, "mp3", |cmd, in_path, out_path| { take_ffmpeg_capture(source, "mp3", |cmd, in_path, out_path| {
cmd.arg("-ss") cmd.arg("-ss")
@ -146,10 +165,13 @@ pub async fn take_audio_clip<P: AsRef<Path>>(
.arg("-t") .arg("-t")
.arg(format!("{:.2}", duration_secs)) .arg(format!("{:.2}", duration_secs))
.arg("-i") .arg("-i")
.arg(in_path) .arg(in_path);
.args(["-loglevel", "quiet"])
.arg("-y") if let Some(idx) = audio_stream_index {
.arg(out_path); cmd.arg("-map").arg(format!("0:a:{}", idx));
}
cmd.args(["-loglevel", "quiet"]).arg("-y").arg(out_path);
}) })
.await .await
} }