diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 4778f48..8b69cd0 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -96,7 +96,7 @@ jobs: if: always() run: | if [[ -f Dockerfile ]]; then - docker build -t $GITHUB_REPOSITORY:test . + docker build -t $(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]'):test . else echo "No Dockerfile found, skipping docker build" fi diff --git a/pyproject.toml b/pyproject.toml index d489bc7..628e128 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] requires = ["setuptools>=68.0", "wheel"] -build-backend = "setuptools.backends._legacy:_Backend" +build-backend = "setuptools.build_meta" [project] name = "episode-matcher" 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 4d639ce..c693fe1 100644 --- a/src/Matcher/episode_renamer.py +++ b/src/Matcher/episode_renamer.py @@ -1,4 +1,43 @@ -"""Episode renamer for creating Jellyfin-compatible filenames.""" +"""Episode renamer for creating Jellyfin-compatible filenames. + +Architecture +------------ +The EpisodeRenamer class handles the full pipeline: + +1. **Duplicate Detection** (`detect_and_move_duplicates`) + Groups files by duration, uses SHA-256 hash for disambiguation. + Moves or deletes duplicates based on --force flag. + +2. **Episode Matching** (`_match_episodes_by_duration`) + Routes to one of three strategies: + - `_match_using_disc_mapping` — explicit disc→episode mapping from CLI + - `_dp_match_episodes` — DP-based optimal assignment (primary) + - `_precise_duration_match` — greedy fallback + +3. **File Renaming** (`rename_episodes`) + Applies Jellyfin naming convention: ``Show Name s01e01.mkv`` + +4. **Extras Handling** (`move_extras_to_folder`) + Moves non-episode files to ``extras/`` subfolder. + +Key algorithms +~~~~~~~~~~~~~~ +- ``_dp_match_episodes``: O(n*m) dynamic programming for minimum-cost assignment + considering disc capacity, disc ordering, and duration matching. +- ``detect_and_move_duplicates``: Duration grouping + SHA-256 first-N-bytes hash. +- ``_estimate_episodes_per_disc``: Storage-aware capacity estimation (45GB Blu-ray). + +Configuration +~~~~~~~~~~~~~ +- ``disc_mapping``: Optional explicit disc→episode mapping +- ``auto_delete_duplicates``: Only ``True`` with ``--force`` flag + +Safety +~~~~~~ +- ``auto_delete_duplicates=False`` by default +- ``--force`` required for actual file deletion +- Deletion manifest written to ``deletion_manifest.json`` +""" import os import shutil from pathlib import Path @@ -940,99 +979,221 @@ class EpisodeRenamer: return matched_episodes def _match_by_duration_and_order(self, episodes_info: List[Dict], tvdb_episodes: List[Dict]) -> List[Dict]: - """Match episodes using lexicographical order and precise duration matching.""" - matched_episodes = [] - - # Try precise duration matching first - duration_matches = self._precise_duration_match(episodes_info, tvdb_episodes, list(range(1, len(tvdb_episodes) + 1))) - - for match in duration_matches: - matched_episodes.append(match) + """Match episodes using DP-based optimal assignment considering disc size, episode deltas, and TVDB duration.""" + allowed = list(range(1, len(tvdb_episodes) + 1)) + + # Primary: DP-based optimal matching + print(" Running DP-based episode matcher (disc size + episode deltas + TVDB duration)...") + matched_episodes = self._dp_match_episodes(episodes_info, tvdb_episodes, allowed) + + for match in matched_episodes: duration_diff = match.get('duration_diff', 0) + fn = match['file_info']['path'].name[:50] if duration_diff is not None: - print(f" Duration match: {match['file_info']['path'].name[:50]}... → Episode {match['episode_number']} (Δ{duration_diff:.1f}min)") + print(f" DP match: {fn}... → Episode {match['episode_number']} (Δ{duration_diff:.1f}min)") else: - print(f" Sequential fallback: {match['file_info']['path'].name[:50]}... → Episode {match['episode_number']} (no duration match)") - + print(f" DP fallback: {fn}... → Episode {match['episode_number']} (no duration match)") + 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.""" + def _dp_match_episodes(self, files: List[Dict], tvdb_episodes: 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|, + then finds the minimum-cost assignment that respects: + - Disc capacity: each disc holds at most its capacity + - Disc ordering: files from earlier discs map to earlier episodes + - Episode deltas: consecutive files prefer consecutive episodes + + Returns matched episodes sorted by episode number. + """ + n_files = len(files) + n_episodes = len(tvdb_episodes) + if not n_files or not n_episodes: + return self._precise_duration_match(files, tvdb_episodes, allowed_episodes) + + allowed_set = set(allowed_episodes) + + 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_num = tvdb_episodes[ei]['episode_number'] + if ep_num not in allowed_set: + continue + tdur = tvdb_episodes[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 + # 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)] + for j in range(n_episodes + 1): + dp[0][j] = 0.0 + + 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) + + # 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) + + 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: + break + matched_episodes = [] - used_files = set() - used_episodes = set() - - # 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 - - 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_idx, ep_idx in assignment.items(): + ep = tvdb_episodes[ep_idx] + fdur = files[fi_idx]['duration'] + tdur = ep.get('runtime', 0) + diff = abs(fdur - tdur) if tdur > 0 else None + matched_episodes.append({ + 'file_info': files[fi_idx]['file_info'], + 'episode_number': ep['episode_number'], + 'tvdb_info': ep, + 'duration_diff': diff, + }) + + assigned_files = set(assignment.keys()) + assigned_eps = set(assignment.values()) + 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): + matched_episodes.append({ + 'file_info': fi['file_info'], + 'episode_number': ep['episode_number'], + 'tvdb_info': ep, + 'duration_diff': None, + }) + return matched_episodes + + def _precise_duration_match(self, files: List[Dict], tvdb_episodes: List[Dict], + allowed_episodes: List[int]) -> List[Dict]: + """DP-based optimal duration matching within allowed episodes. + + 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 = [] + 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, + }) + + # 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] + + 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