From 6b278ebc7d22a7d5fa9a1d7421c6a6f4d5525685 Mon Sep 17 00:00:00 2001 From: TylerCG <117808427+TylerCG@users.noreply.github.com> Date: Sat, 4 Oct 2025 23:46:10 -0400 Subject: [PATCH] improvements --- .gitignore | 2 + config.xml | 85 +++++++++++++++++++ core/__init__.py | 0 core/config_helper.py | 122 +++++++++++++++++++++++++++ main.py | 190 +++++++++++------------------------------- 5 files changed, 257 insertions(+), 142 deletions(-) create mode 100644 .gitignore create mode 100644 config.xml create mode 100644 core/__init__.py create mode 100644 core/config_helper.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df12d56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +processing/* +__pycache__ \ No newline at end of file diff --git a/config.xml b/config.xml new file mode 100644 index 0000000..17fc4f8 --- /dev/null +++ b/config.xml @@ -0,0 +1,85 @@ + + + + + + + processing + + + -EHX + + + .mkv,.mp4 + + + + + + + + + + + + http://10.0.0.10:8989/api/v3 + a3458e2a095e4e1c892626c4a4f6959f + + + http://10.0.0.10:7878/api/v3 + + + + + + + + + 28 + 32 + 32 + 34 + + + + + 1500k + 1750k + 2250k + + 900k + 1250k + 1600k + + + + + lanczos + bicubic + + + + + + + diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/config_helper.py b/core/config_helper.py new file mode 100644 index 0000000..74117b8 --- /dev/null +++ b/core/config_helper.py @@ -0,0 +1,122 @@ +import xml.etree.ElementTree as ET +from pathlib import Path + +# Default XML content to write if missing +DEFAULT_XML = """ + + + processing + -EHX + .mkv,.mp4 + + + + + + + + 28 + 32 + 32 + 34 + + + 1500k + 1750k + 2250k + 900k + 1250k + 1600k + + + lanczos + bicubic + + + + +""" + +def load_config_xml(path: Path) -> dict: + if not path.exists(): + path.write_text(DEFAULT_XML, encoding="utf-8") + print(f"ℹ️ Created default config.xml at {path}") + + tree = ET.parse(path) + root = tree.getroot() + + # --- General --- + general = root.find("general") + processing_folder_elem = general.find("processing_folder") if general is not None else None + processing_folder = processing_folder_elem.text if processing_folder_elem is not None else "processing" + + suffix_elem = general.find("suffix") if general is not None else None + suffix = suffix_elem.text if suffix_elem is not None else " -EHX" + + extensions_elem = general.find("extensions") if general is not None else None + extensions = extensions_elem.text.split(",") if extensions_elem is not None else [".mkv", ".mp4"] + + # --- Path Mappings --- + path_mappings = {} + for m in root.findall("path_mappings/map"): + f = m.attrib.get("from") + t = m.attrib.get("to") + if f and t: + path_mappings[f] = t + + # --- Encode --- + encode_elem = root.find("encode") + cq = {} + fallback = {} + filters = {} + if encode_elem is not None: + cq_elem = encode_elem.find("cq") + if cq_elem is not None: + for child in cq_elem: + if child.text: + cq[child.tag] = int(child.text) + + fallback_elem = encode_elem.find("fallback") + if fallback_elem is not None: + for child in fallback_elem: + if child.text: + fallback[child.tag] = child.text + + filters_elem = encode_elem.find("filters") + if filters_elem is not None: + for child in filters_elem: + if child.text: + filters[child.tag] = child.text + + # --- Audio --- + audio = {"stereo": {}, "multi_channel": {}} + stereo_elem = root.find("audio/stereo") + if stereo_elem is not None: + for child in stereo_elem: + if child.text: + audio["stereo"][child.tag] = int(child.text) + + multi_elem = root.find("audio/multi_channel") + if multi_elem is not None: + for child in multi_elem: + if child.text: + audio["multi_channel"][child.tag] = int(child.text) + + return { + "processing_folder": processing_folder, + "suffix": suffix, + "extensions": [ext.lower() for ext in extensions], + "path_mappings": path_mappings, + "encode": {"cq": cq, "fallback": fallback, "filters": filters}, + "audio": audio + } diff --git a/main.py b/main.py index 8e5da0e..1ee4fbd 100644 --- a/main.py +++ b/main.py @@ -1,101 +1,31 @@ #!/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" +from core.config_helper import load_config_xml # ============================= # AUDIO BUCKET LOGIC # ============================= -def choose_audio_bitrate(channels: int, bitrate_kbps: int) -> int: +def choose_audio_bitrate(channels: int, bitrate_kbps: int, audio_config: dict) -> int: if channels == 2: if bitrate_kbps < 80: - return 64000 + return audio_config["stereo"]["low"] elif bitrate_kbps < 112: - return 96000 + return audio_config["stereo"]["medium"] else: - return 128000 + return audio_config["stereo"]["high"] else: if bitrate_kbps < 176: - return 160000 + return audio_config["multi_channel"]["low"] else: - return 192000 + return audio_config["multi_channel"]["high"] # ============================= -# 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 +# AUDIO STREAM DETECTION # ============================= def get_audio_streams(input_file: Path): cmd = [ @@ -118,7 +48,8 @@ def get_audio_streams(input_file: Path): # ============================= # FFmpeg ENCODE # ============================= -def run_ffmpeg(input_file: Path, output_file: Path, cq: int, scale_width: int, scale_height: int, filter_flags: str): +def run_ffmpeg(input_file: Path, output_file: Path, cq: int, scale_width: int, scale_height: int, + filter_flags: str, audio_config: dict, encode_config: dict): streams = get_audio_streams(input_file) print("\n🧩 ENCODE SETTINGS") @@ -129,54 +60,39 @@ def run_ffmpeg(input_file: Path, output_file: Path, cq: int, scale_width: int, s print(" • Audio Streams:") for (index, channels, bitrate) in streams: - br = choose_audio_bitrate(channels, bitrate) + br = choose_audio_bitrate(channels, bitrate, audio_config) print(f" - Stream #{index}: {channels}ch, orig≈{bitrate}kbps → target {br/1000:.1f}kbps") - # --- Build CQ encode command --- + # 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" + "-c:v", "av1_nvenc", "-preset", "p1", "-cq", str(cq), "-pix_fmt", "p010le" ] - for i, (index, channels, bitrate) in enumerate(streams): - br = choose_audio_bitrate(channels, bitrate) + br = choose_audio_bitrate(channels, bitrate, audio_config) 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 --- + # 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 --- + # 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" + vb, maxrate, bufsize = encode_config["fallback"]["bitrate_1080"], encode_config["fallback"]["maxrate_1080"], encode_config["fallback"]["bufsize_1080"] else: - vb, maxrate, bufsize = "900k", "1250k", "1600k" + vb, maxrate, bufsize = encode_config["fallback"]["bitrate_720"], encode_config["fallback"]["maxrate_720"], encode_config["fallback"]["bufsize_720"] - 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", @@ -185,45 +101,41 @@ def run_ffmpeg(input_file: Path, output_file: Path, cq: int, scale_width: int, s "-b:v", vb, "-maxrate", maxrate, "-bufsize", bufsize, "-pix_fmt", "p010le" ] - for i, (index, channels, bitrate) in enumerate(streams): - br = choose_audio_bitrate(channels, bitrate) + br = choose_audio_bitrate(channels, bitrate, audio_config) 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): +def process_folder(folder: Path, cq: int, resolution: str, processing_folder: Path, config: dict): 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 + filter_flags_default = config["encode"]["filters"]["default"] + filter_flags_tv = config["encode"]["filters"]["tv"] 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 + filter_flags = filter_flags_tv + cq_default_key = f"tv_{resolution}" else: - cq_default = 32 if resolution=="1080" else 34 + filter_flags = filter_flags_default + cq_default_key = f"movie_{resolution}" if cq is None: - cq = cq_default + cq = config["encode"]["cq"][cq_default_key] + + res_height = 1080 if resolution == "1080" else 720 + res_width = 1920 if resolution == "1080" else 1280 processing_folder.mkdir(parents=True, exist_ok=True) for file in folder.rglob("*"): - if file.suffix.lower() not in [".mkv", ".mp4"]: + if file.suffix.lower() not in config["extensions"]: continue if any(tag in file.name.lower() for tag in ["ehx", "megusta"]): print(f"⏭️ Skipping: {file.name}") @@ -232,33 +144,22 @@ def process_folder(folder: Path, cq: int, resolution: str, rename: bool, process print("="*60) print(f"📁 Processing: {file.name}") - # --- Copy to processing folder first --- temp_input = processing_folder / file.name shutil.copy2(file, temp_input) + temp_output = processing_folder / f"{file.stem}{config['suffix']}{file.suffix}" - # --- 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) + run_ffmpeg(temp_input, temp_output, cq, res_width, res_height, filter_flags, + config["audio"], config["encode"]) - # --- 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 + dest_file = file.parent / temp_output.name shutil.move(temp_output, dest_file) print(f"🚚 Moved {temp_output.name} → {dest_file.name}") - # --- Cleanup --- + # Cleanup if dest_file.exists(): try: - temp_input.unlink() # remove processing copy - file.unlink() # remove original + temp_input.unlink() + file.unlink() print(f"🧹 Deleted original and processing copy") except Exception as e: print(f"⚠️ Could not delete files: {e}") @@ -267,16 +168,21 @@ def process_folder(folder: Path, cq: int, resolution: str, rename: bool, process # MAIN # ============================= def main(): - parser = argparse.ArgumentParser(description="Batch encode videos with optional Sonarr/Radarr rename") + parser = argparse.ArgumentParser(description="Batch encode videos") 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") + parser.add_argument("--r", "--resolution", dest="resolution", choices=["720","1080"], help="Target resolution (720 or 1080)") + parser.add_argument("--processing", type=str, help="Processing folder") args = parser.parse_args() - process_folder(Path(args.folder), args.cq, args.resolution, args.rename, Path(args.processing)) + config_path = Path(__file__).parent / "config.xml" + config = load_config_xml(config_path) + cq = args.cq if args.cq is not None else None + resolution = args.resolution if args.resolution else "1080" + processing_folder = Path(args.processing) if args.processing else Path(config["processing_folder"]) + + process_folder(Path(args.folder), cq, resolution, processing_folder, config) if __name__ == "__main__": main()