15 Commits
Author SHA1 Message Date
Bulk RenamerandCursor 64d7a30328 Release v1.0.13: fix TheTVDB match crash and improve stability.
Emit plain tuples across thread boundaries, prevent overlapping match workers, scope episode comparisons by season, and cap API pagination.

Co-authored-by: Cursor <[email protected]>
2026-07-03 20:40:44 -05:00
Bulk RenamerandCursor 445e24ecae Release v1.0.12: fetch English TheTVDB episode titles for matching.
Anime and other non-English shows returned Japanese default titles while filenames use English; add Episode titles language control defaulting to English.

Co-authored-by: Cursor <[email protected]>
2026-07-03 20:25:58 -05:00
Bulk RenamerandCursor fce5ad64e5 Release v1.0.11: fix multi-episode match for end-numbered and dual titles.
Combined pairs are recognized when the SxxExx tag uses the last episode number or the filename lists both story titles separated by dashes.

Co-authored-by: Cursor <[email protected]>
2026-07-03 18:34:24 -05:00
Bulk RenamerandCursor a6339467d4 Release v1.0.10: Jellyfin multi-episode file naming (S01E01-E02).
Match TheTVDB combined-order titles and rename to official aired episode ranges for files that contain two episodes in one video.

Co-authored-by: Cursor <[email protected]>
2026-07-03 18:23:06 -05:00
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
Bulk RenamerandCursor db460fb9b3 Release v1.0.4: fix TheTVDB search ID parsing.
Parse series-76320 style search hits from TheTVDB v4, document Gear Lever Forgejo setup, and adjust AppImage build metadata for update detection.

Co-authored-by: Cursor <[email protected]>
2026-07-03 17:17:49 -05:00
Bulk RenamerandCursor b3ce276d1b Fix release script: use git remote repo and two-step asset upload.
Avoids wrong-repo releases when GITEA_REPO is set in the shell environment.

Co-authored-by: Cursor <[email protected]>
2026-07-03 16:58:45 -05:00
Bulk RenamerandCursor 682e76de2f Update Gitea URLs to git.hisora.dev/Dawnsorrow/HS-Rename.
Align release script, Gear Lever docs, and remote with the new host and repo name.

Co-authored-by: Cursor <[email protected]>
2026-07-03 16:57:25 -05:00
Bulk RenamerandCursor 70a0ff0e84 Add Gitea release script for AppImage publishing.
Automates push, tag, and release asset upload for Gear Lever updates.

Co-authored-by: Cursor <[email protected]>
2026-07-03 16:47:49 -05:00
Bulk RenamerandCursor 085d6dc296 Add TheTVDB episode title matching and release v1.0.3.
Match filenames to TheTVDB episode order (default, aired, DVD, etc.) to fix chaotic SxxExx numbering while preserving titles.

Co-authored-by: Cursor <[email protected]>
2026-07-03 16:47:37 -05:00
24 changed files with 1572 additions and 65 deletions
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
1.0.2 1.0.13
+28 -4
View File
@@ -9,16 +9,20 @@ cd "$(dirname "$0")"
APP_NAME="HSRename" APP_NAME="HSRename"
APPDIR="${APP_NAME}.AppDir" APPDIR="${APP_NAME}.AppDir"
EXE_NAME="$APP_NAME" EXE_NAME="$APP_NAME"
VERSION="$(tr -d '[:space:]' < VERSION 2>/dev/null || echo "")"
echo "=== Installing build deps ===" echo "=== Installing build deps ==="
pip install -q -r requirements.txt -r requirements-build.txt 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 \ pyinstaller --noconfirm --onedir --windowed \
-n "$EXE_NAME" \ -n "$EXE_NAME" \
--hidden-import=engine \ --hidden-import=engine \
--hidden-import=engine.rules \ --hidden-import=engine.rules \
--hidden-import=engine.pipeline \ --hidden-import=engine.pipeline \
--hidden-import=engine.tvdb_client \
--hidden-import=engine.episode_match \
--hidden-import=gui \ --hidden-import=gui \
--hidden-import=gui.main_window \ --hidden-import=gui.main_window \
--hidden-import=gui.rule_widgets \ --hidden-import=gui.rule_widgets \
@@ -28,6 +32,21 @@ echo "=== Creating AppDir ==="
rm -rf "$APPDIR" rm -rf "$APPDIR"
mkdir -p "$APPDIR/usr/bin" mkdir -p "$APPDIR/usr/bin"
cp -a "dist/$EXE_NAME" "$APPDIR/usr/bin/" cp -a "dist/$EXE_NAME" "$APPDIR/usr/bin/"
mkdir -p "$APPDIR/usr/share/hsrename"
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 cat > "$APPDIR/AppRun" << EOF
#!/bin/sh #!/bin/sh
@@ -37,7 +56,7 @@ exec "\$HERE/usr/bin/$EXE_NAME/$EXE_NAME" "\$@"
EOF EOF
chmod +x "$APPDIR/AppRun" chmod +x "$APPDIR/AppRun"
cat > "$APPDIR/hsrename.desktop" << 'EOF' cat > "$APPDIR/hsrename.desktop" << EOF
[Desktop Entry] [Desktop Entry]
Name=HSRename Name=HSRename
Comment=Mass rename files with preview and flexible rules Comment=Mass rename files with preview and flexible rules
@@ -45,6 +64,7 @@ Exec=HSRename
Icon=HSRename Icon=HSRename
Type=Application Type=Application
Categories=Utility;FileTools; Categories=Utility;FileTools;
X-AppImage-Version=${VERSION}
EOF EOF
# Use project icon as app icon and logo # Use project icon as app icon and logo
if [[ -f HSRename.png ]]; then if [[ -f HSRename.png ]]; then
@@ -54,7 +74,6 @@ else
fi fi
echo "=== Building AppImage (requires appimagetool) ===" 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 if command -v appimagetool &>/dev/null; then
OUT="${APP_NAME}.AppImage" OUT="${APP_NAME}.AppImage"
appimagetool "$APPDIR" "$OUT" appimagetool "$APPDIR" "$OUT"
@@ -63,5 +82,10 @@ else
echo "AppDir is ready at $APPDIR/" echo "AppDir is ready at $APPDIR/"
echo "To create the .AppImage, install appimagetool and run:" echo "To create the .AppImage, install appimagetool and run:"
echo " appimagetool $APPDIR ${APP_NAME}.AppImage" 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 fi
+51 -38
View File
@@ -1,58 +1,71 @@
# Using HSRename with Gear Lever # Using HSRename with Gear Lever
[Gear Lever](https://github.com/mijorus/gearlever) can manage and update the HSRename AppImage. Because releases are hosted on **Gitea** (not GitHub), use one of the options below. [Gear Lever](https://github.com/mijorus/gearlever) 4.x can update HSRename from Gitea releases. Gitea uses the same API as **Forgejo**, which Gear Lever supports directly.
## Option 1: Custom update URL (recommended to try first) ## Important: use Forgejo, not Default
Gitea uses the same release path style as GitHub: In the HSRename details screen:
`/owner/repo/releases/download/{tag}/{filename}`
1. Open **Gear Lever** and add the HSRename AppImage (drag & drop or **Add**). - **Source** must be **Forgejo** (not `Default`).
2. Select the HSRename entry and open **Update** / **Custom update URL** (or the equivalent in your Gear Lever version). - The **Website** field is only metadata. It does **not** check for updates.
3. Try this URL pattern (with a wildcard for the tag): - Do **not** paste the release download URL into Website.
```
http://brassnet.ddns.net:33983/Dawnsorrow/HS-Rename/releases/download/*/HSRename.AppImage
```
4. If Gear Lever accepts it (e.g. field turns green or validates), it may use the Gitea releases page to resolve the latest tag and offer updates when you push new releases.
5. Use **Check for updates** / **List updates** to see if it detects new versions.
If your Gear Lever only supports GitHub-style URLs and rejects this, use Option 2. ## Configure updates (Gear Lever 4+)
## Option 2: Static URL (manual update link) 1. Open **Gear Lever** → select **HSRename**.
2. Set **Source** to **Forgejo**.
3. Fill in:
If the wildcard URL does not work: | Field | Value |
|-------|--------|
| **Repo URL** | `https://git.hisora.dev/Dawnsorrow/HS-Rename` |
| **Release file name** | `HSRename.AppImage` |
| **Allow pre-releases** | Off (unless you want draft releases) |
1. In Gear Lever, add HSRename and set **Update** to **Static URL**. 4. Save, then use **Check for updates** / **List updates**.
2. Paste the **direct download URL** of the current release, for example:
- **v1.0.1:**
`http://brassnet.ddns.net:33983/Dawnsorrow/HS-Rename/releases/download/v1.0.1/HSRename.AppImage`
3. When a new version is released (e.g. v1.0.2), open the [releases page](http://brassnet.ddns.net:33983/Dawnsorrow/HS-Rename/releases), open the new release, rightclick **HSRename.AppImage** → **Copy link address**, then in Gear Lever set the Static URL to that new link and run **Update**.
So: Gear Lever will only “see” updates if you change the Static URL to the new releases download link when you want to update. If the fields validate (no error), Gear Lever queries:
## Option 3: CLI (if you use Gear Lever from the terminal) `https://git.hisora.dev/api/v1/repos/Dawnsorrow/HS-Rename/releases/latest`
After adding the AppImage in Gear Lever, you can set the update URL from the command line: and compares the release asset **file size** with your local AppImage.
```bash 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.
# Set custom update URL (try the wildcard pattern)
gearlever --set-update-url /path/to/HSRename.AppImage "http://brassnet.ddns.net:33983/Dawnsorrow/HS-Rename/releases/download/*/HSRename.AppImage"
# Check for updates 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**.
gearlever --list-updates
# Apply update ## Direct download URL pattern
gearlever --update /path/to/HSRename.AppImage
```
Replace `/path/to/HSRename.AppImage` with the actual path where Gear Lever stores the AppImage. For release tag `vX.Y.Z`:
## Release download URL pattern
For any release tag `vX.Y.Z`, the direct download URL is:
``` ```
http://brassnet.ddns.net:33983/Dawnsorrow/HS-Rename/releases/download/vX.Y.Z/HSRename.AppImage https://git.hisora.dev/Dawnsorrow/HS-Rename/releases/download/vX.Y.Z/HSRename.AppImage
``` ```
So when we push a new release (e.g. v1.0.2), that new tags URL is what Gear Lever needs (either via the wildcard in Option 1 or by pasting the new URL in Option 2). Latest release today:
```
https://git.hisora.dev/Dawnsorrow/HS-Rename/releases/download/v1.0.3/HSRename.AppImage
```
## Fallback: Static URL
If Forgejo does not work in your Gear Lever build, set **Source** to **Static URL** and paste the **exact** URL of the newest release (update this manually each release):
```
https://git.hisora.dev/Dawnsorrow/HS-Rename/releases/download/v1.0.3/HSRename.AppImage
```
## CLI (if `gearlever` is installed)
Gear Lever 4 may use different CLI options than older docs. Prefer the GUI Forgejo fields above. If your install still supports `--set-update-url`, the old wildcard pattern is **not** reliable for self-hosted Gitea; use Forgejo instead.
## Troubleshooting
| Symptom | Likely cause |
|---------|----------------|
| 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. |
Releases page: https://git.hisora.dev/Dawnsorrow/HS-Rename/releases
+2 -1
View File
@@ -1,4 +1,4 @@
from .rules import Rule, ReplaceRule, InsertRule, RemoveRule, CaseRule, NumberingRule, EpisodeRenumberRule, RegexRule, PrefixSuffixRule, CsvMappingRule from .rules import Rule, ReplaceRule, InsertRule, RemoveRule, CaseRule, NumberingRule, EpisodeRenumberRule, TvdbEpisodeRenumberRule, RegexRule, PrefixSuffixRule, CsvMappingRule
from .pipeline import apply_pipeline, compute_preview from .pipeline import apply_pipeline, compute_preview
__all__ = [ __all__ = [
@@ -9,6 +9,7 @@ __all__ = [
"CaseRule", "CaseRule",
"NumberingRule", "NumberingRule",
"EpisodeRenumberRule", "EpisodeRenumberRule",
"TvdbEpisodeRenumberRule",
"RegexRule", "RegexRule",
"PrefixSuffixRule", "PrefixSuffixRule",
"CsvMappingRule", "CsvMappingRule",
+489
View File
@@ -0,0 +1,489 @@
"""
Match episode titles from filenames against a reference episode list (e.g. TheTVDB).
"""
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
# 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
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:
"""Lowercase, strip punctuation, collapse whitespace for fuzzy comparison."""
t = title.lower()
t = re.sub(r"[^\w\s]", " ", t, flags=re.UNICODE)
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()
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
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, s_letter, season_s, e_letter, ep_s, _range_block, 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": 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
if target.episode_end is not None and target.episode_end > target.episode:
span = target.episode_end - target.episode + 1
else:
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,
new_first_ep: int,
padding: int = 2,
pattern: str = DEFAULT_EPISODE_PATTERN,
) -> str:
"""Legacy helper: episode only, keep season from filename."""
parsed = parse_episode_stem(stem, pattern)
if not parsed:
return stem
return rewrite_episode_stem(
stem,
EpisodeTarget(season=parsed["season"], episode=new_first_ep),
padding=padding,
pattern=pattern,
)
def _similarity(a: str, b: str) -> float:
if not a or not b:
return 0.0
if a == b:
return 1.0
return SequenceMatcher(None, a, b).ratio()
def _coerce_target(
value: EpisodeTarget | tuple[int, ...] | int,
parsed: dict,
) -> EpisodeTarget:
if isinstance(value, EpisodeTarget):
return value
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=parsed["season"], episode=int(value[0]))
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(
filenames: list[str],
episodes: list[TvdbEpisode],
pattern: str = DEFAULT_EPISODE_PATTERN,
min_score: float = 0.65,
season_filter: int = 0,
official_episodes: Optional[list[TvdbEpisode]] = None,
combined_episodes: Optional[list[TvdbEpisode]] = None,
) -> tuple[dict[str, EpisodeTarget], list[str], list[str]]:
"""
Match filenames to TheTVDB episodes by title.
season_filter: 0 = use all episodes; else only episodes from that season.
official_episodes + combined_episodes: when set, also match combined-order titles
and map to official aired numbers as Jellyfin multi-episode ranges (S01E01-E02).
Returns mapping filename -> target, unmatched list, notes.
"""
if season_filter > 0:
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]] = []
for name in filenames:
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:
continue
if season_filter > 0 and parsed["season"] != season_filter:
continue
norm = normalize_title(parsed["title"]) if parsed["title"] else ""
if norm or parsed.get("span", 1) > 1:
file_entries.append((name, norm, parsed["title"], parsed))
ep_entries = [
(ep.season_number, ep.number, normalize_title(ep.name), ep.name)
for ep in episodes
]
combined_entries: list[tuple[TvdbEpisode, EpisodeTarget, list[str]]] = []
if combined:
for cep in combined:
target = resolve_combined_to_official(cep, official)
if target is None:
continue
combined_entries.append((cep, target, _combined_title_variants(cep.name)))
pairs: list[tuple[float, str, EpisodeTarget, str, str]] = []
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] = {}
used_files: set[str] = set()
used_ranges: list[tuple[int, int, int]] = []
notes: list[str] = []
multi_file_names = {
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:
break
if fname in used_files:
continue
season, start, end = _target_range(target)
if _range_overlaps(season, start, end, used_ranges):
continue
mapping[fname] = target
used_files.add(fname)
used_ranges.append((season, start, end))
pct = min(100, int(round(score * 100)))
code = target.format_code()
notes.append(
f"{fname}: {code} ← “{ep_name}” ({pct}% match, file title “{raw_title}”)"
)
multi_pairs = [p for p in pairs if p[1] in multi_file_names]
other_pairs = [p for p in pairs if p[1] not in multi_file_names]
_assign_pairs(multi_pairs)
_assign_pairs(other_pairs)
unmatched_files = [
name for name in filenames
if name not in mapping
and parse_episode_stem(
(
Path(name).name.rsplit(".", 1)[0]
if "." in Path(name).name and not Path(name).name.startswith(".")
else Path(name).name
),
pattern,
)
]
return mapping, unmatched_files, notes
+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, index: int,
total: int, total: int,
) -> str: ) -> str:
"""Apply all enabled rules to a single filename (no path). Returns new filename.""" """Apply all enabled rules to a filename or relative path. Returns new name/path."""
stem, ext = _split_name(name) 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: 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)
return stem + ext new_name = stem + ext
if parent is not None:
return str(parent / new_name)
return new_name
def compute_preview( def compute_preview(
@@ -77,11 +88,12 @@ 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):
temp_name = f"__temp_{i}_{old_path.name}{temp_suffix}" new_path = base / new_name
while temp_name in final_names or (base / temp_name).exists(): 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 i += 1
temp_name = f"__temp_{i}_{old_path.name}{temp_suffix}" temp_path = old_path.parent / f"__temp_{i}_{old_path.name}{temp_suffix}"
temp_map.append((old_path, base / temp_name, base / new_name)) temp_map.append((old_path, temp_path, new_path))
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:
+38
View File
@@ -9,6 +9,8 @@ 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_stem, EpisodeTarget, _coerce_target, parse_episode_stem
@dataclass @dataclass
class Rule(ABC): class Rule(ABC):
@@ -257,6 +259,42 @@ class EpisodeRenumberRule(Rule):
return new_stem, ext return new_stem, ext
@dataclass
class TvdbEpisodeRenumberRule(Rule):
"""
Renumber SxxExx blocks using a pre-built filename → episode number mapping
(typically from TheTVDB title matching).
"""
episode_mapping: dict = field(default_factory=dict, repr=False)
padding: int = 2
pattern: str = r"(.*?[Ss]\d+[Ee])(\d+)(-[Ee](\d+))?(.*)"
def apply(
self,
stem: str,
ext: str,
index: int,
total: int,
original_name: Optional[str] = None,
) -> tuple[str, str]:
if not self.episode_mapping or original_name is None:
return stem, ext
target_raw = self.episode_mapping.get(original_name)
if target_raw is None:
return stem, ext
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,
target,
padding=self.padding,
pattern=self.pattern,
)
return new_stem, ext
@dataclass @dataclass
class PrefixSuffixRule(Rule): class PrefixSuffixRule(Rule):
prefix: str = "" prefix: str = ""
+264
View File
@@ -0,0 +1,264 @@
"""
TheTVDB API v4 client (read-only). Uses stdlib urllib — no extra dependencies.
"""
from __future__ import annotations
import json
import re
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Any, Optional
BASE_URL = "https://api4.thetvdb.com/v4"
TVDB_API_KEY = "78384b24-0f0b-461e-9415-69cce3da35e4"
# Labels and API path segments for /series/{id}/episodes/{season-type}
SEASON_TYPE_CHOICES: list[tuple[str, str]] = [
("Default (TheTVDB default for show)", "default"),
("Official / aired order", "official"),
("DVD order", "dvd"),
("Absolute order", "absolute"),
("Combined order (multi-episode files)", "alternate"),
("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)
class TvdbSeries:
id: int
name: str
year: Optional[str] = None
@dataclass(frozen=True)
class TvdbEpisode:
number: int
season_number: int
name: str
class TvdbError(Exception):
pass
def _parse_series_id(item: dict[str, Any]) -> Optional[int]:
"""Extract numeric series id from a TheTVDB v4 search hit."""
tvdb_id = item.get("tvdb_id")
if tvdb_id is not None:
try:
return int(tvdb_id)
except (TypeError, ValueError):
pass
raw_id = item.get("id")
if raw_id is None:
return None
if isinstance(raw_id, int):
return raw_id
if isinstance(raw_id, str):
if raw_id.isdigit():
return int(raw_id)
m = re.match(r"^series-(\d+)$", raw_id, re.IGNORECASE)
if m:
return int(m.group(1))
return None
class TvdbClient:
def __init__(self, api_key: str, pin: str = ""):
self.api_key = api_key.strip()
self.pin = pin.strip()
self._token: Optional[str] = None
def _request(
self,
method: str,
path: str,
params: Optional[dict[str, Any]] = None,
body: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
if not self._token and path != "/login":
self.login()
url = BASE_URL + path
if params:
query = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
if query:
url = f"{url}?{query}"
data_bytes: Optional[bytes] = None
headers = {"Accept": "application/json"}
if body is not None:
data_bytes = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
if self._token:
headers["Authorization"] = f"Bearer {self._token}"
req = urllib.request.Request(url, data=data_bytes, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
payload = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", errors="replace")
raise TvdbError(f"TheTVDB HTTP {e.code}: {detail}") from e
except urllib.error.URLError as e:
raise TvdbError(f"TheTVDB network error: {e.reason}") from e
except json.JSONDecodeError as e:
raise TvdbError("TheTVDB returned invalid JSON") from e
if payload.get("status") == "failure":
msg = payload.get("message") or "Unknown TheTVDB error"
raise TvdbError(msg)
return payload
def login(self) -> None:
if not self.api_key:
raise TvdbError("TheTVDB API key is required")
body: dict[str, str] = {"apikey": self.api_key}
if self.pin:
body["pin"] = self.pin
payload = self._request("POST", "/login", body=body)
token = payload.get("data", {}).get("token")
if not token:
raise TvdbError("TheTVDB login did not return a token")
self._token = token
def search_series(self, query: str, limit: int = 25) -> list[TvdbSeries]:
query = query.strip()
if not query:
return []
payload = self._request(
"GET",
"/search",
params={"query": query, "type": "series", "limit": limit},
)
results: list[TvdbSeries] = []
for item in payload.get("data") or []:
item_type = item.get("type") or item.get("primary_type")
if item_type and item_type != "series":
continue
series_id = _parse_series_id(item)
if series_id is None:
continue
year = item.get("year")
if year is not None:
year = str(year)
results.append(
TvdbSeries(
id=series_id,
name=item.get("name") or item.get("title") or f"Series {series_id}",
year=year,
)
)
return results
def _episodes_path(self, series_id: int, season_type: str, language: str = "") -> str:
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,
series_id: int,
season_type: str,
*,
season: Optional[int] = None,
language: str = "",
skip_specials: bool = False,
) -> list[TvdbEpisode]:
"""Fetch episodes; optional translated titles via language code (e.g. eng)."""
episodes: list[TvdbEpisode] = []
page = 0
max_pages = 50
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(
"GET",
self._episodes_path(series_id, season_type, language),
params=params,
)
batch = (payload.get("data") or {}).get("episodes") or []
if not batch:
break
filter_season = season if language else None
episodes.extend(
self._parse_episode_batch(
batch,
season=filter_season,
skip_specials=skip_specials,
)
)
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
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(
self,
series_id: int,
season_type: str = "default",
language: str = "",
) -> list[TvdbEpisode]:
"""Fetch every episode for a series (all seasons), paginated."""
return self._fetch_episodes(
series_id,
season_type,
language=language,
skip_specials=True,
)
+95 -13
View File
@@ -22,11 +22,14 @@ from PyQt6.QtWidgets import (
QHeaderView, QHeaderView,
QAbstractItemView, QAbstractItemView,
QFrame, 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 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,
@@ -36,6 +39,7 @@ from .rule_widgets import (
CaseRuleWidget, CaseRuleWidget,
NumberingRuleWidget, NumberingRuleWidget,
EpisodeRenumberRuleWidget, EpisodeRenumberRuleWidget,
TvdbEpisodeRenumberRuleWidget,
PrefixSuffixRuleWidget, PrefixSuffixRuleWidget,
CsvMappingRuleWidget, CsvMappingRuleWidget,
) )
@@ -49,7 +53,9 @@ 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._setup_ui() self._setup_ui()
self._restore_layout()
self._refresh_preview() self._refresh_preview()
def _setup_ui(self): def _setup_ui(self):
@@ -69,6 +75,22 @@ 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)
@@ -85,6 +107,7 @@ class MainWindow(QMainWindow):
CaseRuleWidget(), CaseRuleWidget(),
NumberingRuleWidget(), NumberingRuleWidget(),
EpisodeRenumberRuleWidget(), EpisodeRenumberRuleWidget(),
TvdbEpisodeRenumberRuleWidget(),
PrefixSuffixRuleWidget(), PrefixSuffixRuleWidget(),
CsvMappingRuleWidget(), CsvMappingRuleWidget(),
] ]
@@ -96,12 +119,17 @@ class MainWindow(QMainWindow):
"5. Case", "5. Case",
"6. Numbering", "6. Numbering",
"7. Episode renumber", "7. Episode renumber",
"8. Prefix / Suffix", "8. TheTVDB episode match",
"9. CSV mapping", "9. Prefix / Suffix",
"10. CSV mapping",
] ]
for title, w in zip(rule_titles, self._rule_widgets): for title, w in zip(rule_titles, self._rule_widgets):
w.enabled_cb.toggled.connect(self._refresh_preview) # always refresh when checkbox toggled w.enabled_cb.toggled.connect(self._refresh_preview) # always refresh when checkbox toggled
w.ruleChanged.connect(self._on_rule_changed) # refresh only when enabled rules options change w.ruleChanged.connect(self._on_rule_changed) # refresh only when enabled rules options change
if hasattr(w, "set_file_names"):
w.set_file_names(self._file_names)
if hasattr(w, "matchCompleted"):
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)
@@ -146,13 +174,53 @@ 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):
@@ -160,10 +228,19 @@ class MainWindow(QMainWindow):
self._file_names = [] self._file_names = []
else: else:
self._base_dir = path self._base_dir = path
self._file_names = sorted( self._reload_files()
f for f in os.listdir(path) return
if os.path.isfile(os.path.join(path, f)) and not f.startswith(".") 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() self._refresh_preview()
def _on_rule_changed(self): def _on_rule_changed(self):
@@ -182,9 +259,7 @@ 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."""
if "." in filename and not filename.startswith("."): return file_extension(filename)
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."""
@@ -206,7 +281,13 @@ class MainWindow(QMainWindow):
self.preview_table.setSortingEnabled(False) self.preview_table.setSortingEnabled(False)
self.preview_table.setRowCount(0) self.preview_table.setRowCount(0)
self.preview_status.setText("Add a folder to see files.") self.preview_status.setText("Add a folder to see files.")
for w in self._rule_widgets:
if hasattr(w, "set_file_names"):
w.set_file_names([])
return return
for w in self._rule_widgets:
if hasattr(w, "set_file_names"):
w.set_file_names(self._file_names)
# Preserve sort and selection so checking a rule doesnt reorder or clear selection # Preserve sort and selection so checking a rule doesnt reorder or clear selection
header = self.preview_table.horizontalHeader() header = self.preview_table.horizontalHeader()
sort_section = header.sortIndicatorSection() sort_section = header.sortIndicatorSection()
@@ -250,7 +331,8 @@ 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:
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("") self.preview_status.setStyleSheet("")
def _apply_renames(self): def _apply_renames(self):
@@ -304,7 +386,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._on_dir_changed() self._reload_files()
def _undo_renames(self): def _undo_renames(self):
if not self._base_dir: if not self._base_dir:
@@ -336,4 +418,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._on_dir_changed() self._reload_files()
+357 -1
View File
@@ -15,8 +15,9 @@ from PyQt6.QtWidgets import (
QStackedWidget, QStackedWidget,
QPushButton, QPushButton,
QFileDialog, QFileDialog,
QMessageBox,
) )
from PyQt6.QtCore import pyqtSignal from PyQt6.QtCore import pyqtSignal, QThread
from engine.rules import ( from engine.rules import (
ReplaceRule, ReplaceRule,
@@ -25,10 +26,13 @@ from engine.rules import (
CaseRule, CaseRule,
NumberingRule, NumberingRule,
EpisodeRenumberRule, EpisodeRenumberRule,
TvdbEpisodeRenumberRule,
RegexRule, RegexRule,
PrefixSuffixRule, PrefixSuffixRule,
CsvMappingRule, CsvMappingRule,
) )
from engine.tvdb_client import TvdbClient, TvdbError, TVDB_API_KEY, SEASON_TYPE_CHOICES, LANGUAGE_CHOICES
from engine.episode_match import match_filenames_to_episodes, target_to_tuple
class ReplaceRuleWidget(QWidget): class ReplaceRuleWidget(QWidget):
@@ -305,6 +309,358 @@ class EpisodeRenumberRuleWidget(QWidget):
return r return r
class _TvdbSearchWorker(QThread):
finished = pyqtSignal(list)
failed = pyqtSignal(str)
def __init__(self, query: str):
super().__init__()
self.query = query
def run(self):
try:
client = TvdbClient(TVDB_API_KEY)
results = client.search_series(self.query)
self.finished.emit(results)
except TvdbError as e:
self.failed.emit(str(e))
except Exception as e:
self.failed.emit(str(e))
class _TvdbMatchWorker(QThread):
finished = pyqtSignal(dict, list, list)
failed = pyqtSignal(str)
def __init__(
self,
series_id: int,
season: int,
season_type: str,
filenames: list[str],
all_seasons: bool,
multi_episode: bool,
language: str,
):
super().__init__()
self.series_id = series_id
self.season = season
self.season_type = season_type
self.filenames = filenames
self.all_seasons = all_seasons
self.multi_episode = multi_episode
self.language = language
def run(self):
try:
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:
episodes = client.get_all_episodes(
self.series_id,
season_type=self.season_type,
language=lang,
)
else:
episodes = client.get_season_episodes(
self.series_id,
self.season,
season_type=self.season_type,
language=lang,
)
if self.season_type == "alternate":
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}"
raise TvdbError(f"No episodes found for {label}")
mapping, unmatched, notes = match_filenames_to_episodes(
self.filenames,
episodes,
season_filter=season_filter,
official_episodes=official,
combined_episodes=combined,
)
safe_mapping = {path: target_to_tuple(t) for path, t in mapping.items()}
self.finished.emit(safe_mapping, unmatched, notes)
except TvdbError as e:
self.failed.emit(str(e))
except Exception as e:
import traceback
self.failed.emit(f"{e}\n\n{traceback.format_exc()}")
class TvdbEpisodeRenumberRuleWidget(QWidget):
ruleChanged = pyqtSignal()
matchCompleted = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._file_names: list[str] = []
self._episode_mapping: dict[str, int] = {}
self._search_results: list = []
self._search_worker: _TvdbSearchWorker | None = None
self._match_worker: _TvdbMatchWorker | None = None
layout = QFormLayout(self)
self.enabled_cb = QCheckBox("Use this rule")
self.enabled_cb.setChecked(False)
self.enabled_cb.toggled.connect(self._emit)
layout.addRow(self.enabled_cb)
layout.addRow("Source:", QLabel("TheTVDB.com"))
search_row = QHBoxLayout()
self.search_query = QLineEdit()
self.search_query.setPlaceholderText("Show name to search")
self.search_query.returnPressed.connect(self._search_series)
search_btn = QPushButton("Search")
search_btn.clicked.connect(self._search_series)
search_row.addWidget(self.search_query, 1)
search_row.addWidget(search_btn)
layout.addRow("Show:", search_row)
self.series_combo = QComboBox()
self.series_combo.setEnabled(False)
self.series_combo.currentIndexChanged.connect(self._emit)
layout.addRow("Pick show:", self.series_combo)
self.order_type = QComboBox()
for label, value in SEASON_TYPE_CHOICES:
self.order_type.addItem(label, value)
self.order_type.currentIndexChanged.connect(self._emit)
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.setMinimum(0)
self.season.setMaximum(99)
self.season.setValue(0)
self.season.setSpecialValueText("All seasons")
self.season.valueChanged.connect(self._emit)
layout.addRow("Season:", self.season)
self.padding = QSpinBox()
self.padding.setMinimum(1)
self.padding.setMaximum(3)
self.padding.setValue(2)
self.padding.valueChanged.connect(self._emit)
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.clicked.connect(self._match_titles)
layout.addRow(self.match_btn)
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 titles from S01E05 - Episode Title, S01E05-E06 (multi-episode), "
"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."
)
info.setWordWrap(True)
layout.addRow(info)
def set_file_names(self, names: list[str]) -> None:
self._file_names = list(names)
def _order_label(self) -> str:
return self.order_type.currentText()
def _order_value(self) -> str:
value = self.order_type.currentData()
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):
self.ruleChanged.emit()
def _search_series(self):
query = self.search_query.text().strip()
if not query:
QMessageBox.warning(self, "Search", "Enter a show name to search.")
return
self.match_btn.setEnabled(False)
self.series_combo.setEnabled(False)
self.status.setText("Searching TheTVDB…")
self._search_worker = _TvdbSearchWorker(query)
self._search_worker.finished.connect(self._on_search_finished)
self._search_worker.failed.connect(self._on_search_failed)
self._search_worker.start()
def _on_search_finished(self, results):
self._search_results = results
self.series_combo.clear()
if not results:
self.status.setText("No series found. Try a different search.")
self.match_btn.setEnabled(True)
return
for s in results:
label = s.name
if s.year:
label = f"{label} ({s.year})"
self.series_combo.addItem(label, s.id)
self.series_combo.setEnabled(True)
self.match_btn.setEnabled(True)
self.status.setText(f"Found {len(results)} series. Pick one and match titles.")
self._emit()
def _on_search_failed(self, message: str):
self.match_btn.setEnabled(True)
self.series_combo.setEnabled(self.series_combo.count() > 0)
self.status.setText(f"Search failed: {message}")
def _match_titles(self):
if self.series_combo.count() == 0:
QMessageBox.warning(self, "Match", "Search and select a show first.")
return
if not self._file_names:
QMessageBox.warning(self, "Match", "Load a folder with files first.")
return
series_id = self.series_combo.currentData()
if series_id is None:
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)
all_seasons = self.season.value() == 0
self.status.setText(
f"Fetching episodes ({self._order_label()}, {self._language_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.multi_episode_cb.isChecked(),
self._language_value(),
)
self._match_worker.finished.connect(self._on_match_finished)
self._match_worker.failed.connect(self._on_match_failed)
self._match_worker.start()
def _on_match_finished(self, mapping: dict, unmatched: list, notes: list):
try:
self._episode_mapping = mapping
self.match_btn.setEnabled(True)
matched = len(mapping)
total = len(self._file_names)
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()})."
if unmatched:
msg += f" {len(unmatched)} file(s) unmatched."
self.status.setText(msg)
self.matchCompleted.emit()
self._emit()
except Exception as e:
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):
self.match_btn.setEnabled(True)
self.status.setText(f"Match failed: {message}")
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(
episode_mapping=mapping,
padding=self.padding.value(),
)
r.enabled = self.enabled_cb.isChecked()
return r
class PrefixSuffixRuleWidget(QWidget): class PrefixSuffixRuleWidget(QWidget):
ruleChanged = pyqtSignal() ruleChanged = pyqtSignal()
+4
View File
@@ -21,7 +21,11 @@ def main():
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())
+4
View File
@@ -0,0 +1,4 @@
HSRename 1.0.10
- Multi-episode files: Jellyfin S01E01-E02 naming from TheTVDB combined order
- Match combined titles (Ep A/Ep B) and map to official aired episode ranges
- Combined order option in episode order dropdown; multi-episode toggle on TheTVDB rule
+3
View File
@@ -0,0 +1,3 @@
HSRename 1.0.11
- Fix multi-episode detection when filename uses end episode (S01E06 for E05-E06)
- Detect dual titles separated by " - " (Otto 3000 - Night Prowler) as combined pairs
+3
View File
@@ -0,0 +1,3 @@
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
+4
View File
@@ -0,0 +1,4 @@
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
+3
View File
@@ -0,0 +1,3 @@
HSRename release stamp 1.0.4
TheTVDB search hotfix: accept TheTVDB v4 search ids like series-76320.
Built for Gear Lever update testing.
+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
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Push tag to Gitea and publish HSRename.AppImage as a release asset.
# Usage:
# GITEA_TOKEN=your_token ./release_gitea.sh
#
# 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")"
GITEA_HOST="${GITEA_HOST:-https://git.hisora.dev}"
GITEA_OWNER="${GITEA_OWNER:-Dawnsorrow}"
if remote_url="$(git remote get-url origin 2>/dev/null)"; then
GITEA_REPO="$(basename "${remote_url%.git}")"
elif [[ -z "${GITEA_REPO:-}" ]]; then
GITEA_REPO="HS-Rename"
fi
GITEA_REPO="${GITEA_REPO:-HS-Rename}"
APP_IMAGE="HSRename.AppImage"
VERSION="$(tr -d '[:space:]' < VERSION)"
TAG="v${VERSION}"
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
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
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"
exit 1
fi
echo "=== Pushing main and tag $TAG ==="
git push origin main
git push origin "$TAG"
if [[ -z "${GITEA_TOKEN:-}" ]]; then
echo ""
echo "Built and verified $APP_IMAGE. Set GITEA_TOKEN and re-run to upload:"
echo " GITEA_TOKEN=... ./release_gitea.sh"
exit 0
fi
API="${GITEA_HOST}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases"
NOTES="$(cat <<EOF
## HSRename ${TAG}
- 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\`
Download:
\`${GITEA_HOST}/${GITEA_OWNER}/${GITEA_REPO}/releases/download/${TAG}/${APP_IMAGE}\`
EOF
)"
echo "=== Creating Gitea release $TAG on ${GITEA_OWNER}/${GITEA_REPO} ==="
RELEASE_JSON="$(curl -fsS -X POST "$API" \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "tag_name=${TAG}" \
-F "name=${TAG}" \
-F "body=${NOTES}")"
RELEASE_ID="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['id'])" "$RELEASE_JSON")"
echo "=== Uploading ${APP_IMAGE} (${RELEASE_ID}) ==="
curl -fsS --max-time 300 -X POST \
"${API}/${RELEASE_ID}/assets?name=${APP_IMAGE}" \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${APP_IMAGE}" >/dev/null
echo ""
echo "Release published:"
echo " ${GITEA_HOST}/${GITEA_OWNER}/${GITEA_REPO}/releases/tag/${TAG}"
echo " ${GITEA_HOST}/${GITEA_OWNER}/${GITEA_REPO}/releases/download/${TAG}/${APP_IMAGE}"