Updated Episode Matcher to take hints about episodes on disc and delete flags
This commit is contained in:
parent
03b03d676f
commit
73dd3f261e
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
*.pyc
|
||||
*.mkv
|
||||
|
||||
src/.tvdb_cache/*
|
||||
@ -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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -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'])
|
||||
|
||||
@ -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."""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
185
test_episode_matching.py
Normal file
185
test_episode_matching.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user