Files
HS-Rename/gui/rule_widgets.py
T
Bulk RenamerandCursor 085d6dc296 Add TheTVDB episode title matching and release v1.0.3.
Match filenames to TheTVDB episode order (default, aired, DVD, etc.) to fix chaotic SxxExx numbering while preserving titles.

Co-authored-by: Cursor <[email protected]>
2026-07-03 16:47:37 -05:00

602 lines
20 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
from engine.episode_match import match_filenames_to_episodes
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):
finished = pyqtSignal(list)
failed = pyqtSignal(str)
def __init__(self, query: str):
super().__init__()
self.query = query
def run(self):
try:
client = TvdbClient(TVDB_API_KEY)
results = client.search_series(self.query)
self.finished.emit(results)
except TvdbError as e:
self.failed.emit(str(e))
except Exception as e:
self.failed.emit(str(e))
class _TvdbMatchWorker(QThread):
finished = pyqtSignal(dict, list, list)
failed = pyqtSignal(str)
def __init__(
self,
series_id: int,
season: int,
season_type: str,
filenames: list[str],
):
super().__init__()
self.series_id = series_id
self.season = season
self.season_type = season_type
self.filenames = filenames
def run(self):
try:
client = TvdbClient(TVDB_API_KEY)
episodes = client.get_season_episodes(
self.series_id,
self.season,
season_type=self.season_type,
)
if not episodes:
raise TvdbError(f"No episodes found for season {self.season}")
mapping, unmatched, notes = match_filenames_to_episodes(self.filenames, episodes)
self.finished.emit(mapping, unmatched, notes)
except TvdbError as e:
self.failed.emit(str(e))
except Exception as e:
self.failed.emit(str(e))
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.season = QSpinBox()
self.season.setMinimum(0)
self.season.setMaximum(99)
self.season.setValue(1)
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.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 and season, then match titles.")
self.status.setWordWrap(True)
layout.addRow(self.status)
info = QLabel(
"Reads episode titles from filenames like S01E05 - Episode Title. "
"Fetches the chosen TheTVDB episode order for that season and rewrites SxxExx numbers to match."
)
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 _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.finished.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_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
self.match_btn.setEnabled(False)
self.status.setText(f"Fetching episodes ({self._order_label()}) and matching titles…")
self._match_worker = _TvdbMatchWorker(
int(series_id),
self.season.value(),
self._order_value(),
self._file_names,
)
self._match_worker.finished.connect(self._on_match_finished)
self._match_worker.failed.connect(self._on_match_failed)
self._match_worker.start()
def _on_match_finished(self, mapping: dict, unmatched: list, notes: list):
self._episode_mapping = mapping
self.match_btn.setEnabled(True)
matched = len(mapping)
total = len(self._file_names)
msg = (
f"Matched {matched} of {total} file(s) for season {self.season.value()} "
f"({self._order_label()})."
)
if unmatched:
msg += f" {len(unmatched)} file(s) unmatched."
self.status.setText(msg)
self.matchCompleted.emit()
self._emit()
def _on_match_failed(self, message: str):
self.match_btn.setEnabled(True)
self.status.setText(f"Match failed: {message}")
def getRule(self) -> TvdbEpisodeRenumberRule:
r = TvdbEpisodeRenumberRule(
episode_mapping=dict(self._episode_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