audiobook_forge/core/
m4b_merger.rs1use 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
10pub struct M4bMerger {
12 ffmpeg: FFmpeg,
13 keep_temp: bool,
14}
15
16impl M4bMerger {
17 pub fn new() -> Result<Self> {
19 Ok(Self {
20 ffmpeg: FFmpeg::new()?,
21 keep_temp: false,
22 })
23 }
24
25 pub fn with_options(keep_temp: bool) -> Result<Self> {
27 Ok(Self {
28 ffmpeg: FFmpeg::new()?,
29 keep_temp,
30 })
31 }
32
33 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_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 let temp_dir = self.create_temp_dir(&book_folder.name)?;
52
53 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 let merged_chapters = merge_chapter_lists(&all_chapters);
80 tracing::info!("Total merged chapters: {}", merged_chapters.len());
81
82 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 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 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 tracing::info!("Copying metadata from first source file...");
112 self.copy_metadata_from_first(&m4b_files[0], &output_path, book_folder).await?;
113
114 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 async fn copy_metadata_from_first(
128 &self,
129 source: &Path,
130 output: &Path,
131 book_folder: &BookFolder,
132 ) -> Result<()> {
133 let metadata = self.ffmpeg.probe_metadata(source).await?;
135
136 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 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}