283 lines
11 KiB
Python
283 lines
11 KiB
Python
#!/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()
|