""" 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