"""Conversion service for video to MKV remux with metadata preservation. This service manages the process of converting AVI/MPG/MPEG/WebM/MP4 files to MKV container: - Fast stream copy (no re-encoding) - Audio language detection and mapping from filename - Subtitle file detection and inclusion - Metadata preservation from multiple sources - Track order matching """ import logging import subprocess import platform from pathlib import Path from typing import Optional, List, Dict, Tuple from renamer.extractors.extractor import MediaExtractor logger = logging.getLogger(__name__) class ConversionService: """Service for converting video files to MKV with metadata preservation. This service handles: - Validating video files for conversion (AVI, MPG, MPEG, WebM, MP4) - Detecting nearby subtitle files - Mapping audio languages from filename to tracks - Building ffmpeg command for fast remux or HEVC encoding - Executing conversion with progress Example: service = ConversionService() # Check if file can be converted if service.can_convert(Path("/media/movie.avi")): success, message = service.convert_avi_to_mkv( Path("/media/movie.avi"), extractor=media_extractor ) """ # Supported subtitle extensions SUBTITLE_EXTENSIONS = {'.srt', '.ass', '.ssa', '.sub', '.idx'} def __init__(self): """Initialize the conversion service.""" self.cpu_arch = self._detect_cpu_architecture() logger.debug(f"ConversionService initialized with CPU architecture: {self.cpu_arch}") def _detect_cpu_architecture(self) -> str: """Detect CPU architecture for optimization. Returns: Architecture string: 'x86_64', 'arm64', 'aarch64', or 'unknown' """ machine = platform.machine().lower() # Try to get more specific CPU info try: if machine in ['x86_64', 'amd64']: # Check for Intel vs AMD with open('/proc/cpuinfo', 'r') as f: cpuinfo = f.read().lower() if 'intel' in cpuinfo or 'xeon' in cpuinfo: return 'intel_x86_64' elif 'amd' in cpuinfo: return 'amd_x86_64' else: return 'x86_64' elif machine in ['arm64', 'aarch64']: # Check for specific ARM chips with open('/proc/cpuinfo', 'r') as f: cpuinfo = f.read().lower() if 'rk3588' in cpuinfo or 'rockchip' in cpuinfo: return 'arm64_rk3588' else: return 'arm64' except Exception as e: logger.debug(f"Could not read /proc/cpuinfo: {e}") return machine def _get_x265_params(self, preset: str = 'medium') -> str: """Get optimized x265 parameters based on CPU architecture. Args: preset: Encoding preset (ultrafast, superfast, veryfast, faster, fast, medium, slow) Returns: x265 parameter string optimized for the detected CPU """ # Base parameters for quality base_params = [ 'profile=main10', 'level=4.1', ] # CPU-specific optimizations if self.cpu_arch in ['intel_x86_64', 'amd_x86_64', 'x86_64']: # Intel Xeon / AMD optimization # Enable assembly optimizations and threading cpu_params = [ 'pools=+', # Enable thread pools 'frame-threads=4', # Parallel frame encoding (adjust based on cores) 'lookahead-threads=2', # Lookahead threads 'asm=auto', # Enable CPU-specific assembly optimizations ] # For faster encoding on servers if preset in ['ultrafast', 'superfast', 'veryfast', 'faster', 'fast']: cpu_params.extend([ 'ref=2', # Fewer reference frames for speed 'bframes=3', # Fewer B-frames 'me=1', # Faster motion estimation (DIA) 'subme=1', # Faster subpixel refinement 'rd=2', # Faster RD refinement ]) else: # medium or slow cpu_params.extend([ 'ref=3', 'bframes=4', 'me=2', # HEX motion estimation 'subme=2', 'rd=3', ]) elif self.cpu_arch in ['arm64_rk3588', 'arm64', 'aarch64']: # ARM64 / RK3588 optimization # RK3588 has 4x Cortex-A76 + 4x Cortex-A55 cpu_params = [ 'pools=+', 'frame-threads=4', # Use big cores 'lookahead-threads=1', # Lighter lookahead for ARM 'asm=auto', # Enable NEON optimizations ] # ARM is slower, so optimize more aggressively for speed if preset in ['ultrafast', 'superfast', 'veryfast', 'faster', 'fast']: cpu_params.extend([ 'ref=1', # Minimal reference frames 'bframes=2', 'me=0', # Full search (faster on ARM) 'subme=0', 'rd=1', 'weightp=0', # Disable weighted prediction for speed 'weightb=0', ]) else: # medium cpu_params.extend([ 'ref=2', 'bframes=3', 'me=1', 'subme=1', 'rd=2', ]) else: # Generic/unknown architecture - conservative settings cpu_params = [ 'pools=+', 'frame-threads=2', 'ref=2', 'bframes=3', ] return ':'.join(base_params + cpu_params) def can_convert(self, file_path: Path) -> bool: """Check if a file can be converted (is AVI, MPG, MPEG, WebM, or MP4). Args: file_path: Path to the file to check Returns: True if file is AVI, MPG, MPEG, WebM, or MP4 and can be converted """ if not file_path.exists() or not file_path.is_file(): return False return file_path.suffix.lower() in {'.avi', '.mpg', '.mpeg', '.webm', '.mp4', '.m4v'} def find_subtitle_files(self, video_path: Path) -> List[Path]: """Find subtitle files near the video file. Looks for subtitle files with the same basename in the same directory. Args: video_path: Path to the video file Returns: List of Path objects for found subtitle files Example: >>> service.find_subtitle_files(Path("/media/movie.avi")) [Path("/media/movie.srt"), Path("/media/movie.eng.srt")] """ subtitle_files = [] base_name = video_path.stem # filename without extension directory = video_path.parent # Look for files with same base name and subtitle extensions for sub_ext in self.SUBTITLE_EXTENSIONS: # Exact match: movie.srt exact_match = directory / f"{base_name}{sub_ext}" if exact_match.exists(): subtitle_files.append(exact_match) # Pattern match: movie.eng.srt, movie.ukr.srt, etc. pattern_files = list(directory.glob(f"{base_name}.*{sub_ext}")) for sub_file in pattern_files: if sub_file not in subtitle_files: subtitle_files.append(sub_file) logger.debug(f"Found {len(subtitle_files)} subtitle files for {video_path.name}") return subtitle_files def map_audio_languages( self, extractor: MediaExtractor, audio_track_count: int ) -> List[Optional[str]]: """Map audio languages from filename to track indices. Extracts audio language list from filename and maps them to tracks in order. If filename has fewer languages than tracks, remaining tracks get None. Args: extractor: MediaExtractor with filename data audio_track_count: Number of audio tracks in the file Returns: List of language codes (or None) for each audio track Example: >>> langs = service.map_audio_languages(extractor, 2) >>> print(langs) ['ukr', 'eng'] """ # Get audio_langs from filename extractor audio_langs_str = extractor.get('audio_langs', 'Filename') if not audio_langs_str: logger.debug("No audio languages found in filename") return [None] * audio_track_count # Split by comma and clean langs = [lang.strip().lower() for lang in audio_langs_str.split(',')] # Map to tracks (pad with None if needed) result = [] for i in range(audio_track_count): if i < len(langs): result.append(langs[i]) else: result.append(None) logger.debug(f"Mapped audio languages: {result}") return result def build_ffmpeg_command( self, source_path: Path, mkv_path: Path, audio_languages: List[Optional[str]], subtitle_files: List[Path], encode_hevc: bool = False, crf: int = 18, preset: str = 'medium' ) -> List[str]: """Build ffmpeg command for video to MKV conversion. Creates a command that: - Copies video and audio streams (no re-encoding) OR - Encodes video to HEVC with high quality settings - Sets audio language metadata - Includes external subtitle files - Sets MKV title from filename Args: source_path: Source video file (AVI, MPG, MPEG, WebM, or MP4) mkv_path: Destination MKV file audio_languages: Language codes for each audio track subtitle_files: List of subtitle files to include encode_hevc: If True, encode video to HEVC instead of copying crf: Constant Rate Factor for HEVC (18=visually lossless, 23=high quality default) preset: x265 preset (ultrafast, veryfast, faster, fast, medium, slow) Returns: List of command arguments for subprocess """ cmd = ['ffmpeg'] # Add flags to fix timestamp issues (particularly for AVI files) cmd.extend(['-fflags', '+genpts']) # Input file cmd.extend(['-i', str(source_path)]) # Add subtitle files as inputs for sub_file in subtitle_files: cmd.extend(['-i', str(sub_file)]) # Map video stream cmd.extend(['-map', '0:v:0']) # Map all audio streams cmd.extend(['-map', '0:a']) # Map subtitle streams for i in range(len(subtitle_files)): cmd.extend(['-map', f'{i+1}:s:0']) # Video codec settings if encode_hevc: # HEVC encoding with CPU-optimized parameters cmd.extend(['-c:v', 'libx265']) cmd.extend(['-crf', str(crf)]) # Use specified preset cmd.extend(['-preset', preset]) # 10-bit encoding for better quality (if source supports it) cmd.extend(['-pix_fmt', 'yuv420p10le']) # CPU-optimized x265 parameters x265_params = self._get_x265_params(preset) cmd.extend(['-x265-params', x265_params]) # Copy audio streams (no audio re-encoding) cmd.extend(['-c:a', 'copy']) # Copy subtitle streams cmd.extend(['-c:s', 'copy']) else: # Copy all streams (no re-encoding) cmd.extend(['-c', 'copy']) # Set audio language metadata for i, lang in enumerate(audio_languages): if lang: cmd.extend([f'-metadata:s:a:{i}', f'language={lang}']) # Set title metadata from filename title = source_path.stem cmd.extend(['-metadata', f'title={title}']) # Output file cmd.append(str(mkv_path)) logger.debug(f"Built ffmpeg command: {' '.join(cmd)}") return cmd def convert_avi_to_mkv( self, avi_path: Path, extractor: Optional[MediaExtractor] = None, output_path: Optional[Path] = None, dry_run: bool = False, encode_hevc: bool = False, crf: int = 18, preset: str = 'medium' ) -> Tuple[bool, str]: """Convert video file to MKV with metadata preservation. Args: avi_path: Source video file path (AVI, MPG, MPEG, WebM, or MP4) extractor: Optional MediaExtractor (creates new if None) output_path: Optional output path (defaults to same name with .mkv) dry_run: If True, build command but don't execute encode_hevc: If True, encode video to HEVC instead of copying crf: Constant Rate Factor for HEVC (18=visually lossless, 23=high quality) preset: x265 preset (ultrafast, veryfast, faster, fast, medium, slow) Returns: Tuple of (success, message) Example: >>> success, msg = service.convert_avi_to_mkv( ... Path("/media/movie.avi"), ... encode_hevc=True, ... crf=18 ... ) >>> print(msg) """ # Validate input if not self.can_convert(avi_path): error_msg = f"File is not a supported format (AVI/MPG/MPEG/WebM/MP4) or doesn't exist: {avi_path}" logger.error(error_msg) return False, error_msg # Create extractor if needed if extractor is None: try: extractor = MediaExtractor(avi_path) except Exception as e: error_msg = f"Failed to create extractor: {e}" logger.error(error_msg) return False, error_msg # Determine output path if output_path is None: output_path = avi_path.with_suffix('.mkv') # Check if output already exists if output_path.exists(): error_msg = f"Output file already exists: {output_path.name}" logger.warning(error_msg) return False, error_msg # Get audio track count from MediaInfo audio_tracks = extractor.get('audio_tracks', 'MediaInfo') or [] audio_track_count = len(audio_tracks) if audio_track_count == 0: error_msg = "No audio tracks found in file" logger.error(error_msg) return False, error_msg # Map audio languages audio_languages = self.map_audio_languages(extractor, audio_track_count) # Find subtitle files subtitle_files = self.find_subtitle_files(avi_path) # Build ffmpeg command cmd = self.build_ffmpeg_command( avi_path, output_path, audio_languages, subtitle_files, encode_hevc, crf, preset ) # Dry run mode if dry_run: cmd_str = ' '.join(cmd) info_msg = f"Would convert: {avi_path.name} → {output_path.name}\n" info_msg += f"Audio languages: {audio_languages}\n" info_msg += f"Subtitles: {[s.name for s in subtitle_files]}\n" info_msg += f"Command: {cmd_str}" logger.info(info_msg) return True, info_msg # Execute conversion try: logger.info(f"Starting conversion: {avi_path.name} → {output_path.name}") result = subprocess.run( cmd, capture_output=True, check=False # Don't raise on non-zero exit, check file instead ) # Check if conversion succeeded by verifying output file exists if output_path.exists() and output_path.stat().st_size > 0: success_msg = f"Converted successfully: {avi_path.name} → {output_path.name}" logger.info(success_msg) return True, success_msg else: # Try to decode stderr for error message try: error_output = result.stderr.decode('utf-8', errors='replace') except Exception: error_output = "Unknown error (could not decode ffmpeg output)" error_msg = f"ffmpeg conversion failed: {error_output[-500:]}" # Last 500 chars logger.error(error_msg) return False, error_msg except FileNotFoundError: error_msg = "ffmpeg not found. Please install ffmpeg." logger.error(error_msg) return False, error_msg except Exception as e: error_msg = f"Conversion failed: {e}" logger.error(error_msg) return False, error_msg