use std::path::{Path, PathBuf};
use anyhow::{anyhow, Result};
#[derive(Debug, Clone)]
pub struct ChapterAudio {
pub title: String,
pub path: PathBuf,
pub duration_secs: f64,
}
pub fn typst_to_plain(body: &str) -> String {
let stripped = strip_leading_heading(body);
let mut out_blocks: Vec<String> = Vec::new();
for block in split_blocks(&stripped) {
let trimmed = block.trim();
if trimmed.is_empty() {
continue;
}
let no_heading = trimmed
.trim_start_matches('=')
.trim_start();
let joined = no_heading
.split('\n')
.map(str::trim)
.collect::<Vec<_>>()
.join(" ");
let plain = strip_inline_markup(&joined);
if !plain.trim().is_empty() {
out_blocks.push(plain.trim().to_string());
}
}
out_blocks.join("\n")
}
fn strip_leading_heading(body: &str) -> String {
let mut lines = body.lines();
if let Some(first) = lines.clone().next() {
if first.trim_start().starts_with("= ") {
lines.next();
let rest: Vec<&str> = lines.collect();
return rest.join("\n").trim_start_matches('\n').to_string();
}
}
body.to_string()
}
fn split_blocks(s: &str) -> Vec<String> {
let mut blocks = Vec::new();
let mut cur = String::new();
for line in s.lines() {
if line.trim().is_empty() {
if !cur.trim().is_empty() {
blocks.push(std::mem::take(&mut cur));
}
} else {
if !cur.is_empty() {
cur.push('\n');
}
cur.push_str(line);
}
}
if !cur.trim().is_empty() {
blocks.push(cur);
}
blocks
}
fn strip_inline_markup(s: &str) -> String {
let no_footnotes = drop_footnotes(s);
no_footnotes
.chars()
.filter(|c| *c != '_' && *c != '*')
.collect()
}
fn drop_footnotes(s: &str) -> String {
let needle = "#footnote[";
let mut out = String::new();
let mut rest = s;
while let Some(pos) = rest.find(needle) {
out.push_str(&rest[..pos]);
let after = &rest[pos + needle.len()..];
if let Some(end) = after.find(']') {
rest = &after[end + 1..];
} else {
return out;
}
}
out.push_str(rest);
out
}
pub fn build_ffmetadata(
book_title: &str,
author: &str,
chapters: &[(String, f64)],
) -> String {
let mut out = String::from(";FFMETADATA1\n");
out.push_str(&format!("title={}\n", escape_ffmeta(book_title)));
out.push_str(&format!("artist={}\n", escape_ffmeta(author)));
out.push_str(&format!("album={}\n", escape_ffmeta(book_title)));
out.push_str("genre=Audiobook\n");
let mut start_ms: i64 = 0;
for (title, dur_secs) in chapters {
let dur_ms = (dur_secs * 1000.0).round() as i64;
let end_ms = start_ms + dur_ms;
out.push_str("\n[CHAPTER]\n");
out.push_str("TIMEBASE=1/1000\n");
out.push_str(&format!("START={start_ms}\n"));
out.push_str(&format!("END={end_ms}\n"));
out.push_str(&format!("title={}\n", escape_ffmeta(title)));
start_ms = end_ms;
}
out
}
fn escape_ffmeta(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'=' | ';' | '#' | '\\' => {
out.push('\\');
out.push(c);
}
'\n' => out.push_str("\\\n"),
_ => out.push(c),
}
}
out
}
pub fn parse_ffprobe_duration(stdout: &str) -> Option<f64> {
stdout.trim().lines().next()?.trim().parse::<f64>().ok()
}
pub fn probe_duration_secs(ffprobe_bin: &str, path: &Path) -> Result<f64> {
let output = std::process::Command::new(ffprobe_bin)
.args([
"-v",
"quiet",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
])
.arg(path)
.output()
.map_err(|e| anyhow!("spawn {ffprobe_bin}: {e}"))?;
if !output.status.success() {
return Err(anyhow!(
"ffprobe failed on {}: {}",
path.display(),
String::from_utf8_lossy(&output.stderr).trim(),
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_ffprobe_duration(&stdout)
.ok_or_else(|| anyhow!("couldn't parse ffprobe duration: {stdout:?}"))
}
pub fn mux_m4b(
ffmpeg_bin: &str,
chapter_paths: &[PathBuf],
ffmeta_path: &Path,
dest: &Path,
work_dir: &Path,
) -> Result<()> {
if chapter_paths.is_empty() {
return Err(anyhow!("no chapter audio to mux"));
}
let list_path = work_dir.join("concat-list.txt");
let mut list = String::new();
for p in chapter_paths {
let abs = p
.canonicalize()
.unwrap_or_else(|_| p.clone());
let escaped = abs.to_string_lossy().replace('\'', "'\\''");
list.push_str(&format!("file '{escaped}'\n"));
}
std::fs::write(&list_path, list)?;
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
let output = std::process::Command::new(ffmpeg_bin)
.args(["-y", "-f", "concat", "-safe", "0", "-i"])
.arg(&list_path)
.arg("-i")
.arg(ffmeta_path)
.args([
"-map_metadata",
"1",
"-c:a",
"aac",
"-b:a",
"64k",
"-movflags",
"+faststart",
])
.arg(dest)
.output()
.map_err(|e| anyhow!("spawn {ffmpeg_bin}: {e}"))?;
if !output.status.success() {
return Err(anyhow!(
"ffmpeg mux failed: {}",
String::from_utf8_lossy(&output.stderr)
.lines()
.rev()
.take(5)
.collect::<Vec<_>>()
.join(" | "),
));
}
Ok(())
}
pub fn binary_on_path(bin: &str) -> bool {
let Some(paths) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&paths).any(|dir| {
let p = dir.join(bin);
std::fs::metadata(&p).map(|m| m.is_file()).unwrap_or(false)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_strips_leading_heading() {
let body = "= 001. Approach\n\nHelena paused.";
assert_eq!(typst_to_plain(body), "Helena paused.");
}
#[test]
fn plain_removes_emphasis_markers() {
let body = "She was _very_ tired and *quite* done.";
assert_eq!(
typst_to_plain(body),
"She was very tired and quite done.",
);
}
#[test]
fn plain_drops_footnotes() {
let body = "A claim#footnote[a citation] continues.";
assert_eq!(typst_to_plain(body), "A claim continues.");
}
#[test]
fn plain_keeps_subheading_text() {
let body = "Lead.\n\n== A Scene\n\nMore.";
let got = typst_to_plain(body);
assert!(got.contains("A Scene"));
assert!(!got.contains("=="));
}
#[test]
fn plain_collapses_intra_block_newlines() {
let body = "Line one\nline two";
assert_eq!(typst_to_plain(body), "Line one line two");
}
#[test]
fn plain_separates_blocks_with_newline() {
let body = "First.\n\nSecond.";
assert_eq!(typst_to_plain(body), "First.\nSecond.");
}
#[test]
fn plain_empty_body_is_empty() {
assert_eq!(typst_to_plain("= Title\n\n"), "");
}
#[test]
fn ffmeta_accumulates_chapter_timestamps() {
let chapters = vec![
("Arrivals".to_string(), 120.0),
("The Wharf".to_string(), 90.5),
];
let meta = build_ffmetadata("My Book", "Author", &chapters);
assert!(meta.starts_with(";FFMETADATA1"));
assert!(meta.contains("title=My Book"));
assert!(meta.contains("artist=Author"));
assert!(meta.contains("START=0\nEND=120000\ntitle=Arrivals"));
assert!(meta.contains("START=120000\nEND=210500\ntitle=The Wharf"));
assert!(meta.contains("genre=Audiobook"));
}
#[test]
fn ffmeta_escapes_special_chars() {
let chapters = vec![("A=B; #note".to_string(), 1.0)];
let meta = build_ffmetadata("T=t", "x", &chapters);
assert!(meta.contains("title=T\\=t"));
assert!(meta.contains("title=A\\=B\\; \\#note"));
}
#[test]
fn ffmeta_single_chapter_starts_at_zero() {
let chapters = vec![("Only".to_string(), 60.0)];
let meta = build_ffmetadata("B", "A", &chapters);
assert!(meta.contains("START=0\nEND=60000\ntitle=Only"));
}
#[test]
fn parse_duration_reads_float() {
assert_eq!(parse_ffprobe_duration("123.456\n"), Some(123.456));
}
#[test]
fn parse_duration_rejects_garbage() {
assert_eq!(parse_ffprobe_duration("N/A\n"), None);
assert_eq!(parse_ffprobe_duration(""), None);
}
#[test]
fn parse_duration_takes_first_line() {
assert_eq!(parse_ffprobe_duration("12.5\nextra\n"), Some(12.5));
}
#[test]
fn binary_on_path_finds_sh() {
#[cfg(unix)]
assert!(binary_on_path("sh"));
}
#[test]
fn binary_on_path_misses_nonsense() {
assert!(!binary_on_path("definitely-not-a-real-binary-xyzzy"));
}
}