5 Commits
Author SHA1 Message Date
Bulk RenamerandCursor 0d7c16372c Release v1.0.15: fix nxnn preview crash and improve truncated title matching.
Co-authored-by: Cursor <[email protected]>
2026-07-03 22:34:29 -05:00
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
Bulk RenamerandCursor 64d7a30328 Release v1.0.13: fix TheTVDB match crash and improve stability.
Emit plain tuples across thread boundaries, prevent overlapping match workers, scope episode comparisons by season, and cap API pagination.

Co-authored-by: Cursor <[email protected]>
2026-07-03 20:40:44 -05:00
Bulk RenamerandCursor 445e24ecae Release v1.0.12: fetch English TheTVDB episode titles for matching.
Anime and other non-English shows returned Japanese default titles while filenames use English; add Episode titles language control defaulting to English.

Co-authored-by: Cursor <[email protected]>
2026-07-03 20:25:58 -05:00
Bulk RenamerandCursor fce5ad64e5 Release v1.0.11: fix multi-episode match for end-numbered and dual titles.
Combined pairs are recognized when the SxxExx tag uses the last episode number or the filename lists both story titles separated by dashes.

Co-authored-by: Cursor <[email protected]>
2026-07-03 18:34:24 -05:00
12 changed files with 343 additions and 109 deletions
+1 -1
View File
@@ -1 +1 @@
1.0.10 1.0.15
+84 -10
View File
@@ -45,6 +45,13 @@ class EpisodeTarget:
return f"S{s}E{e1}" 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]: def split_combined_title(name: str) -> list[str]:
"""Split a combined-order episode title like 'Ep A/Ep B' into parts.""" """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()] return [p.strip() for p in name.replace(" / ", "/").split("/") if p.strip()]
@@ -163,6 +170,7 @@ def rewrite_episode_stem(
span = target.episode_end - target.episode + 1 span = target.episode_end - target.episode + 1
else: else:
span = parsed["span"] span = parsed["span"]
title = parsed.get("title") or ""
if parsed["format"] == "nxnn": if parsed["format"] == "nxnn":
s_pad = max(parsed["season_pad"], len(str(new_season))) s_pad = max(parsed["season_pad"], len(str(new_season)))
@@ -211,6 +219,33 @@ def _similarity(a: str, b: str) -> float:
return SequenceMatcher(None, a, b).ratio() return SequenceMatcher(None, a, b).ratio()
def _title_match_score(fnorm: str, enorm: str) -> float:
"""Fuzzy title match; handles filenames that use a shortened episode title."""
if not fnorm or not enorm:
return 0.0
if fnorm == enorm:
return 1.0
score = _similarity(fnorm, enorm)
if len(fnorm) >= 4 and (fnorm in enorm or enorm.startswith(fnorm)):
score = max(score, 0.78)
ftokens = [t for t in fnorm.split() if len(t) > 2]
if ftokens:
etokens = set(enorm.split())
overlap = sum(1 for t in ftokens if t in etokens) / len(ftokens)
if overlap >= 0.75:
score = max(score, 0.66 + overlap * 0.3)
return score
def _episode_number_boost(parsed: dict, season: int, ep_num: int, title_score: float) -> float:
"""Nudge score up when NxNN/SxxExx in the filename agrees with this episode."""
if title_score < 0.45:
return 0.0
if parsed.get("season") == season and parsed.get("old_first") == ep_num:
return 0.12
return 0.0
def _coerce_target( def _coerce_target(
value: EpisodeTarget | tuple[int, ...] | int, value: EpisodeTarget | tuple[int, ...] | int,
parsed: dict, parsed: dict,
@@ -269,21 +304,46 @@ def _combined_title_variants(name: str) -> list[str]:
return variants return variants
def _combined_match_score(fnorm: str, combined_name: str) -> float: def _combined_match_score(fnorm: str, combined_name: str, file_title: str = "") -> float:
variants = _combined_title_variants(combined_name) variants = _combined_title_variants(combined_name)
return max((_similarity(fnorm, v) for v in variants), default=0.0) best = max((_title_match_score(fnorm, v) for v in variants), default=0.0)
if file_title and _dual_title_matches_combined(file_title, combined_name):
best = max(best, 0.96)
return best
def _combined_allowed_for_file(parsed: dict, target: EpisodeTarget) -> bool: def _dual_title_matches_combined(file_title: str, combined_name: str) -> bool:
"""Only treat as multi-episode when the file's episode code fits the range start.""" """True when the filename lists both stories in a combined-order pair."""
file_parts = [p.strip() for p in re.split(r"\s+-\s+", file_title) if p.strip()]
combined_parts = split_combined_title(combined_name)
if len(file_parts) < 2 or len(combined_parts) < 2:
return False
matched = 0
for fp in file_parts[: len(combined_parts)]:
fn = normalize_title(fp)
if max(_similarity(fn, normalize_title(cp)) for cp in combined_parts) >= 0.72:
matched += 1
return matched >= 2
def _combined_allowed_for_file(
parsed: dict,
target: EpisodeTarget,
combined_name: str = "",
) -> bool:
"""Treat as multi-episode when the episode tag or dual title fits the aired range."""
if target.span <= 1: if target.span <= 1:
return True return True
if parsed.get("span", 1) > 1: if parsed.get("span", 1) > 1:
return True return True
file_title = parsed.get("title") or ""
if combined_name and _dual_title_matches_combined(file_title, combined_name):
return True
file_ep = parsed.get("old_first") file_ep = parsed.get("old_first")
if file_ep is None: if file_ep is None:
return False return False
return file_ep == target.episode end = target.episode_end if target.episode_end is not None else target.episode
return file_ep == target.episode or file_ep == end
def _range_overlaps( def _range_overlaps(
@@ -370,11 +430,25 @@ def match_filenames_to_episodes(
pairs: list[tuple[float, str, EpisodeTarget, str, str]] = [] pairs: list[tuple[float, str, EpisodeTarget, str, str]] = []
for fname, fnorm, raw_title, parsed in file_entries: 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: if not fnorm:
continue continue
title_score = _title_match_score(fnorm, enorm)
score = title_score + _episode_number_boost(parsed, season, ep_num, title_score)
score = _apply_season_hint( score = _apply_season_hint(
_similarity(fnorm, enorm), score,
EpisodeTarget(season=season, episode=ep_num), EpisodeTarget(season=season, episode=ep_num),
parsed, parsed,
) )
@@ -387,12 +461,12 @@ def match_filenames_to_episodes(
ep_name, ep_name,
) )
) )
for _cep, target, variants in combined_entries: for _cep, target, variants in season_combined:
if not fnorm: if not fnorm:
continue continue
if not _combined_allowed_for_file(parsed, target): if not _combined_allowed_for_file(parsed, target, _cep.name):
continue continue
best = _combined_match_score(fnorm, _cep.name) best = _combined_match_score(fnorm, _cep.name, raw_title)
best = _apply_season_hint(best, target, parsed) best = _apply_season_hint(best, target, parsed)
pairs.append((best, fname, target, raw_title, _cep.name)) pairs.append((best, fname, target, raw_title, _cep.name))
+81 -49
View File
@@ -24,6 +24,12 @@ SEASON_TYPE_CHOICES: list[tuple[str, str]] = [
("Regional order", "regional"), ("Regional order", "regional"),
] ]
# Language path segment for /series/{id}/episodes/{season-type}/{language}
LANGUAGE_CHOICES: list[tuple[str, str]] = [
("Default (show language)", ""),
("English", "eng"),
]
@dataclass(frozen=True) @dataclass(frozen=True)
class TvdbSeries: class TvdbSeries:
@@ -154,37 +160,74 @@ class TvdbClient:
) )
return results return results
def get_season_episodes( def _episodes_path(self, series_id: int, season_type: str, language: str = "") -> str:
base = f"/series/{series_id}/episodes/{season_type}"
if language:
return f"{base}/{language}"
return base
def _parse_episode_batch(
self, self,
series_id: int, batch: list[dict[str, Any]],
season: int, *,
season_type: str = "default", season: Optional[int] = None,
skip_specials: bool = False,
) -> list[TvdbEpisode]: ) -> list[TvdbEpisode]:
episodes: list[TvdbEpisode] = [] episodes: list[TvdbEpisode] = []
page = 0
while True:
payload = self._request(
"GET",
f"/series/{series_id}/episodes/{season_type}",
params={"page": page, "season": season},
)
batch = (payload.get("data") or {}).get("episodes") or []
if not batch:
break
for ep in batch: for ep in batch:
if ep.get("seasonNumber") != season: ep_season = ep.get("seasonNumber")
continue
number = ep.get("number") number = ep.get("number")
name = ep.get("name") name = ep.get("name")
if number is None or not name: if number is None or ep_season is None or not name:
continue
ep_season = int(ep_season)
if skip_specials and ep_season == 0:
continue
if season is not None and ep_season != season:
continue continue
episodes.append( episodes.append(
TvdbEpisode( TvdbEpisode(
number=int(number), number=int(number),
season_number=int(season), season_number=ep_season,
name=str(name), name=str(name),
) )
) )
return episodes
def _fetch_episodes(
self,
series_id: int,
season_type: str,
*,
season: Optional[int] = None,
language: str = "",
skip_specials: bool = False,
) -> list[TvdbEpisode]:
"""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 page < max_pages:
params: dict[str, Any] = {"page": page}
if use_api_season:
params["season"] = season
payload = self._request(
"GET",
self._episodes_path(series_id, season_type, language),
params=params,
)
batch = (payload.get("data") or {}).get("episodes") or []
if not batch:
break
filter_season = season if language else None
episodes.extend(
self._parse_episode_batch(
batch,
season=filter_season,
skip_specials=skip_specials,
)
)
links = payload.get("links") or {} links = payload.get("links") or {}
if not links.get("next"): if not links.get("next"):
break break
@@ -192,41 +235,30 @@ class TvdbClient:
episodes.sort(key=lambda e: (e.season_number, e.number)) episodes.sort(key=lambda e: (e.season_number, e.number))
return episodes return episodes
def get_season_episodes(
self,
series_id: int,
season: int,
season_type: str = "default",
language: str = "",
) -> list[TvdbEpisode]:
return self._fetch_episodes(
series_id,
season_type,
season=season,
language=language,
)
def get_all_episodes( def get_all_episodes(
self, self,
series_id: int, series_id: int,
season_type: str = "default", season_type: str = "default",
language: str = "",
) -> list[TvdbEpisode]: ) -> list[TvdbEpisode]:
"""Fetch every episode for a series (all seasons), paginated.""" """Fetch every episode for a series (all seasons), paginated."""
episodes: list[TvdbEpisode] = [] return self._fetch_episodes(
page = 0 series_id,
while True: season_type,
payload = self._request( language=language,
"GET", skip_specials=True,
f"/series/{series_id}/episodes/{season_type}",
params={"page": page},
) )
batch = (payload.get("data") or {}).get("episodes") or []
if not batch:
break
for ep in batch:
number = ep.get("number")
season = ep.get("seasonNumber")
name = ep.get("name")
if number is None or season is None or not name:
continue
if int(season) == 0:
continue
episodes.append(
TvdbEpisode(
number=int(number),
season_number=int(season),
name=str(name),
)
)
links = payload.get("links") or {}
if not links.get("next"):
break
page += 1
episodes.sort(key=lambda e: (e.season_number, e.number))
return episodes
+16 -2
View File
@@ -25,7 +25,7 @@ from PyQt6.QtWidgets import (
QCheckBox, QCheckBox,
QComboBox, 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 PyQt6.QtGui import QFont, QColor
from engine.pipeline import compute_preview, perform_renames, save_undo_log, load_undo_log, perform_undo 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._file_names: list[str] = []
self._preview_by_orig: dict[str, str] = {} # original name -> new name for selection preview self._preview_by_orig: dict[str, str] = {} # original name -> new name for selection preview
self._split: QSplitter | None = None self._split: QSplitter | None = None
self._preview_refresh_scheduled = False
self._setup_ui() self._setup_ui()
self._restore_layout() self._restore_layout()
self._refresh_preview() self._refresh_preview()
@@ -129,7 +130,7 @@ class MainWindow(QMainWindow):
if hasattr(w, "set_file_names"): if hasattr(w, "set_file_names"):
w.set_file_names(self._file_names) w.set_file_names(self._file_names)
if hasattr(w, "matchCompleted"): if hasattr(w, "matchCompleted"):
w.matchCompleted.connect(self._refresh_preview) w.matchCompleted.connect(self._schedule_refresh_preview)
g = QGroupBox(title) g = QGroupBox(title)
g_layout = QVBoxLayout(g) g_layout = QVBoxLayout(g)
g_layout.setContentsMargins(8, 12, 8, 8) 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 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)) 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): def _refresh_preview(self):
rules = self._get_rules() rules = self._get_rules()
if not self._file_names: if not self._file_names:
+112 -36
View File
@@ -31,8 +31,8 @@ from engine.rules import (
PrefixSuffixRule, PrefixSuffixRule,
CsvMappingRule, CsvMappingRule,
) )
from engine.tvdb_client import TvdbClient, TvdbError, TVDB_API_KEY, SEASON_TYPE_CHOICES 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): class ReplaceRuleWidget(QWidget):
@@ -310,18 +310,19 @@ class EpisodeRenumberRuleWidget(QWidget):
class _TvdbSearchWorker(QThread): class _TvdbSearchWorker(QThread):
finished = pyqtSignal(list) search_done = pyqtSignal()
failed = pyqtSignal(str) failed = pyqtSignal(str)
def __init__(self, query: str): def __init__(self, query: str):
super().__init__() super().__init__()
self.query = query self.query = query
self.results: list = []
def run(self): def run(self):
try: try:
client = TvdbClient(TVDB_API_KEY) client = TvdbClient(TVDB_API_KEY)
results = client.search_series(self.query) self.results = client.search_series(self.query)
self.finished.emit(results) self.search_done.emit()
except TvdbError as e: except TvdbError as e:
self.failed.emit(str(e)) self.failed.emit(str(e))
except Exception as e: except Exception as e:
@@ -329,7 +330,7 @@ class _TvdbSearchWorker(QThread):
class _TvdbMatchWorker(QThread): class _TvdbMatchWorker(QThread):
finished = pyqtSignal(dict, list, list) match_done = pyqtSignal()
failed = pyqtSignal(str) failed = pyqtSignal(str)
def __init__( def __init__(
@@ -340,6 +341,7 @@ class _TvdbMatchWorker(QThread):
filenames: list[str], filenames: list[str],
all_seasons: bool, all_seasons: bool,
multi_episode: bool, multi_episode: bool,
language: str,
): ):
super().__init__() super().__init__()
self.series_id = series_id self.series_id = series_id
@@ -348,49 +350,85 @@ class _TvdbMatchWorker(QThread):
self.filenames = filenames self.filenames = filenames
self.all_seasons = all_seasons self.all_seasons = all_seasons
self.multi_episode = multi_episode self.multi_episode = multi_episode
self.language = language
self.mapping: dict[str, tuple[int, ...]] = {}
self.unmatched: list[str] = []
def run(self): def run(self):
try: try:
client = TvdbClient(TVDB_API_KEY) client = TvdbClient(TVDB_API_KEY)
if self.all_seasons: lang = self.language
official = client.get_all_episodes(self.series_id, season_type="official") season_filter = 0 if self.all_seasons else self.season
season_filter = 0 need_official = self.multi_episode or self.season_type == "alternate"
else:
official = client.get_season_episodes(
self.series_id,
self.season,
season_type="official",
)
season_filter = self.season
if self.all_seasons: if self.all_seasons:
episodes = client.get_all_episodes( episodes = client.get_all_episodes(
self.series_id, self.series_id,
season_type=self.season_type, season_type=self.season_type,
language=lang,
) )
else: else:
episodes = client.get_season_episodes( episodes = client.get_season_episodes(
self.series_id, self.series_id,
self.season, self.season,
season_type=self.season_type, 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 combined = None
if self.multi_episode or self.season_type == "alternate": if need_official:
if self.all_seasons: if self.all_seasons:
combined = client.get_all_episodes( combined = client.get_all_episodes(
self.series_id, self.series_id,
season_type="alternate", season_type="alternate",
language=lang,
) )
else: else:
combined = client.get_season_episodes( combined = client.get_season_episodes(
self.series_id, self.series_id,
self.season, self.season,
season_type="alternate", 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 self.season_type == "alternate":
episodes = official
if not episodes and not combined: if not episodes and not combined:
label = "all seasons" if self.all_seasons else f"season {self.season}" label = "all seasons" if self.all_seasons else f"season {self.season}"
@@ -402,11 +440,15 @@ class _TvdbMatchWorker(QThread):
official_episodes=official, official_episodes=official,
combined_episodes=combined, 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: except TvdbError as e:
self.failed.emit(str(e)) self.failed.emit(str(e))
except Exception as 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): class TvdbEpisodeRenumberRuleWidget(QWidget):
@@ -450,6 +492,13 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
self.order_type.currentIndexChanged.connect(self._emit) self.order_type.currentIndexChanged.connect(self._emit)
layout.addRow("Episode order:", self.order_type) 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 = QSpinBox()
self.season.setMinimum(0) self.season.setMinimum(0)
self.season.setMaximum(99) self.season.setMaximum(99)
@@ -485,6 +534,7 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
info = QLabel( info = QLabel(
"Reads titles from S01E05 - Episode Title, S01E05-E06 (multi-episode), " "Reads titles from S01E05 - Episode Title, S01E05-E06 (multi-episode), "
"or Show 04x01 Episode Title filenames. " "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." "Use All seasons to match across every season and fix wrong season numbers too."
) )
info.setWordWrap(True) info.setWordWrap(True)
@@ -500,6 +550,13 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
value = self.order_type.currentData() value = self.order_type.currentData()
return value if value else "default" 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): def _emit(self):
self.ruleChanged.emit() self.ruleChanged.emit()
@@ -512,11 +569,12 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
self.series_combo.setEnabled(False) self.series_combo.setEnabled(False)
self.status.setText("Searching TheTVDB…") self.status.setText("Searching TheTVDB…")
self._search_worker = _TvdbSearchWorker(query) 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.failed.connect(self._on_search_failed)
self._search_worker.start() 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._search_results = results
self.series_combo.clear() self.series_combo.clear()
if not results: if not results:
@@ -548,10 +606,13 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
series_id = self.series_combo.currentData() series_id = self.series_combo.currentData()
if series_id is None: if series_id is None:
return 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) self.match_btn.setEnabled(False)
all_seasons = self.season.value() == 0 all_seasons = self.season.value() == 0
self.status.setText( self.status.setText(
f"Fetching episodes ({self._order_label()}, " f"Fetching episodes ({self._order_label()}, {self._language_label()}, "
f"{'all seasons' if all_seasons else f'season {self.season.value()}'}…) and matching titles…" f"{'all seasons' if all_seasons else f'season {self.season.value()}'}…) and matching titles…"
) )
self._match_worker = _TvdbMatchWorker( self._match_worker = _TvdbMatchWorker(
@@ -561,23 +622,39 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
self._file_names, self._file_names,
all_seasons, all_seasons,
self.multi_episode_cb.isChecked(), 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.failed.connect(self._on_match_failed)
self._match_worker.finished.connect(self._on_match_worker_finished)
self._match_worker.start() self._match_worker.start()
def _on_match_finished(self, mapping: dict, unmatched: list, notes: list): def _on_match_worker_finished(self):
self._episode_mapping = mapping 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) self.match_btn.setEnabled(True)
matched = len(mapping) matched = len(self._episode_mapping)
total = len(self._file_names) total = len(self._file_names)
scope = "all seasons" if self.season.value() == 0 else f"season {self.season.value()}" 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()})." msg = f"Matched {matched} of {total} file(s) ({scope}, {self._order_label()}, {self._language_label()})."
if unmatched: if unmatched:
msg += f" {len(unmatched)} file(s) unmatched." msg += f" {len(unmatched)} file(s) unmatched."
self.status.setText(msg) self.status.setText(msg)
self.matchCompleted.emit() self.matchCompleted.emit()
self._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): def _on_match_failed(self, message: str):
self.match_btn.setEnabled(True) self.match_btn.setEnabled(True)
@@ -586,11 +663,10 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
def getRule(self) -> TvdbEpisodeRenumberRule: def getRule(self) -> TvdbEpisodeRenumberRule:
mapping: dict = {} mapping: dict = {}
for k, v in self._episode_mapping.items(): for k, v in self._episode_mapping.items():
if hasattr(v, "season"): if isinstance(v, tuple):
if getattr(v, "episode_end", None) and v.episode_end > v.episode: mapping[k] = v
mapping[k] = (v.season, v.episode, v.episode_end) elif hasattr(v, "season"):
else: mapping[k] = target_to_tuple(v)
mapping[k] = (v.season, v.episode)
else: else:
mapping[k] = v mapping[k] = v
r = TvdbEpisodeRenumberRule( r = TvdbEpisodeRenumberRule(
+20
View File
@@ -4,6 +4,7 @@ HSRename - Native Linux GUI for mass renaming files.
Inspired by Bulk Rename Utility (Windows); supports preview and flexible rules. Inspired by Bulk Rename Utility (Windows); supports preview and flexible rules.
""" """
import sys import sys
import traceback
from pathlib import Path from pathlib import Path
# Ensure project root is on path when run as script or module # 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 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(): def main():
_install_exception_logger()
QApplication.setHighDpiScaleFactorRoundingPolicy( QApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
) )
+3
View File
@@ -0,0 +1,3 @@
HSRename 1.0.11
- Fix multi-episode detection when filename uses end episode (S01E06 for E05-E06)
- Detect dual titles separated by " - " (Otto 3000 - Night Prowler) as combined pairs
+3
View File
@@ -0,0 +1,3 @@
HSRename 1.0.12
- Episode titles language selector (English / show default) for TheTVDB matching
- Fixes 0 matches on anime and other shows when filenames are English but TheTVDB default is Japanese
+4
View File
@@ -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
+4
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
HSRename 1.0.15
- Fix nxnn preview crash (undefined title in rewrite_episode_stem)
- Improve matching for shortened episode titles (prefix/token overlap scoring)
+2 -1
View File
@@ -76,7 +76,8 @@ API="${GITEA_HOST}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases"
NOTES="$(cat <<EOF NOTES="$(cat <<EOF
## HSRename ${TAG} ## HSRename ${TAG}
- Fix botched v1.0.4 AppImage (restores TheTVDB episode match + search fix) - Fix nxnn preview crash (\`name 'title' is not defined\`)
- Improve TheTVDB matching for shortened episode titles (prefix and token overlap)
- Always verify/update via Gear Lever Forgejo: \`https://git.hisora.dev/Dawnsorrow/HS-Rename\` - Always verify/update via Gear Lever Forgejo: \`https://git.hisora.dev/Dawnsorrow/HS-Rename\`
Download: Download: