use crate::audio::{read_m4b_chapters, merge_chapter_lists, Chapter, FFmpeg};
use crate::audio::{write_mp4box_chapters, inject_chapters_mp4box, inject_metadata_atomicparsley};
use crate::models::BookFolder;
use crate::utils::sort_by_part_number;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
pub struct M4bMerger {
ffmpeg: FFmpeg,
keep_temp: bool,
}
impl M4bMerger {
pub fn new() -> Result<Self> {
Ok(Self {
ffmpeg: FFmpeg::new()?,
keep_temp: false,
})
}
pub fn with_options(keep_temp: bool) -> Result<Self> {
Ok(Self {
ffmpeg: FFmpeg::new()?,
keep_temp,
})
}
pub async fn merge_m4b_files(
&self,
book_folder: &BookFolder,
output_dir: &Path,
) -> Result<PathBuf> {
let mut m4b_files = book_folder.m4b_files.clone();
sort_by_part_number(&mut m4b_files);
tracing::info!(
"Merging {} M4B files for: {}",
m4b_files.len(),
book_folder.name
);
let temp_dir = self.create_temp_dir(&book_folder.name)?;
tracing::info!("Extracting chapters from source files...");
let mut all_chapters: Vec<Vec<Chapter>> = Vec::new();
for m4b_file in &m4b_files {
match read_m4b_chapters(m4b_file).await {
Ok(chapters) => {
tracing::debug!(
" {} chapters from: {}",
chapters.len(),
m4b_file.file_name().unwrap_or_default().to_string_lossy()
);
all_chapters.push(chapters);
}
Err(e) => {
tracing::warn!(
"Could not read chapters from {}: {}",
m4b_file.display(),
e
);
all_chapters.push(Vec::new());
}
}
}
let merged_chapters = merge_chapter_lists(&all_chapters);
tracing::info!("Total merged chapters: {}", merged_chapters.len());
let concat_file = temp_dir.join("concat.txt");
let file_refs: Vec<&Path> = m4b_files.iter().map(|p| p.as_path()).collect();
FFmpeg::create_concat_file(&file_refs, &concat_file)?;
let output_filename = book_folder.get_output_filename();
let output_path = output_dir.join(&output_filename);
tracing::info!("Concatenating audio (lossless copy mode)...");
self.ffmpeg
.concat_m4b_files(&concat_file, &output_path)
.await
.context("Failed to concatenate M4B files")?;
if !merged_chapters.is_empty() {
tracing::info!("Injecting {} merged chapters...", merged_chapters.len());
let chapters_file = temp_dir.join("chapters.txt");
write_mp4box_chapters(&merged_chapters, &chapters_file)?;
inject_chapters_mp4box(&output_path, &chapters_file)
.await
.context("Failed to inject chapters")?;
}
tracing::info!("Copying metadata from first source file...");
self.copy_metadata_from_first(&m4b_files[0], &output_path, book_folder).await?;
if !self.keep_temp {
if let Err(e) = std::fs::remove_dir_all(&temp_dir) {
tracing::warn!("Failed to remove temp directory: {}", e);
}
}
tracing::info!("M4B merge complete: {}", output_path.display());
Ok(output_path)
}
async fn copy_metadata_from_first(
&self,
source: &Path,
output: &Path,
book_folder: &BookFolder,
) -> Result<()> {
let metadata = self.ffmpeg.probe_metadata(source).await?;
let title = metadata.title.or_else(|| Some(book_folder.name.clone()));
let artist = metadata.artist;
let album = metadata.album.or_else(|| title.clone());
let album_artist = metadata.album_artist;
let year = metadata.year;
let genre = metadata.genre;
let composer = metadata.composer;
let comment = metadata.comment;
inject_metadata_atomicparsley(
output,
title.as_deref(),
artist.as_deref(),
album.as_deref(),
album_artist.as_deref(),
year,
genre.as_deref(),
composer.as_deref(),
comment.as_deref(),
book_folder.cover_file.as_deref(),
)
.await
.context("Failed to inject metadata")?;
Ok(())
}
fn create_temp_dir(&self, book_name: &str) -> Result<PathBuf> {
let temp_base = std::env::temp_dir();
let sanitized_name = sanitize_filename::sanitize(book_name);
let temp_dir = temp_base.join(format!("audiobook-forge-merge-{}", sanitized_name));
if temp_dir.exists() {
std::fs::remove_dir_all(&temp_dir).ok();
}
std::fs::create_dir_all(&temp_dir).context("Failed to create temp directory")?;
Ok(temp_dir)
}
}
impl Default for M4bMerger {
fn default() -> Self {
Self::new().expect("Failed to create M4B merger")
}
}