diff --git a/ffmpeg_handling.py b/ffmpeg_handling.py new file mode 100644 index 0000000..6e882f3 --- /dev/null +++ b/ffmpeg_handling.py @@ -0,0 +1,871 @@ +#!/home/lucas/.venv_general/bin/python3 + +""" +FFmpeg Python CLI Tool + +A wrapper for ffmpeg-python to perform common media tasks. +Parameters are documented with their equivalent FFmpeg command-line flags. +""" + +import argparse +import os +import sys +import json +import ffmpeg +import re +REQUIRES_OUTPUT = { + "convert-av", + "convert-audio", + "convert-video", + "remove-subs", + "remove-audio", + "downsize", + "filter-audio", + "cut", + "set-primary-audio", +} +# ---------- Utilities ---------- # + +def run_ffmpeg(stream, overwrite=True): + """ + Run FFmpeg and stream output directly to terminal. + + - No log buffering + - Real-time progress/errors visible + - Easier debugging than capture_stderr=True + """ + try: + ffmpeg.run( + stream, + overwrite_output=overwrite + ) + except ffmpeg.Error as e: + print("FFmpeg failed.\n") + if e.stderr: + print(e.stderr.decode(errors="ignore")) + sys.exit(1) + +def probe_audio_streams(file): + """ + Probes metadata to identify audio tracks. + + FFmpeg Equivalent: + ffprobe -v error -select_streams a -show_entries stream=index:tags=language -of json + """ + try: + probe = ffmpeg.probe(file) + except ffmpeg.Error as e: + print("ffprobe failed:", e) + sys.exit(1) + + return [ + (s.get("index"), s.get("tags", {}).get("language", "unknown")) + for s in probe.get("streams", []) + if s.get("codec_type") == "audio" + ] + +def select_audio_stream(file, provided=None): + """ + Logical handler for stream selection. Ensures we target the correct + absolute stream index when building filter graphs. + """ + streams = probe_audio_streams(file) + + if not streams: + print("No audio streams found.") + sys.exit(1) + + if provided is not None: + if any(s[0] == provided for s in streams): + return provided + print(f"Provided index {provided} is not a valid audio stream.") + sys.exit(1) + + if len(streams) == 1: + return streams[0][0] + + print(f"Found {len(streams)} audio streams:") + for i, (idx, lang) in enumerate(streams): + print(f"[{i}] Index: {idx}, Language: {lang}") + + try: + choice = int(input("Select stream number (the list index [x]): ")) + return streams[choice][0] + except (ValueError, IndexError): + print("Invalid selection.") + sys.exit(1) + +def parse_time_to_seconds(value: str) -> float: + """ + Convert human-readable time formats into seconds. + + Supported formats: + - "100s" → 100 + - "1m20s" → 80 + - "2h10m5s" → 7805 + - "0h1m5s" → 65 + - "0:1:5" → 65 (hh:mm:ss or mm:ss) + """ + + if value is None: + return None + + value = str(value).strip() + + # Case 1: pure seconds (e.g. 100s) + if value.endswith("s") and value[:-1].isdigit(): + return float(value[:-1]) + + # Case 2: h/m/s format (e.g. 1h2m3s) + pattern = re.compile( + r"(?:(?P\d+)h)?(?:(?P\d+)m)?(?:(?P\d+)s)?" + ) + match = pattern.fullmatch(value) + if match and any(match.groups()): + h = int(match.group("h") or 0) + m = int(match.group("m") or 0) + s = int(match.group("s") or 0) + return h * 3600 + m * 60 + s + + # Case 3: colon format (hh:mm:ss or mm:ss) + if ":" in value: + parts = list(map(int, value.split(":"))) + if len(parts) == 2: + m, s = parts + return m * 60 + s + elif len(parts) == 3: + h, m, s = parts + return h * 3600 + m * 60 + s + + # fallback: raw number + try: + return float(value) + except ValueError: + raise argparse.ArgumentTypeError(f"Invalid time format: {value}") + +def get_scodec(output_path: str): + ext = output_path.lower().split(".")[-1] + + if ext == "mp4": + return "mov_text" + + if ext == "mkv": + return "srt" + + return "copy" + +def get_vcodec(output_path: str): + ext = output_path.lower().split(".")[-1] + + if ext == "mp4": + return "libx264" + + return "copy" + +# ---------- Commands ---------- # +def cmd_info(args): + """ + Retrieves and prints metadata. + + FFmpeg Equivalent: + ffprobe -show_format -show_streams -of json [input] + """ + try: + info = ffmpeg.probe(args.input) + print(json.dumps(info, indent=2)) + except ffmpeg.Error as e: + print("Error probing file:", e) + +def cmd_convert_av(args): + """ + Re-encodes both video and audio. + + FFmpeg Equivalent: + ffmpeg -i [input] -c:v [video_codec] -c:a [audio_codec] -preset [preset] -crf [quality] [output] + + Parameters: + - vcodec/c:v: Sets the video encoder (e.g., libx264). + - acodec/c:a: Sets the audio encoder (e.g., aac). + - preset: Trade-off between compression speed and efficiency (ultrafast to veryslow). + - qp/crf: Constant Rate Factor. Lower is higher quality (18-28 is standard). + """ + inp = ffmpeg.input(args.input) + out = ffmpeg.output( + inp, + args.output, + vcodec=args.video_codec, + acodec=args.audio_codec, + scodec=get_scodec(args.output), + preset=args.preset, + qp=args.quality, + # This maps all streams from the first input once + map='0', + # Prevents FFmpeg from trying to guess/add extra default streams + map_metadata=0 + ) + return [out] + +def cmd_convert_audio(args): + """ + Transcodes audio while keeping video intact. + + FFmpeg Equivalent: + ffmpeg -i [input] -map 0:v -map 0:a -c:v copy -c:a [audio_codec] [output] + + Parameters: + - vcodec='copy': Enables 'stream copy' mode, bypassing the video decoder/encoder. + """ + inp = ffmpeg.input(args.input) + out = ffmpeg.output( + inp, + args.output, + vcodec="copy", + acodec=args.audio_codec, + scodec=get_scodec(args.output), + map='0', + map_metadata=0 + ) + return [out] + +def cmd_convert_video(args): + """ + Transcodes video while keeping audio intact. + + FFmpeg Equivalent: + ffmpeg -i [input] -map 0:v -map 0:a -c:v [video_codec] -c:a copy [output] + """ + inp = ffmpeg.input(args.input) + out = ffmpeg.output( + inp, + args.output, + vcodec=args.video_codec, + acodec="copy", + scodec=get_scodec(args.output), + map='0', + map_metadata=0 + ) + return [out] + +def cmd_remove_subs(args): + """ + Strips all subtitle data. + + FFmpeg Equivalent: + ffmpeg -i [input] -sn -c copy [output] + + Parameters: + - sn: Disables subtitle recording. + """ + inp = ffmpeg.input(args.input) + out = ffmpeg.output( + inp, + args.output, + sn=None, + c="copy", + map='0', + map_metadata=0 + ) + return [out] + +def cmd_remove_audio_track(args): + """ + 1. Removes a specific audio track from the video (Main Output). + 2. Saves that specific audio track as a standalone MP3 (Extra Output). + """ + inp = ffmpeg.input(args.input) + probe = ffmpeg.probe(args.input) + + remove_idx = select_audio_stream(args.input, args.audio_index) + + streams_to_keep = [] + extracted_audio = None + + for s in probe["streams"]: + idx = s["index"] + if idx == remove_idx: + extracted_audio = inp[str(idx)] + else: + streams_to_keep.append(inp[str(idx)]) + + video_out = ffmpeg.output( + *streams_to_keep, + args.output, + vcodec="copy", + acodec="copy", + scodec= get_scodec(args.output), + ) + + outputs = [video_out] + + if args.output_audio: + audio_out = ffmpeg.output( + extracted_audio, + args.output_audio, + acodec='libmp3lame', + audio_bitrate='192k' + ) + outputs.append(audio_out) + + return outputs + +def cmd_downsize(args): + """ + Scales resolution and compresses. + + FFmpeg Equivalent: + ffmpeg -i [input] -vf scale=[w]:[h] -c:v libx264 -crf [crf] -c:a copy [output] + + Parameters: + - filter('scale'): Invokes the video filter (vf) for resizing. + - crf: Set to 23 (default) for balanced quality. + """ + inp = ffmpeg.input(args.input) + video = inp.video.filter("scale", args.width, args.height) + info = ffmpeg.probe(args.input) + streams = info.get("streams", []) + subtitle_streams = [s for s in streams if s.get("codec_type") == "subtitle"] + + subs = {} + if len(subtitle_streams) > 0: + subs = {inp["s"]} + + out = ffmpeg.output( + video, + inp.audio, + *subs, + args.output, + vcodec="libx264", + crf=args.crf, + acodec="copy", + scodec=get_scodec(args.output) + ) + return [out] + +def cmd_filter_audio(args): + """ + Multi-stage audio mastering chain. + + FFmpeg Equivalent: + ffmpeg -i [input] -filter_complex "[0:a:X]highpass...,acompressor...,equalizer...[out]" \ + -map 0:v -map [out] -c:v copy -c:a libvorbis [output] + + Filter Descriptions: + 1. highpass: Cuts frequencies below 100Hz (removes low-end rumble). + 2. acompressor: Reduces volume of loud peaks to increase overall clarity. + 3. equalizer: Adjusts specific frequency bands (2.5kHz and 4kHz for presence). + 4. deesser: Dampens harsh 'S' sounds. + 5. alimiter: Hard limit at 0.95 to prevent digital clipping (distortion). + 6. dynaudnorm: Smooths out loudness levels over time. + """ + idx_filt = select_audio_stream(args.input, args.audio_index) + inp = ffmpeg.input(args.input) + + probe = ffmpeg.probe(args.input) + + audio_streams = [ + s["index"] + for s in probe["streams"] + if s.get("codec_type") == "audio" + ] + filtered_audios = [] + for idx in audio_streams: + audio = inp[str(idx)] + if idx == idx_filt: + audio_filtered = ( + audio + .filter("highpass", f=100) + .filter("acompressor", threshold="-22dB", ratio=4, attack=10, release=150) + .filter("equalizer", f=2500, t="q", w=1, g=5) + .filter("equalizer", f=4000, t="q", w=1, g=4) + .filter("deesser", i=0.4, f=0.25) + .filter("alimiter", limit=0.95) + .filter("dynaudnorm", f=200, g=15) + ) + filtered_audios.append(audio_filtered) + else: + + filtered_audios.append(audio) + info = ffmpeg.probe(args.input) + streams = info.get("streams", []) + subtitle_streams = [s for s in streams if s.get("codec_type") == "subtitle"] + + subs = {} + if len(subtitle_streams) > 0: + subs = {inp["s"]} + + out = ffmpeg.output( + inp.video, + *filtered_audios, + *subs, + args.output, + vcodec="copy", + acodec="libvorbis", + scodec=get_scodec(args.output), + ) + + return [out] + +def cmd_cut_segment(args): + """ + Cut a segment from a video or audio file. + + FFmpeg parameters: + ss (start time): where to begin cutting (seconds or timestamp) + t (duration): length of segment to extract + c=copy: stream copy (fast, no re-encoding) + + Notes: + - Using ss before input enables fast seek + - Copy mode is fast but may be less frame-accurate + """ + + start = parse_time_to_seconds(args.start) + duration = parse_time_to_seconds(args.duration) + + inp = ffmpeg.input(args.input) + + out = ffmpeg.output( + inp, + args.output, + ss=start, + t=duration, + vcodec=get_vcodec(args.output), + acodec="copy", + scodec=get_scodec(args.output), + map="0" + ) + + return [out] + +def cmd_set_primary_audio(args): + """ + Set a chosen audio stream as the PRIMARY (first/default) audio track. + + Uses select_audio_stream() to ensure correct stream selection. + + FFmpeg behavior: + - We explicitly reorder audio streams + - Selected stream becomes first audio track + - All other audio streams are preserved + - Video and subtitles are kept + + Important: + FFmpeg does NOT support "set default audio track" directly in MP4. + So we simulate it by stream reordering. + """ + + inp = ffmpeg.input(args.input) + + primary_audio = select_audio_stream(args.input, args.audio_index) + probe = ffmpeg.probe(args.input) + + audio_streams = [ + s["index"] + for s in probe["streams"] + if s.get("codec_type") == "audio" + ] + + reordered_audio = [primary_audio] + [ + a for a in audio_streams if a != primary_audio + ] + + audio_inputs = [inp[f"a:{i - 1}"] for i in reordered_audio] + + out_kwargs={} + for i, stream in enumerate(reordered_audio): + if stream == primary_audio: + out_kwargs[f"disposition:a:{i}"] = "default" + else: + out_kwargs[f"disposition:a:{i}"] = "0" + + info = ffmpeg.probe(args.input) + streams = info.get("streams", []) + subtitle_streams = [s for s in streams if s.get("codec_type") == "subtitle"] + + subs = {} + if len(subtitle_streams) > 0: + subs = {inp["s"]} + + out = ffmpeg.output( + inp.video, + *audio_inputs, + *subs, + args.output, + vcodec="copy", + acodec="copy", + scodec=get_scodec(args.output), + **out_kwargs + ) + + return [out] + +def cmd_test(args): + """ + TEST MODE: + Runs each command individually on test.mp4 and probes outputs. + """ + + script_dir = os.path.dirname(os.path.abspath(__file__)) + test_files = [ "test_auto.mkv", "test_auto.mp4","test_auto_no_subs.mkv", "test_auto_no_subs.mp4"] + test_files = [ os.path.join(script_dir,file) for file in test_files ] + test_dir = "/tmp/" + + + RED = "\033[91m" + GREEN = "\033[92m" + YELLOW = "\033[93m" + BLUE = "\033[94m" + RESET = "\033[0m" + + def red_tag(tag: str) -> str: + return f"{RED}[{tag}]{RESET}" + + def green_tag(tag: str) -> str: + return f"{GREEN}[{tag}]{RESET}" + + def run_and_probe(name, cmd_func, args, test_dict: list=[]): + print(f"TEST: {name}") + # Run FFmpeg for this single command only + outputs = cmd_func(args) + + if not outputs: + print("No outputs returned.") + return + + try: + ffmpeg.run( + outputs, + overwrite_output=True, + quiet=True, + capture_stderr=True + ) + except ffmpeg.Error as e: + print("FFmpeg failed:") + if e.stderr: + print(e.stderr.decode(errors="ignore")) + return + + # Probe result file(s) + out_files = [] + + if hasattr(args, "output") and args.output: + out_files.append(args.output) + + if hasattr(args, "output_audio") and args.output_audio: + out_files.append(args.output_audio) + + + for i, f in enumerate(out_files): + if not os.path.exists(f): + print(f"{red_tag("FAIL")} Missing output: {f}") + continue + + try: + validate_ffprobe(f,test_dict[i]) + except ffmpeg.Error as e: + print(f"{red_tag("FAIL")} Probe error: {f}") + print(e) + + def get_streams(test_file): + info = ffmpeg.probe(test_file) + streams = info.get("streams", []) + + video_streams = [s for s in streams if s.get("codec_type") == "video"] + audio_streams = [s for s in streams if s.get("codec_type") == "audio"] + subtitle_streams = [s for s in streams if s.get("codec_type") == "subtitle"] + if not os.path.exists(test_file): + print("test file not found in script directory.") + sys.exit(1) + + return video_streams,audio_streams,subtitle_streams + + def validate_ffprobe(file_path, expected: dict): + """ + Validate ffprobe output using a dictionary-based expectation. + + Expected dict format: + { + "video_count": int, + "audio_count": int, + "subtitle_count": int, + "audio_codecs": list[str], + "video_codecs": list[str] + } + """ + + info = ffmpeg.probe(file_path) + streams = info.get("streams", []) + + video_streams = [s for s in streams if s.get("codec_type") == "video"] + audio_streams = [s for s in streams if s.get("codec_type") == "audio"] + subtitle_streams = [s for s in streams if s.get("codec_type") == "subtitle"] + + # ---------- COUNT CHECKS ---------- + if "video_count" in expected: + if len(video_streams) != expected["video_count"]: + print(f"{red_tag("VIDEO COUNT FAIL")} expected {expected['video_count']}, got {len(video_streams)}") + + if "audio_count" in expected: + if len(audio_streams) != expected["audio_count"]: + print(f"{red_tag("AUDIO COUNT FAIL")} expected {expected['audio_count']}, got {len(audio_streams)}") + + if "subtitle_count" in expected: + if len(subtitle_streams) != expected["subtitle_count"]: + print(f"{red_tag("SUBTITLE COUNT FAIL")} expected {expected['subtitle_count']}, got {len(subtitle_streams)}") + + # ---------- CODEC CHECKS ---------- + if "audio_codecs" in expected: + actual = sorted([s.get("codec_name") for s in audio_streams]) + expected_codecs = sorted(expected["audio_codecs"]) + if len(expected_codecs) < len(actual): + last = expected_codecs[-1] if expected_codecs else None + expected_codecs = expected_codecs + [last] * (len(actual) - len(expected_codecs)) + + if actual != expected_codecs: + print(f"{red_tag("AUDIO CODEC FAIL")}\nExpected: {expected_codecs}\nGot: {actual}") + + if "video_codecs" in expected: + actual = sorted([s.get("codec_name") for s in video_streams]) + expected_codecs = sorted(expected["video_codecs"]) + if len(expected_codecs) < len(actual): + last = expected_codecs[-1] if expected_codecs else None + expected_codecs = expected_codecs + [last] * (len(actual) - len(expected_codecs)) + if actual != expected_codecs: + print(f"{red_tag("VIDEO CODEC FAIL")}\nExpected: {expected_codecs}\nGot: {actual}") + # ---------- DURATION CHECKS ---------- + if "duration" in expected: + format_info = info.get("format", {}) + actual_duration = float(format_info.get("duration", 0)) + + expected_duration = parse_time_to_seconds(expected["duration"]) + + # allow small ffmpeg timing variance (important!) + tolerance = expected.get("duration_tolerance", 0.2) + + if abs(actual_duration - expected_duration) <= tolerance: + print(f"{red_tag("DURATION FAIL")} expected ~{expected_duration} (±{tolerance}), got {actual_duration}s") + + print(f"{green_tag("PASS")} {file_path}") + return True + + class Args: + pass + + # ---------- INFO ---------- + #args = Args() + #args.input = test_file_mp4 + #test_dict = {} + #run_and_probe("info", cmd_info, args, test_dict) + #args.input = test_file_mkv + #run_and_probe("info", cmd_info, args, test_dict) + + # ---------- DOWNSIZE ---------- + args = Args() + for test_file in test_files: + video_streams, audio_streams, subtitle_streams = get_streams(test_file) + args.input = test_file + args.output = os.path.join(test_dir, "out_downsize" + os.path.splitext(test_file)[1]) + args.width = 640 + args.height = 360 + args.crf = 28 + test_dict = [{"video_count": len(video_streams), "audio_count": len(audio_streams), + "subtitle_count": len(subtitle_streams), "video_codecs": ["h264"]}] + run_and_probe("downsize", cmd_downsize, args, test_dict) + + # ---------- REMOVE AUDIO TRACK ---------- + args = Args() + args.audio_index = 1 + + for test_file in test_files: + video_streams,audio_streams,subtitle_streams = get_streams(test_file) + args.input = test_file + test_dict = [{"video_count": len(video_streams), "audio_count": 1, "subtitle_count": len(subtitle_streams)}, + {"video_count": 0, "audio_count": 1, "subtitle_count": 0}] + args.output = os.path.join(test_dir, "out_remove_audio" + os.path.splitext(test_file)[1]) + args.output_audio = os.path.join(test_dir, "extracted_audio_" + os.path.splitext(test_file)[1][1:] + ".mp3") + run_and_probe("remove-audio-track", cmd_remove_audio_track, args, test_dict) + + # ---------- CONVERT AV ---------- + args = Args() + for test_file in test_files: + video_streams, audio_streams, subtitle_streams = get_streams(test_file) + args.input = test_file + args.output = os.path.join(test_dir, "out_convert_av" + os.path.splitext(test_file)[1]) + args.video_codec = "libx264" + args.audio_codec = "aac" + args.preset = "fast" + args.quality = 23 + test_dict = [ {"video_count": len(video_streams),"audio_count": len(audio_streams), + "subtitle_count": len(subtitle_streams), "video_codecs": ["h264"],"audio_codecs": ["aac"]}] + run_and_probe("convert-av", cmd_convert_av, args, test_dict) + + + # ---------- CONVERT AUDIO ---------- + args = Args() + for test_file in test_files: + video_streams, audio_streams, subtitle_streams = get_streams(test_file) + args.input = test_file + args.output = os.path.join(test_dir, "out_convert_audio" + os.path.splitext(test_file)[1]) + args.audio_codec = "aac" + test_dict = [ {"video_count": len(video_streams), "audio_count": len(audio_streams), + "subtitle_count": len(subtitle_streams), "audio_codecs": ["aac"]}] + run_and_probe("convert-audio", cmd_convert_audio, args, test_dict) + + # ---------- CONVERT VIDEO ---------- + args = Args() + for test_file in test_files: + video_streams, audio_streams, subtitle_streams = get_streams(test_file) + args.input = test_file + args.output = os.path.join(test_dir, "out_convert_video" + os.path.splitext(test_file)[1]) + args.video_codec = "libx264" + test_dict = [ {"video_count": len(video_streams), "audio_count": len(audio_streams), + "subtitle_count": len(subtitle_streams), "video_codecs": ["h264"]}] + run_and_probe("convert-video", cmd_convert_video, args, test_dict) + + # ---------- REMOVE SUBS ---------- + args = Args() + for test_file in test_files: + video_streams, audio_streams, subtitle_streams = get_streams(test_file) + args.input = test_file + test_dict = [ {"video_count": len(video_streams), "audio_count": len(audio_streams), "subtitle_count": 0}] + args.output = os.path.join(test_dir, "out_no_subs" + os.path.splitext(test_file)[1]) + run_and_probe("remove-subs", cmd_remove_subs, args, test_dict) + + # ---------- FILTER AUDIO ---------- + args = Args() + for test_file in test_files: + video_streams, audio_streams, subtitle_streams = get_streams(test_file) + args.input = test_file + args.output = os.path.join(test_dir, "out_filter_audio" + os.path.splitext(test_file)[1]) + args.audio_index = 1 + test_dict = [ {"video_count": len(video_streams), "audio_count": len(audio_streams), + "subtitle_count": len(subtitle_streams), "audio_codecs": ["vorbis"]}] + run_and_probe("filter-audio", cmd_filter_audio, args, test_dict) + + # ---------- CUT ---------- + args = Args() + for test_file in test_files: + video_streams, audio_streams, subtitle_streams = get_streams(test_file) + args.input = test_file + args.output = os.path.join(test_dir, "out_cut" + os.path.splitext(test_file)[1]) + args.start = "0:0:5" + args.duration = "10s" + test_dict = [ {"video_count": len(video_streams), "audio_count": len(audio_streams), + "subtitle_count": len(subtitle_streams), "duration": "10s"}] + run_and_probe("cut", cmd_cut_segment, args, test_dict) + + # ---------- SET PRIMARY AUDIO ---------- + args = Args() + for test_file in test_files: + video_streams, audio_streams, subtitle_streams = get_streams(test_file) + args.input = test_file + args.output = os.path.join(test_dir, "out_primary_audio" + os.path.splitext(test_file)[1]) + args.audio_index = 2 + test_dict = [ {"video_count": len(video_streams), "audio_count": len(audio_streams), + "subtitle_count": len(subtitle_streams)}] + run_and_probe("set-primary-audio", cmd_set_primary_audio, args, test_dict) + + print("\nALL TESTS COMPLETED") + +# ---------- CLI Setup ---------- # + +def main(): + parser = argparse.ArgumentParser( + description="Media processing tool using ffmpeg-python", + formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + + # Base parser shared across all subcommands + base_p = argparse.ArgumentParser(add_help=False) + base_p.add_argument("input", help="Path to input media file") + base_p.add_argument("-o", "--output", help="Path for output file") + + sub = parser.add_subparsers(dest="command", required=True) + + # Info Command + p_info = sub.add_parser("info", parents=[base_p], help="Inspect media metadata") + p_info.set_defaults(func=cmd_info) + # Info Command + p_info = sub.add_parser("test", help="Inspect media metadata") + p_info.set_defaults(func=cmd_test) + + # Convert AV Command + p_av = sub.add_parser("convert-av", parents=[base_p], help="Full re-encode of video/audio") + p_av.add_argument("--video-codec", default="libx264", help="FFmpeg -c:v flag") + p_av.add_argument("--audio-codec", default="aac", help="FFmpeg -c:a flag") + p_av.add_argument("--preset", default="medium", help="FFmpeg -preset flag") + p_av.add_argument("--quality", type=int, default=23, help="FFmpeg -crf value") + p_av.set_defaults(func=cmd_convert_av) + + # Convert Audio Command + p_a = sub.add_parser("convert-audio", parents=[base_p], help="Change audio format only") + p_a.add_argument("--audio-codec", default="mp3", help="FFmpeg -c:a flag") + p_a.set_defaults(func=cmd_convert_audio) + + # Convert Video Command + p_v = sub.add_parser("convert-video", parents=[base_p], help="Change video format only") + p_v.add_argument("--video-codec", default="libx264", help="FFmpeg -c:v flag") + p_v.set_defaults(func=cmd_convert_video) + + # Remove Subs Command + p_rs = sub.add_parser("remove-subs", parents=[base_p], help="Strip all subtitle streams (-sn)") + p_rs.set_defaults(func=cmd_remove_subs) + + # Remove Audio Command + p_ra = sub.add_parser("remove-audio", parents=[base_p], help="Remove one audio track via map exclusion") + p_ra.add_argument("-oa", "--output-audio", required=False) + p_ra.add_argument("-a", "--audio-index", type=int, help="Track index within audio streams") + p_ra.set_defaults(func=cmd_remove_audio_track) + + # Downsize Command + p_ds = sub.add_parser("downsize", parents=[base_p], help="Scale video dimensions (-vf scale)") + p_ds.add_argument("--width", type=int, default=1280) + p_ds.add_argument("--height", type=int, default=720) + p_ds.add_argument("--crf", type=int, default=23) + p_ds.set_defaults(func=cmd_downsize) + + # Filter Audio Command + p_fa = sub.add_parser("filter-audio", parents=[base_p], help="Apply complex audio enhancement chain") + p_fa.add_argument("-a", "--audio-index", type=int, help="Index of audio stream to filter (e.g. 0, 1, 2...)") + p_fa.set_defaults(func=cmd_filter_audio) + + # Cut Video Segment + p_c = sub.add_parser("cut", parents=[base_p], help="Cut X sec out of the video stream") + p_c.add_argument("--start",required=True,help="Start time (e.g. 10, 10s, 1m5s, 0:1:5)") + p_c.add_argument("--duration",required=True,help="Duration (e.g. 30, 30s, 1m5s, 0:1:5)") + p_c.set_defaults(func=cmd_cut_segment) + + # Set audio track as primary + p_pa = sub.add_parser("set-primary-audio", parents=[base_p], help="Set audiotrack X as primary track") + p_pa.add_argument("-a", "--audio-index",type=int,help="Index of audio stream to make primary (e.g. 0, 1, 2...)") + p_pa.set_defaults(func=cmd_set_primary_audio) + + # Switch primary audio + args = parser.parse_args() + if not hasattr(args, "func"): + parser.error("No command function bound.") + # enforce output only for convert-av + if args.command in REQUIRES_OUTPUT and not args.output: + parser.error("--output is required for this command") + + # Default output path generation logic + outputs = args.func(args) + + if not isinstance(outputs, list): + outputs = [outputs] + + if args.command != "test": + try: + ffmpeg.run( + ffmpeg.merge_outputs(*outputs), + overwrite_output=True + ) + except ffmpeg.Error as e: + print("FFmpeg failed.\n") + if e.stderr: + print(e.stderr.decode(errors="ignore")) + sys.exit(1) + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/rename_episodes.py b/rename_episodes.py new file mode 100644 index 0000000..763880f --- /dev/null +++ b/rename_episodes.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +import argparse +import re +import os +from pathlib import Path + +from serverLibary import safe_rename,safe_delete + +VIDEO_EXTENSIONS = {".mpg", ".avi", ".mkv", ".mp4", ".m4v", ".srt"} + +UNWANTED_PATTERNS_POST = [ + r"TV\ Series", r"[a-z]264", r"10bit", r"\[.*?\]", + r"320kbps", r"AC3", r"NF\.WEB", r"bdrip", r"RERIP", + r"DUAL-", r"LostFilm", r"readnfo", r"BluRay", + r"720p", r"1080p", r"WEB", r"AMZN", r"HDTV", + r"DVD", r"BDRip", r"HDRip", r"REPACK", r"XviD", r"ETHEL", f"DL ", f"HEVC" +] + +UNWANTED_PATTERNS_PRE = [ + r"www.UIndex.org", r"\[TorrentCounter\.to\]" +] + +GARBAGE_PATTERNS = [ + r"www\.YTS.*", r".*\.nfo", r"RARBG.*", r".*YIFY.*\.jpg", f"Torrent Downloaded From.*txt", "Screens" +] + +DAMAGE_PATTERNS = [ + (r"\.\.", "."), + (r"__", "_"), + (r"\s+", " "), +] + + +def iter_media_files(base: Path): + if base.is_file(): + yield base + for path in base.rglob("*"): + if path.is_file() and path.suffix.lower() in VIDEO_EXTENSIONS: + yield path + +def clean_name(name: Path, search=None, replace=None): + if name.is_file(): + base, ext = os.path.splitext(name.name) + else: + base = name.name + ext = "" + #base = base.replace(" ", ".") + base = re.sub(r"s(\d{2})e", r"S\1E", base, flags=re.IGNORECASE) + + for pattern in UNWANTED_PATTERNS_PRE: + base = re.sub(rf"{pattern}[^a-zA-Z0-9]*", "", base, flags=re.IGNORECASE) + + + base_new = base + for pattern in UNWANTED_PATTERNS_POST: + base_new = re.sub(rf"{pattern}.*$", "", base_new, flags=re.IGNORECASE) + + if len(base_new) > 5: + base = base_new + + if search and replace: + base = re.sub(search, replace, base) + + # Remove all non-word characters at the end of the string + base = re.sub(r'[^A-Za-z0-9)]+$', '', base) + base = re.sub(r"\.+", ".", base) + base = re.sub(r" +", " ", base) + base = base.strip(".") + return base + ext + + +def capitalize_name(name: str): + return re.sub( + r"(^|[ ._\-])([a-z])", + lambda m: m.group(1) + m.group(2).upper(), + name.lower() + ) + + +# ----------------------------- +# Operations +# ----------------------------- + +def rename_files(base: Path, args): + for file in iter_media_files(base): + new_name = clean_name(file, args.search, args.replace) + + if args.capitalize: + new_name = capitalize_name(new_name) + + new_path = file.with_name(new_name) + safe_rename(file, new_path, args.dryrun, args.confirm) + + +def rename_directories(base: Path, args): + # reverse order → avoid renaming parent first + for path in sorted(base.rglob("*"), reverse=True): + if not path.is_dir(): + continue + + new_name = clean_name(path, args.search, args.replace) + + if args.capitalize: + new_name = capitalize_name(new_name) + + new_path = path.with_name(new_name) + safe_rename(path, new_path, args.dryrun, args.confirm) + + +def repair_names(base: Path, args): + for file in iter_media_files(base): + new_name = file.name + + for pattern, repl in DAMAGE_PATTERNS: + new_name = re.sub(pattern, repl, new_name) + + new_path = file.with_name(new_name) + safe_rename(file, new_path, args.dryrun, args.confirm) + + +def delete_garbage(base: Path, args): + for path in base.rglob("*"): + for pattern in GARBAGE_PATTERNS: + if re.match(pattern, path.name, re.IGNORECASE): + print(f"Deleting: {path}") + if not args.dryrun: + safe_delete(path) + + +# ----------------------------- +# CLI +# ----------------------------- + +def parse_args(): + parser = argparse.ArgumentParser(description="Media renamer tool") + + parser.add_argument("-p","--path", default=".", help="Target directory") + parser.add_argument("-rep","--repair", action="store_true", help="Fix filenames") + parser.add_argument("-d","--delete", dest="delete", action="store_true", help="Delete junk files") + parser.add_argument("-cap","--capitalize", dest="capitalize", action="store_true", help="Capitalize names") + + parser.add_argument("-r","--replace", nargs=2, metavar=("SEARCH", "REPLACE")) + + parser.add_argument("-dr","--dryrun", action="store_true", help="Dry run") + parser.add_argument("-c","--confirm", action="store_true", help="Confirm each rename") + + return parser.parse_args() + + +def main(): + args = parse_args() + + if args.replace: + args.search = args.replace + else: + args.search = args.replace = None + + base = Path(args.path).resolve() + + if not base.exists(): + print("Invalid path") + return + + # 1. fix broken names first (optional but recommended) + if args.repair: + repair_names(base, args) + + # 2. rename directories FIRST (important for path stability) + rename_directories(base, args) + + # 3. rename files AFTER + rename_files(base, args) + + # 4. cleanup + if args.delete: + delete_garbage(base, args) + + +if __name__ == "__main__": + main()