From 73dd3f261e1a4c69072b96c64dc173fde9d7ba47 Mon Sep 17 00:00:00 2001 From: Jarian Cottingham Date: Sat, 9 Aug 2025 12:09:52 -0500 Subject: [PATCH] Updated Episode Matcher to take hints about episodes on disc and delete flags --- .gitignore | 4 + episode_matcher.py | 78 +++- .../episode_renamer.cpython-313.pyc | Bin 26745 -> 41763 bytes .../file_classifier.cpython-313.pyc | Bin 14870 -> 18205 bytes src/Matcher/episode_renamer.py | 357 ++++++++++++++++-- src/Matcher/file_classifier.py | 191 ++++++---- .../__pycache__/tvdb_cache.cpython-313.pyc | Bin 7368 -> 7366 bytes .../__pycache__/tvdb_client.cpython-313.pyc | Bin 8806 -> 8804 bytes test_episode_matching.py | 185 +++++++++ 9 files changed, 719 insertions(+), 96 deletions(-) create mode 100644 .gitignore create mode 100644 test_episode_matching.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20581e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.pyc +*.mkv + +src/.tvdb_cache/* \ No newline at end of file diff --git a/episode_matcher.py b/episode_matcher.py index fa0a90b..8e4faef 100755 --- a/episode_matcher.py +++ b/episode_matcher.py @@ -7,13 +7,15 @@ This tool takes a folder of MKV files and: 2. Moves extras to an "extras" subfolder 3. Renames episodes using Jellyfin naming convention 4. Uses TVDB data to validate episode matching +5. Detects and handles duplicate episodes Usage: - python episode_matcher.py [--api-key ] + python episode_matcher.py [options] Examples: python episode_matcher.py "/path/to/episodes" "Game of Thrones" 1 - python episode_matcher.py "/path/to/episodes" "Breaking Bad" 2 --api-key your_tvdb_api_key + python episode_matcher.py "/path/to/episodes" "Game of Thrones" 2 --disc-mapping "1:1-3,2:4-6,3:7-9,4:10" + python episode_matcher.py "/path/to/episodes" "Breaking Bad" 2 --auto-delete-duplicates --dry-run """ import argparse @@ -30,6 +32,41 @@ from Matcher import FileClassifier, EpisodeRenamer from config import config_manager +def parse_disc_mapping(mapping_str): + """Parse disc mapping string like '1:1-3,2:4-6,3:7-9,4:10' into a dict.""" + mapping = {} + + if not mapping_str: + return mapping + + try: + # Split by comma to get each disc mapping + disc_parts = mapping_str.split(',') + + for part in disc_parts: + # Split by colon to get disc:episodes + disc_str, episodes_str = part.strip().split(':') + disc_num = int(disc_str) + + # Parse episode range + if '-' in episodes_str: + # Range like "1-3" + start, end = episodes_str.split('-') + episodes = list(range(int(start), int(end) + 1)) + else: + # Single episode like "10" + episodes = [int(episodes_str)] + + mapping[disc_num] = episodes + + return mapping + + except Exception as e: + print(f"Error parsing disc mapping '{mapping_str}': {e}") + print("Expected format: '1:1-3,2:4-6,3:7-9,4:10'") + sys.exit(1) + + def main(): """Main function to run the episode matcher.""" parser = argparse.ArgumentParser( @@ -71,6 +108,17 @@ def main(): help="Show detailed analysis information" ) + parser.add_argument( + "--disc-mapping", + help="Disc to episode mapping (e.g., '1:1-3,2:4-6,3:7-9,4:10')" + ) + + parser.add_argument( + "--auto-delete-duplicates", + action="store_true", + help="Automatically delete duplicate files instead of moving them to 'delete me' folder" + ) + args = parser.parse_args() # Validate inputs @@ -164,7 +212,17 @@ def main(): print(f" - {ex['name']} ({ex['size_gb']:.2f} GB, {ex['duration_minutes']:.1f} min)") # Initialize renamer - renamer = EpisodeRenamer(str(folder_path), args.show_name, args.season_number) + disc_mapping = None + if args.disc_mapping: + print(f"Using disc mapping: {args.disc_mapping}") + disc_mapping = parse_disc_mapping(args.disc_mapping) + if args.verbose: + print("Parsed disc mapping:") + for disc, episodes in disc_mapping.items(): + print(f" Disc {disc}: Episodes {episodes}") + + renamer = EpisodeRenamer(str(folder_path), args.show_name, args.season_number, + disc_mapping=disc_mapping, auto_delete_duplicates=args.auto_delete_duplicates) if args.dry_run: print(f"\n=== DRY RUN - No files will be modified ===") @@ -175,6 +233,20 @@ def main(): print(f" - {extra['name']}") print(f"\nWould rename {len(episodes)} episode files:") + if args.verbose: + print("Episodes to be renamed:") + for i, ep in enumerate(episodes): + print(f" {i+1}: {ep} (type: {type(ep)})") + + # Handle the case where episodes is incorrectly formatted + if episodes and not isinstance(episodes[0], dict): + print("Warning: Episodes data is in wrong format, attempting to reconstruct...") + # This is a temporary workaround - we need to get the actual episode data + # from the classifier again + all_files = classifier.file_info + episode_files = [f for f in all_files if f not in extras] + episodes = episode_files[:len(tvdb_episodes)] if tvdb_episodes else episode_files + # Simulate the matching process if tvdb_episodes: matched_episodes = renamer._match_episodes_by_duration(episodes, tvdb_episodes) diff --git a/src/Matcher/__pycache__/episode_renamer.cpython-313.pyc b/src/Matcher/__pycache__/episode_renamer.cpython-313.pyc index 5bc4fcd648aa0894eb8267ae8352ea29bc6df9b1..92f7a2a15cfc59cffe90036ccc0da6734c0a85c3 100644 GIT binary patch literal 41763 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 diff --git a/src/Matcher/__pycache__/file_classifier.cpython-313.pyc b/src/Matcher/__pycache__/file_classifier.cpython-313.pyc index 6f0b7d2a86d3ef67c94b1d2467e0d9cc49f5b646..07f48455bf9dc2d7d49ab1960b1d289f2023ec2e 100644 GIT binary patch delta 5774 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 Y|0*PVyL2bj#2MlXbW$z+qIdLv051k6GXMYp delta 2868 zcma)8eN0=|6~FJ94;wJHv5m3$@C_!|&SFAhNU%xDw}CLi$Kw&c0(-U}#R)vndnR4n z54YMTC2Q4^zNSs5baVR0wyKjxlz*&le=w<&oIj>L*K8m!>yXw?id0Rc>Do`YYT6lxK;XRv%UmP73h7ioe#il z`87ZjV$9NT`>>^jh#mo)61}Qv4YtA$4Mw$DZU0CWSpZc`m3S0f5nZGQy+q&F0C2^0 zF{MUs!BRO@%9V_j(*@o#0D=VoAidE01^`SgK$cv|v|9E@X^@o`Ta`uur5h`!3N(N! zqYE-pPZwsS!L4d0vL+CP8@f)Dj+}>Hg2@A5x=?mVD`g~2dVh^V{sV<1DdT{OQ`72E zql|kfsq!oZ#1jF^#j7XvQJf?6aHVvq9Eh=?Fy`z!LY9tJ%KAe|ndIQI93m(qr)dP7 zmR9OP-z@T}cp?o&HWRd(%+eT_`rN2Y_M#(H!HVnuj<}Piv?O?*z&xIySAR%(%1+%I3aOTJ}l_s7kj&P5}hC9foc` zR`{xY=<5CGp;xlijbe(-4^Rjd*6>5;(dA79Rbv6?m6X*(a)#;Z>`tb~3@+5~a#+qt z8>u|67x}1pgeKeSqB6XqL)+51E(X>-L=RFLTLUHoDVIs0$deB+|zJuyt@;!D^Q5buaD zelR4K)KwBb(O%acJrmJPcL&dfgusk&$T1WAmh&FI+4s;T20XTjR6*E{wqes=^c9{J zSpFg#^umY(x**F5t32j7AL5ur7{urWMv(CW^tg^TMvM@4Ew6H6m!A>XAY{KA<{9q- zj8s5}e5}RLQYZ|0mI*AdL1!TMi9sX)zKbXjFU(! z7{A~o`4BSTo5h!1X7~|Og;F&9^F{C>mCz+7?o@5|jPs~#kPXj?AG$iD5d(xhxjg$X z!2srr4tf|ag*YcVEw>BAPkC0d@=Q5p_^{)&GbD3TK1?->*mv=66ptK6Z=I}yVpIOfNo696kNUPbE%x(@|n46}J zfmNPoTe)y{caMcOmykKRztd`2L&*7gz8NtPaJ9r+jRpmT?c#46TgXop;(r_eBEH%R zU7wf_UhdyELFs@yX==LMw{5Dr+_zf*v=x%2A*E?pcSxs3QpZNNE3Iov@%XX+=$8QK z81kzEQ0DkW*ShlkLFp8gI!yglYu>K5{$%p)Ny+Ysdp5?Ct=~@gf4_8RDOEkQR`hX8 z=Z&sd(H3k>!p3!73c5F|ni6?yDiq7^l(e=#S!&lhx9jSE)_=2KYG~i6-5CGPp`3%d znYF=>Po2IIh`F|+wav-e=5;1j+p<~Xk%mXsdbi!aE%)i9`*h-jG)AY~^ z>_0C|85-{wf=b(N5pbT|aYkA-=l)=r{Sv3- z6I*@kh0WISr0uMvg?FJX)+ZflOV+ieVEdYSM-OA2DSiFpaT3Q83)0!C#H=)ZUb^tC zbpE+r0G=m$NF;nDg~b?o7K@%JIf3N_If+H(6p8*H7^lfE3P45W^%n7Y$$lzjI(4_A zT7pgMlktnmW}np5yTKg%B`tz$=9@s{SQCZ^ib9=d;l0G9gQ6#}Squc@!7lNQ%ybyC@|qcKSutE9C4 z>vNg}mLMVEe}X?Y57_hbepNnTzddlgL81E(TH_b-fLnZwUlL#UG!vJ^kwZ0xAE6Z^ z`sDEud7pnqB)vy0&*J&s*MJ|vQ+(6;?}-Cmrs4`#e~5>d@qoV={3~b>Kk~Mb_rPs^ z>rTGei5v^L9|&Fi-JTo1;ZZk^u+SVx)9D&o66u0p(WKS+vy>}e%_DVF)3BjVy~zGhW0 QkMI+pgJ7QcJKymC0K9O}QUCw| diff --git a/src/Matcher/episode_renamer.py b/src/Matcher/episode_renamer.py index 46baed2..618db74 100644 --- a/src/Matcher/episode_renamer.py +++ b/src/Matcher/episode_renamer.py @@ -9,12 +9,16 @@ import re class EpisodeRenamer: """Renames episode files to Jellyfin format and moves extras to subfolder.""" - def __init__(self, folder_path: str, show_name: str, season_number: int): - """Initialize with folder path, show name, and season number.""" + def __init__(self, folder_path: str, show_name: str, season_number: int, + disc_mapping: Optional[Dict[int, List[int]]] = None, auto_delete_duplicates: bool = False): + """Initialize with folder path, show name, season number, optional disc mapping, and auto-delete option.""" self.folder_path = Path(folder_path) self.show_name = show_name self.season_number = season_number + self.disc_mapping = disc_mapping # {disc_num: [episode_numbers]} + self.auto_delete_duplicates = auto_delete_duplicates self.extras_folder = self.folder_path / "extras" + self.delete_folder = self.folder_path / "delete me" def _create_extras_folder(self) -> bool: """Create extras folder if it doesn't exist.""" @@ -25,6 +29,15 @@ class EpisodeRenamer: print(f"Error creating extras folder: {e}") return False + def _create_delete_folder(self) -> bool: + """Create delete me folder if it doesn't exist.""" + try: + self.delete_folder.mkdir(exist_ok=True) + return True + except Exception as e: + print(f"Error creating delete me folder: {e}") + return False + def _sanitize_filename(self, filename: str) -> str: """Sanitize filename to remove invalid characters.""" # Remove invalid characters for file systems @@ -72,16 +85,110 @@ class EpisodeRenamer: return moved_files + def detect_and_move_duplicates(self, episodes: List[Dict]) -> List[str]: + """Detect duplicate episodes and either delete them or move them to delete me folder.""" + processed_files = [] + duplicates_found = [] + + # Group episodes by duration (within 1 minute tolerance) + duration_groups = {} + for episode in episodes: + duration = episode['duration_minutes'] + + # Find existing group within 1 minute tolerance + matching_group = None + for group_duration in duration_groups.keys(): + if abs(duration - group_duration) <= 0.5: # Tighter tolerance for exact duplicates + matching_group = group_duration + break + + if matching_group: + duration_groups[matching_group].append(episode) + else: + duration_groups[duration] = [episode] + + # Identify duplicates (groups with more than one episode) + for duration, group_episodes in duration_groups.items(): + if len(group_episodes) > 1: + print(f"\nFound {len(group_episodes)} potential duplicates with ~{duration:.1f}min duration:") + + # Sort by disc number (keep lower disc numbers) then file size (largest first) for consistent ordering + group_episodes.sort(key=lambda x: ( + x.get('disc_number', 999) if 'disc_number' in x else 999, # Lower disc numbers first + -x['size_gb'], # Then largest files + x['path'].name # Finally by filename for consistency + )) + + # Keep the first file (from earliest disc, largest size), mark others as duplicates + keeper = group_episodes[0] + duplicates = group_episodes[1:] + + disc_info = "" + if hasattr(keeper['path'], 'name'): + # Try to extract disc info from filename for display + disc_match = re.search(r'Disc\s*(\d+)', keeper['path'].name, re.IGNORECASE) + if disc_match: + disc_info = f" (Disc {disc_match.group(1)})" + + print(f" Keeping: {keeper['path'].name}{disc_info} ({keeper['size_gb']:.2f}GB)") + + for duplicate in duplicates: + dup_disc_info = "" + if hasattr(duplicate['path'], 'name'): + disc_match = re.search(r'Disc\s*(\d+)', duplicate['path'].name, re.IGNORECASE) + if disc_match: + dup_disc_info = f" (Disc {disc_match.group(1)})" + + print(f" Duplicate: {duplicate['path'].name}{dup_disc_info} ({duplicate['size_gb']:.2f}GB)") + duplicates_found.append(duplicate) + + # Handle duplicates based on auto_delete_duplicates flag + if self.auto_delete_duplicates: + # Delete duplicates directly + for duplicate in duplicates_found: + source_path = duplicate['path'] + + try: + source_path.unlink() # Delete the file + processed_files.append(str(source_path)) + print(f"Deleted duplicate: {source_path.name}") + + except Exception as e: + print(f"Error deleting {source_path.name}: {e}") + else: + # Move duplicates to delete me folder (original behavior) + if not self._create_delete_folder(): + return [] + + for duplicate in duplicates_found: + source_path = duplicate['path'] + dest_path = self.delete_folder / source_path.name + + try: + # Check if destination already exists + if dest_path.exists(): + print(f"Warning: {dest_path.name} already exists in delete me folder, skipping.") + continue + + shutil.move(str(source_path), str(dest_path)) + processed_files.append(str(dest_path)) + print(f"Moved to delete me: {source_path.name}") + + except Exception as e: + print(f"Error moving {source_path.name} to delete me: {e}") + + return processed_files + def _extract_disc_info(self, filename: str) -> Dict: """Extract disc number and track number from filename.""" - # Look for patterns like "Disc 1", "Disc1", "D1", etc. - disc_match = re.search(r'[Dd]isc\s*(\d+)', filename, re.IGNORECASE) + # Look for patterns like "Disc 1", "Disk 1", "Disc1", "disc-1", "DISC 1_t03" + disc_match = re.search(r'(?i)\bdis[ck][\s._-]*(\d{1,3})(?!\d)', filename) disc_number = int(disc_match.group(1)) if disc_match else None - - # Look for track patterns like "_t01", "_t22", "track01", etc. - track_match = re.search(r'(?:_t|track)(\d+)', filename, re.IGNORECASE) + + # Look for track patterns like "_t01", "_t22", "track01", allow small separators + track_match = re.search(r'(?i)(?:_t|track)[\s._-]?(\d+)', filename) track_number = int(track_match.group(1)) if track_match else None - + return { 'disc_number': disc_number, 'track_number': track_number, @@ -89,7 +196,7 @@ class EpisodeRenamer: } def _estimate_episodes_per_disc(self, episodes_info: List[Dict], total_episodes: int) -> Dict[int, List[int]]: - """Estimate which episodes are on which disc based on file distribution.""" + """Estimate which episodes are on which disc based on file distribution and storage constraints.""" # Group files by disc and sort within each disc discs = {} for info in episodes_info: @@ -110,19 +217,87 @@ class EpisodeRenamer: sorted_disc_nums = sorted(discs.keys()) episodes_per_disc = {} - # Assign episodes sequentially across discs + # Calculate storage-aware episode distribution episode_number = 1 for disc_num in sorted_disc_nums: files_on_disc = discs[disc_num] episodes_on_this_disc = [] - for _ in files_on_disc: + # Calculate total storage used by episodes on this disc + total_disc_size_gb = sum(info['file_info']['size_gb'] for info in files_on_disc) + + # Estimate Blu-ray capacity constraints + # Standard Blu-ray: ~45GB usable (48GB total - overhead) + # Account for extras that might also be on the disc + estimated_episode_capacity_gb = 45.0 # Conservative estimate + + # If this disc is near capacity, reduce episode count to be conservative + if total_disc_size_gb > 40.0: # Stricter threshold: 40GB + # This disc is likely at capacity limits + # Reduce episode count by 1 if we're close to limits + available_episodes = max(1, len(files_on_disc) - 1) + available_episodes = min(available_episodes, total_episodes - episode_number + 1) + print(f" Warning: Disc {disc_num} near capacity ({total_disc_size_gb:.1f}GB), reducing episode allocation") + elif total_disc_size_gb > 35.0: # Warning threshold: 35GB + # Show warning but don't reduce count yet + available_episodes = min(len(files_on_disc), total_episodes - episode_number + 1) + print(f" Warning: Disc {disc_num} approaching capacity ({total_disc_size_gb:.1f}GB), may have storage constraints") + else: + available_episodes = min(len(files_on_disc), total_episodes - episode_number + 1) + + for _ in range(available_episodes): if episode_number <= total_episodes: episodes_on_this_disc.append(episode_number) episode_number += 1 episodes_per_disc[disc_num] = episodes_on_this_disc + # Diagnostics: print estimated mapping per disc with storage info + print("\nEstimated episodes-per-disc mapping (considering storage constraints):") + total_assigned = 0 + for d in sorted(episodes_per_disc.keys()): + files_on_disc = [f for f in episodes_info if f['disc_number'] == d] + total_size = sum(f['file_info']['size_gb'] for f in files_on_disc) + print(f" Disc {d}: Episodes {episodes_per_disc[d]} ({len(files_on_disc)} files, {total_size:.1f}GB)") + total_assigned += len(episodes_per_disc[d]) + + # Flag potential storage issues + if len(files_on_disc) > len(episodes_per_disc[d]): + extra_files = len(files_on_disc) - len(episodes_per_disc[d]) + print(f" ⚠ Warning: {extra_files} extra files detected - may indicate storage constraints") + + # Ensure we haven't lost any episodes due to capacity constraints + if total_assigned < total_episodes: + missing_episodes = total_episodes - total_assigned + print(f" ⚠ Warning: {missing_episodes} episodes unassigned due to capacity constraints") + + # Try to assign remaining episodes to discs with available space + remaining_episodes = [] + for ep in range(1, total_episodes + 1): + assigned = False + for disc_episodes in episodes_per_disc.values(): + if ep in disc_episodes: + assigned = True + break + if not assigned: + remaining_episodes.append(ep) + + # Find discs with the most available space to assign remaining episodes + if remaining_episodes: + disc_space = {} + for d in sorted(episodes_per_disc.keys()): + files_on_disc = [f for f in episodes_info if f['disc_number'] == d] + total_size = sum(f['file_info']['size_gb'] for f in files_on_disc) + available_space = 45.0 - total_size # Remaining Blu-ray capacity + disc_space[d] = available_space + + # Assign remaining episodes to discs with most space + for ep in remaining_episodes: + best_disc = max(disc_space.keys(), key=lambda d: disc_space[d]) + episodes_per_disc[best_disc].append(ep) + disc_space[best_disc] -= 10.0 # Rough estimate: reduce available space + print(f" ↻ Reassigning Episode {ep} to Disc {best_disc} (most available space)") + return episodes_per_disc def rename_episodes(self, episodes: List[Dict], tvdb_episodes: Optional[List[Dict]] = None) -> List[Dict]: @@ -199,37 +374,136 @@ class EpisodeRenamer: return renamed_files def _match_episodes_by_duration(self, episodes: List[Dict], tvdb_episodes: List[Dict]) -> List[Dict]: - """Match episode files with TVDB episode data based on duration and lexicographical order.""" + """Match episode files with TVDB episode data. Use disc mapping if provided, otherwise use duration matching.""" matched_episodes = [] # Extract disc and track information episodes_info = [] for episode in episodes: - filename = episode['path'].name + # Handle both path objects and filename strings + if isinstance(episode, dict) and 'path' in episode: + filename = episode['path'].name + elif isinstance(episode, dict) and 'name' in episode: + filename = episode['name'] + else: + print(f"Warning: Unexpected episode format: {episode}") + continue + disc_info = self._extract_disc_info(filename) episodes_info.append({ 'file_info': episode, 'disc_number': disc_info['disc_number'], 'track_number': disc_info['track_number'], 'filename': filename, - 'duration': episode['duration_minutes'] + 'duration': episode.get('duration_minutes', 0) }) # Sort by lexicographical order (this should match episode order) episodes_info.sort(key=lambda x: x['filename']) - # If we have disc information, use it for context but prioritize duration matching + # Detect and handle duplicates before processing + print("\n=== Duplicate Detection ===") + episode_files = [info['file_info'] for info in episodes_info] + duplicate_files = self.detect_and_move_duplicates(episode_files) + + # Remove duplicates from episodes_info + if duplicate_files: + duplicate_names = {Path(f).name for f in duplicate_files} + episodes_info = [info for info in episodes_info + if info['filename'] not in duplicate_names] + print(f"Removed {len(duplicate_files)} duplicates from episode processing") + + # Use disc mapping if provided + if self.disc_mapping: + print("\n=== Using Disc Mapping ===") + matched_episodes = self._match_using_disc_mapping(episodes_info, tvdb_episodes) + else: + # Original complex matching logic for when no disc mapping is provided + print("\n=== Using Duration/Disc Analysis ===") + matched_episodes = self._match_using_duration_analysis(episodes_info, tvdb_episodes) + + # Sort by episode number for final output + matched_episodes.sort(key=lambda x: x['episode_number']) + return matched_episodes + + def _match_using_disc_mapping(self, episodes_info: List[Dict], tvdb_episodes: List[Dict]) -> List[Dict]: + """Match episodes using the provided disc mapping.""" + matched_episodes = [] + + # Group files by disc + disc_groups = {} + for info in episodes_info: + disc_num = info['disc_number'] + if disc_num is not None: + disc_groups.setdefault(disc_num, []).append(info) + + print(f"Files found on {len(disc_groups)} discs") + for disc_num in sorted(disc_groups.keys()): + files = disc_groups[disc_num] + expected_episodes = self.disc_mapping.get(disc_num, []) + print(f"Disc {disc_num}: {len(files)} files, expecting episodes {expected_episodes}") + + # Sort files on this disc by filename + files.sort(key=lambda x: x['filename']) + + # Match files to episodes in order + for i, file_info in enumerate(files): + if i < len(expected_episodes): + episode_number = expected_episodes[i] + + # Find the TVDB episode + tvdb_episode = None + for ep in tvdb_episodes: + if ep['episode_number'] == episode_number: + tvdb_episode = ep + break + + matched_episodes.append({ + 'file_info': file_info['file_info'], + 'episode_number': episode_number, + 'tvdb_info': tvdb_episode + }) + + print(f" Episode {episode_number}: {file_info['filename']}") + else: + print(f" Warning: Extra file on disc {disc_num}: {file_info['filename']}") + + return matched_episodes + + def _match_using_duration_analysis(self, episodes_info: List[Dict], tvdb_episodes: List[Dict]) -> List[Dict]: + """Use the original complex duration/disc analysis matching.""" + # Diagnostics: overview of discs/files and durations + try: + disc_groups = {} + for info in episodes_info: + disc_groups.setdefault(info['disc_number'], []).append(info) + print("=== Disc/File Overview ===") + for disc_key in sorted([d for d in disc_groups.keys() if d is not None]): + files = sorted(disc_groups[disc_key], key=lambda x: x['filename']) + print(f"Disc {disc_key}: {len(files)} candidate file(s)") + for f in files: + try: + print(f" - {f['filename']} ({f['duration']:.1f} min)") + except Exception: + pass + except Exception: + pass + + # If we have disc information, enforce disc→episode ordering so early discs map to earlier episodes if any(info['disc_number'] for info in episodes_info): - print("Found disc information - using flexible duration-first matching...") - # Use flexible matching that prioritizes duration over disc constraints - matched_episodes = self._flexible_duration_match(episodes_info, tvdb_episodes) + total_eps = len(tvdb_episodes) + episodes_per_disc = self._estimate_episodes_per_disc(episodes_info, total_eps) + if episodes_per_disc: + print("Found disc information - enforcing disc-to-episode order constraints...") + matched_episodes = self._match_by_duration_and_disc(episodes_info, tvdb_episodes, episodes_per_disc) + else: + print("Found disc information - using flexible duration-first matching...") + matched_episodes = self._flexible_duration_match(episodes_info, tvdb_episodes) else: # No disc info, use lexicographical order with precise duration matching print("No disc information found, using lexicographical order with precise duration matching...") matched_episodes = self._match_by_duration_and_order(episodes_info, tvdb_episodes) - # Sort by episode number for final output - matched_episodes.sort(key=lambda x: x['episode_number']) return matched_episodes def _flexible_duration_match(self, episodes_info: List[Dict], tvdb_episodes: List[Dict]) -> List[Dict]: @@ -492,7 +766,7 @@ class EpisodeRenamer: return True def _analyze_disc_capacity(self, episodes_info: List[Dict]) -> Dict[int, int]: - """Analyze the maximum number of episodes each disc can hold.""" + """Analyze the maximum number of episodes each disc can hold based on storage constraints.""" disc_capacity = {} # Group files by disc @@ -504,9 +778,30 @@ class EpisodeRenamer: disc_files[disc_num] = [] disc_files[disc_num].append(info) - # Each disc can hold at most as many episodes as it has files + # Analyze each disc's capacity considering storage limits for disc_num, files in disc_files.items(): - disc_capacity[disc_num] = len(files) + file_count = len(files) + total_size_gb = sum(f['file_info']['size_gb'] for f in files) + + # Blu-ray capacity constraints + max_bluray_capacity_gb = 48.0 + usable_capacity_gb = 45.0 # Account for overhead and extras + + if total_size_gb > usable_capacity_gb * 0.8: # 80% threshold + # This disc is likely near capacity + # Estimate how many episodes can actually fit based on average episode size + avg_episode_size = total_size_gb / file_count if file_count > 0 else 10.0 + estimated_episodes = min(file_count, int(usable_capacity_gb / avg_episode_size)) + + # Be conservative - if we're unsure, use the smaller number + disc_capacity[disc_num] = min(estimated_episodes, file_count) + + print(f" Storage constraint analysis for Disc {disc_num}:") + print(f" Total files: {file_count}, Total size: {total_size_gb:.1f}GB") + print(f" Estimated episode capacity: {estimated_episodes} (limited by {usable_capacity_gb}GB Blu-ray)") + else: + # Disc has plenty of space, use file count + disc_capacity[disc_num] = file_count return disc_capacity @@ -553,11 +848,14 @@ class EpisodeRenamer: def _match_by_duration_and_disc(self, episodes_info: List[Dict], tvdb_episodes: List[Dict], episodes_per_disc: Dict[int, List[int]]) -> List[Dict]: """Match episodes using disc constraints and precise duration matching.""" matched_episodes = [] - used_episode_numbers = set() # Process each disc in order + used_episode_numbers = set() + + # Process each disc in order for disc_num in sorted(episodes_per_disc.keys()): files_on_disc = [f for f in episodes_info if f['disc_number'] == disc_num] files_on_disc.sort(key=lambda x: x['filename']) allowed_episodes = episodes_per_disc[disc_num] + print(f"\nDisc {disc_num}: allowed episode numbers {allowed_episodes}") # Get TVDB episodes for this disc disc_tvdb_episodes = [ep for ep in tvdb_episodes if ep['episode_number'] in allowed_episodes] @@ -609,6 +907,7 @@ class EpisodeRenamer: file_duration = file_info['duration'] best_match = None best_score = float('inf') + diffs = [] # Diagnostics: collect per-episode duration differences for tvdb_ep in tvdb_episodes: if tvdb_ep['episode_number'] in used_episodes: @@ -622,6 +921,7 @@ class EpisodeRenamer: # Calculate duration difference duration_diff = abs(file_duration - tvdb_duration) + diffs.append((tvdb_ep['episode_number'], tvdb_duration, duration_diff)) if duration_diff < best_score: best_score = duration_diff @@ -637,6 +937,15 @@ class EpisodeRenamer: }) used_files.add(id(file_info)) used_episodes.add(best_match['episode_number']) + + # Diagnostics: print top few closest episodes by duration within allowed set + if diffs: + try: + diffs.sort(key=lambda x: x[2]) + top = ", ".join([f"E{ep}:{dur} (Δ{diff:.1f})" for ep, dur, diff in diffs[:3]]) + print(f" {file_info['filename']}: compare within {allowed_episodes} → {top}") + except Exception: + pass # Second pass: Handle remaining files with sequential assignment remaining_files = [f for f in files if id(f) not in used_files] diff --git a/src/Matcher/file_classifier.py b/src/Matcher/file_classifier.py index a96bf57..d454b3d 100644 --- a/src/Matcher/file_classifier.py +++ b/src/Matcher/file_classifier.py @@ -144,91 +144,144 @@ class FileClassifier: # If we have TVDB episode data, use it for smarter classification if tvdb_episodes and len(tvdb_episodes) > 0: return self._classify_with_tvdb_data(tvdb_episodes, expected_episode_count) - + # Fallback to size/duration-based classification return self._classify_by_stats(expected_episode_count) - + def _classify_with_tvdb_data(self, tvdb_episodes: List[Dict], expected_episode_count: int = None) -> Tuple[List[Dict], List[Dict]]: - """Classify files using TVDB episode duration data for better matching.""" + """TVDB-informed classification with disc-aware capacity and fair distribution.""" if not tvdb_episodes: return self._classify_by_stats(expected_episode_count) - + # Get TVDB episode durations tvdb_durations = [ep.get('runtime', 0) for ep in tvdb_episodes if ep.get('runtime', 0) > 0] if not tvdb_durations: return self._classify_by_stats(expected_episode_count) - + avg_tvdb_duration = sum(tvdb_durations) / len(tvdb_durations) min_episode_duration = min(tvdb_durations) - 5 # 5 minute tolerance below minimum max_episode_duration = max(tvdb_durations) + 10 # 10 minute tolerance above maximum - + print(f"TVDB Episode duration range: {min_episode_duration:.1f} - {max_episode_duration:.1f} min (avg: {avg_tvdb_duration:.1f})") - - episodes = [] - extras = [] - - # Sort files by size (largest first) for consistent processing - sorted_files = sorted(self.file_info, key=lambda x: x['size_bytes'], reverse=True) - - for file_info in sorted_files: + + # Step 1: Build candidate pool limited by duration range + import re + disc_regex = re.compile(r'(?i)\bdis[ck][\s._-]*(\d{1,3})(?!\d)') + + candidates_by_disc = {} + non_candidate_extras = [] + + for file_info in sorted(self.file_info, key=lambda x: x['size_bytes'], reverse=True): duration = file_info['duration_minutes'] - is_extra = False - - # First check: Duration-based classification with tighter tolerances if duration < min_episode_duration or duration > max_episode_duration: - is_extra = True + non_candidate_extras.append(file_info) print(f" Duration-based extra: {file_info['name']} ({duration:.1f} min) - outside {min_episode_duration:.1f}-{max_episode_duration:.1f} range") - - # Second check: If we have expected episode count, limit episodes (but be disc-aware) - elif expected_episode_count and len(episodes) >= expected_episode_count: - # Extract disc number for disc-aware logic - import re - disc_match = re.search(r'[Dd]isc\s*(\d+)', file_info['name'], re.IGNORECASE) - file_disc = int(disc_match.group(1)) if disc_match else None - - # Check how many files from each disc we already have as episodes - disc_episode_counts = {} - for ep in episodes: - ep_disc_match = re.search(r'[Dd]isc\s*(\d+)', ep['name'], re.IGNORECASE) - ep_disc = int(ep_disc_match.group(1)) if ep_disc_match else 0 - disc_episode_counts[ep_disc] = disc_episode_counts.get(ep_disc, 0) + 1 - - current_disc_episodes = disc_episode_counts.get(file_disc, 0) - - # Special logic: ensure we have enough episodes from Disc 4 for episodes 9 & 10 - # Check if we need more Disc 4 episodes and this could be Episode 10 - if file_disc == 4 and current_disc_episodes < 2: - print(f" Disc 4 episode preserved: {file_info['name']} - ensuring final disc has episodes 9-10") - is_extra = False - else: - is_extra = True - print(f" Count-based extra: {file_info['name']} (episode limit reached)") - - # Third check: Look for very close duration matches to confirm episodes - elif expected_episode_count: - # Find closest TVDB episode duration - closest_duration = min(tvdb_durations, key=lambda x: abs(x - duration)) - duration_diff = abs(duration - closest_duration) - - if duration_diff <= 10: # Within 10 minutes is likely an episode - print(f" Duration-matched episode: {file_info['name']} ({duration:.1f} min) ≈ TVDB {closest_duration} min (Δ{duration_diff:.1f})") - else: - # Check if this could still be an episode based on count - if len(episodes) < expected_episode_count: - print(f" Episode by count: {file_info['name']} ({duration:.1f} min) - no close TVDB match but within count") - else: - is_extra = True - print(f" Duration mismatch extra: {file_info['name']} ({duration:.1f} min) - closest TVDB: {closest_duration} min (Δ{duration_diff:.1f})") - - if is_extra: - extras.append(file_info) - else: - episodes.append(file_info) - - # Sort episodes by name for consistent ordering - episodes.sort(key=lambda x: x['name']) - - return episodes, extras + continue + + m = disc_regex.search(file_info['name']) + disc_num = int(m.group(1)) if m else None + candidates_by_disc.setdefault(disc_num, []).append(file_info) + + # If no expected count, fallback to previous simple path using candidates as episodes + if not expected_episode_count: + episodes = [f for disc_files in candidates_by_disc.values() for f in disc_files] + episodes.sort(key=lambda x: x['name']) + extras = non_candidate_extras + return episodes, extras + + # Step 2: Derive per-disc capacity cap from average runtime (no show-specific hardcoding) + MAX_MINUTES_PER_DISC = 240 # Conservative, configurable heuristic + capacity_per_disc = max(1, int(MAX_MINUTES_PER_DISC / avg_tvdb_duration)) + + # Step 3: Fair-share distribution across discs with candidates + discs_in_play = sorted([d for d in candidates_by_disc.keys() if d is not None]) + if not discs_in_play and None in candidates_by_disc: + # No disc labels; treat as a single disc + discs_in_play = [None] + + n_discs = len(discs_in_play) + base = expected_episode_count // n_discs if n_discs else expected_episode_count + rem = expected_episode_count % n_discs if n_discs else 0 + + # Initial caps per disc: fair share limited by capacity and available candidates + caps = {} + allocated = 0 + for idx, d in enumerate(discs_in_play): + fair_cap = base + (1 if idx < rem else 0) + avail = len(candidates_by_disc.get(d, [])) + cap = min(fair_cap, capacity_per_disc, avail) + caps[d] = cap + allocated += cap + + # Redistribute any remaining quota without exceeding per-disc capacity or availability + remaining = expected_episode_count - allocated + if remaining > 0: + for _ in range(3): + if remaining <= 0: + break + for d in discs_in_play: + if remaining <= 0: + break + avail = len(candidates_by_disc.get(d, [])) + if caps[d] < min(capacity_per_disc, avail): + caps[d] += 1 + remaining -= 1 + + # Step 4: Select episodes per disc obeying caps and avoiding duplicates if over cap + def pick_for_disc(files: List[Dict], cap: int) -> List[Dict]: + if cap <= 0: + return [] + if len(files) <= cap: + return sorted(files, key=lambda x: x['name']) + + buckets = {} + for f in files: + key = round(f['duration_minutes'] * 2) / 2.0 + buckets.setdefault(key, []).append(f) + + for k in buckets: + buckets[k].sort(key=lambda x: (-x['size_bytes'], x['name'])) + + selected = [] + bucket_keys = sorted(buckets.keys()) + idx = 0 + while len(selected) < cap and bucket_keys: + key = bucket_keys[idx % len(bucket_keys)] + if buckets[key]: + selected.append(buckets[key].pop(0)) + bucket_keys = [k for k in bucket_keys if buckets[k]] + idx += 1 + + if len(selected) < cap: + remaining_files = [] + for arr in buckets.values(): + remaining_files.extend(arr) + remaining_files.sort(key=lambda x: (-x['size_bytes'], x['name'])) + for f in remaining_files: + if len(selected) >= cap: + break + selected.append(f) + + return sorted(selected, key=lambda x: x['name']) + + selected_episodes = [] + for d in discs_in_play: + disc_files = candidates_by_disc.get(d, []) + chosen = pick_for_disc(disc_files, caps.get(d, 0)) + for f in chosen: + closest = min(tvdb_durations, key=lambda x: abs(x - f['duration_minutes'])) + print(f" Duration-matched episode: {f['name']} ({f['duration_minutes']:.1f} min) ≈ TVDB {closest} min (Δ{abs(f['duration_minutes']-closest):.1f})") + selected_episodes.extend(chosen) + + selected_set = {id(f) for f in selected_episodes} + extras = non_candidate_extras[:] + for files in candidates_by_disc.values(): + for f in files: + if id(f) not in selected_set: + extras.append(f) + + selected_episodes.sort(key=lambda x: x['name']) + return selected_episodes, extras def _classify_by_stats(self, expected_episode_count: int = None) -> Tuple[List[Dict], List[Dict]]: """Fallback classification using statistical analysis of file sizes and durations.""" diff --git a/src/TVDBProvider/__pycache__/tvdb_cache.cpython-313.pyc b/src/TVDBProvider/__pycache__/tvdb_cache.cpython-313.pyc index db1da05211133c79901416818adefc411050ec03..cb7281b2c89c7bfb40a359ac95f6b3249bf07d0c 100644 GIT binary patch delta 31 lcmX?MdCZdgGcPX}0}vb&n6{DoD>I|tWJVT)&BiQKB>_ delta 33 ncmX?RdBT$WGcPX}0}x!Xo4S$vD>GxjWJVSPM!n4jEYltFV2r&Qv delta 33 ncmaFj^2~+%GcPX}0}$}oPu<8Z#mpEuS&i9%QEzi7Grv3ln*s;a diff --git a/test_episode_matching.py b/test_episode_matching.py new file mode 100644 index 0000000..9b87898 --- /dev/null +++ b/test_episode_matching.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +Test script to verify episode matching logic with mock data. +""" +import sys +import os +from pathlib import Path + +# Add src to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from Matcher.episode_renamer import EpisodeRenamer + +def create_mock_episodes(): + """Create mock episode data to test the matching logic.""" + return [ + { + 'path': Path('Disc 1_t01.mkv'), + 'name': 'Disc 1_t01.mkv', + 'size_bytes': 2 * 1024**3, # 2GB + 'size_gb': 2.0, + 'duration_minutes': 61.0, + }, + { + 'path': Path('Disc 1_t02.mkv'), + 'name': 'Disc 1_t02.mkv', + 'size_bytes': 2.1 * 1024**3, # 2.1GB + 'size_gb': 2.1, + 'duration_minutes': 55.0, + }, + { + 'path': Path('Disc 1_t03.mkv'), + 'name': 'Disc 1_t03.mkv', + 'size_bytes': 2.2 * 1024**3, # 2.2GB + 'size_gb': 2.2, + 'duration_minutes': 57.0, + }, + { + 'path': Path('Disc 2_t01.mkv'), + 'name': 'Disc 2_t01.mkv', + 'size_bytes': 2.0 * 1024**3, # 2GB + 'size_gb': 2.0, + 'duration_minutes': 55.0, + }, + { + 'path': Path('Disc 2_t02.mkv'), + 'name': 'Disc 2_t02.mkv', + 'size_bytes': 2.1 * 1024**3, # 2.1GB + 'size_gb': 2.1, + 'duration_minutes': 54.0, + }, + { + 'path': Path('Disc 2_t03.mkv'), + 'name': 'Disc 2_t03.mkv', + 'size_bytes': 1.9 * 1024**3, # 1.9GB + 'size_gb': 1.9, + 'duration_minutes': 52.0, + }, + { + 'path': Path('Disc 3_t01.mkv'), + 'name': 'Disc 3_t01.mkv', + 'size_bytes': 2.2 * 1024**3, # 2.2GB + 'size_gb': 2.2, + 'duration_minutes': 57.0, + }, + { + 'path': Path('Disc 3_t02.mkv'), + 'name': 'Disc 3_t02.mkv', + 'size_bytes': 2.1 * 1024**3, # 2.1GB + 'size_gb': 2.1, + 'duration_minutes': 58.0, + } + ] + +def create_mock_tvdb_episodes(): + """Create mock TVDB episode data.""" + return [ + {'episode_number': 1, 'name': 'Winter Is Coming', 'runtime': 61}, + {'episode_number': 2, 'name': 'The Kingsroad', 'runtime': 55}, + {'episode_number': 3, 'name': 'Lord Snow', 'runtime': 57}, + {'episode_number': 4, 'name': 'Cripples, Bastards, and Broken Things', 'runtime': 55}, + {'episode_number': 5, 'name': 'The Wolf and the Lion', 'runtime': 54}, + {'episode_number': 6, 'name': 'A Golden Crown', 'runtime': 52}, + {'episode_number': 7, 'name': 'You Win or You Die', 'runtime': 57}, + {'episode_number': 8, 'name': 'The Pointy End', 'runtime': 58}, + {'episode_number': 9, 'name': 'Baelor', 'runtime': 56}, + {'episode_number': 10, 'name': 'Fire and Blood', 'runtime': 52}, + ] + +def test_episode_matching(): + """Test the episode matching logic.""" + print("=== Testing Episode Matching Logic ===\n") + + # Create mock data + episodes = create_mock_episodes() + tvdb_episodes = create_mock_tvdb_episodes() + + print(f"Mock episode files ({len(episodes)}):") + for ep in episodes: + print(f" - {ep['name']} ({ep['duration_minutes']} min)") + + print(f"\nTVDB episodes ({len(tvdb_episodes)}):") + for ep in tvdb_episodes: + print(f" - Episode {ep['episode_number']}: {ep['name']} ({ep['runtime']} min)") + + # Initialize renamer + renamer = EpisodeRenamer("/tmp/test", "Game of Thrones", 1) + + # Test the matching logic + print("\n" + "="*80) + print("TESTING EPISODE MATCHING") + print("="*80) + + try: + matched_episodes = renamer._match_episodes_by_duration(episodes, tvdb_episodes) + + print(f"\n=== MATCHING RESULTS ===") + print(f"Successfully matched {len(matched_episodes)} episodes:") + + for match in matched_episodes: + file_name = match['file_info']['path'].name + episode_num = match['episode_number'] + tvdb_name = match['tvdb_info']['name'] if match['tvdb_info'] else 'Unknown' + duration_diff = match.get('duration_diff', 'N/A') + + print(f" {file_name} → Episode {episode_num}: {tvdb_name}") + if duration_diff != 'N/A' and duration_diff is not None: + print(f" Duration difference: {duration_diff:.1f} minutes") + + # Test validity of assignments + print(f"\n=== ASSIGNMENT VALIDATION ===") + episode_numbers = [m['episode_number'] for m in matched_episodes] + if len(set(episode_numbers)) == len(episode_numbers): + print("✓ All episodes assigned unique episode numbers") + else: + print("✗ Duplicate episode number assignments detected!") + duplicates = [x for x in episode_numbers if episode_numbers.count(x) > 1] + print(f" Duplicates: {set(duplicates)}") + + # Check disc order constraint + disc_assignments = {} + for match in matched_episodes: + file_name = match['file_info']['path'].name + episode_num = match['episode_number'] + + # Extract disc number + if 'Disc 1' in file_name: + disc = 1 + elif 'Disc 2' in file_name: + disc = 2 + elif 'Disc 3' in file_name: + disc = 3 + else: + disc = None + + if disc: + if disc not in disc_assignments: + disc_assignments[disc] = [] + disc_assignments[disc].append(episode_num) + + print(f"\nDisc assignment validation:") + for disc in sorted(disc_assignments.keys()): + episodes = sorted(disc_assignments[disc]) + print(f" Disc {disc}: Episodes {episodes}") + + # Check if episodes are in reasonable order (earlier discs should have earlier episodes) + if disc == 1: + expected_range = list(range(1, 4)) # Episodes 1-3 + elif disc == 2: + expected_range = list(range(4, 7)) # Episodes 4-6 + elif disc == 3: + expected_range = list(range(7, 9)) # Episodes 7-8 + + if set(episodes).issubset(set(expected_range)): + print(f" ✓ Episodes {episodes} are within expected range {expected_range}") + else: + print(f" ⚠ Episodes {episodes} outside expected range {expected_range}") + + except Exception as e: + print(f"Error during matching: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + test_episode_matching()