Nested / stand-alone MP3 to M4B converter

This commit is contained in:
Peter Wicks
2024-12-30 20:10:09 -07:00
commit 34bc9fd493
3 changed files with 688 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
# MP3-to-M4B Converter
A Python CLI script that converts folder(s) of MP3 chapters into an M4B audiobook, optionally embedding chapter metadata and cover art. It can also fetch metadata from Open Library (e.g., title, author, cover). This script supports two modes:
1. **Single** – Convert all MP3 files in **one folder** into a single M4B.
2. **Multiple** – For each **subfolder** in the input folder, convert MP3 files within that subfolder into its own M4B.
---
## Table of Contents
- [MP3-to-M4B Converter](#mp3-to-m4b-converter)
- [Table of Contents](#table-of-contents)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Usage](#usage)
- [Single Mode Example](#single-mode-example)
- [Multiple Mode Example](#multiple-mode-example)
- [Metadata Source](#metadata-source)
- [Examples](#examples)
- [Troubleshooting](#troubleshooting)
- [License](#license)
---
## Prerequisites
1. **FFmpeg**
You must have [FFmpeg](https://www.ffmpeg.org/download.html) installed and in your system’s PATH. This script relies on FFmpeg for audio processing (encoding, merging, adding metadata).
2. **Python 3.7+**
- If you don’t already have Python, install it from [python.org/download/releases/](https://www.python.org/download/releases/).
3. **Pip & Requirements**
The script has some Python dependencies, so you’ll want to install them from `requirements.txt`.
---
## Installation
1. **Install FFmpeg**
Follow the [official FFmpeg download guide](https://www.ffmpeg.org/download.html) for your operating system (Windows, macOS, Linux).
- Once installed, confirm `ffmpeg` is accessible by running:
```bash
ffmpeg -version
```
- If FFmpeg is not found, ensure your environment variables are set so that the `ffmpeg` command is recognized.
2. **Install Python 3.7+**
- If you need Python, go to [python.org/download/releases/](https://www.python.org/download/releases/) and download/install the latest version.
3. **Clone or Download This Repository**
- If you have Git:
```bash
git clone https://github.com/your_username/mp3-to-m4b-converter.git
```
- Or simply download the ZIP and extract.
4. **Install Python Dependencies**
In the project directory, run:
```bash
pip install -r requirements.txt
```
---
## Usage
Run the script directly from your terminal using Python:
```bash
python m4binder.py [OPTIONS]
```
Key arguments:
| Argument | Description |
|------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `--mode` | **single** (default) or **multiple**. In single mode, converts all MP3s in one folder into **one** M4B. In multiple mode, each subfolder is treated as a separate book. |
| `--input-folder` | The folder containing MP3 files (in **single** mode) or subfolders containing MP3 files (in **multiple** mode). |
| `--output-file` | **(Single mode)** The M4B filename to create. Required in single mode. |
| `--output-folder` | **(Multiple mode)** Where to place all final M4B files. If not specified, defaults to placing them in the same `--input-folder`. |
| `--metadata-source` | Source to fetch book metadata: **`openlibrary`** or **`none`**. (Google Books is omitted here by request.) |
| `--title` / `--author` | Used for metadata lookup if you choose `--metadata-source openlibrary`. If not provided, the script attempts to read ID3 tags from the first MP3 file. |
### Single Mode Example
You have a folder containing MP3 files for “My Book.” Run:
```bash
python m4binder.py \
--mode single \
--input-folder /path/to/mybook_mp3s \
--output-file /path/to/output/mybook.m4b \
--metadata-source openlibrary \
--title "My Book Title" \
--author "John Doe"
```
- **`--mode single`** indicates we’re converting one folder of MP3s into one M4B.
- **`--title`** and **`--author`** (optional) help fetch metadata, including cover art, from Open Library.
### Multiple Mode Example
You have a folder containing multiple subfolders, each with its own set of MP3 files. For example:
```
/path/to/audiobooks/
├── Book1/
│ ├── 01.mp3
│ ├── 02.mp3
│ └── ...
├── Book2/
│ ├── 01.mp3
│ ├── 02.mp3
│ └── ...
└── Book3/
├── 01.mp3
├── 02.mp3
└── ...
```
To convert **each** of these subfolders into a separate `.m4b`, run:
```bash
python m4binder.py \
--mode multiple \
--input-folder /path/to/audiobooks \
--output-folder /path/to/converted_audiobooks \
--metadata-source openlibrary
```
- Each subfolder (Book1, Book2, Book3) will be processed into its own `.m4b` in `/path/to/converted_audiobooks`.
---
## Metadata Source
- **`openlibrary`**: Attempts to fetch official metadata (title, authors, cover art, etc.) from Open Library.
- **`none`**: Skips online metadata. The script will attempt to read embedded ID3 tags (like cover art) from the first MP3 file in each set. If no tags are found, it’ll default to generic placeholders.
> **Note**: If you omit `--title` and/or `--author`, the script will try to read those values from the **first MP3** file’s ID3 tags.
---
## Examples
1. **Quick Single-Folder Conversion Without Metadata**
```bash
python m4binder.py \
--mode single \
--metadata-source none \
--input-folder /path/to/book_mp3s \
--output-file /path/to/output/book.m4b
```
2. **Multiple-Folder Conversion with OpenLibrary**
```bash
python m4binder.py \
--mode multiple \
--input-folder /path/to/audiobooks \
--output-folder /path/to/final_m4bs \
--metadata-source openlibrary \
--title "Placeholder Title" \
--author "Placeholder Author"
```
If `--title` and `--author` are the same for all subfolders, the script will attempt to find metadata for each subfolder. If subfolders differ significantly, you may rely on each MP3’s ID3 tags or let Open Library do partial matching.
---
## Troubleshooting
1. **FFmpeg Not Found**
- Ensure `ffmpeg` is installed and on your system’s PATH. You can verify by running `ffmpeg -version`.
2. **No MP3 Files Detected**
- Double-check your input folder paths. Make sure files actually have the `.mp3` extension.
3. **Metadata Not Found / Covers Missing**
- Open Library may not have an entry for your exact title/author. Ensure you’ve spelled them correctly, or use `--metadata-source none` to rely on embedded cover art.
4. **High CPU Usage**
- By default, the script parallelizes conversion of MP3 files (for faster performance). If your system is overloaded, you can add a `--max-workers N` argument (if you’ve exposed it in the script) to limit concurrency.
---
## License
This project is provided under the [MIT License](./LICENSE) (or whichever license you apply). Feel free to modify and distribute it according to your needs.
+500
View File
@@ -0,0 +1,500 @@
#!/usr/bin/env python3
"""
CLI script that:
1) Converts MP3 chapters -> M4B with chapter metadata.
2) Optionally fetches book metadata from Open Library.
3) Embeds that metadata in the final M4B.
Usage:
python audiobook_converter.py \
--input-folder /path/to/chapters \
--output-file mybook.m4b \
--title "Dune" \
--author "Frank Herbert"
"""
import argparse
from concurrent.futures import ProcessPoolExecutor, as_completed
import os
import sys
import subprocess
from mutagen.easyid3 import EasyID3
from mutagen.mp3 import MP3
from mutagen.id3 import ID3, APIC
try:
from olclient.openlibrary import OpenLibrary
import olclient.common as ol_common
except ImportError:
OpenLibrary = None
import requests
def get_duration(file_path):
"""
Uses ffprobe to get the duration (in seconds) of the file.
Returns a float.
"""
result = subprocess.run(
[
"ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
file_path
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
try:
return float(result.stdout.strip())
except ValueError:
print(f"Warning: Could not determine duration of {file_path}.")
return 0.0
def extract_id3_tags(file_path):
"""Return basic ID3 title/artist from an MP3 file."""
try:
tags = EasyID3(file_path)
return {
"album": tags.get("album", [None])[0],
"artist": tags.get("artist", [None])[0]
}
except Exception:
return {}
def extract_embedded_cover_art(mp3_path):
"""
Checks if an MP3 file has embedded cover art (APIC frame).
If found, saves it to a file (next to the MP3) and returns the file path.
Otherwise returns None.
"""
# Load MP3 with ID3 tags
audio = MP3(mp3_path, ID3=ID3)
if not audio.tags:
return None
# Look for APIC (attached picture) frames
for tag_key in audio.tags.keys():
if tag_key.startswith("APIC"):
apic_frame = audio.tags[tag_key]
if isinstance(apic_frame, APIC):
# Determine a file extension based on MIME type (jpg, png, etc.)
mime_lower = apic_frame.mime.lower()
if "jpeg" in mime_lower or "jpg" in mime_lower:
extension = ".jpg"
elif "png" in mime_lower:
extension = ".png"
elif "gif" in mime_lower:
extension = ".gif"
else:
extension = ".cover" # fallback if unknown
# Build a file path in the same folder as the MP3
base_name = os.path.splitext(os.path.basename(mp3_path))[0]
cover_filename = f"{base_name}_cover{extension}"
cover_path = os.path.join(os.path.dirname(mp3_path), cover_filename)
# Write out the image data
with open(cover_path, "wb") as f:
f.write(apic_frame.data)
return cover_path
# No APIC frame found
return None
def create_ffmetadata(files, metadata_file, book_metadata=None):
"""
Creates an ffmetadata file with metadata (title, artist, etc.)
plus chapter markers for each file.
"""
lines = []
lines.append(";FFMETADATA1")
# If we have book metadata, inject it here.
if book_metadata:
if "title" in book_metadata:
lines.append(f"title={book_metadata['title']}")
lines.append(f"album={book_metadata['title']}")
if "authors" in book_metadata:
lines.append(f"artist={', '.join(book_metadata['authors'])}")
lines.append(f"album_artist={', '.join(book_metadata['authors'])}")
if "publisher" in book_metadata:
lines.append(f"publisher={book_metadata['publisher']}")
current_start_ms = 0
for idx, file_path in enumerate(files, start=1):
# Get the track duration
duration_sec = get_duration(file_path)
duration_ms = int(round(duration_sec * 1000))
chapter_start = current_start_ms
chapter_end = chapter_start + duration_ms
# Attempt to read a 'title' tag from the MP3
try:
tags = EasyID3(file_path)
mp3_title = tags.get("title", [None])[0]
except Exception:
mp3_title = None
# Fallback to "Chapter X" if no ID3 title is found
if not mp3_title:
mp3_title = f"Chapter {idx}"
lines.append("[CHAPTER]")
lines.append("TIMEBASE=1/1000")
lines.append(f"START={chapter_start}")
lines.append(f"END={chapter_end}")
lines.append(f"title={mp3_title}")
current_start_ms += duration_ms
with open(metadata_file, 'w', encoding='utf-8') as f:
f.write("\n".join(lines))
f.write("\n")
def create_concat_list(file_paths, list_file):
"""Creates a concat list for FFmpeg."""
with open(list_file, 'w', encoding='utf-8') as f:
for path in file_paths:
# For windows, escape backslashes in paths
safe_path = path.replace("\\", "\\\\")
# Escape any apostrophes for ffmpeg’s single-quoted syntax
safe_path = safe_path.replace("'", "'\\''")
f.write(f"file '{safe_path}'\n")
def encode_mp3_to_m4a(mp3_file, out_file, bitrate="96k"):
"""
Convert a single MP3 file to AAC (.m4a) without altering
sample rate/channels if possible.
"""
# if the output file already exists, skip encoding
if os.path.exists(out_file):
print(f"Output file {out_file} already exists. Skipping.")
cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel", "error",
"-y", # Overwrite output
"-i", mp3_file, # Input MP3
"-vn", # This drops any video/art track that might be embedded as H.264:
"-c:a", "aac",
"-b:a", bitrate,
out_file
]
subprocess.run(cmd, check=True)
def parallel_encode_mp3s_to_m4a(input_folder, output_folder, bitrate="96k", max_workers=None):
"""
1) Finds all .mp3 in input_folder.
2) Encodes each in parallel to .m4a in output_folder.
3) Returns a list of output .m4a paths (sorted).
"""
if not os.path.exists(output_folder):
os.makedirs(output_folder, exist_ok=True)
mp3_files = [
os.path.join(input_folder, f)
for f in os.listdir(input_folder)
if f.lower().endswith(".mp3")
]
mp3_files.sort()
results = []
with ProcessPoolExecutor(max_workers=max_workers) as executor:
futures = {}
for mp3 in mp3_files:
basename = os.path.splitext(os.path.basename(mp3))[0]
out_file = os.path.join(output_folder, basename + ".m4a")
fut = executor.submit(encode_mp3_to_m4a, mp3, out_file, bitrate)
futures[fut] = out_file
# Gather results (this blocks until all are done)
for fut in as_completed(futures):
out_file = futures[fut]
try:
fut.result() # Will raise CalledProcessError if FFmpeg fails
results.append(out_file)
except Exception as e:
print(f"Error encoding {out_file}: {e}")
# Return sorted list of .m4a files
return sorted(results)
def convert_mp3_chapters_to_m4b(input_folder, output_file, book_metadata=None):
"""
Main conversion flow:
1) Find MP3s
2) Create ffmetadata with chapters + optional global metadata
3) Create concat list
4) Use ffmpeg to produce final M4B
"""
m4a_files = parallel_encode_mp3s_to_m4a(input_folder, input_folder, bitrate="96k")
metadata_file = os.path.join(input_folder, "chapters.ffmetadata")
list_file = os.path.join(input_folder, "concat_list.txt")
create_ffmetadata(m4a_files, metadata_file, book_metadata=book_metadata)
create_concat_list(m4a_files, list_file)
# 1) Declare the first two inputs (concat list + ffmetadata)
ffmpeg_cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel", "error",
"-f", "concat",
"-safe", "0",
"-i", list_file,
"-i", metadata_file
]
# 2) If we have cover art, declare it as a third input
if book_metadata and 'cover' in book_metadata and book_metadata['cover'] and os.path.exists(book_metadata['cover']):
ffmpeg_cmd += ["-i", book_metadata['cover']]
# 3) Now specify the mapping for each input and output options
ffmpeg_cmd += [
"-map_metadata", "1", # the second input (metadata file)
"-map", "0:a", # the first input (audio from concat list)
"-c", "copy",
"-movflags", "faststart"
]
# 4) If cover art is present, attach it
if book_metadata and 'cover' in book_metadata and book_metadata['cover'] and os.path.exists(book_metadata['cover']):
ffmpeg_cmd += [
"-map", "2", # cover is the third input
"-c:v", "mjpeg",
"-metadata:s:v", 'title="Cover (front)"',
"-metadata:s:v", 'comment="Cover (front)"',
"-disposition:v:0", "attached_pic"
]
# 5) Finally, append the output filename
ffmpeg_cmd.append(output_file)
try:
subprocess.run(ffmpeg_cmd, check=True)
print(f"Created audiobook: {output_file}")
except subprocess.CalledProcessError as e:
print(f"Error converting MP3 chapters to M4B: {e}")
finally:
# Clean up
if os.path.exists(metadata_file):
os.remove(metadata_file)
if os.path.exists(list_file):
os.remove(list_file)
for m4a in m4a_files:
os.remove(m4a)
def get_book_metadata(args, mp3_files):
"""
1) If user wants metadata from Google/OpenLibrary, fetch it.
2) Otherwise, extract from first MP3 (ID3 tags) or fallback to defaults.
"""
# If title/author are not provided, try to read from the first file’s ID3
if not args.title or not args.author:
id3_tags = extract_id3_tags(mp3_files[0])
default_title = id3_tags.get("album", "")
default_author = id3_tags.get("artist", "")
else:
default_title = args.title
default_author = args.author
# Decide metadata source
if args.metadata_source == "google":
# google books example
book_meta = fetch_metadata_google_books(
title=args.title or default_title,
author=args.author or default_author,
isbn=getattr(args, "isbn", None),
api_key=getattr(args, "api_key", None)
)
elif args.metadata_source == "openlibrary":
book_meta = fetch_metadata_openlibrary(
title=args.title or default_title,
author=args.author or default_author,
input_folder=os.path.dirname(mp3_files[0]) # store cover near first MP3
)
else:
book_meta = None
if book_meta:
print("[INFO] Retrieved the following metadata:")
print(book_meta)
else:
# fallback: embedded cover from first MP3
cover_art = extract_embedded_cover_art(mp3_files[0])
book_meta = {
"title": args.title or default_title,
"authors": [args.author or default_author],
"publisher": "",
"cover": cover_art
}
return book_meta
def fetch_metadata_google_books(title=None, author=None, isbn=None, api_key=None):
"""
Very basic example. You’ll want to refine the search logic.
"""
print("[INFO] Fetching metadata from Google Books API...")
# Build a query string
query_parts = []
if isbn:
query_parts.append(f"isbn:{isbn}")
if title:
query_parts.append(f"intitle:{title}")
if author:
query_parts.append(f"inauthor:{author}")
q = " ".join(query_parts).strip() or "audiobook"
params = {
"q": q,
"maxResults": 1
}
if api_key:
params["key"] = api_key
resp = requests.get("https://www.googleapis.com/books/v1/volumes", params=params)
data = resp.json()
items = data.get("items", [])
if not items:
print("[WARN] No results from Google Books.")
return None
vi = items[0].get("volumeInfo", {})
# Extract some metadata
metadata = {
"title": vi.get("title", ""),
"authors": vi.get("authors", []),
"publisher": vi.get("publisher", ""),
"publishedDate": vi.get("publishedDate", ""),
}
return metadata
def fetch_metadata_openlibrary(title=None, author=None, input_folder=None):
"""
Example stub using openlibrary-client (Requires pip install openlibrary-client).
You’ll need to adapt this to your actual usage pattern:
e.g., searching by ISBN, or calling the .get() method, etc.
"""
if not OpenLibrary:
print("[WARN] openlibrary-client not installed. Skipping.")
return None
print("[INFO] Fetching metadata from Open Library...")
ol = OpenLibrary()
work_result = ol.Work.search(title=title, author=author)
if not work_result:
print("[WARN] No results from Open Library.")
return None
work = ol.Work.get(work_result.identifiers['olid'][0])
# Covers are downloaded (with redirect following) using this format:
# https://covers.openlibrary.org/b/id/{cover_id}-L.jpg
cover_id = work.covers[0] if len(work.covers) > 0 else None
cover_url = f"https://covers.openlibrary.org/b/id/{cover_id}-L.jpg" if cover_id else None
# Fetch the cover image
if cover_url:
cover_resp = requests.get(cover_url, allow_redirects=True)
cover_path = f"{input_folder}/{cover_id}-cover.jpg"
with open(cover_path, 'wb') as f:
f.write(cover_resp.content)
print(f"[INFO] Saved cover image to {cover_path}")
return {
"title": work_result.title,
"authors": [auth['name'] for auth in work_result.authors],
"publisher": getattr(work_result, 'publisher', '') or '',
"cover": cover_path if cover_url else None,
}
def main():
parser = argparse.ArgumentParser(
description="Convert MP3 chapters to M4B with optional metadata fetching."
)
parser.add_argument("--input-folder", required=True, help="Folder containing MP3 chapters.")
parser.add_argument("--output-file", help="Output M4B filename (used in single mode).")
# New mode argument
parser.add_argument("--mode",
choices=["single", "multiple"],
default="single",
help="Conversion mode: 'single' folder of MP3s or 'multiple' (each subfolder is its own book).")
parser.add_argument("--metadata-source", default="openlibrary",
choices=["google", "openlibrary", "none"],
help="Source to fetch book metadata.")
parser.add_argument("--title", help="Book title (for metadata lookup).")
parser.add_argument("--author", help="Book author (for metadata lookup).")
parser.add_argument("--output-folder", help="Where to place M4B files in nested mode")
args = parser.parse_args()
# ============== SINGLE MODE ==============
if args.mode == "single":
mp3_files = [
os.path.join(args.input_folder, f)
for f in os.listdir(args.input_folder)
if f.lower().endswith(".mp3")
]
mp3_files.sort()
if not mp3_files:
print("[ERROR] No MP3 files found in input folder.")
sys.exit(1)
# Build metadata
book_meta = get_book_metadata(args, mp3_files)
# If output-file not specified, pick a default
if not args.output_file:
# e.g. the folder name + ".m4b"
folder_name = os.path.basename(args.input_folder.rstrip(os.sep))
args.output_file = folder_name + ".m4b"
convert_mp3_chapters_to_m4b(args.input_folder, args.output_file, book_metadata=book_meta)
# ============== MULTIPLE MODE ==============
else:
subfolders = [
os.path.join(args.input_folder, d)
for d in os.listdir(args.input_folder)
if os.path.isdir(os.path.join(args.input_folder, d))
]
if not subfolders:
print("[ERROR] No subfolders found in input folder for 'multiple' mode.")
sys.exit(1)
for subdir in subfolders:
mp3_files = [
os.path.join(subdir, f)
for f in os.listdir(subdir)
if f.lower().endswith(".mp3")
]
mp3_files.sort()
if not mp3_files:
print(f"[WARN] No MP3 files in subfolder: {subdir}. Skipping.")
continue
print(f"[INFO] Converting subfolder: {subdir}")
# Build metadata
book_meta = get_book_metadata(args, mp3_files)
# Construct output filename for each subfolder
folder_name = os.path.basename(subdir.rstrip(os.sep))
output_m4b = os.path.join(args.output_folder, folder_name + ".m4b")
convert_mp3_chapters_to_m4b(subdir, output_m4b, book_metadata=book_meta)
print(f"[INFO] Finished subfolder -> {output_m4b}")
if __name__ == "__main__":
main()
+2
View File
@@ -0,0 +1,2 @@
git+https://github.com/internetarchive/openlibrary-client.git
mutagen