diff --git a/main.py b/main.py index 9d7069c..73482de 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -import os, sys, subprocess, json +import os, sys, subprocess, json, argparse def ffprobe_json(path): out = subprocess.check_output([ @@ -12,19 +12,54 @@ def run(cmd): print("running:", " ".join(cmd)) subprocess.run(cmd, check=True) -# --- interactive prompts --- -input_dir = input("Enter directory containing .m4b files: ").strip() +# --- argument parsing --- +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 if (input_dir.startswith("'") and input_dir.endswith("'")) or \ (input_dir.startswith('"') and input_dir.endswith('"')): 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): print("❌ Path not found or not a directory:", input_dir) 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) 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.") 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:") @@ -40,21 +74,23 @@ 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) +if args.all: + start, end = 1, len(all_files) +else: + 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) + 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] @@ -63,13 +99,27 @@ 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") +if args.output: + final_name = args.output.strip() +else: + # derive from first selected file + 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 = [] current_start = 0.0 global_tags = {} @@ -80,13 +130,11 @@ 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", @@ -97,15 +145,15 @@ for idx, p in enumerate(selected_paths): except Exception: print("⚠️ No cover art found in first file.") -# Write FFmetadata file (global tags + chapters) +# Write FFmetadata 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") + 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: mf.write("[CHAPTER]\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"," ") mf.write(f"title={safe_title}\n\n") -# Build concat file list (absolute paths) +# Build concat list 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") -# Concatenate (audio only, ignore extra cover streams) +# Concatenate audio only merged = "merged.m4b" try: run([ "ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", file_list, - "-map", "0:a", # only audio + "-map", "0:a", "-c:a", "copy", merged ]) except subprocess.CalledProcessError: - print("❌ ffmpeg failed while concatenating. Check ffmpeg installation and file permissions.") + print("❌ ffmpeg failed while concatenating.") sys.exit(1) -# Mux metadata (chapters + global tags [+ cover art]) into final file +# Mux metadata + cover cmd = [ "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", merged, "-i", meta_path ] - if have_cover: cmd += ["-i", cover_temp] - cmd += [ - "-map", "0", # audio - "-map_metadata", "1", # metadata + "-map", "0", + "-map_metadata", "1", "-c", "copy" ] - if have_cover: cmd += ["-map", "2", "-disposition:v:0", "attached_pic"] - cmd += [final_filename] try: @@ -161,9 +205,8 @@ except subprocess.CalledProcessError: sys.exit(1) 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]: if os.path.exists(f): try: @@ -171,9 +214,14 @@ for f in [merged, meta_path, file_list, cover_temp]: 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": +# delete originals? +# if args.delete: +# 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: try: os.remove(f) @@ -181,4 +229,4 @@ if del_prompt == "y": except Exception as e: print(f"⚠️ Could not delete {f}: {e}") else: - print("Original files kept.") \ No newline at end of file + print("Original files kept.")