2 Commits
Author SHA1 Message Date
Bulk RenamerandCursor 28ec7746b8 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:36:58 -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
9 changed files with 280 additions and 103 deletions
+1 -1
View File
@@ -1 +1 @@
1.0.5 1.0.7
+6
View File
@@ -37,6 +37,12 @@ echo "$VERSION" > "$APPDIR/usr/share/hsrename/VERSION"
if [[ -f "packaging/release-stamp-${VERSION}.txt" ]]; then if [[ -f "packaging/release-stamp-${VERSION}.txt" ]]; then
cp "packaging/release-stamp-${VERSION}.txt" "$APPDIR/usr/share/hsrename/release-stamp.txt" cp "packaging/release-stamp-${VERSION}.txt" "$APPDIR/usr/share/hsrename/release-stamp.txt"
fi 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.
IFS=. read -r VER_MAJOR VER_MINOR VER_PATCH <<< "${VERSION}.0.0"
PAD_KB=$((300 + VER_MAJOR * 100 + VER_MINOR * 20 + VER_PATCH * 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 cat > "$APPDIR/AppRun" << EOF
#!/bin/sh #!/bin/sh
+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` `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 ## 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 | | 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. | | 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. | | Update never finds newer builds | Wrong repo URL, wrong filename, or release has no `HSRename.AppImage` asset attached. |
+159 -64
View File
@@ -4,64 +4,150 @@ Match episode titles from filenames against a reference episode list (e.g. TheTV
from __future__ import annotations from __future__ import annotations
import re import re
from dataclasses import dataclass
from difflib import SequenceMatcher from difflib import SequenceMatcher
from typing import Optional from typing import Optional
from .tvdb_client import TvdbEpisode 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: def normalize_title(title: str) -> str:
"""Lowercase, strip punctuation, collapse whitespace for fuzzy comparison.""" """Lowercase, strip punctuation, collapse whitespace for fuzzy comparison."""
t = title.lower() t = title.lower()
t = re.sub(r"[^\w\s]", " ", t, flags=re.UNICODE) 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]: def parse_episode_stem(stem: str, pattern: str = DEFAULT_EPISODE_PATTERN) -> Optional[dict]:
""" """
Parse SxxExx-style filename stem. Parse common TV filename stems (S01E05 or 04x01 styles).
Returns dict with prefix, episode numbers, title rest, and span — or None if no match. 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: try:
m = re.match(pattern, stem) season = int(season_s)
except re.error: old_first = int(ep_s)
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: except ValueError:
return None return None
span = 1 span = 1
if old_second_s is not None: if ep2_s is not None:
try: try:
old_second = int(old_second_s) old_second = int(ep2_s)
except ValueError: except ValueError:
return None return None
span = old_second - old_first + 1 span = old_second - old_first + 1
if span < 1: if span < 1:
span = 1 span = 1
title = rest.strip() title = _clean_title(rest)
if title.startswith("-") or title.startswith(""):
title = title[1:].strip()
if title.startswith("_"):
title = title[1:].strip()
return { return {
"prefix": prefix, "format": "sxxexx",
"prefix_before_s": prefix_before_s,
"season": season,
"season_pad": len(season_s),
"episode_pad": len(ep_s),
"old_first": old_first, "old_first": old_first,
"span": span, "span": span,
"title": title, "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( def rewrite_episode_number(
stem: str, stem: str,
@@ -69,25 +155,16 @@ def rewrite_episode_number(
padding: int = 2, padding: int = 2,
pattern: str = DEFAULT_EPISODE_PATTERN, pattern: str = DEFAULT_EPISODE_PATTERN,
) -> str: ) -> 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) parsed = parse_episode_stem(stem, pattern)
if not parsed: if not parsed:
return stem return stem
try: return rewrite_episode_stem(
m = re.match(pattern, stem) stem,
except re.error: EpisodeTarget(season=parsed["season"], episode=new_first_ep),
return stem padding=padding,
if not m: pattern=pattern,
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}"
def _similarity(a: str, b: str) -> float: def _similarity(a: str, b: str) -> float:
@@ -98,55 +175,73 @@ def _similarity(a: str, b: str) -> float:
return SequenceMatcher(None, a, b).ratio() return SequenceMatcher(None, a, b).ratio()
def _coerce_target(value: EpisodeTarget | tuple[int, int] | int, parsed: dict) -> EpisodeTarget:
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( 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.72, min_score: float = 0.65,
) -> tuple[dict[str, int], list[str], list[str]]: 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: season_filter: 0 = use all episodes; else only episodes from that season.
mapping: original filename -> correct episode number Returns mapping filename -> (season, episode), unmatched list, notes.
unmatched_files: filenames that could not be matched
notes: human-readable match details for UI
""" """
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: for name in filenames:
stem = name.rsplit(".", 1)[0] if "." in name and not name.startswith(".") else name stem = name.rsplit(".", 1)[0] if "." in name and not name.startswith(".") else name
parsed = parse_episode_stem(stem, pattern) parsed = parse_episode_stem(stem, pattern)
if not parsed or not parsed["title"]: if not parsed or not parsed["title"]:
continue continue
if season_filter > 0 and parsed["season"] != season_filter:
continue
norm = normalize_title(parsed["title"]) norm = normalize_title(parsed["title"])
if norm: 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]] = [] pairs: list[tuple[float, str, int, int, str, str]] = []
for fname, fnorm, raw_title in file_entries: for fname, fnorm, raw_title, _parsed in file_entries:
for ep_num, enorm, ep_name in ep_entries: for season, ep_num, enorm, ep_name in ep_entries:
score = _similarity(fnorm, enorm) 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_files: set[str] = set()
used_eps: set[int] = set() used_eps: set[tuple[int, int]] = set()
notes: list[str] = [] 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: if score < min_score:
break 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 continue
mapping[fname] = ep_num mapping[fname] = EpisodeTarget(season=season, episode=ep_num)
used_files.add(fname) used_files.add(fname)
used_eps.add(ep_num) used_eps.add(ep_key)
pct = int(round(score * 100)) 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 = [ unmatched_files = [
name for name in filenames name for name in filenames
+9 -5
View File
@@ -9,7 +9,7 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import List, Optional 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 @dataclass
@@ -279,12 +279,16 @@ class TvdbEpisodeRenumberRule(Rule):
) -> tuple[str, str]: ) -> tuple[str, str]:
if not self.episode_mapping or original_name is None: if not self.episode_mapping or original_name is None:
return stem, ext return stem, ext
new_ep = self.episode_mapping.get(original_name) target_raw = self.episode_mapping.get(original_name)
if new_ep is None: if target_raw is None:
return stem, ext 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, stem,
int(new_ep), target,
padding=self.padding, padding=self.padding,
pattern=self.pattern, pattern=self.pattern,
) )
+40 -1
View File
@@ -189,5 +189,44 @@ class TvdbClient:
if not links.get("next"): if not links.get("next"):
break break
page += 1 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 return episodes
+34 -12
View File
@@ -338,24 +338,39 @@ class _TvdbMatchWorker(QThread):
season: int, season: int,
season_type: str, season_type: str,
filenames: list[str], filenames: list[str],
all_seasons: bool,
): ):
super().__init__() super().__init__()
self.series_id = series_id self.series_id = series_id
self.season = season self.season = season
self.season_type = season_type self.season_type = season_type
self.filenames = filenames self.filenames = filenames
self.all_seasons = all_seasons
def run(self): def run(self):
try: try:
client = TvdbClient(TVDB_API_KEY) 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( episodes = client.get_season_episodes(
self.series_id, self.series_id,
self.season, self.season,
season_type=self.season_type, season_type=self.season_type,
) )
season_filter = self.season
if not episodes: if not episodes:
raise TvdbError(f"No episodes found for season {self.season}") label = "all seasons" if self.all_seasons else f"season {self.season}"
mapping, unmatched, notes = match_filenames_to_episodes(self.filenames, episodes) 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) self.finished.emit(mapping, unmatched, notes)
except TvdbError as e: except TvdbError as e:
self.failed.emit(str(e)) self.failed.emit(str(e))
@@ -407,7 +422,8 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
self.season = QSpinBox() self.season = QSpinBox()
self.season.setMinimum(0) self.season.setMinimum(0)
self.season.setMaximum(99) self.season.setMaximum(99)
self.season.setValue(1) self.season.setValue(0)
self.season.setSpecialValueText("All seasons")
self.season.valueChanged.connect(self._emit) self.season.valueChanged.connect(self._emit)
layout.addRow("Season:", self.season) layout.addRow("Season:", self.season)
@@ -422,13 +438,13 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
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)
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) self.status.setWordWrap(True)
layout.addRow(self.status) layout.addRow(self.status)
info = QLabel( info = QLabel(
"Reads episode titles from filenames like S01E05 - Episode Title. " "Reads titles from S01E05 - Episode Title or Show 04x01 Episode Title filenames. "
"Fetches the chosen TheTVDB episode order for that season and rewrites SxxExx numbers to match." "Use All seasons to match across every season and fix wrong season numbers too."
) )
info.setWordWrap(True) info.setWordWrap(True)
layout.addRow(info) layout.addRow(info)
@@ -492,12 +508,17 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
if series_id is None: if series_id is None:
return return
self.match_btn.setEnabled(False) 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( self._match_worker = _TvdbMatchWorker(
int(series_id), int(series_id),
self.season.value(), self.season.value(),
self._order_value(), self._order_value(),
self._file_names, self._file_names,
all_seasons,
) )
self._match_worker.finished.connect(self._on_match_finished) self._match_worker.finished.connect(self._on_match_finished)
self._match_worker.failed.connect(self._on_match_failed) self._match_worker.failed.connect(self._on_match_failed)
@@ -508,10 +529,8 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
self.match_btn.setEnabled(True) self.match_btn.setEnabled(True)
matched = len(mapping) matched = len(mapping)
total = len(self._file_names) total = len(self._file_names)
msg = ( scope = "all seasons" if self.season.value() == 0 else f"season {self.season.value()}"
f"Matched {matched} of {total} file(s) for season {self.season.value()} " msg = f"Matched {matched} of {total} file(s) ({scope}, {self._order_label()})."
f"({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)
@@ -524,7 +543,10 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
def getRule(self) -> TvdbEpisodeRenumberRule: def getRule(self) -> TvdbEpisodeRenumberRule:
r = 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(), padding=self.padding.value(),
) )
r.enabled = self.enabled_cb.isChecked() r.enabled = self.enabled_cb.isChecked()
+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