Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87282b0259 | ||
|
|
64d7a30328 |
+21
-2
@@ -45,6 +45,13 @@ class EpisodeTarget:
|
||||
return f"S{s}E{e1}"
|
||||
|
||||
|
||||
def target_to_tuple(target: EpisodeTarget) -> tuple[int, ...]:
|
||||
"""Plain tuple safe to pass through Qt signals."""
|
||||
if target.episode_end is not None and target.episode_end > target.episode:
|
||||
return (target.season, target.episode, target.episode_end)
|
||||
return (target.season, target.episode)
|
||||
|
||||
|
||||
def split_combined_title(name: str) -> list[str]:
|
||||
"""Split a combined-order episode title like 'Ep A/Ep B' into parts."""
|
||||
return [p.strip() for p in name.replace(" / ", "/").split("/") if p.strip()]
|
||||
@@ -395,7 +402,19 @@ def match_filenames_to_episodes(
|
||||
|
||||
pairs: list[tuple[float, str, EpisodeTarget, str, str]] = []
|
||||
for fname, fnorm, raw_title, parsed in file_entries:
|
||||
for season, ep_num, enorm, ep_name in ep_entries:
|
||||
file_season = parsed.get("season")
|
||||
if season_filter == 0 and file_season is not None:
|
||||
season_eps = [e for e in ep_entries if e[0] == file_season]
|
||||
season_combined = [
|
||||
(cep, target, variants)
|
||||
for cep, target, variants in combined_entries
|
||||
if target.season == file_season
|
||||
]
|
||||
else:
|
||||
season_eps = ep_entries
|
||||
season_combined = combined_entries
|
||||
|
||||
for season, ep_num, enorm, ep_name in season_eps:
|
||||
if not fnorm:
|
||||
continue
|
||||
score = _apply_season_hint(
|
||||
@@ -412,7 +431,7 @@ def match_filenames_to_episodes(
|
||||
ep_name,
|
||||
)
|
||||
)
|
||||
for _cep, target, variants in combined_entries:
|
||||
for _cep, target, variants in season_combined:
|
||||
if not fnorm:
|
||||
continue
|
||||
if not _combined_allowed_for_file(parsed, target, _cep.name):
|
||||
|
||||
@@ -206,8 +206,9 @@ class TvdbClient:
|
||||
"""Fetch episodes; optional translated titles via language code (e.g. eng)."""
|
||||
episodes: list[TvdbEpisode] = []
|
||||
page = 0
|
||||
max_pages = 50
|
||||
use_api_season = season is not None and not language
|
||||
while True:
|
||||
while page < max_pages:
|
||||
params: dict[str, Any] = {"page": page}
|
||||
if use_api_season:
|
||||
params["season"] = season
|
||||
|
||||
+16
-2
@@ -25,7 +25,7 @@ from PyQt6.QtWidgets import (
|
||||
QCheckBox,
|
||||
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 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._preview_by_orig: dict[str, str] = {} # original name -> new name for selection preview
|
||||
self._split: QSplitter | None = None
|
||||
self._preview_refresh_scheduled = False
|
||||
self._setup_ui()
|
||||
self._restore_layout()
|
||||
self._refresh_preview()
|
||||
@@ -129,7 +130,7 @@ class MainWindow(QMainWindow):
|
||||
if hasattr(w, "set_file_names"):
|
||||
w.set_file_names(self._file_names)
|
||||
if hasattr(w, "matchCompleted"):
|
||||
w.matchCompleted.connect(self._refresh_preview)
|
||||
w.matchCompleted.connect(self._schedule_refresh_preview)
|
||||
g = QGroupBox(title)
|
||||
g_layout = QVBoxLayout(g)
|
||||
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
|
||||
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):
|
||||
rules = self._get_rules()
|
||||
if not self._file_names:
|
||||
|
||||
+85
-45
@@ -32,7 +32,7 @@ from engine.rules import (
|
||||
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
|
||||
from engine.episode_match import match_filenames_to_episodes, target_to_tuple
|
||||
|
||||
|
||||
class ReplaceRuleWidget(QWidget):
|
||||
@@ -310,18 +310,19 @@ class EpisodeRenumberRuleWidget(QWidget):
|
||||
|
||||
|
||||
class _TvdbSearchWorker(QThread):
|
||||
finished = pyqtSignal(list)
|
||||
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)
|
||||
results = client.search_series(self.query)
|
||||
self.finished.emit(results)
|
||||
self.results = client.search_series(self.query)
|
||||
self.search_done.emit()
|
||||
except TvdbError as e:
|
||||
self.failed.emit(str(e))
|
||||
except Exception as e:
|
||||
@@ -329,7 +330,7 @@ class _TvdbSearchWorker(QThread):
|
||||
|
||||
|
||||
class _TvdbMatchWorker(QThread):
|
||||
finished = pyqtSignal(dict, list, list)
|
||||
match_done = pyqtSignal()
|
||||
failed = pyqtSignal(str)
|
||||
|
||||
def __init__(
|
||||
@@ -350,24 +351,15 @@ class _TvdbMatchWorker(QThread):
|
||||
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
|
||||
if self.all_seasons:
|
||||
official = client.get_all_episodes(
|
||||
self.series_id, season_type="official", language=lang,
|
||||
)
|
||||
season_filter = 0
|
||||
else:
|
||||
official = client.get_season_episodes(
|
||||
self.series_id,
|
||||
self.season,
|
||||
season_type="official",
|
||||
language=lang,
|
||||
)
|
||||
season_filter = self.season
|
||||
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(
|
||||
@@ -383,8 +375,38 @@ class _TvdbMatchWorker(QThread):
|
||||
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 self.multi_episode or self.season_type == "alternate":
|
||||
if need_official:
|
||||
if self.all_seasons:
|
||||
combined = client.get_all_episodes(
|
||||
self.series_id,
|
||||
@@ -399,7 +421,6 @@ class _TvdbMatchWorker(QThread):
|
||||
language=lang,
|
||||
)
|
||||
if not combined and lang:
|
||||
# Combined order may lack translations; fall back to show language.
|
||||
if self.all_seasons:
|
||||
combined = client.get_all_episodes(
|
||||
self.series_id, season_type="alternate",
|
||||
@@ -409,9 +430,6 @@ class _TvdbMatchWorker(QThread):
|
||||
self.series_id, self.season, season_type="alternate",
|
||||
)
|
||||
|
||||
if self.season_type == "alternate":
|
||||
episodes = official
|
||||
|
||||
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}")
|
||||
@@ -422,11 +440,15 @@ class _TvdbMatchWorker(QThread):
|
||||
official_episodes=official,
|
||||
combined_episodes=combined,
|
||||
)
|
||||
self.finished.emit(mapping, unmatched, notes)
|
||||
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:
|
||||
self.failed.emit(str(e))
|
||||
import traceback
|
||||
self.failed.emit(f"{e}\n\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
class TvdbEpisodeRenumberRuleWidget(QWidget):
|
||||
@@ -547,11 +569,12 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
||||
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.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):
|
||||
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:
|
||||
@@ -583,6 +606,9 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
||||
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(
|
||||
@@ -598,22 +624,37 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
||||
self.multi_episode_cb.isChecked(),
|
||||
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.finished.connect(self._on_match_worker_finished)
|
||||
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)
|
||||
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()
|
||||
self._emit()
|
||||
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)
|
||||
@@ -622,11 +663,10 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
||||
def getRule(self) -> TvdbEpisodeRenumberRule:
|
||||
mapping: dict = {}
|
||||
for k, v in self._episode_mapping.items():
|
||||
if hasattr(v, "season"):
|
||||
if getattr(v, "episode_end", None) and v.episode_end > v.episode:
|
||||
mapping[k] = (v.season, v.episode, v.episode_end)
|
||||
else:
|
||||
mapping[k] = (v.season, v.episode)
|
||||
if isinstance(v, tuple):
|
||||
mapping[k] = v
|
||||
elif hasattr(v, "season"):
|
||||
mapping[k] = target_to_tuple(v)
|
||||
else:
|
||||
mapping[k] = v
|
||||
r = TvdbEpisodeRenumberRule(
|
||||
|
||||
@@ -4,6 +4,7 @@ HSRename - Native Linux GUI for mass renaming files.
|
||||
Inspired by Bulk Rename Utility (Windows); supports preview and flexible rules.
|
||||
"""
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
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():
|
||||
_install_exception_logger()
|
||||
QApplication.setHighDpiScaleFactorRoundingPolicy(
|
||||
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
||||
)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
HSRename 1.0.13
|
||||
- Fix crash during TheTVDB match (safe thread signal payloads, worker re-entry guard)
|
||||
- Faster matching by scoping episodes to each file's season
|
||||
- Fewer redundant TheTVDB API calls during match
|
||||
@@ -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