diff --git a/CHANGELOG.md b/CHANGELOG.md index 5610ffb..9bc7752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - 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. - **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 `<>` 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. - 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. diff --git a/README.md b/README.md index 4c1e686..b5fd457 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,9 @@ Similar to chapter markers, it is possible to add metadata tags for `M4B` files. <> <> <> +<> ``` +> 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` 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 --- save-position-on-quit keep-open=yes +audio-display=no # --- Subtitle --- sub-ass-override=no sub-margin-y=50 diff --git a/abogen/book_handler.py b/abogen/book_handler.py index 02758f5..4d19d9c 100644 --- a/abogen/book_handler.py +++ b/abogen/book_handler.py @@ -89,6 +89,9 @@ class HandlerDialog(QDialog): def __init__(self, book_path, file_type=None, checked_chapters=None, parent=None): super().__init__(parent) + # Normalize path + book_path = os.path.normpath(os.path.abspath(book_path)) + # Determine file type if not explicitly provided if file_type: self.file_type = file_type @@ -2211,6 +2214,7 @@ class HandlerDialog(QDialog): def _format_metadata_tags(self): """Format metadata tags for insertion at the beginning of the text""" import datetime + from abogen.utils import get_user_cache_path metadata = self.book_metadata 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'}" ) + # 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"<>" + except Exception as e: + logging.warning(f"Failed to save cover image: {e}") + # Format metadata tags metadata_tags = [ f"<>", @@ -2242,6 +2260,9 @@ class HandlerDialog(QDialog): f"<>", ] + if cover_tag: + metadata_tags.append(cover_tag) + return "\n".join(metadata_tags) def _get_markdown_selected_text(self): diff --git a/abogen/conversion.py b/abogen/conversion.py index 4ec5b38..fafb14f 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -1000,7 +1000,7 @@ class ConversionThread(QThread): static_ffmpeg.add_paths() merged_out_file = None ffmpeg_proc = None - metadata_options = ( + metadata_options, cover_path = ( self._extract_and_add_metadata_tags_to_ffmpeg_cmd() ) # Prepare ffmpeg command for m4b output @@ -1017,13 +1017,32 @@ class ConversionThread(QThread): "1", "-i", "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.append(merged_out_path) ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False) @@ -1529,7 +1548,7 @@ class ConversionThread(QThread): orig_path = merged_out_path root, ext = os.path.splitext(orig_path) tmp_path = root + ".tmp" + ext - metadata_options = ( + metadata_options, cover_path = ( self._extract_and_add_metadata_tags_to_ffmpeg_cmd() ) cmd = [ @@ -1539,15 +1558,35 @@ class ConversionThread(QThread): orig_path, "-i", 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.append(tmp_path) proc = create_process(cmd) @@ -1659,8 +1698,35 @@ class ConversionThread(QThread): static_ffmpeg.add_paths() cmd = ["ffmpeg", "-y", "-thread_queue_size", "32768", "-f", "f32le", "-ar", str(rate), "-ac", "1", "-i", "pipe:0"] if self.output_format == "m4b": - cmd.extend(["-c:a", "aac", "-q:a", "2", "-movflags", "+faststart+use_metadata_tags"]) - cmd.extend(self._extract_and_add_metadata_tags_to_ffmpeg_cmd()) + metadata_options, cover_path = ( + 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": cmd.extend(["-c:a", "libopus", "-b:a", "24000"]) else: @@ -1901,6 +1967,8 @@ class ConversionThread(QThread): album_artist_match = re.search(r"<]*)>>", text) composer_match = re.search(r"<]*)>>", text) genre_match = re.search(r"<]*)>>", text) + cover_match = re.search(r"<]*)>>", text) + cover_path = cover_match.group(1) if cover_match else None # Use display path or filename as fallback for title @@ -1964,7 +2032,7 @@ class ConversionThread(QThread): metadata_options.extend(["-metadata", f"genre=Audiobook"]) # Add these to ffmpeg command - return metadata_options + return metadata_options, cover_path def _srt_time(self, t): """Helper function to format time for SRT files""" diff --git a/abogen/gui.py b/abogen/gui.py index 74e7524..b090d25 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -3635,9 +3635,9 @@ Categories=AudioVideo;Audio;Utility; # Get the abogen cache directory cache_dir = get_user_cache_path() - # Find all .txt files in the abogen cache directory - pattern = os.path.join(cache_dir, "*.txt") - cache_files = glob.glob(pattern) + # Find all .txt files and cover images in the abogen cache directory + cache_files = glob.glob(os.path.join(cache_dir, "*.txt")) + cache_files.extend(glob.glob(os.path.join(cache_dir, "cover_*.jpg"))) # Count the 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?") # 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) # Only enable checkbox if preview files exist preview_cache_checkbox.setEnabled(preview_count > 0)