Compare commits
3
Commits
c54ee7876c
...
v1.0.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85dc873e16 | ||
|
|
1c475ee856 | ||
|
|
69f759b6ce |
@@ -37,6 +37,16 @@ echo "$VERSION" > "$APPDIR/usr/share/hsrename/VERSION"
|
||||
if [[ -f "packaging/release-stamp-${VERSION}.txt" ]]; then
|
||||
cp "packaging/release-stamp-${VERSION}.txt" "$APPDIR/usr/share/hsrename/release-stamp.txt"
|
||||
fi
|
||||
# Gear Lever (Forgejo) treats same-size AppImages as "no update". Version-scoped
|
||||
# padding keeps each release a unique download size without affecting the app.
|
||||
VER_MAJOR="${VERSION%%.*}"
|
||||
VER_REST="${VERSION#*.}"
|
||||
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
|
||||
echo "Release padding: ${PAD_KB} KiB (Gear Lever update detection)"
|
||||
|
||||
cat > "$APPDIR/AppRun" << EOF
|
||||
#!/bin/sh
|
||||
|
||||
+6
-2
@@ -28,7 +28,11 @@ If the fields validate (no error), Gear Lever queries:
|
||||
|
||||
`https://git.hisora.dev/api/v1/repos/Dawnsorrow/HS-Rename/releases/latest`
|
||||
|
||||
and compares the release asset size with your local AppImage.
|
||||
and compares the release asset **file size** with your local AppImage.
|
||||
|
||||
Gear Lever does **not** compare version numbers or file contents. If two releases compress to the same byte size (common with AppImages), it will say “No updates available” even when the app changed. HSRename releases from v1.0.7 onward include a small version-specific padding file so each release has a unique size.
|
||||
|
||||
To see which version you have without updating: open the AppImage mount folder `usr/share/hsrename/VERSION`, or check **X-AppImage-Version** in Gear Lever metadata after **Reload metadata**.
|
||||
|
||||
## Direct download URL pattern
|
||||
|
||||
@@ -60,7 +64,7 @@ Gear Lever 4 may use different CLI options than older docs. Prefer the GUI Forge
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|---------|----------------|
|
||||
| No update offered | Source is still **Default**, or local file is already the latest release (same size as `releases/latest`). |
|
||||
| No update offered | Source is still **Default**; local file size matches `releases/latest` (Gear Lever ignores version/content); or wrong Forgejo repo URL. |
|
||||
| URL looks truncated in Website | Website is cosmetic only; configure **Forgejo** fields instead. |
|
||||
| Update never finds newer builds | Wrong repo URL, wrong filename, or release has no `HSRename.AppImage` asset attached. |
|
||||
|
||||
|
||||
+180
-79
@@ -4,63 +4,150 @@ Match episode titles from filenames against a reference episode list (e.g. TheTV
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from difflib import SequenceMatcher
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .tvdb_client import TvdbEpisode
|
||||
|
||||
DEFAULT_EPISODE_PATTERN = r"(.*?[Ss]\d+[Ee])(\d+)(-[Ee](\d+))?(.*)"
|
||||
# S01E05 - Title or S01E05-E06 - Title
|
||||
PATTERN_SXXEXX = re.compile(
|
||||
r"^(.*?[Ss])(\d+)([Ee])(\d+)(-[Ee](\d+))?(.*)$",
|
||||
)
|
||||
# Show Name 04x01 Title, 4x01 - Title, etc.
|
||||
PATTERN_NXNN = re.compile(
|
||||
r"^(.*?)(\d{1,2})[xX](\d{1,4})(?:([\s._-]+)(.+))?$",
|
||||
)
|
||||
|
||||
DEFAULT_EPISODE_PATTERN = PATTERN_SXXEXX.pattern
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EpisodeTarget:
|
||||
season: int
|
||||
episode: int
|
||||
|
||||
|
||||
def normalize_title(title: str) -> str:
|
||||
"""Lowercase, strip punctuation, collapse whitespace for fuzzy comparison."""
|
||||
t = title.lower()
|
||||
t = re.sub(r"[^\w\s]", " ", t, flags=re.UNICODE)
|
||||
return re.sub(r"\s+", " ", t).strip()
|
||||
t = re.sub(r"\s+", " ", t).strip()
|
||||
if t.startswith("the "):
|
||||
t = t[4:]
|
||||
return t
|
||||
|
||||
|
||||
def parse_episode_stem(stem: str, pattern: str = DEFAULT_EPISODE_PATTERN) -> Optional[dict]:
|
||||
"""
|
||||
Parse SxxExx-style filename stem.
|
||||
Returns dict with prefix, episode numbers, title rest, and span — or None if no match.
|
||||
"""
|
||||
try:
|
||||
m = re.match(pattern, stem)
|
||||
except re.error:
|
||||
return None
|
||||
if not m:
|
||||
return None
|
||||
prefix, old_first_s, _range_dash_e, old_second_s, rest = (
|
||||
m.group(1),
|
||||
m.group(2),
|
||||
m.group(3),
|
||||
m.group(4),
|
||||
m.group(5),
|
||||
)
|
||||
try:
|
||||
old_first = int(old_first_s)
|
||||
except ValueError:
|
||||
return None
|
||||
span = 1
|
||||
if old_second_s is not None:
|
||||
try:
|
||||
old_second = int(old_second_s)
|
||||
except ValueError:
|
||||
return None
|
||||
span = old_second - old_first + 1
|
||||
if span < 1:
|
||||
span = 1
|
||||
def _clean_title(rest: str) -> str:
|
||||
title = rest.strip()
|
||||
for prefix in ("- ", "– ", "_ ", ". "):
|
||||
if title.startswith(prefix):
|
||||
title = title[len(prefix) :].strip()
|
||||
if title.startswith("-") or title.startswith("–"):
|
||||
title = title[1:].strip()
|
||||
if title.startswith("_"):
|
||||
title = title[1:].strip()
|
||||
return {
|
||||
"prefix": prefix,
|
||||
"old_first": old_first,
|
||||
"span": span,
|
||||
"title": title,
|
||||
}
|
||||
return title
|
||||
|
||||
|
||||
def parse_episode_stem(stem: str, pattern: str = DEFAULT_EPISODE_PATTERN) -> Optional[dict]:
|
||||
"""
|
||||
Parse common TV filename stems (S01E05 or 04x01 styles).
|
||||
Returns dict with format, season, episode, title, padding hints — or None.
|
||||
"""
|
||||
del pattern # legacy param; auto-detect formats instead
|
||||
|
||||
m = PATTERN_SXXEXX.match(stem)
|
||||
if m:
|
||||
prefix_before_s, season_s, _e, ep_s, _dash, ep2_s, rest = m.groups()
|
||||
try:
|
||||
season = int(season_s)
|
||||
old_first = int(ep_s)
|
||||
except ValueError:
|
||||
return None
|
||||
span = 1
|
||||
if ep2_s is not None:
|
||||
try:
|
||||
old_second = int(ep2_s)
|
||||
except ValueError:
|
||||
return None
|
||||
span = old_second - old_first + 1
|
||||
if span < 1:
|
||||
span = 1
|
||||
title = _clean_title(rest)
|
||||
return {
|
||||
"format": "sxxexx",
|
||||
"prefix_before_s": prefix_before_s,
|
||||
"season": season,
|
||||
"season_pad": len(season_s),
|
||||
"episode_pad": len(ep_s),
|
||||
"old_first": old_first,
|
||||
"span": span,
|
||||
"title": title,
|
||||
"suffix": rest,
|
||||
}
|
||||
|
||||
m = PATTERN_NXNN.match(stem)
|
||||
if m:
|
||||
prefix, season_s, ep_s, sep, title_part = m.groups()
|
||||
try:
|
||||
season = int(season_s)
|
||||
episode = int(ep_s)
|
||||
except ValueError:
|
||||
return None
|
||||
title = _clean_title(title_part or "")
|
||||
sep = sep or " "
|
||||
if title and not sep.strip():
|
||||
sep = " "
|
||||
return {
|
||||
"format": "nxnn",
|
||||
"prefix": prefix,
|
||||
"season": season,
|
||||
"season_pad": len(season_s),
|
||||
"episode_pad": len(ep_s),
|
||||
"old_first": episode,
|
||||
"span": 1,
|
||||
"title": title,
|
||||
"title_sep": sep,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def rewrite_episode_stem(
|
||||
stem: str,
|
||||
target: EpisodeTarget,
|
||||
padding: int = 2,
|
||||
pattern: str = DEFAULT_EPISODE_PATTERN,
|
||||
) -> str:
|
||||
"""Replace season/episode block in stem, preserving layout and title."""
|
||||
parsed = parse_episode_stem(stem, pattern)
|
||||
if not parsed:
|
||||
return stem
|
||||
|
||||
pad = max(1, padding)
|
||||
new_season = target.season
|
||||
new_ep = target.episode
|
||||
title = parsed["title"]
|
||||
span = parsed["span"]
|
||||
|
||||
if parsed["format"] == "nxnn":
|
||||
s_pad = max(parsed["season_pad"], len(str(new_season)))
|
||||
e_pad = max(parsed["episode_pad"], pad, len(str(new_ep)))
|
||||
block = f"{new_season:0{s_pad}d}x{new_ep:0{e_pad}d}"
|
||||
if title:
|
||||
return f"{parsed['prefix']}{block}{parsed['title_sep']}{title}"
|
||||
return f"{parsed['prefix']}{block}"
|
||||
|
||||
s_pad = max(parsed["season_pad"], len(str(new_season)))
|
||||
e_pad = max(parsed["episode_pad"], pad, len(str(new_ep)))
|
||||
e1 = str(new_ep).zfill(e_pad)
|
||||
prefix = f"{parsed['prefix_before_s']}S{new_season:0{s_pad}d}E"
|
||||
if span <= 1:
|
||||
return f"{prefix}{e1}{parsed['suffix']}"
|
||||
e2 = str(new_ep + span - 1).zfill(e_pad)
|
||||
return f"{prefix}{e1}-E{e2}{parsed['suffix']}"
|
||||
|
||||
|
||||
def rewrite_episode_number(
|
||||
@@ -69,25 +156,16 @@ def rewrite_episode_number(
|
||||
padding: int = 2,
|
||||
pattern: str = DEFAULT_EPISODE_PATTERN,
|
||||
) -> str:
|
||||
"""Replace episode number block in stem, preserving prefix, span, and title."""
|
||||
"""Legacy helper: episode only, keep season from filename."""
|
||||
parsed = parse_episode_stem(stem, pattern)
|
||||
if not parsed:
|
||||
return stem
|
||||
try:
|
||||
m = re.match(pattern, stem)
|
||||
except re.error:
|
||||
return stem
|
||||
if not m:
|
||||
return stem
|
||||
prefix = m.group(1)
|
||||
rest = m.group(5)
|
||||
span = parsed["span"]
|
||||
pad = max(1, padding)
|
||||
e1 = str(new_first_ep).zfill(pad)
|
||||
if span <= 1:
|
||||
return f"{prefix}{e1}{rest}"
|
||||
e2 = str(new_first_ep + span - 1).zfill(pad)
|
||||
return f"{prefix}{e1}-E{e2}{rest}"
|
||||
return rewrite_episode_stem(
|
||||
stem,
|
||||
EpisodeTarget(season=parsed["season"], episode=new_first_ep),
|
||||
padding=padding,
|
||||
pattern=pattern,
|
||||
)
|
||||
|
||||
|
||||
def _similarity(a: str, b: str) -> float:
|
||||
@@ -98,61 +176,84 @@ 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:
|
||||
if isinstance(value, EpisodeTarget):
|
||||
return value
|
||||
if isinstance(value, tuple):
|
||||
return EpisodeTarget(season=int(value[0]), episode=int(value[1]))
|
||||
return EpisodeTarget(season=parsed["season"], episode=int(value))
|
||||
|
||||
|
||||
def match_filenames_to_episodes(
|
||||
filenames: list[str],
|
||||
episodes: list[TvdbEpisode],
|
||||
pattern: str = DEFAULT_EPISODE_PATTERN,
|
||||
min_score: float = 0.72,
|
||||
) -> tuple[dict[str, int], list[str], list[str]]:
|
||||
min_score: float = 0.65,
|
||||
season_filter: int = 0,
|
||||
) -> tuple[dict[str, EpisodeTarget], list[str], list[str]]:
|
||||
"""
|
||||
Match filenames to TheTVDB episode numbers by title.
|
||||
Match filenames to TheTVDB episodes by title.
|
||||
|
||||
Returns:
|
||||
mapping: original filename -> correct episode number
|
||||
unmatched_files: filenames that could not be matched
|
||||
notes: human-readable match details for UI
|
||||
season_filter: 0 = use all episodes; else only episodes from that season.
|
||||
Returns mapping filename -> (season, episode), unmatched list, notes.
|
||||
"""
|
||||
file_entries: list[tuple[str, str, str]] = []
|
||||
if season_filter > 0:
|
||||
episodes = [ep for ep in episodes if ep.season_number == season_filter]
|
||||
|
||||
file_entries: list[tuple[str, str, str, dict]] = []
|
||||
for name in filenames:
|
||||
stem = name.rsplit(".", 1)[0] if "." in name and not name.startswith(".") else name
|
||||
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"]:
|
||||
continue
|
||||
if season_filter > 0 and parsed["season"] != season_filter:
|
||||
continue
|
||||
norm = normalize_title(parsed["title"])
|
||||
if norm:
|
||||
file_entries.append((name, norm, parsed["title"]))
|
||||
file_entries.append((name, norm, parsed["title"], parsed))
|
||||
|
||||
ep_entries = [(ep.number, normalize_title(ep.name), ep.name) for ep in episodes]
|
||||
ep_entries = [
|
||||
(ep.season_number, ep.number, normalize_title(ep.name), ep.name)
|
||||
for ep in episodes
|
||||
]
|
||||
|
||||
pairs: list[tuple[float, str, int, str, str]] = []
|
||||
for fname, fnorm, raw_title in file_entries:
|
||||
for ep_num, enorm, ep_name in ep_entries:
|
||||
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, ep_num, raw_title, ep_name))
|
||||
pairs.append((score, fname, season, ep_num, raw_title, ep_name))
|
||||
|
||||
pairs.sort(key=lambda x: (-x[0], x[1], x[2]))
|
||||
pairs.sort(key=lambda x: (-x[0], x[1], x[2], x[3]))
|
||||
|
||||
mapping: dict[str, int] = {}
|
||||
mapping: dict[str, EpisodeTarget] = {}
|
||||
used_files: set[str] = set()
|
||||
used_eps: set[int] = set()
|
||||
used_eps: set[tuple[int, int]] = set()
|
||||
notes: list[str] = []
|
||||
|
||||
for score, fname, ep_num, raw_title, ep_name in pairs:
|
||||
for score, fname, season, ep_num, raw_title, ep_name in pairs:
|
||||
if score < min_score:
|
||||
break
|
||||
if fname in used_files or ep_num in used_eps:
|
||||
ep_key = (season, ep_num)
|
||||
if fname in used_files or ep_key in used_eps:
|
||||
continue
|
||||
mapping[fname] = ep_num
|
||||
mapping[fname] = EpisodeTarget(season=season, episode=ep_num)
|
||||
used_files.add(fname)
|
||||
used_eps.add(ep_num)
|
||||
used_eps.add(ep_key)
|
||||
pct = int(round(score * 100))
|
||||
notes.append(f"{fname}: E{ep_num:02d} ← “{ep_name}” ({pct}% match, file title “{raw_title}”)")
|
||||
notes.append(
|
||||
f"{fname}: S{season:02d}E{ep_num:02d} ← “{ep_name}” ({pct}% match, file title “{raw_title}”)"
|
||||
)
|
||||
|
||||
unmatched_files = [
|
||||
name for name in filenames
|
||||
if name not in mapping
|
||||
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,
|
||||
)
|
||||
]
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""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)
|
||||
+19
-7
@@ -25,13 +25,24 @@ def apply_pipeline(
|
||||
index: int,
|
||||
total: int,
|
||||
) -> str:
|
||||
"""Apply all enabled rules to a single filename (no path). Returns new filename."""
|
||||
stem, ext = _split_name(name)
|
||||
"""Apply all enabled rules to a filename or relative path. Returns new name/path."""
|
||||
rel = Path(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:
|
||||
if not r.enabled:
|
||||
continue
|
||||
stem, ext = r.apply(stem, ext, index, total, original_name=name)
|
||||
return stem + ext
|
||||
new_name = stem + ext
|
||||
if parent is not None:
|
||||
return str(parent / new_name)
|
||||
return new_name
|
||||
|
||||
|
||||
def compute_preview(
|
||||
@@ -77,11 +88,12 @@ def perform_renames(
|
||||
final_names = {n for _, n in step1}
|
||||
temp_map = []
|
||||
for i, (old_path, new_name) in enumerate(step1):
|
||||
temp_name = f"__temp_{i}_{old_path.name}{temp_suffix}"
|
||||
while temp_name in final_names or (base / temp_name).exists():
|
||||
new_path = base / new_name
|
||||
temp_path = old_path.parent / f"__temp_{i}_{old_path.name}{temp_suffix}"
|
||||
while temp_path.name in final_names or temp_path.exists() or temp_path == new_path:
|
||||
i += 1
|
||||
temp_name = f"__temp_{i}_{old_path.name}{temp_suffix}"
|
||||
temp_map.append((old_path, base / temp_name, base / new_name))
|
||||
temp_path = old_path.parent / f"__temp_{i}_{old_path.name}{temp_suffix}"
|
||||
temp_map.append((old_path, temp_path, new_path))
|
||||
|
||||
if dry_run:
|
||||
for old_p, temp_p, new_p in temp_map:
|
||||
|
||||
+9
-5
@@ -9,7 +9,7 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from .episode_match import rewrite_episode_number
|
||||
from .episode_match import rewrite_episode_stem, EpisodeTarget, _coerce_target, parse_episode_stem
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -279,12 +279,16 @@ class TvdbEpisodeRenumberRule(Rule):
|
||||
) -> tuple[str, str]:
|
||||
if not self.episode_mapping or original_name is None:
|
||||
return stem, ext
|
||||
new_ep = self.episode_mapping.get(original_name)
|
||||
if new_ep is None:
|
||||
target_raw = self.episode_mapping.get(original_name)
|
||||
if target_raw is None:
|
||||
return stem, ext
|
||||
new_stem = rewrite_episode_number(
|
||||
parsed = parse_episode_stem(stem, self.pattern)
|
||||
if not parsed:
|
||||
return stem, ext
|
||||
target = _coerce_target(target_raw, parsed)
|
||||
new_stem = rewrite_episode_stem(
|
||||
stem,
|
||||
int(new_ep),
|
||||
target,
|
||||
padding=self.padding,
|
||||
pattern=self.pattern,
|
||||
)
|
||||
|
||||
+40
-1
@@ -189,5 +189,44 @@ class TvdbClient:
|
||||
if not links.get("next"):
|
||||
break
|
||||
page += 1
|
||||
episodes.sort(key=lambda e: e.number)
|
||||
episodes.sort(key=lambda e: (e.season_number, e.number))
|
||||
return episodes
|
||||
|
||||
def get_all_episodes(
|
||||
self,
|
||||
series_id: int,
|
||||
season_type: str = "default",
|
||||
) -> list[TvdbEpisode]:
|
||||
"""Fetch every episode for a series (all seasons), paginated."""
|
||||
episodes: list[TvdbEpisode] = []
|
||||
page = 0
|
||||
while True:
|
||||
payload = self._request(
|
||||
"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
|
||||
|
||||
+45
-10
@@ -22,11 +22,14 @@ from PyQt6.QtWidgets import (
|
||||
QHeaderView,
|
||||
QAbstractItemView,
|
||||
QFrame,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QDir, QItemSelectionModel
|
||||
from PyQt6.QtGui import QFont, QColor
|
||||
|
||||
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 .rule_widgets import (
|
||||
ReplaceRuleWidget,
|
||||
@@ -70,6 +73,22 @@ class MainWindow(QMainWindow):
|
||||
dir_layout.addWidget(browse_btn)
|
||||
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 = QSplitter(Qt.Orientation.Horizontal)
|
||||
|
||||
@@ -160,6 +179,14 @@ class MainWindow(QMainWindow):
|
||||
if 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):
|
||||
path = self.dir_edit.text().strip()
|
||||
if not path or not os.path.isdir(path):
|
||||
@@ -167,10 +194,19 @@ class MainWindow(QMainWindow):
|
||||
self._file_names = []
|
||||
else:
|
||||
self._base_dir = path
|
||||
self._file_names = sorted(
|
||||
f for f in os.listdir(path)
|
||||
if os.path.isfile(os.path.join(path, f)) and not f.startswith(".")
|
||||
)
|
||||
self._reload_files()
|
||||
return
|
||||
self._refresh_preview()
|
||||
|
||||
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()
|
||||
|
||||
def _on_rule_changed(self):
|
||||
@@ -189,9 +225,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def _ext_for(self, filename: str) -> str:
|
||||
"""Return file extension with leading dot, or empty string if none."""
|
||||
if "." in filename and not filename.startswith("."):
|
||||
return "." + filename.rsplit(".", 1)[-1].lower()
|
||||
return ""
|
||||
return file_extension(filename)
|
||||
|
||||
def _on_preview_selection_changed(self):
|
||||
"""Show preview (new name) only in selected rows; others show original."""
|
||||
@@ -263,7 +297,8 @@ class MainWindow(QMainWindow):
|
||||
self.preview_status.setText("Warning: some new names are duplicated. Fix rules to avoid overwriting.")
|
||||
self.preview_status.setStyleSheet("color: #c00;")
|
||||
else:
|
||||
self.preview_status.setText(f"{len(preview)} file(s). Ready to rename.")
|
||||
scope = "including subfolders" if self.recursive_cb.isChecked() else "top folder only"
|
||||
self.preview_status.setText(f"{len(preview)} file(s) ({scope}). Ready to rename.")
|
||||
self.preview_status.setStyleSheet("")
|
||||
|
||||
def _apply_renames(self):
|
||||
@@ -317,7 +352,7 @@ class MainWindow(QMainWindow):
|
||||
else:
|
||||
save_undo_log(self._base_dir, renames)
|
||||
QMessageBox.information(self, "Done", f"Renamed {len(results)} file(s).")
|
||||
self._on_dir_changed()
|
||||
self._reload_files()
|
||||
|
||||
def _undo_renames(self):
|
||||
if not self._base_dir:
|
||||
@@ -349,4 +384,4 @@ class MainWindow(QMainWindow):
|
||||
QMessageBox.warning(self, "Undo errors", msg)
|
||||
else:
|
||||
QMessageBox.information(self, "Undone", f"Reverted {len(results)} file(s).")
|
||||
self._on_dir_changed()
|
||||
self._reload_files()
|
||||
|
||||
+39
-17
@@ -338,24 +338,39 @@ class _TvdbMatchWorker(QThread):
|
||||
season: int,
|
||||
season_type: str,
|
||||
filenames: list[str],
|
||||
all_seasons: bool,
|
||||
):
|
||||
super().__init__()
|
||||
self.series_id = series_id
|
||||
self.season = season
|
||||
self.season_type = season_type
|
||||
self.filenames = filenames
|
||||
self.all_seasons = all_seasons
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
client = TvdbClient(TVDB_API_KEY)
|
||||
episodes = client.get_season_episodes(
|
||||
self.series_id,
|
||||
self.season,
|
||||
season_type=self.season_type,
|
||||
)
|
||||
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:
|
||||
raise TvdbError(f"No episodes found for season {self.season}")
|
||||
mapping, unmatched, notes = match_filenames_to_episodes(self.filenames, episodes)
|
||||
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,
|
||||
)
|
||||
self.finished.emit(mapping, unmatched, notes)
|
||||
except TvdbError as e:
|
||||
self.failed.emit(str(e))
|
||||
@@ -407,7 +422,8 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
||||
self.season = QSpinBox()
|
||||
self.season.setMinimum(0)
|
||||
self.season.setMaximum(99)
|
||||
self.season.setValue(1)
|
||||
self.season.setValue(0)
|
||||
self.season.setSpecialValueText("All seasons")
|
||||
self.season.valueChanged.connect(self._emit)
|
||||
layout.addRow("Season:", self.season)
|
||||
|
||||
@@ -422,13 +438,13 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
||||
self.match_btn.clicked.connect(self._match_titles)
|
||||
layout.addRow(self.match_btn)
|
||||
|
||||
self.status = QLabel("Search for a show, choose episode order and season, then match titles.")
|
||||
self.status = QLabel("Search for a show, choose episode order, then match titles (0 = all seasons).")
|
||||
self.status.setWordWrap(True)
|
||||
layout.addRow(self.status)
|
||||
|
||||
info = QLabel(
|
||||
"Reads episode titles from filenames like S01E05 - Episode Title. "
|
||||
"Fetches the chosen TheTVDB episode order for that season and rewrites SxxExx numbers to match."
|
||||
"Reads titles from S01E05 - Episode Title or Show 04x01 Episode Title filenames. "
|
||||
"Use All seasons to match across every season and fix wrong season numbers too."
|
||||
)
|
||||
info.setWordWrap(True)
|
||||
layout.addRow(info)
|
||||
@@ -492,12 +508,17 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
||||
if series_id is None:
|
||||
return
|
||||
self.match_btn.setEnabled(False)
|
||||
self.status.setText(f"Fetching episodes ({self._order_label()}) and matching titles…")
|
||||
all_seasons = self.season.value() == 0
|
||||
self.status.setText(
|
||||
f"Fetching episodes ({self._order_label()}, "
|
||||
f"{'all seasons' if all_seasons else f'season {self.season.value()}'}…) and matching titles…"
|
||||
)
|
||||
self._match_worker = _TvdbMatchWorker(
|
||||
int(series_id),
|
||||
self.season.value(),
|
||||
self._order_value(),
|
||||
self._file_names,
|
||||
all_seasons,
|
||||
)
|
||||
self._match_worker.finished.connect(self._on_match_finished)
|
||||
self._match_worker.failed.connect(self._on_match_failed)
|
||||
@@ -508,10 +529,8 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
||||
self.match_btn.setEnabled(True)
|
||||
matched = len(mapping)
|
||||
total = len(self._file_names)
|
||||
msg = (
|
||||
f"Matched {matched} of {total} file(s) for season {self.season.value()} "
|
||||
f"({self._order_label()})."
|
||||
)
|
||||
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()})."
|
||||
if unmatched:
|
||||
msg += f" {len(unmatched)} file(s) unmatched."
|
||||
self.status.setText(msg)
|
||||
@@ -524,7 +543,10 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
||||
|
||||
def getRule(self) -> TvdbEpisodeRenumberRule:
|
||||
r = TvdbEpisodeRenumberRule(
|
||||
episode_mapping=dict(self._episode_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(),
|
||||
)
|
||||
r.enabled = self.enabled_cb.isChecked()
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
HSRename 1.0.6
|
||||
- Support 04x01-style filenames (not just S01E05)
|
||||
- Match all seasons at once (season 0) to fix wrong-season edge cases
|
||||
- Improve title fuzzy matching
|
||||
@@ -0,0 +1,3 @@
|
||||
HSRename 1.0.7
|
||||
- Same features as 1.0.6; unique AppImage size so Gear Lever detects updates
|
||||
- Gear Lever compares file size only; v1.0.5 and v1.0.6 were identical bytes
|
||||
@@ -0,0 +1,4 @@
|
||||
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
|
||||
Reference in New Issue
Block a user