2 Commits
Author SHA1 Message Date
Bulk RenamerandCursor 87282b0259 Release v1.0.14: stop passing match results through Qt signals.
Store worker results on the thread object, defer preview refresh, and log Python errors to crash.log to prevent native Qt crashes after TheTVDB match.

Co-authored-by: Cursor <[email protected]>
2026-07-03 20:51:20 -05:00
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
8 changed files with 153 additions and 51 deletions
+1 -1
View File
@@ -1 +1 @@
1.0.12
1.0.14
+21 -2
View File
@@ -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):
+2 -1
View File
@@ -206,8 +206,9 @@ class TvdbClient:
"""Fetch episodes; optional translated titles via language code (e.g. eng)."""
episodes: list[TvdbEpisode] = []
page = 0
max_pages = 50
use_api_season = season is not None and not language
while True:
while page < max_pages:
params: dict[str, Any] = {"page": page}
if use_api_season:
params["season"] = season
+16 -2
View File
@@ -25,7 +25,7 @@ from PyQt6.QtWidgets import (
QCheckBox,
QComboBox,
)
from PyQt6.QtCore import Qt, QDir, QItemSelectionModel, QSettings
from PyQt6.QtCore import Qt, QDir, QItemSelectionModel, QSettings, QTimer
from PyQt6.QtGui import QFont, QColor
from engine.pipeline import compute_preview, perform_renames, save_undo_log, load_undo_log, perform_undo
@@ -54,6 +54,7 @@ class MainWindow(QMainWindow):
self._file_names: list[str] = []
self._preview_by_orig: dict[str, str] = {} # original name -> new name for selection preview
self._split: QSplitter | None = None
self._preview_refresh_scheduled = False
self._setup_ui()
self._restore_layout()
self._refresh_preview()
@@ -129,7 +130,7 @@ class MainWindow(QMainWindow):
if hasattr(w, "set_file_names"):
w.set_file_names(self._file_names)
if hasattr(w, "matchCompleted"):
w.matchCompleted.connect(self._refresh_preview)
w.matchCompleted.connect(self._schedule_refresh_preview)
g = QGroupBox(title)
g_layout = QVBoxLayout(g)
g_layout.setContentsMargins(8, 12, 8, 8)
@@ -274,6 +275,19 @@ class MainWindow(QMainWindow):
display_name = self._preview_by_orig.get(orig, orig) if row in selected_rows else orig
self.preview_table.setItem(row, 1, QTableWidgetItem(display_name))
def _schedule_refresh_preview(self):
if self._preview_refresh_scheduled:
return
self._preview_refresh_scheduled = True
QTimer.singleShot(0, self._run_scheduled_refresh)
def _run_scheduled_refresh(self):
self._preview_refresh_scheduled = False
try:
self._refresh_preview()
except Exception as exc:
self.preview_status.setText(f"Preview error: {exc}")
def _refresh_preview(self):
rules = self._get_rules()
if not self._file_names:
+77 -37
View File
@@ -32,7 +32,7 @@ from engine.rules import (
CsvMappingRule,
)
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):
@@ -310,18 +310,19 @@ class EpisodeRenumberRuleWidget(QWidget):
class _TvdbSearchWorker(QThread):
finished = pyqtSignal(list)
search_done = pyqtSignal()
failed = pyqtSignal(str)
def __init__(self, query: str):
super().__init__()
self.query = query
self.results: list = []
def run(self):
try:
client = TvdbClient(TVDB_API_KEY)
results = client.search_series(self.query)
self.finished.emit(results)
self.results = client.search_series(self.query)
self.search_done.emit()
except TvdbError as e:
self.failed.emit(str(e))
except Exception as e:
@@ -329,7 +330,7 @@ class _TvdbSearchWorker(QThread):
class _TvdbMatchWorker(QThread):
finished = pyqtSignal(dict, list, list)
match_done = pyqtSignal()
failed = pyqtSignal(str)
def __init__(
@@ -350,24 +351,15 @@ class _TvdbMatchWorker(QThread):
self.all_seasons = all_seasons
self.multi_episode = multi_episode
self.language = language
self.mapping: dict[str, tuple[int, ...]] = {}
self.unmatched: list[str] = []
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", 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
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(
@@ -383,8 +375,38 @@ class _TvdbMatchWorker(QThread):
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,
@@ -399,7 +421,6 @@ class _TvdbMatchWorker(QThread):
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",
@@ -409,9 +430,6 @@ class _TvdbMatchWorker(QThread):
self.series_id, self.season, season_type="alternate",
)
if self.season_type == "alternate":
episodes = official
if not episodes and not combined:
label = "all seasons" if self.all_seasons else f"season {self.season}"
raise TvdbError(f"No episodes found for {label}")
@@ -422,11 +440,15 @@ class _TvdbMatchWorker(QThread):
official_episodes=official,
combined_episodes=combined,
)
self.finished.emit(mapping, unmatched, notes)
self.mapping = {path: target_to_tuple(t) for path, t in mapping.items()}
self.unmatched = list(unmatched)
del notes
self.match_done.emit()
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):
@@ -547,11 +569,12 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
self.series_combo.setEnabled(False)
self.status.setText("Searching TheTVDB…")
self._search_worker = _TvdbSearchWorker(query)
self._search_worker.finished.connect(self._on_search_finished)
self._search_worker.search_done.connect(self._on_search_finished)
self._search_worker.failed.connect(self._on_search_failed)
self._search_worker.start()
def _on_search_finished(self, results):
def _on_search_finished(self):
results = self._search_worker.results if self._search_worker else []
self._search_results = results
self.series_combo.clear()
if not results:
@@ -583,6 +606,9 @@ 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(
@@ -598,14 +624,26 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
self.multi_episode_cb.isChecked(),
self._language_value(),
)
self._match_worker.finished.connect(self._on_match_finished)
self._match_worker.match_done.connect(self._on_match_finished)
self._match_worker.failed.connect(self._on_match_failed)
self._match_worker.finished.connect(self._on_match_worker_finished)
self._match_worker.start()
def _on_match_finished(self, mapping: dict, unmatched: list, notes: list):
self._episode_mapping = mapping
def _on_match_worker_finished(self):
worker = self._match_worker
if worker is not None:
worker.deleteLater()
self._match_worker = None
def _on_match_finished(self):
try:
worker = self._match_worker
if worker is None:
return
self._episode_mapping = dict(worker.mapping)
unmatched = list(worker.unmatched)
self.match_btn.setEnabled(True)
matched = len(mapping)
matched = len(self._episode_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()})."
@@ -613,7 +651,10 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
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)
@@ -622,11 +663,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(
+20
View File
@@ -4,6 +4,7 @@ HSRename - Native Linux GUI for mass renaming files.
Inspired by Bulk Rename Utility (Windows); supports preview and flexible rules.
"""
import sys
import traceback
from pathlib import Path
# Ensure project root is on path when run as script or module
@@ -16,7 +17,26 @@ from PyQt6.QtCore import Qt
from gui.main_window import MainWindow
def _install_exception_logger() -> None:
log_dir = Path.home() / ".config" / "HSRename"
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "crash.log"
def _hook(exc_type, exc, tb):
try:
log_file.write_text(
"".join(traceback.format_exception(exc_type, exc, tb)),
encoding="utf-8",
)
except OSError:
pass
sys.__excepthook__(exc_type, exc, tb)
sys.excepthook = _hook
def main():
_install_exception_logger()
QApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
+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
+4
View File
@@ -0,0 +1,4 @@
HSRename 1.0.14
- Fix Qt thread crashes: workers no longer pass dict/list through signals
- Defer preview refresh after match to avoid re-entrant table updates
- Log Python exceptions to ~/.config/HSRename/crash.log