diff --git a/src/Matcher/__pycache__/__init__.cpython-313.pyc b/src/Matcher/__pycache__/__init__.cpython-313.pyc index 55ff7a0..5d07ec2 100644 Binary files a/src/Matcher/__pycache__/__init__.cpython-313.pyc and b/src/Matcher/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/Matcher/__pycache__/episode_renamer.cpython-313.pyc b/src/Matcher/__pycache__/episode_renamer.cpython-313.pyc index 92f7a2a..4041484 100644 Binary files a/src/Matcher/__pycache__/episode_renamer.cpython-313.pyc and b/src/Matcher/__pycache__/episode_renamer.cpython-313.pyc differ diff --git a/src/Matcher/__pycache__/file_classifier.cpython-313.pyc b/src/Matcher/__pycache__/file_classifier.cpython-313.pyc index 07f4845..469abb3 100644 Binary files a/src/Matcher/__pycache__/file_classifier.cpython-313.pyc and b/src/Matcher/__pycache__/file_classifier.cpython-313.pyc differ diff --git a/src/Matcher/episode_renamer.py b/src/Matcher/episode_renamer.py index 394f3ce..c693fe1 100644 --- a/src/Matcher/episode_renamer.py +++ b/src/Matcher/episode_renamer.py @@ -995,9 +995,8 @@ class EpisodeRenamer: print(f" DP fallback: {fn}... → Episode {match['episode_number']} (no duration match)") return matched_episodes - def _dp_match_episodes(self, files: List[Dict], tvdb_episodes: List[Dict], - allowed_episodes: List[int]) -> List[Dict]: + allowed_episodes: List[int]) -> List[Dict]: """Dynamic programming matcher considering disc size, episode deltas, and TVDB duration. Builds a cost matrix over (file, episode) pairs where cost = |file_dur - tvdb_dur|, @@ -1013,99 +1012,52 @@ class EpisodeRenamer: if not n_files or not n_episodes: return self._precise_duration_match(files, tvdb_episodes, allowed_episodes) - # Build allowed episode index set for fast lookup allowed_set = set(allowed_episodes) - episode_map = {ep['episode_number']: ep for ep in tvdb_episodes} - # Cost matrix: cost[f][e] = |file_duration - tvdb_duration| or INF if not allowed INF = float('inf') cost = [[INF] * n_episodes for _ in range(n_files)] for fi in range(n_files): fdur = files[fi]['duration'] for ei in range(n_episodes): - ep = tvdb_episodes[ei] - ep_num = ep['episode_number'] + ep_num = tvdb_episodes[ei]['episode_number'] if ep_num not in allowed_set: continue - tdur = ep.get('runtime', 0) - if tdur == 0: - cost[fi][ei] = 0 # No duration info, free assignment - else: - cost[fi][ei] = abs(fdur - tdur) + tdur = tvdb_episodes[ei].get('runtime', 0) + cost[fi][ei] = 0.0 if tdur == 0 else abs(fdur - tdur) - # Disc boundary info: for each file, which disc it belongs to - file_discs = [f['disc_number'] for f in files] - - # DP: dp[fi][ei] = min cost to assign files 0..fi-1 using episodes 0..ei-1 - # We process files in order and track the best episode assignment. - # dp[fi][ei] = min over k<=ei of (dp[fi-1][k-1] + cost[fi-1][ei]) - # This is the classic assignment DP with O(n*m) states. - - # dp[ei] = min cost to assign all files processed so far, with the last file - # assigned to episode ei (or skipped). We use a 2D DP for clarity. + # DP: dp[i][j] = min cost to assign first i files from first j episodes + # Transition: + # dp[i][j] = min(dp[i][j-1], # skip episode j-1 + # dp[i-1][j-1] + cost) # assign file i-1 to episode j-1 dp = [[INF] * (n_episodes + 1) for _ in range(n_files + 1)] - dp[0][0] = 0 # Base: 0 files, 0 episodes, 0 cost + for j in range(n_episodes + 1): + dp[0][j] = 0.0 - # parent[fi][ei] = which episode index was used for file fi-1 - parent = [[-1] * (n_episodes + 1) for _ in range(n_files + 1)] + for i in range(1, n_files + 1): + dp[i][0] = INF + for j in range(1, n_episodes + 1): + dp[i][j] = dp[i][j - 1] + c = cost[i - 1][j - 1] + if c < INF and dp[i - 1][j - 1] < INF: + dp[i][j] = min(dp[i][j], dp[i - 1][j - 1] + c) - for fi in range(1, n_files + 1): - # Option: skip file fi-1 (leave unassigned) - for ei in range(n_episodes + 1): - dp[fi][ei] = dp[fi - 1][ei] + # Backtrack + best_j = min(range(1, n_episodes + 1), key=lambda j: dp[n_files][j]) + if dp[n_files][best_j] >= INF: + return self._fallback_sequential(files, tvdb_episodes, allowed_episodes) - # Option: assign file fi-1 to episode ei-1 - for ei in range(1, n_episodes + 1): - c = cost[fi - 1][ei - 1] - if c == INF: - continue - # Check disc ordering: if this file is from disc D, ensure no file from - # disc > D has been assigned to an earlier episode. - # Simplified: just check that previous assignment was from <= disc - prev_ep = parent[fi - 1][ei] - if prev_ep >= 0: - # Find which file was assigned to prev_ep - prev_file_idx = fi - 1 - while prev_file_idx > 0 and parent[prev_file_idx][prev_ep + 1] == -1: - # Trace back to find actual previous assignment - prev_file_idx -= 1 - if prev_file_idx > 0 and file_discs[prev_file_idx - 1] is not None: - if file_discs[prev_file_idx - 1] > file_discs[fi - 1]: - # Previous file was from a later disc - skip - continue - - new_cost = dp[fi - 1][ei - 1] + c - if new_cost < dp[fi][ei]: - dp[fi][ei] = new_cost - parent[fi][ei] = ei - 1 - - # Also check assigning file fi-1 to episode ei directly - new_cost2 = dp[fi - 1][ei] + c if dp[fi - 1][ei] < INF else INF - if new_cost2 < dp[fi][ei]: - dp[fi][ei] = new_cost2 - parent[fi][ei] = ei - 1 - - # Backtrack to find assignment - assignment = {} # file_idx -> episode_idx - fi, ei = n_files, n_episodes - # Find best ei for the last file row - best_ei = min(range(n_episodes + 1), key=lambda x: dp[n_files][x]) - ei = best_ei - - while fi > 0 and ei > 0: - if dp[fi][ei] == dp[fi - 1][ei]: - # File fi-1 was skipped - fi -= 1 - elif parent[fi][ei] >= 0: - ep_idx = parent[fi][ei] - assignment[fi - 1] = ep_idx - fi -= 1 - ei -= 1 + assignment = {} + i, j = n_files, best_j + while i > 0 and j > 0: + if dp[i][j] == dp[i][j - 1]: + j -= 1 + elif dp[i - 1][j - 1] < INF and cost[i - 1][j - 1] < INF: + assignment[i - 1] = j - 1 + i -= 1 + j -= 1 else: - fi -= 1 - ei -= 1 + break - # Build result from assignment matched_episodes = [] for fi_idx, ep_idx in assignment.items(): ep = tvdb_episodes[ep_idx] @@ -1119,11 +1071,10 @@ class EpisodeRenamer: 'duration_diff': diff, }) - # Handle unassigned files with sequential fallback assigned_files = set(assignment.keys()) assigned_eps = set(assignment.values()) - remaining_files = [f for i, f in enumerate(files) if i not in assigned_files] - remaining_eps = [tvdb_episodes[i] for i in range(n_episodes) if i not in assigned_eps] + remaining_files = [f for idx, f in enumerate(files) if idx not in assigned_files] + remaining_eps = [tvdb_episodes[idx] for idx in range(n_episodes) if idx not in assigned_eps] remaining_eps.sort(key=lambda x: x['episode_number']) for fi, ep in zip(remaining_files, remaining_eps): @@ -1137,87 +1088,112 @@ class EpisodeRenamer: return matched_episodes def _precise_duration_match(self, files: List[Dict], tvdb_episodes: List[Dict], - allowed_episodes: List[int]) -> List[Dict]: - """Perform precise duration matching with fallback to lexicographical order. + allowed_episodes: List[int]) -> List[Dict]: + """DP-based optimal duration matching within allowed episodes. - NOTE: This is the greedy fallback. The primary matcher is _dp_match_episodes - which uses dynamic programming for optimal assignment. + Replaces the greedy approach with O(n*m) DP for minimum-cost assignment, + ensuring no file is assigned to a worse episode when a better global + assignment exists. """ + n_files = len(files) + allowed_set = set(allowed_episodes) + allowed_tvdb = sorted( + [ep for ep in tvdb_episodes if ep['episode_number'] in allowed_set], + key=lambda x: x['episode_number'], + ) + n_eps = len(allowed_tvdb) + + if not n_files or not n_eps: + return self._fallback_sequential(files, allowed_tvdb, allowed_episodes) + + INF = float('inf') + + # Cost matrix: cost[fi][ei] = |file_duration - tvdb_duration| + cost = [[INF] * n_eps for _ in range(n_files)] + for fi in range(n_files): + fdur = files[fi]['duration'] + for ei in range(n_eps): + tdur = allowed_tvdb[ei].get('runtime', 0) + cost[fi][ei] = 0.0 if tdur == 0 else abs(fdur - tdur) + + # DP: dp[i][j] = min cost to assign first i files from first j episodes + # dp[i][j] = min(dp[i][j-1], dp[i-1][j-1] + cost[i-1][j-1]) + dp = [[INF] * (n_eps + 1) for _ in range(n_files + 1)] + for j in range(n_eps + 1): + dp[0][j] = 0.0 + + for i in range(1, n_files + 1): + dp[i][0] = INF + for j in range(1, n_eps + 1): + # Skip episode j-1 + dp[i][j] = dp[i][j - 1] + # Assign file i-1 to episode j-1 + c = cost[i - 1][j - 1] + if c < INF and dp[i - 1][j - 1] < INF: + dp[i][j] = min(dp[i][j], dp[i - 1][j - 1] + c) + + # Backtrack to recover assignment + assignment = {} + best_j = min(range(1, n_eps + 1), key=lambda j: dp[n_files][j]) + if dp[n_files][best_j] >= INF: + return self._fallback_sequential(files, allowed_tvdb, allowed_episodes) + + i, j = n_files, best_j + while i > 0 and j > 0: + if j > 0 and dp[i][j] == dp[i][j - 1]: + j -= 1 + elif dp[i - 1][j - 1] < INF and cost[i - 1][j - 1] < INF: + assignment[i - 1] = j - 1 + i -= 1 + j -= 1 + else: + break + matched_episodes = [] - used_files = set() - used_episodes = set() + for fi, ei in assignment.items(): + ep = allowed_tvdb[ei] + fdur = files[fi]['duration'] + tdur = ep.get('runtime', 0) + diff = abs(fdur - tdur) if tdur > 0 else None + matched_episodes.append({ + 'file_info': files[fi]['file_info'], + 'episode_number': ep['episode_number'], + 'tvdb_info': ep, + 'duration_diff': diff, + 'assignment_cost': diff or 0, + }) - # First pass: Find exact or very close duration matches (within 5 minutes) - for file_info in files: - if id(file_info) in used_files: - continue + # Fallback for unassigned files + assigned_fi = set(assignment.keys()) + assigned_ei = set(assignment.values()) + remaining_files = [f for idx, f in enumerate(files) if idx not in assigned_fi] + remaining_eps = [e for idx, e in enumerate(allowed_tvdb) if idx not in assigned_ei] - 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: - continue - if tvdb_ep['episode_number'] not in allowed_episodes: - continue - - tvdb_duration = tvdb_ep.get('runtime', 0) - if tvdb_duration == 0: - continue - - # 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 - best_match = tvdb_ep - - # Accept matches within 1 minute as good matches (strict duration matching) - if best_match and best_score <= 1.0: - matched_episodes.append({ - 'file_info': file_info['file_info'], - 'episode_number': best_match['episode_number'], - 'tvdb_info': best_match, - 'duration_diff': best_score - }) - used_files.add(id(file_info)) - used_episodes.add(best_match['episode_number']) - - # Diagnostics: print top few closest episodes by duration within allowed set - if diffs: - try: - diffs.sort(key=lambda x: x[2]) - top = ", ".join([f"E{ep}:{dur} (Δ{diff:.1f})" for ep, dur, diff in diffs[:3]]) - print(f" {file_info['filename']}: compare within {allowed_episodes} → {top}") - except Exception: - pass - - # Second pass: Handle remaining files with sequential assignment - remaining_files = [f for f in files if id(f) not in used_files] - remaining_files.sort(key=lambda x: x['filename']) - - available_episodes = [ep_num for ep_num in allowed_episodes if ep_num not in used_episodes] - available_episodes.sort() - - for i, file_info in enumerate(remaining_files): - if i < len(available_episodes): - episode_number = available_episodes[i] - - # Find corresponding TVDB episode - tvdb_match = None - for tvdb_ep in tvdb_episodes: - if tvdb_ep['episode_number'] == episode_number: - tvdb_match = tvdb_ep - break - - matched_episodes.append({ - 'file_info': file_info['file_info'], - 'episode_number': episode_number, - 'tvdb_info': tvdb_match, - 'duration_diff': None - }) + for fi, ep in zip(remaining_files, remaining_eps): + matched_episodes.append({ + 'file_info': fi['file_info'], + 'episode_number': ep['episode_number'], + 'tvdb_info': ep, + 'duration_diff': None, + 'assignment_cost': 0, + }) return matched_episodes + + def _fallback_sequential(self, files: List[Dict], tvdb_eps: List[Dict], + allowed_episodes: List[int]) -> List[Dict]: + """Sequential fallback when DP cannot produce an assignment.""" + matched = [] + sorted_files = sorted(files, key=lambda x: x['filename']) + allowed_set = set(allowed_episodes) + avail = [ep for ep in tvdb_eps if ep['episode_number'] in allowed_set] + for i, fi in enumerate(sorted_files): + if i < len(avail): + ep = avail[i] + matched.append({ + 'file_info': fi['file_info'], + 'episode_number': ep['episode_number'], + 'tvdb_info': ep, + 'duration_diff': None, + }) + return matched diff --git a/src/TVDBProvider/__pycache__/__init__.cpython-313.pyc b/src/TVDBProvider/__pycache__/__init__.cpython-313.pyc index 066d4c6..0e3d8c6 100644 Binary files a/src/TVDBProvider/__pycache__/__init__.cpython-313.pyc and b/src/TVDBProvider/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/TVDBProvider/__pycache__/tvdb_cache.cpython-313.pyc b/src/TVDBProvider/__pycache__/tvdb_cache.cpython-313.pyc index cb7281b..f6fde84 100644 Binary files a/src/TVDBProvider/__pycache__/tvdb_cache.cpython-313.pyc and b/src/TVDBProvider/__pycache__/tvdb_cache.cpython-313.pyc differ diff --git a/src/TVDBProvider/__pycache__/tvdb_client.cpython-313.pyc b/src/TVDBProvider/__pycache__/tvdb_client.cpython-313.pyc index 6350c80..e87ea03 100644 Binary files a/src/TVDBProvider/__pycache__/tvdb_client.cpython-313.pyc and b/src/TVDBProvider/__pycache__/tvdb_client.cpython-313.pyc differ