diff --git a/VERSION b/VERSION index 66c4c22..7ee7020 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.9 +1.0.10 diff --git a/engine/episode_match.py b/engine/episode_match.py index f3cc3ed..476f740 100644 --- a/engine/episode_match.py +++ b/engine/episode_match.py @@ -27,6 +27,27 @@ DEFAULT_EPISODE_PATTERN = PATTERN_SXXEXX.pattern class EpisodeTarget: season: 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 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: @@ -48,6 +69,13 @@ def _clean_title(rest: str) -> str: title = title[1:].strip() if title.startswith("_"): 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 @@ -131,8 +159,10 @@ def rewrite_episode_stem( pad = max(1, padding) new_season = target.season new_ep = target.episode - title = parsed["title"] - span = parsed["span"] + if target.episode_end is not None and target.episode_end > target.episode: + span = target.episode_end - target.episode + 1 + else: + span = parsed["span"] if parsed["format"] == "nxnn": s_pad = max(parsed["season_pad"], len(str(new_season))) @@ -181,41 +211,148 @@ def _similarity(a: str, b: str) -> float: 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): return value 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)) +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) -> float: + variants = _combined_title_variants(combined_name) + return max((_similarity(fnorm, v) for v in variants), default=0.0) + + +def _combined_allowed_for_file(parsed: dict, target: EpisodeTarget) -> bool: + """Only treat as multi-episode when the file's episode code fits the range start.""" + if target.span <= 1: + return True + if parsed.get("span", 1) > 1: + return True + file_ep = parsed.get("old_first") + if file_ep is None: + return False + return file_ep == target.episode + + +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( filenames: list[str], episodes: list[TvdbEpisode], pattern: str = DEFAULT_EPISODE_PATTERN, min_score: float = 0.65, season_filter: int = 0, + official_episodes: Optional[list[TvdbEpisode]] = None, + combined_episodes: Optional[list[TvdbEpisode]] = None, ) -> tuple[dict[str, EpisodeTarget], list[str], list[str]]: """ Match filenames to TheTVDB episodes by title. 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: 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]] = [] for name in filenames: base = Path(name).name stem = base.rsplit(".", 1)[0] if "." in base and not base.startswith(".") else base parsed = parse_episode_stem(stem, pattern) - if not parsed or not parsed["title"]: + if not parsed: continue if season_filter > 0 and parsed["season"] != season_filter: continue - norm = normalize_title(parsed["title"]) - if norm: + norm = normalize_title(parsed["title"]) if parsed["title"] else "" + if norm or parsed.get("span", 1) > 1: file_entries.append((name, norm, parsed["title"], parsed)) ep_entries = [ @@ -223,32 +360,75 @@ def match_filenames_to_episodes( for ep in episodes ] - pairs: list[tuple[float, str, int, int, str, str]] = [] - for fname, fnorm, raw_title, _parsed in file_entries: - for season, ep_num, enorm, ep_name in ep_entries: - score = _similarity(fnorm, enorm) - pairs.append((score, fname, season, ep_num, raw_title, ep_name)) + combined_entries: list[tuple[TvdbEpisode, EpisodeTarget, list[str]]] = [] + if combined: + for cep in combined: + target = resolve_combined_to_official(cep, official) + 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: + for season, ep_num, enorm, ep_name in ep_entries: + 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 combined_entries: + if not fnorm: + continue + if not _combined_allowed_for_file(parsed, target): + continue + best = _combined_match_score(fnorm, _cep.name) + 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] = {} used_files: set[str] = set() - used_eps: set[tuple[int, int]] = set() + used_ranges: list[tuple[int, int, int]] = [] notes: list[str] = [] - for score, fname, season, ep_num, raw_title, ep_name in pairs: - if score < min_score: - break - ep_key = (season, ep_num) - if fname in used_files or ep_key in used_eps: - continue - mapping[fname] = EpisodeTarget(season=season, episode=ep_num) - used_files.add(fname) - used_eps.add(ep_key) - pct = int(round(score * 100)) - notes.append( - f"{fname}: S{season:02d}E{ep_num:02d} ← “{ep_name}” ({pct}% match, file title “{raw_title}”)" - ) + 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: + break + if fname in used_files: + continue + season, start, end = _target_range(target) + 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 = [ name for name in filenames diff --git a/engine/tvdb_client.py b/engine/tvdb_client.py index 2632ac9..c74c3d9 100644 --- a/engine/tvdb_client.py +++ b/engine/tvdb_client.py @@ -20,7 +20,7 @@ SEASON_TYPE_CHOICES: list[tuple[str, str]] = [ ("Official / aired order", "official"), ("DVD order", "dvd"), ("Absolute order", "absolute"), - ("Alternate order", "alternate"), + ("Combined order (multi-episode files)", "alternate"), ("Regional order", "regional"), ] diff --git a/gui/rule_widgets.py b/gui/rule_widgets.py index 2af7e09..64afcd7 100644 --- a/gui/rule_widgets.py +++ b/gui/rule_widgets.py @@ -339,6 +339,7 @@ class _TvdbMatchWorker(QThread): season_type: str, filenames: list[str], all_seasons: bool, + multi_episode: bool, ): super().__init__() self.series_id = series_id @@ -346,30 +347,60 @@ class _TvdbMatchWorker(QThread): self.season_type = season_type self.filenames = filenames self.all_seasons = all_seasons + self.multi_episode = multi_episode def run(self): try: client = TvdbClient(TVDB_API_KEY) + if self.all_seasons: + official = client.get_all_episodes(self.series_id, season_type="official") + season_filter = 0 + else: + official = client.get_season_episodes( + self.series_id, + self.season, + season_type="official", + ) + season_filter = self.season + if self.all_seasons: episodes = client.get_all_episodes( self.series_id, season_type=self.season_type, ) - season_filter = 0 else: episodes = client.get_season_episodes( self.series_id, self.season, season_type=self.season_type, ) - season_filter = self.season - if not episodes: + + combined = None + if self.multi_episode or self.season_type == "alternate": + 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: label = "all seasons" if self.all_seasons else f"season {self.season}" raise TvdbError(f"No episodes found for {label}") mapping, unmatched, notes = match_filenames_to_episodes( self.filenames, episodes, season_filter=season_filter, + official_episodes=official, + combined_episodes=combined, ) self.finished.emit(mapping, unmatched, notes) except TvdbError as e: @@ -434,6 +465,15 @@ class TvdbEpisodeRenumberRuleWidget(QWidget): self.padding.valueChanged.connect(self._emit) 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.clicked.connect(self._match_titles) layout.addRow(self.match_btn) @@ -443,7 +483,8 @@ class TvdbEpisodeRenumberRuleWidget(QWidget): layout.addRow(self.status) 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. " "Use All seasons to match across every season and fix wrong season numbers too." ) info.setWordWrap(True) @@ -519,6 +560,7 @@ class TvdbEpisodeRenumberRuleWidget(QWidget): self._order_value(), self._file_names, all_seasons, + self.multi_episode_cb.isChecked(), ) self._match_worker.finished.connect(self._on_match_finished) self._match_worker.failed.connect(self._on_match_failed) @@ -542,11 +584,17 @@ class TvdbEpisodeRenumberRuleWidget(QWidget): self.status.setText(f"Match failed: {message}") def getRule(self) -> TvdbEpisodeRenumberRule: + mapping: dict = {} + for k, v in self._episode_mapping.items(): + if hasattr(v, "season"): + if getattr(v, "episode_end", None) and v.episode_end > v.episode: + mapping[k] = (v.season, v.episode, v.episode_end) + else: + mapping[k] = (v.season, v.episode) + else: + mapping[k] = v r = TvdbEpisodeRenumberRule( - episode_mapping={ - k: (v.season, v.episode) if hasattr(v, "season") else v - for k, v in self._episode_mapping.items() - }, + episode_mapping=mapping, padding=self.padding.value(), ) r.enabled = self.enabled_cb.isChecked() diff --git a/packaging/release-stamp-1.0.10.txt b/packaging/release-stamp-1.0.10.txt new file mode 100644 index 0000000..983ef94 --- /dev/null +++ b/packaging/release-stamp-1.0.10.txt @@ -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