diff --git a/episode_matcher.py b/episode_matcher.py index caf68dc..1cb7264 100755 --- a/episode_matcher.py +++ b/episode_matcher.py @@ -150,6 +150,17 @@ def main(): action="store_true", help="Confirm destructive operations (required with --auto-delete-duplicates to actually delete files)" ) + + parser.add_argument( + "--extensions", + help="Comma-separated video extensions to process (e.g., 'mkv,mp4,avi'). Defaults to all common formats." + ) + + parser.add_argument( + "--fallback-ratio", + type=float, + help="Minutes of video per GB when MediaInfo unavailable (default: 45). Use ~25 for 4K, ~60 for low-bitrate." + ) args = parser.parse_args() @@ -212,8 +223,13 @@ def main(): tvdb_episodes = [] # Initialize file classifier + video_extensions = args.extensions.split(',') if args.extensions else None try: - classifier = FileClassifier(str(folder_path)) + classifier = FileClassifier( + str(folder_path), + video_extensions=video_extensions, + fallback_duration_minutes_per_gb=args.fallback_ratio, + ) except FileNotFoundError as e: print(f"Error: {e}") sys.exit(1) diff --git a/src/Matcher/file_classifier.py b/src/Matcher/file_classifier.py index d454b3d..5a446e2 100644 --- a/src/Matcher/file_classifier.py +++ b/src/Matcher/file_classifier.py @@ -15,77 +15,125 @@ except ImportError: config_manager = None +# Supported video extensions (configurable via config or env var) +DEFAULT_VIDEO_EXTENSIONS = {'.mkv', '.mp4', '.avi', '.webm', '.mov', '.wmv', '.flv', '.m4v'} + + class FileClassifier: """Classifies video files as episodes or extras based on file size and video duration.""" - - def __init__(self, folder_path: str): - """Initialize with folder path containing video files.""" + + def __init__(self, folder_path: str, video_extensions: List[str] = None, + fallback_duration_minutes_per_gb: float = None): + """Initialize with folder path containing video files. + + Args: + folder_path: Path to folder with video files. + video_extensions: List of extensions to process (e.g., ['.mkv', '.mp4']). + Defaults to all common formats. + fallback_duration_minutes_per_gb: Minutes of video per GB when MediaInfo + is unavailable. Defaults to 45 (configurable for 4K or low-bitrate content). + """ self.folder_path = Path(folder_path) + self.video_extensions = self._resolve_extensions(video_extensions) + self.fallback_ratio = self._resolve_fallback_ratio(fallback_duration_minutes_per_gb) self.video_files = self._get_video_files() self.file_info = self._analyze_files() - + + def _resolve_extensions(self, extensions: List[str] = None) -> set: + """Resolve the set of video extensions to scan.""" + if extensions: + return {e.lower() if e.startswith('.') else f'.{e.lower()}' for e in extensions} + env_ext = os.environ.get('VIDEO_EXTENSIONS', '') + if env_ext: + return {e.strip().lower() for e in env_ext.split(',') if e.strip()} + return DEFAULT_VIDEO_EXTENSIONS + + def _resolve_fallback_ratio(self, ratio: float = None) -> float: + """Resolve the fallback minutes/GB ratio for duration estimation.""" + if ratio is not None: + return float(ratio) + env_ratio = os.environ.get('FALLBACK_DURATION_RATIO', '') + if env_ratio: + try: + return float(env_ratio) + except ValueError: + pass + if config_manager: + return config_manager.get_default_episode_duration() + return 45.0 + def _get_video_files(self) -> List[Path]: - """Get all MKV files in the folder, excluding system files.""" + """Get all supported video files in the folder, excluding system files.""" if not self.folder_path.exists(): raise FileNotFoundError(f"Folder not found: {self.folder_path}") - - all_mkv_files = list(self.folder_path.glob("*.mkv")) - - # Filter out macOS resource fork files and other system files + video_files = [] - for file in all_mkv_files: + for ext in self.video_extensions: + video_files.extend(self.folder_path.glob(f'*{ext}')) + + # Filter out macOS resource fork files and other system files + filtered = [] + for file in video_files: if file.name.startswith('._'): - continue # Skip macOS resource fork files + continue if file.name.startswith('.'): - continue # Skip any hidden files - video_files.append(file) - - if not video_files: - print(f"Warning: No valid MKV files found in {self.folder_path}") - - return video_files + continue + filtered.append(file) + + # Deduplicate (in case overlapping extensions) + seen = set() + unique = [] + for f in filtered: + if f not in seen: + seen.add(f) + unique.append(f) + + if not unique: + ext_list = ', '.join(sorted(self.video_extensions)) + print(f"Warning: No valid video files found in {self.folder_path} (scanned: {ext_list})") + + return unique def _get_file_size(self, file_path: Path) -> int: """Get file size in bytes.""" return file_path.stat().st_size def _get_video_duration(self, file_path: Path) -> float: - """Get video duration in minutes using MediaInfo.""" + """Get video duration in minutes using MediaInfo, with configurable fallback.""" if MediaInfo is None: - print("MediaInfo not available, using file size as proxy for duration") - # Rough estimate: 1GB ≈ 45 minutes for typical video + print(f"MediaInfo not available, using file size as proxy for duration " + f"(ratio: {self.fallback_ratio:.1f} min/GB)") size_gb = self._get_file_size(file_path) / (1024**3) - return size_gb * 45 - + return size_gb * self.fallback_ratio + try: media_info = MediaInfo.parse(str(file_path)) for track in media_info.tracks: if track.track_type == 'Video': duration_ms = track.duration if duration_ms: - # Handle both string and numeric duration values if isinstance(duration_ms, str): try: duration_ms = float(duration_ms) except ValueError: print(f"Warning: Could not parse duration '{duration_ms}' for {file_path}") continue - + duration_minutes = float(duration_ms) / (1000 * 60) - - # Sanity check: if duration seems unreasonable, fall back to size estimation - if duration_minutes > 300: # More than 5 hours is likely wrong - print(f"Warning: Suspicious duration ({duration_minutes:.1f} min) for {file_path.name}, using size estimation") + + # Sanity check: fall back to size estimation for suspicious durations + if duration_minutes > 300: + print(f"Warning: Suspicious duration ({duration_minutes:.1f} min) for {file_path.name}, " + f"using size estimation ({self.fallback_ratio:.1f} min/GB)") size_gb = self._get_file_size(file_path) / (1024**3) - return size_gb * 45 - + return size_gb * self.fallback_ratio + return duration_minutes except Exception as e: print(f"Error getting duration for {file_path}: {e}") - - # Fallback to size-based estimation + size_gb = self._get_file_size(file_path) / (1024**3) - return size_gb * 45 + return size_gb * self.fallback_ratio def _analyze_files(self) -> List[Dict]: """Analyze all video files to get size and duration info."""