From 061a3bdeb39b489ba0738e371538522ef5357560 Mon Sep 17 00:00:00 2001 From: Jarian Date: Sun, 5 Jul 2026 17:27:59 +0000 Subject: [PATCH] fix: replace greedy episode matcher with DP-based optimal assignment - Replace greedy _precise_duration_match with O(n*m) DP for minimum-cost duration matching, ensuring globally optimal file-to-episode assignment - Fix _dp_match_episodes: remove buggy new_cost2 transition that could double-assign episodes, fix backtracking logic - Add _fallback_sequential helper for clean sequential fallback path - All 8 mock episodes now match with 0-min duration difference, disc ordering preserved (D1=[1-3], D2=[4-6], D3=[7-8]) --- .../__pycache__/__init__.cpython-313.pyc | Bin 400 -> 386 bytes .../episode_renamer.cpython-313.pyc | Bin 41763 -> 52816 bytes .../file_classifier.cpython-313.pyc | Bin 18205 -> 21272 bytes src/Matcher/episode_renamer.py | 294 ++++++++---------- .../__pycache__/__init__.cpython-313.pyc | Bin 323 -> 309 bytes .../__pycache__/tvdb_cache.cpython-313.pyc | Bin 7366 -> 8324 bytes .../__pycache__/tvdb_client.cpython-313.pyc | Bin 8804 -> 8790 bytes 7 files changed, 135 insertions(+), 159 deletions(-) diff --git a/src/Matcher/__pycache__/__init__.cpython-313.pyc b/src/Matcher/__pycache__/__init__.cpython-313.pyc index 55ff7a0970eb32435542a5e39b8568e5b6f5dc88..5d07ec2d21972e25caf7034e1bd79e50a2a143e3 100644 GIT binary patch delta 44 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 delta 8901 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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 07f48455bf9dc2d7d49ab1960b1d289f2023ec2e..469abb3f427ffd78b88402568eef315454508ff5 100644 GIT binary patch delta 6791 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 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 066d4c633fc2323f853697d14165cdb8f4cc8e88..0e3d8c6d8207857434b102101caedf9613190b66 100644 GIT binary patch delta 42 wcmX@iw3UhHGcPX}0}yQE_sW{cvs=hcKO;XkRX;1SC^IonzbLgJf8z5P00jCC_5c6? delta 56 zcmdnWbeM_fGcPX}0}w=-PtBOfvs=MOKeRZts8~NMu_!Y!FFC)YBr`8PBQaM$peR2p KHMwNs%@_dlh!fBN diff --git a/src/TVDBProvider/__pycache__/tvdb_cache.cpython-313.pyc b/src/TVDBProvider/__pycache__/tvdb_cache.cpython-313.pyc index cb7281b2c89c7bfb40a359ac95f6b3249bf07d0c..f6fde8420bfd94c0b94c80be0462aa160c65f24b 100644 GIT binary patch delta 2336 zcmZWrO>7&-72et9&yu1@iKHY_Kk&K=EfWzbwUR1JQ3G4Hlt_u0Kx;Z_#adaDV`&v? zSDjtiHG!51kQOe|6tNeEZ-vkkTNJ1OT@)aP1x|~2wCms_9!2TYLt&+Flt4Clw({MSL3W_ zy(itbe@xDc(Tign<`x~>)R?XrV~lQ_j^g=r%429-)b%lH?=Yh%LJIAM=}7Q>*({Js zRPhzT5c6b3axO*teTI}5SG>-<;aIn%dh*f~Izt1!Qb-WgKuh+49Bj&3L=aX3JS2eHktcIvSeOu}NK;2iUV2VQhbq&mX6UwFp^8PB zPAvtuq}Zmi&FF1v*vhtMZzwjaYPTt~w6dbvwpM)G$}&tYR|&7)&@z?r%*ySHKbL%2fP8rThv1~2TlzSqR%@&7UlW2wcVd@%_}`iWVk{9M`RJVTv>Gf z?mJr@RXEW!o0gEomaW}Y?&$W0ZYV`O?6s24vK4Q3(=v_9AV2>m`>0jawy9F0HZ8(r z)4yBpPM=CnYkA{jtw2fQirirBQ|nR{vs0n@WT5)I*N=OMn--s z)vo+5_v_rz==|a6d}H)VJ&R${XN zt+#3a7yj=NGVA&?=eU|4pcykfr^P zJHH}db6G-{W#`k#{YNXrbF!MYRGGL8NdzbmI+Q< zn$3Dp*@bWwA%|v9X=iJjy^dmF;fF=KZR@6y4m2=jIWvAzXKU*>#az z-T!OXYhoY9VQ&GXrDg=I4!A7DCJEiI#NLytCDgnOkYmdzoZDmp=CH3-b>T250-J6%JlWxQQN_4Gg?ie;&@YX`$F_IK;BLowt~|by`(d zTBkN!KKEO%yp{ z@x5mFKZ67Qh*^IG_^I$GPv)^F;2qx`{z`!Eq*wMPcPCB+=xP&BusexM-V3$i69Kw| zBJVyaKEcsR(C6*1#ZCn1_To>lJL&R!FOu4GCj#`&-oU%QpWvc7XL@k1|Jih0d2Y)r Z?Ud<@?B~u0gXg>FMY2NvBk+!A{Xg3NKZ*bV delta 1335 zcmZ8gOK2Nc6rDF8eXP$&mi);`j^z)=vST?!a_V1fi#Bm+Xg+?Z1gbKO#}iwwG;-gJ zaGQl4iYW~QLV2WXFWpV*qD@x)EE>G4j0rD&tsp4|Jpu)=n+@s2*MoUF(LxyiHPG^C`vgViwd_ct|Q{X zQc=R5q8EFC_h296i4XgM^%ng(5x2Qa8x9oxzyyE^x=iquFGIy}QI_CD_Jb@TT)sMPU5SvbVvPnG|Z>2j~6V1SmWy&DQTu& zwe6C*x~5geZ<6Y|q1$v&{B}A3l9FMmrdBbWkY#9A)l|(|W!WH(hZuH^n7ROUzI;S* zSp1yOd|-gC`r{sqFh9Xl&99^|%x!{iX-%OZggGo0cr3|WRv6^c9{Q^{H;YmZQXMqr z@T%nq=P#_Jc}FT&H{nDtF*eGYZlp!h30X*zK@Wf$Gp)#?rKgk{`^T*2Kp|W@&9N;76)+h3h?R|}C|8D>8yZhP3;A;(e zvf-PgU-){$tR1(dA#fhioBpo$0)om>mLCgn9MsL-aB!pc@-@33~Pa zZxzH%-ztNBdWFGd28%QwUqB1>kK#A@E|!Me0!Rz31euoPx6T~8UGM4I5k?D)nF6>@ zW&oYmz8!DDe9x?In(pEWn=6^7-z%NU=@6Wdu~D+BD~769Yo-mo{;hPQ5!#*TMVIJg zVhqjDJBdZKMIR?Jk+)ff(=N#Z{Ug!ixyZ;Im6JmrRwT*OndHEbTcg{H+ZC&dr~r)!Vq}RVn-bbi|UOhWEmaW+(UDa%xl$L8Y{3BJO(dOCY8()LsW9}z$?3w77&hHMKaA2C9 zqBONP(B!~8(OvV6{(?o#ATQ9y2ww1&_U`jc~wEBgm^2R{D* diff --git a/src/TVDBProvider/__pycache__/tvdb_client.cpython-313.pyc b/src/TVDBProvider/__pycache__/tvdb_client.cpython-313.pyc index 6350c8046d44be4a37add77108f53cc7dbde9f44..e87ea03890f2838dd1a676a6bbc92cc4a51b49ee 100644 GIT binary patch delta 45 zcmaFja?OS3GcPX}0}yQE_sZJHBgrh}sGpIao2s9cSd^KVr(cv>kiXfLSzaChIO`3i delta 59 zcmccS^2CMbGcPX}0}vb&n3l1TN0M2=Uq7@swWwG>E3qguF)um4q$D#hJtHw!KcFZ- ND>b=fvpKW8JOD&P6UG1l