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]>
This commit is contained in:
+21
-2
@@ -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):
|
||||||
|
|||||||
@@ -206,8 +206,9 @@ class TvdbClient:
|
|||||||
"""Fetch episodes; optional translated titles via language code (e.g. eng)."""
|
"""Fetch episodes; optional translated titles via language code (e.g. eng)."""
|
||||||
episodes: list[TvdbEpisode] = []
|
episodes: list[TvdbEpisode] = []
|
||||||
page = 0
|
page = 0
|
||||||
|
max_pages = 50
|
||||||
use_api_season = season is not None and not language
|
use_api_season = season is not None and not language
|
||||||
while True:
|
while page < max_pages:
|
||||||
params: dict[str, Any] = {"page": page}
|
params: dict[str, Any] = {"page": page}
|
||||||
if use_api_season:
|
if use_api_season:
|
||||||
params["season"] = season
|
params["season"] = season
|
||||||
|
|||||||
+50
-26
@@ -32,7 +32,7 @@ from engine.rules import (
|
|||||||
CsvMappingRule,
|
CsvMappingRule,
|
||||||
)
|
)
|
||||||
from engine.tvdb_client import TvdbClient, TvdbError, TVDB_API_KEY, SEASON_TYPE_CHOICES, LANGUAGE_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):
|
||||||
@@ -355,19 +355,8 @@ class _TvdbMatchWorker(QThread):
|
|||||||
try:
|
try:
|
||||||
client = TvdbClient(TVDB_API_KEY)
|
client = TvdbClient(TVDB_API_KEY)
|
||||||
lang = self.language
|
lang = self.language
|
||||||
if self.all_seasons:
|
season_filter = 0 if self.all_seasons else self.season
|
||||||
official = client.get_all_episodes(
|
need_official = self.multi_episode or self.season_type == "alternate"
|
||||||
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
|
|
||||||
|
|
||||||
if self.all_seasons:
|
if self.all_seasons:
|
||||||
episodes = client.get_all_episodes(
|
episodes = client.get_all_episodes(
|
||||||
@@ -383,8 +372,38 @@ class _TvdbMatchWorker(QThread):
|
|||||||
language=lang,
|
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,
|
||||||
@@ -399,7 +418,6 @@ class _TvdbMatchWorker(QThread):
|
|||||||
language=lang,
|
language=lang,
|
||||||
)
|
)
|
||||||
if not combined and lang:
|
if not combined and lang:
|
||||||
# Combined order may lack translations; fall back to show language.
|
|
||||||
if self.all_seasons:
|
if self.all_seasons:
|
||||||
combined = client.get_all_episodes(
|
combined = client.get_all_episodes(
|
||||||
self.series_id, season_type="alternate",
|
self.series_id, season_type="alternate",
|
||||||
@@ -409,9 +427,6 @@ class _TvdbMatchWorker(QThread):
|
|||||||
self.series_id, self.season, season_type="alternate",
|
self.series_id, self.season, season_type="alternate",
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.season_type == "alternate":
|
|
||||||
episodes = official
|
|
||||||
|
|
||||||
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}"
|
||||||
raise TvdbError(f"No episodes found for {label}")
|
raise TvdbError(f"No episodes found for {label}")
|
||||||
@@ -422,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):
|
||||||
@@ -583,6 +600,9 @@ 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(
|
||||||
@@ -603,6 +623,7 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
|||||||
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):
|
||||||
|
try:
|
||||||
self._episode_mapping = mapping
|
self._episode_mapping = mapping
|
||||||
self.match_btn.setEnabled(True)
|
self.match_btn.setEnabled(True)
|
||||||
matched = len(mapping)
|
matched = len(mapping)
|
||||||
@@ -614,6 +635,10 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
|||||||
self.status.setText(msg)
|
self.status.setText(msg)
|
||||||
self.matchCompleted.emit()
|
self.matchCompleted.emit()
|
||||||
self._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)
|
||||||
@@ -622,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(
|
||||||
|
|||||||
@@ -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