6 Commits
Author SHA1 Message Date
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
Bulk RenamerandCursor a6339467d4 Release v1.0.10: Jellyfin multi-episode file naming (S01E01-E02).
Match TheTVDB combined-order titles and rename to official aired episode ranges for files that contain two episodes in one video.

Co-authored-by: Cursor <[email protected]>
2026-07-03 18:23:06 -05:00
Bulk RenamerandCursor a65ae73bcc Release v1.0.9: persist UI layout and fix SxxExx double-S renames.
Save window geometry, splitter sizes, and preview column widths on exit; restore on launch. Fix episode rewrite duplicating the season letter.

Co-authored-by: Cursor <[email protected]>
2026-07-03 17:58:45 -05:00
12 changed files with 600 additions and 122 deletions
+1 -1
View File
@@ -1 +1 @@
1.0.8 1.0.14
+255 -26
View File
@@ -13,7 +13,7 @@ from .tvdb_client import TvdbEpisode
# S01E05 - Title or S01E05-E06 - Title # S01E05 - Title or S01E05-E06 - Title
PATTERN_SXXEXX = re.compile( PATTERN_SXXEXX = re.compile(
r"^(.*?[Ss])(\d+)([Ee])(\d+)(-[Ee](\d+))?(.*)$", r"^(.*?)([Ss])(\d+)([Ee])(\d+)(-[Ee](\d+))?(.*)$",
) )
# Show Name 04x01 Title, 4x01 - Title, etc. # Show Name 04x01 Title, 4x01 - Title, etc.
PATTERN_NXNN = re.compile( PATTERN_NXNN = re.compile(
@@ -27,6 +27,34 @@ DEFAULT_EPISODE_PATTERN = PATTERN_SXXEXX.pattern
class EpisodeTarget: class EpisodeTarget:
season: int season: int
episode: int episode: int
episode_end: Optional[int] = None
@property
def span(self) -> int:
if self.episode_end is not None and self.episode_end > self.episode:
return self.episode_end - self.episode + 1
return 1
def format_code(self, padding: int = 2) -> str:
pad = max(1, padding)
s = str(self.season).zfill(pad)
e1 = str(self.episode).zfill(pad)
if self.episode_end is not None and self.episode_end > self.episode:
e2 = str(self.episode_end).zfill(pad)
return f"S{s}E{e1}-E{e2}"
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()]
def normalize_title(title: str) -> str: def normalize_title(title: str) -> str:
@@ -48,6 +76,13 @@ def _clean_title(rest: str) -> str:
title = title[1:].strip() title = title[1:].strip()
if title.startswith("_"): if title.startswith("_"):
title = title[1:].strip() title = title[1:].strip()
title = re.sub(
r"\(\s*(?:2160p|1080p|720p|480p|4k|uhd|hd|sd|web[- ]?dl|bluray|dvdrip)\s*\)",
"",
title,
flags=re.IGNORECASE,
)
title = re.sub(r"\s+", " ", title).strip()
return title return title
@@ -60,7 +95,7 @@ def parse_episode_stem(stem: str, pattern: str = DEFAULT_EPISODE_PATTERN) -> Opt
m = PATTERN_SXXEXX.match(stem) m = PATTERN_SXXEXX.match(stem)
if m: if m:
prefix_before_s, season_s, _e, ep_s, _dash, ep2_s, rest = m.groups() prefix, s_letter, season_s, e_letter, ep_s, _range_block, ep2_s, rest = m.groups()
try: try:
season = int(season_s) season = int(season_s)
old_first = int(ep_s) old_first = int(ep_s)
@@ -78,7 +113,9 @@ def parse_episode_stem(stem: str, pattern: str = DEFAULT_EPISODE_PATTERN) -> Opt
title = _clean_title(rest) title = _clean_title(rest)
return { return {
"format": "sxxexx", "format": "sxxexx",
"prefix_before_s": prefix_before_s, "prefix": prefix,
"s_letter": s_letter,
"e_letter": e_letter,
"season": season, "season": season,
"season_pad": len(season_s), "season_pad": len(season_s),
"episode_pad": len(ep_s), "episode_pad": len(ep_s),
@@ -129,7 +166,9 @@ def rewrite_episode_stem(
pad = max(1, padding) pad = max(1, padding)
new_season = target.season new_season = target.season
new_ep = target.episode new_ep = target.episode
title = parsed["title"] if target.episode_end is not None and target.episode_end > target.episode:
span = target.episode_end - target.episode + 1
else:
span = parsed["span"] span = parsed["span"]
if parsed["format"] == "nxnn": if parsed["format"] == "nxnn":
@@ -143,11 +182,14 @@ def rewrite_episode_stem(
s_pad = max(parsed["season_pad"], len(str(new_season))) s_pad = max(parsed["season_pad"], len(str(new_season)))
e_pad = max(parsed["episode_pad"], pad, len(str(new_ep))) e_pad = max(parsed["episode_pad"], pad, len(str(new_ep)))
e1 = str(new_ep).zfill(e_pad) e1 = str(new_ep).zfill(e_pad)
prefix = f"{parsed['prefix_before_s']}S{new_season:0{s_pad}d}E" head = (
f"{parsed['prefix']}{parsed['s_letter']}{new_season:0{s_pad}d}{parsed['e_letter']}"
)
if span <= 1: if span <= 1:
return f"{prefix}{e1}{parsed['suffix']}" return f"{head}{e1}{parsed['suffix']}"
e2 = str(new_ep + span - 1).zfill(e_pad) e2 = str(new_ep + span - 1).zfill(e_pad)
return f"{prefix}{e1}-E{e2}{parsed['suffix']}" range_prefix = f"-{parsed['e_letter']}"
return f"{head}{e1}{range_prefix}{e2}{parsed['suffix']}"
def rewrite_episode_number( def rewrite_episode_number(
@@ -176,41 +218,173 @@ def _similarity(a: str, b: str) -> float:
return SequenceMatcher(None, a, b).ratio() return SequenceMatcher(None, a, b).ratio()
def _coerce_target(value: EpisodeTarget | tuple[int, int] | int, parsed: dict) -> EpisodeTarget: def _coerce_target(
value: EpisodeTarget | tuple[int, ...] | int,
parsed: dict,
) -> EpisodeTarget:
if isinstance(value, EpisodeTarget): if isinstance(value, EpisodeTarget):
return value return value
if isinstance(value, tuple): if isinstance(value, tuple):
if len(value) >= 3:
return EpisodeTarget(
season=int(value[0]),
episode=int(value[1]),
episode_end=int(value[2]) if value[2] is not None else None,
)
if len(value) >= 2:
return EpisodeTarget(season=int(value[0]), episode=int(value[1])) return EpisodeTarget(season=int(value[0]), episode=int(value[1]))
return EpisodeTarget(season=parsed["season"], episode=int(value[0]))
return EpisodeTarget(season=parsed["season"], episode=int(value)) return EpisodeTarget(season=parsed["season"], episode=int(value))
def resolve_combined_to_official(
combined_ep: TvdbEpisode,
official_episodes: list[TvdbEpisode],
) -> Optional[EpisodeTarget]:
"""Map a combined-order episode to official aired SxxExx(-Exx) numbers."""
parts = split_combined_title(combined_ep.name)
if not parts:
return None
matched: list[int] = []
season = combined_ep.season_number
season_official = [ep for ep in official_episodes if ep.season_number == season]
for part in parts:
pn = normalize_title(part)
best_num: Optional[int] = None
best_score = 0.0
for ep in season_official:
score = _similarity(pn, normalize_title(ep.name))
if score > best_score:
best_score = score
best_num = ep.number
if best_num is not None and best_score >= 0.72:
matched.append(best_num)
if not matched:
return None
start, end = min(matched), max(matched)
return EpisodeTarget(
season=season,
episode=start,
episode_end=end if end > start else None,
)
def _combined_title_variants(name: str) -> list[str]:
variants = [normalize_title(name.replace("/", " ")), normalize_title(name)]
for part in split_combined_title(name):
variants.append(normalize_title(part))
return variants
def _combined_match_score(fnorm: str, combined_name: str, file_title: str = "") -> float:
variants = _combined_title_variants(combined_name)
best = max((_similarity(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 _dual_title_matches_combined(file_title: str, combined_name: str) -> bool:
"""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:
return True
if parsed.get("span", 1) > 1:
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")
if file_ep is None:
return False
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(
season: int,
start: int,
end: int,
used_ranges: list[tuple[int, int, int]],
) -> bool:
for s, a, b in used_ranges:
if s != season:
continue
if not (end < a or start > b):
return True
return False
def _target_range(target: EpisodeTarget) -> tuple[int, int, int]:
end = target.episode_end if target.episode_end is not None else target.episode
return target.season, target.episode, end
def _apply_season_hint(score: float, target: EpisodeTarget, parsed: dict) -> float:
"""Prefer matches in the same season as the filename's episode code."""
file_season = parsed.get("season")
if file_season is None:
return score
if target.season == file_season:
return score + 0.08
return score * 0.5
def match_filenames_to_episodes( def match_filenames_to_episodes(
filenames: list[str], filenames: list[str],
episodes: list[TvdbEpisode], episodes: list[TvdbEpisode],
pattern: str = DEFAULT_EPISODE_PATTERN, pattern: str = DEFAULT_EPISODE_PATTERN,
min_score: float = 0.65, min_score: float = 0.65,
season_filter: int = 0, season_filter: int = 0,
official_episodes: Optional[list[TvdbEpisode]] = None,
combined_episodes: Optional[list[TvdbEpisode]] = None,
) -> tuple[dict[str, EpisodeTarget], list[str], list[str]]: ) -> tuple[dict[str, EpisodeTarget], list[str], list[str]]:
""" """
Match filenames to TheTVDB episodes by title. Match filenames to TheTVDB episodes by title.
season_filter: 0 = use all episodes; else only episodes from that season. season_filter: 0 = use all episodes; else only episodes from that season.
Returns mapping filename -> (season, episode), unmatched list, notes. official_episodes + combined_episodes: when set, also match combined-order titles
and map to official aired numbers as Jellyfin multi-episode ranges (S01E01-E02).
Returns mapping filename -> target, unmatched list, notes.
""" """
if season_filter > 0: if season_filter > 0:
episodes = [ep for ep in episodes if ep.season_number == season_filter] episodes = [ep for ep in episodes if ep.season_number == season_filter]
official = official_episodes or episodes
if season_filter > 0:
official = [ep for ep in official if ep.season_number == season_filter]
combined = combined_episodes
if combined and season_filter > 0:
combined = [ep for ep in combined if ep.season_number == season_filter]
file_entries: list[tuple[str, str, str, dict]] = [] file_entries: list[tuple[str, str, str, dict]] = []
for name in filenames: for name in filenames:
base = Path(name).name base = Path(name).name
stem = base.rsplit(".", 1)[0] if "." in base and not base.startswith(".") else base stem = base.rsplit(".", 1)[0] if "." in base and not base.startswith(".") else base
parsed = parse_episode_stem(stem, pattern) parsed = parse_episode_stem(stem, pattern)
if not parsed or not parsed["title"]: if not parsed:
continue continue
if season_filter > 0 and parsed["season"] != season_filter: if season_filter > 0 and parsed["season"] != season_filter:
continue continue
norm = normalize_title(parsed["title"]) norm = normalize_title(parsed["title"]) if parsed["title"] else ""
if norm: if norm or parsed.get("span", 1) > 1:
file_entries.append((name, norm, parsed["title"], parsed)) file_entries.append((name, norm, parsed["title"], parsed))
ep_entries = [ ep_entries = [
@@ -218,33 +392,88 @@ def match_filenames_to_episodes(
for ep in episodes for ep in episodes
] ]
pairs: list[tuple[float, str, int, int, str, str]] = [] combined_entries: list[tuple[TvdbEpisode, EpisodeTarget, list[str]]] = []
for fname, fnorm, raw_title, _parsed in file_entries: if combined:
for season, ep_num, enorm, ep_name in ep_entries: for cep in combined:
score = _similarity(fnorm, enorm) target = resolve_combined_to_official(cep, official)
pairs.append((score, fname, season, ep_num, raw_title, ep_name)) if target is None:
continue
combined_entries.append((cep, target, _combined_title_variants(cep.name)))
pairs.sort(key=lambda x: (-x[0], x[1], x[2], x[3])) pairs: list[tuple[float, str, EpisodeTarget, str, str]] = []
for fname, fnorm, raw_title, parsed in file_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(
_similarity(fnorm, enorm),
EpisodeTarget(season=season, episode=ep_num),
parsed,
)
pairs.append(
(
score,
fname,
EpisodeTarget(season=season, episode=ep_num),
raw_title,
ep_name,
)
)
for _cep, target, variants in season_combined:
if not fnorm:
continue
if not _combined_allowed_for_file(parsed, target, _cep.name):
continue
best = _combined_match_score(fnorm, _cep.name, raw_title)
best = _apply_season_hint(best, target, parsed)
pairs.append((best, fname, target, raw_title, _cep.name))
pairs.sort(key=lambda x: (-x[0], -x[2].span, x[1], x[2].season, x[2].episode))
mapping: dict[str, EpisodeTarget] = {} mapping: dict[str, EpisodeTarget] = {}
used_files: set[str] = set() used_files: set[str] = set()
used_eps: set[tuple[int, int]] = set() used_ranges: list[tuple[int, int, int]] = []
notes: list[str] = [] notes: list[str] = []
for score, fname, season, ep_num, raw_title, ep_name in pairs: multi_file_names = {
fname for fname, _fnorm, _raw, parsed in file_entries if parsed.get("span", 1) > 1
}
def _assign_pairs(candidates: list[tuple[float, str, EpisodeTarget, str, str]]) -> None:
for score, fname, target, raw_title, ep_name in candidates:
if score < min_score: if score < min_score:
break break
ep_key = (season, ep_num) if fname in used_files:
if fname in used_files or ep_key in used_eps:
continue continue
mapping[fname] = EpisodeTarget(season=season, episode=ep_num) season, start, end = _target_range(target)
if _range_overlaps(season, start, end, used_ranges):
continue
mapping[fname] = target
used_files.add(fname) used_files.add(fname)
used_eps.add(ep_key) used_ranges.append((season, start, end))
pct = int(round(score * 100)) pct = min(100, int(round(score * 100)))
code = target.format_code()
notes.append( notes.append(
f"{fname}: S{season:02d}E{ep_num:02d} ← “{ep_name}” ({pct}% match, file title “{raw_title}”)" f"{fname}: {code} ← “{ep_name}” ({pct}% match, file title “{raw_title}”)"
) )
multi_pairs = [p for p in pairs if p[1] in multi_file_names]
other_pairs = [p for p in pairs if p[1] not in multi_file_names]
_assign_pairs(multi_pairs)
_assign_pairs(other_pairs)
unmatched_files = [ unmatched_files = [
name for name in filenames name for name in filenames
if name not in mapping if name not in mapping
+82 -50
View File
@@ -20,10 +20,16 @@ SEASON_TYPE_CHOICES: list[tuple[str, str]] = [
("Official / aired order", "official"), ("Official / aired order", "official"),
("DVD order", "dvd"), ("DVD order", "dvd"),
("Absolute order", "absolute"), ("Absolute order", "absolute"),
("Alternate order", "alternate"), ("Combined order (multi-episode files)", "alternate"),
("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
+50 -2
View File
@@ -25,7 +25,7 @@ from PyQt6.QtWidgets import (
QCheckBox, QCheckBox,
QComboBox, QComboBox,
) )
from PyQt6.QtCore import Qt, QDir, QItemSelectionModel 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
@@ -53,7 +53,10 @@ class MainWindow(QMainWindow):
self._base_dir = "" self._base_dir = ""
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._preview_refresh_scheduled = False
self._setup_ui() self._setup_ui()
self._restore_layout()
self._refresh_preview() self._refresh_preview()
def _setup_ui(self): def _setup_ui(self):
@@ -127,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)
@@ -172,8 +175,40 @@ class MainWindow(QMainWindow):
split.addWidget(right) split.addWidget(right)
split.setSizes([320, 680]) split.setSizes([320, 680])
self._split = split
layout.addWidget(split, 1) layout.addWidget(split, 1)
def _settings(self) -> QSettings:
return QSettings()
def _restore_layout(self) -> None:
settings = self._settings()
geometry = settings.value("window/geometry")
if geometry is not None:
self.restoreGeometry(geometry)
if self._split is not None:
split_state = settings.value("splitter/state")
if split_state is not None:
self._split.restoreState(split_state)
header = self.preview_table.horizontalHeader()
header_state = settings.value("preview_table/header")
if header_state is not None:
header.restoreState(header_state)
def _save_layout(self) -> None:
settings = self._settings()
settings.setValue("window/geometry", self.saveGeometry())
if self._split is not None:
settings.setValue("splitter/state", self._split.saveState())
settings.setValue(
"preview_table/header",
self.preview_table.horizontalHeader().saveState(),
)
def closeEvent(self, event) -> None:
self._save_layout()
super().closeEvent(event)
def _browse(self): def _browse(self):
path = QFileDialog.getExistingDirectory(self, "Select folder") path = QFileDialog.getExistingDirectory(self, "Select folder")
if path: if path:
@@ -240,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:
+149 -25
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__(
@@ -339,6 +340,8 @@ class _TvdbMatchWorker(QThread):
season_type: str, season_type: str,
filenames: list[str], filenames: list[str],
all_seasons: bool, all_seasons: bool,
multi_episode: bool,
language: str,
): ):
super().__init__() super().__init__()
self.series_id = series_id self.series_id = series_id
@@ -346,36 +349,106 @@ class _TvdbMatchWorker(QThread):
self.season_type = season_type self.season_type = season_type
self.filenames = filenames self.filenames = filenames
self.all_seasons = all_seasons 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): def run(self):
try: try:
client = TvdbClient(TVDB_API_KEY) client = TvdbClient(TVDB_API_KEY)
lang = self.language
season_filter = 0 if self.all_seasons else self.season
need_official = self.multi_episode or self.season_type == "alternate"
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,
) )
season_filter = 0
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,
) )
season_filter = self.season
if not episodes: 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 need_official:
if self.all_seasons:
combined = client.get_all_episodes(
self.series_id,
season_type="alternate",
language=lang,
)
else:
combined = client.get_season_episodes(
self.series_id,
self.season,
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 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}"
raise TvdbError(f"No episodes found for {label}") raise TvdbError(f"No episodes found for {label}")
mapping, unmatched, notes = match_filenames_to_episodes( mapping, unmatched, notes = match_filenames_to_episodes(
self.filenames, self.filenames,
episodes, episodes,
season_filter=season_filter, season_filter=season_filter,
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: 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):
@@ -419,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)
@@ -434,6 +514,15 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
self.padding.valueChanged.connect(self._emit) self.padding.valueChanged.connect(self._emit)
layout.addRow("Zero-pad width:", self.padding) layout.addRow("Zero-pad width:", self.padding)
self.multi_episode_cb = QCheckBox("Multi-episode files (Jellyfin S01E01-E02)")
self.multi_episode_cb.setChecked(True)
self.multi_episode_cb.setToolTip(
"Match combined-order titles from TheTVDB and rename using aired episode ranges, "
"e.g. two episodes in one file becomes S01E01-E02."
)
self.multi_episode_cb.toggled.connect(self._emit)
layout.addRow(self.multi_episode_cb)
self.match_btn = QPushButton("Match titles from file list") self.match_btn = QPushButton("Match titles from file list")
self.match_btn.clicked.connect(self._match_titles) self.match_btn.clicked.connect(self._match_titles)
layout.addRow(self.match_btn) layout.addRow(self.match_btn)
@@ -443,7 +532,9 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
layout.addRow(self.status) layout.addRow(self.status)
info = QLabel( info = QLabel(
"Reads titles from S01E05 - Episode Title or Show 04x01 Episode Title filenames. " "Reads titles from S01E05 - Episode Title, S01E05-E06 (multi-episode), "
"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)
@@ -459,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()
@@ -471,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:
@@ -507,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(
@@ -519,34 +621,56 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
self._order_value(), self._order_value(),
self._file_names, self._file_names,
all_seasons, all_seasons,
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)
self.status.setText(f"Match failed: {message}") self.status.setText(f"Match failed: {message}")
def getRule(self) -> TvdbEpisodeRenumberRule: def getRule(self) -> TvdbEpisodeRenumberRule:
mapping: dict = {}
for k, v in self._episode_mapping.items():
if isinstance(v, tuple):
mapping[k] = v
elif hasattr(v, "season"):
mapping[k] = target_to_tuple(v)
else:
mapping[k] = v
r = TvdbEpisodeRenumberRule( r = TvdbEpisodeRenumberRule(
episode_mapping={ episode_mapping=mapping,
k: (v.season, v.episode) if hasattr(v, "season") else v
for k, v in self._episode_mapping.items()
},
padding=self.padding.value(), padding=self.padding.value(),
) )
r.enabled = self.enabled_cb.isChecked() r.enabled = self.enabled_cb.isChecked()
+24
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,12 +17,35 @@ 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
) )
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setOrganizationName("HSRename")
app.setOrganizationDomain("hisora.dev")
app.setApplicationName("HSRename") app.setApplicationName("HSRename")
version_file = _root / "VERSION"
app.setApplicationVersion(version_file.read_text().strip() if version_file.is_file() else "")
win = MainWindow() win = MainWindow()
win.show() win.show()
sys.exit(app.exec()) sys.exit(app.exec())
+4
View File
@@ -0,0 +1,4 @@
HSRename 1.0.10
- Multi-episode files: Jellyfin S01E01-E02 naming from TheTVDB combined order
- Match combined titles (Ep A/Ep B) and map to official aired episode ranges
- Combined order option in episode order dropdown; multi-episode toggle on TheTVDB rule
+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.9
- Remember window size, splitter position, and preview column widths between sessions
- Fix double S in SxxExx renames (SS02E33) when show name ends before the episode code