From e8e8a032b1b3957db09107dbb60045c288343a40 Mon Sep 17 00:00:00 2001 From: TylerCG <117808427+TylerCG@users.noreply.github.com> Date: Sat, 4 Oct 2025 22:49:37 -0400 Subject: [PATCH] Update main.py --- main.py | 381 ++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 259 insertions(+), 122 deletions(-) diff --git a/main.py b/main.py index f0ca4bc..8e5da0e 100644 --- a/main.py +++ b/main.py @@ -1,145 +1,282 @@ +#!/usr/bin/env python3 +import argparse +import json import os import shutil import subprocess from pathlib import Path -import argparse +import requests -def get_audio_info(filepath): - """Return (stream_index, channels, bitrate_in_bits).""" - cmd = [ - "ffprobe", "-v", "error", "-select_streams", "a:0", - "-show_entries", "stream=index,channels,bit_rate", - "-of", "csv=p=0", filepath - ] - result = subprocess.run(cmd, capture_output=True, text=True) - if not result.stdout: - return None, 2, 128000 - parts = result.stdout.strip().split(',') - if len(parts) < 3: - return None, 2, 128000 - _, channels, bitrate = parts - channels = int(channels) - bitrate = int(bitrate) // 1000 # kbps approx +# ============================= +# CONFIGURATION +# ============================= +SONARR_URL = "http://10.0.0.10:8989/api/v3" +RADARR_URL = "http://10.0.0.10:7878/api/v3" +SONARR_API_KEY = os.getenv("SONARR_API_KEY", "") +RADARR_API_KEY = os.getenv("RADARR_API_KEY", "") - # Bucket logic +PATH_MAPPINGS = { + "P:\\tv": "/mnt/plex/tv", + "P:\\anime": "/mnt/plex/anime", +} + +# Relative processing folder next to the script +DEFAULT_PROCESSING_FOLDER = Path(__file__).parent / "processing" + +SUFFIX = " -EHX" + +# ============================= +# AUDIO BUCKET LOGIC +# ============================= +def choose_audio_bitrate(channels: int, bitrate_kbps: int) -> int: if channels == 2: - if bitrate < 80: - br = 64000 - elif bitrate < 112: - br = 96000 + if bitrate_kbps < 80: + return 64000 + elif bitrate_kbps < 112: + return 96000 else: - br = 128000 + return 128000 else: - if bitrate < 176: - br = 160000 + if bitrate_kbps < 176: + return 160000 else: - br = 192000 - return 0, channels, br + return 192000 -def get_language_tag(filepath): +# ============================= +# PATH NORMALIZATION FOR SONARR/RADARR +# ============================= +def normalize_path_for_service(local_path: str) -> str: + for win_path, linux_path in PATH_MAPPINGS.items(): + if local_path.lower().startswith(win_path.lower()): + return local_path.replace(win_path, linux_path).replace("\\", "/") + return local_path.replace("\\", "/") + +# ============================= +# SONARR / RADARR RENAME +# ============================= +def get_service_preferred_name(input_file: Path, service="sonarr") -> str | None: + api_key = SONARR_API_KEY if service == "sonarr" else RADARR_API_KEY + url_base = SONARR_URL if service == "sonarr" else RADARR_URL + + if not api_key: + print(f"⚠️ No {service.upper()} API key; skipping rename lookup.") + return None + + norm_path = normalize_path_for_service(str(input_file)) + + try: + r = requests.get(f"{url_base}/episodefile" if service=="sonarr" else f"{url_base}/moviefile", + headers={"X-Api-Key": api_key}, timeout=10) + r.raise_for_status() + all_files = r.json() + + for f in all_files: + if f.get("path", "").lower() == norm_path.lower(): + id_field = "id" + series_id_field = "seriesId" if service=="sonarr" else "movieId" + preview_endpoint = "rename/preview" + + series_id = f[series_id_field] + file_id = f[id_field] + + preview = requests.post(f"{url_base}/{preview_endpoint}", + headers={"X-Api-Key": api_key, "Content-Type": "application/json"}, + json=[{series_id_field: series_id, "episodeFileId" if service=="sonarr" else "movieFileId": file_id}], + timeout=10) + preview.raise_for_status() + data = preview.json() + if data and "newName" in data[0]: + new_name = data[0]["newName"] + print(f"✅ {service.capitalize()} rename: {input_file.name} → {new_name}") + return new_name + print(f"ℹ️ No {service.capitalize()} match found for {input_file.name}") + except Exception as e: + print(f"❌ {service.capitalize()} rename lookup failed: {e}") + return None + +# ============================= +# AUDIO STREAMS DETECTION +# ============================= +def get_audio_streams(input_file: Path): cmd = [ - "ffprobe", "-v", "error", "-select_streams", "a:0", - "-show_entries", "stream_tags=language", - "-of", "default=nokey=1:noprint_wrappers=1", filepath + "ffprobe", "-v", "error", + "-select_streams", "a", + "-show_entries", "stream=index,channels,bit_rate", + "-of", "json", + str(input_file) ] result = subprocess.run(cmd, capture_output=True, text=True) - return result.stdout.strip() or None + data = json.loads(result.stdout) + streams = [] + for s in data.get("streams", []): + index = s["index"] + channels = s.get("channels", 2) + bitrate = int(int(s.get("bit_rate", 128000)) / 1000) + streams.append((index, channels, bitrate)) + return streams + +# ============================= +# FFmpeg ENCODE +# ============================= +def run_ffmpeg(input_file: Path, output_file: Path, cq: int, scale_width: int, scale_height: int, filter_flags: str): + streams = get_audio_streams(input_file) + + print("\n🧩 ENCODE SETTINGS") + print(f" • Resolution: {scale_width}x{scale_height}") + print(f" • Scale Filter: {filter_flags}") + print(f" • CQ: {cq}") + print(f" • Video Encoder: av1_nvenc (preset p1, pix_fmt p010le)") + + print(" • Audio Streams:") + for (index, channels, bitrate) in streams: + br = choose_audio_bitrate(channels, bitrate) + print(f" - Stream #{index}: {channels}ch, orig≈{bitrate}kbps → target {br/1000:.1f}kbps") + + # --- Build CQ encode command --- + cmd = [ + "ffmpeg", "-y", "-i", str(input_file), + "-vf", f"scale={scale_width}:{scale_height}:flags={filter_flags}:force_original_aspect_ratio=decrease", + "-map", "0:v", "-map", "0:a", "-map", "0:s?", + "-c:v", "av1_nvenc", "-preset", "p1", f"-cq", str(cq), "-pix_fmt", "p010le" + ] + + for i, (index, channels, bitrate) in enumerate(streams): + br = choose_audio_bitrate(channels, bitrate) + cmd += [f"-c:a:{i}", "aac", f"-b:a:{i}", str(br), f"-ac:{i}", str(channels)] + + cmd += ["-c:s", "copy", str(output_file)] + + print(f"\n🎬 Running CQ encode: {output_file.name}") + subprocess.run(cmd, check=True) + + # --- Check size reduction --- + orig_size = input_file.stat().st_size + out_size = output_file.stat().st_size + reduction_ratio = out_size / orig_size + print(f"📦 Original: {orig_size/1e6:.2f} MB → Encoded: {out_size/1e6:.2f} MB ({reduction_ratio:.1%} of original)") + + # --- Fallback if too large --- + if reduction_ratio >= 0.5: + print(f"⚠️ Size reduction insufficient ({reduction_ratio:.0%}). Retrying with bitrate-based encode...") + + output_file.unlink(missing_ok=True) + + # Pick bitrate settings based on resolution + if scale_height >= 1080: + vb, maxrate, bufsize = "1500k", "1750k", "2250k" + else: + vb, maxrate, bufsize = "900k", "1250k", "1600k" + + print("\n🧩 FALLBACK SETTINGS") + print(f" • Bitrate Mode: Target {vb}, Maxrate {maxrate}, Bufsize {bufsize}") + print(f" • Resolution: {scale_width}x{scale_height}") + print(f" • Filter: {filter_flags}") + print(" • Audio Streams:") + for (index, channels, bitrate) in streams: + br = choose_audio_bitrate(channels, bitrate) + print(f" - Stream #{index}: {channels}ch → {br/1000:.1f}kbps AAC") + + # --- Build fallback command --- + cmd = [ + "ffmpeg", "-y", "-i", str(input_file), + "-vf", f"scale={scale_width}:{scale_height}:flags={filter_flags}:force_original_aspect_ratio=decrease", + "-map", "0:v", "-map", "0:a", "-map", "0:s?", + "-c:v", "av1_nvenc", "-preset", "p1", + "-b:v", vb, "-maxrate", maxrate, "-bufsize", bufsize, + "-pix_fmt", "p010le" + ] + + for i, (index, channels, bitrate) in enumerate(streams): + br = choose_audio_bitrate(channels, bitrate) + cmd += [f"-c:a:{i}", "aac", f"-b:a:{i}", str(br), f"-ac:{i}", str(channels)] + + cmd += ["-c:s", "copy", str(output_file)] + + print(f"\n🎬 Running fallback bitrate encode: {output_file.name}") + subprocess.run(cmd, check=True) + +# ============================= +# PROCESS FOLDER +# ============================= +def process_folder(folder: Path, cq: int, resolution: str, rename: bool, processing_folder: Path): + if not folder.exists(): + print(f"❌ Folder not found: {folder}") + return + + # Determine defaults based on folder type and resolution + filter_flags = "lanczos" + res_height = 1080 if resolution == "1080" else 720 + res_width = 1920 if resolution == "1080" else 1280 + + folder_lower = str(folder).lower() + if "\\tv\\" in folder_lower or "/tv/" in folder_lower: + filter_flags = "bicubic" + cq_default = 28 if resolution=="1080" else 32 + else: + cq_default = 32 if resolution=="1080" else 34 + + if cq is None: + cq = cq_default + + processing_folder.mkdir(parents=True, exist_ok=True) + + for file in folder.rglob("*"): + if file.suffix.lower() not in [".mkv", ".mp4"]: + continue + if any(tag in file.name.lower() for tag in ["ehx", "megusta"]): + print(f"⏭️ Skipping: {file.name}") + continue + + print("="*60) + print(f"📁 Processing: {file.name}") + + # --- Copy to processing folder first --- + temp_input = processing_folder / file.name + shutil.copy2(file, temp_input) + + # --- Run FFmpeg --- + temp_output = processing_folder / f"{file.stem}{SUFFIX}{file.suffix}" + run_ffmpeg(temp_input, temp_output, cq, res_width, res_height, filter_flags) + + # --- Optional rename via Sonarr/Radarr --- + final_name = temp_output.name + if rename: + rename_file = get_service_preferred_name(temp_input, "sonarr") + if not rename_file: + rename_file = get_service_preferred_name(temp_input, "radarr") + if rename_file: + final_name = rename_file + temp_output.suffix + + # --- Move to completed folder or back to original --- + dest_file = file.parent / final_name + shutil.move(temp_output, dest_file) + print(f"🚚 Moved {temp_output.name} → {dest_file.name}") + + # --- Cleanup --- + if dest_file.exists(): + try: + temp_input.unlink() # remove processing copy + file.unlink() # remove original + print(f"🧹 Deleted original and processing copy") + except Exception as e: + print(f"⚠️ Could not delete files: {e}") + +# ============================= +# MAIN +# ============================= def main(): - parser = argparse.ArgumentParser( - description="Batch AV1 transcoder (FFmpeg-based) for anime/TV folders." - ) - parser.add_argument("origination", help="Path to origination folder (e.g., P:\\Anime\\Show)") - parser.add_argument("-p", "--processing", default=r"C:\Users\Tyler\Videos\Video Conversion\temp", - help="Temporary processing folder (default: %(default)s)") - parser.add_argument("-c", "--completed", default=None, - help="Completed output folder (default: back to original)") - parser.add_argument("-r", "--resolution", type=int, choices=[720, 1080], default=1080, - help="Output resolution height (default: %(default)s)") - parser.add_argument("-cq", type=int, default=None, - help="Constant quality value for AV1_NVENC (auto if not specified)") - + parser = argparse.ArgumentParser(description="Batch encode videos with optional Sonarr/Radarr rename") + parser.add_argument("folder", help="Path to folder containing videos") + parser.add_argument("--cq", type=int, help="Override default CQ") + parser.add_argument("--r", "--resolution", dest="resolution", default="1080", choices=["720","1080"], help="Target resolution (720 or 1080)") + parser.add_argument("--rename", action="store_true", help="Attempt Sonarr/Radarr rename") + parser.add_argument("--processing", type=str, default=str(DEFAULT_PROCESSING_FOLDER), help="Processing folder") args = parser.parse_args() - orig = args.origination - processing = args.processing - completed = args.completed - res_choice = str(args.resolution) + process_folder(Path(args.folder), args.cq, args.resolution, args.rename, Path(args.processing)) - # Auto-detect filter & default CQ - filter_flags = "lanczos" - if "\\tv\\" in orig.lower() or "/tv/" in orig.lower(): - filter_flags = "bicubic" - cq_default = 28 if res_choice == "1080" else 32 - else: - cq_default = 32 if res_choice == "1080" else 34 - - cq = args.cq if args.cq is not None else cq_default - - print("\n=== Using These Settings ===") - print(f"Origination: {orig}") - print(f"Processing: {processing}") - print(f"Completed: {completed if completed else '[Return to original folder]'}") - print(f"Resolution: {res_choice}") - print(f"Filter: {filter_flags}") - print(f"CQ: {cq}") - print("=============================\n") - - os.makedirs(processing, exist_ok=True) - if completed: - os.makedirs(completed, exist_ok=True) - - suffix = " -EHX" - - for root, dirs, files in os.walk(orig): - for f in files: - if not f.lower().endswith((".mkv", ".mp4")): - continue - if "ehx" in f.lower() or "megusta" in f.lower(): - print(f"Skipping {f} (contains 'EHX' or 'MeGusta')") - continue - - print("=" * 60) - print(f"Processing: {f}") - src = Path(root) / f - tmp = Path(processing) / f - shutil.copy2(src, tmp) - - # Detect audio info - _, channels, abr = get_audio_info(str(tmp)) - lang = get_language_tag(str(tmp)) - lang_metadata = [] - if not lang: - lang_metadata = ["-metadata:s:a:0", "language=eng"] - - width, height = ("1920", "1080") if res_choice == "1080" else ("1280", "720") - out_file = Path(processing) / f"{Path(f).stem}{suffix}.mkv" - - ffmpeg_cmd = [ - "ffmpeg", "-y", "-i", str(tmp), - "-vf", f"scale={width}:{height}:flags={filter_flags}:force_original_aspect_ratio=decrease", - "-map", "0:v", "-map", "0:a", "-map", "0:s?", - "-c:v", "av1_nvenc", "-preset", "p1", "-cq", str(cq), "-pix_fmt", "p010le", - "-c:a", "aac", "-b:a", str(abr), "-ac", str(channels), - *lang_metadata, - "-metadata:s:a:0", f"bit_rate={abr}", - "-c:s", "copy", - str(out_file) - ] - - subprocess.run(ffmpeg_cmd) - - target = Path(completed) / out_file.name if completed else Path(root) / out_file.name - shutil.move(out_file, target) - print(f"Moved file to {target}") - - if target.exists(): - print("Conversion confirmed. Deleting originals...") - os.remove(src) - os.remove(tmp) - else: - print("ERROR: Converted file not found. Skipping deletion of originals.") if __name__ == "__main__": main()