diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20581e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.pyc +*.mkv + +src/.tvdb_cache/* \ No newline at end of file diff --git a/episode_matcher.py b/episode_matcher.py index fa0a90b..8e4faef 100755 --- a/episode_matcher.py +++ b/episode_matcher.py @@ -7,13 +7,15 @@ This tool takes a folder of MKV files and: 2. Moves extras to an "extras" subfolder 3. Renames episodes using Jellyfin naming convention 4. Uses TVDB data to validate episode matching +5. Detects and handles duplicate episodes Usage: - python episode_matcher.py [--api-key ] + python episode_matcher.py [options] Examples: python episode_matcher.py "/path/to/episodes" "Game of Thrones" 1 - python episode_matcher.py "/path/to/episodes" "Breaking Bad" 2 --api-key your_tvdb_api_key + python episode_matcher.py "/path/to/episodes" "Game of Thrones" 2 --disc-mapping "1:1-3,2:4-6,3:7-9,4:10" + python episode_matcher.py "/path/to/episodes" "Breaking Bad" 2 --auto-delete-duplicates --dry-run """ import argparse @@ -30,6 +32,41 @@ from Matcher import FileClassifier, EpisodeRenamer from config import config_manager +def parse_disc_mapping(mapping_str): + """Parse disc mapping string like '1:1-3,2:4-6,3:7-9,4:10' into a dict.""" + mapping = {} + + if not mapping_str: + return mapping + + try: + # Split by comma to get each disc mapping + disc_parts = mapping_str.split(',') + + for part in disc_parts: + # Split by colon to get disc:episodes + disc_str, episodes_str = part.strip().split(':') + disc_num = int(disc_str) + + # Parse episode range + if '-' in episodes_str: + # Range like "1-3" + start, end = episodes_str.split('-') + episodes = list(range(int(start), int(end) + 1)) + else: + # Single episode like "10" + episodes = [int(episodes_str)] + + mapping[disc_num] = episodes + + return mapping + + except Exception as e: + print(f"Error parsing disc mapping '{mapping_str}': {e}") + print("Expected format: '1:1-3,2:4-6,3:7-9,4:10'") + sys.exit(1) + + def main(): """Main function to run the episode matcher.""" parser = argparse.ArgumentParser( @@ -71,6 +108,17 @@ def main(): help="Show detailed analysis information" ) + parser.add_argument( + "--disc-mapping", + help="Disc to episode mapping (e.g., '1:1-3,2:4-6,3:7-9,4:10')" + ) + + parser.add_argument( + "--auto-delete-duplicates", + action="store_true", + help="Automatically delete duplicate files instead of moving them to 'delete me' folder" + ) + args = parser.parse_args() # Validate inputs @@ -164,7 +212,17 @@ def main(): print(f" - {ex['name']} ({ex['size_gb']:.2f} GB, {ex['duration_minutes']:.1f} min)") # Initialize renamer - renamer = EpisodeRenamer(str(folder_path), args.show_name, args.season_number) + disc_mapping = None + if args.disc_mapping: + print(f"Using disc mapping: {args.disc_mapping}") + disc_mapping = parse_disc_mapping(args.disc_mapping) + if args.verbose: + print("Parsed disc mapping:") + for disc, episodes in disc_mapping.items(): + print(f" Disc {disc}: Episodes {episodes}") + + renamer = EpisodeRenamer(str(folder_path), args.show_name, args.season_number, + disc_mapping=disc_mapping, auto_delete_duplicates=args.auto_delete_duplicates) if args.dry_run: print(f"\n=== DRY RUN - No files will be modified ===") @@ -175,6 +233,20 @@ def main(): print(f" - {extra['name']}") print(f"\nWould rename {len(episodes)} episode files:") + if args.verbose: + print("Episodes to be renamed:") + for i, ep in enumerate(episodes): + print(f" {i+1}: {ep} (type: {type(ep)})") + + # Handle the case where episodes is incorrectly formatted + if episodes and not isinstance(episodes[0], dict): + print("Warning: Episodes data is in wrong format, attempting to reconstruct...") + # This is a temporary workaround - we need to get the actual episode data + # from the classifier again + all_files = classifier.file_info + episode_files = [f for f in all_files if f not in extras] + episodes = episode_files[:len(tvdb_episodes)] if tvdb_episodes else episode_files + # Simulate the matching process if tvdb_episodes: matched_episodes = renamer._match_episodes_by_duration(episodes, tvdb_episodes) diff --git a/src/Matcher/__pycache__/episode_renamer.cpython-313.pyc b/src/Matcher/__pycache__/episode_renamer.cpython-313.pyc index 5bc4fcd..92f7a2a 100644 Binary files a/src/Matcher/__pycache__/episode_renamer.cpython-313.pyc and b/src/Matcher/__pycache__/episode_renamer.cpython-313.pyc differ diff --git a/src/Matcher/__pycache__/file_classifier.cpython-313.pyc b/src/Matcher/__pycache__/file_classifier.cpython-313.pyc index 6f0b7d2..07f4845 100644 Binary files a/src/Matcher/__pycache__/file_classifier.cpython-313.pyc and b/src/Matcher/__pycache__/file_classifier.cpython-313.pyc differ diff --git a/src/Matcher/episode_renamer.py b/src/Matcher/episode_renamer.py index 46baed2..618db74 100644 --- a/src/Matcher/episode_renamer.py +++ b/src/Matcher/episode_renamer.py @@ -9,12 +9,16 @@ import re class EpisodeRenamer: """Renames episode files to Jellyfin format and moves extras to subfolder.""" - def __init__(self, folder_path: str, show_name: str, season_number: int): - """Initialize with folder path, show name, and season number.""" + def __init__(self, folder_path: str, show_name: str, season_number: int, + disc_mapping: Optional[Dict[int, List[int]]] = None, auto_delete_duplicates: bool = False): + """Initialize with folder path, show name, season number, optional disc mapping, and auto-delete option.""" self.folder_path = Path(folder_path) self.show_name = show_name self.season_number = season_number + self.disc_mapping = disc_mapping # {disc_num: [episode_numbers]} + self.auto_delete_duplicates = auto_delete_duplicates self.extras_folder = self.folder_path / "extras" + self.delete_folder = self.folder_path / "delete me" def _create_extras_folder(self) -> bool: """Create extras folder if it doesn't exist.""" @@ -25,6 +29,15 @@ class EpisodeRenamer: print(f"Error creating extras folder: {e}") return False + def _create_delete_folder(self) -> bool: + """Create delete me folder if it doesn't exist.""" + try: + self.delete_folder.mkdir(exist_ok=True) + return True + except Exception as e: + print(f"Error creating delete me folder: {e}") + return False + def _sanitize_filename(self, filename: str) -> str: """Sanitize filename to remove invalid characters.""" # Remove invalid characters for file systems @@ -72,16 +85,110 @@ class EpisodeRenamer: return moved_files + def detect_and_move_duplicates(self, episodes: List[Dict]) -> List[str]: + """Detect duplicate episodes and either delete them or move them to delete me folder.""" + processed_files = [] + duplicates_found = [] + + # Group episodes by duration (within 1 minute tolerance) + duration_groups = {} + for episode in episodes: + duration = episode['duration_minutes'] + + # Find existing group within 1 minute tolerance + matching_group = None + for group_duration in duration_groups.keys(): + if abs(duration - group_duration) <= 0.5: # Tighter tolerance for exact duplicates + matching_group = group_duration + break + + if matching_group: + duration_groups[matching_group].append(episode) + else: + duration_groups[duration] = [episode] + + # Identify duplicates (groups with more than one episode) + for duration, group_episodes in duration_groups.items(): + if len(group_episodes) > 1: + print(f"\nFound {len(group_episodes)} potential duplicates with ~{duration:.1f}min duration:") + + # Sort by disc number (keep lower disc numbers) then file size (largest first) for consistent ordering + group_episodes.sort(key=lambda x: ( + x.get('disc_number', 999) if 'disc_number' in x else 999, # Lower disc numbers first + -x['size_gb'], # Then largest files + x['path'].name # Finally by filename for consistency + )) + + # Keep the first file (from earliest disc, largest size), mark others as duplicates + keeper = group_episodes[0] + duplicates = group_episodes[1:] + + disc_info = "" + if hasattr(keeper['path'], 'name'): + # Try to extract disc info from filename for display + disc_match = re.search(r'Disc\s*(\d+)', keeper['path'].name, re.IGNORECASE) + if disc_match: + disc_info = f" (Disc {disc_match.group(1)})" + + print(f" Keeping: {keeper['path'].name}{disc_info} ({keeper['size_gb']:.2f}GB)") + + for duplicate in duplicates: + dup_disc_info = "" + if hasattr(duplicate['path'], 'name'): + disc_match = re.search(r'Disc\s*(\d+)', duplicate['path'].name, re.IGNORECASE) + if disc_match: + dup_disc_info = f" (Disc {disc_match.group(1)})" + + print(f" Duplicate: {duplicate['path'].name}{dup_disc_info} ({duplicate['size_gb']:.2f}GB)") + duplicates_found.append(duplicate) + + # Handle duplicates based on auto_delete_duplicates flag + if self.auto_delete_duplicates: + # Delete duplicates directly + for duplicate in duplicates_found: + source_path = duplicate['path'] + + try: + source_path.unlink() # Delete the file + processed_files.append(str(source_path)) + print(f"Deleted duplicate: {source_path.name}") + + except Exception as e: + print(f"Error deleting {source_path.name}: {e}") + else: + # Move duplicates to delete me folder (original behavior) + if not self._create_delete_folder(): + return [] + + for duplicate in duplicates_found: + source_path = duplicate['path'] + dest_path = self.delete_folder / source_path.name + + try: + # Check if destination already exists + if dest_path.exists(): + print(f"Warning: {dest_path.name} already exists in delete me folder, skipping.") + continue + + shutil.move(str(source_path), str(dest_path)) + processed_files.append(str(dest_path)) + print(f"Moved to delete me: {source_path.name}") + + except Exception as e: + print(f"Error moving {source_path.name} to delete me: {e}") + + return processed_files + def _extract_disc_info(self, filename: str) -> Dict: """Extract disc number and track number from filename.""" - # Look for patterns like "Disc 1", "Disc1", "D1", etc. - disc_match = re.search(r'[Dd]isc\s*(\d+)', filename, re.IGNORECASE) + # Look for patterns like "Disc 1", "Disk 1", "Disc1", "disc-1", "DISC 1_t03" + disc_match = re.search(r'(?i)\bdis[ck][\s._-]*(\d{1,3})(?!\d)', filename) disc_number = int(disc_match.group(1)) if disc_match else None - - # Look for track patterns like "_t01", "_t22", "track01", etc. - track_match = re.search(r'(?:_t|track)(\d+)', filename, re.IGNORECASE) + + # Look for track patterns like "_t01", "_t22", "track01", allow small separators + track_match = re.search(r'(?i)(?:_t|track)[\s._-]?(\d+)', filename) track_number = int(track_match.group(1)) if track_match else None - + return { 'disc_number': disc_number, 'track_number': track_number, @@ -89,7 +196,7 @@ class EpisodeRenamer: } def _estimate_episodes_per_disc(self, episodes_info: List[Dict], total_episodes: int) -> Dict[int, List[int]]: - """Estimate which episodes are on which disc based on file distribution.""" + """Estimate which episodes are on which disc based on file distribution and storage constraints.""" # Group files by disc and sort within each disc discs = {} for info in episodes_info: @@ -110,19 +217,87 @@ class EpisodeRenamer: sorted_disc_nums = sorted(discs.keys()) episodes_per_disc = {} - # Assign episodes sequentially across discs + # Calculate storage-aware episode distribution episode_number = 1 for disc_num in sorted_disc_nums: files_on_disc = discs[disc_num] episodes_on_this_disc = [] - for _ in files_on_disc: + # Calculate total storage used by episodes on this disc + total_disc_size_gb = sum(info['file_info']['size_gb'] for info in files_on_disc) + + # Estimate Blu-ray capacity constraints + # Standard Blu-ray: ~45GB usable (48GB total - overhead) + # Account for extras that might also be on the disc + estimated_episode_capacity_gb = 45.0 # Conservative estimate + + # If this disc is near capacity, reduce episode count to be conservative + if total_disc_size_gb > 40.0: # Stricter threshold: 40GB + # This disc is likely at capacity limits + # Reduce episode count by 1 if we're close to limits + available_episodes = max(1, len(files_on_disc) - 1) + available_episodes = min(available_episodes, total_episodes - episode_number + 1) + print(f" Warning: Disc {disc_num} near capacity ({total_disc_size_gb:.1f}GB), reducing episode allocation") + elif total_disc_size_gb > 35.0: # Warning threshold: 35GB + # Show warning but don't reduce count yet + available_episodes = min(len(files_on_disc), total_episodes - episode_number + 1) + print(f" Warning: Disc {disc_num} approaching capacity ({total_disc_size_gb:.1f}GB), may have storage constraints") + else: + available_episodes = min(len(files_on_disc), total_episodes - episode_number + 1) + + for _ in range(available_episodes): if episode_number <= total_episodes: episodes_on_this_disc.append(episode_number) episode_number += 1 episodes_per_disc[disc_num] = episodes_on_this_disc + # Diagnostics: print estimated mapping per disc with storage info + print("\nEstimated episodes-per-disc mapping (considering storage constraints):") + total_assigned = 0 + for d in sorted(episodes_per_disc.keys()): + files_on_disc = [f for f in episodes_info if f['disc_number'] == d] + total_size = sum(f['file_info']['size_gb'] for f in files_on_disc) + print(f" Disc {d}: Episodes {episodes_per_disc[d]} ({len(files_on_disc)} files, {total_size:.1f}GB)") + total_assigned += len(episodes_per_disc[d]) + + # Flag potential storage issues + if len(files_on_disc) > len(episodes_per_disc[d]): + extra_files = len(files_on_disc) - len(episodes_per_disc[d]) + print(f" ⚠ Warning: {extra_files} extra files detected - may indicate storage constraints") + + # Ensure we haven't lost any episodes due to capacity constraints + if total_assigned < total_episodes: + missing_episodes = total_episodes - total_assigned + print(f" ⚠ Warning: {missing_episodes} episodes unassigned due to capacity constraints") + + # Try to assign remaining episodes to discs with available space + remaining_episodes = [] + for ep in range(1, total_episodes + 1): + assigned = False + for disc_episodes in episodes_per_disc.values(): + if ep in disc_episodes: + assigned = True + break + if not assigned: + remaining_episodes.append(ep) + + # Find discs with the most available space to assign remaining episodes + if remaining_episodes: + disc_space = {} + for d in sorted(episodes_per_disc.keys()): + files_on_disc = [f for f in episodes_info if f['disc_number'] == d] + total_size = sum(f['file_info']['size_gb'] for f in files_on_disc) + available_space = 45.0 - total_size # Remaining Blu-ray capacity + disc_space[d] = available_space + + # Assign remaining episodes to discs with most space + for ep in remaining_episodes: + best_disc = max(disc_space.keys(), key=lambda d: disc_space[d]) + episodes_per_disc[best_disc].append(ep) + disc_space[best_disc] -= 10.0 # Rough estimate: reduce available space + print(f" ↻ Reassigning Episode {ep} to Disc {best_disc} (most available space)") + return episodes_per_disc def rename_episodes(self, episodes: List[Dict], tvdb_episodes: Optional[List[Dict]] = None) -> List[Dict]: @@ -199,37 +374,136 @@ class EpisodeRenamer: return renamed_files def _match_episodes_by_duration(self, episodes: List[Dict], tvdb_episodes: List[Dict]) -> List[Dict]: - """Match episode files with TVDB episode data based on duration and lexicographical order.""" + """Match episode files with TVDB episode data. Use disc mapping if provided, otherwise use duration matching.""" matched_episodes = [] # Extract disc and track information episodes_info = [] for episode in episodes: - filename = episode['path'].name + # Handle both path objects and filename strings + if isinstance(episode, dict) and 'path' in episode: + filename = episode['path'].name + elif isinstance(episode, dict) and 'name' in episode: + filename = episode['name'] + else: + print(f"Warning: Unexpected episode format: {episode}") + continue + disc_info = self._extract_disc_info(filename) episodes_info.append({ 'file_info': episode, 'disc_number': disc_info['disc_number'], 'track_number': disc_info['track_number'], 'filename': filename, - 'duration': episode['duration_minutes'] + 'duration': episode.get('duration_minutes', 0) }) # Sort by lexicographical order (this should match episode order) episodes_info.sort(key=lambda x: x['filename']) - # If we have disc information, use it for context but prioritize duration matching + # Detect and handle duplicates before processing + print("\n=== Duplicate Detection ===") + episode_files = [info['file_info'] for info in episodes_info] + duplicate_files = self.detect_and_move_duplicates(episode_files) + + # Remove duplicates from episodes_info + if duplicate_files: + duplicate_names = {Path(f).name for f in duplicate_files} + episodes_info = [info for info in episodes_info + if info['filename'] not in duplicate_names] + print(f"Removed {len(duplicate_files)} duplicates from episode processing") + + # Use disc mapping if provided + if self.disc_mapping: + print("\n=== Using Disc Mapping ===") + matched_episodes = self._match_using_disc_mapping(episodes_info, tvdb_episodes) + else: + # Original complex matching logic for when no disc mapping is provided + print("\n=== Using Duration/Disc Analysis ===") + matched_episodes = self._match_using_duration_analysis(episodes_info, tvdb_episodes) + + # Sort by episode number for final output + matched_episodes.sort(key=lambda x: x['episode_number']) + return matched_episodes + + def _match_using_disc_mapping(self, episodes_info: List[Dict], tvdb_episodes: List[Dict]) -> List[Dict]: + """Match episodes using the provided disc mapping.""" + matched_episodes = [] + + # Group files by disc + disc_groups = {} + for info in episodes_info: + disc_num = info['disc_number'] + if disc_num is not None: + disc_groups.setdefault(disc_num, []).append(info) + + print(f"Files found on {len(disc_groups)} discs") + for disc_num in sorted(disc_groups.keys()): + files = disc_groups[disc_num] + expected_episodes = self.disc_mapping.get(disc_num, []) + print(f"Disc {disc_num}: {len(files)} files, expecting episodes {expected_episodes}") + + # Sort files on this disc by filename + files.sort(key=lambda x: x['filename']) + + # Match files to episodes in order + for i, file_info in enumerate(files): + if i < len(expected_episodes): + episode_number = expected_episodes[i] + + # Find the TVDB episode + tvdb_episode = None + for ep in tvdb_episodes: + if ep['episode_number'] == episode_number: + tvdb_episode = ep + break + + matched_episodes.append({ + 'file_info': file_info['file_info'], + 'episode_number': episode_number, + 'tvdb_info': tvdb_episode + }) + + print(f" Episode {episode_number}: {file_info['filename']}") + else: + print(f" Warning: Extra file on disc {disc_num}: {file_info['filename']}") + + return matched_episodes + + def _match_using_duration_analysis(self, episodes_info: List[Dict], tvdb_episodes: List[Dict]) -> List[Dict]: + """Use the original complex duration/disc analysis matching.""" + # Diagnostics: overview of discs/files and durations + try: + disc_groups = {} + for info in episodes_info: + disc_groups.setdefault(info['disc_number'], []).append(info) + print("=== Disc/File Overview ===") + for disc_key in sorted([d for d in disc_groups.keys() if d is not None]): + files = sorted(disc_groups[disc_key], key=lambda x: x['filename']) + print(f"Disc {disc_key}: {len(files)} candidate file(s)") + for f in files: + try: + print(f" - {f['filename']} ({f['duration']:.1f} min)") + except Exception: + pass + except Exception: + pass + + # If we have disc information, enforce disc→episode ordering so early discs map to earlier episodes if any(info['disc_number'] for info in episodes_info): - print("Found disc information - using flexible duration-first matching...") - # Use flexible matching that prioritizes duration over disc constraints - matched_episodes = self._flexible_duration_match(episodes_info, tvdb_episodes) + total_eps = len(tvdb_episodes) + episodes_per_disc = self._estimate_episodes_per_disc(episodes_info, total_eps) + if episodes_per_disc: + print("Found disc information - enforcing disc-to-episode order constraints...") + matched_episodes = self._match_by_duration_and_disc(episodes_info, tvdb_episodes, episodes_per_disc) + else: + print("Found disc information - using flexible duration-first matching...") + matched_episodes = self._flexible_duration_match(episodes_info, tvdb_episodes) else: # No disc info, use lexicographical order with precise duration matching print("No disc information found, using lexicographical order with precise duration matching...") matched_episodes = self._match_by_duration_and_order(episodes_info, tvdb_episodes) - # Sort by episode number for final output - matched_episodes.sort(key=lambda x: x['episode_number']) return matched_episodes def _flexible_duration_match(self, episodes_info: List[Dict], tvdb_episodes: List[Dict]) -> List[Dict]: @@ -492,7 +766,7 @@ class EpisodeRenamer: return True def _analyze_disc_capacity(self, episodes_info: List[Dict]) -> Dict[int, int]: - """Analyze the maximum number of episodes each disc can hold.""" + """Analyze the maximum number of episodes each disc can hold based on storage constraints.""" disc_capacity = {} # Group files by disc @@ -504,9 +778,30 @@ class EpisodeRenamer: disc_files[disc_num] = [] disc_files[disc_num].append(info) - # Each disc can hold at most as many episodes as it has files + # Analyze each disc's capacity considering storage limits for disc_num, files in disc_files.items(): - disc_capacity[disc_num] = len(files) + file_count = len(files) + total_size_gb = sum(f['file_info']['size_gb'] for f in files) + + # Blu-ray capacity constraints + max_bluray_capacity_gb = 48.0 + usable_capacity_gb = 45.0 # Account for overhead and extras + + if total_size_gb > usable_capacity_gb * 0.8: # 80% threshold + # This disc is likely near capacity + # Estimate how many episodes can actually fit based on average episode size + avg_episode_size = total_size_gb / file_count if file_count > 0 else 10.0 + estimated_episodes = min(file_count, int(usable_capacity_gb / avg_episode_size)) + + # Be conservative - if we're unsure, use the smaller number + disc_capacity[disc_num] = min(estimated_episodes, file_count) + + print(f" Storage constraint analysis for Disc {disc_num}:") + print(f" Total files: {file_count}, Total size: {total_size_gb:.1f}GB") + print(f" Estimated episode capacity: {estimated_episodes} (limited by {usable_capacity_gb}GB Blu-ray)") + else: + # Disc has plenty of space, use file count + disc_capacity[disc_num] = file_count return disc_capacity @@ -553,11 +848,14 @@ class EpisodeRenamer: def _match_by_duration_and_disc(self, episodes_info: List[Dict], tvdb_episodes: List[Dict], episodes_per_disc: Dict[int, List[int]]) -> List[Dict]: """Match episodes using disc constraints and precise duration matching.""" matched_episodes = [] - used_episode_numbers = set() # Process each disc in order + used_episode_numbers = set() + + # Process each disc in order for disc_num in sorted(episodes_per_disc.keys()): files_on_disc = [f for f in episodes_info if f['disc_number'] == disc_num] files_on_disc.sort(key=lambda x: x['filename']) allowed_episodes = episodes_per_disc[disc_num] + print(f"\nDisc {disc_num}: allowed episode numbers {allowed_episodes}") # Get TVDB episodes for this disc disc_tvdb_episodes = [ep for ep in tvdb_episodes if ep['episode_number'] in allowed_episodes] @@ -609,6 +907,7 @@ class EpisodeRenamer: file_duration = file_info['duration'] best_match = None best_score = float('inf') + diffs = [] # Diagnostics: collect per-episode duration differences for tvdb_ep in tvdb_episodes: if tvdb_ep['episode_number'] in used_episodes: @@ -622,6 +921,7 @@ class EpisodeRenamer: # Calculate duration difference duration_diff = abs(file_duration - tvdb_duration) + diffs.append((tvdb_ep['episode_number'], tvdb_duration, duration_diff)) if duration_diff < best_score: best_score = duration_diff @@ -637,6 +937,15 @@ class EpisodeRenamer: }) used_files.add(id(file_info)) used_episodes.add(best_match['episode_number']) + + # Diagnostics: print top few closest episodes by duration within allowed set + if diffs: + try: + diffs.sort(key=lambda x: x[2]) + top = ", ".join([f"E{ep}:{dur} (Δ{diff:.1f})" for ep, dur, diff in diffs[:3]]) + print(f" {file_info['filename']}: compare within {allowed_episodes} → {top}") + except Exception: + pass # Second pass: Handle remaining files with sequential assignment remaining_files = [f for f in files if id(f) not in used_files] diff --git a/src/Matcher/file_classifier.py b/src/Matcher/file_classifier.py index a96bf57..d454b3d 100644 --- a/src/Matcher/file_classifier.py +++ b/src/Matcher/file_classifier.py @@ -144,91 +144,144 @@ class FileClassifier: # If we have TVDB episode data, use it for smarter classification if tvdb_episodes and len(tvdb_episodes) > 0: return self._classify_with_tvdb_data(tvdb_episodes, expected_episode_count) - + # Fallback to size/duration-based classification return self._classify_by_stats(expected_episode_count) - + def _classify_with_tvdb_data(self, tvdb_episodes: List[Dict], expected_episode_count: int = None) -> Tuple[List[Dict], List[Dict]]: - """Classify files using TVDB episode duration data for better matching.""" + """TVDB-informed classification with disc-aware capacity and fair distribution.""" if not tvdb_episodes: return self._classify_by_stats(expected_episode_count) - + # Get TVDB episode durations tvdb_durations = [ep.get('runtime', 0) for ep in tvdb_episodes if ep.get('runtime', 0) > 0] if not tvdb_durations: return self._classify_by_stats(expected_episode_count) - + avg_tvdb_duration = sum(tvdb_durations) / len(tvdb_durations) min_episode_duration = min(tvdb_durations) - 5 # 5 minute tolerance below minimum max_episode_duration = max(tvdb_durations) + 10 # 10 minute tolerance above maximum - + print(f"TVDB Episode duration range: {min_episode_duration:.1f} - {max_episode_duration:.1f} min (avg: {avg_tvdb_duration:.1f})") - - episodes = [] - extras = [] - - # Sort files by size (largest first) for consistent processing - sorted_files = sorted(self.file_info, key=lambda x: x['size_bytes'], reverse=True) - - for file_info in sorted_files: + + # Step 1: Build candidate pool limited by duration range + import re + disc_regex = re.compile(r'(?i)\bdis[ck][\s._-]*(\d{1,3})(?!\d)') + + candidates_by_disc = {} + non_candidate_extras = [] + + for file_info in sorted(self.file_info, key=lambda x: x['size_bytes'], reverse=True): duration = file_info['duration_minutes'] - is_extra = False - - # First check: Duration-based classification with tighter tolerances if duration < min_episode_duration or duration > max_episode_duration: - is_extra = True + non_candidate_extras.append(file_info) print(f" Duration-based extra: {file_info['name']} ({duration:.1f} min) - outside {min_episode_duration:.1f}-{max_episode_duration:.1f} range") - - # Second check: If we have expected episode count, limit episodes (but be disc-aware) - elif expected_episode_count and len(episodes) >= expected_episode_count: - # Extract disc number for disc-aware logic - import re - disc_match = re.search(r'[Dd]isc\s*(\d+)', file_info['name'], re.IGNORECASE) - file_disc = int(disc_match.group(1)) if disc_match else None - - # Check how many files from each disc we already have as episodes - disc_episode_counts = {} - for ep in episodes: - ep_disc_match = re.search(r'[Dd]isc\s*(\d+)', ep['name'], re.IGNORECASE) - ep_disc = int(ep_disc_match.group(1)) if ep_disc_match else 0 - disc_episode_counts[ep_disc] = disc_episode_counts.get(ep_disc, 0) + 1 - - current_disc_episodes = disc_episode_counts.get(file_disc, 0) - - # Special logic: ensure we have enough episodes from Disc 4 for episodes 9 & 10 - # Check if we need more Disc 4 episodes and this could be Episode 10 - if file_disc == 4 and current_disc_episodes < 2: - print(f" Disc 4 episode preserved: {file_info['name']} - ensuring final disc has episodes 9-10") - is_extra = False - else: - is_extra = True - print(f" Count-based extra: {file_info['name']} (episode limit reached)") - - # Third check: Look for very close duration matches to confirm episodes - elif expected_episode_count: - # Find closest TVDB episode duration - closest_duration = min(tvdb_durations, key=lambda x: abs(x - duration)) - duration_diff = abs(duration - closest_duration) - - if duration_diff <= 10: # Within 10 minutes is likely an episode - print(f" Duration-matched episode: {file_info['name']} ({duration:.1f} min) ≈ TVDB {closest_duration} min (Δ{duration_diff:.1f})") - else: - # Check if this could still be an episode based on count - if len(episodes) < expected_episode_count: - print(f" Episode by count: {file_info['name']} ({duration:.1f} min) - no close TVDB match but within count") - else: - is_extra = True - print(f" Duration mismatch extra: {file_info['name']} ({duration:.1f} min) - closest TVDB: {closest_duration} min (Δ{duration_diff:.1f})") - - if is_extra: - extras.append(file_info) - else: - episodes.append(file_info) - - # Sort episodes by name for consistent ordering - episodes.sort(key=lambda x: x['name']) - - return episodes, extras + continue + + m = disc_regex.search(file_info['name']) + disc_num = int(m.group(1)) if m else None + candidates_by_disc.setdefault(disc_num, []).append(file_info) + + # If no expected count, fallback to previous simple path using candidates as episodes + if not expected_episode_count: + episodes = [f for disc_files in candidates_by_disc.values() for f in disc_files] + episodes.sort(key=lambda x: x['name']) + extras = non_candidate_extras + return episodes, extras + + # Step 2: Derive per-disc capacity cap from average runtime (no show-specific hardcoding) + MAX_MINUTES_PER_DISC = 240 # Conservative, configurable heuristic + capacity_per_disc = max(1, int(MAX_MINUTES_PER_DISC / avg_tvdb_duration)) + + # Step 3: Fair-share distribution across discs with candidates + discs_in_play = sorted([d for d in candidates_by_disc.keys() if d is not None]) + if not discs_in_play and None in candidates_by_disc: + # No disc labels; treat as a single disc + discs_in_play = [None] + + n_discs = len(discs_in_play) + base = expected_episode_count // n_discs if n_discs else expected_episode_count + rem = expected_episode_count % n_discs if n_discs else 0 + + # Initial caps per disc: fair share limited by capacity and available candidates + caps = {} + allocated = 0 + for idx, d in enumerate(discs_in_play): + fair_cap = base + (1 if idx < rem else 0) + avail = len(candidates_by_disc.get(d, [])) + cap = min(fair_cap, capacity_per_disc, avail) + caps[d] = cap + allocated += cap + + # Redistribute any remaining quota without exceeding per-disc capacity or availability + remaining = expected_episode_count - allocated + if remaining > 0: + for _ in range(3): + if remaining <= 0: + break + for d in discs_in_play: + if remaining <= 0: + break + avail = len(candidates_by_disc.get(d, [])) + if caps[d] < min(capacity_per_disc, avail): + caps[d] += 1 + remaining -= 1 + + # Step 4: Select episodes per disc obeying caps and avoiding duplicates if over cap + def pick_for_disc(files: List[Dict], cap: int) -> List[Dict]: + if cap <= 0: + return [] + if len(files) <= cap: + return sorted(files, key=lambda x: x['name']) + + buckets = {} + for f in files: + key = round(f['duration_minutes'] * 2) / 2.0 + buckets.setdefault(key, []).append(f) + + for k in buckets: + buckets[k].sort(key=lambda x: (-x['size_bytes'], x['name'])) + + selected = [] + bucket_keys = sorted(buckets.keys()) + idx = 0 + while len(selected) < cap and bucket_keys: + key = bucket_keys[idx % len(bucket_keys)] + if buckets[key]: + selected.append(buckets[key].pop(0)) + bucket_keys = [k for k in bucket_keys if buckets[k]] + idx += 1 + + if len(selected) < cap: + remaining_files = [] + for arr in buckets.values(): + remaining_files.extend(arr) + remaining_files.sort(key=lambda x: (-x['size_bytes'], x['name'])) + for f in remaining_files: + if len(selected) >= cap: + break + selected.append(f) + + return sorted(selected, key=lambda x: x['name']) + + selected_episodes = [] + for d in discs_in_play: + disc_files = candidates_by_disc.get(d, []) + chosen = pick_for_disc(disc_files, caps.get(d, 0)) + for f in chosen: + closest = min(tvdb_durations, key=lambda x: abs(x - f['duration_minutes'])) + print(f" Duration-matched episode: {f['name']} ({f['duration_minutes']:.1f} min) ≈ TVDB {closest} min (Δ{abs(f['duration_minutes']-closest):.1f})") + selected_episodes.extend(chosen) + + selected_set = {id(f) for f in selected_episodes} + extras = non_candidate_extras[:] + for files in candidates_by_disc.values(): + for f in files: + if id(f) not in selected_set: + extras.append(f) + + selected_episodes.sort(key=lambda x: x['name']) + return selected_episodes, extras def _classify_by_stats(self, expected_episode_count: int = None) -> Tuple[List[Dict], List[Dict]]: """Fallback classification using statistical analysis of file sizes and durations.""" diff --git a/src/TVDBProvider/__pycache__/tvdb_cache.cpython-313.pyc b/src/TVDBProvider/__pycache__/tvdb_cache.cpython-313.pyc index db1da05..cb7281b 100644 Binary files a/src/TVDBProvider/__pycache__/tvdb_cache.cpython-313.pyc and b/src/TVDBProvider/__pycache__/tvdb_cache.cpython-313.pyc differ diff --git a/src/TVDBProvider/__pycache__/tvdb_client.cpython-313.pyc b/src/TVDBProvider/__pycache__/tvdb_client.cpython-313.pyc index e44e570..6350c80 100644 Binary files a/src/TVDBProvider/__pycache__/tvdb_client.cpython-313.pyc and b/src/TVDBProvider/__pycache__/tvdb_client.cpython-313.pyc differ diff --git a/test_episode_matching.py b/test_episode_matching.py new file mode 100644 index 0000000..9b87898 --- /dev/null +++ b/test_episode_matching.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +Test script to verify episode matching logic with mock data. +""" +import sys +import os +from pathlib import Path + +# Add src to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from Matcher.episode_renamer import EpisodeRenamer + +def create_mock_episodes(): + """Create mock episode data to test the matching logic.""" + return [ + { + 'path': Path('Disc 1_t01.mkv'), + 'name': 'Disc 1_t01.mkv', + 'size_bytes': 2 * 1024**3, # 2GB + 'size_gb': 2.0, + 'duration_minutes': 61.0, + }, + { + 'path': Path('Disc 1_t02.mkv'), + 'name': 'Disc 1_t02.mkv', + 'size_bytes': 2.1 * 1024**3, # 2.1GB + 'size_gb': 2.1, + 'duration_minutes': 55.0, + }, + { + 'path': Path('Disc 1_t03.mkv'), + 'name': 'Disc 1_t03.mkv', + 'size_bytes': 2.2 * 1024**3, # 2.2GB + 'size_gb': 2.2, + 'duration_minutes': 57.0, + }, + { + 'path': Path('Disc 2_t01.mkv'), + 'name': 'Disc 2_t01.mkv', + 'size_bytes': 2.0 * 1024**3, # 2GB + 'size_gb': 2.0, + 'duration_minutes': 55.0, + }, + { + 'path': Path('Disc 2_t02.mkv'), + 'name': 'Disc 2_t02.mkv', + 'size_bytes': 2.1 * 1024**3, # 2.1GB + 'size_gb': 2.1, + 'duration_minutes': 54.0, + }, + { + 'path': Path('Disc 2_t03.mkv'), + 'name': 'Disc 2_t03.mkv', + 'size_bytes': 1.9 * 1024**3, # 1.9GB + 'size_gb': 1.9, + 'duration_minutes': 52.0, + }, + { + 'path': Path('Disc 3_t01.mkv'), + 'name': 'Disc 3_t01.mkv', + 'size_bytes': 2.2 * 1024**3, # 2.2GB + 'size_gb': 2.2, + 'duration_minutes': 57.0, + }, + { + 'path': Path('Disc 3_t02.mkv'), + 'name': 'Disc 3_t02.mkv', + 'size_bytes': 2.1 * 1024**3, # 2.1GB + 'size_gb': 2.1, + 'duration_minutes': 58.0, + } + ] + +def create_mock_tvdb_episodes(): + """Create mock TVDB episode data.""" + return [ + {'episode_number': 1, 'name': 'Winter Is Coming', 'runtime': 61}, + {'episode_number': 2, 'name': 'The Kingsroad', 'runtime': 55}, + {'episode_number': 3, 'name': 'Lord Snow', 'runtime': 57}, + {'episode_number': 4, 'name': 'Cripples, Bastards, and Broken Things', 'runtime': 55}, + {'episode_number': 5, 'name': 'The Wolf and the Lion', 'runtime': 54}, + {'episode_number': 6, 'name': 'A Golden Crown', 'runtime': 52}, + {'episode_number': 7, 'name': 'You Win or You Die', 'runtime': 57}, + {'episode_number': 8, 'name': 'The Pointy End', 'runtime': 58}, + {'episode_number': 9, 'name': 'Baelor', 'runtime': 56}, + {'episode_number': 10, 'name': 'Fire and Blood', 'runtime': 52}, + ] + +def test_episode_matching(): + """Test the episode matching logic.""" + print("=== Testing Episode Matching Logic ===\n") + + # Create mock data + episodes = create_mock_episodes() + tvdb_episodes = create_mock_tvdb_episodes() + + print(f"Mock episode files ({len(episodes)}):") + for ep in episodes: + print(f" - {ep['name']} ({ep['duration_minutes']} min)") + + print(f"\nTVDB episodes ({len(tvdb_episodes)}):") + for ep in tvdb_episodes: + print(f" - Episode {ep['episode_number']}: {ep['name']} ({ep['runtime']} min)") + + # Initialize renamer + renamer = EpisodeRenamer("/tmp/test", "Game of Thrones", 1) + + # Test the matching logic + print("\n" + "="*80) + print("TESTING EPISODE MATCHING") + print("="*80) + + try: + matched_episodes = renamer._match_episodes_by_duration(episodes, tvdb_episodes) + + print(f"\n=== MATCHING RESULTS ===") + print(f"Successfully matched {len(matched_episodes)} episodes:") + + for match in matched_episodes: + file_name = match['file_info']['path'].name + episode_num = match['episode_number'] + tvdb_name = match['tvdb_info']['name'] if match['tvdb_info'] else 'Unknown' + duration_diff = match.get('duration_diff', 'N/A') + + print(f" {file_name} → Episode {episode_num}: {tvdb_name}") + if duration_diff != 'N/A' and duration_diff is not None: + print(f" Duration difference: {duration_diff:.1f} minutes") + + # Test validity of assignments + print(f"\n=== ASSIGNMENT VALIDATION ===") + episode_numbers = [m['episode_number'] for m in matched_episodes] + if len(set(episode_numbers)) == len(episode_numbers): + print("✓ All episodes assigned unique episode numbers") + else: + print("✗ Duplicate episode number assignments detected!") + duplicates = [x for x in episode_numbers if episode_numbers.count(x) > 1] + print(f" Duplicates: {set(duplicates)}") + + # Check disc order constraint + disc_assignments = {} + for match in matched_episodes: + file_name = match['file_info']['path'].name + episode_num = match['episode_number'] + + # Extract disc number + if 'Disc 1' in file_name: + disc = 1 + elif 'Disc 2' in file_name: + disc = 2 + elif 'Disc 3' in file_name: + disc = 3 + else: + disc = None + + if disc: + if disc not in disc_assignments: + disc_assignments[disc] = [] + disc_assignments[disc].append(episode_num) + + print(f"\nDisc assignment validation:") + for disc in sorted(disc_assignments.keys()): + episodes = sorted(disc_assignments[disc]) + print(f" Disc {disc}: Episodes {episodes}") + + # Check if episodes are in reasonable order (earlier discs should have earlier episodes) + if disc == 1: + expected_range = list(range(1, 4)) # Episodes 1-3 + elif disc == 2: + expected_range = list(range(4, 7)) # Episodes 4-6 + elif disc == 3: + expected_range = list(range(7, 9)) # Episodes 7-8 + + if set(episodes).issubset(set(expected_range)): + print(f" ✓ Episodes {episodes} are within expected range {expected_range}") + else: + print(f" ⚠ Episodes {episodes} outside expected range {expected_range}") + + except Exception as e: + print(f"Error during matching: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + test_episode_matching()