audiobook_forge/core/
m4b_merger.rs

1//! M4B file merger for lossless concatenation
2
3use crate::audio::{read_m4b_chapters, merge_chapter_lists, Chapter, FFmpeg};
4use crate::audio::{write_mp4box_chapters, inject_chapters_mp4box, inject_metadata_atomicparsley};
5use crate::models::BookFolder;
6use crate::utils::sort_by_part_number;
7use anyhow::{Context, Result};
8use std::path::{Path, PathBuf};
9
10/// Merger for combining multiple M4B files
11pub struct M4bMerger {
12    ffmpeg: FFmpeg,
13    keep_temp: bool,
14}
15
16impl M4bMerger {
17    /// Create a new M4B merger
18    pub fn new() -> Result<Self> {
19        Ok(Self {
20            ffmpeg: FFmpeg::new()?,
21            keep_temp: false,
22        })
23    }
24
25    /// Create merger with options
26    pub fn with_options(keep_temp: bool) -> Result<Self> {
27        Ok(Self {
28            ffmpeg: FFmpeg::new()?,
29            keep_temp,
30        })
31    }
32
33    /// Merge multiple M4B files into one
34    pub async fn merge_m4b_files(
35        &self,
36        book_folder: &BookFolder,
37        output_dir: &Path,
38    ) -> Result<PathBuf> {
39        let mut m4b_files = book_folder.m4b_files.clone();
40
41        // Sort files by part number
42        sort_by_part_number(&mut m4b_files);
43
44        tracing::info!(
45            "Merging {} M4B files for: {}",
46            m4b_files.len(),
47            book_folder.name
48        );
49
50        // Create temp directory
51        let temp_dir = self.create_temp_dir(&book_folder.name)?;
52
53        // Step 1: Extract chapters from all files
54        tracing::info!("Extracting chapters from source files...");
55        let mut all_chapters: Vec<Vec<Chapter>> = Vec::new();
56
57        for m4b_file in &m4b_files {
58            match read_m4b_chapters(m4b_file).await {
59                Ok(chapters) => {
60                    tracing::debug!(
61                        "  {} chapters from: {}",
62                        chapters.len(),
63                        m4b_file.file_name().unwrap_or_default().to_string_lossy()
64                    );
65                    all_chapters.push(chapters);
66                }
67                Err(e) => {
68                    tracing::warn!(
69                        "Could not read chapters from {}: {}",
70                        m4b_file.display(),
71                        e
72                    );
73                    all_chapters.push(Vec::new());
74                }
75            }
76        }
77
78        // Merge chapter lists with adjusted timestamps
79        let merged_chapters = merge_chapter_lists(&all_chapters);
80        tracing::info!("Total merged chapters: {}", merged_chapters.len());
81
82        // Step 2: Create concat file for FFmpeg
83        let concat_file = temp_dir.join("concat.txt");
84        let file_refs: Vec<&Path> = m4b_files.iter().map(|p| p.as_path()).collect();
85        FFmpeg::create_concat_file(&file_refs, &concat_file)?;
86
87        // Step 3: Concatenate audio losslessly
88        let output_filename = book_folder.get_output_filename();
89        let output_path = output_dir.join(&output_filename);
90
91        tracing::info!("Concatenating audio (lossless copy mode)...");
92
93        self.ffmpeg
94            .concat_m4b_files(&concat_file, &output_path)
95            .await
96            .context("Failed to concatenate M4B files")?;
97
98        // Step 4: Inject merged chapters
99        if !merged_chapters.is_empty() {
100            tracing::info!("Injecting {} merged chapters...", merged_chapters.len());
101
102            let chapters_file = temp_dir.join("chapters.txt");
103            write_mp4box_chapters(&merged_chapters, &chapters_file)?;
104
105            inject_chapters_mp4box(&output_path, &chapters_file)
106                .await
107                .context("Failed to inject chapters")?;
108        }
109
110        // Step 5: Copy metadata from first file
111        tracing::info!("Copying metadata from first source file...");
112        self.copy_metadata_from_first(&m4b_files[0], &output_path, book_folder).await?;
113
114        // Clean up
115        if !self.keep_temp {
116            if let Err(e) = std::fs::remove_dir_all(&temp_dir) {
117                tracing::warn!("Failed to remove temp directory: {}", e);
118            }
119        }
120
121        tracing::info!("M4B merge complete: {}", output_path.display());
122
123        Ok(output_path)
124    }
125
126    /// Copy metadata from first source file to output
127    async fn copy_metadata_from_first(
128        &self,
129        source: &Path,
130        output: &Path,
131        book_folder: &BookFolder,
132    ) -> Result<()> {
133        // Extract metadata from first file using ffprobe
134        let metadata = self.ffmpeg.probe_metadata(source).await?;
135
136        // Use folder name as title if not in metadata
137        let title = metadata.title.or_else(|| Some(book_folder.name.clone()));
138        let artist = metadata.artist;
139        let album = metadata.album.or_else(|| title.clone());
140        let year = metadata.year;
141        let genre = metadata.genre;
142
143        inject_metadata_atomicparsley(
144            output,
145            title.as_deref(),
146            artist.as_deref(),
147            album.as_deref(),
148            year,
149            genre.as_deref(),
150            book_folder.cover_file.as_deref(),
151        )
152        .await
153        .context("Failed to inject metadata")?;
154
155        Ok(())
156    }
157
158    /// Create temporary directory
159    fn create_temp_dir(&self, book_name: &str) -> Result<PathBuf> {
160        let temp_base = std::env::temp_dir();
161        let sanitized_name = sanitize_filename::sanitize(book_name);
162        let temp_dir = temp_base.join(format!("audiobook-forge-merge-{}", sanitized_name));
163
164        if temp_dir.exists() {
165            std::fs::remove_dir_all(&temp_dir).ok();
166        }
167
168        std::fs::create_dir_all(&temp_dir).context("Failed to create temp directory")?;
169
170        Ok(temp_dir)
171    }
172}
173
174impl Default for M4bMerger {
175    fn default() -> Self {
176        Self::new().expect("Failed to create M4B merger")
177    }
178}