#!/usr/bin/env python3 import argparse import json import os import shutil import subprocess from pathlib import Path import requests # ============================= # 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", "") 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_kbps < 80: return 64000 elif bitrate_kbps < 112: return 96000 else: return 128000 else: if bitrate_kbps < 176: return 160000 else: return 192000 # ============================= # 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", "-show_entries", "stream=index,channels,bit_rate", "-of", "json", str(input_file) ] result = subprocess.run(cmd, capture_output=True, text=True) 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 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() process_folder(Path(args.folder), args.cq, args.resolution, args.rename, Path(args.processing)) if __name__ == "__main__": main()