Update main.py
This commit is contained in:
parent
149c6f461a
commit
e6b4f7b722
144
main.py
144
main.py
@ -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,21 +74,23 @@ 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()
|
||||||
start, end = 1, len(all_files)
|
try:
|
||||||
elif "-" in rng:
|
if not rng: # empty input = all
|
||||||
a, b = rng.split("-", 1)
|
start, end = 1, len(all_files)
|
||||||
start, end = int(a), int(b)
|
elif "-" in rng:
|
||||||
else:
|
a, b = rng.split("-", 1)
|
||||||
start = int(rng)
|
start, end = int(a), int(b)
|
||||||
end = start
|
else:
|
||||||
assert 1 <= start <= end <= len(all_files)
|
start = int(rng)
|
||||||
except Exception:
|
end = start
|
||||||
print("❌ Invalid range. Use start-end where numbers are within the shown list.")
|
assert 1 <= start <= end <= len(all_files)
|
||||||
sys.exit(1)
|
except Exception:
|
||||||
|
print("❌ Invalid range. Use start-end where numbers are within the shown list.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
selected = all_files[start-1:end]
|
selected = all_files[start-1:end]
|
||||||
selected_paths = [os.path.join(input_dir, s) for s in selected]
|
selected_paths = [os.path.join(input_dir, s) for s in selected]
|
||||||
@ -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)
|
||||||
@ -181,4 +229,4 @@ if del_prompt == "y":
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ Could not delete {f}: {e}")
|
print(f"⚠️ Could not delete {f}: {e}")
|
||||||
else:
|
else:
|
||||||
print("Original files kept.")
|
print("Original files kept.")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user