#!/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" ) 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( "--keep-all-titles", dest="strip_all_titles", default=True, action="store_false", help="Keep title metadata from all audio tracks (default: False, 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 successful file (default: 30s if --no-encode, 0s otherwise). Use --wait 0 to disable, --wait 60 for custom" ) 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()