Files
m4b/src/m4binder.py
T
2026-08-27 11:45:23 -03:00

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()