audiobook_forge/audio/
chapters.rs

1//! Chapter generation and management
2
3use anyhow::{Context, Result};
4use regex::Regex;
5use std::path::Path;
6
7/// Represents a chapter in an audiobook
8#[derive(Debug, Clone)]
9pub struct Chapter {
10    /// Chapter number (1-based)
11    pub number: u32,
12    /// Chapter title
13    pub title: String,
14    /// Start time in milliseconds
15    pub start_time_ms: u64,
16    /// End time in milliseconds
17    pub end_time_ms: u64,
18}
19
20impl Chapter {
21    /// Create a new chapter
22    pub fn new(number: u32, title: String, start_time_ms: u64, end_time_ms: u64) -> Self {
23        Self {
24            number,
25            title,
26            start_time_ms,
27            end_time_ms,
28        }
29    }
30
31    /// Get duration in milliseconds
32    pub fn duration_ms(&self) -> u64 {
33        self.end_time_ms - self.start_time_ms
34    }
35
36    /// Format as MP4Box chapter format
37    pub fn to_mp4box_format(&self) -> String {
38        let start_time = format_time_ms(self.start_time_ms);
39        format!("CHAPTER{}={}\nCHAPTER{}NAME={}\n",
40            self.number, start_time, self.number, self.title)
41    }
42}
43
44/// Format milliseconds as HH:MM:SS.mmm
45fn format_time_ms(ms: u64) -> String {
46    let total_seconds = ms / 1000;
47    let milliseconds = ms % 1000;
48    let hours = total_seconds / 3600;
49    let minutes = (total_seconds % 3600) / 60;
50    let seconds = total_seconds % 60;
51
52    format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, milliseconds)
53}
54
55/// Generate chapters from file list (one file = one chapter)
56pub fn generate_chapters_from_files(
57    files: &[&Path],
58    durations: &[f64], // Duration in seconds for each file
59) -> Vec<Chapter> {
60    let mut chapters = Vec::new();
61    let mut current_time_ms: u64 = 0;
62
63    for (i, (file, &duration_secs)) in files.iter().zip(durations.iter()).enumerate() {
64        let duration_ms = (duration_secs * 1000.0) as u64;
65        let title = file
66            .file_stem()
67            .and_then(|s| s.to_str())
68            .unwrap_or(&format!("Chapter {}", i + 1))
69            .to_string();
70
71        let chapter = Chapter::new(
72            (i + 1) as u32,
73            title,
74            current_time_ms,
75            current_time_ms + duration_ms,
76        );
77
78        chapters.push(chapter);
79        current_time_ms += duration_ms;
80    }
81
82    chapters
83}
84
85/// Parse CUE file and extract chapters
86pub fn parse_cue_file(cue_path: &Path) -> Result<Vec<Chapter>> {
87    let content = std::fs::read_to_string(cue_path)
88        .context("Failed to read CUE file")?;
89
90    let mut chapters = Vec::new();
91    let mut current_chapter = 1u32;
92
93    // Regex patterns for CUE file parsing
94    let track_regex = Regex::new(r"^\s*TRACK\s+(\d+)\s+AUDIO").unwrap();
95    let title_regex = Regex::new(r#"^\s*TITLE\s+"(.+)""#).unwrap();
96    let index_regex = Regex::new(r"^\s*INDEX\s+01\s+(\d+):(\d+):(\d+)").unwrap();
97
98    let mut current_title: Option<String> = None;
99    let mut current_time_ms: Option<u64> = None;
100    let mut last_chapter_start: u64 = 0;
101
102    for line in content.lines() {
103        // Check for TRACK line
104        if track_regex.is_match(line) {
105            // Save previous chapter if we have one
106            if let (Some(title), Some(time_ms)) = (current_title.take(), current_time_ms.take()) {
107                // We don't know the end time yet, will be filled when we see the next chapter
108                chapters.push(Chapter::new(
109                    current_chapter - 1,
110                    title,
111                    last_chapter_start,
112                    time_ms,
113                ));
114                last_chapter_start = time_ms;
115            }
116        }
117
118        // Check for TITLE line
119        if let Some(caps) = title_regex.captures(line) {
120            current_title = Some(caps[1].to_string());
121        }
122
123        // Check for INDEX line
124        if let Some(caps) = index_regex.captures(line) {
125            let minutes: u64 = caps[1].parse().unwrap_or(0);
126            let seconds: u64 = caps[2].parse().unwrap_or(0);
127            let frames: u64 = caps[3].parse().unwrap_or(0);
128
129            // CUE uses frames (75 frames per second)
130            let time_ms = (minutes * 60 * 1000) + (seconds * 1000) + ((frames * 1000) / 75);
131            current_time_ms = Some(time_ms);
132            current_chapter += 1;
133        }
134    }
135
136    // Add the last chapter (end time will be set later based on total duration)
137    if let (Some(title), Some(time_ms)) = (current_title, current_time_ms) {
138        chapters.push(Chapter::new(
139            current_chapter - 1,
140            title,
141            last_chapter_start,
142            time_ms,
143        ));
144    }
145
146    Ok(chapters)
147}
148
149/// Write chapters to MP4Box chapter file format
150pub fn write_mp4box_chapters(chapters: &[Chapter], output_path: &Path) -> Result<()> {
151    let mut content = String::new();
152
153    for chapter in chapters {
154        content.push_str(&chapter.to_mp4box_format());
155    }
156
157    std::fs::write(output_path, content)
158        .context("Failed to write chapter file")?;
159
160    Ok(())
161}
162
163/// Inject chapters into M4B file using MP4Box
164pub async fn inject_chapters_mp4box(
165    m4b_file: &Path,
166    chapters_file: &Path,
167) -> Result<()> {
168    let output = tokio::process::Command::new("MP4Box")
169        .args(&["-chap", &chapters_file.display().to_string()])
170        .arg(m4b_file)
171        .output()
172        .await
173        .context("Failed to execute MP4Box")?;
174
175    if !output.status.success() {
176        let stderr = String::from_utf8_lossy(&output.stderr);
177        anyhow::bail!("MP4Box failed: {}", stderr);
178    }
179
180    Ok(())
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use std::path::PathBuf;
187
188    #[test]
189    fn test_format_time_ms() {
190        assert_eq!(format_time_ms(0), "00:00:00.000");
191        assert_eq!(format_time_ms(1000), "00:00:01.000");
192        assert_eq!(format_time_ms(60000), "00:01:00.000");
193        assert_eq!(format_time_ms(3661500), "01:01:01.500");
194    }
195
196    #[test]
197    fn test_chapter_creation() {
198        let chapter = Chapter::new(1, "Introduction".to_string(), 0, 60000);
199        assert_eq!(chapter.number, 1);
200        assert_eq!(chapter.title, "Introduction");
201        assert_eq!(chapter.duration_ms(), 60000);
202    }
203
204    #[test]
205    fn test_generate_chapters_from_files() {
206        let files = vec![
207            Path::new("chapter01.mp3"),
208            Path::new("chapter02.mp3"),
209            Path::new("chapter03.mp3"),
210        ];
211        let durations = vec![120.5, 180.3, 95.7]; // seconds
212
213        let chapters = generate_chapters_from_files(&files, &durations);
214
215        assert_eq!(chapters.len(), 3);
216        assert_eq!(chapters[0].title, "chapter01");
217        assert_eq!(chapters[0].start_time_ms, 0);
218        assert_eq!(chapters[1].start_time_ms, 120500);
219        assert_eq!(chapters[2].start_time_ms, 300800); // 120.5 + 180.3 = 300.8s
220    }
221
222    #[test]
223    fn test_chapter_mp4box_format() {
224        let chapter = Chapter::new(1, "Test Chapter".to_string(), 0, 60000);
225        let formatted = chapter.to_mp4box_format();
226
227        assert!(formatted.contains("CHAPTER1=00:00:00.000"));
228        assert!(formatted.contains("CHAPTER1NAME=Test Chapter"));
229    }
230}