From 445e24ecae83ee7e3af112b715a5575899389d3a Mon Sep 17 00:00:00 2001 From: Bulk Renamer Date: Fri, 3 Jul 2026 20:25:58 -0500 Subject: [PATCH] Release v1.0.12: fetch English TheTVDB episode titles for matching. Anime and other non-English shows returned Japanese default titles while filenames use English; add Episode titles language control defaulting to English. Co-authored-by: Cursor --- VERSION | 2 +- engine/tvdb_client.py | 133 ++++++++++++++++++----------- gui/rule_widgets.py | 44 +++++++++- packaging/release-stamp-1.0.12.txt | 3 + 4 files changed, 126 insertions(+), 56 deletions(-) create mode 100644 packaging/release-stamp-1.0.12.txt diff --git a/VERSION b/VERSION index 59e9e60..bb83058 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.11 +1.0.12 diff --git a/engine/tvdb_client.py b/engine/tvdb_client.py index c74c3d9..f7cfb88 100644 --- a/engine/tvdb_client.py +++ b/engine/tvdb_client.py @@ -24,6 +24,12 @@ SEASON_TYPE_CHOICES: list[tuple[str, str]] = [ ("Regional order", "regional"), ] +# Language path segment for /series/{id}/episodes/{season-type}/{language} +LANGUAGE_CHOICES: list[tuple[str, str]] = [ + ("Default (show language)", ""), + ("English", "eng"), +] + @dataclass(frozen=True) class TvdbSeries: @@ -154,37 +160,73 @@ class TvdbClient: ) return results - def get_season_episodes( + def _episodes_path(self, series_id: int, season_type: str, language: str = "") -> str: + base = f"/series/{series_id}/episodes/{season_type}" + if language: + return f"{base}/{language}" + return base + + def _parse_episode_batch( self, - series_id: int, - season: int, - season_type: str = "default", + batch: list[dict[str, Any]], + *, + season: Optional[int] = None, + skip_specials: bool = False, ) -> list[TvdbEpisode]: + episodes: list[TvdbEpisode] = [] + for ep in batch: + ep_season = ep.get("seasonNumber") + number = ep.get("number") + name = ep.get("name") + if number is None or ep_season is None or not name: + continue + ep_season = int(ep_season) + if skip_specials and ep_season == 0: + continue + if season is not None and ep_season != season: + continue + episodes.append( + TvdbEpisode( + number=int(number), + season_number=ep_season, + name=str(name), + ) + ) + return episodes + + def _fetch_episodes( + self, + series_id: int, + season_type: str, + *, + season: Optional[int] = None, + language: str = "", + skip_specials: bool = False, + ) -> list[TvdbEpisode]: + """Fetch episodes; optional translated titles via language code (e.g. eng).""" episodes: list[TvdbEpisode] = [] page = 0 + use_api_season = season is not None and not language while True: + params: dict[str, Any] = {"page": page} + if use_api_season: + params["season"] = season payload = self._request( "GET", - f"/series/{series_id}/episodes/{season_type}", - params={"page": page, "season": season}, + self._episodes_path(series_id, season_type, language), + params=params, ) batch = (payload.get("data") or {}).get("episodes") or [] if not batch: break - for ep in batch: - if ep.get("seasonNumber") != season: - continue - number = ep.get("number") - name = ep.get("name") - if number is None or not name: - continue - episodes.append( - TvdbEpisode( - number=int(number), - season_number=int(season), - name=str(name), - ) + filter_season = season if language else None + episodes.extend( + self._parse_episode_batch( + batch, + season=filter_season, + skip_specials=skip_specials, ) + ) links = payload.get("links") or {} if not links.get("next"): break @@ -192,41 +234,30 @@ class TvdbClient: episodes.sort(key=lambda e: (e.season_number, e.number)) return episodes + def get_season_episodes( + self, + series_id: int, + season: int, + season_type: str = "default", + language: str = "", + ) -> list[TvdbEpisode]: + return self._fetch_episodes( + series_id, + season_type, + season=season, + language=language, + ) + def get_all_episodes( self, series_id: int, season_type: str = "default", + language: str = "", ) -> list[TvdbEpisode]: """Fetch every episode for a series (all seasons), paginated.""" - episodes: list[TvdbEpisode] = [] - page = 0 - while True: - payload = self._request( - "GET", - f"/series/{series_id}/episodes/{season_type}", - params={"page": page}, - ) - batch = (payload.get("data") or {}).get("episodes") or [] - if not batch: - break - for ep in batch: - number = ep.get("number") - season = ep.get("seasonNumber") - name = ep.get("name") - if number is None or season is None or not name: - continue - if int(season) == 0: - continue - episodes.append( - TvdbEpisode( - number=int(number), - season_number=int(season), - name=str(name), - ) - ) - links = payload.get("links") or {} - if not links.get("next"): - break - page += 1 - episodes.sort(key=lambda e: (e.season_number, e.number)) - return episodes + return self._fetch_episodes( + series_id, + season_type, + language=language, + skip_specials=True, + ) diff --git a/gui/rule_widgets.py b/gui/rule_widgets.py index 64afcd7..c0e979a 100644 --- a/gui/rule_widgets.py +++ b/gui/rule_widgets.py @@ -31,7 +31,7 @@ from engine.rules import ( PrefixSuffixRule, CsvMappingRule, ) -from engine.tvdb_client import TvdbClient, TvdbError, TVDB_API_KEY, SEASON_TYPE_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 @@ -340,6 +340,7 @@ class _TvdbMatchWorker(QThread): filenames: list[str], all_seasons: bool, multi_episode: bool, + language: str, ): super().__init__() self.series_id = series_id @@ -348,18 +349,23 @@ class _TvdbMatchWorker(QThread): self.filenames = filenames self.all_seasons = all_seasons self.multi_episode = multi_episode + self.language = language def run(self): try: client = TvdbClient(TVDB_API_KEY) + lang = self.language if self.all_seasons: - official = client.get_all_episodes(self.series_id, season_type="official") + 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 @@ -367,12 +373,14 @@ class _TvdbMatchWorker(QThread): episodes = client.get_all_episodes( self.series_id, season_type=self.season_type, + language=lang, ) else: episodes = client.get_season_episodes( self.series_id, self.season, season_type=self.season_type, + language=lang, ) combined = None @@ -381,13 +389,25 @@ class _TvdbMatchWorker(QThread): combined = client.get_all_episodes( self.series_id, season_type="alternate", + language=lang, ) else: combined = client.get_season_episodes( self.series_id, self.season, season_type="alternate", + 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", + ) + else: + combined = client.get_season_episodes( + self.series_id, self.season, season_type="alternate", + ) if self.season_type == "alternate": episodes = official @@ -450,6 +470,13 @@ class TvdbEpisodeRenumberRuleWidget(QWidget): self.order_type.currentIndexChanged.connect(self._emit) layout.addRow("Episode order:", self.order_type) + self.title_language = QComboBox() + for label, value in LANGUAGE_CHOICES: + self.title_language.addItem(label, value) + self.title_language.setCurrentIndex(1) # English — most filenames use it + self.title_language.currentIndexChanged.connect(self._emit) + layout.addRow("Episode titles:", self.title_language) + self.season = QSpinBox() self.season.setMinimum(0) self.season.setMaximum(99) @@ -485,6 +512,7 @@ class TvdbEpisodeRenumberRuleWidget(QWidget): info = QLabel( "Reads titles from S01E05 - Episode Title, S01E05-E06 (multi-episode), " "or Show 04x01 Episode Title filenames. " + "For non-English shows, set Episode titles to English if your filenames use English. " "Use All seasons to match across every season and fix wrong season numbers too." ) info.setWordWrap(True) @@ -500,6 +528,13 @@ class TvdbEpisodeRenumberRuleWidget(QWidget): value = self.order_type.currentData() return value if value else "default" + def _language_value(self) -> str: + value = self.title_language.currentData() + return value if value else "" + + def _language_label(self) -> str: + return self.title_language.currentText() + def _emit(self): self.ruleChanged.emit() @@ -551,7 +586,7 @@ class TvdbEpisodeRenumberRuleWidget(QWidget): self.match_btn.setEnabled(False) all_seasons = self.season.value() == 0 self.status.setText( - f"Fetching episodes ({self._order_label()}, " + f"Fetching episodes ({self._order_label()}, {self._language_label()}, " f"{'all seasons' if all_seasons else f'season {self.season.value()}'}…) and matching titles…" ) self._match_worker = _TvdbMatchWorker( @@ -561,6 +596,7 @@ class TvdbEpisodeRenumberRuleWidget(QWidget): self._file_names, all_seasons, self.multi_episode_cb.isChecked(), + self._language_value(), ) self._match_worker.finished.connect(self._on_match_finished) self._match_worker.failed.connect(self._on_match_failed) @@ -572,7 +608,7 @@ class TvdbEpisodeRenumberRuleWidget(QWidget): 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()})." + 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) diff --git a/packaging/release-stamp-1.0.12.txt b/packaging/release-stamp-1.0.12.txt new file mode 100644 index 0000000..1807605 --- /dev/null +++ b/packaging/release-stamp-1.0.12.txt @@ -0,0 +1,3 @@ +HSRename 1.0.12 +- Episode titles language selector (English / show default) for TheTVDB matching +- Fixes 0 matches on anime and other shows when filenames are English but TheTVDB default is Japanese