audiobook_forge/audio/
chapter_import.rs

1//! Chapter import and merge strategies
2
3use anyhow::{Context, Result};
4use std::path::Path;
5use crate::audio::Chapter;
6
7/// Source of chapter data
8#[derive(Debug, Clone)]
9pub enum ChapterSource {
10    /// Fetch from Audnex API by ASIN
11    Audnex { asin: String },
12    /// Parse from text file
13    TextFile { path: std::path::PathBuf },
14    /// Extract from EPUB file
15    Epub { path: std::path::PathBuf },
16    /// Use existing chapters from M4B
17    Existing,
18}
19
20/// Strategy for merging new chapters with existing ones
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ChapterMergeStrategy {
23    /// Keep existing timestamps, only update names
24    KeepTimestamps,
25    /// Replace both timestamps and names entirely
26    ReplaceAll,
27    /// Skip update if counts don't match
28    SkipOnMismatch,
29    /// Interactively ask user for each file
30    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/// Result of comparing existing vs new chapters
45#[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/// Supported text file formats for chapter import
63#[derive(Debug, Clone, Copy)]
64pub enum TextFormat {
65    /// One title per line
66    Simple,
67    /// Timestamps + titles (e.g., "00:00:00 Prologue")
68    Timestamped,
69    /// MP4Box format (CHAPTER1=00:00:00\nCHAPTER1NAME=Title)
70    Mp4Box,
71}
72
73/// Parse chapters from text file
74pub 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    // Auto-detect format
79    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
88/// Detect text file format
89fn 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    // Check for MP4Box format
98    if MP4BOX_REGEX.is_match(content) {
99        return TextFormat::Mp4Box;
100    }
101
102    // Check for timestamped format (first line)
103    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    // Default to simple
110    TextFormat::Simple
111}
112
113/// Parse simple format (one title per line)
114fn 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, // No timestamps in simple format
124                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
136/// Parse timestamped format (HH:MM:SS Title)
137fn 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            // Set end time for previous chapter
162            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, // Will be set by next chapter or total duration
172            ));
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
185/// Parse MP4Box format
186fn 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    // Build chapters
223    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 // Will be set later
238        };
239
240        chapters.push(Chapter::new(num, title, start_ms, end_ms));
241    }
242
243    Ok(chapters)
244}
245
246/// Parse chapters from EPUB file (extracts from Table of Contents)
247pub 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, // No timestamps in EPUB
261                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
273/// Read existing chapters from M4B file using ffprobe
274pub 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
343/// Parse ffprobe timestamp string (seconds.microseconds) to milliseconds
344fn 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
349/// Merge new chapters with existing chapters according to strategy
350pub 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            // If counts match, fall through to KeepTimestamps behavior
367            merge_keep_timestamps(existing, new)
368        }
369
370        ChapterMergeStrategy::KeepTimestamps => {
371            merge_keep_timestamps(existing, new)
372        }
373
374        ChapterMergeStrategy::ReplaceAll => {
375            // Simply return new chapters
376            Ok(new.to_vec())
377        }
378
379        ChapterMergeStrategy::Interactive => {
380            // This will be handled at a higher level (CLI handler)
381            // For now, default to KeepTimestamps
382            merge_keep_timestamps(existing, new)
383        }
384    }
385}
386
387/// Helper: Keep existing timestamps, update names only
388fn 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 there are extra existing chapters beyond new chapters, keep them
405    if existing.len() > min_len {
406        merged.extend_from_slice(&existing[min_len..]);
407    }
408
409    Ok(merged)
410}
411
412/// Merge chapter lists from multiple M4B files with adjusted timestamps
413///
414/// Takes a slice of chapter lists (one per M4B file) and combines them into
415/// a single list with correctly offset timestamps. Each subsequent file's
416/// chapters are offset by the cumulative duration of previous files.
417pub 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        // Update cumulative offset based on the last chapter's end time
445        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); // 5:30
526        assert_eq!(chapters[2].start_time_ms, 945_000); // 15:45
527    }
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        // This test will fail until we implement parse_epub_chapters()
544        use std::io::Write;
545        use tempfile::NamedTempFile;
546
547        // Create a minimal EPUB-like structure (won't be a real EPUB)
548        let mut temp_file = NamedTempFile::new().unwrap();
549        writeln!(temp_file, "Mock EPUB content").unwrap();
550
551        // This should fail with "not implemented" or similar
552        let result = parse_epub_chapters(temp_file.path());
553
554        // For now, we expect it to fail (function doesn't exist yet)
555        // Once implemented, this will extract chapter titles from EPUB ToC
556        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        // Should merge first chapter and keep the remaining existing ones
640        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)); // 1h 1m 1.25s
652        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        // First file's chapters unchanged
671        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        // Second file's chapters offset by part 1 duration (120_000)
678        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); // 120_000 + 45_000
681        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); // 120_000 + 90_000
684        // Renumbered sequentially
685        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}