Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64d7a30328 | ||
|
|
445e24ecae |
+21
-2
@@ -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):
|
||||
|
||||
+84
-52
@@ -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,74 @@ 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
|
||||
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(
|
||||
"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 +235,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,
|
||||
)
|
||||
|
||||
+95
-35
@@ -31,8 +31,8 @@ from engine.rules import (
|
||||
PrefixSuffixRule,
|
||||
CsvMappingRule,
|
||||
)
|
||||
from engine.tvdb_client import TvdbClient, TvdbError, TVDB_API_KEY, SEASON_TYPE_CHOICES
|
||||
from engine.episode_match import match_filenames_to_episodes
|
||||
from engine.tvdb_client import TvdbClient, TvdbError, TVDB_API_KEY, SEASON_TYPE_CHOICES, LANGUAGE_CHOICES
|
||||
from engine.episode_match import match_filenames_to_episodes, target_to_tuple
|
||||
|
||||
|
||||
class ReplaceRuleWidget(QWidget):
|
||||
@@ -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,49 +349,83 @@ 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)
|
||||
if self.all_seasons:
|
||||
official = client.get_all_episodes(self.series_id, season_type="official")
|
||||
season_filter = 0
|
||||
else:
|
||||
official = client.get_season_episodes(
|
||||
self.series_id,
|
||||
self.season,
|
||||
season_type="official",
|
||||
)
|
||||
season_filter = self.season
|
||||
lang = self.language
|
||||
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(
|
||||
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,
|
||||
)
|
||||
|
||||
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,
|
||||
season_type="alternate",
|
||||
language=lang,
|
||||
)
|
||||
else:
|
||||
combined = client.get_season_episodes(
|
||||
self.series_id,
|
||||
self.season,
|
||||
season_type="alternate",
|
||||
language=lang,
|
||||
)
|
||||
|
||||
if self.season_type == "alternate":
|
||||
episodes = official
|
||||
if not combined and lang:
|
||||
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 not episodes and not combined:
|
||||
label = "all seasons" if self.all_seasons else f"season {self.season}"
|
||||
@@ -402,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):
|
||||
@@ -450,6 +487,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 +529,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 +545,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()
|
||||
|
||||
@@ -548,10 +600,13 @@ 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(
|
||||
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,23 +616,29 @@ 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)
|
||||
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()})."
|
||||
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)
|
||||
@@ -586,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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user