1109 lines
52 KiB
Python
1109 lines
52 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
|
||
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"
|
||
)
|
||
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
|
||
)
|
||
|
||
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
|
||
)
|
||
|
||
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
|
||
)
|
||
|
||
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)
|
||
|
||
if __name__ == "__main__":
|
||
main()
|