From 64d7a303286e2788e19be78941f94eeaa71b4fb9 Mon Sep 17 00:00:00 2001 From: Bulk Renamer Date: Fri, 3 Jul 2026 20:40:44 -0500 Subject: [PATCH] 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 --- VERSION | 2 +- engine/episode_match.py | 23 ++++++- engine/tvdb_client.py | 3 +- gui/rule_widgets.py | 98 +++++++++++++++++++----------- packaging/release-stamp-1.0.13.txt | 4 ++ 5 files changed, 89 insertions(+), 41 deletions(-) create mode 100644 packaging/release-stamp-1.0.13.txt diff --git a/VERSION b/VERSION index bb83058..2ac9634 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.12 +1.0.13 diff --git a/engine/episode_match.py b/engine/episode_match.py index 43f7aa3..46660be 100644 --- a/engine/episode_match.py +++ b/engine/episode_match.py @@ -45,6 +45,13 @@ class EpisodeTarget: 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]: """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()] @@ -395,7 +402,19 @@ def match_filenames_to_episodes( pairs: list[tuple[float, str, EpisodeTarget, str, str]] = [] 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: continue score = _apply_season_hint( @@ -412,7 +431,7 @@ def match_filenames_to_episodes( ep_name, ) ) - for _cep, target, variants in combined_entries: + for _cep, target, variants in season_combined: if not fnorm: continue if not _combined_allowed_for_file(parsed, target, _cep.name): diff --git a/engine/tvdb_client.py b/engine/tvdb_client.py index f7cfb88..c43be7b 100644 --- a/engine/tvdb_client.py +++ b/engine/tvdb_client.py @@ -206,8 +206,9 @@ class TvdbClient: """Fetch episodes; optional translated titles via language code (e.g. eng).""" episodes: list[TvdbEpisode] = [] page = 0 + max_pages = 50 use_api_season = season is not None and not language - while True: + while page < max_pages: params: dict[str, Any] = {"page": page} if use_api_season: params["season"] = season diff --git a/gui/rule_widgets.py b/gui/rule_widgets.py index c0e979a..9ce424a 100644 --- a/gui/rule_widgets.py +++ b/gui/rule_widgets.py @@ -32,7 +32,7 @@ from engine.rules import ( CsvMappingRule, ) 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): @@ -355,19 +355,8 @@ class _TvdbMatchWorker(QThread): try: client = TvdbClient(TVDB_API_KEY) lang = self.language - if self.all_seasons: - official = client.get_all_episodes( - 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 + season_filter = 0 if self.all_seasons else self.season + need_official = self.multi_episode or self.season_type == "alternate" if self.all_seasons: episodes = client.get_all_episodes( @@ -383,8 +372,38 @@ class _TvdbMatchWorker(QThread): 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 - if self.multi_episode or self.season_type == "alternate": + if need_official: if self.all_seasons: combined = client.get_all_episodes( self.series_id, @@ -399,7 +418,6 @@ class _TvdbMatchWorker(QThread): language=lang, ) if not combined and lang: - # Combined order may lack translations; fall back to show language. if self.all_seasons: combined = client.get_all_episodes( self.series_id, season_type="alternate", @@ -409,9 +427,6 @@ class _TvdbMatchWorker(QThread): self.series_id, self.season, season_type="alternate", ) - if self.season_type == "alternate": - episodes = official - if not episodes and not combined: label = "all seasons" if self.all_seasons else f"season {self.season}" raise TvdbError(f"No episodes found for {label}") @@ -422,11 +437,13 @@ class _TvdbMatchWorker(QThread): official_episodes=official, 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: self.failed.emit(str(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): @@ -583,6 +600,9 @@ class TvdbEpisodeRenumberRuleWidget(QWidget): series_id = self.series_combo.currentData() if series_id is None: 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) all_seasons = self.season.value() == 0 self.status.setText( @@ -603,17 +623,22 @@ class TvdbEpisodeRenumberRuleWidget(QWidget): self._match_worker.start() def _on_match_finished(self, mapping: dict, unmatched: list, notes: list): - self._episode_mapping = mapping - self.match_btn.setEnabled(True) - matched = len(mapping) - total = len(self._file_names) - scope = "all seasons" if self.season.value() == 0 else f"season {self.season.value()}" - msg = f"Matched {matched} of {total} file(s) ({scope}, {self._order_label()}, {self._language_label()})." - if unmatched: - msg += f" {len(unmatched)} file(s) unmatched." - self.status.setText(msg) - self.matchCompleted.emit() - self._emit() + try: + self._episode_mapping = mapping + self.match_btn.setEnabled(True) + matched = len(mapping) + total = len(self._file_names) + scope = "all seasons" if self.season.value() == 0 else f"season {self.season.value()}" + msg = f"Matched {matched} of {total} file(s) ({scope}, {self._order_label()}, {self._language_label()})." + if unmatched: + msg += f" {len(unmatched)} file(s) unmatched." + self.status.setText(msg) + 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): self.match_btn.setEnabled(True) @@ -622,11 +647,10 @@ class TvdbEpisodeRenumberRuleWidget(QWidget): def getRule(self) -> TvdbEpisodeRenumberRule: mapping: dict = {} for k, v in self._episode_mapping.items(): - if hasattr(v, "season"): - if getattr(v, "episode_end", None) and v.episode_end > v.episode: - mapping[k] = (v.season, v.episode, v.episode_end) - else: - mapping[k] = (v.season, v.episode) + if isinstance(v, tuple): + mapping[k] = v + elif hasattr(v, "season"): + mapping[k] = target_to_tuple(v) else: mapping[k] = v r = TvdbEpisodeRenumberRule( diff --git a/packaging/release-stamp-1.0.13.txt b/packaging/release-stamp-1.0.13.txt new file mode 100644 index 0000000..cb01bae --- /dev/null +++ b/packaging/release-stamp-1.0.13.txt @@ -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