#!/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 # ============================= # 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" # CQ 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" --m compression --r 480 # Force 480p, try CQ then bitrate for compression """ ) 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", "compression"], help="Encode mode: 'cq' (constant quality only), 'bitrate' (bitrate only), or 'compression' (try CQ then bitrate fallback)" ) 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" ) parser.add_argument( "--r", "--resolution", dest="resolution", default=None, choices=["480", "720", "1080"], help="Force target resolution (if not specified: 4K->1080p, else preserve)" ) 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. Requires --filter-audio" ) parser.add_argument( "--strip-all-titles", dest="strip_all_titles", default=False, action="store_true", help="Strip title metadata from all audio tracks (default: False)" ) 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 # 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) if __name__ == "__main__": main()