diff --git a/VERSION b/VERSION index 2ac9634..5b09c67 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.13 +1.0.14 diff --git a/gui/main_window.py b/gui/main_window.py index be70d4b..7a65c81 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -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: diff --git a/gui/rule_widgets.py b/gui/rule_widgets.py index 9ce424a..87a77f8 100644 --- a/gui/rule_widgets.py +++ b/gui/rule_widgets.py @@ -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,6 +351,8 @@ 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: @@ -437,8 +440,10 @@ class _TvdbMatchWorker(QThread): official_episodes=official, combined_episodes=combined, ) - safe_mapping = {path: target_to_tuple(t) for path, t in mapping.items()} - self.finished.emit(safe_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: @@ -564,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: @@ -618,15 +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): + 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: - self._episode_mapping = mapping + 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()})." @@ -634,7 +651,6 @@ 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}") diff --git a/main.py b/main.py index e2e89e5..037139c 100644 --- a/main.py +++ b/main.py @@ -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 ) diff --git a/packaging/release-stamp-1.0.14.txt b/packaging/release-stamp-1.0.14.txt new file mode 100644 index 0000000..5ed0204 --- /dev/null +++ b/packaging/release-stamp-1.0.14.txt @@ -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