793 lines
38 KiB
Python
793 lines
38 KiB
Python
# core/process_manager.py
|
||
"""Main processing logic for batch transcoding."""
|
||
|
||
import csv
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import time
|
||
from pathlib import Path
|
||
|
||
from core.audio_handler import get_audio_streams
|
||
from core.encode_engine import run_ffmpeg
|
||
from core.logger_helper import setup_logger, setup_failure_logger
|
||
from core.video_handler import get_source_resolution, determine_target_resolution, get_source_bit_depth, has_forced_subtitles
|
||
|
||
logger = setup_logger(Path(__file__).parent.parent / "logs")
|
||
failure_logger = setup_failure_logger(Path(__file__).parent.parent / "logs")
|
||
|
||
|
||
def get_default_cq(folder: Path, config: dict, resolution: str, encoder: str = "hevc") -> int:
|
||
"""
|
||
Get the default CQ value for a given resolution, encoder, and folder type.
|
||
|
||
Args:
|
||
folder: Input folder path (used to detect TV/anime/movie type)
|
||
config: Configuration dictionary
|
||
resolution: Resolution string ("720", "1080", etc.)
|
||
encoder: Encoder type ("hevc" or "av1")
|
||
|
||
Returns:
|
||
Default CQ value for the given parameters
|
||
"""
|
||
# Determine content type from folder path
|
||
folder_lower = str(folder).lower()
|
||
is_tv = "\\tv\\" in folder_lower or "/tv/" in folder_lower
|
||
is_anime = "\\anime\\" in folder_lower or "/anime/" in folder_lower
|
||
|
||
# Build the config key
|
||
if is_tv:
|
||
key = f"tv_{resolution}"
|
||
elif is_anime:
|
||
key = f"anime_{resolution}"
|
||
else:
|
||
key = f"movie_{resolution}"
|
||
|
||
# Get CQ value from config
|
||
cq_config = config.get("encode", {}).get("cq", {}).get(encoder, {})
|
||
return cq_config.get(key, 28) # Default fallback to 28
|
||
|
||
|
||
def get_media_context(file: Path, root_folder: Path) -> dict:
|
||
"""
|
||
Extract media context from file path for structured logging.
|
||
|
||
Parses directory structure to identify show name, media type (TV/Movie),
|
||
season/episode numbers for grouping logs later.
|
||
|
||
Args:
|
||
file: File path to analyze
|
||
root_folder: Root processing folder to use as reference
|
||
|
||
Returns:
|
||
dict with keys: media_type, show_name, season (optional), episode (optional), video_filename
|
||
|
||
Examples:
|
||
P:\\tv\\Breaking Bad\\season01\\episode01.mkv
|
||
→ {"media_type": "tv", "show_name": "Breaking Bad", "season": "01", "episode": "01"}
|
||
|
||
P:\\movies\\Inception.mkv
|
||
→ {"media_type": "movie", "show_name": "Inception"}
|
||
"""
|
||
parts = file.parts
|
||
root_parts = root_folder.parts
|
||
|
||
context = {
|
||
"video_filename": file.name,
|
||
"media_type": None,
|
||
"show_name": None,
|
||
"season": None,
|
||
"episode": None
|
||
}
|
||
|
||
# Find where media type (tv/movie/anime) appears in path
|
||
path_lower = str(file).lower()
|
||
|
||
if "\\tv\\" in path_lower or "/tv/" in path_lower:
|
||
context["media_type"] = "tv"
|
||
elif "\\anime\\" in path_lower or "/anime/" in path_lower:
|
||
context["media_type"] = "anime"
|
||
elif "\\movies\\" in path_lower or "/movies/" in path_lower:
|
||
context["media_type"] = "movie"
|
||
else:
|
||
# Default to movie if path structure unclear
|
||
context["media_type"] = "other"
|
||
|
||
# Extract show name (directory immediately after media type)
|
||
try:
|
||
for i, part in enumerate(parts):
|
||
part_lower = part.lower()
|
||
if part_lower in ("tv", "anime", "movies"):
|
||
# Next part is show name
|
||
if i + 1 < len(parts):
|
||
context["show_name"] = parts[i + 1]
|
||
|
||
# For TV/anime, check if there's a season folder
|
||
if context["media_type"] in ("tv", "anime") and i + 2 < len(parts):
|
||
season_part = parts[i + 2].lower()
|
||
# Pattern: "season01", "s01", "season 1", etc.
|
||
import re
|
||
season_match = re.search(r's(?:eason)?\s*(\d+)', season_part)
|
||
if season_match:
|
||
context["season"] = season_match.group(1).zfill(2)
|
||
|
||
# Extract episode from filename
|
||
# Pattern: "e01", "episode01", "01", etc.
|
||
filename_lower = file.stem.lower()
|
||
ep_match = re.search(r'e(?:pisode)?\s*(\d+)', filename_lower)
|
||
if ep_match:
|
||
context["episode"] = ep_match.group(1).zfill(2)
|
||
break
|
||
except Exception as e:
|
||
logger.warning(f"Could not parse media context from {file}: {e}")
|
||
|
||
return context
|
||
|
||
|
||
def _cleanup_temp_files(temp_input: Path, temp_output: Path):
|
||
"""Helper function to clean up temporary input and output files."""
|
||
try:
|
||
if temp_input.exists():
|
||
temp_input.unlink()
|
||
logger.debug(f"Cleaned up temp input: {temp_input.name}")
|
||
except Exception as e:
|
||
logger.warning(f"Could not delete temp input {temp_input.name}: {e}")
|
||
|
||
try:
|
||
if temp_output.exists():
|
||
temp_output.unlink()
|
||
logger.debug(f"Cleaned up temp output: {temp_output.name}")
|
||
except Exception as e:
|
||
logger.warning(f"Could not delete temp output {temp_output.name}: {e}")
|
||
|
||
|
||
def should_skip_file(file: Path, no_encode: bool, unforce_subs: bool, force_process: bool, ignore_tags: list, travel_output_folder: Path) -> tuple:
|
||
"""
|
||
Determine if a file should be skipped from processing based on multiple criteria.
|
||
|
||
Skip conditions (in order):
|
||
1. If --no-encode + --unforce-subs: skip if file has no forced subtitles
|
||
2. If --force-process NOT set: skip if filename contains any ignore_tags (e.g., [EHX])
|
||
3. Travel mode always processes files (overrides ignore tags)
|
||
|
||
Args:
|
||
file: File path to check
|
||
no_encode: True if --no-encode flag is set
|
||
unforce_subs: True if --unforce-subs flag is set
|
||
force_process: True if --force-process flag is set (bypass ignore_tags)
|
||
ignore_tags: List of filename tags to skip (from config)
|
||
travel_output_folder: If set, travel mode is active (process all files)
|
||
|
||
Returns:
|
||
tuple: (should_skip: bool, reason: str or None)
|
||
"""
|
||
# Check for forced subtitles if using --no-encode + --unforce-subs
|
||
if no_encode and unforce_subs:
|
||
if not has_forced_subtitles(file):
|
||
return True, "no forced subtitles found (--no-encode + --unforce-subs)"
|
||
|
||
# Skip files with ignore tags (unless force_process is enabled)
|
||
# In travel mode, don't skip files based on tags
|
||
if not force_process and not travel_output_folder and any(tag.lower() in file.name.lower() for tag in ignore_tags):
|
||
return True, "matches ignore tags"
|
||
|
||
return False, None
|
||
|
||
|
||
def process_folder(folder: Path, cq: int, transcode_mode: str, resolution: str, config: dict, tracker_file: Path, test_mode: bool = False, audio_language: str = None, filter_audio: bool = None, audio_select: str = None, encoder: str = "hevc", strip_all_titles: bool = False, travel_output_folder: Path = None, unforce_subs: bool = False, no_encode: bool = False, force_process: bool = False, replace_file: bool = False, wait_seconds: int = 0):
|
||
"""
|
||
Process all video files in folder with appropriate encoding settings.
|
||
|
||
Args:
|
||
folder: Input folder path
|
||
cq: CQ override value
|
||
transcode_mode: "cq" or "bitrate"
|
||
resolution: Explicit resolution override ("480", "720", "1080", or None for smart)
|
||
config: Configuration dictionary
|
||
tracker_file: Path to CSV tracker file
|
||
test_mode: If True, only encode first file and skip final move/cleanup
|
||
audio_language: Optional language code to tag audio (e.g., 'eng', 'spa'). If None, no tagging applied.
|
||
filter_audio: If True, show interactive audio selection prompt. If None, use config setting.
|
||
audio_select: Pre-selected audio streams (comma-separated, e.g., "1,2"). Skips interactive prompt.
|
||
encoder: Video encoder to use - "hevc" for HEVC NVENC 10-bit (default) or "av1" for AV1 NVENC 8-bit.
|
||
strip_all_titles: If True, strip all title metadata from all audio tracks.
|
||
unforce_subs: If True, remove forced flag from all subtitle tracks.
|
||
no_encode: If True, skip encoding and copy video/audio streams as-is. Useful with --unforce-subs for re-muxing only.
|
||
force_process: If True, process files even if they match ignore_tags (e.g., already encoded files).
|
||
replace_file: If True, replace original file instead of creating suffix version. Requires no_encode=True.
|
||
wait_seconds: Seconds to wait after each successful file (for Plex detection). 0 = no wait.
|
||
travel_output_folder: If provided, move encoded files to this folder instead of original location.
|
||
"""
|
||
if not folder.exists():
|
||
print(f"❌ Folder not found: {folder}")
|
||
logger.error(f"Folder not found: {folder}")
|
||
return
|
||
|
||
audio_config = config["audio"]
|
||
bitrate_config = config["encode"]["fallback"]
|
||
filters_config = config["encode"]["filters"]
|
||
suffix = config["suffix"]
|
||
extensions = config["extensions"]
|
||
ignore_tags = config["ignore_tags"]
|
||
reduction_ratio_threshold = config["reduction_ratio_threshold"]
|
||
|
||
# Resolution logic: explicit arg takes precedence, else use smart defaults
|
||
explicit_resolution = resolution # Will be None if not specified
|
||
|
||
filter_flags = filters_config.get("default","lanczos")
|
||
folder_lower = str(folder).lower()
|
||
is_tv = "\\tv\\" in folder_lower or "/tv/" in folder_lower
|
||
is_anime = "\\anime\\" in folder_lower or "/anime/" in folder_lower
|
||
if is_tv:
|
||
filter_flags = filters_config.get("tv","bicubic")
|
||
elif is_anime:
|
||
filter_flags = filters_config.get("anime", filters_config.get("default","lanczos"))
|
||
|
||
processing_folder = Path(config["processing_folder"])
|
||
processing_folder.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Determine if we're in smart mode (no explicit mode specified)
|
||
is_smart_mode = transcode_mode not in ["cq", "bitrate"] # Default/smart mode
|
||
is_forced_cq = transcode_mode == "cq"
|
||
is_forced_bitrate = transcode_mode == "bitrate"
|
||
|
||
# Track files for potential retry in smart mode
|
||
failed_cq_files = [] # List of (file_path, metadata) for CQ failures in smart mode
|
||
consecutive_failures = 0
|
||
max_consecutive = 3
|
||
|
||
# Phase 1: Process files with initial mode strategy
|
||
print(f"\n{'='*60}")
|
||
if is_smart_mode:
|
||
print("📋 MODE: Smart (Try CQ first, retry with Bitrate if needed)")
|
||
elif is_forced_cq:
|
||
print("📋 MODE: Forced CQ (skip failures, log them)")
|
||
else:
|
||
print("📋 MODE: Forced Bitrate (skip failures, log them)")
|
||
print(f"{'='*60}\n")
|
||
|
||
skipped_count = 0
|
||
for file in folder.rglob("*"):
|
||
# Skip hidden files/directories (starting with . or ._)
|
||
if file.name.startswith('.') or file.name.startswith('._'):
|
||
continue
|
||
|
||
if file.suffix.lower() not in extensions:
|
||
continue
|
||
|
||
# Check if file should be skipped
|
||
should_skip, skip_reason = should_skip_file(file, no_encode, unforce_subs, force_process, ignore_tags, travel_output_folder)
|
||
if should_skip:
|
||
logger.info(f"Skipping {file.name}: {skip_reason}")
|
||
print(f"⏭️ Skipping {file.name}: {skip_reason}")
|
||
skipped_count += 1
|
||
continue
|
||
|
||
if skipped_count > 0:
|
||
print(f"⏭️ Skipped {skipped_count} file(s)")
|
||
logger.info(f"Skipped {skipped_count} file(s)")
|
||
skipped_count = 0
|
||
|
||
# Extract media context for structured logging
|
||
media_context = get_media_context(file, folder)
|
||
|
||
print("="*60)
|
||
logger.info(f"Processing: {file.name}", extra=media_context)
|
||
print(f"📁 Processing: {file.name}")
|
||
|
||
temp_input = (processing_folder / file.name).resolve()
|
||
|
||
# Check if file already exists in processing folder
|
||
if temp_input.exists() and os.access(temp_input, os.R_OK):
|
||
source_size = file.stat().st_size
|
||
temp_size = temp_input.stat().st_size
|
||
|
||
# Verify it's complete (same size as source)
|
||
if source_size == temp_size:
|
||
print(f"✓ Found existing copy in processing folder (verified complete)")
|
||
logger.info(f"File already in processing: {file.name} ({temp_size/1e6:.2f} MB verified complete)")
|
||
else:
|
||
# File exists but incomplete - recopy
|
||
print(f"⚠️ Existing copy incomplete ({temp_size/1e6:.2f} MB vs {source_size/1e6:.2f} MB source). Re-copying...")
|
||
logger.warning(f"Incomplete copy detected for {file.name}. Re-copying.")
|
||
shutil.copy2(file, temp_input)
|
||
logger.info(f"Re-copied {file.name} → {temp_input.name}")
|
||
else:
|
||
# File doesn't exist or not accessible - copy it
|
||
shutil.copy2(file, temp_input)
|
||
logger.info(f"Copied {file.name} → {temp_input.name}")
|
||
|
||
# Verify file is accessible
|
||
for attempt in range(3):
|
||
if temp_input.exists() and os.access(temp_input, os.R_OK):
|
||
break
|
||
|
||
# Check for matching subtitle file
|
||
subtitle_file = None
|
||
if config.get("general", {}).get("subtitles", {}).get("enabled", True):
|
||
subtitle_exts = config.get("general", {}).get("subtitles", {}).get("extensions", ".vtt,.srt,.ass,.ssa,.sub").split(",")
|
||
# Look for subtitle with same base name (e.g., movie.vtt or movie.en.vtt)
|
||
for ext in subtitle_exts:
|
||
ext = ext.strip()
|
||
# Try exact match first (movie.vtt)
|
||
potential_sub = file.with_suffix(ext)
|
||
if potential_sub.exists():
|
||
subtitle_file = potential_sub
|
||
print(f"📝 Found subtitle: {subtitle_file.name}")
|
||
logger.info(f"Found subtitle file: {subtitle_file.name}")
|
||
break
|
||
|
||
# Try language prefix variants (movie.en.vtt, movie.eng.vtt, etc.)
|
||
# Look for files matching the pattern basename.*language*.ext
|
||
parent_dir = file.parent
|
||
base_name = file.stem
|
||
for item in parent_dir.glob(f"{base_name}.*{ext}"):
|
||
subtitle_file = item
|
||
print(f"📝 Found subtitle: {subtitle_file.name}")
|
||
logger.info(f"Found subtitle file: {subtitle_file.name}")
|
||
break
|
||
|
||
if subtitle_file:
|
||
break
|
||
|
||
try:
|
||
# Detect source resolution and determine target resolution
|
||
src_width, src_height = get_source_resolution(temp_input)
|
||
src_bit_depth = get_source_bit_depth(temp_input)
|
||
res_width, res_height, target_resolution = determine_target_resolution(
|
||
src_width, src_height, explicit_resolution
|
||
)
|
||
|
||
# Auto-select encoder based on detected source bit depth
|
||
if src_bit_depth >= 10:
|
||
# Source is 10-bit or higher - use HEVC NVENC
|
||
selected_encoder = "hevc"
|
||
else:
|
||
# Source is 8-bit - use AV1 NVENC
|
||
selected_encoder = "av1"
|
||
logger.info(f"Auto-selected {selected_encoder.upper()} encoder for detected {src_bit_depth}-bit source")
|
||
|
||
# Log resolution decision
|
||
if explicit_resolution:
|
||
logger.info(f"Using explicitly specified resolution: {res_width}x{res_height}")
|
||
else:
|
||
if src_height > 1080:
|
||
print(f"⚠️ Source {src_width}x{src_height} is above 1080p. Scaling down to 1080p.")
|
||
elif src_height <= 720:
|
||
print(f"ℹ️ Source {src_width}x{src_height} is 720p or lower. Preserving resolution.")
|
||
else:
|
||
print(f"ℹ️ Source {src_width}x{src_height} is at or below 1080p. Preserving resolution.")
|
||
|
||
# Set CQ based on content type, target resolution, and encoder
|
||
if is_anime:
|
||
cq_key = f"anime_{target_resolution}"
|
||
elif is_tv:
|
||
cq_key = f"tv_{target_resolution}"
|
||
else:
|
||
cq_key = f"movie_{target_resolution}"
|
||
# Look up CQ from encoder-specific section (using auto-selected encoder)
|
||
encoder_cq_config = config["encode"]["cq"].get(selected_encoder, {})
|
||
content_cq = encoder_cq_config.get(cq_key, 32)
|
||
file_cq = cq if cq is not None else content_cq
|
||
|
||
# Use the auto-selected encoder for the rest of processing
|
||
actual_encoder = selected_encoder
|
||
content_cq = encoder_cq_config.get(cq_key, 32)
|
||
file_cq = cq if cq is not None else content_cq
|
||
|
||
# Output file with suffix in processing folder (always .mkv container)
|
||
temp_output = (processing_folder / f"{file.stem}{suffix}.mkv").resolve()
|
||
|
||
# Determine which method to try first
|
||
if is_forced_bitrate:
|
||
method = "Bitrate"
|
||
elif is_forced_cq:
|
||
method = "CQ"
|
||
else: # Smart mode
|
||
method = "CQ" # Always try CQ first in smart mode
|
||
|
||
# Attempt encoding
|
||
try:
|
||
# Determine audio_filter config (CLI arg overrides config file)
|
||
# --filter-audio flag means: show interactive prompt
|
||
if filter_audio:
|
||
audio_filter_config = {"enabled": True, "interactive": True}
|
||
# If --audio-select provided, skip interactive and use pre-selected streams
|
||
if audio_select:
|
||
audio_filter_config["preselected"] = audio_select
|
||
elif audio_select:
|
||
# If --audio-select provided (without --filter-audio), use preselected streams
|
||
audio_filter_config = {"enabled": True, "preselected": audio_select}
|
||
else:
|
||
# Use config file setting (if present)
|
||
audio_filter_config = config.get("general", {}).get("audio_filter", {})
|
||
|
||
orig_size, out_size, reduction_ratio = run_ffmpeg(
|
||
temp_input, temp_output, file_cq, res_width, res_height, src_width, src_height,
|
||
filter_flags, audio_config, method, bitrate_config, actual_encoder, [subtitle_file] if subtitle_file else None, audio_language,
|
||
audio_filter_config, test_mode, strip_all_titles, src_bit_depth, unforce_subs, no_encode
|
||
)
|
||
|
||
# Check if encode met size target
|
||
# Skip size check if --no-encode is used (file size will be nearly identical)
|
||
encode_succeeded = True
|
||
if not no_encode:
|
||
if method == "CQ" and reduction_ratio >= reduction_ratio_threshold:
|
||
encode_succeeded = False
|
||
elif method == "Bitrate" and reduction_ratio >= reduction_ratio_threshold:
|
||
encode_succeeded = False
|
||
|
||
if not encode_succeeded:
|
||
# Size threshold not met
|
||
error_msg = f"Size threshold not met ({reduction_ratio:.1%})"
|
||
|
||
if test_mode:
|
||
# In test mode, stop immediately and keep temp files
|
||
print(f"❌ Test mode: {method} failed: {error_msg}")
|
||
print(f" Temp input preserved at: {temp_input}")
|
||
print(f" Temp output preserved at: {temp_output}")
|
||
logger.error(f"Test mode: {method} size threshold failed for {file.name}: {error_msg}")
|
||
raise RuntimeError(error_msg)
|
||
|
||
if is_smart_mode and method == "CQ":
|
||
# In smart mode CQ failure, mark for bitrate retry
|
||
print(f"⚠️ CQ failed size target ({reduction_ratio:.1%}). Will retry with Bitrate.")
|
||
failure_logger.warning(f"{file.name} | CQ failed size target ({reduction_ratio:.1%})")
|
||
failed_cq_files.append({
|
||
'file': file,
|
||
'temp_input': temp_input,
|
||
'temp_output': temp_output,
|
||
'src_width': src_width,
|
||
'src_height': src_height,
|
||
'res_width': res_width,
|
||
'res_height': res_height,
|
||
'target_resolution': target_resolution,
|
||
'file_cq': file_cq,
|
||
'is_tv': is_tv,
|
||
'subtitle_file': subtitle_file,
|
||
'src_bit_depth': src_bit_depth,
|
||
'encoder': actual_encoder,
|
||
'media_context': media_context
|
||
})
|
||
consecutive_failures += 1
|
||
if consecutive_failures >= max_consecutive:
|
||
print(f"\n⚠️ {max_consecutive} consecutive CQ failures. Moving to Phase 2: Bitrate retry.")
|
||
logger.warning(f"{max_consecutive} consecutive CQ failures. Moving to Phase 2.")
|
||
break # Move to Phase 2
|
||
continue
|
||
elif is_forced_cq or is_forced_bitrate:
|
||
# In forced mode, skip the file
|
||
print(f"❌ {method} failed: {error_msg}")
|
||
failure_logger.warning(f"{file.name} | {method} failed: {error_msg}")
|
||
consecutive_failures += 1
|
||
if consecutive_failures >= max_consecutive:
|
||
print(f"\n❌ {max_consecutive} consecutive failures in forced {method} mode. Stopping.")
|
||
logger.error(f"{max_consecutive} consecutive failures. Stopping process.")
|
||
_cleanup_temp_files(temp_input, temp_output)
|
||
break
|
||
_cleanup_temp_files(temp_input, temp_output)
|
||
continue
|
||
|
||
# Encoding succeeded - reset failure counter
|
||
consecutive_failures = 0
|
||
|
||
except subprocess.CalledProcessError as e:
|
||
# FFmpeg execution failed
|
||
error_msg = str(e).split('\n')[0][:100] # First 100 chars of error
|
||
|
||
if test_mode:
|
||
# In test mode, stop immediately on any error and keep temp files
|
||
print(f"❌ Test mode: Encode failed. Stopping script.")
|
||
print(f" Temp input preserved at: {temp_input}")
|
||
print(f" Temp output preserved at: {temp_output}")
|
||
logger.error(f"Test mode: Encode failed for {file.name}: {error_msg}")
|
||
raise
|
||
|
||
if is_smart_mode and method == "CQ":
|
||
# In smart mode, log and retry with bitrate
|
||
print(f"❌ CQ encode error. Will retry with Bitrate.")
|
||
failure_logger.warning(f"{file.name} | CQ error: {error_msg}")
|
||
failed_cq_files.append({
|
||
'file': file,
|
||
'temp_input': temp_input,
|
||
'temp_output': temp_output,
|
||
'src_width': src_width,
|
||
'src_height': src_height,
|
||
'res_width': res_width,
|
||
'res_height': res_height,
|
||
'target_resolution': target_resolution,
|
||
'file_cq': file_cq,
|
||
'is_tv': is_tv,
|
||
'subtitle_file': subtitle_file,
|
||
'media_context': media_context
|
||
})
|
||
consecutive_failures += 1
|
||
if consecutive_failures >= max_consecutive:
|
||
print(f"\n⚠️ {max_consecutive} consecutive CQ failures. Moving to Phase 2: Bitrate retry.")
|
||
logger.warning(f"{max_consecutive} consecutive CQ failures. Moving to Phase 2.")
|
||
break
|
||
continue
|
||
elif is_forced_cq or is_forced_bitrate:
|
||
# In forced mode, skip and log
|
||
if test_mode:
|
||
# In test mode, stop immediately and keep temp files
|
||
print(f"❌ Test mode: {method} encode failed. Stopping script.")
|
||
print(f" Temp input preserved at: {temp_input}")
|
||
print(f" Temp output preserved at: {temp_output}")
|
||
logger.error(f"Test mode: {method} encode failed for {file.name}: {error_msg}")
|
||
raise
|
||
|
||
print(f"❌ {method} encode failed: {error_msg}")
|
||
failure_logger.warning(f"{file.name} | {method} error: {error_msg}")
|
||
consecutive_failures += 1
|
||
if consecutive_failures >= max_consecutive:
|
||
print(f"\n❌ {max_consecutive} consecutive failures in forced {method} mode. Stopping.")
|
||
logger.error(f"{max_consecutive} consecutive failures. Stopping process.")
|
||
_cleanup_temp_files(temp_input, temp_output)
|
||
break
|
||
_cleanup_temp_files(temp_input, temp_output)
|
||
continue
|
||
|
||
# If we get here, encoding succeeded - save file and log
|
||
_save_successful_encoding(
|
||
file, temp_input, temp_output, orig_size, out_size,
|
||
reduction_ratio, method, src_width, src_height, res_width, res_height,
|
||
file_cq, tracker_file, folder, is_tv, suffix, config, test_mode, subtitle_file, travel_output_folder, replace_file, wait_seconds, media_context
|
||
)
|
||
|
||
# In test mode, stop after first successful file
|
||
if test_mode:
|
||
print(f"\n✅ TEST MODE: File processed. Encoded file is in temp folder for inspection.")
|
||
break
|
||
|
||
except Exception as e:
|
||
# Unexpected error
|
||
error_msg = str(e)[:100]
|
||
print(f"❌ Unexpected error: {error_msg}")
|
||
failure_logger.warning(f"{file.name} | Unexpected error: {error_msg}")
|
||
consecutive_failures += 1
|
||
logger.error(f"Unexpected error processing {file.name}: {e}")
|
||
_cleanup_temp_files(temp_input, temp_output)
|
||
|
||
if is_forced_cq or is_forced_bitrate:
|
||
if consecutive_failures >= max_consecutive:
|
||
print(f"\n❌ {max_consecutive} consecutive failures. Stopping.")
|
||
break
|
||
else:
|
||
if consecutive_failures >= max_consecutive:
|
||
print(f"\n⚠️ {max_consecutive} consecutive failures. Moving to Phase 2.")
|
||
break
|
||
|
||
# Phase 2: Retry failed CQ files with Bitrate mode (smart mode only)
|
||
if is_smart_mode and failed_cq_files:
|
||
print(f"\n{'='*60}")
|
||
print(f"📋 PHASE 2: Retrying {len(failed_cq_files)} failed files with Bitrate mode")
|
||
print(f"{'='*60}\n")
|
||
|
||
consecutive_failures = 0
|
||
|
||
for file_data in failed_cq_files:
|
||
file = file_data['file']
|
||
temp_input = file_data['temp_input']
|
||
temp_output = file_data['temp_output']
|
||
|
||
try:
|
||
print(f"🔄 Retrying: {file.name} with Bitrate")
|
||
logger.info(f"Phase 2 Retry: {file.name} with Bitrate mode")
|
||
|
||
# Clean up old output if it exists
|
||
if temp_output.exists():
|
||
temp_output.unlink()
|
||
|
||
# Retry with bitrate
|
||
orig_size, out_size, reduction_ratio = run_ffmpeg(
|
||
temp_input, temp_output, file_data['file_cq'],
|
||
file_data['res_width'], file_data['res_height'],
|
||
file_data['src_width'], file_data['src_height'],
|
||
filter_flags, audio_config, "Bitrate", bitrate_config, file_data.get('encoder', encoder),
|
||
[file_data.get('subtitle_file')] if file_data.get('subtitle_file') else None, audio_language, None, test_mode, strip_all_titles,
|
||
file_data.get('src_bit_depth'), unforce_subs, no_encode
|
||
)
|
||
|
||
# Check if bitrate also failed
|
||
# Skip size check if --no-encode is used (file size will be nearly identical)
|
||
if not no_encode and reduction_ratio >= reduction_ratio_threshold:
|
||
print(f"⚠️ Bitrate also failed size target ({reduction_ratio:.1%}). Skipping.")
|
||
failure_logger.warning(f"{file.name} | Bitrate retry also failed ({reduction_ratio:.1%})")
|
||
consecutive_failures += 1
|
||
_cleanup_temp_files(temp_input, temp_output)
|
||
if consecutive_failures >= max_consecutive:
|
||
print(f"\n⚠️ {max_consecutive} consecutive Phase 2 failures. Stopping retries.")
|
||
break
|
||
continue
|
||
|
||
# Bitrate succeeded
|
||
consecutive_failures = 0
|
||
_save_successful_encoding(
|
||
file, temp_input, temp_output,
|
||
orig_size, out_size, reduction_ratio, "Bitrate",
|
||
file_data['src_width'], file_data['src_height'],
|
||
file_data['res_width'], file_data['res_height'],
|
||
file_data['file_cq'], tracker_file,
|
||
folder, file_data['is_tv'], suffix, config, False,
|
||
file_data.get('subtitle_file'), travel_output_folder, replace_file, wait_seconds,
|
||
file_data.get('media_context')
|
||
)
|
||
|
||
except subprocess.CalledProcessError as e:
|
||
error_msg = str(e).split('\n')[0][:100]
|
||
|
||
if test_mode:
|
||
# In test mode, stop immediately on any error and keep temp files
|
||
print(f"❌ Test mode: Bitrate retry failed. Stopping script.")
|
||
print(f" Temp input preserved at: {temp_input}")
|
||
print(f" Temp output preserved at: {temp_output}")
|
||
logger.error(f"Test mode: Bitrate retry failed for {file.name}: {error_msg}")
|
||
raise
|
||
|
||
print(f"❌ Bitrate retry failed: {error_msg}")
|
||
failure_logger.warning(f"{file.name} | Bitrate retry error: {error_msg}")
|
||
consecutive_failures += 1
|
||
logger.error(f"Bitrate retry failed for {file.name}: {e}")
|
||
_cleanup_temp_files(temp_input, temp_output)
|
||
|
||
if consecutive_failures >= max_consecutive:
|
||
print(f"\n⚠️ {max_consecutive} consecutive Phase 2 failures. Stopping retries.")
|
||
break
|
||
except Exception as e:
|
||
error_msg = str(e)[:100]
|
||
|
||
if test_mode:
|
||
# In test mode, stop immediately on any error and keep temp files
|
||
print(f"❌ Test mode: Unexpected error in Phase 2. Stopping script.")
|
||
print(f" Temp input preserved at: {temp_input}")
|
||
print(f" Temp output preserved at: {temp_output}")
|
||
logger.error(f"Test mode: Phase 2 error for {file.name}: {error_msg}")
|
||
raise
|
||
|
||
print(f"❌ Unexpected error in Phase 2: {error_msg}")
|
||
failure_logger.warning(f"{file.name} | Phase 2 error: {error_msg}")
|
||
consecutive_failures += 1
|
||
_cleanup_temp_files(temp_input, temp_output)
|
||
if consecutive_failures >= max_consecutive:
|
||
print(f"\n⚠️ {max_consecutive} consecutive Phase 2 failures. Stopping retries.")
|
||
break
|
||
|
||
print(f"\n{'='*60}")
|
||
print("✅ Batch processing complete")
|
||
logger.info("Batch processing complete")
|
||
|
||
|
||
def _save_successful_encoding(file, temp_input, temp_output, orig_size, out_size,
|
||
reduction_ratio, method, src_width, src_height, res_width, res_height,
|
||
file_cq, tracker_file, folder, is_tv, suffix, config=None, test_mode=False, subtitle_file=None, travel_output_folder=None, replace_file: bool = False, wait_seconds: int = 0, media_context: dict = None):
|
||
"""Helper function to save successfully encoded files with [EHX] tag and clean up subtitle files."""
|
||
|
||
if media_context is None:
|
||
media_context = {}
|
||
|
||
# In test mode, show ratio and skip file move/cleanup
|
||
if test_mode:
|
||
orig_size_mb = round(orig_size / 1e6, 2)
|
||
out_size_mb = round(out_size / 1e6, 2)
|
||
percentage = round(out_size_mb / orig_size_mb * 100, 1)
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f"📊 TEST MODE RESULTS:")
|
||
print(f"{'='*60}")
|
||
print(f"Original: {orig_size_mb} MB")
|
||
print(f"Encoded: {out_size_mb} MB")
|
||
print(f"Ratio: {percentage}% ({reduction_ratio:.1%} reduction)")
|
||
print(f"Method: {method} (CQ={file_cq if method == 'CQ' else 'N/A'})")
|
||
print(f"{'='*60}")
|
||
print(f"📁 Encoded file location: {temp_output}")
|
||
logger.info(f"TEST MODE - File: {file.name} | Ratio: {percentage}% | Method: {method}", extra=media_context)
|
||
return
|
||
|
||
# Check if file is in a Featurettes folder - if so, remove suffix from destination filename
|
||
folder_parts = [p.lower() for p in file.parent.parts]
|
||
is_featurette = "featurettes" in folder_parts
|
||
|
||
if replace_file:
|
||
# Use original filename (no suffix)
|
||
dest_file = file.parent / file.name
|
||
elif is_featurette:
|
||
# Remove suffix from temp_output.name for Featurettes
|
||
output_name = temp_output.name
|
||
if suffix in output_name:
|
||
output_name = output_name.replace(suffix, "")
|
||
dest_file = file.parent / output_name
|
||
else:
|
||
dest_file = file.parent / temp_output.name
|
||
|
||
# If travel mode is active, use travel output folder instead
|
||
if travel_output_folder:
|
||
# Preserve relative directory structure from input folder
|
||
relative_path = file.parent.relative_to(folder)
|
||
travel_dest_dir = travel_output_folder / relative_path
|
||
travel_dest_dir.mkdir(parents=True, exist_ok=True)
|
||
dest_file = travel_dest_dir / temp_output.name
|
||
print(f"🧳 Travel mode: Moving to {dest_file}")
|
||
logger.info(f"Travel mode destination: {dest_file}", extra=media_context)
|
||
|
||
shutil.move(temp_output, dest_file)
|
||
print(f"🚚 Moved {temp_output.name} → {dest_file.name}")
|
||
logger.info(f"Moved {temp_output.name} → {dest_file.name}", extra=media_context)
|
||
|
||
# Classify file type based on folder (folder_parts already defined earlier)
|
||
if "tv" in folder_parts:
|
||
f_type = "tv"
|
||
tv_index = folder_parts.index("tv")
|
||
show = folder.parts[tv_index + 1] if len(folder.parts) > tv_index + 1 else "Unknown"
|
||
elif "anime" in folder_parts:
|
||
f_type = "anime"
|
||
anime_index = folder_parts.index("anime")
|
||
show = folder.parts[anime_index + 1] if len(folder.parts) > anime_index + 1 else "Unknown"
|
||
else:
|
||
f_type = "movie"
|
||
show = "N/A"
|
||
|
||
orig_size_mb = round(orig_size / 1e6, 2)
|
||
proc_size_mb = round(out_size / 1e6, 2)
|
||
percentage = round(proc_size_mb / orig_size_mb * 100, 1)
|
||
|
||
# Get audio stream count for tracking
|
||
try:
|
||
audio_streams = get_audio_streams(temp_input)
|
||
audio_stream_count = len(audio_streams)
|
||
except:
|
||
audio_stream_count = 0
|
||
|
||
# Format resolutions for tracking
|
||
src_resolution = f"{src_width}x{src_height}"
|
||
target_res = f"{res_width}x{res_height}"
|
||
cq_str = str(file_cq) if method == "CQ" else "N/A"
|
||
|
||
with open(tracker_file, "a", newline="", encoding="utf-8") as f:
|
||
writer = csv.writer(f)
|
||
writer.writerow([
|
||
f_type, show, dest_file.name, orig_size_mb, proc_size_mb, percentage,
|
||
src_resolution, target_res, audio_stream_count, cq_str, method
|
||
])
|
||
|
||
# Enhanced logging with all conversion details
|
||
log_context = {**media_context, "method": method, "original_size_mb": orig_size_mb, "output_size_mb": proc_size_mb, "reduction_pct": 100 - percentage}
|
||
logger.info(f"✅ CONVERSION COMPLETE: {dest_file.name}", extra=log_context)
|
||
logger.info(f" Type: {f_type.upper()} | Show: {show}", extra=log_context)
|
||
logger.info(f" Size: {orig_size_mb}MB → {proc_size_mb}MB ({percentage}% of original, {100-percentage:.1f}% reduction)", extra=log_context)
|
||
logger.info(f" Method: {method} | Status: SUCCESS", extra=log_context)
|
||
print(f"📝 Logged conversion: {dest_file.name} ({percentage}%), method={method}")
|
||
|
||
try:
|
||
temp_input.unlink()
|
||
|
||
# Keep original file if in travel mode, replace mode, or if in Featurettes folder
|
||
if travel_output_folder:
|
||
logger.info(f"Travel mode: Kept original file {file.name}", extra=media_context)
|
||
elif replace_file:
|
||
logger.info(f"Replace mode: Original file has been replaced with processed version at {file.name}", extra=media_context)
|
||
elif not is_featurette:
|
||
file.unlink()
|
||
logger.info(f"Deleted original and processing copy for {file.name}")
|
||
else:
|
||
logger.info(f"Featurettes file preserved at origin: {file.name}")
|
||
|
||
# Clean up subtitle file if it was embedded
|
||
if subtitle_file and subtitle_file.exists():
|
||
try:
|
||
subtitle_file.unlink()
|
||
print(f"🗑️ Removed embedded subtitle: {subtitle_file.name}")
|
||
logger.info(f"Removed embedded subtitle: {subtitle_file.name}")
|
||
except Exception as e:
|
||
logger.warning(f"Could not delete subtitle file {subtitle_file.name}: {e}")
|
||
except Exception as e:
|
||
print(f"⚠️ Could not delete files: {e}")
|
||
logger.warning(f"Could not delete files: {e}")
|
||
|
||
# Wait if specified (for Plex detection)
|
||
if wait_seconds > 0:
|
||
import time
|
||
print(f"⏱️ Waiting {wait_seconds}s for Plex to detect changes...")
|
||
time.sleep(wait_seconds)
|