2 Commits
Author SHA1 Message Date
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
Bulk RenamerandCursor fce5ad64e5 Release v1.0.11: fix multi-episode match for end-numbered and dual titles.
Combined pairs are recognized when the SxxExx tag uses the last episode number or the filename lists both story titles separated by dashes.

Co-authored-by: Cursor <[email protected]>
2026-07-03 18:34:24 -05:00
6 changed files with 161 additions and 63 deletions
+1 -1
View File
@@ -1 +1 @@
1.0.10
1.0.12
+32 -7
View File
@@ -269,21 +269,46 @@ def _combined_title_variants(name: str) -> list[str]:
return variants
def _combined_match_score(fnorm: str, combined_name: str) -> float:
def _combined_match_score(fnorm: str, combined_name: str, file_title: str = "") -> float:
variants = _combined_title_variants(combined_name)
return max((_similarity(fnorm, v) for v in variants), default=0.0)
best = max((_similarity(fnorm, v) for v in variants), default=0.0)
if file_title and _dual_title_matches_combined(file_title, combined_name):
best = max(best, 0.96)
return best
def _combined_allowed_for_file(parsed: dict, target: EpisodeTarget) -> bool:
"""Only treat as multi-episode when the file's episode code fits the range start."""
def _dual_title_matches_combined(file_title: str, combined_name: str) -> bool:
"""True when the filename lists both stories in a combined-order pair."""
file_parts = [p.strip() for p in re.split(r"\s+-\s+", file_title) if p.strip()]
combined_parts = split_combined_title(combined_name)
if len(file_parts) < 2 or len(combined_parts) < 2:
return False
matched = 0
for fp in file_parts[: len(combined_parts)]:
fn = normalize_title(fp)
if max(_similarity(fn, normalize_title(cp)) for cp in combined_parts) >= 0.72:
matched += 1
return matched >= 2
def _combined_allowed_for_file(
parsed: dict,
target: EpisodeTarget,
combined_name: str = "",
) -> bool:
"""Treat as multi-episode when the episode tag or dual title fits the aired range."""
if target.span <= 1:
return True
if parsed.get("span", 1) > 1:
return True
file_title = parsed.get("title") or ""
if combined_name and _dual_title_matches_combined(file_title, combined_name):
return True
file_ep = parsed.get("old_first")
if file_ep is None:
return False
return file_ep == target.episode
end = target.episode_end if target.episode_end is not None else target.episode
return file_ep == target.episode or file_ep == end
def _range_overlaps(
@@ -390,9 +415,9 @@ def match_filenames_to_episodes(
for _cep, target, variants in combined_entries:
if not fnorm:
continue
if not _combined_allowed_for_file(parsed, target):
if not _combined_allowed_for_file(parsed, target, _cep.name):
continue
best = _combined_match_score(fnorm, _cep.name)
best = _combined_match_score(fnorm, _cep.name, raw_title)
best = _apply_season_hint(best, target, parsed)
pairs.append((best, fname, target, raw_title, _cep.name))
+82 -51
View File
@@ -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,
)
+40 -4
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
HSRename 1.0.11
- Fix multi-episode detection when filename uses end episode (S01E06 for E05-E06)
- Detect dual titles separated by " - " (Otto 3000 - Night Prowler) as combined pairs
+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