156 lines
6.6 KiB
Python
156 lines
6.6 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
|
||
|
||
# =============================
|
||
# 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()
|