Update main.py

This commit is contained in:
TylerCG 2025-10-04 22:49:37 -04:00
parent 20a8e3dca2
commit e8e8a032b1

381
main.py
View File

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