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
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 <folder_path> <show_name> <season_number> [--api-key <tvdb_api_key>]
python episode_matcher.py <folder_path> <show_name> <season_number> [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)

View File

@ -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,14 +85,108 @@ 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 {
@ -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
@ -638,6 +938,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]
remaining_files.sort(key=lambda x: x['filename'])

View File

@ -149,7 +149,7 @@ class FileClassifier:
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)
@ -164,71 +164,124 @@ class FileClassifier:
print(f"TVDB Episode duration range: {min_episode_duration:.1f} - {max_episode_duration:.1f} min (avg: {avg_tvdb_duration:.1f})")
episodes = []
extras = []
# Step 1: Build candidate pool limited by duration range
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']
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")
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:
# 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
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)
# 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
# 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
current_disc_episodes = disc_episode_counts.get(file_disc, 0)
# 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))
# 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)")
# 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]
# 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)
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
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})")
# 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
if is_extra:
extras.append(file_info)
else:
episodes.append(file_info)
# 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
# Sort episodes by name for consistent ordering
episodes.sort(key=lambda x: x['name'])
# 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'])
return episodes, extras
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."""

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