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 os
import shutil import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
import argparse import requests
def get_audio_info(filepath): # =============================
"""Return (stream_index, channels, bitrate_in_bits).""" # CONFIGURATION
cmd = [ # =============================
"ffprobe", "-v", "error", "-select_streams", "a:0", SONARR_URL = "http://10.0.0.10:8989/api/v3"
"-show_entries", "stream=index,channels,bit_rate", RADARR_URL = "http://10.0.0.10:7878/api/v3"
"-of", "csv=p=0", filepath SONARR_API_KEY = os.getenv("SONARR_API_KEY", "")
] RADARR_API_KEY = os.getenv("RADARR_API_KEY", "")
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
# 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 channels == 2:
if bitrate < 80: if bitrate_kbps < 80:
br = 64000 return 64000
elif bitrate < 112: elif bitrate_kbps < 112:
br = 96000 return 96000
else: else:
br = 128000 return 128000
else: else:
if bitrate < 176: if bitrate_kbps < 176:
br = 160000 return 160000
else: else:
br = 192000 return 192000
return 0, channels, br
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 = [ cmd = [
"ffprobe", "-v", "error", "-select_streams", "a:0", "ffprobe", "-v", "error",
"-show_entries", "stream_tags=language", "-select_streams", "a",
"-of", "default=nokey=1:noprint_wrappers=1", filepath "-show_entries", "stream=index,channels,bit_rate",
"-of", "json",
str(input_file)
] ]
result = subprocess.run(cmd, capture_output=True, text=True) 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(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description="Batch encode videos with optional Sonarr/Radarr rename")
description="Batch AV1 transcoder (FFmpeg-based) for anime/TV folders." parser.add_argument("folder", help="Path to folder containing videos")
) parser.add_argument("--cq", type=int, help="Override default CQ")
parser.add_argument("origination", help="Path to origination folder (e.g., P:\\Anime\\Show)") parser.add_argument("--r", "--resolution", dest="resolution", default="1080", choices=["720","1080"], help="Target resolution (720 or 1080)")
parser.add_argument("-p", "--processing", default=r"C:\Users\Tyler\Videos\Video Conversion\temp", parser.add_argument("--rename", action="store_true", help="Attempt Sonarr/Radarr rename")
help="Temporary processing folder (default: %(default)s)") parser.add_argument("--processing", type=str, default=str(DEFAULT_PROCESSING_FOLDER), help="Processing folder")
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)")
args = parser.parse_args() args = parser.parse_args()
orig = args.origination process_folder(Path(args.folder), args.cq, args.resolution, args.rename, Path(args.processing))
processing = args.processing
completed = args.completed
res_choice = str(args.resolution)
# 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__": if __name__ == "__main__":
main() main()