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()