Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fce5ad64e5 | ||
|
|
a6339467d4 |
+233
-28
@@ -27,6 +27,27 @@ 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 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 +69,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
|
||||||
|
|
||||||
|
|
||||||
@@ -131,8 +159,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)))
|
||||||
@@ -181,41 +211,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 = [
|
||||||
@@ -223,32 +385,75 @@ 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:
|
||||||
|
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, _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
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ 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"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+56
-8
@@ -339,6 +339,7 @@ class _TvdbMatchWorker(QThread):
|
|||||||
season_type: str,
|
season_type: str,
|
||||||
filenames: list[str],
|
filenames: list[str],
|
||||||
all_seasons: bool,
|
all_seasons: bool,
|
||||||
|
multi_episode: bool,
|
||||||
):
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.series_id = series_id
|
self.series_id = series_id
|
||||||
@@ -346,30 +347,60 @@ 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
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
client = TvdbClient(TVDB_API_KEY)
|
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:
|
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,
|
||||||
)
|
)
|
||||||
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,
|
||||||
)
|
)
|
||||||
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}"
|
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.finished.emit(mapping, unmatched, notes)
|
||||||
except TvdbError as e:
|
except TvdbError as e:
|
||||||
@@ -434,6 +465,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 +483,8 @@ 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. "
|
||||||
"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)
|
||||||
@@ -519,6 +560,7 @@ 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._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)
|
||||||
@@ -542,11 +584,17 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
|||||||
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 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(
|
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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user