audiobook_forge/audio/
chapters.rs1use anyhow::{Context, Result};
4use regex::Regex;
5use std::path::Path;
6
7#[derive(Debug, Clone)]
9pub struct Chapter {
10 pub number: u32,
12 pub title: String,
14 pub start_time_ms: u64,
16 pub end_time_ms: u64,
18}
19
20impl Chapter {
21 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 pub fn duration_ms(&self) -> u64 {
33 self.end_time_ms - self.start_time_ms
34 }
35
36 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
44fn 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
55pub fn generate_chapters_from_files(
57 files: &[&Path],
58 durations: &[f64], ) -> Vec<Chapter> {
60 tracing::debug!("Generating chapters from {} files", files.len());
61
62 let mut chapters = Vec::new();
63 let mut current_time_ms: u64 = 0;
64
65 for (i, (file, &duration_secs)) in files.iter().zip(durations.iter()).enumerate() {
66 let duration_ms = (duration_secs * 1000.0) as u64;
67 let title = file
68 .file_stem()
69 .and_then(|s| s.to_str())
70 .unwrap_or(&format!("Chapter {}", i + 1))
71 .to_string();
72
73 let chapter = Chapter::new(
74 (i + 1) as u32,
75 title,
76 current_time_ms,
77 current_time_ms + duration_ms,
78 );
79
80 chapters.push(chapter);
81 current_time_ms += duration_ms;
82 }
83
84 tracing::debug!("Generated {} chapters", chapters.len());
85
86 chapters
87}
88
89pub fn parse_cue_file(cue_path: &Path) -> Result<Vec<Chapter>> {
91 let content = std::fs::read_to_string(cue_path)
92 .context("Failed to read CUE file")?;
93
94 let mut chapters = Vec::new();
95 let mut current_chapter = 1u32;
96
97 let track_regex = Regex::new(r"^\s*TRACK\s+(\d+)\s+AUDIO").unwrap();
99 let title_regex = Regex::new(r#"^\s*TITLE\s+"(.+)""#).unwrap();
100 let index_regex = Regex::new(r"^\s*INDEX\s+01\s+(\d+):(\d+):(\d+)").unwrap();
101
102 let mut current_title: Option<String> = None;
103 let mut current_time_ms: Option<u64> = None;
104 let mut last_chapter_start: u64 = 0;
105
106 for line in content.lines() {
107 if track_regex.is_match(line) {
109 if let (Some(title), Some(time_ms)) = (current_title.take(), current_time_ms.take()) {
111 chapters.push(Chapter::new(
113 current_chapter - 1,
114 title,
115 last_chapter_start,
116 time_ms,
117 ));
118 last_chapter_start = time_ms;
119 }
120 }
121
122 if let Some(caps) = title_regex.captures(line) {
124 current_title = Some(caps[1].to_string());
125 }
126
127 if let Some(caps) = index_regex.captures(line) {
129 let minutes: u64 = caps[1].parse().unwrap_or(0);
130 let seconds: u64 = caps[2].parse().unwrap_or(0);
131 let frames: u64 = caps[3].parse().unwrap_or(0);
132
133 let time_ms = (minutes * 60 * 1000) + (seconds * 1000) + ((frames * 1000) / 75);
135 current_time_ms = Some(time_ms);
136 current_chapter += 1;
137 }
138 }
139
140 if let (Some(title), Some(time_ms)) = (current_title, current_time_ms) {
142 chapters.push(Chapter::new(
143 current_chapter - 1,
144 title,
145 last_chapter_start,
146 time_ms,
147 ));
148 }
149
150 Ok(chapters)
151}
152
153pub fn write_mp4box_chapters(chapters: &[Chapter], output_path: &Path) -> Result<()> {
155 let mut content = String::new();
156
157 for chapter in chapters {
158 content.push_str(&chapter.to_mp4box_format());
159 }
160
161 std::fs::write(output_path, content)
162 .context("Failed to write chapter file")?;
163
164 Ok(())
165}
166
167pub async fn inject_chapters_mp4box(
169 m4b_file: &Path,
170 chapters_file: &Path,
171) -> Result<()> {
172 let output = tokio::process::Command::new("MP4Box")
173 .args(&["-chap", &chapters_file.display().to_string()])
174 .arg(m4b_file)
175 .output()
176 .await
177 .context("Failed to execute MP4Box")?;
178
179 if !output.status.success() {
180 let stderr = String::from_utf8_lossy(&output.stderr);
181 anyhow::bail!("MP4Box failed: {}", stderr);
182 }
183
184 Ok(())
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use std::path::PathBuf;
191
192 #[test]
193 fn test_format_time_ms() {
194 assert_eq!(format_time_ms(0), "00:00:00.000");
195 assert_eq!(format_time_ms(1000), "00:00:01.000");
196 assert_eq!(format_time_ms(60000), "00:01:00.000");
197 assert_eq!(format_time_ms(3661500), "01:01:01.500");
198 }
199
200 #[test]
201 fn test_chapter_creation() {
202 let chapter = Chapter::new(1, "Introduction".to_string(), 0, 60000);
203 assert_eq!(chapter.number, 1);
204 assert_eq!(chapter.title, "Introduction");
205 assert_eq!(chapter.duration_ms(), 60000);
206 }
207
208 #[test]
209 fn test_generate_chapters_from_files() {
210 let files = vec![
211 Path::new("chapter01.mp3"),
212 Path::new("chapter02.mp3"),
213 Path::new("chapter03.mp3"),
214 ];
215 let durations = vec![120.5, 180.3, 95.7]; let chapters = generate_chapters_from_files(&files, &durations);
218
219 assert_eq!(chapters.len(), 3);
220 assert_eq!(chapters[0].title, "chapter01");
221 assert_eq!(chapters[0].start_time_ms, 0);
222 assert_eq!(chapters[1].start_time_ms, 120500);
223 assert_eq!(chapters[2].start_time_ms, 300800); }
225
226 #[test]
227 fn test_chapter_mp4box_format() {
228 let chapter = Chapter::new(1, "Test Chapter".to_string(), 0, 60000);
229 let formatted = chapter.to_mp4box_format();
230
231 assert!(formatted.contains("CHAPTER1=00:00:00.000"));
232 assert!(formatted.contains("CHAPTER1NAME=Test Chapter"));
233 }
234}