Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
445e24ecae |
+82
-51
@@ -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,73 @@ 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
|
||||||
|
use_api_season = season is not None and not language
|
||||||
while True:
|
while True:
|
||||||
|
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 +234,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
|
|
||||||
|
|||||||
+40
-4
@@ -31,7 +31,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -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,18 +349,23 @@ 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)
|
||||||
|
lang = self.language
|
||||||
if self.all_seasons:
|
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
|
season_filter = 0
|
||||||
else:
|
else:
|
||||||
official = client.get_season_episodes(
|
official = client.get_season_episodes(
|
||||||
self.series_id,
|
self.series_id,
|
||||||
self.season,
|
self.season,
|
||||||
season_type="official",
|
season_type="official",
|
||||||
|
language=lang,
|
||||||
)
|
)
|
||||||
season_filter = self.season
|
season_filter = self.season
|
||||||
|
|
||||||
@@ -367,12 +373,14 @@ class _TvdbMatchWorker(QThread):
|
|||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
combined = None
|
combined = None
|
||||||
@@ -381,13 +389,25 @@ class _TvdbMatchWorker(QThread):
|
|||||||
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:
|
||||||
|
# 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":
|
if self.season_type == "alternate":
|
||||||
episodes = official
|
episodes = official
|
||||||
@@ -450,6 +470,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 +512,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 +528,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()
|
||||||
|
|
||||||
@@ -551,7 +586,7 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
|||||||
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,6 +596,7 @@ 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)
|
||||||
@@ -572,7 +608,7 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
|||||||
matched = len(mapping)
|
matched = len(mapping)
|
||||||
total = len(self._file_names)
|
total = len(self._file_names)
|
||||||
scope = "all seasons" if self.season.value() == 0 else f"season {self.season.value()}"
|
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:
|
if unmatched:
|
||||||
msg += f" {len(unmatched)} file(s) unmatched."
|
msg += f" {len(unmatched)} file(s) unmatched."
|
||||||
self.status.setText(msg)
|
self.status.setText(msg)
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user