Updated Episode Matcher to take hints about episodes on disc and delete flags

This commit is contained in:
Jarian Cottingham 2025-08-09 12:09:52 -05:00
parent 03b03d676f
commit 73dd3f261e
9 changed files with 719 additions and 96 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
*.pyc
*.mkv
src/.tvdb_cache/*

View File

@ -7,13 +7,15 @@ This tool takes a folder of MKV files and:
2. Moves extras to an "extras" subfolder 2. Moves extras to an "extras" subfolder
3. Renames episodes using Jellyfin naming convention 3. Renames episodes using Jellyfin naming convention
4. Uses TVDB data to validate episode matching 4. Uses TVDB data to validate episode matching
5. Detects and handles duplicate episodes
Usage: Usage:
python episode_matcher.py <folder_path> <show_name> <season_number> [--api-key <tvdb_api_key>] python episode_matcher.py <folder_path> <show_name> <season_number> [options]
Examples: Examples:
python episode_matcher.py "/path/to/episodes" "Game of Thrones" 1 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 import argparse
@ -30,6 +32,41 @@ from Matcher import FileClassifier, EpisodeRenamer
from config import config_manager 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(): def main():
"""Main function to run the episode matcher.""" """Main function to run the episode matcher."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@ -71,6 +108,17 @@ def main():
help="Show detailed analysis information" 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() args = parser.parse_args()
# Validate inputs # Validate inputs
@ -164,7 +212,17 @@ def main():
print(f" - {ex['name']} ({ex['size_gb']:.2f} GB, {ex['duration_minutes']:.1f} min)") print(f" - {ex['name']} ({ex['size_gb']:.2f} GB, {ex['duration_minutes']:.1f} min)")
# Initialize renamer # 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: if args.dry_run:
print(f"\n=== DRY RUN - No files will be modified ===") print(f"\n=== DRY RUN - No files will be modified ===")
@ -175,6 +233,20 @@ def main():
print(f" - {extra['name']}") print(f" - {extra['name']}")
print(f"\nWould rename {len(episodes)} episode files:") 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 # Simulate the matching process
if tvdb_episodes: if tvdb_episodes:
matched_episodes = renamer._match_episodes_by_duration(episodes, tvdb_episodes) matched_episodes = renamer._match_episodes_by_duration(episodes, tvdb_episodes)

View File

@ -9,12 +9,16 @@ import re
class EpisodeRenamer: class EpisodeRenamer:
"""Renames episode files to Jellyfin format and moves extras to subfolder.""" """Renames episode files to Jellyfin format and moves extras to subfolder."""
def __init__(self, folder_path: str, show_name: str, season_number: int): def __init__(self, folder_path: str, show_name: str, season_number: int,
"""Initialize with folder path, show name, and season number.""" 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.folder_path = Path(folder_path)
self.show_name = show_name self.show_name = show_name
self.season_number = season_number 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.extras_folder = self.folder_path / "extras"
self.delete_folder = self.folder_path / "delete me"
def _create_extras_folder(self) -> bool: def _create_extras_folder(self) -> bool:
"""Create extras folder if it doesn't exist.""" """Create extras folder if it doesn't exist."""
@ -25,6 +29,15 @@ class EpisodeRenamer:
print(f"Error creating extras folder: {e}") print(f"Error creating extras folder: {e}")
return False 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: def _sanitize_filename(self, filename: str) -> str:
"""Sanitize filename to remove invalid characters.""" """Sanitize filename to remove invalid characters."""
# Remove invalid characters for file systems # Remove invalid characters for file systems
@ -72,16 +85,110 @@ class EpisodeRenamer:
return moved_files 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: def _extract_disc_info(self, filename: str) -> Dict:
"""Extract disc number and track number from filename.""" """Extract disc number and track number from filename."""
# Look for patterns like "Disc 1", "Disc1", "D1", etc. # Look for patterns like "Disc 1", "Disk 1", "Disc1", "disc-1", "DISC 1_t03"
disc_match = re.search(r'[Dd]isc\s*(\d+)', filename, re.IGNORECASE) 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 disc_number = int(disc_match.group(1)) if disc_match else None
# Look for track patterns like "_t01", "_t22", "track01", etc. # Look for track patterns like "_t01", "_t22", "track01", allow small separators
track_match = re.search(r'(?:_t|track)(\d+)', filename, re.IGNORECASE) track_match = re.search(r'(?i)(?:_t|track)[\s._-]?(\d+)', filename)
track_number = int(track_match.group(1)) if track_match else None track_number = int(track_match.group(1)) if track_match else None
return { return {
'disc_number': disc_number, 'disc_number': disc_number,
'track_number': track_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]]: 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 # Group files by disc and sort within each disc
discs = {} discs = {}
for info in episodes_info: for info in episodes_info:
@ -110,19 +217,87 @@ class EpisodeRenamer:
sorted_disc_nums = sorted(discs.keys()) sorted_disc_nums = sorted(discs.keys())
episodes_per_disc = {} episodes_per_disc = {}
# Assign episodes sequentially across discs # Calculate storage-aware episode distribution
episode_number = 1 episode_number = 1
for disc_num in sorted_disc_nums: for disc_num in sorted_disc_nums:
files_on_disc = discs[disc_num] files_on_disc = discs[disc_num]
episodes_on_this_disc = [] 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: if episode_number <= total_episodes:
episodes_on_this_disc.append(episode_number) episodes_on_this_disc.append(episode_number)
episode_number += 1 episode_number += 1
episodes_per_disc[disc_num] = episodes_on_this_disc 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 return episodes_per_disc
def rename_episodes(self, episodes: List[Dict], tvdb_episodes: Optional[List[Dict]] = None) -> List[Dict]: def rename_episodes(self, episodes: List[Dict], tvdb_episodes: Optional[List[Dict]] = None) -> List[Dict]:
@ -199,37 +374,136 @@ class EpisodeRenamer:
return renamed_files return renamed_files
def _match_episodes_by_duration(self, episodes: List[Dict], tvdb_episodes: List[Dict]) -> List[Dict]: 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 = [] matched_episodes = []
# Extract disc and track information # Extract disc and track information
episodes_info = [] episodes_info = []
for episode in episodes: 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) disc_info = self._extract_disc_info(filename)
episodes_info.append({ episodes_info.append({
'file_info': episode, 'file_info': episode,
'disc_number': disc_info['disc_number'], 'disc_number': disc_info['disc_number'],
'track_number': disc_info['track_number'], 'track_number': disc_info['track_number'],
'filename': filename, 'filename': filename,
'duration': episode['duration_minutes'] 'duration': episode.get('duration_minutes', 0)
}) })
# Sort by lexicographical order (this should match episode order) # Sort by lexicographical order (this should match episode order)
episodes_info.sort(key=lambda x: x['filename']) 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): if any(info['disc_number'] for info in episodes_info):
print("Found disc information - using flexible duration-first matching...") total_eps = len(tvdb_episodes)
# Use flexible matching that prioritizes duration over disc constraints episodes_per_disc = self._estimate_episodes_per_disc(episodes_info, total_eps)
matched_episodes = self._flexible_duration_match(episodes_info, tvdb_episodes) 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: else:
# No disc info, use lexicographical order with precise duration matching # No disc info, use lexicographical order with precise duration matching
print("No disc information found, using 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) 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 return matched_episodes
def _flexible_duration_match(self, episodes_info: List[Dict], tvdb_episodes: List[Dict]) -> List[Dict]: def _flexible_duration_match(self, episodes_info: List[Dict], tvdb_episodes: List[Dict]) -> List[Dict]:
@ -492,7 +766,7 @@ class EpisodeRenamer:
return True return True
def _analyze_disc_capacity(self, episodes_info: List[Dict]) -> Dict[int, int]: 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 = {} disc_capacity = {}
# Group files by disc # Group files by disc
@ -504,9 +778,30 @@ class EpisodeRenamer:
disc_files[disc_num] = [] disc_files[disc_num] = []
disc_files[disc_num].append(info) 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(): 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 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]: 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.""" """Match episodes using disc constraints and precise duration matching."""
matched_episodes = [] 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()): 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 = [f for f in episodes_info if f['disc_number'] == disc_num]
files_on_disc.sort(key=lambda x: x['filename']) files_on_disc.sort(key=lambda x: x['filename'])
allowed_episodes = episodes_per_disc[disc_num] allowed_episodes = episodes_per_disc[disc_num]
print(f"\nDisc {disc_num}: allowed episode numbers {allowed_episodes}")
# Get TVDB episodes for this disc # Get TVDB episodes for this disc
disc_tvdb_episodes = [ep for ep in tvdb_episodes if ep['episode_number'] in allowed_episodes] 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'] file_duration = file_info['duration']
best_match = None best_match = None
best_score = float('inf') best_score = float('inf')
diffs = [] # Diagnostics: collect per-episode duration differences
for tvdb_ep in tvdb_episodes: for tvdb_ep in tvdb_episodes:
if tvdb_ep['episode_number'] in used_episodes: if tvdb_ep['episode_number'] in used_episodes:
@ -622,6 +921,7 @@ class EpisodeRenamer:
# Calculate duration difference # Calculate duration difference
duration_diff = abs(file_duration - tvdb_duration) duration_diff = abs(file_duration - tvdb_duration)
diffs.append((tvdb_ep['episode_number'], tvdb_duration, duration_diff))
if duration_diff < best_score: if duration_diff < best_score:
best_score = duration_diff best_score = duration_diff
@ -637,6 +937,15 @@ class EpisodeRenamer:
}) })
used_files.add(id(file_info)) used_files.add(id(file_info))
used_episodes.add(best_match['episode_number']) 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 # Second pass: Handle remaining files with sequential assignment
remaining_files = [f for f in files if id(f) not in used_files] remaining_files = [f for f in files if id(f) not in used_files]

View File

@ -144,91 +144,144 @@ class FileClassifier:
# If we have TVDB episode data, use it for smarter classification # If we have TVDB episode data, use it for smarter classification
if tvdb_episodes and len(tvdb_episodes) > 0: if tvdb_episodes and len(tvdb_episodes) > 0:
return self._classify_with_tvdb_data(tvdb_episodes, expected_episode_count) return self._classify_with_tvdb_data(tvdb_episodes, expected_episode_count)
# Fallback to size/duration-based classification # Fallback to size/duration-based classification
return self._classify_by_stats(expected_episode_count) 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]]: 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: if not tvdb_episodes:
return self._classify_by_stats(expected_episode_count) return self._classify_by_stats(expected_episode_count)
# Get TVDB episode durations # Get TVDB episode durations
tvdb_durations = [ep.get('runtime', 0) for ep in tvdb_episodes if ep.get('runtime', 0) > 0] tvdb_durations = [ep.get('runtime', 0) for ep in tvdb_episodes if ep.get('runtime', 0) > 0]
if not tvdb_durations: if not tvdb_durations:
return self._classify_by_stats(expected_episode_count) return self._classify_by_stats(expected_episode_count)
avg_tvdb_duration = sum(tvdb_durations) / len(tvdb_durations) avg_tvdb_duration = sum(tvdb_durations) / len(tvdb_durations)
min_episode_duration = min(tvdb_durations) - 5 # 5 minute tolerance below minimum min_episode_duration = min(tvdb_durations) - 5 # 5 minute tolerance below minimum
max_episode_duration = max(tvdb_durations) + 10 # 10 minute tolerance above maximum 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})") print(f"TVDB Episode duration range: {min_episode_duration:.1f} - {max_episode_duration:.1f} min (avg: {avg_tvdb_duration:.1f})")
episodes = [] # Step 1: Build candidate pool limited by duration range
extras = [] import re
disc_regex = re.compile(r'(?i)\bdis[ck][\s._-]*(\d{1,3})(?!\d)')
# Sort files by size (largest first) for consistent processing
sorted_files = sorted(self.file_info, key=lambda x: x['size_bytes'], reverse=True) candidates_by_disc = {}
non_candidate_extras = []
for file_info in sorted_files:
for file_info in sorted(self.file_info, key=lambda x: x['size_bytes'], reverse=True):
duration = file_info['duration_minutes'] 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: 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") print(f" Duration-based extra: {file_info['name']} ({duration:.1f} min) - outside {min_episode_duration:.1f}-{max_episode_duration:.1f} range")
continue
# Second check: If we have expected episode count, limit episodes (but be disc-aware)
elif expected_episode_count and len(episodes) >= expected_episode_count: m = disc_regex.search(file_info['name'])
# Extract disc number for disc-aware logic disc_num = int(m.group(1)) if m else None
import re candidates_by_disc.setdefault(disc_num, []).append(file_info)
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 # If no expected count, fallback to previous simple path using candidates as episodes
if not expected_episode_count:
# Check how many files from each disc we already have as episodes episodes = [f for disc_files in candidates_by_disc.values() for f in disc_files]
disc_episode_counts = {} episodes.sort(key=lambda x: x['name'])
for ep in episodes: extras = non_candidate_extras
ep_disc_match = re.search(r'[Dd]isc\s*(\d+)', ep['name'], re.IGNORECASE) return episodes, extras
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 # Step 2: Derive per-disc capacity cap from average runtime (no show-specific hardcoding)
MAX_MINUTES_PER_DISC = 240 # Conservative, configurable heuristic
current_disc_episodes = disc_episode_counts.get(file_disc, 0) capacity_per_disc = max(1, int(MAX_MINUTES_PER_DISC / avg_tvdb_duration))
# Special logic: ensure we have enough episodes from Disc 4 for episodes 9 & 10 # Step 3: Fair-share distribution across discs with candidates
# Check if we need more Disc 4 episodes and this could be Episode 10 discs_in_play = sorted([d for d in candidates_by_disc.keys() if d is not None])
if file_disc == 4 and current_disc_episodes < 2: if not discs_in_play and None in candidates_by_disc:
print(f" Disc 4 episode preserved: {file_info['name']} - ensuring final disc has episodes 9-10") # No disc labels; treat as a single disc
is_extra = False discs_in_play = [None]
else:
is_extra = True n_discs = len(discs_in_play)
print(f" Count-based extra: {file_info['name']} (episode limit reached)") base = expected_episode_count // n_discs if n_discs else expected_episode_count
rem = expected_episode_count % n_discs if n_discs else 0
# Third check: Look for very close duration matches to confirm episodes
elif expected_episode_count: # Initial caps per disc: fair share limited by capacity and available candidates
# Find closest TVDB episode duration caps = {}
closest_duration = min(tvdb_durations, key=lambda x: abs(x - duration)) allocated = 0
duration_diff = abs(duration - closest_duration) for idx, d in enumerate(discs_in_play):
fair_cap = base + (1 if idx < rem else 0)
if duration_diff <= 10: # Within 10 minutes is likely an episode avail = len(candidates_by_disc.get(d, []))
print(f" Duration-matched episode: {file_info['name']} ({duration:.1f} min) ≈ TVDB {closest_duration} min (Δ{duration_diff:.1f})") cap = min(fair_cap, capacity_per_disc, avail)
else: caps[d] = cap
# Check if this could still be an episode based on count allocated += cap
if len(episodes) < expected_episode_count:
print(f" Episode by count: {file_info['name']} ({duration:.1f} min) - no close TVDB match but within count") # Redistribute any remaining quota without exceeding per-disc capacity or availability
else: remaining = expected_episode_count - allocated
is_extra = True if remaining > 0:
print(f" Duration mismatch extra: {file_info['name']} ({duration:.1f} min) - closest TVDB: {closest_duration} min (Δ{duration_diff:.1f})") for _ in range(3):
if remaining <= 0:
if is_extra: break
extras.append(file_info) for d in discs_in_play:
else: if remaining <= 0:
episodes.append(file_info) break
avail = len(candidates_by_disc.get(d, []))
# Sort episodes by name for consistent ordering if caps[d] < min(capacity_per_disc, avail):
episodes.sort(key=lambda x: x['name']) caps[d] += 1
remaining -= 1
return episodes, extras
# 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]]: 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.""" """Fallback classification using statistical analysis of file sizes and durations."""

185
test_episode_matching.py Normal file
View File

@ -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()