2026-08-22 00:58:40 -04:00

1113 lines
53 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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
import time
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"[INFO] 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
# =============================# PATH REACHABILITY CHECK
# =============================
def is_path_reachable(path: Path) -> bool:
"""
Check if a path is reachable (exists).
Used to test if network sources are available.
Args:
path: Path object to check
Returns:
True if path exists and is accessible, False otherwise
"""
try:
# Try to check if path exists
# For network paths, this will fail if unreachable
return path.exists()
except (OSError, PermissionError, TimeoutError):
return False
# =============================# 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
Special: "STOP" as a single line stops processing after the previous item
- 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), ...] where path_str can be "STOP"
"""
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
# Check for STOP marker
if path_str.upper() == "STOP":
batch_items.append(("STOP", ""))
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
# Check for STOP marker
if line.upper() == "STOP":
batch_items.append(("STOP", ""))
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))
# Ensure crop_height exists (in case it wasn't defined in base_args)
if not hasattr(merged, 'crop_height'):
merged.crop_height = None
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 == "--crop":
if i + 1 < len(param_list):
merged.crop_height = int(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 == "--ignore-tags":
merged.ignore_tags = True
i += 1
elif arg == "--force-encode":
merged.force_encode = True
i += 1
elif arg == "--replace":
merged.replace_file = True
i += 1
elif arg == "--move":
merged.move_mode = True
i += 1
elif arg == "--keep-original":
merged.keep_original = True
i += 1
elif arg == "--title-suffix":
if i + 1 < len(param_list):
merged.title_suffix = param_list[i + 1]
i += 2
else:
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", "2160"],
help="Target resolution (acts as max, downscales if source is larger). 2160 enables HDR mode (source must be actual 4K or will skip). 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(
"--ignore-tags", dest="ignore_tags", default=False, action="store_true",
help="Process files even if they contain ignore tags (e.g., already encoded files with suffix)"
)
parser.add_argument(
"--force-encode", dest="force_encode", default=False, action="store_true",
help="Reconvert files even if size threshold is not met (output may be slightly larger than input)"
)
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(
"--move", dest="move_mode", default=False, action="store_true",
help="Move source files to processing folder instead of copying (saves space). Deleted after processing unless --keep-original is set"
)
parser.add_argument(
"--keep-original", dest="keep_original", default=False, action="store_true",
help="Preserve original source files after processing instead of deleting them"
)
parser.add_argument(
"--title-suffix", dest="title_suffix", default=None,
help="Text to insert before main suffix (e.g., '1080p' or 'v2'). Output: 'Movie - 1080p [EHX].mkv'. If not specified, uses config file setting"
)
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"
)
parser.add_argument(
"--crop", dest="crop_height", type=int, default=None,
help="Center-crop video to target height in pixels (e.g., 816 for 1920x816 from 1920x1080 source). Crops from top and bottom equally. Works at any resolution"
)
parser.add_argument(
"--retry-minutes", dest="retry_minutes", type=int, default=None,
help="Queue mode: Minutes to wait between retry attempts when batch fails with 2+ 'Folder not found' errors (default: from config.xml)"
)
parser.add_argument(
"--retry-timeout", dest="retry_timeout", type=int, default=None,
help="Queue mode: Total minutes to keep retrying before giving up (default: from config.xml). If source becomes reachable during retry, queue will immediately restart"
)
parser.add_argument(
"--skip-audio-check", dest="skip_audio_check", default=False, action="store_true",
help="Skip audio bitrate calculation (speeds up testing). Uses metadata bitrate instead of extracting streams"
)
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"[ERROR] 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"[INFO] 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"[ERROR] 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)
# Check for STOP marker
if path_str.upper() == "STOP":
print()
print("=" * 80)
print("🛑 STOP marker encountered - halting batch processing")
print("=" * 80)
logger.info("STOP marker encountered - batch processing halted by user")
break
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)
# Debug logging for crop parameter
if merged_args.crop_height:
logger.info(f"[BATCH {batch_num}] Crop height set to: {merged_args.crop_height}p")
print(f" [BATCH {batch_num}] Crop height: {merged_args.crop_height}p")
# 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.ignore_tags, merged_args.force_encode, merged_args.replace_file,
merged_args.wait_seconds, crop_height=merged_args.crop_height, audio_titles=audio_titles_dict, audio_channels=audio_channels_dict, title_suffix=merged_args.title_suffix, move_mode=merged_args.move_mode, keep_original=merged_args.keep_original, skip_audio_check=merged_args.skip_audio_check
)
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"[ERROR] [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)
# Debug logging for crop parameter
if merged_args.crop_height:
logger.info(f"[BATCH {batch_num}] Crop height set to: {merged_args.crop_height}p")
print(f"[INFO] [BATCH {batch_num}] Crop height: {merged_args.crop_height}p")
travel_output_folder = None
if merged_args.travel_mode:
if not merged_args.output_folder:
print(f"[ERROR] [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"[ERROR] [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.ignore_tags, merged_args.force_encode, merged_args.replace_file,
merged_args.wait_seconds, crop_height=merged_args.crop_height, audio_titles=audio_titles_dict, audio_channels=audio_channels_dict, title_suffix=merged_args.title_suffix, move_mode=merged_args.move_mode, keep_original=merged_args.keep_original, skip_audio_check=merged_args.skip_audio_check
)
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"[ERROR] [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" [ERROR] Failed: {failed}")
print("=" * 80)
logger.info(f"Batch processing finished: {completed} succeeded, {failed} failed out of {total_attempted}")
# =============================
# QUEUE RETRY LOGIC
# =============================
# If failed > 2 and all failures are "Folder not found", enter retry loop
if failed > 2:
# Check if we have folder-not-found failures by re-examining failed items
# Count the number of folder not found vs other errors
folder_not_found_count = 0
# Get list of all paths that failed with folder not found
failed_paths = []
# Re-process to identify folder-not-found failures
try:
all_items = parse_batch_file(paths_file)
for path_str, params_str in all_items:
item_sig = f"{path_str}|{params_str}"
if item_sig in processed_items:
# Was this one that failed?
try:
folder = normalize_input_path(path_str, config.get("path_mappings", {}))
if not folder.exists():
folder_not_found_count += 1
failed_paths.append((folder, path_str))
except:
pass
except:
pass
# If we have folder-not-found failures, offer retry logic
if folder_not_found_count > 2 and failed_paths:
print()
print("=" * 80)
print("⚠️ RETRY LOGIC TRIGGERED")
print(f" Failed items: {failed} | Folder not found: {folder_not_found_count}")
print(f" This suggests the source location ({failed_paths[0][1]}) may be temporarily unreachable")
print("=" * 80)
# Get retry settings from CLI args or config
retry_minutes = args.retry_minutes
if retry_minutes is None:
retry_minutes = config.get("queue_retry", {}).get("retry_minutes", 10)
retry_timeout = args.retry_timeout
if retry_timeout is None:
retry_timeout = config.get("queue_retry", {}).get("retry_timeout", 60)
print(f"\n🔄 Retry Configuration:")
print(f" - Retry interval: {retry_minutes} minute(s)")
print(f" - Total retry timeout: {retry_timeout} minute(s)")
print(f" - Will check source availability and retry if it becomes reachable")
print()
# Retry loop
import time
retry_start_time = time.time()
retry_end_time = retry_start_time + (retry_timeout * 60) # Convert to seconds
attempt_count = 0
while True:
attempt_count += 1
elapsed_minutes = (time.time() - retry_start_time) / 60
# Check if timeout exceeded
if time.time() >= retry_end_time:
print(f"\n⏰ Retry timeout reached ({retry_timeout} minutes elapsed)")
print(f"🛑 Giving up after {attempt_count} retry attempt(s)")
logger.info(f"Queue retry timeout reached after {attempt_count} attempts and {retry_timeout} minutes")
break
# Check if any failed path is now reachable
source_reachable = False
reachable_paths = []
for failed_folder, failed_path_str in failed_paths:
if is_path_reachable(failed_folder):
source_reachable = True
reachable_paths.append(failed_path_str)
if source_reachable:
print(f"\n[OK] Source is now reachable! ({', '.join(reachable_paths)})")
print(f" Restarting queue processing...")
logger.info(f"Queue retry: source became reachable after {elapsed_minutes:.1f} minutes, restarting queue")
# Restart the batch processing by recursively calling with same args
# We'll set a flag to prevent infinite recursion
print("\n" + "=" * 80)
print("🔃 RESTARTING BATCH QUEUE")
print("=" * 80 + "\n")
# Simply return and let the main process restart
# In a real scenario, you might want to re-execute the batch
# For now, we'll just restart by re-parsing and processing failed items
retry_batch_queue = []
for path_str, params_str in all_items:
item_sig = f"{path_str}|{params_str}"
if item_sig not in processed_items or item_sig in [f"{fp[1]}|" for fp in failed_paths]:
# Re-add failed items to queue
try:
folder = normalize_input_path(path_str, config.get("path_mappings", {}))
if not folder.exists():
retry_batch_queue.append((path_str, params_str))
except:
pass
if retry_batch_queue:
print(f"📋 Found {len(retry_batch_queue)} item(s) to retry\n")
for retry_path_str, retry_params_str in retry_batch_queue:
retry_item_sig = f"{retry_path_str}|{retry_params_str}"
print("-" * 80)
print(f"RETRY [BATCH {batch_num}]: {retry_path_str}")
if retry_params_str:
print(f"Parameters: {retry_params_str}")
print("-" * 80)
logger.info(f"[QUEUE RETRY {batch_num}] Processing: {retry_path_str}")
try:
retry_folder = normalize_input_path(retry_path_str, config.get("path_mappings", {}))
if not retry_folder.exists():
print(f"[ERROR] [RETRY {batch_num}] Folder still not found: {retry_folder}")
logger.warning(f"[QUEUE RETRY {batch_num}] Folder still not found: {retry_folder}")
batch_num += 1
continue
merged_args = merge_batch_args(args, retry_params_str)
# Debug logging for crop parameter
if merged_args.crop_height:
logger.info(f"[QUEUE RETRY {batch_num}] Crop height set to: {merged_args.crop_height}p")
print(f"[INFO] [QUEUE RETRY {batch_num}] Crop height: {merged_args.crop_height}p")
retry_travel_output_folder = None
if merged_args.travel_mode:
if not merged_args.output_folder:
print(f"[ERROR] [RETRY {batch_num}] --travel requires --output folder")
logger.error(f"[QUEUE RETRY {batch_num}] --travel requires --output folder")
batch_num += 1
continue
output_base = Path(merged_args.output_folder)
input_folder_name = retry_folder.name
retry_travel_output_folder = output_base / input_folder_name
retry_travel_output_folder.mkdir(parents=True, exist_ok=True)
merged_args.resolution = "720"
default_cq = get_default_cq(retry_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"[ERROR] [RETRY {batch_num}] --replace requires --no-encode")
logger.error(f"[QUEUE RETRY {batch_num}] --replace requires --no-encode")
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
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(
retry_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, retry_travel_output_folder, merged_args.unforce_subs,
merged_args.no_encode, merged_args.ignore_tags, merged_args.force_encode, merged_args.replace_file,
merged_args.wait_seconds, crop_height=merged_args.crop_height, audio_titles=audio_titles_dict, audio_channels=audio_channels_dict, title_suffix=merged_args.title_suffix, move_mode=merged_args.move_mode, keep_original=merged_args.keep_original, skip_audio_check=merged_args.skip_audio_check
)
print(f"✓ [RETRY {batch_num}] Completed: {retry_folder.name}")
logger.info(f"[QUEUE RETRY {batch_num}] Completed successfully")
completed += 1
batch_num += 1
except Exception as retry_e:
print(f"[ERROR] [RETRY {batch_num}] Error: {retry_e}")
logger.error(f"[QUEUE RETRY {batch_num}] Error: {retry_e}", exc_info=True)
batch_num += 1
# Final summary after retry
print()
print("=" * 80)
print(f"✓ QUEUE RETRY COMPLETE")
print("=" * 80)
logger.info(f"Queue retry processing finished")
break
else:
# Source still not reachable, wait and try again
minutes_remaining = (retry_end_time - time.time()) / 60
print(f"\n⏳ Attempt {attempt_count}: Source not yet reachable")
print(f" Elapsed: {elapsed_minutes:.1f}m | Remaining: {minutes_remaining:.1f}m")
print(f" Waiting {retry_minutes} minute(s) before next check...")
logger.info(f"Queue retry attempt {attempt_count}: source still unreachable, waiting {retry_minutes} minutes")
# Wait before next attempt
time.sleep(retry_minutes * 60) # Convert to seconds
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"[ERROR] 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("[ERROR] --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"[OK] 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"[OK] 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("[ERROR] --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("[ERROR] 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"[ERROR] 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("[ERROR] 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.ignore_tags, args.replace_file, args.wait_seconds, color_bit=getattr(args, 'color_bit', None), crop_height=args.crop_height, audio_titles=audio_titles_dict, audio_channels=audio_channels_dict, title_suffix=args.title_suffix, default_language=getattr(args, 'default_language', None), no_replace_und=getattr(args, 'no_replace_und', False), move_mode=args.move_mode, keep_original=args.keep_original, skip_audio_check=args.skip_audio_check)
if __name__ == "__main__":
main()