6 Commits
Author SHA1 Message Date
Bulk RenamerandCursor a65ae73bcc Release v1.0.9: persist UI layout and fix SxxExx double-S renames.
Save window geometry, splitter sizes, and preview column widths on exit; restore on launch. Fix episode rewrite duplicating the season letter.

Co-authored-by: Cursor <[email protected]>
2026-07-03 17:58:45 -05:00
Bulk RenamerandCursor 85dc873e16 Release v1.0.8: recursive folder scan and file type filters.
Adds subfolder inclusion and extension presets so the preview list can target nested media without loading everything.

Co-authored-by: Cursor <[email protected]>
2026-07-03 17:46:58 -05:00
Bulk RenamerandCursor 1c475ee856 Release v1.0.7: unique AppImage sizes for Gear Lever updates.
Forgejo compares release asset size only; v1.0.5 and v1.0.6 were identical at 62933496 bytes.

Co-authored-by: Cursor <[email protected]>
2026-07-03 17:37:16 -05:00
Bulk RenamerandCursor 69f759b6ce Fix TheTVDB matching for 04x01 filenames and add all-seasons mode.
Parse NxNN and SxxExx layouts, match across every season when season is 0, and rewrite season plus episode numbers.

Co-authored-by: Cursor <[email protected]>
2026-07-03 17:34:12 -05:00
Bulk RenamerandCursor c54ee7876c Fix AppImage verify step to inspect the PyInstaller binary.
The squashfs wrapper does not contain module path strings reliably.

Co-authored-by: Cursor <[email protected]>
2026-07-03 17:23:45 -05:00
Bulk RenamerandCursor 9ba2c68ff9 Release v1.0.5: fix botched AppImage publish pipeline.
Stop tracking HSRename.AppImage in git, always rebuild before release, and verify TheTVDB modules are bundled.

Co-authored-by: Cursor <[email protected]>
2026-07-03 17:23:18 -05:00
18 changed files with 540 additions and 149 deletions
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
1.0.4
1.0.9
+18 -10
View File
@@ -14,7 +14,8 @@ VERSION="$(tr -d '[:space:]' < VERSION 2>/dev/null || echo "")"
echo "=== Installing build deps ==="
pip install -q -r requirements.txt -r requirements-build.txt
echo "=== Running PyInstaller ==="
echo "=== Running PyInstaller (clean) ==="
rm -rf build dist HSRename.spec
pyinstaller --noconfirm --onedir --windowed \
-n "$EXE_NAME" \
--hidden-import=engine \
@@ -36,13 +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 compares file sizes; incompressible pad for patch releases (zeros squash to nothing).
dd if=/dev/urandom of="$APPDIR/usr/share/hsrename/.buildpad" bs=1024 count=300 status=none
cat > "$APPDIR/usr/share/hsrename/changelog.txt" << EOF
HSRename ${VERSION}
- Fix TheTVDB search dropping results (parse series-76320 style IDs)
- Gear Lever Forgejo update docs
EOF
# 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
@@ -70,7 +74,6 @@ else
fi
echo "=== Building AppImage (requires appimagetool) ==="
# Note: appimagetool does not accept plain https URLs for -u; use zsync/gh-releases-zsync per AppImage docs.
if command -v appimagetool &>/dev/null; then
OUT="${APP_NAME}.AppImage"
appimagetool "$APPDIR" "$OUT"
@@ -79,5 +82,10 @@ else
echo "AppDir is ready at $APPDIR/"
echo "To create the .AppImage, install appimagetool and run:"
echo " appimagetool $APPDIR ${APP_NAME}.AppImage"
echo " # Get it from: https://github.com/AppImage/appimagetool/releases"
exit 1
fi
if ! strings "dist/$EXE_NAME/$EXE_NAME" | grep -q "engine.tvdb_client"; then
echo "ERROR: Built executable is missing TheTVDB modules." >&2
exit 1
fi
+6 -2
View File
@@ -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. |
+171 -65
View File
@@ -4,64 +4,156 @@ 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 _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 title
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.
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, s_letter, season_s, e_letter, ep_s, _range_block, ep2_s, rest = m.groups()
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)
season = int(season_s)
old_first = int(ep_s)
except ValueError:
return None
span = 1
if old_second_s is not None:
if ep2_s is not None:
try:
old_second = int(old_second_s)
old_second = int(ep2_s)
except ValueError:
return None
span = old_second - old_first + 1
if span < 1:
span = 1
title = rest.strip()
if title.startswith("-") or title.startswith(""):
title = title[1:].strip()
if title.startswith("_"):
title = title[1:].strip()
title = _clean_title(rest)
return {
"format": "sxxexx",
"prefix": prefix,
"s_letter": s_letter,
"e_letter": e_letter,
"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)
head = (
f"{parsed['prefix']}{parsed['s_letter']}{new_season:0{s_pad}d}{parsed['e_letter']}"
)
if span <= 1:
return f"{head}{e1}{parsed['suffix']}"
e2 = str(new_ep + span - 1).zfill(e_pad)
range_prefix = f"-{parsed['e_letter']}"
return f"{head}{e1}{range_prefix}{e2}{parsed['suffix']}"
def rewrite_episode_number(
stem: str,
@@ -69,25 +161,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 +181,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,
)
]
+86
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+79 -10
View File
@@ -22,11 +22,14 @@ from PyQt6.QtWidgets import (
QHeaderView,
QAbstractItemView,
QFrame,
QCheckBox,
QComboBox,
)
from PyQt6.QtCore import Qt, QDir, QItemSelectionModel
from PyQt6.QtCore import Qt, QDir, QItemSelectionModel, QSettings
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,
@@ -50,7 +53,9 @@ class MainWindow(QMainWindow):
self._base_dir = ""
self._file_names: list[str] = []
self._preview_by_orig: dict[str, str] = {} # original name -> new name for selection preview
self._split: QSplitter | None = None
self._setup_ui()
self._restore_layout()
self._refresh_preview()
def _setup_ui(self):
@@ -70,6 +75,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)
@@ -153,13 +174,53 @@ class MainWindow(QMainWindow):
split.addWidget(right)
split.setSizes([320, 680])
self._split = split
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):
path = QFileDialog.getExistingDirectory(self, "Select folder")
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,9 +228,18 @@ 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()
@@ -189,9 +259,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 +331,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 +386,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 +418,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()
+34 -12
View File
@@ -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)
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()
+4
View File
@@ -21,7 +21,11 @@ def main():
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
app = QApplication(sys.argv)
app.setOrganizationName("HSRename")
app.setOrganizationDomain("hisora.dev")
app.setApplicationName("HSRename")
version_file = _root / "VERSION"
app.setApplicationVersion(version_file.read_text().strip() if version_file.is_file() else "")
win = MainWindow()
win.show()
sys.exit(app.exec())
+3
View File
@@ -0,0 +1,3 @@
HSRename 1.0.5
- Restore TheTVDB episode match feature in published AppImage (v1.0.4 asset was an outdated binary)
- TheTVDB search fix for series-76320 style IDs
+4
View File
@@ -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
+3
View File
@@ -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
+4
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
HSRename 1.0.9
- Remember window size, splitter position, and preview column widths between sessions
- Fix double S in SxxExx renames (SS02E33) when show name ends before the episode code
+33 -13
View File
@@ -2,9 +2,10 @@
# Push tag to Gitea and publish HSRename.AppImage as a release asset.
# Usage:
# GITEA_TOKEN=your_token ./release_gitea.sh
# Optional: ./release_gitea.sh --build (rebuild AppImage first)
#
# Requires: curl, git, and network access to your Gitea instance.
# Always rebuilds the AppImage before upload so releases never ship a stale binary.
#
# Requires: curl, git, appimagetool, and network access to your Gitea instance.
set -euo pipefail
cd "$(dirname "$0")"
@@ -22,18 +23,38 @@ APP_IMAGE="HSRename.AppImage"
VERSION="$(tr -d '[:space:]' < VERSION)"
TAG="v${VERSION}"
if [[ "${1:-}" == "--build" ]]; then
verify_appimage() {
if [[ ! -f "$APP_IMAGE" ]]; then
echo "Missing $APP_IMAGE after build." >&2
exit 1
fi
if [[ ! -f "dist/HSRename/HSRename" ]]; then
echo "Missing dist/HSRename/HSRename after build." >&2
exit 1
fi
if ! strings "dist/HSRename/HSRename" | grep -q "engine.tvdb_client"; then
echo "ERROR: Built executable is missing TheTVDB modules; refusing to publish." >&2
exit 1
fi
if ! strings "dist/HSRename/HSRename" | grep -q "engine.episode_match"; then
echo "ERROR: Built executable is missing episode matching; refusing to publish." >&2
exit 1
fi
}
export PATH="/tmp:${PATH}"
if ! command -v appimagetool &>/dev/null && [[ -x /tmp/appimagetool ]]; then
export PATH="/tmp:${PATH}"
fi
./build_appimage.sh
if ! command -v appimagetool &>/dev/null; then
curl -fsSL -o /tmp/appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
chmod +x /tmp/appimagetool
export PATH="/tmp:${PATH}"
fi
if [[ ! -f "$APP_IMAGE" ]]; then
echo "Missing $APP_IMAGE. Run ./build_appimage.sh or ./release_gitea.sh --build"
exit 1
fi
echo "=== Building fresh AppImage for ${TAG} ==="
./build_appimage.sh
verify_appimage
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG not found. Create it with: git tag $TAG"
@@ -46,10 +67,8 @@ git push origin "$TAG"
if [[ -z "${GITEA_TOKEN:-}" ]]; then
echo ""
echo "Code and tag pushed. To upload the AppImage, set GITEA_TOKEN and re-run:"
echo "Built and verified $APP_IMAGE. Set GITEA_TOKEN and re-run to upload:"
echo " GITEA_TOKEN=... ./release_gitea.sh"
echo ""
echo "Or create the release manually on Gitea and attach $APP_IMAGE."
exit 0
fi
@@ -57,9 +76,10 @@ API="${GITEA_HOST}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases"
NOTES="$(cat <<EOF
## HSRename ${TAG}
See repository history for changes in this release.
- Fix botched v1.0.4 AppImage (restores TheTVDB episode match + search fix)
- Always verify/update via Gear Lever Forgejo: \`https://git.hisora.dev/Dawnsorrow/HS-Rename\`
Gear Lever update URL:
Download:
\`${GITEA_HOST}/${GITEA_OWNER}/${GITEA_REPO}/releases/download/${TAG}/${APP_IMAGE}\`
EOF
)"