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]>
This commit is contained in:
+15
-2
@@ -36,6 +36,7 @@ from .rule_widgets import (
|
||||
CaseRuleWidget,
|
||||
NumberingRuleWidget,
|
||||
EpisodeRenumberRuleWidget,
|
||||
TvdbEpisodeRenumberRuleWidget,
|
||||
PrefixSuffixRuleWidget,
|
||||
CsvMappingRuleWidget,
|
||||
)
|
||||
@@ -85,6 +86,7 @@ class MainWindow(QMainWindow):
|
||||
CaseRuleWidget(),
|
||||
NumberingRuleWidget(),
|
||||
EpisodeRenumberRuleWidget(),
|
||||
TvdbEpisodeRenumberRuleWidget(),
|
||||
PrefixSuffixRuleWidget(),
|
||||
CsvMappingRuleWidget(),
|
||||
]
|
||||
@@ -96,12 +98,17 @@ class MainWindow(QMainWindow):
|
||||
"5. Case",
|
||||
"6. Numbering",
|
||||
"7. Episode renumber",
|
||||
"8. Prefix / Suffix",
|
||||
"9. CSV mapping",
|
||||
"8. TheTVDB episode match",
|
||||
"9. Prefix / Suffix",
|
||||
"10. CSV mapping",
|
||||
]
|
||||
for title, w in zip(rule_titles, self._rule_widgets):
|
||||
w.enabled_cb.toggled.connect(self._refresh_preview) # always refresh when checkbox toggled
|
||||
w.ruleChanged.connect(self._on_rule_changed) # refresh only when enabled rule’s options change
|
||||
if hasattr(w, "set_file_names"):
|
||||
w.set_file_names(self._file_names)
|
||||
if hasattr(w, "matchCompleted"):
|
||||
w.matchCompleted.connect(self._refresh_preview)
|
||||
g = QGroupBox(title)
|
||||
g_layout = QVBoxLayout(g)
|
||||
g_layout.setContentsMargins(8, 12, 8, 8)
|
||||
@@ -206,7 +213,13 @@ class MainWindow(QMainWindow):
|
||||
self.preview_table.setSortingEnabled(False)
|
||||
self.preview_table.setRowCount(0)
|
||||
self.preview_status.setText("Add a folder to see files.")
|
||||
for w in self._rule_widgets:
|
||||
if hasattr(w, "set_file_names"):
|
||||
w.set_file_names([])
|
||||
return
|
||||
for w in self._rule_widgets:
|
||||
if hasattr(w, "set_file_names"):
|
||||
w.set_file_names(self._file_names)
|
||||
# Preserve sort and selection so checking a rule doesn’t reorder or clear selection
|
||||
header = self.preview_table.horizontalHeader()
|
||||
sort_section = header.sortIndicatorSection()
|
||||
|
||||
+227
-1
@@ -15,8 +15,9 @@ from PyQt6.QtWidgets import (
|
||||
QStackedWidget,
|
||||
QPushButton,
|
||||
QFileDialog,
|
||||
QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import pyqtSignal
|
||||
from PyQt6.QtCore import pyqtSignal, QThread
|
||||
|
||||
from engine.rules import (
|
||||
ReplaceRule,
|
||||
@@ -25,10 +26,13 @@ from engine.rules import (
|
||||
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):
|
||||
@@ -305,6 +309,228 @@ class EpisodeRenumberRuleWidget(QWidget):
|
||||
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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user