Files
HS-Rename/gui/rule_widgets.py
T
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

748 lines
26 KiB
Python

"""
Rule configuration widgets: each rule type has a small form that updates the Rule model.
"""
from PyQt6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QSpinBox,
QComboBox,
QCheckBox,
QGroupBox,
QFormLayout,
QStackedWidget,
QPushButton,
QFileDialog,
QMessageBox,
)
from PyQt6.QtCore import pyqtSignal, QThread
from engine.rules import (
ReplaceRule,
InsertRule,
RemoveRule,
CaseRule,
NumberingRule,
EpisodeRenumberRule,
TvdbEpisodeRenumberRule,
RegexRule,
PrefixSuffixRule,
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, target_to_tuple
class ReplaceRuleWidget(QWidget):
ruleChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
layout = QFormLayout(self)
self.enabled_cb = QCheckBox("Use this rule")
self.enabled_cb.setChecked(False)
self.enabled_cb.toggled.connect(self._emit)
layout.addRow(self.enabled_cb)
self.find = QLineEdit()
self.find.setPlaceholderText("Text to find")
self.find.textChanged.connect(self._emit)
self.replace = QLineEdit()
self.replace.setPlaceholderText("Replace with")
self.replace.textChanged.connect(self._emit)
self.caseSensitive = QCheckBox("Case sensitive")
self.caseSensitive.toggled.connect(self._emit)
self.wholeWord = QCheckBox("Whole word only")
self.wholeWord.toggled.connect(self._emit)
layout.addRow("Find:", self.find)
layout.addRow("Replace with:", self.replace)
layout.addRow(self.caseSensitive)
layout.addRow(self.wholeWord)
def _emit(self):
self.ruleChanged.emit()
def getRule(self) -> ReplaceRule:
r = ReplaceRule(
find=self.find.text(),
replace=self.replace.text(),
case_sensitive=self.caseSensitive.isChecked(),
whole_word=self.wholeWord.isChecked(),
)
r.enabled = self.enabled_cb.isChecked()
return r
class RegexRuleWidget(QWidget):
ruleChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
layout = QFormLayout(self)
self.enabled_cb = QCheckBox("Use this rule")
self.enabled_cb.setChecked(False)
self.enabled_cb.toggled.connect(self._emit)
layout.addRow(self.enabled_cb)
self.pattern = QLineEdit()
self.pattern.setPlaceholderText(r"e.g. S(\d+)E(\d+)")
self.pattern.textChanged.connect(self._emit)
self.replacement = QLineEdit()
self.replacement.setPlaceholderText(r"e.g. S\1E\2")
self.replacement.textChanged.connect(self._emit)
layout.addRow("Regex pattern:", self.pattern)
layout.addRow("Replacement:", self.replacement)
def _emit(self):
self.ruleChanged.emit()
def getRule(self) -> RegexRule:
r = RegexRule(pattern=self.pattern.text(), replacement=self.replacement.text())
r.enabled = self.enabled_cb.isChecked()
return r
class InsertRuleWidget(QWidget):
ruleChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
layout = QFormLayout(self)
self.enabled_cb = QCheckBox("Use this rule")
self.enabled_cb.setChecked(False)
self.enabled_cb.toggled.connect(self._emit)
layout.addRow(self.enabled_cb)
self.text = QLineEdit()
self.text.setPlaceholderText("Text to insert")
self.text.textChanged.connect(self._emit)
self.position = QComboBox()
self.position.addItems(["At start", "At end", "At position..."])
self.position.setCurrentIndex(0)
self.position.currentIndexChanged.connect(self._emit)
self.positionSpin = QSpinBox()
self.positionSpin.setMinimum(0)
self.positionSpin.setMaximum(9999)
self.positionSpin.valueChanged.connect(self._emit)
layout.addRow("Text:", self.text)
layout.addRow("Position:", self.position)
layout.addRow("Index:", self.positionSpin)
def _emit(self):
self.ruleChanged.emit()
def getRule(self) -> InsertRule:
idx = self.position.currentIndex()
if idx == 0:
pos = 0
elif idx == 1:
pos = -1
else:
pos = self.positionSpin.value()
r = InsertRule(text=self.text.text(), position=pos)
r.enabled = self.enabled_cb.isChecked()
return r
class RemoveRuleWidget(QWidget):
ruleChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
layout = QFormLayout(self)
self.enabled_cb = QCheckBox("Use this rule")
self.enabled_cb.setChecked(False)
self.enabled_cb.toggled.connect(self._emit)
layout.addRow(self.enabled_cb)
self.removeType = QComboBox()
self.removeType.addItems([
"Remove text",
"Remove all digits",
"Remove first N characters",
"Remove last N characters",
])
self.removeType.currentIndexChanged.connect(self._emit)
self.value = QLineEdit()
self.value.setPlaceholderText("Text or number")
self.value.textChanged.connect(self._emit)
layout.addRow("Type:", self.removeType)
layout.addRow("Value:", self.value)
def _emit(self):
self.ruleChanged.emit()
def getRule(self) -> RemoveRule:
idx = self.removeType.currentIndex()
types = ["chars", "digits", "first_n", "last_n"]
r = RemoveRule(remove_type=types[idx], value=self.value.text())
r.enabled = self.enabled_cb.isChecked()
return r
class CaseRuleWidget(QWidget):
ruleChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
layout = QFormLayout(self)
self.enabled_cb = QCheckBox("Use this rule")
self.enabled_cb.setChecked(False)
self.enabled_cb.toggled.connect(self._emit)
layout.addRow(self.enabled_cb)
self.caseType = QComboBox()
self.caseType.addItems(["Title Case", "UPPER", "lower", "Sentence case"])
self.caseType.currentIndexChanged.connect(self._emit)
layout.addRow("Case:", self.caseType)
def _emit(self):
self.ruleChanged.emit()
def getRule(self) -> CaseRule:
types = ["title", "upper", "lower", "sentence"]
r = CaseRule(case_type=types[self.caseType.currentIndex()])
r.enabled = self.enabled_cb.isChecked()
return r
class NumberingRuleWidget(QWidget):
ruleChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
layout = QFormLayout(self)
self.enabled_cb = QCheckBox("Use this rule")
self.enabled_cb.setChecked(False)
self.enabled_cb.toggled.connect(self._emit)
layout.addRow(self.enabled_cb)
self.start = QSpinBox()
self.start.setMinimum(-9999)
self.start.setMaximum(99999)
self.start.setValue(1)
self.start.valueChanged.connect(self._emit)
self.step = QSpinBox()
self.step.setMinimum(1)
self.step.setMaximum(100)
self.step.setValue(1)
self.step.valueChanged.connect(self._emit)
self.padding = QSpinBox()
self.padding.setMinimum(1)
self.padding.setMaximum(10)
self.padding.setValue(2)
self.padding.valueChanged.connect(self._emit)
self.where = QComboBox()
self.where.addItems(["Prefix", "Suffix", "Insert at index"])
self.where.currentIndexChanged.connect(self._emit)
self.separator = QLineEdit()
self.separator.setText(" ")
self.separator.textChanged.connect(self._emit)
self.insertAt = QSpinBox()
self.insertAt.setMinimum(0)
self.insertAt.setMaximum(9999)
self.insertAt.valueChanged.connect(self._emit)
layout.addRow("Start:", self.start)
layout.addRow("Step:", self.step)
layout.addRow("Padding:", self.padding)
layout.addRow("Where:", self.where)
layout.addRow("Separator:", self.separator)
layout.addRow("Insert at index:", self.insertAt)
def _emit(self):
self.ruleChanged.emit()
def getRule(self) -> NumberingRule:
where_map = ["prefix", "suffix", "insert_at"]
r = NumberingRule(
start=self.start.value(),
step=self.step.value(),
padding=self.padding.value(),
where=where_map[self.where.currentIndex()],
insert_at=self.insertAt.value(),
separator=self.separator.text() or " ",
)
r.enabled = self.enabled_cb.isChecked()
return r
class EpisodeRenumberRuleWidget(QWidget):
ruleChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
layout = QFormLayout(self)
self.enabled_cb = QCheckBox("Use this rule")
self.enabled_cb.setChecked(False)
self.enabled_cb.toggled.connect(self._emit)
layout.addRow(self.enabled_cb)
self.start = QSpinBox()
self.start.setMinimum(1)
self.start.setMaximum(9999)
self.start.setValue(1)
self.start.valueChanged.connect(self._emit)
self.step = QSpinBox()
self.step.setMinimum(1)
self.step.setValue(1)
self.step.valueChanged.connect(self._emit)
self.padding = QSpinBox()
self.padding.setMinimum(1)
self.padding.setMaximum(3)
self.padding.setValue(2)
self.padding.valueChanged.connect(self._emit)
layout.addRow("First episode number:", self.start)
layout.addRow("Step:", self.step)
layout.addRow("Zero-pad width:", self.padding)
info = QLabel(
"Matches S01E05 - Title (single) or S01E05-E06 - Title (two-part). "
"Ranges keep their length: S01E01-E02, S01E03-E04, … Order follows the file list / table sort."
)
info.setWordWrap(True)
layout.addRow(info)
def _emit(self):
self.ruleChanged.emit()
def getRule(self) -> EpisodeRenumberRule:
r = EpisodeRenumberRule(
start=self.start.value(),
step=self.step.value(),
padding=self.padding.value(),
)
r.enabled = self.enabled_cb.isChecked()
return r
class _TvdbSearchWorker(QThread):
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)
self.results = client.search_series(self.query)
self.search_done.emit()
except TvdbError as e:
self.failed.emit(str(e))
except Exception as e:
self.failed.emit(str(e))
class _TvdbMatchWorker(QThread):
match_done = pyqtSignal()
failed = pyqtSignal(str)
def __init__(
self,
series_id: int,
season: int,
season_type: str,
filenames: list[str],
all_seasons: bool,
multi_episode: bool,
language: str,
):
super().__init__()
self.series_id = series_id
self.season = season
self.season_type = season_type
self.filenames = filenames
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
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 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 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}"
raise TvdbError(f"No episodes found for {label}")
mapping, unmatched, notes = match_filenames_to_episodes(
self.filenames,
episodes,
season_filter=season_filter,
official_episodes=official,
combined_episodes=combined,
)
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:
import traceback
self.failed.emit(f"{e}\n\n{traceback.format_exc()}")
class TvdbEpisodeRenumberRuleWidget(QWidget):
ruleChanged = pyqtSignal()
matchCompleted = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._file_names: list[str] = []
self._episode_mapping: dict[str, int] = {}
self._search_results: list = []
self._search_worker: _TvdbSearchWorker | None = None
self._match_worker: _TvdbMatchWorker | None = None
layout = QFormLayout(self)
self.enabled_cb = QCheckBox("Use this rule")
self.enabled_cb.setChecked(False)
self.enabled_cb.toggled.connect(self._emit)
layout.addRow(self.enabled_cb)
layout.addRow("Source:", QLabel("TheTVDB.com"))
search_row = QHBoxLayout()
self.search_query = QLineEdit()
self.search_query.setPlaceholderText("Show name to search")
self.search_query.returnPressed.connect(self._search_series)
search_btn = QPushButton("Search")
search_btn.clicked.connect(self._search_series)
search_row.addWidget(self.search_query, 1)
search_row.addWidget(search_btn)
layout.addRow("Show:", search_row)
self.series_combo = QComboBox()
self.series_combo.setEnabled(False)
self.series_combo.currentIndexChanged.connect(self._emit)
layout.addRow("Pick show:", self.series_combo)
self.order_type = QComboBox()
for label, value in SEASON_TYPE_CHOICES:
self.order_type.addItem(label, value)
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)
self.season.setValue(0)
self.season.setSpecialValueText("All seasons")
self.season.valueChanged.connect(self._emit)
layout.addRow("Season:", self.season)
self.padding = QSpinBox()
self.padding.setMinimum(1)
self.padding.setMaximum(3)
self.padding.setValue(2)
self.padding.valueChanged.connect(self._emit)
layout.addRow("Zero-pad width:", self.padding)
self.multi_episode_cb = QCheckBox("Multi-episode files (Jellyfin S01E01-E02)")
self.multi_episode_cb.setChecked(True)
self.multi_episode_cb.setToolTip(
"Match combined-order titles from TheTVDB and rename using aired episode ranges, "
"e.g. two episodes in one file becomes S01E01-E02."
)
self.multi_episode_cb.toggled.connect(self._emit)
layout.addRow(self.multi_episode_cb)
self.match_btn = QPushButton("Match titles from file list")
self.match_btn.clicked.connect(self._match_titles)
layout.addRow(self.match_btn)
self.status = QLabel("Search for a show, choose episode order, then match titles (0 = all seasons).")
self.status.setWordWrap(True)
layout.addRow(self.status)
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)
layout.addRow(info)
def set_file_names(self, names: list[str]) -> None:
self._file_names = list(names)
def _order_label(self) -> str:
return self.order_type.currentText()
def _order_value(self) -> str:
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()
def _search_series(self):
query = self.search_query.text().strip()
if not query:
QMessageBox.warning(self, "Search", "Enter a show name to search.")
return
self.match_btn.setEnabled(False)
self.series_combo.setEnabled(False)
self.status.setText("Searching TheTVDB…")
self._search_worker = _TvdbSearchWorker(query)
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 = self._search_worker.results if self._search_worker else []
self._search_results = results
self.series_combo.clear()
if not results:
self.status.setText("No series found. Try a different search.")
self.match_btn.setEnabled(True)
return
for s in results:
label = s.name
if s.year:
label = f"{label} ({s.year})"
self.series_combo.addItem(label, s.id)
self.series_combo.setEnabled(True)
self.match_btn.setEnabled(True)
self.status.setText(f"Found {len(results)} series. Pick one and match titles.")
self._emit()
def _on_search_failed(self, message: str):
self.match_btn.setEnabled(True)
self.series_combo.setEnabled(self.series_combo.count() > 0)
self.status.setText(f"Search failed: {message}")
def _match_titles(self):
if self.series_combo.count() == 0:
QMessageBox.warning(self, "Match", "Search and select a show first.")
return
if not self._file_names:
QMessageBox.warning(self, "Match", "Load a folder with files first.")
return
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()}, {self._language_label()}, "
f"{'all seasons' if all_seasons else f'season {self.season.value()}'}…) and matching titles…"
)
self._match_worker = _TvdbMatchWorker(
int(series_id),
self.season.value(),
self._order_value(),
self._file_names,
all_seasons,
self.multi_episode_cb.isChecked(),
self._language_value(),
)
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_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(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()})."
if unmatched:
msg += f" {len(unmatched)} file(s) unmatched."
self.status.setText(msg)
self.matchCompleted.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)
self.status.setText(f"Match failed: {message}")
def getRule(self) -> TvdbEpisodeRenumberRule:
mapping: dict = {}
for k, v in self._episode_mapping.items():
if isinstance(v, tuple):
mapping[k] = v
elif hasattr(v, "season"):
mapping[k] = target_to_tuple(v)
else:
mapping[k] = v
r = TvdbEpisodeRenumberRule(
episode_mapping=mapping,
padding=self.padding.value(),
)
r.enabled = self.enabled_cb.isChecked()
return r
class PrefixSuffixRuleWidget(QWidget):
ruleChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
layout = QFormLayout(self)
self.enabled_cb = QCheckBox("Use this rule")
self.enabled_cb.setChecked(False)
self.enabled_cb.toggled.connect(self._emit)
layout.addRow(self.enabled_cb)
self.prefix = QLineEdit()
self.prefix.setPlaceholderText("Prefix")
self.prefix.textChanged.connect(self._emit)
self.suffix = QLineEdit()
self.suffix.setPlaceholderText("Suffix")
self.suffix.textChanged.connect(self._emit)
layout.addRow("Prefix:", self.prefix)
layout.addRow("Suffix:", self.suffix)
def _emit(self):
self.ruleChanged.emit()
def getRule(self) -> PrefixSuffixRule:
r = PrefixSuffixRule(prefix=self.prefix.text(), suffix=self.suffix.text())
r.enabled = self.enabled_cb.isChecked()
return r
class CsvMappingRuleWidget(QWidget):
ruleChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
layout = QFormLayout(self)
self.enabled_cb = QCheckBox("Use this rule")
self.enabled_cb.setChecked(False)
self.enabled_cb.toggled.connect(self._emit)
layout.addRow(self.enabled_cb)
path_row = QHBoxLayout()
self.path_edit = QLineEdit()
self.path_edit.setPlaceholderText("Path to CSV file…")
self.path_edit.textChanged.connect(self._emit)
browse_btn = QPushButton("Browse…")
browse_btn.clicked.connect(self._browse)
path_row.addWidget(self.path_edit, 1)
path_row.addWidget(browse_btn)
layout.addRow("CSV file:", path_row)
info = QLabel('CSV must have columns "Original Name" and "Target Name". Lookup is by current filename.')
info.setWordWrap(True)
layout.addRow(info)
def _browse(self):
path, _ = QFileDialog.getOpenFileName(
self,
"Select CSV",
"",
"CSV (*.csv);;All files (*)",
)
if path:
self.path_edit.setText(path)
def _emit(self):
self.ruleChanged.emit()
def getRule(self) -> CsvMappingRule:
r = CsvMappingRule(csv_path=self.path_edit.text().strip())
r.enabled = self.enabled_cb.isChecked()
return r