224 lines
9.9 KiB
Python
224 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
AV1 Batch Video Transcoder
|
||
Main entry point for batch video encoding with intelligent audio and resolution handling.
|
||
"""
|
||
|
||
import argparse
|
||
import csv
|
||
from pathlib import Path
|
||
|
||
from core.config_helper import load_config_xml
|
||
from core.logger_helper import setup_logger
|
||
from core.process_manager import process_folder, get_default_cq
|
||
|
||
# =============================
|
||
# PATH NORMALIZATION
|
||
# =============================
|
||
def normalize_input_path(input_path: str, path_mappings: dict) -> Path:
|
||
"""
|
||
Normalize input path from various formats to Windows path.
|
||
|
||
Supports:
|
||
- Windows paths: "P:\\tv\\show" or "P:/tv/show"
|
||
- Linux paths: "/mnt/plex/tv/show" (maps to Windows equivalent if mapping exists)
|
||
- Mixed separators: "P:/tv\\show"
|
||
|
||
Args:
|
||
input_path: Path string from user input
|
||
path_mappings: Dict mapping Windows paths to Linux paths from config
|
||
|
||
Returns:
|
||
Path object pointing to the actual local folder
|
||
"""
|
||
# First, try to map Linux paths to Windows paths (reverse mapping)
|
||
# If user provides "/mnt/plex/tv", find the mapping and convert to "P:\\tv"
|
||
if isinstance(path_mappings, list):
|
||
# New format: list of dicts
|
||
for mapping in path_mappings:
|
||
if isinstance(mapping, dict):
|
||
win_path = mapping.get("from")
|
||
linux_path = mapping.get("to")
|
||
if linux_path and input_path.lower().startswith(linux_path.lower()):
|
||
# Found a matching Linux path, convert to Windows
|
||
relative = input_path[len(linux_path):].lstrip("/").lstrip("\\")
|
||
result = Path(win_path) / relative if relative else Path(win_path)
|
||
logger.info(f"Path mapping: {input_path} -> {result}")
|
||
print(f"ℹ️ Mapped Linux path {input_path} to {result}")
|
||
return result
|
||
else:
|
||
# Old format: dict (for backwards compatibility)
|
||
for win_path, linux_path in path_mappings.items():
|
||
if input_path.lower().startswith(linux_path.lower()):
|
||
# Found a matching Linux path, convert to Windows
|
||
relative = input_path[len(linux_path):].lstrip("/").lstrip("\\")
|
||
result = Path(win_path) / relative if relative else Path(win_path)
|
||
logger.info(f"Path mapping: {input_path} -> {result}")
|
||
print(f"ℹ️ Mapped Linux path {input_path} to {result}")
|
||
return result
|
||
|
||
# No mapping found, use path as-is (normalize separators to Windows)
|
||
# Convert forward slashes to backslashes for Windows
|
||
normalized = input_path.replace("/", "\\")
|
||
result = Path(normalized)
|
||
logger.info(f"Using path as-is: {result}")
|
||
return result
|
||
|
||
# =============================
|
||
# Setup
|
||
# =============================
|
||
LOG_FOLDER = Path(__file__).parent / "logs"
|
||
logger = setup_logger(LOG_FOLDER)
|
||
|
||
TRACKER_FILE = Path(__file__).parent / "conversion_tracker.csv"
|
||
if not TRACKER_FILE.exists():
|
||
with open(TRACKER_FILE, "w", newline="", encoding="utf-8") as f:
|
||
writer = csv.writer(f)
|
||
writer.writerow([
|
||
"type", "show", "filename", "original_size_MB", "processed_size_MB", "percentage",
|
||
"source_resolution", "target_resolution", "audio_streams", "cq_value", "method"
|
||
])
|
||
|
||
|
||
# =============================
|
||
# MAIN
|
||
# =============================
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description="Batch AV1 encode videos with intelligent audio and resolution handling",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
Examples:
|
||
%(prog)s "C:\\Videos\\Movies" # Smart mode (preserve resolution, 4K->1080p)
|
||
%(prog)s "C:\\Videos\\TV" --r 720 --m bitrate # Force 720p, bitrate mode
|
||
%(prog)s "C:\\Videos\\Anime" --cq 28 --r 1080 # Force 1080p, CQ=28
|
||
%(prog)s "C:\\Videos\\Low-Res" --r 480 # Force 480p for low-res content
|
||
"""
|
||
)
|
||
|
||
parser.add_argument("folder", help="Input folder containing video files")
|
||
parser.add_argument("--cq", type=int, help="Override default CQ value")
|
||
parser.add_argument(
|
||
"--m", "--mode", dest="transcode_mode", default="cq",
|
||
choices=["cq", "bitrate"],
|
||
help="Encode mode: CQ (constant quality) or Bitrate mode"
|
||
)
|
||
parser.add_argument(
|
||
"--encoder", dest="encoder", default="hevc",
|
||
choices=["hevc", "av1"],
|
||
help="Video encoder: 'hevc' for HEVC NVENC 10-bit (default), 'av1' for AV1 NVENC 8-bit. Auto-selected based on source bit depth if not specified"
|
||
)
|
||
parser.add_argument(
|
||
"--r", "--resolution", dest="resolution", default=None,
|
||
choices=["480", "720", "1080"],
|
||
help="Target resolution (acts as max, downscales if source is larger). If not specified: 4K→1080p, else preserve source"
|
||
)
|
||
parser.add_argument(
|
||
"--test", dest="test_mode", default=False, action="store_true",
|
||
help="Test mode: encode only first file, show ratio, don't move or delete (default: False)"
|
||
)
|
||
parser.add_argument(
|
||
"--language", dest="audio_language", default=None,
|
||
help="Tag audio streams with language code (e.g., eng, spa, fra). If not set, audio language is unchanged"
|
||
)
|
||
parser.add_argument(
|
||
"--filter-audio", dest="filter_audio", default=None, action="store_true",
|
||
help="Interactive audio selection: show audio streams and let user choose which to keep (overrides config setting)"
|
||
)
|
||
parser.add_argument(
|
||
"--audio-select", dest="audio_select", default=None,
|
||
help="Pre-select audio streams to keep (comma-separated, e.g., 1,2). Skips interactive prompt"
|
||
)
|
||
parser.add_argument(
|
||
"--keep-all-titles", dest="strip_all_titles", default=True, action="store_false",
|
||
help="Preserve title metadata on audio tracks (default: titles are stripped)"
|
||
)
|
||
parser.add_argument(
|
||
"--unforce-subs", dest="unforce_subs", default=False, action="store_true",
|
||
help="Remove forced flag from all subtitle tracks"
|
||
)
|
||
parser.add_argument(
|
||
"--no-encode", dest="no_encode", default=False, action="store_true",
|
||
help="Skip encoding: copy video/audio streams as-is. Useful with --unforce-subs to only re-mux subtitles"
|
||
)
|
||
parser.add_argument(
|
||
"--force-process", dest="force_process", default=False, action="store_true",
|
||
help="Process files even if they contain ignore tags (e.g., already encoded files with suffix)"
|
||
)
|
||
parser.add_argument(
|
||
"--replace", dest="replace_file", default=False, action="store_true",
|
||
help="Replace original file instead of creating suffix version. Requires --no-encode"
|
||
)
|
||
parser.add_argument(
|
||
"--wait", "-w", dest="wait_seconds", type=int, nargs='?', const=-1, default=None,
|
||
help="Wait after each file (default: 30s with --no-encode, 0s otherwise). Gives Plex time to detect changes"
|
||
)
|
||
parser.add_argument(
|
||
"--travel", dest="travel_mode", default=False, action="store_true",
|
||
help="Travel mode: force 720p resolution and CQ+2, requires --output flag"
|
||
)
|
||
parser.add_argument(
|
||
"--output", dest="output_folder", default=None,
|
||
help="Output folder for travel mode (creates subfolder based on input folder name)"
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
# Load configuration
|
||
config_path = Path(__file__).parent / "config.xml"
|
||
config = load_config_xml(config_path)
|
||
|
||
# Normalize input path (handle Linux paths, mixed separators, etc.)
|
||
folder = normalize_input_path(args.folder, config.get("path_mappings", {}))
|
||
|
||
# Verify folder exists
|
||
if not folder.exists():
|
||
print(f"❌ Folder not found: {folder}")
|
||
logger.error(f"Folder not found: {folder}")
|
||
return
|
||
|
||
# Handle travel mode
|
||
travel_output_folder = None
|
||
if args.travel_mode:
|
||
if not args.output_folder:
|
||
print("❌ --travel flag requires --output folder to be specified")
|
||
logger.error("--travel flag used without --output folder")
|
||
return
|
||
|
||
# Parse output folder and create subfolder based on input folder name
|
||
output_base = Path(args.output_folder)
|
||
input_folder_name = folder.name
|
||
travel_output_folder = output_base / input_folder_name
|
||
|
||
# Create the output folder structure
|
||
travel_output_folder.mkdir(parents=True, exist_ok=True)
|
||
print(f"✅ Travel mode: Output folder set to {travel_output_folder}")
|
||
logger.info(f"Travel mode enabled: {folder} -> {travel_output_folder}")
|
||
|
||
# Set resolution to 720 in travel mode
|
||
args.resolution = "720"
|
||
|
||
# Get default CQ for 720p and add 2
|
||
default_cq = get_default_cq(folder, config, "720", args.encoder)
|
||
args.cq = default_cq + 2
|
||
print(f"✅ Travel mode: Resolution=720p, CQ={args.cq} (default {default_cq} + 2)")
|
||
logger.info(f"Travel mode: CQ set to {args.cq}")
|
||
|
||
# Validate --replace flag requires --no-encode
|
||
if args.replace_file and not args.no_encode:
|
||
print("❌ --replace requires --no-encode flag")
|
||
logger.error("--replace flag used without --no-encode")
|
||
return
|
||
|
||
# Set wait time default: 30s if --no-encode and --wait used, 0 otherwise
|
||
# -1 means --wait was used without a value (use intelligent default)
|
||
if args.wait_seconds is None:
|
||
args.wait_seconds = 0 # No --wait flag provided
|
||
elif args.wait_seconds == -1:
|
||
args.wait_seconds = 30 if args.no_encode else 0 # --wait used without value
|
||
|
||
# Process folder
|
||
process_folder(folder, args.cq, args.transcode_mode, args.resolution, config, TRACKER_FILE, args.test_mode, args.audio_language, args.filter_audio, args.audio_select, args.encoder, args.strip_all_titles, travel_output_folder, args.unforce_subs, args.no_encode, args.force_process, args.replace_file, args.wait_seconds)
|
||
|
||
if __name__ == "__main__":
|
||
main()
|