Update main.py
This commit is contained in:
parent
68247bf615
commit
149c6f461a
255
main.py
255
main.py
@ -1,99 +1,184 @@
|
||||
import subprocess
|
||||
import json
|
||||
import os
|
||||
import glob
|
||||
#!/usr/bin/env python3
|
||||
import os, sys, subprocess, json
|
||||
|
||||
def run_ffmpeg_cmd(cmd):
|
||||
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||||
return result.stdout.strip()
|
||||
|
||||
# 1. Ask for directory
|
||||
input_dir = input("Enter the directory containing your .m4b files: ").strip()
|
||||
|
||||
# Collect and sort files
|
||||
input_files = sorted(glob.glob(os.path.join(input_dir, "*.m4b")))
|
||||
if not input_files:
|
||||
raise SystemExit("❌ No .m4b files found in that directory.")
|
||||
|
||||
# 2. List files with numbering
|
||||
print("\nAvailable files:")
|
||||
for i, f in enumerate(input_files, 1):
|
||||
print(f"[{i}] {os.path.basename(f)}")
|
||||
|
||||
# 3. Ask user for start-end range
|
||||
user_range = input("\nEnter the range of files to merge (e.g. 1-10): ").strip()
|
||||
try:
|
||||
start, end = map(int, user_range.split("-"))
|
||||
selected_files = input_files[start-1:end]
|
||||
except Exception:
|
||||
raise SystemExit("❌ Invalid range format. Use start-end (e.g. 1-5).")
|
||||
|
||||
# 4. Ask for output name
|
||||
output_name = input("\nEnter the final audiobook name (without extension): ").strip()
|
||||
final_output = f"{output_name}.m4b"
|
||||
|
||||
# 5. Extract metadata (for chapters & global tags)
|
||||
chapters = []
|
||||
current_start = 0
|
||||
global_tags = None
|
||||
|
||||
for f in selected_files:
|
||||
probe = subprocess.check_output([
|
||||
def ffprobe_json(path):
|
||||
out = subprocess.check_output([
|
||||
"ffprobe", "-v", "quiet", "-print_format", "json",
|
||||
"-show_format", "-show_streams", f
|
||||
"-show_format", "-show_streams", path
|
||||
])
|
||||
meta = json.loads(probe)
|
||||
return json.loads(out)
|
||||
|
||||
# Duration
|
||||
duration = float(meta["format"]["duration"])
|
||||
def run(cmd):
|
||||
print("running:", " ".join(cmd))
|
||||
subprocess.run(cmd, check=True)
|
||||
|
||||
# Title for chapter name (falls back to filename)
|
||||
title = meta["format"]["tags"].get("title") if "tags" in meta["format"] else None
|
||||
if not title:
|
||||
title = os.path.splitext(os.path.basename(f))[0]
|
||||
# --- interactive prompts ---
|
||||
input_dir = input("Enter directory containing .m4b files: ").strip()
|
||||
|
||||
chapters.append({
|
||||
"start": current_start,
|
||||
"end": current_start + duration,
|
||||
"title": title
|
||||
})
|
||||
current_start += duration
|
||||
# strip accidental surrounding quotes
|
||||
if (input_dir.startswith("'") and input_dir.endswith("'")) or \
|
||||
(input_dir.startswith('"') and input_dir.endswith('"')):
|
||||
input_dir = input_dir[1:-1]
|
||||
|
||||
# Save global tags from the first file
|
||||
if global_tags is None and "tags" in meta["format"]:
|
||||
global_tags = meta["format"]["tags"]
|
||||
if not os.path.isdir(input_dir):
|
||||
print("❌ Path not found or not a directory:", input_dir)
|
||||
sys.exit(1)
|
||||
|
||||
# 6. Write FFmetadata chapters file
|
||||
with open("chapters.txt", "w", encoding="utf-8") as f:
|
||||
f.write(";FFMETADATA1\n")
|
||||
if global_tags:
|
||||
# Copy album/author/etc. from first file
|
||||
for key, value in global_tags.items():
|
||||
if key.lower() not in ["title", "name"]: # don’t overwrite audiobook title
|
||||
f.write(f"{key}={value}\n")
|
||||
# collect .m4b files robustly (avoid glob issues with [])
|
||||
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 not all_files:
|
||||
print("❌ No .m4b files found in that directory.")
|
||||
sys.exit(1)
|
||||
|
||||
# sort (simple lexicographic; change if you want natural sort)
|
||||
all_files = sorted(all_files, key=lambda s: s.lower())
|
||||
|
||||
print("\nFound the following .m4b files:")
|
||||
for idx, fname in enumerate(all_files, start=1):
|
||||
print(f"[{idx}] {fname}")
|
||||
|
||||
# range selection
|
||||
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 -> select all
|
||||
start, end = 1, len(all_files)
|
||||
elif "-" in rng:
|
||||
a, b = rng.split("-", 1)
|
||||
start, end = int(a), int(b)
|
||||
else:
|
||||
start = int(rng)
|
||||
end = start
|
||||
assert 1 <= start <= end <= len(all_files)
|
||||
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_paths = [os.path.join(input_dir, s) for s in selected]
|
||||
print(f"\nSelected {len(selected)} file(s):")
|
||||
for s in selected:
|
||||
print(" ", s)
|
||||
|
||||
# final output name
|
||||
final_name = input("\nEnter final audiobook name (used as filename and Title metadata): ").strip()
|
||||
if not final_name:
|
||||
print("❌ No output name provided.")
|
||||
sys.exit(1)
|
||||
final_filename = os.path.join(input_dir, final_name if final_name.lower().endswith(".m4b") else final_name + ".m4b")
|
||||
|
||||
# Extract durations and chapter titles; also capture global tags & cover art from first file
|
||||
chapters = []
|
||||
current_start = 0.0
|
||||
global_tags = {}
|
||||
cover_temp = "cover.jpg"
|
||||
have_cover = False
|
||||
|
||||
for idx, p in enumerate(selected_paths):
|
||||
meta = ffprobe_json(p)
|
||||
dur = float(meta["format"]["duration"])
|
||||
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]
|
||||
chapters.append({"start": current_start, "end": current_start+dur, "title": chapter_title})
|
||||
current_start += dur
|
||||
if idx == 0:
|
||||
global_tags = tags
|
||||
# extract cover art from first file (if exists)
|
||||
try:
|
||||
run([
|
||||
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||
"-i", p, "-an", "-vcodec", "copy", cover_temp
|
||||
])
|
||||
have_cover = True
|
||||
print("✅ Extracted cover art from first file.")
|
||||
except Exception:
|
||||
print("⚠️ No cover art found in first file.")
|
||||
|
||||
# Write FFmetadata file (global tags + chapters)
|
||||
meta_path = "chapters_ffmetadata.txt"
|
||||
with open(meta_path, "w", encoding="utf-8") as mf:
|
||||
mf.write(";FFMETADATA1\n")
|
||||
# copy global tags except title/name (we'll set our own)
|
||||
for k, v in (global_tags or {}).items():
|
||||
if k.lower() not in ("title", "name", "©nam"):
|
||||
mf.write(f"{k}={v}\n")
|
||||
mf.write(f"title={final_name}\n\n")
|
||||
for ch in chapters:
|
||||
f.write("\n[CHAPTER]\n")
|
||||
f.write("TIMEBASE=1/1000\n")
|
||||
f.write(f"START={int(ch['start']*1000)}\n")
|
||||
f.write(f"END={int(ch['end']*1000)}\n")
|
||||
f.write(f"title={ch['title']}\n")
|
||||
mf.write("[CHAPTER]\n")
|
||||
mf.write("TIMEBASE=1/1000\n")
|
||||
mf.write(f"START={int(ch['start']*1000)}\n")
|
||||
mf.write(f"END={int(ch['end']*1000)}\n")
|
||||
safe_title = str(ch['title']).replace("\n", " ").replace("\r"," ")
|
||||
mf.write(f"title={safe_title}\n\n")
|
||||
|
||||
# 7. Create file list for ffmpeg concatenation
|
||||
with open("file_list.txt", "w", encoding="utf-8") as f:
|
||||
for fpath in selected_files:
|
||||
f.write(f"file '{os.path.abspath(fpath)}'\n")
|
||||
# Build concat file list (absolute paths)
|
||||
file_list = "concat_list.txt"
|
||||
with open(file_list, "w", encoding="utf-8") as fl:
|
||||
for p in selected_paths:
|
||||
fl.write(f"file '{os.path.abspath(p)}'\n")
|
||||
|
||||
# 8. Concatenate into merged.m4b
|
||||
run_ffmpeg_cmd([
|
||||
"ffmpeg", "-f", "concat", "-safe", "0", "-i", "file_list.txt",
|
||||
"-c", "copy", "merged.m4b"
|
||||
])
|
||||
# Concatenate (audio only, ignore extra cover streams)
|
||||
merged = "merged.m4b"
|
||||
try:
|
||||
run([
|
||||
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||
"-f", "concat", "-safe", "0", "-i", file_list,
|
||||
"-map", "0:a", # only audio
|
||||
"-c:a", "copy",
|
||||
merged
|
||||
])
|
||||
except subprocess.CalledProcessError:
|
||||
print("❌ ffmpeg failed while concatenating. Check ffmpeg installation and file permissions.")
|
||||
sys.exit(1)
|
||||
|
||||
# 9. Add chapter metadata + final output name
|
||||
run_ffmpeg_cmd([
|
||||
"ffmpeg", "-i", "merged.m4b", "-i", "chapters.txt",
|
||||
"-map_metadata", "1", "-c", "copy", final_output
|
||||
])
|
||||
# Mux metadata (chapters + global tags [+ cover art]) into final file
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
|
||||
"-i", merged, "-i", meta_path
|
||||
]
|
||||
|
||||
print(f"\n✅ Done! Created '{final_output}' with {len(chapters)} chapters.")
|
||||
if have_cover:
|
||||
cmd += ["-i", cover_temp]
|
||||
|
||||
cmd += [
|
||||
"-map", "0", # audio
|
||||
"-map_metadata", "1", # metadata
|
||||
"-c", "copy"
|
||||
]
|
||||
|
||||
if have_cover:
|
||||
cmd += ["-map", "2", "-disposition:v:0", "attached_pic"]
|
||||
|
||||
cmd += [final_filename]
|
||||
|
||||
try:
|
||||
run(cmd)
|
||||
except subprocess.CalledProcessError:
|
||||
print("❌ ffmpeg failed while writing metadata.")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n✅ Done. Created '{final_filename}' with {len(chapters)} chapters.")
|
||||
print("Original files were not modified.")
|
||||
|
||||
# --- cleanup temp files ---
|
||||
for f in [merged, meta_path, file_list, cover_temp]:
|
||||
if os.path.exists(f):
|
||||
try:
|
||||
os.remove(f)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to delete temp file {f}: {e}")
|
||||
|
||||
# --- optional deletion of source files ---
|
||||
del_prompt = input("\nDo you want to delete the original files that were merged? [y/N]: ").strip().lower()
|
||||
if del_prompt == "y":
|
||||
for f in selected_paths:
|
||||
try:
|
||||
os.remove(f)
|
||||
print(f"Deleted: {f}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not delete {f}: {e}")
|
||||
else:
|
||||
print("Original files kept.")
|
||||
Loading…
x
Reference in New Issue
Block a user