2 Commits
Author SHA1 Message Date
Bulk RenamerandCursor 64d7a30328 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]>
2026-07-03 20:40:44 -05:00
Bulk RenamerandCursor 445e24ecae 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 <[email protected]>
2026-07-03 20:25:58 -05:00
6 changed files with 208 additions and 90 deletions
+1 -1
View File
@@ -1 +1 @@
1.0.11 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):
+84 -52
View File
@@ -24,6 +24,12 @@ SEASON_TYPE_CHOICES: list[tuple[str, str]] = [
("Regional order", "regional"), ("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) @dataclass(frozen=True)
class TvdbSeries: class TvdbSeries:
@@ -154,37 +160,74 @@ class TvdbClient:
) )
return results 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, self,
series_id: int, batch: list[dict[str, Any]],
season: int, *,
season_type: str = "default", season: Optional[int] = None,
skip_specials: bool = False,
) -> list[TvdbEpisode]: ) -> 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] = [] episodes: list[TvdbEpisode] = []
page = 0 page = 0
while True: max_pages = 50
use_api_season = season is not None and not language
while page < max_pages:
params: dict[str, Any] = {"page": page}
if use_api_season:
params["season"] = season
payload = self._request( payload = self._request(
"GET", "GET",
f"/series/{series_id}/episodes/{season_type}", self._episodes_path(series_id, season_type, language),
params={"page": page, "season": season}, params=params,
) )
batch = (payload.get("data") or {}).get("episodes") or [] batch = (payload.get("data") or {}).get("episodes") or []
if not batch: if not batch:
break break
for ep in batch: filter_season = season if language else None
if ep.get("seasonNumber") != season: episodes.extend(
continue self._parse_episode_batch(
number = ep.get("number") batch,
name = ep.get("name") season=filter_season,
if number is None or not name: skip_specials=skip_specials,
continue
episodes.append(
TvdbEpisode(
number=int(number),
season_number=int(season),
name=str(name),
)
) )
)
links = payload.get("links") or {} links = payload.get("links") or {}
if not links.get("next"): if not links.get("next"):
break break
@@ -192,41 +235,30 @@ class TvdbClient:
episodes.sort(key=lambda e: (e.season_number, e.number)) episodes.sort(key=lambda e: (e.season_number, e.number))
return episodes 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( def get_all_episodes(
self, self,
series_id: int, series_id: int,
season_type: str = "default", season_type: str = "default",
language: str = "",
) -> list[TvdbEpisode]: ) -> list[TvdbEpisode]:
"""Fetch every episode for a series (all seasons), paginated.""" """Fetch every episode for a series (all seasons), paginated."""
episodes: list[TvdbEpisode] = [] return self._fetch_episodes(
page = 0 series_id,
while True: season_type,
payload = self._request( language=language,
"GET", skip_specials=True,
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
+95 -35
View File
@@ -31,8 +31,8 @@ from engine.rules import (
PrefixSuffixRule, PrefixSuffixRule,
CsvMappingRule, 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 from engine.episode_match import match_filenames_to_episodes, target_to_tuple
class ReplaceRuleWidget(QWidget): class ReplaceRuleWidget(QWidget):
@@ -340,6 +340,7 @@ class _TvdbMatchWorker(QThread):
filenames: list[str], filenames: list[str],
all_seasons: bool, all_seasons: bool,
multi_episode: bool, multi_episode: bool,
language: str,
): ):
super().__init__() super().__init__()
self.series_id = series_id self.series_id = series_id
@@ -348,49 +349,83 @@ class _TvdbMatchWorker(QThread):
self.filenames = filenames self.filenames = filenames
self.all_seasons = all_seasons self.all_seasons = all_seasons
self.multi_episode = multi_episode self.multi_episode = multi_episode
self.language = language
def run(self): def run(self):
try: try:
client = TvdbClient(TVDB_API_KEY) client = TvdbClient(TVDB_API_KEY)
if self.all_seasons: lang = self.language
official = client.get_all_episodes(self.series_id, season_type="official") season_filter = 0 if self.all_seasons else self.season
season_filter = 0 need_official = self.multi_episode or self.season_type == "alternate"
else:
official = client.get_season_episodes(
self.series_id,
self.season,
season_type="official",
)
season_filter = self.season
if self.all_seasons: if self.all_seasons:
episodes = client.get_all_episodes( episodes = client.get_all_episodes(
self.series_id, self.series_id,
season_type=self.season_type, season_type=self.season_type,
language=lang,
) )
else: else:
episodes = client.get_season_episodes( episodes = client.get_season_episodes(
self.series_id, self.series_id,
self.season, self.season,
season_type=self.season_type, season_type=self.season_type,
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,
season_type="alternate", season_type="alternate",
language=lang,
) )
else: else:
combined = client.get_season_episodes( combined = client.get_season_episodes(
self.series_id, self.series_id,
self.season, self.season,
season_type="alternate", season_type="alternate",
language=lang,
) )
if not combined and lang:
if self.season_type == "alternate": if self.all_seasons:
episodes = official 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 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}"
@@ -402,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):
@@ -450,6 +487,13 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
self.order_type.currentIndexChanged.connect(self._emit) self.order_type.currentIndexChanged.connect(self._emit)
layout.addRow("Episode order:", self.order_type) 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 = QSpinBox()
self.season.setMinimum(0) self.season.setMinimum(0)
self.season.setMaximum(99) self.season.setMaximum(99)
@@ -485,6 +529,7 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
info = QLabel( info = QLabel(
"Reads titles from S01E05 - Episode Title, S01E05-E06 (multi-episode), " "Reads titles from S01E05 - Episode Title, S01E05-E06 (multi-episode), "
"or Show 04x01 Episode Title filenames. " "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." "Use All seasons to match across every season and fix wrong season numbers too."
) )
info.setWordWrap(True) info.setWordWrap(True)
@@ -500,6 +545,13 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
value = self.order_type.currentData() value = self.order_type.currentData()
return value if value else "default" 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): def _emit(self):
self.ruleChanged.emit() self.ruleChanged.emit()
@@ -548,10 +600,13 @@ 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(
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…" f"{'all seasons' if all_seasons else f'season {self.season.value()}'}…) and matching titles…"
) )
self._match_worker = _TvdbMatchWorker( self._match_worker = _TvdbMatchWorker(
@@ -561,23 +616,29 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
self._file_names, self._file_names,
all_seasons, all_seasons,
self.multi_episode_cb.isChecked(), self.multi_episode_cb.isChecked(),
self._language_value(),
) )
self._match_worker.finished.connect(self._on_match_finished) self._match_worker.finished.connect(self._on_match_finished)
self._match_worker.failed.connect(self._on_match_failed) self._match_worker.failed.connect(self._on_match_failed)
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()})." 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)
@@ -586,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(
+3
View File
@@ -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
+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