1.5.0: make preferred audio and subtitle languages configurable
This commit is contained in:
parent
0f75889a3e
commit
f584b17dd1
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -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",
|
||||||
|
|
|
@ -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"
|
||||||
|
|
32
README.md
32
README.md
|
@ -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
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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")?;
|
||||||
|
|
||||||
|
|
44
src/media.rs
44
src/media.rs
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue