Update main.py

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

351
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)
def main(): print("\n🧩 ENCODE SETTINGS")
parser = argparse.ArgumentParser( print(f" • Resolution: {scale_width}x{scale_height}")
description="Batch AV1 transcoder (FFmpeg-based) for anime/TV folders." print(f" • Scale Filter: {filter_flags}")
) print(f" • CQ: {cq}")
parser.add_argument("origination", help="Path to origination folder (e.g., P:\\Anime\\Show)") print(f" • Video Encoder: av1_nvenc (preset p1, pix_fmt p010le)")
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)")
args = parser.parse_args() 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")
orig = args.origination # --- Build CQ encode command ---
processing = args.processing cmd = [
completed = args.completed "ffmpeg", "-y", "-i", str(input_file),
res_choice = str(args.resolution) "-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"
]
# Auto-detect filter & default CQ for i, (index, channels, bitrate) in enumerate(streams):
filter_flags = "lanczos" br = choose_audio_bitrate(channels, bitrate)
if "\\tv\\" in orig.lower() or "/tv/" in orig.lower(): cmd += [f"-c:a:{i}", "aac", f"-b:a:{i}", str(br), f"-ac:{i}", str(channels)]
filter_flags = "bicubic"
cq_default = 28 if res_choice == "1080" else 32 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: else:
cq_default = 32 if res_choice == "1080" else 34 vb, maxrate, bufsize = "900k", "1250k", "1600k"
cq = args.cq if args.cq is not None else cq_default 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")
print("\n=== Using These Settings ===") # --- Build fallback command ---
print(f"Origination: {orig}") cmd = [
print(f"Processing: {processing}") "ffmpeg", "-y", "-i", str(input_file),
print(f"Completed: {completed if completed else '[Return to original folder]'}") "-vf", f"scale={scale_width}:{scale_height}:flags={filter_flags}:force_original_aspect_ratio=decrease",
print(f"Resolution: {res_choice}") "-map", "0:v", "-map", "0:a", "-map", "0:s?",
print(f"Filter: {filter_flags}") "-c:v", "av1_nvenc", "-preset", "p1",
print(f"CQ: {cq}") "-b:v", vb, "-maxrate", maxrate, "-bufsize", bufsize,
print("=============================\n") "-pix_fmt", "p010le"
]
os.makedirs(processing, exist_ok=True) for i, (index, channels, bitrate) in enumerate(streams):
if completed: br = choose_audio_bitrate(channels, bitrate)
os.makedirs(completed, exist_ok=True) cmd += [f"-c:a:{i}", "aac", f"-b:a:{i}", str(br), f"-ac:{i}", str(channels)]
suffix = " -EHX" cmd += ["-c:s", "copy", str(output_file)]
for root, dirs, files in os.walk(orig): print(f"\n🎬 Running fallback bitrate encode: {output_file.name}")
for f in files: subprocess.run(cmd, check=True)
if not f.lower().endswith((".mkv", ".mp4")):
# =============================
# 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 continue
if "ehx" in f.lower() or "megusta" in f.lower(): if any(tag in file.name.lower() for tag in ["ehx", "megusta"]):
print(f"Skipping {f} (contains 'EHX' or 'MeGusta')") print(f"⏭️ Skipping: {file.name}")
continue continue
print("="*60) print("="*60)
print(f"Processing: {f}") print(f"📁 Processing: {file.name}")
src = Path(root) / f
tmp = Path(processing) / f
shutil.copy2(src, tmp)
# Detect audio info # --- Copy to processing folder first ---
_, channels, abr = get_audio_info(str(tmp)) temp_input = processing_folder / file.name
lang = get_language_tag(str(tmp)) shutil.copy2(file, temp_input)
lang_metadata = []
if not lang:
lang_metadata = ["-metadata:s:a:0", "language=eng"]
width, height = ("1920", "1080") if res_choice == "1080" else ("1280", "720") # --- Run FFmpeg ---
out_file = Path(processing) / f"{Path(f).stem}{suffix}.mkv" temp_output = processing_folder / f"{file.stem}{SUFFIX}{file.suffix}"
run_ffmpeg(temp_input, temp_output, cq, res_width, res_height, filter_flags)
ffmpeg_cmd = [ # --- Optional rename via Sonarr/Radarr ---
"ffmpeg", "-y", "-i", str(tmp), final_name = temp_output.name
"-vf", f"scale={width}:{height}:flags={filter_flags}:force_original_aspect_ratio=decrease", if rename:
"-map", "0:v", "-map", "0:a", "-map", "0:s?", rename_file = get_service_preferred_name(temp_input, "sonarr")
"-c:v", "av1_nvenc", "-preset", "p1", "-cq", str(cq), "-pix_fmt", "p010le", if not rename_file:
"-c:a", "aac", "-b:a", str(abr), "-ac", str(channels), rename_file = get_service_preferred_name(temp_input, "radarr")
*lang_metadata, if rename_file:
"-metadata:s:a:0", f"bit_rate={abr}", final_name = rename_file + temp_output.suffix
"-c:s", "copy",
str(out_file)
]
subprocess.run(ffmpeg_cmd) # --- 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}")
target = Path(completed) / out_file.name if completed else Path(root) / out_file.name # --- Cleanup ---
shutil.move(out_file, target) if dest_file.exists():
print(f"Moved file to {target}") 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 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()