Release v1.0.13: fix TheTVDB match crash and improve stability.

Emit plain tuples across thread boundaries, prevent overlapping match workers, scope episode comparisons by season, and cap API pagination.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Bulk Renamer
2026-07-03 20:40:44 -05:00
co-authored by Cursor
parent 445e24ecae
commit 64d7a30328
5 changed files with 89 additions and 41 deletions
+1 -1
View File
@@ -1 +1 @@
1.0.12 1.0.13
+21 -2
View File
@@ -45,6 +45,13 @@ class EpisodeTarget:
return f"S{s}E{e1}" return f"S{s}E{e1}"
def target_to_tuple(target: EpisodeTarget) -> tuple[int, ...]:
"""Plain tuple safe to pass through Qt signals."""
if target.episode_end is not None and target.episode_end > target.episode:
return (target.season, target.episode, target.episode_end)
return (target.season, target.episode)
def split_combined_title(name: str) -> list[str]: def split_combined_title(name: str) -> list[str]:
"""Split a combined-order episode title like 'Ep A/Ep B' into parts.""" """Split a combined-order episode title like 'Ep A/Ep B' into parts."""
return [p.strip() for p in name.replace(" / ", "/").split("/") if p.strip()] return [p.strip() for p in name.replace(" / ", "/").split("/") if p.strip()]
@@ -395,7 +402,19 @@ def match_filenames_to_episodes(
pairs: list[tuple[float, str, EpisodeTarget, str, str]] = [] pairs: list[tuple[float, str, EpisodeTarget, str, str]] = []
for fname, fnorm, raw_title, parsed in file_entries: for fname, fnorm, raw_title, parsed in file_entries:
for season, ep_num, enorm, ep_name in ep_entries: file_season = parsed.get("season")
if season_filter == 0 and file_season is not None:
season_eps = [e for e in ep_entries if e[0] == file_season]
season_combined = [
(cep, target, variants)
for cep, target, variants in combined_entries
if target.season == file_season
]
else:
season_eps = ep_entries
season_combined = combined_entries
for season, ep_num, enorm, ep_name in season_eps:
if not fnorm: if not fnorm:
continue continue
score = _apply_season_hint( score = _apply_season_hint(
@@ -412,7 +431,7 @@ def match_filenames_to_episodes(
ep_name, ep_name,
) )
) )
for _cep, target, variants in combined_entries: for _cep, target, variants in season_combined:
if not fnorm: if not fnorm:
continue continue
if not _combined_allowed_for_file(parsed, target, _cep.name): if not _combined_allowed_for_file(parsed, target, _cep.name):
+2 -1
View File
@@ -206,8 +206,9 @@ class TvdbClient:
"""Fetch episodes; optional translated titles via language code (e.g. eng).""" """Fetch episodes; optional translated titles via language code (e.g. eng)."""
episodes: list[TvdbEpisode] = [] episodes: list[TvdbEpisode] = []
page = 0 page = 0
max_pages = 50
use_api_season = season is not None and not language use_api_season = season is not None and not language
while True: while page < max_pages:
params: dict[str, Any] = {"page": page} params: dict[str, Any] = {"page": page}
if use_api_season: if use_api_season:
params["season"] = season params["season"] = season
+61 -37
View File
@@ -32,7 +32,7 @@ from engine.rules import (
CsvMappingRule, CsvMappingRule,
) )
from engine.tvdb_client import TvdbClient, TvdbError, TVDB_API_KEY, SEASON_TYPE_CHOICES, LANGUAGE_CHOICES from engine.tvdb_client import TvdbClient, TvdbError, TVDB_API_KEY, SEASON_TYPE_CHOICES, LANGUAGE_CHOICES
from engine.episode_match import match_filenames_to_episodes from engine.episode_match import match_filenames_to_episodes, target_to_tuple
class ReplaceRuleWidget(QWidget): class ReplaceRuleWidget(QWidget):
@@ -355,19 +355,8 @@ class _TvdbMatchWorker(QThread):
try: try:
client = TvdbClient(TVDB_API_KEY) client = TvdbClient(TVDB_API_KEY)
lang = self.language lang = self.language
if self.all_seasons: season_filter = 0 if self.all_seasons else self.season
official = client.get_all_episodes( need_official = self.multi_episode or self.season_type == "alternate"
self.series_id, season_type="official", language=lang,
)
season_filter = 0
else:
official = client.get_season_episodes(
self.series_id,
self.season,
season_type="official",
language=lang,
)
season_filter = self.season
if self.all_seasons: if self.all_seasons:
episodes = client.get_all_episodes( episodes = client.get_all_episodes(
@@ -383,8 +372,38 @@ class _TvdbMatchWorker(QThread):
language=lang, language=lang,
) )
if self.season_type == "alternate":
if self.all_seasons:
official = client.get_all_episodes(
self.series_id, season_type="official", language=lang,
)
else:
official = client.get_season_episodes(
self.series_id,
self.season,
season_type="official",
language=lang,
)
episodes = official
elif need_official:
if self.season_type == "official":
official = episodes
elif self.all_seasons:
official = client.get_all_episodes(
self.series_id, season_type="official", language=lang,
)
else:
official = client.get_season_episodes(
self.series_id,
self.season,
season_type="official",
language=lang,
)
else:
official = episodes
combined = None combined = None
if self.multi_episode or self.season_type == "alternate": if need_official:
if self.all_seasons: if self.all_seasons:
combined = client.get_all_episodes( combined = client.get_all_episodes(
self.series_id, self.series_id,
@@ -399,7 +418,6 @@ class _TvdbMatchWorker(QThread):
language=lang, language=lang,
) )
if not combined and lang: if not combined and lang:
# Combined order may lack translations; fall back to show language.
if self.all_seasons: if self.all_seasons:
combined = client.get_all_episodes( combined = client.get_all_episodes(
self.series_id, season_type="alternate", self.series_id, season_type="alternate",
@@ -409,9 +427,6 @@ class _TvdbMatchWorker(QThread):
self.series_id, self.season, season_type="alternate", self.series_id, self.season, season_type="alternate",
) )
if self.season_type == "alternate":
episodes = official
if not episodes and not combined: if not episodes and not combined:
label = "all seasons" if self.all_seasons else f"season {self.season}" label = "all seasons" if self.all_seasons else f"season {self.season}"
raise TvdbError(f"No episodes found for {label}") raise TvdbError(f"No episodes found for {label}")
@@ -422,11 +437,13 @@ class _TvdbMatchWorker(QThread):
official_episodes=official, official_episodes=official,
combined_episodes=combined, combined_episodes=combined,
) )
self.finished.emit(mapping, unmatched, notes) safe_mapping = {path: target_to_tuple(t) for path, t in mapping.items()}
self.finished.emit(safe_mapping, unmatched, notes)
except TvdbError as e: except TvdbError as e:
self.failed.emit(str(e)) self.failed.emit(str(e))
except Exception as e: except Exception as e:
self.failed.emit(str(e)) import traceback
self.failed.emit(f"{e}\n\n{traceback.format_exc()}")
class TvdbEpisodeRenumberRuleWidget(QWidget): class TvdbEpisodeRenumberRuleWidget(QWidget):
@@ -583,6 +600,9 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
series_id = self.series_combo.currentData() series_id = self.series_combo.currentData()
if series_id is None: if series_id is None:
return return
if self._match_worker is not None and self._match_worker.isRunning():
self.status.setText("Matching already in progress…")
return
self.match_btn.setEnabled(False) self.match_btn.setEnabled(False)
all_seasons = self.season.value() == 0 all_seasons = self.season.value() == 0
self.status.setText( self.status.setText(
@@ -603,17 +623,22 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
self._match_worker.start() self._match_worker.start()
def _on_match_finished(self, mapping: dict, unmatched: list, notes: list): def _on_match_finished(self, mapping: dict, unmatched: list, notes: list):
self._episode_mapping = mapping try:
self.match_btn.setEnabled(True) self._episode_mapping = mapping
matched = len(mapping) self.match_btn.setEnabled(True)
total = len(self._file_names) matched = len(mapping)
scope = "all seasons" if self.season.value() == 0 else f"season {self.season.value()}" total = len(self._file_names)
msg = f"Matched {matched} of {total} file(s) ({scope}, {self._order_label()}, {self._language_label()})." scope = "all seasons" if self.season.value() == 0 else f"season {self.season.value()}"
if unmatched: msg = f"Matched {matched} of {total} file(s) ({scope}, {self._order_label()}, {self._language_label()})."
msg += f" {len(unmatched)} file(s) unmatched." if unmatched:
self.status.setText(msg) msg += f" {len(unmatched)} file(s) unmatched."
self.matchCompleted.emit() self.status.setText(msg)
self._emit() self.matchCompleted.emit()
self._emit()
except Exception as e:
self.match_btn.setEnabled(True)
self.status.setText(f"Match error: {e}")
QMessageBox.warning(self, "Match error", str(e))
def _on_match_failed(self, message: str): def _on_match_failed(self, message: str):
self.match_btn.setEnabled(True) self.match_btn.setEnabled(True)
@@ -622,11 +647,10 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
def getRule(self) -> TvdbEpisodeRenumberRule: def getRule(self) -> TvdbEpisodeRenumberRule:
mapping: dict = {} mapping: dict = {}
for k, v in self._episode_mapping.items(): for k, v in self._episode_mapping.items():
if hasattr(v, "season"): if isinstance(v, tuple):
if getattr(v, "episode_end", None) and v.episode_end > v.episode: mapping[k] = v
mapping[k] = (v.season, v.episode, v.episode_end) elif hasattr(v, "season"):
else: mapping[k] = target_to_tuple(v)
mapping[k] = (v.season, v.episode)
else: else:
mapping[k] = v mapping[k] = v
r = TvdbEpisodeRenumberRule( r = TvdbEpisodeRenumberRule(
+4
View File
@@ -0,0 +1,4 @@
HSRename 1.0.13
- Fix crash during TheTVDB match (safe thread signal payloads, worker re-entry guard)
- Faster matching by scoping episodes to each file's season
- Fewer redundant TheTVDB API calls during match