5 Commits
Author SHA1 Message Date
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
11 changed files with 537 additions and 113 deletions
+1 -1
View File
@@ -1 +1 @@
1.0.8 1.0.13
+263 -34
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,8 +166,10 @@ 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 = parsed["span"] span = target.episode_end - target.episode + 1
else:
span = parsed["span"]
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)))
@@ -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):
return EpisodeTarget(season=int(value[0]), episode=int(value[1])) 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=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,32 +392,87 @@ 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 = {
if score < min_score: fname for fname, _fnorm, _raw, parsed in file_entries if parsed.get("span", 1) > 1
break }
ep_key = (season, ep_num)
if fname in used_files or ep_key in used_eps: def _assign_pairs(candidates: list[tuple[float, str, EpisodeTarget, str, str]]) -> None:
continue for score, fname, target, raw_title, ep_name in candidates:
mapping[fname] = EpisodeTarget(season=season, episode=ep_num) if score < min_score:
used_files.add(fname) break
used_eps.add(ep_key) if fname in used_files:
pct = int(round(score * 100)) continue
notes.append( season, start, end = _target_range(target)
f"{fname}: S{season:02d}E{ep_num:02d} ← “{ep_name}” ({pct}% match, file title “{raw_title}”)" if _range_overlaps(season, start, end, used_ranges):
) continue
mapping[fname] = target
used_files.add(fname)
used_ranges.append((season, start, end))
pct = min(100, int(round(score * 100)))
code = target.format_code()
notes.append(
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
+85 -53
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] = []
for ep in batch:
ep_season = ep.get("seasonNumber")
number = ep.get("number")
name = ep.get("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
episodes.append(
TvdbEpisode(
number=int(number),
season_number=ep_season,
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] = [] episodes: list[TvdbEpisode] = []
page = 0 page = 0
while True: 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( payload = self._request(
"GET", "GET",
f"/series/{series_id}/episodes/{season_type}", self._episodes_path(series_id, season_type, language),
params={"page": page, "season": season}, params=params,
) )
batch = (payload.get("data") or {}).get("episodes") or [] batch = (payload.get("data") or {}).get("episodes") or []
if not batch: if not batch:
break break
for ep in batch: filter_season = season if language else None
if ep.get("seasonNumber") != season: episodes.extend(
continue self._parse_episode_batch(
number = ep.get("number") batch,
name = ep.get("name") season=filter_season,
if number is None or not name: skip_specials=skip_specials,
continue
episodes.append(
TvdbEpisode(
number=int(number),
season_number=int(season),
name=str(name),
)
) )
)
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
+35 -1
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
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,9 @@ 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._setup_ui() self._setup_ui()
self._restore_layout()
self._refresh_preview() self._refresh_preview()
def _setup_ui(self): def _setup_ui(self):
@@ -172,8 +174,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:
+132 -24
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):
@@ -339,6 +339,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 +348,102 @@ 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
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) safe_mapping = {path: target_to_tuple(t) for path, t in mapping.items()}
self.finished.emit(safe_mapping, unmatched, notes)
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 +487,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 +509,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 +527,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 +545,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()
@@ -507,10 +600,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 +615,46 @@ 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.finished.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.start() self._match_worker.start()
def _on_match_finished(self, mapping: dict, unmatched: list, notes: list): def _on_match_finished(self, mapping: dict, unmatched: list, notes: list):
self._episode_mapping = mapping try:
self.match_btn.setEnabled(True) self._episode_mapping = mapping
matched = len(mapping) self.match_btn.setEnabled(True)
total = len(self._file_names) matched = len(mapping)
scope = "all seasons" if self.season.value() == 0 else f"season {self.season.value()}" total = len(self._file_names)
msg = f"Matched {matched} of {total} file(s) ({scope}, {self._order_label()})." scope = "all seasons" if self.season.value() == 0 else f"season {self.season.value()}"
if unmatched: msg = f"Matched {matched} of {total} file(s) ({scope}, {self._order_label()}, {self._language_label()})."
msg += f" {len(unmatched)} file(s) unmatched." if unmatched:
self.status.setText(msg) msg += f" {len(unmatched)} file(s) unmatched."
self.matchCompleted.emit() self.status.setText(msg)
self._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()
+4
View File
@@ -21,7 +21,11 @@ def main():
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
+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