497 lines
17 KiB
Python
497 lines
17 KiB
Python
#!/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 and len(book_metadata['authors']) > 0 and book_metadata['authors'][0] is not None:
|
|
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):
|
|
"""
|
|
Convert a single MP3 file to AAC (.m4a) without altering
|
|
sample rate/channels if possible.
|
|
"""
|
|
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",
|
|
out_file
|
|
]
|
|
subprocess.run(cmd, check=True)
|
|
|
|
def parallel_encode_mp3s_to_m4a(input_folder, output_folder, 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)
|
|
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), mp3_files
|
|
|
|
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, mp3_files = parallel_encode_mp3s_to_m4a(input_folder, input_folder)
|
|
|
|
metadata_file = os.path.join(input_folder, "chapters.ffmetadata")
|
|
list_file = os.path.join(input_folder, "concat_list.txt")
|
|
|
|
create_ffmetadata(mp3_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()
|