Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28ec7746b8 |
+2
-6
@@ -39,12 +39,8 @@ if [[ -f "packaging/release-stamp-${VERSION}.txt" ]]; then
|
|||||||
fi
|
fi
|
||||||
# Gear Lever (Forgejo) treats same-size AppImages as "no update". Version-scoped
|
# Gear Lever (Forgejo) treats same-size AppImages as "no update". Version-scoped
|
||||||
# padding keeps each release a unique download size without affecting the app.
|
# padding keeps each release a unique download size without affecting the app.
|
||||||
VER_MAJOR="${VERSION%%.*}"
|
IFS=. read -r VER_MAJOR VER_MINOR VER_PATCH <<< "${VERSION}.0.0"
|
||||||
VER_REST="${VERSION#*.}"
|
PAD_KB=$((300 + VER_MAJOR * 100 + VER_MINOR * 20 + VER_PATCH * 10))
|
||||||
VER_MINOR="${VER_REST%%.*}"
|
|
||||||
VER_PATCH="${VER_REST#*.}"
|
|
||||||
VER_PATCH="${VER_PATCH%%.*}"
|
|
||||||
PAD_KB=$((300 + 10#${VER_MAJOR:-0} * 100 + 10#${VER_MINOR:-0} * 20 + 10#${VER_PATCH:-0} * 10))
|
|
||||||
dd if=/dev/urandom of="$APPDIR/usr/share/hsrename/.release-pad" bs=1024 count="$PAD_KB" status=none
|
dd if=/dev/urandom of="$APPDIR/usr/share/hsrename/.release-pad" bs=1024 count="$PAD_KB" status=none
|
||||||
echo "Release padding: ${PAD_KB} KiB (Gear Lever update detection)"
|
echo "Release padding: ${PAD_KB} KiB (Gear Lever update detection)"
|
||||||
|
|
||||||
|
|||||||
+28
-263
@@ -6,14 +6,13 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .tvdb_client import TvdbEpisode
|
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,34 +26,6 @@ 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:
|
||||||
@@ -76,13 +47,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -95,7 +59,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, s_letter, season_s, e_letter, ep_s, _range_block, ep2_s, rest = m.groups()
|
prefix_before_s, season_s, _e, ep_s, _dash, 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)
|
||||||
@@ -113,9 +77,7 @@ 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": prefix,
|
"prefix_before_s": prefix_before_s,
|
||||||
"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),
|
||||||
@@ -166,9 +128,7 @@ 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
|
||||||
if target.episode_end is not None and target.episode_end > target.episode:
|
title = parsed["title"]
|
||||||
span = target.episode_end - target.episode + 1
|
|
||||||
else:
|
|
||||||
span = parsed["span"]
|
span = parsed["span"]
|
||||||
|
|
||||||
if parsed["format"] == "nxnn":
|
if parsed["format"] == "nxnn":
|
||||||
@@ -182,14 +142,11 @@ 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)
|
||||||
head = (
|
prefix = f"{parsed['prefix_before_s']}S{new_season:0{s_pad}d}E"
|
||||||
f"{parsed['prefix']}{parsed['s_letter']}{new_season:0{s_pad}d}{parsed['e_letter']}"
|
|
||||||
)
|
|
||||||
if span <= 1:
|
if span <= 1:
|
||||||
return f"{head}{e1}{parsed['suffix']}"
|
return f"{prefix}{e1}{parsed['suffix']}"
|
||||||
e2 = str(new_ep + span - 1).zfill(e_pad)
|
e2 = str(new_ep + span - 1).zfill(e_pad)
|
||||||
range_prefix = f"-{parsed['e_letter']}"
|
return f"{prefix}{e1}-E{e2}{parsed['suffix']}"
|
||||||
return f"{head}{e1}{range_prefix}{e2}{parsed['suffix']}"
|
|
||||||
|
|
||||||
|
|
||||||
def rewrite_episode_number(
|
def rewrite_episode_number(
|
||||||
@@ -218,173 +175,40 @@ def _similarity(a: str, b: str) -> float:
|
|||||||
return SequenceMatcher(None, a, b).ratio()
|
return SequenceMatcher(None, a, b).ratio()
|
||||||
|
|
||||||
|
|
||||||
def _coerce_target(
|
def _coerce_target(value: EpisodeTarget | tuple[int, int] | int, parsed: dict) -> EpisodeTarget:
|
||||||
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.
|
||||||
official_episodes + combined_episodes: when set, also match combined-order titles
|
Returns mapping filename -> (season, episode), unmatched list, notes.
|
||||||
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
|
stem = name.rsplit(".", 1)[0] if "." in name and not name.startswith(".") else name
|
||||||
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:
|
if not parsed or not parsed["title"]:
|
||||||
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"]) if parsed["title"] else ""
|
norm = normalize_title(parsed["title"])
|
||||||
if norm or parsed.get("span", 1) > 1:
|
if norm:
|
||||||
file_entries.append((name, norm, parsed["title"], parsed))
|
file_entries.append((name, norm, parsed["title"], parsed))
|
||||||
|
|
||||||
ep_entries = [
|
ep_entries = [
|
||||||
@@ -392,97 +216,38 @@ def match_filenames_to_episodes(
|
|||||||
for ep in episodes
|
for ep in episodes
|
||||||
]
|
]
|
||||||
|
|
||||||
combined_entries: list[tuple[TvdbEpisode, EpisodeTarget, list[str]]] = []
|
pairs: list[tuple[float, str, int, int, str, str]] = []
|
||||||
if combined:
|
for fname, fnorm, raw_title, _parsed in file_entries:
|
||||||
for cep in combined:
|
for season, ep_num, enorm, ep_name in ep_entries:
|
||||||
target = resolve_combined_to_official(cep, official)
|
score = _similarity(fnorm, enorm)
|
||||||
if target is None:
|
pairs.append((score, fname, season, ep_num, raw_title, ep_name))
|
||||||
continue
|
|
||||||
combined_entries.append((cep, target, _combined_title_variants(cep.name)))
|
|
||||||
|
|
||||||
pairs: list[tuple[float, str, EpisodeTarget, str, str]] = []
|
pairs.sort(key=lambda x: (-x[0], x[1], x[2], x[3]))
|
||||||
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_ranges: list[tuple[int, int, int]] = []
|
used_eps: set[tuple[int, int]] = set()
|
||||||
notes: list[str] = []
|
notes: list[str] = []
|
||||||
|
|
||||||
multi_file_names = {
|
for score, fname, season, ep_num, raw_title, ep_name in pairs:
|
||||||
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
|
||||||
if fname in used_files:
|
ep_key = (season, ep_num)
|
||||||
|
if fname in used_files or ep_key in used_eps:
|
||||||
continue
|
continue
|
||||||
season, start, end = _target_range(target)
|
mapping[fname] = EpisodeTarget(season=season, episode=ep_num)
|
||||||
if _range_overlaps(season, start, end, used_ranges):
|
|
||||||
continue
|
|
||||||
mapping[fname] = target
|
|
||||||
used_files.add(fname)
|
used_files.add(fname)
|
||||||
used_ranges.append((season, start, end))
|
used_eps.add(ep_key)
|
||||||
pct = min(100, int(round(score * 100)))
|
pct = int(round(score * 100))
|
||||||
code = target.format_code()
|
|
||||||
notes.append(
|
notes.append(
|
||||||
f"{fname}: {code} ← “{ep_name}” ({pct}% match, file title “{raw_title}”)"
|
f"{fname}: S{season:02d}E{ep_num:02d} ← “{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
|
||||||
and parse_episode_stem(
|
and parse_episode_stem(
|
||||||
(
|
name.rsplit(".", 1)[0] if "." in name and not name.startswith(".") else name,
|
||||||
Path(name).name.rsplit(".", 1)[0]
|
|
||||||
if "." in Path(name).name and not Path(name).name.startswith(".")
|
|
||||||
else Path(name).name
|
|
||||||
),
|
|
||||||
pattern,
|
pattern,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
"""Scan folders for rename candidates with optional recursion and extension filters."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .pipeline import UNDO_FILENAME
|
|
||||||
|
|
||||||
FILE_TYPE_PRESETS: dict[str, str] = {
|
|
||||||
"All files": "",
|
|
||||||
"Video": ".mkv,.mp4,.avi,.webm,.m4v,.mov",
|
|
||||||
"Audio": ".mp3,.flac,.wav,.ogg,.m4a,.aac",
|
|
||||||
"Images": ".jpg,.jpeg,.png,.gif,.webp,.bmp",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def parse_extension_filter(spec: str) -> frozenset[str] | None:
|
|
||||||
"""
|
|
||||||
Parse a user extension filter into a set of lowercase extensions with a leading dot.
|
|
||||||
Returns None when empty (match all files).
|
|
||||||
"""
|
|
||||||
spec = spec.strip()
|
|
||||||
if not spec:
|
|
||||||
return None
|
|
||||||
exts: set[str] = set()
|
|
||||||
for part in re.split(r"[,;\s]+", spec):
|
|
||||||
part = part.strip().lower()
|
|
||||||
if not part:
|
|
||||||
continue
|
|
||||||
if part.startswith("*."):
|
|
||||||
part = part[1:]
|
|
||||||
if not part.startswith("."):
|
|
||||||
part = "." + part
|
|
||||||
exts.add(part)
|
|
||||||
return frozenset(exts) if exts else None
|
|
||||||
|
|
||||||
|
|
||||||
def file_extension(rel_path: str) -> str:
|
|
||||||
"""Return the lowercase extension (with dot) of the basename, or '' if none."""
|
|
||||||
name = Path(rel_path).name
|
|
||||||
if "." in name and not name.startswith("."):
|
|
||||||
return "." + name.rsplit(".", 1)[-1].lower()
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def matches_extension(rel_path: str, extensions: frozenset[str] | None) -> bool:
|
|
||||||
if extensions is None:
|
|
||||||
return True
|
|
||||||
return file_extension(rel_path) in extensions
|
|
||||||
|
|
||||||
|
|
||||||
def list_files(
|
|
||||||
base_dir: str,
|
|
||||||
*,
|
|
||||||
recursive: bool = False,
|
|
||||||
extensions: frozenset[str] | None = None,
|
|
||||||
) -> list[str]:
|
|
||||||
"""Return sorted paths relative to base_dir."""
|
|
||||||
base = Path(base_dir)
|
|
||||||
if not base.is_dir():
|
|
||||||
return []
|
|
||||||
|
|
||||||
found: list[str] = []
|
|
||||||
|
|
||||||
def consider(path: Path) -> None:
|
|
||||||
if not path.is_file():
|
|
||||||
return
|
|
||||||
if path.name.startswith("."):
|
|
||||||
return
|
|
||||||
if path.name == UNDO_FILENAME:
|
|
||||||
return
|
|
||||||
rel = path.relative_to(base).as_posix()
|
|
||||||
if matches_extension(rel, extensions):
|
|
||||||
found.append(rel)
|
|
||||||
|
|
||||||
if recursive:
|
|
||||||
for dirpath, dirnames, filenames in os.walk(base):
|
|
||||||
dirnames[:] = sorted(d for d in dirnames if not d.startswith("."))
|
|
||||||
for fn in sorted(filenames):
|
|
||||||
consider(Path(dirpath) / fn)
|
|
||||||
else:
|
|
||||||
for entry in sorted(base.iterdir(), key=lambda p: p.name.lower()):
|
|
||||||
consider(entry)
|
|
||||||
|
|
||||||
return sorted(found, key=str.lower)
|
|
||||||
+7
-19
@@ -25,24 +25,13 @@ def apply_pipeline(
|
|||||||
index: int,
|
index: int,
|
||||||
total: int,
|
total: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Apply all enabled rules to a filename or relative path. Returns new name/path."""
|
"""Apply all enabled rules to a single filename (no path). Returns new filename."""
|
||||||
rel = Path(name)
|
stem, ext = _split_name(name)
|
||||||
if len(rel.parts) > 1:
|
|
||||||
parent = rel.parent
|
|
||||||
file_name = rel.name
|
|
||||||
else:
|
|
||||||
parent = None
|
|
||||||
file_name = name
|
|
||||||
|
|
||||||
stem, ext = _split_name(file_name)
|
|
||||||
for r in rules:
|
for r in rules:
|
||||||
if not r.enabled:
|
if not r.enabled:
|
||||||
continue
|
continue
|
||||||
stem, ext = r.apply(stem, ext, index, total, original_name=name)
|
stem, ext = r.apply(stem, ext, index, total, original_name=name)
|
||||||
new_name = stem + ext
|
return stem + ext
|
||||||
if parent is not None:
|
|
||||||
return str(parent / new_name)
|
|
||||||
return new_name
|
|
||||||
|
|
||||||
|
|
||||||
def compute_preview(
|
def compute_preview(
|
||||||
@@ -88,12 +77,11 @@ def perform_renames(
|
|||||||
final_names = {n for _, n in step1}
|
final_names = {n for _, n in step1}
|
||||||
temp_map = []
|
temp_map = []
|
||||||
for i, (old_path, new_name) in enumerate(step1):
|
for i, (old_path, new_name) in enumerate(step1):
|
||||||
new_path = base / new_name
|
temp_name = f"__temp_{i}_{old_path.name}{temp_suffix}"
|
||||||
temp_path = old_path.parent / f"__temp_{i}_{old_path.name}{temp_suffix}"
|
while temp_name in final_names or (base / temp_name).exists():
|
||||||
while temp_path.name in final_names or temp_path.exists() or temp_path == new_path:
|
|
||||||
i += 1
|
i += 1
|
||||||
temp_path = old_path.parent / f"__temp_{i}_{old_path.name}{temp_suffix}"
|
temp_name = f"__temp_{i}_{old_path.name}{temp_suffix}"
|
||||||
temp_map.append((old_path, temp_path, new_path))
|
temp_map.append((old_path, base / temp_name, base / new_name))
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
for old_p, temp_p, new_p in temp_map:
|
for old_p, temp_p, new_p in temp_map:
|
||||||
|
|||||||
+50
-82
@@ -20,16 +20,10 @@ 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"),
|
||||||
("Combined order (multi-episode files)", "alternate"),
|
("Alternate order", "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:
|
||||||
@@ -160,72 +154,35 @@ class TvdbClient:
|
|||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def _episodes_path(self, series_id: int, season_type: str, language: str = "") -> str:
|
def get_season_episodes(
|
||||||
base = f"/series/{series_id}/episodes/{season_type}"
|
|
||||||
if language:
|
|
||||||
return f"{base}/{language}"
|
|
||||||
return base
|
|
||||||
|
|
||||||
def _parse_episode_batch(
|
|
||||||
self,
|
|
||||||
batch: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
season: Optional[int] = None,
|
|
||||||
skip_specials: bool = False,
|
|
||||||
) -> 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,
|
self,
|
||||||
series_id: int,
|
series_id: int,
|
||||||
season_type: str,
|
season: int,
|
||||||
*,
|
season_type: str = "default",
|
||||||
season: Optional[int] = None,
|
|
||||||
language: str = "",
|
|
||||||
skip_specials: bool = False,
|
|
||||||
) -> list[TvdbEpisode]:
|
) -> list[TvdbEpisode]:
|
||||||
"""Fetch episodes; optional translated titles via language code (e.g. eng)."""
|
|
||||||
episodes: list[TvdbEpisode] = []
|
episodes: list[TvdbEpisode] = []
|
||||||
page = 0
|
page = 0
|
||||||
max_pages = 50
|
while True:
|
||||||
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",
|
||||||
self._episodes_path(series_id, season_type, language),
|
f"/series/{series_id}/episodes/{season_type}",
|
||||||
params=params,
|
params={"page": page, "season": season},
|
||||||
)
|
)
|
||||||
batch = (payload.get("data") or {}).get("episodes") or []
|
batch = (payload.get("data") or {}).get("episodes") or []
|
||||||
if not batch:
|
if not batch:
|
||||||
break
|
break
|
||||||
filter_season = season if language else None
|
for ep in batch:
|
||||||
episodes.extend(
|
if ep.get("seasonNumber") != season:
|
||||||
self._parse_episode_batch(
|
continue
|
||||||
batch,
|
number = ep.get("number")
|
||||||
season=filter_season,
|
name = ep.get("name")
|
||||||
skip_specials=skip_specials,
|
if number is None or not name:
|
||||||
|
continue
|
||||||
|
episodes.append(
|
||||||
|
TvdbEpisode(
|
||||||
|
number=int(number),
|
||||||
|
season_number=int(season),
|
||||||
|
name=str(name),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
links = payload.get("links") or {}
|
links = payload.get("links") or {}
|
||||||
@@ -235,30 +192,41 @@ 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."""
|
||||||
return self._fetch_episodes(
|
episodes: list[TvdbEpisode] = []
|
||||||
series_id,
|
page = 0
|
||||||
season_type,
|
while True:
|
||||||
language=language,
|
payload = self._request(
|
||||||
skip_specials=True,
|
"GET",
|
||||||
|
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
|
||||||
|
|||||||
+11
-94
@@ -22,14 +22,11 @@ from PyQt6.QtWidgets import (
|
|||||||
QHeaderView,
|
QHeaderView,
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
QFrame,
|
QFrame,
|
||||||
QCheckBox,
|
|
||||||
QComboBox,
|
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, QDir, QItemSelectionModel, QSettings, QTimer
|
from PyQt6.QtCore import Qt, QDir, QItemSelectionModel
|
||||||
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
|
||||||
from engine.file_scan import FILE_TYPE_PRESETS, list_files, parse_extension_filter, file_extension
|
|
||||||
from engine.rules import Rule
|
from engine.rules import Rule
|
||||||
from .rule_widgets import (
|
from .rule_widgets import (
|
||||||
ReplaceRuleWidget,
|
ReplaceRuleWidget,
|
||||||
@@ -53,10 +50,7 @@ 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):
|
||||||
@@ -76,22 +70,6 @@ class MainWindow(QMainWindow):
|
|||||||
dir_layout.addWidget(browse_btn)
|
dir_layout.addWidget(browse_btn)
|
||||||
layout.addLayout(dir_layout)
|
layout.addLayout(dir_layout)
|
||||||
|
|
||||||
filter_layout = QHBoxLayout()
|
|
||||||
self.recursive_cb = QCheckBox("Include subfolders")
|
|
||||||
self.recursive_cb.toggled.connect(self._reload_files)
|
|
||||||
filter_layout.addWidget(self.recursive_cb)
|
|
||||||
filter_layout.addWidget(QLabel("File types:"))
|
|
||||||
self.types_preset = QComboBox()
|
|
||||||
self.types_preset.addItems(list(FILE_TYPE_PRESETS.keys()) + ["Custom"])
|
|
||||||
self.types_preset.currentTextChanged.connect(self._on_types_preset_changed)
|
|
||||||
filter_layout.addWidget(self.types_preset)
|
|
||||||
self.types_edit = QLineEdit()
|
|
||||||
self.types_edit.setPlaceholderText("e.g. .mkv,.mp4 or *.avi (empty = all types)")
|
|
||||||
self.types_edit.setReadOnly(True)
|
|
||||||
self.types_edit.editingFinished.connect(self._reload_files)
|
|
||||||
filter_layout.addWidget(self.types_edit, 1)
|
|
||||||
layout.addLayout(filter_layout)
|
|
||||||
|
|
||||||
# Split: left = rules, right = file list + preview
|
# Split: left = rules, right = file list + preview
|
||||||
split = QSplitter(Qt.Orientation.Horizontal)
|
split = QSplitter(Qt.Orientation.Horizontal)
|
||||||
|
|
||||||
@@ -130,7 +108,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._schedule_refresh_preview)
|
w.matchCompleted.connect(self._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)
|
||||||
@@ -175,53 +153,13 @@ 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:
|
||||||
self.dir_edit.setText(path)
|
self.dir_edit.setText(path)
|
||||||
|
|
||||||
def _on_types_preset_changed(self, preset: str):
|
|
||||||
if preset != "Custom":
|
|
||||||
self.types_edit.setText(FILE_TYPE_PRESETS.get(preset, ""))
|
|
||||||
self.types_edit.setReadOnly(True)
|
|
||||||
else:
|
|
||||||
self.types_edit.setReadOnly(False)
|
|
||||||
self._reload_files()
|
|
||||||
|
|
||||||
def _on_dir_changed(self):
|
def _on_dir_changed(self):
|
||||||
path = self.dir_edit.text().strip()
|
path = self.dir_edit.text().strip()
|
||||||
if not path or not os.path.isdir(path):
|
if not path or not os.path.isdir(path):
|
||||||
@@ -229,18 +167,9 @@ class MainWindow(QMainWindow):
|
|||||||
self._file_names = []
|
self._file_names = []
|
||||||
else:
|
else:
|
||||||
self._base_dir = path
|
self._base_dir = path
|
||||||
self._reload_files()
|
self._file_names = sorted(
|
||||||
return
|
f for f in os.listdir(path)
|
||||||
self._refresh_preview()
|
if os.path.isfile(os.path.join(path, f)) and not f.startswith(".")
|
||||||
|
|
||||||
def _reload_files(self):
|
|
||||||
if not self._base_dir:
|
|
||||||
return
|
|
||||||
extensions = parse_extension_filter(self.types_edit.text())
|
|
||||||
self._file_names = list_files(
|
|
||||||
self._base_dir,
|
|
||||||
recursive=self.recursive_cb.isChecked(),
|
|
||||||
extensions=extensions,
|
|
||||||
)
|
)
|
||||||
self._refresh_preview()
|
self._refresh_preview()
|
||||||
|
|
||||||
@@ -260,7 +189,9 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def _ext_for(self, filename: str) -> str:
|
def _ext_for(self, filename: str) -> str:
|
||||||
"""Return file extension with leading dot, or empty string if none."""
|
"""Return file extension with leading dot, or empty string if none."""
|
||||||
return file_extension(filename)
|
if "." in filename and not filename.startswith("."):
|
||||||
|
return "." + filename.rsplit(".", 1)[-1].lower()
|
||||||
|
return ""
|
||||||
|
|
||||||
def _on_preview_selection_changed(self):
|
def _on_preview_selection_changed(self):
|
||||||
"""Show preview (new name) only in selected rows; others show original."""
|
"""Show preview (new name) only in selected rows; others show original."""
|
||||||
@@ -275,19 +206,6 @@ 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:
|
||||||
@@ -345,8 +263,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.preview_status.setText("Warning: some new names are duplicated. Fix rules to avoid overwriting.")
|
self.preview_status.setText("Warning: some new names are duplicated. Fix rules to avoid overwriting.")
|
||||||
self.preview_status.setStyleSheet("color: #c00;")
|
self.preview_status.setStyleSheet("color: #c00;")
|
||||||
else:
|
else:
|
||||||
scope = "including subfolders" if self.recursive_cb.isChecked() else "top folder only"
|
self.preview_status.setText(f"{len(preview)} file(s). Ready to rename.")
|
||||||
self.preview_status.setText(f"{len(preview)} file(s) ({scope}). Ready to rename.")
|
|
||||||
self.preview_status.setStyleSheet("")
|
self.preview_status.setStyleSheet("")
|
||||||
|
|
||||||
def _apply_renames(self):
|
def _apply_renames(self):
|
||||||
@@ -400,7 +317,7 @@ class MainWindow(QMainWindow):
|
|||||||
else:
|
else:
|
||||||
save_undo_log(self._base_dir, renames)
|
save_undo_log(self._base_dir, renames)
|
||||||
QMessageBox.information(self, "Done", f"Renamed {len(results)} file(s).")
|
QMessageBox.information(self, "Done", f"Renamed {len(results)} file(s).")
|
||||||
self._reload_files()
|
self._on_dir_changed()
|
||||||
|
|
||||||
def _undo_renames(self):
|
def _undo_renames(self):
|
||||||
if not self._base_dir:
|
if not self._base_dir:
|
||||||
@@ -432,4 +349,4 @@ class MainWindow(QMainWindow):
|
|||||||
QMessageBox.warning(self, "Undo errors", msg)
|
QMessageBox.warning(self, "Undo errors", msg)
|
||||||
else:
|
else:
|
||||||
QMessageBox.information(self, "Undone", f"Reverted {len(results)} file(s).")
|
QMessageBox.information(self, "Undone", f"Reverted {len(results)} file(s).")
|
||||||
self._reload_files()
|
self._on_dir_changed()
|
||||||
|
|||||||
+25
-149
@@ -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, LANGUAGE_CHOICES
|
from engine.tvdb_client import TvdbClient, TvdbError, TVDB_API_KEY, SEASON_TYPE_CHOICES
|
||||||
from engine.episode_match import match_filenames_to_episodes, target_to_tuple
|
from engine.episode_match import match_filenames_to_episodes
|
||||||
|
|
||||||
|
|
||||||
class ReplaceRuleWidget(QWidget):
|
class ReplaceRuleWidget(QWidget):
|
||||||
@@ -310,19 +310,18 @@ class EpisodeRenumberRuleWidget(QWidget):
|
|||||||
|
|
||||||
|
|
||||||
class _TvdbSearchWorker(QThread):
|
class _TvdbSearchWorker(QThread):
|
||||||
search_done = pyqtSignal()
|
finished = pyqtSignal(list)
|
||||||
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)
|
||||||
self.results = client.search_series(self.query)
|
results = client.search_series(self.query)
|
||||||
self.search_done.emit()
|
self.finished.emit(results)
|
||||||
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:
|
||||||
@@ -330,7 +329,7 @@ class _TvdbSearchWorker(QThread):
|
|||||||
|
|
||||||
|
|
||||||
class _TvdbMatchWorker(QThread):
|
class _TvdbMatchWorker(QThread):
|
||||||
match_done = pyqtSignal()
|
finished = pyqtSignal(dict, list, list)
|
||||||
failed = pyqtSignal(str)
|
failed = pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -340,8 +339,6 @@ 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
|
||||||
@@ -349,106 +346,36 @@ 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 self.season_type == "alternate":
|
if not episodes:
|
||||||
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.mapping = {path: target_to_tuple(t) for path, t in mapping.items()}
|
self.finished.emit(mapping, unmatched, notes)
|
||||||
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:
|
||||||
import traceback
|
self.failed.emit(str(e))
|
||||||
self.failed.emit(f"{e}\n\n{traceback.format_exc()}")
|
|
||||||
|
|
||||||
|
|
||||||
class TvdbEpisodeRenumberRuleWidget(QWidget):
|
class TvdbEpisodeRenumberRuleWidget(QWidget):
|
||||||
@@ -492,13 +419,6 @@ 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)
|
||||||
@@ -514,15 +434,6 @@ 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)
|
||||||
@@ -532,9 +443,7 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
|||||||
layout.addRow(self.status)
|
layout.addRow(self.status)
|
||||||
|
|
||||||
info = QLabel(
|
info = QLabel(
|
||||||
"Reads titles from S01E05 - Episode Title, S01E05-E06 (multi-episode), "
|
"Reads titles from S01E05 - Episode Title or Show 04x01 Episode Title filenames. "
|
||||||
"or Show 04x01 Episode Title filenames. "
|
|
||||||
"For non-English shows, set Episode titles to English if your filenames use English. "
|
|
||||||
"Use All seasons to match across every season and fix wrong season numbers too."
|
"Use All seasons to match across every season and fix wrong season numbers too."
|
||||||
)
|
)
|
||||||
info.setWordWrap(True)
|
info.setWordWrap(True)
|
||||||
@@ -550,13 +459,6 @@ 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()
|
||||||
|
|
||||||
@@ -569,12 +471,11 @@ 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.search_done.connect(self._on_search_finished)
|
self._search_worker.finished.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):
|
def _on_search_finished(self, results):
|
||||||
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:
|
||||||
@@ -606,13 +507,10 @@ 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()}, {self._language_label()}, "
|
f"Fetching episodes ({self._order_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(
|
||||||
@@ -621,56 +519,34 @@ 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.match_done.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.finished.connect(self._on_match_worker_finished)
|
|
||||||
self._match_worker.start()
|
self._match_worker.start()
|
||||||
|
|
||||||
def _on_match_worker_finished(self):
|
def _on_match_finished(self, mapping: dict, unmatched: list, notes: list):
|
||||||
worker = self._match_worker
|
self._episode_mapping = mapping
|
||||||
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(self._episode_mapping)
|
matched = len(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()}, {self._language_label()})."
|
msg = f"Matched {matched} of {total} file(s) ({scope}, {self._order_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()
|
||||||
except Exception as e:
|
self._emit()
|
||||||
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=mapping,
|
episode_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,7 +4,6 @@ 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
|
||||||
@@ -17,35 +16,12 @@ 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())
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
HSRename 1.0.8
|
|
||||||
- Include subfolders option for recursive file listing
|
|
||||||
- File type filter with Video, Audio, Images presets and custom extensions
|
|
||||||
- Rename rules preserve subfolder paths; nested apply/undo supported
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
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
|
|
||||||
Reference in New Issue
Block a user