Nested / stand-alone MP3 to M4B converter
This commit is contained in:
@@ -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
@@ -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()
|
||||
@@ -0,0 +1,2 @@
|
||||
git+https://github.com/internetarchive/openlibrary-client.git
|
||||
mutagen
|
||||
Reference in New Issue
Block a user