Update main.py

This commit is contained in:
TylerCG 2025-09-13 09:28:32 -04:00
parent 149c6f461a
commit e6b4f7b722

122
main.py
View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os, sys, subprocess, json import os, sys, subprocess, json, argparse
def ffprobe_json(path): def ffprobe_json(path):
out = subprocess.check_output([ out = subprocess.check_output([
@ -12,19 +12,54 @@ def run(cmd):
print("running:", " ".join(cmd)) print("running:", " ".join(cmd))
subprocess.run(cmd, check=True) subprocess.run(cmd, check=True)
# --- interactive prompts --- # --- argument parsing ---
input_dir = input("Enter directory containing .m4b files: ").strip() parser = argparse.ArgumentParser(
description="Merge multiple .m4b audiobook files into one with chapters."
)
parser.add_argument(
"input_dir",
nargs="?",
help="Directory containing .m4b files (can drag-and-drop). If omitted, you will be prompted."
)
parser.add_argument(
"-a", "--all",
action="store_true",
help="Automatically select all .m4b files (skips range prompt)."
)
parser.add_argument(
"-d","--delete",
action="store_true",
help="Delete the original files that were merged (no prompt)."
)
parser.add_argument(
"-o", "--output",
help="Final output filename (with or without .m4b extension)."
)
args = parser.parse_args()
# --- input dir handling ---
if args.input_dir:
input_dir = args.input_dir.strip()
else:
input_dir = input("Enter directory containing .m4b files (or drag folder here): ").strip()
# strip accidental surrounding quotes # strip accidental surrounding quotes
if (input_dir.startswith("'") and input_dir.endswith("'")) or \ if (input_dir.startswith("'") and input_dir.endswith("'")) or \
(input_dir.startswith('"') and input_dir.endswith('"')): (input_dir.startswith('"') and input_dir.endswith('"')):
input_dir = input_dir[1:-1] input_dir = input_dir[1:-1]
# replace escaped spaces only
input_dir = input_dir.replace(r"\ ", " ")
# normalize
input_dir = os.path.normpath(input_dir)
if not os.path.isdir(input_dir): if not os.path.isdir(input_dir):
print("❌ Path not found or not a directory:", input_dir) print("❌ Path not found or not a directory:", input_dir)
sys.exit(1) sys.exit(1)
# collect .m4b files robustly (avoid glob issues with []) # collect .m4b files robustly
all_files = [f for f in os.listdir(input_dir) all_files = [f for f in os.listdir(input_dir)
if os.path.isfile(os.path.join(input_dir, f)) and f.lower().endswith(".m4b")] if os.path.isfile(os.path.join(input_dir, f)) and f.lower().endswith(".m4b")]
@ -32,7 +67,6 @@ if not all_files:
print("❌ No .m4b files found in that directory.") print("❌ No .m4b files found in that directory.")
sys.exit(1) sys.exit(1)
# sort (simple lexicographic; change if you want natural sort)
all_files = sorted(all_files, key=lambda s: s.lower()) all_files = sorted(all_files, key=lambda s: s.lower())
print("\nFound the following .m4b files:") print("\nFound the following .m4b files:")
@ -40,10 +74,12 @@ for idx, fname in enumerate(all_files, start=1):
print(f"[{idx}] {fname}") print(f"[{idx}] {fname}")
# range selection # range selection
rng = input("\nEnter range to merge (e.g. 1-10) or single number (e.g. 5) [Enter = all]: ").strip() if args.all:
start, end = 1, len(all_files)
try: else:
if not rng: # empty input -> select all rng = input("\nEnter range to merge (e.g. 1-10) or single number (e.g. 5) [Enter = all]: ").strip()
try:
if not rng: # empty input = all
start, end = 1, len(all_files) start, end = 1, len(all_files)
elif "-" in rng: elif "-" in rng:
a, b = rng.split("-", 1) a, b = rng.split("-", 1)
@ -52,7 +88,7 @@ try:
start = int(rng) start = int(rng)
end = start end = start
assert 1 <= start <= end <= len(all_files) assert 1 <= start <= end <= len(all_files)
except Exception: except Exception:
print("❌ Invalid range. Use start-end where numbers are within the shown list.") print("❌ Invalid range. Use start-end where numbers are within the shown list.")
sys.exit(1) sys.exit(1)
@ -63,13 +99,27 @@ for s in selected:
print(" ", s) print(" ", s)
# final output name # final output name
final_name = input("\nEnter final audiobook name (used as filename and Title metadata): ").strip() if args.output:
if not final_name: final_name = args.output.strip()
print("❌ No output name provided.") else:
sys.exit(1) # derive from first selected file
final_filename = os.path.join(input_dir, final_name if final_name.lower().endswith(".m4b") else final_name + ".m4b") first_file = os.path.splitext(selected[0])[0] # filename w/o extension
# cut off at " - " if present
base_name = first_file.split(" - ")[0].strip()
final_name = base_name
# Extract durations and chapter titles; also capture global tags & cover art from first file # if not final_name:
# print("❌ No output name provided.")
# sys.exit(1)
# normalize extension
if not final_name.lower().endswith(".m4b"):
final_name += ".m4b"
final_filename = os.path.join(input_dir, final_name)
# Extract durations, chapters, metadata
chapters = [] chapters = []
current_start = 0.0 current_start = 0.0
global_tags = {} global_tags = {}
@ -80,13 +130,11 @@ for idx, p in enumerate(selected_paths):
meta = ffprobe_json(p) meta = ffprobe_json(p)
dur = float(meta["format"]["duration"]) dur = float(meta["format"]["duration"])
tags = meta["format"].get("tags", {}) or {} tags = meta["format"].get("tags", {}) or {}
# prefer 'title' tag as chapter name; fall back to filename
chapter_title = tags.get("title") or tags.get("©nam") or os.path.splitext(os.path.basename(p))[0] chapter_title = tags.get("title") or tags.get("©nam") or os.path.splitext(os.path.basename(p))[0]
chapters.append({"start": current_start, "end": current_start+dur, "title": chapter_title}) chapters.append({"start": current_start, "end": current_start+dur, "title": chapter_title})
current_start += dur current_start += dur
if idx == 0: if idx == 0:
global_tags = tags global_tags = tags
# extract cover art from first file (if exists)
try: try:
run([ run([
"ffmpeg", "-hide_banner", "-loglevel", "error", "ffmpeg", "-hide_banner", "-loglevel", "error",
@ -97,15 +145,15 @@ for idx, p in enumerate(selected_paths):
except Exception: except Exception:
print("⚠️ No cover art found in first file.") print("⚠️ No cover art found in first file.")
# Write FFmetadata file (global tags + chapters) # Write FFmetadata
meta_path = "chapters_ffmetadata.txt" meta_path = "chapters_ffmetadata.txt"
with open(meta_path, "w", encoding="utf-8") as mf: with open(meta_path, "w", encoding="utf-8") as mf:
mf.write(";FFMETADATA1\n") mf.write(";FFMETADATA1\n")
# copy global tags except title/name (we'll set our own)
for k, v in (global_tags or {}).items(): for k, v in (global_tags or {}).items():
if k.lower() not in ("title", "name", "©nam"): if k.lower() not in ("title", "name", "©nam"):
mf.write(f"{k}={v}\n") mf.write(f"{k}={v}\n")
mf.write(f"title={final_name}\n\n") title_tag = final_name[:-4] if final_name.lower().endswith(".m4b") else final_name #remove .m4b from title
mf.write(f"title={title_tag}\n\n")
for ch in chapters: for ch in chapters:
mf.write("[CHAPTER]\n") mf.write("[CHAPTER]\n")
mf.write("TIMEBASE=1/1000\n") mf.write("TIMEBASE=1/1000\n")
@ -114,44 +162,40 @@ with open(meta_path, "w", encoding="utf-8") as mf:
safe_title = str(ch['title']).replace("\n", " ").replace("\r"," ") safe_title = str(ch['title']).replace("\n", " ").replace("\r"," ")
mf.write(f"title={safe_title}\n\n") mf.write(f"title={safe_title}\n\n")
# Build concat file list (absolute paths) # Build concat list
file_list = "concat_list.txt" file_list = "concat_list.txt"
with open(file_list, "w", encoding="utf-8") as fl: with open(file_list, "w", encoding="utf-8") as fl:
for p in selected_paths: for p in selected_paths:
fl.write(f"file '{os.path.abspath(p)}'\n") fl.write(f"file '{os.path.abspath(p)}'\n")
# Concatenate (audio only, ignore extra cover streams) # Concatenate audio only
merged = "merged.m4b" merged = "merged.m4b"
try: try:
run([ run([
"ffmpeg", "-hide_banner", "-loglevel", "error", "ffmpeg", "-hide_banner", "-loglevel", "error",
"-f", "concat", "-safe", "0", "-i", file_list, "-f", "concat", "-safe", "0", "-i", file_list,
"-map", "0:a", # only audio "-map", "0:a",
"-c:a", "copy", "-c:a", "copy",
merged merged
]) ])
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
print("❌ ffmpeg failed while concatenating. Check ffmpeg installation and file permissions.") print("❌ ffmpeg failed while concatenating.")
sys.exit(1) sys.exit(1)
# Mux metadata (chapters + global tags [+ cover art]) into final file # Mux metadata + cover
cmd = [ cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-i", merged, "-i", meta_path "-i", merged, "-i", meta_path
] ]
if have_cover: if have_cover:
cmd += ["-i", cover_temp] cmd += ["-i", cover_temp]
cmd += [ cmd += [
"-map", "0", # audio "-map", "0",
"-map_metadata", "1", # metadata "-map_metadata", "1",
"-c", "copy" "-c", "copy"
] ]
if have_cover: if have_cover:
cmd += ["-map", "2", "-disposition:v:0", "attached_pic"] cmd += ["-map", "2", "-disposition:v:0", "attached_pic"]
cmd += [final_filename] cmd += [final_filename]
try: try:
@ -161,9 +205,8 @@ except subprocess.CalledProcessError:
sys.exit(1) sys.exit(1)
print(f"\n✅ Done. Created '{final_filename}' with {len(chapters)} chapters.") print(f"\n✅ Done. Created '{final_filename}' with {len(chapters)} chapters.")
print("Original files were not modified.")
# --- cleanup temp files --- # cleanup temp files
for f in [merged, meta_path, file_list, cover_temp]: for f in [merged, meta_path, file_list, cover_temp]:
if os.path.exists(f): if os.path.exists(f):
try: try:
@ -171,9 +214,14 @@ for f in [merged, meta_path, file_list, cover_temp]:
except Exception as e: except Exception as e:
print(f"⚠️ Failed to delete temp file {f}: {e}") print(f"⚠️ Failed to delete temp file {f}: {e}")
# --- optional deletion of source files --- # delete originals?
del_prompt = input("\nDo you want to delete the original files that were merged? [y/N]: ").strip().lower() # if args.delete:
if del_prompt == "y": # delete_confirmed = True
# else:
# del_prompt = input("\nDo you want to delete the original files that were merged? [y/N]: ").strip().lower()
# delete_confirmed = del_prompt == "y"
if args.delete:
for f in selected_paths: for f in selected_paths:
try: try:
os.remove(f) os.remove(f)