Add support for embedding cover images in M4B files and update documentation

This commit is contained in:
Deniz Şafak
2025-11-19 01:03:10 +03:00
parent c72c8e777c
commit 10d3f470e7
5 changed files with 116 additions and 23 deletions
+1
View File
@@ -4,6 +4,7 @@
- New option: **"Subtitle speed adjustment method"**: Choose how to speed up audio when needed: - New option: **"Subtitle speed adjustment method"**: Choose how to speed up audio when needed:
- **TTS Regeneration (better quality):** Re-generates the audio at a faster speed for more natural sound. - **TTS Regeneration (better quality):** Re-generates the audio at a faster speed for more natural sound.
- **FFmpeg Time-stretch (better speed):** Quickly speeds up the generated audio. - **FFmpeg Time-stretch (better speed):** Quickly speeds up the generated audio.
- Added support for embedding cover images in M4B files. Abogen now automatically extracts cover images from EPUB and PDF files. You can also manually specify a cover image using the `<<METADATA_COVER_PATH:path>>` tag in your text file.
- Fixed `[WinError 1114] A dynamic link library (DLL) initialization routine failed` error on Windows, pre-loading PyTorch DLLs before initializing PyQt6 to avoid DLL initialization errors, mentioned in #98 by @ephr0n. - Fixed `[WinError 1114] A dynamic link library (DLL) initialization routine failed` error on Windows, pre-loading PyTorch DLLs before initializing PyQt6 to avoid DLL initialization errors, mentioned in #98 by @ephr0n.
- Potential fix for `CUDA GPU is not available` issue, by ensuring PyTorch is installed correctly with CUDA support on Windows using the installer script. - Potential fix for `CUDA GPU is not available` issue, by ensuring PyTorch is installed correctly with CUDA support on Windows using the installer script.
- Improvements in code and documentation. - Improvements in code and documentation.
+3
View File
@@ -230,7 +230,9 @@ Similar to chapter markers, it is possible to add metadata tags for `M4B` files.
<<METADATA_ALBUM_ARTIST:Album Artist>> <<METADATA_ALBUM_ARTIST:Album Artist>>
<<METADATA_COMPOSER:Narrator>> <<METADATA_COMPOSER:Narrator>>
<<METADATA_GENRE:Audiobook>> <<METADATA_GENRE:Audiobook>>
<<METADATA_COVER_PATH:path/to/cover.jpg>>
``` ```
> Note: `METADATA_COVER_PATH` is used to embed a cover image into the generated M4B file. Abogen automatically extracts the cover from EPUB and PDF files and adds this tag for you.
## `About Timestamp-based Text Files` ## `About Timestamp-based Text Files`
Similar to converting subtitle files to audio, Abogen can automatically detect text files that contain timestamps in `HH:MM:SS` or `HH:MM:SS,ms` format. When timestamps are found inside your text file, Abogen will ask if you want to use them for audio timing. This is useful for creating timed narrations, scripts, or transcripts where you need exact control over when each segment is spoken. Similar to converting subtitle files to audio, Abogen can automatically detect text files that contain timestamps in `HH:MM:SS` or `HH:MM:SS,ms` format. When timestamps are found inside your text file, Abogen will ask if you want to use them for audio timing. This is useful for creating timed narrations, scripts, or transcripts where you need exact control over when each segment is spoken.
@@ -274,6 +276,7 @@ I highly recommend using [MPV](https://mpv.io/installation/) to play your audio
# --- MPV Settings --- # --- MPV Settings ---
save-position-on-quit save-position-on-quit
keep-open=yes keep-open=yes
audio-display=no
# --- Subtitle --- # --- Subtitle ---
sub-ass-override=no sub-ass-override=no
sub-margin-y=50 sub-margin-y=50
+21
View File
@@ -89,6 +89,9 @@ class HandlerDialog(QDialog):
def __init__(self, book_path, file_type=None, checked_chapters=None, parent=None): def __init__(self, book_path, file_type=None, checked_chapters=None, parent=None):
super().__init__(parent) super().__init__(parent)
# Normalize path
book_path = os.path.normpath(os.path.abspath(book_path))
# Determine file type if not explicitly provided # Determine file type if not explicitly provided
if file_type: if file_type:
self.file_type = file_type self.file_type = file_type
@@ -2211,6 +2214,7 @@ class HandlerDialog(QDialog):
def _format_metadata_tags(self): def _format_metadata_tags(self):
"""Format metadata tags for insertion at the beginning of the text""" """Format metadata tags for insertion at the beginning of the text"""
import datetime import datetime
from abogen.utils import get_user_cache_path
metadata = self.book_metadata metadata = self.book_metadata
filename = os.path.splitext(os.path.basename(self.book_path))[0] filename = os.path.splitext(os.path.basename(self.book_path))[0]
@@ -2231,6 +2235,20 @@ class HandlerDialog(QDialog):
f"{total_chapters} {'Chapters' if self.file_type == 'epub' else 'Pages'}" f"{total_chapters} {'Chapters' if self.file_type == 'epub' else 'Pages'}"
) )
# Handle cover image
cover_tag = ""
if metadata.get("cover_image"):
try:
import uuid
cache_dir = get_user_cache_path()
cover_path = os.path.join(cache_dir, f"cover_{uuid.uuid4()}.jpg")
cover_path = os.path.normpath(cover_path)
with open(cover_path, "wb") as f:
f.write(metadata["cover_image"])
cover_tag = f"<<METADATA_COVER_PATH:{cover_path}>>"
except Exception as e:
logging.warning(f"Failed to save cover image: {e}")
# Format metadata tags # Format metadata tags
metadata_tags = [ metadata_tags = [
f"<<METADATA_TITLE:{title}>>", f"<<METADATA_TITLE:{title}>>",
@@ -2242,6 +2260,9 @@ class HandlerDialog(QDialog):
f"<<METADATA_GENRE:Audiobook>>", f"<<METADATA_GENRE:Audiobook>>",
] ]
if cover_tag:
metadata_tags.append(cover_tag)
return "\n".join(metadata_tags) return "\n".join(metadata_tags)
def _get_markdown_selected_text(self): def _get_markdown_selected_text(self):
+87 -19
View File
@@ -1000,7 +1000,7 @@ class ConversionThread(QThread):
static_ffmpeg.add_paths() static_ffmpeg.add_paths()
merged_out_file = None merged_out_file = None
ffmpeg_proc = None ffmpeg_proc = None
metadata_options = ( metadata_options, cover_path = (
self._extract_and_add_metadata_tags_to_ffmpeg_cmd() self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
) )
# Prepare ffmpeg command for m4b output # Prepare ffmpeg command for m4b output
@@ -1017,13 +1017,32 @@ class ConversionThread(QThread):
"1", "1",
"-i", "-i",
"pipe:0", "pipe:0",
"-c:a",
"aac",
"-q:a",
"2",
"-movflags",
"+faststart+use_metadata_tags",
] ]
if cover_path and os.path.exists(cover_path):
cmd.extend(
[
"-i",
cover_path,
"-map",
"0:a",
"-map",
"1",
"-c:v",
"copy",
"-disposition:v",
"attached_pic",
]
)
cmd.extend(
[
"-c:a",
"aac",
"-q:a",
"2",
"-movflags",
"+faststart+use_metadata_tags",
]
)
cmd += metadata_options cmd += metadata_options
cmd.append(merged_out_path) cmd.append(merged_out_path)
ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False) ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False)
@@ -1529,7 +1548,7 @@ class ConversionThread(QThread):
orig_path = merged_out_path orig_path = merged_out_path
root, ext = os.path.splitext(orig_path) root, ext = os.path.splitext(orig_path)
tmp_path = root + ".tmp" + ext tmp_path = root + ".tmp" + ext
metadata_options = ( metadata_options, cover_path = (
self._extract_and_add_metadata_tags_to_ffmpeg_cmd() self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
) )
cmd = [ cmd = [
@@ -1539,15 +1558,35 @@ class ConversionThread(QThread):
orig_path, orig_path,
"-i", "-i",
chapters_info_path, chapters_info_path,
"-map",
"0:a",
"-map_metadata",
"1",
"-map_chapters",
"1",
"-c:a",
"copy",
] ]
if cover_path and os.path.exists(cover_path):
cmd.extend(
[
"-i",
cover_path,
"-map",
"0:a",
"-map",
"2",
"-c:v",
"copy",
"-disposition:v",
"attached_pic",
]
)
else:
cmd.extend(["-map", "0:a"])
cmd.extend(
[
"-map_metadata",
"1",
"-map_chapters",
"1",
"-c:a",
"copy",
]
)
cmd += metadata_options cmd += metadata_options
cmd.append(tmp_path) cmd.append(tmp_path)
proc = create_process(cmd) proc = create_process(cmd)
@@ -1659,8 +1698,35 @@ class ConversionThread(QThread):
static_ffmpeg.add_paths() static_ffmpeg.add_paths()
cmd = ["ffmpeg", "-y", "-thread_queue_size", "32768", "-f", "f32le", "-ar", str(rate), "-ac", "1", "-i", "pipe:0"] cmd = ["ffmpeg", "-y", "-thread_queue_size", "32768", "-f", "f32le", "-ar", str(rate), "-ac", "1", "-i", "pipe:0"]
if self.output_format == "m4b": if self.output_format == "m4b":
cmd.extend(["-c:a", "aac", "-q:a", "2", "-movflags", "+faststart+use_metadata_tags"]) metadata_options, cover_path = (
cmd.extend(self._extract_and_add_metadata_tags_to_ffmpeg_cmd()) self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
)
if cover_path and os.path.exists(cover_path):
cmd.extend(
[
"-i",
cover_path,
"-map",
"0:a",
"-map",
"1",
"-c:v",
"copy",
"-disposition:v",
"attached_pic",
]
)
cmd.extend(
[
"-c:a",
"aac",
"-q:a",
"2",
"-movflags",
"+faststart+use_metadata_tags",
]
)
cmd.extend(metadata_options)
elif self.output_format == "opus": elif self.output_format == "opus":
cmd.extend(["-c:a", "libopus", "-b:a", "24000"]) cmd.extend(["-c:a", "libopus", "-b:a", "24000"])
else: else:
@@ -1901,6 +1967,8 @@ class ConversionThread(QThread):
album_artist_match = re.search(r"<<METADATA_ALBUM_ARTIST:([^>]*)>>", text) album_artist_match = re.search(r"<<METADATA_ALBUM_ARTIST:([^>]*)>>", text)
composer_match = re.search(r"<<METADATA_COMPOSER:([^>]*)>>", text) composer_match = re.search(r"<<METADATA_COMPOSER:([^>]*)>>", text)
genre_match = re.search(r"<<METADATA_GENRE:([^>]*)>>", text) genre_match = re.search(r"<<METADATA_GENRE:([^>]*)>>", text)
cover_match = re.search(r"<<METADATA_COVER_PATH:([^>]*)>>", text)
cover_path = cover_match.group(1) if cover_match else None
# Use display path or filename as fallback for title # Use display path or filename as fallback for title
@@ -1964,7 +2032,7 @@ class ConversionThread(QThread):
metadata_options.extend(["-metadata", f"genre=Audiobook"]) metadata_options.extend(["-metadata", f"genre=Audiobook"])
# Add these to ffmpeg command # Add these to ffmpeg command
return metadata_options return metadata_options, cover_path
def _srt_time(self, t): def _srt_time(self, t):
"""Helper function to format time for SRT files""" """Helper function to format time for SRT files"""
+4 -4
View File
@@ -3635,9 +3635,9 @@ Categories=AudioVideo;Audio;Utility;
# Get the abogen cache directory # Get the abogen cache directory
cache_dir = get_user_cache_path() cache_dir = get_user_cache_path()
# Find all .txt files in the abogen cache directory # Find all .txt files and cover images in the abogen cache directory
pattern = os.path.join(cache_dir, "*.txt") cache_files = glob.glob(os.path.join(cache_dir, "*.txt"))
cache_files = glob.glob(pattern) cache_files.extend(glob.glob(os.path.join(cache_dir, "cover_*.jpg")))
# Count the files # Count the files
file_count = len(cache_files) file_count = len(cache_files)
@@ -3669,7 +3669,7 @@ Categories=AudioVideo;Audio;Utility;
msg_box.setText(msg_text + "\nDo you want to delete them?") msg_box.setText(msg_text + "\nDo you want to delete them?")
# Add checkbox for preview cache # Add checkbox for preview cache
preview_cache_checkbox = QCheckBox("Clean preview cache", msg_box) preview_cache_checkbox = QCheckBox("Also clean preview cache", msg_box)
preview_cache_checkbox.setChecked(False) preview_cache_checkbox.setChecked(False)
# Only enable checkbox if preview files exist # Only enable checkbox if preview files exist
preview_cache_checkbox.setEnabled(preview_count > 0) preview_cache_checkbox.setEnabled(preview_count > 0)