Merge pull request 'fix: fix docker build for CI' (#31) from ci-fix into main
Reviewed-on: https://git.home.ms/jarianc/EpisodeMatcher/pulls/31
This commit is contained in:
commit
18b0683840
@ -96,7 +96,7 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
if [[ -f Dockerfile ]]; then
|
if [[ -f Dockerfile ]]; then
|
||||||
docker build -t $GITHUB_REPOSITORY:test .
|
docker build -t $(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]'):test .
|
||||||
else
|
else
|
||||||
echo "No Dockerfile found, skipping docker build"
|
echo "No Dockerfile found, skipping docker build"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=68.0", "wheel"]
|
requires = ["setuptools>=68.0", "wheel"]
|
||||||
build-backend = "setuptools.backends._legacy:_Backend"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "episode-matcher"
|
name = "episode-matcher"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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 os
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -940,99 +979,221 @@ class EpisodeRenamer:
|
|||||||
return matched_episodes
|
return matched_episodes
|
||||||
|
|
||||||
def _match_by_duration_and_order(self, episodes_info: List[Dict], tvdb_episodes: List[Dict]) -> List[Dict]:
|
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."""
|
"""Match episodes using DP-based optimal assignment considering disc size, episode deltas, and TVDB duration."""
|
||||||
matched_episodes = []
|
allowed = list(range(1, len(tvdb_episodes) + 1))
|
||||||
|
|
||||||
# Try precise duration matching first
|
# Primary: DP-based optimal matching
|
||||||
duration_matches = self._precise_duration_match(episodes_info, tvdb_episodes, list(range(1, len(tvdb_episodes) + 1)))
|
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 duration_matches:
|
for match in matched_episodes:
|
||||||
matched_episodes.append(match)
|
|
||||||
duration_diff = match.get('duration_diff', 0)
|
duration_diff = match.get('duration_diff', 0)
|
||||||
|
fn = match['file_info']['path'].name[:50]
|
||||||
if duration_diff is not None:
|
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:
|
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
|
return matched_episodes
|
||||||
|
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.
|
||||||
|
|
||||||
def _precise_duration_match(self, files: List[Dict], tvdb_episodes: List[Dict], allowed_episodes: List[int]) -> List[Dict]:
|
Builds a cost matrix over (file, episode) pairs where cost = |file_dur - tvdb_dur|,
|
||||||
"""Perform precise duration matching with fallback to lexicographical order."""
|
then finds the minimum-cost assignment that respects:
|
||||||
matched_episodes = []
|
- Disc capacity: each disc holds at most its capacity
|
||||||
used_files = set()
|
- Disc ordering: files from earlier discs map to earlier episodes
|
||||||
used_episodes = set()
|
- Episode deltas: consecutive files prefer consecutive episodes
|
||||||
|
|
||||||
# First pass: Find exact or very close duration matches (within 5 minutes)
|
Returns matched episodes sorted by episode number.
|
||||||
for file_info in files:
|
"""
|
||||||
if id(file_info) in used_files:
|
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
|
continue
|
||||||
|
tdur = tvdb_episodes[ei].get('runtime', 0)
|
||||||
|
cost[fi][ei] = 0.0 if tdur == 0 else abs(fdur - tdur)
|
||||||
|
|
||||||
file_duration = file_info['duration']
|
# DP: dp[i][j] = min cost to assign first i files from first j episodes
|
||||||
best_match = None
|
# Transition:
|
||||||
best_score = float('inf')
|
# dp[i][j] = min(dp[i][j-1], # skip episode j-1
|
||||||
diffs = [] # Diagnostics: collect per-episode duration differences
|
# 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 tvdb_ep in tvdb_episodes:
|
for i in range(1, n_files + 1):
|
||||||
if tvdb_ep['episode_number'] in used_episodes:
|
dp[i][0] = INF
|
||||||
continue
|
for j in range(1, n_episodes + 1):
|
||||||
if tvdb_ep['episode_number'] not in allowed_episodes:
|
dp[i][j] = dp[i][j - 1]
|
||||||
continue
|
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)
|
||||||
|
|
||||||
tvdb_duration = tvdb_ep.get('runtime', 0)
|
# Backtrack
|
||||||
if tvdb_duration == 0:
|
best_j = min(range(1, n_episodes + 1), key=lambda j: dp[n_files][j])
|
||||||
continue
|
if dp[n_files][best_j] >= INF:
|
||||||
|
return self._fallback_sequential(files, tvdb_episodes, allowed_episodes)
|
||||||
|
|
||||||
# Calculate duration difference
|
assignment = {}
|
||||||
duration_diff = abs(file_duration - tvdb_duration)
|
i, j = n_files, best_j
|
||||||
diffs.append((tvdb_ep['episode_number'], tvdb_duration, duration_diff))
|
while i > 0 and j > 0:
|
||||||
|
if dp[i][j] == dp[i][j - 1]:
|
||||||
if duration_diff < best_score:
|
j -= 1
|
||||||
best_score = duration_diff
|
elif dp[i - 1][j - 1] < INF and cost[i - 1][j - 1] < INF:
|
||||||
best_match = tvdb_ep
|
assignment[i - 1] = j - 1
|
||||||
|
i -= 1
|
||||||
# Accept matches within 1 minute as good matches (strict duration matching)
|
j -= 1
|
||||||
if best_match and best_score <= 1.0:
|
else:
|
||||||
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
|
break
|
||||||
|
|
||||||
|
matched_episodes = []
|
||||||
|
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({
|
matched_episodes.append({
|
||||||
'file_info': file_info['file_info'],
|
'file_info': files[fi_idx]['file_info'],
|
||||||
'episode_number': episode_number,
|
'episode_number': ep['episode_number'],
|
||||||
'tvdb_info': tvdb_match,
|
'tvdb_info': ep,
|
||||||
'duration_diff': None
|
'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
|
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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user