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]>
This commit is contained in:
+16
-2
@@ -25,7 +25,7 @@ from PyQt6.QtWidgets import (
|
|||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
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 PyQt6.QtGui import QFont, QColor
|
||||||
|
|
||||||
from engine.pipeline import compute_preview, perform_renames, save_undo_log, load_undo_log, perform_undo
|
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._file_names: list[str] = []
|
||||||
self._preview_by_orig: dict[str, str] = {} # original name -> new name for selection preview
|
self._preview_by_orig: dict[str, str] = {} # original name -> new name for selection preview
|
||||||
self._split: QSplitter | None = None
|
self._split: QSplitter | None = None
|
||||||
|
self._preview_refresh_scheduled = False
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
self._restore_layout()
|
self._restore_layout()
|
||||||
self._refresh_preview()
|
self._refresh_preview()
|
||||||
@@ -129,7 +130,7 @@ class MainWindow(QMainWindow):
|
|||||||
if hasattr(w, "set_file_names"):
|
if hasattr(w, "set_file_names"):
|
||||||
w.set_file_names(self._file_names)
|
w.set_file_names(self._file_names)
|
||||||
if hasattr(w, "matchCompleted"):
|
if hasattr(w, "matchCompleted"):
|
||||||
w.matchCompleted.connect(self._refresh_preview)
|
w.matchCompleted.connect(self._schedule_refresh_preview)
|
||||||
g = QGroupBox(title)
|
g = QGroupBox(title)
|
||||||
g_layout = QVBoxLayout(g)
|
g_layout = QVBoxLayout(g)
|
||||||
g_layout.setContentsMargins(8, 12, 8, 8)
|
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
|
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))
|
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):
|
def _refresh_preview(self):
|
||||||
rules = self._get_rules()
|
rules = self._get_rules()
|
||||||
if not self._file_names:
|
if not self._file_names:
|
||||||
|
|||||||
+29
-13
@@ -310,18 +310,19 @@ class EpisodeRenumberRuleWidget(QWidget):
|
|||||||
|
|
||||||
|
|
||||||
class _TvdbSearchWorker(QThread):
|
class _TvdbSearchWorker(QThread):
|
||||||
finished = pyqtSignal(list)
|
search_done = pyqtSignal()
|
||||||
failed = pyqtSignal(str)
|
failed = pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(self, query: str):
|
def __init__(self, query: str):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.query = query
|
self.query = query
|
||||||
|
self.results: list = []
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
client = TvdbClient(TVDB_API_KEY)
|
client = TvdbClient(TVDB_API_KEY)
|
||||||
results = client.search_series(self.query)
|
self.results = client.search_series(self.query)
|
||||||
self.finished.emit(results)
|
self.search_done.emit()
|
||||||
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:
|
||||||
@@ -329,7 +330,7 @@ class _TvdbSearchWorker(QThread):
|
|||||||
|
|
||||||
|
|
||||||
class _TvdbMatchWorker(QThread):
|
class _TvdbMatchWorker(QThread):
|
||||||
finished = pyqtSignal(dict, list, list)
|
match_done = pyqtSignal()
|
||||||
failed = pyqtSignal(str)
|
failed = pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -350,6 +351,8 @@ class _TvdbMatchWorker(QThread):
|
|||||||
self.all_seasons = all_seasons
|
self.all_seasons = all_seasons
|
||||||
self.multi_episode = multi_episode
|
self.multi_episode = multi_episode
|
||||||
self.language = language
|
self.language = language
|
||||||
|
self.mapping: dict[str, tuple[int, ...]] = {}
|
||||||
|
self.unmatched: list[str] = []
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
@@ -437,8 +440,10 @@ class _TvdbMatchWorker(QThread):
|
|||||||
official_episodes=official,
|
official_episodes=official,
|
||||||
combined_episodes=combined,
|
combined_episodes=combined,
|
||||||
)
|
)
|
||||||
safe_mapping = {path: target_to_tuple(t) for path, t in mapping.items()}
|
self.mapping = {path: target_to_tuple(t) for path, t in mapping.items()}
|
||||||
self.finished.emit(safe_mapping, unmatched, notes)
|
self.unmatched = list(unmatched)
|
||||||
|
del notes
|
||||||
|
self.match_done.emit()
|
||||||
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:
|
||||||
@@ -564,11 +569,12 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
|||||||
self.series_combo.setEnabled(False)
|
self.series_combo.setEnabled(False)
|
||||||
self.status.setText("Searching TheTVDB…")
|
self.status.setText("Searching TheTVDB…")
|
||||||
self._search_worker = _TvdbSearchWorker(query)
|
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.failed.connect(self._on_search_failed)
|
||||||
self._search_worker.start()
|
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._search_results = results
|
||||||
self.series_combo.clear()
|
self.series_combo.clear()
|
||||||
if not results:
|
if not results:
|
||||||
@@ -618,15 +624,26 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
|||||||
self.multi_episode_cb.isChecked(),
|
self.multi_episode_cb.isChecked(),
|
||||||
self._language_value(),
|
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.failed.connect(self._on_match_failed)
|
||||||
|
self._match_worker.finished.connect(self._on_match_worker_finished)
|
||||||
self._match_worker.start()
|
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:
|
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)
|
self.match_btn.setEnabled(True)
|
||||||
matched = len(mapping)
|
matched = len(self._episode_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()}, {self._language_label()})."
|
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."
|
msg += f" {len(unmatched)} file(s) unmatched."
|
||||||
self.status.setText(msg)
|
self.status.setText(msg)
|
||||||
self.matchCompleted.emit()
|
self.matchCompleted.emit()
|
||||||
self._emit()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.match_btn.setEnabled(True)
|
self.match_btn.setEnabled(True)
|
||||||
self.status.setText(f"Match error: {e}")
|
self.status.setText(f"Match error: {e}")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ HSRename - Native Linux GUI for mass renaming files.
|
|||||||
Inspired by Bulk Rename Utility (Windows); supports preview and flexible rules.
|
Inspired by Bulk Rename Utility (Windows); supports preview and flexible rules.
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Ensure project root is on path when run as script or module
|
# 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
|
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():
|
def main():
|
||||||
|
_install_exception_logger()
|
||||||
QApplication.setHighDpiScaleFactorRoundingPolicy(
|
QApplication.setHighDpiScaleFactorRoundingPolicy(
|
||||||
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user