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
|
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)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -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]
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
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