799 lines
34 KiB
Python
799 lines
34 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
|
||
import shlex
|
||
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
|
||
|
||
# =============================
|
||
# BATCH PROCESSING
|
||
# =============================
|
||
def parse_batch_file(file_path: Path) -> list:
|
||
"""
|
||
Parse batch file (.txt or .csv) and extract paths with optional per-row parameters.
|
||
|
||
Formats:
|
||
- Simple list (.txt): One path per line, optional space-separated parameters
|
||
Example: P:\movies\Movie1 --r 720 --cq 28
|
||
- CSV (.csv): First column is path, remaining columns are optional parameters
|
||
Example: "P:\movies\Movie1","--r 720","--cq 28"
|
||
|
||
Args:
|
||
file_path: Path to batch file
|
||
|
||
Returns:
|
||
List of tuples: [(path_str, params_str), ...]
|
||
"""
|
||
batch_items = []
|
||
|
||
if file_path.suffix.lower() == ".csv":
|
||
# CSV format
|
||
with open(file_path, "r", encoding="utf-8") as f:
|
||
reader = csv.reader(f)
|
||
for row_idx, row in enumerate(reader):
|
||
if not row or not row[0].strip():
|
||
continue # Skip empty rows
|
||
|
||
path_str = row[0].strip()
|
||
|
||
# Skip header row (if it starts with "path")
|
||
if row_idx == 0 and path_str.lower().startswith("path"):
|
||
continue
|
||
|
||
# Combine remaining columns as parameters
|
||
params = " ".join(col.strip() for col in row[1:] if col.strip())
|
||
batch_items.append((path_str, params))
|
||
else:
|
||
# Simple list format (.txt or others)
|
||
with open(file_path, "r", encoding="utf-8") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
|
||
# Skip empty lines and comments
|
||
if not line or line.startswith("#"):
|
||
continue
|
||
|
||
# Split path from parameters
|
||
# Handle quoted paths like: "C:\path with spaces" --r 720
|
||
if line.startswith('"'):
|
||
# Find closing quote
|
||
end_quote = line.find('"', 1)
|
||
if end_quote != -1:
|
||
path_str = line[1:end_quote]
|
||
params = line[end_quote+1:].strip()
|
||
else:
|
||
path_str = line
|
||
params = ""
|
||
else:
|
||
# Unquoted path, split on first space
|
||
parts = line.split(None, 1)
|
||
path_str = parts[0]
|
||
params = parts[1] if len(parts) > 1 else ""
|
||
|
||
batch_items.append((path_str, params))
|
||
|
||
return batch_items
|
||
|
||
|
||
def merge_batch_args(base_args, batch_params_str: str) -> argparse.Namespace:
|
||
"""
|
||
Merge batch file row parameters with base CLI parameters.
|
||
Row parameters override base parameters for that specific path.
|
||
|
||
Args:
|
||
base_args: Base argparse.Namespace from CLI
|
||
batch_params_str: Parameter string from batch file row
|
||
|
||
Returns:
|
||
New argparse.Namespace with merged parameters
|
||
"""
|
||
# Create a copy of base args
|
||
merged = argparse.Namespace(**vars(base_args))
|
||
|
||
if not batch_params_str:
|
||
return merged
|
||
|
||
# Parse row parameters as if they were CLI arguments
|
||
try:
|
||
param_list = shlex.split(batch_params_str)
|
||
except ValueError:
|
||
# If shlex fails, try simple split
|
||
param_list = batch_params_str.split()
|
||
|
||
# Build a parser just for these arguments (reuse the same one)
|
||
# We'll manually apply known arguments
|
||
i = 0
|
||
while i < len(param_list):
|
||
arg = param_list[i]
|
||
|
||
if arg == "--cq":
|
||
if i + 1 < len(param_list):
|
||
merged.cq = int(param_list[i + 1])
|
||
i += 2
|
||
else:
|
||
i += 1
|
||
elif arg in ["--m", "--mode"]:
|
||
if i + 1 < len(param_list):
|
||
merged.transcode_mode = param_list[i + 1]
|
||
i += 2
|
||
else:
|
||
i += 1
|
||
elif arg == "--encoder":
|
||
if i + 1 < len(param_list):
|
||
merged.encoder = param_list[i + 1]
|
||
i += 2
|
||
else:
|
||
i += 1
|
||
elif arg in ["--r", "--resolution"]:
|
||
if i + 1 < len(param_list):
|
||
merged.resolution = param_list[i + 1]
|
||
i += 2
|
||
else:
|
||
i += 1
|
||
elif arg == "--test":
|
||
merged.test_mode = True
|
||
i += 1
|
||
elif arg == "--language":
|
||
if i + 1 < len(param_list):
|
||
merged.audio_language = param_list[i + 1]
|
||
i += 2
|
||
else:
|
||
i += 1
|
||
elif arg == "--filter-audio":
|
||
merged.filter_audio = True
|
||
i += 1
|
||
elif arg == "--audio-select":
|
||
if i + 1 < len(param_list):
|
||
merged.audio_select = param_list[i + 1]
|
||
i += 2
|
||
else:
|
||
i += 1
|
||
elif arg == "--audio-titles":
|
||
if i + 1 < len(param_list):
|
||
merged.audio_titles = param_list[i + 1]
|
||
i += 2
|
||
else:
|
||
i += 1
|
||
elif arg == "--audio-channels":
|
||
if i + 1 < len(param_list):
|
||
merged.audio_channels = param_list[i + 1]
|
||
i += 2
|
||
else:
|
||
i += 1
|
||
elif arg == "--keep-all-titles":
|
||
merged.strip_all_titles = False
|
||
i += 1
|
||
elif arg == "--strip-all-titles":
|
||
merged.strip_all_titles = True
|
||
i += 1
|
||
elif arg == "--unforce-subs":
|
||
merged.unforce_subs = True
|
||
i += 1
|
||
elif arg == "--no-encode":
|
||
merged.no_encode = True
|
||
i += 1
|
||
elif arg == "--force-process":
|
||
merged.force_process = True
|
||
i += 1
|
||
elif arg == "--replace":
|
||
merged.replace_file = True
|
||
i += 1
|
||
else:
|
||
i += 1
|
||
|
||
return merged
|
||
|
||
|
||
def parse_audio_dict(audio_str: str, dict_type: str) -> dict:
|
||
"""
|
||
Parse audio titles or channels string into dictionary.
|
||
|
||
Args:
|
||
audio_str: String like "0:English,1:Commentary" or "0:2,1:6"
|
||
dict_type: "titles" or "channels"
|
||
|
||
Returns:
|
||
Dictionary mapping stream indices to values, or empty dict on error
|
||
"""
|
||
if not audio_str:
|
||
return {}
|
||
|
||
result = {}
|
||
try:
|
||
pairs = audio_str.split(",")
|
||
for pair in pairs:
|
||
stream_idx, value = pair.split(":")
|
||
stream_idx = int(stream_idx.strip())
|
||
|
||
if dict_type == "channels":
|
||
# Validate channels: only 2 or 6 allowed
|
||
channels = int(value.strip())
|
||
if channels not in (2, 6):
|
||
logger.warning(f"Invalid channel count: {channels}. Only 2 or 6 allowed. Skipping.")
|
||
continue
|
||
result[stream_idx] = channels
|
||
else: # titles
|
||
result[stream_idx] = value.strip()
|
||
except (ValueError, IndexError) as e:
|
||
logger.warning(f"Error parsing audio {dict_type}: {e}")
|
||
|
||
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", nargs="?", default=None, help="Input folder containing video files (required unless using --paths-file)")
|
||
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(
|
||
"--audio-titles", dest="audio_titles", default=None,
|
||
help="Set custom titles for audio streams (e.g., '0:English,1:Commentary'). Format: stream_index:title,stream_index:title"
|
||
)
|
||
parser.add_argument(
|
||
"--audio-channels", dest="audio_channels", default=None,
|
||
help="Set channel count for specific audio streams (e.g., '0:2,1:6'). Format: stream_index:channels,stream_index:channels. Only 2 or 6 channels allowed"
|
||
)
|
||
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)"
|
||
)
|
||
parser.add_argument(
|
||
"--paths-file", dest="paths_file", default=None,
|
||
help="Batch mode: Read paths from file (.txt or .csv). One path per line with optional per-row parameters"
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
# Load configuration
|
||
config_path = Path(__file__).parent / "config.xml"
|
||
config = load_config_xml(config_path)
|
||
|
||
# =============================
|
||
# BATCH MODE
|
||
# =============================
|
||
if args.paths_file:
|
||
paths_file = Path(args.paths_file)
|
||
if not paths_file.exists():
|
||
print(f"❌ Paths file not found: {paths_file}")
|
||
logger.error(f"Paths file not found: {paths_file}")
|
||
return
|
||
|
||
# Track what we've already processed
|
||
processed_items = set()
|
||
batch_queue = []
|
||
completed = 0
|
||
failed = 0
|
||
total_attempted = 0
|
||
|
||
print("=" * 80)
|
||
print(f"BATCH MODE: Processing paths from {paths_file.name}")
|
||
print(f"ℹ️ File will be rechecked after each item for new additions")
|
||
print("=" * 80)
|
||
logger.info(f"BATCH MODE: Starting batch processing from {paths_file}")
|
||
logger.info("File monitoring enabled - will check for new additions after each item")
|
||
|
||
# Initial load
|
||
batch_items = parse_batch_file(paths_file)
|
||
if not batch_items:
|
||
print(f"❌ No valid paths found in {paths_file}")
|
||
logger.error(f"No valid paths in batch file: {paths_file}")
|
||
return
|
||
|
||
print(f"📋 Found {len(batch_items)} initial path(s)\n")
|
||
|
||
# Create item signatures for tracking (path + params combo)
|
||
for path_str, params_str in batch_items:
|
||
item_sig = f"{path_str}|{params_str}"
|
||
if item_sig not in processed_items:
|
||
batch_queue.append((path_str, params_str))
|
||
|
||
batch_num = 1
|
||
|
||
# Process batch items with recheck after each
|
||
while batch_queue:
|
||
path_str, params_str = batch_queue.pop(0)
|
||
item_sig = f"{path_str}|{params_str}"
|
||
total_attempted += 1
|
||
|
||
print()
|
||
print("-" * 80)
|
||
print(f"BATCH [{batch_num}]: {path_str}")
|
||
if params_str:
|
||
print(f"Parameters: {params_str}")
|
||
print("-" * 80)
|
||
logger.info(f"[BATCH {batch_num}] Processing: {path_str}")
|
||
|
||
try:
|
||
# Normalize path
|
||
folder = normalize_input_path(path_str, config.get("path_mappings", {}))
|
||
|
||
# Check if folder exists
|
||
if not folder.exists():
|
||
print(f"❌ [BATCH {batch_num}] Folder not found: {folder}")
|
||
logger.error(f"[BATCH {batch_num}] Folder not found: {folder}")
|
||
processed_items.add(item_sig)
|
||
failed += 1
|
||
batch_num += 1
|
||
|
||
# Recheck file for new items
|
||
print(f"\n🔄 Rechecking {paths_file.name} for new additions...")
|
||
new_batch_items = parse_batch_file(paths_file)
|
||
for path, params in new_batch_items:
|
||
sig = f"{path}|{params}"
|
||
if sig not in processed_items and sig not in [f"{p}|{pr}" for p, pr in batch_queue]:
|
||
batch_queue.append((path, params))
|
||
print(f" ✨ New item found: {path}")
|
||
|
||
continue
|
||
|
||
# Merge batch parameters with base CLI parameters
|
||
merged_args = merge_batch_args(args, params_str)
|
||
|
||
# Handle travel mode
|
||
travel_output_folder = None
|
||
if merged_args.travel_mode:
|
||
if not merged_args.output_folder:
|
||
print(f"❌ [BATCH {batch_num}] --travel requires --output folder")
|
||
logger.error(f"[BATCH {batch_num}] --travel requires --output folder")
|
||
processed_items.add(item_sig)
|
||
failed += 1
|
||
batch_num += 1
|
||
|
||
# Recheck file for new items
|
||
print(f"\n🔄 Rechecking {paths_file.name} for new additions...")
|
||
new_batch_items = parse_batch_file(paths_file)
|
||
for path, params in new_batch_items:
|
||
sig = f"{path}|{params}"
|
||
if sig not in processed_items and sig not in [f"{p}|{pr}" for p, pr in batch_queue]:
|
||
batch_queue.append((path, params))
|
||
print(f" ✨ New item found: {path}")
|
||
|
||
continue
|
||
|
||
output_base = Path(merged_args.output_folder)
|
||
input_folder_name = folder.name
|
||
travel_output_folder = output_base / input_folder_name
|
||
travel_output_folder.mkdir(parents=True, exist_ok=True)
|
||
|
||
merged_args.resolution = "720"
|
||
default_cq = get_default_cq(folder, config, "720", merged_args.encoder)
|
||
merged_args.cq = default_cq + 2
|
||
|
||
# Validate --replace requires --no-encode
|
||
if merged_args.replace_file and not merged_args.no_encode:
|
||
print(f"❌ [BATCH {batch_num}] --replace requires --no-encode")
|
||
logger.error(f"[BATCH {batch_num}] --replace requires --no-encode")
|
||
processed_items.add(item_sig)
|
||
failed += 1
|
||
batch_num += 1
|
||
|
||
# Recheck file for new items
|
||
print(f"\n🔄 Rechecking {paths_file.name} for new additions...")
|
||
new_batch_items = parse_batch_file(paths_file)
|
||
for path, params in new_batch_items:
|
||
sig = f"{path}|{params}"
|
||
if sig not in processed_items and sig not in [f"{p}|{pr}" for p, pr in batch_queue]:
|
||
batch_queue.append((path, params))
|
||
print(f" ✨ New item found: {path}")
|
||
|
||
continue
|
||
|
||
# Set wait time
|
||
if merged_args.wait_seconds is None:
|
||
merged_args.wait_seconds = 0
|
||
elif merged_args.wait_seconds == -1:
|
||
merged_args.wait_seconds = 30 if merged_args.no_encode else 0
|
||
|
||
# Parse audio dicts from merged args
|
||
audio_titles_dict = parse_audio_dict(merged_args.audio_titles, "titles") if merged_args.audio_titles else {}
|
||
audio_channels_dict = parse_audio_dict(merged_args.audio_channels, "channels") if merged_args.audio_channels else {}
|
||
|
||
# Process folder
|
||
process_folder(
|
||
folder, merged_args.cq, merged_args.transcode_mode, merged_args.resolution,
|
||
config, TRACKER_FILE, merged_args.test_mode, merged_args.audio_language,
|
||
merged_args.filter_audio, merged_args.audio_select, merged_args.encoder,
|
||
merged_args.strip_all_titles, travel_output_folder, merged_args.unforce_subs,
|
||
merged_args.no_encode, merged_args.force_process, merged_args.replace_file,
|
||
merged_args.wait_seconds, audio_titles=audio_titles_dict, audio_channels=audio_channels_dict
|
||
)
|
||
|
||
print(f"✓ [BATCH {batch_num}] Completed: {folder.name}")
|
||
logger.info(f"[BATCH {batch_num}] Completed successfully")
|
||
processed_items.add(item_sig)
|
||
completed += 1
|
||
batch_num += 1
|
||
|
||
# Recheck file for new items after successful completion
|
||
print(f"\n🔄 Rechecking {paths_file.name} for new additions...")
|
||
new_batch_items = parse_batch_file(paths_file)
|
||
new_count = 0
|
||
for path, params in new_batch_items:
|
||
sig = f"{path}|{params}"
|
||
if sig not in processed_items and sig not in [f"{p}|{pr}" for p, pr in batch_queue]:
|
||
batch_queue.append((path, params))
|
||
print(f" ✨ New item found: {path}")
|
||
new_count += 1
|
||
|
||
if new_count == 0:
|
||
print(f" (no new additions)")
|
||
|
||
except Exception as e:
|
||
print(f"❌ [BATCH {batch_num}] Error: {e}")
|
||
logger.error(f"[BATCH {batch_num}] Error: {e}", exc_info=True)
|
||
processed_items.add(item_sig)
|
||
failed += 1
|
||
batch_num += 1
|
||
|
||
# Recheck file for new items even on error
|
||
print(f"\n🔄 Rechecking {paths_file.name} for new additions...")
|
||
try:
|
||
new_batch_items = parse_batch_file(paths_file)
|
||
new_count = 0
|
||
for path, params in new_batch_items:
|
||
sig = f"{path}|{params}"
|
||
if sig not in processed_items and sig not in [f"{p}|{pr}" for p, pr in batch_queue]:
|
||
batch_queue.append((path, params))
|
||
print(f" ✨ New item found: {path}")
|
||
new_count += 1
|
||
|
||
if new_count == 0:
|
||
print(f" (no new additions)")
|
||
except Exception as recheck_error:
|
||
print(f"⚠️ Error rechecking file: {recheck_error}")
|
||
logger.warning(f"Error during file recheck: {recheck_error}")
|
||
|
||
# Final check for any items added while processing the last batch
|
||
print()
|
||
print("=" * 80)
|
||
print("✓ BATCH QUEUE COMPLETE - Performing final file check...")
|
||
print("=" * 80)
|
||
final_items = parse_batch_file(paths_file)
|
||
final_new = []
|
||
for path, params in final_items:
|
||
sig = f"{path}|{params}"
|
||
if sig not in processed_items:
|
||
final_new.append((path, params))
|
||
|
||
if final_new:
|
||
print(f"📋 Found {len(final_new)} new item(s) added during processing!\n")
|
||
|
||
for path_str, params_str in final_new:
|
||
item_sig = f"{path_str}|{params_str}"
|
||
total_attempted += 1
|
||
|
||
print("-" * 80)
|
||
print(f"BATCH [{batch_num}]: {path_str}")
|
||
if params_str:
|
||
print(f"Parameters: {params_str}")
|
||
print("-" * 80)
|
||
logger.info(f"[BATCH {batch_num}] Processing: {path_str}")
|
||
|
||
try:
|
||
folder = normalize_input_path(path_str, config.get("path_mappings", {}))
|
||
|
||
if not folder.exists():
|
||
print(f"❌ [BATCH {batch_num}] Folder not found: {folder}")
|
||
logger.error(f"[BATCH {batch_num}] Folder not found: {folder}")
|
||
failed += 1
|
||
batch_num += 1
|
||
continue
|
||
|
||
merged_args = merge_batch_args(args, params_str)
|
||
|
||
travel_output_folder = None
|
||
if merged_args.travel_mode:
|
||
if not merged_args.output_folder:
|
||
print(f"❌ [BATCH {batch_num}] --travel requires --output folder")
|
||
logger.error(f"[BATCH {batch_num}] --travel requires --output folder")
|
||
failed += 1
|
||
batch_num += 1
|
||
continue
|
||
|
||
output_base = Path(merged_args.output_folder)
|
||
input_folder_name = folder.name
|
||
travel_output_folder = output_base / input_folder_name
|
||
travel_output_folder.mkdir(parents=True, exist_ok=True)
|
||
|
||
merged_args.resolution = "720"
|
||
default_cq = get_default_cq(folder, config, "720", merged_args.encoder)
|
||
merged_args.cq = default_cq + 2
|
||
|
||
if merged_args.replace_file and not merged_args.no_encode:
|
||
print(f"❌ [BATCH {batch_num}] --replace requires --no-encode")
|
||
logger.error(f"[BATCH {batch_num}] --replace requires --no-encode")
|
||
failed += 1
|
||
batch_num += 1
|
||
continue
|
||
|
||
if merged_args.wait_seconds is None:
|
||
merged_args.wait_seconds = 0
|
||
elif merged_args.wait_seconds == -1:
|
||
merged_args.wait_seconds = 30 if merged_args.no_encode else 0
|
||
|
||
# Parse audio dicts from merged args
|
||
audio_titles_dict = parse_audio_dict(merged_args.audio_titles, "titles") if merged_args.audio_titles else {}
|
||
audio_channels_dict = parse_audio_dict(merged_args.audio_channels, "channels") if merged_args.audio_channels else {}
|
||
|
||
process_folder(
|
||
folder, merged_args.cq, merged_args.transcode_mode, merged_args.resolution,
|
||
config, TRACKER_FILE, merged_args.test_mode, merged_args.audio_language,
|
||
merged_args.filter_audio, merged_args.audio_select, merged_args.encoder,
|
||
merged_args.strip_all_titles, travel_output_folder, merged_args.unforce_subs,
|
||
merged_args.no_encode, merged_args.force_process, merged_args.replace_file,
|
||
merged_args.wait_seconds, audio_titles=audio_titles_dict, audio_channels=audio_channels_dict
|
||
)
|
||
|
||
print(f"✓ [BATCH {batch_num}] Completed: {folder.name}")
|
||
logger.info(f"[BATCH {batch_num}] Completed successfully")
|
||
completed += 1
|
||
batch_num += 1
|
||
|
||
except Exception as e:
|
||
print(f"❌ [BATCH {batch_num}] Error: {e}")
|
||
logger.error(f"[BATCH {batch_num}] Error: {e}", exc_info=True)
|
||
failed += 1
|
||
batch_num += 1
|
||
else:
|
||
print("(no new items found)")
|
||
|
||
# Final summary
|
||
print()
|
||
print("=" * 80)
|
||
print(f"✓ BATCH PROCESSING COMPLETE")
|
||
print(f" Total items processed: {total_attempted}")
|
||
print(f" ✓ Succeeded: {completed}")
|
||
if failed > 0:
|
||
print(f" ❌ Failed: {failed}")
|
||
print("=" * 80)
|
||
logger.info(f"Batch processing finished: {completed} succeeded, {failed} failed out of {total_attempted}")
|
||
|
||
return
|
||
|
||
# =============================
|
||
# SINGLE MODE
|
||
# =============================
|
||
if not args.folder:
|
||
parser.print_help()
|
||
return
|
||
|
||
# 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
|
||
|
||
# Parse audio titles from CLI argument
|
||
audio_titles_dict = {}
|
||
if args.audio_titles:
|
||
try:
|
||
# Format: "0:English,1:Commentary,2:Descriptive Audio"
|
||
pairs = args.audio_titles.split(",")
|
||
for pair in pairs:
|
||
stream_idx, title = pair.split(":")
|
||
audio_titles_dict[int(stream_idx.strip())] = title.strip()
|
||
logger.info(f"Audio titles: {audio_titles_dict}")
|
||
except (ValueError, IndexError):
|
||
print("❌ Invalid --audio-titles format. Use: '0:English,1:Commentary'")
|
||
logger.error(f"Invalid audio titles format: {args.audio_titles}")
|
||
return
|
||
|
||
# Parse audio channels from CLI argument
|
||
audio_channels_dict = {}
|
||
if args.audio_channels:
|
||
try:
|
||
# Format: "0:2,1:6" (stream_index:channel_count)
|
||
pairs = args.audio_channels.split(",")
|
||
for pair in pairs:
|
||
stream_idx, channels = pair.split(":")
|
||
stream_idx = int(stream_idx.strip())
|
||
channels = int(channels.strip())
|
||
|
||
# Validate that only 2 or 6 channels are allowed
|
||
if channels not in (2, 6):
|
||
print(f"❌ Invalid channel count: {channels}. Only 2 or 6 channels allowed")
|
||
logger.error(f"Invalid channel count: {channels}. Only 2 or 6 channels allowed")
|
||
return
|
||
|
||
audio_channels_dict[stream_idx] = channels
|
||
logger.info(f"Audio channels: {audio_channels_dict}")
|
||
except (ValueError, IndexError):
|
||
print("❌ Invalid --audio-channels format. Use: '0:2,1:6'")
|
||
logger.error(f"Invalid audio channels format: {args.audio_channels}")
|
||
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, audio_titles=audio_titles_dict, audio_channels=audio_channels_dict)
|
||
|
||
if __name__ == "__main__":
|
||
main()
|