1use anyhow::{Context, Result};
4use std::path::Path;
5use crate::audio::Chapter;
6
7#[derive(Debug, Clone)]
9pub enum ChapterSource {
10 Audnex { asin: String },
12 TextFile { path: std::path::PathBuf },
14 Epub { path: std::path::PathBuf },
16 Existing,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ChapterMergeStrategy {
23 KeepTimestamps,
25 ReplaceAll,
27 SkipOnMismatch,
29 Interactive,
31}
32
33impl std::fmt::Display for ChapterMergeStrategy {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 match self {
36 Self::KeepTimestamps => write!(f, "Keep existing timestamps, update names only"),
37 Self::ReplaceAll => write!(f, "Replace all chapters (timestamps + names)"),
38 Self::SkipOnMismatch => write!(f, "Skip if chapter counts don't match"),
39 Self::Interactive => write!(f, "Ask for each file"),
40 }
41 }
42}
43
44#[derive(Debug)]
46pub struct ChapterComparison {
47 pub existing_count: usize,
48 pub new_count: usize,
49 pub matches: bool,
50}
51
52impl ChapterComparison {
53 pub fn new(existing: &[Chapter], new: &[Chapter]) -> Self {
54 Self {
55 existing_count: existing.len(),
56 new_count: new.len(),
57 matches: existing.len() == new.len(),
58 }
59 }
60}
61
62#[derive(Debug, Clone, Copy)]
64pub enum TextFormat {
65 Simple,
67 Timestamped,
69 Mp4Box,
71}
72
73pub fn parse_text_chapters(path: &Path) -> Result<Vec<Chapter>> {
75 let content = std::fs::read_to_string(path)
76 .context("Failed to read chapter file")?;
77
78 let format = detect_text_format(&content);
80
81 match format {
82 TextFormat::Simple => parse_simple_format(&content),
83 TextFormat::Timestamped => parse_timestamped_format(&content),
84 TextFormat::Mp4Box => parse_mp4box_format(&content),
85 }
86}
87
88fn detect_text_format(content: &str) -> TextFormat {
90 use regex::Regex;
91
92 lazy_static::lazy_static! {
93 static ref MP4BOX_REGEX: Regex = Regex::new(r"CHAPTER\d+=\d{2}:\d{2}:\d{2}").unwrap();
94 static ref TIMESTAMP_REGEX: Regex = Regex::new(r"^\d{1,2}:\d{2}:\d{2}").unwrap();
95 }
96
97 if MP4BOX_REGEX.is_match(content) {
99 return TextFormat::Mp4Box;
100 }
101
102 if let Some(first_line) = content.lines().next() {
104 if TIMESTAMP_REGEX.is_match(first_line.trim()) {
105 return TextFormat::Timestamped;
106 }
107 }
108
109 TextFormat::Simple
111}
112
113fn parse_simple_format(content: &str) -> Result<Vec<Chapter>> {
115 let chapters: Vec<Chapter> = content
116 .lines()
117 .filter(|line| !line.trim().is_empty())
118 .enumerate()
119 .map(|(i, line)| {
120 Chapter::new(
121 (i + 1) as u32,
122 line.trim().to_string(),
123 0, 0,
125 )
126 })
127 .collect();
128
129 if chapters.is_empty() {
130 anyhow::bail!("No chapters found in file");
131 }
132
133 Ok(chapters)
134}
135
136fn parse_timestamped_format(content: &str) -> Result<Vec<Chapter>> {
138 use regex::Regex;
139
140 lazy_static::lazy_static! {
141 static ref TIMESTAMP_REGEX: Regex =
142 Regex::new(r"^(\d{1,2}):(\d{2}):(\d{2})\s*[-:]?\s*(.+)$").unwrap();
143 }
144
145 let mut chapters: Vec<Chapter> = Vec::new();
146
147 for (i, line) in content.lines().enumerate() {
148 let line = line.trim();
149 if line.is_empty() {
150 continue;
151 }
152
153 if let Some(caps) = TIMESTAMP_REGEX.captures(line) {
154 let hours: u64 = caps[1].parse().context("Invalid hour")?;
155 let minutes: u64 = caps[2].parse().context("Invalid minute")?;
156 let seconds: u64 = caps[3].parse().context("Invalid second")?;
157 let title = caps[4].trim().to_string();
158
159 let start_ms = (hours * 3600 + minutes * 60 + seconds) * 1000;
160
161 if !chapters.is_empty() {
163 let prev_idx = chapters.len() - 1;
164 chapters[prev_idx].end_time_ms = start_ms;
165 }
166
167 chapters.push(Chapter::new(
168 (i + 1) as u32,
169 title,
170 start_ms,
171 0, ));
173 } else {
174 tracing::warn!("Skipping malformed line {}: {}", i + 1, line);
175 }
176 }
177
178 if chapters.is_empty() {
179 anyhow::bail!("No valid timestamped chapters found");
180 }
181
182 Ok(chapters)
183}
184
185fn parse_mp4box_format(content: &str) -> Result<Vec<Chapter>> {
187 use regex::Regex;
188
189 lazy_static::lazy_static! {
190 static ref CHAPTER_REGEX: Regex =
191 Regex::new(r"CHAPTER(\d+)=(\d{2}):(\d{2}):(\d{2})\.(\d{3})").unwrap();
192 static ref NAME_REGEX: Regex =
193 Regex::new(r"CHAPTER(\d+)NAME=(.+)").unwrap();
194 }
195
196 let mut chapter_times: std::collections::HashMap<u32, u64> = std::collections::HashMap::new();
197 let mut chapter_names: std::collections::HashMap<u32, String> = std::collections::HashMap::new();
198
199 for line in content.lines() {
200 if let Some(caps) = CHAPTER_REGEX.captures(line) {
201 let num: u32 = caps[1].parse().context("Invalid chapter number")?;
202 let hours: u64 = caps[2].parse().context("Invalid hour")?;
203 let minutes: u64 = caps[3].parse().context("Invalid minute")?;
204 let seconds: u64 = caps[4].parse().context("Invalid second")?;
205 let millis: u64 = caps[5].parse().context("Invalid millisecond")?;
206
207 let start_ms = (hours * 3600 + minutes * 60 + seconds) * 1000 + millis;
208 chapter_times.insert(num, start_ms);
209 }
210
211 if let Some(caps) = NAME_REGEX.captures(line) {
212 let num: u32 = caps[1].parse().context("Invalid chapter number")?;
213 let name = caps[2].trim().to_string();
214 chapter_names.insert(num, name);
215 }
216 }
217
218 if chapter_times.is_empty() {
219 anyhow::bail!("No chapters found in MP4Box format");
220 }
221
222 let mut chapters = Vec::new();
224 let mut numbers: Vec<u32> = chapter_times.keys().copied().collect();
225 numbers.sort();
226
227 for (i, &num) in numbers.iter().enumerate() {
228 let start_ms = *chapter_times.get(&num).unwrap();
229 let title = chapter_names
230 .get(&num)
231 .cloned()
232 .unwrap_or_else(|| format!("Chapter {}", num));
233
234 let end_ms = if i + 1 < numbers.len() {
235 *chapter_times.get(&numbers[i + 1]).unwrap()
236 } else {
237 0 };
239
240 chapters.push(Chapter::new(num, title, start_ms, end_ms));
241 }
242
243 Ok(chapters)
244}
245
246pub fn parse_epub_chapters(path: &Path) -> Result<Vec<Chapter>> {
248 use epub::doc::EpubDoc;
249
250 let doc = EpubDoc::new(path)
251 .context("Failed to open EPUB file")?;
252
253 let toc = doc.toc
254 .iter()
255 .enumerate()
256 .map(|(i, nav_point)| {
257 Chapter::new(
258 (i + 1) as u32,
259 nav_point.label.clone(),
260 0, 0,
262 )
263 })
264 .collect::<Vec<_>>();
265
266 if toc.is_empty() {
267 anyhow::bail!("No chapters found in EPUB table of contents");
268 }
269
270 Ok(toc)
271}
272
273pub async fn read_m4b_chapters(m4b_path: &Path) -> Result<Vec<Chapter>> {
275 use serde::Deserialize;
276 use tokio::process::Command;
277
278 #[derive(Debug, Deserialize)]
279 struct FfprobeChapter {
280 id: i64,
281 #[serde(default)]
282 start_time: String,
283 #[serde(default)]
284 end_time: String,
285 tags: Option<FfprobeTags>,
286 }
287
288 #[derive(Debug, Deserialize)]
289 struct FfprobeTags {
290 title: Option<String>,
291 }
292
293 #[derive(Debug, Deserialize)]
294 struct FfprobeOutput {
295 chapters: Vec<FfprobeChapter>,
296 }
297
298 let output = Command::new("ffprobe")
299 .args([
300 "-v", "quiet",
301 "-print_format", "json",
302 "-show_chapters",
303 ])
304 .arg(m4b_path)
305 .output()
306 .await
307 .context("Failed to execute ffprobe")?;
308
309 if !output.status.success() {
310 anyhow::bail!("ffprobe failed to read chapters from M4B file");
311 }
312
313 let json_str = String::from_utf8(output.stdout)
314 .context("ffprobe output is not valid UTF-8")?;
315
316 let ffprobe_output: FfprobeOutput = serde_json::from_str(&json_str)
317 .context("Failed to parse ffprobe JSON output")?;
318
319 let chapters: Vec<Chapter> = ffprobe_output
320 .chapters
321 .into_iter()
322 .enumerate()
323 .map(|(i, ch)| {
324 let title = ch
325 .tags
326 .and_then(|t| t.title)
327 .unwrap_or_else(|| format!("Chapter {}", i + 1));
328
329 let start_ms = parse_ffprobe_time(&ch.start_time).unwrap_or(0);
330 let end_ms = parse_ffprobe_time(&ch.end_time).unwrap_or(0);
331
332 Chapter::new((i + 1) as u32, title, start_ms, end_ms)
333 })
334 .collect();
335
336 if chapters.is_empty() {
337 tracing::warn!("No chapters found in M4B file");
338 }
339
340 Ok(chapters)
341}
342
343fn parse_ffprobe_time(time_str: &str) -> Option<u64> {
345 let seconds: f64 = time_str.parse().ok()?;
346 Some((seconds * 1000.0) as u64)
347}
348
349pub fn merge_chapters(
351 existing: &[Chapter],
352 new: &[Chapter],
353 strategy: ChapterMergeStrategy,
354) -> Result<Vec<Chapter>> {
355 let comparison = ChapterComparison::new(existing, new);
356
357 match strategy {
358 ChapterMergeStrategy::SkipOnMismatch => {
359 if !comparison.matches {
360 anyhow::bail!(
361 "Chapter count mismatch: existing has {}, new has {}. Skipping update.",
362 comparison.existing_count,
363 comparison.new_count
364 );
365 }
366 merge_keep_timestamps(existing, new)
368 }
369
370 ChapterMergeStrategy::KeepTimestamps => {
371 merge_keep_timestamps(existing, new)
372 }
373
374 ChapterMergeStrategy::ReplaceAll => {
375 Ok(new.to_vec())
377 }
378
379 ChapterMergeStrategy::Interactive => {
380 merge_keep_timestamps(existing, new)
383 }
384 }
385}
386
387fn merge_keep_timestamps(existing: &[Chapter], new: &[Chapter]) -> Result<Vec<Chapter>> {
389 let min_len = existing.len().min(new.len());
390
391 let mut merged: Vec<Chapter> = existing[..min_len]
392 .iter()
393 .zip(&new[..min_len])
394 .map(|(old, new_ch)| {
395 Chapter::new(
396 old.number,
397 new_ch.title.clone(),
398 old.start_time_ms,
399 old.end_time_ms,
400 )
401 })
402 .collect();
403
404 if existing.len() > min_len {
406 merged.extend_from_slice(&existing[min_len..]);
407 }
408
409 Ok(merged)
410}
411
412pub fn merge_chapter_lists(chapter_lists: &[Vec<Chapter>]) -> Vec<Chapter> {
418 if chapter_lists.is_empty() {
419 return Vec::new();
420 }
421
422 if chapter_lists.len() == 1 {
423 return chapter_lists[0].clone();
424 }
425
426 let mut merged = Vec::new();
427 let mut cumulative_offset: u64 = 0;
428 let mut chapter_number: u32 = 1;
429
430 for chapters in chapter_lists {
431 for chapter in chapters {
432 let adjusted_start = chapter.start_time_ms + cumulative_offset;
433 let adjusted_end = chapter.end_time_ms + cumulative_offset;
434
435 merged.push(Chapter::new(
436 chapter_number,
437 chapter.title.clone(),
438 adjusted_start,
439 adjusted_end,
440 ));
441 chapter_number += 1;
442 }
443
444 if let Some(last) = chapters.last() {
446 cumulative_offset += last.end_time_ms;
447 }
448 }
449
450 merged
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 #[test]
458 fn test_chapter_comparison() {
459 let existing = vec![
460 Chapter::new(1, "Ch1".to_string(), 0, 1000),
461 Chapter::new(2, "Ch2".to_string(), 1000, 2000),
462 ];
463
464 let new_matching = vec![
465 Chapter::new(1, "Chapter One".to_string(), 0, 1000),
466 Chapter::new(2, "Chapter Two".to_string(), 1000, 2000),
467 ];
468
469 let new_different = vec![
470 Chapter::new(1, "Chapter One".to_string(), 0, 1000),
471 ];
472
473 let comp1 = ChapterComparison::new(&existing, &new_matching);
474 assert!(comp1.matches);
475 assert_eq!(comp1.existing_count, 2);
476
477 let comp2 = ChapterComparison::new(&existing, &new_different);
478 assert!(!comp2.matches);
479 }
480
481 #[test]
482 fn test_merge_strategy_display() {
483 assert_eq!(
484 ChapterMergeStrategy::KeepTimestamps.to_string(),
485 "Keep existing timestamps, update names only"
486 );
487 }
488
489 #[test]
490 fn test_detect_simple_format() {
491 let content = "Prologue\nChapter 1\nChapter 2";
492 assert!(matches!(detect_text_format(content), TextFormat::Simple));
493 }
494
495 #[test]
496 fn test_detect_timestamped_format() {
497 let content = "00:00:00 Prologue\n00:05:30 Chapter 1";
498 assert!(matches!(detect_text_format(content), TextFormat::Timestamped));
499 }
500
501 #[test]
502 fn test_detect_mp4box_format() {
503 let content = "CHAPTER1=00:00:00.000\nCHAPTER1NAME=Prologue";
504 assert!(matches!(detect_text_format(content), TextFormat::Mp4Box));
505 }
506
507 #[test]
508 fn test_parse_simple_format() {
509 let content = "Prologue\nChapter 1: The Beginning\nChapter 2: The Journey";
510 let chapters = parse_simple_format(content).unwrap();
511
512 assert_eq!(chapters.len(), 3);
513 assert_eq!(chapters[0].title, "Prologue");
514 assert_eq!(chapters[1].title, "Chapter 1: The Beginning");
515 assert_eq!(chapters[2].title, "Chapter 2: The Journey");
516 }
517
518 #[test]
519 fn test_parse_timestamped_format() {
520 let content = "0:00:00 Prologue\n0:05:30 Chapter 1\n0:15:45 Chapter 2";
521 let chapters = parse_timestamped_format(content).unwrap();
522
523 assert_eq!(chapters.len(), 3);
524 assert_eq!(chapters[0].start_time_ms, 0);
525 assert_eq!(chapters[1].start_time_ms, 330_000); assert_eq!(chapters[2].start_time_ms, 945_000); }
528
529 #[test]
530 fn test_parse_mp4box_format() {
531 let content = "CHAPTER1=00:00:00.000\nCHAPTER1NAME=Prologue\nCHAPTER2=00:05:30.500\nCHAPTER2NAME=Chapter 1";
532 let chapters = parse_mp4box_format(content).unwrap();
533
534 assert_eq!(chapters.len(), 2);
535 assert_eq!(chapters[0].title, "Prologue");
536 assert_eq!(chapters[0].start_time_ms, 0);
537 assert_eq!(chapters[1].title, "Chapter 1");
538 assert_eq!(chapters[1].start_time_ms, 330_500);
539 }
540
541 #[test]
542 fn test_parse_epub_chapters() {
543 use std::io::Write;
545 use tempfile::NamedTempFile;
546
547 let mut temp_file = NamedTempFile::new().unwrap();
549 writeln!(temp_file, "Mock EPUB content").unwrap();
550
551 let result = parse_epub_chapters(temp_file.path());
553
554 assert!(result.is_err() || result.unwrap().is_empty());
557 }
558
559 #[test]
560 fn test_merge_keep_timestamps() {
561 let existing = vec![
562 Chapter::new(1, "Chapter 1".to_string(), 0, 1000),
563 Chapter::new(2, "Chapter 2".to_string(), 1000, 2000),
564 Chapter::new(3, "Chapter 3".to_string(), 2000, 3000),
565 ];
566
567 let new = vec![
568 Chapter::new(1, "Prologue".to_string(), 0, 0),
569 Chapter::new(2, "The Beginning".to_string(), 0, 0),
570 Chapter::new(3, "The Journey".to_string(), 0, 0),
571 ];
572
573 let merged = merge_chapters(&existing, &new, ChapterMergeStrategy::KeepTimestamps).unwrap();
574
575 assert_eq!(merged.len(), 3);
576 assert_eq!(merged[0].title, "Prologue");
577 assert_eq!(merged[0].start_time_ms, 0);
578 assert_eq!(merged[0].end_time_ms, 1000);
579 assert_eq!(merged[1].title, "The Beginning");
580 assert_eq!(merged[1].start_time_ms, 1000);
581 assert_eq!(merged[2].title, "The Journey");
582 assert_eq!(merged[2].start_time_ms, 2000);
583 }
584
585 #[test]
586 fn test_merge_replace_all() {
587 let existing = vec![
588 Chapter::new(1, "Chapter 1".to_string(), 0, 1000),
589 Chapter::new(2, "Chapter 2".to_string(), 1000, 2000),
590 ];
591
592 let new = vec![
593 Chapter::new(1, "Prologue".to_string(), 0, 500),
594 Chapter::new(2, "The Beginning".to_string(), 500, 1500),
595 Chapter::new(3, "The Journey".to_string(), 1500, 2500),
596 ];
597
598 let merged = merge_chapters(&existing, &new, ChapterMergeStrategy::ReplaceAll).unwrap();
599
600 assert_eq!(merged.len(), 3);
601 assert_eq!(merged[0].title, "Prologue");
602 assert_eq!(merged[0].start_time_ms, 0);
603 assert_eq!(merged[0].end_time_ms, 500);
604 assert_eq!(merged[2].title, "The Journey");
605 }
606
607 #[test]
608 fn test_merge_skip_on_mismatch() {
609 let existing = vec![
610 Chapter::new(1, "Chapter 1".to_string(), 0, 1000),
611 Chapter::new(2, "Chapter 2".to_string(), 1000, 2000),
612 ];
613
614 let new = vec![
615 Chapter::new(1, "Prologue".to_string(), 0, 0),
616 ];
617
618 let result = merge_chapters(&existing, &new, ChapterMergeStrategy::SkipOnMismatch);
619
620 assert!(result.is_err());
621 let err = result.unwrap_err();
622 assert!(err.to_string().contains("Chapter count mismatch"));
623 }
624
625 #[test]
626 fn test_merge_keep_timestamps_with_extra_existing() {
627 let existing = vec![
628 Chapter::new(1, "Chapter 1".to_string(), 0, 1000),
629 Chapter::new(2, "Chapter 2".to_string(), 1000, 2000),
630 Chapter::new(3, "Chapter 3".to_string(), 2000, 3000),
631 ];
632
633 let new = vec![
634 Chapter::new(1, "Prologue".to_string(), 0, 0),
635 ];
636
637 let merged = merge_chapters(&existing, &new, ChapterMergeStrategy::KeepTimestamps).unwrap();
638
639 assert_eq!(merged.len(), 3);
641 assert_eq!(merged[0].title, "Prologue");
642 assert_eq!(merged[1].title, "Chapter 2");
643 assert_eq!(merged[2].title, "Chapter 3");
644 }
645
646 #[test]
647 fn test_parse_ffprobe_time() {
648 assert_eq!(parse_ffprobe_time("0.000000"), Some(0));
649 assert_eq!(parse_ffprobe_time("5.5"), Some(5500));
650 assert_eq!(parse_ffprobe_time("330.500"), Some(330_500));
651 assert_eq!(parse_ffprobe_time("3661.250"), Some(3_661_250)); assert_eq!(parse_ffprobe_time("invalid"), None);
653 assert_eq!(parse_ffprobe_time(""), None);
654 }
655
656 #[test]
657 fn test_merge_chapter_lists_with_offset() {
658 let chapters1 = vec![
659 Chapter::new(1, "Part1 Ch1".to_string(), 0, 60_000),
660 Chapter::new(2, "Part1 Ch2".to_string(), 60_000, 120_000),
661 ];
662 let chapters2 = vec![
663 Chapter::new(1, "Part2 Ch1".to_string(), 0, 45_000),
664 Chapter::new(2, "Part2 Ch2".to_string(), 45_000, 90_000),
665 ];
666
667 let merged = merge_chapter_lists(&[chapters1, chapters2]);
668
669 assert_eq!(merged.len(), 4);
670 assert_eq!(merged[0].title, "Part1 Ch1");
672 assert_eq!(merged[0].start_time_ms, 0);
673 assert_eq!(merged[0].end_time_ms, 60_000);
674 assert_eq!(merged[1].title, "Part1 Ch2");
675 assert_eq!(merged[1].start_time_ms, 60_000);
676 assert_eq!(merged[1].end_time_ms, 120_000);
677 assert_eq!(merged[2].title, "Part2 Ch1");
679 assert_eq!(merged[2].start_time_ms, 120_000);
680 assert_eq!(merged[2].end_time_ms, 165_000); assert_eq!(merged[3].title, "Part2 Ch2");
682 assert_eq!(merged[3].start_time_ms, 165_000);
683 assert_eq!(merged[3].end_time_ms, 210_000); assert_eq!(merged[0].number, 1);
686 assert_eq!(merged[1].number, 2);
687 assert_eq!(merged[2].number, 3);
688 assert_eq!(merged[3].number, 4);
689 }
690
691 #[test]
692 fn test_merge_chapter_lists_empty() {
693 let result = merge_chapter_lists(&[]);
694 assert!(result.is_empty());
695 }
696
697 #[test]
698 fn test_merge_chapter_lists_single() {
699 let chapters = vec![
700 Chapter::new(1, "Ch1".to_string(), 0, 1000),
701 Chapter::new(2, "Ch2".to_string(), 1000, 2000),
702 ];
703 let result = merge_chapter_lists(&[chapters.clone()]);
704 assert_eq!(result.len(), 2);
705 assert_eq!(result[0].title, "Ch1");
706 assert_eq!(result[1].title, "Ch2");
707 }
708}