# core/process_manager.py """Main processing logic for batch transcoding.""" import csv import os 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, get_source_bit_depth, determine_target_resolution logger = setup_logger(Path(__file__).parent.parent / "logs") failure_logger = setup_failure_logger(Path(__file__).parent.parent / "logs") 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 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"): """ 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. """ 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 encoding mode is_smart_mode = transcode_mode == "compression" # Try CQ first, then bitrate fallback 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 compression 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: Compression (Try CQ first, retry with Bitrate if needed)") elif is_forced_cq: print("šŸ“‹ MODE: CQ (constant quality, skip failures, log them)") else: print("šŸ“‹ MODE: Bitrate (bitrate mode only, skip failures, log them)") print(f"{'='*60}\n") skipped_count = 0 for file in folder.rglob("*"): if file.suffix.lower() not in extensions: continue if any(tag.lower() in file.name.lower() for tag in ignore_tags): 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 print("="*60) logger.info(f"Processing: {file.name}") 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) res_width, res_height, target_resolution = determine_target_resolution( src_width, src_height, explicit_resolution ) # Auto-select encoder based on source bit depth if not explicitly specified # (explicit encoder arg is passed in, so if user didn't specify, it's still the default) # We need to check if encoder came from CLI or is the default # For now, we'll always auto-detect and only skip if encoder was explicitly set # Since we can't distinguish in the current flow, we'll add a parameter to track this selected_encoder = encoder # Start with what was passed (may be default) # Check source bit depth for auto-selection source_bit_depth = get_source_bit_depth(temp_input) # Auto-select encoder based on source bit depth # 10-bit or higher (including 12-bit) → HEVC (supports up to 10-bit) # 8-bit → AV1 (more efficient for 8-bit) if source_bit_depth >= 10: selected_encoder = "hevc" encoder_note = "auto-selected (10+ bit source)" else: selected_encoder = "av1" encoder_note = "auto-selected (8-bit source)" print(f"ā„¹ļø Encoder: {selected_encoder} ({encoder_note})") logger.info(f"Selected encoder: {selected_encoder} - Source bit depth: {source_bit_depth}-bit") # 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.") logger.info(f"Source {src_width}x{src_height} detected. Scaling to 1080p.") elif src_height <= 720: print(f"ā„¹ļø Source {src_width}x{src_height} is 720p or lower. Preserving resolution.") logger.info(f"Source {src_width}x{src_height} (<=720p). Preserving source resolution.") else: print(f"ā„¹ļø Source {src_width}x{src_height} is at or below 1080p. Preserving resolution.") logger.info(f"Source {src_width}x{src_height} (<=1080p). Preserving source 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 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 # Always output as .mkv (AV1 video codec) with [EHX] suffix 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 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, selected_encoder, subtitle_file, audio_language, audio_filter_config, test_mode ) # Check if encode met size target encode_succeeded = True 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 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 }) 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 error_msg = f"Size threshold not met ({reduction_ratio:.1%})" 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 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 }) 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 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 ) # 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, selected_encoder, file_data.get('subtitle_file'), audio_language, None, test_mode ) # Check if bitrate also failed if 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') ) except subprocess.CalledProcessError as e: error_msg = str(e).split('\n')[0][:100] 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] 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): """Helper function to save successfully encoded files with [EHX] tag and clean up subtitle files.""" # 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}") 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 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 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}") # 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 logger.info(f"\nāœ… CONVERSION COMPLETE: {dest_file.name}") logger.info(f" Type: {f_type.upper()} | Show: {show}") logger.info(f" Size: {orig_size_mb}MB → {proc_size_mb}MB ({percentage}% of original, {100-percentage:.1f}% reduction)") logger.info(f" Method: {method} | Status: SUCCESS") print(f"šŸ“ Logged conversion: {dest_file.name} ({percentage}%), method={method}") try: temp_input.unlink() # Only delete original file if NOT in Featurettes folder (Featurettes are re-encoded in place) if 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}")